feat(baoyu-markdown-to-html): inline rendering pipeline and enhance modern theme
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6796ec67bd
commit
226d501e9e
|
|
@ -1,13 +1,17 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
import type { StyleConfig, HtmlDocumentMeta } from './md/types.js';
|
||||
import { DEFAULT_STYLE, THEME_STYLE_DEFAULTS } from './md/constants.js';
|
||||
import { loadThemeCss, normalizeThemeCss } from './md/themes.js';
|
||||
import { initRenderer, renderMarkdown, postProcessHtml } from './md/renderer.js';
|
||||
import {
|
||||
buildCss, loadCodeThemeCss, buildHtmlDocument,
|
||||
inlineCss, normalizeInlineCss, modifyHtmlStructure, removeFirstHeading,
|
||||
} from './md/html-builder.js';
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
|
|
@ -187,33 +191,23 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
|||
|
||||
const modifiedMarkdown = `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${modifiedBody}`;
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'markdown-to-html-'));
|
||||
const tempMdPath = path.join(tempDir, 'temp-article.md');
|
||||
await writeFile(tempMdPath, modifiedMarkdown, 'utf-8');
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const renderScript = path.join(__dirname, 'md', 'render.ts');
|
||||
|
||||
console.error(`[markdown-to-html] Rendering with theme: ${theme}, keepTitle: ${keepTitle}`);
|
||||
|
||||
const args = ['-y', 'bun', renderScript, tempMdPath, '--theme', theme];
|
||||
if (keepTitle) args.push('--keep-title');
|
||||
const themeDefaults = THEME_STYLE_DEFAULTS[theme] ?? {};
|
||||
const style: StyleConfig = { ...DEFAULT_STYLE, ...themeDefaults };
|
||||
const { baseCss, themeCss } = loadThemeCss(theme);
|
||||
const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
|
||||
const codeThemeCss = loadCodeThemeCss('github');
|
||||
|
||||
const result = spawnSync('npx', args, {
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
cwd: baseDir,
|
||||
});
|
||||
const renderer = initRenderer({});
|
||||
const { html: baseHtml, readingTime } = renderMarkdown(modifiedMarkdown, renderer);
|
||||
let htmlContent = postProcessHtml(baseHtml, readingTime, renderer);
|
||||
if (!keepTitle) htmlContent = removeFirstHeading(htmlContent);
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.toString() || '';
|
||||
throw new Error(`Render failed: ${stderr}`);
|
||||
}
|
||||
|
||||
const tempHtmlPath = tempMdPath.replace(/\.md$/i, '.html');
|
||||
if (!fs.existsSync(tempHtmlPath)) {
|
||||
throw new Error(`HTML file not generated: ${tempHtmlPath}`);
|
||||
}
|
||||
const meta: HtmlDocumentMeta = { title, author, description: summary };
|
||||
const fullHtml = buildHtmlDocument(meta, css, htmlContent, codeThemeCss);
|
||||
const inlinedHtml = normalizeInlineCss(await inlineCss(fullHtml), style);
|
||||
const renderedHtml = modifyHtmlStructure(inlinedHtml);
|
||||
|
||||
const finalHtmlPath = markdownPath.replace(/\.md$/i, '.html');
|
||||
let backupPath: string | undefined;
|
||||
|
|
@ -224,11 +218,16 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
|||
fs.renameSync(finalHtmlPath, backupPath);
|
||||
}
|
||||
|
||||
fs.copyFileSync(tempHtmlPath, finalHtmlPath);
|
||||
fs.writeFileSync(finalHtmlPath, renderedHtml, 'utf-8');
|
||||
|
||||
const contentImages: ImageInfo[] = [];
|
||||
let tempDir: string | undefined;
|
||||
for (const img of images) {
|
||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||
if (!tempDir && (img.src.startsWith('http://') || img.src.startsWith('https://'))) {
|
||||
const os = await import('node:os');
|
||||
tempDir = fs.mkdtempSync(path.join(os.default.tmpdir(), 'markdown-to-html-'));
|
||||
}
|
||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir ?? baseDir);
|
||||
contentImages.push({
|
||||
placeholder: img.placeholder,
|
||||
localPath,
|
||||
|
|
@ -236,12 +235,12 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
|||
});
|
||||
}
|
||||
|
||||
let htmlContent = fs.readFileSync(finalHtmlPath, 'utf-8');
|
||||
let finalContent = fs.readFileSync(finalHtmlPath, 'utf-8');
|
||||
for (const img of contentImages) {
|
||||
const imgTag = `<img src="${img.placeholder}" data-local-path="${img.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
htmlContent = htmlContent.replace(img.placeholder, imgTag);
|
||||
const imgTag = `<img src="${img.originalPath}" data-local-path="${img.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
finalContent = finalContent.replace(img.placeholder, imgTag);
|
||||
}
|
||||
fs.writeFileSync(finalHtmlPath, htmlContent, 'utf-8');
|
||||
fs.writeFileSync(finalHtmlPath, finalContent, 'utf-8');
|
||||
|
||||
console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -400,10 +400,10 @@ export function renderMarkdown(raw: string, renderer: RendererAPI): {
|
|||
html: string;
|
||||
readingTime: ReadTimeResults;
|
||||
} {
|
||||
const preprocessed = preprocessCjkEmphasis(raw);
|
||||
const { markdownContent, readingTime: readingTimeResult } =
|
||||
renderer.parseFrontMatterAndContent(preprocessed);
|
||||
const html = marked.parse(markdownContent) as string;
|
||||
renderer.parseFrontMatterAndContent(raw);
|
||||
const preprocessed = preprocessCjkEmphasis(markdownContent);
|
||||
const html = marked.parse(preprocessed) as string;
|
||||
return { html, readingTime: readingTimeResult };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/**
|
||||
* MD 现代主题 (modern)
|
||||
* 大圆角、药丸形标题、宽松行距、现代感
|
||||
* 如需使用主题色,请使用 var(--md-primary-color) 代替颜色值
|
||||
*/
|
||||
|
||||
/* ==================== 容器样式覆盖 ==================== */
|
||||
|
|
@ -9,6 +10,8 @@ container {
|
|||
font-family: var(--md-font-family);
|
||||
font-size: var(--md-font-size);
|
||||
line-height: 2;
|
||||
letter-spacing: 0px;
|
||||
font-weight: 400;
|
||||
background-color: var(--md-container-bg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.01);
|
||||
border-radius: 25px;
|
||||
|
|
@ -44,15 +47,22 @@ h2 {
|
|||
color: var(--md-primary-color);
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.578px;
|
||||
line-height: 1.7;
|
||||
border-bottom: 2px solid var(--md-accent-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* ==================== 三级标题 ==================== */
|
||||
h3 {
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid var(--md-primary-color);
|
||||
border-radius: 2px;
|
||||
margin: 0 8px 10px;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ==================== 四级标题 ==================== */
|
||||
|
|
@ -67,7 +77,7 @@ h4 {
|
|||
h5 {
|
||||
display: inline-block;
|
||||
margin: 0 8px 10px;
|
||||
padding: 4px 10px;
|
||||
padding: 4px 12px;
|
||||
color: hsl(var(--foreground));
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgb(189, 224, 254);
|
||||
|
|
@ -87,86 +97,300 @@ h6 {
|
|||
/* ==================== 段落 ==================== */
|
||||
p {
|
||||
margin: 20px 0;
|
||||
letter-spacing: 0.1em;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 2;
|
||||
letter-spacing: 0px;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ==================== 引用块 ==================== */
|
||||
blockquote {
|
||||
font-style: normal;
|
||||
padding: 15px 12px;
|
||||
padding: 15px 0;
|
||||
margin: 12px 0;
|
||||
border-left: 7px solid var(--md-accent-color);
|
||||
border-radius: 10px;
|
||||
color: hsl(var(--foreground));
|
||||
background-color: var(--blockquote-background);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
blockquote > p {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
letter-spacing: 0.1em;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== GFM 警告块 ==================== */
|
||||
.alert-title-note,
|
||||
.alert-title-tip,
|
||||
.alert-title-info,
|
||||
.alert-title-important,
|
||||
.alert-title-warning,
|
||||
.alert-title-caution,
|
||||
.alert-title-abstract,
|
||||
.alert-title-summary,
|
||||
.alert-title-tldr,
|
||||
.alert-title-todo,
|
||||
.alert-title-success,
|
||||
.alert-title-done,
|
||||
.alert-title-question,
|
||||
.alert-title-help,
|
||||
.alert-title-faq,
|
||||
.alert-title-failure,
|
||||
.alert-title-fail,
|
||||
.alert-title-missing,
|
||||
.alert-title-danger,
|
||||
.alert-title-error,
|
||||
.alert-title-bug,
|
||||
.alert-title-example,
|
||||
.alert-title-quote,
|
||||
.alert-title-cite {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.alert-title-note {
|
||||
color: #478be6;
|
||||
}
|
||||
|
||||
.alert-title-tip {
|
||||
color: #57ab5a;
|
||||
}
|
||||
|
||||
.alert-title-info {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.alert-title-important {
|
||||
color: #986ee2;
|
||||
}
|
||||
|
||||
.alert-title-warning {
|
||||
color: #c69026;
|
||||
}
|
||||
|
||||
.alert-title-caution {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.alert-title-abstract,
|
||||
.alert-title-summary,
|
||||
.alert-title-tldr {
|
||||
color: #00bfff;
|
||||
}
|
||||
|
||||
.alert-title-todo {
|
||||
color: #478be6;
|
||||
}
|
||||
|
||||
.alert-title-success,
|
||||
.alert-title-done {
|
||||
color: #57ab5a;
|
||||
}
|
||||
|
||||
.alert-title-question,
|
||||
.alert-title-help,
|
||||
.alert-title-faq {
|
||||
color: #c69026;
|
||||
}
|
||||
|
||||
.alert-title-failure,
|
||||
.alert-title-fail,
|
||||
.alert-title-missing {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.alert-title-danger,
|
||||
.alert-title-error {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.alert-title-bug {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.alert-title-example {
|
||||
color: #986ee2;
|
||||
}
|
||||
|
||||
.alert-title-quote,
|
||||
.alert-title-cite {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* GFM Alert SVG 图标颜色 */
|
||||
.alert-icon-note {
|
||||
fill: #478be6;
|
||||
}
|
||||
|
||||
.alert-icon-tip {
|
||||
fill: #57ab5a;
|
||||
}
|
||||
|
||||
.alert-icon-info {
|
||||
fill: #93c5fd;
|
||||
}
|
||||
|
||||
.alert-icon-important {
|
||||
fill: #986ee2;
|
||||
}
|
||||
|
||||
.alert-icon-warning {
|
||||
fill: #c69026;
|
||||
}
|
||||
|
||||
.alert-icon-caution {
|
||||
fill: #e5534b;
|
||||
}
|
||||
|
||||
.alert-icon-abstract,
|
||||
.alert-icon-summary,
|
||||
.alert-icon-tldr {
|
||||
fill: #00bfff;
|
||||
}
|
||||
|
||||
.alert-icon-todo {
|
||||
fill: #478be6;
|
||||
}
|
||||
|
||||
.alert-icon-success,
|
||||
.alert-icon-done {
|
||||
fill: #57ab5a;
|
||||
}
|
||||
|
||||
.alert-icon-question,
|
||||
.alert-icon-help,
|
||||
.alert-icon-faq {
|
||||
fill: #c69026;
|
||||
}
|
||||
|
||||
.alert-icon-failure,
|
||||
.alert-icon-fail,
|
||||
.alert-icon-missing {
|
||||
fill: #e5534b;
|
||||
}
|
||||
|
||||
.alert-icon-danger,
|
||||
.alert-icon-error {
|
||||
fill: #e5534b;
|
||||
}
|
||||
|
||||
.alert-icon-bug {
|
||||
fill: #e5534b;
|
||||
}
|
||||
|
||||
.alert-icon-example {
|
||||
fill: #986ee2;
|
||||
}
|
||||
|
||||
.alert-icon-quote,
|
||||
.alert-icon-cite {
|
||||
fill: #9ca3af;
|
||||
}
|
||||
|
||||
/* ==================== 代码块 ==================== */
|
||||
pre.code__pre,
|
||||
.hljs.code__pre {
|
||||
font-size: 90%;
|
||||
overflow-x: auto;
|
||||
border-radius: 10px;
|
||||
padding: 0 !important;
|
||||
line-height: 1.5;
|
||||
margin: 10px 8px;
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* ==================== 图片 ==================== */
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0.1em auto 0.5em;
|
||||
border-radius: 10px;
|
||||
margin: 5px auto;
|
||||
}
|
||||
|
||||
/* ==================== 列表 ==================== */
|
||||
ol {
|
||||
padding-left: 1em;
|
||||
margin: 15px 0;
|
||||
margin-left: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 15px 0;
|
||||
list-style: circle;
|
||||
padding-left: 1em;
|
||||
margin-left: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.2em 0;
|
||||
display: block;
|
||||
margin: 0.2em 8px;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ==================== 脚注 ==================== */
|
||||
p.footnotes {
|
||||
margin: 0.5em 8px;
|
||||
font-size: 80%;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ==================== 图表 ==================== */
|
||||
figure {
|
||||
margin: 1.5em 8px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
figcaption,
|
||||
.md-figcaption {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* ==================== 分隔线 ==================== */
|
||||
hr {
|
||||
border-style: solid;
|
||||
border-width: 1px 0 0;
|
||||
border-color: var(--md-primary-color);
|
||||
border-color: var(--md-accent-color);
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
/* ==================== 行内代码 ==================== */
|
||||
code {
|
||||
font-size: 90%;
|
||||
color: #d14;
|
||||
background: rgba(27, 31, 35, 0.05);
|
||||
padding: 3px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 代码块内的 code 标签需要特殊处理(覆盖行内 code 样式) */
|
||||
pre.code__pre > code,
|
||||
.hljs.code__pre > code {
|
||||
display: -webkit-box;
|
||||
padding: 0.5em 1em 1em;
|
||||
overflow-x: auto;
|
||||
text-indent: 0;
|
||||
color: inherit;
|
||||
background: none;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== 强调 ==================== */
|
||||
strong {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ==================== 标记高亮 ==================== */
|
||||
.markup-highlight {
|
||||
background-color: hsl(var(--foreground));
|
||||
padding: 10px;
|
||||
color: var(--md-container-bg);
|
||||
}
|
||||
|
||||
.markup-underline {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--md-accent-color);
|
||||
em {
|
||||
font-style: italic;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* ==================== 链接 ==================== */
|
||||
|
|
@ -175,7 +399,67 @@ a {
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ==================== 粗体 ==================== */
|
||||
strong {
|
||||
color: var(--md-primary-color);
|
||||
font-weight: bold;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* ==================== 表格 ==================== */
|
||||
table {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
thead {
|
||||
font-weight: bold;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
th {
|
||||
border: 1px solid #dfdfdf;
|
||||
padding: 0.25em 0.5em;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: keep-all;
|
||||
background: color-mix(in srgb, var(--md-primary-color) 10%, transparent);
|
||||
}
|
||||
|
||||
td {
|
||||
border: 1px solid #dfdfdf;
|
||||
padding: 0.25em 0.5em;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* ==================== KaTeX 公式 ==================== */
|
||||
.katex-inline {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.katex-block {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0.5em 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ==================== 标记高亮 ==================== */
|
||||
.markup-highlight {
|
||||
background-color: var(--md-primary-color);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.markup-underline {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--md-primary-color);
|
||||
}
|
||||
|
||||
.markup-wavyline {
|
||||
text-decoration: underline wavy;
|
||||
text-decoration-color: var(--md-primary-color);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue