html-minifier 5.0.2 → 5.0.4
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 +8 -5
- 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 +19 -1
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/constants.d.ts +6 -1
- package/dist/types/lib/constants.d.ts.map +1 -1
- package/dist/types/lib/elements.d.ts +3 -1
- package/dist/types/lib/elements.d.ts.map +1 -1
- package/dist/types/lib/fragments.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/dist/types/lib/unused-css.d.ts.map +1 -1
- package/dist/types/lib/utils.d.ts +8 -1
- package/dist/types/lib/utils.d.ts.map +1 -1
- package/dist/types/lib/whitespace.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/htmlminifier.js +220 -69
- package/src/htmlparser.js +333 -53
- package/src/lib/attributes.js +108 -18
- package/src/lib/constants.js +21 -5
- package/src/lib/elements.js +15 -3
- package/src/lib/fragments.js +12 -3
- package/src/lib/options.js +5 -4
- package/src/lib/unused-css.js +85 -68
- package/src/lib/utils.js +19 -0
- package/src/lib/whitespace.js +2 -4
package/src/htmlparser.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { isThenable, isQuirksDoctype, embedSource, findTagEnd } from './lib/utils.js';
|
|
8
|
+
import { isThenable, isQuirksDoctype, isWhitespaceCode, embedSource, findTagEnd } from './lib/utils.js';
|
|
9
9
|
import { endlessRawTextElements, escapableRawTextElements, formattingElements, genericRawTextElements, processingInstructionTag, RE_HTML_ENCODING, RE_WS_ONLY } from './lib/constants.js';
|
|
10
10
|
|
|
11
11
|
/** @import { HTMLAttribute } from './lib/attributes.js' */
|
|
@@ -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) =>
|
|
@@ -145,13 +172,6 @@ function scanAsciiTagName(html, start) {
|
|
|
145
172
|
}
|
|
146
173
|
}
|
|
147
174
|
|
|
148
|
-
// The characters `\s` matches, by code: Tab through carriage return, space, and the
|
|
149
|
-
// Unicode whitespace and line terminators
|
|
150
|
-
const isWhitespaceCode = (/** @type {number} */ code) =>
|
|
151
|
-
(code >= 9 && code <= 13) || code === 32 || code === 0xA0 || code === 0x1680 ||
|
|
152
|
-
(code >= 0x2000 && code <= 0x200A) || code === 0x2028 || code === 0x2029 ||
|
|
153
|
-
code === 0x202F || code === 0x205F || code === 0x3000 || code === 0xFEFF;
|
|
154
|
-
|
|
155
175
|
/**
|
|
156
176
|
* @param {string} text - The input the tag stands in
|
|
157
177
|
* @param {number} nameEnd - Position right after the tag name
|
|
@@ -184,6 +204,9 @@ function findEndTagClose(text, nameEnd) {
|
|
|
184
204
|
* reaches, or `null` where no end tag of its own follows
|
|
185
205
|
*/
|
|
186
206
|
function findRawTextEnd(text, tag) {
|
|
207
|
+
if (tag === 'script') {
|
|
208
|
+
return findScriptEnd(text);
|
|
209
|
+
}
|
|
187
210
|
const nameEnd = tag.length + 2;
|
|
188
211
|
for (let candidate = text.indexOf('</'); candidate !== -1; candidate = text.indexOf('</', candidate + 2)) {
|
|
189
212
|
if (text.slice(candidate + 2, candidate + nameEnd).toLowerCase() !== tag || !endsTagName(text.charAt(candidate + nameEnd))) {
|
|
@@ -197,14 +220,54 @@ function findRawTextEnd(text, tag) {
|
|
|
197
220
|
return null;
|
|
198
221
|
}
|
|
199
222
|
|
|
200
|
-
//
|
|
201
|
-
|
|
223
|
+
// What changes the state script content is read in; a tag name ends as in `endsTagName`,
|
|
224
|
+
// and `--!>` is left out as it only ends comments, not these states
|
|
225
|
+
const RE_SCRIPT_STATE = /<!--|-->|<\/?script(?=[\s/>])/gi;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Browsers read `<!--` in a script as opening an escaped state, in which
|
|
229
|
+
* `<script` opens a double escaped one that an end tag only leaves again
|
|
230
|
+
* https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state
|
|
231
|
+
* @param {string} text - The input from the start of the content on
|
|
232
|
+
* @returns {{content: string, length: number} | null} What the element holds and how far it
|
|
233
|
+
* reaches, or `null` where no end tag of its own follows
|
|
234
|
+
*/
|
|
235
|
+
function findScriptEnd(text) {
|
|
236
|
+
const DATA = 0;
|
|
237
|
+
const ESCAPED = 1;
|
|
238
|
+
const DOUBLE_ESCAPED = 2;
|
|
239
|
+
let state = DATA;
|
|
240
|
+
RE_SCRIPT_STATE.lastIndex = 0;
|
|
241
|
+
for (let match = RE_SCRIPT_STATE.exec(text); match; match = RE_SCRIPT_STATE.exec(text)) {
|
|
242
|
+
const token = match[0].toLowerCase();
|
|
243
|
+
if (token === '<!--') {
|
|
244
|
+
if (state === DATA) {
|
|
245
|
+
state = ESCAPED;
|
|
246
|
+
// The dashes can close what they open (`<!-->`)
|
|
247
|
+
RE_SCRIPT_STATE.lastIndex = match.index + 2;
|
|
248
|
+
}
|
|
249
|
+
} else if (token === '-->') {
|
|
250
|
+
state = DATA;
|
|
251
|
+
} else if (token === '<script') {
|
|
252
|
+
if (state === ESCAPED) {
|
|
253
|
+
state = DOUBLE_ESCAPED;
|
|
254
|
+
}
|
|
255
|
+
} else if (state === DOUBLE_ESCAPED) {
|
|
256
|
+
state = ESCAPED;
|
|
257
|
+
} else {
|
|
258
|
+
const end = findEndTagClose(text, match.index + token.length);
|
|
259
|
+
return end === -1 ? null : { content: text.slice(0, match.index), length: end + 1 };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
202
264
|
|
|
203
265
|
/** @param {HTMLParserHandler} handler */
|
|
204
266
|
function buildAttrRegex(handler) {
|
|
205
267
|
const unquotedValue = handler.continueOnParseError ? singleAttrValueLenientUnquoted : singleAttrValues[2];
|
|
206
268
|
const attrValues = [singleAttrValues[0], singleAttrValues[1], unquotedValue];
|
|
207
|
-
|
|
269
|
+
const attrName = handler.continueOnParseError ? singleAttrIdentifierLenient : singleAttrIdentifier;
|
|
270
|
+
let pattern = attrName.source +
|
|
208
271
|
'(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
|
|
209
272
|
'[ \\t\\n\\f\\r]*(?:' + attrValues.join('|') + '))?';
|
|
210
273
|
if (handler.customAttrSurround) {
|
|
@@ -229,26 +292,32 @@ function buildAttrRegex(handler) {
|
|
|
229
292
|
return new RegExp('^\\s*' + pattern);
|
|
230
293
|
}
|
|
231
294
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
295
|
+
// Compiled attribute regexes, the sticky one (`y`) for matching at a position in the whole
|
|
296
|
+
// input: By handler where it holds custom patterns, else by the flags they depend on, as
|
|
297
|
+
// a handler lasts one parse only
|
|
298
|
+
/** @type {WeakMap<HTMLParserHandler, {attribute: RegExp, attributeY: RegExp}>} */
|
|
299
|
+
const attrRegexCache = new WeakMap();
|
|
300
|
+
/** @type {Map<number, {attribute: RegExp, attributeY: RegExp}>} */
|
|
301
|
+
const attrRegexPlainCache = new Map();
|
|
240
302
|
|
|
241
|
-
|
|
242
|
-
const
|
|
303
|
+
/** @param {unknown} value */
|
|
304
|
+
const isEmptyOption = value => !value || (Array.isArray(value) && !value.length);
|
|
243
305
|
|
|
244
306
|
/** @param {HTMLParserHandler} handler */
|
|
245
|
-
function
|
|
246
|
-
const
|
|
307
|
+
function getAttrRegexes(handler) {
|
|
308
|
+
const plain = isEmptyOption(handler.customAttrAssign) && isEmptyOption(handler.customAttrSurround);
|
|
309
|
+
// An empty `customAttrSurround` still shapes the pattern, if not what it matches
|
|
310
|
+
const key = (handler.continueOnParseError ? 1 : 0) + (handler.customAttrSurround ? 2 : 0);
|
|
311
|
+
const cached = plain ? attrRegexPlainCache.get(key) : attrRegexCache.get(handler);
|
|
247
312
|
if (cached) return cached;
|
|
248
|
-
const
|
|
249
|
-
//
|
|
250
|
-
const compiled = new RegExp(
|
|
251
|
-
|
|
313
|
+
const attribute = buildAttrRegex(handler);
|
|
314
|
+
// Without the `^` anchor, as stickiness anchors it
|
|
315
|
+
const compiled = { attribute, attributeY: new RegExp(attribute.source.slice(1), 'y') };
|
|
316
|
+
if (plain) {
|
|
317
|
+
attrRegexPlainCache.set(key, compiled);
|
|
318
|
+
} else {
|
|
319
|
+
attrRegexCache.set(handler, compiled);
|
|
320
|
+
}
|
|
252
321
|
return compiled;
|
|
253
322
|
}
|
|
254
323
|
|
|
@@ -280,7 +349,8 @@ export class HTMLParser {
|
|
|
280
349
|
const fullLength = fullHtml.length;
|
|
281
350
|
|
|
282
351
|
// `pScope` is the index of the `p` a start tag would close from within the entry, or -1
|
|
283
|
-
|
|
352
|
+
// A `table` entry’s `impliedRow` and `impliedSection` say what is open without a start tag
|
|
353
|
+
/** @type {Array<{tag: string, lowerTag: string, attrs: HTMLAttribute[], namespace: string, pScope: number, impliedRow?: boolean, impliedSection?: boolean}>} */
|
|
284
354
|
const stack = [];
|
|
285
355
|
/** @type {string} */
|
|
286
356
|
let lastTag = '';
|
|
@@ -293,14 +363,17 @@ export class HTMLParser {
|
|
|
293
363
|
/** @type {'' | 'quirks' | 'no-quirks'} */
|
|
294
364
|
let docMode = '';
|
|
295
365
|
let docModeSettable = true;
|
|
366
|
+
// Whether markup ended that, and whether a processing instruction came up: Unlike after text
|
|
367
|
+
// or such an instruction, which template code may be, a later doctype is sure to be ignored
|
|
368
|
+
let docStartsWithMarkup = false;
|
|
369
|
+
let docHasInstruction = false;
|
|
296
370
|
// Open `p` elements a `table` started in while the mode was unknown
|
|
297
371
|
/** @type {WeakSet<object>} */
|
|
298
372
|
const pHoldingTable = new WeakSet();
|
|
299
373
|
// Lowercase counterpart of `lastTag`, kept in sync to avoid per-iteration lowercasing
|
|
300
374
|
/** @type {string} */
|
|
301
375
|
let lastTagLower = '';
|
|
302
|
-
const attribute =
|
|
303
|
-
const attributeY = getAttrRegexStickyForHandler(handler);
|
|
376
|
+
const { attribute, attributeY } = getAttrRegexes(handler);
|
|
304
377
|
// Index of the attribute name in a match, after each `customAttrSurround` pattern’s captures
|
|
305
378
|
const baseIndex = 1 + (handler.customAttrSurround ? handler.customAttrSurround.length * NCP : 0);
|
|
306
379
|
/** @type {string | undefined} */
|
|
@@ -376,8 +449,24 @@ export class HTMLParser {
|
|
|
376
449
|
return { end: hasCloseAtOrAfter(p + 2) ? nextGtPos + 1 : fullLength, dropped };
|
|
377
450
|
};
|
|
378
451
|
|
|
452
|
+
// Where the comment opened at `p` ends: `<!-->` and `<!--->` are empty comments,
|
|
453
|
+
// and besides `-->`, `--!>` ends one, too
|
|
454
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#comment-end-bang-state
|
|
455
|
+
const matchCommentEnd = (/** @type {number} */ p) => {
|
|
456
|
+
const dataStart = p + 4;
|
|
457
|
+
if (fullHtml[dataStart] === '>') return { dataEnd: dataStart, end: dataStart + 1 };
|
|
458
|
+
if (fullHtml.startsWith('->', dataStart)) return { dataEnd: dataStart, end: dataStart + 2 };
|
|
459
|
+
RE_COMMENT_END.lastIndex = dataStart;
|
|
460
|
+
const match = RE_COMMENT_END.exec(fullHtml);
|
|
461
|
+
return match ? { dataEnd: match.index, end: match.index + match[0].length } : null;
|
|
462
|
+
};
|
|
463
|
+
|
|
379
464
|
const advance = (/** @type {number} */ n) => { pos += n; };
|
|
380
465
|
|
|
466
|
+
// An end tag that is dropped leaves no tag for the whitespace around it to go by
|
|
467
|
+
let endTagDropped = false;
|
|
468
|
+
const endTagTokenOf = (/** @type {string} */ lowerTagName) => endTagDropped ? '' : '/' + lowerTagName;
|
|
469
|
+
|
|
381
470
|
// Lazy line/column calculation—only compute on actual errors
|
|
382
471
|
const getLineColumn = (/** @type {number} */ position) => {
|
|
383
472
|
let line = 1;
|
|
@@ -505,7 +594,7 @@ export class HTMLParser {
|
|
|
505
594
|
// Make sure not to be in an element whose content is text, not markup
|
|
506
595
|
if (!lastTagLower || !topHoldsRawText()) {
|
|
507
596
|
let textEnd = fullHtml.indexOf('<', pos);
|
|
508
|
-
if (textEnd !== -1 && lastTagLower && fullHtml.charCodeAt(textEnd + 1) === 33 && inForeignContent()) {
|
|
597
|
+
if (textEnd !== -1 && lastTagLower && fullHtml.charCodeAt(textEnd + 1) === 33 && (inForeignContent() || lastTagLower === 'svg' || lastTagLower === 'math')) {
|
|
509
598
|
textEnd = skipCDATA(textEnd);
|
|
510
599
|
}
|
|
511
600
|
|
|
@@ -527,7 +616,7 @@ export class HTMLParser {
|
|
|
527
616
|
cachedNextStartTag = null;
|
|
528
617
|
cachedNextEndTag = null;
|
|
529
618
|
advance(endTagMatch.text.length);
|
|
530
|
-
prevTag =
|
|
619
|
+
prevTag = endTagTokenOf(parseEndTag(endTagMatch.text, endTagMatch.name));
|
|
531
620
|
prevAttrs = emptyAttrs;
|
|
532
621
|
continue;
|
|
533
622
|
}
|
|
@@ -542,15 +631,15 @@ export class HTMLParser {
|
|
|
542
631
|
if (nextCode === 33) { // `!`
|
|
543
632
|
commentTestY.lastIndex = pos;
|
|
544
633
|
if (commentTestY.test(fullHtml)) {
|
|
545
|
-
const
|
|
546
|
-
if (
|
|
634
|
+
const comment = commentEndAhead ? matchCommentEnd(pos) : null;
|
|
635
|
+
if (!comment) commentEndAhead = false;
|
|
547
636
|
|
|
548
|
-
if (
|
|
637
|
+
if (comment) {
|
|
549
638
|
if (handler.comment) {
|
|
550
|
-
const result = handler.comment(fullHtml.substring(pos + 4,
|
|
639
|
+
const result = handler.comment(fullHtml.substring(pos + 4, comment.dataEnd));
|
|
551
640
|
if (isThenable(result)) await result;
|
|
552
641
|
}
|
|
553
|
-
advance(
|
|
642
|
+
advance(comment.end - pos);
|
|
554
643
|
prevTag = '';
|
|
555
644
|
prevAttrs = emptyAttrs;
|
|
556
645
|
continue;
|
|
@@ -578,12 +667,14 @@ export class HTMLParser {
|
|
|
578
667
|
doctypeY.lastIndex = pos;
|
|
579
668
|
const doctypeMatch = doctypeY.exec(fullHtml);
|
|
580
669
|
if (doctypeMatch) {
|
|
581
|
-
|
|
670
|
+
const setsMode = docModeSettable;
|
|
671
|
+
if (setsMode) {
|
|
582
672
|
docMode = handler.noQuirksMode || !isQuirksDoctype(doctypeMatch[0]) ? 'no-quirks' : 'quirks';
|
|
583
673
|
docModeSettable = false;
|
|
674
|
+
docStartsWithMarkup = true;
|
|
584
675
|
}
|
|
585
676
|
if (handler.doctype) {
|
|
586
|
-
handler.doctype(doctypeMatch[0], docMode === 'no-quirks');
|
|
677
|
+
handler.doctype(doctypeMatch[0], docMode === 'no-quirks', !setsMode && docStartsWithMarkup && !docHasInstruction);
|
|
587
678
|
}
|
|
588
679
|
advance(doctypeMatch[0].length);
|
|
589
680
|
prevTag = '';
|
|
@@ -593,6 +684,7 @@ export class HTMLParser {
|
|
|
593
684
|
} else if (nextCode === 63) { // `?`
|
|
594
685
|
const processingInstructionEnd = matchProcessingInstruction(pos);
|
|
595
686
|
if (processingInstructionEnd !== -1) {
|
|
687
|
+
docHasInstruction = true;
|
|
596
688
|
if (handler.processingInstruction) {
|
|
597
689
|
handler.processingInstruction(fullHtml.substring(pos, processingInstructionEnd));
|
|
598
690
|
}
|
|
@@ -622,7 +714,7 @@ export class HTMLParser {
|
|
|
622
714
|
const endTagMatch = matchEndTag(pos);
|
|
623
715
|
if (endTagMatch) {
|
|
624
716
|
advance(endTagMatch.text.length);
|
|
625
|
-
prevTag =
|
|
717
|
+
prevTag = endTagTokenOf(parseEndTag(endTagMatch.text, endTagMatch.name));
|
|
626
718
|
prevAttrs = emptyAttrs;
|
|
627
719
|
continue;
|
|
628
720
|
}
|
|
@@ -698,7 +790,7 @@ export class HTMLParser {
|
|
|
698
790
|
} else if (nextCode === 47 && hasCloseAtOrAfter(pos)) {
|
|
699
791
|
const nextEndTagMatch = matchEndTag(pos);
|
|
700
792
|
if (nextEndTagMatch) {
|
|
701
|
-
nextTag = '/' + nextEndTagMatch.name.toLowerCase();
|
|
793
|
+
nextTag = isDroppedEndTag(nextEndTagMatch.name.toLowerCase()) ? '' : '/' + nextEndTagMatch.name.toLowerCase();
|
|
702
794
|
nextAttrs = emptyAttrs;
|
|
703
795
|
cachedNextEndTag = { match: nextEndTagMatch, pos };
|
|
704
796
|
} else {
|
|
@@ -803,6 +895,10 @@ export class HTMLParser {
|
|
|
803
895
|
// Repeated closing quotes belong to the attribute, as in the attribute pattern
|
|
804
896
|
let attrEnd = closeQuote + 1;
|
|
805
897
|
while (fullHtml[attrEnd] === quoteChar) attrEnd++;
|
|
898
|
+
const codeAfter = fullHtml.charCodeAt(attrEnd);
|
|
899
|
+
if (attrEnd < fullLength && !isWhitespaceCode(codeAfter) && codeAfter !== 47 && codeAfter !== 62) {
|
|
900
|
+
attrEnd = closeQuote + 1;
|
|
901
|
+
}
|
|
806
902
|
/** @type {Array<string | undefined>} */
|
|
807
903
|
const attr = [];
|
|
808
904
|
attr[0] = fullHtml.substring(attrPos, attrEnd);
|
|
@@ -955,7 +1051,7 @@ export class HTMLParser {
|
|
|
955
1051
|
break;
|
|
956
1052
|
}
|
|
957
1053
|
|
|
958
|
-
const attrLen = attr[0].length;
|
|
1054
|
+
const attrLen = /** @type {string} */ (attr[0]).length;
|
|
959
1055
|
currentPos += attrLen;
|
|
960
1056
|
consumed += attrLen;
|
|
961
1057
|
match.attrs.push(attr);
|
|
@@ -1015,6 +1111,26 @@ export class HTMLParser {
|
|
|
1015
1111
|
return false;
|
|
1016
1112
|
}
|
|
1017
1113
|
|
|
1114
|
+
function closeCellInCurrentTable() {
|
|
1115
|
+
return closeIfFoundInCurrentTable('td') || closeIfFoundInCurrentTable('th');
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Browsers open a row for a cell and a section for a row where the source has no start tag
|
|
1119
|
+
// for them, and those stay open until an end tag or the next row or section closes them
|
|
1120
|
+
function noteImpliedTableParts(/** @type {string} */ lowerTagName) {
|
|
1121
|
+
const table = stack[findTagInCurrentTable('table')];
|
|
1122
|
+
if (!table) {
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (lowerTagName === 'tbody' || lowerTagName === 'tfoot' || lowerTagName === 'thead') {
|
|
1126
|
+
table.impliedRow = false;
|
|
1127
|
+
table.impliedSection = false;
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
table.impliedRow = lowerTagName !== 'tr' && (table.impliedRow || findTagInCurrentTable('tr') < 0);
|
|
1131
|
+
table.impliedSection = findTagInCurrentTable('tbody') < 0 && findTagInCurrentTable('tfoot') < 0 && findTagInCurrentTable('thead') < 0;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1018
1134
|
// The lenient attribute pattern takes characters into unquoted values that the strict
|
|
1019
1135
|
// one rejects; the start tag has just been consumed, so it ends right before `pos`
|
|
1020
1136
|
function reportUnquotedValueErrors(/** @type {{attrs: Array<Array<string | undefined>>, advance: number}} */ match) {
|
|
@@ -1023,6 +1139,11 @@ export class HTMLParser {
|
|
|
1023
1139
|
const text = attr[0] ?? '';
|
|
1024
1140
|
attrPos = fullHtml.indexOf(text, attrPos);
|
|
1025
1141
|
const surroundCount = handler.customAttrSurround ? handler.customAttrSurround.length : 0;
|
|
1142
|
+
const attrName = attr[baseIndex] ?? '';
|
|
1143
|
+
const nameQuote = attrName.includes('"') || attrName.includes("'") ? attrName.search(RE_ATTR_NAME_QUOTE) : -1;
|
|
1144
|
+
if (nameQuote !== -1) {
|
|
1145
|
+
reportParseError(attrPos + text.indexOf(attrName) + nameQuote, `Kept \`${attrName[nameQuote]}\` in attribute name`);
|
|
1146
|
+
}
|
|
1026
1147
|
for (let i = 0; i <= surroundCount; i++) {
|
|
1027
1148
|
// Unquoted values sit at index 5 of each surround block, and 4 behind the plain name
|
|
1028
1149
|
const value = i < surroundCount ? attr[1 + i * NCP + 5] : attr[1 + surroundCount * NCP + 4];
|
|
@@ -1042,6 +1163,7 @@ export class HTMLParser {
|
|
|
1042
1163
|
const lowerTagName = match.lowerTagName;
|
|
1043
1164
|
let unarySlash = match.unarySlash;
|
|
1044
1165
|
|
|
1166
|
+
docStartsWithMarkup ||= docModeSettable;
|
|
1045
1167
|
docModeSettable = false;
|
|
1046
1168
|
if (handler.parseError) reportUnquotedValueErrors(match);
|
|
1047
1169
|
// A plain loop, so a tag costs no closure allocation per attribute
|
|
@@ -1111,12 +1233,19 @@ export class HTMLParser {
|
|
|
1111
1233
|
// A row or cell start tag closes the row or cell still open in the same table,
|
|
1112
1234
|
// which may sit deeper down the stack than the element that was opened last
|
|
1113
1235
|
if (lowerTagName === 'tr') {
|
|
1114
|
-
closeIfFoundInCurrentTable('tr')
|
|
1236
|
+
if (!closeIfFoundInCurrentTable('tr')) {
|
|
1237
|
+
closeCellInCurrentTable();
|
|
1238
|
+
}
|
|
1115
1239
|
} else if (lowerTagName === 'td' || lowerTagName === 'th') {
|
|
1116
|
-
|
|
1117
|
-
|
|
1240
|
+
closeCellInCurrentTable();
|
|
1241
|
+
} else if (lowerTagName === 'tbody' || lowerTagName === 'tfoot' || lowerTagName === 'thead') {
|
|
1242
|
+
if (!closeIfFoundInCurrentTable('tr')) {
|
|
1243
|
+
closeCellInCurrentTable();
|
|
1118
1244
|
}
|
|
1119
1245
|
}
|
|
1246
|
+
if (tablePartElements.has(lowerTagName)) {
|
|
1247
|
+
noteImpliedTableParts(lowerTagName);
|
|
1248
|
+
}
|
|
1120
1249
|
if (lowerTagName === 'tbody') {
|
|
1121
1250
|
if (!closeIfFoundInCurrentTable('tfoot')) {
|
|
1122
1251
|
closeIfFoundInCurrentTable('thead');
|
|
@@ -1171,7 +1300,9 @@ export class HTMLParser {
|
|
|
1171
1300
|
|
|
1172
1301
|
// The name is returned so the parse loop can skip lowercasing it again
|
|
1173
1302
|
const started = handler.start?.(tagName, attrs, unary, unarySlash);
|
|
1174
|
-
|
|
1303
|
+
// What a slash closes reads as ended to the whitespace logic
|
|
1304
|
+
const token = selfClosed ? '/' + lowerTagName : lowerTagName;
|
|
1305
|
+
return isThenable(started) ? started.then(() => token) : token;
|
|
1175
1306
|
}
|
|
1176
1307
|
|
|
1177
1308
|
// Pushes an open element, noting the `p` in scope from within it: its own index
|
|
@@ -1179,7 +1310,8 @@ export class HTMLParser {
|
|
|
1179
1310
|
// holds text with scripting), and otherwise its parent’s; returns the namespace it sits in
|
|
1180
1311
|
function pushOpenElement(/** @type {string} */ tag, /** @type {string} */ lowerTag, /** @type {HTMLAttribute[]} */ attrs) {
|
|
1181
1312
|
const parent = stack.length ? stack[stack.length - 1] : undefined;
|
|
1182
|
-
|
|
1313
|
+
// An `svg` start tag leads out of MathML from `annotation-xml`, as it does from HTML
|
|
1314
|
+
const namespace = lowerTag === 'svg' && parent?.lowerTag === 'annotation-xml' ? '' : namespaceInside(parent);
|
|
1183
1315
|
let pScope = -1;
|
|
1184
1316
|
if (!namespace) {
|
|
1185
1317
|
if (lowerTag === 'p') {
|
|
@@ -1218,8 +1350,141 @@ export class HTMLParser {
|
|
|
1218
1350
|
parseEndTagAt(stackIndex, false);
|
|
1219
1351
|
}
|
|
1220
1352
|
|
|
1353
|
+
// What a formatting element’s end tag meets among the elements open inside it
|
|
1354
|
+
// @@ Reopen formatting on whitespace (`<p><a><div></div> <div>` should stay as is)
|
|
1355
|
+
function readFormattingScope(/** @type {number} */ stackIndex) {
|
|
1356
|
+
let hasSpecial = false;
|
|
1357
|
+
for (let i = stack.length - 1; i > stackIndex; i--) {
|
|
1358
|
+
const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
|
|
1359
|
+
if (entry.namespace) {
|
|
1360
|
+
return 'foreign';
|
|
1361
|
+
}
|
|
1362
|
+
if (formattingScopeBoundaries.has(entry.lowerTag)) {
|
|
1363
|
+
return 'boundary';
|
|
1364
|
+
}
|
|
1365
|
+
hasSpecial ||= specialElements.has(entry.lowerTag);
|
|
1366
|
+
}
|
|
1367
|
+
return hasSpecial ? 'special' : '';
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// A formatting element’s end tag leaves open the first special element in it, and is ignored
|
|
1371
|
+
// from beyond a scope boundary; returns whether either applied, or `false` to close as usual
|
|
1372
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#adoption-agency-algorithm
|
|
1373
|
+
function closeFormattingElement(/** @type {number} */ stackIndex, /** @type {string} */ rest) {
|
|
1374
|
+
const scope = readFormattingScope(stackIndex);
|
|
1375
|
+
if (scope === 'boundary') {
|
|
1376
|
+
endTagDropped = true;
|
|
1377
|
+
handler.strayEnd?.();
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
if (scope !== 'special') {
|
|
1381
|
+
return false;
|
|
1382
|
+
}
|
|
1383
|
+
const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[stackIndex]);
|
|
1384
|
+
handler.end?.(entry.tag, entry.attrs, false, rest);
|
|
1385
|
+
stack.splice(stackIndex, 1);
|
|
1386
|
+
for (let i = stackIndex; i < stack.length; i++) {
|
|
1387
|
+
const above = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
|
|
1388
|
+
if (above.pScope > stackIndex) {
|
|
1389
|
+
above.pScope--;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
stackVersion++;
|
|
1393
|
+
return true;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// Browsers ignore the end tag of an ordinary element with a special one open inside it
|
|
1397
|
+
function isBehindSpecialElement(/** @type {number} */ stackIndex) {
|
|
1398
|
+
const target = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[stackIndex]);
|
|
1399
|
+
// `dialog` isn’t special, but its end tag closes what is open in it nevertheless
|
|
1400
|
+
if (target.namespace || isSpecialOrBoundary(target.lowerTag) || formattingElements.has(target.lowerTag) || tablePartElements.has(target.lowerTag) || ['dialog', 'math', 'svg'].includes(target.lowerTag)) {
|
|
1401
|
+
return false;
|
|
1402
|
+
}
|
|
1403
|
+
for (let i = stack.length - 1; i > stackIndex; i--) {
|
|
1404
|
+
const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
|
|
1405
|
+
if (entry.namespace) {
|
|
1406
|
+
return false;
|
|
1407
|
+
}
|
|
1408
|
+
if (isSpecialOrBoundary(entry.lowerTag)) {
|
|
1409
|
+
return true;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// An end tag for a row or section whose start tag is implied by the cell or row open in it;
|
|
1416
|
+
// closes what is open within it (returning whether there is such a part)
|
|
1417
|
+
function closeImpliedTablePart(/** @type {string} */ lowerTagName) {
|
|
1418
|
+
if (!isImpliedTablePart(lowerTagName)) {
|
|
1419
|
+
return false;
|
|
1420
|
+
}
|
|
1421
|
+
const table = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[findTagInCurrentTable('table')]);
|
|
1422
|
+
if (lowerTagName === 'tr') {
|
|
1423
|
+
closeCellInCurrentTable();
|
|
1424
|
+
} else {
|
|
1425
|
+
if (!closeIfFoundInCurrentTable('tr')) {
|
|
1426
|
+
closeCellInCurrentTable();
|
|
1427
|
+
}
|
|
1428
|
+
table.impliedSection = false;
|
|
1429
|
+
}
|
|
1430
|
+
table.impliedRow = false;
|
|
1431
|
+
return true;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function isImpliedTablePart(/** @type {string} */ lowerTagName) {
|
|
1435
|
+
const table = stack[findTagInCurrentTable('table')];
|
|
1436
|
+
return lowerTagName === 'tr' ? Boolean(table?.impliedRow) : tableSectionElements.has(lowerTagName) && Boolean(table?.impliedSection);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Whether `parseEndTag` would drop this end tag, for the look-ahead
|
|
1440
|
+
function isDroppedEndTag(/** @type {string} */ lowerTagName) {
|
|
1441
|
+
const stackIndex = findEndTagTarget(lowerTagName);
|
|
1442
|
+
if (stackIndex >= 0) {
|
|
1443
|
+
if (stackIndex === stack.length - 1) {
|
|
1444
|
+
return false;
|
|
1445
|
+
}
|
|
1446
|
+
if (formattingElements.has(lowerTagName) && !stack[stackIndex]?.namespace && readFormattingScope(stackIndex) === 'boundary') {
|
|
1447
|
+
return true;
|
|
1448
|
+
}
|
|
1449
|
+
return isBehindSpecialElement(stackIndex);
|
|
1450
|
+
}
|
|
1451
|
+
return !handler.partialMarkup && !formattingElements.has(lowerTagName) && lowerTagName !== 'br' && lowerTagName !== 'p' && !isImpliedTablePart(lowerTagName);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Table parts end within their own table only
|
|
1455
|
+
function findEndTagTarget(/** @type {string} */ lowerTagName) {
|
|
1456
|
+
const stackIndex = stack.length - 1;
|
|
1457
|
+
if (stack[stackIndex]?.lowerTag === lowerTagName) {
|
|
1458
|
+
return stackIndex;
|
|
1459
|
+
}
|
|
1460
|
+
if (tablePartElements.has(lowerTagName) || lowerTagName === 'caption') {
|
|
1461
|
+
return findTagInCurrentTable(lowerTagName);
|
|
1462
|
+
}
|
|
1463
|
+
const target = findTag(lowerTagName);
|
|
1464
|
+
return target >= 0 && isOutOfScope(lowerTagName, target) ? -1 : target;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// @@ Close only `form` on `</form>` (`<form><div></form>x</div>y` should stay as is)
|
|
1468
|
+
function isOutOfScope(/** @type {string} */ lowerTagName, /** @type {number} */ stackIndex) {
|
|
1469
|
+
if (!scopedEndTagElements.has(lowerTagName) || stack[stackIndex]?.namespace) {
|
|
1470
|
+
return false;
|
|
1471
|
+
}
|
|
1472
|
+
for (let i = stack.length - 1; i > stackIndex; i--) {
|
|
1473
|
+
const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
|
|
1474
|
+
if (entry.namespace) {
|
|
1475
|
+
return false;
|
|
1476
|
+
}
|
|
1477
|
+
if (endTagScopeBoundaries.has(entry.lowerTag) || (lowerTagName === 'p' && entry.lowerTag === 'button') || (lowerTagName === 'li' && (entry.lowerTag === 'ol' || entry.lowerTag === 'ul'))) {
|
|
1478
|
+
return true;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
return false;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1221
1484
|
function parseEndTag(/** @type {string} */ tag, /** @type {string} */ tagName) {
|
|
1485
|
+
docStartsWithMarkup ||= docModeSettable;
|
|
1222
1486
|
docModeSettable = false;
|
|
1487
|
+
endTagDropped = false;
|
|
1223
1488
|
let stackIndex;
|
|
1224
1489
|
const lowerTagName = tagName ? tagName.toLowerCase() : '';
|
|
1225
1490
|
|
|
@@ -1230,7 +1495,7 @@ export class HTMLParser {
|
|
|
1230
1495
|
|
|
1231
1496
|
// Find the closest opened tag of the same type
|
|
1232
1497
|
if (tagName) {
|
|
1233
|
-
stackIndex = findTag(lowerTagName);
|
|
1498
|
+
stackIndex = tag ? findEndTagTarget(lowerTagName) : findTag(lowerTagName);
|
|
1234
1499
|
} else { // If no tag name is provided, clean shop
|
|
1235
1500
|
stackIndex = 0;
|
|
1236
1501
|
}
|
|
@@ -1238,6 +1503,16 @@ export class HTMLParser {
|
|
|
1238
1503
|
// Whatever follows the name, which browsers drop but a custom fragment may stand in for
|
|
1239
1504
|
const rest = tag.length > tagName.length + 3 ? tag.slice(tagName.length + 2, -1) : '';
|
|
1240
1505
|
|
|
1506
|
+
const closesTop = stackIndex === stack.length - 1;
|
|
1507
|
+
if (tag && !closesTop && stackIndex >= 0 && formattingElements.has(lowerTagName) && !stack[stackIndex]?.namespace && closeFormattingElement(stackIndex, rest)) {
|
|
1508
|
+
return lowerTagName;
|
|
1509
|
+
}
|
|
1510
|
+
if (tag && !closesTop && stackIndex >= 0 && isBehindSpecialElement(stackIndex)) {
|
|
1511
|
+
endTagDropped = true;
|
|
1512
|
+
handler.strayEnd?.();
|
|
1513
|
+
return lowerTagName;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1241
1516
|
if (stackIndex >= 0) {
|
|
1242
1517
|
// Close all the open elements, up the stack
|
|
1243
1518
|
for (let i = stack.length - 1; i >= stackIndex; i--) {
|
|
@@ -1256,6 +1531,10 @@ export class HTMLParser {
|
|
|
1256
1531
|
if (handler.end) {
|
|
1257
1532
|
handler.end(tagName, [], false, rest);
|
|
1258
1533
|
}
|
|
1534
|
+
} else if (tag && closeImpliedTablePart(lowerTagName)) {
|
|
1535
|
+
if (handler.end) {
|
|
1536
|
+
handler.end(tagName, [], false, rest);
|
|
1537
|
+
}
|
|
1259
1538
|
} else if (lowerTagName === 'br') {
|
|
1260
1539
|
if (handler.start) {
|
|
1261
1540
|
handler.start(tagName, [], true, '');
|
|
@@ -1267,9 +1546,10 @@ export class HTMLParser {
|
|
|
1267
1546
|
if (handler.end) {
|
|
1268
1547
|
handler.end(tagName, []);
|
|
1269
1548
|
}
|
|
1270
|
-
} else
|
|
1549
|
+
} else {
|
|
1271
1550
|
// An end tag with nothing to close is dropped
|
|
1272
|
-
|
|
1551
|
+
endTagDropped = Boolean(tag);
|
|
1552
|
+
handler.strayEnd?.();
|
|
1273
1553
|
}
|
|
1274
1554
|
// Returned so the parse loop can skip lowercasing the name again
|
|
1275
1555
|
return lowerTagName;
|