feat: introduced render_mermaid tool for ASCII diagram output
- Added render_mermaid tool to convert Mermaid diagrams to ASCII output with configurable rendering options. - Added renderMermaid.enabled setting to control availability of the render_mermaid tool. - Changed Mermaid rendering from PNG terminal graphics to ASCII text format across theme and TUI components. - Migrated mermaid utilities from pi-tui to pi-utils package with new ASCII rendering functions. - Removed PNG-based mermaid rendering APIs (getMermaidImage, renderMermaidToPng) in favor of ASCII alternatives.
This commit is contained in:
+2
-1
@@ -45,7 +45,8 @@
|
||||
"!**/template.generated.ts",
|
||||
"!**/docs-index.generated.ts",
|
||||
"!**/gen/agent_pb.ts",
|
||||
"!.worktrees/**/*"
|
||||
"!.worktrees/**/*",
|
||||
"!.wt/**/*"
|
||||
]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } }
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<bigint, MermaidImage>();
|
||||
const pending = new Map<bigint, Promise<MermaidImage | null>>();
|
||||
const cache = new Map<bigint, string>();
|
||||
const failed = new Set<bigint>();
|
||||
|
||||
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<void> {
|
||||
export function prerenderMermaid(markdown: string): void {
|
||||
const blocks = extractMermaidBlocks(markdown);
|
||||
if (blocks.length === 0) return;
|
||||
|
||||
const promises: Promise<boolean>[] = [];
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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://<id>`) when artifact storage is available.
|
||||
- Returns an error when the Mermaid input is invalid or rendering fails.
|
||||
@@ -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<Tool | null>;
|
||||
export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
||||
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");
|
||||
|
||||
@@ -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<typeof renderMermaidSchema>;
|
||||
|
||||
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<typeof renderMermaidSchema, RenderMermaidToolDetails> {
|
||||
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<RenderMermaidToolDetails>,
|
||||
_context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<RenderMermaidToolDetails>> {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<MermaidImage | null> {
|
||||
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<Map<bigint, MermaidImage>> {
|
||||
const blocks = extractMermaidBlocks(markdown);
|
||||
const cache = new Map<bigint, MermaidImage>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"beautiful-mermaid": "1.1.3",
|
||||
"winston": "^3.19",
|
||||
"winston-daily-rotate-file": "^5.0"
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user