html-minifier 5.0.2 → 5.0.3
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/README.md +7 -4
- package/cli.js +89 -32
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/constants.d.ts +5 -1
- package/dist/types/lib/constants.d.ts.map +1 -1
- package/dist/types/lib/elements.d.ts.map +1 -1
- package/dist/types/lib/options.d.ts +1 -0
- package/dist/types/lib/options.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/htmlminifier.js +131 -30
- package/src/htmlparser.js +309 -24
- package/src/lib/attributes.js +77 -12
- package/src/lib/constants.js +14 -3
- package/src/lib/elements.js +7 -0
- package/src/lib/options.js +5 -4
package/src/htmlparser.js
CHANGED
|
@@ -49,10 +49,14 @@ import { endlessRawTextElements, escapableRawTextElements, formattingElements, g
|
|
|
49
49
|
|
|
50
50
|
// Regular expressions for parsing tags and attributes
|
|
51
51
|
const singleAttrIdentifier = /([^\s"'<>/=]+)/;
|
|
52
|
+
// Lenient attribute name pattern for `continueOnParseError`:
|
|
53
|
+
// Quotes in names are parse errors that browsers keep in the name
|
|
54
|
+
const singleAttrIdentifierLenient = /([^\s<>/=]+)/;
|
|
52
55
|
const singleAttrAssigns = [/=/];
|
|
53
56
|
const singleAttrValues = [
|
|
54
|
-
|
|
55
|
-
/
|
|
57
|
+
// Repeated closing quotes go with the value, unless they run into a name, which they then start
|
|
58
|
+
/"([^"]*)"(?:"+(?![^\s/>]))?/.source,
|
|
59
|
+
/'([^']*)'(?:'+(?![^\s/>]))?/.source,
|
|
56
60
|
// Attr value, no quotes (strict: excludes `=` per HTML spec)
|
|
57
61
|
/([^ \t\n\f\r"'`=<>]+)/.source
|
|
58
62
|
];
|
|
@@ -62,6 +66,7 @@ const singleAttrValues = [
|
|
|
62
66
|
const singleAttrValueLenientUnquoted = /([^ \t\n\f\r"'>]+)/.source;
|
|
63
67
|
// The characters the lenient pattern takes in beyond the strict one, each a parse error
|
|
64
68
|
const RE_UNQUOTED_VALUE_ERROR = /[<=`]/;
|
|
69
|
+
const RE_ATTR_NAME_QUOTE = /["']/;
|
|
65
70
|
// An attribute name and `=`, for reading a quoted value too long for the attribute pattern
|
|
66
71
|
const RE_ATTR_NAME_ASSIGN = /^\s*([^\s"'<>/=]+)\s*=\s*/;
|
|
67
72
|
// The rest of an unquoted value, strict and lenient, as one character class read in a single
|
|
@@ -81,8 +86,10 @@ const qnameCapture = (function () {
|
|
|
81
86
|
const startTagOpen = new RegExp('^<' + qnameCapture);
|
|
82
87
|
const endTagOpen = new RegExp('^</' + qnameCapture);
|
|
83
88
|
|
|
89
|
+
const RE_COMMENT_END = /--!?>/g;
|
|
90
|
+
|
|
84
91
|
// Empty elements
|
|
85
|
-
const empty = new Set(['area', 'base', 'basefont', 'br', 'col', 'embed', 'frame', 'hr', 'img', 'input', '
|
|
92
|
+
const empty = new Set(['area', 'base', 'basefont', 'br', 'col', 'embed', 'frame', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
86
93
|
|
|
87
94
|
// Elements that you can, intentionally, leave open (and which close themselves)
|
|
88
95
|
const closeSelf = new Set(['colgroup', 'dd', 'dt', 'li', 'option', 'p', 'td', 'tfoot', 'th', 'thead', 'tr', 'source']);
|
|
@@ -115,6 +122,26 @@ const buttonScopeBoundaries = new Set(['applet', 'button', 'caption', 'html', 'm
|
|
|
115
122
|
// https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody
|
|
116
123
|
const closesP = new Set(['address', 'article', 'aside', 'blockquote', 'center', 'dd', 'details', 'dialog', 'dir', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', 'listing', 'main', 'menu', 'nav', 'ol', 'p', 'plaintext', 'pre', 'search', 'section', 'summary', 'table', 'ul', 'xmp']);
|
|
117
124
|
|
|
125
|
+
const tableSectionElements = new Set(['tbody', 'tfoot', 'thead']);
|
|
126
|
+
const tablePartElements = new Set([...tableSectionElements, 'td', 'th', 'tr']);
|
|
127
|
+
|
|
128
|
+
// Elements that end the scope a formatting element’s end tag looks for it in,
|
|
129
|
+
// and those that make it leave the elements inside it open
|
|
130
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#has-an-element-in-scope
|
|
131
|
+
const formattingScopeBoundaries = new Set(['applet', 'caption', 'html', 'marquee', 'object', 'table', 'td', 'template', 'th']);
|
|
132
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#special
|
|
133
|
+
const specialElements = new Set(['address', 'article', 'aside', 'blockquote', 'body', 'button', 'center', 'colgroup', 'dd', 'details', 'dir', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'iframe', 'li', 'listing', 'main', 'menu', 'nav', 'noembed', 'noframes', 'noscript', 'ol', 'p', 'plaintext', 'pre', 'script', 'search', 'section', 'select', 'style', 'summary', 'tbody', 'textarea', 'tfoot', 'thead', 'title', 'tr', 'ul', 'xmp']);
|
|
134
|
+
|
|
135
|
+
// End tags browsers ignore unless the element is in scope, which ends at the boundaries below, at
|
|
136
|
+
// `button` for `p`, and at `ol` and `ul` for `li`. Cells and captions are left out, as they’re
|
|
137
|
+
// only open within a `table` that ends the scope anyway, and `form`, whose end tag has an effect
|
|
138
|
+
// even where it’s ignored.
|
|
139
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody
|
|
140
|
+
const scopedEndTagElements = new Set(['address', 'applet', 'article', 'aside', 'blockquote', 'button', 'center', 'dd', 'details', 'dialog', 'dir', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'li', 'listing', 'main', 'marquee', 'menu', 'nav', 'object', 'ol', 'p', 'pre', 'search', 'section', 'summary', 'ul']);
|
|
141
|
+
const endTagScopeBoundaries = new Set(['applet', 'html', 'marquee', 'object', 'table', 'template']);
|
|
142
|
+
|
|
143
|
+
const isSpecialOrBoundary = (/** @type {string} */ lowerTag) => specialElements.has(lowerTag) || formattingScopeBoundaries.has(lowerTag);
|
|
144
|
+
|
|
118
145
|
// A tag name ends at whitespace, a slash, or the closing bracket, so `</scriptx>` names a
|
|
119
146
|
// different element and does not end this one
|
|
120
147
|
const endsTagName = (/** @type {string} */ character) =>
|
|
@@ -184,6 +211,9 @@ function findEndTagClose(text, nameEnd) {
|
|
|
184
211
|
* reaches, or `null` where no end tag of its own follows
|
|
185
212
|
*/
|
|
186
213
|
function findRawTextEnd(text, tag) {
|
|
214
|
+
if (tag === 'script') {
|
|
215
|
+
return findScriptEnd(text);
|
|
216
|
+
}
|
|
187
217
|
const nameEnd = tag.length + 2;
|
|
188
218
|
for (let candidate = text.indexOf('</'); candidate !== -1; candidate = text.indexOf('</', candidate + 2)) {
|
|
189
219
|
if (text.slice(candidate + 2, candidate + nameEnd).toLowerCase() !== tag || !endsTagName(text.charAt(candidate + nameEnd))) {
|
|
@@ -197,6 +227,48 @@ function findRawTextEnd(text, tag) {
|
|
|
197
227
|
return null;
|
|
198
228
|
}
|
|
199
229
|
|
|
230
|
+
// What changes the state script content is read in; a tag name ends as in `endsTagName`,
|
|
231
|
+
// and `--!>` is left out as it only ends comments, not these states
|
|
232
|
+
const RE_SCRIPT_STATE = /<!--|-->|<\/?script(?=[\s/>])/gi;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Browsers read `<!--` in a script as opening an escaped state, in which
|
|
236
|
+
* `<script` opens a double escaped one that an end tag only leaves again
|
|
237
|
+
* https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state
|
|
238
|
+
* @param {string} text - The input from the start of the content on
|
|
239
|
+
* @returns {{content: string, length: number} | null} What the element holds and how far it
|
|
240
|
+
* reaches, or `null` where no end tag of its own follows
|
|
241
|
+
*/
|
|
242
|
+
function findScriptEnd(text) {
|
|
243
|
+
const DATA = 0;
|
|
244
|
+
const ESCAPED = 1;
|
|
245
|
+
const DOUBLE_ESCAPED = 2;
|
|
246
|
+
let state = DATA;
|
|
247
|
+
RE_SCRIPT_STATE.lastIndex = 0;
|
|
248
|
+
for (let match = RE_SCRIPT_STATE.exec(text); match; match = RE_SCRIPT_STATE.exec(text)) {
|
|
249
|
+
const token = match[0].toLowerCase();
|
|
250
|
+
if (token === '<!--') {
|
|
251
|
+
if (state === DATA) {
|
|
252
|
+
state = ESCAPED;
|
|
253
|
+
// The dashes can close what they open (`<!-->`)
|
|
254
|
+
RE_SCRIPT_STATE.lastIndex = match.index + 2;
|
|
255
|
+
}
|
|
256
|
+
} else if (token === '-->') {
|
|
257
|
+
state = DATA;
|
|
258
|
+
} else if (token === '<script') {
|
|
259
|
+
if (state === ESCAPED) {
|
|
260
|
+
state = DOUBLE_ESCAPED;
|
|
261
|
+
}
|
|
262
|
+
} else if (state === DOUBLE_ESCAPED) {
|
|
263
|
+
state = ESCAPED;
|
|
264
|
+
} else {
|
|
265
|
+
const end = findEndTagClose(text, match.index + token.length);
|
|
266
|
+
return end === -1 ? null : { content: text.slice(0, match.index), length: end + 1 };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
200
272
|
// Cache for compiled attribute regexes per handler configuration
|
|
201
273
|
const attrRegexCache = new WeakMap();
|
|
202
274
|
|
|
@@ -204,7 +276,8 @@ const attrRegexCache = new WeakMap();
|
|
|
204
276
|
function buildAttrRegex(handler) {
|
|
205
277
|
const unquotedValue = handler.continueOnParseError ? singleAttrValueLenientUnquoted : singleAttrValues[2];
|
|
206
278
|
const attrValues = [singleAttrValues[0], singleAttrValues[1], unquotedValue];
|
|
207
|
-
|
|
279
|
+
const attrName = handler.continueOnParseError ? singleAttrIdentifierLenient : singleAttrIdentifier;
|
|
280
|
+
let pattern = attrName.source +
|
|
208
281
|
'(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
|
|
209
282
|
'[ \\t\\n\\f\\r]*(?:' + attrValues.join('|') + '))?';
|
|
210
283
|
if (handler.customAttrSurround) {
|
|
@@ -280,7 +353,8 @@ export class HTMLParser {
|
|
|
280
353
|
const fullLength = fullHtml.length;
|
|
281
354
|
|
|
282
355
|
// `pScope` is the index of the `p` a start tag would close from within the entry, or -1
|
|
283
|
-
|
|
356
|
+
// A `table` entry’s `impliedRow` and `impliedSection` say what is open without a start tag
|
|
357
|
+
/** @type {Array<{tag: string, lowerTag: string, attrs: HTMLAttribute[], namespace: string, pScope: number, impliedRow?: boolean, impliedSection?: boolean}>} */
|
|
284
358
|
const stack = [];
|
|
285
359
|
/** @type {string} */
|
|
286
360
|
let lastTag = '';
|
|
@@ -293,6 +367,10 @@ export class HTMLParser {
|
|
|
293
367
|
/** @type {'' | 'quirks' | 'no-quirks'} */
|
|
294
368
|
let docMode = '';
|
|
295
369
|
let docModeSettable = true;
|
|
370
|
+
// Whether markup ended that, and whether a processing instruction came up: Unlike after text
|
|
371
|
+
// or such an instruction, which template code may be, a later doctype is sure to be ignored
|
|
372
|
+
let docStartsWithMarkup = false;
|
|
373
|
+
let docHasInstruction = false;
|
|
296
374
|
// Open `p` elements a `table` started in while the mode was unknown
|
|
297
375
|
/** @type {WeakSet<object>} */
|
|
298
376
|
const pHoldingTable = new WeakSet();
|
|
@@ -376,8 +454,24 @@ export class HTMLParser {
|
|
|
376
454
|
return { end: hasCloseAtOrAfter(p + 2) ? nextGtPos + 1 : fullLength, dropped };
|
|
377
455
|
};
|
|
378
456
|
|
|
457
|
+
// Where the comment opened at `p` ends: `<!-->` and `<!--->` are empty comments,
|
|
458
|
+
// and besides `-->`, `--!>` ends one, too
|
|
459
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#comment-end-bang-state
|
|
460
|
+
const matchCommentEnd = (/** @type {number} */ p) => {
|
|
461
|
+
const dataStart = p + 4;
|
|
462
|
+
if (fullHtml[dataStart] === '>') return { dataEnd: dataStart, end: dataStart + 1 };
|
|
463
|
+
if (fullHtml.startsWith('->', dataStart)) return { dataEnd: dataStart, end: dataStart + 2 };
|
|
464
|
+
RE_COMMENT_END.lastIndex = dataStart;
|
|
465
|
+
const match = RE_COMMENT_END.exec(fullHtml);
|
|
466
|
+
return match ? { dataEnd: match.index, end: match.index + match[0].length } : null;
|
|
467
|
+
};
|
|
468
|
+
|
|
379
469
|
const advance = (/** @type {number} */ n) => { pos += n; };
|
|
380
470
|
|
|
471
|
+
// An end tag that is dropped leaves no tag for the whitespace around it to go by
|
|
472
|
+
let endTagDropped = false;
|
|
473
|
+
const endTagTokenOf = (/** @type {string} */ lowerTagName) => endTagDropped ? '' : '/' + lowerTagName;
|
|
474
|
+
|
|
381
475
|
// Lazy line/column calculation—only compute on actual errors
|
|
382
476
|
const getLineColumn = (/** @type {number} */ position) => {
|
|
383
477
|
let line = 1;
|
|
@@ -505,7 +599,7 @@ export class HTMLParser {
|
|
|
505
599
|
// Make sure not to be in an element whose content is text, not markup
|
|
506
600
|
if (!lastTagLower || !topHoldsRawText()) {
|
|
507
601
|
let textEnd = fullHtml.indexOf('<', pos);
|
|
508
|
-
if (textEnd !== -1 && lastTagLower && fullHtml.charCodeAt(textEnd + 1) === 33 && inForeignContent()) {
|
|
602
|
+
if (textEnd !== -1 && lastTagLower && fullHtml.charCodeAt(textEnd + 1) === 33 && (inForeignContent() || lastTagLower === 'svg' || lastTagLower === 'math')) {
|
|
509
603
|
textEnd = skipCDATA(textEnd);
|
|
510
604
|
}
|
|
511
605
|
|
|
@@ -527,7 +621,7 @@ export class HTMLParser {
|
|
|
527
621
|
cachedNextStartTag = null;
|
|
528
622
|
cachedNextEndTag = null;
|
|
529
623
|
advance(endTagMatch.text.length);
|
|
530
|
-
prevTag =
|
|
624
|
+
prevTag = endTagTokenOf(parseEndTag(endTagMatch.text, endTagMatch.name));
|
|
531
625
|
prevAttrs = emptyAttrs;
|
|
532
626
|
continue;
|
|
533
627
|
}
|
|
@@ -542,15 +636,15 @@ export class HTMLParser {
|
|
|
542
636
|
if (nextCode === 33) { // `!`
|
|
543
637
|
commentTestY.lastIndex = pos;
|
|
544
638
|
if (commentTestY.test(fullHtml)) {
|
|
545
|
-
const
|
|
546
|
-
if (
|
|
639
|
+
const comment = commentEndAhead ? matchCommentEnd(pos) : null;
|
|
640
|
+
if (!comment) commentEndAhead = false;
|
|
547
641
|
|
|
548
|
-
if (
|
|
642
|
+
if (comment) {
|
|
549
643
|
if (handler.comment) {
|
|
550
|
-
const result = handler.comment(fullHtml.substring(pos + 4,
|
|
644
|
+
const result = handler.comment(fullHtml.substring(pos + 4, comment.dataEnd));
|
|
551
645
|
if (isThenable(result)) await result;
|
|
552
646
|
}
|
|
553
|
-
advance(
|
|
647
|
+
advance(comment.end - pos);
|
|
554
648
|
prevTag = '';
|
|
555
649
|
prevAttrs = emptyAttrs;
|
|
556
650
|
continue;
|
|
@@ -578,12 +672,14 @@ export class HTMLParser {
|
|
|
578
672
|
doctypeY.lastIndex = pos;
|
|
579
673
|
const doctypeMatch = doctypeY.exec(fullHtml);
|
|
580
674
|
if (doctypeMatch) {
|
|
581
|
-
|
|
675
|
+
const setsMode = docModeSettable;
|
|
676
|
+
if (setsMode) {
|
|
582
677
|
docMode = handler.noQuirksMode || !isQuirksDoctype(doctypeMatch[0]) ? 'no-quirks' : 'quirks';
|
|
583
678
|
docModeSettable = false;
|
|
679
|
+
docStartsWithMarkup = true;
|
|
584
680
|
}
|
|
585
681
|
if (handler.doctype) {
|
|
586
|
-
handler.doctype(doctypeMatch[0], docMode === 'no-quirks');
|
|
682
|
+
handler.doctype(doctypeMatch[0], docMode === 'no-quirks', !setsMode && docStartsWithMarkup && !docHasInstruction);
|
|
587
683
|
}
|
|
588
684
|
advance(doctypeMatch[0].length);
|
|
589
685
|
prevTag = '';
|
|
@@ -593,6 +689,7 @@ export class HTMLParser {
|
|
|
593
689
|
} else if (nextCode === 63) { // `?`
|
|
594
690
|
const processingInstructionEnd = matchProcessingInstruction(pos);
|
|
595
691
|
if (processingInstructionEnd !== -1) {
|
|
692
|
+
docHasInstruction = true;
|
|
596
693
|
if (handler.processingInstruction) {
|
|
597
694
|
handler.processingInstruction(fullHtml.substring(pos, processingInstructionEnd));
|
|
598
695
|
}
|
|
@@ -622,7 +719,7 @@ export class HTMLParser {
|
|
|
622
719
|
const endTagMatch = matchEndTag(pos);
|
|
623
720
|
if (endTagMatch) {
|
|
624
721
|
advance(endTagMatch.text.length);
|
|
625
|
-
prevTag =
|
|
722
|
+
prevTag = endTagTokenOf(parseEndTag(endTagMatch.text, endTagMatch.name));
|
|
626
723
|
prevAttrs = emptyAttrs;
|
|
627
724
|
continue;
|
|
628
725
|
}
|
|
@@ -698,7 +795,7 @@ export class HTMLParser {
|
|
|
698
795
|
} else if (nextCode === 47 && hasCloseAtOrAfter(pos)) {
|
|
699
796
|
const nextEndTagMatch = matchEndTag(pos);
|
|
700
797
|
if (nextEndTagMatch) {
|
|
701
|
-
nextTag = '/' + nextEndTagMatch.name.toLowerCase();
|
|
798
|
+
nextTag = isDroppedEndTag(nextEndTagMatch.name.toLowerCase()) ? '' : '/' + nextEndTagMatch.name.toLowerCase();
|
|
702
799
|
nextAttrs = emptyAttrs;
|
|
703
800
|
cachedNextEndTag = { match: nextEndTagMatch, pos };
|
|
704
801
|
} else {
|
|
@@ -803,6 +900,10 @@ export class HTMLParser {
|
|
|
803
900
|
// Repeated closing quotes belong to the attribute, as in the attribute pattern
|
|
804
901
|
let attrEnd = closeQuote + 1;
|
|
805
902
|
while (fullHtml[attrEnd] === quoteChar) attrEnd++;
|
|
903
|
+
const codeAfter = fullHtml.charCodeAt(attrEnd);
|
|
904
|
+
if (attrEnd < fullLength && !isWhitespaceCode(codeAfter) && codeAfter !== 47 && codeAfter !== 62) {
|
|
905
|
+
attrEnd = closeQuote + 1;
|
|
906
|
+
}
|
|
806
907
|
/** @type {Array<string | undefined>} */
|
|
807
908
|
const attr = [];
|
|
808
909
|
attr[0] = fullHtml.substring(attrPos, attrEnd);
|
|
@@ -1015,6 +1116,26 @@ export class HTMLParser {
|
|
|
1015
1116
|
return false;
|
|
1016
1117
|
}
|
|
1017
1118
|
|
|
1119
|
+
function closeCellInCurrentTable() {
|
|
1120
|
+
return closeIfFoundInCurrentTable('td') || closeIfFoundInCurrentTable('th');
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Browsers open a row for a cell and a section for a row where the source has no start tag
|
|
1124
|
+
// for them, and those stay open until an end tag or the next row or section closes them
|
|
1125
|
+
function noteImpliedTableParts(/** @type {string} */ lowerTagName) {
|
|
1126
|
+
const table = stack[findTagInCurrentTable('table')];
|
|
1127
|
+
if (!table) {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (lowerTagName === 'tbody' || lowerTagName === 'tfoot' || lowerTagName === 'thead') {
|
|
1131
|
+
table.impliedRow = false;
|
|
1132
|
+
table.impliedSection = false;
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
table.impliedRow = lowerTagName !== 'tr' && (table.impliedRow || findTagInCurrentTable('tr') < 0);
|
|
1136
|
+
table.impliedSection = findTagInCurrentTable('tbody') < 0 && findTagInCurrentTable('tfoot') < 0 && findTagInCurrentTable('thead') < 0;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1018
1139
|
// The lenient attribute pattern takes characters into unquoted values that the strict
|
|
1019
1140
|
// one rejects; the start tag has just been consumed, so it ends right before `pos`
|
|
1020
1141
|
function reportUnquotedValueErrors(/** @type {{attrs: Array<Array<string | undefined>>, advance: number}} */ match) {
|
|
@@ -1023,6 +1144,11 @@ export class HTMLParser {
|
|
|
1023
1144
|
const text = attr[0] ?? '';
|
|
1024
1145
|
attrPos = fullHtml.indexOf(text, attrPos);
|
|
1025
1146
|
const surroundCount = handler.customAttrSurround ? handler.customAttrSurround.length : 0;
|
|
1147
|
+
const attrName = attr[baseIndex] ?? '';
|
|
1148
|
+
const nameQuote = attrName.includes('"') || attrName.includes("'") ? attrName.search(RE_ATTR_NAME_QUOTE) : -1;
|
|
1149
|
+
if (nameQuote !== -1) {
|
|
1150
|
+
reportParseError(attrPos + text.indexOf(attrName) + nameQuote, `Kept \`${attrName[nameQuote]}\` in attribute name`);
|
|
1151
|
+
}
|
|
1026
1152
|
for (let i = 0; i <= surroundCount; i++) {
|
|
1027
1153
|
// Unquoted values sit at index 5 of each surround block, and 4 behind the plain name
|
|
1028
1154
|
const value = i < surroundCount ? attr[1 + i * NCP + 5] : attr[1 + surroundCount * NCP + 4];
|
|
@@ -1042,6 +1168,7 @@ export class HTMLParser {
|
|
|
1042
1168
|
const lowerTagName = match.lowerTagName;
|
|
1043
1169
|
let unarySlash = match.unarySlash;
|
|
1044
1170
|
|
|
1171
|
+
docStartsWithMarkup ||= docModeSettable;
|
|
1045
1172
|
docModeSettable = false;
|
|
1046
1173
|
if (handler.parseError) reportUnquotedValueErrors(match);
|
|
1047
1174
|
// A plain loop, so a tag costs no closure allocation per attribute
|
|
@@ -1111,12 +1238,19 @@ export class HTMLParser {
|
|
|
1111
1238
|
// A row or cell start tag closes the row or cell still open in the same table,
|
|
1112
1239
|
// which may sit deeper down the stack than the element that was opened last
|
|
1113
1240
|
if (lowerTagName === 'tr') {
|
|
1114
|
-
closeIfFoundInCurrentTable('tr')
|
|
1241
|
+
if (!closeIfFoundInCurrentTable('tr')) {
|
|
1242
|
+
closeCellInCurrentTable();
|
|
1243
|
+
}
|
|
1115
1244
|
} else if (lowerTagName === 'td' || lowerTagName === 'th') {
|
|
1116
|
-
|
|
1117
|
-
|
|
1245
|
+
closeCellInCurrentTable();
|
|
1246
|
+
} else if (lowerTagName === 'tbody' || lowerTagName === 'tfoot' || lowerTagName === 'thead') {
|
|
1247
|
+
if (!closeIfFoundInCurrentTable('tr')) {
|
|
1248
|
+
closeCellInCurrentTable();
|
|
1118
1249
|
}
|
|
1119
1250
|
}
|
|
1251
|
+
if (tablePartElements.has(lowerTagName)) {
|
|
1252
|
+
noteImpliedTableParts(lowerTagName);
|
|
1253
|
+
}
|
|
1120
1254
|
if (lowerTagName === 'tbody') {
|
|
1121
1255
|
if (!closeIfFoundInCurrentTable('tfoot')) {
|
|
1122
1256
|
closeIfFoundInCurrentTable('thead');
|
|
@@ -1171,7 +1305,9 @@ export class HTMLParser {
|
|
|
1171
1305
|
|
|
1172
1306
|
// The name is returned so the parse loop can skip lowercasing it again
|
|
1173
1307
|
const started = handler.start?.(tagName, attrs, unary, unarySlash);
|
|
1174
|
-
|
|
1308
|
+
// What a slash closes reads as ended to the whitespace logic
|
|
1309
|
+
const token = selfClosed ? '/' + lowerTagName : lowerTagName;
|
|
1310
|
+
return isThenable(started) ? started.then(() => token) : token;
|
|
1175
1311
|
}
|
|
1176
1312
|
|
|
1177
1313
|
// Pushes an open element, noting the `p` in scope from within it: its own index
|
|
@@ -1179,7 +1315,8 @@ export class HTMLParser {
|
|
|
1179
1315
|
// holds text with scripting), and otherwise its parent’s; returns the namespace it sits in
|
|
1180
1316
|
function pushOpenElement(/** @type {string} */ tag, /** @type {string} */ lowerTag, /** @type {HTMLAttribute[]} */ attrs) {
|
|
1181
1317
|
const parent = stack.length ? stack[stack.length - 1] : undefined;
|
|
1182
|
-
|
|
1318
|
+
// An `svg` start tag leads out of MathML from `annotation-xml`, as it does from HTML
|
|
1319
|
+
const namespace = lowerTag === 'svg' && parent?.lowerTag === 'annotation-xml' ? '' : namespaceInside(parent);
|
|
1183
1320
|
let pScope = -1;
|
|
1184
1321
|
if (!namespace) {
|
|
1185
1322
|
if (lowerTag === 'p') {
|
|
@@ -1218,8 +1355,141 @@ export class HTMLParser {
|
|
|
1218
1355
|
parseEndTagAt(stackIndex, false);
|
|
1219
1356
|
}
|
|
1220
1357
|
|
|
1358
|
+
// What a formatting element’s end tag meets among the elements open inside it
|
|
1359
|
+
// @@ Reopen formatting on whitespace (`<p><a><div></div> <div>` should stay as is)
|
|
1360
|
+
function readFormattingScope(/** @type {number} */ stackIndex) {
|
|
1361
|
+
let hasSpecial = false;
|
|
1362
|
+
for (let i = stack.length - 1; i > stackIndex; i--) {
|
|
1363
|
+
const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
|
|
1364
|
+
if (entry.namespace) {
|
|
1365
|
+
return 'foreign';
|
|
1366
|
+
}
|
|
1367
|
+
if (formattingScopeBoundaries.has(entry.lowerTag)) {
|
|
1368
|
+
return 'boundary';
|
|
1369
|
+
}
|
|
1370
|
+
hasSpecial ||= specialElements.has(entry.lowerTag);
|
|
1371
|
+
}
|
|
1372
|
+
return hasSpecial ? 'special' : '';
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// A formatting element’s end tag leaves open the first special element in it, and is ignored
|
|
1376
|
+
// from beyond a scope boundary; returns whether either applied, or `false` to close as usual
|
|
1377
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#adoption-agency-algorithm
|
|
1378
|
+
function closeFormattingElement(/** @type {number} */ stackIndex, /** @type {string} */ rest) {
|
|
1379
|
+
const scope = readFormattingScope(stackIndex);
|
|
1380
|
+
if (scope === 'boundary') {
|
|
1381
|
+
endTagDropped = true;
|
|
1382
|
+
handler.strayEnd?.();
|
|
1383
|
+
return true;
|
|
1384
|
+
}
|
|
1385
|
+
if (scope !== 'special') {
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[stackIndex]);
|
|
1389
|
+
handler.end?.(entry.tag, entry.attrs, false, rest);
|
|
1390
|
+
stack.splice(stackIndex, 1);
|
|
1391
|
+
for (let i = stackIndex; i < stack.length; i++) {
|
|
1392
|
+
const above = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
|
|
1393
|
+
if (above.pScope > stackIndex) {
|
|
1394
|
+
above.pScope--;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
stackVersion++;
|
|
1398
|
+
return true;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Browsers ignore the end tag of an ordinary element with a special one open inside it
|
|
1402
|
+
function isBehindSpecialElement(/** @type {number} */ stackIndex) {
|
|
1403
|
+
const target = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[stackIndex]);
|
|
1404
|
+
// `dialog` isn’t special, but its end tag closes what is open in it nevertheless
|
|
1405
|
+
if (target.namespace || isSpecialOrBoundary(target.lowerTag) || formattingElements.has(target.lowerTag) || tablePartElements.has(target.lowerTag) || ['dialog', 'math', 'svg'].includes(target.lowerTag)) {
|
|
1406
|
+
return false;
|
|
1407
|
+
}
|
|
1408
|
+
for (let i = stack.length - 1; i > stackIndex; i--) {
|
|
1409
|
+
const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
|
|
1410
|
+
if (entry.namespace) {
|
|
1411
|
+
return false;
|
|
1412
|
+
}
|
|
1413
|
+
if (isSpecialOrBoundary(entry.lowerTag)) {
|
|
1414
|
+
return true;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
return false;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// An end tag for a row or section whose start tag is implied by the cell or row open in it;
|
|
1421
|
+
// closes what is open within it (returning whether there is such a part)
|
|
1422
|
+
function closeImpliedTablePart(/** @type {string} */ lowerTagName) {
|
|
1423
|
+
if (!isImpliedTablePart(lowerTagName)) {
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
const table = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[findTagInCurrentTable('table')]);
|
|
1427
|
+
if (lowerTagName === 'tr') {
|
|
1428
|
+
closeCellInCurrentTable();
|
|
1429
|
+
} else {
|
|
1430
|
+
if (!closeIfFoundInCurrentTable('tr')) {
|
|
1431
|
+
closeCellInCurrentTable();
|
|
1432
|
+
}
|
|
1433
|
+
table.impliedSection = false;
|
|
1434
|
+
}
|
|
1435
|
+
table.impliedRow = false;
|
|
1436
|
+
return true;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function isImpliedTablePart(/** @type {string} */ lowerTagName) {
|
|
1440
|
+
const table = stack[findTagInCurrentTable('table')];
|
|
1441
|
+
return lowerTagName === 'tr' ? Boolean(table?.impliedRow) : tableSectionElements.has(lowerTagName) && Boolean(table?.impliedSection);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Whether `parseEndTag` would drop this end tag, for the look-ahead
|
|
1445
|
+
function isDroppedEndTag(/** @type {string} */ lowerTagName) {
|
|
1446
|
+
const stackIndex = findEndTagTarget(lowerTagName);
|
|
1447
|
+
if (stackIndex >= 0) {
|
|
1448
|
+
if (stackIndex === stack.length - 1) {
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
if (formattingElements.has(lowerTagName) && !stack[stackIndex]?.namespace && readFormattingScope(stackIndex) === 'boundary') {
|
|
1452
|
+
return true;
|
|
1453
|
+
}
|
|
1454
|
+
return isBehindSpecialElement(stackIndex);
|
|
1455
|
+
}
|
|
1456
|
+
return !handler.partialMarkup && !formattingElements.has(lowerTagName) && lowerTagName !== 'br' && lowerTagName !== 'p' && !isImpliedTablePart(lowerTagName);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// Table parts end within their own table only
|
|
1460
|
+
function findEndTagTarget(/** @type {string} */ lowerTagName) {
|
|
1461
|
+
const stackIndex = stack.length - 1;
|
|
1462
|
+
if (stack[stackIndex]?.lowerTag === lowerTagName) {
|
|
1463
|
+
return stackIndex;
|
|
1464
|
+
}
|
|
1465
|
+
if (tablePartElements.has(lowerTagName) || lowerTagName === 'caption') {
|
|
1466
|
+
return findTagInCurrentTable(lowerTagName);
|
|
1467
|
+
}
|
|
1468
|
+
const target = findTag(lowerTagName);
|
|
1469
|
+
return target >= 0 && isOutOfScope(lowerTagName, target) ? -1 : target;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// @@ Close only `form` on `</form>` (`<form><div></form>x</div>y` should stay as is)
|
|
1473
|
+
function isOutOfScope(/** @type {string} */ lowerTagName, /** @type {number} */ stackIndex) {
|
|
1474
|
+
if (!scopedEndTagElements.has(lowerTagName) || stack[stackIndex]?.namespace) {
|
|
1475
|
+
return false;
|
|
1476
|
+
}
|
|
1477
|
+
for (let i = stack.length - 1; i > stackIndex; i--) {
|
|
1478
|
+
const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
|
|
1479
|
+
if (entry.namespace) {
|
|
1480
|
+
return false;
|
|
1481
|
+
}
|
|
1482
|
+
if (endTagScopeBoundaries.has(entry.lowerTag) || (lowerTagName === 'p' && entry.lowerTag === 'button') || (lowerTagName === 'li' && (entry.lowerTag === 'ol' || entry.lowerTag === 'ul'))) {
|
|
1483
|
+
return true;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
return false;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1221
1489
|
function parseEndTag(/** @type {string} */ tag, /** @type {string} */ tagName) {
|
|
1490
|
+
docStartsWithMarkup ||= docModeSettable;
|
|
1222
1491
|
docModeSettable = false;
|
|
1492
|
+
endTagDropped = false;
|
|
1223
1493
|
let stackIndex;
|
|
1224
1494
|
const lowerTagName = tagName ? tagName.toLowerCase() : '';
|
|
1225
1495
|
|
|
@@ -1230,7 +1500,7 @@ export class HTMLParser {
|
|
|
1230
1500
|
|
|
1231
1501
|
// Find the closest opened tag of the same type
|
|
1232
1502
|
if (tagName) {
|
|
1233
|
-
stackIndex = findTag(lowerTagName);
|
|
1503
|
+
stackIndex = tag ? findEndTagTarget(lowerTagName) : findTag(lowerTagName);
|
|
1234
1504
|
} else { // If no tag name is provided, clean shop
|
|
1235
1505
|
stackIndex = 0;
|
|
1236
1506
|
}
|
|
@@ -1238,6 +1508,16 @@ export class HTMLParser {
|
|
|
1238
1508
|
// Whatever follows the name, which browsers drop but a custom fragment may stand in for
|
|
1239
1509
|
const rest = tag.length > tagName.length + 3 ? tag.slice(tagName.length + 2, -1) : '';
|
|
1240
1510
|
|
|
1511
|
+
const closesTop = stackIndex === stack.length - 1;
|
|
1512
|
+
if (tag && !closesTop && stackIndex >= 0 && formattingElements.has(lowerTagName) && !stack[stackIndex]?.namespace && closeFormattingElement(stackIndex, rest)) {
|
|
1513
|
+
return lowerTagName;
|
|
1514
|
+
}
|
|
1515
|
+
if (tag && !closesTop && stackIndex >= 0 && isBehindSpecialElement(stackIndex)) {
|
|
1516
|
+
endTagDropped = true;
|
|
1517
|
+
handler.strayEnd?.();
|
|
1518
|
+
return lowerTagName;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1241
1521
|
if (stackIndex >= 0) {
|
|
1242
1522
|
// Close all the open elements, up the stack
|
|
1243
1523
|
for (let i = stack.length - 1; i >= stackIndex; i--) {
|
|
@@ -1256,6 +1536,10 @@ export class HTMLParser {
|
|
|
1256
1536
|
if (handler.end) {
|
|
1257
1537
|
handler.end(tagName, [], false, rest);
|
|
1258
1538
|
}
|
|
1539
|
+
} else if (tag && closeImpliedTablePart(lowerTagName)) {
|
|
1540
|
+
if (handler.end) {
|
|
1541
|
+
handler.end(tagName, [], false, rest);
|
|
1542
|
+
}
|
|
1259
1543
|
} else if (lowerTagName === 'br') {
|
|
1260
1544
|
if (handler.start) {
|
|
1261
1545
|
handler.start(tagName, [], true, '');
|
|
@@ -1267,9 +1551,10 @@ export class HTMLParser {
|
|
|
1267
1551
|
if (handler.end) {
|
|
1268
1552
|
handler.end(tagName, []);
|
|
1269
1553
|
}
|
|
1270
|
-
} else
|
|
1554
|
+
} else {
|
|
1271
1555
|
// An end tag with nothing to close is dropped
|
|
1272
|
-
|
|
1556
|
+
endTagDropped = Boolean(tag);
|
|
1557
|
+
handler.strayEnd?.();
|
|
1273
1558
|
}
|
|
1274
1559
|
// Returned so the parse loop can skip lowercasing the name again
|
|
1275
1560
|
return lowerTagName;
|