diff --git a/biome.json b/biome.json index cf719f02d..11c2a132d 100644 --- a/biome.json +++ b/biome.json @@ -45,7 +45,8 @@ "!**/template.generated.ts", "!**/docs-index.generated.ts", "!**/gen/agent_pb.ts", - "!.worktrees/**/*" + "!.worktrees/**/*", + "!.wt/**/*" ] }, "assist": { "actions": { "source": { "organizeImports": "on" } } } diff --git a/bun.lock b/bun.lock index ceb88e9a1..480cf61f9 100644 --- a/bun.lock +++ b/bun.lock @@ -165,6 +165,7 @@ "name": "@oh-my-pi/pi-utils", "version": "13.5.1", "dependencies": { + "beautiful-mermaid": "1.1.3", "winston": "^3.19", "winston-daily-rotate-file": "^5.0", }, @@ -524,6 +525,8 @@ "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], + "beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -604,6 +607,8 @@ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "elkjs": ["elkjs@0.11.0", "", {}, "sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], diff --git a/package.json b/package.json index c5c2ef31d..db9a1ad43 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "release": "bun scripts/release.ts", "generate-models": "bun --cwd=packages/ai scripts/generate-models.ts", "generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index", - "sync-exports": "bun scripts/sync-exports.ts" + "sync-exports": "bun scripts/sync-exports.ts", + "mermaid-build": "bun scripts/mermaid-build.ts" }, "devDependencies": { "@biomejs/biome": "^2", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a7af7eef9..c47e7e7ab 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog ## [Unreleased] +### Added + +- Added `render_mermaid` tool to convert Mermaid graph source into ASCII diagram output +- Added `renderMermaid.enabled` setting to control availability of the render_mermaid tool + +### Changed + +- Changed Mermaid rendering from PNG images to ASCII diagrams in theme rendering +- Changed `prerenderMermaid()` function to synchronously render ASCII instead of asynchronously rendering PNG ## [13.5.0] - 2026-03-01 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index f8e97d944..53dfba532 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -460,6 +460,15 @@ export const SETTINGS_SCHEMA = { description: "Enable the ast_edit tool for structural AST rewrites", }, }, + "renderMermaid.enabled": { + type: "boolean", + default: false, + ui: { + tab: "tools", + label: "Enable Render Mermaid", + description: "Enable the render_mermaid tool for Mermaid-to-ASCII rendering", + }, + }, "notebook.enabled": { type: "boolean", default: true, diff --git a/packages/coding-agent/src/modes/theme/mermaid-cache.ts b/packages/coding-agent/src/modes/theme/mermaid-cache.ts index d5ab389c5..bc2e6bfb0 100644 --- a/packages/coding-agent/src/modes/theme/mermaid-cache.ts +++ b/packages/coding-agent/src/modes/theme/mermaid-cache.ts @@ -1,88 +1,48 @@ -import { - extractMermaidBlocks, - type MermaidImage, - type MermaidRenderOptions, - renderMermaidToPng, -} from "@oh-my-pi/pi-tui"; -import { logger } from "@oh-my-pi/pi-utils"; +import { extractMermaidBlocks, logger, renderMermaidAsciiSafe } from "@oh-my-pi/pi-utils"; -const cache = new Map(); -const pending = new Map>(); +const cache = new Map(); const failed = new Set(); -const defaultOptions: MermaidRenderOptions = { - theme: "dark", - backgroundColor: "transparent", -}; - let onRenderNeeded: (() => void) | null = null; /** - * Set callback to trigger TUI re-render when mermaid images become available. + * Set callback to trigger TUI re-render when mermaid ASCII renders become available. */ export function setMermaidRenderCallback(callback: (() => void) | null): void { onRenderNeeded = callback; } /** - * Get a pre-rendered mermaid image by hash. + * Get a pre-rendered mermaid ASCII diagram by hash. * Returns null if not cached or rendering failed. */ -export function getMermaidImage(hash: bigint): MermaidImage | null { +export function getMermaidAscii(hash: bigint): string | null { return cache.get(hash) ?? null; } /** - * Pre-render all mermaid blocks in markdown text. - * Renders in parallel, deduplicates concurrent requests. - * Calls render callback when new images are cached. + * Render all mermaid blocks in markdown text. + * Caches results and calls render callback when new diagrams are available. */ -export async function prerenderMermaid( - markdown: string, - options: MermaidRenderOptions = defaultOptions, -): Promise { +export function prerenderMermaid(markdown: string): void { const blocks = extractMermaidBlocks(markdown); if (blocks.length === 0) return; - const promises: Promise[] = []; + let hasNew = false; for (const { source, hash } of blocks) { if (cache.has(hash) || failed.has(hash)) continue; - let promise = pending.get(hash); - if (!promise) { - promise = renderMermaidToPng(source, options); - pending.set(hash, promise); + const ascii = renderMermaidAsciiSafe(source); + if (ascii) { + cache.set(hash, ascii); + hasNew = true; + } else { + failed.add(hash); } - - promises.push( - promise - .then(image => { - pending.delete(hash); - if (image) { - cache.set(hash, image); - failed.delete(hash); - return true; - } - failed.add(hash); - return false; - }) - .catch(error => { - pending.delete(hash); - failed.add(hash); - logger.warn("Mermaid render failed", { - hash, - error: error instanceof Error ? error.message : String(error), - }); - return false; - }), - ); } - const results = await Promise.all(promises); - const newImages = results.some(added => added); - - if (newImages && onRenderNeeded) { + if (hasNew && onRenderNeeded) { try { onRenderNeeded(); } catch (error) { @@ -107,5 +67,4 @@ export function hasPendingMermaid(markdown: string): boolean { export function clearMermaidCache(): void { cache.clear(); failed.clear(); - pending.clear(); } diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index e50800ce4..df203cfd4 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -16,7 +16,7 @@ import chalk from "chalk"; import darkThemeJson from "./dark.json" with { type: "json" }; import { defaultThemes } from "./defaults"; import lightThemeJson from "./light.json" with { type: "json" }; -import { getMermaidImage } from "./mermaid-cache"; +import { getMermaidAscii } from "./mermaid-cache"; // ============================================================================ // Symbol Presets @@ -2340,7 +2340,7 @@ export function getMarkdownTheme(): MarkdownTheme { underline: (text: string) => theme.underline(text), strikethrough: (text: string) => chalk.strikethrough(text), symbols: getSymbolTheme(), - getMermaidImage, + getMermaidAscii, highlightCode: (code: string, lang?: string): string[] => { const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined; try { diff --git a/packages/coding-agent/src/prompts/tools/render-mermaid.md b/packages/coding-agent/src/prompts/tools/render-mermaid.md new file mode 100644 index 000000000..dd80b3179 --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/render-mermaid.md @@ -0,0 +1,9 @@ +Convert Mermaid graph source into ASCII diagram output. + +Parameters: +- `mermaid` (required): Mermaid graph text to render. +- `config` (optional): JSON render configuration (spacing and layout options). +Behavior: +- Returns ASCII diagram text. +- Saves full ASCII output to an artifact URL (`artifact://`) when artifact storage is available. +- Returns an error when the Mermaid input is invalid or rendering fails. \ No newline at end of file diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index b03e04d3f..aca7d1d27 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -30,6 +30,7 @@ import { NotebookTool } from "./notebook"; import { wrapToolWithMetaNotice } from "./output-meta"; import { PythonTool } from "./python"; import { ReadTool } from "./read"; +import { RenderMermaidTool } from "./render-mermaid"; import { ResolveTool } from "./resolve"; import { reportFindingTool } from "./review"; import { loadSshTool } from "./ssh"; @@ -63,6 +64,7 @@ export * from "./notebook"; export * from "./pending-action"; export * from "./python"; export * from "./read"; +export * from "./render-mermaid"; export * from "./resolve"; export * from "./review"; export * from "./ssh"; @@ -150,6 +152,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise; export const BUILTIN_TOOLS: Record = { ast_grep: s => new AstGrepTool(s), ast_edit: s => new AstEditTool(s), + render_mermaid: s => new RenderMermaidTool(s), ask: AskTool.createIf, bash: s => new BashTool(s), python: s => new PythonTool(s), @@ -281,6 +284,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P if (name === "grep") return session.settings.get("grep.enabled"); if (name === "ast_grep") return session.settings.get("astGrep.enabled"); if (name === "ast_edit") return session.settings.get("astEdit.enabled"); + if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled"); if (name === "notebook") return session.settings.get("notebook.enabled"); if (name === "fetch") return session.settings.get("fetch.enabled"); if (name === "web_search") return session.settings.get("web_search.enabled"); diff --git a/packages/coding-agent/src/tools/render-mermaid.ts b/packages/coding-agent/src/tools/render-mermaid.ts new file mode 100644 index 000000000..d89c07b9d --- /dev/null +++ b/packages/coding-agent/src/tools/render-mermaid.ts @@ -0,0 +1,67 @@ +import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import { type MermaidAsciiRenderOptions, renderMermaidAscii } from "@oh-my-pi/pi-utils"; +import { type Static, Type } from "@sinclair/typebox"; +import { renderPromptTemplate } from "../config/prompt-templates"; +import renderMermaidDescription from "../prompts/tools/render-mermaid.md" with { type: "text" }; +import type { ToolSession } from "./index"; + +const renderMermaidSchema = Type.Object({ + mermaid: Type.String({ description: "Mermaid graph source text" }), + config: Type.Optional( + Type.Object({ + useAscii: Type.Optional(Type.Boolean()), + paddingX: Type.Optional(Type.Number()), + paddingY: Type.Optional(Type.Number()), + boxBorderPadding: Type.Optional(Type.Number()), + }), + ), +}); + +type RenderMermaidParams = Static; + +function sanitizeRenderConfig(config: MermaidAsciiRenderOptions | undefined): MermaidAsciiRenderOptions | undefined { + if (!config) return undefined; + return { + useAscii: config.useAscii, + boxBorderPadding: + config.boxBorderPadding === undefined ? undefined : Math.max(0, Math.floor(config.boxBorderPadding)), + paddingX: config.paddingX === undefined ? undefined : Math.max(0, Math.floor(config.paddingX)), + paddingY: config.paddingY === undefined ? undefined : Math.max(0, Math.floor(config.paddingY)), + }; +} +export interface RenderMermaidToolDetails { + artifactId?: string; +} + +export class RenderMermaidTool implements AgentTool { + readonly name = "render_mermaid"; + readonly label = "RenderMermaid"; + readonly description: string; + readonly parameters = renderMermaidSchema; + readonly strict = true; + + constructor(private readonly session: ToolSession) { + this.description = renderPromptTemplate(renderMermaidDescription); + } + + async execute( + _toolCallId: string, + params: RenderMermaidParams, + _signal?: AbortSignal, + _onUpdate?: AgentToolUpdateCallback, + _context?: AgentToolContext, + ): Promise> { + const ascii = renderMermaidAscii(params.mermaid, sanitizeRenderConfig(params.config)); + const { path: artifactPath, id: artifactId } = + (await this.session.allocateOutputArtifact?.("render_mermaid")) ?? {}; + if (artifactPath) { + await Bun.write(artifactPath, ascii); + } + + const artifactLine = artifactId ? `\n\nSaved artifact: artifact://${artifactId}` : ""; + return { + content: [{ type: "text", text: `${ascii}${artifactLine}` }], + details: { artifactId }, + }; + } +} diff --git a/packages/coding-agent/test/tools/index.test.ts b/packages/coding-agent/test/tools/index.test.ts index 0d8b56519..53d008bd7 100644 --- a/packages/coding-agent/test/tools/index.test.ts +++ b/packages/coding-agent/test/tools/index.test.ts @@ -130,6 +130,25 @@ describe("createTools", () => { expect(names).toContain("ask"); }); + it("excludes render_mermaid tool by default", async () => { + const session = createTestSession(); + const tools = await createTools(session); + const names = tools.map(t => t.name); + + expect(names).not.toContain("render_mermaid"); + }); + + it("includes render_mermaid tool when enabled", async () => { + const session = createTestSession({ + settings: createSettingsWithOverrides({ + "renderMermaid.enabled": true, + }), + }); + const tools = await createTools(session); + const names = tools.map(t => t.name); + + expect(names).toContain("render_mermaid"); + }); it("HIDDEN_TOOLS contains review tools", () => { expect(Object.keys(HIDDEN_TOOLS).sort()).toEqual([ "exit_plan_mode", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8ebc4afe3..b88422b8b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Removed `getMermaidImage` callback from MarkdownTheme; replaced with `getMermaidAscii` that accepts ASCII string instead of image data +- Removed mermaid module exports (`renderMermaidToPng`, `extractMermaidBlocks`, `prerenderMermaidBlocks`, `MermaidImage` interface) + +### Changed + +- Mermaid diagrams now render as ASCII text instead of terminal graphics protocol images ## [13.5.1] - 2026-03-01 ### Fixed diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 88ba5a878..76fb890f4 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1,7 +1,6 @@ import { marked, type Token } from "marked"; -import type { MermaidImage } from "../mermaid"; import type { SymbolTheme } from "../symbols"; -import { encodeITerm2, encodeKitty, getCellDimensions, ImageProtocol, TERMINAL } from "../terminal-capabilities"; +import { TERMINAL } from "../terminal-capabilities"; import type { Component } from "../tui"; import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils"; @@ -45,11 +44,11 @@ export interface MarkdownTheme { underline: (text: string) => string; highlightCode?: (code: string, lang?: string) => string[]; /** - * Lookup a pre-rendered mermaid image by source hash. + * Lookup a pre-rendered mermaid ASCII rendering by source hash. * Hash is computed as `Bun.hash.xxHash64(source.trim())`. - * Return null to fall back to text rendering. + * Return null to fall back to fenced code rendering. */ - getMermaidImage?: (sourceHash: bigint) => MermaidImage | null; + getMermaidAscii?: (sourceHash: bigint) => string | null; symbols: SymbolTheme; } @@ -320,20 +319,19 @@ export class Markdown implements Component { } case "code": { - // Handle mermaid diagrams with image rendering when available - if (token.lang === "mermaid" && this.#theme.getMermaidImage) { + // Handle mermaid diagrams with ASCII rendering when available + if (token.lang === "mermaid" && this.#theme.getMermaidAscii) { const hash = Bun.hash.xxHash64(token.text.trim()); - const image = this.#theme.getMermaidImage(hash); + const ascii = this.#theme.getMermaidAscii(hash); - if (image && TERMINAL.imageProtocol) { - const imageLines = this.#renderMermaidImage(image, width); - if (imageLines) { - lines.push(...imageLines); - if (nextTokenType !== "space") { - lines.push(""); - } - break; + if (ascii) { + for (const asciiLine of Bun.stripANSI(ascii).split("\n")) { + lines.push(asciiLine); } + if (nextTokenType !== "space") { + lines.push(""); + } + break; } } @@ -811,61 +809,4 @@ export class Markdown implements Component { lines.push(""); // Add spacing after table return lines; } - - /** - * Render a mermaid image using terminal graphics protocol. - * Returns array of lines (image placeholder rows) or null if rendering fails. - */ - #renderMermaidImage(image: MermaidImage, availableWidth: number): string[] | null { - if (!TERMINAL.imageProtocol) return null; - - const cellDims = getCellDimensions(); - const scale = 0.5; // Render at 50% of natural size - - // Calculate natural size in cells (don't scale up, only down if needed) - const naturalColumns = Math.ceil((image.widthPx * scale) / cellDims.widthPx); - const naturalRows = Math.ceil((image.heightPx * scale) / cellDims.heightPx); - - // Use natural size, but cap to available width - const columns = Math.min(naturalColumns, availableWidth); - - // If we had to shrink width, calculate proportional height - let rows: number; - if (columns < naturalColumns) { - // Scaled down - recalculate height - const scale = columns / naturalColumns; - rows = Math.max(1, Math.ceil(naturalRows * scale)); - } else { - // Natural size - rows = naturalRows; - } - - let sequence: string; - switch (TERMINAL.imageProtocol) { - case ImageProtocol.Kitty: - sequence = encodeKitty(image.base64, { columns, rows }); - break; - case ImageProtocol.Iterm2: - sequence = encodeITerm2(image.base64, { - width: columns, - height: "auto", - preserveAspectRatio: true, - }); - break; - default: - return null; - } - - // Reserve space with empty lines, then output image with cursor-up - // This ensures TUI accounts for image height in layout - const lines: string[] = []; - for (let i = 0; i < rows - 1; i++) { - lines.push(""); - } - // Move cursor up to first row, then output image - const moveUp = rows > 1 ? `\x1b[${rows - 1}A` : ""; - lines.push(moveUp + sequence); - - return lines; - } } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index f2917f00d..6c72aeb4d 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -25,7 +25,6 @@ export * from "./keybindings"; // Kitty keyboard protocol helpers export * from "./keys"; // Mermaid diagram support -export * from "./mermaid"; // Input buffering for batch splitting export * from "./stdin-buffer"; export type * from "./symbols"; diff --git a/packages/tui/src/mermaid.ts b/packages/tui/src/mermaid.ts deleted file mode 100644 index 0cd616eee..000000000 --- a/packages/tui/src/mermaid.ts +++ /dev/null @@ -1,140 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { $ } from "bun"; - -export interface MermaidImage { - base64: string; - widthPx: number; - heightPx: number; -} - -export interface MermaidRenderOptions { - theme?: "default" | "dark" | "forest" | "neutral"; - backgroundColor?: string; - width?: number; - scale?: number; -} - -/** - * Render mermaid diagram source to PNG. - * - * Uses `mmdc` (mermaid-cli) which must be installed and in PATH. - * Returns null if rendering fails or mmdc is unavailable. - */ -export async function renderMermaidToPng( - source: string, - options: MermaidRenderOptions = {}, -): Promise { - const mmdc = Bun.which("mmdc"); - if (!mmdc) { - return null; - } - - const tmpDir = path.join(os.tmpdir(), `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`); - const inputPath = path.join(tmpDir, "input.mmd"); - const outputPath = path.join(tmpDir, "output.png"); - - try { - await Bun.write(inputPath, source); - - const args: string[] = ["-i", inputPath, "-o", outputPath, "-q"]; - - if (options.theme) { - args.push("-t", options.theme); - } - if (options.backgroundColor) { - args.push("-b", options.backgroundColor); - } - if (options.width) { - args.push("-w", String(options.width)); - } - if (options.scale) { - args.push("-s", String(options.scale)); - } - - const result = await $`${mmdc} ${args}`.quiet().nothrow(); - if (result.exitCode !== 0) { - return null; - } - - const outputFile = Bun.file(outputPath); - if (!(await outputFile.exists())) { - return null; - } - - const buffer = await outputFile.bytes(); - const base64 = buffer.toBase64(); - - const dims = parsePngDimensions(buffer); - if (!dims) { - return null; - } - - return { - base64, - widthPx: dims.width, - heightPx: dims.height, - }; - } catch { - return null; - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - } -} - -function parsePngDimensions(buffer: Uint8Array): { width: number; height: number } | null { - if (buffer.length < 24) return null; - if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) { - return null; - } - const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); - return { - width: view.getUint32(16, false), - height: view.getUint32(20, false), - }; -} - -/** - * Extract mermaid code blocks from markdown text. - * Returns array of { source, startIndex, endIndex } for each block. - */ -export function extractMermaidBlocks(markdown: string): { source: string; hash: bigint }[] { - const blocks: { source: string; hash: bigint }[] = []; - const regex = /```mermaid\s*\n([\s\S]*?)```/g; - - for (let match = regex.exec(markdown); match !== null; match = regex.exec(markdown)) { - const source = match[1].trim(); - const hash = Bun.hash.xxHash64(source); - blocks.push({ source, hash }); - } - - return blocks; -} - -/** - * Pre-render all mermaid blocks in markdown text. - * Returns a cache map: hash → MermaidImage. - */ -export async function prerenderMermaidBlocks( - markdown: string, - options: MermaidRenderOptions = {}, -): Promise> { - const blocks = extractMermaidBlocks(markdown); - const cache = new Map(); - - const results = await Promise.all( - blocks.map(async ({ source, hash }) => { - const image = await renderMermaidToPng(source, options); - return { hash, image }; - }), - ); - - for (const { hash, image } of results) { - if (image) { - cache.set(hash, image); - } - } - - return cache; -} diff --git a/packages/utils/package.json b/packages/utils/package.json index 945cb510e..24b1f762d 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -27,6 +27,7 @@ "test": "bun test" }, "dependencies": { + "beautiful-mermaid": "1.1.3", "winston": "^3.19", "winston-daily-rotate-file": "^5.0" }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 6ad94ed2a..31f97afb0 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -9,6 +9,7 @@ export * from "./glob"; export * from "./indent"; export * from "./json"; export * as logger from "./logger"; +export * from "./mermaid-ascii"; export * as postmortem from "./postmortem"; export * as procmgr from "./procmgr"; export { setNativeKillTree } from "./procmgr"; diff --git a/packages/utils/src/mermaid-ascii.ts b/packages/utils/src/mermaid-ascii.ts new file mode 100644 index 000000000..2f67c2274 --- /dev/null +++ b/packages/utils/src/mermaid-ascii.ts @@ -0,0 +1,31 @@ +import { type AsciiRenderOptions, renderMermaidASCII } from "beautiful-mermaid"; + +export type { AsciiRenderOptions as MermaidAsciiRenderOptions }; + +export function renderMermaidAscii(source: string, options?: AsciiRenderOptions): string { + return renderMermaidASCII(source, options); +} + +export function renderMermaidAsciiSafe(source: string, options?: AsciiRenderOptions): string | null { + try { + return renderMermaidASCII(source, options); + } catch { + return null; + } +} + +/** + * Extract mermaid code blocks from markdown text. + */ +export function extractMermaidBlocks(markdown: string): { source: string; hash: bigint }[] { + const blocks: { source: string; hash: bigint }[] = []; + const regex = /```mermaid\s*\n([\s\S]*?)```/g; + + for (let match = regex.exec(markdown); match !== null; match = regex.exec(markdown)) { + const source = match[1].trim(); + const hash = Bun.hash.xxHash64(source); + blocks.push({ source, hash }); + } + + return blocks; +}