@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 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
+ }