UNPKG

css-select

Version:

a CSS selector compiler/engine

230 lines (192 loc) 7.77 kB
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); }; }; }