@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
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* HTML tokenizer over the unified atom stream.
|
|
4
|
+
*
|
|
5
|
+
* The tokenizer implements the HTML5 tokenizer states (data, tag open/name,
|
|
6
|
+
* attribute states, raw text, comments, doctype, …) but consumes *atoms*
|
|
7
|
+
* instead of a raw character buffer. Text atoms supply the actual HTML text;
|
|
8
|
+
* twig and comment atoms are first-class units that are never mistaken for
|
|
9
|
+
* HTML — they are emitted as twig/comment tokens in data position and as
|
|
10
|
+
* inline items inside start tags (conditional attributes) or attribute values.
|
|
11
|
+
*
|
|
12
|
+
* Because the input is the original source (never a substituted copy), every
|
|
13
|
+
* emitted token carries exact absolute offsets that can be fed straight to
|
|
14
|
+
* Prettier's `locStart`/`locEnd` and to range/cursor mapping.
|
|
15
|
+
*
|
|
16
|
+
* @module twig-html-parser
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {import('./atoms.js').Atom} Atom
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Whitespace characters inside HTML tags. */
|
|
24
|
+
const WS = ' \t\n\f\r';
|
|
25
|
+
/** Elements whose content is raw text (not parsed as markup). */
|
|
26
|
+
const RAWTEXT_ELEMENTS = new Set(['script', 'style']);
|
|
27
|
+
/** Elements whose content is RCDATA (treated as raw text here). */
|
|
28
|
+
const RCDATA_ELEMENTS = new Set(['textarea', 'title']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {number} c
|
|
32
|
+
* @returns {boolean} Whether the code unit is HTML whitespace inside a tag.
|
|
33
|
+
*/
|
|
34
|
+
function isWhitespace(c) {
|
|
35
|
+
return WS.includes(String.fromCharCode(c));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A cursor over the atom list. Text atoms are consumed char by char; twig and
|
|
40
|
+
* comment atoms are taken whole.
|
|
41
|
+
*/
|
|
42
|
+
class Cursor {
|
|
43
|
+
/**
|
|
44
|
+
* @param {Atom[]} atoms
|
|
45
|
+
*/
|
|
46
|
+
constructor(atoms) {
|
|
47
|
+
/** @type {Atom[]} */
|
|
48
|
+
this.atoms = atoms;
|
|
49
|
+
/** @type {number} Index of the current atom. */
|
|
50
|
+
this.index = 0;
|
|
51
|
+
/** @type {number} Offset within the current text atom's raw. */
|
|
52
|
+
this.pos = 0;
|
|
53
|
+
/** @type {number} Last absolute offset returned (used at end of input). */
|
|
54
|
+
this.lastOffset = atoms.length ? atoms[0].rawStart : 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Advances past exhausted text atoms.
|
|
59
|
+
*/
|
|
60
|
+
ensureText() {
|
|
61
|
+
let a = this.atoms[this.index];
|
|
62
|
+
while (a && a.kind === 'text' && this.pos >= a.raw.length) {
|
|
63
|
+
this.index += 1;
|
|
64
|
+
this.pos = 0;
|
|
65
|
+
a = this.atoms[this.index];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @returns {boolean} Whether the cursor reached the end of the atoms.
|
|
71
|
+
*/
|
|
72
|
+
atEnd() {
|
|
73
|
+
this.ensureText();
|
|
74
|
+
return this.index >= this.atoms.length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @returns {boolean} Whether the current atom is a twig/comment atom.
|
|
79
|
+
*/
|
|
80
|
+
atTwig() {
|
|
81
|
+
this.ensureText();
|
|
82
|
+
const a = this.atoms[this.index];
|
|
83
|
+
return !!a && a.kind !== 'text';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @returns {Atom|null} The current text atom, or null at a twig atom / end.
|
|
88
|
+
*/
|
|
89
|
+
textAtom() {
|
|
90
|
+
this.ensureText();
|
|
91
|
+
const a = this.atoms[this.index];
|
|
92
|
+
return a && a.kind === 'text' ? a : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @returns {string|null} The current character, or null at twig/end.
|
|
97
|
+
*/
|
|
98
|
+
peek() {
|
|
99
|
+
const a = this.textAtom();
|
|
100
|
+
if (!a) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return a.raw[this.pos];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @returns {number} Absolute source offset of the current character.
|
|
108
|
+
*/
|
|
109
|
+
offset() {
|
|
110
|
+
const a = this.atoms[this.index];
|
|
111
|
+
if (!a) {
|
|
112
|
+
return this.lastOffset;
|
|
113
|
+
}
|
|
114
|
+
const off = a.rawStart + this.pos;
|
|
115
|
+
this.lastOffset = off;
|
|
116
|
+
return off;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Advances one character.
|
|
121
|
+
*/
|
|
122
|
+
next() {
|
|
123
|
+
this.pos += 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Consumes the current twig/comment atom and returns it.
|
|
128
|
+
*
|
|
129
|
+
* @returns {Atom} The consumed atom.
|
|
130
|
+
*/
|
|
131
|
+
takeTwig() {
|
|
132
|
+
const a = this.atoms[this.index];
|
|
133
|
+
this.index += 1;
|
|
134
|
+
this.pos = 0;
|
|
135
|
+
return a;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* An attribute value chunk: either literal text or a twig/comment atom.
|
|
141
|
+
*
|
|
142
|
+
* @typedef {{type: 'text', text: string, start: number, end: number} | {type: 'twig', atom: Atom}} ValueChunk
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A parsed attribute.
|
|
147
|
+
*
|
|
148
|
+
* @typedef {object} HtmlAttribute
|
|
149
|
+
* @property {'attr'} type
|
|
150
|
+
* @property {string} nameRaw The exact source text of the attribute name.
|
|
151
|
+
* @property {number} nameStart Absolute offset of the attribute name.
|
|
152
|
+
* @property {number} nameEnd Absolute offset just past the attribute name.
|
|
153
|
+
* @property {string|null} quote The quote character, or null when valueless/unquoted.
|
|
154
|
+
* @property {ValueChunk[]} valueChunks The attribute value (empty when valueless).
|
|
155
|
+
* @property {number} valueStart Absolute offset of the value content.
|
|
156
|
+
* @property {number} valueEnd Absolute offset just past the value content.
|
|
157
|
+
* @property {number} rawStart Absolute offset of the whole attribute source.
|
|
158
|
+
* @property {number} rawEnd Absolute offset just past the whole attribute.
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The start tag being assembled by the tokenizer.
|
|
163
|
+
*
|
|
164
|
+
* @typedef {object} TagState
|
|
165
|
+
* @property {string} name
|
|
166
|
+
* @property {string} nameRaw
|
|
167
|
+
* @property {number} nameStart
|
|
168
|
+
* @property {number} nameEnd
|
|
169
|
+
* @property {TagItem[]} attrs
|
|
170
|
+
* @property {boolean} selfClosing
|
|
171
|
+
* @property {number} rawStart
|
|
172
|
+
*/
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The attribute being assembled by the tokenizer.
|
|
176
|
+
*
|
|
177
|
+
* @typedef {object} AttrState
|
|
178
|
+
* @property {string} nameRaw
|
|
179
|
+
* @property {number} nameStart
|
|
180
|
+
* @property {number} nameEnd
|
|
181
|
+
* @property {string|null} quote
|
|
182
|
+
* @property {ValueChunk[]} valueChunks
|
|
183
|
+
* @property {number} valueStart
|
|
184
|
+
* @property {number} valueEnd
|
|
185
|
+
* @property {number} rawStart
|
|
186
|
+
* @property {number} rawEnd
|
|
187
|
+
* @property {number} chunkStart Offset where the current literal value run started.
|
|
188
|
+
*/
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* An item inside a start tag: either an attribute or a twig atom.
|
|
192
|
+
*
|
|
193
|
+
* @typedef {HtmlAttribute | {type: 'twig', atom: Atom}} TagItem
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A single HTML token.
|
|
198
|
+
*
|
|
199
|
+
* @typedef {object} HtmlToken
|
|
200
|
+
* @property {'text'|'twig'|'comment'|'doctype'|'startTag'|'endTag'} type
|
|
201
|
+
* @property {string} raw The exact source text of the token.
|
|
202
|
+
* @property {number} rawStart Absolute offset of the token's raw text.
|
|
203
|
+
* @property {number} rawEnd Absolute offset just past the token's raw text.
|
|
204
|
+
* @property {Atom} [atom] For twig/comment tokens, the source atom.
|
|
205
|
+
* @property {string} [name] For start/end tags, the tag name (lowercased).
|
|
206
|
+
* @property {string} [nameRaw] For start/end tags, the exact tag name source.
|
|
207
|
+
* @property {boolean} [selfClosing] For start tags.
|
|
208
|
+
* @property {TagItem[]} [attrs] For start tags.
|
|
209
|
+
*/
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string} type
|
|
213
|
+
* @param {string} raw
|
|
214
|
+
* @param {number} rawStart
|
|
215
|
+
* @param {number} rawEnd
|
|
216
|
+
* @param {object} [extra]
|
|
217
|
+
* @returns {HtmlToken}
|
|
218
|
+
*/
|
|
219
|
+
function htmlToken(type, raw, rawStart, rawEnd, extra = {}) {
|
|
220
|
+
return /** @type {HtmlToken} */ ({ type, raw, rawStart, rawEnd, ...extra });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Tokenizes the atom stream into HTML tokens.
|
|
225
|
+
*
|
|
226
|
+
* @param {Atom[]} atoms
|
|
227
|
+
* @param {string} source The original template source.
|
|
228
|
+
* @returns {HtmlToken[]} The HTML tokens.
|
|
229
|
+
*/
|
|
230
|
+
export function tokenizeHtml(atoms, source) {
|
|
231
|
+
const cursor = new Cursor(atoms);
|
|
232
|
+
/** @type {HtmlToken[]} */
|
|
233
|
+
const tokens = [];
|
|
234
|
+
|
|
235
|
+
/** The start/end tag being assembled (null outside a tag). */
|
|
236
|
+
/** @type {TagState|null} */
|
|
237
|
+
let tag = null;
|
|
238
|
+
/** The attribute being assembled (null outside an attribute). */
|
|
239
|
+
/** @type {AttrState|null} */
|
|
240
|
+
let attr = null;
|
|
241
|
+
/** The raw-text element name, when scanning raw content. */
|
|
242
|
+
let rawtextTag = null;
|
|
243
|
+
|
|
244
|
+
let state = 'data';
|
|
245
|
+
|
|
246
|
+
// ---- scanning helpers ----
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Reads characters while the predicate holds, across text atoms, stopping at
|
|
250
|
+
* twig atoms (which are left for the state machine).
|
|
251
|
+
*
|
|
252
|
+
* @param {(c: string) => boolean} pred
|
|
253
|
+
* @returns {{text: string, start: number, end: number}}
|
|
254
|
+
*/
|
|
255
|
+
function scanWhile(pred) {
|
|
256
|
+
const start = cursor.offset();
|
|
257
|
+
let out = '';
|
|
258
|
+
let end = start;
|
|
259
|
+
for (;;) {
|
|
260
|
+
const a = cursor.textAtom();
|
|
261
|
+
if (!a) {
|
|
262
|
+
break; // twig or end of input
|
|
263
|
+
}
|
|
264
|
+
const raw = a.raw;
|
|
265
|
+
let i = cursor.pos;
|
|
266
|
+
while (i < raw.length && pred(raw[i])) {
|
|
267
|
+
i += 1;
|
|
268
|
+
}
|
|
269
|
+
out += raw.slice(cursor.pos, i);
|
|
270
|
+
cursor.pos = i;
|
|
271
|
+
end = a.rawStart + i;
|
|
272
|
+
if (i < raw.length) {
|
|
273
|
+
break; // stopped by the predicate inside this atom
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { text: out, start, end };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Scans a run of data text, stopping just before `<` or a twig atom.
|
|
281
|
+
*
|
|
282
|
+
* @returns {{text: string, start: number, end: number}}
|
|
283
|
+
*/
|
|
284
|
+
function scanDataText() {
|
|
285
|
+
const start = cursor.offset();
|
|
286
|
+
let out = '';
|
|
287
|
+
let end = start;
|
|
288
|
+
for (;;) {
|
|
289
|
+
const a = cursor.textAtom();
|
|
290
|
+
if (!a) {
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
const raw = a.raw;
|
|
294
|
+
const idx = raw.indexOf('<', cursor.pos);
|
|
295
|
+
const limit = idx === -1 ? raw.length : idx;
|
|
296
|
+
out += raw.slice(cursor.pos, limit);
|
|
297
|
+
cursor.pos = limit;
|
|
298
|
+
end = a.rawStart + limit;
|
|
299
|
+
if (idx !== -1) {
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { text: out, start, end };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Scans a tag or attribute name (any char except whitespace, `/`, `>`, `=`).
|
|
308
|
+
*
|
|
309
|
+
* @returns {{text: string, end: number}}
|
|
310
|
+
*/
|
|
311
|
+
function scanName() {
|
|
312
|
+
const r = scanWhile((c) => !isWhitespace(c.charCodeAt(0)) && c !== '/' && c !== '>' && c !== '=');
|
|
313
|
+
return { text: r.text, end: r.end };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Consumes up to and including the next `>` (or the end of the current run),
|
|
318
|
+
* including any twig atoms in between.
|
|
319
|
+
*
|
|
320
|
+
* @returns {{text: string, start: number, end: number}}
|
|
321
|
+
*/
|
|
322
|
+
function readTagRest() {
|
|
323
|
+
const start = cursor.offset();
|
|
324
|
+
let out = '';
|
|
325
|
+
let end = start;
|
|
326
|
+
for (;;) {
|
|
327
|
+
const a = cursor.textAtom();
|
|
328
|
+
if (!a) {
|
|
329
|
+
if (cursor.atTwig()) {
|
|
330
|
+
const twig = cursor.takeTwig();
|
|
331
|
+
out += twig.raw;
|
|
332
|
+
end = twig.rawEnd;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
const raw = a.raw;
|
|
338
|
+
const idx = raw.indexOf('>', cursor.pos);
|
|
339
|
+
const limit = idx === -1 ? raw.length : idx + 1;
|
|
340
|
+
out += raw.slice(cursor.pos, limit);
|
|
341
|
+
cursor.pos = limit;
|
|
342
|
+
end = a.rawStart + limit;
|
|
343
|
+
if (idx !== -1) {
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { text: out, start, end };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Consumes a `<!-- ... -->` comment across text and twig atoms. Returns the
|
|
352
|
+
* raw text consumed; when the comment is unterminated the rest of the input
|
|
353
|
+
* is consumed.
|
|
354
|
+
*
|
|
355
|
+
* @returns {{text: string, end: number}}
|
|
356
|
+
*/
|
|
357
|
+
function scanHtmlComment() {
|
|
358
|
+
let out = '';
|
|
359
|
+
let end = cursor.offset();
|
|
360
|
+
for (;;) {
|
|
361
|
+
const a = cursor.textAtom();
|
|
362
|
+
if (!a) {
|
|
363
|
+
if (cursor.atTwig()) {
|
|
364
|
+
const twig = cursor.takeTwig();
|
|
365
|
+
out += twig.raw;
|
|
366
|
+
end = twig.rawEnd;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
const raw = a.raw;
|
|
372
|
+
const idx = raw.indexOf('-->', cursor.pos);
|
|
373
|
+
const limit = idx === -1 ? raw.length : idx + 3;
|
|
374
|
+
out += raw.slice(cursor.pos, limit);
|
|
375
|
+
cursor.pos = limit;
|
|
376
|
+
end = a.rawStart + limit;
|
|
377
|
+
if (idx !== -1) {
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { text: out, end };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Scans raw text up to `</tag` (case-insensitive) or a twig atom.
|
|
386
|
+
*
|
|
387
|
+
* @param {string} tagName
|
|
388
|
+
* @returns {{text: string, textStart: number, textEnd: number, found: boolean, endTag: string, endTagStart: number, endTagEnd: number}}
|
|
389
|
+
*/
|
|
390
|
+
function scanRawText(tagName) {
|
|
391
|
+
const lowerTag = tagName.toLowerCase();
|
|
392
|
+
const start = cursor.offset();
|
|
393
|
+
let out = '';
|
|
394
|
+
let textEnd = start;
|
|
395
|
+
for (;;) {
|
|
396
|
+
const a = cursor.textAtom();
|
|
397
|
+
if (!a) {
|
|
398
|
+
break; // twig or end of input
|
|
399
|
+
}
|
|
400
|
+
const raw = a.raw;
|
|
401
|
+
const idx = raw.toLowerCase().indexOf(`</${lowerTag}`, cursor.pos);
|
|
402
|
+
const limit = idx === -1 ? raw.length : idx;
|
|
403
|
+
out += raw.slice(cursor.pos, limit);
|
|
404
|
+
cursor.pos = limit;
|
|
405
|
+
textEnd = a.rawStart + limit;
|
|
406
|
+
if (idx !== -1) {
|
|
407
|
+
// consume `</tag` plus whitespace up to `>`
|
|
408
|
+
let i = idx;
|
|
409
|
+
while (i < raw.length && raw[i] !== '>') {
|
|
410
|
+
i += 1;
|
|
411
|
+
}
|
|
412
|
+
const endTagEnd = i < raw.length ? a.rawStart + i + 1 : a.rawStart + raw.length;
|
|
413
|
+
const endTag = raw.slice(idx, endTagEnd - a.rawStart);
|
|
414
|
+
cursor.pos = endTagEnd - a.rawStart;
|
|
415
|
+
return { text: out, textStart: start, textEnd, found: true, endTag, endTagStart: a.rawStart + idx, endTagEnd };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return { text: out, textStart: start, textEnd, found: false, endTag: '', endTagStart: 0, endTagEnd: 0 };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Commits the current attribute (if any) to the tag's attribute list.
|
|
423
|
+
*/
|
|
424
|
+
function commitAttr() {
|
|
425
|
+
if (attr) {
|
|
426
|
+
/** @type {HtmlAttribute} */
|
|
427
|
+
const a = {
|
|
428
|
+
type: 'attr',
|
|
429
|
+
nameRaw: /** @type {AttrState} */ (attr).nameRaw,
|
|
430
|
+
nameStart: /** @type {AttrState} */ (attr).nameStart,
|
|
431
|
+
nameEnd: /** @type {AttrState} */ (attr).nameEnd,
|
|
432
|
+
quote: /** @type {AttrState} */ (attr).quote,
|
|
433
|
+
valueChunks: /** @type {AttrState} */ (attr).valueChunks,
|
|
434
|
+
valueStart: /** @type {AttrState} */ (attr).valueStart,
|
|
435
|
+
valueEnd: /** @type {AttrState} */ (attr).valueEnd,
|
|
436
|
+
rawStart: /** @type {AttrState} */ (attr).rawStart,
|
|
437
|
+
rawEnd: /** @type {AttrState} */ (attr).rawEnd,
|
|
438
|
+
};
|
|
439
|
+
/** @type {TagState} */ (tag).attrs.push(a);
|
|
440
|
+
attr = null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Emits the current start tag once its closing `>` has been consumed.
|
|
446
|
+
*/
|
|
447
|
+
function emitStartTag() {
|
|
448
|
+
commitAttr();
|
|
449
|
+
tokens.push(
|
|
450
|
+
htmlToken('startTag', source.slice(/** @type {TagState} */ (tag).rawStart, tagEnd), /** @type {TagState} */ (tag).rawStart, tagEnd, {
|
|
451
|
+
name: /** @type {TagState} */ (tag).name,
|
|
452
|
+
nameRaw: /** @type {TagState} */ (tag).nameRaw,
|
|
453
|
+
selfClosing: /** @type {TagState} */ (tag).selfClosing,
|
|
454
|
+
attrs: /** @type {TagState} */ (tag).attrs,
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
const name = /** @type {TagState} */ (tag).name;
|
|
458
|
+
tag = null;
|
|
459
|
+
if (RAWTEXT_ELEMENTS.has(name) || RCDATA_ELEMENTS.has(name)) {
|
|
460
|
+
rawtextTag = name;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Closes the current literal attribute value run as a text chunk.
|
|
466
|
+
*/
|
|
467
|
+
function addTextChunk() {
|
|
468
|
+
const a = /** @type {AttrState} */ (attr);
|
|
469
|
+
if (a && a.chunkStart < cursor.offset()) {
|
|
470
|
+
a.valueChunks.push({
|
|
471
|
+
type: 'text',
|
|
472
|
+
text: source.slice(a.chunkStart, cursor.offset()),
|
|
473
|
+
start: a.chunkStart,
|
|
474
|
+
end: cursor.offset(),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (a) {
|
|
478
|
+
a.chunkStart = cursor.offset();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Emits a twig or comment token covering the atom's leading trivia and raw
|
|
484
|
+
* so the token stream stays contiguous with the source.
|
|
485
|
+
*
|
|
486
|
+
* @param {Atom} atom
|
|
487
|
+
*/
|
|
488
|
+
function emitAtomToken(atom) {
|
|
489
|
+
tokens.push(
|
|
490
|
+
htmlToken(
|
|
491
|
+
atom.kind === 'comment' ? 'comment' : 'twig',
|
|
492
|
+
atom.leading + atom.raw,
|
|
493
|
+
atom.sourceStart,
|
|
494
|
+
atom.rawEnd,
|
|
495
|
+
{ atom },
|
|
496
|
+
),
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The tag end offset is tracked separately so `emitStartTag` can slice the
|
|
501
|
+
// exact `<...>` source.
|
|
502
|
+
let tagEnd = -1;
|
|
503
|
+
/** Absolute offset of the `<` of the current markup declaration. */
|
|
504
|
+
let declStart = -1;
|
|
505
|
+
|
|
506
|
+
while (!cursor.atEnd()) {
|
|
507
|
+
switch (state) {
|
|
508
|
+
case 'data': {
|
|
509
|
+
if (rawtextTag) {
|
|
510
|
+
if (cursor.atTwig()) {
|
|
511
|
+
emitAtomToken(cursor.takeTwig());
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
const r = scanRawText(rawtextTag);
|
|
515
|
+
if (r.text !== '') {
|
|
516
|
+
tokens.push(htmlToken('text', r.text, r.textStart, r.textEnd));
|
|
517
|
+
}
|
|
518
|
+
if (r.found) {
|
|
519
|
+
tokens.push(
|
|
520
|
+
htmlToken('endTag', r.endTag, r.endTagStart, r.endTagEnd, {
|
|
521
|
+
name: rawtextTag,
|
|
522
|
+
nameRaw: rawtextTag,
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
rawtextTag = null;
|
|
526
|
+
state = 'data';
|
|
527
|
+
}
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
if (cursor.atTwig()) {
|
|
531
|
+
emitAtomToken(cursor.takeTwig());
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
const c = cursor.peek();
|
|
535
|
+
if (c === null) {
|
|
536
|
+
cursor.next();
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
if (c === '<') {
|
|
540
|
+
const start = cursor.offset();
|
|
541
|
+
cursor.next();
|
|
542
|
+
tag = {
|
|
543
|
+
name: '',
|
|
544
|
+
nameRaw: '',
|
|
545
|
+
attrs: [],
|
|
546
|
+
selfClosing: false,
|
|
547
|
+
rawStart: start,
|
|
548
|
+
nameStart: -1,
|
|
549
|
+
nameEnd: -1,
|
|
550
|
+
};
|
|
551
|
+
state = 'tagOpen';
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
const t = scanDataText();
|
|
555
|
+
tokens.push(htmlToken('text', t.text, t.start, t.end));
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
case 'tagOpen': {
|
|
560
|
+
if (cursor.atTwig()) {
|
|
561
|
+
// `<` directly followed by twig: not a tag, treat as text
|
|
562
|
+
tokens.push(htmlToken('text', '<', /** @type {TagState} */ (tag).rawStart, /** @type {TagState} */ (tag).rawStart + 1));
|
|
563
|
+
tag = null;
|
|
564
|
+
state = 'data';
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
const c = cursor.peek();
|
|
568
|
+
if (c === null) {
|
|
569
|
+
cursor.next();
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
if (c === '/') {
|
|
573
|
+
cursor.next();
|
|
574
|
+
state = 'endTagOpen';
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
if (c === '!') {
|
|
578
|
+
cursor.next();
|
|
579
|
+
declStart = /** @type {TagState} */ (tag).rawStart;
|
|
580
|
+
state = 'markupDeclarationOpen';
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
if (c === '?') {
|
|
584
|
+
cursor.next();
|
|
585
|
+
declStart = /** @type {TagState} */ (tag).rawStart;
|
|
586
|
+
tag = null;
|
|
587
|
+
state = 'bogusComment';
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
if (isWhitespace(c.charCodeAt(0)) || c === '>') {
|
|
591
|
+
tokens.push(htmlToken('text', '<', /** @type {TagState} */ (tag).rawStart, /** @type {TagState} */ (tag).rawStart + 1));
|
|
592
|
+
tag = null;
|
|
593
|
+
state = 'data';
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
/** @type {TagState} */ (tag).nameStart = cursor.offset();
|
|
597
|
+
state = 'tagName';
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
case 'tagName': {
|
|
602
|
+
const nameEnd = scanName();
|
|
603
|
+
/** @type {TagState} */ (tag).nameRaw = nameEnd.text;
|
|
604
|
+
/** @type {TagState} */ (tag).name = nameEnd.text.toLowerCase();
|
|
605
|
+
/** @type {TagState} */ (tag).nameEnd = nameEnd.end;
|
|
606
|
+
state = 'beforeAttributeName';
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
case 'endTagOpen': {
|
|
611
|
+
if (cursor.atTwig()) {
|
|
612
|
+
tokens.push(htmlToken('text', '</', /** @type {TagState} */ (tag).rawStart, /** @type {TagState} */ (tag).rawStart + 2));
|
|
613
|
+
tag = null;
|
|
614
|
+
state = 'data';
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
const c = cursor.peek();
|
|
618
|
+
if (c === null) {
|
|
619
|
+
cursor.next();
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
if (c === '>' || isWhitespace(c.charCodeAt(0))) {
|
|
623
|
+
tokens.push(htmlToken('text', '</', /** @type {TagState} */ (tag).rawStart, /** @type {TagState} */ (tag).rawStart + 2));
|
|
624
|
+
tag = null;
|
|
625
|
+
state = 'data';
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
/** @type {TagState} */ (tag).nameStart = cursor.offset();
|
|
629
|
+
const nameEnd = scanName();
|
|
630
|
+
/** @type {TagState} */ (tag).nameRaw = nameEnd.text;
|
|
631
|
+
/** @type {TagState} */ (tag).name = nameEnd.text.toLowerCase();
|
|
632
|
+
/** @type {TagState} */ (tag).nameEnd = nameEnd.end;
|
|
633
|
+
state = 'beforeEndTagName';
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
case 'beforeEndTagName': {
|
|
638
|
+
if (cursor.atTwig()) {
|
|
639
|
+
emitAtomToken(cursor.takeTwig());
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
const c = cursor.peek();
|
|
643
|
+
if (c === null) {
|
|
644
|
+
cursor.next();
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
if (isWhitespace(c.charCodeAt(0))) {
|
|
648
|
+
cursor.next();
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
if (c === '>') {
|
|
652
|
+
const end = cursor.offset() + 1;
|
|
653
|
+
cursor.next();
|
|
654
|
+
tokens.push(
|
|
655
|
+
htmlToken('endTag', source.slice(/** @type {TagState} */ (tag).rawStart, end), /** @type {TagState} */ (tag).rawStart, end, {
|
|
656
|
+
name: /** @type {TagState} */ (tag).name,
|
|
657
|
+
nameRaw: /** @type {TagState} */ (tag).nameRaw,
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
660
|
+
tag = null;
|
|
661
|
+
state = 'data';
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
// unexpected character: keep scanning the end tag name
|
|
665
|
+
const end = scanName();
|
|
666
|
+
/** @type {TagState} */ (tag).nameRaw = end.text;
|
|
667
|
+
/** @type {TagState} */ (tag).name = end.text.toLowerCase();
|
|
668
|
+
/** @type {TagState} */ (tag).nameEnd = end.end;
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
case 'beforeAttributeName': {
|
|
673
|
+
if (cursor.atTwig()) {
|
|
674
|
+
const atom = cursor.takeTwig();
|
|
675
|
+
/** @type {TagState} */ (tag).attrs.push({ type: 'twig', atom });
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
const c = cursor.peek();
|
|
679
|
+
if (c === null) {
|
|
680
|
+
cursor.next();
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
const cc = c.charCodeAt(0);
|
|
684
|
+
if (isWhitespace(cc)) {
|
|
685
|
+
cursor.next();
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
if (c === '>') {
|
|
689
|
+
tagEnd = cursor.offset() + 1;
|
|
690
|
+
cursor.next();
|
|
691
|
+
emitStartTag();
|
|
692
|
+
state = 'data';
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
if (c === '/') {
|
|
696
|
+
cursor.next();
|
|
697
|
+
state = 'selfClosingStartTag';
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
attr = {
|
|
701
|
+
nameRaw: '',
|
|
702
|
+
nameStart: cursor.offset(),
|
|
703
|
+
nameEnd: -1,
|
|
704
|
+
quote: null,
|
|
705
|
+
valueChunks: [],
|
|
706
|
+
valueStart: -1,
|
|
707
|
+
valueEnd: -1,
|
|
708
|
+
rawStart: cursor.offset(),
|
|
709
|
+
rawEnd: -1,
|
|
710
|
+
chunkStart: -1,
|
|
711
|
+
};
|
|
712
|
+
state = 'attributeName';
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
case 'attributeName': {
|
|
717
|
+
if (cursor.atTwig()) {
|
|
718
|
+
// a twig atom ends the attribute name
|
|
719
|
+
/** @type {AttrState} */ (attr).nameRaw = source.slice(/** @type {AttrState} */ (attr).nameStart, cursor.offset());
|
|
720
|
+
/** @type {AttrState} */ (attr).nameEnd = cursor.offset();
|
|
721
|
+
commitAttr();
|
|
722
|
+
const atom = cursor.takeTwig();
|
|
723
|
+
/** @type {TagState} */ (tag).attrs.push({ type: 'twig', atom });
|
|
724
|
+
state = 'beforeAttributeName';
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
const c = cursor.peek();
|
|
728
|
+
if (c === null) {
|
|
729
|
+
cursor.next();
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
const cc = c.charCodeAt(0);
|
|
733
|
+
if (isWhitespace(cc)) {
|
|
734
|
+
/** @type {AttrState} */ (attr).nameRaw = source.slice(/** @type {AttrState} */ (attr).nameStart, cursor.offset());
|
|
735
|
+
/** @type {AttrState} */ (attr).nameEnd = cursor.offset();
|
|
736
|
+
state = 'afterAttributeName';
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
if (c === '=') {
|
|
740
|
+
/** @type {AttrState} */ (attr).nameRaw = source.slice(/** @type {AttrState} */ (attr).nameStart, cursor.offset());
|
|
741
|
+
/** @type {AttrState} */ (attr).nameEnd = cursor.offset();
|
|
742
|
+
cursor.next();
|
|
743
|
+
state = 'beforeAttributeValue';
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
if (c === '>') {
|
|
747
|
+
/** @type {AttrState} */ (attr).nameRaw = source.slice(/** @type {AttrState} */ (attr).nameStart, cursor.offset());
|
|
748
|
+
/** @type {AttrState} */ (attr).nameEnd = cursor.offset();
|
|
749
|
+
/** @type {AttrState} */ (attr).rawEnd = /** @type {AttrState} */ (attr).nameEnd;
|
|
750
|
+
commitAttr();
|
|
751
|
+
tagEnd = cursor.offset() + 1;
|
|
752
|
+
cursor.next();
|
|
753
|
+
emitStartTag();
|
|
754
|
+
state = 'data';
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
if (c === '/') {
|
|
758
|
+
/** @type {AttrState} */ (attr).nameRaw = source.slice(/** @type {AttrState} */ (attr).nameStart, cursor.offset());
|
|
759
|
+
/** @type {AttrState} */ (attr).nameEnd = cursor.offset();
|
|
760
|
+
/** @type {AttrState} */ (attr).rawEnd = /** @type {AttrState} */ (attr).nameEnd;
|
|
761
|
+
commitAttr();
|
|
762
|
+
cursor.next();
|
|
763
|
+
state = 'selfClosingStartTag';
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
cursor.next();
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
case 'afterAttributeName': {
|
|
771
|
+
if (cursor.atTwig()) {
|
|
772
|
+
/** @type {AttrState} */ (attr).rawEnd = /** @type {AttrState} */ (attr).nameEnd;
|
|
773
|
+
commitAttr();
|
|
774
|
+
const atom = cursor.takeTwig();
|
|
775
|
+
/** @type {TagState} */ (tag).attrs.push({ type: 'twig', atom });
|
|
776
|
+
state = 'beforeAttributeName';
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
const c = cursor.peek();
|
|
780
|
+
if (c === null) {
|
|
781
|
+
cursor.next();
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
const cc = c.charCodeAt(0);
|
|
785
|
+
if (isWhitespace(cc)) {
|
|
786
|
+
cursor.next();
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
if (c === '=') {
|
|
790
|
+
cursor.next();
|
|
791
|
+
state = 'beforeAttributeValue';
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
if (c === '>') {
|
|
795
|
+
/** @type {AttrState} */ (attr).rawEnd = /** @type {AttrState} */ (attr).nameEnd;
|
|
796
|
+
commitAttr();
|
|
797
|
+
tagEnd = cursor.offset() + 1;
|
|
798
|
+
cursor.next();
|
|
799
|
+
emitStartTag();
|
|
800
|
+
state = 'data';
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
if (c === '/') {
|
|
804
|
+
/** @type {AttrState} */ (attr).rawEnd = /** @type {AttrState} */ (attr).nameEnd;
|
|
805
|
+
commitAttr();
|
|
806
|
+
cursor.next();
|
|
807
|
+
state = 'selfClosingStartTag';
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
/** @type {AttrState} */ (attr).rawEnd = /** @type {AttrState} */ (attr).nameEnd;
|
|
811
|
+
commitAttr();
|
|
812
|
+
attr = {
|
|
813
|
+
nameRaw: '',
|
|
814
|
+
nameStart: cursor.offset(),
|
|
815
|
+
nameEnd: -1,
|
|
816
|
+
quote: null,
|
|
817
|
+
valueChunks: [],
|
|
818
|
+
valueStart: -1,
|
|
819
|
+
valueEnd: -1,
|
|
820
|
+
rawStart: cursor.offset(),
|
|
821
|
+
rawEnd: -1,
|
|
822
|
+
chunkStart: -1,
|
|
823
|
+
};
|
|
824
|
+
state = 'attributeName';
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
case 'beforeAttributeValue': {
|
|
829
|
+
if (cursor.atTwig()) {
|
|
830
|
+
/** @type {AttrState} */ (attr).valueStart = cursor.offset();
|
|
831
|
+
/** @type {AttrState} */ (attr).valueChunks.push({ type: 'twig', atom: cursor.takeTwig() });
|
|
832
|
+
/** @type {AttrState} */ (attr).valueEnd = cursor.offset();
|
|
833
|
+
/** @type {AttrState} */ (attr).rawEnd = /** @type {AttrState} */ (attr).valueEnd;
|
|
834
|
+
commitAttr();
|
|
835
|
+
state = 'beforeAttributeName';
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
const c = cursor.peek();
|
|
839
|
+
if (c === null) {
|
|
840
|
+
cursor.next();
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
const cc = c.charCodeAt(0);
|
|
844
|
+
if (isWhitespace(cc)) {
|
|
845
|
+
cursor.next();
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
if (c === '"' || c === "'") {
|
|
849
|
+
/** @type {AttrState} */ (attr).quote = c;
|
|
850
|
+
cursor.next();
|
|
851
|
+
/** @type {AttrState} */ (attr).valueStart = cursor.offset();
|
|
852
|
+
/** @type {AttrState} */ (attr).chunkStart = cursor.offset();
|
|
853
|
+
state = 'attributeValueQuoted';
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
856
|
+
if (c === '>') {
|
|
857
|
+
/** @type {AttrState} */ (attr).rawEnd = /** @type {AttrState} */ (attr).nameEnd;
|
|
858
|
+
commitAttr();
|
|
859
|
+
tagEnd = cursor.offset() + 1;
|
|
860
|
+
cursor.next();
|
|
861
|
+
emitStartTag();
|
|
862
|
+
state = 'data';
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
/** @type {AttrState} */ (attr).valueStart = cursor.offset();
|
|
866
|
+
/** @type {AttrState} */ (attr).chunkStart = cursor.offset();
|
|
867
|
+
state = 'attributeValueUnquoted';
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
case 'attributeValueQuoted': {
|
|
872
|
+
if (cursor.atTwig()) {
|
|
873
|
+
addTextChunk();
|
|
874
|
+
/** @type {AttrState} */ (attr).valueChunks.push({ type: 'twig', atom: cursor.takeTwig() });
|
|
875
|
+
/** @type {AttrState} */ (attr).chunkStart = cursor.offset();
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
const c = cursor.peek();
|
|
879
|
+
if (c === null) {
|
|
880
|
+
cursor.next();
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
if (c === /** @type {AttrState} */ (attr).quote) {
|
|
884
|
+
const end = cursor.offset();
|
|
885
|
+
addTextChunk();
|
|
886
|
+
cursor.next();
|
|
887
|
+
/** @type {AttrState} */ (attr).valueEnd = end;
|
|
888
|
+
/** @type {AttrState} */ (attr).rawEnd = end + 1;
|
|
889
|
+
commitAttr();
|
|
890
|
+
state = 'afterAttributeValueQuoted';
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
cursor.next();
|
|
894
|
+
break;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
case 'attributeValueUnquoted': {
|
|
898
|
+
if (cursor.atTwig()) {
|
|
899
|
+
addTextChunk();
|
|
900
|
+
/** @type {AttrState} */ (attr).valueChunks.push({ type: 'twig', atom: cursor.takeTwig() });
|
|
901
|
+
/** @type {AttrState} */ (attr).chunkStart = cursor.offset();
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
const c = cursor.peek();
|
|
905
|
+
if (c === null) {
|
|
906
|
+
cursor.next();
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
const cc = c.charCodeAt(0);
|
|
910
|
+
if (isWhitespace(cc)) {
|
|
911
|
+
addTextChunk();
|
|
912
|
+
/** @type {AttrState} */ (attr).valueEnd = cursor.offset();
|
|
913
|
+
/** @type {AttrState} */ (attr).rawEnd = /** @type {AttrState} */ (attr).valueEnd;
|
|
914
|
+
commitAttr();
|
|
915
|
+
state = 'beforeAttributeName';
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
if (c === '>') {
|
|
919
|
+
addTextChunk();
|
|
920
|
+
/** @type {AttrState} */ (attr).valueEnd = cursor.offset();
|
|
921
|
+
/** @type {AttrState} */ (attr).rawEnd = /** @type {AttrState} */ (attr).valueEnd;
|
|
922
|
+
commitAttr();
|
|
923
|
+
tagEnd = cursor.offset() + 1;
|
|
924
|
+
cursor.next();
|
|
925
|
+
emitStartTag();
|
|
926
|
+
state = 'data';
|
|
927
|
+
break;
|
|
928
|
+
}
|
|
929
|
+
cursor.next();
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
case 'afterAttributeValueQuoted': {
|
|
934
|
+
if (cursor.atTwig()) {
|
|
935
|
+
state = 'beforeAttributeName';
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
const c = cursor.peek();
|
|
939
|
+
if (c === null) {
|
|
940
|
+
state = 'beforeAttributeName';
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
if (isWhitespace(c.charCodeAt(0))) {
|
|
944
|
+
cursor.next();
|
|
945
|
+
state = 'beforeAttributeName';
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
if (c === '>') {
|
|
949
|
+
tagEnd = cursor.offset() + 1;
|
|
950
|
+
cursor.next();
|
|
951
|
+
emitStartTag();
|
|
952
|
+
state = 'data';
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
if (c === '/') {
|
|
956
|
+
cursor.next();
|
|
957
|
+
state = 'selfClosingStartTag';
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
state = 'beforeAttributeName';
|
|
961
|
+
break;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
case 'selfClosingStartTag': {
|
|
965
|
+
if (!cursor.atTwig() && cursor.peek() === '>') {
|
|
966
|
+
tagEnd = cursor.offset() + 1;
|
|
967
|
+
cursor.next();
|
|
968
|
+
/** @type {TagState} */ (tag).selfClosing = true;
|
|
969
|
+
emitStartTag();
|
|
970
|
+
state = 'data';
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
state = 'beforeAttributeName';
|
|
974
|
+
break;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
case 'markupDeclarationOpen': {
|
|
978
|
+
if (cursor.atTwig()) {
|
|
979
|
+
cursor.takeTwig();
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
const c = cursor.peek();
|
|
983
|
+
if (c === null) {
|
|
984
|
+
cursor.next();
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
if (c === '-') {
|
|
988
|
+
cursor.next();
|
|
989
|
+
if (cursor.peek() === '-') {
|
|
990
|
+
cursor.next();
|
|
991
|
+
const r = scanHtmlComment();
|
|
992
|
+
tokens.push(htmlToken('comment', source.slice(declStart, r.end), declStart, r.end));
|
|
993
|
+
tag = null;
|
|
994
|
+
state = 'data';
|
|
995
|
+
} else {
|
|
996
|
+
state = 'bogusComment';
|
|
997
|
+
}
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
// doctype or bogus comment, read until `>`
|
|
1001
|
+
const rest = readTagRest();
|
|
1002
|
+
const trimmed = rest.text.trim().toLowerCase();
|
|
1003
|
+
const isDoctype = /^doctype(\s|$)/.test(trimmed);
|
|
1004
|
+
tokens.push(
|
|
1005
|
+
htmlToken(isDoctype ? 'doctype' : 'comment', source.slice(declStart, rest.end), declStart, rest.end),
|
|
1006
|
+
);
|
|
1007
|
+
tag = null;
|
|
1008
|
+
state = 'data';
|
|
1009
|
+
break;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
case 'bogusComment': {
|
|
1013
|
+
const rest = readTagRest();
|
|
1014
|
+
tokens.push(htmlToken('comment', source.slice(declStart, rest.end), declStart, rest.end));
|
|
1015
|
+
state = 'data';
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
default:
|
|
1020
|
+
state = 'data';
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// flush an unterminated tag as raw text
|
|
1026
|
+
if (tag) {
|
|
1027
|
+
tokens.push(htmlToken('text', source.slice(/** @type {TagState} */ (tag).rawStart), /** @type {TagState} */ (tag).rawStart, source.length));
|
|
1028
|
+
tag = null;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
return tokens;
|
|
1032
|
+
}
|