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/hi.ts
CHANGED
|
@@ -55,7 +55,7 @@ const jsPats: Pat[] = [
|
|
|
55
55
|
["str", /"(?:\\.|[^"\\\n])*"/y],
|
|
56
56
|
["str", /'(?:\\.|[^'\\\n])*'/y],
|
|
57
57
|
["str", /`(?:\\.|[^`\\])*`/y],
|
|
58
|
-
["rx", /\/(
|
|
58
|
+
["rx", /\/(?![*/])(?:\\.|[^/\\\n])+\/[gimsuy]*/y],
|
|
59
59
|
["num", /\b(?:0x[\da-fA-F_]+|0b[01_]+|0o[0-7_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)\b/y],
|
|
60
60
|
["ident", /[A-Za-z_$][\w$]*/y],
|
|
61
61
|
["pun", /[+\-*/=<>!&|^~?:;,.[\](){}]/y],
|
|
@@ -103,7 +103,7 @@ const goPats: Pat[] = [
|
|
|
103
103
|
|
|
104
104
|
const bashPats: Pat[] = [
|
|
105
105
|
["com", /#[^\n]*/y],
|
|
106
|
-
["str", /"(
|
|
106
|
+
["str", /"(?:\\.|[^"\\])*"/y],
|
|
107
107
|
["str", /'[^']*'/y],
|
|
108
108
|
["var", /\$\{[^}]+\}|\$\w+|\$[*@#?!$0-9]/y],
|
|
109
109
|
["num", /\b\d+\b/y],
|
|
@@ -187,6 +187,9 @@ function escapeHtml(s: string): string {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
export function highlight(code: string, lang: string): string {
|
|
190
|
+
// Defense-in-depth: never tokenize a pathologically huge block on the main
|
|
191
|
+
// thread — fall back to plain escaped text.
|
|
192
|
+
if (code.length > 50_000) return escapeHtml(code);
|
|
190
193
|
const conf = LANGS[lang.toLowerCase()];
|
|
191
194
|
if (!conf) return escapeHtml(code);
|
|
192
195
|
|
package/src/html-to-react.ts
CHANGED
|
@@ -10,7 +10,10 @@ const VOID = new Set([
|
|
|
10
10
|
// Attribute name → React prop name, for the handful that differ. Anything not
|
|
11
11
|
// listed passes through verbatim (React forwards data-*/aria-* and lowercase
|
|
12
12
|
// attributes unchanged).
|
|
13
|
-
|
|
13
|
+
// Prototype-free map so an attribute named `constructor`/`hasOwnProperty`/etc.
|
|
14
|
+
// returns undefined (and the `?? name` fallback fires) rather than resolving to
|
|
15
|
+
// an inherited Object.prototype member.
|
|
16
|
+
const ATTR_MAP: Record<string, string> = Object.assign(Object.create(null), {
|
|
14
17
|
class: "className",
|
|
15
18
|
for: "htmlFor",
|
|
16
19
|
colspan: "colSpan",
|
|
@@ -26,7 +29,7 @@ const ATTR_MAP: Record<string, string> = {
|
|
|
26
29
|
crossorigin: "crossOrigin",
|
|
27
30
|
enterkeyhint: "enterKeyHint",
|
|
28
31
|
inputmode: "inputMode",
|
|
29
|
-
};
|
|
32
|
+
});
|
|
30
33
|
|
|
31
34
|
// URL-bearing attributes whose value must be scheme-checked. `htmlToReact` is
|
|
32
35
|
// exported and may be handed untrusted HTML directly; React happily renders a
|
|
@@ -34,6 +37,19 @@ const ATTR_MAP: Record<string, string> = {
|
|
|
34
37
|
// defense-in-depth — the core's own output is already sanitized.
|
|
35
38
|
const URL_ATTRS = new Set(["href", "src", "xlink:href", "formaction", "action", "poster", "data"]);
|
|
36
39
|
|
|
40
|
+
// React-meaningful prop names that must never be forwarded from (possibly
|
|
41
|
+
// untrusted) HTML attributes: `dangerouslySetInnerHTML` as a prop crashes the
|
|
42
|
+
// whole render tree (DoS), and ref/key/defaultValue/etc. are injectable.
|
|
43
|
+
const PROP_DENY = new Set([
|
|
44
|
+
"dangerouslysetinnerhtml", "ref", "key", "defaultvalue", "defaultchecked",
|
|
45
|
+
"suppresshydrationwarning", "suppresscontenteditablewarning",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Only forward attribute names that are a plain HTML attribute identifier
|
|
49
|
+
// (so camelCase / `__proto__` / `constructor` never reach React props). The
|
|
50
|
+
// explicit ATTR_MAP renames and `xlink:href` are allowed past this gate.
|
|
51
|
+
const SAFE_ATTR_NAME = /^[a-z][a-z0-9-]*$/i;
|
|
52
|
+
|
|
37
53
|
/** Replace a dangerous-scheme URL with "#". Mirrors the Rust `is_dangerous_scheme`:
|
|
38
54
|
* strip control chars (C0, DEL, C1 — matching Rust char::is_control),
|
|
39
55
|
* lowercase, then match. The strip affects only the probe, never output. */
|
|
@@ -42,8 +58,10 @@ function safeUrl(value: string): string {
|
|
|
42
58
|
// reaches the DOM, so peel layers to a fixpoint before the scheme check —
|
|
43
59
|
// catches `javascript:` and double-encoded `javascript&#58;`. Only the
|
|
44
60
|
// probe is decoded; the returned value is untouched (safe URLs stay verbatim).
|
|
61
|
+
// Cap at 8 iterations: far beyond any legit URL (browsers entity-decode an
|
|
62
|
+
// href once), and bounds the loop so a hostile value can't make it quadratic.
|
|
45
63
|
let decoded = value;
|
|
46
|
-
for (let prev = ""; decoded !== prev; ) {
|
|
64
|
+
for (let i = 0, prev = ""; i < 8 && decoded !== prev; i++) {
|
|
47
65
|
prev = decoded;
|
|
48
66
|
decoded = decodeEntities(decoded);
|
|
49
67
|
}
|
|
@@ -265,6 +283,9 @@ function attrsToProps(tag: string, attrs: Record<string, string | true>, key: st
|
|
|
265
283
|
// React drops most lowercase `on*` attrs — this also covers casings and
|
|
266
284
|
// future React behavior.
|
|
267
285
|
if (lower.startsWith("on")) continue;
|
|
286
|
+
// Reject React-meaningful names that would crash the render tree or inject
|
|
287
|
+
// internals (dangerouslySetInnerHTML, ref, key, defaultValue, …).
|
|
288
|
+
if (PROP_DENY.has(lower)) continue;
|
|
268
289
|
if (lower === "style" && typeof value === "string") {
|
|
269
290
|
props.style = safeStyle(parseStyle(value));
|
|
270
291
|
continue;
|
|
@@ -280,6 +301,10 @@ function attrsToProps(tag: string, attrs: Record<string, string | true>, key: st
|
|
|
280
301
|
props.defaultChecked = value === true ? true : value;
|
|
281
302
|
continue;
|
|
282
303
|
}
|
|
304
|
+
// Restrict forwarded ORIGINAL names to a plain HTML attribute identifier
|
|
305
|
+
// (plus the ATTR_MAP renames and xlink:href handled above) so weird casings
|
|
306
|
+
// / `__proto__` / `constructor` can never become a React prop.
|
|
307
|
+
if (!(lower in ATTR_MAP) && !SAFE_ATTR_NAME.test(name)) continue;
|
|
283
308
|
props[ATTR_MAP[lower] ?? name] = value;
|
|
284
309
|
}
|
|
285
310
|
return props;
|
|
@@ -307,12 +332,116 @@ function nodesToReact(nodes: HNode[], components: Components, keyPrefix: string)
|
|
|
307
332
|
return out.length === 0 ? null : out.length === 1 ? out[0] : out;
|
|
308
333
|
}
|
|
309
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Split trusted HTML into its TOP-LEVEL node segments (each segment is the
|
|
337
|
+
* exact substring spanning one root node — an element with its full subtree, or
|
|
338
|
+
* a run of text between elements). Used only by the opt-in child-memo path so an
|
|
339
|
+
* OPEN block whose leading children are unchanged can reuse their already-built
|
|
340
|
+
* React nodes instead of re-parsing the whole string every tick. Mirrors
|
|
341
|
+
* `parseTrustedHtml`'s tokenizer for boundary detection, but records spans only.
|
|
342
|
+
*/
|
|
343
|
+
function topLevelSegments(html: string): string[] {
|
|
344
|
+
const segs: string[] = [];
|
|
345
|
+
let depth = 0; // open-element nesting depth
|
|
346
|
+
let segStart = 0; // start of the current top-level segment
|
|
347
|
+
let i = 0;
|
|
348
|
+
while (i < html.length) {
|
|
349
|
+
const lt = html.indexOf("<", i);
|
|
350
|
+
if (lt === -1) break;
|
|
351
|
+
if (html.startsWith("<!--", lt)) {
|
|
352
|
+
const end = html.indexOf("-->", lt + 4);
|
|
353
|
+
i = end === -1 ? html.length : end + 3;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (html[lt + 1] === "!") {
|
|
357
|
+
const end = html.indexOf(">", lt);
|
|
358
|
+
i = end === -1 ? html.length : end + 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (html[lt + 1] === "/") {
|
|
362
|
+
const end = html.indexOf(">", lt);
|
|
363
|
+
if (depth > 0) {
|
|
364
|
+
depth--;
|
|
365
|
+
if (depth === 0) {
|
|
366
|
+
// Closing the outermost element ends one top-level segment.
|
|
367
|
+
const stop = end === -1 ? html.length : end + 1;
|
|
368
|
+
segs.push(html.slice(segStart, stop));
|
|
369
|
+
segStart = stop;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
i = end === -1 ? html.length : end + 1;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const c1 = html[lt + 1];
|
|
376
|
+
const isName = (c1 >= "a" && c1 <= "z") || (c1 >= "A" && c1 <= "Z");
|
|
377
|
+
if (!isName) {
|
|
378
|
+
// A stray '<' is literal text — not a boundary.
|
|
379
|
+
i = lt + 1;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const { tag, selfClose, next } = parseOpenTag(html, lt);
|
|
383
|
+
if (depth === 0) {
|
|
384
|
+
// Any text before this element is its own top-level segment.
|
|
385
|
+
if (lt > segStart) {
|
|
386
|
+
segs.push(html.slice(segStart, lt));
|
|
387
|
+
segStart = lt;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const isVoid = selfClose || VOID.has(tag.toLowerCase());
|
|
391
|
+
if (isVoid) {
|
|
392
|
+
if (depth === 0) {
|
|
393
|
+
segs.push(html.slice(segStart, next));
|
|
394
|
+
segStart = next;
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
depth++;
|
|
398
|
+
}
|
|
399
|
+
i = next;
|
|
400
|
+
}
|
|
401
|
+
// Trailing text (or an unterminated subtree) after the last boundary.
|
|
402
|
+
if (segStart < html.length) segs.push(html.slice(segStart));
|
|
403
|
+
return segs;
|
|
404
|
+
}
|
|
405
|
+
|
|
310
406
|
/**
|
|
311
407
|
* Convert a block's trusted HTML string into a React node tree, replacing any
|
|
312
|
-
* element whose tag name appears in `components`.
|
|
313
|
-
*
|
|
314
|
-
*
|
|
408
|
+
* element whose tag name appears in `components`.
|
|
409
|
+
*
|
|
410
|
+
* With no `childMemoMap`, behavior is byte-identical to a single
|
|
411
|
+
* `parseTrustedHtml` + convert pass — the closed-block call site memoizes on
|
|
412
|
+
* `(html, components)`.
|
|
413
|
+
*
|
|
414
|
+
* Passing a `childMemoMap` opts into OPEN-block child reuse: the html is split
|
|
415
|
+
* into top-level node segments and each is keyed by its exact substring. On a
|
|
416
|
+
* hit the cached React node is reused (no re-parse, no re-serialize); only new /
|
|
417
|
+
* changed trailing segments are parsed. The caller owns the map's lifetime and
|
|
418
|
+
* must scope it per block.id and invalidate it when `components` changes (a hit
|
|
419
|
+
* carries the React node built under the previous components map). Segment keys
|
|
420
|
+
* carry their original document order via `keyOffset` so React keys stay stable.
|
|
315
421
|
*/
|
|
316
|
-
export function htmlToReact(
|
|
317
|
-
|
|
422
|
+
export function htmlToReact(
|
|
423
|
+
html: string,
|
|
424
|
+
components: Components,
|
|
425
|
+
childMemoMap?: Map<string, ReactNode>,
|
|
426
|
+
): ReactNode {
|
|
427
|
+
if (!childMemoMap) return nodesToReact(parseTrustedHtml(html), components, "");
|
|
428
|
+
const segs = topLevelSegments(html);
|
|
429
|
+
const out: ReactNode[] = [];
|
|
430
|
+
for (let idx = 0; idx < segs.length; idx++) {
|
|
431
|
+
const seg = segs[idx];
|
|
432
|
+
// Key the cache by index + segment text so identical segments at different
|
|
433
|
+
// positions get distinct React keys (no collision) and a leading segment
|
|
434
|
+
// stays a hit only while it keeps the same document index (append-only
|
|
435
|
+
// growth — the common open-block case — preserves both).
|
|
436
|
+
const cacheKey = idx + "" + seg;
|
|
437
|
+
const hit = childMemoMap.get(cacheKey);
|
|
438
|
+
if (hit !== undefined) {
|
|
439
|
+
out.push(hit);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const node = nodesToReact(parseTrustedHtml(seg), components, idx + ".");
|
|
443
|
+
childMemoMap.set(cacheKey, node);
|
|
444
|
+
out.push(node);
|
|
445
|
+
}
|
|
446
|
+
return out.length === 0 ? null : out.length === 1 ? out[0] : out;
|
|
318
447
|
}
|
package/src/index.ts
CHANGED
package/src/morph.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny, self-contained idiomorph-style DOM morph (zero npm dependency, to keep
|
|
3
|
+
* the security/supply-chain posture). It mutates a live element's children to
|
|
4
|
+
* match a desired tree **in place** instead of replacing them wholesale, so the
|
|
5
|
+
* browser only repaints/relayouts the parts that actually changed and focus +
|
|
6
|
+
* text-selection survive a streaming update.
|
|
7
|
+
*
|
|
8
|
+
* Scope: this is intentionally minimal — it handles the cases the streaming
|
|
9
|
+
* open-block tail produces (text growth, appended trailing nodes, attribute
|
|
10
|
+
* tweaks). It is OPT-IN (see `morphOpenBlocks` in dom.ts) and never runs on the
|
|
11
|
+
* default path.
|
|
12
|
+
*
|
|
13
|
+
* Matching strategy (per child level):
|
|
14
|
+
* - Build an id-set over the *new* subtree so a node carrying an `id` that the
|
|
15
|
+
* incoming tree also has can be matched across reorders/insertions.
|
|
16
|
+
* - Walk old/new children with two cursors. When the heads are "soft-equal"
|
|
17
|
+
* (same nodeType, same tagName for elements) we morph them in place and
|
|
18
|
+
* recurse; otherwise we id-match, insert, or remove the minimum.
|
|
19
|
+
* - Mutate matched element attributes/text in place; append trailing new nodes;
|
|
20
|
+
* remove old nodes with no match.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Parse an HTML string into a detached `<template>`'s content (a DocumentFragment). */
|
|
24
|
+
export function htmlToFragment(html: string): DocumentFragment {
|
|
25
|
+
const tpl = document.createElement("template");
|
|
26
|
+
tpl.innerHTML = html;
|
|
27
|
+
return tpl.content;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SavedFocus {
|
|
31
|
+
node: Element;
|
|
32
|
+
start: number | null;
|
|
33
|
+
end: number | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Snapshot the active element + (for text inputs/areas) its selection range, so
|
|
37
|
+
// a morph that touches the focused node — or its ancestors — does not blur it.
|
|
38
|
+
function saveFocus(root: Node): SavedFocus | null {
|
|
39
|
+
if (typeof document === "undefined") return null;
|
|
40
|
+
const active = document.activeElement;
|
|
41
|
+
if (!active || active === document.body) return null;
|
|
42
|
+
if (!root.contains(active)) return null;
|
|
43
|
+
let start: number | null = null;
|
|
44
|
+
let end: number | null = null;
|
|
45
|
+
const el = active as HTMLInputElement & HTMLTextAreaElement;
|
|
46
|
+
try {
|
|
47
|
+
if (typeof el.selectionStart === "number") {
|
|
48
|
+
start = el.selectionStart;
|
|
49
|
+
end = el.selectionEnd;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Some input types throw on selectionStart access; ignore.
|
|
53
|
+
}
|
|
54
|
+
return { node: active, start, end };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function restoreFocus(saved: SavedFocus | null): void {
|
|
58
|
+
if (!saved) return;
|
|
59
|
+
const node = saved.node as HTMLElement & HTMLInputElement & HTMLTextAreaElement;
|
|
60
|
+
// Still attached? (a morph could have removed it — then there's nothing to do)
|
|
61
|
+
if (typeof document !== "undefined" && !document.contains(node)) return;
|
|
62
|
+
if (document.activeElement === node) {
|
|
63
|
+
// Selection may still need restoring even if focus was kept.
|
|
64
|
+
} else if (typeof node.focus === "function") {
|
|
65
|
+
try {
|
|
66
|
+
node.focus({ preventScroll: true });
|
|
67
|
+
} catch {
|
|
68
|
+
try {
|
|
69
|
+
node.focus();
|
|
70
|
+
} catch {
|
|
71
|
+
/* not focusable anymore */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (saved.start !== null && typeof node.setSelectionRange === "function") {
|
|
76
|
+
try {
|
|
77
|
+
node.setSelectionRange(saved.start, saved.end ?? saved.start);
|
|
78
|
+
} catch {
|
|
79
|
+
// setSelectionRange throws for input types that don't support it.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Morph `from`'s children to match `to` (a fragment, element, or HTML string).
|
|
86
|
+
* `from` itself is never replaced — only its subtree is reconciled in place.
|
|
87
|
+
*/
|
|
88
|
+
export function morph(from: Element, to: string | DocumentFragment | Element): void {
|
|
89
|
+
const fragment: DocumentFragment | Element =
|
|
90
|
+
typeof to === "string" ? htmlToFragment(to) : to;
|
|
91
|
+
const saved = saveFocus(from);
|
|
92
|
+
morphChildren(from, fragment);
|
|
93
|
+
restoreFocus(saved);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Two element nodes can be morphed into one another (vs. replaced) when they are
|
|
97
|
+
// the same element type. Text/comment nodes match on nodeType alone.
|
|
98
|
+
function isSoftMatch(a: Node, b: Node): boolean {
|
|
99
|
+
if (a.nodeType !== b.nodeType) return false;
|
|
100
|
+
if (a.nodeType === 1) {
|
|
101
|
+
return (a as Element).tagName === (b as Element).tagName;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function nodeId(n: Node): string | null {
|
|
107
|
+
if (n.nodeType !== 1) return null;
|
|
108
|
+
const id = (n as Element).getAttribute("id");
|
|
109
|
+
return id && id.length > 0 ? id : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Collect the set of element ids present anywhere in `parent`'s direct children
|
|
113
|
+
// (one level — recursion handles deeper levels). Used so a keyed node can be
|
|
114
|
+
// matched across insertions before we resort to soft-matching.
|
|
115
|
+
function childIdSet(parent: Node): Set<string> {
|
|
116
|
+
const ids = new Set<string>();
|
|
117
|
+
for (let c = parent.firstChild; c; c = c.nextSibling) {
|
|
118
|
+
const id = nodeId(c);
|
|
119
|
+
if (id) ids.add(id);
|
|
120
|
+
}
|
|
121
|
+
return ids;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function morphChildren(oldParent: Node, newParent: Node): void {
|
|
125
|
+
const newIds = childIdSet(newParent);
|
|
126
|
+
|
|
127
|
+
let oldChild = oldParent.firstChild;
|
|
128
|
+
let newChild = newParent.firstChild;
|
|
129
|
+
|
|
130
|
+
while (newChild) {
|
|
131
|
+
const nextNew = newChild.nextSibling;
|
|
132
|
+
|
|
133
|
+
// No more old nodes → append the rest of the new ones (the common streaming
|
|
134
|
+
// case: trailing tokens/elements grew onto the tail). We must import nodes
|
|
135
|
+
// out of the source fragment into the live document.
|
|
136
|
+
if (!oldChild) {
|
|
137
|
+
oldParent.appendChild(importNode(oldParent, newChild));
|
|
138
|
+
newChild = nextNew;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const nextOld = oldChild.nextSibling;
|
|
143
|
+
|
|
144
|
+
// Exact id match at the head → morph in place.
|
|
145
|
+
const newKey = nodeId(newChild);
|
|
146
|
+
const oldKey = nodeId(oldChild);
|
|
147
|
+
|
|
148
|
+
if (isSoftMatch(oldChild, newChild) && newKey === oldKey) {
|
|
149
|
+
morphNode(oldChild, newChild);
|
|
150
|
+
oldChild = nextOld;
|
|
151
|
+
newChild = nextNew;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The old head carries an id the new tree no longer has → it's gone; remove
|
|
156
|
+
// it and retry this new child against the next old node.
|
|
157
|
+
if (oldKey && !newIds.has(oldKey)) {
|
|
158
|
+
const toRemove = oldChild;
|
|
159
|
+
oldChild = nextOld;
|
|
160
|
+
oldParent.removeChild(toRemove);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The new head has an id that exists further down the old list → pull that
|
|
165
|
+
// old node up to the head, morph, and continue.
|
|
166
|
+
if (newKey) {
|
|
167
|
+
const match = findChildById(oldParent, newKey);
|
|
168
|
+
if (match) {
|
|
169
|
+
if (match !== oldChild) oldParent.insertBefore(match, oldChild);
|
|
170
|
+
morphNode(match, newChild);
|
|
171
|
+
oldChild = match.nextSibling;
|
|
172
|
+
newChild = nextNew;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
// New keyed node not present in the old tree → insert a fresh import.
|
|
176
|
+
oldParent.insertBefore(importNode(oldParent, newChild), oldChild);
|
|
177
|
+
newChild = nextNew;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Soft (unkeyed) match → morph in place.
|
|
182
|
+
if (isSoftMatch(oldChild, newChild)) {
|
|
183
|
+
morphNode(oldChild, newChild);
|
|
184
|
+
oldChild = nextOld;
|
|
185
|
+
newChild = nextNew;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// No match at all: replace the old head with an import of the new head.
|
|
190
|
+
oldParent.insertBefore(importNode(oldParent, newChild), oldChild);
|
|
191
|
+
oldParent.removeChild(oldChild);
|
|
192
|
+
oldChild = nextOld;
|
|
193
|
+
newChild = nextNew;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Any leftover old children have no counterpart → remove them.
|
|
197
|
+
while (oldChild) {
|
|
198
|
+
const next = oldChild.nextSibling;
|
|
199
|
+
oldParent.removeChild(oldChild);
|
|
200
|
+
oldChild = next;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Find a direct child of `parent` whose element id equals `id`.
|
|
205
|
+
function findChildById(parent: Node, id: string): Node | null {
|
|
206
|
+
for (let c = parent.firstChild; c; c = c.nextSibling) {
|
|
207
|
+
if (nodeId(c) === id) return c;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Bring a node from a source fragment into the live document so it can be
|
|
213
|
+
// inserted. `importNode(deep)` clones; the source fragment is discarded after.
|
|
214
|
+
function importNode(target: Node, source: Node): Node {
|
|
215
|
+
const doc = target.ownerDocument ?? document;
|
|
216
|
+
return doc.importNode(source, true);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Morph a single matched node `oldNode` toward `newNode`. Elements get their
|
|
220
|
+
// attributes synced and children recursed; text/comment nodes get their data
|
|
221
|
+
// updated in place (so the live text node identity — and any caret in it —
|
|
222
|
+
// survives a token append).
|
|
223
|
+
function morphNode(oldNode: Node, newNode: Node): void {
|
|
224
|
+
if (oldNode.nodeType === 1) {
|
|
225
|
+
morphAttributes(oldNode as Element, newNode as Element);
|
|
226
|
+
morphChildren(oldNode, newNode);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Text (3) / comment (8) / CDATA: update character data in place.
|
|
230
|
+
if (oldNode.nodeValue !== newNode.nodeValue) {
|
|
231
|
+
oldNode.nodeValue = newNode.nodeValue;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function morphAttributes(oldEl: Element, newEl: Element): void {
|
|
236
|
+
// Set/update attributes present on the new element.
|
|
237
|
+
const newAttrs = newEl.attributes;
|
|
238
|
+
for (let i = 0; i < newAttrs.length; i++) {
|
|
239
|
+
const attr = newAttrs[i];
|
|
240
|
+
if (oldEl.getAttribute(attr.name) !== attr.value) {
|
|
241
|
+
oldEl.setAttribute(attr.name, attr.value);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Remove attributes the new element no longer has. Iterate over a snapshot of
|
|
245
|
+
// names because removeAttribute mutates the live NamedNodeMap.
|
|
246
|
+
const oldAttrs = oldEl.attributes;
|
|
247
|
+
const stale: string[] = [];
|
|
248
|
+
for (let i = 0; i < oldAttrs.length; i++) {
|
|
249
|
+
const name = oldAttrs[i].name;
|
|
250
|
+
if (!newEl.hasAttribute(name)) stale.push(name);
|
|
251
|
+
}
|
|
252
|
+
for (const name of stale) oldEl.removeAttribute(name);
|
|
253
|
+
}
|