"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": {
"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",
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">
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;
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;
}
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;
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;
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;
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;
text-align: center;
}
-/* Mermaid SVG should be bigger */
.markdown-content :global(.mermaid-block-wrapper pre.mermaid svg) {
width: unset !important;
height: auto;
*/
import { copyCodeToClipboard, copyToClipboard } from '$lib/utils';
+import { MERMAID_WRAPPER_CLASS, MERMAID_BLOCK_CLASS, MERMAID_SYNTAX_ATTR } from '$lib/constants';
export interface PreviewState {
previewDialogOpen: boolean;
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();
}
// 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');
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,
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
// 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
--- /dev/null
+/**
+ * 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;
+ });
+ };
+};
-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);
--- /dev/null
+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;
+ });
+ };
+ };
+}
--- /dev/null
+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)
+);
<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;
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>
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';
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';
--- /dev/null
+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}';
--- /dev/null
+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;
+}
--- /dev/null
+/**
+ * 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}` : '';
+}