flux-md 0.5.5 → 0.6.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/src/dom.ts ADDED
@@ -0,0 +1,430 @@
1
+ import type { FluxClient } from "./client";
2
+ import { highlight } from "./hi";
3
+ import type { Block, BlockComponentProps, BlockKindTag } from "./types-core";
4
+ import { blockProps, extractLang } from "./block-props";
5
+
6
+ /**
7
+ * Framework-neutral DOM renderer for a {@link FluxClient}. Mounts the streaming
8
+ * document into a container and keeps it in sync via direct DOM mutation,
9
+ * mirroring the JSX renderer's block model: each block is keyed by its stable
10
+ * parser-assigned id, and a committed block's node is reused untouched on every
11
+ * later patch (the parity analogue of the JSX renderer's block memo). Only the
12
+ * streaming tail is rebuilt.
13
+ *
14
+ * This is the foundation the Web Component / Vue / Svelte / Solid bindings
15
+ * build on; it imports only neutral modules and carries no framework dependency.
16
+ *
17
+ * ## Custom components
18
+ *
19
+ * Pass `components` to override a whole block kind (or a component tag). Keys
20
+ * are capitalized block-kind names (`CodeBlock`, `Table`, `Mermaid`…) or, for
21
+ * `Component` blocks, the tag name (e.g. `Thinking`) with `Component` as the
22
+ * generic fallback. A component receives {@link BlockComponentProps} and returns
23
+ * an `HTMLElement` or an HTML string. There is no tag-level override path (no
24
+ * `table`/`a`/`code` keys) — that requires an HTML→tree pass the DOM renderer
25
+ * doesn't carry.
26
+ */
27
+
28
+ export interface MountHandle {
29
+ destroy(): void;
30
+ refresh(): void;
31
+ }
32
+
33
+ export type DomBlockComponent = (props: BlockComponentProps) => HTMLElement | string;
34
+
35
+ /** Override map: capitalized block-kind / component-tag keys only. */
36
+ export type DomComponents = Record<string, DomBlockComponent>;
37
+
38
+ export interface MountOptions {
39
+ components?: DomComponents;
40
+ /**
41
+ * Optional HTML sanitizer applied to every generic block's HTML before it is
42
+ * injected via `innerHTML` — **including the streaming (open/speculative)
43
+ * tail**. The built-in code/math/mermaid renderers operate on already-escaped
44
+ * content and are not run through it (same as the JSX renderer). When omitted,
45
+ * rendering is byte-identical and zero-cost.
46
+ */
47
+ sanitize?: (html: string) => string;
48
+ /**
49
+ * Skip layout/paint for off-screen *closed* blocks via CSS
50
+ * `content-visibility: auto` (for very long documents). Off by default.
51
+ */
52
+ virtualize?: boolean;
53
+ /**
54
+ * Keep a bottom snap target so the view follows the streaming tail. CSS-only:
55
+ * emits a sentinel with `scroll-snap-align: end`; you add
56
+ * `scroll-snap-type: y proximity` to your scroll container. Off by default.
57
+ */
58
+ stickToBottom?: boolean;
59
+ /** Use the built-in code highlighter. Default true; suppressed when a
60
+ * `components.CodeBlock` override is supplied. */
61
+ highlightCode?: boolean;
62
+ /** Coalesce patches into one DOM write per animation frame. Default true. */
63
+ batch?: boolean;
64
+ }
65
+
66
+ // Per-kind off-screen size estimate for `contain-intrinsic-size`. Duplicated
67
+ // verbatim from the JSX renderer so per-kind virtualization sizes match.
68
+ const INTRINSIC_PX: Record<string, number> = {
69
+ Paragraph: 80, Heading: 44, CodeBlock: 300, MathBlock: 140, Mermaid: 220,
70
+ List: 120, Blockquote: 100, Alert: 120, Table: 200, Rule: 24, Html: 80,
71
+ Component: 120,
72
+ };
73
+
74
+ // The fingerprint that decides whether a block's node may be reused: exactly
75
+ // what the JSX renderer's block memo checks, minus `id` (the map key).
76
+ interface MountedBlock {
77
+ id: number;
78
+ node: HTMLElement;
79
+ html: string;
80
+ open: boolean;
81
+ speculative: boolean;
82
+ kind: BlockKindTag;
83
+ }
84
+
85
+ export function mountFluxMarkdown(
86
+ client: FluxClient,
87
+ container: HTMLElement,
88
+ options: MountOptions = {},
89
+ ): MountHandle {
90
+ if (typeof document === "undefined") {
91
+ throw new Error("mountFluxMarkdown is browser-only; call it after the DOM exists.");
92
+ }
93
+
94
+ // Normalize "no overrides" to undefined so the fast path doesn't churn.
95
+ const components =
96
+ options.components && Object.keys(options.components).length > 0 ? options.components : undefined;
97
+ const { sanitize, virtualize, stickToBottom } = options;
98
+ const highlightCode = options.highlightCode !== false && !components?.CodeBlock;
99
+ const batch = options.batch !== false && typeof requestAnimationFrame === "function";
100
+
101
+ const root = document.createElement("div");
102
+ root.className = "flux-md";
103
+ container.appendChild(root);
104
+
105
+ // CSS-only stick-to-bottom: a permanent sentinel pinned as the last child.
106
+ let anchor: HTMLElement | null = null;
107
+ if (stickToBottom) {
108
+ anchor = document.createElement("div");
109
+ anchor.className = "flux-bottom-anchor";
110
+ anchor.setAttribute("aria-hidden", "true");
111
+ anchor.style.scrollSnapAlign = "end";
112
+ root.appendChild(anchor);
113
+ }
114
+
115
+ const mounted = new Map<number, MountedBlock>();
116
+ let order: number[] = [];
117
+ let dead = false;
118
+ let frame = 0;
119
+
120
+ function sync(): void {
121
+ if (dead) return;
122
+ const snapshot = client.getSnapshot();
123
+ const nextOrder: number[] = new Array(snapshot.length);
124
+ const seen = new Set<number>();
125
+
126
+ for (let i = 0; i < snapshot.length; i++) {
127
+ const b = snapshot[i];
128
+ nextOrder[i] = b.id;
129
+ seen.add(b.id);
130
+ const existing = mounted.get(b.id);
131
+ if (!existing) {
132
+ const node = renderBlock(b);
133
+ mounted.set(b.id, {
134
+ id: b.id, node, html: b.html, open: b.open, speculative: b.speculative, kind: b.kind.type,
135
+ });
136
+ continue;
137
+ }
138
+ // Unchanged fingerprint → reuse the node untouched. Committed blocks land
139
+ // here forever: their node is never recreated, so any one-shot work
140
+ // (highlight, copy listener) runs exactly once. This is the whole point.
141
+ if (existing.html === b.html && existing.open === b.open && existing.speculative === b.speculative) {
142
+ continue;
143
+ }
144
+ // Changed → rebuild and swap in place.
145
+ const node = renderBlock(b);
146
+ existing.node.replaceWith(node);
147
+ existing.node = node;
148
+ existing.html = b.html;
149
+ existing.open = b.open;
150
+ existing.speculative = b.speculative;
151
+ existing.kind = b.kind.type;
152
+ }
153
+
154
+ // Drop ids no longer present (reset() empties the snapshot; a speculative
155
+ // revision can drop a tail block).
156
+ if (mounted.size > seen.size) {
157
+ for (const [id, mb] of mounted) {
158
+ if (!seen.has(id)) {
159
+ mb.node.remove();
160
+ mounted.delete(id);
161
+ }
162
+ }
163
+ }
164
+
165
+ order = nextOrder;
166
+ reconcileChildren();
167
+ }
168
+
169
+ // Keyed reconcile with a single forward cursor (O(n), not O(n²)): walk the
170
+ // desired order and the live children in lockstep, inserting/moving only a
171
+ // node whose live position differs. The `.flux-bottom-anchor` is never part of
172
+ // `order`, so it acts as the end-of-list marker — blocks always land before
173
+ // it, keeping it pinned last. The common streaming case touches 1–2 tail nodes.
174
+ function reconcileChildren(): void {
175
+ let cursor = root.firstChild;
176
+ for (let i = 0; i < order.length; i++) {
177
+ const mb = mounted.get(order[i]);
178
+ if (!mb) continue;
179
+ const want = mb.node;
180
+ if (cursor === want) {
181
+ cursor = want.nextSibling; // already in place; advance
182
+ continue;
183
+ }
184
+ // Out of place: move `want` before the cursor. When an anchor exists the
185
+ // cursor never advances past it (the anchor is never a `want`), so blocks
186
+ // always land before it; without one, a tail cursor of `null` appends.
187
+ root.insertBefore(want, cursor);
188
+ }
189
+ }
190
+
191
+ function renderBlock(b: Block): HTMLElement {
192
+ const content = renderBlockContent(b);
193
+ // Virtualize only *closed* blocks. Unlike the JSX renderer (which wraps in
194
+ // an extra div) the DOM renderer sets the properties on the block node
195
+ // directly — one of the documented byte-faithfulness divergences.
196
+ if (virtualize && !b.open && !b.speculative) {
197
+ const px = INTRINSIC_PX[b.kind.type] ?? 120;
198
+ content.style.contentVisibility = "auto";
199
+ content.style.containIntrinsicSize = `auto ${px}px`;
200
+ }
201
+ return content;
202
+ }
203
+
204
+ function renderBlockContent(b: Block): HTMLElement {
205
+ const kind = b.kind.type;
206
+
207
+ // 1. Block-kind override (a Component block dispatches on its tag first).
208
+ if (components) {
209
+ if (kind === "Component") {
210
+ const tag = (b.kind.data as { tag?: string } | undefined)?.tag;
211
+ const override = (tag && components[tag]) || components.Component;
212
+ if (override) return wrapOverrideResult(override(blockProps(b)));
213
+ }
214
+ const blockOverride = components[kind];
215
+ if (blockOverride) return wrapOverrideResult(blockOverride(blockProps(b)));
216
+ }
217
+
218
+ // 2. Dedicated default renderers.
219
+ switch (kind) {
220
+ case "CodeBlock":
221
+ if (highlightCode) return renderCodeBlock(b);
222
+ break; // fall through to the generic path
223
+ case "MathBlock":
224
+ return renderMathBlock(b);
225
+ case "Mermaid":
226
+ return renderMermaid(b);
227
+ }
228
+
229
+ // 3. Generic fast path.
230
+ const node = document.createElement("div");
231
+ node.className =
232
+ "flux-block flux-block-" +
233
+ kind.toLowerCase() +
234
+ (b.open ? " flux-open" : "") +
235
+ (b.speculative ? " flux-speculative" : "");
236
+ node.innerHTML = sanitize ? sanitize(b.html) : b.html;
237
+ return node;
238
+ }
239
+
240
+ // An override may return an element (used directly) or an HTML string (wrapped
241
+ // in a div so the renderer always owns a single block node to track/swap).
242
+ function wrapOverrideResult(result: HTMLElement | string): HTMLElement {
243
+ if (typeof result === "string") {
244
+ const node = document.createElement("div");
245
+ node.innerHTML = result;
246
+ return node;
247
+ }
248
+ return result;
249
+ }
250
+
251
+ function renderCodeBlock(b: Block): HTMLElement {
252
+ const lang = extractLang(b.html) || "text";
253
+ // Mirror CodeBlock.tsx: text is "" while open, so the body falls to the raw
254
+ // `<div>` path; a closed block decodes once and highlights once. The node is
255
+ // frozen once closed, so highlight runs exactly once (no re-tokenize).
256
+ const text = b.open ? "" : decodeCodeText(b.html);
257
+ const highlighted = text ? highlight(text, lang) : null;
258
+
259
+ const block = document.createElement("div");
260
+ block.className = "flux-code-block" + (b.open ? " flux-streaming" : "");
261
+
262
+ const header = document.createElement("div");
263
+ header.className = "flux-code-header";
264
+ const langSpan = document.createElement("span");
265
+ langSpan.className = "flux-code-lang";
266
+ langSpan.textContent = lang;
267
+ header.appendChild(langSpan);
268
+
269
+ if (b.open) {
270
+ const pill = document.createElement("span");
271
+ pill.className = "flux-code-streaming-pill";
272
+ pill.textContent = "streaming";
273
+ header.appendChild(pill);
274
+ } else {
275
+ header.appendChild(makeCopyButton(text));
276
+ }
277
+ block.appendChild(header);
278
+
279
+ const body = document.createElement("div");
280
+ body.className = "flux-code-body";
281
+ if (highlighted) {
282
+ const pre = document.createElement("pre");
283
+ pre.tabIndex = 0;
284
+ pre.setAttribute("role", "region");
285
+ pre.setAttribute("aria-label", `${lang} code`);
286
+ const code = document.createElement("code");
287
+ code.innerHTML = highlighted;
288
+ pre.appendChild(code);
289
+ body.appendChild(pre);
290
+ } else {
291
+ const div = document.createElement("div");
292
+ div.tabIndex = 0;
293
+ div.setAttribute("role", "region");
294
+ div.setAttribute("aria-label", `${lang} code`);
295
+ div.innerHTML = b.html;
296
+ body.appendChild(div);
297
+ }
298
+ block.appendChild(body);
299
+ return block;
300
+ }
301
+
302
+ function renderMathBlock(b: Block): HTMLElement {
303
+ const block = document.createElement("div");
304
+ block.className = "flux-math-block" + (b.open ? " flux-streaming" : "");
305
+ const header = document.createElement("div");
306
+ header.className = "flux-math-header";
307
+ const lang = document.createElement("span");
308
+ lang.className = "flux-math-lang";
309
+ lang.textContent = "math";
310
+ header.appendChild(lang);
311
+ if (b.open) header.appendChild(streamingPill());
312
+ block.appendChild(header);
313
+ const body = document.createElement("div");
314
+ body.className = "flux-math-body";
315
+ body.innerHTML = b.html;
316
+ block.appendChild(body);
317
+ return block;
318
+ }
319
+
320
+ function renderMermaid(b: Block): HTMLElement {
321
+ const block = document.createElement("div");
322
+ block.className = "flux-mermaid-block" + (b.open ? " flux-streaming" : "");
323
+ const header = document.createElement("div");
324
+ header.className = "flux-mermaid-header";
325
+ const lang = document.createElement("span");
326
+ lang.className = "flux-mermaid-lang";
327
+ lang.textContent = "mermaid";
328
+ header.appendChild(lang);
329
+ if (b.open) header.appendChild(streamingPill());
330
+ block.appendChild(header);
331
+ const body = document.createElement("div");
332
+ body.className = "flux-mermaid-body";
333
+ body.innerHTML = b.html;
334
+ block.appendChild(body);
335
+ return block;
336
+ }
337
+
338
+ function streamingPill(): HTMLElement {
339
+ const pill = document.createElement("span");
340
+ pill.className = "flux-code-streaming-pill";
341
+ pill.textContent = "streaming";
342
+ return pill;
343
+ }
344
+
345
+ // SVG markup uses the live-DOM attribute form (hyphenated, e.g. stroke-width).
346
+ const COPY_ICON =
347
+ '<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>';
348
+ const COPIED_ICON =
349
+ '<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>';
350
+
351
+ function makeCopyButton(text: string): HTMLElement {
352
+ const btn = document.createElement("button");
353
+ btn.type = "button";
354
+ btn.className = "flux-code-copy";
355
+ btn.setAttribute("aria-label", "Copy code");
356
+ btn.setAttribute("aria-live", "polite");
357
+ btn.innerHTML = COPY_ICON;
358
+ // The listener lives as long as the node. A closed block's node is never
359
+ // recreated (frozen fingerprint), so there is no per-patch rebind; it is
360
+ // GC'd when `root` is removed.
361
+ let timer: ReturnType<typeof setTimeout> | null = null;
362
+ btn.addEventListener("click", () => {
363
+ const clip = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
364
+ if (!clip || !clip.writeText || !text) return;
365
+ clip.writeText(text).then(
366
+ () => {
367
+ btn.setAttribute("aria-label", "Copied");
368
+ btn.innerHTML = COPIED_ICON;
369
+ if (timer !== null) clearTimeout(timer);
370
+ timer = setTimeout(() => {
371
+ btn.setAttribute("aria-label", "Copy code");
372
+ btn.innerHTML = COPY_ICON;
373
+ }, 1500);
374
+ },
375
+ // Permission denied / blocked: stay silent, leave button usable.
376
+ () => {},
377
+ );
378
+ });
379
+ return btn;
380
+ }
381
+
382
+ const unsubscribe = client.subscribe(() => {
383
+ if (dead) return;
384
+ if (batch) {
385
+ if (frame === 0) frame = requestAnimationFrame(flush);
386
+ } else {
387
+ sync();
388
+ }
389
+ });
390
+
391
+ function flush(): void {
392
+ frame = 0;
393
+ sync();
394
+ }
395
+
396
+ // Initial render from whatever is already in the snapshot.
397
+ sync();
398
+
399
+ return {
400
+ destroy() {
401
+ if (dead) return;
402
+ dead = true;
403
+ if (frame !== 0) {
404
+ cancelAnimationFrame(frame);
405
+ frame = 0;
406
+ }
407
+ unsubscribe();
408
+ // The caller owns the worker/stream — never call client.destroy() here
409
+ // (same contract as the JSX renderer: unmounting never destroys the client).
410
+ root.remove();
411
+ },
412
+ refresh() {
413
+ if (dead) return;
414
+ sync();
415
+ },
416
+ };
417
+ }
418
+
419
+ // Local copy of the canonical code-text decoder (kept here so dom.ts depends
420
+ // only on neutral modules; block-props.ts keeps its own private copy too).
421
+ function decodeCodeText(html: string): string {
422
+ const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
423
+ if (!m) return "";
424
+ return m[1]
425
+ .replace(/&lt;/g, "<")
426
+ .replace(/&gt;/g, ">")
427
+ .replace(/&quot;/g, '"')
428
+ .replace(/&#39;/g, "'")
429
+ .replace(/&amp;/g, "&");
430
+ }