mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 13:59:47 +08:00
feat(baoyu-md): add Mermaid preprocessing and utility modules
This commit is contained in:
Vendored
+205
-5120
File diff suppressed because one or more lines are too long
Vendored
+205
-5120
File diff suppressed because one or more lines are too long
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 = `})`;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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, ""]]);
|
||||
const replaced = replaceMermaidBlocks(markdown, map);
|
||||
assert.equal(replaced, "before\n\n\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\./);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user