@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/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mr. Henry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
This license applies only to the code in this repository.
|
|
24
|
+
Images are explicitly excluded.
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mrhenry/twig-html-parser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test test/*.test.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@mrhenry/twig-tokenizer": "0.1.0"
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/atoms.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Atom extraction for the unified HTML+Twig parser.
|
|
4
|
+
*
|
|
5
|
+
* The existing `@mrhenry/twig-tokenizer` lexer walks the raw template source
|
|
6
|
+
* and isolates every Twig construct (prints, block tags, comments, verbatim
|
|
7
|
+
* blocks) from the surrounding static text. That token stream is the single
|
|
8
|
+
* source of truth for source positions, so this module turns it into a flat,
|
|
9
|
+
* source-ordered list of *atoms*:
|
|
10
|
+
*
|
|
11
|
+
* * `text` atoms — a `TEXT` token (a static HTML region), except verbatim
|
|
12
|
+
* blocks which stay opaque so their content is never parsed as HTML;
|
|
13
|
+
* * `twig` atoms — a maximal `{{ … }}` or `{% … %}` construct, with its
|
|
14
|
+
* opening tag name extracted from the first `NAME` token;
|
|
15
|
+
* * `comment` atoms — `{# … #}` / `{## … ##}` comments, which the lexer
|
|
16
|
+
* *skips* (they surface as the `leading` trivia of the following token) and
|
|
17
|
+
* which are therefore carved back out of that trivia here.
|
|
18
|
+
*
|
|
19
|
+
* Every atom keeps the exact source text (`raw`), the trivia preceding it
|
|
20
|
+
* (`leading`) and absolute offsets, so concatenating `leading + raw` across
|
|
21
|
+
* all atoms reproduces the template source byte-for-byte. The HTML tokenizer
|
|
22
|
+
* consumes these atoms directly — Twig constructs are first-class units and
|
|
23
|
+
* the HTML parser never sees a stray `{%` or `{{`.
|
|
24
|
+
*
|
|
25
|
+
* @module twig-html-parser
|
|
26
|
+
*/
|
|
27
|
+
import { TokenType } from '@mrhenry/twig-tokenizer';
|
|
28
|
+
|
|
29
|
+
// Delimiter configuration matching the default lexer options. The lexer is
|
|
30
|
+
// always constructed with these defaults in practice.
|
|
31
|
+
const COMMENT_START = '{#';
|
|
32
|
+
const COMMENT_END = '#}';
|
|
33
|
+
const WHITESPACE_TRIM = '-';
|
|
34
|
+
const WHITESPACE_LINE_TRIM = '~';
|
|
35
|
+
const DOC_START = '{##';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* An atom of the unified template stream.
|
|
39
|
+
*
|
|
40
|
+
* @typedef {object} Atom
|
|
41
|
+
* @property {'text'|'twig'|'comment'} kind
|
|
42
|
+
* @property {string} leading Trivia (whitespace) preceding the atom.
|
|
43
|
+
* @property {string} raw The atom's own exact source text.
|
|
44
|
+
* @property {number} sourceStart Absolute offset where `leading` starts.
|
|
45
|
+
* @property {number} rawStart Absolute offset where `raw` starts.
|
|
46
|
+
* @property {number} rawEnd Absolute offset just past `raw`.
|
|
47
|
+
* @property {boolean} [verbatim] Whether a text atom is a verbatim block.
|
|
48
|
+
* @property {boolean} [isPrint] For twig atoms: `{{ }}` print vs `{% %}` block.
|
|
49
|
+
* @property {string|null} [tag] For twig atoms: the tag name (or null for prints).
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
// comment closing markers: `[marker, kind]` where kind is 0 (skip following
|
|
53
|
+
// whitespace), 1 (skip following line-trim chars) or 2 (skip one newline).
|
|
54
|
+
// Mirrors `Lexer#commentMarkers` / `Lexer#docCommentMarkers`.
|
|
55
|
+
/** @type {Array<[string, 0|1|2]>} */
|
|
56
|
+
const COMMENT_MARKERS = [
|
|
57
|
+
[WHITESPACE_TRIM + COMMENT_END, 0],
|
|
58
|
+
[WHITESPACE_LINE_TRIM + COMMENT_END, 1],
|
|
59
|
+
[COMMENT_END, 2],
|
|
60
|
+
];
|
|
61
|
+
/** @type {Array<[string, 0|1|2]>} */
|
|
62
|
+
const DOC_COMMENT_MARKERS = [
|
|
63
|
+
[WHITESPACE_TRIM + '#' + COMMENT_END, 0],
|
|
64
|
+
[WHITESPACE_LINE_TRIM + '#' + COMMENT_END, 1],
|
|
65
|
+
[WHITESPACE_TRIM + COMMENT_END, 0],
|
|
66
|
+
[WHITESPACE_LINE_TRIM + COMMENT_END, 1],
|
|
67
|
+
[COMMENT_END, 2],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {Atom['kind']} kind
|
|
72
|
+
* @param {string} raw
|
|
73
|
+
* @param {number} sourceStart
|
|
74
|
+
* @param {number} rawStart
|
|
75
|
+
* @param {number} rawEnd
|
|
76
|
+
* @param {string} leading
|
|
77
|
+
* @param {object} [extra]
|
|
78
|
+
* @returns {Atom}
|
|
79
|
+
*/
|
|
80
|
+
function atom(kind, raw, sourceStart, rawStart, rawEnd, leading, extra = {}) {
|
|
81
|
+
return { kind, raw, sourceStart, rawStart, rawEnd, leading, ...extra };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Scans the source at a `{#` position and returns the offset just past the
|
|
86
|
+
* comment's closing marker. The comment span is the comment text only; any
|
|
87
|
+
* whitespace the lexer happens to consume after the marker stays in the trivia
|
|
88
|
+
* so the split always reproduces the source exactly.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} code The template source.
|
|
91
|
+
* @param {number} start Offset of the `{#` comment start.
|
|
92
|
+
* @returns {number} Offset just past the comment, or `-1` when unclosed.
|
|
93
|
+
*/
|
|
94
|
+
function scanCommentEnd(code, start) {
|
|
95
|
+
const isDoc = code.startsWith(DOC_START, start) && code.charCodeAt(start + 3) !== /* } */ 0x7d;
|
|
96
|
+
const markers = isDoc ? DOC_COMMENT_MARKERS : COMMENT_MARKERS;
|
|
97
|
+
const firstChars = [WHITESPACE_TRIM.charCodeAt(0), WHITESPACE_LINE_TRIM.charCodeAt(0), COMMENT_END.charCodeAt(0)];
|
|
98
|
+
|
|
99
|
+
for (let p = start + 2; p < code.length; p += 1) {
|
|
100
|
+
const pc = code.charCodeAt(p);
|
|
101
|
+
if (pc !== firstChars[0] && pc !== firstChars[1] && pc !== firstChars[2]) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (let k = 0; k < markers.length; k += 1) {
|
|
105
|
+
const marker = markers[k][0];
|
|
106
|
+
if (code.startsWith(marker, p)) {
|
|
107
|
+
return p + marker.length;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return -1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Splits a trivia span `[from, to)` of the source into whitespace and comment
|
|
116
|
+
* pieces. Comments are the only non-whitespace tokens the lexer can leave in
|
|
117
|
+
* the gap between two tokens.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} code The template source.
|
|
120
|
+
* @param {number} from Absolute start of the trivia.
|
|
121
|
+
* @param {number} to Absolute end of the trivia.
|
|
122
|
+
* @returns {Array<{kind: 'ws'|'comment', start: number, end: number}>} Pieces.
|
|
123
|
+
*/
|
|
124
|
+
function splitTrivia(code, from, to) {
|
|
125
|
+
/** @type {Array<{kind: 'ws'|'comment', start: number, end: number}>} */
|
|
126
|
+
const pieces = [];
|
|
127
|
+
let p = from;
|
|
128
|
+
while (p < to) {
|
|
129
|
+
const commentStart = code.indexOf(COMMENT_START, p);
|
|
130
|
+
if (commentStart === -1 || commentStart >= to) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
if (commentStart > p) {
|
|
134
|
+
pieces.push({ kind: 'ws', start: p, end: commentStart });
|
|
135
|
+
}
|
|
136
|
+
let commentEnd = scanCommentEnd(code, commentStart);
|
|
137
|
+
if (commentEnd === -1 || commentEnd > to) {
|
|
138
|
+
commentEnd = to;
|
|
139
|
+
}
|
|
140
|
+
pieces.push({ kind: 'comment', start: commentStart, end: commentEnd });
|
|
141
|
+
p = commentEnd;
|
|
142
|
+
}
|
|
143
|
+
if (p < to) {
|
|
144
|
+
pieces.push({ kind: 'ws', start: p, end: to });
|
|
145
|
+
}
|
|
146
|
+
return pieces;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Emits comment atoms for the comments in a trivia span, accumulating the
|
|
151
|
+
* whitespace that precedes each comment into its `leading`. Returns the
|
|
152
|
+
* whitespace remaining after the last comment, to be used as the `leading` of
|
|
153
|
+
* the following atom, together with the absolute offset where it starts.
|
|
154
|
+
*
|
|
155
|
+
* @param {Atom[]} out The atom list to append to.
|
|
156
|
+
* @param {string} code The template source.
|
|
157
|
+
* @param {number} from Absolute start of the trivia.
|
|
158
|
+
* @param {number} to Absolute end of the trivia.
|
|
159
|
+
* @returns {{text: string, start: number}} The trailing whitespace trivia.
|
|
160
|
+
*/
|
|
161
|
+
function emitComments(out, code, from, to) {
|
|
162
|
+
let leading = '';
|
|
163
|
+
let leadingStart = from;
|
|
164
|
+
for (const piece of splitTrivia(code, from, to)) {
|
|
165
|
+
if (piece.kind === 'comment') {
|
|
166
|
+
out.push(
|
|
167
|
+
atom(
|
|
168
|
+
'comment',
|
|
169
|
+
code.slice(piece.start, piece.end),
|
|
170
|
+
piece.start - leading.length,
|
|
171
|
+
piece.start,
|
|
172
|
+
piece.end,
|
|
173
|
+
leading,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
leading = '';
|
|
177
|
+
leadingStart = piece.end;
|
|
178
|
+
} else {
|
|
179
|
+
leading = code.slice(piece.start, piece.end);
|
|
180
|
+
leadingStart = piece.start;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { text: leading, start: leadingStart };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Extracts the source-ordered atom list from a token stream.
|
|
188
|
+
*
|
|
189
|
+
* The stream must be produced from `source` (the same source string is passed
|
|
190
|
+
* in so exact raw text can be sliced for twig groups).
|
|
191
|
+
*
|
|
192
|
+
* @param {string} source The template source code.
|
|
193
|
+
* @param {Array<import('@mrhenry/twig-tokenizer').Token>} tokens The lexer's token list.
|
|
194
|
+
* @returns {Atom[]} The atoms, in source order.
|
|
195
|
+
*/
|
|
196
|
+
export function extractAtoms(source, tokens) {
|
|
197
|
+
/** @type {Atom[]} */
|
|
198
|
+
const atoms = [];
|
|
199
|
+
|
|
200
|
+
// Whitespace that a `-%}`/`~%}`/`-%}`-style closing marker caused the lexer
|
|
201
|
+
// to consume into the closing token's raw. It is carried over and becomes
|
|
202
|
+
// trivia of the following atom so twig atoms keep a clean raw.
|
|
203
|
+
let carryStart = -1;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The trivia span preceding the current token, including any carried
|
|
207
|
+
* trim-consumed whitespace.
|
|
208
|
+
*
|
|
209
|
+
* @param {import('@mrhenry/twig-tokenizer').Token} token
|
|
210
|
+
* @returns {number} Absolute start of the trivia.
|
|
211
|
+
*/
|
|
212
|
+
function triviaFrom(token) {
|
|
213
|
+
return carryStart !== -1 ? carryStart : token.sourceStart;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let i = 0;
|
|
217
|
+
while (i < tokens.length) {
|
|
218
|
+
const token = tokens[i];
|
|
219
|
+
if (token.type === TokenType.EOF) {
|
|
220
|
+
const from = triviaFrom(token);
|
|
221
|
+
const trailing = emitComments(atoms, source, from, source.length);
|
|
222
|
+
if (trailing.text !== '') {
|
|
223
|
+
atoms.push(
|
|
224
|
+
atom(
|
|
225
|
+
'text',
|
|
226
|
+
trailing.text,
|
|
227
|
+
trailing.start,
|
|
228
|
+
trailing.start,
|
|
229
|
+
source.length,
|
|
230
|
+
'',
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
carryStart = -1;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (token.type === TokenType.TEXT) {
|
|
239
|
+
const rawText = token.raw ?? '';
|
|
240
|
+
const from = triviaFrom(token);
|
|
241
|
+
const leading = emitComments(atoms, source, from, token.rawStart);
|
|
242
|
+
// fold the trailing whitespace into the raw so the atom's text content
|
|
243
|
+
// is contiguous (the cursor reads `raw` only)
|
|
244
|
+
const fullRaw = leading.text + rawText;
|
|
245
|
+
const rawStart = leading.start;
|
|
246
|
+
const isVerbatim = /^\s*{%[~-]?\s*verbatim\b/.test(fullRaw);
|
|
247
|
+
atoms.push(atom('text', fullRaw, rawStart, rawStart, token.rawEnd, '', isVerbatim ? { verbatim: true } : {}));
|
|
248
|
+
carryStart = -1;
|
|
249
|
+
i += 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (token.type === TokenType.BLOCK_START || token.type === TokenType.VAR_START) {
|
|
254
|
+
const startToken = token;
|
|
255
|
+
const isPrint = token.type === TokenType.VAR_START;
|
|
256
|
+
const endType = isPrint ? TokenType.VAR_END : TokenType.BLOCK_END;
|
|
257
|
+
i += 1;
|
|
258
|
+
let tag = null;
|
|
259
|
+
let endToken = null;
|
|
260
|
+
while (i < tokens.length) {
|
|
261
|
+
const t = tokens[i];
|
|
262
|
+
// the first NAME token names a block tag (`if`, `endif`, ...); print
|
|
263
|
+
// statements have no tag name
|
|
264
|
+
if (t.type === TokenType.NAME && tag === null && !isPrint) {
|
|
265
|
+
tag = /** @type {string} */ (t.value);
|
|
266
|
+
}
|
|
267
|
+
if (t.type === endType || t.type === TokenType.EOF) {
|
|
268
|
+
endToken = t;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
i += 1;
|
|
272
|
+
}
|
|
273
|
+
if (endToken === null) {
|
|
274
|
+
endToken = tokens[tokens.length - 1];
|
|
275
|
+
}
|
|
276
|
+
const from = triviaFrom(startToken);
|
|
277
|
+
const leading = emitComments(atoms, source, from, startToken.rawStart);
|
|
278
|
+
// the closing token's raw may include whitespace that a `-%}`/`~%}`
|
|
279
|
+
// (or plain `%}` + newline) marker trimmed; the twig tag itself ends at
|
|
280
|
+
// the closing delimiter. An unclosed construct runs to the end of the
|
|
281
|
+
// source (the EOF token has no closing delimiter).
|
|
282
|
+
let trueEnd;
|
|
283
|
+
if (endToken.type === TokenType.EOF) {
|
|
284
|
+
trueEnd = endToken.rawStart;
|
|
285
|
+
} else {
|
|
286
|
+
const endRaw = endToken.raw ?? '';
|
|
287
|
+
const markerLength = endRaw.startsWith(WHITESPACE_TRIM) || endRaw.startsWith(WHITESPACE_LINE_TRIM) ? 3 : 2;
|
|
288
|
+
trueEnd = endToken.rawStart + markerLength;
|
|
289
|
+
}
|
|
290
|
+
atoms.push(
|
|
291
|
+
atom(
|
|
292
|
+
'twig',
|
|
293
|
+
source.slice(startToken.rawStart, trueEnd),
|
|
294
|
+
leading.start,
|
|
295
|
+
startToken.rawStart,
|
|
296
|
+
trueEnd,
|
|
297
|
+
leading.text,
|
|
298
|
+
{ isPrint, tag },
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
const carried = source.slice(trueEnd, endToken.rawEnd);
|
|
302
|
+
carryStart = carried === '' ? -1 : trueEnd;
|
|
303
|
+
i += 1;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Parser-synthesised or unexpected tokens are ignored; their source is
|
|
308
|
+
// covered by the surrounding tokens' trivia.
|
|
309
|
+
i += 1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return atoms;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Serializes an atom list back to template source.
|
|
317
|
+
*
|
|
318
|
+
* @param {Atom[]} atoms
|
|
319
|
+
* @returns {string} The reconstructed source.
|
|
320
|
+
*/
|
|
321
|
+
export function serializeAtoms(atoms) {
|
|
322
|
+
let out = '';
|
|
323
|
+
for (const a of atoms) {
|
|
324
|
+
out += a.leading + a.raw;
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @param {Atom} a
|
|
331
|
+
* @returns {string} A one-line description (for debugging).
|
|
332
|
+
*/
|
|
333
|
+
export function atomToDebugString(a) {
|
|
334
|
+
return `[${a.kind}] ${a.rawStart}-${a.rawEnd} ${JSON.stringify(a.raw.slice(0, 40))}`;
|
|
335
|
+
}
|