This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* RenderMermaid (#1025) — check a diagram, then show it.
|
||||
*
|
||||
* The agent gets no guidance anywhere about how to write Mermaid, so it picks a
|
||||
* CDN and a version per document and the syntax it writes often doesn't match
|
||||
* the version that ends up running. This tool closes that loop: it parses with
|
||||
* the version WE ship, hands back the failing line, renders a PNG the agent can
|
||||
* actually look at (ReadImage takes no SVG), and — for HTML — swaps the diagram
|
||||
* source for an inline SVG so the diagram itself needs no script, no network and
|
||||
* no version agreement with anything.
|
||||
*
|
||||
* One render pass produces both files, so what the agent inspects and what the
|
||||
* document carries can never disagree.
|
||||
*
|
||||
* The rest of the page is the author's. This tool replaces diagram blocks and
|
||||
* touches nothing else — in particular it never deletes the page's scripts, not
|
||||
* even a Mermaid loader the SVG has made redundant (#1027).
|
||||
*/
|
||||
import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'fs';
|
||||
import { dirname, join, extname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { chromium } from 'playwright';
|
||||
import sharp from 'sharp';
|
||||
import type { ToolDef } from '../../llm/openai-compat.js';
|
||||
import type { ToolContext, ToolResult } from './tool-context.js';
|
||||
import { resolveAndGuard, resolveOutputPathWithin, assertWritable } from './core.js';
|
||||
import { buildLaunchOptions } from '../browser-launch.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { loadConfig } from '../../config.js';
|
||||
import { extractMermaidBlocks, replaceHtmlBlocksWithSvg, type MermaidSourceKind } from './mermaid-blocks.js';
|
||||
|
||||
const __dirnameMermaid = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Budget for the WHOLE call, shared by every page operation. Per-diagram limits
|
||||
* alone don't bound anything: 30 diagrams × 20s is ten minutes of worker slot
|
||||
* (#1027 P2), so the deadline is what actually protects it.
|
||||
*/
|
||||
const RENDER_TIMEOUT_MS = 60_000;
|
||||
/** Ceiling for one diagram, capped further by whatever is left of the budget. */
|
||||
const DIAGRAM_TIMEOUT_MS = 20_000;
|
||||
const MAX_DIAGRAMS = 30;
|
||||
/** A diagram this long is a mistake, and it is cheaper to refuse than to render. */
|
||||
const MAX_CODE_CHARS = 20_000;
|
||||
/**
|
||||
* The PNG is a screenshot, so it carries exactly as many pixels as the browser
|
||||
* says the device has. At 1 the labels came out at the CSS size and turned to
|
||||
* mush the moment anyone zoomed or printed the page (#1257). 2 doubles the
|
||||
* raster without touching the layout — the SVG in the HTML output is unaffected.
|
||||
*/
|
||||
const DEVICE_SCALE_FACTOR = 2;
|
||||
/**
|
||||
* Ceiling on the pixel count of the PNG we write, applied after the screenshot.
|
||||
*
|
||||
* The doc tells the agent to open the file with ReadImage, and ReadImage does
|
||||
* not shrink anything — the bytes go into the conversation as they are. The gate
|
||||
* in agent-loop/image-context.ts allows at most 8,000 tokens of image per round
|
||||
* and, when the total is over, drops EVERY image of that round, not just the big
|
||||
* one. context/token-estimate.ts charges pixels / 750 and stops counting at
|
||||
* 1568×1568 = 2,458,624 px = 3,279 tokens.
|
||||
*
|
||||
* So the cap has to sit well under that plateau. Cutting at 2.45 Mpx would buy
|
||||
* nothing: a measured 25-step chain came out 504×5174 = 2,607,696 px at scale 2,
|
||||
* already charged the full 3,279, so three of them are 9,837 — over the ceiling
|
||||
* no matter how much context is free. Dividing the ceiling by five diagrams
|
||||
* gives 1,600 tokens each, and 1,600 × 750 = 1,200,000 px. Five capped images
|
||||
* total exactly 8,000, and the gate compares with `>`, so they still get in. The
|
||||
* same 25-step chain measures 341×3501 = 1,193,841 px after the cap: 1,592
|
||||
* tokens, three of them 4,776.
|
||||
*
|
||||
* The cost: nothing until the picture is past 1.2 Mpx (the 10-step chain at
|
||||
* 1,035,216 px is untouched), and output below what scale 1 used to give only
|
||||
* once the CSS box ITSELF is past 1.2 Mpx — a chain of roughly 45 steps in the
|
||||
* shape measured above. The capped 25-step is still 1.8× its own CSS box.
|
||||
*/
|
||||
const MAX_PNG_PIXELS = 1_200_000;
|
||||
|
||||
/**
|
||||
* page.evaluate takes no timeout of its own — Playwright's default only covers
|
||||
* navigation and locator work. Without this, a pathological diagram parks a
|
||||
* Chromium instance and a worker slot forever; the reject unwinds into the
|
||||
* finally that closes the browser.
|
||||
*/
|
||||
async function withTimeout<T>(work: Promise<T>, ms: number, what: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
work,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** A PNG carries its pixel size in the IHDR chunk: width at byte 16, height at 20. */
|
||||
export function readPngPixelSize(png: Buffer): { width: number; height: number } {
|
||||
if (png.length < 24 || png.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG');
|
||||
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrink the screenshot to MAX_PNG_PIXELS if it is over, keeping the shape.
|
||||
*
|
||||
* Both sides are floored on purpose: floor × floor is always at or under the
|
||||
* cap, which is what makes the token estimate provably bounded. Scaling one side
|
||||
* and letting the other be derived can round the product a few hundred pixels
|
||||
* past the cap, and the estimator rounds tokens up.
|
||||
*/
|
||||
export async function capPngPixels(png: Buffer, maxPixels = MAX_PNG_PIXELS): Promise<Buffer> {
|
||||
const { width, height } = readPngPixelSize(png);
|
||||
const pixels = width * height;
|
||||
if (pixels <= maxPixels) return png;
|
||||
const scale = Math.sqrt(maxPixels / pixels);
|
||||
const w = Math.max(1, Math.floor(width * scale));
|
||||
const h = Math.max(1, Math.floor(height * scale));
|
||||
// fit 'inside' preserves the aspect ratio; sharp's default ('cover') crops.
|
||||
return await sharp(png).resize(w, h, { fit: 'inside' }).png().toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the Mermaid bundle lives at runtime. `vendor/` is what the Docker image
|
||||
* ships (the builder copies it out of the UI's node_modules); the node_modules
|
||||
* path is the source checkout. Deliberately ONE source — the UI's own
|
||||
* dependency — so the version that validates is the version that renders.
|
||||
*/
|
||||
export function mermaidBundleCandidates(rootDir: string): string[] {
|
||||
return [
|
||||
join(rootDir, 'vendor', 'mermaid', 'mermaid.min.js'),
|
||||
join(rootDir, 'ui', 'node_modules', 'mermaid', 'dist', 'mermaid.min.js'),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveMermaidBundle(rootDir = resolve(__dirnameMermaid, '../../..')): string {
|
||||
for (const candidate of mermaidBundleCandidates(rootDir)) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(
|
||||
'Mermaid bundle not found. Run `npm run build:ui` (it copies mermaid into vendor/) or install the UI dependencies.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The UMD bundle doesn't carry its own version, so read it from the sidecar the
|
||||
* copy script writes (vendor/) or from the package we copied it out of (dev).
|
||||
* Reporting it matters: "which version said this was fine" is the question the
|
||||
* whole tool exists to answer.
|
||||
*/
|
||||
export function readMermaidVersion(bundlePath: string): string {
|
||||
const sidecar = join(dirname(bundlePath), 'version.txt');
|
||||
if (existsSync(sidecar)) return readFileSync(sidecar, 'utf8').trim();
|
||||
const pkg = join(dirname(bundlePath), '..', 'package.json');
|
||||
if (existsSync(pkg)) {
|
||||
try {
|
||||
return String(JSON.parse(readFileSync(pkg, 'utf8')).version ?? 'unknown');
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export interface MermaidDiagnostic {
|
||||
message: string;
|
||||
/** 1-based line within the diagram source, when the parser reported one. */
|
||||
line?: number;
|
||||
/** The offending line itself — the message alone rarely points anywhere useful. */
|
||||
sourceLine?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a Mermaid parse error into something the agent can act on. Mermaid says
|
||||
* "Parse error on line 2:" and then quotes a flattened, context-free excerpt;
|
||||
* pairing the number with the real line is what makes it fixable.
|
||||
*/
|
||||
export function parseMermaidError(message: string, code: string): MermaidDiagnostic {
|
||||
const firstLine = message.split('\n')[0].trim();
|
||||
const match = message.match(/(?:on|at) line (\d+)/i);
|
||||
if (!match) return { message: firstLine };
|
||||
const line = Number(match[1]);
|
||||
const sourceLine = code.split('\n')[line - 1];
|
||||
return { message: firstLine, line, ...(sourceLine !== undefined ? { sourceLine } : {}) };
|
||||
}
|
||||
|
||||
export interface MermaidRenderResult {
|
||||
ok: boolean;
|
||||
svg?: string;
|
||||
png?: Buffer;
|
||||
diagnostic?: MermaidDiagnostic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render every diagram in ONE browser. Launching Chromium costs about a second,
|
||||
* so a page per diagram would make checking a document with five of them
|
||||
* pointlessly slow.
|
||||
*/
|
||||
export async function renderMermaidDiagrams(
|
||||
codes: string[],
|
||||
opts: { bundlePath?: string; timeoutMs?: number; totalTimeoutMs?: number } = {},
|
||||
): Promise<{ version: string; results: MermaidRenderResult[] }> {
|
||||
const totalMs = opts.totalTimeoutMs ?? RENDER_TIMEOUT_MS;
|
||||
const perDiagramMs = Math.min(opts.timeoutMs ?? DIAGRAM_TIMEOUT_MS, totalMs);
|
||||
const deadline = Date.now() + totalMs;
|
||||
const remaining = () => deadline - Date.now();
|
||||
|
||||
const bundlePath = opts.bundlePath ?? resolveMermaidBundle();
|
||||
const bundle = readFileSync(bundlePath, 'utf8');
|
||||
// Honour the operator's browser settings (executable path / channel) exactly
|
||||
// like the browser tool and the session manager do — this was the only launch
|
||||
// site ignoring them.
|
||||
const browser = await chromium.launch(buildLaunchOptions(loadConfig().browser, true));
|
||||
try {
|
||||
const page = await browser.newPage({ deviceScaleFactor: DEVICE_SCALE_FACTOR });
|
||||
page.setDefaultTimeout(perDiagramMs);
|
||||
await page.setContent('<!doctype html><body><div id="stage"></div></body>');
|
||||
await page.addScriptTag({ content: bundle });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const m = (window as unknown as { mermaid: { initialize: (c: unknown) => void } }).mermaid;
|
||||
// Same options the UI initializes with (FilePreview) — a diagram that
|
||||
// passes here must behave the same when the app renders it.
|
||||
m.initialize({ startOnLoad: false, theme: 'default' });
|
||||
});
|
||||
const version = readMermaidVersion(bundlePath);
|
||||
|
||||
const results: MermaidRenderResult[] = [];
|
||||
// A page that stopped answering does not come back, so the first timeout
|
||||
// ends the run. Carrying on would spend the full per-diagram budget on
|
||||
// every remaining diagram for nothing (#1027 P2).
|
||||
let giveUp: string | null = null;
|
||||
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
if (!giveUp && remaining() <= 0) giveUp = `the ${totalMs}ms render budget ran out`;
|
||||
if (giveUp) {
|
||||
results.push({ ok: false, diagnostic: { message: `Not rendered: ${giveUp}.` } });
|
||||
continue;
|
||||
}
|
||||
const budget = Math.min(perDiagramMs, remaining());
|
||||
|
||||
const rendered = await withTimeout(
|
||||
page.evaluate(
|
||||
async ([code, id]) => {
|
||||
const m = (window as unknown as {
|
||||
mermaid: { parse: (c: string) => Promise<unknown>; render: (id: string, c: string) => Promise<{ svg: string }> };
|
||||
}).mermaid;
|
||||
try {
|
||||
await m.parse(code);
|
||||
const { svg } = await m.render(id, code);
|
||||
return { ok: true as const, svg };
|
||||
} catch (e) {
|
||||
const err = e as { str?: string; message?: string };
|
||||
return { ok: false as const, message: String(err?.str ?? err?.message ?? e) };
|
||||
}
|
||||
},
|
||||
[codes[i], `render-${i}`] as [string, string],
|
||||
),
|
||||
budget,
|
||||
`diagram ${i + 1}`,
|
||||
).catch((e: unknown) => ({ ok: false as const, timedOut: true, message: (e as Error).message ?? String(e) }));
|
||||
|
||||
if (!rendered.ok) {
|
||||
if ('timedOut' in rendered) giveUp = `the renderer stopped responding (diagram ${i + 1})`;
|
||||
results.push({ ok: false, diagnostic: parseMermaidError(rendered.message, codes[i]) });
|
||||
continue;
|
||||
}
|
||||
// Screenshot the SVG we just produced — the PNG is a picture OF the
|
||||
// embedded artwork, not a second, independent render. Both steps draw on
|
||||
// the same budget, so the whole call stays bounded.
|
||||
const png = await withTimeout(
|
||||
(async () => {
|
||||
await page.evaluate((svg) => {
|
||||
const stage = document.getElementById('stage')!;
|
||||
stage.innerHTML = svg;
|
||||
stage.style.cssText = 'display:inline-block;background:#fff;padding:8px';
|
||||
}, rendered.svg);
|
||||
return page.locator('#stage').screenshot({ type: 'png' });
|
||||
})(),
|
||||
Math.max(1, Math.min(perDiagramMs, remaining())),
|
||||
`diagram ${i + 1} screenshot`,
|
||||
).catch((e: unknown) => {
|
||||
giveUp = `the renderer stopped responding (diagram ${i + 1})`;
|
||||
logger.warn(`[mermaid] screenshot failed: ${(e as Error).message ?? String(e)}`);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Scale 2 is what makes the labels readable, but it also quadruples the
|
||||
// pixels, and a tall diagram then blows the image budget and takes every
|
||||
// other image of that round down with it. Shrink here, once, so the file
|
||||
// on disk is the one the agent can actually open.
|
||||
const capped = png
|
||||
? await capPngPixels(png).catch((e: unknown) => {
|
||||
logger.warn(`[mermaid] downscale failed: ${(e as Error).message ?? String(e)}`);
|
||||
return undefined;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!capped) {
|
||||
results.push({ ok: false, diagnostic: { message: `Rendered, but the image could not be produced (diagram ${i + 1}).` } });
|
||||
continue;
|
||||
}
|
||||
results.push({ ok: true, svg: rendered.svg, png: capped });
|
||||
}
|
||||
return { version, results };
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tool surface -----------------------------------------------------------
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
RenderMermaid: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'RenderMermaid',
|
||||
description:
|
||||
'Mermaid 図の構文を検査して PNG 画像に描画し、HTML ファイルなら図を SVG に置き換えて外部依存を無くす。詳細は ReadToolDoc({ name: "RenderMermaid" }) で取得可能。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: 'string', description: 'Mermaid diagram source to check and render.' },
|
||||
path: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Workspace file (.md / .html) to check instead: every Mermaid block in it is validated and rendered.',
|
||||
},
|
||||
embed: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'HTML only, default true: replace each diagram with the rendered SVG so it displays without a script.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function detectKind(filePath: string): MermaidSourceKind {
|
||||
return /\.html?$/i.test(filePath) ? 'html' : 'md';
|
||||
}
|
||||
|
||||
/**
|
||||
* PNG name for a source file. Flattening the WHOLE relative path matters:
|
||||
* naming by basename alone made `output/a.html` and `output/sub/a.html` write
|
||||
* the same file, so the second overwrote the first and the agent then looked at
|
||||
* the wrong diagram (#1027 P3).
|
||||
*/
|
||||
function outputStem(filePath: string | undefined): string {
|
||||
if (!filePath) return join('output', 'diagrams', 'diagram');
|
||||
const withoutExt = filePath.slice(0, filePath.length - extname(filePath).length);
|
||||
const flat = withoutExt.replace(/[^\w.-]+/g, '_').replace(/^_+/, '') || 'diagram';
|
||||
return join('output', 'diagrams', flat);
|
||||
}
|
||||
|
||||
/** `String(e)` on an Error prints "Error: …" into a sentence that already says
|
||||
* what went wrong (#1027 P3). */
|
||||
function errText(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/** Returns null for names this module doesn't own — the dispatch chain in
|
||||
* tools/index.ts relies on that to fall through to the next module. */
|
||||
export async function executeTool(name: string, input: unknown, ctx: ToolContext): Promise<ToolResult | null> {
|
||||
if (name !== 'RenderMermaid') return null;
|
||||
const args = (input ?? {}) as { code?: unknown; path?: unknown; embed?: unknown };
|
||||
const codeArg = typeof args.code === 'string' ? args.code : undefined;
|
||||
const pathArg = typeof args.path === 'string' ? args.path : undefined;
|
||||
|
||||
if (!codeArg && !pathArg) return { isError: true, output: 'RenderMermaid needs either `code` or `path`.' };
|
||||
if (codeArg && pathArg) return { isError: true, output: 'RenderMermaid takes `code` or `path`, not both.' };
|
||||
|
||||
// Every path through this tool saves a PNG, so a task that may not write
|
||||
// files cannot use it at all. Say so before launching a browser, the way
|
||||
// PdfToImages does — rendering first and refusing afterwards left images on
|
||||
// disk under a report that said nothing had been written (#1027 P3).
|
||||
if (!ctx.editAllowed) {
|
||||
return { isError: true, output: 'RenderMermaid is not allowed: edit flag is false' };
|
||||
}
|
||||
|
||||
let sourceText = '';
|
||||
let absPath: string | undefined;
|
||||
let kind: MermaidSourceKind = 'md';
|
||||
if (pathArg) {
|
||||
try {
|
||||
absPath = resolveAndGuard(ctx.workspacePath, pathArg);
|
||||
sourceText = readFileSync(absPath, 'utf8');
|
||||
} catch (e) {
|
||||
return { isError: true, output: `Could not read ${pathArg}: ${errText(e)}` };
|
||||
}
|
||||
kind = detectKind(pathArg);
|
||||
}
|
||||
|
||||
// Same reasoning, and the same rule Write/Edit enforce: readonly/ is the
|
||||
// user's own area. Refuse before rendering rather than after, so the refusal
|
||||
// and what is left on disk agree.
|
||||
const wantsEmbed = Boolean(pathArg) && kind === 'html' && args.embed !== false;
|
||||
if (wantsEmbed && absPath) {
|
||||
try {
|
||||
assertWritable(ctx.workspacePath, absPath);
|
||||
} catch (e) {
|
||||
return {
|
||||
isError: true,
|
||||
output:
|
||||
`Could not update ${pathArg}: ${errText(e)}\n` +
|
||||
'Pass embed: false to check the diagrams and get their PNGs without rewriting the file.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = codeArg ? [{ code: codeArg, start: 0, end: 0 }] : extractMermaidBlocks(sourceText, kind);
|
||||
if (blocks.length === 0) {
|
||||
return { isError: false, output: `No Mermaid blocks found in ${pathArg}.` };
|
||||
}
|
||||
if (blocks.length > MAX_DIAGRAMS) {
|
||||
return { isError: true, output: `Too many diagrams (${blocks.length}); the limit is ${MAX_DIAGRAMS}.` };
|
||||
}
|
||||
const oversized = blocks.findIndex(b => b.code.length > MAX_CODE_CHARS);
|
||||
if (oversized >= 0) {
|
||||
return {
|
||||
isError: true,
|
||||
output:
|
||||
`Diagram ${oversized + 1} is ${blocks[oversized].code.length} characters; the limit is ${MAX_CODE_CHARS}. ` +
|
||||
'Split it into several smaller diagrams — one that large is unreadable anyway.',
|
||||
};
|
||||
}
|
||||
|
||||
let version: string;
|
||||
let results: MermaidRenderResult[];
|
||||
try {
|
||||
({ version, results } = await renderMermaidDiagrams(blocks.map(b => b.code)));
|
||||
} catch (e) {
|
||||
logger.warn(`[mermaid] render failed: ${errText(e)}`);
|
||||
return { isError: true, output: `Mermaid rendering is unavailable: ${errText(e)}` };
|
||||
}
|
||||
|
||||
const stem = outputStem(pathArg);
|
||||
const lines: string[] = [`mermaid ${version} — ${blocks.length} diagram(s)`];
|
||||
const svgs: (string | null)[] = [];
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i];
|
||||
const label = blocks.length === 1 ? '' : ` #${i + 1}`;
|
||||
if (!r.ok) {
|
||||
svgs.push(null);
|
||||
const d = r.diagnostic!;
|
||||
lines.push(
|
||||
`FAILED${label}: ${d.message}` +
|
||||
(d.line ? `\n line ${d.line}: ${d.sourceLine ?? ''}` : ''),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
svgs.push(r.svg ?? null);
|
||||
const pngRel = `${stem}${blocks.length === 1 ? '' : `-${i + 1}`}.png`;
|
||||
try {
|
||||
const pngAbs = resolveOutputPathWithin(ctx.workspacePath, pngRel, ['output']);
|
||||
mkdirSync(dirname(pngAbs), { recursive: true });
|
||||
writeFileSync(pngAbs, r.png!);
|
||||
lines.push(`OK${label}: ${pngRel} — open it with ReadImage to check the diagram is what you meant.`);
|
||||
} catch (e) {
|
||||
lines.push(`OK${label}: rendered, but the image could not be saved (${errText(e)}).`);
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter(r => !r.ok).length;
|
||||
// Failing to write is a result the agent must not read past — every diagram
|
||||
// can render fine and the file still not be updated (#1027 P3).
|
||||
let embedError: string | null = null;
|
||||
if (wantsEmbed && absPath) {
|
||||
const rendered = svgs.filter(Boolean).length;
|
||||
if (rendered > 0) {
|
||||
try {
|
||||
writeFileSync(absPath, replaceHtmlBlocksWithSvg(sourceText, blocks, svgs), 'utf8');
|
||||
// Claim only what is true, and only about the diagrams: nothing else in
|
||||
// the file was touched, so nothing else can be promised about it.
|
||||
lines.push(
|
||||
`Embedded ${rendered} diagram(s) into ${pathArg} as inline SVG — they display with no script and no ` +
|
||||
'network. Everything else in the file is untouched, and the original source is kept in ' +
|
||||
'data-mermaid-source for later edits.',
|
||||
);
|
||||
} catch (e) {
|
||||
embedError = errText(e);
|
||||
lines.push(`Could not update ${pathArg}: ${embedError}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
lines.push(
|
||||
`${failed} diagram(s) failed. Common cause: unquoted parentheses or brackets in a label — ` +
|
||||
'write A["text (note)"] instead of A[text (note)].',
|
||||
);
|
||||
}
|
||||
return { isError: failed > 0 || embedError !== null, output: lines.join('\n') };
|
||||
}
|
||||
Reference in New Issue
Block a user