diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json index f46a98d1a..61fa529d2 100644 --- a/tools/ui/package-lock.json +++ b/tools/ui/package-lock.json @@ -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", @@ -8651,9 +8652,9 @@ "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": { diff --git a/tools/ui/package.json b/tools/ui/package.json index 6c750431f..2f47992e1 100644 --- a/tools/ui/package.json +++ b/tools/ui/package.json @@ -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", diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte index 9c4c49c0c..7139d2e63 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -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'; @@ -38,11 +40,26 @@ 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'; @@ -77,11 +94,32 @@ let renderedBlocks = $state([]); let unstableBlockHtml = $state(''); let incompleteCodeBlock = $state(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(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(); @@ -124,8 +162,10 @@ .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g.,
,
    ) inside Markdown tables .use(rehypeEnhanceLinks) // Add target="_blank" to links .use(rehypeMermaidPre) // Convert mermaid blocks to
    +			.use(rehypeSvgPre) // Convert svg blocks to 
     			.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
    @@ -462,17 +502,19 @@
     		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('pre.mermaid[data-mermaid-syntax]');
    +			const preElement = wrapper.querySelector(
    +				`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();
    @@ -491,19 +533,70 @@
     				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(
    +				`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;
     	}
     
    @@ -515,6 +608,7 @@
     		mermaidPreviewOpen = open;
     		if (!open) {
     			mermaidPreviewSvgHtml = '';
    +			svgPreviewLive = false;
     		}
     	}
     
    @@ -527,12 +621,14 @@
     	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;
    @@ -565,6 +661,34 @@
     		}
     	}
     
    +	/**
    +	 * 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(
    +			`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.
    @@ -647,6 +771,7 @@
     			setupCodeBlockActions();
     			setupImageErrorHandlers();
     			renderMermaidDiagrams();
    +			renderSvgDiagrams();
     		}
     	});
     
    @@ -689,7 +814,7 @@
     	{/if}
     
     	{#if incompleteCodeBlock}
    -		{#if incompleteCodeBlock.language === 'mermaid'}
    +		{#if incompleteCodeBlock.language === MERMAID_LANGUAGE}
     			
    mermaid @@ -705,6 +830,30 @@ Generating diagram...
    + {:else if streamingSvgCode !== null} +
    +
    + svg +
    + +
    +
    + {#if liveSvgHtml} +
    +
    +
    +
    +
    + {:else} +
    + Rendering svg... +
    + {/if} +
    {:else}
    diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css index 07904f768..072db383d 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css @@ -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; diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts index 554408485..80052945f 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts @@ -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('pre.mermaid[data-mermaid-syntax]'); + const preElement = wrapper.querySelector( + `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'); diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts index ab24e7823..f9decf206 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts @@ -13,7 +13,14 @@ 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 index 000000000..e5e7514ed --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts @@ -0,0 +1,80 @@ +/** + * Rehype plugin to enhance svg blocks with wrapper, header, and action buttons. + * + * Wraps
     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;
    +		});
    +	};
    +};
    diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
    index e2270a658..61322f045 100644
    --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
    +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
    @@ -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 
     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 
     elements.
    - *
    - * Transforms:
    - *   
    graph TD; A-->B
    - * into: - *
    graph TD; A-->B
    - * - * 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 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 index 000000000..06848eb26 --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts @@ -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
    
    + * blocks into 
     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  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
    index 000000000..eb0e2c699
    --- /dev/null
    +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/svg-pre.ts
    @@ -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 
     for client-side rendering.
    + * Also claims xml blocks whose content starts with  text.startsWith(SVG_TAG_PREFIX)
    +);
    diff --git a/tools/ui/src/lib/components/app/content/MermaidPreview.svelte b/tools/ui/src/lib/components/app/content/MermaidPreview.svelte
    index d4825889d..a30f585b9 100644
    --- a/tools/ui/src/lib/components/app/content/MermaidPreview.svelte
    +++ b/tools/ui/src/lib/components/app/content/MermaidPreview.svelte
    @@ -1,5 +1,7 @@