@lithent/lithent-template-parser 0.1.0
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/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/codegen/createElement.d.ts +10 -0
- package/dist/codegen/index.d.ts +1 -0
- package/dist/compiler.d.ts +26 -0
- package/dist/docPipe/index.d.ts +40 -0
- package/dist/docPipe/scan.d.ts +6 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.mjs +2302 -0
- package/dist/index.mjs.map +1 -0
- package/dist/index.umd.cjs +13 -0
- package/dist/index.umd.cjs.map +1 -0
- package/dist/parser/ast.d.ts +207 -0
- package/dist/parser/lexer.d.ts +109 -0
- package/dist/parser/parser.d.ts +79 -0
- package/dist/parser/tokens.d.ts +51 -0
- package/dist/transform/directives.d.ts +30 -0
- package/dist/transform/index.d.ts +14 -0
- package/dist/transform/optimize.d.ts +21 -0
- package/package.json +53 -0
- package/src/codegen/createElement.ts +661 -0
- package/src/codegen/index.ts +1 -0
- package/src/compiler.ts +70 -0
- package/src/docPipe/index.ts +104 -0
- package/src/docPipe/scan.ts +318 -0
- package/src/index.ts +16 -0
- package/src/parser/ast.ts +478 -0
- package/src/parser/lexer.ts +561 -0
- package/src/parser/parser.ts +514 -0
- package/src/parser/tokens.ts +84 -0
- package/src/playground/main.ts +206 -0
- package/src/playground/style.css +135 -0
- package/src/shims/babel-env.ts +17 -0
- package/src/tests/compiler.test.ts +434 -0
- package/src/tests/docPipe.test.ts +41 -0
- package/src/tests/helpers.ts +96 -0
- package/src/tests/lexer.test.ts +282 -0
- package/src/tests/parser.test.ts +691 -0
- package/src/transform/directives.ts +160 -0
- package/src/transform/index.ts +29 -0
- package/src/transform/optimize.ts +203 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { Token, TokenType } from './tokens';
|
|
2
|
+
import {
|
|
3
|
+
RootNode,
|
|
4
|
+
TemplateNode,
|
|
5
|
+
AttributeNode,
|
|
6
|
+
DirectiveNode,
|
|
7
|
+
createRootNode,
|
|
8
|
+
createElementNode,
|
|
9
|
+
createTextNode,
|
|
10
|
+
createInterpolationNode,
|
|
11
|
+
createCommentNode,
|
|
12
|
+
createAttributeNode,
|
|
13
|
+
createDirectiveIfNode,
|
|
14
|
+
createDirectiveElseIfNode,
|
|
15
|
+
createDirectiveElseNode,
|
|
16
|
+
createDirectiveForNode,
|
|
17
|
+
createFragmentNode,
|
|
18
|
+
} from './ast';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parser for Lithent templates
|
|
22
|
+
* Converts token stream into AST
|
|
23
|
+
*/
|
|
24
|
+
export class Parser {
|
|
25
|
+
private tokens: Token[];
|
|
26
|
+
private position: number = 0;
|
|
27
|
+
|
|
28
|
+
constructor(tokens: Token[]) {
|
|
29
|
+
// Filter out whitespace tokens for easier parsing
|
|
30
|
+
this.tokens = tokens.filter(t => t.type !== TokenType.WHITESPACE);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse tokens into AST
|
|
35
|
+
*/
|
|
36
|
+
public parse(): RootNode {
|
|
37
|
+
const children: TemplateNode[] = [];
|
|
38
|
+
|
|
39
|
+
while (!this.isEOF()) {
|
|
40
|
+
const node = this.parseTemplateNode();
|
|
41
|
+
if (node) {
|
|
42
|
+
children.push(node);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const start =
|
|
47
|
+
children.length > 0
|
|
48
|
+
? children[0].start
|
|
49
|
+
: { line: 1, column: 1, offset: 0 };
|
|
50
|
+
const end =
|
|
51
|
+
children.length > 0
|
|
52
|
+
? children[children.length - 1].end
|
|
53
|
+
: { line: 1, column: 1, offset: 0 };
|
|
54
|
+
|
|
55
|
+
return createRootNode(children, start, end);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a single template node
|
|
60
|
+
*/
|
|
61
|
+
private parseTemplateNode(): TemplateNode | null {
|
|
62
|
+
const token = this.peek();
|
|
63
|
+
|
|
64
|
+
switch (token.type) {
|
|
65
|
+
case TokenType.TAG_OPEN_START:
|
|
66
|
+
return this.parseElement();
|
|
67
|
+
case TokenType.FRAGMENT_OPEN:
|
|
68
|
+
return this.parseFragment();
|
|
69
|
+
case TokenType.TEXT:
|
|
70
|
+
return this.parseText();
|
|
71
|
+
case TokenType.EXPRESSION_START:
|
|
72
|
+
return this.parseInterpolation();
|
|
73
|
+
case TokenType.COMMENT:
|
|
74
|
+
return this.parseComment();
|
|
75
|
+
case TokenType.TAG_CLOSE_START:
|
|
76
|
+
// Unexpected closing tag - skip it
|
|
77
|
+
this.advance();
|
|
78
|
+
return null;
|
|
79
|
+
case TokenType.FRAGMENT_CLOSE:
|
|
80
|
+
this.advance();
|
|
81
|
+
return null;
|
|
82
|
+
case TokenType.EOF:
|
|
83
|
+
return null;
|
|
84
|
+
default:
|
|
85
|
+
this.advance();
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse fragment node
|
|
92
|
+
*/
|
|
93
|
+
private parseFragment(): TemplateNode | null {
|
|
94
|
+
const start = this.peek().start;
|
|
95
|
+
this.expect(TokenType.FRAGMENT_OPEN);
|
|
96
|
+
|
|
97
|
+
const children: TemplateNode[] = [];
|
|
98
|
+
|
|
99
|
+
while (!this.isEOF() && !this.check(TokenType.FRAGMENT_CLOSE)) {
|
|
100
|
+
const child = this.parseTemplateNode();
|
|
101
|
+
if (child) {
|
|
102
|
+
children.push(child);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (this.check(TokenType.FRAGMENT_CLOSE)) {
|
|
107
|
+
const closeToken = this.expect(TokenType.FRAGMENT_CLOSE);
|
|
108
|
+
return createFragmentNode(children, start, closeToken.end);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Unclosed fragment starting at line ${start.line}, column ${start.column}`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse element
|
|
118
|
+
*/
|
|
119
|
+
private parseElement(): TemplateNode | null {
|
|
120
|
+
const start = this.peek().start;
|
|
121
|
+
|
|
122
|
+
// Consume <
|
|
123
|
+
this.expect(TokenType.TAG_OPEN_START);
|
|
124
|
+
|
|
125
|
+
// Get tag name
|
|
126
|
+
const tagToken = this.peek();
|
|
127
|
+
if (tagToken.type !== TokenType.IDENTIFIER) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Expected tag name at line ${tagToken.start.line}, column ${tagToken.start.column}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const tag = tagToken.value;
|
|
134
|
+
this.advance();
|
|
135
|
+
|
|
136
|
+
// Parse attributes and directives
|
|
137
|
+
const { attributes, directives } = this.parseAttributesAndDirectives();
|
|
138
|
+
|
|
139
|
+
// Check for self-closing tag
|
|
140
|
+
if (this.check(TokenType.TAG_SELF_CLOSE)) {
|
|
141
|
+
const end = this.peek().end;
|
|
142
|
+
this.advance(); // consume />
|
|
143
|
+
return createElementNode(
|
|
144
|
+
tag,
|
|
145
|
+
attributes,
|
|
146
|
+
directives,
|
|
147
|
+
[],
|
|
148
|
+
true,
|
|
149
|
+
start,
|
|
150
|
+
end
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Consume >
|
|
155
|
+
this.expect(TokenType.TAG_OPEN_END);
|
|
156
|
+
|
|
157
|
+
// Parse children (if not self-closing)
|
|
158
|
+
const children: TemplateNode[] = [];
|
|
159
|
+
|
|
160
|
+
while (!this.isEOF() && !this.check(TokenType.TAG_CLOSE_START)) {
|
|
161
|
+
const child = this.parseTemplateNode();
|
|
162
|
+
if (child) {
|
|
163
|
+
children.push(child);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Parse closing tag
|
|
168
|
+
if (this.check(TokenType.TAG_CLOSE_START)) {
|
|
169
|
+
this.advance(); // consume </
|
|
170
|
+
|
|
171
|
+
const closeTagToken = this.peek();
|
|
172
|
+
if (closeTagToken.type !== TokenType.IDENTIFIER) {
|
|
173
|
+
const error = new Error(
|
|
174
|
+
`Expected closing tag name at line ${closeTagToken.start.line}, column ${closeTagToken.start.column}`
|
|
175
|
+
) as Error & { line?: number; column?: number };
|
|
176
|
+
error.line = closeTagToken.start.line;
|
|
177
|
+
error.column = closeTagToken.start.column;
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const closeTag = closeTagToken.value;
|
|
182
|
+
if (closeTag !== tag) {
|
|
183
|
+
const error = new Error(
|
|
184
|
+
`Mismatched closing tag: expected </${tag}> but got </${closeTag}> at line ${closeTagToken.start.line}, column ${closeTagToken.start.column}`
|
|
185
|
+
) as Error & { line?: number; column?: number };
|
|
186
|
+
error.line = closeTagToken.start.line;
|
|
187
|
+
error.column = closeTagToken.start.column;
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.advance(); // consume tag name
|
|
192
|
+
|
|
193
|
+
const end = this.peek().end;
|
|
194
|
+
this.expect(TokenType.TAG_OPEN_END); // consume >
|
|
195
|
+
|
|
196
|
+
return createElementNode(
|
|
197
|
+
tag,
|
|
198
|
+
attributes,
|
|
199
|
+
directives,
|
|
200
|
+
children,
|
|
201
|
+
false,
|
|
202
|
+
start,
|
|
203
|
+
end
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// No closing tag found
|
|
208
|
+
const end = children.length > 0 ? children[children.length - 1].end : start;
|
|
209
|
+
return createElementNode(
|
|
210
|
+
tag,
|
|
211
|
+
attributes,
|
|
212
|
+
directives,
|
|
213
|
+
children,
|
|
214
|
+
false,
|
|
215
|
+
start,
|
|
216
|
+
end
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parse attributes and directives
|
|
222
|
+
*/
|
|
223
|
+
private parseAttributesAndDirectives(): {
|
|
224
|
+
attributes: AttributeNode[];
|
|
225
|
+
directives: DirectiveNode[];
|
|
226
|
+
} {
|
|
227
|
+
const attributes: AttributeNode[] = [];
|
|
228
|
+
const directives: DirectiveNode[] = [];
|
|
229
|
+
|
|
230
|
+
while (
|
|
231
|
+
!this.isEOF() &&
|
|
232
|
+
!this.check(TokenType.TAG_OPEN_END) &&
|
|
233
|
+
!this.check(TokenType.TAG_SELF_CLOSE)
|
|
234
|
+
) {
|
|
235
|
+
const token = this.peek();
|
|
236
|
+
|
|
237
|
+
// Check for directives
|
|
238
|
+
if (
|
|
239
|
+
token.type === TokenType.DIRECTIVE_IF ||
|
|
240
|
+
token.type === TokenType.DIRECTIVE_ELSE_IF ||
|
|
241
|
+
token.type === TokenType.DIRECTIVE_ELSE ||
|
|
242
|
+
token.type === TokenType.DIRECTIVE_FOR
|
|
243
|
+
) {
|
|
244
|
+
const directive = this.parseDirective();
|
|
245
|
+
if (directive) {
|
|
246
|
+
directives.push(directive);
|
|
247
|
+
}
|
|
248
|
+
} else if (
|
|
249
|
+
token.type === TokenType.IDENTIFIER ||
|
|
250
|
+
token.type === TokenType.KEYWORD_REF
|
|
251
|
+
) {
|
|
252
|
+
const attribute = this.parseAttribute();
|
|
253
|
+
if (attribute) {
|
|
254
|
+
attributes.push(attribute);
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
// Unexpected token, skip it
|
|
258
|
+
this.advance();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { attributes, directives };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Parse attribute
|
|
267
|
+
*/
|
|
268
|
+
private parseAttribute(): AttributeNode | null {
|
|
269
|
+
const start = this.peek().start;
|
|
270
|
+
const nameToken = this.advance();
|
|
271
|
+
const name = nameToken.value;
|
|
272
|
+
|
|
273
|
+
// Check for = sign
|
|
274
|
+
if (!this.check(TokenType.ATTRIBUTE_EQUALS)) {
|
|
275
|
+
// Attribute without value (e.g., disabled, checked)
|
|
276
|
+
return createAttributeNode(name, null, false, start, nameToken.end);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
this.advance(); // consume =
|
|
280
|
+
|
|
281
|
+
// Parse attribute value
|
|
282
|
+
const valueToken = this.peek();
|
|
283
|
+
let value: string | { expression: string };
|
|
284
|
+
let isDynamic = false;
|
|
285
|
+
let end = valueToken.end;
|
|
286
|
+
|
|
287
|
+
if (valueToken.type === TokenType.STRING_LITERAL) {
|
|
288
|
+
// Static string value
|
|
289
|
+
value = valueToken.value.slice(1, -1); // Remove quotes
|
|
290
|
+
this.advance();
|
|
291
|
+
} else if (valueToken.type === TokenType.EXPRESSION_START) {
|
|
292
|
+
// Dynamic expression value
|
|
293
|
+
this.advance(); // consume {
|
|
294
|
+
|
|
295
|
+
const exprToken = this.peek();
|
|
296
|
+
if (exprToken.type === TokenType.EXPRESSION_CONTENT) {
|
|
297
|
+
value = { expression: exprToken.value.trim() };
|
|
298
|
+
isDynamic = true;
|
|
299
|
+
this.advance();
|
|
300
|
+
} else {
|
|
301
|
+
value = { expression: '' };
|
|
302
|
+
isDynamic = true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (this.check(TokenType.EXPRESSION_END)) {
|
|
306
|
+
end = this.peek().end;
|
|
307
|
+
this.advance(); // consume }
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
// Unexpected token
|
|
311
|
+
return createAttributeNode(name, null, false, start, nameToken.end);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return createAttributeNode(name, value, isDynamic, start, end);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Parse directive
|
|
319
|
+
*/
|
|
320
|
+
private parseDirective(): DirectiveNode | null {
|
|
321
|
+
const start = this.peek().start;
|
|
322
|
+
const directiveToken = this.advance();
|
|
323
|
+
|
|
324
|
+
switch (directiveToken.type) {
|
|
325
|
+
case TokenType.DIRECTIVE_IF:
|
|
326
|
+
case TokenType.DIRECTIVE_ELSE_IF: {
|
|
327
|
+
this.expect(TokenType.ATTRIBUTE_EQUALS);
|
|
328
|
+
this.expect(TokenType.EXPRESSION_START);
|
|
329
|
+
|
|
330
|
+
const exprToken = this.peek();
|
|
331
|
+
let condition = '';
|
|
332
|
+
|
|
333
|
+
if (exprToken.type === TokenType.EXPRESSION_CONTENT) {
|
|
334
|
+
condition = exprToken.value.trim();
|
|
335
|
+
this.advance();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const end = this.peek().end;
|
|
339
|
+
this.expect(TokenType.EXPRESSION_END);
|
|
340
|
+
|
|
341
|
+
return directiveToken.type === TokenType.DIRECTIVE_IF
|
|
342
|
+
? createDirectiveIfNode(condition, start, end)
|
|
343
|
+
: createDirectiveElseIfNode(condition, start, end);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case TokenType.DIRECTIVE_ELSE: {
|
|
347
|
+
const end = directiveToken.end;
|
|
348
|
+
return createDirectiveElseNode(start, end);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case TokenType.DIRECTIVE_FOR: {
|
|
352
|
+
this.expect(TokenType.ATTRIBUTE_EQUALS);
|
|
353
|
+
this.expect(TokenType.EXPRESSION_START);
|
|
354
|
+
|
|
355
|
+
const exprToken = this.peek();
|
|
356
|
+
let expression = '';
|
|
357
|
+
|
|
358
|
+
if (exprToken.type === TokenType.EXPRESSION_CONTENT) {
|
|
359
|
+
expression = exprToken.value.trim();
|
|
360
|
+
this.advance();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const end = this.peek().end;
|
|
364
|
+
this.expect(TokenType.EXPRESSION_END);
|
|
365
|
+
|
|
366
|
+
// Parse for expression: "item in list" or "(item, index) in list"
|
|
367
|
+
const { item, index, list } = this.parseForExpression(expression);
|
|
368
|
+
|
|
369
|
+
return createDirectiveForNode(item, list, index, start, end);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
default:
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Parse l-for expression
|
|
379
|
+
*/
|
|
380
|
+
private parseForExpression(expression: string): {
|
|
381
|
+
item: string;
|
|
382
|
+
index?: string;
|
|
383
|
+
list: string;
|
|
384
|
+
} {
|
|
385
|
+
const identifier = '[A-Za-z_$][\\w$]*';
|
|
386
|
+
const withParenRegex = new RegExp(
|
|
387
|
+
`^\\s*\\(\\s*(${identifier})\\s*,\\s*(${identifier})\\s*\\)\\s+in\\s+([\\s\\S]+)$`
|
|
388
|
+
);
|
|
389
|
+
const simpleRegex = new RegExp(
|
|
390
|
+
`^\\s*(${identifier})\\s+in\\s+([\\s\\S]+)$`
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Match pattern: "(item, index) in list" or "item in list"
|
|
394
|
+
const withParenMatch = expression.match(withParenRegex);
|
|
395
|
+
if (withParenMatch) {
|
|
396
|
+
return {
|
|
397
|
+
item: withParenMatch[1],
|
|
398
|
+
index: withParenMatch[2],
|
|
399
|
+
list: withParenMatch[3].trim(),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const simpleMatch = expression.match(simpleRegex);
|
|
404
|
+
if (simpleMatch) {
|
|
405
|
+
return {
|
|
406
|
+
item: simpleMatch[1],
|
|
407
|
+
list: simpleMatch[2].trim(),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Fallback
|
|
412
|
+
return {
|
|
413
|
+
item: 'item',
|
|
414
|
+
list: expression,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Parse text node
|
|
420
|
+
*/
|
|
421
|
+
private parseText(): TemplateNode | null {
|
|
422
|
+
const token = this.advance();
|
|
423
|
+
return createTextNode(token.value, token.start, token.end);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Parse interpolation node
|
|
428
|
+
*/
|
|
429
|
+
private parseInterpolation(): TemplateNode | null {
|
|
430
|
+
const start = this.peek().start;
|
|
431
|
+
|
|
432
|
+
this.expect(TokenType.EXPRESSION_START);
|
|
433
|
+
|
|
434
|
+
const exprToken = this.peek();
|
|
435
|
+
let expression = '';
|
|
436
|
+
|
|
437
|
+
if (exprToken.type === TokenType.EXPRESSION_CONTENT) {
|
|
438
|
+
expression = exprToken.value.trim();
|
|
439
|
+
this.advance();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const end = this.peek().end;
|
|
443
|
+
this.expect(TokenType.EXPRESSION_END);
|
|
444
|
+
|
|
445
|
+
return createInterpolationNode(expression, start, end);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Parse comment node
|
|
450
|
+
*/
|
|
451
|
+
private parseComment(): TemplateNode | null {
|
|
452
|
+
const token = this.advance();
|
|
453
|
+
// Extract content between <!-- and -->
|
|
454
|
+
const content = token.value.replace(/^<!--\s*|\s*-->$/g, '');
|
|
455
|
+
return createCommentNode(content, token.start, token.end);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Peek at current token without consuming
|
|
460
|
+
*/
|
|
461
|
+
private peek(): Token {
|
|
462
|
+
return this.tokens[this.position];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Advance to next token and return current
|
|
467
|
+
*/
|
|
468
|
+
private advance(): Token {
|
|
469
|
+
const token = this.tokens[this.position];
|
|
470
|
+
this.position++;
|
|
471
|
+
return token;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Check if current token matches type
|
|
476
|
+
*/
|
|
477
|
+
private check(type: TokenType): boolean {
|
|
478
|
+
if (this.isEOF()) return false;
|
|
479
|
+
return this.peek().type === type;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Expect a token of specific type, throw error if not found
|
|
484
|
+
*/
|
|
485
|
+
private expect(type: TokenType): Token {
|
|
486
|
+
const token = this.peek();
|
|
487
|
+
if (token.type !== type) {
|
|
488
|
+
const error = new Error(
|
|
489
|
+
`Expected token ${type} but got ${token.type} at line ${token.start.line}, column ${token.start.column}`
|
|
490
|
+
) as Error & { line?: number; column?: number };
|
|
491
|
+
error.line = token.start.line;
|
|
492
|
+
error.column = token.start.column;
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
return this.advance();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Check if we've reached EOF
|
|
500
|
+
*/
|
|
501
|
+
private isEOF(): boolean {
|
|
502
|
+
return (
|
|
503
|
+
this.position >= this.tokens.length || this.peek().type === TokenType.EOF
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Parse template string into AST
|
|
510
|
+
*/
|
|
511
|
+
export function parse(tokens: Token[]): RootNode {
|
|
512
|
+
const parser = new Parser(tokens);
|
|
513
|
+
return parser.parse();
|
|
514
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token types for Lithent template lexer
|
|
3
|
+
*/
|
|
4
|
+
export enum TokenType {
|
|
5
|
+
// Tag tokens
|
|
6
|
+
TAG_OPEN_START = 'TAG_OPEN_START', // <
|
|
7
|
+
TAG_OPEN_END = 'TAG_OPEN_END', // >
|
|
8
|
+
TAG_CLOSE_START = 'TAG_CLOSE_START', // </
|
|
9
|
+
TAG_SELF_CLOSE = 'TAG_SELF_CLOSE', // />
|
|
10
|
+
|
|
11
|
+
// Identifiers and values
|
|
12
|
+
IDENTIFIER = 'IDENTIFIER', // tag names, attribute names
|
|
13
|
+
ATTRIBUTE_EQUALS = 'ATTRIBUTE_EQUALS', // =
|
|
14
|
+
STRING_LITERAL = 'STRING_LITERAL', // "..." or '...'
|
|
15
|
+
|
|
16
|
+
// Expressions
|
|
17
|
+
EXPRESSION_START = 'EXPRESSION_START', // {
|
|
18
|
+
EXPRESSION_END = 'EXPRESSION_END', // }
|
|
19
|
+
EXPRESSION_CONTENT = 'EXPRESSION_CONTENT', // JavaScript code inside {}
|
|
20
|
+
|
|
21
|
+
// Directives
|
|
22
|
+
DIRECTIVE_IF = 'DIRECTIVE_IF', // l-if
|
|
23
|
+
DIRECTIVE_ELSE_IF = 'DIRECTIVE_ELSE_IF', // l-else-if
|
|
24
|
+
DIRECTIVE_ELSE = 'DIRECTIVE_ELSE', // l-else
|
|
25
|
+
DIRECTIVE_FOR = 'DIRECTIVE_FOR', // l-for
|
|
26
|
+
|
|
27
|
+
// Fragments
|
|
28
|
+
FRAGMENT_OPEN = 'FRAGMENT_OPEN', // <>
|
|
29
|
+
FRAGMENT_CLOSE = 'FRAGMENT_CLOSE', // </>
|
|
30
|
+
|
|
31
|
+
KEYWORD_REF = 'KEYWORD_REF', // ref
|
|
32
|
+
|
|
33
|
+
// Text and comments
|
|
34
|
+
TEXT = 'TEXT', // text content
|
|
35
|
+
COMMENT = 'COMMENT', // <!-- ... -->
|
|
36
|
+
|
|
37
|
+
// Whitespace
|
|
38
|
+
WHITESPACE = 'WHITESPACE',
|
|
39
|
+
|
|
40
|
+
// End of file
|
|
41
|
+
EOF = 'EOF',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Position information for error reporting
|
|
46
|
+
*/
|
|
47
|
+
export interface Position {
|
|
48
|
+
line: number;
|
|
49
|
+
column: number;
|
|
50
|
+
offset: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Token interface
|
|
55
|
+
*/
|
|
56
|
+
export interface Token {
|
|
57
|
+
type: TokenType;
|
|
58
|
+
value: string;
|
|
59
|
+
start: Position;
|
|
60
|
+
end: Position;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create a position object
|
|
65
|
+
*/
|
|
66
|
+
export function createPosition(
|
|
67
|
+
line: number,
|
|
68
|
+
column: number,
|
|
69
|
+
offset: number
|
|
70
|
+
): Position {
|
|
71
|
+
return { line, column, offset };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a token
|
|
76
|
+
*/
|
|
77
|
+
export function createToken(
|
|
78
|
+
type: TokenType,
|
|
79
|
+
value: string,
|
|
80
|
+
start: Position,
|
|
81
|
+
end: Position
|
|
82
|
+
): Token {
|
|
83
|
+
return { type, value, start, end };
|
|
84
|
+
}
|