/** * The renderer slot: ONE seam between "a diagram kind" and "the engine that * turns its source into an svg". `renderDiagram(kind, renderId, source, * theme)` looks the kind up in a per-kind map (`mermaid` through the * runtime slot in `./mermaid`, `graphviz` through the one in `./graphviz`); * the entry loads its engine lazily and hands back the sanitized svg NODE * plus the finder that knows that engine's id grammar (`diagramFinder(kind)` * hands the viewer the same finder for the pointer and the anchors). A later * engine is one entry here behind a lazy import, one finder and tests over a * captured svg; nothing in the viewer, the source pane or the comment UI * changes. * * Errors are values: a source that does not parse resolves to * `{ ok: false, message, line, runtimeUnavailable: false }`, never a throw, * so the canvas keeps the last good render dimmed under the strip. A * runtime that could not be LOADED (a chunking host's fetch) resolves with * `runtimeUnavailable: true`, which is the one failure a Retry can change: * the entry re-attempts the load once after the slot's retry delay before * giving up, and the block's Retry re-runs the render with a fresh import. * * Security: Mermaid runs under MERMAID_CONFIG with `securityLevel: 'strict'` * (pinned by components/MermaidBlock.test.ts). The svg it emits is the * runtime's output, sanitized by Mermaid's own DOMPurify pass; * `sanitizeDiagramSvg` is the belt over that boundary, and it hands the * viewer a NODE, never markup: DOMPurify parses the string under the svg * profile and returns a DOM fragment, the post-pass walks that fragment, and * the canvas mounts the element with `replaceChildren`. No html string * crosses into the app DOM anywhere in the viewer. What the pass removes: no * script, no event handler attribute, no javascript: or data: reference * survives it, and no `` keeps an href: under strict Mermaid disables * click CALLBACKS but a `click A "https://..."` binding still wraps the node * in an svg ``, which would turn the pinpoint click on that node * into a navigation. The canvas owns every click. */ import DOMPurify from 'dompurify'; import type { DiagramKind } from '@plannotator/core/diagram-anchor'; import { MERMAID_FINDER, type DiagramFinder } from './diagram-anchor'; import { GRAPHVIZ_FINDER } from './diagram-anchor-graphviz'; import { getGraphvizRetryDelayMs, loadGraphvizRuntime, type GraphvizRuntime } from './graphviz'; import { loadMathRenderer } from './math'; import { getMermaidRetryDelayMs, loadMermaidRuntime } from './mermaid'; import { hasMermaidMath } from './mermaid-math-slot'; import { applyMermaidTheme, mermaidThemeKey, type MermaidThemeMode } from './mermaidTheme'; export type { DiagramKind } from '@plannotator/core/diagram-anchor'; type Mermaid = Awaited>; /** * The (palette, mode) a render is for: the same pair `useTheme()` resolves * for code fences, plus the node shadow amount the user chose. The mermaid * entry passes them to `applyMermaidTheme`, which runs the global `initialize` * once per key; the graphviz entry recolors its defaults onto CSS tokens and * needs neither. A host without ThemeProvider passes any palette id with the * mode it renders in. */ export interface DiagramTheme { readonly colorTheme: string; readonly mode: MermaidThemeMode; /** * Node drop shadow strength, 0..1 (see `mermaidTheme.buildMermaidShadow`). * Absent means the shipped default, so a host that builds its own * `DiagramTheme` renders exactly what Plannotator renders. */ readonly shadowAmount?: number; } export type DiagramRenderError = { readonly ok: false; readonly message: string; readonly line: number | null; /** The engine could not be loaded (as opposed to the source not * parsing): the one failure a Retry can change. */ readonly runtimeUnavailable: boolean; }; export type DiagramRenderResult = | { readonly ok: true; /** The sanitized svg root, ready to mount. The viewer never holds * the markup: see the header. */ readonly svgNode: SVGSVGElement; readonly findTarget: DiagramFinder['findTarget']; } | DiagramRenderError; interface DiagramRenderer { render(renderId: string, source: string, theme: DiagramTheme): Promise; readonly finder: DiagramFinder; } /** Elements that never belong in a rendered diagram. DOMPurify's profiles * already drop every one of them; the sweep below is the belt that does not * depend on which profile a later config edit turns on. */ const FORBIDDEN_ELEMENTS = 'script, iframe, object, embed, link, meta'; /** * The belt over the engine's output (see the header). DOMPurify parses the * string under the svg, svg-filter and html profiles and hands back a * detached fragment, so nothing executes and no markup reaches the app DOM. * `foreignobject` and its html children are added back the way Mermaid's * own pass adds them (the same ADD_TAGS, ADD_ATTR and * HTML_INTEGRATION_POINTS entries); without them every html label in the * diagram would be dropped. The walk afterwards closes what DOMPurify leaves * open for an svg: an `` around a node, and a `data:` reference on * an ``. * * Returns the sanitized svg root, or null when the string carries no svg or * there is no document to parse it with. */ export function sanitizeDiagramSvg(svg: string): SVGSVGElement | null { const root = parseSvg(svg); if (root === null) return null; scrubDiagramSvg(root); return root; } /** * Step one of the sanitizer: DOMPurify parses the markup under the svg, * svg-filter and html profiles and hands back a detached fragment; the svg * root is adopted into the page's document so the node the canvas mounts * belongs to it. Null when the markup carries no svg root, when there is no * document, or when the fragment cannot be adopted. */ export function parseDiagramSvg(svg: string): SVGSVGElement | null { if (typeof document === 'undefined') return null; const fragment = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true, html: true }, ADD_TAGS: ['foreignobject'], ADD_ATTR: ['dominant-baseline'], HTML_INTEGRATION_POINTS: { foreignobject: true }, RETURN_DOM_FRAGMENT: true, }); const root = fragment.querySelector('svg'); if (root === null) return null; try { return document.adoptNode(root) as SVGSVGElement; } catch { return null; } } /** * Step two, in place on the tree that is about to be mounted: the belt that * does not depend on which DOMPurify profile a later config edit turns on. * Forbidden elements go, every `