perf(markdown): rewrite rendering on marked + Shiki + morphdom

Replace the react-markdown/Prism component tree with an HTML-string
pipeline (marked -> KaTeX -> Shiki -> DOMPurify -> DOM decorators) patched
into the DOM via morphdom, with per-block reconciliation and a paced
streaming reveal. Cuts streaming CPU versus the previous renderer while
preserving file-reference links, mermaid diagrams, table export, and
agent/skill/favicon link handling. Public MarkdownRenderer/
SimpleMarkdownRenderer props are unchanged (drop-in).
This commit is contained in:
Bohdan Triapitsyn
2026-06-15 18:17:47 +03:00
parent 7290c4aa51
commit 4d506fba4e
5 changed files with 1237 additions and 1171 deletions
@@ -1,9 +1,9 @@
import React from 'react';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
// Thin lazy wrapper around the heavy MarkdownRenderer implementation.
// The full implementation (marked, react-markdown, beautiful-mermaid,
// react-syntax-highlighter, etc.) is loaded on demand, keeping the
// Thin lazy wrapper around the MarkdownRenderer implementation.
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom
// DOM morphing, plus beautiful-mermaid) is loaded on demand, keeping the
// initial bundle lean.
export type { MarkdownVariant } from './MarkdownRendererImpl';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,448 @@
import { copyTextToClipboard } from '@/lib/clipboard';
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl } from '@/lib/url';
import { dropdownMenuItemClass, dropdownMenuPopupClass } from '@/components/ui/dropdown-menu.styles';
// ---------------------------------------------------------------------------
// Shared decoration context
// ---------------------------------------------------------------------------
export type MermaidRender = { svg?: string; ascii?: string };
export type DecorateLabels = {
copy: string;
copied: string;
copyTable: string;
downloadTable: string;
copyDiagram: string;
downloadDiagram: string;
previewLabel: string;
previewTitle: string;
};
export type DecorateContext = {
labels: DecorateLabels;
// Renders a mermaid block source to svg/ascii using current theme colors.
renderMermaid: (source: string) => MermaidRender;
onPreviewLoopback?: (url: string) => void;
};
// Reference the app's icon sprite (injected into <body> by the shared Icon
// component) so DOM-built controls use the same themed icons as the rest of
// the app. Sprite symbols are registered under `#oc-<name>`.
const spriteIcon = (name: string): string =>
`<svg class="remixicon size-3.5" viewBox="0 0 24 24" aria-hidden="true"><use href="#oc-${name}"></use></svg>`;
const ICONS = {
copy: spriteIcon('file-copy'),
check: spriteIcon('check'),
download: spriteIcon('download'),
} as const;
const ICON_BTN_CLASS =
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors';
const setHtml = (el: Element, html: string): void => {
el.innerHTML = html;
};
const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string): HTMLButtonElement => {
const button = document.createElement('button');
button.type = 'button';
button.className = ICON_BTN_CLASS;
button.setAttribute('data-md-action', slot);
button.setAttribute('title', title);
button.setAttribute('aria-label', title);
setHtml(button, ICONS[icon]);
return button;
};
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
setHtml(button, ICONS.check);
button.setAttribute('title', copiedTitle);
window.setTimeout(() => {
setHtml(button, ICONS[restore]);
button.setAttribute('title', restoreTitle);
}, 2000);
};
// ---------------------------------------------------------------------------
// Code blocks: inline-code marker + copy button wrapper
// ---------------------------------------------------------------------------
const decorateInlineCode = (root: HTMLElement): void => {
const inline = root.querySelectorAll<HTMLElement>(':not(pre) > code');
for (const code of Array.from(inline)) {
if (code.getAttribute('data-markdown') !== 'inline-code') {
code.setAttribute('data-markdown', 'inline-code');
}
}
};
const decorateCodeBlocks = (root: HTMLElement, labels: DecorateLabels): void => {
const blocks = root.querySelectorAll<HTMLPreElement>('pre');
for (const pre of Array.from(blocks)) {
// Skip mermaid placeholders (handled separately).
if (pre.querySelector('code.language-mermaid')) continue;
const parent = pre.parentElement;
if (!parent) continue;
// Already wrapped (idempotent across morphdom passes).
if (parent.closest('[data-component="markdown-code"]')) continue;
const language = pre.getAttribute('data-md-lang') ?? 'text';
const wrapper = document.createElement('div');
wrapper.setAttribute('data-component', 'markdown-code');
wrapper.className =
'my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]';
const header = document.createElement('div');
header.className = 'flex items-center justify-between border-b border-border/70 px-3 py-1.5';
const langLabel = document.createElement('span');
langLabel.className = 'font-mono text-[13px] text-muted-foreground';
langLabel.textContent = language;
const copyBtn = makeIconButton('copy', labels.copy, 'copy-code');
header.appendChild(langLabel);
header.appendChild(copyBtn);
const body = document.createElement('div');
body.className = 'px-3 py-2.5 overflow-x-auto';
parent.replaceChild(wrapper, pre);
pre.style.margin = '0';
pre.style.background = 'transparent';
body.appendChild(pre);
wrapper.appendChild(header);
wrapper.appendChild(body);
}
};
// ---------------------------------------------------------------------------
// Tables: wrapper + copy/download toolbars
// ---------------------------------------------------------------------------
const extractTableData = (table: HTMLTableElement): { headers: string[]; rows: string[][] } => {
const headers: string[] = [];
const rows: string[][] = [];
const headerCells = table.querySelectorAll('thead th');
for (const cell of Array.from(headerCells)) headers.push((cell.textContent ?? '').trim());
const bodyRows = table.querySelectorAll('tbody tr');
for (const row of Array.from(bodyRows)) {
const cells = Array.from(row.querySelectorAll('td')).map((c) => (c.textContent ?? '').trim());
if (cells.length > 0) rows.push(cells);
}
return { headers, rows };
};
const escapeCsv = (value: string): string =>
/[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
export const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string =>
[headers, ...rows].map((row) => row.map(escapeCsv).join(',')).join('\n');
export const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string =>
[headers, ...rows].map((row) => row.join('\t')).join('\n');
export const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
const head = `| ${headers.join(' | ')} |`;
const sep = `| ${headers.map(() => '---').join(' | ')} |`;
const body = rows.map((row) => `| ${row.join(' | ')} |`).join('\n');
return `${head}\n${sep}\n${body}`;
};
const buildTableMenu = (action: string, items: Array<{ key: string; label: string }>): HTMLDivElement => {
const menu = document.createElement('div');
// Match the app's DropdownMenu look (same class tokens + surface colors).
menu.className = `absolute top-full right-0 mt-1 hidden ${dropdownMenuPopupClass}`;
menu.style.backgroundColor = 'var(--surface-elevated)';
menu.style.color = 'var(--surface-elevated-foreground)';
menu.setAttribute('data-md-menu', action);
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.className = `w-full text-left ${dropdownMenuItemClass}`;
button.setAttribute('data-md-action', `${action}-${item.key}`);
button.textContent = item.label;
menu.appendChild(button);
}
return menu;
};
const decorateTables = (root: HTMLElement, labels: DecorateLabels): void => {
const tables = root.querySelectorAll<HTMLTableElement>('table');
for (const table of Array.from(tables)) {
const existing = table.closest('[data-markdown="table-wrapper"]');
if (existing) continue;
const wrapper = document.createElement('div');
wrapper.className = 'group my-4 flex flex-col space-y-2';
wrapper.setAttribute('data-markdown', 'table-wrapper');
const toolbar = document.createElement('div');
toolbar.className = 'flex items-center justify-end gap-1';
const copyGroup = document.createElement('div');
copyGroup.className = 'relative';
copyGroup.appendChild(makeIconButton('copy', labels.copyTable, 'table-copy-toggle'));
copyGroup.appendChild(buildTableMenu('table-copy', [
{ key: 'csv', label: 'CSV' },
{ key: 'tsv', label: 'TSV' },
{ key: 'markdown', label: 'Markdown' },
]));
const downloadGroup = document.createElement('div');
downloadGroup.className = 'relative';
downloadGroup.appendChild(makeIconButton('download', labels.downloadTable, 'table-download-toggle'));
downloadGroup.appendChild(buildTableMenu('table-download', [
{ key: 'csv', label: 'CSV' },
{ key: 'markdown', label: 'Markdown' },
]));
toolbar.appendChild(copyGroup);
toolbar.appendChild(downloadGroup);
const scroll = document.createElement('div');
scroll.className = 'overflow-x-auto rounded-lg border border-border/80 bg-[var(--surface-elevated)]';
const parent = table.parentElement;
if (!parent) continue;
parent.replaceChild(wrapper, table);
table.setAttribute('data-markdown', 'table');
table.classList.add('w-full', 'border-collapse', 'text-sm');
for (const tr of Array.from(table.querySelectorAll('tr'))) {
tr.classList.add('border-b', 'border-border/60');
}
const lastBodyRow = table.querySelector('tbody tr:last-child');
lastBodyRow?.classList.remove('border-b');
lastBodyRow?.classList.add('border-0');
for (const th of Array.from(table.querySelectorAll('th'))) {
th.classList.add('border-r', 'border-border/60', 'px-4', 'py-2.5', 'text-left', 'align-middle', 'font-semibold', 'text-foreground', 'last:border-r-0');
}
for (const td of Array.from(table.querySelectorAll('td'))) {
td.classList.add('border-r', 'border-border/60', 'px-4', 'py-2.5', 'align-middle', 'text-foreground/90', 'last:border-r-0');
}
scroll.appendChild(table);
wrapper.appendChild(toolbar);
wrapper.appendChild(scroll);
}
};
// ---------------------------------------------------------------------------
// Mermaid: replace ```mermaid code fences with rendered diagram blocks
// ---------------------------------------------------------------------------
const decorateMermaid = (root: HTMLElement, ctx: DecorateContext): void => {
const codes = root.querySelectorAll<HTMLElement>('pre > code.language-mermaid');
for (const code of Array.from(codes)) {
const pre = code.parentElement as HTMLPreElement | null;
if (!pre) continue;
const source = (code.textContent ?? '').replace(/\s+$/, '');
const rendered = ctx.renderMermaid(source);
const block = document.createElement('div');
block.setAttribute('data-markdown', 'mermaid-block');
block.className = 'group relative';
const scroll = document.createElement('div');
scroll.setAttribute('data-markdown', 'mermaid-scroll');
const toolbar = document.createElement('div');
toolbar.className = 'absolute top-1 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity';
if (rendered.svg) {
const svgHost = document.createElement('div');
svgHost.setAttribute('data-markdown', 'mermaid');
setHtml(svgHost, rendered.svg);
scroll.appendChild(svgHost);
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
copy.setAttribute('data-md-source', source);
const download = makeIconButton('download', ctx.labels.downloadDiagram, 'mermaid-download');
download.setAttribute('data-md-svg', '1');
toolbar.appendChild(copy);
toolbar.appendChild(download);
} else {
const asciiPre = document.createElement('pre');
asciiPre.setAttribute('data-markdown', 'mermaid-ascii');
asciiPre.textContent = rendered.ascii || source;
scroll.appendChild(asciiPre);
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
copy.setAttribute('data-md-source', rendered.ascii || source);
toolbar.appendChild(copy);
}
block.appendChild(scroll);
block.appendChild(toolbar);
const host = pre.parentElement;
if (!host) continue;
host.replaceChild(block, pre);
}
};
// ---------------------------------------------------------------------------
// External links: favicon + loopback preview button
// ---------------------------------------------------------------------------
const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
const anchors = root.querySelectorAll<HTMLAnchorElement>('a[href]');
for (const anchor of Array.from(anchors)) {
if (anchor.getAttribute('data-md-link-decorated') === 'true') continue;
if (anchor.getAttribute('data-openchamber-file-link') === 'true') continue;
const href = anchor.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) continue;
anchor.setAttribute('data-md-link-decorated', 'true');
const faviconUrl = getExternalFaviconUrl(href);
if (faviconUrl) {
const favWrap = document.createElement('span');
favWrap.className =
'mr-1 inline-flex size-[18px] items-center justify-center rounded border border-[var(--border)] bg-[var(--interactive-hover)] align-middle';
const img = document.createElement('img');
img.src = faviconUrl;
img.alt = '';
img.setAttribute('aria-hidden', 'true');
img.loading = 'lazy';
img.decoding = 'async';
img.className = 'size-3.5 rounded-sm';
img.addEventListener('error', () => favWrap.remove(), { once: true });
favWrap.appendChild(img);
anchor.parentNode?.insertBefore(favWrap, anchor);
}
if (ctx.onPreviewLoopback && isLoopbackHttpUrl(href)) {
const preview = document.createElement('button');
preview.type = 'button';
preview.className = `ml-1 align-middle ${ICON_BTN_CLASS}`;
preview.setAttribute('data-md-action', 'preview-loopback');
preview.setAttribute('data-md-url', href);
preview.setAttribute('title', ctx.labels.previewTitle);
preview.setAttribute('aria-label', ctx.labels.previewLabel);
setHtml(preview, ICONS.download);
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
}
}
};
/** Run all idempotent DOM decoration passes over freshly-rendered markdown. */
export const decorateMarkdown = (root: HTMLElement, ctx: DecorateContext): void => {
decorateInlineCode(root);
decorateMermaid(root, ctx);
decorateCodeBlocks(root, ctx.labels);
decorateTables(root, ctx.labels);
decorateLinks(root, ctx);
};
// ---------------------------------------------------------------------------
// Delegated interactions (copy/download/menus/preview)
// ---------------------------------------------------------------------------
const downloadBlob = (filename: string, content: string, mime: string): void => {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const closeAllMenus = (container: HTMLElement): void => {
for (const menu of Array.from(container.querySelectorAll<HTMLElement>('[data-md-menu]'))) {
menu.classList.add('hidden');
}
};
/**
* Attach a single delegated click listener for all in-markdown actions: code
* copy, table copy/download menus, mermaid copy/download, loopback preview.
* Returns a cleanup function.
*/
export const attachMarkdownInteractions = (
container: HTMLElement,
ctx: DecorateContext,
): (() => void) => {
const handleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const actionEl = target.closest<HTMLElement>('[data-md-action]');
if (!actionEl) {
closeAllMenus(container);
return;
}
const action = actionEl.getAttribute('data-md-action') ?? '';
// Copy code
if (action === 'copy-code') {
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
const text = code?.textContent ?? '';
if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy));
return;
}
// Toggle table menus
if (action === 'table-copy-toggle' || action === 'table-download-toggle') {
event.preventDefault();
const menu = actionEl.parentElement?.querySelector<HTMLElement>('[data-md-menu]') ?? null;
const willOpen = menu?.classList.contains('hidden') ?? false;
closeAllMenus(container);
if (menu && willOpen) menu.classList.remove('hidden');
return;
}
// Table copy formats
if (action.startsWith('table-copy-')) {
const format = action.replace('table-copy-', '');
const table = actionEl.closest('[data-markdown="table-wrapper"]')?.querySelector('table');
if (table instanceof HTMLTableElement) {
const data = extractTableData(table);
const content = format === 'csv' ? tableToCSV(data) : format === 'tsv' ? tableToTSV(data) : tableToMarkdown(data);
void copyTextToClipboard(content);
}
closeAllMenus(container);
return;
}
// Table download formats
if (action.startsWith('table-download-')) {
const format = action.replace('table-download-', '');
const table = actionEl.closest('[data-markdown="table-wrapper"]')?.querySelector('table');
if (table instanceof HTMLTableElement) {
const data = extractTableData(table);
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
downloadBlob(format === 'csv' ? 'table.csv' : 'table.md', content, format === 'csv' ? 'text/csv' : 'text/markdown');
}
closeAllMenus(container);
return;
}
// Mermaid copy source / ascii
if (action === 'mermaid-copy') {
const source = actionEl.getAttribute('data-md-source') ?? '';
if (source) void copyTextToClipboard(source).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copyDiagram));
return;
}
// Mermaid download svg
if (action === 'mermaid-download') {
const svgHost = actionEl.closest('[data-markdown="mermaid-block"]')?.querySelector('[data-markdown="mermaid"]');
const svg = svgHost?.innerHTML ?? '';
if (svg) downloadBlob('diagram.svg', svg, 'image/svg+xml;charset=utf-8');
return;
}
// Loopback preview
if (action === 'preview-loopback') {
event.preventDefault();
const url = actionEl.getAttribute('data-md-url') ?? '';
if (url) ctx.onPreviewLoopback?.(url);
return;
}
};
container.addEventListener('click', handleClick);
return () => container.removeEventListener('click', handleClick);
};
@@ -0,0 +1,362 @@
import { marked, type Tokens } from 'marked';
import remend from 'remend';
import katex from 'katex';
import DOMPurify from 'dompurify';
import {
getSharedHighlighter,
type DiffsThemeNames,
type SupportedLanguages,
} from '@pierre/diffs';
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
import { isVSCodeRuntime } from '@/lib/desktop';
import { ensureMarkdownShikiTheme, MARKDOWN_SHIKI_THEME } from './markdownTheme';
const escapeAttr = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// ---------------------------------------------------------------------------
// Streaming block segmentation (port of OpenCode's markdown-stream)
// ---------------------------------------------------------------------------
export type MarkdownBlock = {
raw: string;
src: string;
mode: 'full' | 'live';
// When false, skip syntax highlighting for this block. Set for the actively
// streaming open code fence so we don't re-tokenize a growing block ~40x/sec
// (O(n^2)); it highlights once the fence closes and becomes a stable block.
highlight: boolean;
};
const hasReferenceDefinitions = (text: string): boolean =>
/^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text);
// Returns true when `raw` opens a fenced code block whose closing fence has not
// arrived yet — meaning the block is still streaming and must be rendered as
// raw text, not parsed.
const hasOpenFence = (raw: string): boolean => {
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/);
if (!match) return false;
const mark = match[1];
if (!mark) return false;
const char = mark[0];
const size = mark.length;
const last = raw.trimEnd().split('\n').at(-1)?.trim() ?? '';
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
};
const heal = (text: string): string => {
try {
return remend(text, { linkMode: 'text-only' });
} catch {
return text;
}
};
/**
* Split markdown into render blocks. When not streaming, returns a single
* `full` block. While streaming, heals incomplete syntax and isolates an
* unclosed trailing code fence into its own `live` block so a partial fence
* does not corrupt the parse of stable content above it.
*/
export const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
if (!live) return [{ raw: text, src: text, mode: 'full', highlight: true }];
// Reference-style links/footnotes span multiple tokens (definition elsewhere);
// keep them as a single block so per-block parsing doesn't break the refs.
if (hasReferenceDefinitions(text)) {
return [{ raw: text, src: heal(text), mode: 'live', highlight: true }];
}
let tokens: Tokens.Generic[];
try {
tokens = marked.lexer(text) as Tokens.Generic[];
} catch {
return [{ raw: text, src: heal(text), mode: 'live', highlight: true }];
}
let tail = -1;
for (let i = tokens.length - 1; i >= 0; i -= 1) {
if (tokens[i]?.type !== 'space') {
tail = i;
break;
}
}
if (tail < 0) return [{ raw: text, src: heal(text), mode: 'live', highlight: true }];
// Split into per-token blocks. Stable leading blocks become `full` (complete,
// cache-stable, not re-healed); only the trailing block is `live` and gets
// re-parsed as content streams in. This keeps per-step work proportional to
// the last block rather than the whole message.
const blocks: MarkdownBlock[] = [];
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (!token || token.type === 'space') continue;
const raw = token.raw ?? '';
const isLast = i === tail;
const openFence = token.type === 'code' && hasOpenFence(raw);
blocks.push({
raw,
src: openFence ? raw : heal(raw),
mode: isLast ? 'live' : 'full',
highlight: !openFence,
});
}
if (blocks.length === 0) {
return [{ raw: text, src: heal(text), mode: 'live', highlight: true }];
}
return blocks;
};
// ---------------------------------------------------------------------------
// marked parser (HTML string output) with safe external links
// ---------------------------------------------------------------------------
const parser = marked.use({
gfm: true,
breaks: false,
renderer: {
link({ href, title, text }) {
const target = href ?? '';
const agentName = parseAgentHref(target);
if (agentName) {
return `<a href="${escapeAttr(buildAgentMentionUrl(agentName))}" data-openchamber-agent-mention="true" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`;
}
const skillName = parseSkillHref(target);
if (skillName) {
return `<a href="${escapeAttr(target)}" data-skill-name="${escapeAttr(skillName)}" class="text-primary hover:underline">${text}</a>`;
}
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
},
},
});
// ---------------------------------------------------------------------------
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
// ---------------------------------------------------------------------------
const renderMathInText = (text: string): string => {
let result = text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, math: string) => {
try {
return katex.renderToString(math, { displayMode: true, throwOnError: false });
} catch {
return `$$${math}$$`;
}
});
result = result.replace(/(?<!\$)\$(?!\$)((?:[^$\\]|\\.)+?)\$(?!\$)/g, (_match, math: string) => {
try {
return katex.renderToString(math, { displayMode: false, throwOnError: false });
} catch {
return `$${math}$`;
}
});
return result;
};
const renderMathExpressions = (html: string): string => {
const codeBlockPattern = /(<(?:pre|code|kbd)[^>]*>[\s\S]*?<\/(?:pre|code|kbd)>)/gi;
return html
.split(codeBlockPattern)
.map((part, index) => (index % 2 === 1 ? part : renderMathInText(part)))
.join('');
};
// ---------------------------------------------------------------------------
// Syntax highlighting (Shiki via @pierre/diffs shared highlighter)
// ---------------------------------------------------------------------------
const CODE_BLOCK_RE = /<pre><code(?:\s+class="language-([^"]*)")?>([\s\S]*?)<\/code><\/pre>/g;
// Skip syntax highlighting for very large blocks — tokenizing thousands of
// lines blocks the main thread. Plain (escaped) code is shown instead.
const CODE_HIGHLIGHT_LINE_LIMIT = 1200;
const VSCODE_CODE_HIGHLIGHT_LINE_LIMIT = 200;
const exceedsLineLimit = (value: string, limit: number): boolean => {
let lines = 1;
for (let i = 0; i < value.length; i += 1) {
if (value.charCodeAt(i) === 10 && ++lines > limit) return true;
}
return false;
};
const unescapeHtml = (value: string): string =>
value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
const highlightCodeBlocks = async (html: string): Promise<string> => {
const matches = [...html.matchAll(CODE_BLOCK_RE)];
if (matches.length === 0) return html;
ensureMarkdownShikiTheme();
const highlighter = await getSharedHighlighter({
themes: [MARKDOWN_SHIKI_THEME as DiffsThemeNames],
langs: [],
preferredHighlighter: 'shiki-wasm',
});
const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT;
let result = html;
for (const match of matches) {
const [full, rawLang, escapedCode] = match;
const requested = (rawLang || 'text').toLowerCase();
// Leave mermaid fences untouched so the decorate pass can render them as
// diagrams (highlighting would strip the `language-mermaid` class).
if (requested === 'mermaid') continue;
const code = unescapeHtml(escapedCode ?? '');
// Oversized block: skip highlight, keep plain code but stamp the language.
if (exceedsLineLimit(code, lineLimit)) {
result = result.replace(full, () => full.replace('<pre', `<pre data-md-lang="${requested}"`));
continue;
}
let lang = requested;
if (lang !== 'text' && !highlighter.getLoadedLanguages().includes(lang)) {
try {
await highlighter.loadLanguage(lang as SupportedLanguages);
} catch {
lang = 'text';
}
}
try {
const highlighted = highlighter
.codeToHtml(code, {
lang: lang as SupportedLanguages,
theme: MARKDOWN_SHIKI_THEME as DiffsThemeNames,
tabindex: false,
})
// Stamp the language so the decorate pass can show a header label.
.replace(/^<pre/, `<pre data-md-lang="${requested}"`);
result = result.replace(full, () => highlighted);
} catch {
// Leave the original (escaped, sanitized) <pre><code> in place.
}
}
return result;
};
// ---------------------------------------------------------------------------
// Sanitization (DOMPurify) — allow Shiki/KaTeX/SVG output
// ---------------------------------------------------------------------------
const SANITIZE_CONFIG = {
USE_PROFILES: { html: true, mathMl: true, svg: true },
ADD_TAGS: ['svg', 'path', 'g', 'rect', 'line', 'polygon', 'polyline', 'circle', 'ellipse', 'text', 'tspan', 'defs', 'marker'],
ADD_ATTR: ['d', 'viewBox', 'preserveAspectRatio', 'xmlns', 'target', 'fill', 'stroke', 'stroke-width', 'transform', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'style'],
FORBID_TAGS: ['script'],
FORBID_CONTENTS: ['script'],
};
let sanitizeHookInstalled = false;
const ensureSanitizeHook = (): void => {
if (sanitizeHookInstalled) return;
if (typeof window === 'undefined' || !DOMPurify.isSupported) return;
sanitizeHookInstalled = true;
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (!(node instanceof HTMLAnchorElement)) return;
if (node.target !== '_blank') return;
node.setAttribute('rel', 'noopener noreferrer');
});
};
const sanitize = (html: string): string => {
if (!DOMPurify.isSupported) return '';
ensureSanitizeHook();
return DOMPurify.sanitize(html, SANITIZE_CONFIG) as unknown as string;
};
const escapeHtml = (text: string): string =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
export const fallbackHtml = (markdown: string): string =>
escapeHtml(markdown).replace(/\r\n?/g, '\n').replace(/\n/g, '<br>');
// ---------------------------------------------------------------------------
// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
// ---------------------------------------------------------------------------
const CACHE_MAX = 240;
const htmlCache = new Map<string, { hash: string; html: string }>();
// FNV-1a 32-bit hash of the block content.
const hash = (value: string): string => {
let h = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(36);
};
const touch = (key: string, entry: { hash: string; html: string }): void => {
htmlCache.delete(key);
htmlCache.set(key, entry);
if (htmlCache.size <= CACHE_MAX) return;
const oldest = htmlCache.keys().next().value;
if (oldest) htmlCache.delete(oldest);
};
const parseBlock = async (block: MarkdownBlock): Promise<string> => {
const parsed = await Promise.resolve(parser.parse(block.src));
const withMath = renderMathExpressions(parsed);
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
return sanitize(highlighted);
};
export type RenderedBlock = {
// Stable identity across renders for per-block DOM reconciliation. Encodes
// content + mode + highlight so any change forces that block (and only that
// block) to re-morph; unchanged leading blocks are skipped entirely.
id: string;
html: string;
};
/**
* Render markdown into an array of per-block sanitized HTML. Streaming-aware:
* splits into blocks, caches per-block, heals incomplete syntax. Returning
* blocks (instead of one joined string) lets the renderer re-morph only the
* block that changed, keeping per-step streaming cost ~O(last block).
*/
export const renderMarkdownBlocks = async (
text: string,
streaming: boolean,
cacheKey: string,
): Promise<RenderedBlock[]> => {
if (!text) return [];
const blocks = streamBlocks(text, streaming);
return Promise.all(
blocks.map(async (block, index) => {
const contentHash = hash(block.raw);
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}`;
const key = `${cacheKey}:${index}:${block.mode}`;
const cached = htmlCache.get(key);
if (cached && cached.hash === contentHash) {
touch(key, cached);
return { id, html: cached.html };
}
const html = await parseBlock(block);
touch(key, { hash: contentHash, html });
return { id, html };
}),
);
};
@@ -0,0 +1,133 @@
import { registerCustomTheme, type ThemeRegistrationResolved } from '@pierre/diffs';
import type { Theme } from '@/types/theme';
// Name of the static Shiki theme we register once. Its token colors reference
// CSS custom properties (`--md-syntax-*`) instead of concrete colors, so a
// highlighted code block does NOT need to be re-tokenized when the app theme
// changes — only the CSS variables on the markdown container update, and the
// browser repaints. This mirrors OpenCode's `var(--syntax-*)` theme approach
// and keeps highlighting results cacheable across theme switches.
export const MARKDOWN_SHIKI_THEME = 'openchamber-md';
let registered = false;
/**
* Register the static, CSS-variable-driven Shiki theme. Safe to call multiple
* times; only the first call registers.
*/
export const ensureMarkdownShikiTheme = (): void => {
if (registered) return;
registered = true;
registerCustomTheme(MARKDOWN_SHIKI_THEME, () =>
Promise.resolve({
name: MARKDOWN_SHIKI_THEME,
colors: {
'editor.background': 'transparent',
'editor.foreground': 'var(--md-syntax-foreground)',
},
tokenColors: [
{
scope: ['comment', 'punctuation.definition.comment', 'string.comment'],
settings: { foreground: 'var(--md-syntax-comment)', fontStyle: 'italic' },
},
{
scope: ['string', 'punctuation.definition.string', 'string.template'],
settings: { foreground: 'var(--md-syntax-string)' },
},
{
scope: ['constant.numeric', 'constant.language', 'constant.character', 'constant'],
settings: { foreground: 'var(--md-syntax-number)' },
},
{
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier', 'keyword.control'],
settings: { foreground: 'var(--md-syntax-keyword)' },
},
{
scope: ['keyword.operator', 'punctuation.separator', 'punctuation.terminator'],
settings: { foreground: 'var(--md-syntax-operator)' },
},
{
scope: ['entity.name.function', 'support.function', 'meta.function-call'],
settings: { foreground: 'var(--md-syntax-function)' },
},
{
scope: [
'entity.name.type',
'entity.name.class',
'support.type',
'support.class',
'entity.other.inherited-class',
],
settings: { foreground: 'var(--md-syntax-type)' },
},
{
scope: ['variable', 'variable.other', 'variable.parameter', 'meta.definition.variable'],
settings: { foreground: 'var(--md-syntax-variable)' },
},
{
scope: ['variable.other.property', 'meta.property-name', 'support.type.property-name'],
settings: { foreground: 'var(--md-syntax-property)' },
},
{
scope: ['entity.name.tag', 'punctuation.definition.tag'],
settings: { foreground: 'var(--md-syntax-keyword)' },
},
{
scope: ['entity.other.attribute-name'],
settings: { foreground: 'var(--md-syntax-property)' },
},
{
scope: ['markup.bold', 'punctuation.definition.bold'],
settings: { fontStyle: 'bold' },
},
{
scope: ['markup.italic', 'punctuation.definition.italic'],
settings: { fontStyle: 'italic' },
},
{
scope: ['markup.heading', 'markup.heading entity.name'],
settings: { foreground: 'var(--md-syntax-keyword)', fontStyle: 'bold' },
},
{
scope: ['markup.inserted', 'punctuation.definition.inserted'],
settings: { foreground: 'var(--md-syntax-inserted)' },
},
{
scope: ['markup.deleted', 'punctuation.definition.deleted'],
settings: { foreground: 'var(--md-syntax-deleted)' },
},
{
scope: ['invalid', 'invalid.illegal'],
settings: { foreground: 'var(--md-syntax-deleted)' },
},
],
} as unknown as ThemeRegistrationResolved),
);
};
/**
* Build the `--md-syntax-*` CSS custom properties for the given app theme.
* Apply the result as inline styles on the markdown container so the static
* Shiki theme resolves to the active palette.
*/
export const getMarkdownSyntaxVars = (theme: Theme): Record<string, string> => {
const base = theme.colors.syntax.base;
const tokens = theme.colors.syntax.tokens ?? {};
const status = theme.colors.status;
return {
'--md-syntax-foreground': base.foreground,
'--md-syntax-comment': base.comment,
'--md-syntax-string': base.string,
'--md-syntax-number': base.number,
'--md-syntax-keyword': base.keyword,
'--md-syntax-operator': base.operator,
'--md-syntax-function': base.function,
'--md-syntax-type': base.type,
'--md-syntax-variable': base.variable,
'--md-syntax-property': tokens.variableProperty ?? base.variable,
'--md-syntax-inserted': status.success,
'--md-syntax-deleted': status.error,
};
};