diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz index 2ff90e800a..a0cadebfe8 100644 Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ diff --git a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte index 2a4a39535e..0bf8764584 100644 --- a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte +++ b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte @@ -7,15 +7,18 @@ 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 } from 'svelte'; + import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer'; + 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 { v4 as uuid } from 'uuid'; import CodePreviewDialog from './CodePreviewDialog.svelte'; interface Props { @@ -23,33 +26,24 @@ class?: string; } + interface MarkdownBlock { + id: string; + html: string; + } + let { content, class: className = '' }: Props = $props(); let containerRef = $state(); - let processedHtml = $state(''); + let renderedBlocks = $state([]); + let unstableBlockHtml = $state(''); let previewDialogOpen = $state(false); let previewCode = $state(''); let previewLanguage = $state('text'); - function loadHighlightTheme(isDark: boolean) { - if (!browser) return; + let pendingMarkdown: string | null = null; + let isProcessing = false; - const existingThemes = document.querySelectorAll('style[data-highlight-theme]'); - existingThemes.forEach((style) => style.remove()); - - 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-${uuid()}`; let processor = $derived(() => { return remark() @@ -64,136 +58,174 @@ .use(rehypeStringify); // Convert to HTML string }); - function enhanceLinks(html: string): string { - if (!html.includes('('.copy-code-btn'); + const previewButtons = containerRef.querySelectorAll('.preview-code-btn'); + + for (const button of copyButtons) { + button.removeEventListener('click', handleCopyClick); } - 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'); - - if (target !== '_blank' || rel !== 'noopener noreferrer') { - mutated = true; - } - - link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener noreferrer'); + for (const button of previewButtons) { + button.removeEventListener('click', handlePreviewClick); } - - return mutated ? tempDiv.innerHTML : html; } + /** + * 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; + + const existingTheme = document.getElementById(themeStyleId); + existingTheme?.remove(); + } + + /** + * Enhances code blocks with wrapper, header, language label, and action buttons. + * Adds copy button to all code blocks and preview button to HTML blocks. + * @param html - The HTML string containing code blocks to enhance + * @returns Enhanced HTML string with wrapped code blocks + */ function enhanceCodeBlocks(html: string): string { - if (!html.includes(' { + const preElements = tempDiv.querySelectorAll('pre'); + let mutated = false; - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; + for (const [index, pre] of Array.from(preElements).entries()) { + const codeElement = pre.querySelector('code'); - const preElements = tempDiv.querySelectorAll('pre'); - let mutated = false; + if (!codeElement) { + continue; + } - for (const [index, pre] of Array.from(preElements).entries()) { - const codeElement = pre.querySelector('code'); + mutated = true; - if (!codeElement) { - continue; + 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-${uuid()}-${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 = ` + +`; + + 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 = ` + +`; + + 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); } - mutated = true; + return mutated; + }); + } - let language = 'text'; - const classList = Array.from(codeElement.classList); + /** + * Enhances links to open in new tabs with security attributes. + * Sets target="_blank" and rel="noopener noreferrer" on all anchor elements. + * @param html - The HTML string containing links to enhance + * @returns Enhanced HTML string with modified link attributes + */ + function enhanceLinks(html: string): string { + return processHtml(html, ' { + const linkElements = tempDiv.querySelectorAll('a[href]'); + let mutated = false; - for (const className of classList) { - if (className.startsWith('language-')) { - language = className.replace('language-', ''); - break; + for (const link of linkElements) { + const target = link.getAttribute('target'); + const rel = link.getAttribute('rel'); + + // Only mutate if attributes need to change + if (target !== '_blank' || rel !== 'noopener noreferrer') { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + mutated = true; } } - 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 = ` - - `; - - 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 = ` - - `; - - 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); - } - - return mutated ? tempDiv.innerHTML : html; + return mutated; + }); } - async function processMarkdown(text: string): Promise { - 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, '
'); - } + 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'); @@ -222,6 +254,28 @@ 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(); @@ -245,6 +299,25 @@ } } + /** + * 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(); @@ -266,6 +339,81 @@ previewDialogOpen = true; } + /** + * Helper to process HTML with a temporary DOM element. + * Returns original HTML if not in browser or tag not found. + */ + function processHtml( + html: string, + tagCheck: string, + processor: (tempDiv: HTMLDivElement) => boolean + ): string { + if (!browser || !html.includes(tagCheck)) { + return html; + } + + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const mutated = processor(tempDiv); + + return mutated ? tempDiv.innerHTML : html; + } + + /** + * 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; + 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; @@ -287,40 +435,99 @@ } } - 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, + processedRoot: HastRoot, + child: unknown + ) { + const root: HastRoot = { + ...(processedRoot as HastRoot), + children: [child as never] + }; - if (!open) { - previewCode = ''; - previewLanguage = 'text'; + const html = processorInstance.stringify(root); + + return enhanceCodeBlocks(enhanceLinks(html)); + } + + /** + * 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; + } + + 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, '
'); + } 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, '
'); - }); - } 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(); + });
- - {@html processedHtml} + {#each renderedBlocks as block (block.id)} +
+ + {@html block.html} +
+ {/each} + + {#if unstableBlockHtml} +
+ + {@html unstableBlockHtml} +
+ {/if}