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/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +50 -0
- package/src/client.ts +315 -0
- package/src/hi.ts +244 -0
- package/src/html-to-react.ts +282 -0
- package/src/index.ts +33 -0
- package/src/react.tsx +223 -0
- package/src/renderers/CodeBlock.tsx +62 -0
- package/src/renderers/Math.tsx +26 -0
- package/src/renderers/Mermaid.tsx +26 -0
- package/src/types.ts +130 -0
- package/src/wasm/flux_md_core.d.ts +94 -0
- package/src/wasm/flux_md_core.js +399 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/flux_md_core_bg.wasm.d.ts +18 -0
- package/src/wasm/package.json +17 -0
- package/src/worker.ts +151 -0
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 += "<";
|
|
181
|
+
else if (c === ">") out += ">";
|
|
182
|
+
else if (c === "&") out += "&";
|
|
183
|
+
else if (c === '"') out += """;
|
|
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
|
+
}
|