Skip to content

Commit 9b24547

Browse files
authored
feat: Add pseudos option (#757)
1 parent 0c1cdd8 commit 9b24547

File tree

7 files changed

+289
-571
lines changed

7 files changed

+289
-571
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ All options are optional.
144144
sometimes greatly improving querying performance. Disable this if your
145145
document can change in between queries with the same compiled selector.
146146
Default: `true`.
147+
- `pseudos`: A map of pseudo-selectors to functions or strings.
147148

148149
#### Custom Adapters
149150

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,5 @@ export function is<Node, ElementNode extends Node>(
198198
export default selectAll;
199199

200200
// Export filters, pseudos and aliases to allow users to supply their own.
201+
/** @deprecated Use the `pseudos` option instead. */
201202
export { filters, pseudos, aliases } from "./pseudo-selectors/index.js";

src/pseudo-selectors/index.ts

+19-9
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
* of `next()` and your code.
1313
* Pseudos should be used to implement simple checks.
1414
*/
15-
import boolbase from "boolbase";
1615
import type { CompiledQuery, InternalOptions, CompileToken } from "../types.js";
1716
import { parse, PseudoSelector } from "css-what";
1817
import { filters } from "./filters.js";
@@ -38,27 +37,38 @@ export function compilePseudoSelector<Node, ElementNode extends Node>(
3837

3938
return subselects[name](next, data, options, context, compileToken);
4039
}
41-
if (name in aliases) {
40+
41+
const userPseudo = options.pseudos?.[name];
42+
43+
const stringPseudo =
44+
typeof userPseudo === "string" ? userPseudo : aliases[name];
45+
46+
if (typeof stringPseudo === "string") {
4247
if (data != null) {
4348
throw new Error(`Pseudo ${name} doesn't have any arguments`);
4449
}
4550

4651
// The alias has to be parsed here, to make sure options are respected.
47-
const alias = parse(aliases[name]);
52+
const alias = parse(stringPseudo);
4853
return subselects["is"](next, alias, options, context, compileToken);
4954
}
55+
56+
if (typeof userPseudo === "function") {
57+
verifyPseudoArgs(userPseudo, name, data, 1);
58+
59+
return (elem) => userPseudo(elem, data) && next(elem);
60+
}
61+
5062
if (name in filters) {
5163
return filters[name](next, data as string, options, context);
5264
}
65+
5366
if (name in pseudos) {
5467
const pseudo = pseudos[name];
55-
verifyPseudoArgs(pseudo, name, data);
68+
verifyPseudoArgs(pseudo, name, data, 2);
5669

57-
return pseudo === boolbase.falseFunc
58-
? boolbase.falseFunc
59-
: next === boolbase.trueFunc
60-
? (elem) => pseudo(elem, options, data)
61-
: (elem) => pseudo(elem, options, data) && next(elem);
70+
return (elem) => pseudo(elem, options, data) && next(elem);
6271
}
72+
6373
throw new Error(`Unknown pseudo-class :${name}`);
6474
}

src/pseudo-selectors/pseudos.ts

+9-8
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { InternalOptions } from "../types.js";
44
export type Pseudo = <Node, ElementNode extends Node>(
55
elem: ElementNode,
66
options: InternalOptions<Node, ElementNode>,
7-
subselect?: ElementNode | string | null
7+
subselect?: string | null
88
) => boolean;
99

1010
// While filters are precompiled, pseudos get called when they are needed
@@ -92,16 +92,17 @@ export const pseudos: Record<string, Pseudo> = {
9292
},
9393
};
9494

95-
export function verifyPseudoArgs(
96-
func: Pseudo,
95+
export function verifyPseudoArgs<T extends Array<unknown>>(
96+
func: (...args: T) => boolean,
9797
name: string,
98-
subselect: PseudoSelector["data"]
98+
subselect: PseudoSelector["data"],
99+
argIndex: number
99100
): void {
100101
if (subselect === null) {
101-
if (func.length > 2) {
102-
throw new Error(`pseudo-selector :${name} requires an argument`);
102+
if (func.length > argIndex) {
103+
throw new Error(`Pseudo-class :${name} requires an argument`);
103104
}
104-
} else if (func.length === 2) {
105-
throw new Error(`pseudo-selector :${name} doesn't have any arguments`);
105+
} else if (func.length === argIndex) {
106+
throw new Error(`Pseudo-class :${name} doesn't have any arguments`);
106107
}
107108
}

src/types.ts

+12
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,18 @@ export interface Options<Node, ElementNode extends Node> {
127127
* @default false
128128
*/
129129
quirksMode?: boolean;
130+
/**
131+
* Pseudo-classes that override the default ones.
132+
*
133+
* Maps from names to either strings of functions.
134+
* - A string value is a selector that the element must match to be selected.
135+
* - A function is called with the element as its first argument, and optional
136+
* parameters second. If it returns true, the element is selected.
137+
*/
138+
pseudos?: Record<
139+
string,
140+
string | ((elem: ElementNode, value?: string | null) => boolean)
141+
>;
130142
/**
131143
* The last function in the stack, will be called with the last element
132144
* that's looked at.

test/api.ts

+18
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,24 @@ describe("API", () => {
127127

128128
delete CSSselect.pseudos["foovalue"];
129129
});
130+
131+
it("should throw if parameter is supplied for user-provided pseudo", () =>
132+
expect(() =>
133+
CSSselect.compile(":foovalue(boo)", {
134+
pseudos: { foovalue: "tag" },
135+
})
136+
).toThrow("doesn't have any arguments"));
137+
138+
it("should throw if no parameter is supplied for user-provided pseudo", () =>
139+
expect(() =>
140+
CSSselect.compile(":foovalue", {
141+
pseudos: {
142+
foovalue(_el, data) {
143+
return data != null;
144+
},
145+
},
146+
})
147+
).toThrow("requires an argument"));
130148
});
131149

132150
describe("unsatisfiable and universally valid selectors", () => {

0 commit comments

Comments
 (0)