2026-06-15 23:06:54 +03:00
|
|
|
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
|
2026-08-24 20:02:59 +03:00
|
|
|
import { isVSCodeRuntime } from '@/stores/utils/vscodeRuntime';
|
2026-08-10 13:50:20 +00:00
|
|
|
import {
|
2026-08-11 13:23:43 +00:00
|
|
|
contentFingerprint,
|
|
|
|
|
estimateTokenRunsBytes,
|
2026-08-10 13:50:20 +00:00
|
|
|
HighlightResultCache,
|
2026-08-11 13:23:43 +00:00
|
|
|
utf16Bytes,
|
2026-08-10 13:50:20 +00:00
|
|
|
} from './highlightResultCache';
|
2026-06-16 00:58:00 +03:00
|
|
|
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
2026-06-15 23:06:54 +03:00
|
|
|
|
|
|
|
|
// Main-thread client for the markdown Shiki worker. Moves syntax tokenization
|
|
|
|
|
// off the UI thread: a closed code block is shipped to the worker, which returns
|
|
|
|
|
// ready-to-splice Shiki HTML. On any failure (no worker support, worker crash,
|
|
|
|
|
// tokenization error) the promise resolves to `null` and the caller keeps the
|
|
|
|
|
// escaped plain-text code — highlighting never falls back onto the main thread.
|
2026-08-10 13:50:20 +00:00
|
|
|
//
|
2026-08-11 13:23:43 +00:00
|
|
|
// Results are memoized by content fingerprint (+ lang / theme). Unchanged
|
|
|
|
|
// content must not re-enter the worker — that was the sustained ~40 msg/s
|
|
|
|
|
// re-highlight load in openchamber/openchamber#2769. In-flight requests with
|
|
|
|
|
// the same key coalesce so remount storms share one round-trip. Cache keys are
|
|
|
|
|
// fingerprints (not full source) so large files are not duplicated in the Map.
|
2026-08-17 16:45:53 +03:00
|
|
|
//
|
|
|
|
|
// This module is the only sender to the worker, so memoizing here is sufficient
|
|
|
|
|
// and the worker itself stays stateless apart from the Shiki instance. A second
|
|
|
|
|
// cache inside the worker would only duplicate these payloads in another heap.
|
|
|
|
|
//
|
|
|
|
|
// `highlight` / `highlightLines` results are theme-independent: the worker
|
|
|
|
|
// tokenizes with the CSS-variable `MARKDOWN_SHIKI_THEME`, so a theme switch
|
|
|
|
|
// repaints via CSS and must not invalidate these entries. Only
|
|
|
|
|
// `highlightTokens` resolves concrete colors, so only its key carries a theme.
|
2026-06-15 23:06:54 +03:00
|
|
|
|
2026-06-15 23:37:24 +03:00
|
|
|
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
|
|
|
|
|
|
2026-08-10 13:50:20 +00:00
|
|
|
type CachedHighlight =
|
|
|
|
|
| { type: 'highlight'; html: string }
|
|
|
|
|
| { type: 'highlightLines'; lines: string[] }
|
|
|
|
|
| { type: 'highlightTokens'; lines: MarkdownTokenRun[][] };
|
|
|
|
|
|
|
|
|
|
const CLIENT_CACHE_MAX_ENTRIES = 2000;
|
|
|
|
|
const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
|
|
|
|
|
2026-08-11 13:23:43 +00:00
|
|
|
const resultCache = new HighlightResultCache<CachedHighlight>({
|
|
|
|
|
maxEntries: CLIENT_CACHE_MAX_ENTRIES,
|
|
|
|
|
maxBytes: CLIENT_CACHE_MAX_BYTES,
|
|
|
|
|
});
|
2026-08-10 13:50:20 +00:00
|
|
|
|
|
|
|
|
const inflight = new Map<string, Promise<CachedHighlight | null>>();
|
|
|
|
|
|
2026-06-15 23:06:54 +03:00
|
|
|
let worker: Worker | undefined;
|
2026-08-24 20:02:59 +03:00
|
|
|
let workerCreation: Promise<Worker | undefined> | undefined;
|
|
|
|
|
let workerObjectUrl: string | undefined;
|
2026-06-15 23:06:54 +03:00
|
|
|
let nextId = 0;
|
2026-06-15 23:37:24 +03:00
|
|
|
const pending = new Map<number, PendingResolver>();
|
2026-06-16 00:58:00 +03:00
|
|
|
// Theme names whose full definition we've already shipped to the live worker, so
|
|
|
|
|
// repeat tokenization sends only the name (not the whole theme object) again.
|
|
|
|
|
const sentThemes = new Set<string>();
|
2026-06-15 23:06:54 +03:00
|
|
|
|
2026-08-11 13:23:43 +00:00
|
|
|
const entryBytes = (key: string, value: CachedHighlight): number => {
|
|
|
|
|
const keyBytes = utf16Bytes(key);
|
|
|
|
|
if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html);
|
|
|
|
|
if (value.type === 'highlightLines') {
|
|
|
|
|
let total = keyBytes;
|
|
|
|
|
for (const line of value.lines) total += utf16Bytes(line);
|
|
|
|
|
return total;
|
|
|
|
|
}
|
|
|
|
|
return keyBytes + estimateTokenRunsBytes(value.lines);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-15 23:06:54 +03:00
|
|
|
const failAll = (): void => {
|
|
|
|
|
pending.forEach((resolve) => resolve(null));
|
|
|
|
|
pending.clear();
|
2026-06-16 00:58:00 +03:00
|
|
|
sentThemes.clear();
|
2026-08-10 13:50:20 +00:00
|
|
|
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
|
|
|
|
|
inflight.clear();
|
2026-06-15 23:06:54 +03:00
|
|
|
worker?.terminate();
|
|
|
|
|
worker = undefined;
|
2026-08-24 20:02:59 +03:00
|
|
|
workerCreation = undefined;
|
|
|
|
|
if (workerObjectUrl) {
|
|
|
|
|
URL.revokeObjectURL(workerObjectUrl);
|
|
|
|
|
workerObjectUrl = undefined;
|
|
|
|
|
}
|
2026-06-15 23:06:54 +03:00
|
|
|
};
|
|
|
|
|
|
2026-08-24 20:02:59 +03:00
|
|
|
const createWorker = async (): Promise<Worker | undefined> => {
|
2026-06-15 23:06:54 +03:00
|
|
|
if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined;
|
|
|
|
|
try {
|
2026-08-24 20:02:59 +03:00
|
|
|
let workerUrl = MarkdownShikiWorkerUrl;
|
|
|
|
|
if (isVSCodeRuntime(null)) {
|
|
|
|
|
const response = await fetch(workerUrl);
|
|
|
|
|
if (!response.ok) throw new Error(`Shiki worker request failed with ${response.status}`);
|
|
|
|
|
workerObjectUrl = URL.createObjectURL(await response.blob());
|
|
|
|
|
workerUrl = workerObjectUrl;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const instance = new Worker(workerUrl, { type: 'module' });
|
|
|
|
|
worker = instance;
|
|
|
|
|
instance.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
|
|
|
|
const resolve = pending.get(event.data.id);
|
|
|
|
|
if (!resolve) return;
|
|
|
|
|
pending.delete(event.data.id);
|
|
|
|
|
resolve(event.data);
|
|
|
|
|
};
|
|
|
|
|
instance.onerror = failAll;
|
|
|
|
|
instance.onmessageerror = failAll;
|
|
|
|
|
instance.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
|
|
|
|
|
return instance;
|
2026-07-11 22:50:00 +11:00
|
|
|
} catch (err) {
|
2026-08-24 20:02:59 +03:00
|
|
|
if (workerObjectUrl) {
|
|
|
|
|
URL.revokeObjectURL(workerObjectUrl);
|
|
|
|
|
workerObjectUrl = undefined;
|
|
|
|
|
}
|
2026-07-11 22:50:00 +11:00
|
|
|
console.error('Failed to create Shiki worker:', err);
|
2026-06-15 23:06:54 +03:00
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-24 20:02:59 +03:00
|
|
|
const getWorker = async (): Promise<Worker | undefined> => {
|
|
|
|
|
if (worker) return worker;
|
|
|
|
|
workerCreation ??= createWorker().finally(() => {
|
|
|
|
|
workerCreation = undefined;
|
|
|
|
|
});
|
|
|
|
|
return workerCreation;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
|
|
|
|
|
const instance = await getWorker();
|
2026-06-15 23:06:54 +03:00
|
|
|
if (!instance) return Promise.resolve(null);
|
|
|
|
|
const id = ++nextId;
|
2026-06-15 23:37:24 +03:00
|
|
|
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
|
2026-06-15 23:06:54 +03:00
|
|
|
pending.set(id, resolve);
|
2026-06-15 23:37:24 +03:00
|
|
|
instance.postMessage(payload(id));
|
2026-06-15 23:06:54 +03:00
|
|
|
});
|
|
|
|
|
};
|
2026-06-15 23:37:24 +03:00
|
|
|
|
2026-08-10 13:50:20 +00:00
|
|
|
const coalesce = (
|
|
|
|
|
key: string,
|
|
|
|
|
run: () => Promise<CachedHighlight | null>,
|
|
|
|
|
): Promise<CachedHighlight | null> => {
|
|
|
|
|
const existing = inflight.get(key);
|
|
|
|
|
if (existing) return existing;
|
|
|
|
|
const pendingRequest = run().finally(() => {
|
|
|
|
|
inflight.delete(key);
|
|
|
|
|
});
|
|
|
|
|
inflight.set(key, pendingRequest);
|
|
|
|
|
return pendingRequest;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-11 13:23:43 +00:00
|
|
|
const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => {
|
|
|
|
|
const fp = contentFingerprint(code);
|
|
|
|
|
return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-10 13:50:20 +00:00
|
|
|
/** Test-only: clear client-side highlight memoization. */
|
|
|
|
|
export const resetMarkdownWorkerClientCacheForTests = (): void => {
|
|
|
|
|
resultCache.clear();
|
|
|
|
|
inflight.clear();
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-15 23:37:24 +03:00
|
|
|
/**
|
|
|
|
|
* 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 = async (code: string, lang: string): Promise<string | null> => {
|
2026-08-11 13:23:43 +00:00
|
|
|
const key = cacheKeyFor('highlight', lang, code);
|
2026-08-10 13:50:20 +00:00
|
|
|
const cached = resultCache.get(key);
|
|
|
|
|
if (cached?.type === 'highlight') return cached.html;
|
|
|
|
|
|
|
|
|
|
const result = await coalesce(key, async () => {
|
|
|
|
|
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
|
|
|
|
|
if (response?.type !== 'highlight') return null;
|
|
|
|
|
const entry: CachedHighlight = { type: 'highlight', html: response.html };
|
2026-08-11 13:23:43 +00:00
|
|
|
resultCache.set(key, entry, entryBytes(key, entry));
|
2026-08-10 13:50:20 +00:00
|
|
|
return entry;
|
|
|
|
|
});
|
|
|
|
|
return result?.type === 'highlight' ? result.html : null;
|
2026-06-15 23:37:24 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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> => {
|
2026-08-11 13:23:43 +00:00
|
|
|
const key = cacheKeyFor('highlightLines', lang, code);
|
2026-08-10 13:50:20 +00:00
|
|
|
const cached = resultCache.get(key);
|
|
|
|
|
if (cached?.type === 'highlightLines') return cached.lines;
|
|
|
|
|
|
|
|
|
|
const result = await coalesce(key, async () => {
|
|
|
|
|
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
|
|
|
|
if (response?.type !== 'highlightLines') return null;
|
|
|
|
|
const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
|
2026-08-11 13:23:43 +00:00
|
|
|
resultCache.set(key, entry, entryBytes(key, entry));
|
2026-08-10 13:50:20 +00:00
|
|
|
return entry;
|
|
|
|
|
});
|
|
|
|
|
return result?.type === 'highlightLines' ? result.lines : null;
|
2026-06-15 23:37:24 +03:00
|
|
|
};
|
2026-06-16 00:58:00 +03:00
|
|
|
|
2026-08-22 02:10:03 +03:00
|
|
|
/** Return an already-tokenized line result without scheduling a worker request. */
|
|
|
|
|
export const getCachedHighlightedLines = (code: string, lang: string): string[] | null => {
|
|
|
|
|
const cached = resultCache.get(cacheKeyFor('highlightLines', lang, code));
|
|
|
|
|
return cached?.type === 'highlightLines' ? cached.lines : null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-16 00:58:00 +03:00
|
|
|
/**
|
|
|
|
|
* Tokenize `code` with the given resolved TextMate theme and return per-line
|
|
|
|
|
* styled runs with offsets — for building CodeMirror decorations that match the
|
|
|
|
|
* Shiki file view exactly. The full theme object is shipped only the first time
|
|
|
|
|
* a theme name is seen by the live worker. Resolves to `null` on failure.
|
|
|
|
|
*/
|
|
|
|
|
export const highlightTokensInWorker = async (
|
|
|
|
|
code: string,
|
|
|
|
|
lang: string,
|
|
|
|
|
themeName: string,
|
|
|
|
|
theme: unknown,
|
|
|
|
|
): Promise<MarkdownTokenRun[][] | null> => {
|
2026-08-11 13:23:43 +00:00
|
|
|
const key = cacheKeyFor('highlightTokens', lang, code, themeName);
|
2026-08-10 13:50:20 +00:00
|
|
|
const cached = resultCache.get(key);
|
|
|
|
|
if (cached?.type === 'highlightTokens') return cached.lines;
|
|
|
|
|
|
|
|
|
|
const result = await coalesce(key, async () => {
|
|
|
|
|
const needsTheme = !sentThemes.has(themeName);
|
|
|
|
|
const response = await request((id) => ({
|
|
|
|
|
type: 'highlightTokens',
|
|
|
|
|
id,
|
|
|
|
|
code,
|
|
|
|
|
lang,
|
|
|
|
|
themeName,
|
|
|
|
|
...(needsTheme ? { theme } : {}),
|
|
|
|
|
}));
|
|
|
|
|
if (response?.type !== 'highlightTokens') return null;
|
2026-06-16 00:58:00 +03:00
|
|
|
sentThemes.add(themeName);
|
2026-08-10 13:50:20 +00:00
|
|
|
const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
|
2026-08-11 13:23:43 +00:00
|
|
|
resultCache.set(key, entry, entryBytes(key, entry));
|
2026-08-10 13:50:20 +00:00
|
|
|
return entry;
|
|
|
|
|
});
|
|
|
|
|
return result?.type === 'highlightTokens' ? result.lines : null;
|
2026-06-16 00:58:00 +03:00
|
|
|
};
|