From 896054613e865e7dd84d59db021a47d5d2e26c28 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Mon, 2 Nov 2026 00:20:59 +0900 Subject: [PATCH] fix: lowercase HTML attribute names like browsers do --- cjs/interface/document.js | 4 +- cjs/interface/element.js | 77 +++-- cjs/mixin/inner-html.js | 5 +- cjs/shared/parse-from-string.js | 17 +- cjs/shared/parse-json.js | 5 +- cjs/shared/utils.js | 34 +++ esm/interface/document.js | 4 +- esm/interface/element.js | 77 +++-- esm/mixin/inner-html.js | 5 +- esm/shared/parse-from-string.js | 17 +- esm/shared/parse-json.js | 5 +- esm/shared/utils.js | 31 ++ test/html/document.js | 21 ++ test/interface/custom-element-registry.js | 20 ++ test/interface/element.js | 62 ++++ test/shared/parse-json.js | 9 + test/svg/element.js | 27 +- test/xml/document.js | 23 ++ types/esm/shared/parse-from-string.d.ts | 2 +- types/esm/shared/utils.d.ts | 3 + worker.js | 338 +++++++++++++--------- 21 files changed, 580 insertions(+), 206 deletions(-) diff --git a/cjs/interface/document.js b/cjs/interface/document.js index 1a92fb28..dbbf6cd9 100644 --- a/cjs/interface/document.js +++ b/cjs/interface/document.js @@ -8,7 +8,7 @@ const { const {Facades, illegalConstructor} = require('../shared/facades.js'); const {HTMLClasses} = require('../shared/html-classes.js'); const {Mime} = require('../shared/mime.js'); -const {knownSiblings} = require('../shared/utils.js'); +const {asciiLowerCase, knownSiblings} = require('../shared/utils.js'); const {assign, create, defineProperties, setPrototypeOf} = require('../shared/object.js'); const {NonElementParentNode} = require('../mixin/non-element-parent-node.js'); @@ -169,7 +169,7 @@ class Document extends NonElementParentNode { return this[EVENT_TARGET]; } - createAttribute(name) { return new Attr(this, name); } + createAttribute(name) { return new Attr(this, this[MIME].ignoreCase ? asciiLowerCase(name) : name); } createCDATASection(data) { return new CDATASection(this, data); } createComment(textContent) { return new Comment(this, textContent); } createDocumentFragment() { return new DocumentFragment(this); } diff --git a/cjs/interface/element.js b/cjs/interface/element.js index 5d513d6a..20c23893 100644 --- a/cjs/interface/element.js +++ b/cjs/interface/element.js @@ -21,8 +21,11 @@ const { } = require('../shared/symbols.js'); const { + asciiLowerCase, + hasUpperCase, htmlToFragment, ignoreCase, + inSvg, knownAdjacent, localCase, String @@ -71,6 +74,34 @@ const isVoid = ({localName, ownerDocument}) => { return ownerDocument[MIME].voidElements.test(localName); }; +const findAttribute = (element, name) => { + let next = element[NEXT]; + while (next.nodeType === ATTRIBUTE_NODE) { + if (next.name === name) + return next; + next = next[NEXT]; + } + return null; +}; + +// the name an element stores: only HTML elements in HTML documents hold ASCII lowercase names +const attributeName = (element, name) => ( + hasUpperCase(name) && ignoreCase(element) && !inSvg(element) ? + asciiLowerCase(name) : + name +); + +// https://dom.spec.whatwg.org/#concept-element-attributes-get-by-name +// every attribute created for an HTML element is ASCII lowercase, so the name is looked up as +// given first, and only a non canonical one (from another document, for instance) is kept as is +const attributeByName = (element, name) => { + const attribute = findAttribute(element, name); + if (attribute) + return attribute; + const storedName = attributeName(element, name); + return storedName === name ? null : findAttribute(element, storedName); +}; + // /** @@ -226,20 +257,17 @@ class Element extends ParentNode { focus() { this.dispatchEvent(new Event('focus')); } getAttribute(name) { - if (name === 'class') + const attribute = attributeByName(this, name); + if (!attribute) + return name === 'class' ? this.className : null; + // class is proxied through classList + if (attribute.name === 'class') return this.className; - const attribute = this.getAttributeNode(name); - return attribute && (ignoreCase(this) ? attribute.value : escape(attribute.value)); + return ignoreCase(this) ? attribute.value : escape(attribute.value); } getAttributeNode(name) { - let next = this[NEXT]; - while (next.nodeType === ATTRIBUTE_NODE) { - if (next.name === name) - return next; - next = next[NEXT]; - } - return null; + return attributeByName(this, name); } getAttributeNames() { @@ -252,19 +280,15 @@ class Element extends ParentNode { return attributes; } - hasAttribute(name) { return !!this.getAttributeNode(name); } + hasAttribute(name) { return !!attributeByName(this, name); } hasAttributes() { return this[NEXT].nodeType === ATTRIBUTE_NODE; } removeAttribute(name) { - if (name === 'class' && this[CLASS_LIST]) + const attribute = attributeByName(this, name); + if (attribute) { + if (attribute.name === 'class' && this[CLASS_LIST]) this[CLASS_LIST].clear(); - let next = this[NEXT]; - while (next.nodeType === ATTRIBUTE_NODE) { - if (next.name === name) { - removeAttribute(this, next); - return; - } - next = next[NEXT]; + removeAttribute(this, attribute); } } @@ -280,12 +304,17 @@ class Element extends ParentNode { } setAttribute(name, value) { - if (name === 'class') - this.className = value; - else { - const attribute = this.getAttributeNode(name); - if (attribute) + const attribute = attributeByName(this, name); + if (attribute) { + if (attribute.name === 'class') + this.className = value; + else attribute.value = value; + } + else { + name = attributeName(this, name); + if (name === 'class') + this.className = value; else setAttribute(this, new Attr(this.ownerDocument, name, value)); } diff --git a/cjs/mixin/inner-html.js b/cjs/mixin/inner-html.js index ec106d80..c1b254b5 100644 --- a/cjs/mixin/inner-html.js +++ b/cjs/mixin/inner-html.js @@ -2,7 +2,7 @@ const {ELEMENT_NODE, DOCUMENT_FRAGMENT_NODE} = require('../shared/constants.js'); const {CUSTOM_ELEMENTS} = require('../shared/symbols.js'); const {parseFromString} = require('../shared/parse-from-string.js'); -const {ignoreCase} = require('../shared/utils.js'); +const {ignoreCase, inSvg} = require('../shared/utils.js'); /** @@ -21,7 +21,8 @@ const setInnerHtml = (node, html) => { const {constructor} = ownerDocument; const document = new constructor; document[CUSTOM_ELEMENTS] = ownerDocument[CUSTOM_ELEMENTS]; - const {childNodes} = parseFromString(document, ignoreCase(node), html); + // an SVG context keeps attribute names as they are, like it does when parsing a whole document + const {childNodes} = parseFromString(document, ignoreCase(node), html, inSvg(node)); node.replaceChildren(...childNodes.map(setOwnerDocument, ownerDocument)); }; diff --git a/cjs/shared/parse-from-string.js b/cjs/shared/parse-from-string.js index 84a9fdf3..0f1d3db7 100644 --- a/cjs/shared/parse-from-string.js +++ b/cjs/shared/parse-from-string.js @@ -5,9 +5,11 @@ const {ELEMENT_NODE, SVG_NAMESPACE} = require('./constants.js'); const {CUSTOM_ELEMENTS, PREV, END, VALUE} = require('./symbols.js'); const {keys} = require('./object.js'); -const {knownBoundaries, knownSiblings} = require('./utils.js'); +const {asciiLowerCase, hasUpperCase, knownBoundaries, knownSiblings} = require('./utils.js'); const {attributeChangedCallback, connectedCallback} = require('../interface/custom-element-registry.js'); +const {Attr} = require('../interface/attr.js'); + const {Parser} = HTMLParser2; // import {Mime} from './mime.js'; @@ -40,7 +42,7 @@ const attribute = (element, end, attribute, value, active) => { const isNotParsing = () => notParsing; exports.isNotParsing = isNotParsing; -const parseFromString = (document, isHTML, markupLanguage) => { +const parseFromString = (document, isHTML, markupLanguage, svg = false) => { const {active, registry} = document[CUSTOM_ELEMENTS]; let node = document; @@ -59,16 +61,19 @@ const parseFromString = (document, isHTML, markupLanguage) => { // onopentag(name, attributes) { let create = true; + let htmlAttributes = isHTML && !svg; if (isHTML) { if (ownerSVGElement) { node = append(node, document.createElementNS(SVG_NAMESPACE, name), active); node.ownerSVGElement = ownerSVGElement; create = false; + htmlAttributes = false; } else if (name === 'svg' || name === 'SVG') { ownerSVGElement = document.createElementNS(SVG_NAMESPACE, name); node = append(node, ownerSVGElement, active); create = false; + htmlAttributes = false; } else if (active) { const ce = name.includes('-') ? name : (attributes.is || ''); @@ -85,8 +90,12 @@ const parseFromString = (document, isHTML, markupLanguage) => { node = append(node, document.createElement(name), false); let end = node[END]; - for (const name of keys(attributes)) - attribute(node, end, document.createAttribute(name), attributes[name], active); + // the HTML tokenizer lowercases attribute names, but SVG ones keep their case + // https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state + for (const name of keys(attributes)) { + const htmlName = htmlAttributes && hasUpperCase(name) ? asciiLowerCase(name) : name; + attribute(node, end, new Attr(document, htmlName), attributes[name], active); + } }, // #text, #comment diff --git a/cjs/shared/parse-json.js b/cjs/shared/parse-json.js index 7779cccb..8067cb87 100644 --- a/cjs/shared/parse-json.js +++ b/cjs/shared/parse-json.js @@ -14,7 +14,7 @@ const { const {END, PREV} = require('./symbols.js'); const {htmlClasses} = require('./register-html-class.js'); -const {knownBoundaries, knownSiblings} = require('./utils.js'); +const {asciiLowerCase, hasUpperCase, inSvg, knownBoundaries, knownSiblings} = require('./utils.js'); const {Attr} = require('../interface/attr.js'); const {CDATASection} = require('../interface/cdata-section.js'); @@ -75,7 +75,8 @@ const parseJSON = value => { case ATTRIBUTE_NODE: { const name = array[i++]; const value = typeof array[i] === 'string' ? array[i++] : ''; - const attr = new Attr(document, name, value); + const htmlName = inSvg(parentNode) || !hasUpperCase(name) ? name : asciiLowerCase(name); + const attr = new Attr(document, htmlName, value); attr.ownerElement = parentNode; knownSiblings(end[PREV], attr, end); break; diff --git a/cjs/shared/utils.js b/cjs/shared/utils.js index 1dfb969e..0e81a2e5 100644 --- a/cjs/shared/utils.js +++ b/cjs/shared/utils.js @@ -5,9 +5,43 @@ const {END, MIME, NEXT, PREV} = require('./symbols.js'); const $String = String; exports.String = $String; +const allUpperCase = /[A-Z]/g; + +// ASCII uppercase only: cheaper than a regular expression, as this runs on every lookup +const hasUpperCase = name => { + if (typeof name !== 'string') + return false; + for (let i = 0; i < name.length; i++) { + const code = name.charCodeAt(i); + if (64 < code && 91 > code) + return true; + } + return false; +}; +exports.hasUpperCase = hasUpperCase; + +// https://infra.spec.whatwg.org/#ascii-lowercase +const asciiLowerCase = name => $String(name).replace( + allUpperCase, + character => character.toLowerCase() +); +exports.asciiLowerCase = asciiLowerCase; + const getEnd = node => node.nodeType === ELEMENT_NODE ? node[END] : node; exports.getEnd = getEnd; +// an element is in SVG when it is one, or when it lives inside one: svg.innerHTML builds +// HTML class elements (a pre-existing limitation), so checking ownerSVGElement is not enough +const inSvg = node => { + while (node) { + if (node.ownerSVGElement !== undefined) + return true; + node = node.parentNode; + } + return false; +}; +exports.inSvg = inSvg; + const ignoreCase = ({ownerDocument}) => ownerDocument[MIME].ignoreCase; exports.ignoreCase = ignoreCase; diff --git a/esm/interface/document.js b/esm/interface/document.js index d68c9eb5..7f0a75d1 100644 --- a/esm/interface/document.js +++ b/esm/interface/document.js @@ -8,7 +8,7 @@ import { import {Facades, illegalConstructor} from '../shared/facades.js'; import {HTMLClasses} from '../shared/html-classes.js'; import {Mime} from '../shared/mime.js'; -import {knownSiblings} from '../shared/utils.js'; +import {asciiLowerCase, knownSiblings} from '../shared/utils.js'; import {assign, create, defineProperties, setPrototypeOf} from '../shared/object.js'; import {NonElementParentNode} from '../mixin/non-element-parent-node.js'; @@ -169,7 +169,7 @@ export class Document extends NonElementParentNode { return this[EVENT_TARGET]; } - createAttribute(name) { return new Attr(this, name); } + createAttribute(name) { return new Attr(this, this[MIME].ignoreCase ? asciiLowerCase(name) : name); } createCDATASection(data) { return new CDATASection(this, data); } createComment(textContent) { return new Comment(this, textContent); } createDocumentFragment() { return new DocumentFragment(this); } diff --git a/esm/interface/element.js b/esm/interface/element.js index c98f5f7d..4f48cff8 100644 --- a/esm/interface/element.js +++ b/esm/interface/element.js @@ -23,8 +23,11 @@ import { } from '../shared/symbols.js'; import { + asciiLowerCase, + hasUpperCase, htmlToFragment, ignoreCase, + inSvg, knownAdjacent, localCase, String @@ -73,6 +76,34 @@ const isVoid = ({localName, ownerDocument}) => { return ownerDocument[MIME].voidElements.test(localName); }; +const findAttribute = (element, name) => { + let next = element[NEXT]; + while (next.nodeType === ATTRIBUTE_NODE) { + if (next.name === name) + return next; + next = next[NEXT]; + } + return null; +}; + +// the name an element stores: only HTML elements in HTML documents hold ASCII lowercase names +const attributeName = (element, name) => ( + hasUpperCase(name) && ignoreCase(element) && !inSvg(element) ? + asciiLowerCase(name) : + name +); + +// https://dom.spec.whatwg.org/#concept-element-attributes-get-by-name +// every attribute created for an HTML element is ASCII lowercase, so the name is looked up as +// given first, and only a non canonical one (from another document, for instance) is kept as is +const attributeByName = (element, name) => { + const attribute = findAttribute(element, name); + if (attribute) + return attribute; + const storedName = attributeName(element, name); + return storedName === name ? null : findAttribute(element, storedName); +}; + // /** @@ -228,20 +259,17 @@ export class Element extends ParentNode { focus() { this.dispatchEvent(new Event('focus')); } getAttribute(name) { - if (name === 'class') + const attribute = attributeByName(this, name); + if (!attribute) + return name === 'class' ? this.className : null; + // class is proxied through classList + if (attribute.name === 'class') return this.className; - const attribute = this.getAttributeNode(name); - return attribute && (ignoreCase(this) ? attribute.value : escape(attribute.value)); + return ignoreCase(this) ? attribute.value : escape(attribute.value); } getAttributeNode(name) { - let next = this[NEXT]; - while (next.nodeType === ATTRIBUTE_NODE) { - if (next.name === name) - return next; - next = next[NEXT]; - } - return null; + return attributeByName(this, name); } getAttributeNames() { @@ -254,19 +282,15 @@ export class Element extends ParentNode { return attributes; } - hasAttribute(name) { return !!this.getAttributeNode(name); } + hasAttribute(name) { return !!attributeByName(this, name); } hasAttributes() { return this[NEXT].nodeType === ATTRIBUTE_NODE; } removeAttribute(name) { - if (name === 'class' && this[CLASS_LIST]) + const attribute = attributeByName(this, name); + if (attribute) { + if (attribute.name === 'class' && this[CLASS_LIST]) this[CLASS_LIST].clear(); - let next = this[NEXT]; - while (next.nodeType === ATTRIBUTE_NODE) { - if (next.name === name) { - removeAttribute(this, next); - return; - } - next = next[NEXT]; + removeAttribute(this, attribute); } } @@ -282,12 +306,17 @@ export class Element extends ParentNode { } setAttribute(name, value) { - if (name === 'class') - this.className = value; - else { - const attribute = this.getAttributeNode(name); - if (attribute) + const attribute = attributeByName(this, name); + if (attribute) { + if (attribute.name === 'class') + this.className = value; + else attribute.value = value; + } + else { + name = attributeName(this, name); + if (name === 'class') + this.className = value; else setAttribute(this, new Attr(this.ownerDocument, name, value)); } diff --git a/esm/mixin/inner-html.js b/esm/mixin/inner-html.js index ac37e8f7..e1df3a50 100644 --- a/esm/mixin/inner-html.js +++ b/esm/mixin/inner-html.js @@ -1,7 +1,7 @@ import {ELEMENT_NODE, DOCUMENT_FRAGMENT_NODE} from '../shared/constants.js'; import {CUSTOM_ELEMENTS} from '../shared/symbols.js'; import {parseFromString} from '../shared/parse-from-string.js'; -import {ignoreCase} from '../shared/utils.js'; +import {ignoreCase, inSvg} from '../shared/utils.js'; /** @@ -19,7 +19,8 @@ export const setInnerHtml = (node, html) => { const {constructor} = ownerDocument; const document = new constructor; document[CUSTOM_ELEMENTS] = ownerDocument[CUSTOM_ELEMENTS]; - const {childNodes} = parseFromString(document, ignoreCase(node), html); + // an SVG context keeps attribute names as they are, like it does when parsing a whole document + const {childNodes} = parseFromString(document, ignoreCase(node), html, inSvg(node)); node.replaceChildren(...childNodes.map(setOwnerDocument, ownerDocument)); }; diff --git a/esm/shared/parse-from-string.js b/esm/shared/parse-from-string.js index 08c6fe39..992d4f99 100644 --- a/esm/shared/parse-from-string.js +++ b/esm/shared/parse-from-string.js @@ -4,9 +4,11 @@ import {ELEMENT_NODE, SVG_NAMESPACE} from './constants.js'; import {CUSTOM_ELEMENTS, PREV, END, VALUE} from './symbols.js'; import {keys} from './object.js'; -import {knownBoundaries, knownSiblings} from './utils.js'; +import {asciiLowerCase, hasUpperCase, knownBoundaries, knownSiblings} from './utils.js'; import {attributeChangedCallback, connectedCallback} from '../interface/custom-element-registry.js'; +import {Attr} from '../interface/attr.js'; + const {Parser} = HTMLParser2; // import {Mime} from './mime.js'; @@ -38,7 +40,7 @@ const attribute = (element, end, attribute, value, active) => { export const isNotParsing = () => notParsing; -export const parseFromString = (document, isHTML, markupLanguage) => { +export const parseFromString = (document, isHTML, markupLanguage, svg = false) => { const {active, registry} = document[CUSTOM_ELEMENTS]; let node = document; @@ -57,16 +59,19 @@ export const parseFromString = (document, isHTML, markupLanguage) => { // onopentag(name, attributes) { let create = true; + let htmlAttributes = isHTML && !svg; if (isHTML) { if (ownerSVGElement) { node = append(node, document.createElementNS(SVG_NAMESPACE, name), active); node.ownerSVGElement = ownerSVGElement; create = false; + htmlAttributes = false; } else if (name === 'svg' || name === 'SVG') { ownerSVGElement = document.createElementNS(SVG_NAMESPACE, name); node = append(node, ownerSVGElement, active); create = false; + htmlAttributes = false; } else if (active) { const ce = name.includes('-') ? name : (attributes.is || ''); @@ -83,8 +88,12 @@ export const parseFromString = (document, isHTML, markupLanguage) => { node = append(node, document.createElement(name), false); let end = node[END]; - for (const name of keys(attributes)) - attribute(node, end, document.createAttribute(name), attributes[name], active); + // the HTML tokenizer lowercases attribute names, but SVG ones keep their case + // https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state + for (const name of keys(attributes)) { + const htmlName = htmlAttributes && hasUpperCase(name) ? asciiLowerCase(name) : name; + attribute(node, end, new Attr(document, htmlName), attributes[name], active); + } }, // #text, #comment diff --git a/esm/shared/parse-json.js b/esm/shared/parse-json.js index 36f762b3..0136cfdb 100644 --- a/esm/shared/parse-json.js +++ b/esm/shared/parse-json.js @@ -13,7 +13,7 @@ import { import {END, PREV} from './symbols.js'; import {htmlClasses} from './register-html-class.js'; -import {knownBoundaries, knownSiblings} from './utils.js'; +import {asciiLowerCase, hasUpperCase, inSvg, knownBoundaries, knownSiblings} from './utils.js'; import {Attr} from '../interface/attr.js'; import {CDATASection} from '../interface/cdata-section.js'; @@ -74,7 +74,8 @@ export const parseJSON = value => { case ATTRIBUTE_NODE: { const name = array[i++]; const value = typeof array[i] === 'string' ? array[i++] : ''; - const attr = new Attr(document, name, value); + const htmlName = inSvg(parentNode) || !hasUpperCase(name) ? name : asciiLowerCase(name); + const attr = new Attr(document, htmlName, value); attr.ownerElement = parentNode; knownSiblings(end[PREV], attr, end); break; diff --git a/esm/shared/utils.js b/esm/shared/utils.js index 50946b2e..2d3381ea 100644 --- a/esm/shared/utils.js +++ b/esm/shared/utils.js @@ -4,8 +4,39 @@ import {END, MIME, NEXT, PREV} from './symbols.js'; const $String = String; export {$String as String}; +const allUpperCase = /[A-Z]/g; + +// ASCII uppercase only: cheaper than a regular expression, as this runs on every lookup +export const hasUpperCase = name => { + if (typeof name !== 'string') + return false; + for (let i = 0; i < name.length; i++) { + const code = name.charCodeAt(i); + if (64 < code && 91 > code) + return true; + } + return false; +}; + +// https://infra.spec.whatwg.org/#ascii-lowercase +export const asciiLowerCase = name => $String(name).replace( + allUpperCase, + character => character.toLowerCase() +); + export const getEnd = node => node.nodeType === ELEMENT_NODE ? node[END] : node; +// an element is in SVG when it is one, or when it lives inside one: svg.innerHTML builds +// HTML class elements (a pre-existing limitation), so checking ownerSVGElement is not enough +export const inSvg = node => { + while (node) { + if (node.ownerSVGElement !== undefined) + return true; + node = node.parentNode; + } + return false; +}; + export const ignoreCase = ({ownerDocument}) => ownerDocument[MIME].ignoreCase; export const knownAdjacent = (prev, next) => { diff --git a/test/html/document.js b/test/html/document.js index b5e15062..119c875f 100644 --- a/test/html/document.js +++ b/test/html/document.js @@ -38,6 +38,7 @@ document = (new DOMParser).parseFromString('', 'text/html assert(document.title, '', 'no title'); document.title = '"hello"'; assert(document.title, '"hello"', 'title not escaped'); + assert(document.toString(), '"hello"'); assert(document.body.tagName, 'BODY'); @@ -98,3 +99,23 @@ const issue187 = (new DOMParser).parseFromString(`
', 'text/html'); +const idElement = attributeCaseDocument.getElementById('target'); + +assert(idElement.tagName, 'DIV', 'getElementById finds an uppercase ID attribute'); +assert(idElement.getAttributeNames().join(' '), 'id class style', 'uppercase ID, CLASS and STYLE are parsed as lowercase'); +assert(idElement.className, 'a b', 'classList reads the uppercase CLASS attribute'); +assert(idElement.classList.contains('b'), true, 'classList contains the uppercase CLASS tokens'); +assert(idElement.style.color, 'red', 'style reads the uppercase STYLE attribute'); +assert(idElement.getAttribute('class'), 'a b', 'getAttribute reads the uppercase CLASS attribute'); +assert(idElement.getAttribute('CLASS'), 'a b', 'getAttribute lowercases CLASS through classList'); +assert(idElement.toString(), '
', 'uppercase attributes serialize lowercased'); + +idElement.setAttribute('CLASS', 'c'); +assert(idElement.className, 'c', 'setAttribute updates the lowercase class attribute'); +assert(idElement.getAttributeNames().join(' '), 'id class style', 'setAttribute does not duplicate the class attribute'); +assert(idElement.toString(), '
', 'setAttribute keeps serialization lowercase'); +assert(idElement.getAttribute('CLASS'), 'c', 'getAttribute reads class after a CLASS update'); diff --git a/test/interface/custom-element-registry.js b/test/interface/custom-element-registry.js index b857ce64..14ea1079 100644 --- a/test/interface/custom-element-registry.js +++ b/test/interface/custom-element-registry.js @@ -235,3 +235,23 @@ assert(document.querySelector('custom-element').isCurrentThisSameAsConstructorTh const ceWithoutConstructorArgs = document.querySelector('custom-element-without-constructor-args'); customElements.define('custom-element-without-constructor-args', class extends HTMLElement {}); assert(ceWithoutConstructorArgs.localName, 'custom-element-without-constructor-args', 'constructor without args'); + +args = []; +customElements.define('c-e-case', class extends HTMLElement { + static get observedAttributes() { return ['test']; } + attributeChangedCallback(name, oldValue, newValue) { + args.push(name, oldValue, newValue); + } +}); + +ce = document.createElement('c-e-case'); +ce.setAttribute('TEST', 'value'); +assert(JSON.stringify(args.splice(0)), '["test",null,"value"]', 'attributeChangedCallback receives the lowercase name'); + +ce.removeAttribute('TEST'); +assert(JSON.stringify(args.splice(0)), '["test","value",null]', 'attributeChangedCallback receives the lowercase name on removal'); + +const caseContainer = document.createElement('div'); +caseContainer.innerHTML = ''; +assert(JSON.stringify(args.splice(0)), '["test",null,"parsed"]', 'parsed uppercase attributes are reported lowercase'); +assert(caseContainer.toString(), '
', 'parsed uppercase attributes are lowercase'); diff --git a/test/interface/element.js b/test/interface/element.js index bff3f829..73f907f6 100644 --- a/test/interface/element.js +++ b/test/interface/element.js @@ -94,3 +94,65 @@ const xmlDocWithEmptyAttrFromSet = parser.parseFromString(`'); assert(xmlDocWithEmptyAttrFromSet.innerHTML, ''); + +// https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state +// https://dom.spec.whatwg.org/#concept-element-attributes-get-by-name +const {document: attributeCaseDocument} = parseHTML('
'); + +let attributeCaseDiv = attributeCaseDocument.querySelector('div'); +assert(attributeCaseDiv.getAttributeNames().join(' '), 'data-itemid title', 'HTML attribute names are parsed as ASCII lowercase'); +assert(attributeCaseDiv.getAttribute('data-itemId'), 'a', 'getAttribute lowercases the name'); +assert(attributeCaseDiv.getAttribute('data-itemid'), 'a', 'getAttribute works with the parsed name'); +assert(attributeCaseDiv.getAttribute('DATA-ITEMID'), 'a', 'getAttribute lowercases ASCII uppercase'); +assert(attributeCaseDiv.getAttribute('data-ITEMid'), 'a', 'getAttribute lowercases any ASCII uppercase'); +assert(attributeCaseDiv.getAttribute('Data-ItemId'), 'a', 'getAttribute lowercases mixed case'); +assert(attributeCaseDiv.getAttributeNode('DATA-ITEMID').name, 'data-itemid', 'getAttributeNode lowercases the name'); +assert(attributeCaseDiv.hasAttribute('data-itemId'), true, 'hasAttribute lowercases the name'); +assert(attributeCaseDiv.outerHTML, '
', 'serialization uses lowercase names'); +assert(attributeCaseDocument.querySelector('[data-itemid]'), attributeCaseDiv, 'querySelector matches the lowercase name'); +assert(attributeCaseDocument.querySelector('[data-itemId]'), attributeCaseDiv, 'querySelector matches the original name too'); +assert(attributeCaseDocument.querySelector('[DATA-ITEMID]'), attributeCaseDiv, 'querySelector lowercases the selector name'); +assert(attributeCaseDiv.matches('[TITLE]'), true, 'matches lowercases the selector name'); + +attributeCaseDiv.setAttribute('Foo', 'x'); +assert(attributeCaseDiv.getAttributeNames().join(' '), 'foo data-itemid title', 'setAttribute lowercases the name'); +assert(attributeCaseDiv.getAttribute('FOO'), 'x', 'setAttribute is reachable with any case'); + +attributeCaseDiv.setAttribute('FOO', 'y'); +assert(attributeCaseDiv.getAttributeNames().join(' '), 'foo data-itemid title', 'setAttribute does not create case variants'); +assert(attributeCaseDiv.getAttribute('foo'), 'y', 'setAttribute updates the same attribute'); + +attributeCaseDiv.removeAttribute('DATA-ITEMID'); +assert(attributeCaseDiv.hasAttribute('data-itemid'), false, 'removeAttribute lowercases the name'); + +assert(attributeCaseDiv.toggleAttribute('Bar'), true, 'toggleAttribute adds a lowercase attribute'); +assert(attributeCaseDiv.getAttributeNames().join(' '), 'bar foo title', 'toggleAttribute lowercases the name'); +assert(attributeCaseDiv.toggleAttribute('BAR'), false, 'toggleAttribute removes a lowercase attribute'); +assert(attributeCaseDiv.hasAttribute('bar'), false, 'toggleAttribute lowercased the name again'); + +assert(attributeCaseDocument.querySelectorAll('[title]').length, 1, 'querySelectorAll matches the lowercase name'); + +const createdAttribute = attributeCaseDocument.createAttribute('Data-Foo'); +assert(createdAttribute.name, 'data-foo', 'createAttribute lowercases the name in an HTML document'); +attributeCaseDiv.setAttributeNode(createdAttribute); +assert(attributeCaseDiv.getAttribute('data-foo'), '', 'setAttributeNode uses the lowercased created name'); +assert(attributeCaseDiv.attributes.getNamedItem('TITLE').name, 'title', 'NamedNodeMap#getNamedItem lowercases the name'); +assert(attributeCaseDiv.dataset.itemId, undefined, 'data-itemId is parsed as data-itemid, not as a dataset entry'); + +assert(attributeCaseDiv.getAttribute(null), null, 'non string names are not lowercased'); +assert(attributeCaseDiv.hasAttribute(undefined), false, 'non string names do not throw'); +attributeCaseDiv.setAttribute(null, 'null-attribute'); +assert(attributeCaseDiv.getAttribute('null'), 'null-attribute', 'non string names are stored as strings'); + +// an attribute from another document keeps its name: the name is looked up as given before the +// ASCII lowercase one is used, which is the one case where this differs from browsers +const xmlAttribute = xmlDoc.ownerDocument.createAttribute('Foo'); +attributeCaseDiv.setAttributeNode(xmlAttribute); +assert(attributeCaseDiv.getAttributeNames().includes('Foo'), true, 'a non canonical attribute keeps its name'); +assert(attributeCaseDiv.getAttribute('Foo'), '', 'a non canonical attribute is found by its exact name'); +assert(attributeCaseDiv.hasAttribute('Foo'), true, 'hasAttribute finds a non canonical attribute by its exact name'); +assert(attributeCaseDiv.getAttribute('foo'), null, 'the lowercase name does not match a non canonical attribute'); +attributeCaseDiv.setAttribute('Foo', 'updated'); +assert(attributeCaseDiv.getAttribute('Foo'), 'updated', 'setAttribute updates a non canonical attribute by its exact name'); +attributeCaseDiv.removeAttribute('Foo'); +assert(attributeCaseDiv.hasAttribute('Foo'), false, 'a non canonical attribute is removed by its exact name'); diff --git a/test/shared/parse-json.js b/test/shared/parse-json.js index 1e15c991..57a8291f 100644 --- a/test/shared/parse-json.js +++ b/test/shared/parse-json.js @@ -39,6 +39,15 @@ assert(div.toString(), '
'); assert(div.querySelector('c-e').constructed, void 0, 'not constructed'); +const parsedCase = parseJSON([1,'div',2,'data-itemId','a',-1]); +assert(parsedCase.getAttributeNames().join(' '), 'data-itemid', 'restored HTML attributes are ASCII lowercase'); +assert(parsedCase.getAttribute('data-itemId'), 'a', 'restored HTML attributes are reachable with any case'); +assert(parsedCase.toString(), '
', 'restored HTML attributes serialize lowercase'); + +const parsedSVG = parseJSON([1,'div',1,'svg',2,'viewBox','0 0 1 1',-2]); +assert(parsedSVG.querySelector('svg').getAttributeNames().join(' '), 'viewBox', 'restored SVG attributes keep their case'); +assert(parsedSVG.querySelector('svg').getAttribute('viewBox'), '0 0 1 1', 'restored SVG attributes are reachable with their case'); + document.importNode(div, true); assert(div.querySelector('c-e').constructed, void 0, 'not constructed'); diff --git a/test/svg/element.js b/test/svg/element.js index 4f5259ca..e0e96857 100644 --- a/test/svg/element.js +++ b/test/svg/element.js @@ -46,4 +46,29 @@ assert(svg.getAttribute('class'), 'a b c'); svg.setAttribute('class', 'd e'); assert(svg.getAttribute('class'), 'd e'); -assert(svg.namespaceURI, 'http://www.w3.org/2000/svg'); \ No newline at end of file +assert(svg.namespaceURI, 'http://www.w3.org/2000/svg'); + +const {document: svgCaseDocument} = parseHTML('
x
'); +const svgCaseRoot = svgCaseDocument.querySelector('svg'); +const svgCaseText = svgCaseDocument.querySelector('text'); + +assert(svgCaseRoot.getAttributeNames().join(' '), 'viewBox', 'SVG attributes in HTML keep their case when parsed'); +assert(svgCaseRoot.getAttribute('viewBox'), '0 0 1 1', 'SVG attributes are reachable with their case'); +assert(svgCaseRoot.getAttribute('viewbox'), null, 'SVG attributes are not reachable lowercased'); +assert(svgCaseText.getAttribute('textLength'), '10', 'SVG children attributes keep their case too'); +assert(svgCaseText.getAttribute('textlength'), null, 'SVG children attributes stay case sensitive'); +assert(svgCaseRoot.toString(), 'x', 'SVG serialization keeps the case'); + +svgCaseRoot.setAttribute('viewBox', '0 0 2 2'); +assert(svgCaseRoot.getAttributeNames().join(' '), 'viewBox', 'setAttribute does not lowercase SVG attribute names'); +assert(svgCaseRoot.getAttribute('viewBox'), '0 0 2 2', 'setAttribute kept the SVG attribute case'); + +svgCaseRoot.removeAttribute('viewBox'); +assert(svgCaseRoot.hasAttributes(), false, 'removeAttribute does not lowercase SVG attribute names'); + +svgCaseRoot.innerHTML = 'x'; +assert(svgCaseRoot.innerHTML, 'x', 'innerHTML keeps SVG attribute names as they are'); +assert(svgCaseRoot.firstElementChild.getAttribute('textLength'), '10', 'innerHTML SVG attributes are reachable with their case'); + +svgCaseRoot.firstElementChild.innerHTML = 'y'; +assert(svgCaseRoot.innerHTML, 'y', 'nested innerHTML keeps SVG attribute names'); diff --git a/test/xml/document.js b/test/xml/document.js index deaa763c..5e20a2a9 100644 --- a/test/xml/document.js +++ b/test/xml/document.js @@ -19,3 +19,26 @@ document.documentElement.innerHTML = ` assert(document.querySelectorAll('Element').length, 2, 'case sesntivive 2'); assert(document.querySelectorAll('element').length, 0, 'case sesntivive 0'); + +const caseDocument = (new DOMParser).parseFromString('', 'text/xml'); +const caseRoot = caseDocument.documentElement; + +assert(caseRoot.getAttributeNames().join(' '), 'SomeAttr', 'XML attribute names keep their case when parsed'); +assert(caseRoot.getAttribute('SomeAttr'), 'a', 'XML attributes are reachable with their case'); +assert(caseRoot.getAttribute('someattr'), null, 'XML attributes are not reachable lowercased'); +assert(caseRoot.firstElementChild.getAttribute('AnotherAttr'), 'b', 'XML children attributes keep their case too'); + +caseRoot.setAttribute('OtherAttr', 'c'); +assert(caseRoot.getAttributeNames().join(' '), 'OtherAttr SomeAttr', 'setAttribute keeps the XML attribute case'); +assert(caseRoot.getAttribute('OtherAttr'), 'c', 'setAttribute stored the XML attribute case'); +assert(caseRoot.getAttribute('otherattr'), null, 'setAttribute did not lowercase the XML attribute'); +caseRoot.removeAttribute('SomeAttr'); +assert(caseRoot.getAttributeNames().join(' '), 'OtherAttr', 'removeAttribute keeps the XML attribute case'); + +assert(caseDocument.createAttribute('Data-Foo').name, 'Data-Foo', 'createAttribute keeps the case in an XML document'); +assert(caseDocument.toString(), '', 'XML serialization keeps the case'); + +const svgDocument = (new DOMParser).parseFromString('x', 'image/svg+xml'); +assert(svgDocument.querySelector('[viewBox]'), svgDocument.documentElement, 'SVG selectors stay case sensitive in an SVG document'); +assert(svgDocument.querySelector('text').getAttribute('textLength'), '10', 'SVG attribute names keep their case in an SVG document'); +assert(svgDocument.querySelector('text').getAttribute('textlength'), null, 'SVG attribute names are not lowercased in an SVG document'); diff --git a/types/esm/shared/parse-from-string.d.ts b/types/esm/shared/parse-from-string.d.ts index 1cda3495..65906a64 100644 --- a/types/esm/shared/parse-from-string.d.ts +++ b/types/esm/shared/parse-from-string.d.ts @@ -1,2 +1,2 @@ export function isNotParsing(): boolean; -export function parseFromString(document: any, isHTML: any, markupLanguage: any): any; +export function parseFromString(document: any, isHTML: any, markupLanguage: any, svg?: boolean): any; diff --git a/types/esm/shared/utils.d.ts b/types/esm/shared/utils.d.ts index 827f1de8..5ce41d76 100644 --- a/types/esm/shared/utils.d.ts +++ b/types/esm/shared/utils.d.ts @@ -1,5 +1,8 @@ export { $String as String }; +export function hasUpperCase(name: any): boolean; +export function asciiLowerCase(name: any): string; export function getEnd(node: any): any; +export function inSvg(node: any): boolean; export function ignoreCase({ ownerDocument }: { ownerDocument: any; }): any; diff --git a/worker.js b/worker.js index fa7dc792..eaeec2c2 100644 --- a/worker.js +++ b/worker.js @@ -1986,8 +1986,39 @@ const { const $String = String; +const allUpperCase = /[A-Z]/g; + +// ASCII uppercase only: cheaper than a regular expression, as this runs on every lookup +const hasUpperCase = name => { + if (typeof name !== 'string') + return false; + for (let i = 0; i < name.length; i++) { + const code = name.charCodeAt(i); + if (64 < code && 91 > code) + return true; + } + return false; +}; + +// https://infra.spec.whatwg.org/#ascii-lowercase +const asciiLowerCase = name => $String(name).replace( + allUpperCase, + character => character.toLowerCase() +); + const getEnd = node => node.nodeType === ELEMENT_NODE ? node[END] : node; +// an element is in SVG when it is one, or when it lives inside one: svg.innerHTML builds +// HTML class elements (a pre-existing limitation), so checking ownerSVGElement is not enough +const inSvg = node => { + while (node) { + if (node.ownerSVGElement !== undefined) + return true; + node = node.parentNode; + } + return false; +}; + const ignoreCase = ({ownerDocument}) => ownerDocument[MIME].ignoreCase; const knownAdjacent = (prev, next) => { @@ -2248,115 +2279,6 @@ class CustomElementRegistry { } } -const {Parser: Parser$1} = HTMLParser2; - -const append$2 = (self, node, active) => { - const end = self[END]; - node.parentNode = self; - knownBoundaries(end[PREV], node, end); - if (active && node.nodeType === ELEMENT_NODE) - connectedCallback(node); - return node; -}; - -const attribute = (element, end, attribute, value, active) => { - attribute[VALUE] = value; - attribute.ownerElement = element; - knownSiblings(end[PREV], attribute, end); - if (attribute.name === 'class') - element.className = value; - if (active) - attributeChangedCallback$1(element, attribute.name, null, value); -}; - -const parseFromString = (document, isHTML, markupLanguage) => { - const {active, registry} = document[CUSTOM_ELEMENTS]; - - let node = document; - let ownerSVGElement = null; - let parsingCData = false; - - const content = new Parser$1({ - // - onprocessinginstruction(name, data) { - if (name.toLowerCase() === '!doctype') - document.doctype = data.slice(name.length).trim(); - }, - - // - onopentag(name, attributes) { - let create = true; - if (isHTML) { - if (ownerSVGElement) { - node = append$2(node, document.createElementNS(SVG_NAMESPACE, name), active); - node.ownerSVGElement = ownerSVGElement; - create = false; - } - else if (name === 'svg' || name === 'SVG') { - ownerSVGElement = document.createElementNS(SVG_NAMESPACE, name); - node = append$2(node, ownerSVGElement, active); - create = false; - } - else if (active) { - const ce = name.includes('-') ? name : (attributes.is || ''); - if (ce && registry.has(ce)) { - const {Class} = registry.get(ce); - node = append$2(node, new Class, active); - delete attributes.is; - create = false; - } - } - } - - if (create) - node = append$2(node, document.createElement(name), false); - - let end = node[END]; - for (const name of keys(attributes)) - attribute(node, end, document.createAttribute(name), attributes[name], active); - }, - - // #text, #comment - oncomment(data) { append$2(node, document.createComment(data), active); }, - ontext(text) { - if (parsingCData) { - append$2(node, document.createCDATASection(text), active); - } else { - append$2(node, document.createTextNode(text), active); - } - }, - - // #cdata - oncdatastart() { parsingCData = true; }, - oncdataend() { parsingCData = false; }, - - // - onclosetag() { - if (isHTML && node === ownerSVGElement) - ownerSVGElement = null; - node = node.parentNode; - } - }, { - lowerCaseAttributeNames: false, - decodeEntities: true, - xmlMode: !isHTML - }); - - content.write(markupLanguage); - content.end(); - - return document; -}; - -const htmlClasses = new Map; - -const registerHTMLClass = (names, Class) => { - for (const name of [].concat(names)) { - htmlClasses.set(name, Class); - htmlClasses.set(name.toUpperCase(), Class); - } -}; - const loopSegment = ({[NEXT]: next, [END]: end}, json) => { while (next !== end) { switch (next.nodeType) { @@ -3043,6 +2965,122 @@ let Attr$1 = class Attr extends Node$1 { } }; +const {Parser: Parser$1} = HTMLParser2; + +const append$2 = (self, node, active) => { + const end = self[END]; + node.parentNode = self; + knownBoundaries(end[PREV], node, end); + if (active && node.nodeType === ELEMENT_NODE) + connectedCallback(node); + return node; +}; + +const attribute = (element, end, attribute, value, active) => { + attribute[VALUE] = value; + attribute.ownerElement = element; + knownSiblings(end[PREV], attribute, end); + if (attribute.name === 'class') + element.className = value; + if (active) + attributeChangedCallback$1(element, attribute.name, null, value); +}; + +const parseFromString = (document, isHTML, markupLanguage, svg = false) => { + const {active, registry} = document[CUSTOM_ELEMENTS]; + + let node = document; + let ownerSVGElement = null; + let parsingCData = false; + + const content = new Parser$1({ + // + onprocessinginstruction(name, data) { + if (name.toLowerCase() === '!doctype') + document.doctype = data.slice(name.length).trim(); + }, + + // + onopentag(name, attributes) { + let create = true; + let htmlAttributes = isHTML && !svg; + if (isHTML) { + if (ownerSVGElement) { + node = append$2(node, document.createElementNS(SVG_NAMESPACE, name), active); + node.ownerSVGElement = ownerSVGElement; + create = false; + htmlAttributes = false; + } + else if (name === 'svg' || name === 'SVG') { + ownerSVGElement = document.createElementNS(SVG_NAMESPACE, name); + node = append$2(node, ownerSVGElement, active); + create = false; + htmlAttributes = false; + } + else if (active) { + const ce = name.includes('-') ? name : (attributes.is || ''); + if (ce && registry.has(ce)) { + const {Class} = registry.get(ce); + node = append$2(node, new Class, active); + delete attributes.is; + create = false; + } + } + } + + if (create) + node = append$2(node, document.createElement(name), false); + + let end = node[END]; + // the HTML tokenizer lowercases attribute names, but SVG ones keep their case + // https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state + for (const name of keys(attributes)) { + const htmlName = htmlAttributes && hasUpperCase(name) ? asciiLowerCase(name) : name; + attribute(node, end, new Attr$1(document, htmlName), attributes[name], active); + } + }, + + // #text, #comment + oncomment(data) { append$2(node, document.createComment(data), active); }, + ontext(text) { + if (parsingCData) { + append$2(node, document.createCDATASection(text), active); + } else { + append$2(node, document.createTextNode(text), active); + } + }, + + // #cdata + oncdatastart() { parsingCData = true; }, + oncdataend() { parsingCData = false; }, + + // + onclosetag() { + if (isHTML && node === ownerSVGElement) + ownerSVGElement = null; + node = node.parentNode; + } + }, { + lowerCaseAttributeNames: false, + decodeEntities: true, + xmlMode: !isHTML + }); + + content.write(markupLanguage); + content.end(); + + return document; +}; + +const htmlClasses = new Map; + +const registerHTMLClass = (names, Class) => { + for (const name of [].concat(names)) { + htmlClasses.set(name, Class); + htmlClasses.set(name.toUpperCase(), Class); + } +}; + const isConnected = ({ownerDocument, parentNode}) => { while (parentNode) { if (parentNode === ownerDocument) @@ -7056,7 +7094,8 @@ const setInnerHtml = (node, html) => { const {constructor} = ownerDocument; const document = new constructor; document[CUSTOM_ELEMENTS] = ownerDocument[CUSTOM_ELEMENTS]; - const {childNodes} = parseFromString(document, ignoreCase(node), html); + // an SVG context keeps attribute names as they are, like it does when parsing a whole document + const {childNodes} = parseFromString(document, ignoreCase(node), html, inSvg(node)); node.replaceChildren(...childNodes.map(setOwnerDocument, ownerDocument)); }; @@ -7486,6 +7525,34 @@ const isVoid = ({localName, ownerDocument}) => { return ownerDocument[MIME].voidElements.test(localName); }; +const findAttribute = (element, name) => { + let next = element[NEXT]; + while (next.nodeType === ATTRIBUTE_NODE) { + if (next.name === name) + return next; + next = next[NEXT]; + } + return null; +}; + +// the name an element stores: only HTML elements in HTML documents hold ASCII lowercase names +const attributeName = (element, name) => ( + hasUpperCase(name) && ignoreCase(element) && !inSvg(element) ? + asciiLowerCase(name) : + name +); + +// https://dom.spec.whatwg.org/#concept-element-attributes-get-by-name +// every attribute created for an HTML element is ASCII lowercase, so the name is looked up as +// given first, and only a non canonical one (from another document, for instance) is kept as is +const attributeByName = (element, name) => { + const attribute = findAttribute(element, name); + if (attribute) + return attribute; + const storedName = attributeName(element, name); + return storedName === name ? null : findAttribute(element, storedName); +}; + // /** @@ -7641,20 +7708,17 @@ let Element$1 = class Element extends ParentNode { focus() { this.dispatchEvent(new GlobalEvent('focus')); } getAttribute(name) { - if (name === 'class') + const attribute = attributeByName(this, name); + if (!attribute) + return name === 'class' ? this.className : null; + // class is proxied through classList + if (attribute.name === 'class') return this.className; - const attribute = this.getAttributeNode(name); - return attribute && (ignoreCase(this) ? attribute.value : escape(attribute.value)); + return ignoreCase(this) ? attribute.value : escape(attribute.value); } getAttributeNode(name) { - let next = this[NEXT]; - while (next.nodeType === ATTRIBUTE_NODE) { - if (next.name === name) - return next; - next = next[NEXT]; - } - return null; + return attributeByName(this, name); } getAttributeNames() { @@ -7667,19 +7731,15 @@ let Element$1 = class Element extends ParentNode { return attributes; } - hasAttribute(name) { return !!this.getAttributeNode(name); } + hasAttribute(name) { return !!attributeByName(this, name); } hasAttributes() { return this[NEXT].nodeType === ATTRIBUTE_NODE; } removeAttribute(name) { - if (name === 'class' && this[CLASS_LIST]) + const attribute = attributeByName(this, name); + if (attribute) { + if (attribute.name === 'class' && this[CLASS_LIST]) this[CLASS_LIST].clear(); - let next = this[NEXT]; - while (next.nodeType === ATTRIBUTE_NODE) { - if (next.name === name) { - removeAttribute(this, next); - return; - } - next = next[NEXT]; + removeAttribute(this, attribute); } } @@ -7695,12 +7755,17 @@ let Element$1 = class Element extends ParentNode { } setAttribute(name, value) { - if (name === 'class') - this.className = value; - else { - const attribute = this.getAttributeNode(name); - if (attribute) + const attribute = attributeByName(this, name); + if (attribute) { + if (attribute.name === 'class') + this.className = value; + else attribute.value = value; + } + else { + name = attributeName(this, name); + if (name === 'class') + this.className = value; else setAttribute(this, new Attr$1(this.ownerDocument, name, value)); } @@ -19318,7 +19383,7 @@ let Document$1 = class Document extends NonElementParentNode { return this[EVENT_TARGET]; } - createAttribute(name) { return new Attr$1(this, name); } + createAttribute(name) { return new Attr$1(this, this[MIME].ignoreCase ? asciiLowerCase(name) : name); } createCDATASection(data) { return new CDATASection$1(this, data); } createComment(textContent) { return new Comment$2(this, textContent); } createDocumentFragment() { return new DocumentFragment$1(this); } @@ -19631,7 +19696,8 @@ const parseJSON = value => { case ATTRIBUTE_NODE: { const name = array[i++]; const value = typeof array[i] === 'string' ? array[i++] : ''; - const attr = new Attr$1(document, name, value); + const htmlName = inSvg(parentNode) || !hasUpperCase(name) ? name : asciiLowerCase(name); + const attr = new Attr$1(document, htmlName, value); attr.ownerElement = parentNode; knownSiblings(end[PREV], attr, end); break;