css-select
Version:
a CSS selector compiler/engine
230 lines (192 loc) • 7.77 kB
text/typescript
import * as boolbase from "boolbase";
import { parse } from "css-what";
import getNCheck from "nth-check";
import { cacheParentResults } from "../helpers/cache.js";
import { copyOptions } from "../helpers/options.js";
import { getElementParent } from "../helpers/querying.js";
import type { CompiledQuery, CompileToken, InternalOptions } from "../types.js";
/**
* RFC 4647 extended filtering with pre-split subtags.
* @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 {
if (range[0] !== "*" && range[0] !== tag[0]) return false;
let tagIndex = 1;
for (let rangeIndex = 1; rangeIndex < range.length; rangeIndex++) {
if (range[rangeIndex] === "*") continue;
// Skip non-singleton tag subtags until we find a match.
while (tagIndex < tag.length && tag[tagIndex] !== range[rangeIndex]) {
if (tag[tagIndex++].length <= 1) return false;
}
if (tagIndex >= tag.length) return false;
tagIndex++;
}
return true;
}
/** @see {@link https://www.w3.org/TR/selectors-4/#the-nth-child-pseudo} */
const nthOfRegex = /^(.+?)\s+of\s+(.+)$/is;
/** A pre-compiled pseudo filter. */
export type Filter = <Node, ElementNode extends Node>(
next: CompiledQuery<ElementNode>,
text: string,
options: InternalOptions<Node, ElementNode>,
context?: Node[],
compileToken?: CompileToken<Node, ElementNode>,
) => CompiledQuery<ElementNode>;
function compileNth(reverse: boolean, ofType: boolean): Filter {
return function nth(next, rule, options, context, compileToken) {
const { adapter, equals } = options;
const ofMatch = ofType ? null : rule.match(nthOfRegex);
const nthCheck = getNCheck(ofMatch ? ofMatch[1].trim() : rule);
if (nthCheck === boolbase.falseFunc) return boolbase.falseFunc;
const ofSelector =
ofMatch && compileToken
? compileToken(
parse(ofMatch[2].trim()),
copyOptions(options),
context,
)
: undefined;
if (ofSelector === boolbase.falseFunc) return boolbase.falseFunc;
if (nthCheck === boolbase.trueFunc && !ofSelector) {
return (element) =>
getElementParent(element, adapter) !== null && next(element);
}
type ElementNode = Parameters<typeof next>[0];
const shouldCount = ofSelector
? (_element: ElementNode, sibling: ElementNode) =>
ofSelector(sibling)
: ofType
? (element: ElementNode, sibling: ElementNode) =>
adapter.getName(sibling) === adapter.getName(element)
: boolbase.trueFunc;
if (reverse) {
return function nthLast(element) {
if (ofSelector && !ofSelector(element)) return false;
const siblings = adapter.getSiblings(element);
let pos = 0;
for (let index = siblings.length - 1; index >= 0; index--) {
const sibling = siblings[index];
if (equals(element, sibling)) break;
if (adapter.isTag(sibling) && shouldCount(element, sibling))
pos++;
}
return nthCheck(pos) && next(element);
};
}
return function nth(element) {
if (ofSelector && !ofSelector(element)) return false;
const siblings = adapter.getSiblings(element);
let pos = 0;
for (const sibling of siblings) {
if (equals(element, sibling)) break;
if (adapter.isTag(sibling) && shouldCount(element, sibling))
pos++;
}
return nthCheck(pos) && next(element);
};
};
}
/**
* Pre-compiled pseudo filters.
*/
export const filters: Record<string, Filter> = {
contains(next, text, options) {
const { getText } = options.adapter;
return cacheParentResults(next, options, (element) =>
getText(element).includes(text),
);
},
icontains(next, text, options) {
const itext = text.toLowerCase();
const { getText } = options.adapter;
return cacheParentResults(next, options, (element) =>
getText(element).toLowerCase().includes(itext),
);
},
// Location specific methods
"nth-child": compileNth(false, false),
"nth-last-child": compileNth(true, false),
"nth-of-type": compileNth(false, true),
"nth-last-of-type": compileNth(true, true),
// TODO determine the actual root element
root(next, _rule, { adapter }) {
return (element) =>
getElementParent(element, adapter) === null && next(element);
},
scope<Node, ElementNode extends Node>(
next: CompiledQuery<ElementNode>,
rule: string,
options: InternalOptions<Node, ElementNode>,
context?: Node[],
): CompiledQuery<ElementNode> {
const { equals } = options;
if (!context || context.length === 0) {
// Equivalent to :root
return filters["root"](next, rule, options);
}
if (context.length === 1) {
// NOTE: can't be unpacked, as :has uses this for side-effects
return (element) => equals(context[0], element) && next(element);
}
return (element) => context.includes(element) && next(element);
},
lang(next, code, { adapter }) {
const ranges = code
.split(",")
.map((r) => r.trim())
.filter((r) => r.length > 0)
.map((r) =>
r
.replace(/^['"]|['"]$/g, "")
.toLowerCase()
.split("-"),
);
return function lang(element) {
let node: typeof element | null = element;
while (node != null) {
const value =
adapter.getAttributeValue(node, "xml:lang") ??
adapter.getAttributeValue(node, "lang");
if (value != null) {
if (!value) {
return ranges.some((r) => r[0] === "") && next(element);
}
const tag = value.toLowerCase().split("-");
return (
ranges.some((r) => extendedFilter(tag, r)) &&
next(element)
);
}
const parent = adapter.getParent(node);
node =
parent != null && adapter.isTag(parent)
? (parent as typeof element)
: null;
}
return ranges.some((r) => r[0] === "") && next(element);
};
},
hover: dynamicStatePseudo("isHovered"),
visited: dynamicStatePseudo("isVisited"),
active: dynamicStatePseudo("isActive"),
};
/**
* Dynamic state pseudos. These depend on optional Adapter methods.
* @param name The name of the adapter method to call.
* @returns Pseudo for the `filters` object.
*/
function dynamicStatePseudo(
name: "isHovered" | "isVisited" | "isActive",
): Filter {
return function dynamicPseudo(next, _rule, { adapter }) {
const filterFunction = adapter[name];
if (typeof filterFunction !== "function") {
return boolbase.falseFunc;
}
return function active(element) {
return filterFunction(element) && next(element);
};
};
}