@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
|
@@ -1,658 +1,128 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Template Processor
|
|
2
|
+
* Template Processor — Unified entry point for template processing.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - {#if} conditionals with {:else if} and {:else}
|
|
6
|
-
* - {#each} loops with index, keys, and {:else}
|
|
7
|
-
* - Expression evaluation (delegates to ExpressionEvaluator)
|
|
4
|
+
* Single API: process(template, data) → HTML
|
|
8
5
|
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
* - Build service (Node.js)
|
|
6
|
+
* Used identically by:
|
|
7
|
+
* - Preview (browser, via window global)
|
|
8
|
+
* - Build service (Node.js, via npm import)
|
|
12
9
|
* - CMS rendering engine
|
|
13
10
|
*
|
|
11
|
+
* Internally:
|
|
12
|
+
* 1. Parses template into AST (cached)
|
|
13
|
+
* 2. Evaluates AST with data context → HTML
|
|
14
|
+
*
|
|
14
15
|
* @module template-processor
|
|
15
16
|
*/
|
|
16
17
|
|
|
18
|
+
import { TemplateParser } from './template-parser.js';
|
|
19
|
+
import { TemplateEvaluator } from './template-evaluator.js';
|
|
20
|
+
import expressionEvaluator from './expression-evaluator.js';
|
|
21
|
+
|
|
17
22
|
class TemplateProcessor {
|
|
18
23
|
constructor() {
|
|
19
|
-
|
|
20
|
-
this.
|
|
21
|
-
this.
|
|
22
|
-
this.
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Initialize with expression evaluator functions
|
|
27
|
-
* @param {Function} evaluateExprFn - Function to evaluate expressions (returns boolean)
|
|
28
|
-
* @param {Function} evaluateExprValueFn - Function to evaluate expressions (returns value)
|
|
29
|
-
* @param {Object} propSchema - Optional: Prop schema for markdown detection
|
|
30
|
-
*/
|
|
31
|
-
init(evaluateExprFn, evaluateExprValueFn, propSchema = null) {
|
|
32
|
-
this.evaluateExpression = evaluateExprFn;
|
|
33
|
-
this.evaluateExpressionValue = evaluateExprValueFn;
|
|
34
|
-
this.propSchema = propSchema;
|
|
24
|
+
this.parser = new TemplateParser();
|
|
25
|
+
this.evaluator = new TemplateEvaluator(expressionEvaluator);
|
|
26
|
+
this.templateCache = new Map();
|
|
27
|
+
this.maxCacheSize = 200;
|
|
35
28
|
}
|
|
36
29
|
|
|
37
30
|
/**
|
|
38
|
-
* Process
|
|
31
|
+
* Process a template with data and return HTML.
|
|
39
32
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
33
|
+
* @param {string} template - HTML template with Svelte-style syntax
|
|
34
|
+
* @param {Object} data - Data context (props)
|
|
35
|
+
* @param {Object} [options] - Optional configuration
|
|
36
|
+
* @param {boolean} [options.cache=true] - Whether to cache the parsed AST
|
|
37
|
+
* @returns {string} Processed HTML
|
|
42
38
|
*
|
|
43
|
-
* @
|
|
44
|
-
*
|
|
45
|
-
*
|
|
39
|
+
* @example
|
|
40
|
+
* const html = processor.process(
|
|
41
|
+
* '<h1>{title}</h1>{#if show}<p>{text}</p>{/if}',
|
|
42
|
+
* { title: 'Hello', show: true, text: 'World' }
|
|
43
|
+
* );
|
|
44
|
+
* // → '<h1>Hello</h1><p>World</p>'
|
|
46
45
|
*/
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return html.replace(/<([a-z][a-z0-9-]*)([^>]*)>/gi, (match, tagName, attrs) => {
|
|
59
|
-
const conditionalClasses = [];
|
|
60
|
-
let processedAttrs = attrs;
|
|
61
|
-
|
|
62
|
-
let classMatch;
|
|
63
|
-
classCondPattern.lastIndex = 0;
|
|
64
|
-
while ((classMatch = classCondPattern.exec(attrs)) !== null) {
|
|
65
|
-
let className = classMatch[1];
|
|
66
|
-
const condition = classMatch[2];
|
|
67
|
-
|
|
68
|
-
// Normalize calc() expressions
|
|
69
|
-
className = className.replace(/calc\([^)]*\)/g, (m) => m.replace(/\s+/g, ''));
|
|
70
|
-
|
|
71
|
-
// Evaluate condition
|
|
72
|
-
let conditionResult = false;
|
|
73
|
-
try {
|
|
74
|
-
conditionResult = this.evaluateExpression(condition.trim(), contextData);
|
|
75
|
-
console.log(`[Class:] "${className}" condition="${condition}" result=${conditionResult}`);
|
|
76
|
-
} catch (err) {
|
|
77
|
-
console.warn(`[Preview] Failed to evaluate class condition "${condition}":`, err);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (conditionResult) {
|
|
81
|
-
conditionalClasses.push(className);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Remove the class:name={} from attributes
|
|
85
|
-
processedAttrs = processedAttrs.replace(classMatch[0], '');
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Merge conditional classes into class attribute
|
|
89
|
-
if (conditionalClasses.length > 0) {
|
|
90
|
-
const classAttrMatch = processedAttrs.match(/class="([^"]*)"/);
|
|
91
|
-
if (classAttrMatch) {
|
|
92
|
-
const existingClasses = classAttrMatch[1];
|
|
93
|
-
const mergedClasses = existingClasses + ' ' + conditionalClasses.join(' ');
|
|
94
|
-
processedAttrs = processedAttrs.replace(/class="[^"]*"/, `class="${mergedClasses.trim()}"`);
|
|
95
|
-
} else {
|
|
96
|
-
processedAttrs += ` class="${conditionalClasses.join(' ')}"`;
|
|
97
|
-
}
|
|
46
|
+
process(template, data = {}, options = {}) {
|
|
47
|
+
const useCache = options.cache !== false;
|
|
48
|
+
|
|
49
|
+
// 1. Parse template into AST (or retrieve from cache)
|
|
50
|
+
let ast;
|
|
51
|
+
if (useCache) {
|
|
52
|
+
ast = this.templateCache.get(template);
|
|
53
|
+
if (!ast) {
|
|
54
|
+
ast = this.parser.parse(template);
|
|
55
|
+
this.cacheTemplate(template, ast);
|
|
98
56
|
}
|
|
57
|
+
} else {
|
|
58
|
+
ast = this.parser.parse(template);
|
|
59
|
+
}
|
|
99
60
|
|
|
100
|
-
|
|
101
|
-
|
|
61
|
+
// 2. Evaluate AST with data → HTML
|
|
62
|
+
// Create a shallow copy of data so @const doesn't pollute the original
|
|
63
|
+
const ctx = { ...data };
|
|
64
|
+
return this.evaluator.evaluate(ast, ctx);
|
|
102
65
|
}
|
|
103
66
|
|
|
104
67
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
68
|
+
* Backwards-compatible methods for gradual migration.
|
|
69
|
+
* These all delegate to process() internally.
|
|
107
70
|
*
|
|
108
|
-
* @
|
|
109
|
-
* @param {number} startPos - Position after {#each ...}
|
|
110
|
-
* @returns {number} Position of matching {/each} or -1 if not found
|
|
71
|
+
* @deprecated Use process() instead
|
|
111
72
|
*/
|
|
112
|
-
findMatchingEachEnd(html, startPos) {
|
|
113
|
-
let depth = 1;
|
|
114
|
-
let pos = startPos;
|
|
115
|
-
|
|
116
|
-
while (depth > 0 && pos < html.length) {
|
|
117
|
-
const nextOpen = html.indexOf('{#each', pos);
|
|
118
|
-
const nextClose = html.indexOf('{/each}', pos);
|
|
119
|
-
|
|
120
|
-
if (nextClose === -1) return -1; // No closing tag found
|
|
121
73
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// Found closing tag first
|
|
128
|
-
depth--;
|
|
129
|
-
if (depth === 0) {
|
|
130
|
-
return nextClose;
|
|
131
|
-
}
|
|
132
|
-
pos = nextClose + 7; // length of '{/each}'
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return -1;
|
|
74
|
+
/**
|
|
75
|
+
* @deprecated Use process() instead
|
|
76
|
+
*/
|
|
77
|
+
init(evaluateExprFn, evaluateExprValueFn, propSchema = null) {
|
|
78
|
+
// No-op — expression evaluator is built in now
|
|
137
79
|
}
|
|
138
80
|
|
|
139
81
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* Syntax supported:
|
|
143
|
-
* - {#each items as item}
|
|
144
|
-
* - {#each items as item, index}
|
|
145
|
-
* - {#each items as item (item.id)}
|
|
146
|
-
* - {#each items as item, index (item.id)}
|
|
147
|
-
* - {#each items as item}{:else}Empty state{/each}
|
|
148
|
-
* - {#each Array(n) as _} or {#each Array(n) as _, index} (creates array of n elements)
|
|
149
|
-
*
|
|
150
|
-
* @param {string} html - HTML template with {#each} blocks
|
|
151
|
-
* @param {Object} contextData - Data context for evaluation
|
|
152
|
-
* @param {number} depth - Nesting depth (for logging)
|
|
153
|
-
* @param {string|null} parentLoopVar - Parent loop variable name
|
|
154
|
-
* @returns {string} Processed HTML
|
|
82
|
+
* @deprecated Use process() instead
|
|
155
83
|
*/
|
|
156
|
-
processEachLoops(html, contextData
|
|
157
|
-
|
|
158
|
-
console.log(`${indent}[processEachLoops] depth=${depth}, parentLoopVar=${parentLoopVar}, contextData keys:`, Object.keys(contextData));
|
|
159
|
-
|
|
160
|
-
if (depth === 0) {
|
|
161
|
-
console.log(`${indent}[DEBUG] 🎯 processEachLoops INPUT HTML (first 600 chars):`, html.substring(0, 600));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Find all {#each} opening tags with optional index parameter and key
|
|
165
|
-
// Matches: {#each items as item} or {#each items as item, index} or {#each items as item (item.id)}
|
|
166
|
-
// Also matches: {#each Array(n) as _} or {#each Array(expr) as _, index} for generating arrays
|
|
167
|
-
const openPattern = /\{#each\s+((?:[a-zA-Z_][a-zA-Z0-9_.]*|Array\([^)]+\)))\s+as\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:,\s*([a-zA-Z_][a-zA-Z0-9_]*))?\s*(?:\(([^)]+)\))?\}/g;
|
|
168
|
-
|
|
169
|
-
let result = html;
|
|
170
|
-
let iteration = 0;
|
|
171
|
-
const maxIterations = 20;
|
|
172
|
-
|
|
173
|
-
// Process loops from innermost to outermost by repeatedly finding and replacing
|
|
174
|
-
while (iteration < maxIterations) {
|
|
175
|
-
iteration++;
|
|
176
|
-
let foundMatch = false;
|
|
177
|
-
|
|
178
|
-
// Reset regex
|
|
179
|
-
openPattern.lastIndex = 0;
|
|
180
|
-
let match;
|
|
181
|
-
|
|
182
|
-
// Process ONLY the first match to avoid index issues
|
|
183
|
-
if ((match = openPattern.exec(html)) !== null) {
|
|
184
|
-
const arrayPath = match[1];
|
|
185
|
-
const loopVar = match[2];
|
|
186
|
-
const indexVar = match[3]; // Optional index variable
|
|
187
|
-
const keyExpr = match[4]; // Optional key expression
|
|
188
|
-
const openStart = match.index;
|
|
189
|
-
const openEnd = match.index + match[0].length;
|
|
190
|
-
|
|
191
|
-
if (indexVar) {
|
|
192
|
-
console.log(`${indent}[processEachLoops] Found index variable: ${indexVar}`);
|
|
193
|
-
}
|
|
194
|
-
if (keyExpr) {
|
|
195
|
-
console.log(`${indent}[processEachLoops] Found key expression: ${keyExpr} (used for tracking, no effect in preview mode)`);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Find matching closing tag
|
|
199
|
-
const closeStart = this.findMatchingEachEnd(html, openEnd);
|
|
200
|
-
if (closeStart === -1) {
|
|
201
|
-
console.warn(`${indent}[processEachLoops] No matching {/each} for ${loopVar} in ${arrayPath}`);
|
|
202
|
-
break;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const closeEnd = closeStart + 7; // length of '{/each}'
|
|
206
|
-
let content = html.substring(openEnd, closeStart);
|
|
207
|
-
let elseContent = '';
|
|
208
|
-
|
|
209
|
-
console.log(`${indent}[DEBUG] Extracted content for ${loopVar} in ${arrayPath}, content snippet:`, content.substring(0, 300));
|
|
210
|
-
|
|
211
|
-
// Check for {:else} within this each block (for empty state)
|
|
212
|
-
// IMPORTANT: Only match {:else} at depth 0 (not inside nested {#if} blocks)
|
|
213
|
-
let elsePos = -1;
|
|
214
|
-
let ifDepth = 0; // Track {#if} nesting depth to avoid matching {:else} inside conditionals
|
|
215
|
-
for (let i = 0; i < content.length; i++) {
|
|
216
|
-
// Check for {#if} opening
|
|
217
|
-
if (content.substring(i, i + 4) === '{#if' && (content[i + 4] === ' ' || content[i + 4] === '}')) {
|
|
218
|
-
ifDepth++;
|
|
219
|
-
i += 3; // Skip ahead
|
|
220
|
-
continue;
|
|
221
|
-
}
|
|
222
|
-
// Check for {/if} closing
|
|
223
|
-
if (ifDepth > 0 && content.substring(i, i + 5) === '{/if}') {
|
|
224
|
-
ifDepth--;
|
|
225
|
-
i += 4; // Skip ahead
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
// Check for {:else} at depth 0 (belongs to the each block)
|
|
229
|
-
if (ifDepth === 0 && content.substring(i, i + 7) === '{:else}') {
|
|
230
|
-
elsePos = i;
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (elsePos !== -1) {
|
|
236
|
-
elseContent = content.substring(elsePos + 7); // Content after {:else}
|
|
237
|
-
content = content.substring(0, elsePos); // Content before {:else}
|
|
238
|
-
console.log(`${indent}[processEachLoops] Found {:else} in each block at depth 0`);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
foundMatch = true;
|
|
242
|
-
console.log(`${indent}[processEachLoops] iteration=${iteration}, FOUND: ${loopVar} in ${arrayPath}`);
|
|
243
|
-
|
|
244
|
-
let arrayValue;
|
|
245
|
-
let pathValid = true;
|
|
246
|
-
|
|
247
|
-
// Check for Array(n) pattern - creates an array of n elements
|
|
248
|
-
const arrayConstructorMatch = arrayPath.match(/^Array\(([^)]+)\)$/);
|
|
249
|
-
if (arrayConstructorMatch) {
|
|
250
|
-
const countExpr = arrayConstructorMatch[1].trim();
|
|
251
|
-
console.log(`${indent}[processEachLoops] Array constructor pattern detected: Array(${countExpr})`);
|
|
252
|
-
|
|
253
|
-
// Evaluate the expression to get the count
|
|
254
|
-
let count = 0;
|
|
255
|
-
try {
|
|
256
|
-
// First try to parse as a simple number
|
|
257
|
-
if (/^\d+$/.test(countExpr)) {
|
|
258
|
-
count = parseInt(countExpr, 10);
|
|
259
|
-
} else {
|
|
260
|
-
// Otherwise evaluate as expression (e.g., item.rating)
|
|
261
|
-
const rawValue = this.evaluateExpressionValue(countExpr, contextData);
|
|
262
|
-
// Convert to number (handles string values like "3" from CMS)
|
|
263
|
-
count = typeof rawValue === 'string' ? parseInt(rawValue, 10) : Number(rawValue);
|
|
264
|
-
}
|
|
265
|
-
console.log(`${indent}[processEachLoops] Array count evaluated to: ${count}`);
|
|
266
|
-
} catch (err) {
|
|
267
|
-
console.warn(`${indent}[processEachLoops] Failed to evaluate Array count expression "${countExpr}":`, err.message);
|
|
268
|
-
pathValid = false;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Create array with 'count' undefined elements (like Array(n) in JavaScript)
|
|
272
|
-
// Use Number.isFinite to handle NaN and non-numeric values
|
|
273
|
-
if (pathValid && Number.isFinite(count) && count > 0) {
|
|
274
|
-
arrayValue = Array(count).fill(undefined);
|
|
275
|
-
} else {
|
|
276
|
-
arrayValue = [];
|
|
277
|
-
}
|
|
278
|
-
} else {
|
|
279
|
-
// Parse array path: "items" or "item.subitems"
|
|
280
|
-
let pathParts = arrayPath.split('.');
|
|
281
|
-
|
|
282
|
-
// If we're in a nested context and the path starts with the parent loop variable,
|
|
283
|
-
// strip it since the context IS that variable
|
|
284
|
-
if (depth > 0 && parentLoopVar && pathParts.length > 1 && pathParts[0] === parentLoopVar) {
|
|
285
|
-
console.log(`${indent}[processEachLoops] Stripping parent loop var "${parentLoopVar}" from path "${arrayPath}"`);
|
|
286
|
-
pathParts = pathParts.slice(1);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
arrayValue = contextData;
|
|
290
|
-
|
|
291
|
-
// Navigate through path
|
|
292
|
-
for (const part of pathParts) {
|
|
293
|
-
if (arrayValue === null || arrayValue === undefined) {
|
|
294
|
-
console.warn(`[Preview] Array path not found: ${arrayPath} (stopped at ${part})`);
|
|
295
|
-
pathValid = false;
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
298
|
-
arrayValue = arrayValue[part];
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
let replacement = '';
|
|
303
|
-
|
|
304
|
-
// If array doesn't exist or path invalid, use else content or remove
|
|
305
|
-
if (!pathValid || !Array.isArray(arrayValue)) {
|
|
306
|
-
if (!pathValid || !Array.isArray(arrayValue)) {
|
|
307
|
-
console.warn(`[Preview] Not an array: ${arrayPath}`, arrayValue);
|
|
308
|
-
}
|
|
309
|
-
replacement = elseContent;
|
|
310
|
-
}
|
|
311
|
-
// If array is empty, use else content or remove
|
|
312
|
-
else if (arrayValue.length === 0) {
|
|
313
|
-
console.log(`${indent}[Preview] Array ${arrayPath} is empty, using else content:`, elseContent ? 'yes' : 'no');
|
|
314
|
-
replacement = elseContent;
|
|
315
|
-
}
|
|
316
|
-
// Render each item
|
|
317
|
-
else {
|
|
318
|
-
replacement = arrayValue.map((item, index) => {
|
|
319
|
-
console.log(`${indent}[Preview] Processing loop item #${index}: ${loopVar} in ${arrayPath}`, item);
|
|
320
|
-
let itemHtml = content;
|
|
321
|
-
|
|
322
|
-
// Build loop context with the loop variable and index
|
|
323
|
-
// Also merge with parent context so nested expressions can access parent data
|
|
324
|
-
const loopContext = { ...contextData, [loopVar]: item };
|
|
325
|
-
if (indexVar) {
|
|
326
|
-
loopContext[indexVar] = index;
|
|
327
|
-
}
|
|
328
|
-
// Add the array itself to context (use the last part of the path as name)
|
|
329
|
-
const arrayName = arrayPath.split('.').pop();
|
|
330
|
-
if (arrayName && !arrayName.includes('(')) {
|
|
331
|
-
loopContext[arrayName] = arrayValue;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// First, recursively process any nested loops with the merged context
|
|
335
|
-
// Pass loopVar as parentLoopVar so nested loops can strip it from paths
|
|
336
|
-
console.log(`${indent}[DEBUG] BEFORE nested processEachLoops - HTML snippet:`, itemHtml.substring(0, 300));
|
|
337
|
-
itemHtml = this.processEachLoops(itemHtml, loopContext, depth + 1, loopVar);
|
|
338
|
-
console.log(`${indent}[DEBUG] AFTER nested processEachLoops - HTML snippet:`, itemHtml.substring(0, 300));
|
|
339
|
-
|
|
340
|
-
// Process class:name={loopVar.field} conditionals BEFORE replacing {loopVar.field}
|
|
341
|
-
itemHtml = this.processClassConditionals(itemHtml, loopContext);
|
|
342
|
-
|
|
343
|
-
// First: Replace attribute expressions with quoted values (e.g., alt={item.image.alt} or alt="{item.image.alt}" → alt="value")
|
|
344
|
-
// Pattern matches both: attr={expr} and attr="{expr}"
|
|
345
|
-
itemHtml = itemHtml.replace(new RegExp(`(\\w+)=["']?\\{\\s*${loopVar}\\.([a-zA-Z0-9_.]+)\\s*\\}["']?`, 'g'), (m, attrName, path) => {
|
|
346
|
-
// Navigate through nested path (e.g., "image.src")
|
|
347
|
-
const pathParts = path.split('.');
|
|
348
|
-
let value = item;
|
|
349
|
-
for (const part of pathParts) {
|
|
350
|
-
if (value === null || value === undefined) {
|
|
351
|
-
value = '';
|
|
352
|
-
break;
|
|
353
|
-
}
|
|
354
|
-
value = value[part];
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Handle link objects (from LinkField) - extract path for preview/production
|
|
358
|
-
if (typeof value === 'object' && value !== null && value.type === 'page' && value.pageUuid) {
|
|
359
|
-
// In preview mode: use /preview/{pageUuid}
|
|
360
|
-
value = `/preview/${value.pageUuid}`;
|
|
361
|
-
}
|
|
362
|
-
// Handle image objects - extract URL
|
|
363
|
-
else if (typeof value === 'object' && value !== null && (value.src || value.filename)) {
|
|
364
|
-
value = value.src || value.filename || '';
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Ensure value is string
|
|
368
|
-
if (value === undefined || value === null) {
|
|
369
|
-
value = '';
|
|
370
|
-
} else {
|
|
371
|
-
value = String(value);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// Escape quotes
|
|
375
|
-
value = value.replace(/"/g, '"');
|
|
376
|
-
|
|
377
|
-
// Always return quoted attribute
|
|
378
|
-
return `${attrName}="${value}"`;
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
// Second: Replace {loopVar.field} and {loopVar.nested.path} in text content
|
|
382
|
-
itemHtml = itemHtml.replace(new RegExp(`\\{\\s*${loopVar}\\.([a-zA-Z0-9_.]+)\\s*\\}`, 'g'), (m, path) => {
|
|
383
|
-
// Navigate through nested path (e.g., "image.src")
|
|
384
|
-
const pathParts = path.split('.');
|
|
385
|
-
let value = item;
|
|
386
|
-
for (const part of pathParts) {
|
|
387
|
-
if (value === null || value === undefined) {
|
|
388
|
-
return '';
|
|
389
|
-
}
|
|
390
|
-
value = value[part];
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Handle link objects (from LinkField) - extract path for preview
|
|
394
|
-
if (typeof value === 'object' && value !== null && value.type === 'page' && value.pageUuid) {
|
|
395
|
-
return `/preview/${value.pageUuid}`;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// Handle image objects - extract URL
|
|
399
|
-
if (typeof value === 'object' && value !== null && (value.src || value.filename)) {
|
|
400
|
-
return value.src || value.filename || '';
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
return value !== undefined ? value : '';
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
// Replace {@html loopVar.field} (unescaped) - keep this for compatibility
|
|
407
|
-
itemHtml = itemHtml.replace(new RegExp(`{@html\\s+${loopVar}\\.([a-zA-Z0-9_.]+)}`, 'g'), (m, path) => {
|
|
408
|
-
// Navigate through nested path (e.g., "image.src")
|
|
409
|
-
const pathParts = path.split('.');
|
|
410
|
-
let value = item;
|
|
411
|
-
for (const part of pathParts) {
|
|
412
|
-
if (value === null || value === undefined) {
|
|
413
|
-
return '';
|
|
414
|
-
}
|
|
415
|
-
value = value[part];
|
|
416
|
-
}
|
|
417
|
-
return value !== undefined ? value : '';
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
// Replace {indexVar} with the current loop index (if indexVar is defined) - single braces
|
|
421
|
-
if (indexVar) {
|
|
422
|
-
itemHtml = itemHtml.replace(new RegExp(`{\\s*${indexVar}\\s*}`, 'g'), () => {
|
|
423
|
-
return String(index);
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Replace {loopVar} with the item value (for primitive arrays like ['A', 'B']) - single braces
|
|
428
|
-
itemHtml = itemHtml.replace(new RegExp(`{\\s*${loopVar}\\s*}`, 'g'), () => {
|
|
429
|
-
// If item is object, convert to string representation
|
|
430
|
-
if (typeof item === 'object' && item !== null) {
|
|
431
|
-
return JSON.stringify(item);
|
|
432
|
-
}
|
|
433
|
-
return String(item !== undefined ? item : '');
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
// Process {#if} conditionals within loop (supports {:else if})
|
|
437
|
-
// Use loopContext which already has parent context + loop variable + index
|
|
438
|
-
itemHtml = this.processIfConditionals(itemHtml, loopContext, depth + 2);
|
|
439
|
-
|
|
440
|
-
// Process complex expressions within loop (e.g., {item.image.src ? "Ja" : "Nein"})
|
|
441
|
-
// This handles expressions that couldn't be processed by the simple regex replacements above
|
|
442
|
-
itemHtml = itemHtml.replace(/\{(?![#/@:])[^}]+\}/g, (match) => {
|
|
443
|
-
const expression = match.slice(1, -1).trim();
|
|
444
|
-
|
|
445
|
-
// Skip JSON-like patterns
|
|
446
|
-
if (/^["']/.test(expression) && expression.includes(':')) {
|
|
447
|
-
return match;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Skip simple variable references already handled above
|
|
451
|
-
if (/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]+)*$/.test(expression)) {
|
|
452
|
-
return match; // Already processed
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Evaluate complex expressions with loop context
|
|
456
|
-
try {
|
|
457
|
-
const result = this.evaluateExpressionValue(expression, loopContext);
|
|
458
|
-
if (result === undefined || result === null) {
|
|
459
|
-
return '';
|
|
460
|
-
}
|
|
461
|
-
// Handle link objects (from LinkField) - extract path for preview
|
|
462
|
-
if (typeof result === 'object' && result !== null && result.type === 'page' && result.pageUuid) {
|
|
463
|
-
return `/preview/${result.pageUuid}`;
|
|
464
|
-
}
|
|
465
|
-
// Handle image objects
|
|
466
|
-
if (typeof result === 'object' && result !== null && (result.src || result.filename)) {
|
|
467
|
-
return result.src || result.filename || '';
|
|
468
|
-
}
|
|
469
|
-
return String(result);
|
|
470
|
-
} catch (err) {
|
|
471
|
-
console.warn(`[Loop] Failed to evaluate expression "${expression}":`, err.message);
|
|
472
|
-
return match; // Keep as-is on error
|
|
473
|
-
}
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
return itemHtml;
|
|
477
|
-
}).join('');
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// Replace in html
|
|
481
|
-
html = html.substring(0, openStart) + replacement + html.substring(closeEnd);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (!foundMatch) break;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (iteration >= maxIterations) {
|
|
488
|
-
console.warn(`${indent}[processEachLoops] Max iterations reached - possible infinite loop`);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
return html;
|
|
84
|
+
processEachLoops(html, contextData) {
|
|
85
|
+
return this.process(html, contextData);
|
|
492
86
|
}
|
|
493
87
|
|
|
494
88
|
/**
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* Syntax supported:
|
|
498
|
-
* - {#if condition}...{/if}
|
|
499
|
-
* - {#if condition}...{:else}...{/if}
|
|
500
|
-
* - {#if condition}...{:else if condition2}...{:else}...{/if}
|
|
501
|
-
*
|
|
502
|
-
* @param {string} html - HTML template with {#if} blocks
|
|
503
|
-
* @param {Object} contextData - Data context for evaluation
|
|
504
|
-
* @param {number} depth - Nesting depth (for logging)
|
|
505
|
-
* @returns {string} Processed HTML
|
|
89
|
+
* @deprecated Use process() instead
|
|
506
90
|
*/
|
|
507
|
-
processIfConditionals(html, contextData
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
console.log('[processIfConditionals] ✅ NEW VERSION v3.0 - {:else if} support');
|
|
511
|
-
}
|
|
512
|
-
let result = html;
|
|
513
|
-
let iteration = 0;
|
|
514
|
-
const maxIterations = 50;
|
|
515
|
-
|
|
516
|
-
// Process from innermost to outermost by repeatedly finding and replacing
|
|
517
|
-
while (iteration < maxIterations) {
|
|
518
|
-
iteration++;
|
|
519
|
-
let foundMatch = false;
|
|
520
|
-
|
|
521
|
-
// Find {#if} opening tag
|
|
522
|
-
const openPattern = /\{#if\s+([^}]+)\}/g;
|
|
523
|
-
let match;
|
|
524
|
-
|
|
525
|
-
// Find the first {#if}
|
|
526
|
-
if ((match = openPattern.exec(result)) !== null) {
|
|
527
|
-
const condition = match[1];
|
|
528
|
-
const openStart = match.index;
|
|
529
|
-
const openEnd = match.index + match[0].length;
|
|
530
|
-
|
|
531
|
-
// Find matching closing tag and all {:else if} / {:else} branches
|
|
532
|
-
let depth = 1;
|
|
533
|
-
let pos = openEnd;
|
|
534
|
-
let branches = []; // Array of {type: 'if'|'else if'|'else', condition: string, start: number, end: number}
|
|
535
|
-
let closeStart = -1;
|
|
536
|
-
|
|
537
|
-
console.log(`${indent}[DEBUG] Looking for matching {/if} for condition: ${condition}, starting at pos ${openEnd}, html length: ${result.length}`);
|
|
538
|
-
|
|
539
|
-
// Add initial {#if} branch
|
|
540
|
-
branches.push({ type: 'if', condition: condition, start: openEnd, end: -1 });
|
|
541
|
-
|
|
542
|
-
while (pos < result.length && depth > 0) {
|
|
543
|
-
// Check for nested {#if} - must have space or } after to avoid false matches
|
|
544
|
-
if (result.substring(pos, pos + 4) === '{#if' && (result[pos + 4] === ' ' || result[pos + 4] === '}')) {
|
|
545
|
-
depth++;
|
|
546
|
-
console.log(`${indent}[DEBUG] Found nested {#if} at pos ${pos}, depth now: ${depth}`);
|
|
547
|
-
console.log(`${indent}[DEBUG] Context:`, JSON.stringify(result.substring(pos, pos + 20)));
|
|
548
|
-
pos += 4;
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// Check for {:else if condition} at current depth
|
|
553
|
-
if (depth === 1 && result.substring(pos, pos + 9) === '{:else if') {
|
|
554
|
-
// Find the closing } to get the full condition
|
|
555
|
-
const condEnd = result.indexOf('}', pos + 9);
|
|
556
|
-
if (condEnd !== -1) {
|
|
557
|
-
const elseIfCondition = result.substring(pos + 10, condEnd).trim();
|
|
558
|
-
// Close previous branch
|
|
559
|
-
branches[branches.length - 1].end = pos;
|
|
560
|
-
// Add new else if branch
|
|
561
|
-
branches.push({ type: 'else if', condition: elseIfCondition, start: condEnd + 1, end: -1 });
|
|
562
|
-
console.log(`${indent}[DEBUG] Found {:else if ${elseIfCondition}} at pos ${pos}`);
|
|
563
|
-
pos = condEnd + 1;
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Check for {:else} at current depth
|
|
569
|
-
if (depth === 1 && result.substring(pos, pos + 7) === '{:else}') {
|
|
570
|
-
// Close previous branch
|
|
571
|
-
branches[branches.length - 1].end = pos;
|
|
572
|
-
// Add else branch
|
|
573
|
-
branches.push({ type: 'else', condition: null, start: pos + 7, end: -1 });
|
|
574
|
-
console.log(`${indent}[DEBUG] Found {:else} at pos ${pos}`);
|
|
575
|
-
pos += 7;
|
|
576
|
-
continue;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// Check for {/if} - CORRECTED: {/if} is 5 chars, not 6!
|
|
580
|
-
const substr = result.substring(pos, pos + 5);
|
|
581
|
-
if (substr === '{/if}') {
|
|
582
|
-
depth--;
|
|
583
|
-
console.log(`${indent}[DEBUG] Found {/if} at pos ${pos}, depth now: ${depth}`);
|
|
584
|
-
if (depth === 0) {
|
|
585
|
-
closeStart = pos;
|
|
586
|
-
// Close last branch
|
|
587
|
-
branches[branches.length - 1].end = pos;
|
|
588
|
-
break;
|
|
589
|
-
}
|
|
590
|
-
pos += 5;
|
|
591
|
-
continue;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// Debug: Log when we see something that looks like {/
|
|
595
|
-
if (result[pos] === '{' && result[pos + 1] === '/') {
|
|
596
|
-
console.log(`${indent}[DEBUG] At pos ${pos}, found '{/' but full check failed. Next 10 chars:`, JSON.stringify(result.substring(pos, pos + 10)));
|
|
597
|
-
}
|
|
598
|
-
pos++;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
console.log(`${indent}[DEBUG] Scan complete. closeStart: ${closeStart}, final depth: ${depth}, branches:`, branches.length);
|
|
602
|
-
|
|
603
|
-
if (closeStart === -1) {
|
|
604
|
-
console.warn(`${indent}[processIfConditionals] No matching {/if} for condition: ${condition}`);
|
|
605
|
-
console.log(`${indent}[DEBUG] HTML snippet around openEnd:`, result.substring(openEnd, Math.min(openEnd + 200, result.length)));
|
|
606
|
-
break;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
foundMatch = true;
|
|
610
|
-
const closeEnd = closeStart + 5; // length of '{/if}' - CORRECTED: 5 chars, not 6!
|
|
611
|
-
|
|
612
|
-
// Process each branch recursively and evaluate conditions
|
|
613
|
-
let replacement = '';
|
|
614
|
-
for (const branch of branches) {
|
|
615
|
-
let branchContent = result.substring(branch.start, branch.end);
|
|
616
|
-
|
|
617
|
-
// Recursively process nested ifs
|
|
618
|
-
branchContent = this.processIfConditionals(branchContent, contextData, depth + 1);
|
|
619
|
-
|
|
620
|
-
// Evaluate condition
|
|
621
|
-
if (branch.type === 'if' || branch.type === 'else if') {
|
|
622
|
-
const shouldShow = this.evaluateExpression(branch.condition.trim(), contextData);
|
|
623
|
-
console.log(`${indent}[If] ${branch.type}="${branch.condition}" result=${shouldShow}`);
|
|
91
|
+
processIfConditionals(html, contextData) {
|
|
92
|
+
return this.process(html, contextData);
|
|
93
|
+
}
|
|
624
94
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
console.log(`${indent}[If] Using else branch`);
|
|
632
|
-
replacement = branchContent;
|
|
633
|
-
break;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
95
|
+
/**
|
|
96
|
+
* @deprecated Use process() instead
|
|
97
|
+
*/
|
|
98
|
+
processClassConditionals(html, contextData) {
|
|
99
|
+
return this.process(html, contextData);
|
|
100
|
+
}
|
|
636
101
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Clear the template AST cache
|
|
104
|
+
*/
|
|
105
|
+
clearCache() {
|
|
106
|
+
this.templateCache.clear();
|
|
107
|
+
expressionEvaluator.clearCache();
|
|
108
|
+
}
|
|
640
109
|
|
|
641
|
-
|
|
642
|
-
|
|
110
|
+
// ========================================
|
|
111
|
+
// Internal
|
|
112
|
+
// ========================================
|
|
643
113
|
|
|
644
|
-
|
|
645
|
-
|
|
114
|
+
cacheTemplate(key, ast) {
|
|
115
|
+
if (this.templateCache.size >= this.maxCacheSize) {
|
|
116
|
+
// Remove oldest entry
|
|
117
|
+
const firstKey = this.templateCache.keys().next().value;
|
|
118
|
+
this.templateCache.delete(firstKey);
|
|
646
119
|
}
|
|
647
|
-
|
|
648
|
-
return result;
|
|
120
|
+
this.templateCache.set(key, ast);
|
|
649
121
|
}
|
|
650
122
|
}
|
|
651
123
|
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
// For Node.js: Use as ES6 module import
|
|
655
|
-
export default TemplateProcessor;
|
|
124
|
+
// Singleton
|
|
125
|
+
const templateProcessor = new TemplateProcessor();
|
|
656
126
|
|
|
657
|
-
|
|
658
|
-
export
|
|
127
|
+
export default TemplateProcessor;
|
|
128
|
+
export { templateProcessor, TemplateProcessor };
|