pulse-js-framework 1.10.0 → 1.10.3

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.
Files changed (37) hide show
  1. package/compiler/parser/_extract.js +393 -0
  2. package/compiler/parser/blocks.js +361 -0
  3. package/compiler/parser/core.js +306 -0
  4. package/compiler/parser/expressions.js +386 -0
  5. package/compiler/parser/imports.js +108 -0
  6. package/compiler/parser/index.js +47 -0
  7. package/compiler/parser/state.js +155 -0
  8. package/compiler/parser/style.js +445 -0
  9. package/compiler/parser/view.js +632 -0
  10. package/compiler/parser.js +15 -2372
  11. package/compiler/parser.js.original +2376 -0
  12. package/package.json +2 -1
  13. package/runtime/a11y/announcements.js +213 -0
  14. package/runtime/a11y/contrast.js +125 -0
  15. package/runtime/a11y/focus.js +412 -0
  16. package/runtime/a11y/index.js +35 -0
  17. package/runtime/a11y/preferences.js +121 -0
  18. package/runtime/a11y/utils.js +164 -0
  19. package/runtime/a11y/validation.js +258 -0
  20. package/runtime/a11y/widgets.js +545 -0
  21. package/runtime/a11y.js +15 -1840
  22. package/runtime/a11y.js.original +1844 -0
  23. package/runtime/graphql/cache.js +69 -0
  24. package/runtime/graphql/client.js +563 -0
  25. package/runtime/graphql/hooks.js +492 -0
  26. package/runtime/graphql/index.js +62 -0
  27. package/runtime/graphql/subscriptions.js +241 -0
  28. package/runtime/graphql.js +12 -1322
  29. package/runtime/graphql.js.original +1326 -0
  30. package/runtime/router/core.js +956 -0
  31. package/runtime/router/guards.js +90 -0
  32. package/runtime/router/history.js +204 -0
  33. package/runtime/router/index.js +36 -0
  34. package/runtime/router/lazy.js +180 -0
  35. package/runtime/router/utils.js +226 -0
  36. package/runtime/router.js +12 -1600
  37. package/runtime/router.js.original +1605 -0
@@ -0,0 +1,2376 @@
1
+ /**
2
+ * Pulse Parser - AST builder for .pulse files
3
+ *
4
+ * Converts tokens into an Abstract Syntax Tree
5
+ */
6
+
7
+ import { TokenType, tokenize } from './lexer.js';
8
+ import { ParserError, SUGGESTIONS, getDocsUrl } from '../runtime/errors.js';
9
+
10
+ // AST Node types
11
+ export const NodeType = {
12
+ Program: 'Program',
13
+ ImportDeclaration: 'ImportDeclaration',
14
+ ImportSpecifier: 'ImportSpecifier',
15
+ PageDeclaration: 'PageDeclaration',
16
+ RouteDeclaration: 'RouteDeclaration',
17
+ PropsBlock: 'PropsBlock',
18
+ StateBlock: 'StateBlock',
19
+ ViewBlock: 'ViewBlock',
20
+ ActionsBlock: 'ActionsBlock',
21
+ StyleBlock: 'StyleBlock',
22
+ SlotElement: 'SlotElement',
23
+ Element: 'Element',
24
+ TextNode: 'TextNode',
25
+ Interpolation: 'Interpolation',
26
+ Directive: 'Directive',
27
+ IfDirective: 'IfDirective',
28
+ EachDirective: 'EachDirective',
29
+ EventDirective: 'EventDirective',
30
+ ModelDirective: 'ModelDirective',
31
+
32
+ // Accessibility directives
33
+ A11yDirective: 'A11yDirective',
34
+ LiveDirective: 'LiveDirective',
35
+ FocusTrapDirective: 'FocusTrapDirective',
36
+
37
+ // SSR directives
38
+ ClientDirective: 'ClientDirective',
39
+ ServerDirective: 'ServerDirective',
40
+
41
+ Property: 'Property',
42
+ ObjectLiteral: 'ObjectLiteral',
43
+ ArrayLiteral: 'ArrayLiteral',
44
+ Identifier: 'Identifier',
45
+ MemberExpression: 'MemberExpression',
46
+ CallExpression: 'CallExpression',
47
+ BinaryExpression: 'BinaryExpression',
48
+ UnaryExpression: 'UnaryExpression',
49
+ UpdateExpression: 'UpdateExpression',
50
+ Literal: 'Literal',
51
+ TemplateLiteral: 'TemplateLiteral',
52
+ ConditionalExpression: 'ConditionalExpression',
53
+ ArrowFunction: 'ArrowFunction',
54
+ SpreadElement: 'SpreadElement',
55
+ AssignmentExpression: 'AssignmentExpression',
56
+ FunctionDeclaration: 'FunctionDeclaration',
57
+ StyleRule: 'StyleRule',
58
+ StyleProperty: 'StyleProperty',
59
+
60
+ // Router nodes
61
+ RouterBlock: 'RouterBlock',
62
+ RoutesBlock: 'RoutesBlock',
63
+ RouteDefinition: 'RouteDefinition',
64
+ GuardHook: 'GuardHook',
65
+
66
+ // Store nodes
67
+ StoreBlock: 'StoreBlock',
68
+ GettersBlock: 'GettersBlock',
69
+ GetterDeclaration: 'GetterDeclaration',
70
+
71
+ // View directives for router
72
+ LinkDirective: 'LinkDirective',
73
+ OutletDirective: 'OutletDirective',
74
+ NavigateDirective: 'NavigateDirective'
75
+ };
76
+
77
+ /**
78
+ * AST Node class
79
+ */
80
+ export class ASTNode {
81
+ constructor(type, props = {}) {
82
+ this.type = type;
83
+ Object.assign(this, props);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Parser class
89
+ */
90
+ export class Parser {
91
+ constructor(tokens) {
92
+ this.tokens = tokens;
93
+ this.pos = 0;
94
+ }
95
+
96
+ /**
97
+ * Get current token
98
+ */
99
+ current() {
100
+ return this.tokens[this.pos];
101
+ }
102
+
103
+ /**
104
+ * Peek at token at offset
105
+ */
106
+ peek(offset = 1) {
107
+ const index = this.pos + offset;
108
+ if (index < 0 || index >= this.tokens.length) return undefined;
109
+ return this.tokens[index];
110
+ }
111
+
112
+ /**
113
+ * Check if current token matches type
114
+ */
115
+ is(type) {
116
+ return this.current()?.type === type;
117
+ }
118
+
119
+ /**
120
+ * Check if current token matches any of types
121
+ */
122
+ isAny(...types) {
123
+ return types.includes(this.current()?.type);
124
+ }
125
+
126
+ /**
127
+ * Advance to next token and return current
128
+ */
129
+ advance() {
130
+ return this.tokens[this.pos++];
131
+ }
132
+
133
+ /**
134
+ * Expect a specific token type
135
+ */
136
+ expect(type, message = null) {
137
+ if (!this.is(type)) {
138
+ const token = this.current();
139
+ throw this.createError(
140
+ message || `Expected ${type} but got ${token?.type}`,
141
+ token,
142
+ { suggestion: SUGGESTIONS['unexpected-token']?.(type, token?.type) }
143
+ );
144
+ }
145
+ return this.advance();
146
+ }
147
+
148
+ /**
149
+ * Create a parse error with detailed information
150
+ * @param {string} message - Error message
151
+ * @param {Object} [token] - Token where error occurred
152
+ * @param {Object} [options] - Additional options (suggestion, code)
153
+ * @returns {ParserError} The parser error
154
+ */
155
+ createError(message, token = null, options = {}) {
156
+ const t = token || this.current();
157
+ const code = options.code || 'PARSER_ERROR';
158
+
159
+ // Build enhanced message with docs link
160
+ let enhancedMessage = message;
161
+ if (options.suggestion) {
162
+ enhancedMessage += `\n → ${options.suggestion}`;
163
+ }
164
+ enhancedMessage += `\n See: ${getDocsUrl(code)}`;
165
+
166
+ return new ParserError(enhancedMessage, {
167
+ line: t?.line || 1,
168
+ column: t?.column || 1,
169
+ token: t,
170
+ code,
171
+ ...options
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Parse the entire program
177
+ */
178
+ parse() {
179
+ const program = new ASTNode(NodeType.Program, {
180
+ imports: [],
181
+ page: null,
182
+ route: null,
183
+ props: null,
184
+ state: null,
185
+ view: null,
186
+ actions: null,
187
+ style: null,
188
+ router: null,
189
+ store: null
190
+ });
191
+
192
+ while (!this.is(TokenType.EOF)) {
193
+ // Import declarations (must come first)
194
+ if (this.is(TokenType.IMPORT)) {
195
+ program.imports.push(this.parseImportDeclaration());
196
+ }
197
+ // Page/Route declarations
198
+ else if (this.is(TokenType.AT)) {
199
+ this.advance();
200
+ if (this.is(TokenType.PAGE)) {
201
+ program.page = this.parsePageDeclaration();
202
+ } else if (this.is(TokenType.ROUTE)) {
203
+ program.route = this.parseRouteDeclaration();
204
+ } else {
205
+ throw this.createError(
206
+ `Expected 'page' or 'route' after '@', got '${this.current()?.value}'`
207
+ );
208
+ }
209
+ }
210
+ // Props block
211
+ else if (this.is(TokenType.PROPS)) {
212
+ if (program.props) {
213
+ throw this.createError('Duplicate props block - only one props block allowed per file', null, {
214
+ code: 'DUPLICATE_BLOCK',
215
+ suggestion: SUGGESTIONS['duplicate-declaration']?.('props')
216
+ });
217
+ }
218
+ program.props = this.parsePropsBlock();
219
+ }
220
+ // State block
221
+ else if (this.is(TokenType.STATE)) {
222
+ if (program.state) {
223
+ throw this.createError('Duplicate state block - only one state block allowed per file', null, {
224
+ code: 'DUPLICATE_BLOCK',
225
+ suggestion: SUGGESTIONS['duplicate-declaration']?.('state')
226
+ });
227
+ }
228
+ program.state = this.parseStateBlock();
229
+ }
230
+ // View block
231
+ else if (this.is(TokenType.VIEW)) {
232
+ if (program.view) {
233
+ throw this.createError('Duplicate view block - only one view block allowed per file', null, {
234
+ code: 'DUPLICATE_BLOCK',
235
+ suggestion: SUGGESTIONS['duplicate-declaration']?.('view')
236
+ });
237
+ }
238
+ program.view = this.parseViewBlock();
239
+ }
240
+ // Actions block
241
+ else if (this.is(TokenType.ACTIONS)) {
242
+ if (program.actions) {
243
+ throw this.createError('Duplicate actions block - only one actions block allowed per file', null, {
244
+ code: 'DUPLICATE_BLOCK',
245
+ suggestion: SUGGESTIONS['duplicate-declaration']?.('actions')
246
+ });
247
+ }
248
+ program.actions = this.parseActionsBlock();
249
+ }
250
+ // Style block
251
+ else if (this.is(TokenType.STYLE)) {
252
+ if (program.style) {
253
+ throw this.createError('Duplicate style block - only one style block allowed per file', null, {
254
+ code: 'DUPLICATE_BLOCK',
255
+ suggestion: SUGGESTIONS['duplicate-declaration']?.('style')
256
+ });
257
+ }
258
+ program.style = this.parseStyleBlock();
259
+ }
260
+ // Router block
261
+ else if (this.is(TokenType.ROUTER)) {
262
+ if (program.router) {
263
+ throw this.createError('Duplicate router block - only one router block allowed per file', null, {
264
+ code: 'DUPLICATE_BLOCK',
265
+ suggestion: SUGGESTIONS['duplicate-declaration']?.('router')
266
+ });
267
+ }
268
+ program.router = this.parseRouterBlock();
269
+ }
270
+ // Store block
271
+ else if (this.is(TokenType.STORE)) {
272
+ if (program.store) {
273
+ throw this.createError('Duplicate store block - only one store block allowed per file', null, {
274
+ code: 'DUPLICATE_BLOCK',
275
+ suggestion: SUGGESTIONS['duplicate-declaration']?.('store')
276
+ });
277
+ }
278
+ program.store = this.parseStoreBlock();
279
+ }
280
+ else {
281
+ const token = this.current();
282
+ throw this.createError(
283
+ `Unexpected token '${token?.value || token?.type}' at line ${token?.line}:${token?.column}. ` +
284
+ `Expected: import, @page, @route, props, state, view, actions, style, router, or store`
285
+ );
286
+ }
287
+ }
288
+
289
+ return program;
290
+ }
291
+
292
+ /**
293
+ * Parse import declaration
294
+ * Supports:
295
+ * import Component from './Component.pulse'
296
+ * import { helper, util } from './utils.pulse'
297
+ * import { helper as h } from './utils.pulse'
298
+ * import * as Utils from './utils.pulse'
299
+ */
300
+ parseImportDeclaration() {
301
+ const startToken = this.expect(TokenType.IMPORT);
302
+ const specifiers = [];
303
+ let source = null;
304
+
305
+ // import * as Name from '...'
306
+ if (this.is(TokenType.STAR)) {
307
+ this.advance();
308
+ this.expect(TokenType.AS);
309
+ const local = this.expect(TokenType.IDENT);
310
+ specifiers.push(new ASTNode(NodeType.ImportSpecifier, {
311
+ type: 'namespace',
312
+ local: local.value,
313
+ imported: '*'
314
+ }));
315
+ }
316
+ // import { a, b } from '...'
317
+ else if (this.is(TokenType.LBRACE)) {
318
+ this.advance();
319
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
320
+ const imported = this.expect(TokenType.IDENT);
321
+ let local = imported.value;
322
+
323
+ // Handle 'as' alias
324
+ if (this.is(TokenType.AS)) {
325
+ this.advance();
326
+ local = this.expect(TokenType.IDENT).value;
327
+ }
328
+
329
+ specifiers.push(new ASTNode(NodeType.ImportSpecifier, {
330
+ type: 'named',
331
+ local,
332
+ imported: imported.value
333
+ }));
334
+
335
+ if (this.is(TokenType.COMMA)) {
336
+ this.advance();
337
+ }
338
+ }
339
+ this.expect(TokenType.RBRACE);
340
+ }
341
+ // import DefaultExport from '...'
342
+ else if (this.is(TokenType.IDENT)) {
343
+ const name = this.advance();
344
+ specifiers.push(new ASTNode(NodeType.ImportSpecifier, {
345
+ type: 'default',
346
+ local: name.value,
347
+ imported: 'default'
348
+ }));
349
+ }
350
+
351
+ // from '...'
352
+ this.expect(TokenType.FROM);
353
+ const sourceToken = this.expect(TokenType.STRING);
354
+ source = sourceToken.value;
355
+
356
+ return new ASTNode(NodeType.ImportDeclaration, {
357
+ specifiers,
358
+ source,
359
+ line: startToken.line,
360
+ column: startToken.column
361
+ });
362
+ }
363
+
364
+ /**
365
+ * Parse @page declaration
366
+ */
367
+ parsePageDeclaration() {
368
+ this.expect(TokenType.PAGE);
369
+ const name = this.expect(TokenType.IDENT);
370
+ return new ASTNode(NodeType.PageDeclaration, { name: name.value });
371
+ }
372
+
373
+ /**
374
+ * Parse @route declaration
375
+ */
376
+ parseRouteDeclaration() {
377
+ this.expect(TokenType.ROUTE);
378
+ const path = this.expect(TokenType.STRING);
379
+ return new ASTNode(NodeType.RouteDeclaration, { path: path.value });
380
+ }
381
+
382
+ /**
383
+ * Parse props block
384
+ * props {
385
+ * label: "Default"
386
+ * disabled: false
387
+ * }
388
+ */
389
+ parsePropsBlock() {
390
+ this.expect(TokenType.PROPS);
391
+ this.expect(TokenType.LBRACE);
392
+
393
+ const properties = [];
394
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
395
+ properties.push(this.parsePropsProperty());
396
+ }
397
+
398
+ this.expect(TokenType.RBRACE);
399
+ return new ASTNode(NodeType.PropsBlock, { properties });
400
+ }
401
+
402
+ /**
403
+ * Parse a props property (name: defaultValue)
404
+ */
405
+ parsePropsProperty() {
406
+ const name = this.expect(TokenType.IDENT);
407
+ this.expect(TokenType.COLON);
408
+ const value = this.parseValue();
409
+ return new ASTNode(NodeType.Property, { name: name.value, value });
410
+ }
411
+
412
+ /**
413
+ * Parse state block
414
+ */
415
+ parseStateBlock() {
416
+ this.expect(TokenType.STATE);
417
+ this.expect(TokenType.LBRACE);
418
+
419
+ const properties = [];
420
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
421
+ properties.push(this.parseStateProperty());
422
+ }
423
+
424
+ this.expect(TokenType.RBRACE);
425
+ return new ASTNode(NodeType.StateBlock, { properties });
426
+ }
427
+
428
+ /**
429
+ * Parse a state property
430
+ */
431
+ parseStateProperty() {
432
+ const name = this.expect(TokenType.IDENT);
433
+ this.expect(TokenType.COLON);
434
+ const value = this.parseValue();
435
+ return new ASTNode(NodeType.Property, { name: name.value, value });
436
+ }
437
+
438
+ /**
439
+ * Try to parse a literal token (STRING, NUMBER, TRUE, FALSE, NULL)
440
+ * Returns the AST node or null if not a literal
441
+ */
442
+ tryParseLiteral() {
443
+ const token = this.current();
444
+ if (!token) return null;
445
+
446
+ const literalMap = {
447
+ [TokenType.STRING]: () => new ASTNode(NodeType.Literal, { value: this.advance().value, raw: token.raw }),
448
+ [TokenType.NUMBER]: () => new ASTNode(NodeType.Literal, { value: this.advance().value }),
449
+ [TokenType.TRUE]: () => (this.advance(), new ASTNode(NodeType.Literal, { value: true })),
450
+ [TokenType.FALSE]: () => (this.advance(), new ASTNode(NodeType.Literal, { value: false })),
451
+ [TokenType.NULL]: () => (this.advance(), new ASTNode(NodeType.Literal, { value: null }))
452
+ };
453
+
454
+ return literalMap[token.type]?.() || null;
455
+ }
456
+
457
+ /**
458
+ * Parse a value (literal, object, array, etc.)
459
+ */
460
+ parseValue() {
461
+ if (this.is(TokenType.LBRACE)) return this.parseObjectLiteral();
462
+ if (this.is(TokenType.LBRACKET)) return this.parseArrayLiteral();
463
+
464
+ const literal = this.tryParseLiteral();
465
+ if (literal) return literal;
466
+
467
+ if (this.is(TokenType.IDENT)) return this.parseIdentifierOrExpression();
468
+
469
+ throw this.createError(
470
+ `Unexpected token ${this.current()?.type} in value`
471
+ );
472
+ }
473
+
474
+ /**
475
+ * Parse object literal
476
+ */
477
+ parseObjectLiteral() {
478
+ this.expect(TokenType.LBRACE);
479
+ const properties = [];
480
+
481
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
482
+ const name = this.expect(TokenType.IDENT);
483
+ this.expect(TokenType.COLON);
484
+ const value = this.parseValue();
485
+ properties.push(new ASTNode(NodeType.Property, { name: name.value, value }));
486
+
487
+ if (this.is(TokenType.COMMA)) {
488
+ this.advance();
489
+ }
490
+ }
491
+
492
+ this.expect(TokenType.RBRACE);
493
+ return new ASTNode(NodeType.ObjectLiteral, { properties });
494
+ }
495
+
496
+ /**
497
+ * Parse array literal
498
+ */
499
+ parseArrayLiteral() {
500
+ this.expect(TokenType.LBRACKET);
501
+ const elements = [];
502
+
503
+ while (!this.is(TokenType.RBRACKET) && !this.is(TokenType.EOF)) {
504
+ elements.push(this.parseValue());
505
+
506
+ if (this.is(TokenType.COMMA)) {
507
+ this.advance();
508
+ }
509
+ }
510
+
511
+ this.expect(TokenType.RBRACKET);
512
+ return new ASTNode(NodeType.ArrayLiteral, { elements });
513
+ }
514
+
515
+ /**
516
+ * Parse view block
517
+ */
518
+ parseViewBlock() {
519
+ this.expect(TokenType.VIEW);
520
+ this.expect(TokenType.LBRACE);
521
+
522
+ const children = [];
523
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
524
+ children.push(this.parseViewChild());
525
+ }
526
+
527
+ this.expect(TokenType.RBRACE);
528
+ return new ASTNode(NodeType.ViewBlock, { children });
529
+ }
530
+
531
+ /**
532
+ * Parse a view child (element, directive, slot, or text)
533
+ */
534
+ parseViewChild() {
535
+ if (this.is(TokenType.AT)) {
536
+ return this.parseDirective();
537
+ }
538
+ // Slot element
539
+ if (this.is(TokenType.SLOT)) {
540
+ return this.parseSlotElement();
541
+ }
542
+ if (this.is(TokenType.SELECTOR) || this.is(TokenType.IDENT)) {
543
+ return this.parseElement();
544
+ }
545
+ if (this.is(TokenType.STRING)) {
546
+ return this.parseTextNode();
547
+ }
548
+
549
+ const token = this.current();
550
+ throw this.createError(
551
+ `Unexpected token '${token?.value || token?.type}' in view block. ` +
552
+ `Expected: element selector, @directive, slot, or "text"`
553
+ );
554
+ }
555
+
556
+ /**
557
+ * Parse slot element for component composition
558
+ * Supports:
559
+ * slot - default slot
560
+ * slot "name" - named slot
561
+ * slot { default content }
562
+ */
563
+ parseSlotElement() {
564
+ const startToken = this.expect(TokenType.SLOT);
565
+ let name = 'default';
566
+ const fallback = [];
567
+
568
+ // Named slot: slot "header"
569
+ if (this.is(TokenType.STRING)) {
570
+ name = this.advance().value;
571
+ }
572
+
573
+ // Fallback content: slot { ... }
574
+ if (this.is(TokenType.LBRACE)) {
575
+ this.advance();
576
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
577
+ fallback.push(this.parseViewChild());
578
+ }
579
+ this.expect(TokenType.RBRACE);
580
+ }
581
+
582
+ return new ASTNode(NodeType.SlotElement, {
583
+ name,
584
+ fallback,
585
+ line: startToken.line,
586
+ column: startToken.column
587
+ });
588
+ }
589
+
590
+ /**
591
+ * Parse an element
592
+ */
593
+ parseElement() {
594
+ const selector = this.isAny(TokenType.SELECTOR, TokenType.IDENT)
595
+ ? this.advance().value
596
+ : '';
597
+
598
+ const directives = [];
599
+ const textContent = [];
600
+ const children = [];
601
+ const props = []; // Props passed to component
602
+
603
+ // Check if this is a component with props: Component(prop=value, ...)
604
+ if (this.is(TokenType.LPAREN)) {
605
+ this.advance(); // consume (
606
+ while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
607
+ props.push(this.parseComponentProp());
608
+ if (this.is(TokenType.COMMA)) {
609
+ this.advance();
610
+ }
611
+ }
612
+ this.expect(TokenType.RPAREN);
613
+ }
614
+
615
+ // Parse inline directives and text
616
+ while (!this.is(TokenType.LBRACE) && !this.is(TokenType.RBRACE) &&
617
+ !this.is(TokenType.SELECTOR) && !this.is(TokenType.EOF)) {
618
+ if (this.is(TokenType.AT)) {
619
+ // Check if this is a block directive (@if, @for, @each) - if so, break
620
+ const nextToken = this.peek();
621
+ if (nextToken && (nextToken.type === TokenType.IF ||
622
+ nextToken.type === TokenType.FOR ||
623
+ nextToken.type === TokenType.EACH)) {
624
+ break;
625
+ }
626
+ directives.push(this.parseInlineDirective());
627
+ } else if (this.is(TokenType.STRING)) {
628
+ textContent.push(this.parseTextNode());
629
+ } else if (this.is(TokenType.IDENT) && !this.couldBeElement()) {
630
+ break;
631
+ } else {
632
+ break;
633
+ }
634
+ }
635
+
636
+ // Parse children if there's a block
637
+ if (this.is(TokenType.LBRACE)) {
638
+ this.advance();
639
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
640
+ children.push(this.parseViewChild());
641
+ }
642
+ this.expect(TokenType.RBRACE);
643
+ }
644
+
645
+ return new ASTNode(NodeType.Element, {
646
+ selector,
647
+ directives,
648
+ textContent,
649
+ children,
650
+ props
651
+ });
652
+ }
653
+
654
+ /**
655
+ * Parse a component prop: name=value or name={expression}
656
+ */
657
+ parseComponentProp() {
658
+ const name = this.expect(TokenType.IDENT);
659
+ this.expect(TokenType.EQ);
660
+
661
+ let value;
662
+ if (this.is(TokenType.LBRACE)) {
663
+ this.advance();
664
+ value = this.parseExpression();
665
+ this.expect(TokenType.RBRACE);
666
+ } else {
667
+ value = this.tryParseLiteral();
668
+ if (!value) {
669
+ if (this.is(TokenType.IDENT)) {
670
+ value = this.parseIdentifierOrExpression();
671
+ } else {
672
+ throw this.createError(`Unexpected token in prop value: ${this.current()?.type}`);
673
+ }
674
+ }
675
+ }
676
+
677
+ return new ASTNode(NodeType.Property, { name: name.value, value });
678
+ }
679
+
680
+ /**
681
+ * Check if current position could be an element
682
+ */
683
+ couldBeElement() {
684
+ const next = this.peek();
685
+ return next?.type === TokenType.LBRACE ||
686
+ next?.type === TokenType.AT ||
687
+ next?.type === TokenType.STRING;
688
+ }
689
+
690
+ /**
691
+ * Parse a text node
692
+ */
693
+ parseTextNode() {
694
+ const token = this.expect(TokenType.STRING);
695
+ const parts = this.parseInterpolatedString(token.value);
696
+ return new ASTNode(NodeType.TextNode, { parts });
697
+ }
698
+
699
+ /**
700
+ * Parse interpolated string into parts
701
+ * "Hello, {name}!" -> ["Hello, ", { expr: "name" }, "!"]
702
+ */
703
+ parseInterpolatedString(str) {
704
+ const parts = [];
705
+ let current = '';
706
+ let i = 0;
707
+
708
+ while (i < str.length) {
709
+ if (str[i] === '{') {
710
+ if (current) {
711
+ parts.push(current);
712
+ current = '';
713
+ }
714
+ i++; // skip {
715
+ let expr = '';
716
+ let braceCount = 1;
717
+ while (i < str.length && braceCount > 0) {
718
+ if (str[i] === '{') braceCount++;
719
+ else if (str[i] === '}') braceCount--;
720
+ if (braceCount > 0) expr += str[i];
721
+ i++;
722
+ }
723
+ parts.push(new ASTNode(NodeType.Interpolation, { expression: expr.trim() }));
724
+ } else {
725
+ current += str[i];
726
+ i++;
727
+ }
728
+ }
729
+
730
+ if (current) {
731
+ parts.push(current);
732
+ }
733
+
734
+ return parts;
735
+ }
736
+
737
+ /**
738
+ * Parse a directive (@if, @for, @each, @click, @link, @outlet, @navigate, etc.)
739
+ */
740
+ parseDirective() {
741
+ this.expect(TokenType.AT);
742
+
743
+ // Handle @if - IF is a keyword token, not IDENT
744
+ if (this.is(TokenType.IF)) {
745
+ this.advance();
746
+ return this.parseIfDirective();
747
+ }
748
+
749
+ // Handle @for - FOR is a keyword token, not IDENT
750
+ if (this.is(TokenType.FOR)) {
751
+ this.advance();
752
+ return this.parseEachDirective();
753
+ }
754
+
755
+ // Handle router directives
756
+ if (this.is(TokenType.LINK)) {
757
+ this.advance();
758
+ return this.parseLinkDirective();
759
+ }
760
+ if (this.is(TokenType.OUTLET)) {
761
+ this.advance();
762
+ return this.parseOutletDirective();
763
+ }
764
+ if (this.is(TokenType.NAVIGATE)) {
765
+ this.advance();
766
+ return this.parseNavigateDirective();
767
+ }
768
+ if (this.is(TokenType.BACK)) {
769
+ this.advance();
770
+ return new ASTNode(NodeType.NavigateDirective, { action: 'back' });
771
+ }
772
+ if (this.is(TokenType.FORWARD)) {
773
+ this.advance();
774
+ return new ASTNode(NodeType.NavigateDirective, { action: 'forward' });
775
+ }
776
+
777
+ const name = this.expect(TokenType.IDENT).value;
778
+
779
+ // Collect modifiers (.prevent, .stop, .enter, .lazy, etc.)
780
+ const modifiers = [];
781
+ while (this.is(TokenType.DIRECTIVE_MOD)) {
782
+ modifiers.push(this.advance().value);
783
+ }
784
+
785
+ if (name === 'if') {
786
+ return this.parseIfDirective();
787
+ }
788
+ if (name === 'each' || name === 'for') {
789
+ return this.parseEachDirective();
790
+ }
791
+
792
+ // Accessibility directives
793
+ if (name === 'a11y') {
794
+ return this.parseA11yDirective();
795
+ }
796
+ if (name === 'live') {
797
+ return this.parseLiveDirective();
798
+ }
799
+ if (name === 'focusTrap') {
800
+ return this.parseFocusTrapDirective();
801
+ }
802
+ if (name === 'srOnly') {
803
+ return this.parseSrOnlyDirective();
804
+ }
805
+
806
+ // SSR directives
807
+ if (name === 'client') {
808
+ return new ASTNode(NodeType.ClientDirective, {});
809
+ }
810
+ if (name === 'server') {
811
+ return new ASTNode(NodeType.ServerDirective, {});
812
+ }
813
+
814
+ // @model directive for two-way binding
815
+ if (name === 'model') {
816
+ return this.parseModelDirective(modifiers);
817
+ }
818
+
819
+ // Event directive like @click
820
+ return this.parseEventDirective(name, modifiers);
821
+ }
822
+
823
+ /**
824
+ * Parse inline directive
825
+ */
826
+ parseInlineDirective() {
827
+ this.expect(TokenType.AT);
828
+ const name = this.expect(TokenType.IDENT).value;
829
+
830
+ // Collect modifiers (.prevent, .stop, .enter, .lazy, etc.)
831
+ const modifiers = [];
832
+ while (this.is(TokenType.DIRECTIVE_MOD)) {
833
+ modifiers.push(this.advance().value);
834
+ }
835
+
836
+ // Check for a11y directives
837
+ if (name === 'a11y') {
838
+ return this.parseA11yDirective();
839
+ }
840
+ if (name === 'live') {
841
+ return this.parseLiveDirective();
842
+ }
843
+ if (name === 'focusTrap') {
844
+ return this.parseFocusTrapDirective();
845
+ }
846
+ if (name === 'srOnly') {
847
+ return this.parseSrOnlyDirective();
848
+ }
849
+
850
+ // SSR directives
851
+ if (name === 'client') {
852
+ return new ASTNode(NodeType.ClientDirective, {});
853
+ }
854
+ if (name === 'server') {
855
+ return new ASTNode(NodeType.ServerDirective, {});
856
+ }
857
+
858
+ // @model directive for two-way binding
859
+ if (name === 'model') {
860
+ return this.parseModelDirective(modifiers);
861
+ }
862
+
863
+ // Event directive (click, submit, etc.)
864
+ this.expect(TokenType.LPAREN);
865
+ const expression = this.parseExpression();
866
+ this.expect(TokenType.RPAREN);
867
+
868
+ return new ASTNode(NodeType.EventDirective, { event: name, handler: expression, modifiers });
869
+ }
870
+
871
+ /**
872
+ * Parse @if directive with @else-if/@else chains
873
+ * Syntax: @if (cond) { } @else-if (cond) { } @else { }
874
+ */
875
+ parseIfDirective() {
876
+ this.expect(TokenType.LPAREN);
877
+ const condition = this.parseExpression();
878
+ this.expect(TokenType.RPAREN);
879
+
880
+ this.expect(TokenType.LBRACE);
881
+ const consequent = [];
882
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
883
+ consequent.push(this.parseViewChild());
884
+ }
885
+ this.expect(TokenType.RBRACE);
886
+
887
+ const elseIfBranches = [];
888
+ let alternate = null;
889
+
890
+ // Parse @else-if and @else chains
891
+ while (this.is(TokenType.AT)) {
892
+ const nextToken = this.peek();
893
+
894
+ // Check for @else or @else-if
895
+ if (nextToken?.value === 'else') {
896
+ this.advance(); // @
897
+ this.advance(); // else
898
+
899
+ // Check if followed by @if or -if (making @else @if or @else-if)
900
+ if (this.is(TokenType.AT) && (this.peek()?.type === TokenType.IF || this.peek()?.value === 'if')) {
901
+ // @else @if pattern
902
+ this.advance(); // @
903
+ this.advance(); // if
904
+
905
+ this.expect(TokenType.LPAREN);
906
+ const elseIfCondition = this.parseExpression();
907
+ this.expect(TokenType.RPAREN);
908
+
909
+ this.expect(TokenType.LBRACE);
910
+ const elseIfConsequent = [];
911
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
912
+ elseIfConsequent.push(this.parseViewChild());
913
+ }
914
+ this.expect(TokenType.RBRACE);
915
+
916
+ elseIfBranches.push({ condition: elseIfCondition, consequent: elseIfConsequent });
917
+ }
918
+ // Check for -if pattern (@else-if as hyphenated)
919
+ else if (this.is(TokenType.MINUS) && (this.peek()?.type === TokenType.IF || this.peek()?.value === 'if')) {
920
+ this.advance(); // -
921
+ this.advance(); // if
922
+
923
+ this.expect(TokenType.LPAREN);
924
+ const elseIfCondition = this.parseExpression();
925
+ this.expect(TokenType.RPAREN);
926
+
927
+ this.expect(TokenType.LBRACE);
928
+ const elseIfConsequent = [];
929
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
930
+ elseIfConsequent.push(this.parseViewChild());
931
+ }
932
+ this.expect(TokenType.RBRACE);
933
+
934
+ elseIfBranches.push({ condition: elseIfCondition, consequent: elseIfConsequent });
935
+ }
936
+ // Plain @else
937
+ else {
938
+ this.expect(TokenType.LBRACE);
939
+ alternate = [];
940
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
941
+ alternate.push(this.parseViewChild());
942
+ }
943
+ this.expect(TokenType.RBRACE);
944
+ break; // @else terminates the chain
945
+ }
946
+ } else {
947
+ break; // Not an @else variant
948
+ }
949
+ }
950
+
951
+ return new ASTNode(NodeType.IfDirective, { condition, consequent, elseIfBranches, alternate });
952
+ }
953
+
954
+ /**
955
+ * Parse @each/@for directive with optional key function
956
+ * Syntax: @for (item of items) key(item.id) { ... }
957
+ */
958
+ parseEachDirective() {
959
+ this.expect(TokenType.LPAREN);
960
+ const itemName = this.expect(TokenType.IDENT).value;
961
+ // Accept both 'in' and 'of' keywords
962
+ if (this.is(TokenType.IN)) {
963
+ this.advance();
964
+ } else if (this.is(TokenType.OF)) {
965
+ this.advance();
966
+ } else {
967
+ throw this.createError('Expected "in" or "of" in loop directive');
968
+ }
969
+ const iterable = this.parseExpression();
970
+ this.expect(TokenType.RPAREN);
971
+
972
+ // Parse optional key function: key(item.id)
973
+ let keyExpr = null;
974
+ if (this.is(TokenType.IDENT) && this.current().value === 'key') {
975
+ this.advance(); // consume 'key'
976
+ this.expect(TokenType.LPAREN);
977
+ keyExpr = this.parseExpression();
978
+ this.expect(TokenType.RPAREN);
979
+ }
980
+
981
+ this.expect(TokenType.LBRACE);
982
+ const template = [];
983
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
984
+ template.push(this.parseViewChild());
985
+ }
986
+ this.expect(TokenType.RBRACE);
987
+
988
+ return new ASTNode(NodeType.EachDirective, { itemName, iterable, template, keyExpr });
989
+ }
990
+
991
+ /**
992
+ * Parse event directive with optional modifiers
993
+ * @param {string} event - Event name (click, keydown, etc.)
994
+ * @param {string[]} modifiers - Array of modifier names (prevent, stop, enter, etc.)
995
+ */
996
+ parseEventDirective(event, modifiers = []) {
997
+ this.expect(TokenType.LPAREN);
998
+ const handler = this.parseExpression();
999
+ this.expect(TokenType.RPAREN);
1000
+
1001
+ const children = [];
1002
+ if (this.is(TokenType.LBRACE)) {
1003
+ this.advance();
1004
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
1005
+ children.push(this.parseViewChild());
1006
+ }
1007
+ this.expect(TokenType.RBRACE);
1008
+ }
1009
+
1010
+ return new ASTNode(NodeType.EventDirective, { event, handler, children, modifiers });
1011
+ }
1012
+
1013
+ /**
1014
+ * Parse @model directive for two-way binding
1015
+ * @model(name) or @model.lazy(name) or @model.lazy.trim(name)
1016
+ * @param {string[]} modifiers - Array of modifier names (lazy, trim, number)
1017
+ */
1018
+ parseModelDirective(modifiers = []) {
1019
+ this.expect(TokenType.LPAREN);
1020
+ const binding = this.parseExpression();
1021
+ this.expect(TokenType.RPAREN);
1022
+
1023
+ return new ASTNode(NodeType.ModelDirective, { binding, modifiers });
1024
+ }
1025
+
1026
+ /**
1027
+ * Parse @a11y directive - sets aria attributes
1028
+ * @a11y(label="Close menu") or @a11y(label="Close", describedby="desc")
1029
+ */
1030
+ parseA11yDirective() {
1031
+ this.expect(TokenType.LPAREN);
1032
+
1033
+ const attrs = {};
1034
+
1035
+ // Parse key=value pairs
1036
+ while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
1037
+ const key = this.expect(TokenType.IDENT).value;
1038
+ this.expect(TokenType.EQ);
1039
+
1040
+ let value;
1041
+ if (this.is(TokenType.STRING)) {
1042
+ value = this.advance().value;
1043
+ } else if (this.is(TokenType.TRUE)) {
1044
+ value = true;
1045
+ this.advance();
1046
+ } else if (this.is(TokenType.FALSE)) {
1047
+ value = false;
1048
+ this.advance();
1049
+ } else if (this.is(TokenType.IDENT)) {
1050
+ // Treat unquoted identifier as a string (e.g., role=dialog -> "dialog")
1051
+ value = this.advance().value;
1052
+ } else {
1053
+ value = this.parseExpression();
1054
+ }
1055
+
1056
+ attrs[key] = value;
1057
+
1058
+ if (this.is(TokenType.COMMA)) {
1059
+ this.advance();
1060
+ }
1061
+ }
1062
+
1063
+ this.expect(TokenType.RPAREN);
1064
+
1065
+ return new ASTNode(NodeType.A11yDirective, { attrs });
1066
+ }
1067
+
1068
+ /**
1069
+ * Parse @live directive - creates live region for screen readers
1070
+ * @live(polite) or @live(assertive)
1071
+ */
1072
+ parseLiveDirective() {
1073
+ this.expect(TokenType.LPAREN);
1074
+
1075
+ let priority = 'polite';
1076
+ if (this.is(TokenType.IDENT)) {
1077
+ priority = this.advance().value;
1078
+ }
1079
+
1080
+ this.expect(TokenType.RPAREN);
1081
+
1082
+ return new ASTNode(NodeType.LiveDirective, { priority });
1083
+ }
1084
+
1085
+ /**
1086
+ * Parse @focusTrap directive - traps focus within element
1087
+ * @focusTrap or @focusTrap(autoFocus=true)
1088
+ */
1089
+ parseFocusTrapDirective() {
1090
+ const options = {};
1091
+
1092
+ if (this.is(TokenType.LPAREN)) {
1093
+ this.advance();
1094
+
1095
+ while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
1096
+ const key = this.expect(TokenType.IDENT).value;
1097
+
1098
+ if (this.is(TokenType.EQ)) {
1099
+ this.advance();
1100
+ if (this.is(TokenType.TRUE)) {
1101
+ options[key] = true;
1102
+ this.advance();
1103
+ } else if (this.is(TokenType.FALSE)) {
1104
+ options[key] = false;
1105
+ this.advance();
1106
+ } else if (this.is(TokenType.STRING)) {
1107
+ options[key] = this.advance().value;
1108
+ } else {
1109
+ options[key] = this.parseExpression();
1110
+ }
1111
+ } else {
1112
+ options[key] = true;
1113
+ }
1114
+
1115
+ if (this.is(TokenType.COMMA)) {
1116
+ this.advance();
1117
+ }
1118
+ }
1119
+
1120
+ this.expect(TokenType.RPAREN);
1121
+ }
1122
+
1123
+ return new ASTNode(NodeType.FocusTrapDirective, { options });
1124
+ }
1125
+
1126
+ /**
1127
+ * Parse @srOnly directive - visually hidden but accessible text
1128
+ */
1129
+ parseSrOnlyDirective() {
1130
+ return new ASTNode(NodeType.A11yDirective, {
1131
+ attrs: { srOnly: true }
1132
+ });
1133
+ }
1134
+
1135
+ /**
1136
+ * Parse expression
1137
+ */
1138
+ parseExpression() {
1139
+ return this.parseAssignmentExpression();
1140
+ }
1141
+
1142
+ /**
1143
+ * Parse assignment expression (a = b, a += b, a -= b, etc.)
1144
+ */
1145
+ parseAssignmentExpression() {
1146
+ const left = this.parseConditionalExpression();
1147
+
1148
+ // Check for assignment operators
1149
+ const assignmentOps = [
1150
+ TokenType.EQ, // =
1151
+ TokenType.PLUS_ASSIGN, // +=
1152
+ TokenType.MINUS_ASSIGN, // -=
1153
+ TokenType.STAR_ASSIGN, // *=
1154
+ TokenType.SLASH_ASSIGN, // /=
1155
+ TokenType.AND_ASSIGN, // &&=
1156
+ TokenType.OR_ASSIGN, // ||=
1157
+ TokenType.NULLISH_ASSIGN // ??=
1158
+ ];
1159
+
1160
+ if (this.isAny(...assignmentOps)) {
1161
+ const operator = this.advance().value;
1162
+ const right = this.parseAssignmentExpression();
1163
+ return new ASTNode(NodeType.AssignmentExpression, {
1164
+ left,
1165
+ right,
1166
+ operator
1167
+ });
1168
+ }
1169
+
1170
+ return left;
1171
+ }
1172
+
1173
+ /**
1174
+ * Parse conditional (ternary) expression
1175
+ */
1176
+ parseConditionalExpression() {
1177
+ const test = this.parseOrExpression();
1178
+
1179
+ if (this.is(TokenType.QUESTION)) {
1180
+ this.advance();
1181
+ const consequent = this.parseAssignmentExpression();
1182
+ this.expect(TokenType.COLON);
1183
+ const alternate = this.parseAssignmentExpression();
1184
+ return new ASTNode(NodeType.ConditionalExpression, { test, consequent, alternate });
1185
+ }
1186
+
1187
+ return test;
1188
+ }
1189
+
1190
+ /**
1191
+ * Binary operator precedence table (higher = binds tighter)
1192
+ */
1193
+ static BINARY_OPS = [
1194
+ { ops: [TokenType.OR], name: 'or' },
1195
+ { ops: [TokenType.AND], name: 'and' },
1196
+ { ops: [TokenType.EQEQ, TokenType.EQEQEQ, TokenType.NEQ, TokenType.NEQEQ,
1197
+ TokenType.LT, TokenType.GT, TokenType.LTE, TokenType.GTE], name: 'comparison' },
1198
+ { ops: [TokenType.PLUS, TokenType.MINUS], name: 'additive' },
1199
+ { ops: [TokenType.STAR, TokenType.SLASH, TokenType.PERCENT], name: 'multiplicative' }
1200
+ ];
1201
+
1202
+ /**
1203
+ * Generic binary expression parser using precedence climbing
1204
+ */
1205
+ parseBinaryExpr(level = 0) {
1206
+ if (level >= Parser.BINARY_OPS.length) {
1207
+ return this.parseUnaryExpression();
1208
+ }
1209
+
1210
+ let left = this.parseBinaryExpr(level + 1);
1211
+ const { ops } = Parser.BINARY_OPS[level];
1212
+
1213
+ while (this.isAny(...ops)) {
1214
+ const operator = this.advance().value;
1215
+ const right = this.parseBinaryExpr(level + 1);
1216
+ left = new ASTNode(NodeType.BinaryExpression, { operator, left, right });
1217
+ }
1218
+
1219
+ return left;
1220
+ }
1221
+
1222
+ /** Parse OR expression (entry point for binary expressions) */
1223
+ parseOrExpression() { return this.parseBinaryExpr(0); }
1224
+
1225
+ /**
1226
+ * Parse unary expression
1227
+ */
1228
+ parseUnaryExpression() {
1229
+ if (this.is(TokenType.NOT)) {
1230
+ this.advance();
1231
+ const argument = this.parseUnaryExpression();
1232
+ return new ASTNode(NodeType.UnaryExpression, { operator: '!', argument });
1233
+ }
1234
+ if (this.is(TokenType.MINUS)) {
1235
+ this.advance();
1236
+ const argument = this.parseUnaryExpression();
1237
+ return new ASTNode(NodeType.UnaryExpression, { operator: '-', argument });
1238
+ }
1239
+
1240
+ return this.parsePostfixExpression();
1241
+ }
1242
+
1243
+ /**
1244
+ * Parse postfix expression (++, --)
1245
+ */
1246
+ parsePostfixExpression() {
1247
+ let expr = this.parsePrimaryExpression();
1248
+
1249
+ while (this.isAny(TokenType.PLUSPLUS, TokenType.MINUSMINUS)) {
1250
+ const operator = this.advance().value;
1251
+ expr = new ASTNode(NodeType.UpdateExpression, {
1252
+ operator,
1253
+ argument: expr,
1254
+ prefix: false
1255
+ });
1256
+ }
1257
+
1258
+ return expr;
1259
+ }
1260
+
1261
+ /**
1262
+ * Parse primary expression
1263
+ */
1264
+ parsePrimaryExpression() {
1265
+ // Check for arrow function: (params) => expr or () => expr
1266
+ if (this.is(TokenType.LPAREN)) {
1267
+ // Try to parse as arrow function by looking ahead
1268
+ const savedPos = this.pos;
1269
+ if (this.tryParseArrowFunction()) {
1270
+ this.pos = savedPos;
1271
+ return this.parseArrowFunction();
1272
+ }
1273
+ // Not an arrow function, parse as grouped expression
1274
+ this.advance();
1275
+ const expr = this.parseExpression();
1276
+ this.expect(TokenType.RPAREN);
1277
+ // Check if this grouped expression is actually arrow function params
1278
+ if (this.is(TokenType.ARROW)) {
1279
+ this.pos = savedPos;
1280
+ return this.parseArrowFunction();
1281
+ }
1282
+ return expr;
1283
+ }
1284
+
1285
+ // Single param arrow function: x => expr
1286
+ if ((this.is(TokenType.IDENT) || this.is(TokenType.SELECTOR)) && this.peek()?.type === TokenType.ARROW) {
1287
+ return this.parseArrowFunction();
1288
+ }
1289
+
1290
+ // Array literal
1291
+ if (this.is(TokenType.LBRACKET)) {
1292
+ return this.parseArrayLiteralExpr();
1293
+ }
1294
+
1295
+ // Object literal in expression context
1296
+ if (this.is(TokenType.LBRACE)) {
1297
+ return this.parseObjectLiteralExpr();
1298
+ }
1299
+
1300
+ // Template literal
1301
+ if (this.is(TokenType.TEMPLATE)) {
1302
+ const token = this.advance();
1303
+ return new ASTNode(NodeType.TemplateLiteral, { value: token.value, raw: token.raw });
1304
+ }
1305
+
1306
+ // Spread operator
1307
+ if (this.is(TokenType.SPREAD)) {
1308
+ this.advance();
1309
+ const argument = this.parseAssignmentExpression();
1310
+ return new ASTNode(NodeType.SpreadElement, { argument });
1311
+ }
1312
+
1313
+ // Try parsing a literal (NUMBER, STRING, TRUE, FALSE, NULL)
1314
+ const literal = this.tryParseLiteral();
1315
+ if (literal) return literal;
1316
+
1317
+ // In expressions, SELECTOR tokens should be treated as IDENT
1318
+ // This happens when identifiers like 'selectedCategory' are followed by space in view context
1319
+ if (this.is(TokenType.IDENT) || this.is(TokenType.SELECTOR)) {
1320
+ return this.parseIdentifierOrExpression();
1321
+ }
1322
+
1323
+ throw this.createError(
1324
+ `Unexpected token ${this.current()?.type} in expression`
1325
+ );
1326
+ }
1327
+
1328
+ /**
1329
+ * Try to determine if we're looking at an arrow function
1330
+ */
1331
+ tryParseArrowFunction() {
1332
+ if (!this.is(TokenType.LPAREN)) return false;
1333
+
1334
+ let depth = 0;
1335
+ let i = 0;
1336
+
1337
+ while (this.peek(i)) {
1338
+ const token = this.peek(i);
1339
+ if (token.type === TokenType.LPAREN) depth++;
1340
+ else if (token.type === TokenType.RPAREN) {
1341
+ depth--;
1342
+ if (depth === 0) {
1343
+ // Check if next token is =>
1344
+ const next = this.peek(i + 1);
1345
+ return next?.type === TokenType.ARROW;
1346
+ }
1347
+ }
1348
+ i++;
1349
+ }
1350
+ return false;
1351
+ }
1352
+
1353
+ /**
1354
+ * Parse arrow function: (params) => expr or param => expr
1355
+ */
1356
+ parseArrowFunction() {
1357
+ const params = [];
1358
+
1359
+ // Single param without parens: x => expr
1360
+ if ((this.is(TokenType.IDENT) || this.is(TokenType.SELECTOR)) && this.peek()?.type === TokenType.ARROW) {
1361
+ params.push(this.advance().value);
1362
+ } else {
1363
+ // Params in parens: (a, b) => expr or () => expr
1364
+ this.expect(TokenType.LPAREN);
1365
+ while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
1366
+ if (this.is(TokenType.SPREAD)) {
1367
+ this.advance();
1368
+ params.push('...' + this.expect(TokenType.IDENT).value);
1369
+ } else {
1370
+ params.push(this.expect(TokenType.IDENT).value);
1371
+ }
1372
+ if (this.is(TokenType.COMMA)) {
1373
+ this.advance();
1374
+ }
1375
+ }
1376
+ this.expect(TokenType.RPAREN);
1377
+ }
1378
+
1379
+ this.expect(TokenType.ARROW);
1380
+
1381
+ // Body can be expression or block
1382
+ let body;
1383
+ if (this.is(TokenType.LBRACE)) {
1384
+ // Block body - collect tokens
1385
+ this.advance();
1386
+ body = this.parseFunctionBody();
1387
+ this.expect(TokenType.RBRACE);
1388
+ return new ASTNode(NodeType.ArrowFunction, { params, body, block: true });
1389
+ } else {
1390
+ // Expression body
1391
+ body = this.parseAssignmentExpression();
1392
+ return new ASTNode(NodeType.ArrowFunction, { params, body, block: false });
1393
+ }
1394
+ }
1395
+
1396
+ /**
1397
+ * Parse array literal in expression context
1398
+ */
1399
+ parseArrayLiteralExpr() {
1400
+ this.expect(TokenType.LBRACKET);
1401
+ const elements = [];
1402
+
1403
+ while (!this.is(TokenType.RBRACKET) && !this.is(TokenType.EOF)) {
1404
+ if (this.is(TokenType.SPREAD)) {
1405
+ this.advance();
1406
+ elements.push(new ASTNode(NodeType.SpreadElement, {
1407
+ argument: this.parseAssignmentExpression()
1408
+ }));
1409
+ } else {
1410
+ elements.push(this.parseAssignmentExpression());
1411
+ }
1412
+ if (this.is(TokenType.COMMA)) {
1413
+ this.advance();
1414
+ }
1415
+ }
1416
+
1417
+ this.expect(TokenType.RBRACKET);
1418
+ return new ASTNode(NodeType.ArrayLiteral, { elements });
1419
+ }
1420
+
1421
+ /**
1422
+ * Parse object literal in expression context
1423
+ */
1424
+ parseObjectLiteralExpr() {
1425
+ this.expect(TokenType.LBRACE);
1426
+ const properties = [];
1427
+
1428
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
1429
+ if (this.is(TokenType.SPREAD)) {
1430
+ this.advance();
1431
+ properties.push(new ASTNode(NodeType.SpreadElement, {
1432
+ argument: this.parseAssignmentExpression()
1433
+ }));
1434
+ } else {
1435
+ const key = this.expect(TokenType.IDENT);
1436
+ if (this.is(TokenType.COLON)) {
1437
+ this.advance();
1438
+ const value = this.parseAssignmentExpression();
1439
+ properties.push(new ASTNode(NodeType.Property, { name: key.value, value }));
1440
+ } else {
1441
+ // Shorthand property: { x } is same as { x: x }
1442
+ properties.push(new ASTNode(NodeType.Property, {
1443
+ name: key.value,
1444
+ value: new ASTNode(NodeType.Identifier, { name: key.value }),
1445
+ shorthand: true
1446
+ }));
1447
+ }
1448
+ }
1449
+ if (this.is(TokenType.COMMA)) {
1450
+ this.advance();
1451
+ }
1452
+ }
1453
+
1454
+ this.expect(TokenType.RBRACE);
1455
+ return new ASTNode(NodeType.ObjectLiteral, { properties });
1456
+ }
1457
+
1458
+ /**
1459
+ * Parse identifier with possible member access and calls
1460
+ */
1461
+ parseIdentifierOrExpression() {
1462
+ // Accept both IDENT and SELECTOR (selector tokens can be identifiers in expression context)
1463
+ const token = this.advance();
1464
+ let expr = new ASTNode(NodeType.Identifier, { name: token.value });
1465
+
1466
+ while (true) {
1467
+ if (this.is(TokenType.DOT)) {
1468
+ this.advance();
1469
+ const property = this.expect(TokenType.IDENT);
1470
+ expr = new ASTNode(NodeType.MemberExpression, {
1471
+ object: expr,
1472
+ property: property.value
1473
+ });
1474
+ } else if (this.is(TokenType.LBRACKET)) {
1475
+ this.advance();
1476
+ const property = this.parseExpression();
1477
+ this.expect(TokenType.RBRACKET);
1478
+ expr = new ASTNode(NodeType.MemberExpression, {
1479
+ object: expr,
1480
+ property,
1481
+ computed: true
1482
+ });
1483
+ } else if (this.is(TokenType.LPAREN)) {
1484
+ this.advance();
1485
+ const args = [];
1486
+ while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
1487
+ args.push(this.parseExpression());
1488
+ if (this.is(TokenType.COMMA)) {
1489
+ this.advance();
1490
+ }
1491
+ }
1492
+ this.expect(TokenType.RPAREN);
1493
+ expr = new ASTNode(NodeType.CallExpression, { callee: expr, arguments: args });
1494
+ } else {
1495
+ break;
1496
+ }
1497
+ }
1498
+
1499
+ return expr;
1500
+ }
1501
+
1502
+ /**
1503
+ * Parse actions block
1504
+ */
1505
+ parseActionsBlock() {
1506
+ this.expect(TokenType.ACTIONS);
1507
+ this.expect(TokenType.LBRACE);
1508
+
1509
+ const functions = [];
1510
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
1511
+ functions.push(this.parseFunctionDeclaration());
1512
+ }
1513
+
1514
+ this.expect(TokenType.RBRACE);
1515
+ return new ASTNode(NodeType.ActionsBlock, { functions });
1516
+ }
1517
+
1518
+ /**
1519
+ * Parse function declaration
1520
+ */
1521
+ parseFunctionDeclaration() {
1522
+ let async = false;
1523
+ if (this.is(TokenType.IDENT) && this.current().value === 'async') {
1524
+ this.advance();
1525
+ async = true;
1526
+ }
1527
+
1528
+ const name = this.expect(TokenType.IDENT).value;
1529
+ this.expect(TokenType.LPAREN);
1530
+
1531
+ const params = [];
1532
+ while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
1533
+ // Accept IDENT or keyword tokens that can be used as parameter names
1534
+ const paramToken = this.current();
1535
+ if (this.is(TokenType.IDENT) || this.is(TokenType.PAGE) ||
1536
+ this.is(TokenType.ROUTE) || this.is(TokenType.FROM) ||
1537
+ this.is(TokenType.STATE) || this.is(TokenType.VIEW) ||
1538
+ this.is(TokenType.STORE) || this.is(TokenType.ROUTER)) {
1539
+ params.push(this.advance().value);
1540
+ } else {
1541
+ throw this.createError(`Expected parameter name but got ${paramToken?.type}`);
1542
+ }
1543
+ if (this.is(TokenType.COMMA)) {
1544
+ this.advance();
1545
+ }
1546
+ }
1547
+ this.expect(TokenType.RPAREN);
1548
+
1549
+ // Parse function body as raw JS
1550
+ this.expect(TokenType.LBRACE);
1551
+ const body = this.parseFunctionBody();
1552
+ this.expect(TokenType.RBRACE);
1553
+
1554
+ return new ASTNode(NodeType.FunctionDeclaration, { name, params, body, async });
1555
+ }
1556
+
1557
+ /**
1558
+ * Parse function body (raw content between braces)
1559
+ */
1560
+ parseFunctionBody() {
1561
+ // Simplified: collect all tokens until matching }
1562
+ const statements = [];
1563
+ let braceCount = 1;
1564
+
1565
+ while (!this.is(TokenType.EOF)) {
1566
+ if (this.is(TokenType.LBRACE)) {
1567
+ braceCount++;
1568
+ } else if (this.is(TokenType.RBRACE)) {
1569
+ braceCount--;
1570
+ if (braceCount === 0) break;
1571
+ }
1572
+
1573
+ // Collect raw token for reconstruction
1574
+ statements.push(this.current());
1575
+ this.advance();
1576
+ }
1577
+
1578
+ return statements;
1579
+ }
1580
+
1581
+ /**
1582
+ * Parse style block
1583
+ */
1584
+ parseStyleBlock() {
1585
+ this.expect(TokenType.STYLE);
1586
+ const startBrace = this.expect(TokenType.LBRACE);
1587
+
1588
+ // Extract raw CSS content for preprocessor support
1589
+ // Instead of parsing token by token, collect all tokens until matching }
1590
+ const rawTokens = [];
1591
+ let braceDepth = 1; // We've already consumed the opening {
1592
+ const startPos = this.pos;
1593
+
1594
+ while (braceDepth > 0 && !this.is(TokenType.EOF)) {
1595
+ const token = this.current();
1596
+ if (token.type === TokenType.LBRACE) braceDepth++;
1597
+ if (token.type === TokenType.RBRACE) braceDepth--;
1598
+
1599
+ if (braceDepth > 0) {
1600
+ rawTokens.push(token);
1601
+ this.advance();
1602
+ }
1603
+ }
1604
+
1605
+ this.expect(TokenType.RBRACE);
1606
+
1607
+ // Reconstruct raw CSS from tokens for preprocessor
1608
+ const rawCSS = this.reconstructCSS(rawTokens);
1609
+
1610
+ // Try to parse as structured CSS (will work for plain CSS)
1611
+ // If parsing fails, fall back to raw mode for preprocessors
1612
+ let rules = [];
1613
+ let parseError = null;
1614
+
1615
+ // Reset to try parsing
1616
+ const savedPos = this.pos;
1617
+ this.pos = startPos;
1618
+
1619
+ try {
1620
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
1621
+ rules.push(this.parseStyleRule());
1622
+ }
1623
+ } catch (error) {
1624
+ // Parsing failed - likely preprocessor syntax (LESS/SASS/Stylus)
1625
+ parseError = error;
1626
+ rules = []; // Clear any partial parse
1627
+ }
1628
+
1629
+ // Restore position to after the closing }
1630
+ this.pos = savedPos;
1631
+
1632
+ return new ASTNode(NodeType.StyleBlock, {
1633
+ rules,
1634
+ raw: rawCSS,
1635
+ parseError: parseError ? parseError.message : null
1636
+ });
1637
+ }
1638
+
1639
+ /**
1640
+ * Reconstruct CSS from tokens, preserving formatting
1641
+ */
1642
+ reconstructCSS(tokens) {
1643
+ if (!tokens.length) return '';
1644
+
1645
+ const lines = [];
1646
+ let currentLine = [];
1647
+ let lastLine = tokens[0].line;
1648
+
1649
+ for (const token of tokens) {
1650
+ if (token.line !== lastLine) {
1651
+ lines.push(currentLine.join(''));
1652
+ currentLine = [];
1653
+ lastLine = token.line;
1654
+ }
1655
+ currentLine.push(token.raw || token.value);
1656
+ }
1657
+
1658
+ if (currentLine.length > 0) {
1659
+ lines.push(currentLine.join(''));
1660
+ }
1661
+
1662
+ return lines.join('\n').trim();
1663
+ }
1664
+
1665
+ /**
1666
+ * Parse style rule
1667
+ */
1668
+ parseStyleRule() {
1669
+ // Parse selector - preserve spaces between tokens
1670
+ const selectorParts = [];
1671
+ let lastLine = this.current()?.line;
1672
+ let lastToken = null;
1673
+ let inAtRule = false; // Track if we're inside an @-rule like @media
1674
+ let inParens = 0; // Track parenthesis depth
1675
+
1676
+ while (!this.is(TokenType.LBRACE) && !this.is(TokenType.EOF)) {
1677
+ const token = this.advance();
1678
+ const currentLine = token.line;
1679
+ const tokenValue = String(token.value);
1680
+
1681
+ // Track @-rules (media queries, keyframes, etc.)
1682
+ if (tokenValue === '@') {
1683
+ inAtRule = true;
1684
+ }
1685
+
1686
+ // Track parenthesis depth for media queries
1687
+ if (tokenValue === '(') inParens++;
1688
+ if (tokenValue === ')') inParens--;
1689
+
1690
+ // Determine if we need a space before this token
1691
+ if (selectorParts.length > 0 && currentLine === lastLine) {
1692
+ const lastPart = selectorParts[selectorParts.length - 1];
1693
+
1694
+ // Don't add space after these (they attach to what follows)
1695
+ const noSpaceAfter = new Set(['.', '#', '[', '(', '>', '+', '~', '-', '@', ':']);
1696
+
1697
+ // Don't add space before these (they attach to what precedes)
1698
+ // In @media queries inside parens: "max-width:" should not have space before ":"
1699
+ const noSpaceBefore = new Set([']', ')', ',', '.', '#', '-', ':']);
1700
+
1701
+ // CSS units that should attach to numbers (no space before)
1702
+ const cssUnits = new Set(['px', 'em', 'rem', 'vh', 'vw', 'vmin', 'vmax', '%', 'fr', 's', 'ms', 'deg', 'rad', 'turn', 'grad', 'ex', 'ch', 'pt', 'pc', 'in', 'cm', 'mm', 'dvh', 'dvw', 'svh', 'svw', 'lvh', 'lvw']);
1703
+
1704
+ // Special case: . or # after an identifier needs space (descendant selector)
1705
+ // e.g., ".school .date" - need space between "school" and "."
1706
+ // BUT NOT for "body.dark" where . is directly adjacent to body (no whitespace)
1707
+ // We check if tokens are adjacent by comparing positions
1708
+ const expectedNextCol = lastToken ? (lastToken.column + String(lastToken.value).length) : 0;
1709
+ const tokensAreAdjacent = token.column === expectedNextCol;
1710
+ const isDescendantSelector = (tokenValue === '.' || tokenValue === '#') &&
1711
+ lastToken?.type === TokenType.IDENT &&
1712
+ !inAtRule && // Don't add space in @media selectors
1713
+ !tokensAreAdjacent; // Only add space if not directly adjacent
1714
+
1715
+ // Special case: hyphenated class/id names like .job-title, .card-3d, max-width
1716
+ // Check if we're continuing a class/id name - the last part should end with alphanumeric
1717
+ // that was started by . or # (no space in between)
1718
+ const lastPartJoined = selectorParts.join('');
1719
+ // Check if we're in the middle of a class/id name (last char is alphanumeric or -)
1720
+ // AND there's a . or # that started this name (not separated by space)
1721
+ const lastSegmentMatch = lastPartJoined.match(/[.#]([a-zA-Z0-9_-]*)$/);
1722
+ const inClassName = lastSegmentMatch && lastSegmentMatch[1].length > 0;
1723
+
1724
+ // Don't add space if current token is '-' and last token was IDENT or NUMBER
1725
+ // Or if last token was '-' (the next token should attach to it)
1726
+ // Also handle .card-3d where we have NUMBER followed by IDENT (but only if in class name context)
1727
+ const isHyphenatedIdent = (tokenValue === '-' && (lastToken?.type === TokenType.IDENT || lastToken?.type === TokenType.NUMBER)) ||
1728
+ (lastToken?.type === TokenType.MINUS) ||
1729
+ (inClassName && lastToken?.type === TokenType.NUMBER && token.type === TokenType.IDENT);
1730
+
1731
+ // Special case: CSS units after numbers (768px, 1.5em)
1732
+ const isUnitAfterNumber = cssUnits.has(tokenValue) && lastToken?.type === TokenType.NUMBER;
1733
+
1734
+ // Special case: @-rule keywords (media, keyframes, etc.) should attach to @
1735
+ const isAtRuleKeyword = lastPart === '@' && /^[a-zA-Z]/.test(tokenValue);
1736
+
1737
+ const needsSpace = !noSpaceAfter.has(lastPart) &&
1738
+ !noSpaceBefore.has(tokenValue) &&
1739
+ !isHyphenatedIdent &&
1740
+ !isUnitAfterNumber &&
1741
+ !isAtRuleKeyword ||
1742
+ isDescendantSelector;
1743
+
1744
+ if (needsSpace) {
1745
+ selectorParts.push(' ');
1746
+ }
1747
+ }
1748
+ selectorParts.push(tokenValue);
1749
+ lastLine = currentLine;
1750
+ lastToken = token;
1751
+ }
1752
+ const selector = selectorParts.join('').trim();
1753
+
1754
+ this.expect(TokenType.LBRACE);
1755
+
1756
+ const properties = [];
1757
+ const nestedRules = [];
1758
+
1759
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
1760
+ // Check if this is a nested rule or a property
1761
+ if (this.isNestedRule()) {
1762
+ nestedRules.push(this.parseStyleRule());
1763
+ } else {
1764
+ properties.push(this.parseStyleProperty());
1765
+ }
1766
+ }
1767
+
1768
+ this.expect(TokenType.RBRACE);
1769
+ return new ASTNode(NodeType.StyleRule, { selector, properties, nestedRules });
1770
+ }
1771
+
1772
+ /**
1773
+ * Check if current position is a nested rule
1774
+ * A nested rule starts with a selector followed by { on the same logical line
1775
+ */
1776
+ isNestedRule() {
1777
+ const currentToken = this.peek(0);
1778
+ if (!currentToken) return false;
1779
+
1780
+ // & is always a CSS parent selector, never a property name
1781
+ // So &:hover, &.class, etc. are always nested rules
1782
+ if (currentToken.type === TokenType.AMPERSAND) {
1783
+ return true;
1784
+ }
1785
+
1786
+ const startLine = currentToken.line;
1787
+ let i = 0;
1788
+
1789
+ while (this.peek(i) && this.peek(i).type !== TokenType.EOF) {
1790
+ const token = this.peek(i);
1791
+
1792
+ // Found { before : - this is a nested rule
1793
+ if (token.type === TokenType.LBRACE) return true;
1794
+
1795
+ // Found : - this is a property, not a nested rule
1796
+ if (token.type === TokenType.COLON) return false;
1797
+
1798
+ // Found } - end of current rule
1799
+ if (token.type === TokenType.RBRACE) return false;
1800
+
1801
+ // If we've moved to a different line and the selector isn't continuing,
1802
+ // check if this new line starts with a selector pattern
1803
+ if (token.line > startLine && i > 0) {
1804
+ // We're on a new line - only continue if we haven't found anything significant
1805
+ // A selector on a new line followed by { is a nested rule
1806
+ // Check next few tokens on this new line
1807
+ const nextLine = token.line;
1808
+ let j = i;
1809
+ while (this.peek(j) && this.peek(j).line === nextLine) {
1810
+ const t = this.peek(j);
1811
+ if (t.type === TokenType.LBRACE) return true;
1812
+ if (t.type === TokenType.COLON) return false;
1813
+ if (t.type === TokenType.RBRACE) return false;
1814
+ j++;
1815
+ }
1816
+ return false;
1817
+ }
1818
+
1819
+ i++;
1820
+ }
1821
+ return false;
1822
+ }
1823
+
1824
+ /**
1825
+ * Parse style property
1826
+ * Handles CSS property names (including custom properties like --var-name)
1827
+ * and complex CSS values with proper spacing
1828
+ */
1829
+ parseStyleProperty() {
1830
+ // Parse property name (including custom properties with --)
1831
+ let name = '';
1832
+ let nameTokens = [];
1833
+ while (!this.is(TokenType.COLON) && !this.is(TokenType.EOF)) {
1834
+ nameTokens.push(this.advance());
1835
+ }
1836
+ // Join name tokens without spaces (property names don't have spaces)
1837
+ name = nameTokens.map(t => t.value).join('').trim();
1838
+
1839
+ this.expect(TokenType.COLON);
1840
+
1841
+ // CSS functions that should not have space before (
1842
+ const cssFunctions = new Set([
1843
+ 'rgba', 'rgb', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklch', 'oklab',
1844
+ 'var', 'calc', 'min', 'max', 'clamp', 'url', 'attr', 'env', 'counter', 'counters',
1845
+ 'linear-gradient', 'radial-gradient', 'conic-gradient', 'repeating-linear-gradient', 'repeating-radial-gradient',
1846
+ 'translate', 'translateX', 'translateY', 'translateZ', 'translate3d',
1847
+ 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'rotate3d',
1848
+ 'scale', 'scaleX', 'scaleY', 'scaleZ', 'scale3d',
1849
+ 'skew', 'skewX', 'skewY', 'matrix', 'matrix3d', 'perspective',
1850
+ 'cubic-bezier', 'steps', 'drop-shadow', 'blur', 'brightness', 'contrast',
1851
+ 'grayscale', 'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia',
1852
+ 'minmax', 'repeat', 'fit-content', 'image', 'element', 'cross-fade',
1853
+ 'color-mix', 'light-dark'
1854
+ ]);
1855
+
1856
+ // CSS units that should attach to preceding number (no space before)
1857
+ const cssUnits = new Set([
1858
+ '%', 'px', 'em', 'rem', 'vh', 'vw', 'vmin', 'vmax', 'dvh', 'dvw', 'svh', 'svw', 'lvh', 'lvw',
1859
+ 'fr', 's', 'ms', 'deg', 'rad', 'turn', 'grad',
1860
+ 'ex', 'ch', 'cap', 'ic', 'lh', 'rlh',
1861
+ 'pt', 'pc', 'in', 'cm', 'mm', 'Q',
1862
+ 'dpi', 'dpcm', 'dppx', 'x'
1863
+ ]);
1864
+
1865
+ // Tokens that should not have space before them
1866
+ const noSpaceBefore = new Set([')', ',', '(', ';']);
1867
+ cssUnits.forEach(u => noSpaceBefore.add(u));
1868
+
1869
+ // Collect value tokens
1870
+ let valueTokens = [];
1871
+ let lastTokenLine = this.current()?.line || 0;
1872
+
1873
+ while (!this.is(TokenType.SEMICOLON) && !this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
1874
+ const currentToken = this.current();
1875
+
1876
+ // Check if we're on a new line - if so, check for property start or nested rule
1877
+ if (currentToken && currentToken.line > lastTokenLine) {
1878
+ if (this.isPropertyStart() || this.isNestedRule()) {
1879
+ break;
1880
+ }
1881
+ lastTokenLine = currentToken.line;
1882
+ }
1883
+
1884
+ valueTokens.push(this.advance());
1885
+ }
1886
+
1887
+ // Build value string with proper spacing
1888
+ let value = '';
1889
+ let inHexColor = false;
1890
+ let hexLength = 0;
1891
+ let parenDepth = 0;
1892
+ let inCssVar = false;
1893
+ let inCalc = false; // Track if we're inside calc(), min(), max(), clamp() where operators need spaces
1894
+ let calcDepth = 0; // Track nested calc depth
1895
+
1896
+ // Functions where arithmetic operators need spaces
1897
+ const mathFunctions = new Set(['calc', 'min', 'max', 'clamp']);
1898
+
1899
+ // Helper to check if a string is valid hex
1900
+ const isValidHex = (str) => /^[0-9a-fA-F]+$/.test(String(str));
1901
+
1902
+ for (let i = 0; i < valueTokens.length; i++) {
1903
+ const token = valueTokens[i];
1904
+ const tokenValue = token.raw || String(token.value);
1905
+ const prevToken = i > 0 ? valueTokens[i - 1] : null;
1906
+ const prevValue = prevToken ? (prevToken.raw || String(prevToken.value)) : '';
1907
+
1908
+ // Track parenthesis depth
1909
+ if (tokenValue === '(') parenDepth++;
1910
+ if (tokenValue === ')') parenDepth--;
1911
+
1912
+ // Track CSS var() context
1913
+ if (prevValue === 'var' && tokenValue === '(') {
1914
+ inCssVar = true;
1915
+ } else if (inCssVar && tokenValue === ')') {
1916
+ inCssVar = false;
1917
+ }
1918
+
1919
+ // Track calc/min/max/clamp context - operators need spaces in these
1920
+ if (mathFunctions.has(prevValue) && tokenValue === '(') {
1921
+ inCalc = true;
1922
+ calcDepth = parenDepth;
1923
+ } else if (inCalc && tokenValue === ')' && parenDepth < calcDepth) {
1924
+ inCalc = false;
1925
+ }
1926
+
1927
+ // Handle HEX_COLOR token (from lexer) - it's already a complete hex color
1928
+ if (token.type === TokenType.HEX_COLOR) {
1929
+ // HEX_COLOR is already complete, no tracking needed
1930
+ inHexColor = false;
1931
+ }
1932
+ // Track hex colors for legacy cases - look for # followed by hex digits
1933
+ // Handle cases like #667eea being tokenized as # 667 eea
1934
+ // Valid hex colors are 3, 4, 6, or 8 chars long
1935
+ else if (tokenValue === '#') {
1936
+ inHexColor = true;
1937
+ hexLength = 0;
1938
+ } else if (inHexColor) {
1939
+ // Check if this token could be part of hex color
1940
+ // Numbers and identifiers that are valid hex chars continue the color
1941
+ const tokenStr = String(tokenValue);
1942
+ if (isValidHex(tokenStr) && hexLength + tokenStr.length <= 8) {
1943
+ hexLength += tokenStr.length;
1944
+
1945
+ // Check if we should stop collecting hex color now
1946
+ // Stop if: we have 6+ chars, OR the next token is likely a CSS value (%, px, etc.)
1947
+ const nextToken = valueTokens[i + 1];
1948
+ const nextValue = nextToken ? String(nextToken.raw || nextToken.value) : '';
1949
+
1950
+ // CSS units/symbols that indicate the hex color is complete
1951
+ const cssValueIndicators = new Set(['%', 'px', 'em', 'rem', 'vh', 'vw', ',', ')', ' ', '']);
1952
+
1953
+ // End hex color if:
1954
+ // - We've reached 6 or 8 chars (complete hex)
1955
+ // - Next token is a CSS unit/punctuation (like %, px, comma, paren)
1956
+ // - Next token is empty (end of value)
1957
+ if (hexLength >= 6 || cssValueIndicators.has(nextValue) || nextToken?.type === TokenType.PERCENT || nextToken?.type === TokenType.COMMA || nextToken?.type === TokenType.RPAREN) {
1958
+ inHexColor = false; // Done collecting hex color
1959
+ }
1960
+ } else {
1961
+ // This token is not part of hex, end hex color collection
1962
+ inHexColor = false;
1963
+ }
1964
+ }
1965
+
1966
+ // Determine if we need space before this token
1967
+ let needsSpace = value.length > 0;
1968
+
1969
+ if (needsSpace) {
1970
+ // No space after # (hex color start)
1971
+ if (prevValue === '#') {
1972
+ needsSpace = false;
1973
+ }
1974
+ // No space after these
1975
+ else if (prevValue === '(' || prevValue === '.' || prevValue === '/' || prevValue === '@') {
1976
+ needsSpace = false;
1977
+ }
1978
+ // No space after ! for !important
1979
+ else if (prevValue === '!' && tokenValue === 'important') {
1980
+ needsSpace = false;
1981
+ }
1982
+ // No space after CSS functions before (
1983
+ else if (cssFunctions.has(prevValue) && tokenValue === '(') {
1984
+ needsSpace = false;
1985
+ }
1986
+ // No space before these
1987
+ else if (noSpaceBefore.has(tokenValue)) {
1988
+ needsSpace = false;
1989
+ }
1990
+ // No space in hex colors (continuing after #)
1991
+ else if (inHexColor && hexLength > 0) {
1992
+ needsSpace = false;
1993
+ }
1994
+ // No space in CSS var() content
1995
+ else if (inCssVar) {
1996
+ needsSpace = false;
1997
+ }
1998
+ // No space for hyphenated identifiers (ease-in-out, sans-serif, -apple-system)
1999
+ // BUT in calc(), min(), max(), clamp() - operators need spaces around them
2000
+ // Note: Some CSS keywords like 'in' are also Pulse keywords, so check token value too
2001
+ // Check if current token is '-' and should attach to previous identifier-like token
2002
+ else if (tokenValue === '-' && !inCalc && (prevToken?.type === TokenType.IDENT || prevToken?.type === TokenType.NUMBER || /^[a-zA-Z]/.test(prevValue))) {
2003
+ needsSpace = false;
2004
+ }
2005
+ // Check if current token follows a '-' (either prevValue is '-' or value ends with '-')
2006
+ // Include keywords that might appear in CSS values (in, from, to, etc.)
2007
+ // BUT in calc(), don't attach numbers to '-' (keep space for operators)
2008
+ else if (!inCalc && (prevValue === '-' || value.endsWith('-')) && (token.type === TokenType.IDENT || token.type === TokenType.NUMBER || /^[a-zA-Z]/.test(tokenValue))) {
2009
+ needsSpace = false;
2010
+ }
2011
+ // No space for -- (CSS custom property reference)
2012
+ else if (prevValue === '-' && tokenValue === '-') {
2013
+ needsSpace = false;
2014
+ }
2015
+ else if (prevValue === '--' || value.endsWith('--')) {
2016
+ needsSpace = false;
2017
+ }
2018
+ // CSS unit after number
2019
+ else if (cssUnits.has(tokenValue) && prevToken?.type === TokenType.NUMBER) {
2020
+ needsSpace = false;
2021
+ }
2022
+ // Handle cases like preserve-3d where identifier follows number in hyphenated value
2023
+ // Check if we're continuing a hyphenated identifier (value ends with number after hyphen)
2024
+ else if (token.type === TokenType.IDENT && prevToken?.type === TokenType.NUMBER) {
2025
+ // Check if the value looks like it's a hyphenated pattern: word-NUM + IDENT (e.g., preserve-3 + d, card-3 + d)
2026
+ const hyphenNumberPattern = /-\d+$/;
2027
+ if (hyphenNumberPattern.test(value)) {
2028
+ needsSpace = false;
2029
+ }
2030
+ }
2031
+ }
2032
+
2033
+ if (needsSpace) {
2034
+ value += ' ';
2035
+ }
2036
+
2037
+ value += tokenValue;
2038
+ }
2039
+
2040
+ value = value.trim();
2041
+
2042
+ if (this.is(TokenType.SEMICOLON)) {
2043
+ this.advance();
2044
+ }
2045
+
2046
+ return new ASTNode(NodeType.StyleProperty, { name, value });
2047
+ }
2048
+
2049
+ // =============================================================================
2050
+ // Router Parsing
2051
+ // =============================================================================
2052
+
2053
+ /**
2054
+ * Parse router block
2055
+ * router {
2056
+ * mode: "hash"
2057
+ * base: "/app"
2058
+ * routes { "/": HomePage }
2059
+ * beforeEach(to, from) { ... }
2060
+ * afterEach(to) { ... }
2061
+ * }
2062
+ */
2063
+ parseRouterBlock() {
2064
+ this.expect(TokenType.ROUTER);
2065
+ this.expect(TokenType.LBRACE);
2066
+
2067
+ const config = {
2068
+ mode: 'history',
2069
+ base: '',
2070
+ routes: [],
2071
+ beforeEach: null,
2072
+ afterEach: null
2073
+ };
2074
+
2075
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
2076
+ // mode: "hash"
2077
+ if (this.is(TokenType.MODE)) {
2078
+ this.advance();
2079
+ this.expect(TokenType.COLON);
2080
+ config.mode = this.expect(TokenType.STRING).value;
2081
+ }
2082
+ // base: "/app"
2083
+ else if (this.is(TokenType.BASE)) {
2084
+ this.advance();
2085
+ this.expect(TokenType.COLON);
2086
+ config.base = this.expect(TokenType.STRING).value;
2087
+ }
2088
+ // routes { ... }
2089
+ else if (this.is(TokenType.ROUTES)) {
2090
+ config.routes = this.parseRoutesBlock();
2091
+ }
2092
+ // beforeEach(to, from) { ... }
2093
+ else if (this.is(TokenType.BEFORE_EACH)) {
2094
+ config.beforeEach = this.parseGuardHook('beforeEach');
2095
+ }
2096
+ // afterEach(to) { ... }
2097
+ else if (this.is(TokenType.AFTER_EACH)) {
2098
+ config.afterEach = this.parseGuardHook('afterEach');
2099
+ }
2100
+ else {
2101
+ throw this.createError(
2102
+ `Unexpected token '${this.current()?.value}' in router block. ` +
2103
+ `Expected: mode, base, routes, beforeEach, or afterEach`
2104
+ );
2105
+ }
2106
+ }
2107
+
2108
+ this.expect(TokenType.RBRACE);
2109
+ return new ASTNode(NodeType.RouterBlock, config);
2110
+ }
2111
+
2112
+ /**
2113
+ * Parse routes block
2114
+ * routes {
2115
+ * "/": HomePage
2116
+ * "/users/:id": UserPage
2117
+ * }
2118
+ */
2119
+ parseRoutesBlock() {
2120
+ this.expect(TokenType.ROUTES);
2121
+ this.expect(TokenType.LBRACE);
2122
+
2123
+ const routes = [];
2124
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
2125
+ const path = this.expect(TokenType.STRING).value;
2126
+ this.expect(TokenType.COLON);
2127
+ const handler = this.expect(TokenType.IDENT).value;
2128
+ routes.push(new ASTNode(NodeType.RouteDefinition, { path, handler }));
2129
+ }
2130
+
2131
+ this.expect(TokenType.RBRACE);
2132
+ return routes;
2133
+ }
2134
+
2135
+ /**
2136
+ * Parse guard hook: beforeEach(to, from) { ... }
2137
+ */
2138
+ parseGuardHook(name) {
2139
+ this.advance(); // skip keyword
2140
+ this.expect(TokenType.LPAREN);
2141
+ const params = [];
2142
+ while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
2143
+ // Accept IDENT or FROM (since 'from' is a keyword but valid as parameter name)
2144
+ if (this.is(TokenType.IDENT)) {
2145
+ params.push(this.advance().value);
2146
+ } else if (this.is(TokenType.FROM)) {
2147
+ params.push(this.advance().value);
2148
+ } else {
2149
+ throw this.createError(`Expected parameter name but got ${this.current()?.type}`);
2150
+ }
2151
+ if (this.is(TokenType.COMMA)) this.advance();
2152
+ }
2153
+ this.expect(TokenType.RPAREN);
2154
+ this.expect(TokenType.LBRACE);
2155
+ const body = this.parseFunctionBody();
2156
+ this.expect(TokenType.RBRACE);
2157
+
2158
+ return new ASTNode(NodeType.GuardHook, { name, params, body });
2159
+ }
2160
+
2161
+ // =============================================================================
2162
+ // Store Parsing
2163
+ // =============================================================================
2164
+
2165
+ /**
2166
+ * Parse store block
2167
+ * store {
2168
+ * state { ... }
2169
+ * getters { ... }
2170
+ * actions { ... }
2171
+ * persist: true
2172
+ * storageKey: "my-store"
2173
+ * }
2174
+ */
2175
+ parseStoreBlock() {
2176
+ this.expect(TokenType.STORE);
2177
+ this.expect(TokenType.LBRACE);
2178
+
2179
+ const config = {
2180
+ state: null,
2181
+ getters: null,
2182
+ actions: null,
2183
+ persist: false,
2184
+ storageKey: 'pulse-store',
2185
+ plugins: []
2186
+ };
2187
+
2188
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
2189
+ // state { ... }
2190
+ if (this.is(TokenType.STATE)) {
2191
+ config.state = this.parseStateBlock();
2192
+ }
2193
+ // getters { ... }
2194
+ else if (this.is(TokenType.GETTERS)) {
2195
+ config.getters = this.parseGettersBlock();
2196
+ }
2197
+ // actions { ... }
2198
+ else if (this.is(TokenType.ACTIONS)) {
2199
+ config.actions = this.parseActionsBlock();
2200
+ }
2201
+ // persist: true
2202
+ else if (this.is(TokenType.PERSIST)) {
2203
+ this.advance();
2204
+ this.expect(TokenType.COLON);
2205
+ if (this.is(TokenType.TRUE)) {
2206
+ this.advance();
2207
+ config.persist = true;
2208
+ } else if (this.is(TokenType.FALSE)) {
2209
+ this.advance();
2210
+ config.persist = false;
2211
+ } else {
2212
+ throw this.createError('Expected true or false for persist');
2213
+ }
2214
+ }
2215
+ // storageKey: "my-store"
2216
+ else if (this.is(TokenType.STORAGE_KEY)) {
2217
+ this.advance();
2218
+ this.expect(TokenType.COLON);
2219
+ config.storageKey = this.expect(TokenType.STRING).value;
2220
+ }
2221
+ // plugins: [historyPlugin, loggerPlugin]
2222
+ else if (this.is(TokenType.PLUGINS)) {
2223
+ this.advance();
2224
+ this.expect(TokenType.COLON);
2225
+ config.plugins = this.parseArrayLiteral();
2226
+ }
2227
+ else {
2228
+ throw this.createError(
2229
+ `Unexpected token '${this.current()?.value}' in store block. ` +
2230
+ `Expected: state, getters, actions, persist, storageKey, or plugins`
2231
+ );
2232
+ }
2233
+ }
2234
+
2235
+ this.expect(TokenType.RBRACE);
2236
+ return new ASTNode(NodeType.StoreBlock, config);
2237
+ }
2238
+
2239
+ /**
2240
+ * Parse getters block
2241
+ * getters {
2242
+ * doubled() { return this.count * 2 }
2243
+ * }
2244
+ */
2245
+ parseGettersBlock() {
2246
+ this.expect(TokenType.GETTERS);
2247
+ this.expect(TokenType.LBRACE);
2248
+
2249
+ const getters = [];
2250
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
2251
+ getters.push(this.parseGetterDeclaration());
2252
+ }
2253
+
2254
+ this.expect(TokenType.RBRACE);
2255
+ return new ASTNode(NodeType.GettersBlock, { getters });
2256
+ }
2257
+
2258
+ /**
2259
+ * Parse getter declaration: name() { return ... }
2260
+ */
2261
+ parseGetterDeclaration() {
2262
+ const name = this.expect(TokenType.IDENT).value;
2263
+ this.expect(TokenType.LPAREN);
2264
+ this.expect(TokenType.RPAREN);
2265
+ this.expect(TokenType.LBRACE);
2266
+ const body = this.parseFunctionBody();
2267
+ this.expect(TokenType.RBRACE);
2268
+
2269
+ return new ASTNode(NodeType.GetterDeclaration, { name, body });
2270
+ }
2271
+
2272
+ // =============================================================================
2273
+ // Router View Directives
2274
+ // =============================================================================
2275
+
2276
+ /**
2277
+ * Parse @link directive: @link("/path") "text"
2278
+ */
2279
+ parseLinkDirective() {
2280
+ this.expect(TokenType.LPAREN);
2281
+ const path = this.parseExpression();
2282
+
2283
+ let options = null;
2284
+ if (this.is(TokenType.COMMA)) {
2285
+ this.advance();
2286
+ options = this.parseObjectLiteralExpr();
2287
+ }
2288
+ this.expect(TokenType.RPAREN);
2289
+
2290
+ // Parse link content (text or children)
2291
+ let content = null;
2292
+ if (this.is(TokenType.STRING)) {
2293
+ content = this.parseTextNode();
2294
+ } else if (this.is(TokenType.LBRACE)) {
2295
+ this.advance();
2296
+ content = [];
2297
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
2298
+ content.push(this.parseViewChild());
2299
+ }
2300
+ this.expect(TokenType.RBRACE);
2301
+ }
2302
+
2303
+ return new ASTNode(NodeType.LinkDirective, { path, options, content });
2304
+ }
2305
+
2306
+ /**
2307
+ * Parse @outlet directive
2308
+ */
2309
+ parseOutletDirective() {
2310
+ let container = null;
2311
+ if (this.is(TokenType.LPAREN)) {
2312
+ this.advance();
2313
+ if (this.is(TokenType.STRING)) {
2314
+ container = this.expect(TokenType.STRING).value;
2315
+ }
2316
+ this.expect(TokenType.RPAREN);
2317
+ }
2318
+ return new ASTNode(NodeType.OutletDirective, { container });
2319
+ }
2320
+
2321
+ /**
2322
+ * Parse @navigate directive
2323
+ */
2324
+ parseNavigateDirective() {
2325
+ this.expect(TokenType.LPAREN);
2326
+ const path = this.parseExpression();
2327
+
2328
+ let options = null;
2329
+ if (this.is(TokenType.COMMA)) {
2330
+ this.advance();
2331
+ options = this.parseObjectLiteralExpr();
2332
+ }
2333
+ this.expect(TokenType.RPAREN);
2334
+
2335
+ return new ASTNode(NodeType.NavigateDirective, { path, options });
2336
+ }
2337
+
2338
+ /**
2339
+ * Check if current position starts a new property
2340
+ */
2341
+ isPropertyStart() {
2342
+ // Check if it looks like: identifier (with possible hyphens) followed by :
2343
+ // CSS properties can be: margin, margin-bottom, -webkit-transform, --custom-prop, etc.
2344
+ // Include MINUSMINUS for CSS custom properties (--var-name)
2345
+ if (!this.is(TokenType.IDENT) && !this.is(TokenType.MINUS) && !this.is(TokenType.MINUSMINUS)) return false;
2346
+
2347
+ let i = 0;
2348
+ // Skip over property name tokens (IDENT, MINUS, MINUSMINUS for hyphenated/custom props)
2349
+ while (this.peek(i)) {
2350
+ const token = this.peek(i);
2351
+ if (token.type === TokenType.IDENT || token.type === TokenType.MINUS || token.type === TokenType.MINUSMINUS) {
2352
+ i++;
2353
+ } else {
2354
+ break;
2355
+ }
2356
+ }
2357
+
2358
+ return this.peek(i)?.type === TokenType.COLON;
2359
+ }
2360
+ }
2361
+
2362
+ /**
2363
+ * Parse source code into AST
2364
+ */
2365
+ export function parse(source) {
2366
+ const tokens = tokenize(source);
2367
+ const parser = new Parser(tokens);
2368
+ return parser.parse();
2369
+ }
2370
+
2371
+ export default {
2372
+ NodeType,
2373
+ ASTNode,
2374
+ Parser,
2375
+ parse
2376
+ };