@dinoreic/fez 0.4.0 → 0.5.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.
- package/README.md +723 -198
- package/bin/fez +16 -6
- package/bin/fez-compile +347 -0
- package/bin/fez-debug +25 -0
- package/bin/fez-index +16 -4
- package/bin/refactor +699 -0
- package/dist/fez.js +142 -33
- package/dist/fez.js.map +4 -4
- package/fez.d.ts +533 -0
- package/package.json +25 -15
- package/src/fez/compile.js +396 -164
- package/src/fez/connect.js +250 -143
- package/src/fez/defaults.js +275 -84
- package/src/fez/instance.js +673 -514
- package/src/fez/lib/await-helper.js +64 -0
- package/src/fez/lib/global-state.js +22 -4
- package/src/fez/lib/index.js +140 -0
- package/src/fez/lib/localstorage.js +44 -0
- package/src/fez/lib/n.js +38 -23
- package/src/fez/lib/pubsub.js +208 -0
- package/src/fez/lib/svelte-template-lib.js +339 -0
- package/src/fez/lib/svelte-template.js +472 -0
- package/src/fez/lib/template.js +114 -119
- package/src/fez/morph.js +384 -0
- package/src/fez/root.js +284 -164
- package/src/fez/utility.js +319 -149
- package/src/fez/utils/dump.js +114 -84
- package/src/fez/utils/highlight_all.js +1 -1
- package/src/fez.js +65 -43
- package/src/rollup.js +1 -1
- package/src/svelte-cde-adapter.coffee +21 -12
- package/src/fez/vendor/idiomorph.js +0 -860
package/src/fez/lib/template.js
CHANGED
|
@@ -1,128 +1,123 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
1
|
+
/**
|
|
2
|
+
* Fez Template Compiler
|
|
3
|
+
*
|
|
4
|
+
* Compiles Svelte-style templates to render functions.
|
|
5
|
+
* Supports legacy {{ }} and [[ ]] syntax via auto-conversion.
|
|
6
|
+
*
|
|
7
|
+
* Syntax:
|
|
8
|
+
* {expression} - Output escaped value
|
|
9
|
+
* {@html expr} - Output raw HTML
|
|
10
|
+
* {@json expr} - Output formatted JSON
|
|
11
|
+
* {#if cond}...{/if} - Conditional
|
|
12
|
+
* {#each arr as item} - Loop
|
|
13
|
+
* {#for item in arr} - Loop (alt syntax)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import createSvelteTemplate from "./svelte-template.js";
|
|
17
|
+
|
|
18
|
+
// Template cache
|
|
19
|
+
const cache = new Map();
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// MAIN EXPORT
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a template render function
|
|
27
|
+
*
|
|
28
|
+
* @param {string} text - Template HTML
|
|
29
|
+
* @param {Object} opts - { name: componentName }
|
|
30
|
+
* @returns {Function} Render function (ctx) => html
|
|
31
|
+
*/
|
|
32
|
+
export default function createTemplate(text, opts = {}) {
|
|
33
|
+
// Check cache
|
|
34
|
+
if (cache.has(text)) {
|
|
35
|
+
return cache.get(text);
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
|
|
38
|
+
// Convert legacy syntax if detected
|
|
39
|
+
if (hasLegacySyntax(text)) {
|
|
40
|
+
text = convertLegacySyntax(text, opts.name);
|
|
39
41
|
}
|
|
40
|
-
else {
|
|
41
|
-
const prefix = '@html '
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
43
|
+
// Compile
|
|
44
|
+
const fn = createSvelteTemplate(text, opts);
|
|
45
|
+
cache.set(text, fn);
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
} else {
|
|
51
|
-
data = `Fez.htmlEscape(${data})`
|
|
52
|
-
}
|
|
47
|
+
return fn;
|
|
48
|
+
}
|
|
53
49
|
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Clear template cache (for testing)
|
|
52
|
+
*/
|
|
53
|
+
export function clearTemplateCache() {
|
|
54
|
+
cache.clear();
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
return outFunc
|
|
123
|
-
} catch(e) {
|
|
124
|
-
e.message = `FEZ template compile error: ${e.message}Template source:\n${result}`
|
|
125
|
-
console.error(e)
|
|
126
|
-
return ()=>Fez.error(`Template Compile Error`, true)
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// LEGACY SYNTAX SUPPORT
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if text uses old {{ }} or [[ ]] syntax
|
|
63
|
+
*/
|
|
64
|
+
function hasLegacySyntax(text) {
|
|
65
|
+
return (
|
|
66
|
+
(text.includes("{{") && text.includes("}}")) ||
|
|
67
|
+
(text.includes("[[") && text.includes("]]"))
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert {{ }}/[[ ]] syntax to { } syntax
|
|
73
|
+
*
|
|
74
|
+
* Mappings:
|
|
75
|
+
* {{ expr }} -> {expr}
|
|
76
|
+
* {{if cond}} -> {#if cond}
|
|
77
|
+
* {{for x in y}} -> {#for x in y}
|
|
78
|
+
* {{each y as x}} -> {#each y as x}
|
|
79
|
+
* {{raw x}} -> {@html x}
|
|
80
|
+
* {{json x}} -> {@json x}
|
|
81
|
+
* {{block x}} -> {@block x}
|
|
82
|
+
*/
|
|
83
|
+
function convertLegacySyntax(text, componentName) {
|
|
84
|
+
// Normalize [[ ]] to {{ }}
|
|
85
|
+
text = text.replaceAll("[[", "{{").replaceAll("]]", "}}");
|
|
86
|
+
|
|
87
|
+
// Blocks
|
|
88
|
+
text = text.replace(/\{\{block\s+(\w+)\s*\}\}/g, "{@block $1}");
|
|
89
|
+
text = text.replace(/\{\{\/block\}\}/g, "{/block}");
|
|
90
|
+
text = text.replace(/\{\{block:([\w\-]+)\s*\}\}/g, "{@block:$1}");
|
|
91
|
+
|
|
92
|
+
// Conditionals
|
|
93
|
+
text = text.replace(/\{\{#?if\s+(.*?)\}\}/g, "{#if $1}");
|
|
94
|
+
text = text.replace(/\{\{\/if\}\}/g, "{/if}");
|
|
95
|
+
text = text.replace(/\{\{#?unless\s+(.*?)\}\}/g, "{#unless $1}");
|
|
96
|
+
text = text.replace(/\{\{\/unless\}\}/g, "{/unless}");
|
|
97
|
+
text = text.replace(/\{\{:?else\s+if\s+(.*?)\}\}/g, "{:else if $1}");
|
|
98
|
+
text = text.replace(/\{\{:?elsif\s+(.*?)\}\}/g, "{:else if $1}");
|
|
99
|
+
text = text.replace(/\{\{:?elseif\s+(.*?)\}\}/g, "{:else if $1}");
|
|
100
|
+
text = text.replace(/\{\{:?else\}\}/g, "{:else}");
|
|
101
|
+
|
|
102
|
+
// Loops
|
|
103
|
+
text = text.replace(/\{\{#?for\s+(.*?)\}\}/g, "{#for $1}");
|
|
104
|
+
text = text.replace(/\{\{\/for\}\}/g, "{/for}");
|
|
105
|
+
text = text.replace(/\{\{#?each\s+(.*?)\}\}/g, "{#each $1}");
|
|
106
|
+
text = text.replace(/\{\{\/each\}\}/g, "{/each}");
|
|
107
|
+
|
|
108
|
+
// Special directives
|
|
109
|
+
text = text.replace(/\{\{#?(?:raw|html)\s+(.*?)\}\}/g, "{@html $1}");
|
|
110
|
+
text = text.replace(/\{\{json\s+(.*?)\}\}/g, "{@json $1}");
|
|
111
|
+
|
|
112
|
+
// Expressions
|
|
113
|
+
text = text.replace(/\{\{\s*(.*?)\s*\}\}/g, "{$1}");
|
|
114
|
+
|
|
115
|
+
// Log warning
|
|
116
|
+
if (componentName) {
|
|
117
|
+
console.warn(
|
|
118
|
+
`Fez component "${componentName}" uses old {{ ... }} notation, converting.`,
|
|
119
|
+
);
|
|
127
120
|
}
|
|
121
|
+
|
|
122
|
+
return text;
|
|
128
123
|
}
|
package/src/fez/morph.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fez-morph - Component-aware DOM differ for Fez
|
|
3
|
+
*
|
|
4
|
+
* Replaces Idiomorph with a simpler, Fez-specific algorithm that:
|
|
5
|
+
* - Matches fez components by UID (never morphs them, only moves/preserves/destroys)
|
|
6
|
+
* - Matches keyed elements by fez-keep attribute (preserved entirely)
|
|
7
|
+
* - Matches elements by id (morphed in place)
|
|
8
|
+
* - Falls back to tag+position matching
|
|
9
|
+
* - Uses classList.add/remove for class sync (preserves CSS animations)
|
|
10
|
+
* - Skips value sync on focused INPUT/TEXTAREA/SELECT
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Public API
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Morph target element to match newNode structure.
|
|
19
|
+
* Mutates target in-place. newNode is consumed (nodes may be moved out of it).
|
|
20
|
+
*
|
|
21
|
+
* @param {Element} target - live DOM element to update
|
|
22
|
+
* @param {Element} newNode - detached element with desired state
|
|
23
|
+
* @param {Object} opts
|
|
24
|
+
* @param {Function} opts.skipNode(oldNode) - return true to preserve subtree as-is
|
|
25
|
+
* @param {Function} opts.beforeRemove(node) - called before removing a node
|
|
26
|
+
*/
|
|
27
|
+
export function fezMorph(target, newNode, opts = {}) {
|
|
28
|
+
// NOTE: We do NOT sync root element attributes here.
|
|
29
|
+
// The root is the component wrapper (class="fez fez-name goXXX") managed by Fez,
|
|
30
|
+
// not by the template. Syncing root attrs would strip component CSS classes.
|
|
31
|
+
|
|
32
|
+
// Diff children recursively
|
|
33
|
+
diffChildren(target, newNode, opts);
|
|
34
|
+
|
|
35
|
+
// Clean up trailing whitespace text node (matches old behavior)
|
|
36
|
+
const next = target.nextSibling;
|
|
37
|
+
if (next?.nodeType === 3 && !next.textContent.trim()) {
|
|
38
|
+
next.remove();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Attribute Sync
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function syncAttributes(oldNode, newNode) {
|
|
47
|
+
const oldAttrs = oldNode.attributes;
|
|
48
|
+
const newAttrs = newNode.attributes;
|
|
49
|
+
|
|
50
|
+
// Check if this is a focused form input - skip value/checked sync
|
|
51
|
+
const isActiveInput =
|
|
52
|
+
oldNode === document.activeElement && isFormInput(oldNode);
|
|
53
|
+
|
|
54
|
+
// Remove attributes not present in new node
|
|
55
|
+
for (let i = oldAttrs.length - 1; i >= 0; i--) {
|
|
56
|
+
const name = oldAttrs[i].name;
|
|
57
|
+
if (!newNode.hasAttribute(name)) {
|
|
58
|
+
oldNode.removeAttribute(name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Set/update attributes from new node
|
|
63
|
+
for (let i = 0; i < newAttrs.length; i++) {
|
|
64
|
+
const attr = newAttrs[i];
|
|
65
|
+
|
|
66
|
+
// Skip value/checked on focused form inputs
|
|
67
|
+
if (isActiveInput && (attr.name === "value" || attr.name === "checked")) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (oldNode.getAttribute(attr.name) !== attr.value) {
|
|
72
|
+
if (attr.name === "class") {
|
|
73
|
+
syncClassList(oldNode, newNode);
|
|
74
|
+
} else {
|
|
75
|
+
try {
|
|
76
|
+
oldNode.setAttribute(attr.name, attr.value);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error("Error setting attribute:", {
|
|
79
|
+
node: oldNode,
|
|
80
|
+
attribute: attr.name,
|
|
81
|
+
error: error.message,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Sync classes using classList.add/remove to preserve CSS animations.
|
|
91
|
+
*/
|
|
92
|
+
function syncClassList(oldNode, newNode) {
|
|
93
|
+
const oldClasses = new Set(
|
|
94
|
+
(oldNode.getAttribute("class") || "").split(/\s+/).filter(Boolean),
|
|
95
|
+
);
|
|
96
|
+
const newClasses = new Set(
|
|
97
|
+
(newNode.getAttribute("class") || "").split(/\s+/).filter(Boolean),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
for (const cls of oldClasses) {
|
|
101
|
+
if (!newClasses.has(cls)) {
|
|
102
|
+
oldNode.classList.remove(cls);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const cls of newClasses) {
|
|
106
|
+
if (!oldClasses.has(cls)) {
|
|
107
|
+
oldNode.classList.add(cls);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isFormInput(node) {
|
|
113
|
+
const tag = node.nodeName;
|
|
114
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Node Keys
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function getNodeKey(node) {
|
|
122
|
+
if (node.nodeType !== 1) return null;
|
|
123
|
+
|
|
124
|
+
// Fez component - match by UID
|
|
125
|
+
if (node.classList?.contains("fez") && node.fez) {
|
|
126
|
+
return "fez-uid-" + node.fez.UID;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// fez-keep attribute
|
|
130
|
+
const keepKey = node.getAttribute?.("fez-keep");
|
|
131
|
+
if (keepKey) return "keep-" + keepKey;
|
|
132
|
+
|
|
133
|
+
// id attribute
|
|
134
|
+
const id = node.id;
|
|
135
|
+
if (id) return "id-" + id;
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* For new (template) nodes, get the key they SHOULD match against.
|
|
142
|
+
* New nodes don't have .fez instances, but they may have fez-keep or id.
|
|
143
|
+
*/
|
|
144
|
+
function getNewNodeKey(node) {
|
|
145
|
+
if (node.nodeType !== 1) return null;
|
|
146
|
+
|
|
147
|
+
// fez-keep attribute
|
|
148
|
+
const keepKey = node.getAttribute?.("fez-keep");
|
|
149
|
+
if (keepKey) return "keep-" + keepKey;
|
|
150
|
+
|
|
151
|
+
// id attribute
|
|
152
|
+
const id = node.id;
|
|
153
|
+
if (id) return "id-" + id;
|
|
154
|
+
|
|
155
|
+
// Check if this is a placeholder for a fez component (has fez class)
|
|
156
|
+
// New template nodes don't have .fez but may have class="fez fez-name"
|
|
157
|
+
if (node.classList?.contains("fez")) {
|
|
158
|
+
// Match by fez-name class
|
|
159
|
+
for (const cls of node.classList) {
|
|
160
|
+
if (cls.startsWith("fez-") && cls !== "fez") {
|
|
161
|
+
return "fez-class-" + cls;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Child Diffing
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function diffChildren(target, newParent, opts) {
|
|
174
|
+
const oldChildren = Array.from(target.childNodes);
|
|
175
|
+
const newChildren = Array.from(newParent.childNodes);
|
|
176
|
+
|
|
177
|
+
if (oldChildren.length === 0 && newChildren.length === 0) return;
|
|
178
|
+
|
|
179
|
+
// Fast path: no old children, just append all new
|
|
180
|
+
if (oldChildren.length === 0) {
|
|
181
|
+
for (const child of newChildren) {
|
|
182
|
+
target.appendChild(child);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Fast path: no new children, remove all old
|
|
188
|
+
if (newChildren.length === 0) {
|
|
189
|
+
for (const child of oldChildren) {
|
|
190
|
+
if (opts.beforeRemove && child.nodeType === 1) {
|
|
191
|
+
callBeforeRemoveDeep(child, opts);
|
|
192
|
+
}
|
|
193
|
+
target.removeChild(child);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Build key map for old children
|
|
199
|
+
// A node can have multiple keys (e.g., fez component has UID key + id key)
|
|
200
|
+
const oldByKey = new Map();
|
|
201
|
+
for (const child of oldChildren) {
|
|
202
|
+
const key = getNodeKey(child);
|
|
203
|
+
if (key) {
|
|
204
|
+
oldByKey.set(key, child);
|
|
205
|
+
// Fez components also match by id and class (template placeholders have no .fez)
|
|
206
|
+
if (key.startsWith("fez-uid-")) {
|
|
207
|
+
if (child.id) {
|
|
208
|
+
oldByKey.set("id-" + child.id, child);
|
|
209
|
+
}
|
|
210
|
+
// Also match by fez-name class (e.g., "fez-class-fez-my-comp")
|
|
211
|
+
if (child.classList) {
|
|
212
|
+
for (const cls of child.classList) {
|
|
213
|
+
if (cls.startsWith("fez-") && cls !== "fez") {
|
|
214
|
+
oldByKey.set("fez-class-" + cls, child);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Phase 1: Match each new child to an old child
|
|
224
|
+
const matches = []; // [{old, new, preserve}]
|
|
225
|
+
const usedOld = new Set();
|
|
226
|
+
|
|
227
|
+
// Pass 1a: match by key (fez-keep, id, fez UID)
|
|
228
|
+
for (let i = 0; i < newChildren.length; i++) {
|
|
229
|
+
const newChild = newChildren[i];
|
|
230
|
+
const key = getNewNodeKey(newChild);
|
|
231
|
+
|
|
232
|
+
if (key && oldByKey.has(key)) {
|
|
233
|
+
const oldChild = oldByKey.get(key);
|
|
234
|
+
const preserve = key.startsWith("keep-") || key.startsWith("fez-uid-");
|
|
235
|
+
matches.push({ old: oldChild, new: newChild, preserve });
|
|
236
|
+
usedOld.add(oldChild);
|
|
237
|
+
} else {
|
|
238
|
+
matches.push({ old: null, new: newChild, preserve: false });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Pass 1b: for unmatched new children, try soft match with unmatched old
|
|
243
|
+
// Skip elements with fez-keep or fez components - they only match by key
|
|
244
|
+
const unmatchedOld = oldChildren.filter((c) => !usedOld.has(c));
|
|
245
|
+
let uIdx = 0;
|
|
246
|
+
for (let i = 0; i < matches.length; i++) {
|
|
247
|
+
if (matches[i].old) continue; // already matched
|
|
248
|
+
|
|
249
|
+
const newChild = matches[i].new;
|
|
250
|
+
// Don't soft-match new nodes that have a key (fez-keep, id)
|
|
251
|
+
// They should only match by their key
|
|
252
|
+
if (
|
|
253
|
+
getNewNodeKey(newChild) &&
|
|
254
|
+
getNewNodeKey(newChild).startsWith("keep-")
|
|
255
|
+
) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
while (uIdx < unmatchedOld.length) {
|
|
260
|
+
const candidate = unmatchedOld[uIdx];
|
|
261
|
+
uIdx++;
|
|
262
|
+
// Don't soft-match old nodes that have fez-keep or are fez components
|
|
263
|
+
if (
|
|
264
|
+
candidate.nodeType === 1 &&
|
|
265
|
+
(candidate.getAttribute?.("fez-keep") ||
|
|
266
|
+
(candidate.classList?.contains("fez") && candidate.fez))
|
|
267
|
+
) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (softMatch(candidate, newChild)) {
|
|
271
|
+
matches[i].old = candidate;
|
|
272
|
+
usedOld.add(candidate);
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Phase 2: Remove unmatched old children
|
|
279
|
+
for (const child of oldChildren) {
|
|
280
|
+
if (!usedOld.has(child)) {
|
|
281
|
+
if (child.nodeType === 1) {
|
|
282
|
+
callBeforeRemoveDeep(child, opts);
|
|
283
|
+
}
|
|
284
|
+
target.removeChild(child);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Phase 3: Apply matches in order (morph + position)
|
|
289
|
+
let cursor = target.firstChild;
|
|
290
|
+
|
|
291
|
+
for (const match of matches) {
|
|
292
|
+
if (match.old) {
|
|
293
|
+
// We have a matched old node
|
|
294
|
+
const oldChild = match.old;
|
|
295
|
+
const newChild = match.new;
|
|
296
|
+
|
|
297
|
+
if (match.preserve) {
|
|
298
|
+
// fez-keep or fez component: preserve entirely, just ensure position
|
|
299
|
+
if (oldChild !== cursor) {
|
|
300
|
+
target.insertBefore(oldChild, cursor);
|
|
301
|
+
} else {
|
|
302
|
+
cursor = cursor.nextSibling;
|
|
303
|
+
}
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Morph the matched pair
|
|
308
|
+
if (oldChild.nodeType === 3 && newChild.nodeType === 3) {
|
|
309
|
+
// Both text nodes
|
|
310
|
+
if (oldChild.textContent !== newChild.textContent) {
|
|
311
|
+
oldChild.textContent = newChild.textContent;
|
|
312
|
+
}
|
|
313
|
+
} else if (oldChild.nodeType === 8 && newChild.nodeType === 8) {
|
|
314
|
+
// Both comment nodes
|
|
315
|
+
if (oldChild.textContent !== newChild.textContent) {
|
|
316
|
+
oldChild.textContent = newChild.textContent;
|
|
317
|
+
}
|
|
318
|
+
} else if (oldChild.nodeType === 1 && newChild.nodeType === 1) {
|
|
319
|
+
// Both elements
|
|
320
|
+
if (opts.skipNode && opts.skipNode(oldChild)) {
|
|
321
|
+
// Skip this subtree entirely (fez component via skipNode callback)
|
|
322
|
+
} else if (oldChild.nodeName === newChild.nodeName) {
|
|
323
|
+
// Same tag: sync attributes and recurse
|
|
324
|
+
syncAttributes(oldChild, newChild);
|
|
325
|
+
diffChildren(oldChild, newChild, opts);
|
|
326
|
+
} else {
|
|
327
|
+
// Different tag: replace
|
|
328
|
+
callBeforeRemoveDeep(oldChild, opts);
|
|
329
|
+
const replacement = newChild;
|
|
330
|
+
target.insertBefore(replacement, oldChild);
|
|
331
|
+
target.removeChild(oldChild);
|
|
332
|
+
cursor = replacement.nextSibling;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
// Different node types: replace
|
|
337
|
+
if (oldChild.nodeType === 1) {
|
|
338
|
+
callBeforeRemoveDeep(oldChild, opts);
|
|
339
|
+
}
|
|
340
|
+
target.insertBefore(newChild, oldChild);
|
|
341
|
+
target.removeChild(oldChild);
|
|
342
|
+
cursor = newChild.nextSibling;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Ensure correct position
|
|
347
|
+
if (oldChild !== cursor) {
|
|
348
|
+
target.insertBefore(oldChild, cursor);
|
|
349
|
+
} else {
|
|
350
|
+
cursor = cursor.nextSibling;
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
// No old match: insert new node
|
|
354
|
+
target.insertBefore(match.new, cursor);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Soft match: same node type and (for elements) same tag name.
|
|
361
|
+
*/
|
|
362
|
+
function softMatch(oldNode, newNode) {
|
|
363
|
+
if (oldNode.nodeType !== newNode.nodeType) return false;
|
|
364
|
+
if (oldNode.nodeType === 1) {
|
|
365
|
+
return oldNode.nodeName === newNode.nodeName;
|
|
366
|
+
}
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Call beforeRemove on a node and all fez components inside it.
|
|
372
|
+
*/
|
|
373
|
+
function callBeforeRemoveDeep(node, opts) {
|
|
374
|
+
if (!opts.beforeRemove) return;
|
|
375
|
+
opts.beforeRemove(node);
|
|
376
|
+
if (node.querySelectorAll) {
|
|
377
|
+
node.querySelectorAll(".fez").forEach((child) => {
|
|
378
|
+
opts.beforeRemove(child);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Export for testing
|
|
384
|
+
export { syncClassList, isFormInput };
|