#!/usr/bin/env node
/**
* mermaid_inject.ts — splice agent-emitted Mermaid blocks into a page body.
*
* Per v2 design §17 + diag Path G (cross-referencing compilation), every
* source / mapper agent that produces diagram-worthy structured data can
* return a `mermaid` field in its JSON output:
*
* { "status": "success",
* "data": { ... },
* "mermaid": { "type": "erDiagram",
* "title": "User service ERD",
* "code": "erDiagram\n ..." } }
*
* During `/doc-wiki:ingest` step 9 the compiler gathers those outputs from
* parallel agent dispatches and splices each diagram into the compiled
* page as a fenced code block under a matching `##
` heading.
* Before this module the splicing was LLM judgment; now it is
* deterministic — same outputs give the same page bytes.
*
* Idempotence:
* Each block is wrapped in HTML-comment markers
* `` / `... end -->`. Re-running
* the injector with an updated `mermaid.code` for the same title
* replaces the block in-place instead of stacking duplicates. New
* titles are appended at the end, separated by a blank line.
*
* Usage as a library:
* import { injectMermaidBlocks } from "./mermaid_inject.js";
* const body = injectMermaidBlocks(pageBody, agentOutputs);
*
* Usage as a CLI:
* node mermaid_inject.js --page wiki/topic/page.md --agents outputs.json
* # --agents points at a JSON array of agent-output envelopes.
* # --in-place rewrites the page; otherwise the new body is on stdout.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { parseFlags } from "./_cli_args.js";
/** Input shape — mirrors `MermaidBlock` from lib/mermaid_format.ts. */
export interface MermaidBlock {
type: string;
title: string;
code: string;
}
/** One agent output envelope; only the `mermaid` field is read. */
export interface AgentOutput {
readonly agent?: string;
readonly mermaid?: MermaidBlock;
}
/** Marker builders; shared with tests so the invariants stay in one place. */
export function startMarker(title: string): string {
return ``;
}
export function endMarker(title: string): string {
return ``;
}
function renderBlock(block: MermaidBlock): string {
const lines: string[] = [];
lines.push(startMarker(block.title));
lines.push(`## ${block.title}`);
lines.push("");
lines.push("```mermaid");
lines.push(block.code.replace(/\s+$/, ""));
lines.push("```");
lines.push(endMarker(block.title));
return lines.join("\n");
}
/**
* Replace the first existing wrapped block with the same title, or
* return `null` when no such block is present. Keeps whitespace above
* and below the block unchanged.
*/
function replaceBlock(body: string, block: MermaidBlock): string | null {
const start = startMarker(block.title);
const end = endMarker(block.title);
const sIdx = body.indexOf(start);
if (sIdx < 0) return null;
const eIdx = body.indexOf(end, sIdx + start.length);
if (eIdx < 0) return null;
const before = body.slice(0, sIdx);
const after = body.slice(eIdx + end.length);
return before + renderBlock(block) + after;
}
/** Append a new block to the body, separated by a single blank line. */
function appendBlock(body: string, block: MermaidBlock): string {
const trimmed = body.replace(/\s+$/, "");
const prefix = trimmed === "" ? "" : trimmed + "\n\n";
return prefix + renderBlock(block) + "\n";
}
/** Extract valid MermaidBlock envelopes from agent outputs, preserving order. */
function extractBlocks(outputs: readonly AgentOutput[]): MermaidBlock[] {
const blocks: MermaidBlock[] = [];
for (const out of outputs) {
const m = out?.mermaid;
if (m === undefined || m === null) continue;
if (
typeof m.type !== "string" ||
typeof m.title !== "string" ||
typeof m.code !== "string"
) {
continue;
}
const title = m.title.trim();
const code = m.code.trim();
const type = m.type.trim();
if (title === "" || code === "" || type === "") continue;
blocks.push({ type, title, code });
}
return blocks;
}
/**
* Splice Mermaid blocks emitted by dispatched agents into `pageBody`.
* For each block:
* - If a wrapped block with the same title already exists, replace it.
* - Otherwise append at the end.
* Agent outputs without a well-formed `mermaid` field are ignored.
*/
export function injectMermaidBlocks(
pageBody: string,
agentOutputs: readonly AgentOutput[],
): string {
let body = pageBody;
for (const block of extractBlocks(agentOutputs)) {
const replaced = replaceBlock(body, block);
body = replaced !== null ? replaced : appendBlock(body, block);
}
return body;
}
// ── CLI ────────────────────────────────────────────────────────────
const FLAG_SPEC = {
"--page": "page",
"--agents": "agents",
"--in-place": "inPlace",
} as const;
const HELP_TEXT = `usage: mermaid_inject.js --page PAGE --agents AGENTS [--in-place]
Splice agent-emitted Mermaid blocks into a wiki page body.
arguments:
--page PAGE Path to the markdown page to modify
--agents AGENTS Path to a JSON file containing an array of agent
output envelopes ({ mermaid?: {type,title,code} })
--in-place Rewrite PAGE in place. Without this flag, the new
body is written to stdout and PAGE is untouched.
options:
-h, --help show this help message and exit
`;
function loadAgents(file: string): AgentOutput[] {
const raw = fs.readFileSync(file, { encoding: "utf-8" });
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error(`--agents file must contain a JSON array, got ${typeof parsed}`);
}
return parsed as AgentOutput[];
}
export function main(argv: readonly string[] = process.argv.slice(2)): number {
if (argv[0] === "-h" || argv[0] === "--help") {
process.stdout.write(HELP_TEXT);
return 0;
}
let parsed: ReturnType;
try {
parsed = parseFlags(argv, FLAG_SPEC);
} catch (e) {
process.stderr.write(`${(e as Error).message}\n`);
return 2;
}
if (parsed.help) {
process.stdout.write(HELP_TEXT);
return 0;
}
const pagePath = parsed.values["page"];
const agentsPath = parsed.values["agents"];
if (typeof pagePath !== "string" || pagePath === "") {
process.stderr.write("the following arguments are required: --page\n");
return 2;
}
if (typeof agentsPath !== "string" || agentsPath === "") {
process.stderr.write("the following arguments are required: --agents\n");
return 2;
}
let body: string;
try {
body = fs.readFileSync(pagePath, { encoding: "utf-8" });
} catch (e) {
process.stderr.write(`could not read --page: ${(e as Error).message}\n`);
return 2;
}
let agents: AgentOutput[];
try {
agents = loadAgents(agentsPath);
} catch (e) {
process.stderr.write(`could not parse --agents: ${(e as Error).message}\n`);
return 2;
}
const next = injectMermaidBlocks(body, agents);
if (parsed.values["inPlace"] !== undefined) {
fs.writeFileSync(pagePath, next);
} else {
process.stdout.write(next);
}
return 0;
}
const thisFile = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === thisFile) {
process.exit(main());
}