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.
@@ -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 `<` → `<`).
8
+ * This is the simple ordered chain, not the numeric/named-entity decoder. */
9
+ function decodeEntities(s: string): string {
10
+ return s
11
+ .replace(/&lt;/g, "<")
12
+ .replace(/&gt;/g, ">")
13
+ .replace(/&quot;/g, '"')
14
+ .replace(/&#39;/g, "'")
15
+ .replace(/&amp;/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. `&amp;` decodes last so
38
+ * `&amp;lt;` → `&lt;`, not `<`. */
39
+ function htmlToText(html: string): string {
40
+ return html
41
+ .replace(/<[^>]*>/g, " ")
42
+ .replace(/&lt;/g, "<")
43
+ .replace(/&gt;/g, ">")
44
+ .replace(/&quot;/g, '"')
45
+ .replace(/&#39;/g, "'")
46
+ .replace(/&amp;/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
- readyResolvers: Array<() => void>;
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
- return new Promise((resolve) => pw.readyResolvers.push(resolve));
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 = { worker: this.factory(), ready: false, streamCount: 0, readyResolvers: [] };
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 resolvers = pw.readyResolvers;
144
- pw.readyResolvers = [];
145
- for (const r of resolvers) r();
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(options: { pool?: FluxPool; config?: ParserConfig } = {}) {
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
- // eslint-disable-next-line no-console
307
- console.error("flux worker error:", msg.message);
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
  }