Skip to content

Commit

Permalink
Merge pull request #913 from capricorn86/task/911-domexception-is-thr…
Browse files Browse the repository at this point in the history
…own-for-substring-matching-attribute-selectors-with-whitespaces

#911@minor: Adds support for "i" and "s" modifiers to attribute query…
  • Loading branch information
capricorn86 committed May 12, 2023
2 parents a895911 + c0631e1 commit 0744805
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 63 deletions.
2 changes: 2 additions & 0 deletions packages/happy-dom/src/query-selector/ISelectorAttribute.ts
Expand Up @@ -2,4 +2,6 @@ export default interface ISelectorAttribute {
name: string;
operator: string | null;
value: string | null;
modifier: 's' | 'i' | null;
regExp: RegExp | null;
}
50 changes: 7 additions & 43 deletions packages/happy-dom/src/query-selector/SelectorItem.ts
Expand Up @@ -236,49 +236,13 @@ export default class SelectorItem {

priorityWeight += 10;

if (attribute.value !== null) {
if (elementAttribute.value === null) {
return null;
}

switch (attribute.operator) {
// [attribute~="value"] - Contains a specified word.
case null:
if (attribute.value !== elementAttribute.value) {
return null;
}
break;
// [attribute~="value"] - Contains a specified word.
case '~':
if (!elementAttribute.value.split(' ').includes(attribute.value)) {
return null;
}
break;
// [attribute|="value"] - Starts with the specified word.
case '|':
if (!new RegExp(`^${attribute.value}[- ]`).test(elementAttribute.value)) {
return null;
}
break;
// [attribute^="value"] - Begins with a specified value.
case '^':
if (!elementAttribute.value.startsWith(attribute.value)) {
return null;
}
break;
// [attribute$="value"] - Ends with a specified value.
case '$':
if (!elementAttribute.value.endsWith(attribute.value)) {
return null;
}
break;
// [attribute*="value"] - Contains a specified value.
case '*':
if (!elementAttribute.value.includes(attribute.value)) {
return null;
}
break;
}
if (
attribute.value !== null &&
(elementAttribute.value === null ||
(attribute.regExp && !attribute.regExp.test(elementAttribute.value)) ||
(!attribute.regExp && attribute.value !== elementAttribute.value))
) {
return null;
}
}

Expand Down
92 changes: 72 additions & 20 deletions packages/happy-dom/src/query-selector/SelectorParser.ts
Expand Up @@ -14,16 +14,17 @@ import ISelectorPseudo from './ISelectorPseudo';
* Group 6: Attribute name when there is a value using apostrophe (e.g. "attr1")
* Group 7: Attribute operator when using apostrophe (e.g. "~")
* Group 8: Attribute value when using apostrophe (e.g. "value1")
* Group 9: Attribute name when threre is a value not using apostrophe (e.g. "attr1")
* Group 10: Attribute operator when not using apostrophe (e.g. "~")
* Group 11: Attribute value when notusing apostrophe (e.g. "value1")
* Group 12: Pseudo name when arguments (e.g. "nth-child")
* Group 13: Arguments of pseudo (e.g. "2n + 1")
* Group 14: Pseudo name when no arguments (e.g. "empty")
* Group 15: Combinator.
* Group 9: Attribute modifier when using apostrophe (e.g. "i" or "s")
* Group 10: Attribute name when threre is a value not using apostrophe (e.g. "attr1")
* Group 11: Attribute operator when not using apostrophe (e.g. "~")
* Group 12: Attribute value when notusing apostrophe (e.g. "value1")
* Group 13: Pseudo name when arguments (e.g. "nth-child")
* Group 14: Arguments of pseudo (e.g. "2n + 1")
* Group 15: Pseudo name when no arguments (e.g. "empty")
* Group 16: Combinator.
*/
const SELECTOR_REGEXP =
/(\*)|([a-zA-Z0-9-]+)|#((?:[a-zA-Z0-9-_]|\\.)+)|\.((?:[a-zA-Z0-9-_]|\\.)+)|\[([a-zA-Z0-9-_]+)\]|\[([a-zA-Z0-9-_]+)([~|^$*]{0,1}) *= *["']{1}([^"']*)["']{1}\]|\[([a-zA-Z0-9-_]+)([~|^$*]{0,1}) *= *([^\]]*)\]|:([a-zA-Z-]+) *\(([^)]+)\)|:([a-zA-Z-]+)|([ ,+>]*)/g;
/(\*)|([a-zA-Z0-9-]+)|#((?:[a-zA-Z0-9-_]|\\.)+)|\.((?:[a-zA-Z0-9-_]|\\.)+)|\[([a-zA-Z0-9-_]+)\]|\[([a-zA-Z0-9-_]+) *([~|^$*]{0,1}) *= *["']{1}([^"']*)["']{1} *(s|i){0,1}\]|\[([a-zA-Z0-9-_]+) *([~|^$*]{0,1}) *= *([^\]]*)\]|:([a-zA-Z-]+) *\(([^)]+)\)|:([a-zA-Z-]+)|([ ,+>]*)/g;

/**
* Escaped Character RegExp.
Expand Down Expand Up @@ -117,30 +118,40 @@ export default class SelectorParser {
currentSelectorItem.attributes.push({
name: match[5].toLowerCase(),
operator: null,
value: null
value: null,
modifier: null,
regExp: null
});
} else if (match[6] && match[8] !== undefined) {
currentSelectorItem.attributes = currentSelectorItem.attributes || [];
currentSelectorItem.attributes.push({
name: match[6].toLowerCase(),
operator: match[7] || null,
value: match[8]
value: match[8],
modifier: match[9] || null,
regExp: this.getAttributeRegExp({
operator: match[7],
value: match[8],
modifier: match[9]
})
});
} else if (match[9] && match[11] !== undefined) {
} else if (match[10] && match[12] !== undefined) {
currentSelectorItem.attributes = currentSelectorItem.attributes || [];
currentSelectorItem.attributes.push({
name: match[9].toLowerCase(),
operator: match[10] || null,
value: match[11]
name: match[10].toLowerCase(),
operator: match[11] || null,
value: match[12],
modifier: null,
regExp: this.getAttributeRegExp({ operator: match[7], value: match[8] })
});
} else if (match[12] && match[13]) {
} else if (match[13] && match[14]) {
currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
currentSelectorItem.pseudos.push(this.getPseudo(match[12], match[13]));
} else if (match[14]) {
currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
currentSelectorItem.pseudos.push(this.getPseudo(match[14]));
currentSelectorItem.pseudos.push(this.getPseudo(match[13], match[14]));
} else if (match[15]) {
switch (match[15].trim()) {
currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
currentSelectorItem.pseudos.push(this.getPseudo(match[15]));
} else if (match[16]) {
switch (match[16].trim()) {
case ',':
currentSelectorItem = new SelectorItem({
combinator: SelectorCombinatorEnum.descendant
Expand Down Expand Up @@ -178,6 +189,47 @@ export default class SelectorParser {
return groups;
}

/**
* Returns attribute RegExp.
*
* @param attribute Attribute.
* @param attribute.value Attribute value.
* @param attribute.operator Attribute operator.
* @param attribute.modifier Attribute modifier.
* @returns Attribute RegExp.
*/
private static getAttributeRegExp(attribute: {
value?: string;
operator?: string;
modifier?: string;
}): RegExp | null {
const modifier = attribute.modifier === 'i' ? 'i' : '';

if (!attribute.operator || !attribute.value) {
return null;
}

switch (attribute.operator) {
// [attribute~="value"] - Contains a specified word.
case '~':
return new RegExp(`[- ]${attribute.value}|${attribute.value}[- ]`, modifier);
// [attribute|="value"] - Starts with the specified word.
case '|':
return new RegExp(`^${attribute.value}[- ]`, modifier);
// [attribute^="value"] - Begins with a specified value.
case '^':
return new RegExp(`^${attribute.value}`, modifier);
// [attribute$="value"] - Ends with a specified value.
case '$':
return new RegExp(`${attribute.value}$`, modifier);
// [attribute*="value"] - Contains a specified value.
case '*':
return new RegExp(`${attribute.value}`, modifier);
default:
return null;
}
}

/**
* Returns pseudo.
*
Expand Down
9 changes: 9 additions & 0 deletions packages/happy-dom/test/query-selector/QuerySelector.test.ts
Expand Up @@ -814,6 +814,15 @@ describe('QuerySelector', () => {
).toEqual(['div.n3', 'div.n6', 'div.n9']);
});

it('Returns all elements matching "a[href]:not([href *= "javascript:" i])".', () => {
const container = document.createElement('div');
container.innerHTML = `<a href="JAVASCRIPT:alert(1)">Link</a><a href="https://example.com">Link</a>`;
const elements = container.querySelectorAll('a[href]:not([href *= "javascript:" i])');

expect(elements.length).toBe(1);
expect(elements[0] === container.children[1]).toBe(true);
});

it('Returns all elements matching ":nth-child(odd)".', () => {
const container = document.createElement('div');
container.innerHTML = QuerySelectorNthChildHTML;
Expand Down

0 comments on commit 0744805

Please sign in to comment.