From 7c44f134137137f5cd9e6942fded52fbbbcfb4d4 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:31:41 +0900 Subject: [PATCH] Read uppercase attribute names the way a browser does HTML attribute names are case-insensitive, but linkedom keeps them as written. On a page written as , or

, every getAttribute and attribute selector missed: the link had no href and printed as plain text, the hidden csrf input was numbered as a text field, and hidden content showed up in the view and in raw. Lowercasing attribute names once after parsing fixes all of them in one place. --- src/distill.js | 24 ++++++++++++++++++++++++ tests/distill.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/distill.js b/src/distill.js index 001325c..64cde67 100644 --- a/src/distill.js +++ b/src/distill.js @@ -284,6 +284,7 @@ function isFollowableHref(href, base, pageUrl) { */ export function distill(html, url = '', { view = null, type = '' } = {}) { const { document } = parseHTML(asHTML(html, url, { view, type })); + lowerAttributeNames(document); const title = clean(document.querySelector('title')?.textContent ?? ''); const base = documentBase(document, url); /** @type {Block[]} */ @@ -615,6 +616,28 @@ function number(blocks) { */ const bodyOf = (document) => document.querySelector('body') ?? document.documentElement; +/** + * HTML attribute names are ASCII case-insensitive, but linkedom keeps them as + * written, so `` had no href, `` no type and + * `

` no style. Lowercase them once after parsing so + * every lookup below sees what a browser sees. As in a browser, the first of + * two spellings of one attribute wins. + * @param {Document} document + */ +function lowerAttributeNames(document) { + for (const el of document.querySelectorAll('*')) { + const attributes = [...el.attributes]; + if (attributes.every(({ name }) => name === name.toLowerCase())) continue; + const first = new Map(); + for (const { name, value } of attributes) { + const lower = name.toLowerCase(); + if (!first.has(lower)) first.set(lower, value); + } + for (const { name } of attributes) el.removeAttribute(name); + for (const [name, value] of first) el.setAttribute(name, value); + } +} + /** * Shared cleanup for the raw modes: parse, then delete the same noise * distill() skips, so neither raw output ever leaks scripts, styles, or @@ -627,6 +650,7 @@ function cleanDocument(html, url = '', type = '') { // Raw is the mode an agent reaches for when the compact view left something // out, so it is the one place a JSON response keeps every field. const { document } = parseHTML(asHTML(html, url, { full: true, type })); + lowerAttributeNames(document); // Read the title before the sweep below removes the head with it. const title = clean(document.querySelector('title')?.textContent ?? ''); for (const tag of DROP) { diff --git a/tests/distill.test.js b/tests/distill.test.js index ffd60e9..e6ca5b3 100644 --- a/tests/distill.test.js +++ b/tests/distill.test.js @@ -56,6 +56,31 @@ test('input type is matched case-insensitively, as HTML does', () => { } }); +test('attribute names are matched case-insensitively, as HTML does', () => { + // HTML attribute names are ASCII case-insensitive and linkedom keeps them + // as written. Uppercase markup lost its links, numbered a hidden input as + // a text field and printed hidden content, in the view and in raw. + const html = `Legacy +

Legacy page

+ Next page +
+

secret-style

+ + + Twice + `; + const page = distill(html, 'https://example.com/old'); + const links = page.blocks.filter((b) => b.type === 'link'); + assert.deepEqual(links.map((b) => [b.text, b.href]), [['Next page', '/next'], ['Twice', '/first']]); + assert.ok(!page.blocks.some((b) => b.type === 'input'), 'hidden input numbered'); + const view = render(page, { budget: 5000 }).text; + for (const out of [view, toMarkdown(html), toHTML(html)]) { + for (const secret of ['secret-token', 'secret-style', 'secret-hidden', 'secret-aria']) { + assert.ok(!out.includes(secret), `${secret} leaked`); + } + } +}); + test('raw mode emits real markdown with hrefs an agent can follow', () => { const md = toMarkdown(html); assert.ok(md.startsWith('# Fixture News'));