@markuplint/tagged-template-literal-parser 5.0.0-alpha.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/ARCHITECTURE.ja.md +102 -0
- package/ARCHITECTURE.md +102 -0
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/SKILL.md +57 -0
- package/docs/maintenance.ja.md +96 -0
- package/docs/maintenance.md +96 -0
- package/lib/find-template-literals.d.ts +37 -0
- package/lib/find-template-literals.js +116 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +7 -0
- package/lib/parser.d.ts +36 -0
- package/lib/parser.js +109 -0
- package/package.json +34 -0
- package/src/find-template-literals.spec.ts +161 -0
- package/src/find-template-literals.ts +160 -0
- package/src/index.spec.ts +423 -0
- package/src/index.ts +8 -0
- package/src/parser.ts +122 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
|
|
3
|
+
import { ParserError, nodeListToDebugMaps } from '@markuplint/parser-utils';
|
|
4
|
+
import { describe, test, expect } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { parser } from './parser.js';
|
|
7
|
+
|
|
8
|
+
const parse = parser.parse.bind(parser);
|
|
9
|
+
|
|
10
|
+
describe('Basic parsing', () => {
|
|
11
|
+
test('simple element', () => {
|
|
12
|
+
const doc = parse('const t = html`<div></div>`;');
|
|
13
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
14
|
+
'[1:16]>[1:21](15,20)div: <div>',
|
|
15
|
+
'[1:21]>[1:27](20,26)div: </div>',
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('element with text', () => {
|
|
20
|
+
const doc = parse('const t = html`<div>hello</div>`;');
|
|
21
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
22
|
+
'[1:16]>[1:21](15,20)div: <div>',
|
|
23
|
+
'[1:21]>[1:26](20,25)#text: hello',
|
|
24
|
+
'[1:26]>[1:32](25,31)div: </div>',
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('self-closing element', () => {
|
|
29
|
+
const doc = parse('const t = html`<br />`;');
|
|
30
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual(['[1:16]>[1:22](15,21)br: <br␣/>']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('nested elements', () => {
|
|
34
|
+
const doc = parse('const t = html`<div><span>text</span></div>`;');
|
|
35
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
36
|
+
'[1:16]>[1:21](15,20)div: <div>',
|
|
37
|
+
'[1:21]>[1:27](20,26)span: <span>',
|
|
38
|
+
'[1:27]>[1:31](26,30)#text: text',
|
|
39
|
+
'[1:31]>[1:38](30,37)span: </span>',
|
|
40
|
+
'[1:38]>[1:44](37,43)div: </div>',
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('empty template literal', () => {
|
|
45
|
+
const doc = parse('const t = html``;');
|
|
46
|
+
expect(doc.nodeList.length).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('whitespace-only template literal', () => {
|
|
50
|
+
const doc = parse('const t = html` `;');
|
|
51
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual(['[1:16]>[1:17](15,16)#text: ␣']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('empty string input', () => {
|
|
55
|
+
const doc = parse('');
|
|
56
|
+
expect(doc.nodeList.length).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Template expressions as PSBlock', () => {
|
|
61
|
+
test('expression in text content', () => {
|
|
62
|
+
const doc = parse('const t = html`<div>${name}</div>`;');
|
|
63
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
64
|
+
'[1:16]>[1:21](15,20)div: <div>',
|
|
65
|
+
'[1:21]>[1:28](20,27)#ps:ttl-expression: ${name}',
|
|
66
|
+
'[1:28]>[1:34](27,33)div: </div>',
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('expression surrounded by text', () => {
|
|
71
|
+
const doc = parse('const t = html`<div>hello ${name} world</div>`;');
|
|
72
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
73
|
+
'[1:16]>[1:21](15,20)div: <div>',
|
|
74
|
+
'[1:21]>[1:27](20,26)#text: hello␣',
|
|
75
|
+
'[1:27]>[1:34](26,33)#ps:ttl-expression: ${name}',
|
|
76
|
+
'[1:34]>[1:40](33,39)#text: ␣world',
|
|
77
|
+
'[1:40]>[1:46](39,45)div: </div>',
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('multiple expressions', () => {
|
|
82
|
+
const doc = parse('const t = html`<div>${first} ${last}</div>`;');
|
|
83
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
84
|
+
'[1:16]>[1:21](15,20)div: <div>',
|
|
85
|
+
'[1:21]>[1:29](20,28)#ps:ttl-expression: ${first}',
|
|
86
|
+
'[1:29]>[1:30](28,29)#text: ␣',
|
|
87
|
+
'[1:30]>[1:37](29,36)#ps:ttl-expression: ${last}',
|
|
88
|
+
'[1:37]>[1:43](36,42)div: </div>',
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('adjacent expressions with no separator', () => {
|
|
93
|
+
const doc = parse('const t = html`<div>${a}${b}</div>`;');
|
|
94
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
95
|
+
'[1:16]>[1:21](15,20)div: <div>',
|
|
96
|
+
'[1:21]>[1:25](20,24)#ps:ttl-expression: ${a}',
|
|
97
|
+
'[1:25]>[1:29](24,28)#ps:ttl-expression: ${b}',
|
|
98
|
+
'[1:29]>[1:35](28,34)div: </div>',
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('expression in attribute value', () => {
|
|
103
|
+
const doc = parse('const t = html`<div class="${cls}"></div>`;');
|
|
104
|
+
expect(nodeListToDebugMaps(doc.nodeList, true)).toStrictEqual([
|
|
105
|
+
'[1:16]>[1:36](15,35)div: <div␣class="${cls}">',
|
|
106
|
+
'[1:21]>[1:35](20,34)class: class="${cls}"',
|
|
107
|
+
' [1:20]>[1:21](19,20)bN: ␣',
|
|
108
|
+
' [1:21]>[1:26](20,25)name: class',
|
|
109
|
+
' [1:26]>[1:26](25,25)bE: ',
|
|
110
|
+
' [1:26]>[1:27](25,26)equal: =',
|
|
111
|
+
' [1:27]>[1:27](26,26)aE: ',
|
|
112
|
+
' [1:27]>[1:28](26,27)sQ: "',
|
|
113
|
+
' [1:28]>[1:34](27,33)value: ${cls}',
|
|
114
|
+
' [1:34]>[1:35](33,34)eQ: "',
|
|
115
|
+
' isDirective: false',
|
|
116
|
+
' isDynamicValue: true',
|
|
117
|
+
'[1:36]>[1:42](35,41)div: </div>',
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('expression in attribute with static prefix', () => {
|
|
122
|
+
const doc = parse('const t = html`<div class="prefix-${cls}"></div>`;');
|
|
123
|
+
expect(nodeListToDebugMaps(doc.nodeList, true)).toStrictEqual([
|
|
124
|
+
'[1:16]>[1:43](15,42)div: <div␣class="prefix-${cls}">',
|
|
125
|
+
'[1:21]>[1:42](20,41)class: class="prefix-${cls}"',
|
|
126
|
+
' [1:20]>[1:21](19,20)bN: ␣',
|
|
127
|
+
' [1:21]>[1:26](20,25)name: class',
|
|
128
|
+
' [1:26]>[1:26](25,25)bE: ',
|
|
129
|
+
' [1:26]>[1:27](25,26)equal: =',
|
|
130
|
+
' [1:27]>[1:27](26,26)aE: ',
|
|
131
|
+
' [1:27]>[1:28](26,27)sQ: "',
|
|
132
|
+
' [1:28]>[1:41](27,40)value: prefix-${cls}',
|
|
133
|
+
' [1:41]>[1:42](40,41)eQ: "',
|
|
134
|
+
' isDirective: false',
|
|
135
|
+
' isDynamicValue: true',
|
|
136
|
+
'[1:43]>[1:49](42,48)div: </div>',
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('PSBlock node name', () => {
|
|
141
|
+
const doc = parse('const t = html`<div>${name}</div>`;');
|
|
142
|
+
expect(doc.nodeList[1].nodeName).toBe('#ps:ttl-expression');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('Multiline templates', () => {
|
|
147
|
+
test('multiline HTML', () => {
|
|
148
|
+
const doc = parse(`const t = html\`
|
|
149
|
+
<div>
|
|
150
|
+
<span>hello</span>
|
|
151
|
+
</div>
|
|
152
|
+
\`;`);
|
|
153
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
154
|
+
'[1:16]>[2:1](15,16)#text: ⏎',
|
|
155
|
+
'[2:1]>[2:6](16,21)div: <div>',
|
|
156
|
+
'[2:6]>[3:3](21,24)#text: ⏎␣␣',
|
|
157
|
+
'[3:3]>[3:9](24,30)span: <span>',
|
|
158
|
+
'[3:9]>[3:14](30,35)#text: hello',
|
|
159
|
+
'[3:14]>[3:21](35,42)span: </span>',
|
|
160
|
+
'[3:21]>[4:1](42,43)#text: ⏎',
|
|
161
|
+
'[4:1]>[4:7](43,49)div: </div>',
|
|
162
|
+
'[4:7]>[5:1](49,50)#text: ⏎',
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('multiline with expressions', () => {
|
|
167
|
+
const doc = parse(`const t = html\`
|
|
168
|
+
<ul>
|
|
169
|
+
<li>\${item1}</li>
|
|
170
|
+
<li>\${item2}</li>
|
|
171
|
+
</ul>
|
|
172
|
+
\`;`);
|
|
173
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
174
|
+
'[1:16]>[2:1](15,16)#text: ⏎',
|
|
175
|
+
'[2:1]>[2:5](16,20)ul: <ul>',
|
|
176
|
+
'[2:5]>[3:3](20,23)#text: ⏎␣␣',
|
|
177
|
+
'[3:3]>[3:7](23,27)li: <li>',
|
|
178
|
+
'[3:7]>[3:15](27,35)#ps:ttl-expression: ${item1}',
|
|
179
|
+
'[3:15]>[3:20](35,40)li: </li>',
|
|
180
|
+
'[3:20]>[4:3](40,43)#text: ⏎␣␣',
|
|
181
|
+
'[4:3]>[4:7](43,47)li: <li>',
|
|
182
|
+
'[4:7]>[4:15](47,55)#ps:ttl-expression: ${item2}',
|
|
183
|
+
'[4:15]>[4:20](55,60)li: </li>',
|
|
184
|
+
'[4:20]>[5:1](60,61)#text: ⏎',
|
|
185
|
+
'[5:1]>[5:6](61,66)ul: </ul>',
|
|
186
|
+
'[5:6]>[6:1](66,67)#text: ⏎',
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('Multiple template literals', () => {
|
|
192
|
+
test('two template literals in one file', () => {
|
|
193
|
+
const doc = parse(`const a = html\`<div>aaa</div>\`;
|
|
194
|
+
const b = html\`<span>bbb</span>\`;`);
|
|
195
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
196
|
+
// First template literal: html` starts at offset 14, content at 15
|
|
197
|
+
expect(maps).toContain('[1:16]>[1:21](15,20)div: <div>');
|
|
198
|
+
expect(maps).toContain('[1:21]>[1:24](20,23)#text: aaa');
|
|
199
|
+
expect(maps).toContain('[1:24]>[1:30](23,29)div: </div>');
|
|
200
|
+
// Second template literal: html` starts at offset 46, content at 47
|
|
201
|
+
expect(maps).toContain('[2:16]>[2:22](47,53)span: <span>');
|
|
202
|
+
expect(maps).toContain('[2:22]>[2:25](53,56)#text: bbb');
|
|
203
|
+
expect(maps).toContain('[2:25]>[2:32](56,63)span: </span>');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('Non-matching tags', () => {
|
|
208
|
+
test('untagged template literal is ignored', () => {
|
|
209
|
+
const doc = parse('const t = `<div></div>`;');
|
|
210
|
+
expect(doc.nodeList.length).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('different tag name is ignored', () => {
|
|
214
|
+
const doc = parse('const t = css`div { color: red; }`;');
|
|
215
|
+
expect(doc.nodeList.length).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('no template literal at all', () => {
|
|
219
|
+
const doc = parse('const x = 42;');
|
|
220
|
+
expect(doc.nodeList.length).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('Complex expressions', () => {
|
|
225
|
+
test('expression with method call', () => {
|
|
226
|
+
const doc = parse('const t = html`<div>${obj.getName()}</div>`;');
|
|
227
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
228
|
+
'[1:16]>[1:21](15,20)div: <div>',
|
|
229
|
+
'[1:21]>[1:37](20,36)#ps:ttl-expression: ${obj.getName()}',
|
|
230
|
+
'[1:37]>[1:43](36,42)div: </div>',
|
|
231
|
+
]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('expression with ternary operator', () => {
|
|
235
|
+
const doc = parse('const t = html`<div>${cond ? "a" : "b"}</div>`;');
|
|
236
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
237
|
+
'[1:16]>[1:21](15,20)div: <div>',
|
|
238
|
+
'[1:21]>[1:40](20,39)#ps:ttl-expression: ${cond␣?␣"a"␣:␣"b"}',
|
|
239
|
+
'[1:40]>[1:46](39,45)div: </div>',
|
|
240
|
+
]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('expression with nested template literal', () => {
|
|
244
|
+
// The outer template's ${...} is split by ignoreBlock at the first }
|
|
245
|
+
// inside the inner template. This is a known limitation.
|
|
246
|
+
// The inner html`<li>${i}</li>` is correctly parsed as a separate template.
|
|
247
|
+
const doc = parse('const t = html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>`;');
|
|
248
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
249
|
+
|
|
250
|
+
// Outer template: <ul> and </ul> tags are present
|
|
251
|
+
expect(maps[0]).toBe('[1:16]>[1:20](15,19)ul: <ul>');
|
|
252
|
+
|
|
253
|
+
// Inner template: correctly parsed as separate template literal
|
|
254
|
+
expect(maps).toContainEqual(expect.stringContaining('li: <li>'));
|
|
255
|
+
expect(maps).toContainEqual(expect.stringContaining('#ps:ttl-expression: ${i}'));
|
|
256
|
+
expect(maps).toContainEqual(expect.stringContaining('li: </li>'));
|
|
257
|
+
|
|
258
|
+
// Total node count is deterministic
|
|
259
|
+
expect(maps).toHaveLength(8);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('Attributes', () => {
|
|
264
|
+
test('static attributes', () => {
|
|
265
|
+
const doc = parse('const t = html`<div id="main" class="container"></div>`;');
|
|
266
|
+
expect(nodeListToDebugMaps(doc.nodeList, true)).toStrictEqual([
|
|
267
|
+
'[1:16]>[1:49](15,48)div: <div␣id="main"␣class="container">',
|
|
268
|
+
'[1:21]>[1:30](20,29)id: id="main"',
|
|
269
|
+
' [1:20]>[1:21](19,20)bN: ␣',
|
|
270
|
+
' [1:21]>[1:23](20,22)name: id',
|
|
271
|
+
' [1:23]>[1:23](22,22)bE: ',
|
|
272
|
+
' [1:23]>[1:24](22,23)equal: =',
|
|
273
|
+
' [1:24]>[1:24](23,23)aE: ',
|
|
274
|
+
' [1:24]>[1:25](23,24)sQ: "',
|
|
275
|
+
' [1:25]>[1:29](24,28)value: main',
|
|
276
|
+
' [1:29]>[1:30](28,29)eQ: "',
|
|
277
|
+
' isDirective: false',
|
|
278
|
+
' isDynamicValue: false',
|
|
279
|
+
'[1:31]>[1:48](30,47)class: class="container"',
|
|
280
|
+
' [1:30]>[1:31](29,30)bN: ␣',
|
|
281
|
+
' [1:31]>[1:36](30,35)name: class',
|
|
282
|
+
' [1:36]>[1:36](35,35)bE: ',
|
|
283
|
+
' [1:36]>[1:37](35,36)equal: =',
|
|
284
|
+
' [1:37]>[1:37](36,36)aE: ',
|
|
285
|
+
' [1:37]>[1:38](36,37)sQ: "',
|
|
286
|
+
' [1:38]>[1:47](37,46)value: container',
|
|
287
|
+
' [1:47]>[1:48](46,47)eQ: "',
|
|
288
|
+
' isDirective: false',
|
|
289
|
+
' isDynamicValue: false',
|
|
290
|
+
'[1:49]>[1:55](48,54)div: </div>',
|
|
291
|
+
]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('boolean attribute', () => {
|
|
295
|
+
const doc = parse('const t = html`<input disabled />`;');
|
|
296
|
+
const maps = nodeListToDebugMaps(doc.nodeList, true);
|
|
297
|
+
expect(maps[0]).toContain('input: <input␣disabled␣/>');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('expression in attribute position (spread-like)', () => {
|
|
301
|
+
const doc = parse('const t = html`<div ${attrs}></div>`;');
|
|
302
|
+
// Expression in attribute position is handled by ignoreBlock
|
|
303
|
+
expect(doc.nodeList.length).toBeGreaterThan(0);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('Lit binding syntax', () => {
|
|
308
|
+
test('property binding (.prop)', () => {
|
|
309
|
+
// Lit uses .prop=${val} for property bindings
|
|
310
|
+
// The expression is masked by ignoreBlock, resulting in unquoted attribute handling
|
|
311
|
+
const doc = parse('const t = html`<input .value=${val} />`;');
|
|
312
|
+
expect(doc.nodeList.length).toBeGreaterThan(0);
|
|
313
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
314
|
+
expect(maps[0]).toContain('input');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('boolean attribute binding (?attr)', () => {
|
|
318
|
+
// Lit uses ?attr=${val} for boolean attribute bindings
|
|
319
|
+
const doc = parse('const t = html`<div ?hidden=${hide}></div>`;');
|
|
320
|
+
expect(doc.nodeList.length).toBeGreaterThan(0);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('event binding (@event)', () => {
|
|
324
|
+
// Lit uses @event=${handler} for event bindings
|
|
325
|
+
const doc = parse('const t = html`<button @click=${handler}>Click</button>`;');
|
|
326
|
+
expect(doc.nodeList.length).toBeGreaterThan(0);
|
|
327
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
328
|
+
expect(maps[0]).toContain('button');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('HTML comments', () => {
|
|
333
|
+
test('HTML comment', () => {
|
|
334
|
+
const doc = parse('const t = html`<!-- comment --><div></div>`;');
|
|
335
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
336
|
+
'[1:16]>[1:32](15,31)#comment: <!--␣comment␣-->',
|
|
337
|
+
'[1:32]>[1:37](31,36)div: <div>',
|
|
338
|
+
'[1:37]>[1:43](36,42)div: </div>',
|
|
339
|
+
]);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('Fragment', () => {
|
|
344
|
+
test('isFragment is true', () => {
|
|
345
|
+
const doc = parse('const t = html`<div></div>`;');
|
|
346
|
+
expect(doc.isFragment).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('Error handling', () => {
|
|
351
|
+
test('invalid JS throws ParserError with location', () => {
|
|
352
|
+
expect(() => parse('const t = html`<div></div>`; @#$invalid')).toThrow(ParserError);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('ParserError preserves line and column', () => {
|
|
356
|
+
try {
|
|
357
|
+
parse('const t = html`<div></div>`; @#$invalid');
|
|
358
|
+
expect.unreachable('should have thrown');
|
|
359
|
+
} catch (error) {
|
|
360
|
+
expect(error).toBeInstanceOf(ParserError);
|
|
361
|
+
expect(error.line).toBe(1);
|
|
362
|
+
expect(error.col).toBeGreaterThan(0);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('Known limitations', () => {
|
|
368
|
+
test('object literal in expression causes incorrect splitting', () => {
|
|
369
|
+
// ${{ key: "value" }} has a nested } that ignoreBlock matches first
|
|
370
|
+
const doc = parse('const t = html`<div>${{ key: "value" }}</div>`;');
|
|
371
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
372
|
+
// The expression is split at the first } inside the object literal.
|
|
373
|
+
// This results in the trailing } appearing as a text node.
|
|
374
|
+
expect(maps).toContainEqual(expect.stringContaining('#text: }'));
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe('Edge cases', () => {
|
|
379
|
+
test('template with preceding code', () => {
|
|
380
|
+
const doc = parse(`import { html } from 'lit';
|
|
381
|
+
const greeting = html\`<h1>Hello</h1>\`;`);
|
|
382
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
383
|
+
expect(maps).toStrictEqual([
|
|
384
|
+
'[2:23]>[2:27](50,54)h1: <h1>',
|
|
385
|
+
'[2:27]>[2:32](54,59)#text: Hello',
|
|
386
|
+
'[2:32]>[2:37](59,64)h1: </h1>',
|
|
387
|
+
]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('member expression tag (e.g., LitElement.html)', () => {
|
|
391
|
+
const doc = parse('const t = LitElement.html`<div></div>`;');
|
|
392
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
393
|
+
expect(maps[0]).toContain('div: <div>');
|
|
394
|
+
expect(maps[1]).toContain('div: </div>');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('expression with curly brace in string', () => {
|
|
398
|
+
const doc = parse('const t = html`<div>${"}"}</div>`;');
|
|
399
|
+
// The } inside the string should not close the expression prematurely
|
|
400
|
+
// This is handled by the ignoreBlock mechanism
|
|
401
|
+
expect(doc.nodeList.length).toBeGreaterThan(0);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('TypeScript source with type annotation', () => {
|
|
405
|
+
const doc = parse('const t: TemplateResult = html`<div></div>`;');
|
|
406
|
+
expect(nodeListToDebugMaps(doc.nodeList)).toStrictEqual([
|
|
407
|
+
'[1:32]>[1:37](31,36)div: <div>',
|
|
408
|
+
'[1:37]>[1:43](36,42)div: </div>',
|
|
409
|
+
]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('TypeScript class with decorator', () => {
|
|
413
|
+
const doc = parse(`class MyElement extends HTMLElement {
|
|
414
|
+
render() {
|
|
415
|
+
return html\`<div>hello</div>\`;
|
|
416
|
+
}
|
|
417
|
+
}`);
|
|
418
|
+
const maps = nodeListToDebugMaps(doc.nodeList);
|
|
419
|
+
expect(maps).toContainEqual(expect.stringContaining('div: <div>'));
|
|
420
|
+
expect(maps).toContainEqual(expect.stringContaining('#text: hello'));
|
|
421
|
+
expect(maps).toContainEqual(expect.stringContaining('div: </div>'));
|
|
422
|
+
});
|
|
423
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @markuplint/tagged-template-literal-parser
|
|
3
|
+
* Tagged template literal parser for markuplint. Extracts HTML from tagged
|
|
4
|
+
* template literals (e.g., `html\`<div>...</div>\``) in TypeScript/JavaScript
|
|
5
|
+
* files and parses the HTML content for linting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { TaggedTemplateLiteralParser, parser } from './parser.js';
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { MLASTDocument } from '@markuplint/ml-ast';
|
|
2
|
+
import type { ParseOptions } from '@markuplint/parser-utils';
|
|
3
|
+
|
|
4
|
+
import { HtmlParser } from '@markuplint/html-parser';
|
|
5
|
+
import { ParserError } from '@markuplint/parser-utils';
|
|
6
|
+
|
|
7
|
+
import { findTemplateLiterals } from './find-template-literals.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parser for tagged template literals containing HTML.
|
|
11
|
+
* Extracts HTML content from tagged template expressions (e.g., `html\`<div>...</div>\``)
|
|
12
|
+
* and delegates HTML parsing to the standard HtmlParser with `${...}` expressions
|
|
13
|
+
* masked as preprocessor-specific blocks.
|
|
14
|
+
*/
|
|
15
|
+
export class TaggedTemplateLiteralParser extends HtmlParser {
|
|
16
|
+
readonly #tagNames: readonly string[];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates a new parser for tagged template literals.
|
|
20
|
+
*
|
|
21
|
+
* @param tagNames - Tag function names to recognize as HTML templates (default: `['html']`).
|
|
22
|
+
* For example, `['html', 'svg']` would match both `html\`...\`` and `svg\`...\``.
|
|
23
|
+
* Member expressions are resolved to their property name, so `LitElement.html\`...\``
|
|
24
|
+
* matches the tag name `'html'`.
|
|
25
|
+
*/
|
|
26
|
+
constructor(tagNames: readonly string[] = ['html']) {
|
|
27
|
+
super({
|
|
28
|
+
ignoreTags: [
|
|
29
|
+
{
|
|
30
|
+
type: 'ttl-expression',
|
|
31
|
+
start: '${',
|
|
32
|
+
end: '}',
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
this.#tagNames = tagNames;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parses a TypeScript/JavaScript source file, extracts tagged template literals
|
|
41
|
+
* matching the configured tag names, and parses their HTML content.
|
|
42
|
+
* Each `${...}` expression within the template is preserved as a
|
|
43
|
+
* `#ps:ttl-expression` preprocessor-specific block in the resulting AST.
|
|
44
|
+
*
|
|
45
|
+
* @param rawCode - The full TypeScript/JavaScript source code
|
|
46
|
+
* @param options - Parse options forwarded to the underlying HTML parser
|
|
47
|
+
* @returns The parsed AST document containing nodes from all matched template literals
|
|
48
|
+
*/
|
|
49
|
+
parse(rawCode: string, options?: ParseOptions): MLASTDocument {
|
|
50
|
+
let templateLiterals;
|
|
51
|
+
try {
|
|
52
|
+
templateLiterals = findTemplateLiterals(rawCode, this.#tagNames);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error instanceof Error && 'location' in error) {
|
|
55
|
+
const loc = error.location as { start?: { line: number; column: number } };
|
|
56
|
+
if (loc.start) {
|
|
57
|
+
throw new ParserError(error.message, {
|
|
58
|
+
line: loc.start.line,
|
|
59
|
+
col: loc.start.column,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (templateLiterals.length === 0) {
|
|
67
|
+
return {
|
|
68
|
+
raw: rawCode,
|
|
69
|
+
nodeList: [],
|
|
70
|
+
isFragment: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Parse all matched template literals and merge their node lists.
|
|
75
|
+
const allNodeLists: MLASTDocument['nodeList'][number][] = [];
|
|
76
|
+
|
|
77
|
+
for (const tpl of templateLiterals) {
|
|
78
|
+
const { line: offsetLine, col: offsetColumn } = getLineAndColumn(rawCode, tpl.contentStart);
|
|
79
|
+
|
|
80
|
+
const doc = super.parse(tpl.htmlContent, {
|
|
81
|
+
...options,
|
|
82
|
+
offsetOffset: tpl.contentStart,
|
|
83
|
+
offsetLine,
|
|
84
|
+
offsetColumn,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
allNodeLists.push(...doc.nodeList);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
raw: rawCode,
|
|
92
|
+
nodeList: allNodeLists,
|
|
93
|
+
isFragment: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Computes the 1-based line number and 1-based column for a given offset in a string.
|
|
100
|
+
*
|
|
101
|
+
* @param source - The source string
|
|
102
|
+
* @param offset - The character offset (0-based)
|
|
103
|
+
* @returns An object with 1-based `line` and `col` values
|
|
104
|
+
*/
|
|
105
|
+
function getLineAndColumn(source: string, offset: number): { line: number; col: number } {
|
|
106
|
+
let line = 1;
|
|
107
|
+
let col = 1;
|
|
108
|
+
for (let i = 0; i < offset; i++) {
|
|
109
|
+
if (source[i] === '\n') {
|
|
110
|
+
line++;
|
|
111
|
+
col = 1;
|
|
112
|
+
} else {
|
|
113
|
+
col++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { line, col };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Default singleton parser instance configured to match the `html` tag name.
|
|
121
|
+
*/
|
|
122
|
+
export const parser = new TaggedTemplateLiteralParser();
|