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..2dc7aa2 100644 --- a/src/index.js +++ b/src/index.js @@ -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 @@ -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 ` { - 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) +// 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 `` 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 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 } diff --git a/test/index.js b/test/index.js index 644862a..941de3e 100644 --- a/test/index.js +++ b/test/index.js @@ -294,7 +294,16 @@ test('an end tag inside a script does not close the element around it', t => { }) test('raw text elements hide the scripts inside them', t => { - for (const tag of ['iframe', 'noembed', 'noframes', 'noscript']) { + for (const tag of [ + 'iframe', + 'noembed', + 'noframes', + 'noscript', + 'style', + 'textarea', + 'title', + 'xmp' + ]) { t.deepEqual( parseIframe( `<${tag}>${tag}>` @@ -345,3 +354,269 @@ 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('