Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"src"
],
"dependencies": {
"htmlparser2": "~10.1.0"
"htmlparser2": "~12.0.0"
},
"devDependencies": {
"@commitlint/cli": "latest",
Expand All @@ -48,7 +48,7 @@
"standard-version": "latest"
},
"engines": {
"node": ">= 10"
"node": ">= 20.19"
},
"files": [
"src"
Expand Down
321 changes: 178 additions & 143 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,58 +1,78 @@
'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'])
// These stay in MathML even when the current node is a text integration point.
const MATH_TEXT_INTEGRATION_EXCEPTIONS = new Set(['malignmark', 'mglyph'])

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
Expand All @@ -64,118 +84,133 @@ 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 `</script` that ends an HTML script, or the end of the string.
const findScriptEnd = (source, from) => {
for (
let index = source.indexOf('<', from);
index !== -1;
index = source.indexOf('<', index + 1)
) {
if (source.slice(index, index + 8).toLowerCase() !== '</script') continue
const next = source.charCodeAt(index + 8)
if (next === 62 || isWhitespace(next)) return index
const isIntegrationPoint = (namespace, name, encoding) => {
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)
// The tokenizer increments index after this callback returns.
const continueTokenizingFrom = (parser, index) => {
parser.tokenizer.index = index - 1
}

module.exports = html => {
const source = String(html)
const scripts = []
const namespaces = [HTML]
const templates = []
const mathAnnotationDepths = []
const mathTextIntegrationDepths = []
const mathExceptionDepths = []
let depth = 0
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 `<script/>` and then swallows the
// real `</script>`. 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 isChildOfMathAnnotation = () => mathAnnotationDepths[0] === depth - 1

const isMathTextIntegrationException = name =>
MATH_TEXT_INTEGRATION_EXCEPTIONS.has(name) &&
mathTextIntegrationDepths[0] === depth - 1

const namespaceOfNested = name => {
if (!isInForeignContent()) return name
if (name === SVG && isChildOfMathAnnotation()) return SVG
return namespaces[0]
}

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 => {
depth++
tagName = name
attributes = null
encoding = null
hasFontBreakoutAttribute = false
if (FOREIGN_CONTENT_BREAKOUT_ELEMENTS.has(name)) {
breakOutOfForeignContent()
}
if (name === SVG || name === MATH) {
namespaces.unshift(namespaceOfNested(name))
} else if (isMathTextIntegrationException(name)) {
namespaces.unshift(MATH)
mathExceptionDepths.unshift(depth)
} 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]
const isHtml = isIntegrationPoint(namespace, name, encoding)
namespaces.unshift(isHtml ? HTML : namespace)
if (name === ANNOTATION_XML && namespace === MATH) {
mathAnnotationDepths.unshift(depth)
} else if (isHtml && MATH_TEXT_INTEGRATION_POINTS.has(name)) {
mathTextIntegrationDepths.unshift(depth)
}
if (isInScript) return
if (name === 'annotation-xml') {
if (annotationHtmlStack.shift()) foreignContext.shift()
return
} else if (name === 'noscript') {
if (!isInForeignContent()) {
continueTokenizingFrom(
parser,
findNoscriptEnd(source, parser.endIndex + 1)
)
}
if (isForeignContextElement(name)) foreignContext.shift()
if (name === 'template' && templateDepth > 0) templateDepth--
} else if (name === 'font' && hasFontBreakoutAttribute) {
breakOutOfForeignContent()
}
},
{ Tokenizer: ForeignTokenizer }
)
onclosetag: name => {
if (mathExceptionDepths[0] === depth) {
mathExceptionDepths.shift()
namespaces.shift()
} else if (NAMESPACE_STACK_ELEMENTS.has(name)) {
namespaces.shift()
if (mathAnnotationDepths[0] === depth) mathAnnotationDepths.shift()
if (mathTextIntegrationDepths[0] === depth) {
mathTextIntegrationDepths.shift()
}
} else if (name === 'template' && templates.shift()) {
htmlTemplateDepth--
}
depth--
}
})
parser.isInForeignContext = isInForeignContent
parser.end(source)
return scripts
}
Loading
Loading