flux-md 0.16.2 → 0.17.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.
Files changed (66) hide show
  1. package/README.md +70 -39
  2. package/dist/block-props.d.ts +18 -0
  3. package/dist/block-props.js +75 -0
  4. package/dist/client.d.ts +310 -0
  5. package/{src/client.ts → dist/client.js} +123 -319
  6. package/dist/dom.d.ts +110 -0
  7. package/dist/dom.js +576 -0
  8. package/dist/element.d.ts +20 -0
  9. package/dist/element.js +287 -0
  10. package/dist/hi.d.ts +12 -0
  11. package/{src/hi.ts → dist/hi.js} +42 -74
  12. package/dist/html-to-react.d.ts +40 -0
  13. package/dist/html-to-react.js +344 -0
  14. package/{src/index.ts → dist/index.d.ts} +5 -25
  15. package/dist/index.js +16 -0
  16. package/dist/morph.d.ts +28 -0
  17. package/dist/morph.js +166 -0
  18. package/dist/react.d.ts +204 -0
  19. package/dist/react.js +490 -0
  20. package/dist/renderers/CodeBlock.d.ts +7 -0
  21. package/dist/renderers/CodeBlock.js +75 -0
  22. package/dist/renderers/Math.d.ts +14 -0
  23. package/dist/renderers/Math.js +15 -0
  24. package/dist/renderers/Mermaid.d.ts +13 -0
  25. package/dist/renderers/Mermaid.js +15 -0
  26. package/dist/server.d.ts +61 -0
  27. package/dist/server.js +126 -0
  28. package/{src/solid.tsx → dist/solid.d.ts} +19 -95
  29. package/dist/solid.js +54 -0
  30. package/dist/svelte.d.ts +80 -0
  31. package/dist/svelte.js +59 -0
  32. package/dist/types-core.d.ts +377 -0
  33. package/dist/types-core.js +0 -0
  34. package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
  35. package/dist/types-react.js +0 -0
  36. package/dist/types.d.ts +2 -0
  37. package/dist/types.js +2 -0
  38. package/dist/vue.d.ts +94 -0
  39. package/dist/vue.js +79 -0
  40. package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
  41. package/{src → dist}/wasm/flux_md_core.js +50 -55
  42. package/dist/wasm/flux_md_core_bg.wasm +0 -0
  43. package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
  44. package/dist/worker-core.d.ts +60 -0
  45. package/dist/worker-core.js +121 -0
  46. package/dist/worker.d.ts +1 -0
  47. package/dist/worker.js +48 -0
  48. package/package.json +22 -18
  49. package/src/block-props.ts +0 -141
  50. package/src/dom.ts +0 -946
  51. package/src/element.ts +0 -400
  52. package/src/html-to-react.ts +0 -455
  53. package/src/morph.ts +0 -253
  54. package/src/react.tsx +0 -1020
  55. package/src/renderers/CodeBlock.tsx +0 -116
  56. package/src/renderers/Math.tsx +0 -28
  57. package/src/renderers/Mermaid.tsx +0 -27
  58. package/src/server.tsx +0 -221
  59. package/src/svelte.ts +0 -179
  60. package/src/types-core.ts +0 -398
  61. package/src/types.ts +0 -7
  62. package/src/vue.ts +0 -184
  63. package/src/wasm/flux_md_core_bg.wasm +0 -0
  64. package/src/worker-core.ts +0 -174
  65. package/src/worker.ts +0 -72
  66. /package/{src → dist}/styles.css +0 -0
package/src/dom.ts DELETED
@@ -1,946 +0,0 @@
1
- import type { FluxClient } from "./client";
2
- import { highlight } from "./hi";
3
- import { morph } from "./morph";
4
- import type { Align, Block, BlockComponentProps, BlockKindTag, ListData, RenderMetricsHook, TableData } from "./types-core";
5
- import { blockProps, extractLang } from "./block-props";
6
-
7
- /**
8
- * Framework-neutral DOM renderer for a {@link FluxClient}. Mounts the streaming
9
- * document into a container and keeps it in sync via direct DOM mutation,
10
- * mirroring the JSX renderer's block model: each block is keyed by its stable
11
- * parser-assigned id, and a committed block's node is reused untouched on every
12
- * later patch (the parity analogue of the JSX renderer's block memo). Only the
13
- * streaming tail is rebuilt.
14
- *
15
- * This is the foundation the Web Component / Vue / Svelte / Solid bindings
16
- * build on; it imports only neutral modules and carries no framework dependency.
17
- *
18
- * ## Custom components
19
- *
20
- * Pass `components` to override a whole block kind (or a component tag). Keys
21
- * are capitalized block-kind names (`CodeBlock`, `Table`, `Mermaid`…) or, for
22
- * `Component` blocks, the tag name (e.g. `Thinking`) with `Component` as the
23
- * generic fallback. A component receives {@link BlockComponentProps} and returns
24
- * an `HTMLElement` or an HTML string. There is no tag-level override path (no
25
- * `table`/`a`/`code` keys) — that requires an HTML→tree pass the DOM renderer
26
- * doesn't carry.
27
- */
28
-
29
- export interface MountHandle {
30
- destroy(): void;
31
- refresh(): void;
32
- /**
33
- * The id of the streaming **tail** block — the one block that may re-render on
34
- * the next patch (a committed block's node is frozen, so its id never appears
35
- * here). Returns `null` when no block is open (idle / fully committed).
36
- *
37
- * Purely derived from the live snapshot; reading it renders nothing and mutates
38
- * nothing. It exists so a fine-grained framework binding (Solid `createMemo`,
39
- * Vue `computed`, Svelte `derived`) can narrow a reactive cell to *just the tail*
40
- * for its own scheduling/diagnostics — the DOM is already updated by the
41
- * renderer's own subscribe loop, so this never changes what is drawn.
42
- */
43
- openBlockId(): number | null;
44
- }
45
-
46
- export type DomBlockComponent = (props: BlockComponentProps) => HTMLElement | string;
47
-
48
- /** Override map: capitalized block-kind / component-tag keys only. */
49
- export type DomComponents = Record<string, DomBlockComponent>;
50
-
51
- export interface MountOptions {
52
- components?: DomComponents;
53
- /**
54
- * Optional HTML sanitizer applied to every generic block's HTML before it is
55
- * injected via `innerHTML` — **including the streaming (open/speculative)
56
- * tail**. The built-in code/math/mermaid renderers operate on already-escaped
57
- * content and are not run through it (same as the JSX renderer). When omitted,
58
- * rendering is byte-identical and zero-cost.
59
- */
60
- sanitize?: (html: string) => string;
61
- /**
62
- * Skip layout/paint for off-screen *closed* blocks via CSS
63
- * `content-visibility: auto` (for very long documents). Off by default.
64
- */
65
- virtualize?: boolean;
66
- /**
67
- * Keep a bottom snap target so the view follows the streaming tail. CSS-only:
68
- * emits a sentinel with `scroll-snap-align: end`; you add
69
- * `scroll-snap-type: y proximity` to your scroll container. Off by default.
70
- */
71
- stickToBottom?: boolean;
72
- /** Use the built-in code highlighter. Default true; suppressed when a
73
- * `components.CodeBlock` override is supplied. */
74
- highlightCode?: boolean;
75
- /** Coalesce patches into one DOM write per animation frame. Default true. */
76
- batch?: boolean;
77
- /**
78
- * Opt-in (default false). When a generic open/streaming block grows, morph its
79
- * existing DOM subtree **in place** toward the new HTML instead of rebuilding
80
- * the whole node with `innerHTML`. The browser then only repaints/relayouts
81
- * the parts that changed, and focus/text-selection inside the streaming tail
82
- * survive a token append. The default path (full rebuild) is byte-identical
83
- * and unchanged; this only affects generic blocks rendered via the `innerHTML`
84
- * fast path (not code/math/mermaid/component overrides). The morphed subtree is
85
- * equivalent to the rebuilt one. */
86
- morphOpenBlocks?: boolean;
87
- /** Appended to the root's `className` (the `flux-md` class is always present). */
88
- className?: string;
89
- /** Set on the root element. */
90
- id?: string;
91
- /** Set on the root element (e.g. `"article"`, `"log"`). */
92
- role?: string;
93
- /**
94
- * Make the root a live region so screen readers announce streamed content.
95
- * `"polite"` coalesces rapid updates (does not read every token). Off by default.
96
- */
97
- ariaLive?: "off" | "polite" | "assertive";
98
- /** Live-region atomicity; pair with `ariaLive`. Off by default. */
99
- ariaAtomic?: boolean;
100
- /**
101
- * Optional render-churn probe. Fires once per ACTUAL node build/rebuild of a
102
- * block — never for a committed block whose node is reused untouched on a
103
- * tail-only patch. The callback gets the block id and a {@link RenderMetrics}
104
- * sample (per-block `renderCount`/rebuild count, `speculativeToggleCount`,
105
- * `lastRenderMs`, `kind`). Zero overhead when omitted, and advances
106
- * `client.getMetrics().rebuildCount`.
107
- */
108
- onRenderMetrics?: RenderMetricsHook;
109
- }
110
-
111
- // Per-kind off-screen size estimate for `contain-intrinsic-size`. Duplicated
112
- // verbatim from the JSX renderer so per-kind virtualization sizes match.
113
- const INTRINSIC_PX: Record<string, number> = {
114
- Paragraph: 80, Heading: 44, CodeBlock: 300, MathBlock: 140, Mermaid: 220,
115
- List: 120, Blockquote: 100, Alert: 120, Table: 200, Rule: 24, Html: 80,
116
- Component: 120,
117
- };
118
-
119
- // The fingerprint that decides whether a block's node may be reused: exactly
120
- // what the JSX renderer's block memo checks, minus `id` (the map key).
121
- interface MountedBlock {
122
- id: number;
123
- node: HTMLElement;
124
- html: string;
125
- open: boolean;
126
- speculative: boolean;
127
- kind: BlockKindTag;
128
- // Render-churn probe state (only maintained when an onRenderMetrics hook is
129
- // wired; otherwise these stay at their initial values and are never read).
130
- renderCount: number;
131
- toggleCount: number;
132
- // Set only for an OPEN table rendered via the keyed-tbody path (blockData on).
133
- // Lets a later patch update just the growing trailing row in place instead of
134
- // rebuilding the whole node.
135
- table?: KeyedTable;
136
- // True when `node` is the generic `<div class="flux-block…">` whose entire
137
- // `innerHTML` is exactly `html` (no special wrapper, no sanitizer transform).
138
- // Only such a node is eligible for the prefix-extension tail-append fast path.
139
- generic: boolean;
140
- }
141
-
142
- // Incremental keyed-tbody state for one OPEN table. `<tr>` nodes for committed
143
- // rows are appended once and never rebuilt; only the last (open) row's cells are
144
- // re-set each patch — never a whole-`<tbody>` rebuild.
145
- interface KeyedTable {
146
- table: HTMLTableElement;
147
- tbody: HTMLTableSectionElement | null;
148
- scope: boolean;
149
- // Number of LEADING rows whose `<tr>` is frozen in the DOM (built once and
150
- // never touched again). The last data row is OPEN — re-rendered each patch —
151
- // so after every update `committed === rows.length - 1`.
152
- committed: number;
153
- // The current open trailing `<tr>` (re-rendered each patch); replaced in place.
154
- lastRow: HTMLTableRowElement | null;
155
- }
156
-
157
- export function mountFluxMarkdown(
158
- client: FluxClient,
159
- container: HTMLElement,
160
- options: MountOptions = {},
161
- ): MountHandle {
162
- if (typeof document === "undefined") {
163
- throw new Error("mountFluxMarkdown is browser-only; call it after the DOM exists.");
164
- }
165
-
166
- // Normalize "no overrides" to undefined so the fast path doesn't churn.
167
- const components =
168
- options.components && Object.keys(options.components).length > 0 ? options.components : undefined;
169
- const { sanitize, virtualize, stickToBottom, onRenderMetrics } = options;
170
- const hasPerf = typeof performance !== "undefined";
171
- const highlightCode = options.highlightCode !== false && !components?.CodeBlock;
172
- const batch = options.batch !== false && typeof requestAnimationFrame === "function";
173
- const morphOpenBlocks = options.morphOpenBlocks === true;
174
-
175
- const root = document.createElement("div");
176
- root.className = options.className ? `flux-md ${options.className}` : "flux-md";
177
- if (options.id) root.id = options.id;
178
- if (options.role) root.setAttribute("role", options.role);
179
- if (options.ariaLive) root.setAttribute("aria-live", options.ariaLive);
180
- if (options.ariaAtomic !== undefined) root.setAttribute("aria-atomic", String(options.ariaAtomic));
181
- container.appendChild(root);
182
-
183
- // CSS-only stick-to-bottom: a permanent sentinel pinned as the last child.
184
- let anchor: HTMLElement | null = null;
185
- if (stickToBottom) {
186
- anchor = document.createElement("div");
187
- anchor.className = "flux-bottom-anchor";
188
- anchor.setAttribute("aria-hidden", "true");
189
- anchor.style.scrollSnapAlign = "end";
190
- root.appendChild(anchor);
191
- }
192
-
193
- const mounted = new Map<number, MountedBlock>();
194
- let order: number[] = [];
195
- let dead = false;
196
- let frame = 0;
197
- // Set by `renderBlockContent` for the call in flight: true only when it took
198
- // the generic `<div class="flux-block…">innerHTML=html` path (no override, no
199
- // dedicated renderer). Read immediately after the render to tag the mount.
200
- let lastRenderGeneric = false;
201
-
202
- function sync(): void {
203
- if (dead) return;
204
- const snapshot = client.getSnapshot();
205
- const nextOrder: number[] = new Array(snapshot.length);
206
- const seen = new Set<number>();
207
-
208
- for (let i = 0; i < snapshot.length; i++) {
209
- const b = snapshot[i];
210
- nextOrder[i] = b.id;
211
- seen.add(b.id);
212
- const existing = mounted.get(b.id);
213
- if (!existing) {
214
- const t0 = onRenderMetrics && hasPerf ? performance.now() : 0;
215
- const mb: MountedBlock = {
216
- id: b.id, node: undefined as unknown as HTMLElement,
217
- html: b.html, open: b.open, speculative: b.speculative, kind: b.kind.type,
218
- renderCount: 0, toggleCount: 0, generic: false,
219
- };
220
- mb.node = renderBlock(b, mb);
221
- mb.generic = lastRenderGeneric;
222
- mounted.set(b.id, mb);
223
- if (onRenderMetrics) noteRender(mb, b, t0);
224
- continue;
225
- }
226
- // Unchanged fingerprint → reuse the node untouched. Committed blocks land
227
- // here forever: their node is never recreated, so any one-shot work
228
- // (highlight, copy listener) runs exactly once. This is the whole point.
229
- if (existing.html === b.html && existing.open === b.open && existing.speculative === b.speculative) {
230
- continue;
231
- }
232
- const t0 = onRenderMetrics && hasPerf ? performance.now() : 0;
233
- // Keyed-table fast path: an OPEN table that already mounted via the keyed
234
- // tbody updates only its growing trailing row in place — committed `<tr>`
235
- // nodes are never rebuilt. Reuses the same block node (no replaceWith).
236
- // This is still a render of the node, so it feeds the render-churn probe.
237
- if (existing.table && b.open && b.kind.type === "Table") {
238
- const data = tableData(b);
239
- if (data) {
240
- syncTbody(existing.table, data);
241
- if (onRenderMetrics) noteRender(existing, b, t0);
242
- // Keep the wrapper's speculative class in sync (parity with the
243
- // full-rebuild path) without recreating the node.
244
- if (existing.speculative !== b.speculative) {
245
- existing.node.classList.toggle("flux-speculative", b.speculative);
246
- }
247
- existing.html = b.html;
248
- existing.open = b.open;
249
- existing.speculative = b.speculative;
250
- continue;
251
- }
252
- }
253
- // Opt-in morph fast path: an open generic block that only grew its HTML
254
- // (same kind, still routed through the innerHTML path) is morphed in place,
255
- // preserving the node's identity, focus, and selection. Falls through to a
256
- // full rebuild for anything not eligible (commit transition, kind change,
257
- // code/math/mermaid/override blocks). This is still a render of the node, so
258
- // it feeds the render-churn probe.
259
- if (
260
- morphOpenBlocks &&
261
- b.open &&
262
- existing.open &&
263
- existing.generic &&
264
- existing.kind === b.kind.type &&
265
- usesGenericPath(b)
266
- ) {
267
- morph(existing.node, sanitize ? sanitize(b.html) : b.html);
268
- if (onRenderMetrics) noteRender(existing, b, t0);
269
- existing.html = b.html;
270
- existing.speculative = b.speculative;
271
- existing.node.className = genericClassName(b);
272
- // The node stays the generic innerHTML mirror, so it remains eligible for
273
- // the prefix-append / morph fast paths on later patches.
274
- existing.generic = !sanitize;
275
- continue;
276
- }
277
- // Prefix-extension tail-append fast path (generic blocks only, no
278
- // sanitizer). When the new html merely *appends* one or more WHOLE
279
- // top-level sibling elements to the old html, we can splice the suffix
280
- // onto the live node instead of rebuilding the whole subtree. The result
281
- // is byte-identical to a full rebuild because the appended suffix is
282
- // self-contained markup that begins a new depth-0 sibling — the browser
283
- // parses it the same way whether appended or rendered whole. This is still
284
- // a render of the node, so it feeds the render-churn probe.
285
- if (
286
- !sanitize &&
287
- existing.generic &&
288
- existing.kind === b.kind.type &&
289
- existing.open === b.open &&
290
- existing.speculative === b.speculative &&
291
- b.html.length > existing.html.length &&
292
- b.html.startsWith(existing.html) &&
293
- isDepth0Boundary(existing.html, b.html)
294
- ) {
295
- existing.node.insertAdjacentHTML("beforeend", b.html.slice(existing.html.length));
296
- if (onRenderMetrics) noteRender(existing, b, t0);
297
- existing.html = b.html;
298
- continue;
299
- }
300
- // Changed → rebuild and swap in place. A table that just closed (or whose
301
- // data vanished) drops its keyed manager and re-renders the full HTML once.
302
- existing.table = undefined;
303
- const node = renderBlock(b, existing);
304
- existing.node.replaceWith(node);
305
- existing.node = node;
306
- if (onRenderMetrics) noteRender(existing, b, t0);
307
- existing.html = b.html;
308
- existing.open = b.open;
309
- existing.speculative = b.speculative;
310
- existing.kind = b.kind.type;
311
- existing.generic = lastRenderGeneric;
312
- }
313
-
314
- // Drop ids no longer present (reset() empties the snapshot; a speculative
315
- // revision can drop a tail block).
316
- if (mounted.size > seen.size) {
317
- for (const [id, mb] of mounted) {
318
- if (!seen.has(id)) {
319
- mb.node.remove();
320
- mounted.delete(id);
321
- }
322
- }
323
- }
324
-
325
- order = nextOrder;
326
- reconcileChildren();
327
- }
328
-
329
- // Fire the render-churn probe for one actual node build/rebuild. `mb` carries
330
- // the PRE-update fingerprint (its `speculative` is the prior value) so the
331
- // toggle count is correct; the caller updates the fingerprint afterward. Only
332
- // called when an onRenderMetrics hook is wired, so it stays zero-cost off.
333
- function noteRender(mb: MountedBlock, b: Block, t0: number): void {
334
- mb.renderCount++;
335
- if (mb.speculative !== b.speculative) mb.toggleCount++;
336
- client.__noteRebuild();
337
- onRenderMetrics!(b.id, {
338
- renderCount: mb.renderCount,
339
- speculativeToggleCount: mb.toggleCount,
340
- lastRenderMs: hasPerf ? performance.now() - t0 : 0,
341
- kind: b.kind.type,
342
- });
343
- }
344
-
345
- // Keyed reconcile with a single forward cursor (O(n), not O(n²)): walk the
346
- // desired order and the live children in lockstep, inserting/moving only a
347
- // node whose live position differs. The `.flux-bottom-anchor` is never part of
348
- // `order`, so it acts as the end-of-list marker — blocks always land before
349
- // it, keeping it pinned last. The common streaming case touches 1–2 tail nodes.
350
- function reconcileChildren(): void {
351
- let cursor = root.firstChild;
352
- for (let i = 0; i < order.length; i++) {
353
- const mb = mounted.get(order[i]);
354
- if (!mb) continue;
355
- const want = mb.node;
356
- if (cursor === want) {
357
- cursor = want.nextSibling; // already in place; advance
358
- continue;
359
- }
360
- // Out of place: move `want` before the cursor. When an anchor exists the
361
- // cursor never advances past it (the anchor is never a `want`), so blocks
362
- // always land before it; without one, a tail cursor of `null` appends.
363
- root.insertBefore(want, cursor);
364
- }
365
- }
366
-
367
- function renderBlock(b: Block, mb: MountedBlock): HTMLElement {
368
- const content = renderBlockContent(b, mb);
369
- // Virtualize only *closed* blocks. Unlike the JSX renderer (which wraps in
370
- // an extra div) the DOM renderer sets the properties on the block node
371
- // directly — one of the documented byte-faithfulness divergences.
372
- if (virtualize && !b.open && !b.speculative) {
373
- const px = INTRINSIC_PX[b.kind.type] ?? 120;
374
- content.style.contentVisibility = "auto";
375
- content.style.containIntrinsicSize = `auto ${px}px`;
376
- }
377
- return content;
378
- }
379
-
380
- function renderBlockContent(b: Block, mb: MountedBlock): HTMLElement {
381
- const kind = b.kind.type;
382
- lastRenderGeneric = false;
383
-
384
- // 1. Block-kind override (a Component block dispatches on its tag first).
385
- if (components) {
386
- if (kind === "Component") {
387
- const tag = (b.kind.data as { tag?: string } | undefined)?.tag;
388
- const override = (tag && components[tag]) || components.Component;
389
- if (override) return wrapOverrideResult(override(blockProps(b)));
390
- }
391
- const blockOverride = components[kind];
392
- if (blockOverride) return wrapOverrideResult(blockOverride(blockProps(b)));
393
- }
394
-
395
- // 2. Dedicated default renderers.
396
- switch (kind) {
397
- case "CodeBlock":
398
- if (highlightCode) return renderCodeBlock(b);
399
- break; // fall through to the generic path
400
- case "MathBlock":
401
- return renderMathBlock(b);
402
- case "Mermaid":
403
- return renderMermaid(b);
404
- }
405
-
406
- // 2b. Keyed-table path for the streaming tail: an OPEN table with `blockData`
407
- // renders a real `<table>` whose committed `<tr>` nodes are appended once and
408
- // frozen, so a later patch updates only the growing trailing row. Closed
409
- // tables (and blockData-off tables) take the generic full-HTML path below
410
- // (closed nodes are frozen by the fingerprint check, already free).
411
- if (kind === "Table" && b.open) {
412
- const data = tableData(b);
413
- if (data) return buildKeyedTable(b, data, mb);
414
- }
415
-
416
- // 2c. Keyed list renderer (opt-in: only when `blockData` is on, so
417
- // `kind.data.items` carries per-item inner HTML). For an OPEN list, stamp one
418
- // `<li>` per item — each item's inner HTML routed through the SAME sanitize
419
- // path the generic innerHTML branch uses — so the rebuilt list tracks the
420
- // structured items instead of re-parsing the whole `<ul>`/`<ol>` HTML. Closed
421
- // lists fall through (their node is reused untouched, never rebuilt).
422
- if (b.open && kind === "List") {
423
- const keyed = renderKeyedList(b);
424
- if (keyed) return keyed;
425
- }
426
-
427
- // 3. Generic fast path.
428
- const node = document.createElement("div");
429
- node.className = genericClassName(b);
430
- // Streaming-tail keyed path: an OPEN Blockquote / Alert with structured
431
- // `nested` data (blockData on) builds its wrapper with one child node per
432
- // inner sub-block instead of a single full-wrapper `innerHTML`. Each child's
433
- // `html` is the SAME safe-allowlist-serialized fragment as the corresponding
434
- // slice of `b.html` (no new innerHTML hole). A `sanitize` hook disables it
435
- // (it must run over the full wrapper string). Closed blocks fall through —
436
- // their node fingerprint is stable, so they are never rebuilt anyway.
437
- if (b.open && !sanitize && (kind === "Blockquote" || kind === "Alert")) {
438
- const wrapper = renderKeyedContainer(b);
439
- if (wrapper) {
440
- node.appendChild(wrapper);
441
- return node;
442
- }
443
- }
444
- node.innerHTML = sanitize ? sanitize(b.html) : b.html;
445
- // Eligible for the prefix-append fast path only when no sanitizer rewrote
446
- // the html (the stored `html` must equal the node's actual innerHTML source).
447
- lastRenderGeneric = !sanitize;
448
- return node;
449
- }
450
-
451
- // Build a `<div class="flux-block flux-block-list flux-open …"><ul|ol>…</ul|ol>`
452
- // node from the structured `kind.data.items`, one `<li>` per item with its inner
453
- // HTML sanitized via the shared `sanitize` path. Returns `null` when the items
454
- // channel is absent (blockData off) so the caller falls back to opaque HTML.
455
- function renderKeyedList(b: Block): HTMLElement | null {
456
- const ld = b.kind.data as ListData | undefined;
457
- const items = ld?.items;
458
- if (!Array.isArray(items) || items.length === 0) return null;
459
- const node = document.createElement("div");
460
- node.className =
461
- "flux-block flux-block-list" +
462
- (b.open ? " flux-open" : "") +
463
- (b.speculative ? " flux-speculative" : "");
464
- const list = document.createElement(ld?.ordered ? "ol" : "ul");
465
- if (ld?.ordered && ld.start !== undefined && ld.start !== 1) {
466
- list.setAttribute("start", String(ld.start));
467
- }
468
- for (const it of items) {
469
- const li = document.createElement("li");
470
- li.innerHTML = sanitize ? sanitize(it.html) : it.html;
471
- list.appendChild(li);
472
- }
473
- node.appendChild(list);
474
- return node;
475
- }
476
-
477
- // Build a Blockquote / Alert wrapper with KEYED inner sub-block nodes from the
478
- // structured `nested` channel. The wrapper element + its attributes (`dir`/
479
- // `class`/`data-alert`/`role`) come from `b.html`'s opening tag so the streamed
480
- // wrapper is byte-faithful; the alert title `<p>` is kept as the first child
481
- // (it is the wrapper, not a body block). Returns null when `nested` is absent.
482
- function renderKeyedContainer(b: Block): HTMLElement | null {
483
- const nested = (b.kind.data as { nested?: { html: string }[] } | undefined)?.nested;
484
- if (!Array.isArray(nested)) return null;
485
- const tagName = b.kind.type === "Alert" ? "div" : "blockquote";
486
- const wrapper = document.createElement(tagName);
487
- applyOpenTagAttrs(wrapper, b.html);
488
- if (b.kind.type === "Alert") {
489
- const title = alertTitleHtml(b.html);
490
- if (title) {
491
- const t = document.createElement("div");
492
- t.innerHTML = title;
493
- const titleNode = t.firstElementChild;
494
- if (titleNode) wrapper.appendChild(titleNode);
495
- }
496
- }
497
- for (let i = 0; i < nested.length; i++) {
498
- const child = document.createElement("div");
499
- child.innerHTML = nested[i].html;
500
- const inner = child.firstElementChild;
501
- // A nested block is a single root element (`<p>…</p>`, `<ul>…</ul>`, …);
502
- // unwrap the temp `<div>` so the wrapper holds the real element directly.
503
- wrapper.appendChild(inner ?? child);
504
- }
505
- return wrapper;
506
- }
507
-
508
- // Build the initial keyed table node + manager. The `<thead>` and all-but-last
509
- // `<tr>` are emitted once; the manager remembers the committed row count so a
510
- // later patch (via syncTbody) only re-renders the open trailing row.
511
- function buildKeyedTable(b: Block, data: TableData, mb: MountedBlock): HTMLElement {
512
- const node = document.createElement("div");
513
- node.className = "flux-block flux-block-table flux-open" + (b.speculative ? " flux-speculative" : "");
514
- const table = document.createElement("table");
515
- if (b.html.startsWith('<table dir="auto"')) table.setAttribute("dir", "auto");
516
- const scope = b.html.includes('<th scope="col"');
517
-
518
- const thead = document.createElement("thead");
519
- const htr = document.createElement("tr");
520
- for (let j = 0; j < data.headers.length; j++) {
521
- htr.appendChild(makeCell("th", data.headers[j].html, data.aligns[j] ?? null, scope));
522
- }
523
- thead.appendChild(htr);
524
- table.appendChild(thead);
525
-
526
- const km: KeyedTable = { table, tbody: null, scope, committed: 0, lastRow: null };
527
- mb.table = km;
528
- node.appendChild(table);
529
- syncTbody(km, data);
530
- return node;
531
- }
532
-
533
- // Append any newly-committed rows once, then (re)render only the open trailing
534
- // row. Shared by build (committed===0) and update. The whole `<tbody>` is never
535
- // rebuilt — committed `<tr>` nodes keep their identity across patches.
536
- function syncTbody(km: KeyedTable, data: TableData): void {
537
- const n = data.rows.length;
538
- if (n === 0) {
539
- // No body rows yet (header-only streamed table). Tear down any stale tbody.
540
- if (km.tbody) {
541
- km.tbody.remove();
542
- km.tbody = null;
543
- }
544
- km.committed = 0;
545
- km.lastRow = null;
546
- return;
547
- }
548
- if (!km.tbody) {
549
- km.tbody = document.createElement("tbody");
550
- km.table.appendChild(km.tbody);
551
- }
552
- const tbody = km.tbody;
553
- // The prior open trailing row is now superseded — drop it before freezing the
554
- // rows that have since committed and rendering the new trailing row.
555
- if (km.lastRow) {
556
- km.lastRow.remove();
557
- km.lastRow = null;
558
- }
559
- // Freeze every row from the first uncommitted up to (but not including) the
560
- // last: append its `<tr>` once and never touch it again (committed cell html
561
- // is byte-stable).
562
- for (let i = km.committed; i < n - 1; i++) {
563
- tbody.appendChild(makeRow(data.rows[i], data.aligns));
564
- }
565
- km.committed = n - 1;
566
- // Render the still-OPEN last row and remember it so the next patch replaces it.
567
- const last = makeRow(data.rows[n - 1], data.aligns);
568
- tbody.appendChild(last);
569
- km.lastRow = last;
570
- }
571
-
572
- function makeRow(cells: TableData["rows"][number], aligns: Align[]): HTMLTableRowElement {
573
- const tr = document.createElement("tr");
574
- for (let j = 0; j < cells.length; j++) {
575
- tr.appendChild(makeCell("td", cells[j].html, aligns[j] ?? null, false));
576
- }
577
- return tr;
578
- }
579
-
580
- function makeCell(tag: "th" | "td", html: string, align: Align, scope: boolean): HTMLElement {
581
- const cell = document.createElement(tag);
582
- if (tag === "th" && scope) cell.setAttribute("scope", "col");
583
- if (align) cell.style.textAlign = align;
584
- // Route cell html through the same sanitize path the generic block uses.
585
- cell.innerHTML = sanitize ? sanitize(html) : html;
586
- return cell;
587
- }
588
-
589
- // The class string for a generic-path block node. Shared by the initial
590
- // render and the in-place morph branch so a morphed node keeps the exact
591
- // class string (e.g. dropping `flux-speculative`) a rebuild would have set.
592
- function genericClassName(b: Block): string {
593
- return (
594
- "flux-block flux-block-" +
595
- b.kind.type.toLowerCase() +
596
- (b.open ? " flux-open" : "") +
597
- (b.speculative ? " flux-speculative" : "")
598
- );
599
- }
600
-
601
- // True when a block renders through the generic `innerHTML` fast path — the
602
- // only path the in-place morph applies to. Mirrors the dispatch order in
603
- // renderBlockContent: an override (block-kind or Component tag) or a dedicated
604
- // renderer (highlighted code / math / mermaid) all opt OUT of morphing.
605
- function usesGenericPath(b: Block): boolean {
606
- const kind = b.kind.type;
607
- if (components) {
608
- if (kind === "Component") {
609
- const tag = (b.kind.data as { tag?: string } | undefined)?.tag;
610
- if ((tag && components[tag]) || components.Component) return false;
611
- }
612
- if (components[kind]) return false;
613
- }
614
- if (kind === "CodeBlock") return !highlightCode;
615
- if (kind === "MathBlock" || kind === "Mermaid") return false;
616
- return true;
617
- }
618
-
619
- // An override may return an element (used directly) or an HTML string (wrapped
620
- // in a div so the renderer always owns a single block node to track/swap).
621
- function wrapOverrideResult(result: HTMLElement | string): HTMLElement {
622
- if (typeof result === "string") {
623
- const node = document.createElement("div");
624
- node.innerHTML = result;
625
- return node;
626
- }
627
- return result;
628
- }
629
-
630
- function renderCodeBlock(b: Block): HTMLElement {
631
- const lang = extractLang(b.html) || "text";
632
- // Mirror CodeBlock.tsx: text is "" while open, so the body falls to the raw
633
- // `<div>` path; a closed block decodes once and highlights once. The node is
634
- // frozen once closed, so highlight runs exactly once (no re-tokenize).
635
- const text = b.open ? "" : decodeCodeText(b.html);
636
- const highlighted = text ? highlight(text, lang) : null;
637
-
638
- const block = document.createElement("div");
639
- block.className = "flux-code-block" + (b.open ? " flux-streaming" : "");
640
-
641
- const header = document.createElement("div");
642
- header.className = "flux-code-header";
643
- const langSpan = document.createElement("span");
644
- langSpan.className = "flux-code-lang";
645
- langSpan.textContent = lang;
646
- header.appendChild(langSpan);
647
-
648
- if (b.open) {
649
- const pill = document.createElement("span");
650
- pill.className = "flux-code-streaming-pill";
651
- pill.textContent = "streaming";
652
- header.appendChild(pill);
653
- } else {
654
- header.appendChild(makeCopyButton(text));
655
- }
656
- block.appendChild(header);
657
-
658
- const body = document.createElement("div");
659
- body.className = "flux-code-body";
660
- if (highlighted) {
661
- const pre = document.createElement("pre");
662
- pre.tabIndex = 0;
663
- pre.setAttribute("role", "region");
664
- pre.setAttribute("aria-label", `${lang} code`);
665
- const code = document.createElement("code");
666
- code.innerHTML = highlighted;
667
- pre.appendChild(code);
668
- body.appendChild(pre);
669
- } else {
670
- const div = document.createElement("div");
671
- div.tabIndex = 0;
672
- div.setAttribute("role", "region");
673
- div.setAttribute("aria-label", `${lang} code`);
674
- div.innerHTML = b.html;
675
- body.appendChild(div);
676
- }
677
- block.appendChild(body);
678
- return block;
679
- }
680
-
681
- function renderMathBlock(b: Block): HTMLElement {
682
- const block = document.createElement("div");
683
- block.className = "flux-math-block" + (b.open ? " flux-streaming" : "");
684
- const header = document.createElement("div");
685
- header.className = "flux-math-header";
686
- const lang = document.createElement("span");
687
- lang.className = "flux-math-lang";
688
- lang.textContent = "math";
689
- header.appendChild(lang);
690
- if (b.open) header.appendChild(streamingPill());
691
- block.appendChild(header);
692
- const body = document.createElement("div");
693
- body.className = "flux-math-body";
694
- body.innerHTML = b.html;
695
- block.appendChild(body);
696
- return block;
697
- }
698
-
699
- function renderMermaid(b: Block): HTMLElement {
700
- const block = document.createElement("div");
701
- block.className = "flux-mermaid-block" + (b.open ? " flux-streaming" : "");
702
- const header = document.createElement("div");
703
- header.className = "flux-mermaid-header";
704
- const lang = document.createElement("span");
705
- lang.className = "flux-mermaid-lang";
706
- lang.textContent = "mermaid";
707
- header.appendChild(lang);
708
- if (b.open) header.appendChild(streamingPill());
709
- block.appendChild(header);
710
- const body = document.createElement("div");
711
- body.className = "flux-mermaid-body";
712
- body.innerHTML = b.html;
713
- block.appendChild(body);
714
- return block;
715
- }
716
-
717
- function streamingPill(): HTMLElement {
718
- const pill = document.createElement("span");
719
- pill.className = "flux-code-streaming-pill";
720
- pill.textContent = "streaming";
721
- return pill;
722
- }
723
-
724
- // SVG markup uses the live-DOM attribute form (hyphenated, e.g. stroke-width).
725
- const COPY_ICON =
726
- '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15V5a2 2 0 0 1 2-2h10"></path></svg><span>Copy</span>';
727
- const COPIED_ICON =
728
- '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg><span>Copied</span>';
729
-
730
- function makeCopyButton(text: string): HTMLElement {
731
- const btn = document.createElement("button");
732
- btn.type = "button";
733
- btn.className = "flux-code-copy";
734
- btn.setAttribute("aria-label", "Copy code");
735
- btn.setAttribute("aria-live", "polite");
736
- btn.innerHTML = COPY_ICON;
737
- // The listener lives as long as the node. A closed block's node is never
738
- // recreated (frozen fingerprint), so there is no per-patch rebind; it is
739
- // GC'd when `root` is removed.
740
- let timer: ReturnType<typeof setTimeout> | null = null;
741
- btn.addEventListener("click", () => {
742
- const clip = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
743
- if (!clip || !clip.writeText || !text) return;
744
- clip.writeText(text).then(
745
- () => {
746
- btn.setAttribute("aria-label", "Copied");
747
- btn.innerHTML = COPIED_ICON;
748
- if (timer !== null) clearTimeout(timer);
749
- timer = setTimeout(() => {
750
- btn.setAttribute("aria-label", "Copy code");
751
- btn.innerHTML = COPY_ICON;
752
- }, 1500);
753
- },
754
- // Permission denied / blocked: stay silent, leave button usable.
755
- () => {},
756
- );
757
- });
758
- return btn;
759
- }
760
-
761
- const unsubscribe = client.subscribe(() => {
762
- if (dead) return;
763
- if (batch) {
764
- if (frame === 0) frame = requestAnimationFrame(flush);
765
- } else {
766
- sync();
767
- }
768
- });
769
-
770
- function flush(): void {
771
- frame = 0;
772
- sync();
773
- }
774
-
775
- // Initial render from whatever is already in the snapshot.
776
- sync();
777
-
778
- return {
779
- destroy() {
780
- if (dead) return;
781
- dead = true;
782
- if (frame !== 0) {
783
- cancelAnimationFrame(frame);
784
- frame = 0;
785
- }
786
- unsubscribe();
787
- // The caller owns the worker/stream — never call client.destroy() here
788
- // (same contract as the JSX renderer: unmounting never destroys the client).
789
- root.remove();
790
- },
791
- refresh() {
792
- if (dead) return;
793
- sync();
794
- },
795
- openBlockId() {
796
- return tailOpenBlockId(client.getSnapshot());
797
- },
798
- };
799
- }
800
-
801
- // The structured `TableData` (opt-in `blockData`) on a Table block, or
802
- // `undefined` when the flag is off (the keyed path then falls back to full HTML).
803
- function tableData(b: Block): TableData | undefined {
804
- if (b.kind.type !== "Table") return undefined;
805
- const data = b.kind.data as TableData | undefined;
806
- // Validate the shapes the keyed path indexes (rows/aligns/headers) so a
807
- // drifted/malformed blockData wire falls back to the full-HTML path instead
808
- // of crashing makeRow/makeCell on `aligns[j]`/`headers.map` of undefined.
809
- if (
810
- !data ||
811
- !Array.isArray(data.rows) ||
812
- !Array.isArray(data.aligns) ||
813
- !Array.isArray(data.headers)
814
- ) {
815
- return undefined;
816
- }
817
- return data;
818
- }
819
-
820
- // HTML void elements: they self-terminate, so they never push element depth.
821
- const VOID_ELEMENTS = new Set([
822
- "area", "base", "br", "col", "embed", "hr", "img", "input", "link",
823
- "meta", "param", "source", "track", "wbr",
824
- ]);
825
-
826
- /**
827
- * True when `prefix` is a complete run of balanced top-level markup (element
828
- * depth returns to 0 at its end and any trailing whitespace/text is harmless)
829
- * AND the appended suffix `full.slice(prefix.length)` begins a NEW depth-0
830
- * sibling element (an opening tag, not a close tag / text / mid-tag).
831
- *
832
- * When both hold, splicing the suffix onto the live node via
833
- * `insertAdjacentHTML('beforeend', suffix)` yields the exact same DOM the
834
- * browser would build from parsing `full` whole — the appended markup is a
835
- * self-contained sibling appended after the last existing child. Any other
836
- * shape (an unclosed element at the prefix boundary, a suffix that continues
837
- * text, closes a tag, or splits a tag) must fall back to a full rebuild.
838
- *
839
- * The scan is single-pass over `prefix` (O(prefix length)); it is run only on a
840
- * confirmed `startsWith` prefix extension, so the amortized streaming cost stays
841
- * proportional to the bytes seen.
842
- */
843
- function isDepth0Boundary(prefix: string, full: string): boolean {
844
- // Suffix must open a new element: '<' immediately followed by an ASCII letter.
845
- const c0 = full.charCodeAt(prefix.length);
846
- if (c0 !== 60 /* '<' */) return false;
847
- const c1 = full.charCodeAt(prefix.length + 1);
848
- const isLetter = (c1 >= 65 && c1 <= 90) || (c1 >= 97 && c1 <= 122);
849
- if (!isLetter) return false;
850
-
851
- // Walk `prefix`, tracking element depth. Bail (return false) on anything we
852
- // cannot cheaply prove balanced: comments, CDATA, processing instructions,
853
- // or any tag that leaves the cursor inside markup at the end.
854
- let depth = 0;
855
- let i = 0;
856
- const n = prefix.length;
857
- while (i < n) {
858
- const lt = prefix.indexOf("<", i);
859
- if (lt === -1) break; // only text remains; depth unchanged
860
- i = lt + 1;
861
- if (i >= n) return false; // trailing '<' with nothing after → mid-tag
862
- const ch = prefix.charCodeAt(i);
863
- // Comments / CDATA / declarations / PIs: not handled — fall back.
864
- if (ch === 33 /* '!' */ || ch === 63 /* '?' */) return false;
865
- let closing = false;
866
- if (ch === 47 /* '/' */) {
867
- closing = true;
868
- i++;
869
- }
870
- // Read the tag name.
871
- const nameStart = i;
872
- while (i < n) {
873
- const t = prefix.charCodeAt(i);
874
- const nameChar =
875
- (t >= 65 && t <= 90) || (t >= 97 && t <= 122) || (t >= 48 && t <= 57) || t === 45;
876
- if (!nameChar) break;
877
- i++;
878
- }
879
- if (i === nameStart) return false; // '<' not followed by a tag name
880
- const name = prefix.slice(nameStart, i).toLowerCase();
881
- // Find the tag's '>' (attribute values here never contain a literal '>'
882
- // because the renderer emits entity-escaped attributes; if we hit EOF first
883
- // the prefix ends mid-tag → not a boundary).
884
- const gt = prefix.indexOf(">", i);
885
- if (gt === -1) return false;
886
- const selfClosing = prefix.charCodeAt(gt - 1) === 47; /* '/' */
887
- i = gt + 1;
888
- if (closing) {
889
- depth--;
890
- if (depth < 0) return false; // unbalanced close
891
- } else if (!selfClosing && !VOID_ELEMENTS.has(name)) {
892
- depth++;
893
- }
894
- }
895
- return depth === 0;
896
- }
897
-
898
- /**
899
- * Derive the streaming tail's block id from an ordered snapshot: the id of the
900
- * last block when it is open, else `null`. The open block is always the tail by
901
- * construction (the parser only keeps the final block speculative/open), so this
902
- * is an O(1) read of the last element — no scan. Shared so the framework
903
- * adapters expose the same "what may re-render next" signal as the DOM handle.
904
- */
905
- export function tailOpenBlockId(snapshot: readonly Block[]): number | null {
906
- const tail = snapshot.length > 0 ? snapshot[snapshot.length - 1] : undefined;
907
- return tail && tail.open ? tail.id : null;
908
- }
909
-
910
- // Local copy of the canonical code-text decoder (kept here so dom.ts depends
911
- // only on neutral modules; block-props.ts keeps its own private copy too).
912
- function decodeCodeText(html: string): string {
913
- const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
914
- if (!m) return "";
915
- return m[1]
916
- .replace(/&lt;/g, "<")
917
- .replace(/&gt;/g, ">")
918
- .replace(/&quot;/g, '"')
919
- .replace(/&#39;/g, "'")
920
- .replace(/&amp;/g, "&");
921
- }
922
-
923
- // Attributes the Rust renderer emits on a blockquote / alert wrapper open tag
924
- // (`dir`/`class`/`data-alert`/`role`). Whitelisted (not a generic HTML parser):
925
- // only these names are forwarded onto the keyed wrapper element so it is
926
- // byte-faithful to the full-wrapper innerHTML path.
927
- const CONTAINER_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
928
- function applyOpenTagAttrs(el: HTMLElement, html: string): void {
929
- const gt = html.indexOf(">");
930
- const open = gt < 0 ? html : html.slice(0, gt);
931
- let m: RegExpExecArray | null;
932
- CONTAINER_ATTR_RE.lastIndex = 0;
933
- while ((m = CONTAINER_ATTR_RE.exec(open))) {
934
- const name = m[1].toLowerCase();
935
- if (name === "class" || name === "dir" || name === "role" || name.startsWith("data-")) {
936
- el.setAttribute(name, m[2]);
937
- }
938
- }
939
- }
940
-
941
- // Extract an alert's title `<p class="markdown-alert-title"…>Title</p>` from the
942
- // wrapper HTML so the keyed path keeps it as the first child (never in `nested`).
943
- function alertTitleHtml(html: string): string {
944
- const m = html.match(/<p class="markdown-alert-title"[^>]*>[\s\S]*?<\/p>/);
945
- return m ? m[0] : "";
946
- }