Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/nodejs-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ jobs:
contents: read # to fetch code (actions/checkout)
checks: write # to create new checks (coverallsapp/github-action)

name: Node ${{ matrix.node == '20.19.0' && '20' || matrix.node }}
name: Node ${{ matrix.node }}
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
node:
- 20.19.0
- 20
- 22
- lts/*

Expand Down
10 changes: 9 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,13 @@ export default defineConfig([
...commonTypeScriptRules,
},
},
eslintConfigBiome
eslintConfigBiome,
{
rules: {
// Use the Number namespace required by the existing Biome configuration.
"unicorn/prefer-global-number-constants": "off",
// This module uses parser DOM nodes, which do not implement browser traversal APIs.
"unicorn/better-dom-traversing": "off",
},
},
]);
631 changes: 283 additions & 348 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"version": "2.1.0",
"author": "Felix Boehm <me@feedic.com>",
"funding": {
"url": "https://github.com/sponsors/fb55"
"url": "https://github.com/sponsors/fb55",
"type": "github"
Comment thread
fb55 marked this conversation as resolved.
},
"license": "BSD-2-Clause",
"sideEffects": false,
Expand Down Expand Up @@ -50,7 +51,7 @@
"devDependencies": {
"@biomejs/biome": "^2.5.15",
"@eslint/compat": "^2.1.1",
"@feedic/eslint-config": "^0.2.3",
"@feedic/eslint-config": "^0.5.0",
"@types/jest": "^30.0.0",
"@types/node": "^26.6.4",
"eslint": "^10.12.0",
Expand Down
32 changes: 18 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface Options extends CSSSelectOptions<AnyNode, Element> {
* @param selector Selector used to match elements.
* @param options Options that control this operation.
*/
// eslint-disable-next-line unicorn/consistent-boolean-name -- Preserve the existing exported function name.
export function is(
element: Element,
selector: string | ((element: Element) => boolean),
Expand All @@ -56,6 +57,7 @@ export function is(
* @param selector Selector used to match elements.
* @param options Options that control this operation.
*/
// eslint-disable-next-line unicorn/consistent-boolean-name -- Preserve the existing exported function name.
export function some(
elements: Element[],
selector: string | ((element: Element) => boolean),
Expand All @@ -80,6 +82,7 @@ function filterByPosition(
options: Options,
): Element[] {
const position =
// eslint-disable-next-line unicorn/prefer-number-coercion -- Preserve integer-prefix parsing of selector arguments.
typeof data === "string" ? Number.parseInt(data, 10) : Number.NaN;

switch (filter) {
Expand All @@ -98,9 +101,10 @@ function filterByPosition(
return Number.isFinite(position) &&
Math.abs(position) < elements.length
? [
position < 0
? elements[elements.length + position]
: elements[position],
elements[
// eslint-disable-next-line unicorn/no-unsafe-property-key -- The numeric positional index is guarded by finite and bounds checks.
position < 0 ? elements.length + position : position
],
]
: [];
}
Expand Down Expand Up @@ -296,14 +300,14 @@ export function select(
* @param root Element(s) to search from.
* @param selector Selector to look for.
* @param options Options for querying.
* @param queryForSelector Query multiple levels deep for the initial selector, even if it doesn't contain a traversal.
* @param shouldQueryForSelector Query multiple levels deep for the initial selector, even if it doesn't contain a traversal.
* @param totalLimit Overall cap on the number of collected results.
*/
function findFilterElements(
root: AnyNode | AnyNode[],
selector: Selector[],
options: Options,
queryForSelector: boolean,
shouldQueryForSelector: boolean,
totalLimit: number,
): Element[] {
const filterIndex = selector.findIndex(isFilter);
Expand Down Expand Up @@ -332,7 +336,7 @@ function findFilterElements(
? DomUtils.getChildren(root).filter(isTag)
: sub.length === 0
? (Array.isArray(root) ? root : [root]).filter(isTag)
: queryForSelector || sub.some(isTraversal)
: shouldQueryForSelector || sub.some(isTraversal)
? findElements(root, [sub], options, limit)
: filterElements(root, [sub], options);

Expand All @@ -345,9 +349,9 @@ function findFilterElements(
}

const remainingSelector = selector.slice(filterIndex + 1);
const remainingHasTraversal = remainingSelector.some(isTraversal);
const hasRemainingTraversal = remainingSelector.some(isTraversal);

if (remainingHasTraversal) {
if (hasRemainingTraversal) {
if (isTraversal(remainingSelector[0])) {
const { type } = remainingSelector[0];

Expand Down Expand Up @@ -394,7 +398,7 @@ function findFilterElements(
false,
totalLimit,
)
: remainingHasTraversal
: hasRemainingTraversal
? // Query existing elements to resolve traversal.
findElements(result, [remainingSelector], options, totalLimit)
: // If we don't have any more traversals, simply filter elements.
Expand All @@ -412,27 +416,27 @@ function findElements(
options: Options,
limit: number,
): Element[] {
const query: CompiledQuery = compileToken<AnyNode, Element>(
const isMatch: CompiledQuery = compileToken<AnyNode, Element>(
sel,
options,
root,
);

return find(root, query, limit);
return find(root, isMatch, limit);
}

function find(
root: AnyNode | AnyNode[],
query: CompiledQuery,
isMatch: CompiledQuery,
limit = Number.POSITIVE_INFINITY,
): Element[] {
const elements = prepareContext<AnyNode, Element>(
query.shouldTestNextSiblings ? appendNextSiblings(root) : root,
isMatch.shouldTestNextSiblings ? appendNextSiblings(root) : root,
adapter,
);

return DomUtils.find(
(node: AnyNode) => isTag(node) && query(node),
(node: AnyNode) => isTag(node) && isMatch(node),
elements,
true,
limit,
Expand Down
1 change: 1 addition & 0 deletions src/positionals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function getLimit(
data: string | null,
partLimit: number,
): number {
// eslint-disable-next-line unicorn/prefer-number-coercion -- Preserve integer-prefix parsing of selector arguments.
const parsedNumber = data == null ? Number.NaN : Number.parseInt(data, 10);

switch (filter) {
Expand Down
60 changes: 31 additions & 29 deletions test/sizzle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function getDOM(string_: string) {
const broken = (selector: string) =>
expect(() => select(selector, [])).toThrow(Error);

function matchesSelector(
function isSelectorMatch(
element: AnyNode,
selector: string,
options?: Options,
Expand All @@ -28,6 +28,7 @@ function matchesSelector(

describe("Sizzle", () => {
beforeEach(() => {
// eslint-disable-next-line unicorn/no-top-level-assignment-in-function -- Reset the shared fixture before each test.
document = loadDocument();
});

Expand All @@ -42,7 +43,7 @@ describe("Sizzle", () => {
).toStrictEqual([]);
const form = document.getElementById("form");
// Empty string passed to matchesSelector does not match
expect(matchesSelector(form, "")).toBe(false);
expect(isSelectorMatch(form, "")).toBe(false);
// Empty selector returns an empty array
expect(select(" ", document)).toHaveLength(0);
// Empty selector returns an empty array
Expand All @@ -51,9 +52,11 @@ describe("Sizzle", () => {
// Select all
expect(select("*", document).length >= 30).toBe(true);
const all = select("*", document);
const good = all.every((element: AnyNode) => element.nodeType !== 8);
const hasNoComments = all.every(
(element: AnyNode) => element.nodeType !== 8,
);
// Select all elements, no comment nodes
expect(good).toBe(true);
expect(hasNoComments).toBe(true);
// Element Selector
t("html", ["html"]);
// Element Selector
Expand Down Expand Up @@ -199,7 +202,7 @@ describe("Sizzle", () => {
// Attribute selector filter with ID
expect(
select("component", xml).filter((node) =>
matchesSelector(node, "#seite1"),
isSelectorMatch(node, "#seite1"),
),
).toHaveLength(1);
// Descendent selector and dir caching
Expand All @@ -208,7 +211,7 @@ describe("Sizzle", () => {
const xmlOptions = { xmlMode: true };
const tag = xml.findLast((t) => t.type === "tag") as Element;
expect(
matchesSelector(tag, String.raw`soap\:Envelope`, xmlOptions),
isSelectorMatch(tag, String.raw`soap\:Envelope`, xmlOptions),
).toBe(true);

xml = parseDocument(
Expand Down Expand Up @@ -352,7 +355,8 @@ describe("Sizzle", () => {
],
);

for (const node of getDOM(String.raw`<a id='backslash\foo'></a>`)) {
const backslashNodes = getDOM(String.raw`<a id='backslash\foo'></a>`);
for (const node of backslashNodes) {
DomUtils.appendChild(document.getElementById("form"), node);
}
// ID Selector contains backslash
Expand Down Expand Up @@ -433,14 +437,14 @@ describe("Sizzle", () => {
expect(select(".e", div)).toStrictEqual([div.children[0], lastChild]);

// .null does not match an element with no class
expect(matchesSelector(div, ".null")).toBe(false);
expect(isSelectorMatch(div, ".null")).toBe(false);
// .null does not match an element with no class
expect(matchesSelector(div.children[0], ".null div")).toBe(false);
expect(isSelectorMatch(div.children[0], ".null div")).toBe(false);
div.attribs["class"] = "null";
// .null matches element with class 'null'
expect(matchesSelector(div, ".null")).toBe(true);
expect(isSelectorMatch(div, ".null")).toBe(true);
// Caching system respects DOM changes
expect(matchesSelector(div.children[0], ".null div")).toBe(true);
expect(isSelectorMatch(div.children[0], ".null div")).toBe(true);
lastChild.attribs["class"] += " hasOwnProperty toString";
// Classes match Object.prototype properties
expect(select(".e.hasOwnProperty.toString", div)).toStrictEqual([
Expand Down Expand Up @@ -714,6 +718,7 @@ describe("Sizzle", () => {
// Attribute Equals
t("#qunit-fixture a[rel=bookmark]", ["simon1"]);
// Attribute Equals
// eslint-disable-next-line unicorn/prefer-https -- Match the original HTTP fixture exactly.
t("#qunit-fixture a[href='http://www.google.com/']", ["google"]);
// Attribute Equals
t("#qunit-fixture a[ rel = 'bookmark' ]", ["simon1"]);
Expand Down Expand Up @@ -805,20 +810,20 @@ describe("Sizzle", () => {
opt.attribs["test"] = "";

// Attribute Is Not Equal Matches
expect(matchesSelector(opt, "[id*=option1][type!=checkbox]")).toBe(
expect(isSelectorMatch(opt, "[id*=option1][type!=checkbox]")).toBe(
true,
);
// Attribute With No Quotes Contains Matches
expect(matchesSelector(opt, "[id*=option1]")).toBe(true);
expect(isSelectorMatch(opt, "[id*=option1]")).toBe(true);
// Attribute With No Quotes No Content Matches
expect(matchesSelector(opt, "[test=]")).toBe(true);
expect(isSelectorMatch(opt, "[test=]")).toBe(true);
// Attribute with empty string value does not match startsWith selector (^=)
expect(matchesSelector(opt, "[test^='']")).toBe(false);
expect(isSelectorMatch(opt, "[test^='']")).toBe(false);
// Attribute With No Quotes Equals Matches
expect(matchesSelector(opt, "[id=option1a]")).toBe(true);
expect(isSelectorMatch(opt, "[id=option1a]")).toBe(true);
// Attribute With No Quotes Href Contains Matches
expect(
matchesSelector(document.getElementById("simon1"), "a[href*=#]"),
isSelectorMatch(document.getElementById("simon1"), "a[href*=#]"),
).toBe(true);

// Empty values
Expand All @@ -842,20 +847,20 @@ describe("Sizzle", () => {
input.attribs["title"] = "Don't click me";

// Quote within attribute value does not mess up tokenizer
expect(matchesSelector(input, 'input[title="Don\'t click me"]')).toBe(
expect(isSelectorMatch(input, 'input[title="Don\'t click me"]')).toBe(
true,
);

// See jQuery #12303
input.attribs["data-pos"] = ":first";
// POS within attribute value is treated as an attribute value
expect(
matchesSelector(input, String.raw`input[data-pos=\:first]`),
isSelectorMatch(input, String.raw`input[data-pos=\:first]`),
).toBe(true);
// POS within attribute value is treated as an attribute value
expect(matchesSelector(input, "input[data-pos=':first']")).toBe(true);
expect(isSelectorMatch(input, "input[data-pos=':first']")).toBe(true);
// POS within attribute value after pseudo is treated as an attribute value
expect(matchesSelector(input, ":input[data-pos=':first']")).toBe(true);
expect(isSelectorMatch(input, ":input[data-pos=':first']")).toBe(true);
delete input.attribs["data-pos"];

/*
Expand Down Expand Up @@ -915,7 +920,7 @@ describe("Sizzle", () => {
t(String.raw`input[data-attr='\04e00']`, ["attrbad_unicode"]);

document.getElementById("attrbad_unicode").attribs["data-attr"] =
"\uD834\uDF06A";
"\u{1D306}A";
/*
* It was too much code to fix Safari 5.x Supplemental Plane crashes (see ba5f09fa404379a87370ec905ffa47f8ac40aaa3)
* Long numeric escape (non-BMP)
Expand Down Expand Up @@ -1356,7 +1361,7 @@ describe("Sizzle", () => {

const select1 = document.getElementById("select1");
// Has Option Matches
expect(matchesSelector(select1, ":has(option)")).toBe(true);
expect(isSelectorMatch(select1, ":has(option)")).toBe(true);

// Empty string contains
expect(select("a:contains('')", document).length).toBeTruthy();
Expand All @@ -1380,20 +1385,17 @@ describe("Sizzle", () => {

for (const type of ["button", "submit", "reset"]) {
const els = getDOM(
"<input id='input_%' type='%'/><button id='button_%' type='%'>test</button>".replace(
/%/g,
type,
),
`<input id='input_${type}' type='${type}'/><button id='button_${type}' type='${type}'>test</button>`,
);
for (const element of els) DomUtils.appendChild(temporary, element);

// Input Buttons :${type}
t(`#tmp_input :${type}`, [`input_${type}`, `button_${type}`]);

// Input Matches :${type}
expect(matchesSelector(els[0], `:${type}`)).toBe(true);
expect(isSelectorMatch(els[0], `:${type}`)).toBe(true);
// Button Matches :${type}
expect(matchesSelector(els[1], `:${type}`)).toBe(true);
expect(isSelectorMatch(els[1], `:${type}`)).toBe(true);
}

document.body.children.pop();
Expand Down
3 changes: 2 additions & 1 deletion test/tools/sizzle-testinit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function getDocument(file: string): SimpleDocument {
document.createTextNode = (content: string) => new Text(content);
document.createElement = (name: string) =>
new Element(name.toLocaleLowerCase(), {});
[document.body] = DomUtils.getElementsByTagName("body", document, true, 1);
document.body = DomUtils.getElementsByTagName("body", document, true, 1)[0];
document.documentElement = document.find(isTag) as Element;

return document;
Expand All @@ -38,6 +38,7 @@ function getDocument(file: string): SimpleDocument {
let document = getDocument("sizzle.html");

export function loadDocument(): SimpleDocument {
// eslint-disable-next-line unicorn/no-top-level-assignment-in-function -- Reset the shared fixture before each test.
document = getDocument("sizzle.html");
return document;
}
Expand Down
Loading