From bb4dd82e9ebc75cecc9f8bca0d845aff9f325ae3 Mon Sep 17 00:00:00 2001 From: Pascal Date: Tue, 9 Dec 2025 21:50:01 +0100 Subject: [PATCH 1/7] draft: incremental markdown rendering with stable blocks --- .../app/misc/MarkdownContent.svelte | 185 ++++++++++++++---- 1 file changed, 143 insertions(+), 42 deletions(-) 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..3bdf7d9f06 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,16 @@ 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 } from 'hast'; + import type { Root as MdastRoot, RootContent } from 'mdast'; import { browser } from '$app/environment'; + 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 CodePreviewDialog from './CodePreviewDialog.svelte'; interface Props { @@ -26,7 +27,8 @@ 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'); @@ -64,8 +66,13 @@ .use(rehypeStringify); // Convert to HTML string }); + type MarkdownBlock = { + id: string; + html: string; + }; + function enhanceLinks(html: string): string { - if (!html.includes(' - `; + +`; const actions = document.createElement('div'); actions.className = 'code-block-actions'; @@ -159,8 +166,8 @@ previewButton.setAttribute('type', 'button'); previewButton.innerHTML = ` - - `; + +`; actions.appendChild(previewButton); } @@ -178,22 +185,6 @@ return mutated ? tempDiv.innerHTML : html; } - 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); - - return enhanceCodeBlocks(enhancedLinks); - } catch (error) { - console.error('Markdown processing error:', error); - - // Fallback to plain text with line breaks - return text.replace(/\n/g, '
'); - } - } - function getCodeInfoFromTarget(target: HTMLElement) { const wrapper = target.closest('.code-block-wrapper'); @@ -296,31 +287,136 @@ } } - $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 = ''; + function getNodeId(node: RootContent, indexFallback: number): string { + const position = node.position; + + if (position?.start?.offset != null && position?.end?.offset != null) { + return `${position.start.offset}-${position.end.offset}`; } + + return `${node.type}-${indexFallback}`; + } + + function stringifyProcessedNode( + processorInstance: ReturnType, + processedRoot: HastRoot, + child: unknown + ) { + const root: HastRoot = { + ...(processedRoot as HastRoot), + children: [child as never] + }; + + const html = processorInstance.stringify(root); + + return enhanceCodeBlocks(enhanceLinks(html)); + } + + let pendingMarkdown: string | null = null; + let isProcessing = false; + + 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 children = ast.children ?? []; + const nodeIds = children.map((node, index) => getNodeId(node as RootContent, index)); + + const processedRoot = (await processorInstance.run(ast)) as HastRoot; + const processedChildren = processedRoot.children ?? []; + const stableCount = Math.max(processedChildren.length - 1, 0); + const availableStable = Math.min(stableCount, processedChildren.length); + + const nextBlocks: MarkdownBlock[] = []; + + for (let index = 0; index < availableStable; index++) { + const id = nodeIds[index] ?? `processed-${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 > availableStable) { + const unstableChild = processedChildren[availableStable]; + unstableHtml = stringifyProcessedNode(processorInstance, processedRoot, unstableChild); + } + + renderedBlocks = nextBlocks; + unstableBlockHtml = unstableHtml; + } + + 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(() => { + updateRenderedBlocks(content); }); $effect(() => { - if (containerRef && processedHtml) { + const hasRenderedBlocks = renderedBlocks.length > 0; + const hasUnstableBlock = Boolean(unstableBlockHtml); + + if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) { setupCodeBlockActions(); } });
- - {@html processedHtml} + {#each renderedBlocks as block (block.id)} +
+ + {@html block.html} +
+ {/each} + + {#if unstableBlockHtml} +
+ + {@html unstableBlockHtml} +
+ {/if}