/** * mermaid_format.ts — shared Mermaid output-envelope helpers for agents. * * Per v2 design §6, every source / mapper agent that produces * diagram-worthy structured data returns a `mermaid` field in its JSON * output so the wiki compiler can splice the diagram directly into a * compiled page. This module owns: * * - The `MermaidBlock` envelope type (`{ type, title, code }`). * - Label escaping (Mermaid treats `"`, `[`, `]`, `|`, `{`, `}`, `(`, `)` * specially inside node labels). * - Node-id sanitization (Mermaid node ids must be alphanumeric / * underscore; we normalize unsafe characters to underscores and * collapse runs). * - Builders for the two shapes the connector envelopes need today: * * `formatGraph("TB"|"LR", nodes, edges)` — flowchart-style * service topology, dependency graphs, infrastructure topology. * * `formatErDiagram(tables)` — entity-relationship diagrams * (db schema introspection envelopes). * * Envelopes that don't produce diagram-worthy data MUST omit the `mermaid` * field entirely — do not emit an empty string or null. Compilation * treats the absence as "no diagram" rather than "empty diagram". */ /** Output envelope written into the agent's JSON result. */ export interface MermaidBlock { /** Top-level Mermaid diagram keyword, e.g. `graph TB`, `erDiagram`. */ type: string; /** Human-readable title for the diagram (shown in the compiled page). */ title: string; /** Mermaid source code, including the `type` header line. */ code: string; } /** One node in a `graph TB` / `graph LR` diagram. */ export interface GraphNode { id: string; label: string; /** Optional Mermaid shape — `["label"]` (default), `(("label"))`, etc. * Pass the opening token; the helper pairs it with the closing one. */ shape?: "default" | "rounded" | "circle" | "stadium" | "subroutine"; } /** One directed edge between two nodes. */ export interface GraphEdge { from: string; to: string; /** Optional edge label — rendered as `-->|label|`. */ label?: string; } /** One column in an ER diagram table. */ export interface ErColumn { name: string; type?: string; /** Optional tag(s): PK, FK, UK. */ key?: "PK" | "FK" | "UK"; } /** One table + columns in an ER diagram. */ export interface ErTable { name: string; columns: ErColumn[]; } /** One ER relationship — uses Mermaid cardinality tokens. */ export interface ErRelationship { from: string; to: string; /** Mermaid cardinality, e.g. `||--o{`, `}o--o{`, `||--||`. */ cardinality: string; label: string; } /** One node in a `flowchart TD` phase-pipeline diagram. */ export interface PhaseNode { id: string; /** Multi-line labels are supported — `\n` becomes `
` in the output. */ label: string; /** `rect` (default) renders as `["label"]`; `diamond` as `{"label"}` for decisions. */ shape?: "rect" | "diamond"; /** Optional class name from `classDefs` to apply this node's styling. */ className?: string; } /** One directed edge in a `flowchart TD` phase-pipeline diagram. */ export interface PhaseEdge { from: string; to: string; /** Optional edge label — rendered as `-- label -->` (Mermaid flowchart syntax). */ label?: string; } /** One classDef styling block for a phase-pipeline diagram. */ export interface ClassDef { /** Class name referenced by `PhaseNode.className`. */ name: string; /** CSS color, e.g. `#fff3cd`. */ fill: string; /** CSS stroke color, e.g. `#856404`. */ stroke: string; } /** * Replace characters that Mermaid treats specially inside node labels * with HTML-escaped equivalents. Keeps the label readable while making * the output parse-safe in every Mermaid runtime. */ export function sanitizeLabel(label: string): string { return label .replace(/&/g, "&") .replace(/"/g, """) .replace(/\|/g, "|") .replace(/\[/g, "[") .replace(/\]/g, "]") .replace(/\{/g, "{") .replace(/\}/g, "}") .replace(/[\r\n]+/g, " ") .trim(); } /** * Coerce an arbitrary string into a Mermaid-legal node id: alphanumeric * and underscore only, with leading digits prefixed by `_` to dodge * Mermaid's identifier rules. Empty or all-special input returns `"_"`. */ export function sanitizeNodeId(raw: string): string { const cleaned = raw.replace(/[^A-Za-z0-9_]/g, "_").replace(/_+/g, "_"); if (cleaned === "" || cleaned === "_") return "_"; return /^[0-9]/.test(cleaned) ? "_" + cleaned : cleaned; } function shapeOpen(s: GraphNode["shape"]): string { switch (s) { case "rounded": return "("; case "circle": return "(("; case "stadium": return "(["; case "subroutine":return "[["; default: return "["; } } function shapeClose(s: GraphNode["shape"]): string { switch (s) { case "rounded": return ")"; case "circle": return "))"; case "stadium": return "])"; case "subroutine":return "]]"; default: return "]"; } } /** * Format a `graph TB` / `graph LR` flowchart-style diagram. Use for * service topologies (cloud infra, issue hierarchies, dependency * graphs). Returns a full `MermaidBlock` ready to inline into JSON * output. */ export function formatGraph( direction: "TB" | "LR" | "RL" | "BT", title: string, nodes: readonly GraphNode[], edges: readonly GraphEdge[], ): MermaidBlock { const lines: string[] = [`graph ${direction}`]; const seen = new Set(); for (const n of nodes) { const id = sanitizeNodeId(n.id); if (seen.has(id)) continue; seen.add(id); const open = shapeOpen(n.shape); const close = shapeClose(n.shape); lines.push(` ${id}${open}"${sanitizeLabel(n.label)}"${close}`); } for (const e of edges) { const from = sanitizeNodeId(e.from); const to = sanitizeNodeId(e.to); const edge = e.label !== undefined && e.label !== "" ? ` ${from} -->|${sanitizeLabel(e.label)}| ${to}` : ` ${from} --> ${to}`; lines.push(edge); } return { type: `graph ${direction}`, title, code: lines.join("\n"), }; } function formatErColumn(col: ErColumn): string { const type = (col.type ?? "string").replace(/\s+/g, "_"); const key = col.key ? ` ${col.key}` : ""; return ` ${type} ${col.name}${key}`; } /** * Format an ER diagram (`erDiagram`). Used by `mermaid_augment.ts` for * `db` connector envelopes (schema introspection) and available to * `wiki-orm-agent` if it wants to share the output shape. */ export function formatErDiagram( title: string, tables: readonly ErTable[], relationships: readonly ErRelationship[] = [], ): MermaidBlock { const lines: string[] = ["erDiagram"]; for (const t of tables) { lines.push(` ${t.name} {`); for (const col of t.columns) lines.push(formatErColumn(col)); lines.push(" }"); } for (const rel of relationships) { lines.push( ` ${rel.from} ${rel.cardinality} ${rel.to} : "${sanitizeLabel(rel.label)}"`, ); } return { type: "erDiagram", title, code: lines.join("\n") }; } /** * Sanitize a phase-pipeline node label. Same character-escape policy as * `sanitizeLabel`, but preserves multi-line content by converting real * newlines to `
` so Mermaid renders them as line breaks inside the * node box. */ function sanitizePhaseLabel(label: string): string { return label .replace(/&/g, "&") .replace(/"/g, """) .replace(/\|/g, "|") .replace(/\[/g, "[") .replace(/\]/g, "]") .replace(/\{/g, "{") .replace(/\}/g, "}") .replace(/\r\n|\n|\r/g, "
") .trim(); } /** * Format a `flowchart TD` phase-pipeline diagram with optional classDef * styling. Use for self-describing process diagrams: orchestrator phases, * decision gates, lifecycle flows. Mirrors the styled-flowchart pattern * documented in `docs/architecture.md` (the atlas-pipeline diagram). * * - Rectangular nodes (default): `${id}["label"]` * - Diamond decision nodes: `${id}{"label"}` * - Edges: `from --> to` or `from -- label --> to` * - Per-class color via `classDef name fill:#hex,stroke:#hex` * - Class assignment grouped: `class id1,id2 className` * * Multi-line labels are preserved (real `\n` becomes `
`). */ export function formatPhaseFlow( title: string, nodes: readonly PhaseNode[], edges: readonly PhaseEdge[], classDefs: readonly ClassDef[] = [], ): MermaidBlock { const lines: string[] = ["flowchart TD"]; const seen = new Set(); for (const n of nodes) { const id = sanitizeNodeId(n.id); if (seen.has(id)) continue; seen.add(id); const label = sanitizePhaseLabel(n.label); if (n.shape === "diamond") { lines.push(` ${id}{"${label}"}`); } else { lines.push(` ${id}["${label}"]`); } } for (const e of edges) { const from = sanitizeNodeId(e.from); const to = sanitizeNodeId(e.to); if (e.label !== undefined && e.label !== "") { lines.push(` ${from} -- ${sanitizePhaseLabel(e.label)} --> ${to}`); } else { lines.push(` ${from} --> ${to}`); } } for (const c of classDefs) { lines.push(` classDef ${c.name} fill:${c.fill},stroke:${c.stroke}`); } // Group class assignments by className so each className gets one line. const byClass = new Map(); for (const n of nodes) { if (!n.className) continue; const id = sanitizeNodeId(n.id); const list = byClass.get(n.className); if (list) list.push(id); else byClass.set(n.className, [id]); } for (const [name, ids] of byClass) { lines.push(` class ${ids.join(",")} ${name}`); } return { type: "flowchart TD", title, code: lines.join("\n"), }; }