@webmate-studio/builder 0.2.110 → 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/src/markdown.js CHANGED
@@ -114,6 +114,7 @@ export function markdownToHtml(markdown, options = {}) {
114
114
 
115
115
  /**
116
116
  * Process markdown props: Convert markdown strings to HTML
117
+ * Supports nested arrays with markdown fields (e.g., items[].text)
117
118
  *
118
119
  * @param {Object} props - Component props
119
120
  * @param {Object} propSchema - Prop schema with format info (from component.json)
@@ -126,17 +127,35 @@ export function processMarkdownProps(props, propSchema = null, componentMetadata
126
127
  const processed = { ...props };
127
128
 
128
129
  for (const [key, value] of Object.entries(processed)) {
129
- // Only process strings
130
+ const schema = propSchema?.[key];
131
+
132
+ // Handle arrays with nested markdown fields
133
+ if (Array.isArray(value) && schema?.type === 'array' && schema?.props) {
134
+ // Process each item in the array using the nested props schema
135
+ processed[key] = value.map(item => {
136
+ if (typeof item !== 'object' || item === null) return item;
137
+ return processMarkdownProps(item, schema.props, componentMetadata);
138
+ });
139
+ continue;
140
+ }
141
+
142
+ // Handle nested objects
143
+ if (typeof value === 'object' && value !== null && !Array.isArray(value) && schema?.props) {
144
+ processed[key] = processMarkdownProps(value, schema.props, componentMetadata);
145
+ continue;
146
+ }
147
+
148
+ // Only process strings for markdown conversion
130
149
  if (typeof value !== 'string' || !value) continue;
131
150
 
132
151
  // Check if this prop is markdown (via schema)
133
- const isMarkdown = propSchema?.[key]?.format === 'markdown';
152
+ const isMarkdown = schema?.format === 'markdown';
134
153
 
135
154
  if (isMarkdown) {
136
155
  const options = {};
137
156
 
138
157
  // Apply headingStartLevel from prop schema (preferred) or component metadata (fallback)
139
- const headingStartLevel = propSchema?.[key]?.headingStartLevel || componentMetadata?.headingStartLevel;
158
+ const headingStartLevel = schema?.headingStartLevel || componentMetadata?.headingStartLevel;
140
159
  if (headingStartLevel) {
141
160
  options.headingStartLevel = headingStartLevel;
142
161
  }
@@ -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, '&apos;')}'`
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, '&amp;')
366
+ .replace(/</g, '&lt;')
367
+ .replace(/>/g, '&gt;')
368
+ .replace(/"/g, '&quot;');
369
+ }
370
+
371
+ escapeAttr(str) {
372
+ return str
373
+ .replace(/&/g, '&amp;')
374
+ .replace(/"/g, '&quot;');
375
+ }
376
+ }
377
+
378
+ export { TemplateEvaluator };
379
+ export default TemplateEvaluator;