{asciiText}
- {source}
-
- {code}
-
- )}
- {children}
; - }, - thead({ children, ...props }) { - return {children}; - }, - tbody({ children, ...props }) { - return {children}; - }, - tr({ children, ...props }) { - return{children}; - }, - pre({ children, ...props }) { - const child = React.Children.only(children) as React.ReactElement<{ className?: string; children?: React.ReactNode }>; - const className = child.props.className; - const language = getCodeLanguage(className); - const code = normalizeCodeBlockText(extractCodeText(child.props.children).replace(/\n$/, ''), language); - if (language === 'mermaid') { - return
- {children}
-
- );
- },
- a({ href, children, ...props }) {
- const targetHref = href ?? '';
- const agentName = parseAgentHref(targetHref);
- if (agentName) {
- return (
- event.stopPropagation()}
- >
- {children}
-
- );
- }
-
- const skillName = parseSkillHref(targetHref);
- if (skillName) {
- return (
- event.stopPropagation()}
- >
- {children}
-
- );
- }
-
- const isExternal = isExternalHttpUrl(targetHref);
- const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
- return (
- <>
-
- {isExternal ? ([\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 => {
+ 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(' highlighted);
+ } catch {
+ // Leave the original (escaped, sanitized) 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, ''');
+
+export const fallbackHtml = (markdown: string): string =>
+ escapeHtml(markdown).replace(/\r\n?/g, '\n').replace(/\n/g, '
');
+
+// ---------------------------------------------------------------------------
+// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
+// ---------------------------------------------------------------------------
+
+const CACHE_MAX = 240;
+const htmlCache = new Map();
+
+// 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 => {
+ 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 => {
+ 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 };
+ }),
+ );
+};
diff --git a/packages/ui/src/components/chat/markdown/markdownTheme.ts b/packages/ui/src/components/chat/markdown/markdownTheme.ts
new file mode 100644
index 00000000..fbdd370a
--- /dev/null
+++ b/packages/ui/src/components/chat/markdown/markdownTheme.ts
@@ -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 => {
+ 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,
+ };
+};