draft: incremental markdown rendering with stable blocks

This commit is contained in:
Pascal 2025-12-09 21:50:01 +01:00 committed by Aleksander Grygier
parent 3034836d36
commit bb4dd82e9e
1 changed files with 143 additions and 42 deletions

View File

@ -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<HTMLDivElement>();
let processedHtml = $state('');
let renderedBlocks = $state<MarkdownBlock[]>([]);
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('<a')) {
if (!browser || !html.includes('<a')) {
return html;
}
@ -92,7 +99,7 @@
}
function enhanceCodeBlocks(html: string): string {
if (!html.includes('<pre')) {
if (!browser || !html.includes('<pre')) {
return html;
}
@ -178,22 +185,6 @@
return mutated ? tempDiv.innerHTML : html;
}
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);
return enhanceCodeBlocks(enhancedLinks);
} catch (error) {
console.error('Markdown processing error:', error);
// Fallback to plain text with line breaks
return text.replace(/\n/g, '<br>');
}
}
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, '<br>');
});
} 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<typeof processor>,
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, '<br>');
} finally {
isProcessing = false;
}
}
$effect(() => {
updateRenderedBlocks(content);
});
$effect(() => {
if (containerRef && processedHtml) {
const hasRenderedBlocks = renderedBlocks.length > 0;
const hasUnstableBlock = Boolean(unstableBlockHtml);
if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
setupCodeBlockActions();
}
});
</script>
<div bind:this={containerRef} class={className}>
{#each renderedBlocks as block (block.id)}
<div class="markdown-block" data-block-id={block.id}>
<!-- eslint-disable-next-line no-at-html-tags -->
{@html processedHtml}
{@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
@ -331,6 +427,11 @@
/>
<style>
.markdown-block,
.markdown-block--unstable {
display: contents;
}
/* Base typography styles */
div :global(p:not(:last-child)) {
margin-bottom: 1rem;