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
1 change: 1 addition & 0 deletions .github/workflows/nodejs-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
- 20
- 22
- 24
- 26
- lts/*

steps:
Expand Down
6 changes: 6 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,10 @@ export default defineConfig([
},
},
eslintConfigBiome,
{
rules: {
// This module uses parser DOM nodes, which do not implement browser traversal APIs.
"unicorn/better-dom-traversing": "off",
},
},
]);
422 changes: 225 additions & 197 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"devDependencies": {
"@biomejs/biome": "^2.5.15",
"@eslint/compat": "^2.1.1",
"@feedic/eslint-config": "^0.4.1",
"@feedic/eslint-config": "^0.5.0",
Comment thread
fb55 marked this conversation as resolved.
"@types/node": "^26.6.4",
"eslint": "^10.12.0",
"eslint-config-biome": "^2.1.3",
Expand Down
6 changes: 3 additions & 3 deletions src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { compileGeneralSelector } from "./general.js";
import { getElementParent } from "./helpers/querying.js";
import {
getQuality,
includesScopePseudo,
hasScopePseudo,
isTraversal,
sortRules,
} from "./helpers/selectors.js";
Expand All @@ -28,7 +28,7 @@ const SCOPE_TOKEN: Selector = {

/*
* CSS 4 Spec (Draft): 3.4.1. Absolutizing a Relative Selector
* http://www.w3.org/TR/selectors4/#absolutizing
* https://www.w3.org/TR/selectors4/#absolutizing
*/
function absolutize<Node, ElementNode extends Node>(
token: InternalSelector[][],
Expand All @@ -50,7 +50,7 @@ function absolutize<Node, ElementNode extends Node>(
t[0].type !== SelectorType.Descendant
) {
// Don't continue in else branch
} else if (hasContext && !t.some(includesScopePseudo)) {
} else if (hasContext && !t.some(hasScopePseudo)) {
t.unshift(DESCENDANT_TOKEN);
} else {
continue;
Expand Down
20 changes: 11 additions & 9 deletions src/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function compileGeneralSelector<Node, ElementNode extends Node>(
name = name.toLowerCase();
}

return function tag(element: ElementNode): boolean {
return function isTag(element: ElementNode): boolean {
return adapter.getName(element) === name && next(element);
};
}
Expand All @@ -88,7 +88,7 @@ export function compileGeneralSelector<Node, ElementNode extends Node>(
cacheResults === false ||
typeof WeakMap === "undefined"
) {
return function descendant(element: ElementNode): boolean {
return function isDescendant(element: ElementNode): boolean {
let current: ElementNode | null = element;

while ((current = getElementParent(current, adapter))) {
Expand All @@ -106,7 +106,7 @@ export function compileGeneralSelector<Node, ElementNode extends Node>(
ElementNode,
{ matches: boolean }
>();
return function cachedDescendant(element: ElementNode): boolean {
return function isCachedDescendant(element: ElementNode): boolean {
let current: ElementNode | null = element;
let result: { matches: boolean } | undefined;

Expand All @@ -133,7 +133,9 @@ export function compileGeneralSelector<Node, ElementNode extends Node>(
}
case "_flexibleDescendant": {
// Include element itself, only used while querying an array
return function flexibleDescendant(element: ElementNode): boolean {
return function isFlexibleDescendant(
element: ElementNode,
): boolean {
let current: ElementNode | null = element;

do {
Expand All @@ -147,20 +149,20 @@ export function compileGeneralSelector<Node, ElementNode extends Node>(
};
}
case SelectorType.Parent: {
return function parent(element: ElementNode): boolean {
return function isParent(element: ElementNode): boolean {
return adapter
.getChildren(element)
.some((element) => adapter.isTag(element) && next(element));
};
}
case SelectorType.Child: {
return function child(element: ElementNode): boolean {
return function isChild(element: ElementNode): boolean {
const parent = getElementParent(element, adapter);
return parent !== null && next(parent);
};
}
case SelectorType.Sibling: {
return function sibling(element: ElementNode): boolean {
return function isSibling(element: ElementNode): boolean {
const siblings = adapter.getSiblings(element);

for (const currentSibling of siblings) {
Expand All @@ -177,14 +179,14 @@ export function compileGeneralSelector<Node, ElementNode extends Node>(
}
case SelectorType.Adjacent: {
if (adapter.prevElementSibling) {
return function adjacent(element: ElementNode): boolean {
return function isAdjacent(element: ElementNode): boolean {
// biome-ignore lint/style/noNonNullAssertion: checked by if statement
const previous = adapter.prevElementSibling!(element);
return previous != null && next(previous);
};
}

return function adjacent(element: ElementNode): boolean {
return function isAdjacent(element: ElementNode): boolean {
const siblings = adapter.getSiblings(element);
let lastElement: ElementNode | undefined;

Expand Down
12 changes: 6 additions & 6 deletions src/helpers/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ import { getElementParent } from "./querying.js";
* @param options Configuration object for cache behavior.
* @param options.adapter Adapter implementation used for DOM access.
* @param options.cacheResults Whether results should be memoized by input root.
* @param matches Compiled matcher function to wrap with caching.
* @param isMatch Compiled matcher function to wrap with caching.
*/
export function cacheParentResults<Node, ElementNode extends Node>(
next: CompiledQuery<ElementNode>,
{ adapter, cacheResults }: InternalOptions<Node, ElementNode>,
matches: (element: ElementNode) => boolean,
isMatch: (element: ElementNode) => boolean,
): CompiledQuery<ElementNode> {
if (cacheResults === false || typeof WeakMap === "undefined") {
return (element) => next(element) && matches(element);
return (element) => next(element) && isMatch(element);
}

// Use a cache to avoid re-checking children of an element.
Expand All @@ -30,10 +30,10 @@ export function cacheParentResults<Node, ElementNode extends Node>(
const resultCache = new WeakMap<Node, boolean>();

function addResultToCache(element: ElementNode) {
const result = matches(element);
const isMatched = isMatch(element);

resultCache.set(element, result);
return result;
resultCache.set(element, isMatched);
return isMatched;
}

return function cachedMatcher(element) {
Expand Down
1 change: 1 addition & 0 deletions src/helpers/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { InternalOptions } from "../types.js";
*
* This is used when compiling nested selectors (e.g. inside `:is`, `:not`,
* `:nth-child(… of S)`) so that the parent compilation state doesn't leak.
* @param options The parent compilation options.
*/
export function copyOptions<Node, ElementNode extends Node>(
options: InternalOptions<Node, ElementNode>,
Expand Down
5 changes: 3 additions & 2 deletions src/helpers/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable unicorn/prefer-includes-over-repeated-comparisons -- Keep selector quality comparisons allocation free. */
import {
AttributeAction,
type AttributeSelector,
Expand Down Expand Up @@ -126,11 +127,11 @@ export function getQuality(token: InternalSelector): number {
* Check whether a token or nested token includes `:scope`.
* @param t Selector token under inspection.
*/
export function includesScopePseudo(t: InternalSelector): boolean {
export function hasScopePseudo(t: InternalSelector): boolean {
return (
t.type === SelectorType.Pseudo &&
(t.name === "scope" ||
(Array.isArray(t.data) &&
t.data.some((data) => data.some(includesScopePseudo))))
t.data.some((data) => data.some(hasScopePseudo))))
);
}
7 changes: 4 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import type {
Query,
} from "./types.js";

const defaultEquals = <Node>(a: Node, b: Node) => a === b;
const isEqualDefault = <Node>(a: Node, b: Node) => a === b;
const defaultOptions: InternalOptions<DomHandlerNode, DomHandlerElement> = {
adapter: { ...DomUtils, isTag },
equals: defaultEquals,
equals: isEqualDefault,
};

function convertOptionFormats<Node, ElementNode extends Node>(
Expand All @@ -34,7 +34,7 @@ function convertOptionFormats<Node, ElementNode extends Node>(
// @ts-expect-error Same as above.
finalOptions.adapter ??= defaultOptions.adapter;
// @ts-expect-error `equals` does not exist on `Options`
finalOptions.equals ??= finalOptions.adapter?.equals ?? defaultEquals;
finalOptions.equals ??= finalOptions.adapter?.equals ?? isEqualDefault;

return finalOptions as InternalOptions<Node, ElementNode>;
}
Expand Down Expand Up @@ -207,6 +207,7 @@ export const selectOne: <Node, ElementNode extends Node>(
* @see compile for supported selector queries.
* @returns Whether the element matches the query.
*/
// eslint-disable-next-line unicorn/consistent-boolean-name -- Preserve the existing exported function name.
export function is<Node, ElementNode extends Node>(
element: ElementNode,
query: Query<ElementNode>,
Expand Down
17 changes: 10 additions & 7 deletions src/pseudo-selectors/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { CompiledQuery, CompileToken, InternalOptions } from "../types.js";
* @param tag - Lowercased subtags of the element's language value.
* @param range - Lowercased subtags of the language range to match against.
*/
function extendedFilter(tag: string[], range: string[]): boolean {
function isExtendedFilter(tag: string[], range: string[]): boolean {
if (range[0] !== "*" && range[0] !== tag[0]) return false;

let tagIndex = 1;
Expand Down Expand Up @@ -43,10 +43,13 @@ export type Filter = <Node, ElementNode extends Node>(
compileToken?: CompileToken<Node, ElementNode>,
) => CompiledQuery<ElementNode>;

function compileNth(reverse: boolean, ofType: boolean): Filter {
function compileNth(
shouldCountFromEnd: boolean,
shouldCountSameType: boolean,
): Filter {
return function nth(next, rule, options, context, compileToken) {
const { adapter, equals } = options;
const ofMatch = ofType ? null : rule.match(nthOfRegex);
const ofMatch = shouldCountSameType ? null : rule.match(nthOfRegex);
const nthCheck = getNCheck(ofMatch ? ofMatch[1].trim() : rule);

if (nthCheck === boolbase.falseFunc) return boolbase.falseFunc;
Expand All @@ -72,12 +75,12 @@ function compileNth(reverse: boolean, ofType: boolean): Filter {
const shouldCount = ofSelector
? (_element: ElementNode, sibling: ElementNode) =>
ofSelector(sibling)
: ofType
: shouldCountSameType
? (element: ElementNode, sibling: ElementNode) =>
adapter.getName(sibling) === adapter.getName(element)
: boolbase.trueFunc;

if (reverse) {
if (shouldCountFromEnd) {
return function nthLast(element) {
if (ofSelector && !ofSelector(element)) return false;
const siblings = adapter.getSiblings(element);
Expand Down Expand Up @@ -186,7 +189,7 @@ export const filters: Record<string, Filter> = {

const tag = value.toLowerCase().split("-");
return (
ranges.some((r) => extendedFilter(tag, r)) &&
ranges.some((r) => isExtendedFilter(tag, r)) &&
next(element)
);
}
Expand Down Expand Up @@ -222,7 +225,7 @@ function dynamicStatePseudo(
return boolbase.falseFunc;
}

return function active(element) {
return function isActive(element) {
return filterFunction(element) && next(element);
};
};
Expand Down
11 changes: 7 additions & 4 deletions src/pseudo-selectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,17 @@ export function compilePseudoSelector<Node, ElementNode extends Node>(
const { name, data } = selector;

if (Array.isArray(data)) {
if (!(name in subselects)) {
if (!Object.hasOwn(subselects, name)) {
throw new Error(`Unknown pseudo-class :${name}(${data})`);
}

return subselects[name](next, data, options, context, compileToken);
}

const userPseudo = options.pseudos?.[name];
const userPseudo =
options.pseudos && Object.hasOwn(options.pseudos, name)
? options.pseudos[name]
: undefined;

const stringPseudo =
typeof userPseudo === "string" ? userPseudo : aliases[name];
Expand All @@ -66,7 +69,7 @@ export function compilePseudoSelector<Node, ElementNode extends Node>(
return (element) => userPseudo(element, data) && next(element);
}

if (name in filters) {
if (Object.hasOwn(filters, name)) {
return filters[name](
next,
data as string,
Expand All @@ -76,7 +79,7 @@ export function compilePseudoSelector<Node, ElementNode extends Node>(
);
}

if (name in pseudos) {
if (Object.hasOwn(pseudos, name)) {
const pseudo = pseudos[name];
verifyPseudoArguments(pseudo, name, data, 2);

Expand Down
12 changes: 6 additions & 6 deletions src/pseudo-selectors/pseudos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type Pseudo = <Node, ElementNode extends Node>(
* line feeds.
* @see {@link https://www.w3.org/TR/css-text-3/#white-space}
*/
const isDocumentWhiteSpace = /^[ \t\r\n]*$/;
const documentWhitespaceRegex = /^[ \t\r\n]*$/;

// While filters are precompiled, pseudos get called when they are needed
/** Runtime pseudo selector implementations. */
Expand All @@ -26,7 +26,7 @@ export const pseudos: Record<string, Pseudo> = {
// Then, check that the text content is only whitespace.
children.every((element) =>
// FIXME: `getText` call is potentially expensive.
isDocumentWhiteSpace.test(adapter.getText(element)),
documentWhitespaceRegex.test(adapter.getText(element)),
)
);
},
Expand Down Expand Up @@ -116,22 +116,22 @@ export const pseudos: Record<string, Pseudo> = {

/**
* Validate pseudo selector argument arity.
* @param pseudoClassCondition Pseudo-function implementation to wrap.
* @param isPseudoClassCondition Pseudo-function implementation to wrap.
* @param name Name of the pseudo selector.
* @param subselect Subselector passed to the pseudo-function.
* @param argumentIndex Index of the argument parser to apply.
*/
export function verifyPseudoArguments<T extends unknown[]>(
pseudoClassCondition: (...parameters: T) => boolean,
isPseudoClassCondition: (...parameters: T) => boolean,
name: string,
subselect: PseudoSelector["data"],
argumentIndex: number,
): void {
if (subselect === null) {
if (pseudoClassCondition.length > argumentIndex) {
if (isPseudoClassCondition.length > argumentIndex) {
throw new Error(`Pseudo-class :${name} requires an argument`);
}
} else if (pseudoClassCondition.length === argumentIndex) {
} else if (isPseudoClassCondition.length === argumentIndex) {
throw new Error(`Pseudo-class :${name} doesn't have any arguments`);
}
}
Loading
Loading