flux-md 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,315 @@
1
+ import type { Block, FromWorker, ParserConfig, Patch, ToWorker, WorkerLike } from "./types";
2
+
3
+ /**
4
+ * The ordered-block store backing a stream, extracted as a pure function so
5
+ * its reference-stability contract is testable without a Worker.
6
+ *
7
+ * **The contract that prevents extra React re-renders:** a block, once
8
+ * committed, is never re-sent by the parser, so `applyPatch` never replaces it
9
+ * in the map. Its object reference stays identical across every later patch —
10
+ * which is exactly what `blocksEqual` (the BlockView memo) checks, so committed
11
+ * blocks never re-render (and never re-parse) as the stream grows. Only the
12
+ * `active` tail gets fresh references each patch, and only it re-renders.
13
+ */
14
+ export interface BlockStore {
15
+ committed: Map<number, Block>;
16
+ committedOrder: number[];
17
+ active: Block[];
18
+ snapshot: Block[];
19
+ }
20
+
21
+ export function emptyBlockStore(): BlockStore {
22
+ return { committed: new Map(), committedOrder: [], active: [], snapshot: [] };
23
+ }
24
+
25
+ export function applyPatch(store: BlockStore, patch: Patch): void {
26
+ for (const b of patch.newly_committed) {
27
+ if (!store.committed.has(b.id)) store.committedOrder.push(b.id);
28
+ store.committed.set(b.id, b);
29
+ }
30
+ store.active = patch.active;
31
+ // Fresh array each patch (immutable for React reference checks), but the
32
+ // committed entries inside it are the same object references as before.
33
+ const next: Block[] = new Array(store.committedOrder.length + store.active.length);
34
+ for (let i = 0; i < store.committedOrder.length; i++) {
35
+ next[i] = store.committed.get(store.committedOrder[i])!;
36
+ }
37
+ for (let i = 0; i < store.active.length; i++) {
38
+ next[store.committedOrder.length + i] = store.active[i];
39
+ }
40
+ store.snapshot = next;
41
+ }
42
+
43
+ // --------------------------------------------------------------------------
44
+ // Worker pool
45
+ // --------------------------------------------------------------------------
46
+
47
+ interface PoolWorker {
48
+ worker: WorkerLike;
49
+ ready: boolean;
50
+ streamCount: number;
51
+ readyResolvers: Array<() => void>;
52
+ }
53
+
54
+ /**
55
+ * A pool of Web Workers, each multiplexing many `FluxParser`s keyed by stream
56
+ * id. This is what lets flux-md scale past `hardwareConcurrency` concurrent
57
+ * streams without oversubscribing OS threads: 50 streams share (at most) the
58
+ * cap's worth of workers instead of spawning 50.
59
+ *
60
+ * Worker creation is **lazy and load-aware**: while under the cap, each new
61
+ * stream gets its own worker (so 1 stream = 1 worker, identical to the old
62
+ * behavior); once at the cap, new streams attach to the least-loaded worker.
63
+ *
64
+ * The constructor injects a `WorkerLike` factory so the routing and lifecycle
65
+ * logic is unit-testable with a fake worker — no real Worker or WASM needed.
66
+ */
67
+ export class FluxPool {
68
+ private workers: PoolWorker[] = [];
69
+ private handlers = new Map<number, (msg: FromWorker) => void>();
70
+ private nextStreamId = 1;
71
+
72
+ constructor(
73
+ private factory: () => WorkerLike,
74
+ private cap: number,
75
+ ) {}
76
+
77
+ /** Reserve a stream id and assign a worker, registering its message handler. */
78
+ acquire(handler: (msg: FromWorker) => void): { streamId: number; pw: PoolWorker } {
79
+ const streamId = this.nextStreamId++;
80
+ const pw = this.pick();
81
+ pw.streamCount++;
82
+ this.handlers.set(streamId, handler);
83
+ return { streamId, pw };
84
+ }
85
+
86
+ /** Free a stream's parser in its worker; keep the worker warm for siblings. */
87
+ release(streamId: number, pw: PoolWorker): void {
88
+ this.handlers.delete(streamId);
89
+ pw.streamCount = Math.max(0, pw.streamCount - 1);
90
+ try {
91
+ pw.worker.postMessage({ type: "dispose", streamId });
92
+ } catch {
93
+ /* worker already gone */
94
+ }
95
+ }
96
+
97
+ send(pw: PoolWorker, msg: ToWorker): void {
98
+ pw.worker.postMessage(msg);
99
+ }
100
+
101
+ /** Resolves when the given worker has finished WASM init. */
102
+ whenWorkerReady(pw: PoolWorker): Promise<void> {
103
+ if (pw.ready) return Promise.resolve();
104
+ return new Promise((resolve) => pw.readyResolvers.push(resolve));
105
+ }
106
+
107
+ /** Terminate every worker (test teardown / full shutdown). */
108
+ disposeAll(): void {
109
+ for (const pw of this.workers) {
110
+ try {
111
+ pw.worker.terminate();
112
+ } catch {
113
+ /* ignore */
114
+ }
115
+ }
116
+ this.workers = [];
117
+ this.handlers.clear();
118
+ }
119
+
120
+ get workerCount(): number {
121
+ return this.workers.length;
122
+ }
123
+
124
+ // Create a new worker while under cap and every existing worker is busy;
125
+ // otherwise attach to the least-loaded existing worker.
126
+ private pick(): PoolWorker {
127
+ if (this.workers.length < this.cap && this.workers.every((w) => w.streamCount > 0)) {
128
+ return this.create();
129
+ }
130
+ return this.workers.reduce((a, b) => (b.streamCount < a.streamCount ? b : a));
131
+ }
132
+
133
+ private create(): PoolWorker {
134
+ const pw: PoolWorker = { worker: this.factory(), ready: false, streamCount: 0, readyResolvers: [] };
135
+ pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
136
+ this.workers.push(pw);
137
+ return pw;
138
+ }
139
+
140
+ private onMessage(pw: PoolWorker, msg: FromWorker): void {
141
+ if (msg.type === "ready") {
142
+ pw.ready = true;
143
+ const resolvers = pw.readyResolvers;
144
+ pw.readyResolvers = [];
145
+ for (const r of resolvers) r();
146
+ return;
147
+ }
148
+ this.handlers.get(msg.streamId)?.(msg);
149
+ }
150
+ }
151
+
152
+ function poolCap(): number {
153
+ const hc = typeof navigator !== "undefined" ? navigator.hardwareConcurrency : 0;
154
+ return Math.min(hc || 4, 8);
155
+ }
156
+
157
+ let defaultPool: FluxPool | null = null;
158
+
159
+ /** The process-wide default pool every `FluxClient` shares unless given one. */
160
+ export function getDefaultPool(): FluxPool {
161
+ if (!defaultPool) {
162
+ defaultPool = new FluxPool(
163
+ () => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike,
164
+ poolCap(),
165
+ );
166
+ }
167
+ return defaultPool;
168
+ }
169
+
170
+ // --------------------------------------------------------------------------
171
+ // Client
172
+ // --------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Subscriber-driven store backing a single streaming parser. Each client owns
176
+ * one stream within a shared {@link FluxPool}; many clients multiplex over a
177
+ * small set of workers (see the pool for the scaling story).
178
+ *
179
+ * The store exposes:
180
+ * - subscribe(listener): for React's useSyncExternalStore
181
+ * - getSnapshot(): the current ordered list of blocks
182
+ * - getMetrics(): per-stream perf metrics
183
+ *
184
+ * Mutation methods:
185
+ * - append(chunk): forward to the worker
186
+ * - finalize(): mark the stream done
187
+ * - reset(): start fresh
188
+ */
189
+ export class FluxClient {
190
+ private pool: FluxPool;
191
+ private pw: PoolWorker;
192
+ private streamId: number;
193
+ private config?: ParserConfig;
194
+ private configSent = false;
195
+ private listeners = new Set<() => void>();
196
+ private store: BlockStore = emptyBlockStore();
197
+
198
+ // Perf
199
+ private appendedBytes = 0;
200
+ private patchCount = 0;
201
+ private totalParseMicros = 0;
202
+ private lastPatchMs = 0;
203
+ private firstAppendMs = 0;
204
+ private retainedBytes = 0;
205
+ private wasmMemoryBytes = 0;
206
+
207
+ /**
208
+ * @param options.pool worker pool to join (defaults to the shared
209
+ * process-wide pool — pass a dedicated `FluxPool` only for isolation).
210
+ * @param options.config per-stream parser flags (see {@link ParserConfig});
211
+ * omitted fields use library defaults. Applied once, immutable thereafter.
212
+ */
213
+ constructor(options: { pool?: FluxPool; config?: ParserConfig } = {}) {
214
+ this.pool = options.pool ?? getDefaultPool();
215
+ this.config = options.config;
216
+ const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
217
+ this.streamId = streamId;
218
+ this.pw = pw;
219
+ }
220
+
221
+ get ready(): boolean {
222
+ return this.pw.ready;
223
+ }
224
+
225
+ whenReady(): Promise<void> {
226
+ return this.pool.whenWorkerReady(this.pw);
227
+ }
228
+
229
+ // The config rides on the first message a stream sends; the worker applies it
230
+ // when it creates the parser. postMessage is FIFO per worker, so it always
231
+ // lands before any append is processed. Returns undefined after the first use.
232
+ private firstConfig(): ParserConfig | undefined {
233
+ if (this.configSent || !this.config) return undefined;
234
+ this.configSent = true;
235
+ return this.config;
236
+ }
237
+
238
+ append(chunk: string) {
239
+ if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
240
+ this.pool.send(this.pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig() });
241
+ }
242
+
243
+ finalize() {
244
+ this.pool.send(this.pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
245
+ }
246
+
247
+ reset() {
248
+ this.store = emptyBlockStore();
249
+ this.appendedBytes = 0;
250
+ this.patchCount = 0;
251
+ this.totalParseMicros = 0;
252
+ this.lastPatchMs = 0;
253
+ this.firstAppendMs = 0;
254
+ this.retainedBytes = 0;
255
+ this.wasmMemoryBytes = 0;
256
+ // Same streamId + worker — the worker frees and lazily recreates the parser.
257
+ this.pool.send(this.pw, { type: "reset", streamId: this.streamId });
258
+ this.emit();
259
+ }
260
+
261
+ destroy() {
262
+ // Free this stream's parser; the shared worker stays warm for siblings.
263
+ this.pool.release(this.streamId, this.pw);
264
+ this.listeners.clear();
265
+ }
266
+
267
+ subscribe = (fn: () => void) => {
268
+ this.listeners.add(fn);
269
+ return () => this.listeners.delete(fn);
270
+ };
271
+
272
+ getSnapshot = (): Block[] => this.store.snapshot;
273
+
274
+ getMetrics() {
275
+ const elapsed = this.firstAppendMs ? Math.max(1, performance.now() - this.firstAppendMs) : 1;
276
+ return {
277
+ bytes: this.appendedBytes,
278
+ patches: this.patchCount,
279
+ meanParseMicros: this.patchCount > 0 ? this.totalParseMicros / this.patchCount : 0,
280
+ totalParseMs: this.totalParseMicros / 1000,
281
+ throughputKBs: (this.appendedBytes / 1024) / (elapsed / 1000),
282
+ committedBlocks: this.store.committed.size,
283
+ activeBlocks: this.store.active.length,
284
+ lastPatchAgoMs: this.lastPatchMs === 0 ? 0 : performance.now() - this.lastPatchMs,
285
+ retainedBytes: this.retainedBytes,
286
+ // NOTE: with the worker pool, this is the *shared* worker's WASM heap —
287
+ // clients on the same worker report the same number. Use Math.max (not
288
+ // sum) when aggregating across clients; summing double-counts.
289
+ wasmMemoryBytes: this.wasmMemoryBytes,
290
+ };
291
+ }
292
+
293
+ private onMessage(msg: FromWorker) {
294
+ switch (msg.type) {
295
+ case "patch":
296
+ applyPatch(this.store, msg.patch);
297
+ this.appendedBytes = msg.appendedBytes;
298
+ this.totalParseMicros += msg.parseMicros;
299
+ this.retainedBytes = msg.retainedBytes;
300
+ this.wasmMemoryBytes = msg.wasmMemoryBytes;
301
+ this.patchCount += 1;
302
+ this.lastPatchMs = performance.now();
303
+ this.emit();
304
+ break;
305
+ case "error":
306
+ // eslint-disable-next-line no-console
307
+ console.error("flux worker error:", msg.message);
308
+ break;
309
+ }
310
+ }
311
+
312
+ private emit() {
313
+ for (const fn of this.listeners) fn();
314
+ }
315
+ }
package/src/hi.ts ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * In-house syntax highlighter. Native RegExp only — no Shiki, no Prism, no
3
+ * Highlight.js. Covers the languages an LLM typically emits:
4
+ * js/ts/tsx/jsx, rust, python, go, bash, json, html, css, sql. Unknown
5
+ * languages fall through to plain escaped text. ~6KB minified.
6
+ *
7
+ * Highlighting is per-block, runs once when the block closes. We never
8
+ * highlight an open (streaming) block — that's what gives us the big perf
9
+ * win vs Streamdown's per-chunk Shiki invocation.
10
+ */
11
+
12
+ const KEYWORDS_JS = new Set(
13
+ "async await break case catch class const continue debugger default delete do else export extends false finally for from function if import in instanceof let new null of return static super switch this throw true try typeof undefined var void while with yield".split(
14
+ " ",
15
+ ),
16
+ );
17
+ const KEYWORDS_TS = new Set([
18
+ ...KEYWORDS_JS,
19
+ ...["any", "as", "boolean", "declare", "enum", "interface", "is", "keyof", "module", "namespace", "never", "number", "private", "protected", "public", "readonly", "string", "type", "unknown", "satisfies"],
20
+ ]);
21
+ const KEYWORDS_RUST = new Set(
22
+ "as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return Self self static struct super trait true type unsafe use where while".split(
23
+ " ",
24
+ ),
25
+ );
26
+ const KEYWORDS_PY = new Set(
27
+ "False None True and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield".split(
28
+ " ",
29
+ ),
30
+ );
31
+ const KEYWORDS_GO = new Set(
32
+ "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false".split(
33
+ " ",
34
+ ),
35
+ );
36
+ const KEYWORDS_BASH = new Set(
37
+ "if then elif else fi case esac for select while until do done function in time coproc return break continue".split(
38
+ " ",
39
+ ),
40
+ );
41
+ const KEYWORDS_SQL = new Set(
42
+ "SELECT FROM WHERE JOIN LEFT RIGHT INNER OUTER ON GROUP BY ORDER HAVING LIMIT OFFSET INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE DROP ALTER INDEX VIEW IF EXISTS NOT NULL DEFAULT PRIMARY KEY FOREIGN REFERENCES UNIQUE AS WITH UNION ALL DISTINCT IS BETWEEN LIKE IN AND OR".split(
43
+ " ",
44
+ ),
45
+ );
46
+
47
+ // Each language is described by an ordered list of (token-class, regex) pairs.
48
+ // The regex must be sticky (y flag) so it only matches at the current cursor.
49
+ // First match wins.
50
+ type Pat = [string, RegExp];
51
+
52
+ const jsPats: Pat[] = [
53
+ ["com", /\/\/[^\n]*/y],
54
+ ["com", /\/\*[\s\S]*?\*\//y],
55
+ ["str", /"(?:\\.|[^"\\\n])*"/y],
56
+ ["str", /'(?:\\.|[^'\\\n])*'/y],
57
+ ["str", /`(?:\\.|[^`\\])*`/y],
58
+ ["rx", /\/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n])+\/[gimsuy]*/y],
59
+ ["num", /\b(?:0x[\da-fA-F_]+|0b[01_]+|0o[0-7_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)\b/y],
60
+ ["ident", /[A-Za-z_$][\w$]*/y],
61
+ ["pun", /[+\-*/=<>!&|^~?:;,.[\](){}]/y],
62
+ ["ws", /\s+/y],
63
+ ];
64
+
65
+ const rustPats: Pat[] = [
66
+ ["com", /\/\/[^\n]*/y],
67
+ ["com", /\/\*[\s\S]*?\*\//y],
68
+ ["str", /b?"(?:\\.|[^"\\])*"/y],
69
+ ["str", /b?'(?:\\.|[^'\\])'/y],
70
+ ["lt", /'[a-zA-Z_][\w]*/y],
71
+ ["num", /\b\d[\d_]*(?:\.\d[\d_]*)?(?:[ui](?:8|16|32|64|128|size)|f(?:32|64))?\b/y],
72
+ ["mac", /[A-Za-z_]\w*!/y],
73
+ ["attr", /#!?\[[^\]]*\]/y],
74
+ ["ident", /[A-Za-z_]\w*/y],
75
+ ["pun", /[+\-*/=<>!&|^~?:;,.\[\](){}@]/y],
76
+ ["ws", /\s+/y],
77
+ ];
78
+
79
+ const pyPats: Pat[] = [
80
+ ["com", /#[^\n]*/y],
81
+ ["str", /[fFrRbB]{0,2}"""[\s\S]*?"""/y],
82
+ ["str", /[fFrRbB]{0,2}'''[\s\S]*?'''/y],
83
+ ["str", /[fFrRbB]{0,2}"(?:\\.|[^"\\\n])*"/y],
84
+ ["str", /[fFrRbB]{0,2}'(?:\\.|[^'\\\n])*'/y],
85
+ ["num", /\b(?:0x[\da-fA-F_]+|0b[01_]+|0o[0-7_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?[jJ]?)\b/y],
86
+ ["dec", /@[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*/y],
87
+ ["ident", /[A-Za-z_]\w*/y],
88
+ ["pun", /[+\-*/=<>!&|^~?:;,.[\](){}@%]/y],
89
+ ["ws", /\s+/y],
90
+ ];
91
+
92
+ const goPats: Pat[] = [
93
+ ["com", /\/\/[^\n]*/y],
94
+ ["com", /\/\*[\s\S]*?\*\//y],
95
+ ["str", /"(?:\\.|[^"\\\n])*"/y],
96
+ ["str", /`[^`]*`/y],
97
+ ["str", /'(?:\\.|[^'\\\n])'/y],
98
+ ["num", /\b\d[\d_]*(?:\.\d[\d_]*)?\b/y],
99
+ ["ident", /[A-Za-z_]\w*/y],
100
+ ["pun", /[+\-*/=<>!&|^~?:;,.[\](){}]/y],
101
+ ["ws", /\s+/y],
102
+ ];
103
+
104
+ const bashPats: Pat[] = [
105
+ ["com", /#[^\n]*/y],
106
+ ["str", /"(?:\\.|\$\([^)]*\)|[^"\\])*"/y],
107
+ ["str", /'[^']*'/y],
108
+ ["var", /\$\{[^}]+\}|\$\w+|\$[*@#?!$0-9]/y],
109
+ ["num", /\b\d+\b/y],
110
+ ["ident", /[A-Za-z_][\w-]*/y],
111
+ ["pun", /[|&;<>(){}[\]=]/y],
112
+ ["ws", /\s+/y],
113
+ ];
114
+
115
+ const jsonPats: Pat[] = [
116
+ ["str", /"(?:\\.|[^"\\\n])*"/y],
117
+ ["num", /-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/y],
118
+ ["kw", /\b(?:true|false|null)\b/y],
119
+ ["pun", /[{}[\]:,]/y],
120
+ ["ws", /\s+/y],
121
+ ];
122
+
123
+ const sqlPats: Pat[] = [
124
+ ["com", /--[^\n]*/y],
125
+ ["com", /\/\*[\s\S]*?\*\//y],
126
+ ["str", /'(?:''|[^'])*'/y],
127
+ ["str", /"(?:""|[^"])*"/y],
128
+ ["num", /\b\d+(?:\.\d+)?\b/y],
129
+ ["ident", /[A-Za-z_][\w]*/y],
130
+ ["pun", /[+\-*/=<>!,;.(){}]/y],
131
+ ["ws", /\s+/y],
132
+ ];
133
+
134
+ const htmlPats: Pat[] = [
135
+ ["com", /<!--[\s\S]*?-->/y],
136
+ ["tag", /<\/?[A-Za-z][\w-]*/y],
137
+ ["str", /"[^"]*"/y],
138
+ ["str", /'[^']*'/y],
139
+ ["attr", /[A-Za-z][\w-]*(?==)/y],
140
+ ["pun", /[=/>]/y],
141
+ ["txt", /[^<>"'=]+/y],
142
+ ];
143
+
144
+ const cssPats: Pat[] = [
145
+ ["com", /\/\*[\s\S]*?\*\//y],
146
+ ["str", /"[^"]*"/y],
147
+ ["str", /'[^']*'/y],
148
+ ["num", /-?\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms|deg)?/y],
149
+ ["sel", /[#.]?[A-Za-z][\w-]*/y],
150
+ ["pun", /[:;,{}()]/y],
151
+ ["ws", /\s+/y],
152
+ ];
153
+
154
+ const LANGS: Record<string, { pats: Pat[]; kw?: Set<string> }> = {
155
+ js: { pats: jsPats, kw: KEYWORDS_JS },
156
+ javascript: { pats: jsPats, kw: KEYWORDS_JS },
157
+ ts: { pats: jsPats, kw: KEYWORDS_TS },
158
+ tsx: { pats: jsPats, kw: KEYWORDS_TS },
159
+ jsx: { pats: jsPats, kw: KEYWORDS_JS },
160
+ typescript: { pats: jsPats, kw: KEYWORDS_TS },
161
+ rust: { pats: rustPats, kw: KEYWORDS_RUST },
162
+ rs: { pats: rustPats, kw: KEYWORDS_RUST },
163
+ py: { pats: pyPats, kw: KEYWORDS_PY },
164
+ python: { pats: pyPats, kw: KEYWORDS_PY },
165
+ go: { pats: goPats, kw: KEYWORDS_GO },
166
+ bash: { pats: bashPats, kw: KEYWORDS_BASH },
167
+ sh: { pats: bashPats, kw: KEYWORDS_BASH },
168
+ shell: { pats: bashPats, kw: KEYWORDS_BASH },
169
+ json: { pats: jsonPats },
170
+ sql: { pats: sqlPats, kw: KEYWORDS_SQL },
171
+ html: { pats: htmlPats },
172
+ xml: { pats: htmlPats },
173
+ css: { pats: cssPats },
174
+ };
175
+
176
+ function escapeHtml(s: string): string {
177
+ let out = "";
178
+ for (let i = 0; i < s.length; i++) {
179
+ const c = s[i];
180
+ if (c === "<") out += "&lt;";
181
+ else if (c === ">") out += "&gt;";
182
+ else if (c === "&") out += "&amp;";
183
+ else if (c === '"') out += "&quot;";
184
+ else out += c;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ export function highlight(code: string, lang: string): string {
190
+ const conf = LANGS[lang.toLowerCase()];
191
+ if (!conf) return escapeHtml(code);
192
+
193
+ let out = "";
194
+ let pos = 0;
195
+ const pats = conf.pats;
196
+ const kw = conf.kw;
197
+ // Linear pass with sticky regex tracking lastIndex.
198
+ while (pos < code.length) {
199
+ let matched = false;
200
+ for (let i = 0; i < pats.length; i++) {
201
+ const [cls, re] = pats[i];
202
+ re.lastIndex = pos;
203
+ const m = re.exec(code);
204
+ if (!m || m.index !== pos) continue;
205
+ const text = m[0];
206
+ const after = pos + text.length;
207
+ let finalCls = cls;
208
+ if (cls === "ident") {
209
+ if (kw && kw.has(text)) {
210
+ finalCls = "kw";
211
+ } else if (after < code.length && code[after] === "(") {
212
+ finalCls = "fn";
213
+ } else if (text.length > 1 && text[0] >= "A" && text[0] <= "Z") {
214
+ finalCls = "ty";
215
+ } else {
216
+ // Plain identifier — no span needed.
217
+ out += escapeHtml(text);
218
+ pos = after;
219
+ matched = true;
220
+ break;
221
+ }
222
+ }
223
+ if (cls === "ws") {
224
+ out += text;
225
+ } else {
226
+ out += `<span class="t-${finalCls}">${escapeHtml(text)}</span>`;
227
+ }
228
+ pos = after;
229
+ matched = true;
230
+ break;
231
+ }
232
+ if (!matched) {
233
+ // No pattern matched (shouldn't happen with a catch-all ws/other) — emit
234
+ // one char as plain text to make progress.
235
+ out += escapeHtml(code[pos]);
236
+ pos += 1;
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+
242
+ export function supportedLangs(): string[] {
243
+ return Object.keys(LANGS);
244
+ }