@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.
@@ -1,128 +1,123 @@
1
- function parseBlock(data, ifStack) {
2
- data = data
3
- .replace(/^#?raw/, '@html')
4
- .replace(/^#?html/, '@html')
5
-
6
- // Handle #if directive
7
- if (data.startsWith('#if') || data.startsWith('if')) {
8
- ifStack.push(false)
9
- data = data.replace(/^#?if/, '')
10
- return `\${ ${data} ? \``
11
- }
12
- else if (data.startsWith('#unless') || data.startsWith('unless')) {
13
- ifStack.push(false)
14
- data = data.replace(/^#?unless/, '')
15
- return `\${ !(${data}) ? \``
16
- }
17
- else if (data == '/block') {
18
- return '`) && \'\'}'
19
- }
20
- else if (data.startsWith('#for') || data.startsWith('for')) {
21
- data = data.replace(/^#?for/, '')
22
- const el = data.split(' in ', 2)
23
- return '${' + el[1] + '.map((' + el[0] + ')=>`'
24
- }
25
- else if (data.startsWith('#each') || data.startsWith('each')) {
26
- data = data.replace(/^#?each/, '')
27
- const el = data.split(' as ', 2)
28
- return '${' + el[0] + '.map((' + el[1] + ')=>`'
29
- }
30
- else if (data == ':else' || data == 'else') {
31
- ifStack[ifStack.length - 1] = true
32
- return '` : `'
33
- }
34
- else if (data == '/if' || data == '/unless') {
35
- return ifStack.pop() ? '`}' : '` : ``}'
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
- else if (data == '/for' || data == '/each') {
38
- return '`).join("")}'
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
- if (data.startsWith('json ')) {
44
- data = data.replace('json ', "@html '<pre class=json>'+JSON.stringify(")
45
- data += ", null, 2) + '</pre>'"
46
- }
43
+ // Compile
44
+ const fn = createSvelteTemplate(text, opts);
45
+ cache.set(text, fn);
47
46
 
48
- if (data.startsWith(prefix)) {
49
- data = data.replace(prefix, '')
50
- } else {
51
- data = `Fez.htmlEscape(${data})`
52
- }
47
+ return fn;
48
+ }
53
49
 
54
- return '${' + data + '}'
55
- }
50
+ /**
51
+ * Clear template cache (for testing)
52
+ */
53
+ export function clearTemplateCache() {
54
+ cache.clear();
56
55
  }
57
56
 
58
- // let tpl = createTemplate(string)
59
- // tpl({ ... this state ...})
60
- export default function createTemplate(text, opts = {}) {
61
- const ifStack = []
62
-
63
- // some templating engines, as GoLangs use {{ for templates. Allow usage of [[ for fez
64
- text = text
65
- .replaceAll('[[', '{{')
66
- .replaceAll(']]', '}}')
67
-
68
- text = text.replace(/(\w+)=\{\{\s*(.*?)\s*\}\}([\s>])/g, (match, p1, p2, p3) => {
69
- return `${p1}="{`+`{ ${p2} }`+`}"${p3}`
70
- })
71
-
72
- // {{block foo}} ... {{/block}}
73
- // {{block:foo}}
74
- const blocks = {}
75
- text = text.replace(/\{\{block\s+(\w+)\s*\}\}([^§]+)\{\{\/block\}\}/g, (_, name, block) => {
76
- blocks[name] = block
77
- return ''
78
- })
79
- text = text.replace(/\{\{block:([\w\-]+)\s*\}\}/g, (_, name) => blocks[name] || `block:${name}?`)
80
-
81
- // {{#for el in list }}}}
82
- // <ui-comment :comment="el"></ui-comment>
83
- // -> :comment="{{ JSON.stringify(el) }}"
84
- // skip attr="foo.bar"
85
- text = text.replace(/:(\w+)="([\w\.\[\]]+)"/, (_, m1, m2) => {
86
- return `:${m1}=Fez.store.delete({{ Fez.store.set(${m2}) }})`
87
- })
88
-
89
- let result = text.replace(/{{(.*?)}}/g, (_, content) => {
90
- content = content.replaceAll('&#x60;', '`')
91
-
92
- content = content
93
- .replaceAll('&lt;', '<')
94
- .replaceAll('&gt;', '>')
95
- .replaceAll('&amp;', '&')
96
- const parsedData = parseBlock(content, ifStack);
97
-
98
- return parsedData
99
- });
100
-
101
- result = result
102
- .replace(/<!\-\-.*?\-\->/g, '')
103
- .replace(/>\s+</g, '><')
104
-
105
- result = '`' + result.trim() + '`'
106
-
107
- try {
108
- const funcBody = `const fez = this;
109
- with (this) {
110
- return ${result}
111
- }
112
- `
113
- const tplFunc = new Function(funcBody);
114
- const outFunc = (o) => {
115
- try {
116
- return tplFunc.bind(o)()
117
- } catch(e) {
118
- e.message = `FEZ template runtime error: ${e.message}\n\nTemplate source: ${result}`
119
- console.error(e)
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
  }
@@ -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 };