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:
Jim Liu 宝玉
2026-03-01 00:00:57 -06:00
parent 6796ec67bd
commit 226d501e9e
3 changed files with 347 additions and 64 deletions
+32 -33
View File
@@ -1,13 +1,17 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { writeFile } from 'node:fs/promises';
import os from 'node:os';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import https from 'node:https'; import https from 'node:https';
import http from 'node:http'; import http from 'node:http';
import { spawnSync } from 'node:child_process';
import process from 'node: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 { interface ImageInfo {
placeholder: string; 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 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}`); console.error(`[markdown-to-html] Rendering with theme: ${theme}, keepTitle: ${keepTitle}`);
const args = ['-y', 'bun', renderScript, tempMdPath, '--theme', theme]; const themeDefaults = THEME_STYLE_DEFAULTS[theme] ?? {};
if (keepTitle) args.push('--keep-title'); 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, { const renderer = initRenderer({});
stdio: ['inherit', 'pipe', 'pipe'], const { html: baseHtml, readingTime } = renderMarkdown(modifiedMarkdown, renderer);
cwd: baseDir, let htmlContent = postProcessHtml(baseHtml, readingTime, renderer);
}); if (!keepTitle) htmlContent = removeFirstHeading(htmlContent);
if (result.status !== 0) { const meta: HtmlDocumentMeta = { title, author, description: summary };
const stderr = result.stderr?.toString() || ''; const fullHtml = buildHtmlDocument(meta, css, htmlContent, codeThemeCss);
throw new Error(`Render failed: ${stderr}`); const inlinedHtml = normalizeInlineCss(await inlineCss(fullHtml), style);
} const renderedHtml = modifyHtmlStructure(inlinedHtml);
const tempHtmlPath = tempMdPath.replace(/\.md$/i, '.html');
if (!fs.existsSync(tempHtmlPath)) {
throw new Error(`HTML file not generated: ${tempHtmlPath}`);
}
const finalHtmlPath = markdownPath.replace(/\.md$/i, '.html'); const finalHtmlPath = markdownPath.replace(/\.md$/i, '.html');
let backupPath: string | undefined; let backupPath: string | undefined;
@@ -224,11 +218,16 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
fs.renameSync(finalHtmlPath, backupPath); fs.renameSync(finalHtmlPath, backupPath);
} }
fs.copyFileSync(tempHtmlPath, finalHtmlPath); fs.writeFileSync(finalHtmlPath, renderedHtml, 'utf-8');
const contentImages: ImageInfo[] = []; const contentImages: ImageInfo[] = [];
let tempDir: string | undefined;
for (const img of images) { 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({ contentImages.push({
placeholder: img.placeholder, placeholder: img.placeholder,
localPath, 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) { 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;">`; const imgTag = `<img src="${img.originalPath}" data-local-path="${img.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
htmlContent = htmlContent.replace(img.placeholder, imgTag); 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}`); console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);
@@ -400,10 +400,10 @@ export function renderMarkdown(raw: string, renderer: RendererAPI): {
html: string; html: string;
readingTime: ReadTimeResults; readingTime: ReadTimeResults;
} { } {
const preprocessed = preprocessCjkEmphasis(raw);
const { markdownContent, readingTime: readingTimeResult } = const { markdownContent, readingTime: readingTimeResult } =
renderer.parseFrontMatterAndContent(preprocessed); renderer.parseFrontMatterAndContent(raw);
const html = marked.parse(markdownContent) as string; const preprocessed = preprocessCjkEmphasis(markdownContent);
const html = marked.parse(preprocessed) as string;
return { html, readingTime: readingTimeResult }; return { html, readingTime: readingTimeResult };
} }
@@ -1,6 +1,7 @@
/** /**
* MD 现代主题 (modern) * MD 现代主题 (modern)
* 大圆角、药丸形标题、宽松行距、现代感 * 大圆角、药丸形标题、宽松行距、现代感
* 如需使用主题色,请使用 var(--md-primary-color) 代替颜色值
*/ */
/* ==================== 容器样式覆盖 ==================== */ /* ==================== 容器样式覆盖 ==================== */
@@ -9,6 +10,8 @@ container {
font-family: var(--md-font-family); font-family: var(--md-font-family);
font-size: var(--md-font-size); font-size: var(--md-font-size);
line-height: 2; line-height: 2;
letter-spacing: 0px;
font-weight: 400;
background-color: var(--md-container-bg); background-color: var(--md-container-bg);
border: 1px solid rgba(255, 255, 255, 0.01); border: 1px solid rgba(255, 255, 255, 0.01);
border-radius: 25px; border-radius: 25px;
@@ -44,15 +47,22 @@ h2 {
color: var(--md-primary-color); color: var(--md-primary-color);
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
letter-spacing: 0.578px;
line-height: 1.7;
border-bottom: 2px solid var(--md-accent-color); border-bottom: 2px solid var(--md-accent-color);
text-align: left;
} }
/* ==================== 三级标题 ==================== */ /* ==================== 三级标题 ==================== */
h3 { h3 {
padding-left: 10px;
border-left: 4px solid var(--md-primary-color);
border-radius: 2px;
margin: 0 8px 10px; margin: 0 8px 10px;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-size: 18px; font-size: 20px;
font-weight: bold; font-weight: bold;
line-height: 1.2;
} }
/* ==================== 四级标题 ==================== */ /* ==================== 四级标题 ==================== */
@@ -67,7 +77,7 @@ h4 {
h5 { h5 {
display: inline-block; display: inline-block;
margin: 0 8px 10px; margin: 0 8px 10px;
padding: 4px 10px; padding: 4px 12px;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
background: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.7);
border: 1px solid rgb(189, 224, 254); border: 1px solid rgb(189, 224, 254);
@@ -87,86 +97,300 @@ h6 {
/* ==================== 段落 ==================== */ /* ==================== 段落 ==================== */
p { p {
margin: 20px 0; margin: 20px 0;
letter-spacing: 0.1em;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
line-height: 2; line-height: 2;
letter-spacing: 0px; letter-spacing: 0px;
font-size: 15px; font-size: 15px;
font-weight: 400;
word-break: break-all;
} }
/* ==================== 引用块 ==================== */ /* ==================== 引用块 ==================== */
blockquote { blockquote {
font-style: normal; font-style: normal;
padding: 15px 12px; padding: 15px 0;
margin: 12px 0;
border-left: 7px solid var(--md-accent-color); border-left: 7px solid var(--md-accent-color);
border-radius: 10px; border-radius: 10px;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
background-color: var(--blockquote-background); background-color: var(--blockquote-background);
margin: 12px 0;
} }
blockquote > p { blockquote > p {
display: block;
font-size: 1em;
letter-spacing: 0.1em;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-size: 14px;
margin: 0; 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, pre.code__pre,
.hljs.code__pre { .hljs.code__pre {
font-size: 90%;
overflow-x: auto;
border-radius: 10px; 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); box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
} }
/* ==================== 图片 ==================== */ /* ==================== 图片 ==================== */
img { img {
display: block;
max-width: 100%;
margin: 0.1em auto 0.5em;
border-radius: 10px; border-radius: 10px;
margin: 5px auto;
} }
/* ==================== 列表 ==================== */ /* ==================== 列表 ==================== */
ol { ol {
padding-left: 1em; padding-left: 1em;
margin: 15px 0; margin-left: 0;
color: hsl(var(--foreground));
line-height: 2; line-height: 2;
} }
ul { ul {
list-style: none; list-style: circle;
padding-left: 0; padding-left: 1em;
margin: 15px 0; margin-left: 0;
color: hsl(var(--foreground));
line-height: 2; line-height: 2;
} }
li { li {
margin: 0.2em 0; display: block;
margin: 0.2em 8px;
color: hsl(var(--foreground)); 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 { hr {
border-style: solid; border-style: solid;
border-width: 1px 0 0; 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; margin: 0;
} }
/* ==================== 强调 ==================== */ /* ==================== 强调 ==================== */
strong { em {
color: hsl(var(--foreground)); font-style: italic;
font-weight: bold; font-size: inherit;
}
/* ==================== 标记高亮 ==================== */
.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);
} }
/* ==================== 链接 ==================== */ /* ==================== 链接 ==================== */
@@ -175,7 +399,67 @@ a {
text-decoration: none; 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 { 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); 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;
}