From 6f76759be699b0e0a9cc95d356eb5d0c8e994494 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 28 Oct 2026 10:16:04 +0100 Subject: [PATCH 1/6] feat: read any tags with elements(html, tagNames) The parser state (namespaces, integration points, breakout, template and noscript handling) now runs for a list of tag names. `elements` returns their attributes with DOM semantics: string values, first value of a repeated attribute wins. The default export is unchanged. Lets simple-wappalyzer read meta and script tags with the same spec handling instead of keeping its own htmlparser2 loop. Co-Authored-By: Claude Opus 5.5 --- src/index.js | 36 ++++++++++++++++------- test/elements.js | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 test/elements.js diff --git a/src/index.js b/src/index.js index 2dc7aa2..4e2e4ea 100644 --- a/src/index.js +++ b/src/index.js @@ -73,12 +73,22 @@ 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 } +const setLastValue = (attributes, name, value, quote) => { + attributes[name] = getValue(value, quote) +} + +const setFirstValue = (attributes, name, value) => { + if (!Object.hasOwn(attributes, name)) attributes[name] = value +} + const isHtmlEncoding = value => { const mime = value.toLowerCase() return mime === 'text/html' || mime === 'application/xhtml+xml' @@ -104,9 +114,11 @@ const continueTokenizingFrom = (parser, index) => { parser.tokenizer.index = index - 1 } -module.exports = html => { +const extract = (html, tagNames, setAttribute) => { const source = String(html) - const scripts = [] + const targets = new Set(tagNames) + const elements = {} + for (const name of targets) elements[name] = [] const namespaces = [HTML] const templates = [] const mathAnnotationDepths = [] @@ -158,23 +170,20 @@ module.exports = html => { const isHtmlTemplate = !isInForeignContent() templates.unshift(isHtmlTemplate) if (isHtmlTemplate) htmlTemplateDepth++ - } else if (name === 'script' && htmlTemplateDepth === 0) { - attributes = {} } + if (htmlTemplateDepth === 0 && targets.has(name)) 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 (attributes !== null) elements[name].push(attributes) + if (INTEGRATION_CANDIDATES.has(name)) { const namespace = namespaces[0] const isHtml = isIntegrationPoint(namespace, name, encoding) namespaces.unshift(isHtml ? HTML : namespace) @@ -212,5 +221,10 @@ module.exports = html => { }) parser.isInForeignContext = isInForeignContent parser.end(source) - return scripts + return elements } + +module.exports = html => extract(html, SCRIPT_TAGS, setLastValue).script + +module.exports.elements = (html, tagNames) => + extract(html, tagNames, setFirstValue) diff --git a/test/elements.js b/test/elements.js new file mode 100644 index 0000000..11aede7 --- /dev/null +++ b/test/elements.js @@ -0,0 +1,76 @@ +'use strict' + +const test = require('ava').default + +const { elements } = require('../') + +test('collect the requested tags in document order', t => { + t.deepEqual( + elements( + '', + ['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('

hello

', ['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']) + .script, + [{ src: '/a.js', async: '', defer: '1' }] + ) +}) + +test('keep the first value of a repeated attribute', t => { + t.deepEqual(elements('', ['meta']).meta, [ + { name: 'a', content: '1' } + ]) +}) + +test('read attributes named like object properties', t => { + t.deepEqual(elements('', ['meta']).meta, [ + { constructor: 'x', tostring: 'y' } + ]) +}) + +test('ignore tags inside template, noscript, and raw text', t => { + t.deepEqual( + elements( + '<meta name="y">', + ['meta'] + ).meta, + [{ name: 'kept' }] + ) +}) + +test('meta breaks out of svg like the html parser does', t => { + t.deepEqual( + elements( + '', + ['meta', 'script'] + ), + { meta: [{ name: 'a' }], script: [] } + ) +}) + +test('read tags written as svg markup', t => { + t.deepEqual( + elements('', [ + 'script', + 'a' + ]), + { script: [{ src: '/a.js' }], a: [{ href: '/b' }] } + ) +}) From 5ff0c8ff17a47d74f8177770ba5204e1d41c252f Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 28 Oct 2026 10:16:05 +0100 Subject: [PATCH 2/6] docs: document elements Co-Authored-By: Claude Opus 5.5 --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index ef73502..d0cd543 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,27 @@ 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', 'script'] +) + +console.log(meta) +// [ { name: 'generator', content: 'WordPress 6.4' } ] + +console.log(script) +// [ { async: '', src: '/app.js' } ] +``` + +Tag names are lowercase. Tags inside `