2626// runs effects, so the block carries each state as a plain prop and the
2727// wrapper only wires the async render into it.
2828// 4. The rendered SVG is sanitized before it is injected, and the diagram is
29- // drawn with the theme the app is actually showing. Both are load-bearing
30- // — see the two sections below.
29+ // drawn with the theme the app is actually showing — and redrawn when
30+ // that theme changes, because the palette is baked into the SVG. Both
31+ // are load-bearing — see the two sections below.
3132
3233import { useEffect , useState , type ReactElement } from "react" ;
3334
@@ -101,6 +102,27 @@ export function readWebuiDocumentTheme(
101102 * body, not assembled from user HTML, so the surface is small — but small is
102103 * not zero, and the filter is what makes "the SVG carries no script" a
103104 * property of this component rather than a property of the library version.
105+ *
106+ * What is deliberately kept: the `<style>` element the library emits. The
107+ * diagram's class CSS lives there — every colour the theme rules assign — so
108+ * removing it would unstyle the drawing. It is acceptable input because each
109+ * way a diagram body can reach into it is validated by the library, checked
110+ * against the pinned version under `securityLevel: "strict"`:
111+ *
112+ * - `classDef` / `style` / `linkStyle` reject `url(…)` and stray `{`/`;` at
113+ * parse time (`classDef ex fill:red,stroke:url(https://…)` is a parse
114+ * error, not a rendered rule);
115+ * - the colours an `%%{init} …%%` directive can set go through a colour
116+ * parser that rejects anything that is not a colour — a breakout payload
117+ * like `#f00; } * { background-image: url(https://…)` dies as
118+ * "Unsupported color format";
119+ * - the directive cannot flip security-relevant keys at all (`securityLevel`
120+ * and `htmlLabels` stay as configured here), and `click … call` binds
121+ * nothing on the `render()` path.
122+ *
123+ * The `@import ` and off-page `url()` rules below are the backstop: they cost
124+ * nothing on a healthy diagram and they hold the line if a future diagram
125+ * type grows a raw-CSS surface.
104126 */
105127export function sanitizeWebuiMermaidSvg ( svg : string ) : string {
106128 return svg
@@ -120,7 +142,13 @@ export function sanitizeWebuiMermaidSvg(svg: string): string {
120142 // Script-ish URLs in href / xlink:href / src.
121143 . replace ( / \s ( x l i n k : h r e f | h r e f | s r c ) \s * = \s * " \s * j a v a s c r i p t : [ ^ " ] * " / giu, "" )
122144 . replace ( / \s ( x l i n k : h r e f | h r e f | s r c ) \s * = \s * ' \s * j a v a s c r i p t : [ ^ ' ] * ' / giu, "" )
123- . replace ( / \s ( x l i n k : h r e f | h r e f | s r c ) \s * = \s * j a v a s c r i p t : [ ^ \s > ] * / giu, "" ) ;
145+ . replace ( / \s ( x l i n k : h r e f | h r e f | s r c ) \s * = \s * j a v a s c r i p t : [ ^ \s > ] * / giu, "" )
146+ // CSS imports and off-page url() references. A <style> inside inline SVG
147+ // applies to the whole page, not just the diagram, and an off-page url()
148+ // turns a diagram into a network fetch. Fragment refs (`url(#…)` — the
149+ // arrowhead markers) and data: images are drawing, not fetches, and stay.
150+ . replace ( / @ i m p o r t \b [ ^ ; { ] * [ ; { ] ? / giu, "" )
151+ . replace ( / u r l \( \s * ( [ ' " ] ? ) \s * (?: h t t p s ? : | \/ \/ | d a t a : t e x t \/ h t m l ) [ ^ ) ' " ] * \1\s * \) / giu, "none" ) ;
124152}
125153
126154/* Rendering -------------------------------------------------------------- */
@@ -147,7 +175,9 @@ async function loadMermaid(theme: WebuiMermaidTheme): Promise<MermaidModule> {
147175 // `theme` is the app's own theme, not the library's default: Mermaid
148176 // bakes its palette into the SVG it returns, so a diagram drawn with the
149177 // light palette is a light diagram even on a dark page. Re-initializing
150- // on a theme change is what lets the next diagram follow the switch.
178+ // on a theme change is what lets the renderer follow the switch — the
179+ // next diagram, and (through the subscription in `WebuiMermaid`) the
180+ // ones already on screen.
151181 module . default . initialize ( {
152182 startOnLoad : false ,
153183 securityLevel : "strict" ,
@@ -172,21 +202,43 @@ export interface WebuiMermaidRenderOptions {
172202 * callers never pass it.
173203 */
174204 readonly timeoutMs ?: number ;
175- /** The theme to draw with; defaults to the app's current theme. */
205+ /**
206+ * The theme to draw with. Defaults to `light`: this function is pure and
207+ * has no document to ask, so an unspecified theme means the library's own
208+ * default palette. The component passes the document's current theme
209+ * explicitly — nothing on the production path relies on the default.
210+ */
176211 readonly theme ?: WebuiMermaidTheme ;
177212}
178213
214+ /**
215+ * A diagram body whose `@{img: '…'}` node image is not a `data:` URI.
216+ *
217+ * A remote value — an `https://` URL, the `iconify:` scheme — makes the
218+ * library fetch the image over the network while it renders (verified: the
219+ * request leaves the page before `render` settles). A diagram body is model
220+ * output that can be steered by whatever the model read, and a render must
221+ * not turn it into a request to an arbitrary host, so such a body is refused
222+ * before the library is even imported. `data:` URIs are self-contained and
223+ * draw offline; they pass.
224+ */
225+ const REMOTE_IMAGE_IN_BODY = / @ \{ i m g \s * : \s * [ ' " ] \s * (? ! d a t a : ) / u;
226+
179227/**
180228 * Render one Mermaid diagram body to an SVG string.
181229 *
182230 * Rejects — rather than returning a fallback — on every failure so the caller
183- * decides what the user sees: an empty body, invalid syntax, a thrown render
184- * and a render that outlives the timeout all end here. The empty-body check
185- * runs before the dynamic import so an empty fence never pulls the library
186- * down the wire.
231+ * decides what the user sees: an empty body, a body that references a remote
232+ * image, invalid syntax, a thrown render and a render that outlives the
233+ * timeout all end here. The empty-body and remote-image checks run before the
234+ * dynamic import so neither ever pulls the library down the wire, and the
235+ * remote-image one also never lets it touch the network.
187236 */
188237export async function renderWebuiMermaidSvg ( text : string , options ?: WebuiMermaidRenderOptions ) : Promise < string > {
189238 if ( text . trim ( ) === "" ) throw new Error ( "webui-mermaid: empty diagram body" ) ;
239+ if ( REMOTE_IMAGE_IN_BODY . test ( text ) ) {
240+ throw new Error ( "webui-mermaid: diagram body references a remote image" ) ;
241+ }
190242 const theme = options ?. theme ?? "light" ;
191243 const mermaid = ( await loadMermaid ( theme ) ) . default ;
192244 diagramSequence += 1 ;
@@ -269,16 +321,26 @@ export interface WebuiMermaidProps {
269321 * The effectful half: renders the diagram once per body and repaints the block
270322 * with the outcome. `cancelled` guards the state updates so a fence that
271323 * unmounts mid-render — a session switch, a trimmed transcript — writes
272- * nothing. The dependency is the body alone: a re-render with the same text
273- * must not re-run the library.
324+ * nothing. The dependencies are the body and the theme: a re-render with the
325+ * same text must not re-run the library, while a theme switch must — the
326+ * palette is baked into the SVG, so the only way a drawn diagram follows the
327+ * app's theme is to draw it again. The theme arrives as state from the DOM
328+ * port's subscription (see `subscribeDocumentTheme`); until the document
329+ * first changes theme after mount it is `undefined`, and the draw reads the
330+ * document directly instead, so the first diagram does not wait for an event.
331+ * While a theme switch redraws, the previous SVG stays on screen — a reader
332+ * who changed the theme never sees the fence flash back to its source text.
274333 */
275334export function WebuiMermaid ( { text, lang } : WebuiMermaidProps ) : ReactElement {
276335 const [ state , setState ] = useState < WebuiMermaidState > ( { status : "pending" } ) ;
277336 const { dom } = useWebuiBrowserCapabilities ( ) ;
337+ const [ theme , setTheme ] = useState < WebuiMermaidTheme | undefined > ( undefined ) ;
338+ useEffect ( ( ) => dom . subscribeDocumentTheme ( setTheme ) , [ dom ] ) ;
278339 useEffect ( ( ) => {
279340 let cancelled = false ;
280- const theme = readWebuiDocumentTheme ( ( ) => dom . readDocumentTheme ( ) ) ;
281- renderWebuiMermaidSvg ( text , { theme } )
341+ renderWebuiMermaidSvg ( text , {
342+ theme : theme ?? readWebuiDocumentTheme ( ( ) => dom . readDocumentTheme ( ) ) ,
343+ } )
282344 . then ( ( svg ) => {
283345 if ( ! cancelled ) setState ( { status : "rendered" , svg } ) ;
284346 } )
@@ -288,6 +350,6 @@ export function WebuiMermaid({ text, lang }: WebuiMermaidProps): ReactElement {
288350 return ( ) => {
289351 cancelled = true ;
290352 } ;
291- } , [ dom , text ] ) ;
353+ } , [ dom , text , theme ] ) ;
292354 return < WebuiMermaidBlock state = { state } text = { text } lang = { lang } /> ;
293355}
0 commit comments