@lithent/lithent-template-parser 0.1.0 → 0.1.1

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/README.md +1 -1
  2. package/dist/codegen/createElement.d.cts +10 -0
  3. package/dist/codegen/createElement.d.ts +1 -1
  4. package/dist/codegen/index.d.cts +1 -0
  5. package/dist/codegen/index.d.ts +1 -1
  6. package/dist/compiler.d.cts +26 -0
  7. package/dist/compiler.d.ts +8 -8
  8. package/dist/docPipe/index.d.cts +40 -0
  9. package/dist/docPipe/index.d.ts +2 -2
  10. package/dist/docPipe/scan.d.cts +8 -0
  11. package/dist/docPipe/scan.d.ts +2 -0
  12. package/dist/index.d.cts +13 -0
  13. package/dist/index.d.ts +9 -9
  14. package/dist/index.mjs +955 -1192
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/index.umd.cjs +12 -12
  17. package/dist/index.umd.cjs.map +1 -1
  18. package/dist/parser/ast.d.cts +207 -0
  19. package/dist/parser/ast.d.ts +1 -1
  20. package/dist/parser/lexer.d.cts +109 -0
  21. package/dist/parser/lexer.d.ts +1 -1
  22. package/dist/parser/parser.d.cts +80 -0
  23. package/dist/parser/parser.d.ts +5 -4
  24. package/dist/parser/tokens.d.cts +51 -0
  25. package/dist/transform/directives.d.cts +30 -0
  26. package/dist/transform/directives.d.ts +1 -1
  27. package/dist/transform/index.d.cts +14 -0
  28. package/dist/transform/index.d.ts +5 -5
  29. package/dist/transform/optimize.d.cts +21 -0
  30. package/dist/transform/optimize.d.ts +1 -1
  31. package/package.json +2 -2
  32. package/src/codegen/createElement.ts +48 -332
  33. package/src/compiler.ts +1 -1
  34. package/src/docPipe/scan.ts +209 -246
  35. package/src/parser/lexer.ts +41 -91
  36. package/src/parser/parser.ts +45 -39
  37. package/src/tests/compiler.test.ts +2 -2
  38. package/src/tests/parser.test.ts +2 -2
  39. package/src/tests/regressions.test.ts +200 -0
  40. package/src/transform/directives.ts +25 -2
  41. package/src/transform/optimize.ts +37 -25
@@ -4,201 +4,214 @@ export interface TemplateMatch {
4
4
  snippet: string;
5
5
  }
6
6
 
7
- const DEFAULT_ALPHA_NUMERIC = new Set<number>();
7
+ const identifierStart = (char: string): boolean => /[A-Za-z_$]/.test(char);
8
+ const identifierPart = (char: string): boolean => /[\w$]/.test(char);
9
+ const expressionKeywords = new Set([
10
+ 'return',
11
+ 'throw',
12
+ 'yield',
13
+ 'await',
14
+ 'case',
15
+ 'delete',
16
+ 'void',
17
+ 'typeof',
18
+ 'new',
19
+ 'in',
20
+ 'instanceof',
21
+ 'else',
22
+ 'do',
23
+ ]);
8
24
 
9
- for (let code = 48; code <= 57; code++) {
10
- DEFAULT_ALPHA_NUMERIC.add(code);
11
- }
12
- for (let code = 65; code <= 90; code++) {
13
- DEFAULT_ALPHA_NUMERIC.add(code);
14
- }
15
- for (let code = 97; code <= 122; code++) {
16
- DEFAULT_ALPHA_NUMERIC.add(code);
17
- }
18
- DEFAULT_ALPHA_NUMERIC.add(95); // _
19
- DEFAULT_ALPHA_NUMERIC.add(36); // $
20
-
21
- const isAlphaNumeric = (code: number): boolean => {
22
- return DEFAULT_ALPHA_NUMERIC.has(code);
23
- };
24
-
25
- const shouldAttemptTemplate = (source: string, index: number): boolean => {
26
- if (source[index] !== '<') {
27
- return false;
28
- }
29
-
30
- const next = source.charCodeAt(index + 1);
31
- if (Number.isNaN(next)) {
32
- return false;
33
- }
34
-
35
- // Allow fragments <>, closing </, comments <!-, normal tags <a
36
- const isValidNext =
37
- next === 47 || // /
38
- next === 62 || // >
39
- next === 33 || // ! (e.g. <!-- -->)
40
- isAlphaNumeric(next);
41
- if (!isValidNext) {
42
- return false;
25
+ const skipQuoted = (source: string, index: number, quote: string): number => {
26
+ let i = index + 1;
27
+ while (i < source.length) {
28
+ if (source[i] === '\\') i += 2;
29
+ else if (source[i++] === quote) return i;
43
30
  }
31
+ return source.length;
32
+ };
44
33
 
45
- // Ensure previous meaningful char is not part of an identifier (e.g. a<b)
46
- let i = index - 1;
47
- while (i >= 0) {
48
- const code = source.charCodeAt(i);
49
- if (code === 32 || code === 9 || code === 10 || code === 13) {
50
- i--;
51
- continue;
52
- }
53
- if (isAlphaNumeric(code)) {
54
- // Identifier characters => likely relational operator (e.g. a<b)
55
- return false;
56
- }
57
- break;
58
- }
34
+ const skipLineComment = (source: string, index: number): number => {
35
+ let i = index;
36
+ while (i < source.length && !/[\r\n]/.test(source[i])) i++;
37
+ return i;
38
+ };
59
39
 
60
- return true;
40
+ const skipBlockComment = (source: string, index: number): number => {
41
+ const end = source.indexOf('*/', index + 2);
42
+ return end === -1 ? source.length : end + 2;
61
43
  };
62
44
 
63
- const skipQuoted = (source: string, index: number, quote: string): number => {
45
+ const skipRegex = (source: string, index: number): number => {
64
46
  let i = index + 1;
47
+ let inClass = false;
65
48
  while (i < source.length) {
66
- const ch = source[i];
67
- if (ch === '\\') {
68
- i += 2;
69
- continue;
70
- }
71
- if (ch === quote) {
72
- return i + 1;
73
- }
74
- i++;
49
+ const char = source[i++];
50
+ if (char === '\\') i++;
51
+ else if (char === '[') inClass = true;
52
+ else if (char === ']') inClass = false;
53
+ else if (char === '/' && !inClass) {
54
+ while (i < source.length && /[a-z]/i.test(source[i])) i++;
55
+ return i;
56
+ } else if (char === '\n' || char === '\r') return i;
75
57
  }
76
- return source.length;
58
+ return i;
77
59
  };
78
60
 
79
61
  const skipTemplateLiteral = (source: string, index: number): number => {
80
62
  let i = index + 1;
81
63
  while (i < source.length) {
82
- const ch = source[i];
83
- if (ch === '\\') {
84
- i += 2;
85
- continue;
86
- }
87
- if (ch === '`') {
88
- return i + 1;
89
- }
90
- if (ch === '$' && source[i + 1] === '{') {
91
- const result = skipJsExpression(source, i + 2);
92
- if (result == null) {
93
- return source.length;
94
- }
95
- i = result;
96
- continue;
97
- }
98
- i++;
99
- }
100
- return source.length;
101
- };
102
-
103
- const skipLineComment = (source: string, index: number): number => {
104
- let i = index;
105
- while (i < source.length && source[i] !== '\n') {
106
- i++;
64
+ if (source[i] === '\\') i += 2;
65
+ else if (source[i] === '`') return i + 1;
66
+ else if (source[i] === '$' && source[i + 1] === '{') {
67
+ const end = skipJsExpression(source, i + 2);
68
+ if (end === null) return source.length;
69
+ i = end;
70
+ } else i++;
107
71
  }
108
72
  return i;
109
73
  };
110
74
 
111
- const skipBlockComment = (source: string, index: number): number => {
112
- const end = source.indexOf('*/', index);
113
- return end === -1 ? source.length : end + 2;
114
- };
75
+ interface JsContext {
76
+ controlParentheses: boolean[];
77
+ controlKeyword: boolean;
78
+ blocks: boolean[];
79
+ previous: string;
80
+ statementStart: boolean;
81
+ }
82
+
83
+ const createContext = (statementStart: boolean): JsContext => ({
84
+ controlParentheses: [],
85
+ controlKeyword: false,
86
+ blocks: [],
87
+ previous: '',
88
+ statementStart,
89
+ });
90
+
91
+ interface JsToken {
92
+ end: number;
93
+ expressionAllowed: boolean;
94
+ }
115
95
 
116
- const skipHtmlComment = (source: string, index: number): number => {
117
- const end = source.indexOf('-->', index);
118
- return end === -1 ? source.length : end + 3;
96
+ /** Advance one JS token without interpreting strings, comments, or regexes as markup. */
97
+ const nextJsToken = (
98
+ source: string,
99
+ index: number,
100
+ expressionAllowed: boolean,
101
+ context: JsContext
102
+ ): JsToken => {
103
+ const char = source[index];
104
+ if (/\s/.test(char)) return { end: index + 1, expressionAllowed };
105
+ if (char === '/' && source[index + 1] === '/') {
106
+ return { end: skipLineComment(source, index + 2), expressionAllowed };
107
+ }
108
+ if (char === '/' && source[index + 1] === '*') {
109
+ return { end: skipBlockComment(source, index), expressionAllowed };
110
+ }
111
+ if (char === '"' || char === "'") {
112
+ context.previous = 'literal';
113
+ context.statementStart = false;
114
+ return { end: skipQuoted(source, index, char), expressionAllowed: false };
115
+ }
116
+ if (char === '`')
117
+ return {
118
+ end: skipTemplateLiteral(source, index),
119
+ expressionAllowed: false,
120
+ };
121
+ if (char === '/' && expressionAllowed)
122
+ return { end: skipRegex(source, index), expressionAllowed: false };
123
+ if (identifierStart(char)) {
124
+ let end = index + 1;
125
+ while (end < source.length && identifierPart(source[end])) end++;
126
+ const word = source.slice(index, end);
127
+ context.controlKeyword = /^(if|while|for|with|switch|catch)$/.test(word);
128
+ context.previous = word;
129
+ context.statementStart = /^(else|do|try|finally)$/.test(word);
130
+ return { end, expressionAllowed: expressionKeywords.has(word) };
131
+ }
132
+ if (/[0-9]/.test(char)) {
133
+ let end = index + 1;
134
+ while (end < source.length && /[\w.]/.test(source[end])) end++;
135
+ return { end, expressionAllowed: false };
136
+ }
137
+ if (char === '(') {
138
+ context.controlParentheses.push(context.controlKeyword);
139
+ context.controlKeyword = false;
140
+ context.previous = char;
141
+ context.statementStart = false;
142
+ return { end: index + 1, expressionAllowed: true };
143
+ }
144
+ if (char === ')') {
145
+ const control = context.controlParentheses.pop() ?? false;
146
+ context.previous = char;
147
+ context.statementStart = control;
148
+ return { end: index + 1, expressionAllowed: control };
149
+ }
150
+ if (char === '{') {
151
+ const block =
152
+ context.statementStart ||
153
+ context.previous === ')' ||
154
+ context.previous === '>';
155
+ context.blocks.push(block);
156
+ context.previous = char;
157
+ context.statementStart = block;
158
+ return { end: index + 1, expressionAllowed: true };
159
+ }
160
+ if (char === '}') {
161
+ const block = context.blocks.pop() ?? false;
162
+ context.previous = char;
163
+ context.statementStart = block;
164
+ return { end: index + 1, expressionAllowed: block };
165
+ }
166
+ context.previous = char;
167
+ context.statementStart = char === ';';
168
+ if ((char === '+' || char === '-') && source[index + 1] === char) {
169
+ return { end: index + 2, expressionAllowed };
170
+ }
171
+ return { end: index + 1, expressionAllowed: !/[)\]}.]/.test(char) };
119
172
  };
120
173
 
121
- const skipJsExpression = (source: string, index: number): number | null => {
174
+ /** Return the offset after a matching }, starting immediately after the opening {. */
175
+ export const skipJsExpression = (
176
+ source: string,
177
+ index: number
178
+ ): number | null => {
122
179
  let depth = 1;
123
180
  let i = index;
181
+ let expressionAllowed = true;
182
+ const context = createContext(false);
124
183
  while (i < source.length) {
125
- const ch = source[i];
126
- if (ch === '\\') {
127
- i += 2;
128
- continue;
129
- }
130
- if (ch === "'" || ch === '"') {
131
- i = skipQuoted(source, i, ch);
132
- continue;
133
- }
134
- if (ch === '`') {
135
- i = skipTemplateLiteral(source, i);
136
- continue;
137
- }
138
- if (ch === '/' && source[i + 1] === '*') {
139
- i = skipBlockComment(source, i + 2);
140
- continue;
141
- }
142
- if (ch === '/' && source[i + 1] === '/') {
143
- i = skipLineComment(source, i + 2);
144
- continue;
145
- }
146
- if (ch === '{') {
147
- depth++;
148
- i++;
149
- continue;
150
- }
151
- if (ch === '}') {
152
- depth--;
153
- i++;
154
- if (depth === 0) {
155
- return i;
184
+ const char = source[i];
185
+ if (char === '<' && expressionAllowed) {
186
+ const match = extractTemplate(source, i);
187
+ if (match) {
188
+ i = match.end;
189
+ expressionAllowed = false;
190
+ context.previous = 'template';
191
+ context.statementStart = false;
192
+ continue;
156
193
  }
157
- continue;
158
194
  }
159
- i++;
195
+ if (char === '{') depth++;
196
+ else if (char === '}' && --depth === 0) return i + 1;
197
+ const token = nextJsToken(source, i, expressionAllowed, context);
198
+ i = token.end;
199
+ expressionAllowed = token.expressionAllowed;
160
200
  }
161
201
  return null;
162
202
  };
163
203
 
164
- interface SkipTagResult {
165
- position: number;
166
- selfClosing: boolean;
167
- }
168
-
169
- const skipTag = (
170
- source: string,
171
- index: number,
172
- closing: boolean
173
- ): SkipTagResult | null => {
204
+ const skipTag = (source: string, index: number): number | null => {
174
205
  let i = index;
175
206
  while (i < source.length) {
176
- const ch = source[i];
177
- if (ch === "'" || ch === '"') {
178
- i = skipQuoted(source, i, ch);
179
- continue;
180
- }
181
- if (ch === '{') {
182
- const result = skipJsExpression(source, i + 1);
183
- if (result == null) {
184
- return null;
185
- }
186
- i = result;
187
- continue;
188
- }
189
- if (ch === '/' && source[i + 1] === '*') {
190
- i = skipBlockComment(source, i + 2);
191
- continue;
192
- }
193
- if (ch === '/' && source[i + 1] === '/') {
194
- i = skipLineComment(source, i + 2);
195
- continue;
196
- }
197
- if (ch === '>') {
198
- const selfClosing = !closing && source[i - 1] === '/';
199
- return { position: i + 1, selfClosing };
200
- }
201
- i++;
207
+ const char = source[i];
208
+ if (char === '"' || char === "'") i = skipQuoted(source, i, char);
209
+ else if (char === '{') {
210
+ const end = skipJsExpression(source, i + 1);
211
+ if (end === null) return null;
212
+ i = end;
213
+ } else if (char === '>') return i + 1;
214
+ else i++;
202
215
  }
203
216
  return null;
204
217
  };
@@ -207,112 +220,62 @@ const extractTemplate = (
207
220
  source: string,
208
221
  start: number
209
222
  ): TemplateMatch | null => {
210
- const len = source.length;
223
+ if (source[start] !== '<' || !/[A-Za-z_$>!]/.test(source[start + 1] ?? ''))
224
+ return null;
211
225
  let pos = start;
212
226
  let depth = 0;
213
-
214
- while (pos < len) {
215
- const ch = source[pos];
216
-
217
- if (ch === '/' && source[pos + 1] === '*') {
218
- pos = skipBlockComment(source, pos + 2);
219
- continue;
220
- }
221
- if (ch === '/' && source[pos + 1] === '/') {
222
- pos = skipLineComment(source, pos + 2);
227
+ while (pos < source.length) {
228
+ if (source.startsWith('<!--', pos)) {
229
+ const end = source.indexOf('-->', pos + 4);
230
+ if (end === -1) return null;
231
+ pos = end + 3;
223
232
  continue;
224
233
  }
225
- if (ch === '{') {
226
- const result = skipJsExpression(source, pos + 1);
227
- if (result != null) {
228
- pos = result;
234
+ if (source[pos] === '{') {
235
+ const end = skipJsExpression(source, pos + 1);
236
+ if (end !== null) {
237
+ pos = end;
229
238
  continue;
230
239
  }
240
+ // Still extract a malformed template when possible so compile can diagnose it.
231
241
  }
232
- if (ch === '<') {
233
- if (source.startsWith('<!--', pos)) {
234
- pos = skipHtmlComment(source, pos + 4);
235
- continue;
236
- }
237
-
238
- if (source[pos + 1] === '/') {
239
- depth--;
240
- const res = skipTag(source, pos + 2, true);
241
- if (!res) {
242
- return null;
243
- }
244
- pos = res.position;
245
- if (depth === 0) {
246
- return {
247
- start,
248
- end: pos,
249
- snippet: source.slice(start, pos),
250
- };
251
- }
252
- continue;
253
- } else {
254
- depth++;
255
- const res = skipTag(source, pos + 1, false);
256
- if (!res) {
257
- return null;
258
- }
259
- pos = res.position;
260
- if (res.selfClosing) {
261
- depth--;
262
- if (depth === 0) {
263
- return {
264
- start,
265
- end: pos,
266
- snippet: source.slice(start, pos),
267
- };
268
- }
269
- }
270
- continue;
271
- }
242
+ if (source[pos] === '<') {
243
+ const closing = source[pos + 1] === '/';
244
+ const end = skipTag(source, pos + (closing ? 2 : 1));
245
+ if (end === null) return null;
246
+ depth += closing ? -1 : 1;
247
+ if (!closing && source[end - 2] === '/') depth--;
248
+ pos = end;
249
+ if (depth === 0)
250
+ return { start, end: pos, snippet: source.slice(start, pos) };
251
+ continue;
272
252
  }
273
-
253
+ // Template text is not JavaScript: quotes and // or /* have no special meaning here.
274
254
  pos++;
275
255
  }
276
-
277
256
  return null;
278
257
  };
279
258
 
280
259
  export const scanTemplates = (code: string): TemplateMatch[] => {
281
260
  const matches: TemplateMatch[] = [];
282
- const len = code.length;
283
261
  let index = 0;
284
-
285
- while (index < len) {
286
- const ch = code[index];
287
-
288
- if (ch === "'" || ch === '"') {
289
- index = skipQuoted(code, index, ch);
290
- continue;
291
- }
292
- if (ch === '`') {
293
- index = skipTemplateLiteral(code, index);
294
- continue;
295
- }
296
- if (ch === '/' && code[index + 1] === '*') {
297
- index = skipBlockComment(code, index + 2);
298
- continue;
299
- }
300
- if (ch === '/' && code[index + 1] === '/') {
301
- index = skipLineComment(code, index + 2);
302
- continue;
303
- }
304
-
305
- if (ch === '<' && shouldAttemptTemplate(code, index)) {
262
+ let expressionAllowed = true;
263
+ const context = createContext(true);
264
+ while (index < code.length) {
265
+ if (code[index] === '<' && expressionAllowed) {
306
266
  const match = extractTemplate(code, index);
307
267
  if (match) {
308
268
  matches.push(match);
309
269
  index = match.end;
270
+ expressionAllowed = false;
271
+ context.previous = 'template';
272
+ context.statementStart = false;
310
273
  continue;
311
274
  }
312
275
  }
313
-
314
- index++;
276
+ const token = nextJsToken(code, index, expressionAllowed, context);
277
+ index = token.end;
278
+ expressionAllowed = token.expressionAllowed;
315
279
  }
316
-
317
280
  return matches;
318
281
  };