@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +159 -0
  3. package/dist/codegen/createElement.d.ts +10 -0
  4. package/dist/codegen/index.d.ts +1 -0
  5. package/dist/compiler.d.ts +26 -0
  6. package/dist/docPipe/index.d.ts +40 -0
  7. package/dist/docPipe/scan.d.ts +6 -0
  8. package/dist/index.d.ts +13 -0
  9. package/dist/index.mjs +2302 -0
  10. package/dist/index.mjs.map +1 -0
  11. package/dist/index.umd.cjs +13 -0
  12. package/dist/index.umd.cjs.map +1 -0
  13. package/dist/parser/ast.d.ts +207 -0
  14. package/dist/parser/lexer.d.ts +109 -0
  15. package/dist/parser/parser.d.ts +79 -0
  16. package/dist/parser/tokens.d.ts +51 -0
  17. package/dist/transform/directives.d.ts +30 -0
  18. package/dist/transform/index.d.ts +14 -0
  19. package/dist/transform/optimize.d.ts +21 -0
  20. package/package.json +53 -0
  21. package/src/codegen/createElement.ts +661 -0
  22. package/src/codegen/index.ts +1 -0
  23. package/src/compiler.ts +70 -0
  24. package/src/docPipe/index.ts +104 -0
  25. package/src/docPipe/scan.ts +318 -0
  26. package/src/index.ts +16 -0
  27. package/src/parser/ast.ts +478 -0
  28. package/src/parser/lexer.ts +561 -0
  29. package/src/parser/parser.ts +514 -0
  30. package/src/parser/tokens.ts +84 -0
  31. package/src/playground/main.ts +206 -0
  32. package/src/playground/style.css +135 -0
  33. package/src/shims/babel-env.ts +17 -0
  34. package/src/tests/compiler.test.ts +434 -0
  35. package/src/tests/docPipe.test.ts +41 -0
  36. package/src/tests/helpers.ts +96 -0
  37. package/src/tests/lexer.test.ts +282 -0
  38. package/src/tests/parser.test.ts +691 -0
  39. package/src/transform/directives.ts +160 -0
  40. package/src/transform/index.ts +29 -0
  41. 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
+ }