feat(baoyu-md): add Mermaid preprocessing and utility modules

This commit is contained in:
Jim Liu 宝玉
2026-05-24 21:27:38 -05:00
parent f60702cc8e
commit 8d2fd7aae0
8 changed files with 867 additions and 10255 deletions
+205 -5120
View File
File diff suppressed because one or more lines are too long
+205 -5120
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -5,6 +5,8 @@ export * from "./document.js";
export * from "./extend-config.js";
export * from "./html-builder.js";
export * from "./images.js";
export * from "./mermaid-preprocess.js";
export * from "./mermaid-utils.js";
export * from "./renderer.js";
export * from "./themes.js";
export * from "./types.js";
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
preprocessMermaidInMarkdown,
type MermaidRenderFn,
} from "./mermaid-preprocess.ts";
function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mermaid-preprocess-test-"));
return fn(dir).finally(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
}
const stubPngBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const stubRender: MermaidRenderFn = async (_code, outPath) => {
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
await fs.promises.writeFile(outPath, stubPngBytes);
};
test("preprocessMermaidInMarkdown skips when disabled", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn: stubRender,
enabled: false,
});
assert.equal(result.markdown, markdown);
assert.equal(result.images.length, 0);
});
});
test("preprocessMermaidInMarkdown skips when renderFn is missing", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
const result = await preprocessMermaidInMarkdown(markdown, { baseDir });
assert.equal(result.markdown, markdown);
assert.equal(result.images.length, 0);
});
});
test("preprocessMermaidInMarkdown deduplicates identical blocks via hashed cache", async () => {
await withTempDir(async (baseDir) => {
const block = "```mermaid\ngraph TD\nA-->B\n```";
const markdown = `${block}\n\nsome text\n\n${block}\n\nother text\n\n\`\`\`mermaid\nflowchart LR\nX-->Y\n\`\`\``;
let renderCalls = 0;
const renderFn: MermaidRenderFn = async (code, outPath) => {
renderCalls += 1;
await stubRender(code, outPath, {});
};
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn,
});
assert.equal(renderCalls, 2, "should render two distinct blocks");
assert.equal(result.images.length, 3, "all three blocks produce image entries");
const uniqueHashes = new Set(result.images.map((image) => image.hash));
assert.equal(uniqueHashes.size, 2);
const matches = result.markdown.match(/!\[Mermaid diagram\]/g) ?? [];
assert.equal(matches.length, 3);
assert.ok(!result.markdown.includes("```mermaid"));
});
});
test("preprocessMermaidInMarkdown reuses cached files (cached=true when file exists)", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
let renderCalls = 0;
const renderFn: MermaidRenderFn = async (code, outPath) => {
renderCalls += 1;
await stubRender(code, outPath, {});
};
const first = await preprocessMermaidInMarkdown(markdown, { baseDir, renderFn });
assert.equal(renderCalls, 1);
assert.equal(first.images[0]!.cached, false);
const second = await preprocessMermaidInMarkdown(markdown, { baseDir, renderFn });
assert.equal(renderCalls, 1, "second pass hits cache");
assert.equal(second.images[0]!.cached, true);
});
});
test("preprocessMermaidInMarkdown survives renderFn errors and keeps raw block", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ninvalid syntax!!\n```\n\nrest";
const errors: string[] = [];
const failingRender: MermaidRenderFn = async () => {
throw new Error("render boom");
};
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn: failingRender,
onError: (error) => {
errors.push(error instanceof Error ? error.message : String(error));
},
});
assert.equal(errors.length, 1);
assert.equal(errors[0], "render boom");
assert.equal(result.images.length, 0);
assert.ok(result.markdown.includes("```mermaid"));
assert.ok(result.markdown.includes("rest"));
});
});
test("preprocessMermaidInMarkdown writes PNGs under imgs/.mermaid-cache/", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn: stubRender,
});
assert.equal(result.images.length, 1);
const image = result.images[0]!;
assert.ok(image.localPath.includes(path.join("imgs", ".mermaid-cache")));
assert.ok(image.mdRef.includes("imgs/.mermaid-cache/"));
assert.ok(fs.existsSync(image.localPath));
});
});
+131
View File
@@ -0,0 +1,131 @@
import fs from "node:fs";
import path from "node:path";
import {
MERMAID_VERSION,
extractMermaidBlocks,
hashMermaidCode,
replaceMermaidBlocks,
type MermaidBlock,
} from "./mermaid-utils.js";
export interface MermaidRenderOptions {
theme?: string;
scale?: number;
background?: string;
minWidth?: number;
}
export type MermaidRenderFn = (
code: string,
outputPath: string,
options: MermaidRenderOptions,
) => Promise<void>;
export interface MermaidPreprocessOptions extends MermaidRenderOptions {
baseDir: string;
imgSubdir?: string;
renderFn?: MermaidRenderFn;
enabled?: boolean;
alt?: string;
onError?: (error: unknown, block: MermaidBlock) => void;
}
export interface MermaidPreprocessedImage {
raw: string;
code: string;
hash: string;
localPath: string;
mdRef: string;
cached: boolean;
}
export interface MermaidPreprocessResult {
markdown: string;
images: MermaidPreprocessedImage[];
}
export async function preprocessMermaidInMarkdown(
markdown: string,
options: MermaidPreprocessOptions,
): Promise<MermaidPreprocessResult> {
const {
baseDir,
imgSubdir = "imgs/.mermaid-cache",
renderFn,
enabled = true,
theme,
scale,
background,
minWidth,
alt = "Mermaid diagram",
onError,
} = options;
if (!enabled || !renderFn) {
return { markdown, images: [] };
}
const blocks = extractMermaidBlocks(markdown);
if (blocks.length === 0) {
return { markdown, images: [] };
}
const cacheDir = path.resolve(baseDir, imgSubdir);
fs.mkdirSync(cacheDir, { recursive: true });
const replacements = new Map<string, string>();
const images: MermaidPreprocessedImage[] = [];
const renderedHashes = new Set<string>();
for (const block of blocks) {
const hash = hashMermaidCode({
code: block.code,
theme,
scale,
background,
minWidth,
version: MERMAID_VERSION,
});
const filename = `mermaid-${hash}.png`;
const localPath = path.join(cacheDir, filename);
const mdRef = `![${alt}](${path.posix.join(imgSubdir, filename)})`;
const cached = fs.existsSync(localPath);
if (!cached && !renderedHashes.has(hash)) {
try {
await renderFn(block.code, localPath, { theme, scale, background, minWidth });
renderedHashes.add(hash);
} catch (error) {
if (onError) {
onError(error, block);
} else {
console.error(
`[mermaid] render failed for block (hash ${hash}): ${
error instanceof Error ? error.message : String(error)
}`,
);
}
continue;
}
}
if (!fs.existsSync(localPath)) {
continue;
}
replacements.set(block.raw, mdRef);
images.push({
raw: block.raw,
code: block.code,
hash,
localPath,
mdRef,
cached,
});
}
const newMarkdown = replaceMermaidBlocks(markdown, replacements);
return { markdown: newMarkdown, images };
}
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MERMAID_VERSION,
extractMermaidBlocks,
hashMermaidCode,
replaceMermaidBlocks,
} from "./mermaid-utils.ts";
test("extractMermaidBlocks finds fenced mermaid blocks at the top level", () => {
const markdown = `Intro
\`\`\`mermaid
graph TD
A --> B
\`\`\`
Outro`;
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.code.trim(), "graph TD\n A --> B");
assert.equal(blocks[0]!.infoString, "");
assert.ok(blocks[0]!.raw.includes("```mermaid"));
});
test("extractMermaidBlocks preserves info-string suffixes", () => {
const markdown = "```mermaid theme=dark\nflowchart LR\n A --> B\n```";
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.infoString, "theme=dark");
});
test("extractMermaidBlocks finds blocks nested inside lists", () => {
const markdown = `Steps:
1. First, render the diagram:
\`\`\`mermaid
sequenceDiagram
Alice->>Bob: Hello
\`\`\`
2. Then do something else.`;
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.code.includes("sequenceDiagram"), true);
});
test("extractMermaidBlocks ignores non-mermaid fences and empty blocks", () => {
const markdown = `\`\`\`ts
const x = 1;
\`\`\`
\`\`\`mermaid
\`\`\`
\`\`\`mermaidsomething
not a real lang
\`\`\``;
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.infoString, "something");
assert.equal(blocks[0]!.code.trim(), "not a real lang");
});
test("replaceMermaidBlocks performs exact string replacement", () => {
const markdown = "before\n\n```mermaid\ngraph TD\nA-->B\n```\n\nafter";
const blocks = extractMermaidBlocks(markdown);
const map = new Map([[blocks[0]!.raw, "![diagram](img.png)"]]);
const replaced = replaceMermaidBlocks(markdown, map);
assert.equal(replaced, "before\n\n![diagram](img.png)\n\nafter");
});
test("replaceMermaidBlocks leaves markdown unchanged when no replacements match", () => {
const markdown = "hello\n\nworld";
const replaced = replaceMermaidBlocks(markdown, new Map([["nope", "x"]]));
assert.equal(replaced, markdown);
});
test("hashMermaidCode is stable for the same inputs", () => {
const a = hashMermaidCode({ code: "graph TD\nA-->B" });
const b = hashMermaidCode({ code: "graph TD\nA-->B" });
assert.equal(a, b);
assert.equal(a.length, 12);
});
test("hashMermaidCode defaults to 2x render scale", () => {
const implicit = hashMermaidCode({ code: "graph TD\nA-->B" });
const explicit = hashMermaidCode({ code: "graph TD\nA-->B", scale: 2 });
assert.equal(implicit, explicit);
});
test("hashMermaidCode ignores trailing whitespace", () => {
const a = hashMermaidCode({ code: "graph TD\nA-->B" });
const b = hashMermaidCode({ code: "graph TD\nA-->B \n\n" });
assert.equal(a, b);
});
test("hashMermaidCode reflects theme/scale/background/version changes", () => {
const base = hashMermaidCode({ code: "graph TD\nA-->B" });
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", theme: "dark" }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", scale: 3 }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", minWidth: 860 }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", background: "#000" }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", version: "x.y.z" }));
});
test("MERMAID_VERSION matches the vendored bundle (10.x)", () => {
assert.match(MERMAID_VERSION, /^10\./);
});
+74
View File
@@ -0,0 +1,74 @@
import { createHash } from "node:crypto";
import { Marked, type Tokens } from "marked";
export const MERMAID_VERSION = "10.9.1";
export interface MermaidBlock {
raw: string;
code: string;
infoString: string;
}
export interface HashMermaidInput {
code: string;
theme?: string;
scale?: number;
background?: string;
minWidth?: number;
version?: string;
}
export function extractMermaidBlocks(markdown: string): MermaidBlock[] {
const blocks: MermaidBlock[] = [];
const lexer = new Marked({ breaks: true });
const tokens = lexer.lexer(markdown);
walkTokens(tokens, (token) => {
if (token.type !== "code") return;
const codeToken = token as Tokens.Code;
const lang = (codeToken.lang ?? "").trim();
if (!lang.startsWith("mermaid")) return;
const infoString = lang.slice("mermaid".length).trim();
const code = codeToken.text ?? "";
if (code.trim() === "") return;
blocks.push({
raw: codeToken.raw,
code,
infoString,
});
});
return blocks;
}
export function replaceMermaidBlocks(
markdown: string,
replacements: Map<string, string>,
): string {
let result = markdown;
for (const [raw, replacement] of replacements) {
if (!raw || replacement === undefined) continue;
result = result.split(raw).join(replacement);
}
return result;
}
export function hashMermaidCode(input: HashMermaidInput): string {
const payload = JSON.stringify({
code: input.code.replace(/\s+$/g, ""),
theme: input.theme ?? "default",
scale: input.scale ?? 2,
minWidth: input.minWidth ?? null,
background: input.background ?? "white",
version: input.version ?? MERMAID_VERSION,
});
return createHash("sha256").update(payload).digest("hex").slice(0, 12);
}
type AnyToken = { type?: string; tokens?: AnyToken[]; items?: AnyToken[] };
function walkTokens(tokens: AnyToken[], visit: (token: AnyToken) => void): void {
for (const token of tokens) {
visit(token);
if (Array.isArray(token.tokens)) walkTokens(token.tokens, visit);
if (Array.isArray(token.items)) walkTokens(token.items, visit);
}
}
+1 -15
View File
@@ -119,7 +119,6 @@ function wrapInlineCode(value: string): string {
export function initRenderer(opts: IOpts = {}): RendererAPI {
const footnotes: [number, string, string][] = [];
let footnoteIndex = 0;
let codeIndex = 0;
const listOrderedStack: boolean[] = [];
const listCounters: number[] = [];
const isBrowser = typeof window !== "undefined";
@@ -208,20 +207,7 @@ export function initRenderer(opts: IOpts = {}): RendererAPI {
code({ text, lang = "" }: Tokens.Code): string {
if (lang.startsWith("mermaid")) {
if (isBrowser) {
clearTimeout(codeIndex as any);
codeIndex = setTimeout(async () => {
const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
if (windowRef && windowRef.mermaid) {
const mermaid = windowRef.mermaid;
await mermaid.run();
} else {
const mermaid = await import("mermaid");
await mermaid.default.run();
}
}, 0) as any as number;
}
return `<pre class="mermaid">${text}</pre>`;
return `<pre class="mermaid">${escapeHtml(text)}</pre>`;
}
const langText = lang.split(" ")[0];
const isLanguageRegistered = hljs.getLanguage(langText);