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