]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: Fix selecting generated output issues during active streaming (#18091)
authorAleksander Grygier <redacted>
Thu, 18 Dec 2025 10:13:52 +0000 (11:13 +0100)
committerGitHub <redacted>
Thu, 18 Dec 2025 10:13:52 +0000 (11:13 +0100)
* draft: incremental markdown rendering with stable blocks

* refactor: Logic improvements

* refactor: DRY Markdown post-processing logic

* refactor: ID generation improvements

* fix: Remove runes

* refactor: Clean up & add JSDocs

* chore: update webui static output

* fix: Add tick to prevent race conditions for rendering Markdown blocks

Suggestion from @ServeurpersoCom

Co-authored-by: Pascal <redacted>
* chore: Run `npm audit fix`

* chore: update webui static output

* feat: Improve performance using global counter & id instead of UUID

* refactor: Enhance Markdown rendering with link and code features

* chore: update webui static output

* fix: Code block content extraction

* chore: update webui static output

* chore: update webui static output

---------

Co-authored-by: Pascal <redacted>
tools/server/public/index.html.gz
tools/server/webui/package-lock.json
tools/server/webui/src/app.d.ts
tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
tools/server/webui/src/lib/markdown/enhance-code-blocks.ts [new file with mode: 0644]
tools/server/webui/src/lib/markdown/enhance-links.ts [new file with mode: 0644]

index e603c522f6b45d5c8e5cc425a2a0ddd9a1af6c4e..a1d62273b285a65cffab0d9053f5bb00559e2b6b 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index 4f37b308b1348d51abb7e788ed0c9c112b4e079f..0d1a03aca34b2fb2d1d222d3f8a7fd29404d9835 100644 (file)
                        }
                },
                "node_modules/@sveltejs/kit": {
-                       "version": "2.48.5",
-                       "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.5.tgz",
-                       "integrity": "sha512-/rnwfSWS3qwUSzvHynUTORF9xSJi7PCR9yXkxUOnRrNqyKmCmh3FPHH+E9BbgqxXfTevGXBqgnlh9kMb+9T5XA==",
+                       "version": "2.49.2",
+                       "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.2.tgz",
+                       "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==",
                        "dev": true,
                        "license": "MIT",
                        "dependencies": {
                        }
                },
                "node_modules/mdast-util-to-hast": {
-                       "version": "13.2.0",
-                       "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
-                       "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
+                       "version": "13.2.1",
+                       "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+                       "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
                        "license": "MIT",
                        "dependencies": {
                                "@types/hast": "^3.0.0",
index 71976936edd876211d22ea0fafd1596b247eac45..73287d91b657c7ed165bd72c93545ddfe2431a87 100644 (file)
@@ -124,3 +124,10 @@ declare global {
                SettingsConfigType
        };
 }
+
+declare global {
+       interface Window {
+               idxThemeStyle?: number;
+               idxCodeBlock?: number;
+       }
+}
index 2a4a39535e0b45bd7d7ac9f728ed2e8896113675..cb3ae17a63fbae601ccb876c8753f8128cb45e0e 100644 (file)
@@ -7,15 +7,19 @@
        import remarkRehype from 'remark-rehype';
        import rehypeKatex from 'rehype-katex';
        import rehypeStringify from 'rehype-stringify';
-       import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
-       import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
+       import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
+       import type { Root as MdastRoot } from 'mdast';
        import { browser } from '$app/environment';
+       import { onDestroy, tick } from 'svelte';
+       import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
+       import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
+       import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
+       import { remarkLiteralHtml } from '$lib/markdown/literal-html';
+       import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
        import '$styles/katex-custom.scss';
-
        import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
        import githubLightCss from 'highlight.js/styles/github.css?inline';
        import { mode } from 'mode-watcher';
-       import { remarkLiteralHtml } from '$lib/markdown/literal-html';
        import CodePreviewDialog from './CodePreviewDialog.svelte';
 
        interface Props {
                class?: string;
        }
 
+       interface MarkdownBlock {
+               id: string;
+               html: string;
+       }
+
        let { content, class: className = '' }: Props = $props();
 
        let containerRef = $state<HTMLDivElement>();
-       let processedHtml = $state('');
+       let renderedBlocks = $state<MarkdownBlock[]>([]);
+       let unstableBlockHtml = $state('');
        let previewDialogOpen = $state(false);
        let previewCode = $state('');
        let previewLanguage = $state('text');
 
-       function loadHighlightTheme(isDark: boolean) {
-               if (!browser) return;
-
-               const existingThemes = document.querySelectorAll('style[data-highlight-theme]');
-               existingThemes.forEach((style) => style.remove());
+       let pendingMarkdown: string | null = null;
+       let isProcessing = false;
 
-               const style = document.createElement('style');
-               style.setAttribute('data-highlight-theme', 'true');
-               style.textContent = isDark ? githubDarkCss : githubLightCss;
-
-               document.head.appendChild(style);
-       }
-
-       $effect(() => {
-               const currentMode = mode.current;
-               const isDark = currentMode === 'dark';
-
-               loadHighlightTheme(isDark);
-       });
+       const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
 
        let processor = $derived(() => {
                return remark()
                        .use(rehypeKatex) // Render math using KaTeX
                        .use(rehypeHighlight) // Add syntax highlighting
                        .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
-                       .use(rehypeStringify); // Convert to HTML string
+                       .use(rehypeEnhanceLinks) // Add target="_blank" to links
+                       .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
+                       .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
        });
 
-       function enhanceLinks(html: string): string {
-               if (!html.includes('<a')) {
-                       return html;
-               }
-
-               const tempDiv = document.createElement('div');
-               tempDiv.innerHTML = html;
-
-               // Make all links open in new tabs
-               const linkElements = tempDiv.querySelectorAll('a[href]');
-               let mutated = false;
-
-               for (const link of linkElements) {
-                       const target = link.getAttribute('target');
-                       const rel = link.getAttribute('rel');
+       /**
+        * Removes click event listeners from copy and preview buttons.
+        * Called on component destroy.
+        */
+       function cleanupEventListeners() {
+               if (!containerRef) return;
 
-                       if (target !== '_blank' || rel !== 'noopener noreferrer') {
-                               mutated = true;
-                       }
+               const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
+               const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn');
 
-                       link.setAttribute('target', '_blank');
-                       link.setAttribute('rel', 'noopener noreferrer');
+               for (const button of copyButtons) {
+                       button.removeEventListener('click', handleCopyClick);
                }
 
-               return mutated ? tempDiv.innerHTML : html;
-       }
-
-       function enhanceCodeBlocks(html: string): string {
-               if (!html.includes('<pre')) {
-                       return html;
+               for (const button of previewButtons) {
+                       button.removeEventListener('click', handlePreviewClick);
                }
+       }
 
-               const tempDiv = document.createElement('div');
-               tempDiv.innerHTML = html;
-
-               const preElements = tempDiv.querySelectorAll('pre');
-               let mutated = false;
-
-               for (const [index, pre] of Array.from(preElements).entries()) {
-                       const codeElement = pre.querySelector('code');
-
-                       if (!codeElement) {
-                               continue;
-                       }
-
-                       mutated = true;
-
-                       let language = 'text';
-                       const classList = Array.from(codeElement.classList);
-
-                       for (const className of classList) {
-                               if (className.startsWith('language-')) {
-                                       language = className.replace('language-', '');
-                                       break;
-                               }
-                       }
-
-                       const rawCode = codeElement.textContent || '';
-                       const codeId = `code-${Date.now()}-${index}`;
-                       codeElement.setAttribute('data-code-id', codeId);
-                       codeElement.setAttribute('data-raw-code', rawCode);
-
-                       const wrapper = document.createElement('div');
-                       wrapper.className = 'code-block-wrapper';
-
-                       const header = document.createElement('div');
-                       header.className = 'code-block-header';
-
-                       const languageLabel = document.createElement('span');
-                       languageLabel.className = 'code-language';
-                       languageLabel.textContent = language;
-
-                       const copyButton = document.createElement('button');
-                       copyButton.className = 'copy-code-btn';
-                       copyButton.setAttribute('data-code-id', codeId);
-                       copyButton.setAttribute('title', 'Copy code');
-                       copyButton.setAttribute('type', 'button');
-
-                       copyButton.innerHTML = `
-                                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
-                        `;
-
-                       const actions = document.createElement('div');
-                       actions.className = 'code-block-actions';
-
-                       actions.appendChild(copyButton);
-
-                       if (language.toLowerCase() === 'html') {
-                               const previewButton = document.createElement('button');
-                               previewButton.className = 'preview-code-btn';
-                               previewButton.setAttribute('data-code-id', codeId);
-                               previewButton.setAttribute('title', 'Preview code');
-                               previewButton.setAttribute('type', 'button');
-
-                               previewButton.innerHTML = `
-                                        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye lucide-eye-icon"><path d="M2.062 12.345a1 1 0 0 1 0-.69C3.5 7.73 7.36 5 12 5s8.5 2.73 9.938 6.655a1 1 0 0 1 0 .69C20.5 16.27 16.64 19 12 19s-8.5-2.73-9.938-6.655"/><circle cx="12" cy="12" r="3"/></svg>
-                                `;
-
-                               actions.appendChild(previewButton);
-                       }
-
-                       header.appendChild(languageLabel);
-                       header.appendChild(actions);
-                       wrapper.appendChild(header);
-
-                       const clonedPre = pre.cloneNode(true) as HTMLElement;
-                       wrapper.appendChild(clonedPre);
-
-                       pre.parentNode?.replaceChild(wrapper, pre);
-               }
+       /**
+        * Removes this component's highlight.js theme style from the document head.
+        * Called on component destroy to clean up injected styles.
+        */
+       function cleanupHighlightTheme() {
+               if (!browser) return;
 
-               return mutated ? tempDiv.innerHTML : html;
+               const existingTheme = document.getElementById(themeStyleId);
+               existingTheme?.remove();
        }
 
-       async function processMarkdown(text: string): Promise<string> {
-               try {
-                       let normalized = preprocessLaTeX(text);
-                       const result = await processor().process(normalized);
-                       const html = String(result);
-                       const enhancedLinks = enhanceLinks(html);
+       /**
+        * Loads the appropriate highlight.js theme based on dark/light mode.
+        * Injects a scoped style element into the document head.
+        * @param isDark - Whether to load the dark theme (true) or light theme (false)
+        */
+       function loadHighlightTheme(isDark: boolean) {
+               if (!browser) return;
 
-                       return enhanceCodeBlocks(enhancedLinks);
-               } catch (error) {
-                       console.error('Markdown processing error:', error);
+               const existingTheme = document.getElementById(themeStyleId);
+               existingTheme?.remove();
 
-                       // Fallback to plain text with line breaks
-                       return text.replace(/\n/g, '<br>');
-               }
+               const style = document.createElement('style');
+               style.id = themeStyleId;
+               style.textContent = isDark ? githubDarkCss : githubLightCss;
+
+               document.head.appendChild(style);
        }
 
+       /**
+        * Extracts code information from a button click target within a code block.
+        * @param target - The clicked button element
+        * @returns Object with rawCode and language, or null if extraction fails
+        */
        function getCodeInfoFromTarget(target: HTMLElement) {
                const wrapper = target.closest('.code-block-wrapper');
 
                        return null;
                }
 
-               const rawCode = codeElement.getAttribute('data-raw-code');
-
-               if (rawCode === null) {
-                       console.error('No raw code found');
-                       return null;
-               }
+               const rawCode = codeElement.textContent ?? '';
 
                const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
                const language = languageLabel?.textContent?.trim() || 'text';
                return { rawCode, language };
        }
 
+       /**
+        * Generates a unique identifier for a HAST node based on its position.
+        * Used for stable block identification during incremental rendering.
+        * @param node - The HAST root content node
+        * @param indexFallback - Fallback index if position is unavailable
+        * @returns Unique string identifier for the node
+        */
+       function getHastNodeId(node: HastRootContent, indexFallback: number): string {
+               const position = node.position;
+
+               if (position?.start?.offset != null && position?.end?.offset != null) {
+                       return `hast-${position.start.offset}-${position.end.offset}`;
+               }
+
+               return `${node.type}-${indexFallback}`;
+       }
+
+       /**
+        * Handles click events on copy buttons within code blocks.
+        * Copies the raw code content to the clipboard.
+        * @param event - The click event from the copy button
+        */
        async function handleCopyClick(event: Event) {
                event.preventDefault();
                event.stopPropagation();
                }
        }
 
+       /**
+        * Handles preview dialog open state changes.
+        * Clears preview content when dialog is closed.
+        * @param open - Whether the dialog is being opened or closed
+        */
+       function handlePreviewDialogOpenChange(open: boolean) {
+               previewDialogOpen = open;
+
+               if (!open) {
+                       previewCode = '';
+                       previewLanguage = 'text';
+               }
+       }
+
+       /**
+        * Handles click events on preview buttons within HTML code blocks.
+        * Opens a preview dialog with the rendered HTML content.
+        * @param event - The click event from the preview button
+        */
        function handlePreviewClick(event: Event) {
                event.preventDefault();
                event.stopPropagation();
                previewDialogOpen = true;
        }
 
+       /**
+        * Processes markdown content into stable and unstable HTML blocks.
+        * Uses incremental rendering: stable blocks are cached, unstable block is re-rendered.
+        * @param markdown - The raw markdown string to process
+        */
+       async function processMarkdown(markdown: string) {
+               if (!markdown) {
+                       renderedBlocks = [];
+                       unstableBlockHtml = '';
+                       return;
+               }
+
+               const normalized = preprocessLaTeX(markdown);
+               const processorInstance = processor();
+               const ast = processorInstance.parse(normalized) as MdastRoot;
+               const processedRoot = (await processorInstance.run(ast)) as HastRoot;
+               const processedChildren = processedRoot.children ?? [];
+               const stableCount = Math.max(processedChildren.length - 1, 0);
+               const nextBlocks: MarkdownBlock[] = [];
+
+               for (let index = 0; index < stableCount; index++) {
+                       const hastChild = processedChildren[index];
+                       const id = getHastNodeId(hastChild, index);
+                       const existing = renderedBlocks[index];
+
+                       if (existing && existing.id === id) {
+                               nextBlocks.push(existing);
+                               continue;
+                       }
+
+                       const html = stringifyProcessedNode(
+                               processorInstance,
+                               processedRoot,
+                               processedChildren[index]
+                       );
+
+                       nextBlocks.push({ id, html });
+               }
+
+               let unstableHtml = '';
+
+               if (processedChildren.length > stableCount) {
+                       const unstableChild = processedChildren[stableCount];
+                       unstableHtml = stringifyProcessedNode(processorInstance, processedRoot, unstableChild);
+               }
+
+               renderedBlocks = nextBlocks;
+               await tick(); // Force DOM sync before updating unstable HTML block
+               unstableBlockHtml = unstableHtml;
+       }
+
+       /**
+        * Attaches click event listeners to copy and preview buttons in code blocks.
+        * Uses data-listener-bound attribute to prevent duplicate bindings.
+        */
        function setupCodeBlockActions() {
                if (!containerRef) return;
 
                }
        }
 
-       function handlePreviewDialogOpenChange(open: boolean) {
-               previewDialogOpen = open;
+       /**
+        * Converts a single HAST node to an enhanced HTML string.
+        * Applies link and code block enhancements to the output.
+        * @param processorInstance - The remark/rehype processor instance
+        * @param processedRoot - The full processed HAST root (for context)
+        * @param child - The specific HAST child node to stringify
+        * @returns Enhanced HTML string representation of the node
+        */
+       function stringifyProcessedNode(
+               processorInstance: ReturnType<typeof processor>,
+               processedRoot: HastRoot,
+               child: unknown
+       ) {
+               const root: HastRoot = {
+                       ...(processedRoot as HastRoot),
+                       children: [child as never]
+               };
+
+               return processorInstance.stringify(root);
+       }
+
+       /**
+        * Queues markdown for processing with coalescing support.
+        * Only processes the latest markdown when multiple updates arrive quickly.
+        * @param markdown - The markdown content to render
+        */
+       async function updateRenderedBlocks(markdown: string) {
+               pendingMarkdown = markdown;
+
+               if (isProcessing) {
+                       return;
+               }
 
-               if (!open) {
-                       previewCode = '';
-                       previewLanguage = 'text';
+               isProcessing = true;
+
+               try {
+                       while (pendingMarkdown !== null) {
+                               const nextMarkdown = pendingMarkdown;
+                               pendingMarkdown = null;
+
+                               await processMarkdown(nextMarkdown);
+                       }
+               } catch (error) {
+                       console.error('Failed to process markdown:', error);
+                       renderedBlocks = [];
+                       unstableBlockHtml = markdown.replace(/\n/g, '<br>');
+               } finally {
+                       isProcessing = false;
                }
        }
 
        $effect(() => {
-               if (content) {
-                       processMarkdown(content)
-                               .then((result) => {
-                                       processedHtml = result;
-                               })
-                               .catch((error) => {
-                                       console.error('Failed to process markdown:', error);
-                                       processedHtml = content.replace(/\n/g, '<br>');
-                               });
-               } else {
-                       processedHtml = '';
-               }
+               const currentMode = mode.current;
+               const isDark = currentMode === 'dark';
+
+               loadHighlightTheme(isDark);
        });
 
        $effect(() => {
-               if (containerRef && processedHtml) {
+               updateRenderedBlocks(content);
+       });
+
+       $effect(() => {
+               const hasRenderedBlocks = renderedBlocks.length > 0;
+               const hasUnstableBlock = Boolean(unstableBlockHtml);
+
+               if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
                        setupCodeBlockActions();
                }
        });
+
+       onDestroy(() => {
+               cleanupEventListeners();
+               cleanupHighlightTheme();
+       });
 </script>
 
 <div bind:this={containerRef} class={className}>
-       <!-- eslint-disable-next-line no-at-html-tags -->
-       {@html processedHtml}
+       {#each renderedBlocks as block (block.id)}
+               <div class="markdown-block" data-block-id={block.id}>
+                       <!-- eslint-disable-next-line no-at-html-tags -->
+                       {@html block.html}
+               </div>
+       {/each}
+
+       {#if unstableBlockHtml}
+               <div class="markdown-block markdown-block--unstable" data-block-id="unstable">
+                       <!-- eslint-disable-next-line no-at-html-tags -->
+                       {@html unstableBlockHtml}
+               </div>
+       {/if}
 </div>
 
 <CodePreviewDialog
 />
 
 <style>
+       .markdown-block,
+       .markdown-block--unstable {
+               display: contents;
+       }
+
        /* Base typography styles */
        div :global(p:not(:last-child)) {
                margin-bottom: 1rem;
diff --git a/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts b/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts
new file mode 100644 (file)
index 0000000..6f0e03e
--- /dev/null
@@ -0,0 +1,162 @@
+/**
+ * Rehype plugin to enhance code blocks with wrapper, header, and action buttons.
+ *
+ * Wraps <pre><code> elements with a container that includes:
+ * - Language label
+ * - Copy button
+ * - Preview button (for HTML code blocks)
+ *
+ * This operates directly on the HAST tree for better performance,
+ * avoiding the need to stringify and re-parse HTML.
+ */
+
+import type { Plugin } from 'unified';
+import type { Root, Element, ElementContent } from 'hast';
+import { visit } from 'unist-util-visit';
+
+declare global {
+       interface Window {
+               idxCodeBlock?: number;
+       }
+}
+
+const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
+
+const PREVIEW_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye lucide-eye-icon"><path d="M2.062 12.345a1 1 0 0 1 0-.69C3.5 7.73 7.36 5 12 5s8.5 2.73 9.938 6.655a1 1 0 0 1 0 .69C20.5 16.27 16.64 19 12 19s-8.5-2.73-9.938-6.655"/><circle cx="12" cy="12" r="3"/></svg>`;
+
+/**
+ * Creates an SVG element node from raw SVG string.
+ * Since we can't parse HTML in HAST directly, we use the raw property.
+ */
+function createRawHtmlElement(html: string): Element {
+       return {
+               type: 'element',
+               tagName: 'span',
+               properties: {},
+               children: [{ type: 'raw', value: html } as unknown as ElementContent]
+       };
+}
+
+function createCopyButton(codeId: string): Element {
+       return {
+               type: 'element',
+               tagName: 'button',
+               properties: {
+                       className: ['copy-code-btn'],
+                       'data-code-id': codeId,
+                       title: 'Copy code',
+                       type: 'button'
+               },
+               children: [createRawHtmlElement(COPY_ICON_SVG)]
+       };
+}
+
+function createPreviewButton(codeId: string): Element {
+       return {
+               type: 'element',
+               tagName: 'button',
+               properties: {
+                       className: ['preview-code-btn'],
+                       'data-code-id': codeId,
+                       title: 'Preview code',
+                       type: 'button'
+               },
+               children: [createRawHtmlElement(PREVIEW_ICON_SVG)]
+       };
+}
+
+function createHeader(language: string, codeId: string): Element {
+       const actions: Element[] = [createCopyButton(codeId)];
+
+       if (language.toLowerCase() === 'html') {
+               actions.push(createPreviewButton(codeId));
+       }
+
+       return {
+               type: 'element',
+               tagName: 'div',
+               properties: { className: ['code-block-header'] },
+               children: [
+                       {
+                               type: 'element',
+                               tagName: 'span',
+                               properties: { className: ['code-language'] },
+                               children: [{ type: 'text', value: language }]
+                       },
+                       {
+                               type: 'element',
+                               tagName: 'div',
+                               properties: { className: ['code-block-actions'] },
+                               children: actions
+                       }
+               ]
+       };
+}
+
+function createWrapper(header: Element, preElement: Element): Element {
+       return {
+               type: 'element',
+               tagName: 'div',
+               properties: { className: ['code-block-wrapper'] },
+               children: [header, preElement]
+       };
+}
+
+function extractLanguage(codeElement: Element): string {
+       const className = codeElement.properties?.className;
+       if (!Array.isArray(className)) return 'text';
+
+       for (const cls of className) {
+               if (typeof cls === 'string' && cls.startsWith('language-')) {
+                       return cls.replace('language-', '');
+               }
+       }
+
+       return 'text';
+}
+
+/**
+ * Generates a unique code block ID using a global counter.
+ */
+function generateCodeId(): string {
+       if (typeof window !== 'undefined') {
+               return `code-${(window.idxCodeBlock = (window.idxCodeBlock ?? 0) + 1)}`;
+       }
+       // Fallback for SSR - use timestamp + random
+       return `code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+}
+
+/**
+ * Rehype plugin to enhance code blocks with wrapper, header, and action buttons.
+ * This plugin wraps <pre><code> elements with a container that includes:
+ * - Language label
+ * - Copy button
+ * - Preview button (for HTML code blocks)
+ */
+export const rehypeEnhanceCodeBlocks: 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 language = extractLanguage(codeElement);
+                       const codeId = generateCodeId();
+
+                       codeElement.properties = {
+                               ...codeElement.properties,
+                               'data-code-id': codeId
+                       };
+
+                       const header = createHeader(language, codeId);
+                       const wrapper = createWrapper(header, node);
+
+                       // Replace pre with wrapper in parent
+                       (parent.children as ElementContent[])[index] = wrapper;
+               });
+       };
+};
diff --git a/tools/server/webui/src/lib/markdown/enhance-links.ts b/tools/server/webui/src/lib/markdown/enhance-links.ts
new file mode 100644 (file)
index 0000000..b5fbcbd
--- /dev/null
@@ -0,0 +1,33 @@
+/**
+ * Rehype plugin to enhance links with security attributes.
+ *
+ * Adds target="_blank" and rel="noopener noreferrer" to all anchor elements,
+ * ensuring external links open in new tabs safely.
+ */
+
+import type { Plugin } from 'unified';
+import type { Root, Element } from 'hast';
+import { visit } from 'unist-util-visit';
+
+/**
+ * Rehype plugin that adds security attributes to all links.
+ * This plugin ensures external links open in new tabs safely by adding:
+ * - target="_blank"
+ * - rel="noopener noreferrer"
+ */
+export const rehypeEnhanceLinks: Plugin<[], Root> = () => {
+       return (tree: Root) => {
+               visit(tree, 'element', (node: Element) => {
+                       if (node.tagName !== 'a') return;
+
+                       const props = node.properties ?? {};
+
+                       // Only modify if href exists
+                       if (!props.href) return;
+
+                       props.target = '_blank';
+                       props.rel = 'noopener noreferrer';
+                       node.properties = props;
+               });
+       };
+};