feat(baoyu-markdown-to-html): render Mermaid code blocks to PNG via headless Chrome

This commit is contained in:
Jim Liu 宝玉
2026-05-24 21:27:43 -05:00
parent 8d2fd7aae0
commit cda76e20fd
3 changed files with 148 additions and 10 deletions
+18 -4
View File
@@ -1,7 +1,7 @@
---
name: baoyu-markdown-to-html
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md 转 html", "微信外链转底部引用", or needs styled HTML output from markdown.
version: 1.56.1
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, Mermaid (rendered to PNG via headless Chrome), PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md 转 html", "微信外链转底部引用", or needs styled HTML output from markdown.
version: 1.57.0
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-markdown-to-html
@@ -45,7 +45,7 @@ Check EXTEND.md in priority order — the first one found wins:
If none found, use defaults.
**EXTEND.md supports**: default theme, custom CSS variables, code block style.
**EXTEND.md supports**: default theme, custom CSS variables, code block style, mermaid defaults (`mermaid_theme`, `mermaid_scale`, `mermaid_background`).
## Workflow
@@ -124,6 +124,11 @@ ${BUN_X} {baseDir}/scripts/main.ts <markdown_file> [options]
| `--title <title>` | Override title from frontmatter | |
| `--cite` | Convert external links to bottom citations, append `引用链接` section | false (off) |
| `--keep-title` | Keep the first heading in content | false (removed) |
| `--mermaid-theme <name>` | Mermaid theme: `default`, `forest`, `dark`, `neutral`, `base` | default |
| `--mermaid-scale <N>` | Mermaid render scale (positive number ≤ 4) | 2 |
| `--mermaid-width <N>` | Mermaid target display width in CSS px; PNG is rendered at `width × scale` pixels when the diagram is narrower than this | 860 |
| `--mermaid-bg <value>` | Mermaid background: `white`, `transparent`, or `#hex` | white |
| `--no-mermaid` | Skip Mermaid PNG rendering; emit `<pre class="mermaid">` fallback | false |
| `--help` | Show help | |
**Color Presets:**
@@ -190,10 +195,19 @@ ${BUN_X} {baseDir}/scripts/main.ts article.md --title "My Article"
"localPath": "/path/to/img.png",
"originalPath": "imgs/image.png"
}
],
"mermaidImages": [
{
"hash": "a1b2c3d4e5f6",
"localPath": "/path/to/imgs/.mermaid-cache/mermaid-a1b2c3d4e5f6.png",
"cached": false
}
]
}
```
**Mermaid rendering**: Code blocks fenced as ` ```mermaid ` are rendered to PNGs via headless Chrome (CDP) and cached at `imgs/.mermaid-cache/mermaid-<hash>.png`. The cache key includes the code, theme, scale, target width, background, and mermaid version. Add `imgs/.mermaid-cache/` to `.gitignore` if you do not want generated diagrams checked in. Requires Chrome/Chromium/Edge on the system; otherwise the block falls back to `<pre class="mermaid">…</pre>` and conversion still succeeds.
## Themes
| Theme | Description |
@@ -219,7 +233,7 @@ ${BUN_X} {baseDir}/scripts/main.ts article.md --title "My Article"
| Alerts | `> [!NOTE]`, `> [!WARNING]`, etc. |
| Footnotes | `[^1]` references |
| Ruby text | `{base|annotation}` |
| Mermaid | ` ```mermaid ` diagrams |
| Mermaid | ` ```mermaid ` blocks rendered to local PNG via headless Chrome (cached under `imgs/.mermaid-cache/`); falls back to `<pre class="mermaid">` if Chrome is unavailable or rendering fails |
| PlantUML | ` ```plantuml ` diagrams |
## Frontmatter
+129 -6
View File
@@ -13,6 +13,7 @@ import {
formatTimestamp,
parseArgs,
parseFrontmatter,
preprocessMermaidInMarkdown,
renderMarkdownDocument,
replaceMarkdownImagesWithPlaceholders,
resolveContentImages,
@@ -20,6 +21,7 @@ import {
stripWrappingQuotes,
} from "baoyu-md";
import type { CliOptions } from "baoyu-md";
import { closeRenderer, renderMermaidToPng } from "baoyu-chrome-cdp/mermaid";
interface ImageInfo {
placeholder: string;
@@ -27,6 +29,12 @@ interface ImageInfo {
originalPath: string;
}
interface MermaidImageInfo {
hash: string;
localPath: string;
cached: boolean;
}
interface ParsedResult {
title: string;
author: string;
@@ -34,10 +42,20 @@ interface ParsedResult {
htmlPath: string;
backupPath?: string;
contentImages: ImageInfo[];
mermaidImages: MermaidImageInfo[];
}
interface MermaidCliOptions {
enabled?: boolean;
theme?: string;
scale?: number;
background?: string;
minWidth?: number;
}
type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
title?: string;
mermaid?: MermaidCliOptions;
};
export async function convertMarkdown(
@@ -70,8 +88,34 @@ export async function convertMarkdown(
? { ...frontmatter, title }
: frontmatter;
const mermaidEnabled = options?.mermaid?.enabled !== false;
const mermaidMinWidth = options?.mermaid?.minWidth ?? 860;
const { markdown: mermaidProcessedBody, images: mermaidImages } =
await preprocessMermaidInMarkdown(body, {
baseDir,
renderFn: renderMermaidToPng,
enabled: mermaidEnabled,
theme: options?.mermaid?.theme,
scale: options?.mermaid?.scale,
background: options?.mermaid?.background,
minWidth: mermaidMinWidth,
onError: (error, block) => {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[markdown-to-html] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, " ")}…): ${message}`,
);
},
});
if (mermaidImages.length > 0) {
const fresh = mermaidImages.filter((image) => !image.cached).length;
console.error(
`[markdown-to-html] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,
);
}
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
body,
mermaidProcessedBody,
"MDTOHTMLIMGPH_",
);
const rewrittenMarkdown = `${serializeFrontmatter(effectiveFrontmatter)}${rewrittenBody}`;
@@ -130,6 +174,11 @@ export async function convertMarkdown(
htmlPath: finalHtmlPath,
backupPath,
contentImages,
mermaidImages: mermaidImages.map((image) => ({
hash: image.hash,
localPath: image.localPath,
cached: image.cached,
})),
};
}
@@ -156,6 +205,11 @@ Options:
--count Show reading time / word count
--legend <value> Image caption: title-alt, alt-title, title, alt, none
--keep-title Keep the first heading in content. Default: false (removed)
--mermaid-theme <name> Mermaid theme: default, forest, dark, neutral. Default: default
--mermaid-scale <N> Mermaid render scale: 1, 1.5, 2, 3. Default: 2
--mermaid-width <N> Mermaid target display width in CSS px. Default: 860
--mermaid-bg <value> Mermaid background: white, transparent, or #hex. Default: white
--no-mermaid Skip Mermaid rendering; emit <pre class="mermaid"> fallback
--help Show this help
Output:
@@ -215,13 +269,78 @@ function extractTitleArg(argv: string[]): { renderArgs: string[]; title?: string
return { renderArgs, title };
}
const VALID_MERMAID_THEMES = new Set(["default", "forest", "dark", "neutral", "base"]);
function extractMermaidArgs(argv: string[]): { renderArgs: string[]; mermaid: MermaidCliOptions } {
const mermaid: MermaidCliOptions = {};
const renderArgs: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
if (arg === "--no-mermaid") {
mermaid.enabled = false;
continue;
}
if (arg === "--mermaid-theme" || arg.startsWith("--mermaid-theme=")) {
const value = parseArgValue(argv, i, "--mermaid-theme");
if (!value) {
console.error("Missing value for --mermaid-theme");
printUsage(1);
}
if (!VALID_MERMAID_THEMES.has(value)) {
console.error(`Invalid --mermaid-theme: ${value} (choose one of ${[...VALID_MERMAID_THEMES].join(", ")})`);
printUsage(1);
}
mermaid.theme = value;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--mermaid-scale" || arg.startsWith("--mermaid-scale=")) {
const value = parseArgValue(argv, i, "--mermaid-scale");
const parsed = Number.parseFloat(value ?? "");
if (!value || !Number.isFinite(parsed) || parsed <= 0 || parsed > 4) {
console.error(`Invalid --mermaid-scale: ${value} (expect a positive number ≤ 4)`);
printUsage(1);
}
mermaid.scale = parsed;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--mermaid-width" || arg.startsWith("--mermaid-width=")) {
const value = parseArgValue(argv, i, "--mermaid-width");
const parsed = Number.parseInt(value ?? "", 10);
if (!value || !Number.isFinite(parsed) || parsed <= 0) {
console.error(`Invalid --mermaid-width: ${value} (expect a positive integer)`);
printUsage(1);
}
mermaid.minWidth = parsed;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--mermaid-bg" || arg.startsWith("--mermaid-bg=")) {
const value = parseArgValue(argv, i, "--mermaid-bg");
if (!value) {
console.error("Missing value for --mermaid-bg");
printUsage(1);
}
mermaid.background = value;
if (!arg.includes("=")) i += 1;
continue;
}
renderArgs.push(arg);
}
return { renderArgs, mermaid };
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
printUsage(0);
}
const { renderArgs, title } = extractTitleArg(args);
const { renderArgs: afterTitle, title } = extractTitleArg(args);
const { renderArgs, mermaid } = extractMermaidArgs(afterTitle);
const options = parseArgs(renderArgs);
if (!options) {
printUsage(1);
@@ -238,11 +357,15 @@ async function main(): Promise<void> {
process.exit(1);
}
const result = await convertMarkdown(markdownPath, { ...options, title });
const result = await convertMarkdown(markdownPath, { ...options, title, mermaid });
console.log(JSON.stringify(result, null, 2));
}
await main().catch((error) => {
try {
await main();
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
process.exitCode = 1;
} finally {
await closeRenderer();
}
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-md": "^0.1.0"
}
}