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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,34 @@ console.log(scripts)
// ]
```

### Other tags

`elements` reads any set of tags with the same HTML parsing rules, and returns their attributes the way a browser DOM has them: values are strings, and a repeated attribute keeps its first value.

```js
const { elements } = require('html-scripts')

const { meta, script } = elements(
'<meta name="generator" content="WordPress 6.4"><script async src="/app.js"></script>',
['meta', 'script']
)

console.log(meta)
// [ { name: 'generator', content: 'WordPress 6.4' } ]

console.log(script)
// [ { async: '', src: '/app.js' } ]
```

Tag names are lowercase. Tags inside `<template>` content, `<noscript>`, and raw text elements such as `<textarea>` are not read.

Pass `{ scripting: false }` to parse the way a browser with JavaScript disabled does, where `<noscript>` content is markup:

```js
elements('<noscript><meta name="robots" content="noindex"></noscript>', ['meta'], { scripting: false })
// { meta: [ { name: 'robots', content: 'noindex' } ] }
```

## License

**html-scripts** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/Kikobeats/html-scripts/blob/master/LICENSE.md) License.<br>
Expand Down
71 changes: 59 additions & 12 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,37 @@ const NAMESPACE_STACK_ELEMENTS = new Set([SVG, MATH, ...INTEGRATION_CANDIDATES])

const NOSCRIPT_END = /<\/noscript[\t\n\f\r />]/gi

const SCRIPT_TAGS = ['script']

const getValue = (value, quote) => {
if (quote === undefined) return true
if (value === '1') return true
return value
}

// Assignment goes through the prototype setter for "__proto__" and never
// creates an own property.
const defineOwn = (object, key, value) => {
if (key === '__proto__') {
Object.defineProperty(object, key, {
value,
writable: true,
enumerable: true,
configurable: true
})
return
}
object[key] = value
}

const setLastValue = (attributes, name, value, quote) => {
defineOwn(attributes, name, getValue(value, quote))
}

const setFirstValue = (attributes, name, value) => {
if (!Object.hasOwn(attributes, name)) defineOwn(attributes, name, value)
}

const isHtmlEncoding = value => {
const mime = value.toLowerCase()
return mime === 'text/html' || mime === 'application/xhtml+xml'
Expand All @@ -104,9 +129,13 @@ const continueTokenizingFrom = (parser, index) => {
parser.tokenizer.index = index - 1
}

module.exports = html => {
const extract = (html, tagNames, setAttribute, scripting) => {
const source = String(html)
const scripts = []
const targets = new Set(tagNames)
// `script` is never recased, so the default export skips the lowercase lookup.
const foldTagCase = targets.size !== 1 || !targets.has('script')
const elements = {}
for (const name of targets) defineOwn(elements, name, [])
const namespaces = [HTML]
const templates = []
const mathAnnotationDepths = []
Expand All @@ -116,6 +145,7 @@ module.exports = html => {
let htmlTemplateDepth = 0
let tagName = ''
let attributes = null
let collectedAs = null
let encoding = null
let hasFontBreakoutAttribute = false

Expand Down Expand Up @@ -144,8 +174,10 @@ module.exports = html => {
depth++
tagName = name
attributes = null
collectedAs = null
encoding = null
hasFontBreakoutAttribute = false
const outsideTemplate = htmlTemplateDepth === 0
if (FOREIGN_CONTENT_BREAKOUT_ELEMENTS.has(name)) {
breakOutOfForeignContent()
}
Expand All @@ -158,23 +190,28 @@ module.exports = html => {
const isHtmlTemplate = !isInForeignContent()
templates.unshift(isHtmlTemplate)
if (isHtmlTemplate) htmlTemplateDepth++
} else if (name === 'script' && htmlTemplateDepth === 0) {
attributes = {}
}
if (outsideTemplate) {
// htmlparser2 spells some SVG tags in camelCase. Callers ask in lowercase.
if (targets.has(name)) collectedAs = name
else if (foldTagCase) {
const lower = name.toLowerCase()
if (lower !== name && targets.has(lower)) collectedAs = lower
}
if (collectedAs !== null) attributes = {}
}
},
onattribute: (name, value, quote) => {
if (attributes !== null) {
attributes[name] = getValue(value, quote)
} else if (tagName === ANNOTATION_XML) {
if (attributes !== null) setAttribute(attributes, name, value, quote)
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)) {
if (collectedAs !== null) elements[collectedAs].push(attributes)
if (INTEGRATION_CANDIDATES.has(name)) {
const namespace = namespaces[0]
const isHtml = isIntegrationPoint(namespace, name, encoding)
namespaces.unshift(isHtml ? HTML : namespace)
Expand All @@ -184,7 +221,7 @@ module.exports = html => {
mathTextIntegrationDepths.unshift(depth)
}
} else if (name === 'noscript') {
if (!isInForeignContent()) {
if (scripting && !isInForeignContent()) {
continueTokenizingFrom(
parser,
findNoscriptEnd(source, parser.endIndex + 1)
Expand Down Expand Up @@ -212,5 +249,15 @@ module.exports = html => {
})
parser.isInForeignContext = isInForeignContent
parser.end(source)
return scripts
return elements
}

module.exports = html => extract(html, SCRIPT_TAGS, setLastValue, true).script

module.exports.elements = (html, tagNames, options) => {
if (!Array.isArray(tagNames)) {
throw new TypeError('tagNames must be an array')
}
const { scripting = true } = options ?? {}
return extract(html, tagNames, setFirstValue, scripting)
}
214 changes: 214 additions & 0 deletions test/elements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
'use strict'

const test = require('ava').default

const { elements } = require('../')

test('collect the requested tags in document order', t => {
t.deepEqual(
elements(
'<meta name="a" content="1"><script src="/a.js"></script><meta property="b">',
['meta', 'script']
),
{
meta: [{ name: 'a', content: '1' }, { property: 'b' }],
script: [{ src: '/a.js' }]
}
)
})

test('return an empty list for a tag that is not present', t => {
t.deepEqual(elements('<p>hello</p>', ['meta', 'script']), {
meta: [],
script: []
})
t.deepEqual(elements(undefined, ['meta']), { meta: [] })
})

test('keep attribute values as the strings a browser reads', t => {
t.deepEqual(
elements('<script src="/a.js" async defer="1"></script>', ['script'])
.script,
[{ src: '/a.js', async: '', defer: '1' }]
)
})

test('keep the first value of a repeated attribute', t => {
t.deepEqual(elements('<meta name="a" name="b" content="1">', ['meta']).meta, [
{ name: 'a', content: '1' }
])
})

test('read attributes named like object properties', t => {
const proto = '__proto__'
const [meta] = elements('<meta constructor="x" toString="y" __proto__="z">', [
'meta'
]).meta
t.is(meta.constructor, 'x')
t.is(meta.tostring, 'y')
t.is(meta[proto], 'z')
t.true(Object.hasOwn(meta, proto))
t.is(Object.getPrototypeOf(meta), Object.prototype)
const [first] = elements('<meta __proto__="z" __proto__="later">', [
'meta'
]).meta
t.is(first[proto], 'z')
})

test('ignore tags inside template, noscript, and raw text', t => {
t.deepEqual(
elements(
'<template><meta name="t"></template><noscript><meta name="n"></noscript><textarea><meta name="x"></textarea><title><meta name="y"></title><meta name="kept">',
['meta']
).meta,
[{ name: 'kept' }]
)
})

test('meta breaks out of svg like the html parser does', t => {
t.deepEqual(
elements(
'<svg><meta name="a"><style><script src="/x.js"></script></style></svg>',
['meta', 'script']
),
{ meta: [{ name: 'a' }], script: [] }
)
})

test('lowercase svg tag names match the parser camelCase', t => {
t.deepEqual(
elements(
'<svg><linearGradient id="g"></linearGradient><foreignObject id="f"><script src="/a.js"/></foreignObject></svg><script src="/b.js"></script>',
['lineargradient', 'foreignobject', 'script']
),
{
lineargradient: [{ id: 'g' }],
foreignobject: [{ id: 'f' }],
script: [{ src: '/a.js' }]
}
)
t.deepEqual(
elements('<svg><clipPath id="c"></clipPath></svg>', ['clipPath']),
{ clipPath: [{ id: 'c' }] }
)
})

test('read tags written as svg markup', t => {
t.deepEqual(
elements('<svg><script src="/a.js"/><a href="/b"></a></svg>', [
'script',
'a'
]),
{ script: [{ src: '/a.js' }], a: [{ href: '/b' }] }
)
})

test('collect an html template element but not the tags inside it', t => {
t.deepEqual(
elements(
'<template id="outer"><meta name="in"><template id="inner"></template></template><template id="after"></template><meta name="out">',
['template', 'meta']
),
{
template: [{ id: 'outer' }, { id: 'after' }],
meta: [{ name: 'out' }]
}
)
})

test('an svg title stays an html integration point when it is collected', t => {
t.deepEqual(
elements(
'<svg><title id="t"><script src="/a.js"/></title></svg><script src="/b.js"></script>',
['title', 'script']
),
{ title: [{ id: 't' }], script: [{ src: '/a.js' }] }
)
})

test('collecting mi still keeps mglyph in math', t => {
t.deepEqual(
elements(
'<math><mi id="i"><mglyph><style><script src="/a.js"></script></style></mglyph></mi><script src="/b.js"/></math>',
['mi', 'script']
),
{ mi: [{ id: 'i' }], script: [{ src: '/a.js' }, { src: '/b.js' }] }
)
})

test('collecting annotation-xml still keeps a direct svg child in svg', t => {
t.deepEqual(
elements(
'<math><annotation-xml id="a"><svg><desc><style><script src="/a.js"></script></style></desc></svg></annotation-xml></math>',
['annotation-xml', 'script']
),
{ 'annotation-xml': [{ id: 'a' }], script: [] }
)
})

test('collecting annotation-xml still honors an html encoding', t => {
t.deepEqual(
elements(
'<math><annotation-xml encoding="text/html"><script src="/a.js"/></annotation-xml></math><script src="/b.js"></script>',
['annotation-xml', 'script']
),
{
'annotation-xml': [{ encoding: 'text/html' }],
script: [{ src: '/a.js' }]
}
)
})

test('collecting font still breaks out of svg on a breakout attribute', t => {
t.deepEqual(
elements(
'<svg><font color="red"><style><script src="/a.js"></script></style></svg>',
['font', 'script']
),
{ font: [{ color: 'red' }], script: [] }
)
t.deepEqual(
elements(
'<svg><font><style><script src="/a.js"></script></style></font></svg>',
['font', 'script']
),
{ font: [{}], script: [{ src: '/a.js' }] }
)
})

test('read svg noscript content when scripting is off', t => {
const html = '<svg><noscript><a href="/a"></a></noscript></svg>'
const expected = { noscript: [{}], a: [{ href: '/a' }] }
t.deepEqual(elements(html, ['noscript', 'a']), expected)
t.deepEqual(elements(html, ['noscript', 'a'], { scripting: false }), expected)
})

test('reject tag names that are not an array', t => {
t.throws(() => elements('<meta name="a">', 'meta'), { instanceOf: TypeError })
})

test('requesting __proto__ does not replace the result prototype', t => {
const proto = '__proto__'
const result = elements('<meta name="m">', [proto, 'meta'])
t.is(Object.getPrototypeOf(result), Object.prototype)
t.true(Object.hasOwn(result, proto))
t.deepEqual(result[proto], [])
t.deepEqual(result.meta, [{ name: 'm' }])
})

test('read noscript content as markup when scripting is off', t => {
const html =
'<noscript><meta name="n"><script src="/n.js"></script></noscript><meta name="kept">'
t.deepEqual(elements(html, ['meta', 'script']), {
meta: [{ name: 'kept' }],
script: []
})
t.deepEqual(elements(html, ['meta', 'script'], null), {
meta: [{ name: 'kept' }],
script: []
})
t.deepEqual(elements(html, ['meta', 'script'], { scripting: false }), {
meta: [{ name: 'n' }, { name: 'kept' }],
script: [{ src: '/n.js' }]
})
})
Loading
Loading