@webmate-studio/builder 0.2.112 → 0.2.114

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/index.js CHANGED
@@ -5,6 +5,7 @@ import { bundleIsland, bundleComponentIslands } from './bundler.js';
5
5
  import { deduplicateCSS } from './css-deduplicator.js';
6
6
  import { markdownToHtml, processMarkdownProps } from './markdown.js';
7
7
  import TemplateProcessor, { templateProcessor } from './template-processor.js';
8
+ import { SafeHtml } from './safe-html.js';
8
9
  import { defaultDesignTokens, generateTailwindV4Theme, generateTailwindConfig, generateCSSFromTokens } from './design-tokens.js';
9
10
  import { readFileSync } from 'fs';
10
11
  import { fileURLToPath } from 'url';
@@ -18,4 +19,4 @@ function getMotionRuntime() {
18
19
  return readFileSync(join(__dirname, '../dist/motion-runtime.min.js'), 'utf-8');
19
20
  }
20
21
 
21
- export { build, generateComponentCSS, generateTailwindCSS, extractTailwindClasses, cleanComponentHTML, bundleIsland, bundleComponentIslands, deduplicateCSS, markdownToHtml, processMarkdownProps, getMotionRuntime, TemplateProcessor, templateProcessor, defaultDesignTokens, generateTailwindV4Theme, generateTailwindConfig, generateCSSFromTokens };
22
+ export { build, generateComponentCSS, generateTailwindCSS, extractTailwindClasses, cleanComponentHTML, bundleIsland, bundleComponentIslands, deduplicateCSS, markdownToHtml, processMarkdownProps, SafeHtml, getMotionRuntime, TemplateProcessor, templateProcessor, defaultDesignTokens, generateTailwindV4Theme, generateTailwindConfig, generateCSSFromTokens };
package/src/markdown.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { marked } from 'marked';
7
7
  import DOMPurify from 'dompurify';
8
8
  import { JSDOM } from 'jsdom';
9
+ import { SafeHtml } from './safe-html.js';
9
10
 
10
11
  // DOMPurify für Server-Side Rendering konfigurieren
11
12
  const window = new JSDOM('').window;
@@ -160,8 +161,9 @@ export function processMarkdownProps(props, propSchema = null, componentMetadata
160
161
  options.headingStartLevel = headingStartLevel;
161
162
  }
162
163
 
163
- // Convert markdown to HTML
164
- processed[key] = markdownToHtml(value, options);
164
+ // Convert markdown to HTML and wrap in SafeHtml
165
+ // (already sanitized by DOMPurify — evaluator will not escape)
166
+ processed[key] = new SafeHtml(markdownToHtml(value, options));
165
167
  }
166
168
  }
167
169
 
@@ -0,0 +1,30 @@
1
+ /**
2
+ * SafeHtml — Marker for pre-sanitized HTML content.
3
+ *
4
+ * When processMarkdownProps() converts markdown to HTML, the result is
5
+ * already sanitized by DOMPurify. Wrapping it in SafeHtml tells the
6
+ * template evaluator to output it without escaping.
7
+ *
8
+ * Usage:
9
+ * const safe = new SafeHtml('<h2>Hello</h2>');
10
+ * safe.toString() // → '<h2>Hello</h2>'
11
+ * safe.__safeHtml // → true (marker for evaluator)
12
+ */
13
+
14
+ class SafeHtml {
15
+ constructor(html) {
16
+ this.html = String(html ?? '');
17
+ this.__safeHtml = true;
18
+ }
19
+
20
+ toString() {
21
+ return this.html;
22
+ }
23
+
24
+ valueOf() {
25
+ return this.html;
26
+ }
27
+ }
28
+
29
+ export { SafeHtml };
30
+ export default SafeHtml;
@@ -0,0 +1,389 @@
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
+ * SafeHtml instances (from processMarkdownProps) are output without escaping.
77
+ */
78
+ evalExpression(node, ctx) {
79
+ const value = this.expr.evaluate(node.expression, ctx);
80
+
81
+ if (value === undefined || value === null) return '';
82
+ if (typeof value === 'boolean') return '';
83
+
84
+ // SafeHtml marker — already sanitized by DOMPurify, don't escape
85
+ if (value && value.__safeHtml) {
86
+ return value.toString();
87
+ }
88
+
89
+ // Handle special objects
90
+ const resolved = this.resolveValue(value);
91
+ return this.escapeHtml(String(resolved));
92
+ }
93
+
94
+ /**
95
+ * {@html expression} — evaluate without escaping
96
+ */
97
+ evalHtmlExpression(node, ctx) {
98
+ const value = this.expr.evaluate(node.expression, ctx);
99
+ if (value === undefined || value === null) return '';
100
+ return String(value);
101
+ }
102
+
103
+ /**
104
+ * {@const name = expression} — adds to context, outputs nothing
105
+ * Returns empty string but modifies the mutable context wrapper
106
+ */
107
+ evalConstTag(node, ctx) {
108
+ const value = this.expr.evaluate(node.expression, ctx);
109
+ // We need to mutate ctx here so subsequent nodes can access the const.
110
+ // This is intentional — @const is a side-effect.
111
+ ctx[node.name] = value;
112
+ return '';
113
+ }
114
+
115
+ /**
116
+ * {@debug var1, var2} — logs to console, outputs nothing
117
+ */
118
+ evalDebugTag(node, ctx) {
119
+ const values = {};
120
+ for (const varName of node.variables) {
121
+ values[varName] = this.expr.evaluate(varName, ctx);
122
+ }
123
+ console.log('[Debug]', values);
124
+ return '';
125
+ }
126
+
127
+ /**
128
+ * {#if condition}...{:else if}...{:else}...{/if}
129
+ */
130
+ evalIfBlock(node, ctx) {
131
+ // Check main condition
132
+ if (this.expr.isTruthy(node.condition, ctx)) {
133
+ return this.evalNode(node.consequent, ctx);
134
+ }
135
+
136
+ // Check else-if branches
137
+ for (const branch of node.elseIfBranches) {
138
+ if (this.expr.isTruthy(branch.condition, ctx)) {
139
+ return this.evalNode(branch.body, ctx);
140
+ }
141
+ }
142
+
143
+ // Else branch
144
+ if (node.alternate) {
145
+ return this.evalNode(node.alternate, ctx);
146
+ }
147
+
148
+ return '';
149
+ }
150
+
151
+ /**
152
+ * {#each expression as item, index (key)}...{:else}...{/each}
153
+ *
154
+ * The key insight for correct nesting: we spread the parent context
155
+ * and add the loop variables on top. This means a 3-level deep loop
156
+ * has access to all parent loop variables automatically.
157
+ */
158
+ evalEachBlock(node, ctx) {
159
+ // Evaluate the iterable expression
160
+ const iterable = this.expr.evaluate(node.expression, ctx);
161
+
162
+ // Handle non-array or empty
163
+ if (!Array.isArray(iterable) || iterable.length === 0) {
164
+ if (node.else) {
165
+ return this.evalNode(node.else, ctx);
166
+ }
167
+ return '';
168
+ }
169
+
170
+ // Render each item
171
+ let result = '';
172
+ for (let i = 0; i < iterable.length; i++) {
173
+ // Create new scope: parent context + loop variable + index
174
+ const loopCtx = { ...ctx, [node.itemVar]: iterable[i] };
175
+ if (node.indexVar) {
176
+ loopCtx[node.indexVar] = i;
177
+ }
178
+
179
+ result += this.evalNode(node.body, loopCtx);
180
+ }
181
+
182
+ return result;
183
+ }
184
+
185
+ /**
186
+ * HTML Tag — reconstruct the tag with evaluated attributes
187
+ */
188
+ evalTag(node, ctx) {
189
+ // Check for PascalCase tag (Island component)
190
+ // PascalCase tags like <Counter />, <ProductCard /> are treated as Islands:
191
+ // - Tag name converted to kebab-case with wm- prefix
192
+ // - All props serialized as data-island-props JSON
193
+ if (/^[A-Z]/.test(node.tagName)) {
194
+ return this.evalIslandTag(node, ctx);
195
+ }
196
+
197
+ let result = `<${node.tagName}`;
198
+
199
+ // Collect all class names (static + conditional)
200
+ let staticClasses = '';
201
+ const conditionalClasses = [];
202
+
203
+ // Process static attributes
204
+ for (const attr of node.attributes) {
205
+ if (attr.type === 'StaticAttribute') {
206
+ if (attr.name === 'class') {
207
+ staticClasses = attr.value;
208
+ } else if (attr.value === true) {
209
+ // Boolean attribute
210
+ result += ` ${attr.name}`;
211
+ } else {
212
+ result += ` ${attr.name}="${this.escapeAttr(attr.value)}"`;
213
+ }
214
+ } else if (attr.type === 'DynamicAttribute') {
215
+ const value = this.expr.evaluate(attr.expression, ctx);
216
+ const resolved = this.resolveValueForAttr(value);
217
+ result += ` ${attr.name}="${this.escapeAttr(String(resolved ?? ''))}"`;
218
+ } else if (attr.type === 'DynamicQuotedAttribute') {
219
+ // Attribute with mixed static and dynamic parts: "Hello {name}"
220
+ let value = '';
221
+ for (const part of attr.parts) {
222
+ if (part.type === 'text') {
223
+ value += part.value;
224
+ } else {
225
+ const evaluated = this.expr.evaluate(part.value, ctx);
226
+ const resolved = this.resolveValueForAttr(evaluated);
227
+ value += String(resolved ?? '');
228
+ }
229
+ }
230
+ result += ` ${attr.name}="${this.escapeAttr(value)}"`;
231
+ }
232
+ }
233
+
234
+ // Process class: directives
235
+ for (const directive of node.classDirectives) {
236
+ const conditionResult = this.expr.isTruthy(directive.condition, ctx);
237
+ if (conditionResult) {
238
+ conditionalClasses.push(directive.name);
239
+ }
240
+ }
241
+
242
+ // Merge class attribute
243
+ const allClasses = [staticClasses, ...conditionalClasses].filter(Boolean).join(' ');
244
+ if (allClasses) {
245
+ result += ` class="${this.escapeAttr(allClasses)}"`;
246
+ }
247
+
248
+ if (node.selfClosing) {
249
+ result += ' />';
250
+ } else {
251
+ result += '>';
252
+ }
253
+
254
+ return result;
255
+ }
256
+
257
+ /**
258
+ * Island Tag — PascalCase components like <Counter />, <ProductCard item={item} />
259
+ * Transforms to: <wm-counter data-island-props='{"item":...}'></wm-counter>
260
+ *
261
+ * This runs inside the evaluator, so it works correctly inside {#each} loops:
262
+ * {#each items as item}<Counter data={item} />{/each}
263
+ * → <wm-counter data-island-props='{"data":{"name":"A"}}'></wm-counter>
264
+ * <wm-counter data-island-props='{"data":{"name":"B"}}'></wm-counter>
265
+ */
266
+ evalIslandTag(node, ctx) {
267
+ // Convert PascalCase to kebab-case with wm- prefix
268
+ const kebabTag = 'wm-' + node.tagName
269
+ .replace(/([A-Z])/g, '-$1')
270
+ .toLowerCase()
271
+ .replace(/^-/, '');
272
+
273
+ // Collect all props (evaluate dynamic ones)
274
+ const props = {};
275
+
276
+ for (const attr of node.attributes) {
277
+ if (attr.type === 'StaticAttribute') {
278
+ if (attr.value === true) {
279
+ props[attr.name] = true;
280
+ } else {
281
+ props[attr.name] = attr.value;
282
+ }
283
+ } else if (attr.type === 'DynamicAttribute') {
284
+ props[attr.name] = this.expr.evaluate(attr.expression, ctx);
285
+ } else if (attr.type === 'DynamicQuotedAttribute') {
286
+ let value = '';
287
+ for (const part of attr.parts) {
288
+ if (part.type === 'text') {
289
+ value += part.value;
290
+ } else {
291
+ const evaluated = this.expr.evaluate(part.value, ctx);
292
+ value += String(evaluated ?? '');
293
+ }
294
+ }
295
+ props[attr.name] = value;
296
+ }
297
+ }
298
+
299
+ // Build tag with data-island-props
300
+ const hasProps = Object.keys(props).length > 0;
301
+ const propsAttr = hasProps
302
+ ? ` data-island-props='${JSON.stringify(props).replace(/'/g, '&apos;')}'`
303
+ : '';
304
+
305
+ return `<${kebabTag}${propsAttr}></${kebabTag}>`;
306
+ }
307
+
308
+ // ========================================
309
+ // Value resolution helpers
310
+ // ========================================
311
+
312
+ /**
313
+ * Resolve special object types to display values
314
+ * Handles CMS-specific types like links and images
315
+ */
316
+ resolveValue(value) {
317
+ if (typeof value !== 'object' || value === null) return value;
318
+
319
+ // Link objects (from LinkField) — extract path
320
+ if (value.type === 'page' && value.pageUuid) {
321
+ return value.path || `/preview/${value.pageUuid}`;
322
+ }
323
+
324
+ // Image objects — extract URL
325
+ if (value.src || value.filename) {
326
+ return value.src || value.filename || '';
327
+ }
328
+
329
+ // Arrays — don't stringify
330
+ if (Array.isArray(value)) return '';
331
+
332
+ // Other objects — try to stringify
333
+ try {
334
+ return JSON.stringify(value);
335
+ } catch {
336
+ return '[Object]';
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Resolve values for attribute context (slightly different from text)
342
+ */
343
+ resolveValueForAttr(value) {
344
+ if (value === undefined || value === null) return '';
345
+ if (typeof value !== 'object') return value;
346
+
347
+ // Link objects
348
+ if (value.type === 'page' && value.pageUuid) {
349
+ return value.path || `/preview/${value.pageUuid}`;
350
+ }
351
+
352
+ // Image objects
353
+ if (value.src || value.filename) {
354
+ return value.src || value.filename || '';
355
+ }
356
+
357
+ // Arrays/objects — stringify for data attributes
358
+ try {
359
+ return JSON.stringify(value);
360
+ } catch {
361
+ return '';
362
+ }
363
+ }
364
+
365
+ // ========================================
366
+ // HTML escaping
367
+ // ========================================
368
+
369
+ /**
370
+ * Escape HTML for text content.
371
+ * Only escapes < and > (XSS protection).
372
+ * Does NOT escape & — CMS users legitimately use & in content,
373
+ * and escaping it would turn "Hallo & Hi" into "Hallo &amp; Hi".
374
+ */
375
+ escapeHtml(str) {
376
+ return str
377
+ .replace(/</g, '&lt;')
378
+ .replace(/>/g, '&gt;');
379
+ }
380
+
381
+ escapeAttr(str) {
382
+ return str
383
+ .replace(/&/g, '&amp;')
384
+ .replace(/"/g, '&quot;');
385
+ }
386
+ }
387
+
388
+ export { TemplateEvaluator };
389
+ export default TemplateEvaluator;