flux-md 0.15.0 → 0.16.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/README.md +42 -1
- package/package.json +2 -3
- package/src/block-props.ts +13 -3
- package/src/client.ts +115 -3
- package/src/dom.ts +502 -14
- package/src/element.ts +9 -1
- package/src/hi.ts +5 -2
- package/src/html-to-react.ts +137 -8
- package/src/index.ts +2 -0
- package/src/morph.ts +253 -0
- package/src/react.tsx +511 -18
- package/src/server.tsx +5 -5
- package/src/solid.tsx +37 -2
- package/src/svelte.ts +26 -1
- package/src/types-core.ts +73 -3
- package/src/vue.ts +29 -2
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/CHANGELOG.md +0 -712
- package/src/wasm/package.json +0 -17
package/src/dom.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FluxClient } from "./client";
|
|
2
2
|
import { highlight } from "./hi";
|
|
3
|
-
import
|
|
3
|
+
import { morph } from "./morph";
|
|
4
|
+
import type { Align, Block, BlockComponentProps, BlockKindTag, ListData, RenderMetricsHook, TableData } from "./types-core";
|
|
4
5
|
import { blockProps, extractLang } from "./block-props";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -28,6 +29,18 @@ import { blockProps, extractLang } from "./block-props";
|
|
|
28
29
|
export interface MountHandle {
|
|
29
30
|
destroy(): void;
|
|
30
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;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
export type DomBlockComponent = (props: BlockComponentProps) => HTMLElement | string;
|
|
@@ -61,6 +74,16 @@ export interface MountOptions {
|
|
|
61
74
|
highlightCode?: boolean;
|
|
62
75
|
/** Coalesce patches into one DOM write per animation frame. Default true. */
|
|
63
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;
|
|
64
87
|
/** Appended to the root's `className` (the `flux-md` class is always present). */
|
|
65
88
|
className?: string;
|
|
66
89
|
/** Set on the root element. */
|
|
@@ -74,6 +97,15 @@ export interface MountOptions {
|
|
|
74
97
|
ariaLive?: "off" | "polite" | "assertive";
|
|
75
98
|
/** Live-region atomicity; pair with `ariaLive`. Off by default. */
|
|
76
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;
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
// Per-kind off-screen size estimate for `contain-intrinsic-size`. Duplicated
|
|
@@ -93,6 +125,33 @@ interface MountedBlock {
|
|
|
93
125
|
open: boolean;
|
|
94
126
|
speculative: boolean;
|
|
95
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;
|
|
96
155
|
}
|
|
97
156
|
|
|
98
157
|
export function mountFluxMarkdown(
|
|
@@ -107,9 +166,11 @@ export function mountFluxMarkdown(
|
|
|
107
166
|
// Normalize "no overrides" to undefined so the fast path doesn't churn.
|
|
108
167
|
const components =
|
|
109
168
|
options.components && Object.keys(options.components).length > 0 ? options.components : undefined;
|
|
110
|
-
const { sanitize, virtualize, stickToBottom } = options;
|
|
169
|
+
const { sanitize, virtualize, stickToBottom, onRenderMetrics } = options;
|
|
170
|
+
const hasPerf = typeof performance !== "undefined";
|
|
111
171
|
const highlightCode = options.highlightCode !== false && !components?.CodeBlock;
|
|
112
172
|
const batch = options.batch !== false && typeof requestAnimationFrame === "function";
|
|
173
|
+
const morphOpenBlocks = options.morphOpenBlocks === true;
|
|
113
174
|
|
|
114
175
|
const root = document.createElement("div");
|
|
115
176
|
root.className = options.className ? `flux-md ${options.className}` : "flux-md";
|
|
@@ -133,6 +194,10 @@ export function mountFluxMarkdown(
|
|
|
133
194
|
let order: number[] = [];
|
|
134
195
|
let dead = false;
|
|
135
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;
|
|
136
201
|
|
|
137
202
|
function sync(): void {
|
|
138
203
|
if (dead) return;
|
|
@@ -146,10 +211,16 @@ export function mountFluxMarkdown(
|
|
|
146
211
|
seen.add(b.id);
|
|
147
212
|
const existing = mounted.get(b.id);
|
|
148
213
|
if (!existing) {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
id: b.id, node
|
|
152
|
-
|
|
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);
|
|
153
224
|
continue;
|
|
154
225
|
}
|
|
155
226
|
// Unchanged fingerprint → reuse the node untouched. Committed blocks land
|
|
@@ -158,14 +229,86 @@ export function mountFluxMarkdown(
|
|
|
158
229
|
if (existing.html === b.html && existing.open === b.open && existing.speculative === b.speculative) {
|
|
159
230
|
continue;
|
|
160
231
|
}
|
|
161
|
-
|
|
162
|
-
|
|
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);
|
|
163
304
|
existing.node.replaceWith(node);
|
|
164
305
|
existing.node = node;
|
|
306
|
+
if (onRenderMetrics) noteRender(existing, b, t0);
|
|
165
307
|
existing.html = b.html;
|
|
166
308
|
existing.open = b.open;
|
|
167
309
|
existing.speculative = b.speculative;
|
|
168
310
|
existing.kind = b.kind.type;
|
|
311
|
+
existing.generic = lastRenderGeneric;
|
|
169
312
|
}
|
|
170
313
|
|
|
171
314
|
// Drop ids no longer present (reset() empties the snapshot; a speculative
|
|
@@ -183,6 +326,22 @@ export function mountFluxMarkdown(
|
|
|
183
326
|
reconcileChildren();
|
|
184
327
|
}
|
|
185
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
|
+
|
|
186
345
|
// Keyed reconcile with a single forward cursor (O(n), not O(n²)): walk the
|
|
187
346
|
// desired order and the live children in lockstep, inserting/moving only a
|
|
188
347
|
// node whose live position differs. The `.flux-bottom-anchor` is never part of
|
|
@@ -205,8 +364,8 @@ export function mountFluxMarkdown(
|
|
|
205
364
|
}
|
|
206
365
|
}
|
|
207
366
|
|
|
208
|
-
function renderBlock(b: Block): HTMLElement {
|
|
209
|
-
const content = renderBlockContent(b);
|
|
367
|
+
function renderBlock(b: Block, mb: MountedBlock): HTMLElement {
|
|
368
|
+
const content = renderBlockContent(b, mb);
|
|
210
369
|
// Virtualize only *closed* blocks. Unlike the JSX renderer (which wraps in
|
|
211
370
|
// an extra div) the DOM renderer sets the properties on the block node
|
|
212
371
|
// directly — one of the documented byte-faithfulness divergences.
|
|
@@ -218,8 +377,9 @@ export function mountFluxMarkdown(
|
|
|
218
377
|
return content;
|
|
219
378
|
}
|
|
220
379
|
|
|
221
|
-
function renderBlockContent(b: Block): HTMLElement {
|
|
380
|
+
function renderBlockContent(b: Block, mb: MountedBlock): HTMLElement {
|
|
222
381
|
const kind = b.kind.type;
|
|
382
|
+
lastRenderGeneric = false;
|
|
223
383
|
|
|
224
384
|
// 1. Block-kind override (a Component block dispatches on its tag first).
|
|
225
385
|
if (components) {
|
|
@@ -243,17 +403,219 @@ export function mountFluxMarkdown(
|
|
|
243
403
|
return renderMermaid(b);
|
|
244
404
|
}
|
|
245
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
|
+
|
|
246
427
|
// 3. Generic fast path.
|
|
247
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 (!items || items.length === 0) return null;
|
|
459
|
+
const node = document.createElement("div");
|
|
248
460
|
node.className =
|
|
249
|
-
"flux-block flux-block-" +
|
|
250
|
-
kind.toLowerCase() +
|
|
461
|
+
"flux-block flux-block-list" +
|
|
251
462
|
(b.open ? " flux-open" : "") +
|
|
252
463
|
(b.speculative ? " flux-speculative" : "");
|
|
253
|
-
|
|
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);
|
|
254
530
|
return node;
|
|
255
531
|
}
|
|
256
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
|
+
|
|
257
619
|
// An override may return an element (used directly) or an HTML string (wrapped
|
|
258
620
|
// in a div so the renderer always owns a single block node to track/swap).
|
|
259
621
|
function wrapOverrideResult(result: HTMLElement | string): HTMLElement {
|
|
@@ -430,9 +792,110 @@ export function mountFluxMarkdown(
|
|
|
430
792
|
if (dead) return;
|
|
431
793
|
sync();
|
|
432
794
|
},
|
|
795
|
+
openBlockId() {
|
|
796
|
+
return tailOpenBlockId(client.getSnapshot());
|
|
797
|
+
},
|
|
433
798
|
};
|
|
434
799
|
}
|
|
435
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
|
+
return data && Array.isArray(data.rows) ? data : undefined;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// HTML void elements: they self-terminate, so they never push element depth.
|
|
810
|
+
const VOID_ELEMENTS = new Set([
|
|
811
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input", "link",
|
|
812
|
+
"meta", "param", "source", "track", "wbr",
|
|
813
|
+
]);
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* True when `prefix` is a complete run of balanced top-level markup (element
|
|
817
|
+
* depth returns to 0 at its end and any trailing whitespace/text is harmless)
|
|
818
|
+
* AND the appended suffix `full.slice(prefix.length)` begins a NEW depth-0
|
|
819
|
+
* sibling element (an opening tag, not a close tag / text / mid-tag).
|
|
820
|
+
*
|
|
821
|
+
* When both hold, splicing the suffix onto the live node via
|
|
822
|
+
* `insertAdjacentHTML('beforeend', suffix)` yields the exact same DOM the
|
|
823
|
+
* browser would build from parsing `full` whole — the appended markup is a
|
|
824
|
+
* self-contained sibling appended after the last existing child. Any other
|
|
825
|
+
* shape (an unclosed element at the prefix boundary, a suffix that continues
|
|
826
|
+
* text, closes a tag, or splits a tag) must fall back to a full rebuild.
|
|
827
|
+
*
|
|
828
|
+
* The scan is single-pass over `prefix` (O(prefix length)); it is run only on a
|
|
829
|
+
* confirmed `startsWith` prefix extension, so the amortized streaming cost stays
|
|
830
|
+
* proportional to the bytes seen.
|
|
831
|
+
*/
|
|
832
|
+
function isDepth0Boundary(prefix: string, full: string): boolean {
|
|
833
|
+
// Suffix must open a new element: '<' immediately followed by an ASCII letter.
|
|
834
|
+
const c0 = full.charCodeAt(prefix.length);
|
|
835
|
+
if (c0 !== 60 /* '<' */) return false;
|
|
836
|
+
const c1 = full.charCodeAt(prefix.length + 1);
|
|
837
|
+
const isLetter = (c1 >= 65 && c1 <= 90) || (c1 >= 97 && c1 <= 122);
|
|
838
|
+
if (!isLetter) return false;
|
|
839
|
+
|
|
840
|
+
// Walk `prefix`, tracking element depth. Bail (return false) on anything we
|
|
841
|
+
// cannot cheaply prove balanced: comments, CDATA, processing instructions,
|
|
842
|
+
// or any tag that leaves the cursor inside markup at the end.
|
|
843
|
+
let depth = 0;
|
|
844
|
+
let i = 0;
|
|
845
|
+
const n = prefix.length;
|
|
846
|
+
while (i < n) {
|
|
847
|
+
const lt = prefix.indexOf("<", i);
|
|
848
|
+
if (lt === -1) break; // only text remains; depth unchanged
|
|
849
|
+
i = lt + 1;
|
|
850
|
+
if (i >= n) return false; // trailing '<' with nothing after → mid-tag
|
|
851
|
+
const ch = prefix.charCodeAt(i);
|
|
852
|
+
// Comments / CDATA / declarations / PIs: not handled — fall back.
|
|
853
|
+
if (ch === 33 /* '!' */ || ch === 63 /* '?' */) return false;
|
|
854
|
+
let closing = false;
|
|
855
|
+
if (ch === 47 /* '/' */) {
|
|
856
|
+
closing = true;
|
|
857
|
+
i++;
|
|
858
|
+
}
|
|
859
|
+
// Read the tag name.
|
|
860
|
+
const nameStart = i;
|
|
861
|
+
while (i < n) {
|
|
862
|
+
const t = prefix.charCodeAt(i);
|
|
863
|
+
const nameChar =
|
|
864
|
+
(t >= 65 && t <= 90) || (t >= 97 && t <= 122) || (t >= 48 && t <= 57) || t === 45;
|
|
865
|
+
if (!nameChar) break;
|
|
866
|
+
i++;
|
|
867
|
+
}
|
|
868
|
+
if (i === nameStart) return false; // '<' not followed by a tag name
|
|
869
|
+
const name = prefix.slice(nameStart, i).toLowerCase();
|
|
870
|
+
// Find the tag's '>' (attribute values here never contain a literal '>'
|
|
871
|
+
// because the renderer emits entity-escaped attributes; if we hit EOF first
|
|
872
|
+
// the prefix ends mid-tag → not a boundary).
|
|
873
|
+
const gt = prefix.indexOf(">", i);
|
|
874
|
+
if (gt === -1) return false;
|
|
875
|
+
const selfClosing = prefix.charCodeAt(gt - 1) === 47; /* '/' */
|
|
876
|
+
i = gt + 1;
|
|
877
|
+
if (closing) {
|
|
878
|
+
depth--;
|
|
879
|
+
if (depth < 0) return false; // unbalanced close
|
|
880
|
+
} else if (!selfClosing && !VOID_ELEMENTS.has(name)) {
|
|
881
|
+
depth++;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return depth === 0;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Derive the streaming tail's block id from an ordered snapshot: the id of the
|
|
889
|
+
* last block when it is open, else `null`. The open block is always the tail by
|
|
890
|
+
* construction (the parser only keeps the final block speculative/open), so this
|
|
891
|
+
* is an O(1) read of the last element — no scan. Shared so the framework
|
|
892
|
+
* adapters expose the same "what may re-render next" signal as the DOM handle.
|
|
893
|
+
*/
|
|
894
|
+
export function tailOpenBlockId(snapshot: readonly Block[]): number | null {
|
|
895
|
+
const tail = snapshot.length > 0 ? snapshot[snapshot.length - 1] : undefined;
|
|
896
|
+
return tail && tail.open ? tail.id : null;
|
|
897
|
+
}
|
|
898
|
+
|
|
436
899
|
// Local copy of the canonical code-text decoder (kept here so dom.ts depends
|
|
437
900
|
// only on neutral modules; block-props.ts keeps its own private copy too).
|
|
438
901
|
function decodeCodeText(html: string): string {
|
|
@@ -445,3 +908,28 @@ function decodeCodeText(html: string): string {
|
|
|
445
908
|
.replace(/'/g, "'")
|
|
446
909
|
.replace(/&/g, "&");
|
|
447
910
|
}
|
|
911
|
+
|
|
912
|
+
// Attributes the Rust renderer emits on a blockquote / alert wrapper open tag
|
|
913
|
+
// (`dir`/`class`/`data-alert`/`role`). Whitelisted (not a generic HTML parser):
|
|
914
|
+
// only these names are forwarded onto the keyed wrapper element so it is
|
|
915
|
+
// byte-faithful to the full-wrapper innerHTML path.
|
|
916
|
+
const CONTAINER_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
|
|
917
|
+
function applyOpenTagAttrs(el: HTMLElement, html: string): void {
|
|
918
|
+
const gt = html.indexOf(">");
|
|
919
|
+
const open = gt < 0 ? html : html.slice(0, gt);
|
|
920
|
+
let m: RegExpExecArray | null;
|
|
921
|
+
CONTAINER_ATTR_RE.lastIndex = 0;
|
|
922
|
+
while ((m = CONTAINER_ATTR_RE.exec(open))) {
|
|
923
|
+
const name = m[1].toLowerCase();
|
|
924
|
+
if (name === "class" || name === "dir" || name === "role" || name.startsWith("data-")) {
|
|
925
|
+
el.setAttribute(name, m[2]);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Extract an alert's title `<p class="markdown-alert-title"…>Title</p>` from the
|
|
931
|
+
// wrapper HTML so the keyed path keeps it as the first child (never in `nested`).
|
|
932
|
+
function alertTitleHtml(html: string): string {
|
|
933
|
+
const m = html.match(/<p class="markdown-alert-title"[^>]*>[\s\S]*?<\/p>/);
|
|
934
|
+
return m ? m[0] : "";
|
|
935
|
+
}
|
package/src/element.ts
CHANGED
|
@@ -90,6 +90,7 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
90
90
|
return this.#components;
|
|
91
91
|
}
|
|
92
92
|
set components(value: DomComponents | undefined) {
|
|
93
|
+
if (value === this.#components) return; // no-op re-assign: don't remount
|
|
93
94
|
this.#components = value;
|
|
94
95
|
if (this.#connected) this.#remount();
|
|
95
96
|
}
|
|
@@ -98,6 +99,7 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
98
99
|
return this.#sanitize;
|
|
99
100
|
}
|
|
100
101
|
set sanitize(value: ((html: string) => string) | undefined) {
|
|
102
|
+
if (value === this.#sanitize) return; // no-op re-assign: don't remount
|
|
101
103
|
this.#sanitize = value;
|
|
102
104
|
if (this.#connected) this.#remount();
|
|
103
105
|
}
|
|
@@ -155,10 +157,16 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
attributeChangedCallback(name: string,
|
|
160
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
|
|
159
161
|
// attributeChangedCallback fires before connectedCallback for attributes
|
|
160
162
|
// present at upgrade; ignore until connected so config reads happen once.
|
|
161
163
|
if (!this.#connected) return;
|
|
164
|
+
// setAttribute fires this on EVERY set, including setting an attribute to
|
|
165
|
+
// its current value (common when a host framework re-applies the same
|
|
166
|
+
// attrs on re-render). A no-op value change must not tear down the client
|
|
167
|
+
// and reparse the whole document — only a genuine change proceeds.
|
|
168
|
+
// (Attribute removal yields null, distinct from an empty string.)
|
|
169
|
+
if (oldValue === newValue) return;
|
|
162
170
|
|
|
163
171
|
if (name === "markdown" || name === "src") {
|
|
164
172
|
// One-shot content source change — only for a self-owned client. A
|