` 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
+