perf(code): replace react-syntax-highlighter and prismjs with the Shiki worker
Route all non-markdown code highlighting through the off-main-thread Shiki worker, removing react-syntax-highlighter and prismjs entirely. - Extend the worker with highlightLines: tokenize a whole block once and return per-line inner HTML, so per-line layouts (diffs, gutters, virtualization) make one worker call instead of one highlighter per line. - Add shared WorkerHighlightedCode (whole-block) and useWorkerHighlightedLines (per-line) primitives. Colors resolve via the --md-syntax-* CSS variables, so theme changes never re-highlight. - Migrate all 12 react-syntax-highlighter call sites: PermissionCard, ToolPart, ContextSidebarTab, ToolOutputDialog (whole block) and DiffPreview/WritePreview (per line). - Migrate VirtualizedCodeBlock off prismjs to the worker, keeping virtua virtualization; whole-block tokenization also restores cross-line syntax context that per-line highlighting lost. - Drop react-syntax-highlighter (+types) from ui and web, prismjs (+types) from ui, and the orphaned create-element type shim.
This commit is contained in:
@@ -1,9 +1,31 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { bundledLanguages, createHighlighter, type BundledLanguage } from 'shiki';
|
||||
import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki';
|
||||
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
|
||||
import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
||||
|
||||
// Shiki FontStyle bitmask (from @shikijs/types). Inlined to avoid an extra import.
|
||||
const FONT_STYLE_ITALIC = 1;
|
||||
const FONT_STYLE_BOLD = 2;
|
||||
const FONT_STYLE_UNDERLINE = 4;
|
||||
|
||||
const escapeHtml = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
const tokenSpan = (token: ThemedToken): string => {
|
||||
const styles: string[] = [];
|
||||
if (token.color) styles.push(`color:${token.color}`);
|
||||
const fontStyle = token.fontStyle ?? 0;
|
||||
if (fontStyle & FONT_STYLE_ITALIC) styles.push('font-style:italic');
|
||||
if (fontStyle & FONT_STYLE_BOLD) styles.push('font-weight:bold');
|
||||
if (fontStyle & FONT_STYLE_UNDERLINE) styles.push('text-decoration:underline');
|
||||
const style = styles.length ? ` style="${styles.join(';')}"` : '';
|
||||
return `<span${style}>${escapeHtml(token.content)}</span>`;
|
||||
};
|
||||
|
||||
// Single shared highlighter for the worker. Languages load lazily on demand.
|
||||
let highlighter: ReturnType<typeof createHighlighter> | undefined;
|
||||
|
||||
@@ -25,20 +47,31 @@ self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
|
||||
ensureHighlighter();
|
||||
return;
|
||||
}
|
||||
queue = queue.then(() => highlight(request)).catch(() => {});
|
||||
if (request.type === 'highlight') {
|
||||
queue = queue.then(() => highlight(request)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
queue = queue.then(() => highlightLines(request)).catch(() => {});
|
||||
};
|
||||
|
||||
type Instance = Awaited<ReturnType<typeof createHighlighter>>;
|
||||
|
||||
const resolveLanguage = async (instance: Instance, requested: string): Promise<string> => {
|
||||
let lang = requested in bundledLanguages ? requested : 'text';
|
||||
if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) {
|
||||
try {
|
||||
await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]);
|
||||
} catch {
|
||||
lang = 'text';
|
||||
}
|
||||
}
|
||||
return lang;
|
||||
};
|
||||
|
||||
async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highlight' }>): Promise<void> {
|
||||
try {
|
||||
const instance = await ensureHighlighter();
|
||||
let lang = request.lang in bundledLanguages ? request.lang : 'text';
|
||||
if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) {
|
||||
try {
|
||||
await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]);
|
||||
} catch {
|
||||
lang = 'text';
|
||||
}
|
||||
}
|
||||
const lang = await resolveLanguage(instance, request.lang);
|
||||
const html = instance.codeToHtml(request.code, {
|
||||
lang,
|
||||
theme: MARKDOWN_SHIKI_THEME,
|
||||
@@ -50,6 +83,21 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highli
|
||||
}
|
||||
}
|
||||
|
||||
async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'highlightLines' }>): Promise<void> {
|
||||
try {
|
||||
const instance = await ensureHighlighter();
|
||||
const lang = await resolveLanguage(instance, request.lang);
|
||||
const { tokens } = instance.codeToTokens(request.code, {
|
||||
lang: lang as BundledLanguage,
|
||||
theme: MARKDOWN_SHIKI_THEME,
|
||||
});
|
||||
const lines = tokens.map((line) => line.map(tokenSpan).join(''));
|
||||
post({ type: 'highlightLines', id: request.id, lines });
|
||||
} catch (error) {
|
||||
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function post(response: MarkdownWorkerResponse): void {
|
||||
self.postMessage(response);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
|
||||
export type MarkdownWorkerRequest =
|
||||
| { type: 'init' }
|
||||
| { type: 'highlight'; id: number; code: string; lang: string };
|
||||
// Highlight a whole block to ready-to-splice Shiki `<pre>` HTML.
|
||||
| { type: 'highlight'; id: number; code: string; lang: string }
|
||||
// Highlight a whole block but return per-line inner HTML (one entry per line),
|
||||
// so per-line layouts (diffs, gutters, virtualization) tokenize in ONE call
|
||||
// instead of one worker round-trip per line.
|
||||
| { type: 'highlightLines'; id: number; code: string; lang: string };
|
||||
|
||||
export type MarkdownWorkerResponse =
|
||||
| { type: 'highlight'; id: number; html: string }
|
||||
| { type: 'highlightLines'; id: number; lines: string[] }
|
||||
| { type: 'error'; id: number; message: string };
|
||||
|
||||
@@ -7,9 +7,11 @@ import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-w
|
||||
// tokenization error) the promise resolves to `null` and the caller keeps the
|
||||
// escaped plain-text code — highlighting never falls back onto the main thread.
|
||||
|
||||
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
|
||||
|
||||
let worker: Worker | undefined;
|
||||
let nextId = 0;
|
||||
const pending = new Map<number, (html: string | null) => void>();
|
||||
const pending = new Map<number, PendingResolver>();
|
||||
|
||||
const failAll = (): void => {
|
||||
pending.forEach((resolve) => resolve(null));
|
||||
@@ -30,7 +32,7 @@ const getWorker = (): Worker | undefined => {
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (!resolve) return;
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data.type === 'highlight' ? event.data.html : null);
|
||||
resolve(event.data);
|
||||
};
|
||||
worker.onerror = failAll;
|
||||
worker.onmessageerror = failAll;
|
||||
@@ -38,16 +40,31 @@ const getWorker = (): Worker | undefined => {
|
||||
return worker;
|
||||
};
|
||||
|
||||
const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
|
||||
const instance = getWorker();
|
||||
if (!instance) return Promise.resolve(null);
|
||||
const id = ++nextId;
|
||||
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
instance.postMessage(payload(id));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight a complete code block in the worker. Resolves to Shiki `<pre>` HTML,
|
||||
* or `null` if highlighting is unavailable or failed (caller keeps plain code).
|
||||
*/
|
||||
export const highlightCodeInWorker = (code: string, lang: string): Promise<string | null> => {
|
||||
const instance = getWorker();
|
||||
if (!instance) return Promise.resolve(null);
|
||||
const id = ++nextId;
|
||||
return new Promise<string | null>((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
instance.postMessage({ type: 'highlight', id, code, lang } satisfies MarkdownWorkerRequest);
|
||||
});
|
||||
export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
|
||||
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
|
||||
return response?.type === 'highlight' ? response.html : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight a whole block and return per-line inner HTML (one entry per source
|
||||
* line). For per-line layouts (diffs, gutters, virtualization) — one worker
|
||||
* round-trip instead of one per line. Resolves to `null` on failure.
|
||||
*/
|
||||
export const highlightLinesInWorker = async (code: string, lang: string): Promise<string[] | null> => {
|
||||
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
||||
return response?.type === 'highlightLines' ? response.lines : null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user