galath 1.0.2

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 (55) hide show
  1. package/AGENTS.md +1 -0
  2. package/README.md +206 -0
  3. package/TODO.md +140 -0
  4. package/index.html +188 -0
  5. package/logo.jpg +0 -0
  6. package/logo.svg +96 -0
  7. package/package.json +32 -0
  8. package/packages/galath/package.json +28 -0
  9. package/packages/galath/src/behavior.js +193 -0
  10. package/packages/galath/src/binding.js +247 -0
  11. package/packages/galath/src/boot.js +52 -0
  12. package/packages/galath/src/command.js +117 -0
  13. package/packages/galath/src/component.js +505 -0
  14. package/packages/galath/src/controller.js +181 -0
  15. package/packages/galath/src/core.js +190 -0
  16. package/packages/galath/src/imports.js +132 -0
  17. package/packages/galath/src/index.js +38 -0
  18. package/packages/galath/src/instance-model.js +343 -0
  19. package/packages/galath/src/morph.js +237 -0
  20. package/packages/galath/src/rendering.js +556 -0
  21. package/packages/galath/src/signals.js +215 -0
  22. package/packages/galath/src/templates.js +24 -0
  23. package/packages/galath/src/xml-events.js +53 -0
  24. package/packages/galath-css/css/bootstrap-icons.min.css +5 -0
  25. package/packages/galath-css/css/bootstrap.min.css +6 -0
  26. package/packages/galath-css/css/fonts/bootstrap-icons.json +2077 -0
  27. package/packages/galath-css/css/fonts/bootstrap-icons.woff +0 -0
  28. package/packages/galath-css/css/fonts/bootstrap-icons.woff2 +0 -0
  29. package/packages/galath-css/js/bootstrap.bundle.min.js +7 -0
  30. package/packages/galath-css/package.json +13 -0
  31. package/playground/app.xml +214 -0
  32. package/playground/chapters/01-welcome.xml +94 -0
  33. package/playground/chapters/02-signals.xml +166 -0
  34. package/playground/chapters/03-instance.xml +130 -0
  35. package/playground/chapters/04-bindings.xml +156 -0
  36. package/playground/chapters/05-lists.xml +138 -0
  37. package/playground/chapters/06-commands.xml +144 -0
  38. package/playground/chapters/07-controller.xml +115 -0
  39. package/playground/chapters/08-events.xml +126 -0
  40. package/playground/chapters/09-behaviors.xml +210 -0
  41. package/playground/chapters/10-components.xml +152 -0
  42. package/playground/chapters/11-imports.xml +108 -0
  43. package/playground/chapters/12-expressions.xml +161 -0
  44. package/playground/chapters/13-paths.xml +197 -0
  45. package/playground/components/chapter-shell.xml +29 -0
  46. package/playground/components/highlighter.js +111 -0
  47. package/playground/components/run-snippet.js +120 -0
  48. package/public/basic/bootstrap-icons.min.css +5 -0
  49. package/public/basic/bootstrap.bundle.min.js +7 -0
  50. package/public/basic/bootstrap.min.css +6 -0
  51. package/public/basic/fonts/bootstrap-icons.json +2077 -0
  52. package/public/basic/fonts/bootstrap-icons.woff +0 -0
  53. package/public/basic/fonts/bootstrap-icons.woff2 +0 -0
  54. package/public/basic/theme.css +209 -0
  55. package/seed.html +321 -0
@@ -0,0 +1,343 @@
1
+ // =============================================================================
2
+ // instance-model.js
3
+ //
4
+ // XForms-style data model. State is an XML *instance tree* of `XNode`s.
5
+ // Every attribute and text node is itself a `Signal`, so views can bind
6
+ // directly to paths and react to mutations.
7
+ //
8
+ // Public surface:
9
+ //
10
+ // XNode - one element node with its own attribute signals.
11
+ // XTree - the tree wrapper; emits routed events on every mutation.
12
+ // coerce - string -> primitive coercion shared with bindings.
13
+ // parseDataElement - parse an XML element into an XNode, recursively.
14
+ //
15
+ // Path syntax (a strict subset of XPath - intentional):
16
+ //
17
+ // /todos/todo child step
18
+ // /todos/todo[@done=true] attribute predicate (equality)
19
+ // /todos/todo[@text=hello] unquoted predicate value
20
+ // /todos/todo[2] 1-based index predicate
21
+ // $todo local variable bound by repeat/items
22
+ // $todo/@text attribute on a local variable
23
+ //
24
+ // We only implement what views need; full XPath is deliberately out of scope.
25
+ // If you find yourself wanting predicates with `or`, multi-step descendants,
26
+ // or function calls, please reach for a `<computed>` instead - that's the
27
+ // pressure valve.
28
+ // =============================================================================
29
+
30
+ import { assert } from './core.js';
31
+
32
+ export function instanceModelFeature(language) {
33
+ const { Signal, Disposable } = language;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // XNode - reactive XML element node
37
+ // ---------------------------------------------------------------------------
38
+ class XNode {
39
+ constructor(name, attrs = {}, text = '') {
40
+ this.name = name;
41
+ this.parent = null;
42
+ this.children = [];
43
+ this.tree = null; // back-pointer to the owning XTree, set on insert.
44
+ this.text = new Signal(text); // own text content (not children's).
45
+ this.attributes = new Map();
46
+ for (const [k, v] of Object.entries(attrs)) {
47
+ this.attributes.set(k, new Signal(coerce(v)));
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Get the signal for an attribute, creating an empty one if it doesn't
53
+ * exist. Lazy creation matters: a binding can subscribe to `@foo` before
54
+ * any code has set it, and we want the first set to fire that
55
+ * subscriber.
56
+ */
57
+ attr(name) {
58
+ if (!this.attributes.has(name)) this.attributes.set(name, new Signal(''));
59
+ return this.attributes.get(name);
60
+ }
61
+
62
+ get(name) {
63
+ return this.attr(name).value;
64
+ }
65
+
66
+ set(name, value) {
67
+ const sig = this.attr(name);
68
+ const oldValue = sig.value;
69
+ sig.value = coerce(value);
70
+ if (!Object.is(oldValue, sig.value)) {
71
+ this.tree?.notify({
72
+ type: 'xforms-value-changed',
73
+ node: this,
74
+ attribute: name,
75
+ oldValue,
76
+ value: sig.value,
77
+ });
78
+ }
79
+ }
80
+
81
+ /** Append a child. Pass `silent: true` during initial parse to skip events. */
82
+ append(child, { silent = false } = {}) {
83
+ child.parent = this;
84
+ this.children.push(child);
85
+ child.adoptTree(this.tree);
86
+ if (!silent) {
87
+ this.tree?.notify({ type: 'xforms-insert', node: child, parent: this });
88
+ }
89
+ return child;
90
+ }
91
+
92
+ insertBefore(child, reference, { silent = false } = {}) {
93
+ child.parent = this;
94
+ child.adoptTree(this.tree);
95
+ const i = this.children.indexOf(reference);
96
+ if (i < 0) this.children.push(child);
97
+ else this.children.splice(i, 0, child);
98
+ if (!silent) {
99
+ this.tree?.notify({ type: 'xforms-insert', node: child, parent: this });
100
+ }
101
+ return child;
102
+ }
103
+
104
+ remove() {
105
+ if (!this.parent) return;
106
+ const parent = this.parent;
107
+ parent.children = parent.children.filter(c => c !== this);
108
+ this.parent = null;
109
+ parent.tree?.notify({ type: 'xforms-delete', node: this, parent });
110
+ }
111
+
112
+ /** Recursively claim a tree pointer for this subtree (after splicing). */
113
+ adoptTree(tree) {
114
+ this.tree = tree;
115
+ for (const c of this.children) c.adoptTree(tree);
116
+ }
117
+
118
+ /** Slash-separated path from the root, e.g. "/todos/todo". */
119
+ path() {
120
+ const parts = [];
121
+ let n = this;
122
+ while (n && n.parent) {
123
+ parts.unshift(n.name);
124
+ n = n.parent;
125
+ }
126
+ return '/' + parts.join('/');
127
+ }
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // XTree - owner of the root XNode plus listener bus
132
+ // ---------------------------------------------------------------------------
133
+ class XTree {
134
+ constructor(root) {
135
+ this.root = root;
136
+ // Monotonic version counter. The renderer subscribes to this so any
137
+ // mutation in the tree triggers a re-render.
138
+ this.version = new Signal(0);
139
+ // Routed listeners by event type. Use '*' to listen to everything.
140
+ this.listeners = new Map();
141
+ root.adoptTree(this);
142
+ }
143
+
144
+ on(type, fn) {
145
+ if (!this.listeners.has(type)) this.listeners.set(type, new Set());
146
+ this.listeners.get(type).add(fn);
147
+ return new Disposable(() => this.listeners.get(type)?.delete(fn));
148
+ }
149
+
150
+ /**
151
+ * Dispatch an event to subscribers. Always increments `version` so the
152
+ * renderer wakes up. Event objects are augmented with a `path` so XML
153
+ * `<listener observer="/some/path">` can filter by prefix.
154
+ */
155
+ notify(event) {
156
+ event.path = event.node?.path?.() || event.parent?.path?.() || '/';
157
+ this.version.value = this.version.value + 1;
158
+ for (const fn of [...(this.listeners.get(event.type) ?? [])]) fn(event);
159
+ for (const fn of [...(this.listeners.get('*') ?? [])]) fn(event);
160
+ }
161
+
162
+ /** Resolve a path to a list of XNodes. Returns [] when no match. */
163
+ select(path, context = {}) {
164
+ if (!path) return [];
165
+ path = String(path).trim();
166
+ // `/foo/@attr` and `/foo/text()` are *value* selectors; treated here as
167
+ // "node containing the value" so callers can post-process.
168
+ if (path.includes('/@')) path = path.slice(0, path.lastIndexOf('/@'));
169
+ if (path.endsWith('/text()')) path = path.slice(0, -7);
170
+
171
+ // Local variable form: $name or $name/...
172
+ if (path.startsWith('$')) {
173
+ const m = path.match(/^\$([A-Za-z_][\w-]*)(?:\/(.*))?$/);
174
+ if (!m) return [];
175
+ const base = context[`$${m[1]}`] ?? context[m[1]];
176
+ if (!base) return [];
177
+ const rest = m[2];
178
+ return rest ? selectFrom(base, splitPath(rest)) : [base];
179
+ }
180
+
181
+ // Absolute form: /foo/bar
182
+ return selectFrom(this.root, splitPath(path.replace(/^\//, '')));
183
+ }
184
+
185
+ /** Resolve a path or `path/@attr` or `path/text()` to a value. */
186
+ valueOf(path, context = {}) {
187
+ const a = String(path).match(/^(.*)\/@([\w:-]+)$/);
188
+ if (a) return this.select(a[1], context)[0]?.get(a[2]) ?? '';
189
+ const t = String(path).match(/^(.*)\/text\(\)$/);
190
+ if (t) return this.select(t[1], context)[0]?.text.value ?? '';
191
+ const node = this.select(path, context)[0];
192
+ return node?.text.value ?? node ?? '';
193
+ }
194
+
195
+ /** Write a value to `path/@attr` or `path/text()`. */
196
+ setValue(path, value, context = {}) {
197
+ const a = String(path).match(/^(.*)\/@([\w:-]+)$/);
198
+ if (a) {
199
+ for (const node of this.select(a[1], context)) node.set(a[2], value);
200
+ return;
201
+ }
202
+ const t = String(path).match(/^(.*)\/text\(\)$/);
203
+ if (t) {
204
+ for (const node of this.select(t[1], context)) {
205
+ const old = node.text.value;
206
+ node.text.value = coerce(value);
207
+ if (!Object.is(old, node.text.value)) {
208
+ this.notify({
209
+ type: 'xforms-value-changed',
210
+ node,
211
+ oldValue: old,
212
+ value: node.text.value,
213
+ });
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Path internals
222
+ // ---------------------------------------------------------------------------
223
+
224
+ // Split a path into steps but tolerate predicate brackets that may
225
+ // contain slashes in the value. Today we don't allow slashes in values, so
226
+ // the simple split is fine; this helper keeps the call site readable.
227
+ function splitPath(pathBody) {
228
+ return pathBody.split('/').filter(Boolean);
229
+ }
230
+
231
+ // Recursive walk: from `node`, take the step in `parts[0]` and recurse on
232
+ // the rest. `*` matches any element name.
233
+ function selectFrom(node, parts) {
234
+ if (parts.length === 0) return [node];
235
+ const [raw, ...tail] = parts;
236
+ const step = parseStep(raw);
237
+ if (!step) return [];
238
+ let matches = node.children.filter(c => step.name === '*' || c.name === step.name);
239
+ if (step.attr) {
240
+ matches = matches.filter(c => looseEqual(c.get(step.attr), step.value));
241
+ }
242
+ if (step.index != null) {
243
+ matches = matches[step.index - 1] ? [matches[step.index - 1]] : [];
244
+ }
245
+ return matches.flatMap(c => selectFrom(c, tail));
246
+ }
247
+
248
+ // Parse one step: `name`, `name[@attr=value]`, `name[2]`.
249
+ function parseStep(step) {
250
+ const m = step.match(
251
+ /^([\w:-]+|\*)(?:\[@([\w:-]+)=['"]?([^'"\]]+)['"]?\])?(?:\[(\d+)\])?$/,
252
+ );
253
+ if (!m) return null;
254
+ return {
255
+ name: m[1],
256
+ attr: m[2],
257
+ value: coerce(m[3]),
258
+ index: m[4] ? Number(m[4]) : null,
259
+ };
260
+ }
261
+
262
+ // Equality is "loose" (string comparison) because XML attributes start as
263
+ // strings; we coerce on the right side, so for a predicate like
264
+ // `[@done=true]` against `done="true"` the comparison succeeds.
265
+ function looseEqual(a, b) {
266
+ return String(a) === String(b);
267
+ }
268
+
269
+ /**
270
+ * Coerce a string into a primitive: "true"/"false" -> Boolean,
271
+ * "12.5" -> Number, "" -> empty string. Used everywhere a value enters
272
+ * the tree from text.
273
+ */
274
+ function coerce(value) {
275
+ if (value === true || value === 'true') return true;
276
+ if (value === false || value === 'false') return false;
277
+ if (
278
+ typeof value === 'string' &&
279
+ value.trim() !== '' &&
280
+ /^-?\d+(\.\d+)?$/.test(value)
281
+ ) {
282
+ return Number(value);
283
+ }
284
+ return value ?? '';
285
+ }
286
+
287
+ // Read element attributes into a plain object, optionally interpolating
288
+ // `{expr}` placeholders. Skips namespaced attrs (those with `:`) because
289
+ // those carry framework directives like `bind:` / `on:`.
290
+ function attrsObject(element, instance = null, local = {}) {
291
+ const out = {};
292
+ for (const attr of [...element.attributes]) {
293
+ if (attr.name.includes(':')) continue;
294
+ out[attr.name] = instance
295
+ ? language.interpolate(attr.value, instance, local, { raw: true })
296
+ : attr.value;
297
+ }
298
+ return out;
299
+ }
300
+
301
+ /**
302
+ * Recursively turn an XML element tree (as produced by DOMParser) into an
303
+ * XNode tree. `silent: true` skips events while the tree is still empty.
304
+ */
305
+ function parseDataElement(element, instance = null, local = {}) {
306
+ const node = new XNode(
307
+ element.localName,
308
+ attrsObject(element, instance, local),
309
+ ownText(element),
310
+ );
311
+ for (const child of [...element.children]) {
312
+ node.append(parseDataElement(child, instance, local), { silent: true });
313
+ }
314
+ return node;
315
+ }
316
+
317
+ // Direct text children only - we don't merge descendant text.
318
+ function ownText(element) {
319
+ return [...element.childNodes]
320
+ .filter(n => n.nodeType === Node.TEXT_NODE)
321
+ .map(n => n.textContent)
322
+ .join('')
323
+ .trim();
324
+ }
325
+
326
+ // Expose to other features.
327
+ language.XNode = XNode;
328
+ language.XTree = XTree;
329
+ language.coerce = coerce;
330
+ language.parseDataElement = parseDataElement;
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Self-tests
334
+ // ---------------------------------------------------------------------------
335
+ language.test('instance: path selection, predicate, valueOf', () => {
336
+ const root = new XNode('data');
337
+ const tree = new XTree(root);
338
+ root.append(new XNode('todo', { id: 'a', done: 'true' }), { silent: true });
339
+ root.append(new XNode('todo', { id: 'b', done: 'false' }), { silent: true });
340
+ assert(tree.select('/todo[@done=true]').length === 1, 'predicate failed');
341
+ assert(tree.valueOf('/todo[@id=b]/@done') === false, 'valueOf failed');
342
+ });
343
+ }
@@ -0,0 +1,237 @@
1
+ // =============================================================================
2
+ // morph.js
3
+ //
4
+ // A small, dependency-free DOM morpher.
5
+ //
6
+ // The Galath rendering pipeline produces a fresh HTML string on every change
7
+ // and would prefer to *replace* the DOM each time. That's catastrophic for UX:
8
+ // inputs lose focus, scroll positions reset, transitions tear. We can't ship
9
+ // that. Instead we compute the new HTML, parse it into an off-screen tree,
10
+ // and surgically rewrite the *live* tree to match the new tree, leaving every
11
+ // untouched node alone.
12
+ //
13
+ // This is the same job morphdom does. We write our own here because the
14
+ // project rule is "no external npm packages". Ours is intentionally short
15
+ // (~80 lines of logic) and tuned for Galath's needs:
16
+ //
17
+ // * Preserve focus. If the user is typing in an <input> when the morph
18
+ // happens, we DO NOT clobber their value or selection. We let the live
19
+ // element keep its state and just sync attributes that aren't user-typed.
20
+ //
21
+ // * Preserve checkbox / radio state in the same way.
22
+ //
23
+ // * Match children purely by position. Galath's renderer emits stable order
24
+ // based on selection results, so positional matching is fine. Keyed
25
+ // reconciliation is intentionally out of scope - if you need stable
26
+ // identity across reorders, write a `<datatemplate key="...">` and rely
27
+ // on the rendering layer to emit a stable order.
28
+ //
29
+ // * Skip subtrees the renderer marked `data-xes-frozen`. (Reserved for
30
+ // future use; right now nothing emits it, but leaving the hook open lets
31
+ // us escape-hatch later without touching every caller.)
32
+ //
33
+ // The exported function `morph(fromNode, toNode)` mutates `fromNode` in place
34
+ // so its content equals `toNode`. It returns nothing.
35
+ // =============================================================================
36
+
37
+ // Element tags whose content is "user input" - we should never overwrite
38
+ // `value`, `selectionStart`, etc. on these while they have focus.
39
+ const FORM_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
40
+
41
+ /**
42
+ * Morph `fromNode` so it structurally matches `toNode`.
43
+ * Both should be Elements (not DocumentFragments).
44
+ *
45
+ * IMPORTANT: when `fromNode` is a Custom Element (its tag contains a hyphen
46
+ * AND it's a registered element), we sync ATTRIBUTES but never recurse into
47
+ * its children. Custom elements own their internal DOM via their own
48
+ * renderNow; parents must not clobber that. Attribute changes still flow,
49
+ * which lets prop updates re-trigger the child component's render.
50
+ */
51
+ export function morph(fromNode, toNode) {
52
+ if (fromNode.nodeName !== toNode.nodeName) {
53
+ fromNode.replaceWith(toNode);
54
+ return;
55
+ }
56
+ syncAttributes(fromNode, toNode);
57
+ if (isCustomElement(fromNode)) return; // its internals are not ours.
58
+ syncChildren(fromNode, toNode);
59
+ }
60
+
61
+ function isCustomElement(el) {
62
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) return false;
63
+ const name = el.localName;
64
+ return name.includes('-') && !!customElements.get(name);
65
+ }
66
+
67
+ // -----------------------------------------------------------------------------
68
+ // Attribute sync
69
+ // -----------------------------------------------------------------------------
70
+ // Walk the new attribute set and copy values across; remove attributes that
71
+ // don't exist in the new tree. We special-case form fields so that an input
72
+ // being edited doesn't have its `value` ripped out from under the user.
73
+ // -----------------------------------------------------------------------------
74
+ function syncAttributes(fromEl, toEl) {
75
+ const isFocusedInput =
76
+ fromEl === document.activeElement && FORM_TAGS.has(fromEl.tagName);
77
+
78
+ // Copy/replace attributes from the new element.
79
+ for (const attr of toEl.attributes) {
80
+ // The DOM `value` attribute is only the *initial* value of an input, but
81
+ // many devs (and Galath bindings) write to .value as a property. If the
82
+ // input is currently focused we leave its live value alone.
83
+ if (isFocusedInput && (attr.name === 'value' || attr.name === 'checked')) continue;
84
+ if (fromEl.getAttribute(attr.name) !== attr.value) {
85
+ fromEl.setAttribute(attr.name, attr.value);
86
+ }
87
+ }
88
+
89
+ // Remove attributes that no longer exist in the new tree.
90
+ // We iterate over a snapshot because we mutate during the loop.
91
+ for (const attr of [...fromEl.attributes]) {
92
+ if (!toEl.hasAttribute(attr.name)) {
93
+ if (isFocusedInput && (attr.name === 'value' || attr.name === 'checked')) continue;
94
+ fromEl.removeAttribute(attr.name);
95
+ }
96
+ }
97
+
98
+ // Sync the `value` *property* of form inputs that aren't focused. The
99
+ // attribute alone doesn't update the live input box once it's been typed
100
+ // into.
101
+ if (FORM_TAGS.has(fromEl.tagName) && !isFocusedInput) {
102
+ const newValue = toEl.getAttribute('value') ?? '';
103
+ if (fromEl.value !== newValue) fromEl.value = newValue;
104
+ if ('checked' in fromEl) {
105
+ const newChecked = toEl.hasAttribute('checked');
106
+ if (fromEl.checked !== newChecked) fromEl.checked = newChecked;
107
+ }
108
+ }
109
+ }
110
+
111
+ // -----------------------------------------------------------------------------
112
+ // Children sync
113
+ // -----------------------------------------------------------------------------
114
+ // Pair children by index. Where a child changes type (e.g. <span> became
115
+ // <div>), we replace it. Where it's the same tag, we recurse. Where the new
116
+ // tree has fewer children, we trim. Where it has more, we append.
117
+ //
118
+ // Text nodes are handled specially because they can't be morphed - we just
119
+ // overwrite their `data` property when it differs.
120
+ // -----------------------------------------------------------------------------
121
+ function syncChildren(fromEl, toEl) {
122
+ const fromChildren = [...fromEl.childNodes];
123
+ const toChildren = [...toEl.childNodes];
124
+
125
+ // Keyed fast path: when *every* element child of the new tree carries
126
+ // `data-xes-key`, reconcile by key instead of by position. This survives
127
+ // reorders without churning DOM that just changed places.
128
+ if (allKeyed(toChildren)) {
129
+ syncKeyedChildren(fromEl, fromChildren, toChildren);
130
+ return;
131
+ }
132
+
133
+ const max = Math.max(fromChildren.length, toChildren.length);
134
+
135
+ for (let i = 0; i < max; i++) {
136
+ const fromChild = fromChildren[i];
137
+ const toChild = toChildren[i];
138
+
139
+ if (!toChild) {
140
+ // New tree is shorter - drop the extra live node.
141
+ fromChild.remove();
142
+ continue;
143
+ }
144
+
145
+ if (!fromChild) {
146
+ // New tree is longer - append the new node (cloned so the off-screen
147
+ // template stays reusable).
148
+ fromEl.appendChild(toChild.cloneNode(true));
149
+ continue;
150
+ }
151
+
152
+ // Both exist at this index. Decide what to do based on node types.
153
+ if (fromChild.nodeType !== toChild.nodeType) {
154
+ // E.g. text became element. Just replace.
155
+ fromChild.replaceWith(toChild.cloneNode(true));
156
+ continue;
157
+ }
158
+
159
+ if (fromChild.nodeType === Node.TEXT_NODE) {
160
+ if (fromChild.data !== toChild.data) fromChild.data = toChild.data;
161
+ continue;
162
+ }
163
+
164
+ if (fromChild.nodeType === Node.COMMENT_NODE) {
165
+ // Comments rarely matter for behavior; sync data anyway.
166
+ if (fromChild.data !== toChild.data) fromChild.data = toChild.data;
167
+ continue;
168
+ }
169
+
170
+ if (fromChild.nodeType === Node.ELEMENT_NODE) {
171
+ // Future hook: a renderer can mark a subtree `data-xes-frozen` to opt
172
+ // out of morphing. Today nothing emits it. Cheap to keep.
173
+ if (fromChild.hasAttribute && fromChild.hasAttribute('data-xes-frozen')) continue;
174
+
175
+ if (fromChild.nodeName !== toChild.nodeName) {
176
+ fromChild.replaceWith(toChild.cloneNode(true));
177
+ continue;
178
+ }
179
+
180
+ // Same tag. Recurse.
181
+ morph(fromChild, toChild);
182
+ }
183
+ }
184
+ }
185
+
186
+ // Returns true when there is at least one element child and every element
187
+ // child has `data-xes-key`. Non-element nodes (text, comments) are ignored
188
+ // for this decision but are dropped during the keyed sync.
189
+ function allKeyed(nodes) {
190
+ let any = false;
191
+ for (const n of nodes) {
192
+ if (n.nodeType !== Node.ELEMENT_NODE) continue;
193
+ if (!n.hasAttribute || !n.hasAttribute('data-xes-key')) return false;
194
+ any = true;
195
+ }
196
+ return any;
197
+ }
198
+
199
+ // Reorder `fromEl`'s children to match `toChildren` by key. Existing nodes
200
+ // are MOVED (not recreated) so their internal state - input value, scroll,
201
+ // focus, attached listeners - survives a list reorder. Missing keys are
202
+ // inserted from a deep clone of the new template; surplus keyed nodes are
203
+ // removed.
204
+ function syncKeyedChildren(fromEl, fromChildren, toChildren) {
205
+ // Drop any non-keyed live children up front; they don't survive keying.
206
+ for (const child of fromChildren) {
207
+ if (child.nodeType !== Node.ELEMENT_NODE) {
208
+ child.remove();
209
+ continue;
210
+ }
211
+ if (!child.hasAttribute('data-xes-key')) child.remove();
212
+ }
213
+ const byKey = new Map();
214
+ for (const child of [...fromEl.childNodes]) {
215
+ if (child.nodeType === Node.ELEMENT_NODE && child.hasAttribute('data-xes-key')) {
216
+ byKey.set(child.getAttribute('data-xes-key'), child);
217
+ }
218
+ }
219
+ const used = new Set();
220
+ for (const newChild of toChildren) {
221
+ if (newChild.nodeType !== Node.ELEMENT_NODE) continue;
222
+ const key = newChild.getAttribute('data-xes-key');
223
+ const existing = byKey.get(key);
224
+ if (existing) {
225
+ // appendChild on a child already inside fromEl moves it, which is
226
+ // exactly what we want for "place at the next position".
227
+ fromEl.appendChild(existing);
228
+ morph(existing, newChild);
229
+ used.add(key);
230
+ } else {
231
+ fromEl.appendChild(newChild.cloneNode(true));
232
+ }
233
+ }
234
+ for (const [key, el] of byKey) {
235
+ if (!used.has(key) && el.parentNode === fromEl) el.remove();
236
+ }
237
+ }