/**
* The Diagram block - first-class Mermaid.
* Mermaid is marver's dependency behind a dynamic import: a workspace with no
* Diagram loads no mermaid bytes. Source-level theme overrides (%%{init}%%,
* yaml frontmatter) are stripped so the marver palette always holds; the
* rendered SVG is sanitized of external references (strict securityLevel is
* not a no-network policy). A parse error renders an in-frame card - never a
* blank frame, never a tripped readiness timeout.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { FONT_STACK, THEME_CSS, themeVars } from './palette.ts'
let uidSeq = 0
/** Renders in flight - mermaid loads and lays out asynchronously, so a Doc is not done growing
* until this is 0 (the Doc's settled measurement reads it; the frame's own innerHTML is set
* before the count drops). */
let diagramsBusy = 0
export const diagramsPending = (): number => diagramsBusy
const isDark = () =>
document.documentElement.classList.contains('dark') || document.documentElement.dataset.theme === 'dark'
/** Strip yaml frontmatter and %%{...}%% directives - diagram source cannot re-theme itself. */
export function cleanSource(src: string): string {
return src
.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\r?\n/, '')
.replace(/%%\{[\s\S]*?\}%%/g, '')
.trim()
}
/** flowchart/graph is where classDef + markdown-string labels both apply; other diagram
* types (sequence, pie, ...) don't take the family/hierarchy sugar. */
function isFlowchart(src: string): boolean {
const first = src.split('\n').find((l) => l.trim())?.trim() ?? ''
return /^(flowchart|graph)\b/.test(first)
}
// D1: head/gloss auto-hierarchy. An agent writes a natural `Head :: gloss` label and marver
// renders the head BOLD on top with the gloss on a lighter, smaller line below - no backticks,
// no `**`, no ` ` to hand-author. It expands the label into a mermaid markdown string (bold
// head, blank line -> a second
the THEME_CSS styles down). ` :: ` (spaced double colon) is
// the token: rare in prose, and distinct from the `:::family` class tag (no spaces, three colons).
const GLOSS = ' :: '
export function withLabelHierarchy(src: string): string {
if (!isFlowchart(src)) return src
return src.replace(/"([^"\n]*?)"/g, (m, body: string) => {
const at = body.indexOf(GLOSS)
if (at < 0 || body.startsWith('`')) return m // no token, or already a markdown string
const head = body.slice(0, at).trim()
const gloss = body.slice(at + GLOSS.length).trim()
if (!head || !gloss) return m
const bhead = /[*`]/.test(head) ? head : `**${head}**` // don't double-bold a hand-marked head
return `"\`${bhead}\n\n${gloss}\`"` // "`**Head**⏎⏎gloss`"
})
}
// D2: named family fills - the SAME colour language as the Md `:blue[...]` families, so an agent
// tags a node `HQ:::blue` with zero classDef boilerplate and prose + diagram read as one palette.
const DIAGRAM_FAMILIES: Record = {
blue: 'fill:#0088FF,stroke:#0066CC,color:#fff',
orange: 'fill:#F5820A,stroke:#C96A08,color:#fff',
purple: 'fill:#B32BC8,stroke:#8F22A0,color:#fff',
green: 'fill:#1FA34A,stroke:#178139,color:#fff',
red: 'fill:#E5342B,stroke:#B71C13,color:#fff',
gray: 'fill:#E5E5EA,stroke:#C7C7CC,color:#1C1C1E',
}
/** Append the family classDefs to flowchart/graph diagrams (classDef is a flowchart feature).
* Unused defs are harmless; `X:::blue` resolves them regardless of position. */
export function withFamilies(src: string): string {
if (!isFlowchart(src)) return src
const defs = Object.entries(DIAGRAM_FAMILIES).map(([n, s]) => `classDef ${n} ${s}`).join('\n')
return `${src}\n${defs}`
}
/** The zero-external-request boundary, BEFORE render: mermaid's image shapes fetch their URL
* during render(), so post-render SVG sanitizing alone is too late. Rejected: any URL shape
* (scheme + // or scheme + \\, protocol-relative //), and the `img:` shape data of the
* flowchart node syntax `A@{ img: ... }` - the one construct that loads a resource at all. */
export function guardDiagramSource(src: string): void {
// judge the DECODED text: mermaid resolves \uXXXX, \xXX and HTML entities in shape data and
// labels before it acts on them, so an escaped `//` or a quoted, escaped `"img"` key is the
// same request in the end. Directives and front matter are gone by now (cleanSource) - a
// themeCSS with url() never reaches the renderer either way, url( is refused here too.
const text = decodeEscapes(src)
if (/(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(text) || /[a-z][a-z0-9+.-]*:\\/i.test(text)) throw new Error('URLs are not allowed in diagram source - use local design/assets/ images in an Img block instead')
if (/url\s*\(/i.test(text) || /@import\b/i.test(text)) throw new Error('external resources are not allowed in diagram source')
if (/@\s*\{/.test(text) && /["'`]?\s*img\s*["'`]?\s*:/i.test(text)) throw new Error('image shapes are not allowed in diagram source')
if (/%%\s*\{/.test(text) || /^\s*---/.test(text)) throw new Error('directives are not allowed in diagram source')
}
/** \uXXXX, \u{...}, \xXX and numeric/named HTML entities -> the characters they stand for. */
export function decodeEscapes(src: string): string {
return src
.replace(/\\u\{([0-9a-f]{1,6})\}/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/\\u([0-9a-f]{4})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/\\x([0-9a-f]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/([0-9a-f]{1,6});/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/(\d{1,7});/g, (_, d) => String.fromCodePoint(Number(d)))
.replace(///gi, '/').replace(/\/gi, '\\').replace(/:/gi, ':').replace(/"/gi, '"').replace(/'/gi, "'")
}
/** Remove external URL references from rendered SVG (images, links, href attrs). */
export function sanitizeSvg(svg: string): string {
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
const external = (v: string | null) => !!v && !v.trim().startsWith('#') && !v.trim().startsWith('data:')
for (const el of [...doc.querySelectorAll('image')]) el.remove()
for (const el of [...doc.querySelectorAll('script, foreignObject iframe')]) el.remove()
for (const el of [...doc.querySelectorAll('*')]) {
for (const attr of ['href', 'xlink:href']) {
if (external(el.getAttribute(attr))) el.removeAttribute(attr)
}
}
return new XMLSerializer().serializeToString(doc.documentElement)
}
export function Diagram({ title, children }: { title?: string; children?: ReactNode }) {
const src = withFamilies(withLabelHierarchy(cleanSource(
typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children ?? ''),
)))
const ref = useRef(null)
const [error, setError] = useState(null)
const uid = useRef(`mv-mmd-${++uidSeq}`)
useEffect(() => {
let live = true
let seq = 0
let renderSeq = 0
const render = async () => {
const mySeq = ++seq
diagramsBusy++
try {
// the zero-external-request boundary must hold BEFORE render: mermaid's image
// shapes fetch their URL during render(), so post-render SVG sanitizing alone
// would be too late. Reject ANY URL shape - absolute (scheme://) and
// protocol-relative (//host) alike; neither has a place in diagram source.
guardDiagramSource(src)
const mermaid = (await import('mermaid')).default
if (!live || mySeq !== seq) return
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: 'base',
themeVariables: themeVars(isDark()),
themeCSS: THEME_CSS,
fontFamily: FONT_STACK,
})
// unique id per render: mermaid mounts a temp element under it, and a
// superseded render must never collide with the one that lands
const { svg } = await mermaid.render(`${uid.current}-${++renderSeq}`, src)
if (!live || mySeq !== seq || !ref.current) return // superseded by a newer theme - discard
ref.current.innerHTML = sanitizeSvg(svg)
setError(null)
} catch (e) {
if (live && mySeq === seq) setError(String((e as Error)?.message ?? e))
} finally { diagramsBusy-- }
}
render()
// the bridge mutates /.dark with no React event - observe and re-render
const mo = new MutationObserver(render)
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] })
return () => { live = false; mo.disconnect() }
}, [src])
return (
{/* the render target stays MOUNTED through errors - a healed source or theme
re-render must find its ref alive to clear the card */}
{error &&
diagram error{error}fix the mermaid source - the frame heals live