@webmate-studio/builder 0.2.112 → 0.2.113
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/package.json +1 -1
- package/src/expression-evaluator.js +1234 -0
- package/src/template-evaluator.js +379 -0
- package/src/template-parser.js +597 -0
- package/src/template-processor.js +87 -617
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Evaluator — Evaluates a Template AST with data to produce HTML.
|
|
3
|
+
*
|
|
4
|
+
* Takes the AST from TemplateParser and a data context, walks the tree,
|
|
5
|
+
* and produces the final HTML string.
|
|
6
|
+
*
|
|
7
|
+
* Key design principles:
|
|
8
|
+
* - Every node type is handled by exactly one method
|
|
9
|
+
* - Context is passed through via scope chain (spread), never mutated
|
|
10
|
+
* - Nested loops work automatically because parent scope is preserved
|
|
11
|
+
* - No regex, no string scanning — pure AST traversal
|
|
12
|
+
*
|
|
13
|
+
* @module template-evaluator
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
class TemplateEvaluator {
|
|
17
|
+
/**
|
|
18
|
+
* @param {import('./expression-evaluator.js').ExpressionEvaluator} expressionEvaluator
|
|
19
|
+
*/
|
|
20
|
+
constructor(expressionEvaluator) {
|
|
21
|
+
this.expr = expressionEvaluator;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Evaluate an AST node with a data context and produce HTML
|
|
26
|
+
* @param {Object} ast - AST node from TemplateParser
|
|
27
|
+
* @param {Object} context - Data context
|
|
28
|
+
* @returns {string} HTML output
|
|
29
|
+
*/
|
|
30
|
+
evaluate(ast, context) {
|
|
31
|
+
if (!ast) return '';
|
|
32
|
+
return this.evalNode(ast, context);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Main dispatch — evaluates a single AST node
|
|
37
|
+
*/
|
|
38
|
+
evalNode(node, ctx) {
|
|
39
|
+
switch (node.type) {
|
|
40
|
+
case 'Fragment':
|
|
41
|
+
return this.evalFragment(node, ctx);
|
|
42
|
+
case 'Text':
|
|
43
|
+
return node.value;
|
|
44
|
+
case 'Expression':
|
|
45
|
+
return this.evalExpression(node, ctx);
|
|
46
|
+
case 'HtmlExpression':
|
|
47
|
+
return this.evalHtmlExpression(node, ctx);
|
|
48
|
+
case 'ConstTag':
|
|
49
|
+
return this.evalConstTag(node, ctx);
|
|
50
|
+
case 'DebugTag':
|
|
51
|
+
return this.evalDebugTag(node, ctx);
|
|
52
|
+
case 'IfBlock':
|
|
53
|
+
return this.evalIfBlock(node, ctx);
|
|
54
|
+
case 'EachBlock':
|
|
55
|
+
return this.evalEachBlock(node, ctx);
|
|
56
|
+
case 'Tag':
|
|
57
|
+
return this.evalTag(node, ctx);
|
|
58
|
+
default:
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fragment — sequence of children
|
|
65
|
+
*/
|
|
66
|
+
evalFragment(node, ctx) {
|
|
67
|
+
let result = '';
|
|
68
|
+
for (const child of node.children) {
|
|
69
|
+
result += this.evalNode(child, ctx);
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* {expression} — evaluate and escape for safe HTML output
|
|
76
|
+
*/
|
|
77
|
+
evalExpression(node, ctx) {
|
|
78
|
+
const value = this.expr.evaluate(node.expression, ctx);
|
|
79
|
+
|
|
80
|
+
if (value === undefined || value === null) return '';
|
|
81
|
+
if (typeof value === 'boolean') return '';
|
|
82
|
+
|
|
83
|
+
// Handle special objects
|
|
84
|
+
const resolved = this.resolveValue(value);
|
|
85
|
+
return this.escapeHtml(String(resolved));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* {@html expression} — evaluate without escaping
|
|
90
|
+
*/
|
|
91
|
+
evalHtmlExpression(node, ctx) {
|
|
92
|
+
const value = this.expr.evaluate(node.expression, ctx);
|
|
93
|
+
if (value === undefined || value === null) return '';
|
|
94
|
+
return String(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* {@const name = expression} — adds to context, outputs nothing
|
|
99
|
+
* Returns empty string but modifies the mutable context wrapper
|
|
100
|
+
*/
|
|
101
|
+
evalConstTag(node, ctx) {
|
|
102
|
+
const value = this.expr.evaluate(node.expression, ctx);
|
|
103
|
+
// We need to mutate ctx here so subsequent nodes can access the const.
|
|
104
|
+
// This is intentional — @const is a side-effect.
|
|
105
|
+
ctx[node.name] = value;
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* {@debug var1, var2} — logs to console, outputs nothing
|
|
111
|
+
*/
|
|
112
|
+
evalDebugTag(node, ctx) {
|
|
113
|
+
const values = {};
|
|
114
|
+
for (const varName of node.variables) {
|
|
115
|
+
values[varName] = this.expr.evaluate(varName, ctx);
|
|
116
|
+
}
|
|
117
|
+
console.log('[Debug]', values);
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* {#if condition}...{:else if}...{:else}...{/if}
|
|
123
|
+
*/
|
|
124
|
+
evalIfBlock(node, ctx) {
|
|
125
|
+
// Check main condition
|
|
126
|
+
if (this.expr.isTruthy(node.condition, ctx)) {
|
|
127
|
+
return this.evalNode(node.consequent, ctx);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check else-if branches
|
|
131
|
+
for (const branch of node.elseIfBranches) {
|
|
132
|
+
if (this.expr.isTruthy(branch.condition, ctx)) {
|
|
133
|
+
return this.evalNode(branch.body, ctx);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Else branch
|
|
138
|
+
if (node.alternate) {
|
|
139
|
+
return this.evalNode(node.alternate, ctx);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* {#each expression as item, index (key)}...{:else}...{/each}
|
|
147
|
+
*
|
|
148
|
+
* The key insight for correct nesting: we spread the parent context
|
|
149
|
+
* and add the loop variables on top. This means a 3-level deep loop
|
|
150
|
+
* has access to all parent loop variables automatically.
|
|
151
|
+
*/
|
|
152
|
+
evalEachBlock(node, ctx) {
|
|
153
|
+
// Evaluate the iterable expression
|
|
154
|
+
const iterable = this.expr.evaluate(node.expression, ctx);
|
|
155
|
+
|
|
156
|
+
// Handle non-array or empty
|
|
157
|
+
if (!Array.isArray(iterable) || iterable.length === 0) {
|
|
158
|
+
if (node.else) {
|
|
159
|
+
return this.evalNode(node.else, ctx);
|
|
160
|
+
}
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Render each item
|
|
165
|
+
let result = '';
|
|
166
|
+
for (let i = 0; i < iterable.length; i++) {
|
|
167
|
+
// Create new scope: parent context + loop variable + index
|
|
168
|
+
const loopCtx = { ...ctx, [node.itemVar]: iterable[i] };
|
|
169
|
+
if (node.indexVar) {
|
|
170
|
+
loopCtx[node.indexVar] = i;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
result += this.evalNode(node.body, loopCtx);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* HTML Tag — reconstruct the tag with evaluated attributes
|
|
181
|
+
*/
|
|
182
|
+
evalTag(node, ctx) {
|
|
183
|
+
// Check for PascalCase tag (Island component)
|
|
184
|
+
// PascalCase tags like <Counter />, <ProductCard /> are treated as Islands:
|
|
185
|
+
// - Tag name converted to kebab-case with wm- prefix
|
|
186
|
+
// - All props serialized as data-island-props JSON
|
|
187
|
+
if (/^[A-Z]/.test(node.tagName)) {
|
|
188
|
+
return this.evalIslandTag(node, ctx);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let result = `<${node.tagName}`;
|
|
192
|
+
|
|
193
|
+
// Collect all class names (static + conditional)
|
|
194
|
+
let staticClasses = '';
|
|
195
|
+
const conditionalClasses = [];
|
|
196
|
+
|
|
197
|
+
// Process static attributes
|
|
198
|
+
for (const attr of node.attributes) {
|
|
199
|
+
if (attr.type === 'StaticAttribute') {
|
|
200
|
+
if (attr.name === 'class') {
|
|
201
|
+
staticClasses = attr.value;
|
|
202
|
+
} else if (attr.value === true) {
|
|
203
|
+
// Boolean attribute
|
|
204
|
+
result += ` ${attr.name}`;
|
|
205
|
+
} else {
|
|
206
|
+
result += ` ${attr.name}="${this.escapeAttr(attr.value)}"`;
|
|
207
|
+
}
|
|
208
|
+
} else if (attr.type === 'DynamicAttribute') {
|
|
209
|
+
const value = this.expr.evaluate(attr.expression, ctx);
|
|
210
|
+
const resolved = this.resolveValueForAttr(value);
|
|
211
|
+
result += ` ${attr.name}="${this.escapeAttr(String(resolved ?? ''))}"`;
|
|
212
|
+
} else if (attr.type === 'DynamicQuotedAttribute') {
|
|
213
|
+
// Attribute with mixed static and dynamic parts: "Hello {name}"
|
|
214
|
+
let value = '';
|
|
215
|
+
for (const part of attr.parts) {
|
|
216
|
+
if (part.type === 'text') {
|
|
217
|
+
value += part.value;
|
|
218
|
+
} else {
|
|
219
|
+
const evaluated = this.expr.evaluate(part.value, ctx);
|
|
220
|
+
const resolved = this.resolveValueForAttr(evaluated);
|
|
221
|
+
value += String(resolved ?? '');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
result += ` ${attr.name}="${this.escapeAttr(value)}"`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Process class: directives
|
|
229
|
+
for (const directive of node.classDirectives) {
|
|
230
|
+
const conditionResult = this.expr.isTruthy(directive.condition, ctx);
|
|
231
|
+
if (conditionResult) {
|
|
232
|
+
conditionalClasses.push(directive.name);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Merge class attribute
|
|
237
|
+
const allClasses = [staticClasses, ...conditionalClasses].filter(Boolean).join(' ');
|
|
238
|
+
if (allClasses) {
|
|
239
|
+
result += ` class="${this.escapeAttr(allClasses)}"`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (node.selfClosing) {
|
|
243
|
+
result += ' />';
|
|
244
|
+
} else {
|
|
245
|
+
result += '>';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Island Tag — PascalCase components like <Counter />, <ProductCard item={item} />
|
|
253
|
+
* Transforms to: <wm-counter data-island-props='{"item":...}'></wm-counter>
|
|
254
|
+
*
|
|
255
|
+
* This runs inside the evaluator, so it works correctly inside {#each} loops:
|
|
256
|
+
* {#each items as item}<Counter data={item} />{/each}
|
|
257
|
+
* → <wm-counter data-island-props='{"data":{"name":"A"}}'></wm-counter>
|
|
258
|
+
* <wm-counter data-island-props='{"data":{"name":"B"}}'></wm-counter>
|
|
259
|
+
*/
|
|
260
|
+
evalIslandTag(node, ctx) {
|
|
261
|
+
// Convert PascalCase to kebab-case with wm- prefix
|
|
262
|
+
const kebabTag = 'wm-' + node.tagName
|
|
263
|
+
.replace(/([A-Z])/g, '-$1')
|
|
264
|
+
.toLowerCase()
|
|
265
|
+
.replace(/^-/, '');
|
|
266
|
+
|
|
267
|
+
// Collect all props (evaluate dynamic ones)
|
|
268
|
+
const props = {};
|
|
269
|
+
|
|
270
|
+
for (const attr of node.attributes) {
|
|
271
|
+
if (attr.type === 'StaticAttribute') {
|
|
272
|
+
if (attr.value === true) {
|
|
273
|
+
props[attr.name] = true;
|
|
274
|
+
} else {
|
|
275
|
+
props[attr.name] = attr.value;
|
|
276
|
+
}
|
|
277
|
+
} else if (attr.type === 'DynamicAttribute') {
|
|
278
|
+
props[attr.name] = this.expr.evaluate(attr.expression, ctx);
|
|
279
|
+
} else if (attr.type === 'DynamicQuotedAttribute') {
|
|
280
|
+
let value = '';
|
|
281
|
+
for (const part of attr.parts) {
|
|
282
|
+
if (part.type === 'text') {
|
|
283
|
+
value += part.value;
|
|
284
|
+
} else {
|
|
285
|
+
const evaluated = this.expr.evaluate(part.value, ctx);
|
|
286
|
+
value += String(evaluated ?? '');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
props[attr.name] = value;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Build tag with data-island-props
|
|
294
|
+
const hasProps = Object.keys(props).length > 0;
|
|
295
|
+
const propsAttr = hasProps
|
|
296
|
+
? ` data-island-props='${JSON.stringify(props).replace(/'/g, ''')}'`
|
|
297
|
+
: '';
|
|
298
|
+
|
|
299
|
+
return `<${kebabTag}${propsAttr}></${kebabTag}>`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ========================================
|
|
303
|
+
// Value resolution helpers
|
|
304
|
+
// ========================================
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve special object types to display values
|
|
308
|
+
* Handles CMS-specific types like links and images
|
|
309
|
+
*/
|
|
310
|
+
resolveValue(value) {
|
|
311
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
312
|
+
|
|
313
|
+
// Link objects (from LinkField) — extract path
|
|
314
|
+
if (value.type === 'page' && value.pageUuid) {
|
|
315
|
+
return value.path || `/preview/${value.pageUuid}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Image objects — extract URL
|
|
319
|
+
if (value.src || value.filename) {
|
|
320
|
+
return value.src || value.filename || '';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Arrays — don't stringify
|
|
324
|
+
if (Array.isArray(value)) return '';
|
|
325
|
+
|
|
326
|
+
// Other objects — try to stringify
|
|
327
|
+
try {
|
|
328
|
+
return JSON.stringify(value);
|
|
329
|
+
} catch {
|
|
330
|
+
return '[Object]';
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Resolve values for attribute context (slightly different from text)
|
|
336
|
+
*/
|
|
337
|
+
resolveValueForAttr(value) {
|
|
338
|
+
if (value === undefined || value === null) return '';
|
|
339
|
+
if (typeof value !== 'object') return value;
|
|
340
|
+
|
|
341
|
+
// Link objects
|
|
342
|
+
if (value.type === 'page' && value.pageUuid) {
|
|
343
|
+
return value.path || `/preview/${value.pageUuid}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Image objects
|
|
347
|
+
if (value.src || value.filename) {
|
|
348
|
+
return value.src || value.filename || '';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Arrays/objects — stringify for data attributes
|
|
352
|
+
try {
|
|
353
|
+
return JSON.stringify(value);
|
|
354
|
+
} catch {
|
|
355
|
+
return '';
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ========================================
|
|
360
|
+
// HTML escaping
|
|
361
|
+
// ========================================
|
|
362
|
+
|
|
363
|
+
escapeHtml(str) {
|
|
364
|
+
return str
|
|
365
|
+
.replace(/&/g, '&')
|
|
366
|
+
.replace(/</g, '<')
|
|
367
|
+
.replace(/>/g, '>')
|
|
368
|
+
.replace(/"/g, '"');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
escapeAttr(str) {
|
|
372
|
+
return str
|
|
373
|
+
.replace(/&/g, '&')
|
|
374
|
+
.replace(/"/g, '"');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export { TemplateEvaluator };
|
|
379
|
+
export default TemplateEvaluator;
|