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

View File

@ -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}`);

View File

@ -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 };
}

View File

@ -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;
}