]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
UI/svg block rendering (#24080)
authorPascal <redacted>
Mon, 15 Jun 2026 06:11:36 +0000 (08:11 +0200)
committerGitHub <redacted>
Mon, 15 Jun 2026 06:11:36 +0000 (08:11 +0200)
* 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 <style> and
keyframe or smil animations run while staying scoped to the host element.
Relax the sanitizer to forbid only foreignObject and script, which lets
animation, href and external resource refs through for wider compatibility.
Render the inline block and the zoom dialog from the same reactive source,
so a streaming svg keeps drawing live inside the open zoom popup.

16 files changed:
tools/ui/package-lock.json
tools/ui/package.json
tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css
tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/svg-pre.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MermaidPreview.svelte
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/constants/mermaid-blocks.ts
tools/ui/src/lib/constants/svg-blocks.ts [new file with mode: 0644]
tools/ui/src/lib/utils/sanitize-svg.ts [new file with mode: 0644]
tools/ui/src/lib/utils/svg-shadow.ts [new file with mode: 0644]

index f46a98d1ad401d7b25ccb710628be8ff41061273..61fa529d209262cf907fee34479297e3b5645e56 100644 (file)
@@ -35,6 +35,7 @@
                                "bits-ui": "2.18.1",
                                "clsx": "2.1.1",
                                "dexie": "4.4.3",
+                               "dompurify": "3.4.5",
                                "eslint": "9.39.4",
                                "eslint-config-prettier": "10.1.8",
                                "eslint-plugin-storybook": "10.4.2",
                        "peer": true
                },
                "node_modules/dompurify": {
-                       "version": "3.4.8",
-                       "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
-                       "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
+                       "version": "3.4.5",
+                       "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
+                       "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
                        "dev": true,
                        "license": "(MPL-2.0 OR Apache-2.0)",
                        "optionalDependencies": {
index 6c750431fe6c8965250b3f9ccc8951384bbbe598..2f47992e1e0efb52d4b8777cd0bf1f02fa0cf8a3 100644 (file)
@@ -54,6 +54,7 @@
                "bits-ui": "2.18.1",
                "clsx": "2.1.1",
                "dexie": "4.4.3",
+               "dompurify": "3.4.5",
                "eslint": "9.39.4",
                "eslint-config-prettier": "10.1.8",
                "eslint-plugin-storybook": "10.4.2",
index 9c4c49c0ce82993568e7bf6ebede53979dba455e..7139d2e639da9bb4720637767ef5d9ed68f7aed5 100644 (file)
@@ -18,6 +18,8 @@
        import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
        import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
        import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
+       import { rehypeSvgPre } from './plugins/rehype/svg-pre';
+       import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
        import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
        import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
        import { remarkLiteralHtml } from './plugins/remark/literal-html';
                DATA_ERROR_BOUND_ATTR,
                DATA_ERROR_HANDLED_ATTR,
                BOOL_TRUE_STRING,
-               SETTINGS_KEYS
+               SETTINGS_KEYS,
+               MERMAID_WRAPPER_CLASS,
+               MERMAID_BLOCK_CLASS,
+               MERMAID_LANGUAGE,
+               MERMAID_SYNTAX_ATTR,
+               MERMAID_RENDERED_ATTR,
+               SVG_WRAPPER_CLASS,
+               SVG_BLOCK_CLASS,
+               SVG_LANGUAGE,
+               XML_LANGUAGE,
+               SVG_TAG_PREFIX,
+               SVG_SOURCE_ATTR,
+               SVG_RENDERED_ATTR,
+               SVG_INLINE_SHADOW_STYLE
        } from '$lib/constants';
        import { ColorMode, UrlProtocol } from '$lib/enums';
        import { FileTypeText } from '$lib/enums/files.enums';
        import { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from '$lib/utils';
+       import { sanitizeSvg } from '$lib/utils/sanitize-svg';
+       import { mountSvgShadow } from '$lib/utils/svg-shadow';
        import '$styles/katex-custom.scss';
        import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
        import githubLightCss from 'highlight.js/styles/github.css?inline';
        let renderedBlocks = $state<MarkdownBlock[]>([]);
        let unstableBlockHtml = $state('');
        let incompleteCodeBlock = $state<IncompleteCodeBlock | null>(null);
+       const streamingSvgCode = $derived.by(() => {
+               const block = incompleteCodeBlock;
+               if (!block) return null;
+               if (block.language === SVG_LANGUAGE) return block.code;
+               if (block.language === XML_LANGUAGE && block.code.trimStart().startsWith(SVG_TAG_PREFIX))
+                       return block.code;
+               return null;
+       });
+       const liveSvgHtml = $derived(streamingSvgCode !== null ? sanitizeSvg(streamingSvgCode) : '');
        let previewDialogOpen = $state(false);
        let previewCode = $state('');
        let previewLanguage = $state('text');
        let mermaidPreviewOpen = $state(false);
        let mermaidPreviewSvgHtml = $state('');
+       let svgPreviewLive = $state(false);
+       let streamingSvgHost = $state<HTMLDivElement | null>(null);
+
+       // While the zoom dialog is open on a streaming svg, mirror the live render into it
+       $effect(() => {
+               if (svgPreviewLive && liveSvgHtml) mermaidPreviewSvgHtml = liveSvgHtml;
+       });
+
+       // Mount the streaming svg into its shadow host on every chunk so it renders live
+       $effect(() => {
+               if (streamingSvgHost) mountSvgShadow(streamingSvgHost, liveSvgHtml, SVG_INLINE_SHADOW_STYLE);
+       });
 
        let streamingCodeScrollContainer = $state<HTMLDivElement>();
 
                        .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
                        .use(rehypeEnhanceLinks) // Add target="_blank" to links
                        .use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
+                       .use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
                        .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
                        .use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
+                       .use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
                        .use(rehypeResolveAttachmentImages, { attachments })
                        .use(rehypeRtlSupport) // Add bidirectional text support
                        .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
                const target = event.target as HTMLElement;
 
                // Check if clicking on copy or preview button in mermaid block
-               const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
-               const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
+               const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
+               const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
 
                if (copyBtn || previewBtn) {
-                       const wrapper = target.closest('.mermaid-block-wrapper');
+                       const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
                        if (!wrapper) return;
 
-                       const preElement = wrapper.querySelector<HTMLElement>('pre.mermaid[data-mermaid-syntax]');
+                       const preElement = wrapper.querySelector<HTMLElement>(
+                               `pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
+                       );
                        if (!preElement) return;
 
-                       const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
+                       const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
 
                        if (copyBtn) {
                                event.preventDefault();
                                const svg = preElement.querySelector('svg');
                                if (!svg) return;
                                mermaidPreviewSvgHtml = svg.outerHTML;
+                               svgPreviewLive = false;
+                               mermaidPreviewOpen = true;
+                               return;
+                       }
+               }
+
+               // Check if clicking on copy or preview button in svg block
+               const svgCopyBtn = target.closest(`.${SVG_WRAPPER_CLASS} .copy-code-btn`);
+               const svgPreviewBtn = target.closest(`.${SVG_WRAPPER_CLASS} .preview-code-btn`);
+
+               if (svgCopyBtn || svgPreviewBtn) {
+                       const wrapper = target.closest(`.${SVG_WRAPPER_CLASS}`);
+                       if (!wrapper) return;
+
+                       const preElement = wrapper.querySelector<HTMLElement>(
+                               `pre.${SVG_BLOCK_CLASS}[${SVG_SOURCE_ATTR}]`
+                       );
+                       if (!preElement) return;
+
+                       if (svgCopyBtn) {
+                               event.preventDefault();
+                               event.stopPropagation();
+                               try {
+                                       await copyToClipboard(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
+                               } catch (error) {
+                                       console.error('Failed to copy svg source:', error);
+                               }
+                               return;
+                       }
+
+                       if (svgPreviewBtn) {
+                               event.preventDefault();
+                               event.stopPropagation();
+                               mermaidPreviewSvgHtml = sanitizeSvg(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
+                               svgPreviewLive = false;
                                mermaidPreviewOpen = true;
                                return;
                        }
                }
 
+               // Open preview when clicking the svg block itself. A final block carries its
+               // source, a streaming block does not and is mirrored live into the dialog.
+               const svgEl = target.closest(`.${SVG_BLOCK_CLASS}`);
+               if (svgEl) {
+                       const source = svgEl.getAttribute(SVG_SOURCE_ATTR);
+                       if (source !== null) {
+                               mermaidPreviewSvgHtml = sanitizeSvg(source);
+                               svgPreviewLive = false;
+                       } else {
+                               svgPreviewLive = true;
+                       }
+                       mermaidPreviewOpen = true;
+                       return;
+               }
+
                // Otherwise, open preview when clicking on the mermaid diagram itself
-               const mermaidEl = target.closest('.mermaid');
+               const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
                if (!mermaidEl) return;
 
                const svg = mermaidEl.querySelector('svg');
                if (!svg) return;
 
                mermaidPreviewSvgHtml = svg.outerHTML;
+               svgPreviewLive = false;
                mermaidPreviewOpen = true;
        }
 
                mermaidPreviewOpen = open;
                if (!open) {
                        mermaidPreviewSvgHtml = '';
+                       svgPreviewLive = false;
                }
        }
 
        async function renderMermaidDiagrams() {
                if (!containerRef) return;
 
-               const nodes = containerRef.querySelectorAll('pre.mermaid:not([data-mermaid-rendered])');
+               const nodes = containerRef.querySelectorAll(
+                       `pre.${MERMAID_BLOCK_CLASS}:not([${MERMAID_RENDERED_ATTR}])`
+               );
                if (nodes.length === 0) return;
 
                // Mark nodes immediately to prevent duplicate renders if called again during streaming.
                // This avoids needing a guard that would block node discovery.
-               nodes.forEach((node) => node.setAttribute('data-mermaid-rendered', 'true'));
+               nodes.forEach((node) => node.setAttribute(MERMAID_RENDERED_ATTR, 'true'));
 
                // Read mode before await so Svelte tracks it reactively.
                const isDark = mode.current === ColorMode.DARK;
                }
        }
 
+       /**
+        * Renders svg diagrams that haven't been rendered yet.
+        * Sanitizes the source before injecting and marks each node so it renders once.
+        * An empty sanitize result keeps the raw source as escaped text.
+        */
+       function renderSvgDiagrams() {
+               if (!containerRef) return;
+
+               const nodes = containerRef.querySelectorAll<HTMLElement>(
+                       `pre.${SVG_BLOCK_CLASS}:not([${SVG_RENDERED_ATTR}])`
+               );
+               if (nodes.length === 0) return;
+
+               nodes.forEach((node) => {
+                       node.setAttribute(SVG_RENDERED_ATTR, 'true');
+
+                       const source = node.getAttribute(SVG_SOURCE_ATTR) ?? node.textContent ?? '';
+                       const clean = sanitizeSvg(source);
+
+                       if (clean) {
+                               node.textContent = '';
+                               const host = document.createElement('div');
+                               node.appendChild(host);
+                               mountSvgShadow(host, clean, SVG_INLINE_SHADOW_STYLE);
+                       }
+               });
+       }
+
        /**
         * Handles image load errors by replacing the image with a fallback UI.
         * Shows a placeholder with a link to open the image in a new tab.
                        setupCodeBlockActions();
                        setupImageErrorHandlers();
                        renderMermaidDiagrams();
+                       renderSvgDiagrams();
                }
        });
 
        {/if}
 
        {#if incompleteCodeBlock}
-               {#if incompleteCodeBlock.language === 'mermaid'}
+               {#if incompleteCodeBlock.language === MERMAID_LANGUAGE}
                        <div class="mermaid-block-wrapper streaming-mermaid-block">
                                <div class="code-block-header">
                                        <span class="code-language">mermaid</span>
                                        <span class="mermaid-loading-text">Generating diagram...</span>
                                </div>
                        </div>
+               {:else if streamingSvgCode !== null}
+                       <div class="svg-block-wrapper streaming-svg-block">
+                               <div class="code-block-header">
+                                       <span class="code-language">svg</span>
+                                       <div class="code-block-actions">
+                                               <ActionIconCopyToClipboard
+                                                       text={incompleteCodeBlock.code}
+                                                       canCopy={false}
+                                                       ariaLabel="Diagram incomplete"
+                                               />
+                                       </div>
+                               </div>
+                               {#if liveSvgHtml}
+                                       <div class="svg-scroll-container">
+                                               <div class={SVG_BLOCK_CLASS}>
+                                                       <div bind:this={streamingSvgHost}></div>
+                                               </div>
+                                       </div>
+                               {:else}
+                                       <div class="mermaid-loading-placeholder">
+                                               <span class="mermaid-loading-text">Rendering svg...</span>
+                                       </div>
+                               {/if}
+                       </div>
                {:else}
                        <div class="code-block-wrapper streaming-code-block relative">
                                <div class="code-block-header">
index 07904f76812bb87ecb501c4c371d43bb13014dd5..072db383d860c73734ea559e95992a9828582721 100644 (file)
@@ -560,8 +560,9 @@ div.markdown-user-content :global(.table-wrapper) {
        border-color: var(--primary);
 }
 
-/* Mermaid diagrams */
-.markdown-content :global(pre.mermaid) {
+/* Mermaid and svg blocks share the same block styling */
+.markdown-content :global(pre.mermaid),
+.markdown-content :global(.svg-block) {
        background: transparent;
        border: none;
        padding: 0;
@@ -572,13 +573,25 @@ div.markdown-user-content :global(.table-wrapper) {
        position: relative;
 }
 
+/* The svg block fills its flex container so the shadow host has a definite width to render into */
+.markdown-content :global(.svg-block) {
+       width: 100%;
+}
+
 /* Hide mermaid code text until rendered - prevents flash */
 .markdown-content :global(pre.mermaid:not([data-mermaid-rendered])),
 .markdown-content :global(pre.mermaid[data-mermaid-rendered]:not(:has(svg))) {
        display: none;
 }
 
-.markdown-content :global(pre.mermaid:hover) {
+/* Hide svg source until rendered - prevents flash. A rendered-but-unsanitized
+   block (oversized source) keeps its raw text visible as a safe fallback. */
+.markdown-content :global(pre.svg-block:not([data-svg-rendered])) {
+       display: none;
+}
+
+.markdown-content :global(pre.mermaid:hover),
+.markdown-content :global(.svg-block:hover) {
        opacity: 0.85;
 }
 
@@ -590,8 +603,9 @@ div.markdown-user-content :global(.table-wrapper) {
        padding: 3rem 1rem;
 }
 
-/* Mermaid block wrapper - matches code block styling */
-.markdown-content :global(.mermaid-block-wrapper) {
+/* Diagram block wrapper - matches code block styling */
+.markdown-content :global(.mermaid-block-wrapper),
+.markdown-content :global(.svg-block-wrapper) {
        margin: 1.5rem 0;
        border-radius: 0.75rem;
        overflow: hidden;
@@ -603,11 +617,13 @@ div.markdown-user-content :global(.table-wrapper) {
        max-height: var(--max-message-height);
 }
 
-.markdown-content:global(.dark) :global(.mermaid-block-wrapper) {
+.markdown-content:global(.dark) :global(.mermaid-block-wrapper),
+.markdown-content:global(.dark) :global(.svg-block-wrapper) {
        border-color: color-mix(in oklch, var(--border) 20%, transparent);
 }
 
-.markdown-content :global(.mermaid-scroll-container) {
+.markdown-content :global(.mermaid-scroll-container),
+.markdown-content :global(.svg-scroll-container) {
        min-height: 350px;
        max-height: var(--max-message-height);
        overflow-y: auto;
@@ -618,17 +634,20 @@ div.markdown-user-content :global(.table-wrapper) {
        padding: 3rem 1rem 1rem;
 }
 
-.full-height-code-blocks :global(.mermaid-block-wrapper) {
+.full-height-code-blocks :global(.mermaid-block-wrapper),
+.full-height-code-blocks :global(.svg-block-wrapper) {
        max-height: none;
 }
 
-.full-height-code-blocks :global(.mermaid-scroll-container) {
+.full-height-code-blocks :global(.mermaid-scroll-container),
+.full-height-code-blocks :global(.svg-scroll-container) {
        max-height: none;
        overflow-y: visible;
 }
 
-/* Mermaid block uses same header styling as code blocks */
-.markdown-content :global(.mermaid-block-wrapper .code-block-header) {
+/* Diagram block uses same header styling as code blocks */
+.markdown-content :global(.mermaid-block-wrapper .code-block-header),
+.markdown-content :global(.svg-block-wrapper .code-block-header) {
        display: flex;
        justify-content: space-between;
        align-items: center;
@@ -640,14 +659,16 @@ div.markdown-user-content :global(.table-wrapper) {
        right: 0;
 }
 
-.markdown-content :global(.mermaid-block-wrapper .code-block-actions) {
+.markdown-content :global(.mermaid-block-wrapper .code-block-actions),
+.markdown-content :global(.svg-block-wrapper .code-block-actions) {
        display: flex;
        align-items: center;
        gap: 0.5rem;
 }
 
-/* Mermaid pre element - remove default margins */
-.markdown-content :global(.mermaid-block-wrapper pre.mermaid) {
+/* Diagram pre element - remove default margins */
+.markdown-content :global(.mermaid-block-wrapper pre.mermaid),
+.markdown-content :global(.svg-block-wrapper pre.svg-block) {
        background: transparent;
        border: none;
        padding: 0;
@@ -655,7 +676,6 @@ div.markdown-user-content :global(.table-wrapper) {
        text-align: center;
 }
 
-/* Mermaid SVG should be bigger */
 .markdown-content :global(.mermaid-block-wrapper pre.mermaid svg) {
        width: unset !important;
        height: auto;
index 554408485910bd2d5bcb0cba2b211f632f80dd39..80052945f0c5c74c2f99e6d6df84d31f34780406 100644 (file)
@@ -4,6 +4,7 @@
  */
 
 import { copyCodeToClipboard, copyToClipboard } from '$lib/utils';
+import { MERMAID_WRAPPER_CLASS, MERMAID_BLOCK_CLASS, MERMAID_SYNTAX_ATTR } from '$lib/constants';
 
 export interface PreviewState {
        previewDialogOpen: boolean;
@@ -106,17 +107,19 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
                const target = event.target as HTMLElement;
 
                // Check if clicking on copy or preview button in mermaid block
-               const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
-               const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
+               const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
+               const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
 
                if (copyBtn || previewBtn) {
-                       const wrapper = target.closest('.mermaid-block-wrapper');
+                       const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
                        if (!wrapper) return;
 
-                       const preElement = wrapper.querySelector<HTMLElement>('pre.mermaid[data-mermaid-syntax]');
+                       const preElement = wrapper.querySelector<HTMLElement>(
+                               `pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
+                       );
                        if (!preElement) return;
 
-                       const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
+                       const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
 
                        if (copyBtn) {
                                event.preventDefault();
@@ -141,7 +144,7 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
                }
 
                // Otherwise, open preview when clicking on the mermaid diagram itself
-               const mermaidEl = target.closest('.mermaid');
+               const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
                if (!mermaidEl) return;
 
                const svg = mermaidEl.querySelector('svg');
index ab24e78230f7c11ab2542f3e5825ba99ae4f68e3..f9decf206390c21f11205f8658bbc5a2b6e3acad 100644 (file)
 import type { Plugin } from 'unified';
 import type { Root, Element, ElementContent } from 'hast';
 import { visit } from 'unist-util-visit';
-import { MERMAID_WRAPPER_CLASS, MERMAID_SCROLL_CONTAINER_CLASS } from '$lib/constants';
+import {
+       MERMAID_WRAPPER_CLASS,
+       MERMAID_SCROLL_CONTAINER_CLASS,
+       MERMAID_BLOCK_CLASS,
+       MERMAID_LANGUAGE,
+       MERMAID_SYNTAX_ATTR,
+       MERMAID_ID_ATTR
+} from '$lib/constants';
 import {
        createBlockHeader,
        createCopyButton,
@@ -43,11 +50,13 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
                        const className = node.properties?.className;
                        if (!Array.isArray(className)) return;
 
-                       const isMermaid = className.some((cls) => typeof cls === 'string' && cls === 'mermaid');
+                       const isMermaid = className.some(
+                               (cls) => typeof cls === 'string' && cls === MERMAID_BLOCK_CLASS
+                       );
 
                        if (!isMermaid) return;
 
-                       const mermaidId = generateBlockId('mermaid', 'idxMermaidBlock');
+                       const mermaidId = generateBlockId(MERMAID_LANGUAGE, 'idxMermaidBlock');
 
                        // Extract the mermaid syntax (text content of the pre element)
                        const diagramText = node.children
@@ -60,22 +69,22 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
                        // Store the mermaid syntax in data attribute for copy functionality
                        node.properties = {
                                ...node.properties,
-                               'data-mermaid-syntax': diagramText,
-                               'data-mermaid-id': mermaidId
+                               [MERMAID_SYNTAX_ATTR]: diagramText,
+                               [MERMAID_ID_ATTR]: mermaidId
                        };
 
                        const actions = [
-                               createCopyButton(mermaidId, 'data-mermaid-id', 'Copy mermaid syntax'),
-                               createPreviewButton(mermaidId, 'data-mermaid-id', 'Preview diagram')
+                               createCopyButton(mermaidId, MERMAID_ID_ATTR, 'Copy mermaid syntax'),
+                               createPreviewButton(mermaidId, MERMAID_ID_ATTR, 'Preview diagram')
                        ];
 
-                       const header = createBlockHeader('mermaid', mermaidId, 'data-mermaid-id', actions);
+                       const header = createBlockHeader(MERMAID_LANGUAGE, mermaidId, MERMAID_ID_ATTR, actions);
                        const wrapper = createWrapper(
                                header,
                                node,
                                MERMAID_WRAPPER_CLASS,
                                MERMAID_SCROLL_CONTAINER_CLASS,
-                               { 'data-mermaid-id': mermaidId }
+                               { [MERMAID_ID_ATTR]: mermaidId }
                        );
 
                        // Replace pre with wrapper in parent
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts
new file mode 100644 (file)
index 0000000..e5e7514
--- /dev/null
@@ -0,0 +1,80 @@
+/**
+ * Rehype plugin to enhance svg blocks with wrapper, header, and action buttons.
+ *
+ * Wraps <pre class="svg-block"> elements with a container that includes:
+ * - Language label ("svg")
+ * - Copy button (copies svg source to clipboard)
+ * - Preview button (opens fullscreen preview dialog)
+ *
+ * Operates directly on the HAST tree and reuses the shared code-block builders.
+ */
+
+import type { Plugin } from 'unified';
+import type { Root, Element, ElementContent } from 'hast';
+import { visit } from 'unist-util-visit';
+import {
+       SVG_WRAPPER_CLASS,
+       SVG_SCROLL_CONTAINER_CLASS,
+       SVG_BLOCK_CLASS,
+       SVG_LANGUAGE,
+       SVG_SOURCE_ATTR,
+       SVG_ID_ATTR
+} from '$lib/constants';
+import {
+       createBlockHeader,
+       createCopyButton,
+       createPreviewButton,
+       createWrapper,
+       generateBlockId
+} from './code-block-utils';
+
+declare global {
+       interface Window {
+               idxSvgBlock?: number;
+       }
+}
+
+export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
+       return (tree: Root) => {
+               visit(tree, 'element', (node: Element, index, parent) => {
+                       if (node.tagName !== 'pre' || !parent || index === undefined) return;
+
+                       const className = node.properties?.className;
+                       if (!Array.isArray(className)) return;
+
+                       const isSvg = className.some((cls) => typeof cls === 'string' && cls === SVG_BLOCK_CLASS);
+
+                       if (!isSvg) return;
+
+                       const svgId = generateBlockId(SVG_LANGUAGE, 'idxSvgBlock');
+
+                       // Extract the svg source (text content of the pre element)
+                       const svgSource = node.children
+                               .map((child) => {
+                                       if (child.type === 'text') return child.value;
+                                       return '';
+                               })
+                               .join('');
+
+                       // Store the svg source in data attribute for copy and render
+                       node.properties = {
+                               ...node.properties,
+                               [SVG_SOURCE_ATTR]: svgSource,
+                               [SVG_ID_ATTR]: svgId
+                       };
+
+                       const actions = [
+                               createCopyButton(svgId, SVG_ID_ATTR, 'Copy svg source'),
+                               createPreviewButton(svgId, SVG_ID_ATTR, 'Preview svg')
+                       ];
+
+                       const header = createBlockHeader(SVG_LANGUAGE, svgId, SVG_ID_ATTR, actions);
+                       const wrapper = createWrapper(header, node, SVG_WRAPPER_CLASS, SVG_SCROLL_CONTAINER_CLASS, {
+                               [SVG_ID_ATTR]: svgId
+                       });
+
+                       // Replace pre with wrapper in parent
+                       (parent.children as ElementContent[])[index] = wrapper;
+               });
+       };
+};
index e2270a6583d6ec335e19597fc057314ddce15d8c..61322f045cf128fcc245bf6a4fe86553fa653d9e 100644 (file)
@@ -1,67 +1,7 @@
-import type { Plugin } from 'unified';
-import type { Root, Element, ElementContent, Text } from 'hast';
-import { visit } from 'unist-util-visit';
+import { createPreTransform } from './pre-transform';
+import { MERMAID_BLOCK_CLASS, MERMAID_LANGUAGE } from '$lib/constants';
 
 /**
- * Recursively extracts all text content from a HAST node.
- * Handles nested elements (e.g., span wrappers from syntax highlighting).
+ * Converts mermaid code blocks to <pre class="mermaid"> for client-side rendering.
  */
-function extractText(node: ElementContent): string {
-       if (node.type === 'text') return node.value;
-       if (node.type === 'element') {
-               return (node.children ?? []).map(extractText).join('');
-       }
-       return '';
-}
-
-/**
- * Rehype plugin to convert mermaid code blocks to <pre class="mermaid"> elements.
- *
- * Transforms:
- *   <pre><code class="language-mermaid">graph TD; A-->B</code></pre>
- * into:
- *   <pre class="mermaid">graph TD; A-->B</pre>
- *
- * The mermaid library renders these client-side via mermaid.run().
- *
- * Must run BEFORE rehypeEnhanceCodeBlocks so mermaid blocks are not wrapped
- * with code block headers/buttons (they have no <code> child, so they're skipped).
- */
-export const rehypeMermaidPre: Plugin<[], Root> = () => {
-       return (tree: Root) => {
-               visit(tree, 'element', (node: Element, index, parent) => {
-                       if (node.tagName !== 'pre' || !parent || index === undefined) return;
-
-                       const codeElement = node.children.find(
-                               (child): child is Element => child.type === 'element' && child.tagName === 'code'
-                       );
-
-                       if (!codeElement) return;
-
-                       const className = codeElement.properties?.className;
-                       if (!Array.isArray(className)) return;
-
-                       const isMermaid = className.some(
-                               (cls) => typeof cls === 'string' && cls === 'language-mermaid'
-                       );
-
-                       if (!isMermaid) return;
-
-                       // Recursively extract text to handle nested spans from syntax highlighting
-                       const diagramText = codeElement.children.map(extractText).join('').trim();
-
-                       if (!diagramText) return;
-
-                       const mermaidPre: Element = {
-                               type: 'element',
-                               tagName: 'pre',
-                               properties: {
-                                       className: ['mermaid']
-                               },
-                               children: [{ type: 'text', value: diagramText } as Text]
-                       };
-
-                       (parent.children as ElementContent[])[index] = mermaidPre;
-               });
-       };
-};
+export const rehypeMermaidPre = createPreTransform(MERMAID_LANGUAGE, MERMAID_BLOCK_CLASS);
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts
new file mode 100644 (file)
index 0000000..06848eb
--- /dev/null
@@ -0,0 +1,79 @@
+import type { Plugin } from 'unified';
+import type { Root, Element, ElementContent, Text } from 'hast';
+import { visit } from 'unist-util-visit';
+
+/**
+ * Recursively extracts all text content from a HAST node.
+ * Handles nested elements (e.g., span wrappers from syntax highlighting).
+ */
+function extractText(node: ElementContent): string {
+       if (node.type === 'text') return node.value;
+       if (node.type === 'element') {
+               return (node.children ?? []).map(extractText).join('');
+       }
+       return '';
+}
+
+/**
+ * Builds a rehype plugin that converts <pre><code class="language-{language}">
+ * blocks into <pre class="{targetClass}"> elements carrying the raw text.
+ *
+ * Accepts one or more source languages, and an optional contentGuard that
+ * receives the trimmed text and decides whether the block qualifies. The guard
+ * lets a shared fence language be claimed only when its content matches, e.g.
+ * an xml block is converted to svg only when it starts with <svg.
+ *
+ * The result has no <code> child, so rehypeEnhanceCodeBlocks skips it. Rendering
+ * happens client-side, so no markup is injected at this stage. Must run BEFORE
+ * rehypeEnhanceCodeBlocks.
+ */
+export function createPreTransform(
+       languages: string | string[],
+       targetClass: string,
+       contentGuard?: (text: string) => boolean
+): Plugin<[], Root> {
+       const codeClasses = (Array.isArray(languages) ? languages : [languages]).map(
+               (language) => `language-${language}`
+       );
+
+       return () => {
+               return (tree: Root) => {
+                       visit(tree, 'element', (node: Element, index, parent) => {
+                               if (node.tagName !== 'pre' || !parent || index === undefined) return;
+
+                               const codeElement = node.children.find(
+                                       (child): child is Element => child.type === 'element' && child.tagName === 'code'
+                               );
+
+                               if (!codeElement) return;
+
+                               const className = codeElement.properties?.className;
+                               if (!Array.isArray(className)) return;
+
+                               const matches = className.some(
+                                       (cls) => typeof cls === 'string' && codeClasses.includes(cls)
+                               );
+
+                               if (!matches) return;
+
+                               // Recursively extract text to handle nested spans from syntax highlighting
+                               const text = codeElement.children.map(extractText).join('').trim();
+
+                               if (!text) return;
+
+                               if (contentGuard && !contentGuard(text)) return;
+
+                               const pre: Element = {
+                                       type: 'element',
+                                       tagName: 'pre',
+                                       properties: {
+                                               className: [targetClass]
+                                       },
+                                       children: [{ type: 'text', value: text } as Text]
+                               };
+
+                               (parent.children as ElementContent[])[index] = pre;
+                       });
+               };
+       };
+}
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/svg-pre.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/svg-pre.ts
new file mode 100644 (file)
index 0000000..eb0e2c6
--- /dev/null
@@ -0,0 +1,13 @@
+import { createPreTransform } from './pre-transform';
+import { SVG_BLOCK_CLASS, SVG_LANGUAGE, XML_LANGUAGE, SVG_TAG_PREFIX } from '$lib/constants';
+
+/**
+ * Converts svg code blocks to <pre class="svg-block"> for client-side rendering.
+ * Also claims xml blocks whose content starts with <svg, since models often emit
+ * svg inside an xml fence.
+ */
+export const rehypeSvgPre = createPreTransform(
+       [SVG_LANGUAGE, XML_LANGUAGE],
+       SVG_BLOCK_CLASS,
+       (text) => text.startsWith(SVG_TAG_PREFIX)
+);
index d4825889d26812135b29a87a55a1211dc3d6de5a..a30f585b93cb7c2df273246a30642592c13ca8f4 100644 (file)
@@ -1,5 +1,7 @@
 <script lang="ts">
        import MermaidPreviewControls from './MermaidPreviewControls.svelte';
+       import { mountSvgShadow } from '$lib/utils/svg-shadow';
+       import { SVG_DIALOG_SHADOW_STYLE } from '$lib/constants';
 
        interface Props {
                svgHtml: string;
@@ -7,6 +9,13 @@
 
        let { svgHtml }: Props = $props();
 
+       let svgHost = $state<HTMLDivElement | null>(null);
+
+       // Re-mount on every svgHtml change so a live streaming svg keeps rendering while zoomed
+       $effect(() => {
+               if (svgHost) mountSvgShadow(svgHost, svgHtml, SVG_DIALOG_SHADOW_STYLE);
+       });
+
        // Zoom and pan state
        let scale = $state(1);
        let translateX = $state(0);
                onpointerup={handlePointerUp}
                onpointerleave={handlePointerUp}
        >
-               <!-- eslint-disable-next-line no-at-html-tags -->
-               {@html svgHtml}
+               <div bind:this={svgHost}></div>
        </div>
 
        <MermaidPreviewControls
                onResetView={resetView}
        />
 </div>
-
-<style lang="postcss" scoped>
-       /* Styles for SVGs rendered via {@html} — no Tailwind class can target child elements */
-       .mermaid-preview-diagram :global(svg) {
-               min-height: min(50vh, 12rem);
-               min-width: min(80vw, 20rem);
-               max-width: none !important;
-               max-height: none !important;
-               height: auto !important;
-               width: auto !important;
-               display: block;
-       }
-</style>
index a81f734d6843cf973e35f8f19d1336391a409065..07e441112d01fc03154e7ce869b278ad50d33a6f 100644 (file)
@@ -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';
index 3f43942f08bcd480fe94636eff169c5dcff83353..cd9467fb6e6366ae8190e873dbb355b2e803b7e9 100644 (file)
@@ -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 (file)
index 0000000..ccca937
--- /dev/null
@@ -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 = '<svg';
+
+export const SVG_SOURCE_ATTR = 'data-svg-source';
+export const SVG_ID_ATTR = 'data-svg-id';
+export const SVG_RENDERED_ATTR = 'data-svg-rendered';
+
+/**
+ * Hard size ceiling for a single inline svg block.
+ * Above this the source is left as raw text instead of being rendered.
+ */
+export const SVG_MAX_BYTES = 256 * 1024;
+
+/**
+ * DOMPurify config for untrusted svg coming from model output.
+ *
+ * foreignObject and script stay forbidden unconditionally, they are the only
+ * inline svg vectors that execute arbitrary html or js. Everything else is
+ * allowed for maximum rendering compatibility: href and xlink:href stay so
+ * use, image, a and animateMotion work, and DOMPurify still neutralizes
+ * javascript: and data: uri schemes natively. External resource refs are
+ * allowed by design on a local first tool, the user browser fetches them.
+ *
+ * The sanitized svg is always mounted inside a shadow root (see svg-shadow),
+ * so an author <style> 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 (file)
index 0000000..e5a9493
--- /dev/null
@@ -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 (file)
index 0000000..71caff8
--- /dev/null
@@ -0,0 +1,10 @@
+/**
+ * Mounts svg markup inside an open shadow root on the host element.
+ * The shadow boundary scopes the svg <style> and its animations to the host,
+ * so model authored css can not reach the surrounding page. The caller passes
+ * markup that is already sanitized, this only isolates and sizes it.
+ */
+export function mountSvgShadow(host: HTMLElement, markup: string, style: string): void {
+       const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
+       root.innerHTML = markup ? `<style>${style}</style>${markup}` : '';
+}