Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #581 - coerce attribute names to lower case in HTML mode #3306

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
8 changes: 8 additions & 0 deletions src/__fixtures__/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export const vegetables = [
'</ul>',
].join('');

export const meats = [
'<ul id="meats">',
'<li class="beef" COOKED="mediumrare">Beef</li>',
'<li class="chicken">Chicken</li>',
'<li class="pork">Pork</li>',
'</ul>',
].join('');

export const divcontainers = [
'<div class="container">',
'<div class="inner">First</div>',
Expand Down
54 changes: 51 additions & 3 deletions src/api/attributes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
chocolates,
inputs,
mixedText,
meats,
} from '../__fixtures__/fixtures.js';

function withClass(attr: string) {
Expand Down Expand Up @@ -42,6 +43,34 @@ describe('$(...)', () => {
expect(attr).toBe('autofocus');
});

it('(valid key) should get uppercase attr with lowercase name in HTML mode', () => {
const $casetest = load(meats);
const $meats = $casetest('.beef');
expect($meats.attr('COOKED')).toBe('mediumrare');
expect($meats.attr('cooked')).toBe('mediumrare');
});

it('(valid key) should get lowercase attr with uppercase name in HTML mode', () => {
const $casetest = load(meats);
const $meats = $casetest('.beef');
expect($meats.attr('CLASS')).toBe('beef');
expect($meats.attr('class')).toBe('beef');
});

it('(valid key) should get uppercase attr with uppercase name only in XML mode', () => {
const $casetest = load(meats, { xml: true });
const $meats = $casetest('[class="beef"]');
expect($meats.attr('COOKED')).toBe('mediumrare');
expect($meats.attr('cooked')).toBeUndefined();
});

it('(valid key) should get lowercase attr with lowercase name only in XML mode', () => {
const $casetest = load(meats, { xml: true });
const $meats = $casetest('[class="beef"]');
expect($meats.attr('CLASS')).toBeUndefined();
expect($meats.attr('class')).toBe('beef');
});

it('(key, value) : should set one attr', () => {
const $pear = $('.pear').attr('id', 'pear');
expect($('#pear')).toHaveLength(1);
Expand All @@ -65,6 +94,20 @@ describe('$(...)', () => {
expect($src[0]).toBeUndefined();
});

it('(key, value) should save uppercase attr name as lowercase in HTML mode', () => {
const $casetest = load(meats);
const $meats = $casetest('.beef').attr('USDA', 'choice');
expect($meats.attr('USDA')).toBe('choice');
expect($meats.attr('usda')).toBe('choice');
});

it('(key, value) should save uppercase attr name as uppercase in XML mode', () => {
const $casetest = load(meats, { xml: true });
const $meats = $casetest('[class="beef"]').attr('USDA', 'choice');
expect($meats.attr('USDA')).toBe('choice');
expect($meats.attr('usda')).toBeUndefined();
});

it('(map) : object map should set multiple attributes', () => {
$('.apple').attr({
id: 'apple',
Expand Down Expand Up @@ -168,19 +211,24 @@ describe('$(...)', () => {
});

it('(chaining) setting value and calling attr returns result', () => {
const pearAttr = $('.pear').attr('fizz', 'buzz').attr('fizz');
expect(pearAttr).toBe('buzz');
});

it('(chaining) overwriting value and calling attr returns result', () => {
const pearAttr = $('.pear').attr('foo', 'bar').attr('foo');
expect(pearAttr).toBe('bar');
});

it('(chaining) setting attr to null returns a $', () => {
const $pear = $('.pear').attr('foo', null);
const $pear = $('.pear').attr('bar', null);
expect($pear).toBeInstanceOf($);
});

it('(chaining) setting attr to undefined returns a $', () => {
const $pear = $('.pear').attr('foo', undefined);
const $pear = $('.pear').attr('bar', undefined);
expect($('.pear')).toHaveLength(1);
expect($('.pear').attr('foo')).toBeUndefined();
expect($('.pear').attr('bar')).toBeUndefined();
expect($pear).toBeInstanceOf($);
});

Expand Down
41 changes: 31 additions & 10 deletions src/api/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,26 @@ function getAttr(
return elem.attribs;
}

if (hasOwn.call(elem.attribs, name)) {
// Coerce attribute names to lowercase to match load() and setAttr() behavior (HTML only)
const nameToUse = xmlMode ? name : name.toLowerCase();

if (hasOwn.call(elem.attribs, nameToUse)) {
// Get the (decoded) attribute
return !xmlMode && rboolean.test(name) ? name : elem.attribs[name];
return !xmlMode && rboolean.test(nameToUse)
? nameToUse
: elem.attribs[nameToUse];
}

// Mimic the DOM and return text content as value for `option's`
if (elem.name === 'option' && name === 'value') {
if (elem.name === 'option' && nameToUse === 'value') {
return text(elem.children);
}

// Mimic DOM with default value for radios/checkboxes
if (
elem.name === 'input' &&
(elem.attribs['type'] === 'radio' || elem.attribs['type'] === 'checkbox') &&
name === 'value'
nameToUse === 'value'
) {
return 'on';
}
Expand All @@ -86,12 +91,21 @@ function getAttr(
* @param el - The element to set the attribute on.
* @param name - The attribute's name.
* @param value - The attribute's value.
* @param xmlMode - True if running in XML mode.
*/
function setAttr(el: Element, name: string, value: string | null) {
function setAttr(
el: Element,
name: string,
value: string | null,
xmlMode?: boolean
) {
// Coerce attr names to lowercase to match load() behavior (HTML only)
const nameToUse = xmlMode ? name : name.toLowerCase();

if (value === null) {
removeAttribute(el, name);
removeAttribute(el, nameToUse);
} else {
el.attribs[name] = `${value}`;
el.attribs[nameToUse] = `${value}`;
}
}

Expand Down Expand Up @@ -197,7 +211,14 @@ export function attr<T extends AnyNode>(
}
}
return domEach(this, (el, i) => {
if (isTag(el)) setAttr(el, name, value.call(el, i, el.attribs[name]));
if (isTag(el)) {
setAttr(
el,
name,
value.call(el, i, el.attribs[name]),
this.options.xmlMode
);
}
});
}
return domEach(this, (el) => {
Expand All @@ -206,10 +227,10 @@ export function attr<T extends AnyNode>(
if (typeof name === 'object') {
for (const objName of Object.keys(name)) {
const objValue = name[objName];
setAttr(el, objName, objValue);
setAttr(el, objName, objValue, this.options.xmlMode);
}
} else {
setAttr(el, name as string, value as string);
setAttr(el, name as string, value as string, this.options.xmlMode);
}
});
}
Expand Down