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.
- package/compiler/parser/_extract.js +393 -0
- package/compiler/parser/blocks.js +361 -0
- package/compiler/parser/core.js +306 -0
- package/compiler/parser/expressions.js +386 -0
- package/compiler/parser/imports.js +108 -0
- package/compiler/parser/index.js +47 -0
- package/compiler/parser/state.js +155 -0
- package/compiler/parser/style.js +445 -0
- package/compiler/parser/view.js +632 -0
- package/compiler/parser.js +15 -2372
- package/compiler/parser.js.original +2376 -0
- package/package.json +2 -1
- package/runtime/a11y/announcements.js +213 -0
- package/runtime/a11y/contrast.js +125 -0
- package/runtime/a11y/focus.js +412 -0
- package/runtime/a11y/index.js +35 -0
- package/runtime/a11y/preferences.js +121 -0
- package/runtime/a11y/utils.js +164 -0
- package/runtime/a11y/validation.js +258 -0
- package/runtime/a11y/widgets.js +545 -0
- package/runtime/a11y.js +15 -1840
- package/runtime/a11y.js.original +1844 -0
- package/runtime/graphql/cache.js +69 -0
- package/runtime/graphql/client.js +563 -0
- package/runtime/graphql/hooks.js +492 -0
- package/runtime/graphql/index.js +62 -0
- package/runtime/graphql/subscriptions.js +241 -0
- package/runtime/graphql.js +12 -1322
- package/runtime/graphql.js.original +1326 -0
- package/runtime/router/core.js +956 -0
- package/runtime/router/guards.js +90 -0
- package/runtime/router/history.js +204 -0
- package/runtime/router/index.js +36 -0
- package/runtime/router/lazy.js +180 -0
- package/runtime/router/utils.js +226 -0
- package/runtime/router.js +12 -1600
- 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
|
+
};
|