@mrhenry/twig-html-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 +24 -0
- package/package.json +16 -0
- package/src/atoms.js +335 -0
- package/src/html-tokenizer.js +1032 -0
- package/src/index.js +11 -0
- package/src/parse.js +36 -0
- package/src/parser.js +330 -0
- package/test/atoms.test.js +161 -0
- package/test/html-tokenizer.test.js +161 -0
- package/test/twig-html-parser.test.js +176 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Public API of the `@mrhenry/twig-html-parser` package.
|
|
4
|
+
*
|
|
5
|
+
* @module twig-html-parser
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { extractAtoms, serializeAtoms, atomToDebugString } from './atoms.js';
|
|
9
|
+
export { tokenizeHtml } from './html-tokenizer.js';
|
|
10
|
+
export { buildTree, pairTwigBlocks, isBlockOpen } from './parser.js';
|
|
11
|
+
export { parse } from './parse.js';
|
package/src/parse.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* End-to-end parsing of a Twig template into the hybrid HTML+Twig AST.
|
|
4
|
+
*
|
|
5
|
+
* This is the single entry point used by the Prettier plugin:
|
|
6
|
+
*
|
|
7
|
+
* 1. the `@mrhenry/twig-tokenizer` lexer produces the source-ordered token
|
|
8
|
+
* stream with exact offsets;
|
|
9
|
+
* 2. `extractAtoms` flattens it into text/twig/comment atoms;
|
|
10
|
+
* 3. `tokenizeHtml` runs the HTML5-style tokenizer over the atoms;
|
|
11
|
+
* 4. `buildTree` assembles the hybrid AST and pairs twig blocks.
|
|
12
|
+
*
|
|
13
|
+
* Because the source is never transformed, every node keeps exact offsets in
|
|
14
|
+
* the original text (needed for report-only mode, range formatting and cursor
|
|
15
|
+
* mapping).
|
|
16
|
+
*
|
|
17
|
+
* @module twig-html-parser
|
|
18
|
+
*/
|
|
19
|
+
import { Lexer, Source } from '@mrhenry/twig-tokenizer';
|
|
20
|
+
import { extractAtoms } from './atoms.js';
|
|
21
|
+
import { tokenizeHtml } from './html-tokenizer.js';
|
|
22
|
+
import { buildTree } from './parser.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses a template source string into the hybrid AST.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} source The template source code.
|
|
28
|
+
* @param {string} [name] The template name (for diagnostics).
|
|
29
|
+
* @returns {ReturnType<typeof buildTree>} The root AST node.
|
|
30
|
+
*/
|
|
31
|
+
export function parse(source, name = 'index.twig') {
|
|
32
|
+
const stream = new Lexer().tokenize(new Source(source, name));
|
|
33
|
+
const atoms = extractAtoms(source, stream.getTokens());
|
|
34
|
+
const tokens = tokenizeHtml(atoms, source);
|
|
35
|
+
return buildTree(tokens);
|
|
36
|
+
}
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Builds the hybrid HTML+Twig AST from the tokenizer's token list.
|
|
4
|
+
*
|
|
5
|
+
* The tree mirrors the source structure exactly: elements nest according to
|
|
6
|
+
* the tags the author wrote (no implied end tags, no foster parenting), and
|
|
7
|
+
* twig atoms appear in data position, inside attribute values and between
|
|
8
|
+
* attributes. Twig block tags (`if`/`for`/`block`/…) are then paired into
|
|
9
|
+
* `twigBlock` nodes so the printer can indent their bodies.
|
|
10
|
+
*
|
|
11
|
+
* @module twig-html-parser
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {import('./atoms.js').Atom} Atom
|
|
16
|
+
* @typedef {import('./html-tokenizer.js').HtmlToken} HtmlToken
|
|
17
|
+
* @typedef {import('./html-tokenizer.js').TagItem} TagItem
|
|
18
|
+
* @typedef {import('./html-tokenizer.js').HtmlAttribute} HtmlAttribute
|
|
19
|
+
* @typedef {import('./html-tokenizer.js').ValueChunk} ValueChunk
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Block tags that introduce a body terminated by a matching `end*` tag.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors `@mrhenry/twig-parser`'s `BLOCK_TAGS` / `TAG_END_NAMES` (and
|
|
26
|
+
* `spec/04-tags.md` §4.21). `set` is handled separately by {@link isBlockOpen}
|
|
27
|
+
* because only its capture form (`{% set x %}...{% endset %}`) has a body.
|
|
28
|
+
*/
|
|
29
|
+
const BLOCK_OPEN_TAGS = new Set([
|
|
30
|
+
'if', 'for', 'block', 'macro', 'embed', 'apply', 'autoescape', 'with',
|
|
31
|
+
'sandbox', 'guard',
|
|
32
|
+
]);
|
|
33
|
+
/** Closing tag name → opening tag name. */
|
|
34
|
+
/** @type {Record<string, string>} */
|
|
35
|
+
const BLOCK_CLOSE_TAGS = {
|
|
36
|
+
endif: 'if',
|
|
37
|
+
endfor: 'for',
|
|
38
|
+
endblock: 'block',
|
|
39
|
+
endset: 'set',
|
|
40
|
+
endmacro: 'macro',
|
|
41
|
+
endembed: 'embed',
|
|
42
|
+
endapply: 'apply',
|
|
43
|
+
endautoescape: 'autoescape',
|
|
44
|
+
endwith: 'with',
|
|
45
|
+
endsandbox: 'sandbox',
|
|
46
|
+
endguard: 'guard',
|
|
47
|
+
};
|
|
48
|
+
/** Tags that separate sections inside a block body. */
|
|
49
|
+
const BLOCK_MID_TAGS = new Set(['else', 'elseif']);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A hybrid AST node.
|
|
53
|
+
*
|
|
54
|
+
* @typedef {object} AstNode
|
|
55
|
+
* @property {string} type
|
|
56
|
+
* @property {number} rawStart Absolute offset where the node starts.
|
|
57
|
+
* @property {number} rawEnd Absolute offset just past the node.
|
|
58
|
+
* @property {string} [raw] The exact source text (text/twig/comment/doctype).
|
|
59
|
+
* @property {string} [name] The element name.
|
|
60
|
+
* @property {string} [nameRaw] The exact source text of the element name.
|
|
61
|
+
* @property {boolean} [selfClosing] Whether the start tag is self-closing.
|
|
62
|
+
* @property {string} [startTagRaw] The start tag source.
|
|
63
|
+
* @property {number} [startTagStart] The start tag offset.
|
|
64
|
+
* @property {number} [startTagEnd] Offset just past the start tag.
|
|
65
|
+
* @property {string} [endTagRaw] The end tag source.
|
|
66
|
+
* @property {number} [endTagStart] The end tag offset.
|
|
67
|
+
* @property {number} [endTagEnd] Offset just past the end tag.
|
|
68
|
+
* @property {Array<AstNode>} [children] Child nodes of an element or block.
|
|
69
|
+
* @property {Array<AstNode | HtmlAttribute>} [attrs] Items inside a start tag.
|
|
70
|
+
* @property {Array<{head: AstNode|null, body: Array<AstNode | HtmlAttribute>}>} [sections] Block sections.
|
|
71
|
+
* @property {Atom} [atom] The source atom of a twig leaf.
|
|
72
|
+
* @property {string} [tag] The twig block tag name.
|
|
73
|
+
* @property {AstNode} [open] The opening twig leaf of a block.
|
|
74
|
+
* @property {AstNode} [close] The closing twig leaf of a block.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} type
|
|
79
|
+
* @param {number} rawStart
|
|
80
|
+
* @param {number} rawEnd
|
|
81
|
+
* @param {object} [extra]
|
|
82
|
+
* @returns {AstNode}
|
|
83
|
+
*/
|
|
84
|
+
function node(type, rawStart, rawEnd, extra = {}) {
|
|
85
|
+
return { type, rawStart, rawEnd, ...extra };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether a twig leaf atom opens a block (has a body).
|
|
90
|
+
*
|
|
91
|
+
* @param {Atom} atom
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function isBlockOpen(atom) {
|
|
95
|
+
if (atom.isPrint || atom.kind !== 'twig' || !atom.tag) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (BLOCK_OPEN_TAGS.has(atom.tag)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
// `{% set x %}...{% endset %}` is a capture block; `{% set x = … %}` is not
|
|
102
|
+
if (atom.tag === 'set') {
|
|
103
|
+
return !/=/.test(atom.raw);
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether a twig leaf atom closes a block opened by `openTag`.
|
|
110
|
+
*
|
|
111
|
+
* @param {Atom} atom
|
|
112
|
+
* @param {string} openTag
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
function isBlockClose(atom, openTag) {
|
|
116
|
+
const tag = atom.tag;
|
|
117
|
+
if (tag === null || tag === undefined) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return BLOCK_CLOSE_TAGS[tag] === openTag;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds the hybrid AST from the HTML token list.
|
|
125
|
+
*
|
|
126
|
+
* @param {HtmlToken[]} tokens
|
|
127
|
+
* @returns {AstNode} The root node.
|
|
128
|
+
*/
|
|
129
|
+
export function buildTree(tokens) {
|
|
130
|
+
/** @type {AstNode} */
|
|
131
|
+
const root = node('root', tokens.length ? tokens[0].rawStart : 0, tokens.length ? tokens[tokens.length - 1].rawEnd : 0, { children: [] });
|
|
132
|
+
/** @type {Array<AstNode>} Stack of open elements. */
|
|
133
|
+
const stack = [root];
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @returns {AstNode} The current container (root or element).
|
|
137
|
+
*/
|
|
138
|
+
function current() {
|
|
139
|
+
return stack[stack.length - 1];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {AstNode} child
|
|
144
|
+
*/
|
|
145
|
+
function append(child) {
|
|
146
|
+
/** @type {AstNode} */
|
|
147
|
+
const container = current();
|
|
148
|
+
if (!Array.isArray(container.children)) {
|
|
149
|
+
container.children = [];
|
|
150
|
+
}
|
|
151
|
+
if (child.type === 'text') {
|
|
152
|
+
const siblings = /** @type {Array<AstNode>} */ (container.children);
|
|
153
|
+
const last = siblings[siblings.length - 1];
|
|
154
|
+
if (last && last.type === 'text') {
|
|
155
|
+
last.raw = /** @type {string} */ (last.raw) + /** @type {string} */ (child.raw);
|
|
156
|
+
last.rawEnd = child.rawEnd;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
container.children.push(child);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const token of tokens) {
|
|
164
|
+
switch (token.type) {
|
|
165
|
+
case 'startTag': {
|
|
166
|
+
const element = node('element', token.rawStart, token.rawEnd, {
|
|
167
|
+
name: token.name,
|
|
168
|
+
nameRaw: token.nameRaw,
|
|
169
|
+
selfClosing: token.selfClosing,
|
|
170
|
+
startTagRaw: token.raw,
|
|
171
|
+
startTagStart: token.rawStart,
|
|
172
|
+
startTagEnd: token.rawEnd,
|
|
173
|
+
children: [],
|
|
174
|
+
attrs: /** @type {Array<AstNode | HtmlAttribute>} */ (
|
|
175
|
+
(token.attrs ?? []).map((item) =>
|
|
176
|
+
item.type === 'twig'
|
|
177
|
+
? node('twig', item.atom.sourceStart, item.atom.rawEnd, {
|
|
178
|
+
atom: item.atom,
|
|
179
|
+
raw: item.atom.leading + item.atom.raw,
|
|
180
|
+
})
|
|
181
|
+
: item,
|
|
182
|
+
)
|
|
183
|
+
),
|
|
184
|
+
});
|
|
185
|
+
append(element);
|
|
186
|
+
if (!token.selfClosing && !VOID_ELEMENTS.has(token.name ?? '')) {
|
|
187
|
+
stack.push(element);
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case 'endTag': {
|
|
192
|
+
// pop the matching open element (lenient: closes implicit ancestors)
|
|
193
|
+
let closed = false;
|
|
194
|
+
for (let i = stack.length - 1; i > 0; i -= 1) {
|
|
195
|
+
if (stack[i].name === token.name) {
|
|
196
|
+
const element = stack[i];
|
|
197
|
+
element.endTagRaw = token.raw;
|
|
198
|
+
element.endTagStart = token.rawStart;
|
|
199
|
+
element.endTagEnd = token.rawEnd;
|
|
200
|
+
element.rawEnd = token.rawEnd;
|
|
201
|
+
stack.length = i;
|
|
202
|
+
closed = true;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!closed) {
|
|
207
|
+
// dangling end tag: keep it as text so nothing is lost
|
|
208
|
+
append(node('text', token.rawStart, token.rawEnd, { raw: token.raw }));
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'text':
|
|
213
|
+
append(node('text', token.rawStart, token.rawEnd, { raw: token.raw }));
|
|
214
|
+
break;
|
|
215
|
+
case 'comment': {
|
|
216
|
+
if (token.atom && token.atom.kind === 'comment') {
|
|
217
|
+
// twig comment
|
|
218
|
+
append(node('twigComment', token.rawStart, token.rawEnd, { atom: token.atom, raw: token.raw }));
|
|
219
|
+
} else {
|
|
220
|
+
append(node('comment', token.rawStart, token.rawEnd, { raw: token.raw }));
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'doctype':
|
|
225
|
+
append(node('doctype', token.rawStart, token.rawEnd, { raw: token.raw }));
|
|
226
|
+
break;
|
|
227
|
+
case 'twig': {
|
|
228
|
+
const atom = /** @type {Atom} */ (token.atom);
|
|
229
|
+
append(node('twig', token.rawStart, token.rawEnd, { atom, raw: token.raw }));
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
default:
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
pairTree(root);
|
|
238
|
+
return root;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Void elements that never have an end tag. */
|
|
242
|
+
const VOID_ELEMENTS = new Set([
|
|
243
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
|
|
244
|
+
'meta', 'param', 'source', 'track', 'wbr',
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Pairs twig block tags into `twigBlock` nodes at every container level and
|
|
249
|
+
* inside attribute lists.
|
|
250
|
+
*
|
|
251
|
+
* @param {AstNode} root
|
|
252
|
+
*/
|
|
253
|
+
function pairTree(root) {
|
|
254
|
+
/** @param {AstNode} n */
|
|
255
|
+
const walk = (n) => {
|
|
256
|
+
if (Array.isArray(n.children)) {
|
|
257
|
+
n.children = /** @type {AstNode[]} */ (pairTwigBlocks(n.children));
|
|
258
|
+
for (const child of n.children) {
|
|
259
|
+
walk(child);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (n.type === 'element' && Array.isArray(n.attrs)) {
|
|
263
|
+
n.attrs = pairTwigBlocks(n.attrs);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const children = root.children;
|
|
267
|
+
if (Array.isArray(children)) {
|
|
268
|
+
root.children = /** @type {AstNode[]} */ (pairTwigBlocks(children));
|
|
269
|
+
for (const child of root.children) {
|
|
270
|
+
walk(child);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Replaces runs of block-open/close twig leaves in a flat item list with
|
|
277
|
+
* `twigBlock` nodes.
|
|
278
|
+
*
|
|
279
|
+
* @param {Array<AstNode | HtmlAttribute>} items
|
|
280
|
+
* @returns {Array<AstNode | HtmlAttribute>}
|
|
281
|
+
*/
|
|
282
|
+
export function pairTwigBlocks(items) {
|
|
283
|
+
/** @type {Array<{open: AstNode, sections: Array<{head: AstNode|null, body: Array<AstNode | HtmlAttribute>}>}>} */
|
|
284
|
+
const stack = [];
|
|
285
|
+
/** @type {Array<AstNode | HtmlAttribute>} */
|
|
286
|
+
const out = [];
|
|
287
|
+
|
|
288
|
+
for (const item of items) {
|
|
289
|
+
if (/** @type {any} */ (item).type === 'twig') {
|
|
290
|
+
const twig = /** @type {AstNode} */ (item);
|
|
291
|
+
const atom = /** @type {Atom} */ (twig.atom);
|
|
292
|
+
if (isBlockOpen(atom)) {
|
|
293
|
+
stack.push({ open: twig, sections: [{ head: null, body: [] }] });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (stack.length > 0 && atom.tag && BLOCK_MID_TAGS.has(atom.tag)) {
|
|
297
|
+
stack[stack.length - 1].sections.push({ head: twig, body: [] });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (stack.length > 0 && isBlockClose(atom, /** @type {string} */ ((/** @type {any} */ (stack[stack.length - 1]).open).atom.tag ?? ''))) {
|
|
301
|
+
const frame = /** @type {any} */ (stack.pop());
|
|
302
|
+
const block = node('twigBlock', frame.open.rawStart, twig.rawEnd, {
|
|
303
|
+
tag: frame.open.atom.tag,
|
|
304
|
+
open: frame.open,
|
|
305
|
+
sections: frame.sections,
|
|
306
|
+
close: twig,
|
|
307
|
+
children: frame.sections.flatMap((/** @type {any} */ s) => s.body),
|
|
308
|
+
});
|
|
309
|
+
if (stack.length > 0) {
|
|
310
|
+
stack[stack.length - 1].sections[stack[stack.length - 1].sections.length - 1].body.push(block);
|
|
311
|
+
} else {
|
|
312
|
+
out.push(block);
|
|
313
|
+
}
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (stack.length > 0) {
|
|
318
|
+
stack[stack.length - 1].sections[stack[stack.length - 1].sections.length - 1].body.push(item);
|
|
319
|
+
} else {
|
|
320
|
+
out.push(item);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// flush any unclosed blocks (malformed source) back as leaves in order
|
|
325
|
+
for (const frame of stack) {
|
|
326
|
+
out.push(frame.open, ...frame.sections.flatMap((/** @type {any} */ s) => s.body));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Round-trip and structure tests for `@mrhenry/twig-html-parser` atom
|
|
4
|
+
* extraction.
|
|
5
|
+
*
|
|
6
|
+
* Every tokenizable source in the corpus (fixture templates, fuzzer cases and
|
|
7
|
+
* hand-written edge cases) must produce an atom list that serializes back to
|
|
8
|
+
* the exact source it was extracted from. Atoms must also carry exact offsets
|
|
9
|
+
* and correctly classify twig constructs, comments and verbatim blocks.
|
|
10
|
+
*
|
|
11
|
+
* @module test
|
|
12
|
+
*/
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { Lexer, Source } from '@mrhenry/twig-tokenizer';
|
|
16
|
+
import { extractAtoms, serializeAtoms } from '@mrhenry/twig-html-parser';
|
|
17
|
+
import { collectSources, EDGE_CASES } from '../../test/serialize-sources.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} source
|
|
21
|
+
* @returns {ReturnType<typeof extractAtoms>}
|
|
22
|
+
*/
|
|
23
|
+
function atoms(source) {
|
|
24
|
+
const tokens = new Lexer().tokenize(new Source(source, 'index.twig')).getTokens();
|
|
25
|
+
return extractAtoms(source, tokens);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {ReturnType<typeof extractAtoms>} list
|
|
30
|
+
* @returns {string[]}
|
|
31
|
+
*/
|
|
32
|
+
function kinds(list) {
|
|
33
|
+
return list.map((a) => a.kind);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let ran = 0;
|
|
37
|
+
let skipped = 0;
|
|
38
|
+
|
|
39
|
+
for (const { source, name } of collectSources()) {
|
|
40
|
+
test(`atoms round trip: ${name}`, () => {
|
|
41
|
+
let stream;
|
|
42
|
+
try {
|
|
43
|
+
stream = new Lexer().tokenize(new Source(source, name));
|
|
44
|
+
} catch (e) {
|
|
45
|
+
skipped += 1;
|
|
46
|
+
return; // not a tokenizable source
|
|
47
|
+
}
|
|
48
|
+
ran += 1;
|
|
49
|
+
const list = extractAtoms(source, stream.getTokens());
|
|
50
|
+
assert.equal(serializeAtoms(list), source, `lossy serialization for ${name}`);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const [label, source] of Object.entries(EDGE_CASES)) {
|
|
55
|
+
test(`atoms edge case: ${label}`, () => {
|
|
56
|
+
const list = atoms(source);
|
|
57
|
+
ran += 1;
|
|
58
|
+
assert.equal(serializeAtoms(list), source, `lossy serialization for ${label}`);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('atoms classify a mixed template', () => {
|
|
63
|
+
const source = 'a {# c #} <div {% if x %} id="{{ i }}" {% endif %}>b</div> {{ y }}';
|
|
64
|
+
const list = atoms(source);
|
|
65
|
+
assert.deepEqual(kinds(list), ['text', 'comment', 'text', 'twig', 'text', 'twig', 'text', 'twig', 'text', 'twig']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('atoms extract tag names and print flags', () => {
|
|
69
|
+
const source = '{% if x %}{{ y }}{% endif %}';
|
|
70
|
+
const list = atoms(source);
|
|
71
|
+
const twigs = list.filter((a) => a.kind === 'twig');
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
twigs.map((a) => [a.tag, a.isPrint]),
|
|
74
|
+
[['if', false], [null, true], ['endif', false]],
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('atoms carve comments out of leading trivia', () => {
|
|
79
|
+
const source = 'foo\n{# c #}\nbar';
|
|
80
|
+
const list = atoms(source);
|
|
81
|
+
const comment = list.find((a) => a.kind === 'comment');
|
|
82
|
+
assert.ok(comment);
|
|
83
|
+
assert.equal(comment.raw, '{# c #}');
|
|
84
|
+
assert.equal(comment.leading, '');
|
|
85
|
+
// the comment precedes `bar`; the whitespace it consumed folds into the
|
|
86
|
+
// following text atom's raw
|
|
87
|
+
assert.equal(comment.rawStart, 4);
|
|
88
|
+
assert.equal(list[list.length - 1].raw, '\nbar');
|
|
89
|
+
assert.equal(list[list.length - 1].leading, '');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('atoms carve multiple comments with interleaved whitespace', () => {
|
|
93
|
+
const source = 'a {# one #} b {# two #} c';
|
|
94
|
+
const list = atoms(source);
|
|
95
|
+
const comments = list.filter((a) => a.kind === 'comment');
|
|
96
|
+
assert.equal(comments.length, 2);
|
|
97
|
+
// the spaces around the comments live inside the adjacent text atom raws
|
|
98
|
+
assert.equal(comments[0].raw, '{# one #}');
|
|
99
|
+
assert.equal(comments[0].leading, '');
|
|
100
|
+
assert.equal(comments[1].raw, '{# two #}');
|
|
101
|
+
assert.equal(comments[1].leading, '');
|
|
102
|
+
assert.equal(list[list.length - 1].raw, ' c');
|
|
103
|
+
assert.equal(list[list.length - 1].leading, '');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('atoms keep a whitespace text atom between comments', () => {
|
|
107
|
+
const source = '{# a #} {# b #}x';
|
|
108
|
+
const list = atoms(source);
|
|
109
|
+
assert.deepEqual(kinds(list), ['comment', 'text', 'comment', 'text']);
|
|
110
|
+
assert.equal(list[1].raw, ' ');
|
|
111
|
+
assert.equal(list[2].raw, '{# b #}');
|
|
112
|
+
assert.equal(serializeAtoms(list), source);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('atoms keep a trailing comment and whitespace', () => {
|
|
116
|
+
const source = 'foo {# c #}';
|
|
117
|
+
const list = atoms(source);
|
|
118
|
+
assert.deepEqual(kinds(list), ['text', 'comment']);
|
|
119
|
+
assert.equal(serializeAtoms(list), source);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('atoms keep a leading comment', () => {
|
|
123
|
+
const source = '{# c #}\n<div>hi</div>';
|
|
124
|
+
const list = atoms(source);
|
|
125
|
+
assert.deepEqual(kinds(list), ['comment', 'text']);
|
|
126
|
+
assert.equal(list[0].rawStart, 0);
|
|
127
|
+
assert.equal(list[1].raw, '\n<div>hi</div>');
|
|
128
|
+
assert.equal(serializeAtoms(list), source);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('atoms flag verbatim blocks as opaque text', () => {
|
|
132
|
+
const source = '{% verbatim %}<div>{{ not parsed }}</div>{% endverbatim %}';
|
|
133
|
+
const list = atoms(source);
|
|
134
|
+
const verbatim = list.find((a) => a.verbatim);
|
|
135
|
+
assert.ok(verbatim);
|
|
136
|
+
assert.equal(verbatim.kind, 'text');
|
|
137
|
+
assert.equal(verbatim.raw, source);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('atoms preserve offsets across a tag group', () => {
|
|
141
|
+
const source = '<a href="{{ url }}">x</a>';
|
|
142
|
+
const list = atoms(source);
|
|
143
|
+
const print = list.find((a) => a.kind === 'twig' && a.isPrint);
|
|
144
|
+
assert.ok(print);
|
|
145
|
+
assert.equal(print.raw, '{{ url }}');
|
|
146
|
+
assert.equal(source.slice(print.rawStart, print.rawEnd), '{{ url }}');
|
|
147
|
+
assert.equal(print.rawStart, 9);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('atoms round trip trim markers and whitespace control', () => {
|
|
151
|
+
const source = '{%- if x -%}\n\t{{- y -}}\n{%- endif -%}';
|
|
152
|
+
assert.equal(serializeAtoms(atoms(source)), source);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('atoms summary', () => {
|
|
156
|
+
assert.ok(ran > 0, 'no sources were extracted');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
process.on('exit', () => {
|
|
160
|
+
console.log(`\nAtom extraction: ran=${ran} skipped=${skipped}`);
|
|
161
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Structure and coverage tests for the `@mrhenry/twig-html-parser` HTML
|
|
4
|
+
* tokenizer.
|
|
5
|
+
*
|
|
6
|
+
* Every source in the corpus must tokenize to a contiguous, source-ordered
|
|
7
|
+
* token list (no gaps, no out-of-order spans) with exact offsets — the
|
|
8
|
+
* guarantee that report-only / range / cursor modes rely on. Focused tests
|
|
9
|
+
* cover the interesting tokenizer behaviour: twig between attributes, twig in
|
|
10
|
+
* attribute values, raw text elements, comments, doctype and void elements.
|
|
11
|
+
*
|
|
12
|
+
* @module test
|
|
13
|
+
*/
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { Lexer, Source } from '@mrhenry/twig-tokenizer';
|
|
17
|
+
import { extractAtoms, tokenizeHtml } from '@mrhenry/twig-html-parser';
|
|
18
|
+
import { collectSources, EDGE_CASES } from '../../test/serialize-sources.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tokenizes a source into HTML tokens.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} source
|
|
24
|
+
* @returns {ReturnType<typeof tokenizeHtml>}
|
|
25
|
+
*/
|
|
26
|
+
function html(source) {
|
|
27
|
+
const tokens = new Lexer().tokenize(new Source(source, 'index.twig')).getTokens();
|
|
28
|
+
return tokenizeHtml(extractAtoms(source, tokens), source);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Asserts the token list covers the source contiguously and in order.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} source
|
|
35
|
+
* @param {ReturnType<typeof tokenizeHtml>} tokens
|
|
36
|
+
*/
|
|
37
|
+
function assertContiguous(source, tokens) {
|
|
38
|
+
let prev = 0;
|
|
39
|
+
for (const t of tokens) {
|
|
40
|
+
assert.ok(t.rawStart >= prev, `token out of order at ${t.rawStart} (prev ${prev})`);
|
|
41
|
+
assert.ok(t.rawStart === prev, `gap at ${t.rawStart} (prev ${prev})`);
|
|
42
|
+
assert.ok(t.rawEnd >= t.rawStart, `negative span at ${t.rawStart}`);
|
|
43
|
+
prev = t.rawEnd;
|
|
44
|
+
}
|
|
45
|
+
assert.equal(prev, source.length, `trailing gap (covered ${prev}/${source.length})`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {ReturnType<typeof tokenizeHtml>} tokens
|
|
50
|
+
* @returns {string[]}
|
|
51
|
+
*/
|
|
52
|
+
function types(tokens) {
|
|
53
|
+
return tokens.map((t) => t.type);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let ran = 0;
|
|
57
|
+
let skipped = 0;
|
|
58
|
+
|
|
59
|
+
for (const { source, name } of collectSources()) {
|
|
60
|
+
test(`html tokenizer contiguous: ${name}`, () => {
|
|
61
|
+
try {
|
|
62
|
+
new Lexer().tokenize(new Source(source, name));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
skipped += 1;
|
|
65
|
+
return; // not tokenizable
|
|
66
|
+
}
|
|
67
|
+
ran += 1;
|
|
68
|
+
assertContiguous(source, html(source));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const [label, source] of Object.entries(EDGE_CASES)) {
|
|
73
|
+
test(`html tokenizer edge case: ${label}`, () => {
|
|
74
|
+
assertContiguous(source, html(source));
|
|
75
|
+
ran += 1;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('tokenizes elements and text', () => {
|
|
80
|
+
const tokens = html('<div class="a">hello</div>');
|
|
81
|
+
assert.deepEqual(types(tokens), ['startTag', 'text', 'endTag']);
|
|
82
|
+
assert.equal(tokens[0].name, 'div');
|
|
83
|
+
assert.equal(tokens[0].raw, '<div class="a">');
|
|
84
|
+
assert.equal(tokens[1].raw, 'hello');
|
|
85
|
+
assert.equal(tokens[2].raw, '</div>');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('tokenizes a twig print in an attribute value', () => {
|
|
89
|
+
const tokens = html('<a href="{{ url }}">x</a>');
|
|
90
|
+
const tag = /** @type {any} */ (tokens[0]);
|
|
91
|
+
const attr = /** @type {any} */ (tag.attrs[0]);
|
|
92
|
+
assert.equal(attr.nameRaw, 'href');
|
|
93
|
+
assert.equal(attr.valueChunks.length, 1);
|
|
94
|
+
assert.equal(attr.valueChunks[0].type, 'twig');
|
|
95
|
+
assert.equal(/** @type {any} */ (attr.valueChunks[0]).atom.raw, '{{ url }}');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('tokenizes conditional attributes via twig between attributes', () => {
|
|
99
|
+
const source = '<div class="row" {% if x %} id="y" {% endif %} data-x>';
|
|
100
|
+
const tokens = html(source);
|
|
101
|
+
assert.deepEqual(types(tokens), ['startTag']);
|
|
102
|
+
const tag = /** @type {any} */ (tokens[0]);
|
|
103
|
+
const kinds = tag.attrs.map((/** @type {any} */ a) => a.type ?? 'attr');
|
|
104
|
+
assert.deepEqual(kinds, ['attr', 'twig', 'attr', 'twig', 'attr']);
|
|
105
|
+
assert.equal(tag.attrs[1].atom.tag, 'if');
|
|
106
|
+
assert.equal(tag.attrs[3].atom.tag, 'endif');
|
|
107
|
+
assert.equal(tag.raw, source);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('tokenizes doctype and comments', () => {
|
|
111
|
+
const tokens = html('<!doctype html>\n<!-- note --><p>x</p>');
|
|
112
|
+
assert.deepEqual(types(tokens), ['doctype', 'text', 'comment', 'startTag', 'text', 'endTag']);
|
|
113
|
+
assert.equal(tokens[0].raw, '<!doctype html>');
|
|
114
|
+
assert.equal(tokens[2].raw, '<!-- note -->');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('tokenizes raw text elements (script) with twig inside', () => {
|
|
118
|
+
const source = '<script>var x = "{{ v }}";</script>';
|
|
119
|
+
const tokens = html(source);
|
|
120
|
+
assert.deepEqual(types(tokens), ['startTag', 'text', 'twig', 'text', 'endTag']);
|
|
121
|
+
assert.equal(tokens[1].raw, 'var x = "');
|
|
122
|
+
assert.equal(tokens[2].raw, '{{ v }}');
|
|
123
|
+
assert.equal(tokens[3].raw, '";');
|
|
124
|
+
assert.equal(tokens[4].raw, '</script>');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('does not parse markup inside style content', () => {
|
|
128
|
+
const source = '<style>.a { color: red; }</style>';
|
|
129
|
+
const tokens = html(source);
|
|
130
|
+
assert.deepEqual(types(tokens), ['startTag', 'text', 'endTag']);
|
|
131
|
+
assert.equal(tokens[1].raw, '.a { color: red; }');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('handles void elements without an end tag', () => {
|
|
135
|
+
const tokens = html('<img src="a" alt="b">tail');
|
|
136
|
+
assert.deepEqual(types(tokens), ['startTag', 'text']);
|
|
137
|
+
assert.equal(tokens[0].name, 'img');
|
|
138
|
+
assert.equal(tokens[1].raw, 'tail');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('preserves self-closing tags', () => {
|
|
142
|
+
const tokens = html('<link rel="x" />');
|
|
143
|
+
assert.equal(tokens[0].type, 'startTag');
|
|
144
|
+
assert.equal(/** @type {any} */ (tokens[0]).selfClosing, true);
|
|
145
|
+
assert.equal(tokens[0].raw, '<link rel="x" />');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('treats a stray < followed by twig as text', () => {
|
|
149
|
+
const source = '< {{ x }}';
|
|
150
|
+
const tokens = html(source);
|
|
151
|
+
assert.deepEqual(types(tokens), ['text', 'text', 'twig']);
|
|
152
|
+
assert.equal(tokens[0].raw, '<');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('tokenizer summary', () => {
|
|
156
|
+
assert.ok(ran > 0, 'no sources were tokenized');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
process.on('exit', () => {
|
|
160
|
+
console.log(`\nHTML tokenizer: ran=${ran} skipped=${skipped}`);
|
|
161
|
+
});
|