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 `` and then swallows the
- // real ``. 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 = [
+ '',
+ '',
+ '',
+ '',
+ '',
+ ''
+ ]
+ for (const html of cases) {
+ t.deepEqual(parseIframe(html), [{ src: '/a.js' }], html)
+ }
+ t.deepEqual(parseIframe('
'
),
- [{ src: '/a.js' }]
+ [{ src: '/b.js' }]
)
t.deepEqual(
parseIframe(
- ''
+ ''
),
- []
+ [{ src: '/b.js' }]
)
+ for (const attribute of ['color', 'face', 'size', 'COLOR']) {
+ t.deepEqual(
+ parseIframe(
+ ``
+ ),
+ [],
+ attribute
+ )
+ }
t.deepEqual(
parseIframe(
- ''
+ ''
),
[]
)
@@ -442,6 +550,56 @@ test('a script tag cut off at the end of the input is not a script', t => {
])
})
+test('mglyph and malignmark inside a math text integration point stay in math', t => {
+ const cases = [
+ '',
+ '',
+ '',
+ '',
+ '',
+ ''
+ ]
+ for (const html of cases) {
+ t.deepEqual(parseIframe(html), [{ src: '/a.js' }, { src: '/b.js' }], html)
+ }
+ t.deepEqual(
+ parseIframe(
+ ''
+ ),
+ [{ src: '/a.js' }, { src: '/b.js' }]
+ )
+ t.deepEqual(
+ parseIframe(
+ ''
+ ),
+ [{ src: '/a.js' }]
+ )
+ t.deepEqual(
+ parseIframe(
+ ''
+ ),
+ [{ src: '/b.js' }]
+ )
+ t.deepEqual(
+ parseIframe(
+ ''
+ ),
+ [{ src: '/a.js' }, { src: '/b.js' }]
+ )
+ t.deepEqual(
+ parseIframe(
+ ''
+ ),
+ [{ src: '/b.js' }]
+ )
+ t.deepEqual(
+ parseIframe(
+ ''
+ ),
+ [{ src: '/a.js' }]
+ )
+})
+
test('svg directly inside a math annotation-xml is svg', t => {
t.deepEqual(
parseIframe(