@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/package.json +1 -1
- package/src/expression-evaluator.js +1234 -0
- package/src/index.js +2 -1
- package/src/markdown.js +4 -2
- package/src/safe-html.js +30 -0
- package/src/template-evaluator.js +389 -0
- package/src/template-parser.js +597 -0
- package/src/template-processor.js +87 -617
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
|
-
|
|
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
|
|
package/src/safe-html.js
ADDED
|
@@ -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, ''')}'`
|
|
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 & Hi".
|
|
374
|
+
*/
|
|
375
|
+
escapeHtml(str) {
|
|
376
|
+
return str
|
|
377
|
+
.replace(/</g, '<')
|
|
378
|
+
.replace(/>/g, '>');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
escapeAttr(str) {
|
|
382
|
+
return str
|
|
383
|
+
.replace(/&/g, '&')
|
|
384
|
+
.replace(/"/g, '"');
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export { TemplateEvaluator };
|
|
389
|
+
export default TemplateEvaluator;
|