flux-md 0.5.5 → 0.7.0
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 +143 -0
- package/README.md +261 -21
- package/package.json +21 -5
- package/src/block-props.ts +96 -0
- package/src/client.ts +111 -11
- package/src/dom.ts +430 -0
- package/src/element.ts +381 -0
- package/src/renderers/CodeBlock.tsx +62 -5
- package/src/solid.tsx +70 -0
- package/src/svelte.ts +55 -0
- package/src/types-core.ts +147 -0
- package/src/types-react.ts +14 -0
- package/src/types.ts +7 -150
- package/src/vue.ts +100 -0
- package/src/wasm/flux_md_core.d.ts +7 -0
- package/src/wasm/flux_md_core.js +9 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/flux_md_core_bg.wasm.d.ts +1 -0
- package/src/wasm/package.json +1 -1
- package/src/worker.ts +11 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Block, BlockComponentProps } from "./types-core";
|
|
2
|
+
|
|
3
|
+
// Pure helpers duplicated from the JSX renderer / its CodeBlock so the
|
|
4
|
+
// framework-neutral DOM renderer carries no framework dependency. The JSX
|
|
5
|
+
// renderer is held byte-identical, so these are copies — match it exactly.
|
|
6
|
+
|
|
7
|
+
/** Decode the small entity set the core emits (amp last so `&lt;` → `<`).
|
|
8
|
+
* This is the simple ordered chain, not the numeric/named-entity decoder. */
|
|
9
|
+
function decodeEntities(s: string): string {
|
|
10
|
+
return s
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
.replace(/'/g, "'")
|
|
15
|
+
.replace(/&/g, "&");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Decoded source text inside `<pre><code>…</code></pre>`. */
|
|
19
|
+
function decodeCodeText(html: string): string {
|
|
20
|
+
const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
|
|
21
|
+
return m ? decodeEntities(m[1]) : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The LaTeX source for a MathBlock. Display math (`$$…$$` / `\[…\]`) renders as
|
|
26
|
+
* `<div class="math math-display">…</div>`; a fenced ```math block renders as
|
|
27
|
+
* `<pre><code>…</code></pre>`. Either way the body is the HTML-escaped LaTeX —
|
|
28
|
+
* decode it back so a `components.MathBlock` override gets the raw source.
|
|
29
|
+
*/
|
|
30
|
+
function decodeMathText(html: string): string {
|
|
31
|
+
const d = html.match(/<div class="math math-display">([\s\S]*?)<\/div>/);
|
|
32
|
+
if (d) return decodeEntities(d[1]);
|
|
33
|
+
return decodeCodeText(html);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Info-string language from a code block's `data-lang="…"`. */
|
|
37
|
+
export function extractLang(html: string): string {
|
|
38
|
+
const m = html.match(/data-lang="([^"]+)"/);
|
|
39
|
+
return m ? m[1] : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Strip the `<tag …>` open and trailing `</tag>` from a component block's HTML,
|
|
43
|
+
* leaving the inner (already-rendered markdown) HTML. Handles open (unclosed)
|
|
44
|
+
* blocks, where there is no close tag yet. */
|
|
45
|
+
function componentInnerHtml(html: string, tag: string): string {
|
|
46
|
+
const gt = html.indexOf(">");
|
|
47
|
+
if (gt < 0) return "";
|
|
48
|
+
let inner = html.slice(gt + 1);
|
|
49
|
+
const close = `</${tag}>`;
|
|
50
|
+
if (inner.endsWith(close)) inner = inner.slice(0, -close.length);
|
|
51
|
+
return inner.replace(/^\n/, "").replace(/\n$/, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Convert sanitized HTML attribute pairs into a spreadable object, keeping the
|
|
56
|
+
* HTML-form names (`class`, `for`) verbatim. This is the deliberate divergence
|
|
57
|
+
* from the JSX renderer (which renames to `className`/`htmlFor` for a prop
|
|
58
|
+
* spread): the DOM renderer applies them via `el.setAttribute(name, value)`,
|
|
59
|
+
* which wants the literal HTML names.
|
|
60
|
+
*/
|
|
61
|
+
export function htmlAttrs(pairs: [string, string][]): Record<string, string> {
|
|
62
|
+
const out: Record<string, string> = {};
|
|
63
|
+
for (const [k, v] of pairs) out[k] = v;
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build the props a block-kind / component-tag override receives — the same
|
|
69
|
+
* shape the JSX renderer's block-kind props carry, with ONE deliberate
|
|
70
|
+
* divergence: for `Component` blocks `attrs` stay in HTML form (`class`/`for`)
|
|
71
|
+
* because DOM overrides apply them via `setAttribute` (see {@link htmlAttrs}).
|
|
72
|
+
*/
|
|
73
|
+
export function blockProps(block: Block): BlockComponentProps {
|
|
74
|
+
const props: BlockComponentProps = {
|
|
75
|
+
block,
|
|
76
|
+
html: block.html,
|
|
77
|
+
open: block.open,
|
|
78
|
+
speculative: block.speculative,
|
|
79
|
+
};
|
|
80
|
+
const data = block.kind.data as
|
|
81
|
+
| { lang?: string | null; tag?: string; attrs?: [string, string][] }
|
|
82
|
+
| undefined;
|
|
83
|
+
if (block.kind.type === "CodeBlock") {
|
|
84
|
+
props.text = decodeCodeText(block.html);
|
|
85
|
+
props.language = data?.lang ?? "";
|
|
86
|
+
} else if (block.kind.type === "MathBlock") {
|
|
87
|
+
props.text = decodeMathText(block.html);
|
|
88
|
+
} else if (block.kind.type === "Component") {
|
|
89
|
+
props.tag = data?.tag ?? "";
|
|
90
|
+
props.attrs = htmlAttrs(data?.attrs ?? []);
|
|
91
|
+
// An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
|
|
92
|
+
// (markdown already rendered) rather than the full wrapped block.
|
|
93
|
+
props.html = componentInnerHtml(block.html, props.tag);
|
|
94
|
+
}
|
|
95
|
+
return props;
|
|
96
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Block, FromWorker, ParserConfig, Patch, ToWorker, WorkerLike } from "./types";
|
|
1
|
+
import type { Block, FromWorker, ParserConfig, Patch, ToWorker, WorkerLike } from "./types-core";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The ordered-block store backing a stream, extracted as a pure function so
|
|
@@ -22,6 +22,32 @@ export function emptyBlockStore(): BlockStore {
|
|
|
22
22
|
return { committed: new Map(), committedOrder: [], active: [], snapshot: [] };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** A heading entry for building a table of contents — see {@link FluxClient.outline}. */
|
|
26
|
+
export interface OutlineEntry {
|
|
27
|
+
/** Heading level 1–6. */
|
|
28
|
+
level: number;
|
|
29
|
+
/** Plain-text heading content (tags stripped, entities decoded). */
|
|
30
|
+
text: string;
|
|
31
|
+
/** Stable block id — usable as a scroll target / React key. */
|
|
32
|
+
id: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Strip tags (→ space) and decode the small entity set the core emits, then
|
|
36
|
+
* collapse whitespace. The core's HTML is well-formed and escapes `>` inside
|
|
37
|
+
* attributes, so the simple tag regex is safe here. `&` decodes last so
|
|
38
|
+
* `&lt;` → `<`, not `<`. */
|
|
39
|
+
function htmlToText(html: string): string {
|
|
40
|
+
return html
|
|
41
|
+
.replace(/<[^>]*>/g, " ")
|
|
42
|
+
.replace(/</g, "<")
|
|
43
|
+
.replace(/>/g, ">")
|
|
44
|
+
.replace(/"/g, '"')
|
|
45
|
+
.replace(/'/g, "'")
|
|
46
|
+
.replace(/&/g, "&")
|
|
47
|
+
.replace(/\s+/g, " ")
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
export function applyPatch(store: BlockStore, patch: Patch): void {
|
|
26
52
|
for (const b of patch.newly_committed) {
|
|
27
53
|
if (!store.committed.has(b.id)) store.committedOrder.push(b.id);
|
|
@@ -47,8 +73,12 @@ export function applyPatch(store: BlockStore, patch: Patch): void {
|
|
|
47
73
|
interface PoolWorker {
|
|
48
74
|
worker: WorkerLike;
|
|
49
75
|
ready: boolean;
|
|
76
|
+
/** Set once WASM init fails; whenWorkerReady rejects with this thereafter. */
|
|
77
|
+
failed: Error | null;
|
|
50
78
|
streamCount: number;
|
|
51
|
-
|
|
79
|
+
/** Live stream ids on this worker — so a fatal failure can notify each one. */
|
|
80
|
+
streamIds: Set<number>;
|
|
81
|
+
readyWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }>;
|
|
52
82
|
}
|
|
53
83
|
|
|
54
84
|
/**
|
|
@@ -79,6 +109,7 @@ export class FluxPool {
|
|
|
79
109
|
const streamId = this.nextStreamId++;
|
|
80
110
|
const pw = this.pick();
|
|
81
111
|
pw.streamCount++;
|
|
112
|
+
pw.streamIds.add(streamId);
|
|
82
113
|
this.handlers.set(streamId, handler);
|
|
83
114
|
return { streamId, pw };
|
|
84
115
|
}
|
|
@@ -86,6 +117,7 @@ export class FluxPool {
|
|
|
86
117
|
/** Free a stream's parser in its worker; keep the worker warm for siblings. */
|
|
87
118
|
release(streamId: number, pw: PoolWorker): void {
|
|
88
119
|
this.handlers.delete(streamId);
|
|
120
|
+
pw.streamIds.delete(streamId);
|
|
89
121
|
pw.streamCount = Math.max(0, pw.streamCount - 1);
|
|
90
122
|
try {
|
|
91
123
|
pw.worker.postMessage({ type: "dispose", streamId });
|
|
@@ -98,10 +130,11 @@ export class FluxPool {
|
|
|
98
130
|
pw.worker.postMessage(msg);
|
|
99
131
|
}
|
|
100
132
|
|
|
101
|
-
/** Resolves when the given worker has finished WASM init. */
|
|
133
|
+
/** Resolves when the given worker has finished WASM init; rejects if it failed. */
|
|
102
134
|
whenWorkerReady(pw: PoolWorker): Promise<void> {
|
|
103
135
|
if (pw.ready) return Promise.resolve();
|
|
104
|
-
|
|
136
|
+
if (pw.failed) return Promise.reject(pw.failed);
|
|
137
|
+
return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
|
|
105
138
|
}
|
|
106
139
|
|
|
107
140
|
/** Terminate every worker (test teardown / full shutdown). */
|
|
@@ -131,7 +164,14 @@ export class FluxPool {
|
|
|
131
164
|
}
|
|
132
165
|
|
|
133
166
|
private create(): PoolWorker {
|
|
134
|
-
const pw: PoolWorker = {
|
|
167
|
+
const pw: PoolWorker = {
|
|
168
|
+
worker: this.factory(),
|
|
169
|
+
ready: false,
|
|
170
|
+
failed: null,
|
|
171
|
+
streamCount: 0,
|
|
172
|
+
streamIds: new Set(),
|
|
173
|
+
readyWaiters: [],
|
|
174
|
+
};
|
|
135
175
|
pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
|
|
136
176
|
this.workers.push(pw);
|
|
137
177
|
return pw;
|
|
@@ -140,9 +180,23 @@ export class FluxPool {
|
|
|
140
180
|
private onMessage(pw: PoolWorker, msg: FromWorker): void {
|
|
141
181
|
if (msg.type === "ready") {
|
|
142
182
|
pw.ready = true;
|
|
143
|
-
const
|
|
144
|
-
pw.
|
|
145
|
-
for (const
|
|
183
|
+
const waiters = pw.readyWaiters;
|
|
184
|
+
pw.readyWaiters = [];
|
|
185
|
+
for (const w of waiters) w.resolve();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (msg.type === "error" && msg.fatal) {
|
|
189
|
+
// A fatal (WASM-init) failure dooms every stream on this worker. Reject
|
|
190
|
+
// anyone awaiting readiness, then notify each live stream's client so its
|
|
191
|
+
// onError fires — the message carries no real streamId to route by. The
|
|
192
|
+
// doomed worker stays in the pool: a later stream that pick()s it rejects
|
|
193
|
+
// immediately via pw.failed (no auto-recovery — fine for a load failure).
|
|
194
|
+
const err = new Error(msg.message);
|
|
195
|
+
pw.failed = err;
|
|
196
|
+
const waiters = pw.readyWaiters;
|
|
197
|
+
pw.readyWaiters = [];
|
|
198
|
+
for (const w of waiters) w.reject(err);
|
|
199
|
+
for (const sid of pw.streamIds) this.handlers.get(sid)?.(msg);
|
|
146
200
|
return;
|
|
147
201
|
}
|
|
148
202
|
this.handlers.get(msg.streamId)?.(msg);
|
|
@@ -194,6 +248,7 @@ export class FluxClient {
|
|
|
194
248
|
private configSent = false;
|
|
195
249
|
private listeners = new Set<() => void>();
|
|
196
250
|
private store: BlockStore = emptyBlockStore();
|
|
251
|
+
private onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
197
252
|
|
|
198
253
|
// Perf
|
|
199
254
|
private appendedBytes = 0;
|
|
@@ -209,10 +264,20 @@ export class FluxClient {
|
|
|
209
264
|
* process-wide pool — pass a dedicated `FluxPool` only for isolation).
|
|
210
265
|
* @param options.config per-stream parser flags (see {@link ParserConfig});
|
|
211
266
|
* omitted fields use library defaults. Applied once, immutable thereafter.
|
|
267
|
+
* @param options.onError invoked on a worker/parse error or a fatal WASM-init
|
|
268
|
+
* failure (`fatal: true`). Without it, errors are only `console.error`d and
|
|
269
|
+
* a load failure surfaces solely as a rejected {@link FluxClient.whenReady}.
|
|
212
270
|
*/
|
|
213
|
-
constructor(
|
|
271
|
+
constructor(
|
|
272
|
+
options: {
|
|
273
|
+
pool?: FluxPool;
|
|
274
|
+
config?: ParserConfig;
|
|
275
|
+
onError?: (err: { message: string; fatal?: boolean }) => void;
|
|
276
|
+
} = {},
|
|
277
|
+
) {
|
|
214
278
|
this.pool = options.pool ?? getDefaultPool();
|
|
215
279
|
this.config = options.config;
|
|
280
|
+
this.onError = options.onError;
|
|
216
281
|
const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
|
|
217
282
|
this.streamId = streamId;
|
|
218
283
|
this.pw = pw;
|
|
@@ -290,6 +355,37 @@ export class FluxClient {
|
|
|
290
355
|
};
|
|
291
356
|
}
|
|
292
357
|
|
|
358
|
+
/**
|
|
359
|
+
* A heading outline of the current snapshot (committed + active), in document
|
|
360
|
+
* order — for a table of contents. Works mid-stream; entries appear as their
|
|
361
|
+
* headings stream in. The `id` is stable, so a built ToC won't re-key.
|
|
362
|
+
*/
|
|
363
|
+
outline(): OutlineEntry[] {
|
|
364
|
+
const out: OutlineEntry[] = [];
|
|
365
|
+
for (const b of this.store.snapshot) {
|
|
366
|
+
if (b.kind.type === "Heading") {
|
|
367
|
+
out.push({ level: (b.kind.data as number) ?? 1, text: htmlToText(b.html), id: b.id });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The rendered document as plain text — tags stripped, entities decoded,
|
|
375
|
+
* blocks separated by blank lines. Derived from the rendered HTML (the source
|
|
376
|
+
* markdown is parsed away in WASM and not retained client-side), so it is a
|
|
377
|
+
* readable approximation for search indexing / summaries, not a round-trip of
|
|
378
|
+
* the original source.
|
|
379
|
+
*/
|
|
380
|
+
toPlaintext(): string {
|
|
381
|
+
const parts: string[] = [];
|
|
382
|
+
for (const b of this.store.snapshot) {
|
|
383
|
+
const t = htmlToText(b.html);
|
|
384
|
+
if (t) parts.push(t);
|
|
385
|
+
}
|
|
386
|
+
return parts.join("\n\n");
|
|
387
|
+
}
|
|
388
|
+
|
|
293
389
|
private onMessage(msg: FromWorker) {
|
|
294
390
|
switch (msg.type) {
|
|
295
391
|
case "patch":
|
|
@@ -303,8 +399,12 @@ export class FluxClient {
|
|
|
303
399
|
this.emit();
|
|
304
400
|
break;
|
|
305
401
|
case "error":
|
|
306
|
-
|
|
307
|
-
|
|
402
|
+
if (this.onError) {
|
|
403
|
+
this.onError({ message: msg.message, fatal: msg.fatal });
|
|
404
|
+
} else {
|
|
405
|
+
// eslint-disable-next-line no-console
|
|
406
|
+
console.error("flux worker error:", msg.message);
|
|
407
|
+
}
|
|
308
408
|
break;
|
|
309
409
|
}
|
|
310
410
|
}
|