From 2a6c391a5e84535bccd68453f3e06d46fb847106 Mon Sep 17 00:00:00 2001 From: Pascal Date: Mon, 15 Jun 2026 08:11:36 +0200 Subject: [PATCH] UI/svg block rendering (#24080) * ui: add svg block visualizer based on allozaur's mermaid PR * ui: rationalise diagram block styling and pre transforms shared by mermaid and svg * ui: live render streaming svg blocks * ui: also render svg authored in xml code fences * ui: refactor svg block rendering, address review from allozaur - Move the svg size ceiling and DOMPurify config out of sanitize-svg.ts into /constants. - Rename the svg-diagram class to svg-block so the name no longer implies diagrams only. - Replace the svg, xml and svg tag magic strings in the markdown pipeline with shared constants. - Promote the data-svg-rendered marker and its sibling data attributes to constants. * ui: render svg blocks in a shadow root for animation and live zoom Mount each sanitized svg inside an open shadow root so author diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts index a81f734d68..07e441112d 100644 --- a/tools/ui/src/lib/constants/index.ts +++ b/tools/ui/src/lib/constants/index.ts @@ -29,6 +29,7 @@ export * from './latex-protection'; export * from './literal-html'; export * from './markdown'; export * from './mermaid-blocks'; +export * from './svg-blocks'; export * from './max-bundle-size'; export * from './mcp'; export * from './mcp-form'; diff --git a/tools/ui/src/lib/constants/mermaid-blocks.ts b/tools/ui/src/lib/constants/mermaid-blocks.ts index 3f43942f08..cd9467fb6e 100644 --- a/tools/ui/src/lib/constants/mermaid-blocks.ts +++ b/tools/ui/src/lib/constants/mermaid-blocks.ts @@ -1,2 +1,9 @@ export const MERMAID_WRAPPER_CLASS = 'mermaid-block-wrapper'; export const MERMAID_SCROLL_CONTAINER_CLASS = 'mermaid-scroll-container'; +export const MERMAID_BLOCK_CLASS = 'mermaid'; + +export const MERMAID_LANGUAGE = 'mermaid'; + +export const MERMAID_SYNTAX_ATTR = 'data-mermaid-syntax'; +export const MERMAID_ID_ATTR = 'data-mermaid-id'; +export const MERMAID_RENDERED_ATTR = 'data-mermaid-rendered'; diff --git a/tools/ui/src/lib/constants/svg-blocks.ts b/tools/ui/src/lib/constants/svg-blocks.ts new file mode 100644 index 0000000000..ccca9376c6 --- /dev/null +++ b/tools/ui/src/lib/constants/svg-blocks.ts @@ -0,0 +1,49 @@ +export const SVG_WRAPPER_CLASS = 'svg-block-wrapper'; +export const SVG_SCROLL_CONTAINER_CLASS = 'svg-scroll-container'; +export const SVG_BLOCK_CLASS = 'svg-block'; + +export const SVG_LANGUAGE = 'svg'; +export const XML_LANGUAGE = 'xml'; +export const SVG_TAG_PREFIX = ' stays scoped to that root and can not reach the page. + */ +export const SVG_SANITIZE_CONFIG = { + USE_PROFILES: { svg: true, svgFilters: true }, + FORBID_TAGS: ['foreignObject', 'script'] +}; + +/** + * Shadow root style for an inline svg block. Mirrors the centered, padded + * sizing the light dom used before the svg moved behind a shadow boundary. + */ +export const SVG_INLINE_SHADOW_STYLE = + ':host{display:block;width:100%;text-align:center}svg{display:block;margin:0 auto;width:auto;height:auto;max-width:100%;max-height:70vh;min-height:8rem;padding:3rem 1rem}'; + +/** + * Shadow root style for the zoom dialog svg. Lets the svg grow past its + * intrinsic size so pan and zoom have room to work. + */ +export const SVG_DIALOG_SHADOW_STYLE = + ':host{display:inline-block}svg{min-height:min(50vh,12rem);min-width:min(80vw,20rem);max-width:none;max-height:none;height:auto;width:auto;display:block}'; diff --git a/tools/ui/src/lib/utils/sanitize-svg.ts b/tools/ui/src/lib/utils/sanitize-svg.ts new file mode 100644 index 0000000000..e5a9493efe --- /dev/null +++ b/tools/ui/src/lib/utils/sanitize-svg.ts @@ -0,0 +1,22 @@ +import DOMPurify from 'dompurify'; +import { SVG_MAX_BYTES, SVG_SANITIZE_CONFIG, SVG_TAG_PREFIX } from '$lib/constants'; + +/** + * Sanitizes a raw svg string for safe inline rendering. + * Returns the cleaned svg markup, or an empty string when the input is not a + * usable svg, exceeds the size ceiling, or sanitizes to nothing. An empty + * return tells the caller to keep the raw code block instead of rendering. + */ +export function sanitizeSvg(source: string): string { + const trimmed = source.trim(); + + if (!trimmed || trimmed.length > SVG_MAX_BYTES) return ''; + + if (!trimmed.startsWith(SVG_TAG_PREFIX)) return ''; + + const clean = DOMPurify.sanitize(trimmed, SVG_SANITIZE_CONFIG) as unknown as string; + + if (!clean || !clean.includes(SVG_TAG_PREFIX)) return ''; + + return clean; +} diff --git a/tools/ui/src/lib/utils/svg-shadow.ts b/tools/ui/src/lib/utils/svg-shadow.ts new file mode 100644 index 0000000000..71caff8c24 --- /dev/null +++ b/tools/ui/src/lib/utils/svg-shadow.ts @@ -0,0 +1,10 @@ +/** + * Mounts svg markup inside an open shadow root on the host element. + * The shadow boundary scopes the svg ${markup}` : ''; +}