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:
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user