/**
* 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 `