@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.
@@ -0,0 +1,597 @@
1
+ /**
2
+ * Template Parser — Parses Svelte-style template syntax into an AST.
3
+ *
4
+ * Supported syntax:
5
+ * - {expression} → ExpressionNode
6
+ * - {#if cond}...{:else if}...{:else}...{/if} → IfBlock
7
+ * - {#each expr as item, index (key)}...{:else}...{/each} → EachBlock
8
+ * - {@html expr} → HtmlExpression
9
+ * - {@const name = expr} → ConstTag
10
+ * - {@debug var1, var2} → DebugTag
11
+ * - class:name={condition} → ClassDirective (inside tags)
12
+ * - attr={expression} → DynamicAttribute (inside tags)
13
+ * - Everything else → Text
14
+ *
15
+ * The parser does NOT fully parse HTML — it only recognizes opening tags
16
+ * enough to extract class: directives and dynamic attributes.
17
+ *
18
+ * @module template-parser
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} ASTNode
23
+ * @property {string} type
24
+ */
25
+
26
+ class TemplateParser {
27
+ /**
28
+ * Parse a template string into an AST
29
+ * @param {string} template
30
+ * @returns {ASTNode} Root Fragment node
31
+ */
32
+ parse(template) {
33
+ this.input = template;
34
+ this.pos = 0;
35
+ this.length = template.length;
36
+
37
+ const children = this.parseFragment([]);
38
+ return { type: 'Fragment', children };
39
+ }
40
+
41
+ /**
42
+ * Parse a sequence of nodes until we hit a stop token.
43
+ * @param {string[]} stopTokens - e.g. ['{/if}', '{:else}', '{:else if']
44
+ * @returns {ASTNode[]}
45
+ */
46
+ parseFragment(stopTokens) {
47
+ const children = [];
48
+
49
+ while (this.pos < this.length) {
50
+ // Check for stop tokens
51
+ if (stopTokens.length > 0) {
52
+ for (const stop of stopTokens) {
53
+ if (this.lookingAt(stop)) {
54
+ return children;
55
+ }
56
+ }
57
+ }
58
+
59
+ const node = this.parseNext();
60
+ if (node) {
61
+ // Merge adjacent text nodes
62
+ if (node.type === 'Text' && children.length > 0 && children[children.length - 1].type === 'Text') {
63
+ children[children.length - 1].value += node.value;
64
+ } else {
65
+ children.push(node);
66
+ }
67
+ }
68
+ }
69
+
70
+ return children;
71
+ }
72
+
73
+ /**
74
+ * Parse the next node at current position
75
+ * @returns {ASTNode|null}
76
+ */
77
+ parseNext() {
78
+ if (this.pos >= this.length) return null;
79
+
80
+ const ch = this.input[this.pos];
81
+
82
+ // Template syntax: { ... }
83
+ if (ch === '{') {
84
+ const next = this.input[this.pos + 1];
85
+
86
+ // Block opening: {#if ...} or {#each ...}
87
+ if (next === '#') {
88
+ if (this.lookingAt('{#if ') || this.lookingAt('{#if}')) {
89
+ return this.parseIfBlock();
90
+ }
91
+ if (this.lookingAt('{#each ')) {
92
+ return this.parseEachBlock();
93
+ }
94
+ // Unknown block — treat as text
95
+ return this.parseText();
96
+ }
97
+
98
+ // Special tags: {@html}, {@const}, {@debug}
99
+ if (next === '@') {
100
+ if (this.lookingAt('{@html ')) {
101
+ return this.parseHtmlTag();
102
+ }
103
+ if (this.lookingAt('{@const ')) {
104
+ return this.parseConstTag();
105
+ }
106
+ if (this.lookingAt('{@debug ')) {
107
+ return this.parseDebugTag();
108
+ }
109
+ return this.parseText();
110
+ }
111
+
112
+ // Closing/continuation tags — should be handled by parent
113
+ if (next === '/' || next === ':') {
114
+ return null;
115
+ }
116
+
117
+ // Expression: {expr}
118
+ return this.parseExpression();
119
+ }
120
+
121
+ // HTML tag: < ... >
122
+ if (ch === '<') {
123
+ const next = this.input[this.pos + 1];
124
+ // Only parse opening tags (not closing tags or comments)
125
+ if (next && /[a-zA-Z]/.test(next)) {
126
+ return this.parseTag();
127
+ }
128
+ }
129
+
130
+ // Plain text
131
+ return this.parseText();
132
+ }
133
+
134
+ // ========================================
135
+ // {#if} Block
136
+ // ========================================
137
+
138
+ parseIfBlock() {
139
+ // {#if condition}
140
+ this.pos += 4; // skip '{#if'
141
+ const condition = this.readUntilCloseBrace();
142
+
143
+ // Parse consequent (the "then" branch)
144
+ const consequent = this.parseFragment(['{:else if ', '{:else if\t', '{:else if\n', '{:else}', '{/if}']);
145
+
146
+ // Collect else-if branches
147
+ const elseIfBranches = [];
148
+ while (this.lookingAt('{:else if ') || this.lookingAt('{:else if\t') || this.lookingAt('{:else if\n')) {
149
+ this.pos += 9; // skip '{:else if'
150
+ const elseIfCondition = this.readUntilCloseBrace();
151
+ const elseIfBody = this.parseFragment(['{:else if ', '{:else if\t', '{:else if\n', '{:else}', '{/if}']);
152
+ elseIfBranches.push({
153
+ condition: elseIfCondition,
154
+ body: { type: 'Fragment', children: elseIfBody },
155
+ });
156
+ }
157
+
158
+ // Optional else branch
159
+ let alternate = null;
160
+ if (this.lookingAt('{:else}')) {
161
+ this.pos += 7; // skip '{:else}'
162
+ const elseChildren = this.parseFragment(['{/if}']);
163
+ alternate = { type: 'Fragment', children: elseChildren };
164
+ }
165
+
166
+ // Consume {/if}
167
+ if (this.lookingAt('{/if}')) {
168
+ this.pos += 5;
169
+ }
170
+
171
+ return {
172
+ type: 'IfBlock',
173
+ condition,
174
+ consequent: { type: 'Fragment', children: consequent },
175
+ elseIfBranches,
176
+ alternate,
177
+ };
178
+ }
179
+
180
+ // ========================================
181
+ // {#each} Block
182
+ // ========================================
183
+
184
+ parseEachBlock() {
185
+ // {#each expression as item, index (key)}
186
+ this.pos += 7; // skip '{#each '
187
+
188
+ // Read everything until closing }
189
+ const fullExpr = this.readUntilCloseBrace();
190
+
191
+ // Parse: expression as item, index (key)
192
+ const asIndex = fullExpr.indexOf(' as ');
193
+ if (asIndex === -1) {
194
+ // Malformed each — treat as text
195
+ return { type: 'Text', value: `{#each ${fullExpr}}` };
196
+ }
197
+
198
+ const iterableExpr = fullExpr.substring(0, asIndex).trim();
199
+ let rest = fullExpr.substring(asIndex + 4).trim();
200
+
201
+ // Extract optional (key) at the end
202
+ let keyExpr = null;
203
+ const keyMatch = rest.match(/\(([^)]+)\)\s*$/);
204
+ if (keyMatch) {
205
+ keyExpr = keyMatch[1].trim();
206
+ rest = rest.substring(0, keyMatch.index).trim();
207
+ }
208
+
209
+ // Extract item and optional index: "item" or "item, index"
210
+ const parts = rest.split(',').map(s => s.trim());
211
+ const itemVar = parts[0];
212
+ const indexVar = parts[1] || null;
213
+
214
+ // Parse body
215
+ const body = this.parseFragment(['{:else}', '{/each}']);
216
+
217
+ // Optional else
218
+ let elseBlock = null;
219
+ if (this.lookingAt('{:else}')) {
220
+ this.pos += 7;
221
+ const elseChildren = this.parseFragment(['{/each}']);
222
+ elseBlock = { type: 'Fragment', children: elseChildren };
223
+ }
224
+
225
+ // Consume {/each}
226
+ if (this.lookingAt('{/each}')) {
227
+ this.pos += 7;
228
+ }
229
+
230
+ return {
231
+ type: 'EachBlock',
232
+ expression: iterableExpr,
233
+ itemVar,
234
+ indexVar,
235
+ keyExpr,
236
+ body: { type: 'Fragment', children: body },
237
+ else: elseBlock,
238
+ };
239
+ }
240
+
241
+ // ========================================
242
+ // {@html expression}
243
+ // ========================================
244
+
245
+ parseHtmlTag() {
246
+ this.pos += 7; // skip '{@html '
247
+ const expression = this.readUntilCloseBrace();
248
+ return { type: 'HtmlExpression', expression };
249
+ }
250
+
251
+ // ========================================
252
+ // {@const name = expression}
253
+ // ========================================
254
+
255
+ parseConstTag() {
256
+ this.pos += 8; // skip '{@const '
257
+ const content = this.readUntilCloseBrace();
258
+ const eqIndex = content.indexOf('=');
259
+ if (eqIndex === -1) {
260
+ return { type: 'Text', value: '' };
261
+ }
262
+ const name = content.substring(0, eqIndex).trim();
263
+ const expression = content.substring(eqIndex + 1).trim();
264
+ return { type: 'ConstTag', name, expression };
265
+ }
266
+
267
+ // ========================================
268
+ // {@debug var1, var2}
269
+ // ========================================
270
+
271
+ parseDebugTag() {
272
+ this.pos += 8; // skip '{@debug '
273
+ const content = this.readUntilCloseBrace();
274
+ const variables = content.split(',').map(s => s.trim()).filter(Boolean);
275
+ return { type: 'DebugTag', variables };
276
+ }
277
+
278
+ // ========================================
279
+ // {expression} — value interpolation
280
+ // ========================================
281
+
282
+ parseExpression() {
283
+ this.pos++; // skip '{'
284
+ const expression = this.readUntilCloseBraceRaw();
285
+ return { type: 'Expression', expression };
286
+ }
287
+
288
+ // ========================================
289
+ // HTML Tag parsing (for class: and attr={})
290
+ // ========================================
291
+
292
+ parseTag() {
293
+ const start = this.pos;
294
+ this.pos++; // skip '<'
295
+
296
+ // Read tag name
297
+ const tagName = this.readWhile(/[a-zA-Z0-9-]/);
298
+ if (!tagName) {
299
+ // Not a valid tag, treat as text
300
+ this.pos = start;
301
+ return this.parseTextChar();
302
+ }
303
+
304
+ // Parse attributes
305
+ const attributes = [];
306
+ const classDirectives = [];
307
+
308
+ this.skipWhitespace();
309
+
310
+ while (this.pos < this.length && this.input[this.pos] !== '>' && !this.lookingAt('/>')) {
311
+ this.skipWhitespace();
312
+ if (this.pos >= this.length || this.input[this.pos] === '>' || this.lookingAt('/>')) break;
313
+
314
+ // class:name={condition}
315
+ if (this.lookingAt('class:')) {
316
+ this.pos += 6; // skip 'class:'
317
+ // Read class name (can contain brackets for Tailwind: e.g. lg:bg-[#fff])
318
+ let className = '';
319
+ while (this.pos < this.length) {
320
+ const c = this.input[this.pos];
321
+ if (c === '=') break;
322
+ if (c === ' ' || c === '>' || c === '/') break;
323
+ if (c === '[') {
324
+ // Bracket notation (Tailwind arbitrary values)
325
+ className += c;
326
+ this.pos++;
327
+ while (this.pos < this.length && this.input[this.pos] !== ']') {
328
+ className += this.input[this.pos];
329
+ this.pos++;
330
+ }
331
+ if (this.pos < this.length) {
332
+ className += this.input[this.pos]; // closing ]
333
+ this.pos++;
334
+ }
335
+ continue;
336
+ }
337
+ className += c;
338
+ this.pos++;
339
+ }
340
+
341
+ if (this.input[this.pos] === '=' && this.input[this.pos + 1] === '{') {
342
+ this.pos += 2; // skip ={
343
+ const condition = this.readUntilCloseBraceRaw();
344
+ classDirectives.push({ name: className, condition });
345
+ }
346
+ continue;
347
+ }
348
+
349
+ // Regular attribute
350
+ const attrName = this.readWhile(/[a-zA-Z0-9_:@.-]/);
351
+ if (!attrName) {
352
+ // Skip unknown character
353
+ this.pos++;
354
+ continue;
355
+ }
356
+
357
+ this.skipWhitespace();
358
+
359
+ if (this.input[this.pos] === '=') {
360
+ this.pos++; // skip '='
361
+ this.skipWhitespace();
362
+
363
+ if (this.input[this.pos] === '{') {
364
+ // Dynamic attribute: attr={expression}
365
+ this.pos++; // skip '{'
366
+ const expression = this.readUntilCloseBraceRaw();
367
+ attributes.push({ type: 'DynamicAttribute', name: attrName, expression });
368
+ } else if (this.input[this.pos] === '"') {
369
+ // Quoted attribute: attr="value" (may contain {expr} inside)
370
+ this.pos++; // skip opening "
371
+ const value = this.readQuotedAttributeValue('"');
372
+ attributes.push(value.dynamic
373
+ ? { type: 'DynamicQuotedAttribute', name: attrName, parts: value.parts }
374
+ : { type: 'StaticAttribute', name: attrName, value: value.text }
375
+ );
376
+ } else if (this.input[this.pos] === "'") {
377
+ // Single-quoted attribute
378
+ this.pos++; // skip opening '
379
+ const value = this.readQuotedAttributeValue("'");
380
+ attributes.push(value.dynamic
381
+ ? { type: 'DynamicQuotedAttribute', name: attrName, parts: value.parts }
382
+ : { type: 'StaticAttribute', name: attrName, value: value.text }
383
+ );
384
+ } else {
385
+ // Unquoted attribute value
386
+ const value = this.readWhile(/[^\s>]/);
387
+ attributes.push({ type: 'StaticAttribute', name: attrName, value });
388
+ }
389
+ } else {
390
+ // Boolean attribute: disabled, checked, etc.
391
+ attributes.push({ type: 'StaticAttribute', name: attrName, value: true });
392
+ }
393
+ }
394
+
395
+ // Consume closing > or />
396
+ let selfClosing = false;
397
+ if (this.lookingAt('/>')) {
398
+ selfClosing = true;
399
+ this.pos += 2;
400
+ } else if (this.input[this.pos] === '>') {
401
+ this.pos++;
402
+ }
403
+
404
+ return {
405
+ type: 'Tag',
406
+ tagName,
407
+ attributes,
408
+ classDirectives,
409
+ selfClosing,
410
+ };
411
+ }
412
+
413
+ /**
414
+ * Read a quoted attribute value, detecting {expression} inside quotes.
415
+ * e.g. "Hello {name}, welcome" → parts with text and expressions
416
+ */
417
+ readQuotedAttributeValue(quote) {
418
+ const parts = [];
419
+ let text = '';
420
+ let hasDynamic = false;
421
+
422
+ while (this.pos < this.length && this.input[this.pos] !== quote) {
423
+ if (this.input[this.pos] === '{' && this.input[this.pos + 1] !== '#' &&
424
+ this.input[this.pos + 1] !== '/' && this.input[this.pos + 1] !== ':' &&
425
+ this.input[this.pos + 1] !== '@') {
426
+ // Expression inside quoted attribute
427
+ if (text) {
428
+ parts.push({ type: 'text', value: text });
429
+ text = '';
430
+ }
431
+ this.pos++; // skip {
432
+ const expr = this.readUntilCloseBraceRaw();
433
+ parts.push({ type: 'expr', value: expr });
434
+ hasDynamic = true;
435
+ } else {
436
+ text += this.input[this.pos];
437
+ this.pos++;
438
+ }
439
+ }
440
+
441
+ this.pos++; // skip closing quote
442
+
443
+ if (text) {
444
+ parts.push({ type: 'text', value: text });
445
+ }
446
+
447
+ if (!hasDynamic) {
448
+ return { dynamic: false, text: parts.map(p => p.value).join('') };
449
+ }
450
+
451
+ return { dynamic: true, parts };
452
+ }
453
+
454
+ // ========================================
455
+ // Text
456
+ // ========================================
457
+
458
+ parseText() {
459
+ let text = '';
460
+ while (this.pos < this.length) {
461
+ const ch = this.input[this.pos];
462
+
463
+ // Stop at template syntax
464
+ if (ch === '{') break;
465
+
466
+ // Stop at HTML tags that we want to parse for attributes
467
+ if (ch === '<' && this.pos + 1 < this.length) {
468
+ const next = this.input[this.pos + 1];
469
+ if (/[a-zA-Z]/.test(next)) break;
470
+ }
471
+
472
+ text += ch;
473
+ this.pos++;
474
+ }
475
+
476
+ if (!text) return null;
477
+ return { type: 'Text', value: text };
478
+ }
479
+
480
+ parseTextChar() {
481
+ const ch = this.input[this.pos];
482
+ this.pos++;
483
+ return { type: 'Text', value: ch };
484
+ }
485
+
486
+ // ========================================
487
+ // Utilities
488
+ // ========================================
489
+
490
+ lookingAt(str) {
491
+ return this.input.substring(this.pos, this.pos + str.length) === str;
492
+ }
493
+
494
+ /**
495
+ * Read until matching } (handles nested {} in expressions)
496
+ * Skips the closing }
497
+ * Trims the result
498
+ */
499
+ readUntilCloseBrace() {
500
+ return this.readUntilCloseBraceRaw().trim();
501
+ }
502
+
503
+ /**
504
+ * Read until matching } (handles nested {}, strings, template literals)
505
+ * Skips the closing }
506
+ */
507
+ readUntilCloseBraceRaw() {
508
+ let depth = 1;
509
+ let result = '';
510
+
511
+ while (this.pos < this.length && depth > 0) {
512
+ const ch = this.input[this.pos];
513
+
514
+ // Handle strings (don't count {} inside strings)
515
+ if (ch === '"' || ch === "'" || ch === '`') {
516
+ result += ch;
517
+ this.pos++;
518
+ // Read until matching quote
519
+ while (this.pos < this.length && this.input[this.pos] !== ch) {
520
+ if (this.input[this.pos] === '\\') {
521
+ result += this.input[this.pos];
522
+ this.pos++;
523
+ if (this.pos < this.length) {
524
+ result += this.input[this.pos];
525
+ this.pos++;
526
+ }
527
+ } else {
528
+ // Handle ${...} in template literals
529
+ if (ch === '`' && this.input[this.pos] === '$' && this.input[this.pos + 1] === '{') {
530
+ result += '${';
531
+ this.pos += 2;
532
+ // Read the inner expression
533
+ let innerDepth = 1;
534
+ while (this.pos < this.length && innerDepth > 0) {
535
+ if (this.input[this.pos] === '{') innerDepth++;
536
+ else if (this.input[this.pos] === '}') innerDepth--;
537
+ if (innerDepth > 0) {
538
+ result += this.input[this.pos];
539
+ this.pos++;
540
+ }
541
+ }
542
+ if (this.pos < this.length) {
543
+ result += '}';
544
+ this.pos++; // skip closing }
545
+ }
546
+ } else {
547
+ result += this.input[this.pos];
548
+ this.pos++;
549
+ }
550
+ }
551
+ }
552
+ if (this.pos < this.length) {
553
+ result += this.input[this.pos]; // closing quote
554
+ this.pos++;
555
+ }
556
+ continue;
557
+ }
558
+
559
+ if (ch === '{') {
560
+ depth++;
561
+ result += ch;
562
+ this.pos++;
563
+ } else if (ch === '}') {
564
+ depth--;
565
+ if (depth === 0) {
566
+ this.pos++; // skip closing }
567
+ return result;
568
+ }
569
+ result += ch;
570
+ this.pos++;
571
+ } else {
572
+ result += ch;
573
+ this.pos++;
574
+ }
575
+ }
576
+
577
+ return result;
578
+ }
579
+
580
+ readWhile(regex) {
581
+ let result = '';
582
+ while (this.pos < this.length && regex.test(this.input[this.pos])) {
583
+ result += this.input[this.pos];
584
+ this.pos++;
585
+ }
586
+ return result;
587
+ }
588
+
589
+ skipWhitespace() {
590
+ while (this.pos < this.length && /\s/.test(this.input[this.pos])) {
591
+ this.pos++;
592
+ }
593
+ }
594
+ }
595
+
596
+ export { TemplateParser };
597
+ export default TemplateParser;