From b0fc2e55d9b5beee43230a4c1ec0b7e813e99739 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 28 Oct 2026 08:44:55 +0100 Subject: [PATCH 1/3] build(deps)!: upgrade htmlparser2 to v12 htmlparser2 v12 tokenizes foreign content itself through `isInForeignContext`, so the Tokenizer subclass no longer ran. The parser now points that hook at our own namespace stack, and the workarounds v12 makes redundant (self-closing script end search, iframe/noembed/noframes/ plaintext raw text tracking, isInScript) are gone. v12 always treats annotation-xml as an integration point and makes more tags raw text, which broke annotation-xml without an HTML encoding and let raw text tags swallow . Fixed by: - skipping noscript content up to its end tag, as the spec raw text state does - namespace-aware integration points, pushed after the self-closing decision - foreign content breakout (p, div, font[color], ...) - template inside svg/math is not inert - svg/math nested in foreign content keep the parent namespace - scripts cut off at the end of the input are not reported Differential fuzz against parse5 (WHATWG spec): spec mismatches go from ~7% of random inputs to ~0.05%. Benchmark is at parity with master. BREAKING CHANGE: htmlparser2 v11+ requires Node.js >= 20.19. Closes #20 Co-Authored-By: Claude Opus 5.5 --- package.json | 4 +- src/index.js | 288 +++++++++++++++++++++++++------------------------- test/index.js | 96 +++++++++++++++++ 3 files changed, 243 insertions(+), 145 deletions(-) diff --git a/package.json b/package.json index e1500ec..32ddc35 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "src" ], "dependencies": { - "htmlparser2": "~10.1.0" + "htmlparser2": "~12.0.0" }, "devDependencies": { "@commitlint/cli": "latest", @@ -48,7 +48,7 @@ "standard-version": "latest" }, "engines": { - "node": ">= 10" + "node": ">= 20.19" }, "files": [ "src" diff --git a/src/index.js b/src/index.js index c38b0ae..cf54136 100644 --- a/src/index.js +++ b/src/index.js @@ -1,58 +1,76 @@ 'use strict' -const { dirname, join } = require('path') - const { Parser } = require('htmlparser2') -const Tokenizer = require(join( - dirname(require.resolve('htmlparser2')), - 'Tokenizer.js' -)).default - -// htmlparser2 treats title/textarea/style/script as raw text everywhere. -// In svg/math that is only true for HTML, so parse those tags as markup. -const IN_TAG_NAME = 3 - -class ForeignTokenizer extends Tokenizer { - stateBeforeSpecialS (char) { - if (this.cbs.foreignContext[0] === true) { - this.state = IN_TAG_NAME - this.stateInTagName(char) - return - } - super.stateBeforeSpecialS(char) - } - stateBeforeSpecialT (char) { - if (this.cbs.foreignContext[0] === true) { - this.state = IN_TAG_NAME - this.stateInTagName(char) - return - } - super.stateBeforeSpecialT(char) - } -} +const HTML = 'html' +const SVG = 'svg' +const MATH = 'math' +const ANNOTATION_XML = 'annotation-xml' -// svg/math are foreign content. These elements switch back to HTML. -// annotation-xml does that only with an HTML encoding, so it is handled apart. -const FOREIGN_ELEMENTS = new Set(['math', 'svg']) -const HTML_INTEGRATION_ELEMENTS = new Set([ +const SVG_HTML_INTEGRATION_POINTS = new Set([ 'desc', + 'foreignObject', 'foreignobject', - 'mi', - 'mn', - 'mo', - 'ms', - 'mtext', 'title' ]) -// style, textarea, xmp, and script are raw in HTML via the tokenizer. -const RAW_TEXT_ELEMENTS = new Set([ - 'iframe', - 'noembed', - 'noframes', - 'noscript', - 'plaintext' +const MATH_TEXT_INTEGRATION_POINTS = new Set(['mi', 'mn', 'mo', 'ms', 'mtext']) + +const FOREIGN_CONTENT_BREAKOUT_ELEMENTS = new Set([ + 'b', + 'big', + 'blockquote', + 'body', + 'br', + 'center', + 'code', + 'dd', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'hr', + 'i', + 'img', + 'li', + 'listing', + 'menu', + 'meta', + 'nobr', + 'ol', + 'p', + 'pre', + 'ruby', + 's', + 'small', + 'span', + 'strike', + 'strong', + 'sub', + 'sup', + 'table', + 'tt', + 'u', + 'ul', + 'var' +]) +const FONT_BREAKOUT_ATTRIBUTES = new Set(['color', 'face', 'size']) +const INTEGRATION_CANDIDATES = new Set([ + ...SVG_HTML_INTEGRATION_POINTS, + ...MATH_TEXT_INTEGRATION_POINTS, + ANNOTATION_XML ]) +const NAMESPACE_STACK_ELEMENTS = new Set([SVG, MATH, ...INTEGRATION_CANDIDATES]) + +const NOSCRIPT_END = /<\/noscript[\t\n\f\r />]/gi + const getValue = (value, quote) => { if (quote === undefined) return true if (value === '1') return true @@ -64,118 +82,102 @@ const isHtmlEncoding = value => { return mime === 'text/html' || mime === 'application/xhtml+xml' } -const isWhitespace = code => - code === 9 || code === 10 || code === 12 || code === 13 || code === 32 - -// Index of the ` { - for ( - let index = source.indexOf('<', from); - index !== -1; - index = source.indexOf('<', index + 1) - ) { - if (source.slice(index, index + 8).toLowerCase() !== ' { + if (namespace === SVG) return SVG_HTML_INTEGRATION_POINTS.has(name) + if (namespace !== MATH) return false + if (name === ANNOTATION_XML) { + return encoding !== null && isHtmlEncoding(encoding) } - return source.length + return MATH_TEXT_INTEGRATION_POINTS.has(name) +} + +const findNoscriptEnd = (source, from) => { + NOSCRIPT_END.lastIndex = from + const match = NOSCRIPT_END.exec(source) + return match === null ? source.length : match.index } -const isForeignContextElement = name => - FOREIGN_ELEMENTS.has(name) || HTML_INTEGRATION_ELEMENTS.has(name) +const continueTokenizingFrom = (parser, index) => { + parser.tokenizer.index = index - 1 +} module.exports = html => { const source = String(html) const scripts = [] + const namespaces = [HTML] + const templates = [] + let htmlTemplateDepth = 0 + let tagName = '' let attributes = null - let templateDepth = 0 - let isInScript = false - // Taken when the script opens. htmlparser2 pops an integration point - // before the implied close, so the close event is too late to read. - let scriptInForeign = false - // htmlparser2 drops script-data mode on ``. Events before this index are still that script's text. - let scriptEnd = -1 - let rawText = null - let sawAnnotation = false - let annotationHtml = false - let sawAnnotationEncoding = false - const annotationHtmlStack = [] - const foreignContext = [false] - const leaveScriptIfEnded = () => { - if (scriptEnd !== -1 && parser.endIndex >= scriptEnd) { - isInScript = false - scriptEnd = -1 + let encoding = null + let hasFontBreakoutAttribute = false + + const isInForeignContent = () => namespaces[0] !== HTML + + const breakOutOfForeignContent = () => { + for (let index = 0; namespaces[index] !== HTML; index++) { + namespaces[index] = HTML } } - const parser = new Parser( - { - onopentagname: name => { - leaveScriptIfEnded() - attributes = null - if (isInScript || rawText) return - if (FOREIGN_ELEMENTS.has(name)) foreignContext.unshift(true) - else if (HTML_INTEGRATION_ELEMENTS.has(name)) { - foreignContext.unshift(false) - } - if (name === 'annotation-xml') sawAnnotation = true - // These elements are raw text in HTML only. In svg/math they are markup. - if (RAW_TEXT_ELEMENTS.has(name) && foreignContext[0] !== true) { - rawText = name - return - } - if (name === 'template') templateDepth++ - if (name !== 'script') return - scriptInForeign = foreignContext[0] === true - // A foreign script does not swallow the markup inside it. - if (!scriptInForeign) isInScript = true - if (templateDepth === 0) { - attributes = {} - scripts.push(attributes) - } - }, - onattribute: (name, value, quote) => { - if (sawAnnotation && name === 'encoding' && !sawAnnotationEncoding) { - sawAnnotationEncoding = true - annotationHtml = isHtmlEncoding(value) - } - if (attributes) attributes[name] = getValue(value, quote) - }, - onopentag: name => { - if (name === 'annotation-xml' && sawAnnotation) { - annotationHtmlStack.unshift(annotationHtml) - if (annotationHtml) foreignContext.unshift(false) - sawAnnotation = false - annotationHtml = false - sawAnnotationEncoding = false - } - if (name !== 'script' || !isInScript) return - scriptEnd = findScriptEnd(source, parser.endIndex + 1) - }, - onclosetag: (name, isImplied) => { - leaveScriptIfEnded() - if (rawText) { - if (name === rawText && name !== 'plaintext') rawText = null - return - } - if (name === 'script') { - if (!isImplied || scriptInForeign) { - isInScript = false - scriptEnd = -1 - } - return + + const parser = new Parser({ + onopentagname: name => { + tagName = name + attributes = null + encoding = null + hasFontBreakoutAttribute = false + if (isInForeignContent() && FOREIGN_CONTENT_BREAKOUT_ELEMENTS.has(name)) { + breakOutOfForeignContent() + } + if (name === SVG || name === MATH) { + namespaces.unshift(isInForeignContent() ? namespaces[0] : name) + } else if (name === 'template') { + const isHtmlTemplate = !isInForeignContent() + templates.unshift(isHtmlTemplate) + if (isHtmlTemplate) htmlTemplateDepth++ + } else if (name === 'script' && htmlTemplateDepth === 0) { + attributes = {} + } + }, + onattribute: (name, value, quote) => { + if (attributes !== null) { + attributes[name] = getValue(value, quote) + } else if (tagName === ANNOTATION_XML) { + if (name === 'encoding' && encoding === null) encoding = value + } else if (tagName === 'font' && FONT_BREAKOUT_ATTRIBUTES.has(name)) { + hasFontBreakoutAttribute = true + } + }, + onopentag: name => { + if (attributes !== null) { + scripts.push(attributes) + } else if (INTEGRATION_CANDIDATES.has(name)) { + const namespace = namespaces[0] + namespaces.unshift( + isIntegrationPoint(namespace, name, encoding) ? HTML : namespace + ) + } else if (name === 'noscript') { + if (!isInForeignContent()) { + continueTokenizingFrom( + parser, + findNoscriptEnd(source, parser.endIndex + 1) + ) } - if (isInScript) return - if (name === 'annotation-xml') { - if (annotationHtmlStack.shift()) foreignContext.shift() - return + } else if (name === 'font') { + if (hasFontBreakoutAttribute && isInForeignContent()) { + breakOutOfForeignContent() } - if (isForeignContextElement(name)) foreignContext.shift() - if (name === 'template' && templateDepth > 0) templateDepth-- } }, - { Tokenizer: ForeignTokenizer } - ) + onclosetag: name => { + if (NAMESPACE_STACK_ELEMENTS.has(name)) { + namespaces.shift() + } else if (name === 'template' && templates.shift()) { + htmlTemplateDepth-- + } + } + }) + parser.isInForeignContext = isInForeignContent parser.end(source) return scripts } diff --git a/test/index.js b/test/index.js index 644862a..c5bfd28 100644 --- a/test/index.js +++ b/test/index.js @@ -345,3 +345,99 @@ test('a stray after a self-closing script keeps later scripts', t => [{ src: '/a.js' }, { src: '/b.js' }] ) }) + +test('noscript ends at its own end tag', t => { + const cases = [ + '