@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.
@@ -0,0 +1,472 @@
1
+ // Svelte-like template parser for Fez
2
+ // Compiles to a single function that returns HTML string
3
+ //
4
+ // Supports:
5
+ // {#if cond}...{:else if cond}...{:else}...{/if}
6
+ // {#unless cond}...{/unless}
7
+ // {#each items as item}...{/each} - implicit index `i` available
8
+ // {#each items as item, index}...{/each} - explicit index name
9
+ // {#for item in items}...{/for} - implicit index `i` available
10
+ // {#for item, index in items}...{/for} - explicit index name
11
+ // {#each obj as key, value, index} - object iteration (3 params)
12
+ // {#await promise}...{:then value}...{:catch error}...{/await}
13
+ // {@html rawContent} - unescaped HTML
14
+ // {@json obj} - debug JSON output
15
+ // {expression} - escaped expression
16
+
17
+ import {
18
+ getLoopVarNames,
19
+ getLoopItemVars,
20
+ buildCollectionExpr,
21
+ buildLoopParams,
22
+ isArrowFunction,
23
+ transformArrowToHandler,
24
+ extractBracedExpression,
25
+ getAttributeContext,
26
+ getEventAttributeContext,
27
+ } from "./svelte-template-lib.js";
28
+
29
+ /**
30
+ * Compile template to a function that returns HTML string
31
+ *
32
+ * @param {string} text - Template source
33
+ * @param {Object} opts - Options
34
+ * @param {string} opts.name - Component name for error messages
35
+ */
36
+ export default function createSvelteTemplate(text, opts = {}) {
37
+ const componentName = opts.name || "unknown";
38
+
39
+ try {
40
+ // Decode HTML entities that might have been encoded by browser DOM
41
+ text = text
42
+ .replaceAll("`", "`")
43
+ .replaceAll("&lt;", "<")
44
+ .replaceAll("&gt;", ">")
45
+ .replaceAll("&amp;", "&");
46
+
47
+ // Allow Svelte-style fez:attr namespace syntax as alias for fez-attr
48
+ text = text.replace(/\bfez:([a-z]+)=/gi, "fez-$1=");
49
+
50
+ // Error if fez-keep is placed on a fez component (custom element with dash in tag name)
51
+ const keepOnComponent = text.match(
52
+ /<([a-z]+-[a-z][a-z0-9-]*)\b[^>]*\bfez-keep=/,
53
+ );
54
+ if (keepOnComponent) {
55
+ console.error(
56
+ `FEZ: fez:keep must be on plain HTML elements, not on fez components. Found on <${keepOnComponent[1]}> in <${componentName}>`,
57
+ );
58
+ }
59
+
60
+ // Process block definitions and references before parsing
61
+ const blocks = {};
62
+ text = text.replace(
63
+ /\{@block\s+(\w+)\}([\s\S]*?)\{\/block\}/g,
64
+ (_, name, content) => {
65
+ blocks[name] = content;
66
+ return "";
67
+ },
68
+ );
69
+ text = text.replace(/\{@block:(\w+)\}/g, (_, name) => blocks[name] || "");
70
+
71
+ // Convert :attr="expr" to use Fez(UID).fezGlobals for passing values through DOM
72
+ // This allows loop variables to be passed as props to child components
73
+ // :file="el.file" -> :file={`Fez(${UID}).fezGlobals.delete(${fez.fezGlobals.set(el.file)})`}
74
+ // Uses Fez(UID) so child component can find parent's fezGlobals
75
+ text = text.replace(/:(\w+)="([^"{}]+)"/g, (match, attr, expr) => {
76
+ // Only convert if expr looks like a variable access (not a string literal)
77
+ if (/^[\w.[\]]+$/.test(expr.trim())) {
78
+ return `:${attr}={\`Fez(\${UID}).fezGlobals.delete(\${fez.fezGlobals.set(${expr})})\`}`;
79
+ }
80
+ return match;
81
+ });
82
+
83
+ // Remove HTML comments
84
+ text = text.replace(/<!--[\s\S]*?-->/g, "");
85
+
86
+ // Normalize whitespace between tags
87
+ text = text.replace(/>\s+</g, "><").trim();
88
+
89
+ // Convert self-closing custom elements to paired tags
90
+ // <ui-icon name="foo" /> -> <ui-icon name="foo"></ui-icon>
91
+ // Custom elements contain a hyphen in the tag name
92
+ // Uses (?:[^>]|=>) to skip => (arrow functions) inside attributes
93
+ text = text.replace(
94
+ /<([a-z][a-z0-9]*-[a-z0-9-]*)((?:=>|[^>])*)>/gi,
95
+ (match, tag, attrs) => {
96
+ if (attrs.trimEnd().endsWith("/")) {
97
+ return `<${tag}${attrs.replace(/\s*\/$/, "")}></${tag}>`;
98
+ }
99
+ return match;
100
+ },
101
+ );
102
+
103
+ // Convert self-closing <slot /> to <slot></slot>
104
+ text = text.replace(/<slot\s*\/>/gi, "<slot></slot>");
105
+
106
+ // Parse and build template literal
107
+ let result = "";
108
+ let i = 0;
109
+ const ifStack = []; // Track if blocks have else
110
+ const loopVarStack = []; // Track all loop variables for arrow function transformation
111
+ const loopItemVarStack = []; // Track item vars (non-index) that could be objects
112
+ const loopStack = []; // Track loop info for :else support
113
+ const awaitStack = []; // Track await blocks for :then/:catch
114
+ let awaitCounter = 0; // Unique ID for each await block
115
+
116
+ while (i < text.length) {
117
+ // Skip JavaScript template literals (backtick strings)
118
+ // Content inside backticks should not be processed as Fez expressions
119
+ if (text[i] === "`") {
120
+ result += "\\`";
121
+ i++;
122
+ // Copy everything until closing backtick
123
+ while (i < text.length && text[i] !== "`") {
124
+ if (text[i] === "\\") {
125
+ // Handle escaped characters
126
+ result += "\\\\";
127
+ i++;
128
+ if (i < text.length) {
129
+ if (text[i] === "`") {
130
+ result += "\\`";
131
+ } else if (text[i] === "$") {
132
+ result += "\\$";
133
+ } else {
134
+ result += text[i];
135
+ }
136
+ i++;
137
+ }
138
+ } else if (text[i] === "$" && text[i + 1] === "{") {
139
+ // Keep JS template literal interpolation as-is (escape $ for outer template)
140
+ result += "\\${";
141
+ i += 2;
142
+ // Copy until matching }
143
+ let depth = 1;
144
+ while (i < text.length && depth > 0) {
145
+ if (text[i] === "{") depth++;
146
+ else if (text[i] === "}") depth--;
147
+ if (depth > 0 || text[i] !== "}") {
148
+ if (text[i] === "`") result += "\\`";
149
+ else if (text[i] === "\\") result += "\\\\";
150
+ else result += text[i];
151
+ } else {
152
+ result += "}";
153
+ }
154
+ i++;
155
+ }
156
+ } else {
157
+ // Regular character inside backticks - escape special chars for outer template
158
+ if (text[i] === "$") {
159
+ result += "\\$";
160
+ } else {
161
+ result += text[i];
162
+ }
163
+ i++;
164
+ }
165
+ }
166
+ if (i < text.length) {
167
+ result += "\\`";
168
+ i++;
169
+ }
170
+ continue;
171
+ }
172
+
173
+ // Escaped brace
174
+ if (text[i] === "\\" && text[i + 1] === "{") {
175
+ result += "{";
176
+ i += 2;
177
+ continue;
178
+ }
179
+
180
+ // Expression or directive
181
+ if (text[i] === "{") {
182
+ const { expression, endIndex } = extractBracedExpression(text, i);
183
+ const expr = expression.trim();
184
+
185
+ // Check if this is a JavaScript object literal (e.g., {d: 'top'}, {foo: 1, bar: 2})
186
+ // Object literals start with key: where key is identifier or quoted string
187
+ if (/^(\w+|"\w+"|'\w+')\s*:/.test(expr)) {
188
+ // Keep object literal as-is in the output
189
+ result += "{" + expression + "}";
190
+ i = endIndex + 1;
191
+ continue;
192
+ }
193
+
194
+ // Block directives
195
+ if (expr.startsWith("#if ")) {
196
+ const cond = expr.slice(4);
197
+ result += "${Fez.isTruthy(" + cond + ") ? `";
198
+ ifStack.push(false); // No else yet
199
+ } else if (expr.startsWith("#unless ")) {
200
+ const cond = expr.slice(8);
201
+ result += "${!Fez.isTruthy(" + cond + ") ? `";
202
+ ifStack.push(false); // No else yet
203
+ } else if (expr === ":else" || expr === "else") {
204
+ // Check if we're inside a loop (for empty list handling)
205
+ if (loopStack.length > 0 && !ifStack.length) {
206
+ // :else inside a loop - for empty array case
207
+ const loopInfo = loopStack[loopStack.length - 1];
208
+ loopInfo.hasElse = true;
209
+ result += '`).join("") : `';
210
+ } else {
211
+ // :else inside an if block
212
+ result += "` : `";
213
+ ifStack[ifStack.length - 1] = true; // Has else
214
+ }
215
+ } else if (
216
+ expr.startsWith(":else if ") ||
217
+ expr.startsWith("else if ") ||
218
+ expr.startsWith("elsif ") ||
219
+ expr.startsWith("elseif ")
220
+ ) {
221
+ const cond = expr.startsWith(":else if ")
222
+ ? expr.slice(9)
223
+ : expr.startsWith("else if ")
224
+ ? expr.slice(8)
225
+ : expr.startsWith("elseif ")
226
+ ? expr.slice(7)
227
+ : expr.slice(6);
228
+ result += "` : Fez.isTruthy(" + cond + ") ? `";
229
+ // Keep hasElse as false - still need final else
230
+ } else if (expr === "/if" || expr === "/unless") {
231
+ const hasElse = ifStack.pop();
232
+ result += hasElse ? "`}" : "` : ``}";
233
+ } else if (expr.startsWith("#each ") || expr.startsWith("#for ")) {
234
+ const isEach = expr.startsWith("#each ");
235
+ let collection, binding;
236
+
237
+ if (isEach) {
238
+ const rest = expr.slice(6);
239
+ const asIdx = rest.indexOf(" as ");
240
+ collection = rest.slice(0, asIdx).trim();
241
+ binding = rest.slice(asIdx + 4).trim();
242
+ } else {
243
+ const rest = expr.slice(5);
244
+ const inIdx = rest.indexOf(" in ");
245
+ binding = rest.slice(0, inIdx).trim();
246
+ collection = rest.slice(inIdx + 4).trim();
247
+ }
248
+
249
+ const collectionExpr = buildCollectionExpr(collection, binding);
250
+ const loopParams = buildLoopParams(binding);
251
+
252
+ // Track loop variables for arrow function transformation
253
+ loopVarStack.push(getLoopVarNames(binding));
254
+ loopItemVarStack.push(getLoopItemVars(binding));
255
+
256
+ // Track loop info for :else support
257
+ // Use a wrapper that allows checking length and provides else support
258
+ // ((_arr) => _arr.length ? _arr.map(...).join('') : elseContent)(collection)
259
+ loopStack.push({ collectionExpr, hasElse: false });
260
+
261
+ result +=
262
+ "${((_arr) => _arr.length ? _arr.map((" + loopParams + ") => `";
263
+ } else if (expr === "/each" || expr === "/for") {
264
+ loopVarStack.pop(); // Remove loop vars when exiting loop
265
+ loopItemVarStack.pop(); // Remove item vars when exiting loop
266
+ const loopInfo = loopStack.pop();
267
+ if (loopInfo.hasElse) {
268
+ // Close the else branch
269
+ result += "`)(" + loopInfo.collectionExpr + ")}";
270
+ } else {
271
+ // No else - just close the ternary with empty string
272
+ result += '`).join("") : "")(' + loopInfo.collectionExpr + ")}";
273
+ }
274
+ }
275
+ // {#await promise}...{:then value}...{:catch error}...{/await}
276
+ else if (expr.startsWith("#await ")) {
277
+ const promiseExpr = expr.slice(7).trim();
278
+ const awaitId = awaitCounter++;
279
+ awaitStack.push({
280
+ awaitId,
281
+ promiseExpr,
282
+ hasThen: false,
283
+ hasCatch: false,
284
+ thenVar: "_value",
285
+ catchVar: "_error",
286
+ });
287
+ // Start with pending block - Fez.await returns { status, value, error }
288
+ result += '${((_aw) => _aw.status === "pending" ? `';
289
+ } else if (expr.startsWith(":then")) {
290
+ const awaitInfo = awaitStack[awaitStack.length - 1];
291
+ if (awaitInfo) {
292
+ awaitInfo.hasThen = true;
293
+ // Extract optional value binding: {:then value} or just {:then}
294
+ awaitInfo.thenVar = expr.slice(5).trim() || "_value";
295
+ result +=
296
+ '` : _aw.status === "resolved" ? ((' +
297
+ awaitInfo.thenVar +
298
+ ") => `";
299
+ }
300
+ } else if (expr.startsWith(":catch")) {
301
+ const awaitInfo = awaitStack[awaitStack.length - 1];
302
+ if (awaitInfo) {
303
+ awaitInfo.hasCatch = true;
304
+ // Extract optional error binding: {:catch error} or just {:catch}
305
+ awaitInfo.catchVar = expr.slice(6).trim() || "_error";
306
+ if (awaitInfo.hasThen) {
307
+ // Close the :then block, open :catch
308
+ result +=
309
+ '`)(_aw.value) : _aw.status === "rejected" ? ((' +
310
+ awaitInfo.catchVar +
311
+ ") => `";
312
+ } else {
313
+ // No :then block, go directly from pending to catch (skip resolved state)
314
+ result +=
315
+ '` : _aw.status === "rejected" ? ((' +
316
+ awaitInfo.catchVar +
317
+ ") => `";
318
+ }
319
+ }
320
+ } else if (expr === "/await") {
321
+ const awaitInfo = awaitStack.pop();
322
+ if (awaitInfo) {
323
+ // Close the await expression
324
+ // The structure depends on which blocks were present:
325
+ // - pending + then + catch: pending ? ... : resolved ? ...then... : ...catch...
326
+ // - pending + then: pending ? ... : resolved ? ...then... : ``
327
+ // - pending + catch: pending ? ... : rejected ? ...catch... : ``
328
+ // - pending only: pending ? ... : ``
329
+ if (awaitInfo.hasThen && awaitInfo.hasCatch) {
330
+ result +=
331
+ "`)(_aw.error) : ``)(Fez.fezAwait(fez, " +
332
+ awaitInfo.awaitId +
333
+ ", " +
334
+ awaitInfo.promiseExpr +
335
+ "))}";
336
+ } else if (awaitInfo.hasThen) {
337
+ result +=
338
+ "`)(_aw.value) : ``)(Fez.fezAwait(fez, " +
339
+ awaitInfo.awaitId +
340
+ ", " +
341
+ awaitInfo.promiseExpr +
342
+ "))}";
343
+ } else if (awaitInfo.hasCatch) {
344
+ result +=
345
+ "`)(_aw.error) : ``)(Fez.fezAwait(fez, " +
346
+ awaitInfo.awaitId +
347
+ ", " +
348
+ awaitInfo.promiseExpr +
349
+ "))}";
350
+ } else {
351
+ // Only pending block (no :then or :catch)
352
+ result +=
353
+ "` : ``)(Fez.fezAwait(fez, " +
354
+ awaitInfo.awaitId +
355
+ ", " +
356
+ awaitInfo.promiseExpr +
357
+ "))}";
358
+ }
359
+ }
360
+ } else if (expr.startsWith("@html ")) {
361
+ const content = expr.slice(6);
362
+ result += "${" + content + "}";
363
+ } else if (expr.startsWith("@json ")) {
364
+ const content = expr.slice(6);
365
+ result +=
366
+ '${`<pre class="json">${Fez.htmlEscape(JSON.stringify(' +
367
+ content +
368
+ ", null, 2))}</pre>`}";
369
+ } else if (isArrowFunction(expr)) {
370
+ // Arrow function - check if we're in an event attribute
371
+ const eventAttr = getEventAttributeContext(text, i);
372
+ if (eventAttr) {
373
+ // Get all current loop variables
374
+ const allLoopVars = loopVarStack.flat();
375
+ const allItemVars = loopItemVarStack.flat();
376
+ let handler = transformArrowToHandler(
377
+ expr,
378
+ allLoopVars,
379
+ allItemVars,
380
+ );
381
+ // Escape double quotes for HTML attribute
382
+ handler = handler.replace(/"/g, "&quot;");
383
+ // Output as quoted attribute value with interpolation for loop vars
384
+ result += '"' + handler + '"';
385
+ } else {
386
+ // Arrow function outside event attribute - just output as expression
387
+ result += "${" + expr + "}";
388
+ }
389
+ } else {
390
+ // Plain expression - check if inside attribute
391
+ const attrContext = getAttributeContext(text, i);
392
+ if (attrContext) {
393
+ // Inside attribute - wrap with quotes and escape
394
+ result += '"${Fez.htmlEscape(' + expr + ')}"';
395
+ } else {
396
+ // Regular content - just escape HTML
397
+ result += "${Fez.htmlEscape(" + expr + ")}";
398
+ }
399
+ }
400
+
401
+ i = endIndex + 1;
402
+ continue;
403
+ }
404
+
405
+ // Escape special characters for template literal
406
+ if (text[i] === "$" && text[i + 1] === "{") {
407
+ result += "\\$";
408
+ } else if (text[i] === "\\") {
409
+ result += "\\\\";
410
+ } else {
411
+ result += text[i];
412
+ }
413
+ i++;
414
+ }
415
+
416
+ // Auto-generate IDs for fez-this elements (static values only)
417
+ // This helps the DOM differ match and preserve nodes across re-renders
418
+ result = result.replace(
419
+ /(<[a-z][a-z0-9-]*\s+)([^>]*?)(fez-this="([^"{}]+)")([^>]*?)>/gi,
420
+ (match, tagStart, before, fezThisAttr, fezThisValue, after) => {
421
+ // Skip if id already exists
422
+ if (/\bid=/.test(before) || /\bid=/.test(after)) {
423
+ return match;
424
+ }
425
+ // Sanitize: replace non-alphanumeric with -
426
+ const sanitized = fezThisValue.replace(/[^a-zA-Z0-9]/g, "-");
427
+ return `${tagStart}${before}${fezThisAttr}${after} id="fez-\${UID}-${sanitized}">`;
428
+ },
429
+ );
430
+
431
+ // Warn about dynamic fez-this values in dev mode (won't get auto-ID)
432
+ if (typeof Fez !== "undefined" && Fez.LOG) {
433
+ const dynamicFezThis = result.match(/fez-this="[^"]*\{[^}]+\}[^"]*"/g);
434
+ if (dynamicFezThis) {
435
+ console.warn(
436
+ `Fez <${componentName}>: Dynamic fez-this values won't get auto-ID for DOM differ matching:`,
437
+ dynamicFezThis,
438
+ );
439
+ }
440
+ }
441
+
442
+ // Build the function
443
+ const funcBody = `
444
+ const fez = this;
445
+ with (this) {
446
+ return \`${result}\`
447
+ }
448
+ `;
449
+
450
+ const tplFunc = new Function(funcBody);
451
+
452
+ return (ctx) => {
453
+ try {
454
+ return tplFunc.bind(ctx)();
455
+ } catch (e) {
456
+ console.error(
457
+ `FEZ svelte template runtime error in <${ctx.fezName || componentName}>:`,
458
+ e.message,
459
+ );
460
+ console.error("Template source:", result.substring(0, 500));
461
+ return "";
462
+ }
463
+ };
464
+ } catch (e) {
465
+ console.error(
466
+ `FEZ svelte template compile error in <${componentName}>:`,
467
+ e.message,
468
+ );
469
+ console.error("Template:", text.substring(0, 200));
470
+ return () => "";
471
+ }
472
+ }