@canvasengine/compiler 2.0.0-beta.3 → 2.0.0-beta.30

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/grammar.pegjs CHANGED
@@ -5,6 +5,134 @@
5
5
  `at line ${start.line}, column ${start.column} to line ${end.line}, column ${end.column}`;
6
6
  throw new Error(errorMessage);
7
7
  }
8
+
9
+ /*——— Custom error handler for syntax errors ———*/
10
+ function parseError(error) {
11
+ // error.expected : array of { type, description }
12
+ // error.found : string | null
13
+ // error.location : { start, end }
14
+ const { expected, found, location } = error;
15
+
16
+ // Group expected items by description to avoid duplicates
17
+ const uniqueExpected = [...new Set(expected.map(e => e.description))];
18
+
19
+ // Format the expected values in a more readable way
20
+ const expectedDesc = uniqueExpected
21
+ .map(desc => `'${desc}'`)
22
+ .join(' or ');
23
+
24
+ const foundDesc = found === null ? 'end of input' : `'${found}'`;
25
+
26
+ generateError(
27
+ `Syntax error: expected ${expectedDesc} but found ${foundDesc}`,
28
+ location
29
+ );
30
+ }
31
+
32
+ // List of standard HTML DOM elements
33
+ const domElements = new Set([
34
+ 'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio', 'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button', 'caption', 'cite', 'code', 'col', 'colgroup', 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'link', 'main', 'map', 'mark', 'menu', 'meta', 'meter', 'nav', 'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'param', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 'samp', 's', 'script', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', 'u', 'ul', 'var', 'video', 'wbr'
35
+ ]);
36
+
37
+ // Framework components that should NOT be transformed to DOM elements
38
+ const frameworkComponents = new Set([
39
+ 'Canvas', 'Container', 'Sprite', 'Text', 'DOMElement', 'Svg', 'Button'
40
+ ]);
41
+
42
+ // DisplayObject special attributes that should not be in attrs
43
+ const displayObjectAttributes = new Set([
44
+ 'x', 'y', 'scale', 'anchor', 'skew', 'tint', 'rotation', 'angle',
45
+ 'zIndex', 'roundPixels', 'cursor', 'visible', 'alpha', 'pivot', 'filters', 'maskOf',
46
+ 'blendMode', 'filterArea', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight',
47
+ 'aspectRatio', 'flexGrow', 'flexShrink', 'flexBasis', 'rowGap', 'columnGap',
48
+ 'positionType', 'top', 'right', 'bottom', 'left', 'objectFit', 'objectPosition',
49
+ 'transformOrigin', 'flexDirection', 'justifyContent', 'alignItems', 'alignContent',
50
+ 'alignSelf', 'margin', 'padding', 'border', 'gap', 'blur', 'shadow'
51
+ ]);
52
+
53
+ function isDOMElement(tagName) {
54
+ // Don't transform framework components to DOM elements
55
+ if (frameworkComponents.has(tagName)) {
56
+ return false;
57
+ }
58
+ return domElements.has(tagName.toLowerCase());
59
+ }
60
+
61
+ function formatAttributes(attributes) {
62
+ if (attributes.length === 0) {
63
+ return null;
64
+ }
65
+
66
+ // Check if there's exactly one attribute and it's a spread attribute
67
+ if (attributes.length === 1 && attributes[0].startsWith('...')) {
68
+ // Return the identifier directly, removing the '...'
69
+ return attributes[0].substring(3);
70
+ }
71
+
72
+ // Otherwise, format as an object literal
73
+ const formattedAttrs = attributes.map(attr => {
74
+ // If it's a spread attribute, keep it as is
75
+ if (attr.startsWith('...')) {
76
+ return attr;
77
+ }
78
+ // If it's a standalone attribute (doesn't contain ':'), format as shorthand property 'name'
79
+ if (!attr.includes(':')) {
80
+ return attr; // JS object literal shorthand
81
+ }
82
+ // Otherwise (key: value), keep it as is
83
+ return attr;
84
+ });
85
+
86
+ return `{ ${formattedAttrs.join(', ')} }`;
87
+ }
88
+
89
+ function formatDOMElement(tagName, attributes) {
90
+ if (attributes.length === 0) {
91
+ return `h(DOMElement, { element: "${tagName}" })`;
92
+ }
93
+
94
+ // Separate DisplayObject attributes from DOM attributes
95
+ const domAttrs = [];
96
+ const displayObjectAttrs = [];
97
+
98
+ attributes.forEach(attr => {
99
+ // Handle spread attributes
100
+ if (attr.startsWith('...')) {
101
+ displayObjectAttrs.push(attr);
102
+ return;
103
+ }
104
+
105
+ // Extract attribute name
106
+ let attrName;
107
+ if (attr.includes(':')) {
108
+ // Format: "name: value" or "'name': value"
109
+ attrName = attr.split(':')[0].trim().replace(/['"]/g, '');
110
+ } else {
111
+ // Standalone attribute
112
+ attrName = attr.replace(/['"]/g, '');
113
+ }
114
+
115
+ // Check if it's a DisplayObject attribute
116
+ if (displayObjectAttributes.has(attrName)) {
117
+ displayObjectAttrs.push(attr);
118
+ } else {
119
+ domAttrs.push(attr);
120
+ }
121
+ });
122
+
123
+ // Build the result
124
+ const parts = [`element: "${tagName}"`];
125
+
126
+ if (domAttrs.length > 0) {
127
+ parts.push(`attrs: { ${domAttrs.join(', ')} }`);
128
+ }
129
+
130
+ if (displayObjectAttrs.length > 0) {
131
+ parts.push(...displayObjectAttrs);
132
+ }
133
+
134
+ return `h(DOMElement, { ${parts.join(', ')} })`;
135
+ }
8
136
  }
9
137
 
10
138
  start
@@ -15,30 +143,246 @@ start
15
143
  return `[${elements.join(',')}]`;
16
144
  }
17
145
 
18
- element
146
+ element "component or control structure"
19
147
  = forLoop
20
148
  / ifCondition
149
+ / svgElement
150
+ / domElementWithText
21
151
  / selfClosingElement
22
152
  / openCloseElement
23
- / comment
153
+ / openUnclosedTag
154
+ / comment
24
155
 
25
- selfClosingElement
156
+ selfClosingElement "self-closing component tag"
26
157
  = _ "<" _ tagName:tagName _ attributes:attributes _ "/>" _ {
27
- const attrs = attributes.length > 0 ? `{ ${attributes.join(', ')} }` : null;
28
- return attrs ? `h(${tagName}, ${attrs})` : `h(${tagName})`;
158
+ // Check if it's a DOM element
159
+ if (isDOMElement(tagName)) {
160
+ return formatDOMElement(tagName, attributes);
161
+ }
162
+ // Otherwise, treat as regular component
163
+ const attrsString = formatAttributes(attributes);
164
+ return attrsString ? `h(${tagName}, ${attrsString})` : `h(${tagName})`;
29
165
  }
30
166
 
31
- openCloseElement
32
- = "<" _ tagName:tagName _ attributes:attributes _ ">" _ content:content _ "</" _ closingTagName:tagName _ ">" {
167
+ domElementWithText "DOM element with text content"
168
+ = "<" _ tagName:tagName _ attributes:attributes _ ">" _ text:simpleTextContent _ "</" _ closingTagName:tagName _ ">" _ {
33
169
  if (tagName !== closingTagName) {
34
- error("Mismatched opening and closing tags");
170
+ generateError(
171
+ `Mismatched tag: opened <${tagName}> but closed </${closingTagName}>`,
172
+ location()
173
+ );
174
+ }
175
+
176
+ if (isDOMElement(tagName)) {
177
+ if (attributes.length === 0) {
178
+ return `h(DOMElement, { element: "${tagName}", textContent: ${text} })`;
179
+ }
180
+
181
+ // Separate DisplayObject attributes from DOM attributes
182
+ const domAttrs = [];
183
+ const displayObjectAttrs = [];
184
+
185
+ attributes.forEach(attr => {
186
+ // Handle spread attributes
187
+ if (attr.startsWith('...')) {
188
+ displayObjectAttrs.push(attr);
189
+ return;
190
+ }
191
+
192
+ // Extract attribute name
193
+ let attrName;
194
+ if (attr.includes(':')) {
195
+ // Format: "name: value" or "'name': value"
196
+ attrName = attr.split(':')[0].trim().replace(/['"]/g, '');
197
+ } else {
198
+ // Standalone attribute
199
+ attrName = attr.replace(/['"]/g, '');
200
+ }
201
+
202
+ // Check if it's a DisplayObject attribute
203
+ if (displayObjectAttributes.has(attrName)) {
204
+ displayObjectAttrs.push(attr);
205
+ } else {
206
+ domAttrs.push(attr);
207
+ }
208
+ });
209
+
210
+ // Build the result
211
+ const parts = [`element: "${tagName}"`];
212
+
213
+ if (domAttrs.length > 0) {
214
+ parts.push(`attrs: { ${domAttrs.join(', ')} }`);
215
+ }
216
+
217
+ parts.push(`textContent: ${text}`);
218
+
219
+ if (displayObjectAttrs.length > 0) {
220
+ parts.push(...displayObjectAttrs);
221
+ }
222
+
223
+ return `h(DOMElement, { ${parts.join(', ')} })`;
224
+ }
225
+
226
+ // If not a DOM element, fall back to regular parsing
227
+ return null;
228
+ }
229
+
230
+ simpleTextContent "simple text content"
231
+ = parts:(simpleDynamicPart / simpleTextPart)+ {
232
+ const validParts = parts.filter(p => p !== null);
233
+ if (validParts.length === 0) return null;
234
+ if (validParts.length === 1) return validParts[0];
235
+
236
+ // Multiple parts - need to concatenate
237
+ const hasSignals = validParts.some(part => part && part.includes && part.includes('()'));
238
+ if (hasSignals) {
239
+ return `computed(() => ${validParts.join(' + ')})`;
240
+ }
241
+ return validParts.join(' + ');
242
+ }
243
+
244
+ simpleTextPart "simple text part"
245
+ = !("@for" / "@if") text:$([^<{@]+) {
246
+ const trimmed = text.trim();
247
+ return trimmed ? `'${trimmed}'` : null;
248
+ }
249
+
250
+ simpleDynamicPart "simple dynamic part"
251
+ = "{{" _ expr:attributeValue _ "}}" {
252
+ // Handle double brace expressions like {{ object.x }} or {{ @object.x }} or {{ @object.@x }}
253
+ if (expr.trim().match(/^(@?[a-zA-Z_][a-zA-Z0-9_]*)(\.@?[a-zA-Z_][a-zA-Z0-9_]*)*$/)) {
254
+ let foundSignal = false;
255
+ let hasLiterals = false;
256
+
257
+ // Split by dots to handle each part separately
258
+ const parts = expr.split('.');
259
+ const allLiterals = parts.every(part => part.trim().startsWith('@'));
260
+
261
+ let computedValue;
262
+
263
+ if (allLiterals) {
264
+ // All parts are literals, just remove @ prefixes
265
+ computedValue = parts.map(part => part.replace('@', '')).join('.');
266
+ hasLiterals = true;
267
+ } else {
268
+ // Transform each part individually
269
+ computedValue = parts.map(part => {
270
+ const trimmedPart = part.trim();
271
+ if (trimmedPart.startsWith('@')) {
272
+ hasLiterals = true;
273
+ return trimmedPart.substring(1); // Remove @ prefix for literals
274
+ } else {
275
+ // Don't transform keywords
276
+ if (['true', 'false', 'null'].includes(trimmedPart)) {
277
+ return trimmedPart;
278
+ }
279
+ foundSignal = true;
280
+ return `${trimmedPart}()`;
281
+ }
282
+ }).join('.');
283
+ }
284
+
285
+ if (foundSignal && !allLiterals) {
286
+ return `computed(() => ${computedValue})`;
287
+ }
288
+ return computedValue;
289
+ }
290
+ return expr;
291
+ }
292
+ / "{" _ expr:attributeValue _ "}" {
293
+ // Handle single brace expressions like {item.name} or {@text}
294
+ if (expr.trim().match(/^@?[a-zA-Z_][a-zA-Z0-9_.]*$/)) {
295
+ let foundSignal = false;
296
+ const computedValue = expr.replace(/@?[a-zA-Z_][a-zA-Z0-9_]*(?!:)/g, (match) => {
297
+ if (match.startsWith('@')) {
298
+ return match.substring(1);
299
+ }
300
+ foundSignal = true;
301
+ return `${match}()`;
302
+ });
303
+ if (foundSignal) {
304
+ return `computed(() => ${computedValue})`;
305
+ }
306
+ return computedValue;
307
+ }
308
+ return expr;
309
+ }
310
+
311
+ openCloseElement "component with content"
312
+ = "<" _ tagName:tagName _ attributes:attributes _ ">" _ content:content _ "</" _ closingTagName:tagName _ ">" _ {
313
+ if (tagName !== closingTagName) {
314
+ generateError(
315
+ `Mismatched tag: opened <${tagName}> but closed </${closingTagName}>`,
316
+ location()
317
+ );
318
+ }
319
+
320
+ // Check if it's a DOM element
321
+ if (isDOMElement(tagName)) {
322
+ const children = content ? content : null;
323
+
324
+ if (attributes.length === 0) {
325
+ if (children) {
326
+ return `h(DOMElement, { element: "${tagName}" }, ${children})`;
327
+ } else {
328
+ return `h(DOMElement, { element: "${tagName}" })`;
329
+ }
330
+ }
331
+
332
+ // Separate DisplayObject attributes from DOM attributes
333
+ const domAttrs = [];
334
+ const displayObjectAttrs = [];
335
+
336
+ attributes.forEach(attr => {
337
+ // Handle spread attributes
338
+ if (attr.startsWith('...')) {
339
+ displayObjectAttrs.push(attr);
340
+ return;
341
+ }
342
+
343
+ // Extract attribute name
344
+ let attrName;
345
+ if (attr.includes(':')) {
346
+ // Format: "name: value" or "'name': value"
347
+ attrName = attr.split(':')[0].trim().replace(/['"]/g, '');
348
+ } else {
349
+ // Standalone attribute
350
+ attrName = attr.replace(/['"]/g, '');
351
+ }
352
+
353
+ // Check if it's a DisplayObject attribute
354
+ if (displayObjectAttributes.has(attrName)) {
355
+ displayObjectAttrs.push(attr);
356
+ } else {
357
+ domAttrs.push(attr);
358
+ }
359
+ });
360
+
361
+ // Build the result
362
+ const parts = [`element: "${tagName}"`];
363
+
364
+ if (domAttrs.length > 0) {
365
+ parts.push(`attrs: { ${domAttrs.join(', ')} }`);
366
+ }
367
+
368
+ if (displayObjectAttrs.length > 0) {
369
+ parts.push(...displayObjectAttrs);
370
+ }
371
+
372
+ if (children) {
373
+ return `h(DOMElement, { ${parts.join(', ')} }, ${children})`;
374
+ } else {
375
+ return `h(DOMElement, { ${parts.join(', ')} })`;
376
+ }
35
377
  }
36
- const attrs = attributes.length > 0 ? `{ ${attributes.join(', ')} }` : null;
378
+
379
+ // Otherwise, treat as regular component
380
+ const attrsString = formatAttributes(attributes);
37
381
  const children = content ? content : null;
38
- if (attrs && children) {
39
- return `h(${tagName}, ${attrs}, ${children})`;
40
- } else if (attrs) {
41
- return `h(${tagName}, ${attrs})`;
382
+ if (attrsString && children) {
383
+ return `h(${tagName}, ${attrsString}, ${children})`;
384
+ } else if (attrsString) {
385
+ return `h(${tagName}, ${attrsString})`;
42
386
  } else if (children) {
43
387
  return `h(${tagName}, null, ${children})`;
44
388
  } else {
@@ -46,59 +390,243 @@ openCloseElement
46
390
  }
47
391
  }
48
392
 
49
- / "<" _ tagName:tagName _ attributes:attributes _ {
50
- generateError("Syntaxe d'élément invalide", location());
51
- }
52
-
53
- attributes
393
+ attributes "component attributes"
54
394
  = attrs:(attribute (_ attribute)*)? {
55
395
  return attrs
56
396
  ? [attrs[0]].concat(attrs[1].map(a => a[1]))
57
397
  : [];
58
398
  }
59
399
 
60
- attribute
400
+ attribute "attribute"
61
401
  = staticAttribute
62
402
  / dynamicAttribute
63
403
  / eventHandler
404
+ / spreadAttribute
405
+ / unclosedQuote
406
+ / unclosedBrace
407
+
408
+ spreadAttribute "spread attribute"
409
+ = "..." expr:(functionCallExpr / dotNotation) {
410
+ return "..." + expr;
411
+ }
412
+
413
+ functionCallExpr "function call"
414
+ = name:dotNotation "(" args:functionArgs? ")" {
415
+ return `${name}(${args || ''})`;
416
+ }
417
+
418
+ dotNotation "property access"
419
+ = first:identifier rest:("." identifier)* {
420
+ return text();
421
+ }
64
422
 
65
- eventHandler
423
+ eventHandler "event handler"
66
424
  = "@" eventName:identifier _ "=" _ "{" _ handlerName:attributeValue _ "}" {
67
- return `${eventName}: ${handlerName}`;
425
+ const needsQuotes = /[^a-zA-Z0-9_$]/.test(eventName);
426
+ const formattedName = needsQuotes ? `'${eventName}'` : eventName;
427
+ return `${formattedName}: ${handlerName}`;
68
428
  }
69
429
  / "@" eventName:attributeName _ {
70
- return eventName;
430
+ const needsQuotes = /[^a-zA-Z0-9_$]/.test(eventName);
431
+ return needsQuotes ? `'${eventName}'` : eventName;
71
432
  }
72
433
 
73
- dynamicAttribute
434
+ dynamicAttribute "dynamic attribute"
74
435
  = attributeName:attributeName _ "=" _ "{" _ attributeValue:attributeValue _ "}" {
75
- if (attributeValue.trim().match(/^[a-zA-Z_]\w*$/)) {
76
- return `${attributeName}: ${attributeValue}`;
436
+ // Check if attributeName needs to be quoted (contains dash or other invalid JS identifier chars)
437
+ const needsQuotes = /[^a-zA-Z0-9_$]/.test(attributeName);
438
+ const formattedName = needsQuotes ? `'${attributeName}'` : attributeName;
439
+
440
+
441
+ // If it's a complex object with strings, preserve it as is
442
+ if (attributeValue.trim().startsWith('{') && attributeValue.trim().endsWith('}') &&
443
+ (attributeValue.includes('"') || attributeValue.includes("'"))) {
444
+ return `${formattedName}: ${attributeValue}`;
445
+ }
446
+
447
+ // If it's a template string, transform expressions inside ${}
448
+ if (attributeValue.trim().startsWith('`') && attributeValue.trim().endsWith('`')) {
449
+ // Transform expressions inside ${} in template strings
450
+ let transformedTemplate = attributeValue;
451
+
452
+ // Find and replace ${expression} patterns
453
+ let startIndex = 0;
454
+ while (true) {
455
+ const dollarIndex = transformedTemplate.indexOf('${', startIndex);
456
+ if (dollarIndex === -1) break;
457
+
458
+ const braceIndex = transformedTemplate.indexOf('}', dollarIndex);
459
+ if (braceIndex === -1) break;
460
+
461
+ const expr = transformedTemplate.substring(dollarIndex + 2, braceIndex);
462
+ const trimmedExpr = expr.trim();
463
+
464
+ let replacement;
465
+ if (trimmedExpr.startsWith('@')) {
466
+ // Remove @ prefix for literals
467
+ replacement = '${' + trimmedExpr.substring(1) + '}';
468
+ } else if (trimmedExpr.match(/^[a-zA-Z_][a-zA-Z0-9_.]*$/)) {
469
+ // Transform identifiers to signals
470
+ replacement = '${' + trimmedExpr + '()}';
471
+ } else {
472
+ // Keep as is for complex expressions
473
+ replacement = '${' + expr + '}';
474
+ }
475
+
476
+ transformedTemplate = transformedTemplate.substring(0, dollarIndex) +
477
+ replacement +
478
+ transformedTemplate.substring(braceIndex + 1);
479
+
480
+ startIndex = dollarIndex + replacement.length;
481
+ }
482
+
483
+ return formattedName + ': ' + transformedTemplate;
484
+ }
485
+
486
+ // Handle other types of values
487
+ if (attributeValue.startsWith('h(') || attributeValue.includes('=>')) {
488
+ return `${formattedName}: ${attributeValue}`;
489
+ } else if (attributeValue.trim().match(/^[a-zA-Z_]\w*$/)) {
490
+ return `${formattedName}: ${attributeValue}`;
77
491
  } else {
78
- return `${attributeName}: computed(() => ${attributeValue.replace(/@?[a-zA-Z_][a-zA-Z0-9_]*(?!:)/g, (match) => {
79
- if (match.startsWith('@')) {
80
- return match.substring(1);
492
+ // Check if this is an object or array literal
493
+ const isObjectLiteral = attributeValue.trim().startsWith('{ ') && attributeValue.trim().endsWith(' }');
494
+ const isArrayLiteral = attributeValue.trim().startsWith('[') && attributeValue.trim().endsWith(']');
495
+
496
+ let foundSignal = false;
497
+ let hasLiterals = false;
498
+ let computedValue = attributeValue;
499
+
500
+ // For simple object and array literals (like {x: x, y: 20} or [x, 20]),
501
+ // don't use computed() at all and don't transform identifiers
502
+ if ((isObjectLiteral || isArrayLiteral) && !attributeValue.includes('()')) {
503
+ // Don't transform anything, return as is
504
+ foundSignal = false;
505
+ computedValue = attributeValue;
506
+ } else {
507
+ // Apply signal transformation for other values
508
+ computedValue = attributeValue.replace(/@?([a-zA-Z_][a-zA-Z0-9_]*)\b(?!\s*:)/g, (match, p1, offset) => {
509
+ // Don't transform keywords, numbers, or if we're inside quotes
510
+ if (['true', 'false', 'null'].includes(p1) || /^\d+(\.\d+)?$/.test(p1)) {
511
+ return match;
512
+ }
513
+
514
+ // Check if we're inside a string literal
515
+ const beforeMatch = attributeValue.substring(0, offset);
516
+ const singleQuotesBefore = (beforeMatch.match(/'/g) || []).length;
517
+ const doubleQuotesBefore = (beforeMatch.match(/"/g) || []).length;
518
+
519
+ // If we're inside quotes, don't transform
520
+ if (singleQuotesBefore % 2 === 1 || doubleQuotesBefore % 2 === 1) {
521
+ return match;
522
+ }
523
+
524
+ if (match.startsWith('@')) {
525
+ hasLiterals = true;
526
+ return p1; // Remove @ prefix
527
+ }
528
+ foundSignal = true;
529
+ return `${p1}()`;
530
+ });
531
+
532
+ // Check if any values already contain signals (ending with ())
533
+ if (attributeValue.includes('()')) {
534
+ foundSignal = true;
81
535
  }
82
- return `${match}()`;
83
- })})`;
536
+ }
537
+
538
+ if (foundSignal) {
539
+ // For objects, wrap in parentheses
540
+ if (attributeValue.trim().startsWith('{') && attributeValue.trim().endsWith('}')) {
541
+ // Remove spaces for objects in parentheses
542
+ const cleanedObject = computedValue.replace(/{ /g, '{').replace(/ }/g, '}');
543
+ return `${formattedName}: computed(() => (${cleanedObject}))`;
544
+ }
545
+ return `${formattedName}: computed(() => ${computedValue})`;
546
+ }
547
+
548
+ // If only literals (all @), don't use computed
549
+ if (hasLiterals && !foundSignal) {
550
+ return `${formattedName}: ${computedValue}`;
551
+ }
552
+
553
+ // For static objects and arrays, return as is without parentheses
554
+ return `${formattedName}: ${computedValue}`;
84
555
  }
85
556
  }
86
557
  / attributeName:attributeName _ {
87
- return attributeName;
558
+ const needsQuotes = /[^a-zA-Z0-9_$]/.test(attributeName);
559
+ return needsQuotes ? `'${attributeName}'` : attributeName;
88
560
  }
89
561
 
90
- attributeValue
91
- = $([^{}]* ("{" [^{}]* "}" [^{}]*)*) {
92
- const t = text().trim()
93
- if (t.startsWith("{") && t.endsWith("}")) {
94
- return `(${t})`;
95
- }
96
- return t
562
+ attributeValue "attribute value"
563
+ = element
564
+ / functionWithElement
565
+ / objectLiteral
566
+ / $([^{}]* ("{" [^{}]* "}" [^{}]*)*) {
567
+ return text().trim()
568
+ }
569
+
570
+ objectLiteral "object literal"
571
+ = "{" _ objContent:objectContent _ "}" {
572
+ return `{ ${objContent} }`;
97
573
  }
98
574
 
99
- staticAttribute
575
+ objectContent
576
+ = prop:objectProperty rest:(_ "," _ objectProperty)* {
577
+ return [prop].concat(rest.map(r => r[3])).join(', ');
578
+ }
579
+ / "" { return ""; }
580
+
581
+ objectProperty
582
+ = key:identifier _ ":" _ value:propertyValue {
583
+ return `${key}: ${value}`;
584
+ }
585
+ / key:identifier {
586
+ return key;
587
+ }
588
+
589
+ propertyValue
590
+ = nestedObject
591
+ / element
592
+ / functionWithElement
593
+ / stringLiteral
594
+ / number
595
+ / identifier
596
+
597
+ nestedObject
598
+ = "{" _ objContent:objectContent _ "}" {
599
+ return `{ ${objContent} }`;
600
+ }
601
+
602
+ stringLiteral
603
+ = '"' chars:[^"]* '"' { return text(); }
604
+ / "'" chars:[^']* "'" { return text(); }
605
+
606
+ functionWithElement "function expression"
607
+ = "(" _ params:functionParams? _ ")" _ "=>" _ elem:element {
608
+ return `${params ? `(${params}) =>` : '() =>'} ${elem}`;
609
+ }
610
+
611
+ functionParams
612
+ = destructuredParams
613
+ / simpleParams
614
+
615
+ destructuredParams
616
+ = "{" _ param:identifier rest:(_ "," _ identifier)* _ "}" {
617
+ return `{${[param].concat(rest.map(r => r[3])).join(', ')}}`;
618
+ }
619
+
620
+ simpleParams
621
+ = param:identifier rest:(_ "," _ identifier)* {
622
+ return [param].concat(rest.map(r => r[3])).join(', ');
623
+ }
624
+
625
+ staticAttribute "static attribute"
100
626
  = attributeName:attributeName _ "=" _ "\"" attributeValue:staticValue "\"" {
101
- return `${attributeName}: ${attributeValue}`;
627
+ const needsQuotes = /[^a-zA-Z0-9_$]/.test(attributeName);
628
+ const formattedName = needsQuotes ? `'${attributeName}'` : attributeName;
629
+ return `${formattedName}: ${attributeValue}`;
102
630
  }
103
631
 
104
632
  eventAttribute
@@ -109,10 +637,10 @@ eventAttribute
109
637
  staticValue
110
638
  = [^"]+ {
111
639
  var val = text();
112
- return isNaN(val) ? `'${val}'` : val;
640
+ return `'${val}'`
113
641
  }
114
642
 
115
- content
643
+ content "component content"
116
644
  = elements:(element)* {
117
645
  const filteredElements = elements.filter(el => el !== null);
118
646
  if (filteredElements.length === 0) return null;
@@ -120,6 +648,8 @@ content
120
648
  return `[${filteredElements.join(', ')}]`;
121
649
  }
122
650
 
651
+
652
+
123
653
  textNode
124
654
  = text:$([^<]+) {
125
655
  const trimmed = text.trim();
@@ -132,20 +662,58 @@ textElement
132
662
  return trimmed ? JSON.stringify(trimmed) : null;
133
663
  }
134
664
 
135
- forLoop
136
- = _ "@for" _ "(" _ variableName:identifier _ "of" _ iterable:identifier _ ")" _ "{" _ content:content _ "}" _ {
137
- return `loop(${iterable}, (${variableName}) => ${content})`;
665
+ forLoop "for loop"
666
+ = _ "@for" _ "(" _ variableName:(tupleDestructuring / identifier) _ "of" _ iterable:iterable _ ")" _ "{" _ content:content _ "}" _ {
667
+ return `loop(${iterable}, ${variableName} => ${content})`;
138
668
  }
139
669
 
140
- ifCondition
141
- = _ "@if" _ "(" _ condition:condition _ ")" _ "{" _ content:content _ "}" _ {
142
- return `cond(${condition}, () => ${content})`;
670
+ tupleDestructuring "destructuring pattern"
671
+ = "(" _ first:identifier _ "," _ second:identifier _ ")" {
672
+ return `(${first}, ${second})`;
143
673
  }
144
674
 
145
- tagName
146
- = [a-zA-Z][a-zA-Z0-9]* { return text(); }
675
+ ifCondition "if condition"
676
+ = _ "@if" _ "(" _ condition:condition _ ")" _ "{" _ content:content _ "}" _ elseIfs:elseIfClause* elseClause:elseClause? _ {
677
+ let result = `cond(${condition}, () => ${content}`;
678
+
679
+ // Add else if clauses
680
+ elseIfs.forEach(elseIf => {
681
+ result += `, [${elseIf.condition}, () => ${elseIf.content}]`;
682
+ });
683
+
684
+ // Add else clause if present
685
+ if (elseClause) {
686
+ result += `, () => ${elseClause}`;
687
+ }
688
+
689
+ result += ')';
690
+ return result;
691
+ }
692
+
693
+ elseIfClause "else if clause"
694
+ = _ "@else" _ "if" _ "(" _ condition:condition _ ")" _ "{" _ content:content _ "}" _ {
695
+ return { condition, content };
696
+ }
697
+
698
+ elseClause "else clause"
699
+ = _ "@else" _ "{" _ content:content _ "}" _ {
700
+ return content;
701
+ }
702
+
703
+ tagName "tag name"
704
+ = tagExpression
705
+
706
+ tagExpression "tag expression"
707
+ = first:tagPart rest:("." tagPart)* {
708
+ return text();
709
+ }
710
+
711
+ tagPart "tag part"
712
+ = name:[a-zA-Z][a-zA-Z0-9]* args:("(" functionArgs? ")")? {
713
+ return text();
714
+ }
147
715
 
148
- attributeName
716
+ attributeName "attribute name"
149
717
  = [a-zA-Z][a-zA-Z0-9-]* { return text(); }
150
718
 
151
719
  eventName
@@ -154,11 +722,83 @@ eventName
154
722
  variableName
155
723
  = [a-zA-Z_][a-zA-Z0-9_]* { return text(); }
156
724
 
157
- iterable
158
- = [a-zA-Z_][a-zA-Z0-9_]* { return text(); }
725
+ iterable "iterable expression"
726
+ = id:identifier "(" _ args:functionArgs? _ ")" { // Direct function call
727
+ return `${id}(${args || ''})`;
728
+ }
729
+ / first:identifier "." rest:dotFunctionChain { // Dot notation possibly with function call
730
+ return `${first}.${rest}`;
731
+ }
732
+ / id:identifier { return id; }
159
733
 
160
- condition
161
- = $([^)]*) { return text().trim(); }
734
+ dotFunctionChain
735
+ = segment:identifier "(" _ args:functionArgs? _ ")" rest:("." dotFunctionChain)? {
736
+ const restStr = rest ? `.${rest[1]}` : '';
737
+ return `${segment}(${args || ''})${restStr}`;
738
+ }
739
+ / segment:identifier rest:("." dotFunctionChain)? {
740
+ const restStr = rest ? `.${rest[1]}` : '';
741
+ return `${segment}${restStr}`;
742
+ }
743
+
744
+ condition "condition expression"
745
+ = functionCall
746
+ / text_condition:$([^)]*) {
747
+ const originalText = text_condition.trim();
748
+
749
+ // Transform simple identifiers to function calls like "foo" to "foo()"
750
+ // This regex matches identifiers not followed by an opening parenthesis.
751
+ // This transformation should only apply if we are wrapping in 'computed'.
752
+ if (originalText.includes('!') || originalText.includes('&&') || originalText.includes('||') ||
753
+ originalText.includes('>=') || originalText.includes('<=') || originalText.includes('===') ||
754
+ originalText.includes('!==') || originalText.includes('==') || originalText.includes('!=') ||
755
+ originalText.includes('>') || originalText.includes('<')) {
756
+ const transformedText = originalText.replace(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b(?!\s*\()/g, (match, p1, offset) => {
757
+ // Do not transform keywords (true, false, null) or numeric literals
758
+ if (['true', 'false', 'null'].includes(match) || /^\d+(\.\d+)?$/.test(match)) {
759
+ return match;
760
+ }
761
+ // Check if the match is inside quotes
762
+ const beforeMatch = originalText.substring(0, offset);
763
+ const afterMatch = originalText.substring(offset + match.length);
764
+ const singleQuotesBefore = (beforeMatch.match(/'/g) || []).length;
765
+ const doubleQuotesBefore = (beforeMatch.match(/"/g) || []).length;
766
+
767
+ // If we're inside quotes, don't transform
768
+ if (singleQuotesBefore % 2 === 1 || doubleQuotesBefore % 2 === 1) {
769
+ return match;
770
+ }
771
+
772
+ return `${match}()`;
773
+ });
774
+ return `computed(() => ${transformedText})`;
775
+ }
776
+ // For simple conditions (no !, &&, ||), return the original text as is.
777
+ // Cases like `myFunction()` are handled by the `functionCall` rule.
778
+ return originalText;
779
+ }
780
+
781
+ functionCall "function call"
782
+ = name:identifier "(" args:functionArgs? ")" {
783
+ return `${name}(${args || ''})`;
784
+ }
785
+
786
+ functionArgs
787
+ = arg:functionArg rest:("," _ functionArg)* {
788
+ return [arg].concat(rest.map(r => r[2])).join(', ');
789
+ }
790
+
791
+ functionArg
792
+ = _ value:(identifier / number / string) _ {
793
+ return value;
794
+ }
795
+
796
+ number
797
+ = [0-9]+ ("." [0-9]+)? { return text(); }
798
+
799
+ string
800
+ = '"' chars:[^"]* '"' { return text(); }
801
+ / "'" chars:[^']* "'" { return text(); }
162
802
 
163
803
  eventAction
164
804
  = [^"]* { return text(); }
@@ -177,4 +817,45 @@ comment
177
817
  singleComment
178
818
  = "<!--" _ content:((!("-->") .)* "-->") _ {
179
819
  return null;
820
+ }
821
+
822
+ // Add a special error detection rule for unclosed tags
823
+ openUnclosedTag "unclosed tag"
824
+ = "<" _ tagName:tagName _ attributes:attributes _ ">" _ content:content _ !("</" _ closingTagName:tagName _ ">") {
825
+ generateError(
826
+ `Unclosed tag: <${tagName}> is missing its closing tag`,
827
+ location()
828
+ );
829
+ }
830
+
831
+ // Add error detection for unclosed quotes in static attributes
832
+ unclosedQuote "unclosed string"
833
+ = attributeName:attributeName _ "=" _ "\"" [^"]* !("\"") {
834
+ generateError(
835
+ `Missing closing quote in attribute '${attributeName}'`,
836
+ location()
837
+ );
838
+ }
839
+
840
+ // Add error detection for unclosed braces in dynamic attributes
841
+ unclosedBrace "unclosed brace"
842
+ = attributeName:attributeName _ "=" _ "{" !("}" / _ "}") [^{}]* {
843
+ generateError(
844
+ `Missing closing brace in dynamic attribute '${attributeName}'`,
845
+ location()
846
+ );
847
+ }
848
+
849
+ svgElement "SVG element"
850
+ = "<svg" attrs:([^>]*) ">" content:svgInnerContent "</svg>" _ {
851
+ const attributes = attrs.join('').trim();
852
+ // Clean up the content by removing extra whitespace and newlines
853
+ const cleanContent = content.replace(/\s+/g, ' ').trim();
854
+ const rawContent = `<svg${attributes ? ' ' + attributes : ''}>${cleanContent}</svg>`;
855
+ return `h(Svg, { content: \`${rawContent}\` })`;
856
+ }
857
+
858
+ svgInnerContent "SVG inner content"
859
+ = content:$((!("</svg>") .)*) {
860
+ return content;
180
861
  }