Initial commit

This commit is contained in:
mats committed 2026-10-19 16:04:17 +02:00
commit afed023152
8152 files changed
+2086461

No files matched your search

+379
View File
@@ -0,0 +1,379 @@
/*!
* DOM Selector - A CSS selector engine.
* @license MIT
* @copyright asamuzaK (Kazz)
* @see {@link https://github.com/asamuzaK/domSelector/blob/main/LICENSE}
*/
/* import */
import { LRUCache } from 'lru-cache';
import { Finder } from './js/finder.js';
import { Nwsapi } from './js/nwsapi.js';
import { extractSubjectsAst, parseSelector } from './js/parser.js';
import {
extractSubjectsRegExp,
filterSelector,
isSupportedAST
} from './js/selector.js';
import { collectAllDescendants, getType } from './js/utility.js';
/* constants */
import {
DOCUMENT_NODE,
ELEMENT_NODE,
TARGET_ALL,
TARGET_FIRST,
TARGET_LINEAL,
TARGET_SELF
} from './js/constant.js';
const CACHE_SIZE = 4096;
/* regexp */
const REG_SELECTOR = /[[\]():\\"'`]/;
const REG_UNIVERSAL = /^(?:\*\|)?\*$/;
/**
* @typedef {object} CheckResult
* @property {boolean} match - The match result.
* @property {string?} pseudoElement - The pseudo-element, if any.
* @property {object?} ast - The AST object.
*/
/* DOMSelector */
export class DOMSelector {
/* private fields */
#window;
#document;
#finder;
#idlUtils;
#nwsapi;
#cache;
/**
* Creates an instance of DOMSelector.
* @param {Window} window - The window object.
* @param {Document} document - The document object.
* @param {object} [opt] - Options.
*/
constructor(window, document, opt = {}) {
const { cacheSize, idlUtils } = opt;
this.#window = window;
this.#document = document ?? window.document;
this.#idlUtils = idlUtils;
this.#cache = new LRUCache({
max: cacheSize ?? CACHE_SIZE
});
this.#finder = new Finder(this.#window);
this.#nwsapi = new Nwsapi(this.#window, this.#document, cacheSize);
}
/**
* Wraps the node for IDL internal implementation if idlUtils is present.
* @private
* @param {Document|DocumentFragment|Element} node - The raw node.
* @returns {object} The wrapped or raw node.
*/
#wrapNode = node =>
this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node;
/**
* Validates a node and returns an Error if invalid.
* @private
* @param {Document|DocumentFragment|Element} node - The node to check.
* @param {boolean} [element] - `true` if the node must be an Element.
* @returns {TypeError|null} Returns a TypeError if invalid, otherwise null.
*/
#validateNodeType = (node, element = false) => {
if (!node?.nodeType) {
return new this.#window.TypeError(`Unexpected type ${getType(node)}`);
}
if (element && node.nodeType !== ELEMENT_NODE) {
return new this.#window.TypeError(`Unexpected node ${node.nodeName}`);
}
return null;
};
/**
* Executes Nwsapi matching logic with caching and error wrapping.
* @private
* @param {string} selector - The CSS selector to match against.
* @param {Document|Element} node - The target node to check.
* @param {number} targetType - The target constant indicating the scope (e.g., TARGET_SELF).
* @param {function(object): (Array<Element>|Element|boolean|null)} callback - The callback function that executes the specific nwsapi method.
* @param {boolean} [isCheck] - True if is check method.
* @returns {{success: boolean, result: Array<Element>|Element|boolean|null}} An object indicating whether the execution succeeded and its result.
*/
#tryNwsapi = (selector, node, targetType, callback, isCheck = false) => {
const document = node.ownerDocument;
if (
node.isConnected &&
document === this.#document &&
document.contentType === 'text/html' &&
document.documentElement
) {
const cacheKey = `${isCheck ? 'check' : targetType}_${selector}`;
let filterMatches = this.#cache.get(cacheKey);
if (filterMatches === undefined) {
filterMatches = filterSelector(selector, targetType);
this.#cache.set(cacheKey, filterMatches);
}
if (filterMatches) {
try {
return { success: true, result: callback(node) };
} catch {
// fall through
}
}
}
return { success: false, result: null };
};
/**
* Encapsulates Finder traversal logic and error handling.
* @private
* @param {string} selector - The CSS selector to match against.
* @param {Document|DocumentFragment|Element} node - The node from which to start searching.
* @param {object} opt - Optional parameters.
* @param {number} targetType - The target constant indicating the scope (e.g., TARGET_FIRST, TARGET_ALL).
* @returns {Set<Element>|Array<Element>|Element|boolean|null} The search results from Finder, or the error handling return value.
*/
#findNodes = (selector, node, opt, targetType) => {
try {
return this.#finder.setup(selector, node, opt).find(targetType);
} catch (e) {
return this.#finder.onError(e, opt);
}
};
/**
* Clears the internal caches.
* @param {boolean} [clearAll] - Whether to clear all caches. If false,
* only cached matching results are cleared.
* @returns {void}
*/
clear = (clearAll = false) => {
if (clearAll) {
this.#cache.clear();
}
this.#finder.clearResults(true);
this.#nwsapi.clear(clearAll);
};
/**
* Parses a selector and extracts the rightmost subject keys (Id, Class, Tag).
* @param {string} selector - The CSS selector to parse.
* @param {boolean} caseSensitive - True if tag key should be case sensitive.
* @returns {Array<{id: string|null, className: string|null, tag: string|null}>} The list of extracted keys for each selector group.
*/
extractSubjects = (selector, caseSensitive = false) => {
if (!selector || typeof selector !== 'string') {
return [{ id: null, className: null, tag: null }];
}
const cacheKey = `extract_${selector}_${caseSensitive}`;
let subjects = this.#cache.get(cacheKey);
if (subjects !== undefined) {
return subjects;
}
subjects = [];
if (!REG_SELECTOR.test(selector)) {
subjects = extractSubjectsRegExp(selector, caseSensitive);
} else {
try {
const ast = parseSelector(selector);
subjects = extractSubjectsAst(ast);
} catch {
// fall through
}
}
if (!subjects.length) {
subjects.push({ id: null, className: null, tag: null });
}
this.#cache.set(cacheKey, subjects);
return subjects;
};
/**
* Checks if the given CSS selector is supported by this engine.
* @param {string} selector - The CSS selector to check.
* @returns {boolean} `true` if the selector is supported, `false` otherwise.
*/
supports = selector => {
if (typeof selector !== 'string') {
return false;
}
const cacheKey = `supports_${selector}`;
let isSupported = this.#cache.get(cacheKey);
if (isSupported !== undefined) {
return isSupported;
}
if (filterSelector(selector, TARGET_SELF)) {
isSupported = true;
} else {
try {
const ast = parseSelector(selector);
isSupported = isSupportedAST(ast);
} catch {
isSupported = false;
}
}
this.#cache.set(cacheKey, isSupported);
return isSupported;
};
/**
* Checks if an element matches a CSS selector.
* @param {string} selector - The CSS selector to check against.
* @param {Element} node - The element node to check.
* @param {object} [opt] - Optional parameters.
* @returns {CheckResult} An object containing the check result.
*/
check = (selector, node, opt = {}) => {
node = this.#wrapNode(node);
const error = this.#validateNodeType(node, true);
if (error) {
return this.#finder.onError(error, opt);
}
if (REG_UNIVERSAL.test(selector)) {
return {
ast: parseSelector(selector),
match: true,
pseudoElement: null
};
}
const nwsapiRes = this.#tryNwsapi(
selector,
node,
TARGET_SELF,
n => this.#nwsapi.match(selector, n),
true
);
if (nwsapiRes.success) {
let ast = null;
if (nwsapiRes.result) {
const astCacheKey = `check_ast_${selector}`;
ast = this.#cache.get(astCacheKey);
if (ast === undefined) {
ast = parseSelector(selector);
this.#cache.set(astCacheKey, ast);
}
}
return {
match: nwsapiRes.result,
ast,
pseudoElement: null
};
}
const options = { ...opt, check: true, noexcept: true, warn: false };
return this.#finder.setup(selector, node, options).find(TARGET_SELF);
};
/**
* Returns true if the element matches the selector.
* @param {string} selector - The CSS selector to match against.
* @param {Element} node - The element node to test.
* @param {object} [opt] - Optional parameters.
* @returns {boolean} True if the element matches, false otherwise.
*/
matches = (selector, node, opt = {}) => {
node = this.#wrapNode(node);
const error = this.#validateNodeType(node, true);
if (error) {
return this.#finder.onError(error, opt);
}
if (REG_UNIVERSAL.test(selector)) {
return true;
}
const nwsapiRes = this.#tryNwsapi(selector, node, TARGET_SELF, n =>
this.#nwsapi.match(selector, n)
);
if (nwsapiRes.success) {
return nwsapiRes.result;
}
const nodes = this.#findNodes(selector, node, opt, TARGET_SELF);
return !!(nodes && nodes.size > 0);
};
/**
* Traverses up the DOM tree to find the first node that matches the selector.
* @param {string} selector - The CSS selector to match against.
* @param {Element} node - The element from which to start traversing.
* @param {object} [opt] - Optional parameters.
* @returns {?Element} The first matching ancestor element, or `null`.
*/
closest = (selector, node, opt = {}) => {
node = this.#wrapNode(node);
const error = this.#validateNodeType(node, true);
if (error) {
return this.#finder.onError(error, opt);
}
if (REG_UNIVERSAL.test(selector)) {
return node;
}
const nwsapiRes = this.#tryNwsapi(selector, node, TARGET_LINEAL, n =>
this.#nwsapi.closest(selector, n)
);
if (nwsapiRes.success) {
return nwsapiRes.result;
}
const nodes = this.#findNodes(selector, node, opt, TARGET_LINEAL);
if (nodes && nodes.size) {
let refNode = node;
while (refNode) {
if (nodes.has(refNode)) {
return refNode;
}
refNode = refNode.parentNode;
}
}
return null;
};
/**
* Returns the first element within the subtree that matches the selector.
* @param {string} selector - The CSS selector to match.
* @param {Document|DocumentFragment|Element} node - The node to find within.
* @param {object} [opt] - Optional parameters.
* @returns {?Element} The first matching element, or `null`.
*/
querySelector = (selector, node, opt = {}) => {
node = this.#wrapNode(node);
const error = this.#validateNodeType(node);
if (error) {
return this.#finder.onError(error, opt);
}
if (REG_UNIVERSAL.test(selector)) {
return node.firstElementChild;
}
const nodes = this.#findNodes(selector, node, opt, TARGET_FIRST);
if (nodes && nodes.size) {
return nodes.values().next().value;
}
return null;
};
/**
* Returns an array of elements within the subtree that match the selector.
* Note: This method returns an Array, not a NodeList.
* @param {string} selector - The CSS selector to match.
* @param {Document|DocumentFragment|Element} node - The node to find within.
* @param {object} [opt] - Optional parameters.
* @returns {Array<Element>} An array of elements, or an empty array.
*/
querySelectorAll = (selector, node, opt = {}) => {
node = this.#wrapNode(node);
const error = this.#validateNodeType(node);
if (error) {
return this.#finder.onError(error, opt);
}
if (REG_UNIVERSAL.test(selector)) {
const document =
node.nodeType === DOCUMENT_NODE ? node : node.ownerDocument;
return collectAllDescendants(node, document);
}
const nodes = this.#findNodes(selector, node, opt, TARGET_ALL);
if (nodes && nodes.size) {
return [...nodes];
}
return [];
};
}
+286
View File
@@ -0,0 +1,286 @@
/**
* constant.js
*/
/* string */
export const ATRULE = 'Atrule';
export const ATTR_SELECTOR = 'AttributeSelector';
export const CLASS_SELECTOR = 'ClassSelector';
export const COMBINATOR = 'Combinator';
export const DIR_NEXT = 'next';
export const DIR_PREV = 'prev';
export const IDENT = 'Identifier';
export const ID_SELECTOR = 'IdSelector';
export const NEST_SELECTOR = 'NestingSelector';
export const NOT_SUPPORTED_ERR = 'NotSupportedError';
export const NTH = 'Nth';
export const OPERATOR = 'Operator';
export const PS_CLASS_SELECTOR = 'PseudoClassSelector';
export const PS_ELEMENT_SELECTOR = 'PseudoElementSelector';
export const RULE = 'Rule';
export const SCOPE = 'Scope';
export const SELECTOR = 'Selector';
export const SELECTOR_LIST = 'SelectorList';
export const STRING = 'String';
export const SYNTAX_ERR = 'SyntaxError';
export const TARGET_ALL = 'all';
export const TARGET_FIRST = 'first';
export const TARGET_LINEAL = 'lineal';
export const TARGET_SELF = 'self';
export const TYPE_SELECTOR = 'TypeSelector';
/* numeric */
export const BIT_01 = 1;
export const BIT_02 = 2;
export const BIT_04 = 4;
export const BIT_08 = 8;
export const BIT_16 = 0x10;
export const BIT_32 = 0x20;
export const BIT_FFFF = 0xffff;
export const DUO = 2;
export const HEX = 16;
export const TYPE_FROM = 8;
export const TYPE_TO = -1;
/* Node */
export const ELEMENT_NODE = 1;
export const TEXT_NODE = 3;
export const DOCUMENT_NODE = 9;
export const DOCUMENT_FRAGMENT_NODE = 11;
export const DOCUMENT_POSITION_PRECEDING = 2;
export const DOCUMENT_POSITION_CONTAINS = 8;
export const DOCUMENT_POSITION_CONTAINED_BY = 0x10;
/* NodeFilter */
export const FILTER_ACCEPT = 1;
export const FILTER_REJECT = 2;
export const FILTER_SKIP = 3;
export const SHOW_ALL = 0xffffffff;
export const SHOW_CONTAINER = 0x501;
export const SHOW_DOCUMENT = 0x100;
export const SHOW_DOCUMENT_FRAGMENT = 0x400;
export const SHOW_ELEMENT = 1;
/* selectors */
export const ALPHA_NUM = '[A-Z\\d]+';
export const CHILD_IDX = '(?:first|last|only)-(?:child|of-type)';
export const DIGIT = '(?:0|[1-9]\\d*)';
export const LANG_PART = `(?:-${ALPHA_NUM})*`;
export const PSEUDO_CLASS = `(?:any-)?link|${CHILD_IDX}|checked|empty|indeterminate|read-(?:only|write)|target`;
export const ANB = `[+-]?(?:${DIGIT}n?|n)|(?:[+-]?${DIGIT})?n\\s*[+-]\\s*${DIGIT}`;
// combinators
export const COMBO = '\\s?[\\s>~+]\\s?';
export const DESCEND = '\\s?[\\s>]\\s?';
export const SIBLING = '\\s?[+~]\\s?';
// LOGIC_IS: :is()
export const LOGIC_IS = `:is\\(\\s*[^)]+\\s*\\)`;
// N_TH: excludes An+B with selector list, e.g. :nth-child(2n+1 of .foo)
export const N_TH = `nth-(?:last-)?(?:child|of-type)\\(\\s*(?:even|odd|${ANB})\\s*\\)`;
// ATTR_TYPE: excludes [foo|=bar]
export const ATTR_TYPE = '\\[[^|\\]]+\\]';
// SUB_TYPE: attr, id, class, pseudo-class
export const SUB_TYPE = `${ATTR_TYPE}|[#.:][\\w-]+`;
export const SUB_TYPE_WO_PSEUDO = `${ATTR_TYPE}|[#.][\\w-]+`;
// TAG_TYPE: *, tag
export const TAG_TYPE_WO_UNIVERSAL = '[A-Za-z][\\w-]*';
export const TAG_TYPE = `\\*|${TAG_TYPE_WO_UNIVERSAL}`;
export const TAG_TYPE_I = '\\*|[A-Z][\\w-]*';
export const COMPOUND = `(?:${TAG_TYPE}|(?:${TAG_TYPE})?(?:${SUB_TYPE})+)`;
export const COMPOUND_L = `(?:${TAG_TYPE}|(?:${TAG_TYPE})?(?:${SUB_TYPE}|${LOGIC_IS})+)`;
export const COMPOUND_I = `(?:${TAG_TYPE_I}|(?:${TAG_TYPE_I})?(?:${SUB_TYPE})+)`;
export const COMPOUND_WO_PSEUDO = `(?:${TAG_TYPE}|(?:${TAG_TYPE})?(?:${SUB_TYPE_WO_PSEUDO})+)`;
export const COMPLEX = `${COMPOUND}(?:${COMBO}${COMPOUND})*`;
export const COMPLEX_L = `${COMPOUND_L}(?:${COMBO}${COMPOUND_L})*`;
export const HAS_COMPOUND = `has\\([\\s>]?\\s*${COMPOUND_WO_PSEUDO}\\s*\\)`;
export const LOGIC_COMPOUND = `(?:is|not)\\(\\s*${COMPOUND_L}(?:\\s*,\\s*${COMPOUND_L})*\\s*\\)`;
export const LOGIC_COMPLEX = `(?:is|not)\\(\\s*${COMPLEX_L}(?:\\s*,\\s*${COMPLEX_L})*\\s*\\)`;
/* forms and input types */
export const FORM_PARTS = Object.freeze([
'button',
'input',
'select',
'textarea'
]);
export const INPUT_BUTTON = Object.freeze(['button', 'reset', 'submit']);
export const INPUT_CHECK = Object.freeze(['checkbox', 'radio']);
export const INPUT_DATE = Object.freeze([
'date',
'datetime-local',
'month',
'time',
'week'
]);
export const INPUT_TEXT = Object.freeze([
'email',
'password',
'search',
'tel',
'text',
'url'
]);
export const INPUT_EDIT = Object.freeze([
...INPUT_DATE,
...INPUT_TEXT,
'number'
]);
export const INPUT_LTR = Object.freeze([
...INPUT_CHECK,
'color',
'date',
'image',
'number',
'range',
'time'
]);
/* Attribute names that must treet values as ASCII case-insensitive */
export const ATTR_VALUE_I = Object.freeze([
'accept',
'accept-charset',
'align',
'alink',
'axis',
'bgcolor',
'charset',
'checked',
'clear',
'codetype',
'color',
'compact',
'declare',
'defer',
'dir',
'direction',
'disabled',
'enctype',
'face',
'frame',
'hreflang',
'http-equiv',
'lang',
'language',
'link',
'media',
'method',
'multiple',
'nohref',
'noresize',
'noshade',
'nowrap',
'readonly',
'rel',
'rev',
'rules',
'scope',
'scrolling',
'selected',
'shape',
'target',
'text',
'type',
'valign',
'valuetype',
'vlink'
]);
/* logical combination pseudo-classes */
export const KEYS_LOGICAL = new Set(['has', 'is', 'not', 'where']);
/* lists of supported / unsupported pseudo-classes and pseudo-elements */
export const KEYS_PS_CLASS_SUPPORTED = new Set([
'active',
'any-link',
'checked',
'closed',
'default',
'defined',
'dir',
'disabled',
'empty',
'enabled',
'first-child',
'first-of-type',
'focus',
'focus-visible',
'focus-within',
'has',
'host',
'host-context',
'hover',
'in-range',
'indeterminate',
'invalid',
'is',
'lang',
'last-child',
'last-of-type',
'link',
'local-link',
'not',
'nth-child',
'nth-last-child',
'nth-last-of-type',
'nth-of-type',
'only-child',
'only-of-type',
'open',
'optional',
'out-of-range',
'placeholder-shown',
'read-only',
'read-write',
'required',
'root',
'scope',
'state',
'target',
'target-within',
'valid',
'visited',
'where'
]);
export const KEYS_PS_CLASS_UNSUPPORTED = new Set([
'autofill',
'blank',
'buffering',
'contains',
'current',
'fullscreen',
'future',
'has-slotted',
'heading',
'modal',
'muted',
'nth-col',
'nth-last-col',
'past',
'paused',
'picture-in-picture',
'playing',
'popover-open',
'seeking',
'stalled',
'user-invalid',
'user-valid',
'volume-locked',
'-webkit-autofill'
]);
export const KEYS_PS_ELEMENT_UNSUPPORTED = new Set([
'after',
'backdrop',
'before',
'cue',
'cue-region',
'file-selector-button',
'first-letter',
'first-line',
'marker',
'part',
'placeholder',
'selection',
'slotted',
'target-text'
]);
File diff suppressed because it is too large. Load diff
+154
View File
@@ -0,0 +1,154 @@
/**
* event.js
*/
/* constants */
const KEYS_MODIFIER = new Set([
'Alt',
'AltGraph',
'CapsLock',
'Control',
'Fn',
'FnLock',
'Hyper',
'Meta',
'NumLock',
'ScrollLock',
'Shift',
'Super',
'Symbol',
'SymbolLock'
]);
/**
* EventHandler
* Tracks window events to record recent user interactions.
*/
export class EventHandler {
/* private fields */
#window;
#event;
#eventHandlers;
#fucusEvent;
#lastFocusVisible;
#cleanupFunctions;
/**
* @param {object} window - The window object.
*/
constructor(window) {
this.#window = window;
this.#event = null;
this.#fucusEvent = null;
this.#lastFocusVisible = null;
this.#eventHandlers = [
{
keys: ['focus', 'focusin'],
handler: this.handleFocusEvent
},
{
keys: ['keydown', 'keyup'],
handler: this.handleKeyboardEvent
},
{
keys: ['mouseover', 'mousedown', 'mouseup', 'click', 'mouseout'],
handler: this.handleMouseEvent
}
];
this.registerEventListeners();
}
/**
* Gets the most recent non-modifier keyboard or mouse event.
* @returns {Event|null} The current event object.
*/
get currentEvent() {
return this.#event;
}
/**
* Gets the most recent focus event.
* @returns {Event|null} The current focus event object.
*/
get currentFocus() {
return this.#fucusEvent;
}
/**
* Gets the node that last had visible focus.
* @returns {Node|null} The last focused node.
*/
get lastFocusVisible() {
return this.#lastFocusVisible;
}
/**
* Sets the node that last had visible focus.
* @param {Node} node - The node to set as the last visible focus.
*/
set lastFocusVisible(node) {
this.#lastFocusVisible = node;
}
/**
* Handles focus events and updates the current focus state.
* @param {Event} evt - The focus event.
*/
handleFocusEvent = evt => {
this.#fucusEvent = evt;
};
/**
* Handles keyboard events and updates the current event state.
* @param {KeyboardEvent} evt - The keyboard event.
*/
handleKeyboardEvent = evt => {
const { key } = evt;
if (!KEYS_MODIFIER.has(key)) {
this.#event = evt;
}
};
/**
* Handles mouse events and updates the current event state.
* @param {MouseEvent} evt - The mouse event.
*/
handleMouseEvent = evt => {
this.#event = evt;
};
/**
* Registers all predefined event listeners on the window object.
*/
registerEventListeners = () => {
if (!this.#cleanupFunctions) {
this.#cleanupFunctions = [];
}
for (const { keys, handler } of this.#eventHandlers) {
for (const key of keys) {
this.#window.addEventListener(key, handler, {
capture: true,
passive: true
});
this.#cleanupFunctions.push(() => {
this.#window.removeEventListener(key, handler, { capture: true });
});
}
}
};
/**
* Removes all registered event listeners and resets all internal states.
*/
destroy = () => {
if (this.#cleanupFunctions) {
for (const cleanup of this.#cleanupFunctions) {
cleanup();
}
}
this.#cleanupFunctions = null;
this.#event = null;
this.#fucusEvent = null;
this.#lastFocusVisible = null;
};
}
File diff suppressed because it is too large. Load diff
+101
View File
@@ -0,0 +1,101 @@
/**
* mapper.js
*/
import { SelectorProcessor } from './processor.js';
import { parseSelector, walkAST } from './parser.js';
import { createHasValidator } from './selector.js';
/**
* Mapper
* Maps CSS selectors to parsed ASTs and sets up node arrays.
*/
export class Mapper {
#context;
#processor;
/**
* @param {import('./finder.js').Finder} context - The Finder instance.
*/
constructor(context) {
this.#context = context;
this.#processor = new SelectorProcessor(context);
}
/**
* Gets the corresponding AST and empty nodes array for the given selector.
* @param {string} selector - The CSS selector string.
* @returns {Array} An array containing the AST, empty nodes array, and selector AST.
*/
correspond(selector) {
const ctx = this.#context;
const nodes = [];
let ast = null;
// Check cache.
if (ctx.documentCache.has(ctx.document)) {
const cachedItem = ctx.documentCache.get(ctx.document);
if (cachedItem && cachedItem.has(selector)) {
const item = cachedItem.get(selector);
ast = item.ast;
ctx.invalidate = item.invalidate;
ctx.selectorAST = item.selectorAST;
}
}
// Clear flags for reuse if cache hit.
if (ast) {
const l = ast.length;
for (let i = 0; i < l; i++) {
ast[i].dir = null;
ast[i].filtered = false;
ast[i].find = false;
nodes[i] = [];
}
return [ast, nodes, ctx.selectorAST];
}
// Parse selector and build metadata.
const selectorAST = parseSelector(selector);
const { branches, info } = walkAST(
selectorAST,
true,
createHasValidator(ctx.window)
);
const {
hasHasPseudoFunc,
hasLogicalPseudoFunc,
hasNestingSelector,
hasNthChildOfSelector,
hasStatePseudoClass,
hasUnsupportedPseudoClass
} = info;
// Determine invalidation flags.
ctx.invalidate =
hasHasPseudoFunc ||
hasNestingSelector ||
hasStatePseudoClass ||
hasUnsupportedPseudoClass ||
!!(hasLogicalPseudoFunc && hasNthChildOfSelector);
// Process branches.
const processed = this.#processor.process(branches, selector);
ast = processed.ast;
const descendant = processed.descendant;
// Store in cache.
let cachedItem;
if (ctx.documentCache.has(ctx.document)) {
cachedItem = ctx.documentCache.get(ctx.document);
} else {
cachedItem = new Map();
}
cachedItem.set(selector, {
ast,
descendant,
invalidate: ctx.invalidate,
selectorAST
});
ctx.documentCache.set(ctx.document, cachedItem);
// Initialize nodes.
const l = ast.length;
for (let i = 0; i < l; i++) {
nodes[i] = [];
}
return [ast, nodes, selectorAST];
}
}
+768
View File
@@ -0,0 +1,768 @@
/**
* matcher.js
*/
/* import */
import { generateCSS, parseAstName, unescapeSelector } from './parser.js';
import {
generateException,
getDirectionality,
getLanguageAttribute,
getType,
isContentEditable,
isNamespaceDeclared
} from './utility.js';
/* constants */
import {
ALPHA_NUM,
ATTR_VALUE_I,
INPUT_EDIT,
LANG_PART,
NOT_SUPPORTED_ERR,
PS_ELEMENT_SELECTOR,
STRING,
SYNTAX_ERR
} from './constant.js';
const KEYS_ATTR_VALUE_I = new Set(ATTR_VALUE_I);
const KEYS_INPUT_EDIT = new Set(INPUT_EDIT);
/* regexp */
const REG_LANG_VALID = new RegExp(`^(?:\\*-)?${ALPHA_NUM}${LANG_PART}$`, 'i');
/* cache */
const astMetaCache = new WeakMap();
/**
* Validates a pseudo-element selector.
* @param {string} astName - The name of the pseudo-element from the AST.
* @param {string} astType - The type of the selector from the AST.
* @param {object} [opt] - Optional parameters.
* @param {boolean} [opt.forgive] - If true, ignores unknown pseudo-elements.
* @param {object} [opt.globalObject] - The global object.
* @param {boolean} [opt.warn] - If true, throws an error for unsupported ones.
* @throws {DOMException} If the selector is invalid or unsupported.
* @returns {void}
*/
export const matchPseudoElementSelector = (
astName,
astType,
{ forgive, globalObject, warn } = {}
) => {
if (astType !== PS_ELEMENT_SELECTOR) {
// Ensure the AST node is a pseudo-element selector.
throw new TypeError(`Unexpected ast type ${getType(astType)}`);
}
switch (astName) {
case 'after':
case 'backdrop':
case 'before':
case 'cue':
case 'cue-region':
case 'first-letter':
case 'first-line':
case 'file-selector-button':
case 'marker':
case 'placeholder':
case 'selection':
case 'target-text': {
// Warn if the pseudo-element is known but unsupported.
if (warn) {
throw generateException(
`Unsupported pseudo-element ::${astName}`,
NOT_SUPPORTED_ERR,
globalObject
);
}
break;
}
case 'part':
case 'slotted': {
// Warn if the functional pseudo-element is known but unsupported.
if (warn) {
throw generateException(
`Unsupported pseudo-element ::${astName}()`,
NOT_SUPPORTED_ERR,
globalObject
);
}
break;
}
default: {
// Handle vendor-prefixed unknown pseudo-elements.
if (astName.startsWith('-webkit-')) {
if (warn) {
throw generateException(
`Unsupported pseudo-element ::${astName}`,
NOT_SUPPORTED_ERR,
globalObject
);
}
} else if (!forgive) {
// Throw an error for unknown pseudo-elements if not forgiven.
throw generateException(
`Unknown pseudo-element ::${astName}`,
SYNTAX_ERR,
globalObject
);
}
}
}
};
/**
* Matches the :dir() pseudo-class against an element's directionality.
* @param {object} ast - The AST object for the pseudo-class.
* @param {object} node - The element node to match against.
* @param {WeakMap} [dirCache] - Cache for directionality.
* @throws {TypeError} If the AST does not contain a valid direction value.
* @returns {boolean} - True if matches, otherwise false.
*/
export const matchDirectionPseudoClass = (
ast,
node,
dirCache = new WeakMap()
) => {
const { name } = ast;
if (!name) {
const type = name === '' ? '(empty String)' : getType(name);
throw new TypeError(`Unexpected ast type ${type}`);
}
const dir = getDirectionality(node, dirCache);
return name === dir;
};
/**
* Matches the :lang() pseudo-class against an element's language attribute.
* @param {object} ast - The AST object for the pseudo-class child.
* @param {object} node - The element node to match against.
* @param {WeakMap} [langCache] - Cache for language attributes.
* @throws {TypeError} If the AST does not contain a valid language value.
* @returns {boolean} - True if matches, otherwise false.
*/
export const matchLanguagePseudoClass = (
ast,
node,
langCache = new WeakMap()
) => {
const elementLang = getLanguageAttribute(node, langCache);
if (elementLang === null) {
return false;
}
let meta = astMetaCache.get(ast);
if (meta === undefined) {
meta = {};
astMetaCache.set(ast, meta);
}
if (meta.langRegex !== undefined) {
if (meta.langPattern === '*') {
return elementLang !== '';
}
if (meta.langRegex === null) {
return false;
}
return meta.langRegex.test(elementLang);
}
const { name, type, value } = ast;
const langPattern =
type === STRING && value !== undefined ? value : unescapeSelector(name);
meta.langPattern = langPattern;
if (typeof langPattern !== 'string') {
meta.langRegex = null;
return false;
}
if (langPattern === '*') {
meta.langRegex = null;
return elementLang !== '';
}
if (!REG_LANG_VALID.test(langPattern)) {
meta.langRegex = null;
return false;
}
const regexStr = langPattern
.split('-')
.map((part, index) => {
const core = part === '*' ? ALPHA_NUM : part;
return (index === 0 ? core : `-${core}`) + LANG_PART;
})
.join('');
const matcherRegex = new RegExp(`^${regexStr}$`, 'i');
meta.langRegex = matcherRegex;
return matcherRegex.test(elementLang);
};
/**
* Matches the :checked pseudo-class.
* @param {object} node - The Element node.
* @returns {boolean} True if matches, otherwise false.
*/
export const matchCheckedPseudoClass = node => {
const { localName } = node;
if (localName === 'option') {
return node.selected;
}
if (localName === 'input') {
const attrType = node.getAttribute('type');
if (node.checked) {
return attrType === 'checkbox' || attrType === 'radio';
}
}
return false;
};
/**
* Matches the :any-link and :link pseudo-classes.
* @param {object} node - The Element node.
* @returns {boolean} True if matches, otherwise false.
*/
export const matchLinkPseudoClass = node => {
const { localName } = node;
if (localName === 'a' || localName === 'area') {
return node.hasAttribute('href');
}
return false;
};
/**
* Matches the :open pseudo-class.
* @param {object} node - The Element node.
* @returns {boolean} True if matches, otherwise false.
*/
export const matchOpenPseudoClass = node => {
const { localName } = node;
if (localName === 'details' || localName === 'dialog') {
return node.hasAttribute('open');
}
return false;
};
/**
* Matches the :placeholder-shown pseudo-class.
* @param {object} node - The Element node.
* @param {Set.<string>} keys - A set of input type keys.
* @returns {boolean} True if matches, otherwise false.
*/
export const matchPlaceholderShownPseudoClass = (node, keys) => {
let placeholder;
if (node.placeholder) {
placeholder = node.placeholder;
} else if (node.hasAttribute('placeholder')) {
placeholder = node.getAttribute('placeholder');
}
if (typeof placeholder === 'string' && !/[\r\n]/.test(placeholder)) {
let targetNode;
const { localName } = node;
if (localName === 'textarea') {
targetNode = node;
} else if (localName === 'input') {
if (node.hasAttribute('type')) {
if (keys.has(node.getAttribute('type'))) {
targetNode = node;
}
} else {
targetNode = node;
}
}
if (targetNode) {
return node.value === '';
}
}
return false;
};
/**
* Matches the :in-range and :out-of-range pseudo-classes.
* @param {string} astName - The name of the pseudo-class.
* @param {object} node - The Element node.
* @param {Set.<string>} keys - A set of input type keys.
* @returns {boolean} True if matches, otherwise false.
*/
export const matchRangePseudoClass = (astName, node, keys) => {
const { localName } = node;
const attrType = node.getAttribute('type');
if (
localName === 'input' &&
!(node.readOnly || node.hasAttribute('readonly')) &&
!(node.disabled || node.hasAttribute('disabled')) &&
keys.has(attrType)
) {
const flowed = node.validity.rangeUnderflow || node.validity.rangeOverflow;
if (astName === 'out-of-range') {
return flowed;
}
if (flowed) {
return false;
}
return (
node.hasAttribute('min') ||
node.hasAttribute('max') ||
attrType === 'range'
);
}
return false;
};
/**
* Match the :read-only and :read-write pseudo-classes
* @param {string} astName - pseudo-class name
* @param {object} node - Element node
* @returns {boolean} - True if matches, otherwise false.
*/
export const matchReadOnlyPseudoClass = (astName, node) => {
const { localName } = node;
let isReadOnly = false;
switch (localName) {
case 'textarea':
case 'input': {
const isEditableInput = !node.type || KEYS_INPUT_EDIT.has(node.type);
if (localName === 'textarea' || isEditableInput) {
isReadOnly =
node.readOnly ||
node.hasAttribute('readonly') ||
node.disabled ||
node.hasAttribute('disabled');
} else {
// Non-editable input types are always read-only
isReadOnly = true;
}
break;
}
default: {
isReadOnly = !isContentEditable(node);
}
}
if (astName === 'read-only') {
return isReadOnly;
}
return !isReadOnly;
};
/**
* Matches the :required and :optional pseudo-classes.
* @param {string} astName - The name of the pseudo-class.
* @param {object} node - The Element node.
* @param {Set.<string>} keys - A set of input type keys.
* @returns {boolean} True if matches, otherwise false.
*/
export const matchRequiredPseudoClass = (astName, node, keys) => {
const { localName } = node;
let required = false;
if (localName === 'select' || localName === 'textarea') {
if (node.required || node.hasAttribute('required')) {
required = true;
}
} else if (localName === 'input') {
if (node.hasAttribute('type')) {
const attrType = node.getAttribute('type');
if (keys.has(attrType)) {
if (node.required || node.hasAttribute('required')) {
required = true;
}
}
} else if (node.required || node.hasAttribute('required')) {
required = true;
}
} else {
return false;
}
if (astName === 'optional') {
return !required;
}
return required;
};
/**
* Matches an attribute selector against an element.
* @param {object} ast - The AST for the attribute selector.
* @param {object} node - The element node to match against.
* @param {object} [opt] - Optional parameters.
* @param {boolean} [opt.check] - True if running in an internal check.
* @param {boolean} [opt.forgive] - True to forgive certain syntax errors.
* @param {object} [opt.globalObject] - The global object.
* @returns {boolean} - True if the attribute selector matches, otherwise false.
*/
export const matchAttributeSelector = (
ast,
node,
{ check, forgive, globalObject } = {}
) => {
const {
flags: astFlags,
matcher: astMatcher,
name: astName,
value: astValue
} = ast;
if (typeof astFlags === 'string' && !/^[is]$/i.test(astFlags) && !forgive) {
const css = generateCSS(ast);
throw generateException(
`Invalid selector ${css}`,
SYNTAX_ERR,
globalObject
);
}
const isHTML = node.ownerDocument.contentType === 'text/html';
let meta = astMetaCache.get(ast);
if (meta === undefined) {
meta = {
attrValues: new Set()
};
astMetaCache.set(ast, meta);
}
if (astMatcher === null && !astFlags && typeof astName?.name === 'string') {
if (meta.astName === undefined) {
const rawName = unescapeSelector(astName.name);
meta.astName = rawName;
meta.hasPipe = rawName.indexOf('|') > -1;
}
if (!meta.hasPipe) {
if (
meta.astName === 'lang' &&
node.hasAttributeNS('http://www.w3.org/XML/1998/namespace', 'lang')
) {
return false;
}
if (node.hasAttribute(meta.astName)) {
return true;
}
const attrs = node.attributes;
if (!attrs || attrs.length === 0) {
return false;
}
const checkName = isHTML ? meta.astName.toLowerCase() : meta.astName;
for (let i = 0, len = attrs.length; i < len; i++) {
let itemName = attrs[i].name;
if (isHTML) {
itemName = itemName.toLowerCase();
}
const colonIdx = itemName.indexOf(':');
if (colonIdx > -1) {
const itemLocalName = itemName
.substring(colonIdx + 1)
.replace(/^:/, '');
if (checkName === itemLocalName) {
return true;
}
} else if (checkName === itemName) {
return true;
}
}
return false;
}
}
const { attributes } = node;
if (!attributes || !attributes.length) {
return false;
}
if (meta.caseInsensitive === undefined) {
let caseInsensitive = false;
if (typeof astFlags === 'string') {
caseInsensitive = /^i$/i.test(astFlags);
} else if (isHTML) {
caseInsensitive = KEYS_ATTR_VALUE_I.has(astName.name);
}
meta.caseInsensitive = caseInsensitive;
let astAttrName = unescapeSelector(astName.name);
meta.hasPipeInName = astAttrName.indexOf('|') > -1;
if ((isHTML && !meta.hasPipeInName) || caseInsensitive) {
astAttrName = astAttrName.toLowerCase();
}
meta.astAttrName = astAttrName;
if (meta.hasPipeInName) {
const { prefix, localName } = parseAstName(astAttrName);
meta.astPrefix = prefix;
meta.astLocalName = localName;
}
const { name: astIdentValue, value: astStringValue } = astValue ?? {};
let attrValue;
if (astIdentValue) {
attrValue = unescapeSelector(astIdentValue);
} else if (astStringValue !== undefined) {
attrValue = astStringValue;
}
if (caseInsensitive && typeof attrValue === 'string') {
attrValue = attrValue.toLowerCase();
}
meta.cachedAttrValue = attrValue;
if (astMatcher === '~=' && attrValue && typeof attrValue === 'string') {
meta.tildeTarget = /\s/.test(attrValue) ? null : ` ${attrValue} `;
}
}
const caseInsensitive = meta.caseInsensitive;
const astAttrName = meta.astAttrName;
const attrValue = meta.cachedAttrValue;
const attrValues = meta.attrValues;
if (attrValues.size) {
attrValues.clear();
}
if (meta.hasPipeInName) {
const astPrefix = meta.astPrefix;
const astLocalName = meta.astLocalName;
for (let i = 0, len = attributes.length; i < len; i++) {
const item = attributes[i];
let itemName = item.name;
let itemValue = item.value;
if (caseInsensitive) {
itemName = itemName.toLowerCase();
itemValue = itemValue.toLowerCase();
}
const colonIdx = itemName.indexOf(':');
switch (astPrefix) {
case '': {
if (astLocalName === itemName) {
attrValues.add(itemValue);
}
break;
}
case '*': {
if (colonIdx > -1) {
const itemLocalName = itemName
.substring(colonIdx + 1)
.replace(/^:/, '');
if (itemLocalName === astLocalName) {
attrValues.add(itemValue);
}
} else if (astLocalName === itemName) {
attrValues.add(itemValue);
}
break;
}
default: {
if (!check) {
if (forgive) {
return false;
}
const css = generateCSS(ast);
throw generateException(
`Invalid selector ${css}`,
SYNTAX_ERR,
globalObject
);
}
if (colonIdx > -1) {
const itemPrefix = itemName.substring(0, colonIdx);
const itemLocalName = itemName
.substring(colonIdx + 1)
.replace(/^:/, '');
if (itemPrefix === 'xml' && itemLocalName === 'lang') {
continue;
}
if (astPrefix === itemPrefix && astLocalName === itemLocalName) {
if (isNamespaceDeclared(astPrefix, node)) {
attrValues.add(itemValue);
}
}
}
}
}
}
} else {
for (let i = 0, len = attributes.length; i < len; i++) {
const item = attributes[i];
const origName = item.name;
if (
item.namespaceURI === 'http://www.w3.org/XML/1998/namespace' &&
item.localName === 'lang'
) {
continue;
}
const colonIdx = origName.indexOf(':');
if (colonIdx > -1) {
const itemPrefix = origName.substring(0, colonIdx);
const itemLocalName = origName
.substring(colonIdx + 1)
.replace(/^:/, '');
if (itemPrefix === 'xml' && itemLocalName === 'lang') {
continue;
}
}
let isMatch = false;
if (origName === astAttrName) {
isMatch = true;
} else if (caseInsensitive && origName.toLowerCase() === astAttrName) {
isMatch = true;
}
if (isMatch) {
attrValues.add(caseInsensitive ? item.value.toLowerCase() : item.value);
} else if (colonIdx > -1) {
const itemLocalName = origName
.substring(colonIdx + 1)
.replace(/^:/, '');
let isLocalMatch = false;
if (itemLocalName === astAttrName) {
isLocalMatch = true;
} else if (
caseInsensitive &&
itemLocalName.toLowerCase() === astAttrName
) {
isLocalMatch = true;
}
if (isLocalMatch) {
attrValues.add(
caseInsensitive ? item.value.toLowerCase() : item.value
);
}
}
}
}
if (!attrValues.size) {
return false;
}
switch (astMatcher) {
case '=': {
return typeof attrValue === 'string' && attrValues.has(attrValue);
}
case '~=': {
if (meta.tildeTarget === null || !attrValue) {
return false;
}
const target = meta.tildeTarget;
for (const value of attrValues) {
if (` ${value.replace(/[\t\r\n\f]/g, ' ')} `.includes(target)) {
return true;
}
}
return false;
}
case '|=': {
if (!attrValue || typeof attrValue !== 'string') {
return false;
}
const prefix = `${attrValue}-`;
for (const value of attrValues) {
if (value === attrValue || value.startsWith(prefix)) {
return true;
}
}
return false;
}
case '^=': {
if (!attrValue || typeof attrValue !== 'string') {
return false;
}
for (const value of attrValues) {
if (value.startsWith(attrValue)) {
return true;
}
}
return false;
}
case '$=': {
if (!attrValue || typeof attrValue !== 'string') {
return false;
}
for (const value of attrValues) {
if (value.endsWith(attrValue)) {
return true;
}
}
return false;
}
case '*=': {
if (!attrValue || typeof attrValue !== 'string') {
return false;
}
for (const value of attrValues) {
if (value.includes(attrValue)) {
return true;
}
}
return false;
}
case null:
default: {
return true;
}
}
};
/**
* Matches a type selector against an element.
* @param {object} ast - The AST for the type selector.
* @param {object} node - The element node to match against.
* @param {object} [opt] - Optional parameters.
* @param {boolean} [opt.check] - True if running in an internal check.
* @param {boolean} [opt.forgive] - True to forgive undeclared namespace.
* @param {object} [opt.globalObject] - The global object.
* @returns {boolean} - True if the type selector matches, otherwise false.
*/
export const matchTypeSelector = (
ast,
node,
{ check, forgive, globalObject } = {}
) => {
const { localName, namespaceURI, prefix } = node;
let meta = astMetaCache.get(ast);
if (meta === undefined) {
meta = {};
astMetaCache.set(ast, meta);
}
if (meta.astName === undefined) {
const astName = unescapeSelector(ast.name);
const { prefix: parsedPrefix, localName: parsedLocalName } = parseAstName(
astName,
node
);
meta.astName = astName;
meta.astPrefix = parsedPrefix;
meta.astPrefixLowerCased = parsedPrefix.toLowerCase();
meta.astLocalName = parsedLocalName;
meta.astLocalNameLowerCased = parsedLocalName.toLowerCase();
meta.hasPipe = astName.includes('|');
}
const isHTML =
node.ownerDocument.contentType === 'text/html' &&
(!namespaceURI || namespaceURI === 'http://www.w3.org/1999/xhtml');
if (isHTML && localName === meta.astLocalName && !meta.hasPipe) {
return true;
}
let nodePrefix;
let nodeLocalName;
const colonIdx = localName.indexOf(':');
if (colonIdx > -1) {
nodePrefix = localName.substring(0, colonIdx);
nodeLocalName = localName.substring(colonIdx + 1);
} else {
nodePrefix = prefix || '';
nodeLocalName = localName;
}
const astPrefix = isHTML ? meta.astPrefixLowerCased : meta.astPrefix;
const astLocalName = isHTML ? meta.astLocalNameLowerCased : meta.astLocalName;
const isUniversal = astLocalName === '*';
switch (astPrefix) {
case '': {
return (
!nodePrefix &&
!namespaceURI &&
(isUniversal || astLocalName === nodeLocalName)
);
}
case '*': {
return isUniversal || astLocalName === nodeLocalName;
}
default: {
if (!check) {
if (forgive) {
return false;
}
const css = generateCSS(ast);
throw generateException(
`Invalid selector ${css}`,
SYNTAX_ERR,
globalObject
);
}
const astNS = node.lookupNamespaceURI(astPrefix);
const nodeNS = node.lookupNamespaceURI(nodePrefix);
if (astNS === nodeNS && astPrefix === nodePrefix) {
return isUniversal || astLocalName === nodeLocalName;
} else if (!forgive && !astNS) {
throw generateException(
`Undeclared namespace ${astPrefix}`,
SYNTAX_ERR,
globalObject
);
}
return false;
}
}
};
File diff suppressed because it is too large. Load diff
+504
View File
@@ -0,0 +1,504 @@
/**
* parser.js
*/
/* import */
import * as cssTree from 'css-tree';
import { getType } from './utility.js';
/* constants */
import {
ATTR_SELECTOR,
BIT_01,
BIT_02,
BIT_04,
BIT_08,
BIT_16,
BIT_32,
BIT_FFFF,
CLASS_SELECTOR,
COMBINATOR,
DUO,
HEX,
ID_SELECTOR,
KEYS_LOGICAL,
KEYS_PS_CLASS_SUPPORTED,
NEST_SELECTOR,
NTH,
PS_CLASS_SELECTOR,
PS_ELEMENT_SELECTOR,
SELECTOR,
SYNTAX_ERR,
TYPE_SELECTOR
} from './constant.js';
const AST_SORT_ORDER = new Map([
[PS_ELEMENT_SELECTOR, BIT_01],
[ID_SELECTOR, BIT_02],
[CLASS_SELECTOR, BIT_04],
[TYPE_SELECTOR, BIT_08],
[ATTR_SELECTOR, BIT_16],
[PS_CLASS_SELECTOR, BIT_32]
]);
const KEYS_PS_NTH = new Set([
'first-child',
'first-of-type',
'last-child',
'last-of-type',
'only-child',
'only-of-type'
]);
const KEYS_PS_CLASS_STATE = new Set([
'checked',
'closed',
'disabled',
'empty',
'enabled',
'in-range',
'indeterminate',
'invalid',
'open',
'out-of-range',
'placeholder-shown',
'read-only',
'read-write',
'valid',
...KEYS_PS_NTH
]);
const KEYS_SHADOW_HOST = new Set(['host', 'host-context']);
const U_FFFD = '\uFFFD';
/* regexp */
const REG_EMPTY_PS_FUNC =
/(?<=:(?:dir|has|host(?:-context)?|is|lang|not|nth-(?:last-)?(?:child|of-type)|where))\(\s+\)/g;
const REG_SHADOW_PS_ELEMENT = /^part|slotted$/;
/**
* Unescapes a CSS selector string.
* @param {string} selector - The CSS selector to unescape.
* @returns {string} The unescaped selector string.
*/
export const unescapeSelector = (selector = '') => {
if (typeof selector === 'string' && selector.indexOf('\\', 0) >= 0) {
const arr = selector.split('\\');
const selectorItems = [arr[0]];
const l = arr.length;
for (let i = 1; i < l; i++) {
const item = arr[i];
if (item === '' && i === l - 1) {
selectorItems.push(U_FFFD);
} else if (item === '') {
// Empty segment at non-last position means \\ (escaped backslash)
selectorItems.push('\\');
// skip the next segment which is the remainder after \\
i++;
if (i < l) {
selectorItems.push(arr[i]);
}
} else {
const hexExists = /^([\da-f]{1,6}\s?)/i.exec(item);
if (hexExists) {
const [, hex] = hexExists;
let str;
try {
const low = parseInt('D800', HEX);
const high = parseInt('DFFF', HEX);
const deci = parseInt(hex, HEX);
if (deci === 0 || (deci >= low && deci <= high)) {
str = U_FFFD;
} else {
str = String.fromCodePoint(deci);
}
} catch (e) {
str = U_FFFD;
}
let postStr = '';
if (item.length > hex.length) {
postStr = item.substring(hex.length);
}
selectorItems.push(`${str}${postStr}`);
} else if (/^[\n\r\f]/.test(item)) {
// whitespace
selectorItems.push(`\\${item}`);
} else {
selectorItems.push(item);
}
}
}
return selectorItems.join('');
}
return selector;
};
/**
* Preprocesses a selector string according to the specification.
* @see https://drafts.csswg.org/css-syntax-3/#input-preprocessing
* @param {string} value - The value to preprocess.
* @returns {string} The preprocessed selector string.
*/
export const preprocess = value => {
// Non-string values will be converted to string.
if (typeof value !== 'string') {
if (value === undefined || value === null) {
return getType(value).toLowerCase();
} else if (Array.isArray(value)) {
return value.join(',');
} else if (Object.hasOwn(value, 'toString')) {
return value.toString();
} else {
throw new DOMException(`Invalid selector ${value}`, SYNTAX_ERR);
}
}
let selector = value;
let index = 0;
while (index >= 0) {
// @see https://drafts.csswg.org/selectors/#id-selectors
index = selector.indexOf('#', index);
if (index < 0) {
break;
}
const preHash = selector.substring(0, index + 1);
let postHash = selector.substring(index + 1);
const codePoint = postHash.codePointAt(0);
if (codePoint > BIT_FFFF) {
const str = `\\${codePoint.toString(HEX)} `;
if (postHash.length === DUO) {
postHash = str;
} else {
postHash = `${str}${postHash.substring(DUO)}`;
}
}
selector = `${preHash}${postHash}`;
index++;
}
selector = selector
.replace(/\f|\r\n?/g, '\n')
.replace(/[\0\uD800-\uDFFF]|\\$/g, U_FFFD);
if (selector === '&') {
return '';
}
return selector;
};
/**
* Creates an Abstract Syntax Tree (AST) from a CSS selector string.
* @param {string} sel - The CSS selector string.
* @param {string} context - Parse what part of CSS.
* @returns {object} The parsed AST object.
*/
export const parseSelector = (sel, context = 'selectorList') => {
const selector = preprocess(sel);
// invalid selectors
if (/^$|^\s*>|,\s*$/.test(selector)) {
throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
}
try {
return cssTree.parse(selector, { context });
} catch (e) {
const { message } = e;
if (
/^(?:"\]"|Attribute selector [()\s,=~^$*|]+) is expected$/.test(
message
) &&
!selector.endsWith(']')
) {
const index = selector.lastIndexOf('[');
const selPart = selector.substring(index);
if (selPart.includes('"')) {
const quotes = selPart.match(/"/g).length;
if (quotes % 2) {
return parseSelector(`${selector}"]`);
}
return parseSelector(`${selector}]`);
}
return parseSelector(`${selector}]`);
} else if (message === '")" is expected') {
// workaround for https://github.com/csstree/csstree/issues/283
if (REG_EMPTY_PS_FUNC.test(selector)) {
return parseSelector(`${selector.replaceAll(REG_EMPTY_PS_FUNC, '()')}`);
} else if (!selector.endsWith(')')) {
return parseSelector(`${selector})`);
} else {
throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
}
} else {
throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
}
}
};
/**
* Walks the provided AST to collect selector branches and gather information
* about its contents.
* @param {object} ast - The AST to traverse.
* @param {boolean} toObject - True if converts ast to object, false otherwise.
* @param {function(object): void} [callback] - Optional callback for each node.
* @returns {{branches: Array<object>, info: object}} An object containing the selector branches and info.
*/
export const walkAST = (ast = {}, toObject = false, callback = null) => {
const branches = new Set();
const info = {
hasForgivenPseudoFunc: false,
hasHasPseudoFunc: false,
hasLogicalPseudoFunc: false,
hasNestedSelector: false,
hasNestingSelector: false,
hasNotPseudoFunc: false,
hasNthChildOfSelector: false,
hasStatePseudoClass: false,
hasUnsupportedPseudoClass: false
};
const opt = {
enter(node) {
if (typeof callback === 'function') {
callback(node);
}
switch (node.type) {
case CLASS_SELECTOR: {
if (/^-?\d/.test(node.name)) {
throw new DOMException(
`Invalid selector .${node.name}`,
SYNTAX_ERR
);
}
break;
}
case ID_SELECTOR: {
if (/^-?\d/.test(node.name)) {
throw new DOMException(
`Invalid selector #${node.name}`,
SYNTAX_ERR
);
}
break;
}
case PS_CLASS_SELECTOR: {
const name = node.name.toLowerCase();
if (KEYS_LOGICAL.has(name)) {
info.hasNestedSelector = true;
info.hasLogicalPseudoFunc = true;
if (name === 'has') {
info.hasHasPseudoFunc = true;
} else if (name === 'not') {
info.hasNotPseudoFunc = true;
} else {
info.hasForgivenPseudoFunc = true;
}
} else if (KEYS_PS_CLASS_STATE.has(name)) {
info.hasStatePseudoClass = true;
} else if (
KEYS_SHADOW_HOST.has(name) &&
Array.isArray(node.children) &&
node.children.length
) {
info.hasNestedSelector = true;
} else if (!KEYS_PS_CLASS_SUPPORTED.has(name)) {
info.hasUnsupportedPseudoClass = true;
}
break;
}
case PS_ELEMENT_SELECTOR: {
if (REG_SHADOW_PS_ELEMENT.test(node.name)) {
info.hasNestedSelector = true;
}
break;
}
case NTH: {
if (node.selector) {
info.hasNestedSelector = true;
info.hasNthChildOfSelector = true;
}
break;
}
case SELECTOR: {
branches.add(node.children);
break;
}
case NEST_SELECTOR: {
info.hasNestingSelector = true;
break;
}
default:
}
}
};
const clonedAst = cssTree.clone(ast);
cssTree.walk(toObject ? cssTree.toPlainObject(clonedAst) : clonedAst, opt);
if (info.hasNestedSelector === true) {
cssTree.findAll(clonedAst, (node, item, list) => {
if (list) {
if (node.type === PS_CLASS_SELECTOR && KEYS_LOGICAL.has(node.name)) {
const itemList = list.filter(i => {
const { name, type } = i;
return type === PS_CLASS_SELECTOR && KEYS_LOGICAL.has(name);
});
for (const { children } of itemList) {
// SelectorList
for (const { children: grandChildren } of children) {
// Selector
for (const { children: greatGrandChildren } of grandChildren) {
if (branches.has(greatGrandChildren)) {
branches.delete(greatGrandChildren);
}
}
}
}
} else if (
node.type === PS_CLASS_SELECTOR &&
KEYS_SHADOW_HOST.has(node.name) &&
Array.isArray(node.children) &&
node.children.length
) {
const itemList = list.filter(i => {
const { children, name, type } = i;
const res =
type === PS_CLASS_SELECTOR &&
KEYS_SHADOW_HOST.has(name) &&
Array.isArray(children) &&
children.length;
return res;
});
for (const { children } of itemList) {
// Selector
for (const { children: grandChildren } of children) {
if (branches.has(grandChildren)) {
branches.delete(grandChildren);
}
}
}
} else if (
node.type === PS_ELEMENT_SELECTOR &&
REG_SHADOW_PS_ELEMENT.test(node.name)
) {
const itemList = list.filter(i => {
const { name, type } = i;
const res =
type === PS_ELEMENT_SELECTOR && REG_SHADOW_PS_ELEMENT.test(name);
return res;
});
for (const { children } of itemList) {
// Selector
for (const { children: grandChildren } of children) {
if (branches.has(grandChildren)) {
branches.delete(grandChildren);
}
}
}
} else if (node.type === NTH && node.selector) {
const itemList = list.filter(i => {
const { selector, type } = i;
const res = type === NTH && selector;
return res;
});
for (const { selector } of itemList) {
const { children } = selector;
// Selector
for (const { children: grandChildren } of children) {
if (branches.has(grandChildren)) {
branches.delete(grandChildren);
}
}
}
}
}
});
}
return {
info,
branches: [...branches]
};
};
/**
* Comparison function for sorting AST nodes based on specificity.
* @param {object} a - The first AST node.
* @param {object} b - The second AST node.
* @returns {number} -1, 0 or 1, depending on the sort order.
*/
export const compareASTNodes = (a, b) => {
const bitA = AST_SORT_ORDER.get(a.type);
const bitB = AST_SORT_ORDER.get(b.type);
if (bitA === bitB) {
return 0;
} else if (bitA > bitB) {
return 1;
} else {
return -1;
}
};
/**
* Sorts a collection of AST nodes based on CSS specificity rules.
* @param {Array<object>} asts - A collection of AST nodes to sort.
* @returns {Array<object>} A new array containing the sorted AST nodes.
*/
export const sortAST = asts => {
const arr = [...asts];
if (arr.length > 1) {
arr.sort(compareASTNodes);
}
return arr;
};
/**
* Parses a type selector's name, which may include a namespace prefix.
* @param {string} selector - The type selector name (e.g., 'ns|E' or 'E').
* @returns {{prefix: string, localName: string}} An object with `prefix` and
* `localName` properties.
*/
export const parseAstName = selector => {
let prefix;
let localName;
if (selector && typeof selector === 'string') {
if (selector.indexOf('|') > -1) {
[prefix, localName] = selector.split('|');
} else {
prefix = '*';
localName = selector;
}
} else {
throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
}
return {
prefix,
localName
};
};
/**
* Extracts the rightmost subject keys (id, class, tag) from a CSS selector AST.
* @param {object} ast - The AST representation of the CSS selector.
* @returns {Array<{id: string|null, className: string|null, tag: string|null}>} The list of extracted keys for each selector group.
*/
export const extractSubjectsAst = ast => {
const subjects = [];
if (ast?.type === 'SelectorList') {
for (const selectorNode of ast.children) {
let idKey = null;
let classKey = null;
let tagKey = null;
let current = selectorNode.children.tail;
while (current) {
const node = current.data;
if (node.type === COMBINATOR) {
break;
}
if (node.type === ID_SELECTOR) {
idKey = idKey ?? unescapeSelector(node.name);
} else if (node.type === CLASS_SELECTOR) {
classKey = classKey ?? unescapeSelector(node.name);
} else if (node.type === TYPE_SELECTOR) {
const { localName } = parseAstName(unescapeSelector(node.name));
if (localName !== '*') {
tagKey = tagKey ?? localName.toLowerCase();
}
}
current = current.prev;
}
subjects.push({ id: idKey, className: classKey, tag: tagKey });
}
}
return subjects;
};
/* Re-exported from css-tree. */
export { find as findAST, generate as generateCSS } from 'css-tree';
+86
View File
@@ -0,0 +1,86 @@
/**
* processor.js
*/
/* import */
import { sortAST, unescapeSelector } from './parser.js';
import { isInvalidCombinator } from './selector.js';
import { generateException } from './utility.js';
/* constants */
import { COMBINATOR, SYNTAX_ERR } from './constant.js';
/**
* SelectorProcessor
* Processes raw selector branches into an internal AST format.
*/
export class SelectorProcessor {
#context;
/**
* @param {import('./finder.js').Finder} context - The Finder instance.
*/
constructor(context) {
this.#context = context;
}
/**
* Processes selector branches into the internal AST.
* @param {Array.<object>} branches - The raw selector branches.
* @param {string} selector - The original CSS selector string.
* @returns {object} An object containing the ast and a descendant flag.
*/
process(branches, selector) {
let descendant = false;
const ast = [];
for (const items of branches) {
const branch = [];
let prevType = null;
const itemsLen = items.length;
if (itemsLen) {
const leaves = new Set();
for (let j = 0; j < itemsLen; j++) {
const item = items[j];
const isLast = j === itemsLen - 1;
// Validate combinator syntax.
if (isInvalidCombinator(item.type, prevType, isLast)) {
const msg = `Invalid selector ${selector}`;
this.#context.onError(
generateException(msg, SYNTAX_ERR, this.#context.window)
);
return { ast: [], descendant: false, invalidate: false };
}
if (item.type === COMBINATOR) {
// Flag if the branch contains descendant or child combinators.
if (item.name === ' ' || item.name === '>') {
descendant = true;
}
branch.push({ combo: item, leaves: sortAST(leaves) });
leaves.clear();
} else {
// Unescape selector names and detect namespaces.
if (item.name && typeof item.name === 'string') {
const unescapedName = unescapeSelector(item.name);
if (unescapedName !== item.name) {
item.name = unescapedName;
}
if (/[|:]/.test(unescapedName)) {
item.namespace = true;
}
}
leaves.add(item);
}
prevType = item.type;
// Handle the trailing items after the loop finishes.
if (isLast) {
branch.push({ combo: null, leaves: sortAST(leaves) });
leaves.clear();
}
}
}
// Initialize the structure required for node collection.
ast.push({ branch, dir: null, filtered: false, find: false });
}
return { ast, descendant };
}
}
+334
View File
@@ -0,0 +1,334 @@
/**
* selector.js
*/
/* import */
import * as cssTree from 'css-tree';
import { generateException } from './utility.js';
/* constants */
import {
ATTR_TYPE,
COMBINATOR,
COMBO,
COMPOUND_I,
HAS_COMPOUND,
KEYS_LOGICAL,
KEYS_PS_CLASS_SUPPORTED,
LOGIC_COMPLEX,
LOGIC_COMPOUND,
N_TH,
PSEUDO_CLASS,
PS_CLASS_SELECTOR,
PS_ELEMENT_SELECTOR,
SELECTOR,
SYNTAX_ERR,
TAG_TYPE_WO_UNIVERSAL,
TARGET_ALL
} from './constant.js';
/* regexp */
const REG_EXCLUDE_BASIC =
/[|\\]|::|[^\u0021-\u007F\s]|\[\s*[\w$*=^|~-]+(?:(?:"[\w$*=^|~\s'-]+"|'[\w$*=^|~\s"-]+')?(?:\s+[\w$*=^|~-]+)+|"[^"\]]{1,255}|'[^'\]]{1,255})\s*\]|:(?:is|where)\(\s*\)|\[\s*data-/;
const REG_EXCLUDE_QSA = new RegExp(
`(?:^(?:[A-Z]|\\.)[\\w-]*$|${COMPOUND_I}${COMBO}${COMPOUND_I})`,
'i'
);
const REG_COMPLEX = new RegExp(`${COMPOUND_I}${COMBO}${COMPOUND_I}`, 'i');
const REG_LOGIC_COMPLEX = new RegExp(
`:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPLEX})`
);
const REG_LOGIC_COMPOUND = new RegExp(
`:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPOUND})`
);
const REG_LOGIC_HAS_COMPOUND = new RegExp(
`:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPOUND}|${HAS_COMPOUND})`
);
const REG_END_WITH_HAS = new RegExp(`:${HAS_COMPOUND}$`);
const REG_WO_LOGICAL = new RegExp(`:(?!${PSEUDO_CLASS}|${N_TH})`);
const REG_COMBO = new RegExp(COMBO);
const REG_ID = /#(\D[^#.*]+)/g;
const REG_CLASS = /\.(\D[^#.*]+)/g;
const REG_TAG = /^([^#.]+)/;
const REG_INVALID_SYNTAX =
/[+~>]\s*[+~>]|^\s*[+~>]|[+~>]\s*$|^\s*,|,\s*,|,\s*$/;
const REG_TEST_LIB = new RegExp(
`^(?:[*]?${ATTR_TYPE}(?:\\s*,\\s*${TAG_TYPE_WO_UNIVERSAL}${COMBO}${TAG_TYPE_WO_UNIVERSAL})?)$`
);
/**
* Find a nested :has() pseudo-class.
* @param {object} leaf - The AST leaf to check.
* @returns {?object} The leaf if it's :has, otherwise null.
*/
export const findNestedHas = leaf => leaf.name === 'has';
/**
* Find a logical pseudo-class that contains a nested :has().
* @param {object} leaf - The AST leaf to check.
* @returns {?object} The leaf if it matches, otherwise null.
*/
export const findLogicalWithNestedHas = leaf => {
if (KEYS_LOGICAL.has(leaf.name) && cssTree.find(leaf, findNestedHas)) {
return leaf;
}
return null;
};
/**
* Validates nesting restrictions within :has() arguments.
* @param {Array<object>} astChildren - The AST nodes representing the :has() arguments.
* @returns {boolean} False if there's an invalid nesting constraint violation.
*/
export const validateHasNesting = astChildren => {
const l = astChildren.length;
for (let i = 0; i < l; i++) {
const item = cssTree.find(astChildren[i], findLogicalWithNestedHas);
if (item) {
// If nested :has() is wrapped inside :is() or :where(), it is forgiven.
if (item.name !== 'is' && item.name !== 'where') {
return false;
}
}
}
return true;
};
/**
* Creates a callback function to validate :has() nesting during AST walk.
* @param {object} globalObj - The global window object.
* @returns {function(object): void} The callback function for walkAST.
*/
export const createHasValidator = globalObj => node => {
if (
node.type === PS_CLASS_SELECTOR &&
node.name.toLowerCase() === 'has' &&
!validateHasNesting(Array.from(node.children || []))
) {
const css = cssTree.generate(node);
throw generateException(
`Disallowed nested :has() pseudo-class: ${css}`,
SYNTAX_ERR,
globalObj
);
}
};
/**
* Check if a combinator node is invalid (leading, trailing, or consecutive).
* @param {string} type - The current node type.
* @param {string|null} prevType - The previous node type.
* @param {boolean} isLast - Whether the current node is the last in the list.
* @returns {boolean} True if the combinator is invalid.
*/
export const isInvalidCombinator = (type, prevType, isLast) =>
type === COMBINATOR &&
(prevType === null || prevType === COMBINATOR || isLast);
/**
* Checks if a given AST is supported by the DOMSelector engine.
* @param {object} ast - The AST to validate.
* @returns {boolean} True if the selector is fully supported.
*/
export const isSupportedAST = ast => {
let isSupported = true;
const walk = (
node,
context = { insideHas: false, insideForgiving: false }
) => {
if (!isSupported || !node) {
return;
}
const nextContext = { ...context };
if (node.type === PS_ELEMENT_SELECTOR) {
isSupported = false;
return;
} else if (node.type === PS_CLASS_SELECTOR) {
let name = node.name;
if (name && typeof name === 'string') {
name = name.toLowerCase();
}
if (!KEYS_PS_CLASS_SUPPORTED.has(name)) {
isSupported = false;
return;
}
if (name === 'has') {
if (context.insideHas && !context.insideForgiving) {
isSupported = false;
return;
}
nextContext.insideHas = true;
} else if (name === 'is' || name === 'where') {
nextContext.insideForgiving = true;
}
}
if (node.children) {
let prevType = null;
if (node.children.head !== undefined) {
let current = node.children.head;
while (current) {
if (!current.data) {
current = current.next;
continue;
}
const childType = current.data.type;
if (
node.type === SELECTOR &&
isInvalidCombinator(childType, prevType, !current.next)
) {
if (!(prevType === null && context.insideHas)) {
isSupported = false;
return;
}
}
prevType = childType;
walk(current.data, nextContext);
if (!isSupported) {
return;
}
current = current.next;
}
} else if (Array.isArray(node.children)) {
const l = node.children.length;
for (let i = 0; i < l; i++) {
const child = node.children[i];
if (!child) {
continue;
}
const childType = child.type;
if (
node.type === SELECTOR &&
isInvalidCombinator(childType, prevType, i === l - 1)
) {
if (!(prevType === null && context.insideHas)) {
isSupported = false;
return;
}
}
prevType = childType;
walk(child, nextContext);
if (!isSupported) {
return;
}
}
}
}
if (node.selector) {
walk(node.selector, nextContext);
}
};
walk(ast);
return isSupported;
};
/**
* Extracts the rightmost subject keys (id, class, tag) from a selector.
* @param {string} selector - The CSS selector string to parse.
* @param {boolean} caseSensitive - True if the tag should be case-sensitive.
* @returns {Array<{id: string|null, className: string|null, tag: string|null}>} The list of extracted keys for each selector group.
*/
export const extractSubjectsRegExp = (selector, caseSensitive) => {
const subjects = [];
const groups = selector.split(',');
for (let i = 0; i < groups.length; i++) {
const group = groups[i].trim();
if (!group) {
continue;
}
const compounds = group.split(REG_COMBO);
const rightmost = compounds[compounds.length - 1];
let idKey = null;
let classKey = null;
let tagKey = null;
if (rightmost) {
const idMatch = rightmost.match(REG_ID);
if (idMatch) {
idKey = idMatch[idMatch.length - 1].slice(1);
}
const classMatch = rightmost.match(REG_CLASS);
if (classMatch) {
classKey = classMatch[classMatch.length - 1].slice(1);
}
const tagMatch = rightmost.match(REG_TAG);
if (tagMatch) {
const tag = tagMatch[1];
if (tag !== '*') {
tagKey = caseSensitive ? tag : tag.toLowerCase();
}
}
}
subjects.push({ id: idKey, className: classKey, tag: tagKey });
}
return subjects;
};
/**
* Filter a selector for use with nwsapi.
* @param {string} selector - The selector string.
* @param {string} target - The target type.
* @returns {boolean} - True if the selector is valid for nwsapi.
*/
export const filterSelector = (selector, target) => {
// Basic validation and fast-fail for null/undefined/non-string values.
if (
!selector ||
typeof selector !== 'string' ||
/null|undefined/.test(selector)
) {
return false;
}
// Validate syntax.
if (REG_INVALID_SYNTAX.test(selector)) {
return false;
}
// Target-specific early exits.
if (
target === TARGET_ALL &&
REG_EXCLUDE_QSA.test(selector) &&
!REG_TEST_LIB.test(selector)
) {
return false;
}
// Exclude various complex or unsupported selectors early.
// i.e. non-ASCII, escaped selectors, namespaced selectors, pseudo-elements.
if (
selector.includes('/') ||
selector.includes('&') ||
REG_EXCLUDE_BASIC.test(selector)
) {
return false;
}
// Validate attribute selector integrity.
if (selector.includes('[')) {
const index = selector.lastIndexOf('[');
if (selector.indexOf(']', index) === -1) {
return false;
}
}
// Logic for pseudo-classes.
if (selector.includes(':')) {
// Determine if the selector has complex logical structures.
const isComplex =
target === TARGET_ALL ? false : REG_COMPLEX.test(selector);
// Handle :has() specifically.
if (selector.includes(':has(')) {
if (!isComplex || REG_LOGIC_HAS_COMPOUND.test(selector)) {
return false;
}
return REG_END_WITH_HAS.test(selector);
}
// Handle :is() and :not().
if (/(?:is|not)\(/.test(selector)) {
if (isComplex) {
return !REG_LOGIC_COMPLEX.test(selector);
} else {
return !REG_LOGIC_COMPOUND.test(selector);
}
}
// Default check for other pseudo-classes against known list.
if (REG_WO_LOGICAL.test(selector)) {
return false;
}
}
return true;
};
+949
View File
@@ -0,0 +1,949 @@
/**
* utility.js
*/
/* import */
import bidiFactory from 'bidi-js';
import isCustomElementName from 'is-potential-custom-element-name';
/* constants */
import {
CLASS_SELECTOR,
DIR_NEXT,
DIR_PREV,
DOCUMENT_FRAGMENT_NODE,
DOCUMENT_NODE,
DOCUMENT_POSITION_CONTAINS,
DOCUMENT_POSITION_PRECEDING,
ELEMENT_NODE,
ID_SELECTOR,
INPUT_BUTTON,
INPUT_EDIT,
INPUT_LTR,
INPUT_TEXT,
PS_ELEMENT_SELECTOR,
SHOW_ELEMENT,
TARGET_FIRST,
TEXT_NODE,
TYPE_FROM,
TYPE_SELECTOR,
TYPE_TO
} from './constant.js';
const KEYS_DIR_AUTO = new Set([...INPUT_BUTTON, ...INPUT_TEXT, 'hidden']);
const KEYS_DIR_LTR = new Set(INPUT_LTR);
const KEYS_INPUT_EDIT = new Set(INPUT_EDIT);
const KEYS_NODE_DIR_EXCLUDE = new Set(['bdi', 'script', 'style', 'textarea']);
const KEYS_NODE_FOCUSABLE = new Set(['button', 'select', 'textarea']);
const KEYS_NODE_FOCUSABLE_SVG = new Set([
'clipPath',
'defs',
'desc',
'linearGradient',
'marker',
'mask',
'metadata',
'pattern',
'radialGradient',
'script',
'style',
'symbol',
'title'
]);
/* regexp */
const REG_IS_HTML = /^(?:application\/xhtml\+x|text\/ht)ml$/;
const REG_IS_XML =
/^(?:application\/(?:[\w\-.]+\+)?|image\/[\w\-.]+\+|text\/)xml$/;
/**
* Get type of an object.
* @param {object} o - Object to check.
* @returns {string} - Type of the object.
*/
export const getType = o =>
Object.prototype.toString.call(o).slice(TYPE_FROM, TYPE_TO);
/**
* Verify array contents.
* @param {Array} arr - The array.
* @param {string} type - Expected type, e.g. 'String'.
* @throws {TypeError} - Throws if array or its items are of unexpected type.
* @returns {Array} - The verified array.
*/
export const verifyArray = (arr, type) => {
if (!Array.isArray(arr)) {
throw new TypeError(`Unexpected type ${getType(arr)}`);
}
if (typeof type !== 'string') {
throw new TypeError(`Unexpected type ${getType(type)}`);
}
for (const item of arr) {
if (getType(item) !== type) {
throw new TypeError(`Unexpected type ${getType(item)}`);
}
}
return arr;
};
/**
* Generate a DOMException.
* @param {string} msg - The error message.
* @param {string} name - The error name.
* @param {object} globalObject - The global object (e.g., window).
* @returns {DOMException} The generated DOMException object.
*/
export const generateException = (msg, name, globalObject = globalThis) => {
return new globalObject.DOMException(msg, name);
};
/**
* Resolve content document, root node, and check if it's in a shadow DOM.
* @param {object} node - Document, DocumentFragment, or Element node.
* @returns {Array.<object|boolean>} - [document, root, isInShadow].
*/
export const resolveContent = node => {
if (!node?.nodeType) {
throw new TypeError(`Unexpected type ${getType(node)}`);
}
let document;
let root;
let shadow;
switch (node.nodeType) {
case DOCUMENT_NODE: {
document = node;
root = node;
break;
}
case DOCUMENT_FRAGMENT_NODE: {
const { host, mode, ownerDocument } = node;
document = ownerDocument;
root = node;
shadow = host && (mode === 'close' || mode === 'open');
break;
}
case ELEMENT_NODE: {
document = node.ownerDocument;
let refNode = node;
while (refNode) {
const { host, mode, nodeType, parentNode } = refNode;
if (nodeType === DOCUMENT_FRAGMENT_NODE) {
shadow = host && (mode === 'close' || mode === 'open');
break;
} else if (parentNode) {
refNode = parentNode;
} else {
break;
}
}
root = refNode;
break;
}
default: {
throw new TypeError(`Unexpected node ${node.nodeName}`);
}
}
return [document, root, !!shadow];
};
/**
* Traverse node tree with a TreeWalker.
* @param {object} node - The target node.
* @param {object} walker - The TreeWalker instance.
* @param {boolean} [force] - Traverse only to the next node.
* @returns {?object} - The current node if found, otherwise null.
*/
export const traverseNode = (node, walker, force = false) => {
if (!node?.nodeType) {
throw new TypeError(`Unexpected type ${getType(node)}`);
}
if (!walker) {
return null;
}
let refNode = walker.currentNode;
if (refNode === node) {
return refNode;
} else if (force || refNode.contains(node)) {
refNode = walker.nextNode();
while (refNode) {
if (refNode === node) {
break;
}
refNode = walker.nextNode();
}
return refNode;
} else {
if (refNode !== walker.root) {
let bool;
while (refNode) {
if (refNode === node) {
bool = true;
break;
} else if (refNode === walker.root || refNode.contains(node)) {
break;
}
refNode = walker.parentNode();
}
if (bool) {
return refNode;
}
}
if (node.nodeType === ELEMENT_NODE) {
let bool;
while (refNode) {
if (refNode === node) {
bool = true;
break;
}
refNode = walker.nextNode();
}
if (bool) {
return refNode;
}
}
}
return null;
};
/**
* Check if a node is a custom element.
* @param {object} node - The Element node.
* @param {object} [opt] - Options.
* @param {boolean} [opt.formAssociated] - True if the node is form associated.
* @returns {boolean} - True if it's a custom element.
*/
export const isCustomElement = (node, { formAssociated } = {}) => {
if (!node?.nodeType) {
throw new TypeError(`Unexpected type ${getType(node)}`);
}
if (node.nodeType !== ELEMENT_NODE) {
return false;
}
const { localName, ownerDocument } = node;
const window = ownerDocument.defaultView;
let elmConstructor;
const attr = node.getAttribute('is');
if (attr) {
elmConstructor =
isCustomElementName(attr) && window.customElements.get(attr);
} else {
elmConstructor =
isCustomElementName(localName) && window.customElements.get(localName);
}
if (elmConstructor) {
if (formAssociated) {
return !!elmConstructor.formAssociated;
}
return true;
}
return false;
};
/**
* Get slotted text content.
* @param {object} node - The Element node (likely a <slot>).
* @returns {?string} - The text content.
*/
export const getSlottedTextContent = node => {
if (!node?.nodeType) {
throw new TypeError(`Unexpected type ${getType(node)}`);
}
if (typeof node.assignedNodes !== 'function') {
return null;
}
const nodes = node.assignedNodes();
if (nodes.length) {
let text = '';
const l = nodes.length;
for (let i = 0; i < l; i++) {
const item = nodes[i];
text = item.textContent.trim();
if (text) {
break;
}
}
return text;
}
return node.textContent.trim();
};
/**
* Get directionality of a node.
* @see https://html.spec.whatwg.org/multipage/dom.html#the-dir-attribute
* @param {object} node - The Element node.
* @param {WeakMap} [dirCache] - Cache for directionality.
* @returns {?string} - 'ltr' or 'rtl'.
*/
export const getDirectionality = (node, dirCache = new WeakMap()) => {
if (!node?.nodeType) {
throw new TypeError(`Unexpected type ${getType(node)}`);
}
if (node.nodeType !== ELEMENT_NODE) {
return null;
}
if (dirCache.has(node)) {
return dirCache.get(node);
}
const { dir: dirAttr, localName, parentNode } = node;
const { getEmbeddingLevels } = bidiFactory();
let result = 'ltr';
if (dirAttr === 'ltr' || dirAttr === 'rtl') {
result = dirAttr;
} else if (dirAttr === 'auto') {
let text = '';
switch (localName) {
case 'input': {
if (!node.type || KEYS_DIR_AUTO.has(node.type)) {
text = node.value;
} else if (KEYS_DIR_LTR.has(node.type)) {
result = 'ltr';
text = null; // Flag to skip text evaluation
}
break;
}
case 'slot': {
text = getSlottedTextContent(node);
break;
}
case 'textarea': {
text = node.value;
break;
}
default: {
const items = [].slice.call(node.childNodes);
for (const item of items) {
const {
dir: itemDir,
localName: itemLocalName,
nodeType: itemNodeType,
textContent: itemTextContent
} = item;
if (itemNodeType === TEXT_NODE) {
text = itemTextContent.trim();
} else if (
itemNodeType === ELEMENT_NODE &&
!KEYS_NODE_DIR_EXCLUDE.has(itemLocalName) &&
(!itemDir || (itemDir !== 'ltr' && itemDir !== 'rtl'))
) {
if (itemLocalName === 'slot') {
text = getSlottedTextContent(item);
} else {
text = itemTextContent.trim();
}
}
if (text) {
break;
}
}
}
}
if (text !== null) {
if (text) {
const {
paragraphs: [{ level }]
} = getEmbeddingLevels(text);
if (level % 2 === 1) {
result = 'rtl';
}
} else if (parentNode) {
const { nodeType: parentNodeType } = parentNode;
if (parentNodeType === ELEMENT_NODE) {
result = getDirectionality(parentNode, dirCache);
}
}
}
} else if (localName === 'input' && node.type === 'tel') {
result = 'ltr';
} else if (localName === 'bdi') {
const text = node.textContent.trim();
if (text) {
const {
paragraphs: [{ level }]
} = getEmbeddingLevels(text);
if (level % 2 === 1) {
result = 'rtl';
}
}
} else if (parentNode) {
if (localName === 'slot') {
const text = getSlottedTextContent(node);
if (text) {
const {
paragraphs: [{ level }]
} = getEmbeddingLevels(text);
if (level % 2 === 1) {
result = 'rtl';
} else {
result = 'ltr';
}
} else {
const { nodeType: parentNodeType } = parentNode;
if (parentNodeType === ELEMENT_NODE) {
result = getDirectionality(parentNode, dirCache);
}
}
} else {
const { nodeType: parentNodeType } = parentNode;
if (parentNodeType === ELEMENT_NODE) {
result = getDirectionality(parentNode, dirCache);
}
}
}
dirCache.set(node, result);
return result;
};
/**
* Get language attribute of a node.
* @param {object} node - The Element node.
* @param {WeakMap} [langCache] - Cache for language attributes.
* @returns {?string} - Language attribute value.
*/
export const getLanguageAttribute = (node, langCache = new WeakMap()) => {
if (!node?.nodeType) {
throw new TypeError(`Unexpected type ${getType(node)}`);
}
if (node.nodeType !== ELEMENT_NODE) {
return null;
}
if (langCache.has(node)) {
return langCache.get(node);
}
const { contentType } = node.ownerDocument;
const isHtml = REG_IS_HTML.test(contentType);
const isXml = REG_IS_XML.test(contentType);
let isShadow = false;
let result;
const visited = [];
// Traverse up from the current node to the root.
let current = node;
while (current) {
if (current.nodeType === ELEMENT_NODE && langCache.has(current)) {
result = langCache.get(current);
break;
}
if (current.nodeType === ELEMENT_NODE) {
visited.push(current);
}
// Check if the current node is an element.
switch (current.nodeType) {
case ELEMENT_NODE: {
// Check for and return the language attribute if present.
if (isHtml && current.hasAttribute('lang')) {
result = current.getAttribute('lang');
} else if (isXml && current.hasAttribute('xml:lang')) {
result = current.getAttribute('xml:lang');
}
break;
}
case DOCUMENT_FRAGMENT_NODE: {
// Continue traversal if the current node is a shadow root.
if (current.host) {
isShadow = true;
}
break;
}
case DOCUMENT_NODE:
default: {
// Stop if we reach the root document node.
result = null;
}
}
if (result !== undefined) {
break;
}
if (isShadow) {
current = current.host;
isShadow = false;
} else if (current.parentNode) {
current = current.parentNode;
} else {
break;
}
}
if (result === undefined) {
result = null;
}
for (const visitedNode of visited) {
langCache.set(visitedNode, result);
}
return result;
};
/**
* Check if content is editable.
* NOTE: Not implemented in jsdom https://github.com/jsdom/jsdom/issues/1670
* @param {object} node - The Element node.
* @returns {boolean} - True if content is editable.
*/
export const isContentEditable = node => {
if (!node?.nodeType) {
throw new TypeError(`Unexpected type ${getType(node)}`);
}
if (node.nodeType !== ELEMENT_NODE) {
return false;
}
if (typeof node.isContentEditable === 'boolean') {
return node.isContentEditable;
} else if (node.ownerDocument.designMode === 'on') {
return true;
} else {
let attr;
if (node.hasAttribute('contenteditable')) {
attr = node.getAttribute('contenteditable');
} else {
attr = 'inherit';
}
switch (attr) {
case '':
case 'true': {
return true;
}
case 'plaintext-only': {
// FIXME:
// @see https://github.com/w3c/editing/issues/470
// @see https://github.com/whatwg/html/issues/10651
return true;
}
case 'false': {
return false;
}
default: {
if (node?.parentNode?.nodeType === ELEMENT_NODE) {
return isContentEditable(node.parentNode);
}
return false;
}
}
}
};
/**
* Check if a node is visible.
* @param {object} node - The Element node.
* @returns {boolean} - True if the node is visible.
*/
export const isVisible = node => {
if (node?.nodeType !== ELEMENT_NODE) {
return false;
}
// TODO: switch to node.checkVisibility().
const window = node.ownerDocument.defaultView;
const { display, visibility } = window.getComputedStyle(node);
return display !== 'none' && visibility === 'visible';
};
/**
* Check if focus is visible on the node.
* @param {object} node - The Element node.
* @returns {boolean} - True if focus is visible.
*/
export const isFocusVisible = node => {
if (node?.nodeType !== ELEMENT_NODE) {
return false;
}
const { localName, type } = node;
switch (localName) {
case 'input': {
if (!type || KEYS_INPUT_EDIT.has(type)) {
return true;
}
return false;
}
case 'textarea': {
return true;
}
default: {
return isContentEditable(node);
}
}
};
/**
* Check if an area is focusable.
* @param {object} node - The Element node.
* @returns {boolean} - True if the area is focusable.
*/
export const isFocusableArea = node => {
if (node?.nodeType !== ELEMENT_NODE) {
return false;
}
if (!node.isConnected) {
return false;
}
const window = node.ownerDocument.defaultView;
if (node instanceof window.HTMLElement) {
if (Number.isInteger(parseInt(node.getAttribute('tabindex')))) {
return true;
}
if (isContentEditable(node)) {
return true;
}
const { localName, parentNode } = node;
switch (localName) {
case 'a': {
if (node.href || node.hasAttribute('href')) {
return true;
}
return false;
}
case 'iframe': {
return true;
}
case 'input': {
if (
node.disabled ||
node.hasAttribute('disabled') ||
node.hidden ||
node.hasAttribute('hidden')
) {
return false;
}
return true;
}
case 'summary': {
if (parentNode.localName === 'details') {
let child = parentNode.firstElementChild;
let bool = false;
while (child) {
if (child.localName === 'summary') {
bool = child === node;
break;
}
child = child.nextElementSibling;
}
return bool;
}
return false;
}
default: {
if (
KEYS_NODE_FOCUSABLE.has(localName) &&
!(node.disabled || node.hasAttribute('disabled'))
) {
return true;
}
}
}
} else if (node instanceof window.SVGElement) {
if (Number.isInteger(parseInt(node.getAttributeNS(null, 'tabindex')))) {
const ns = 'http://www.w3.org/2000/svg';
let bool;
let refNode = node;
while (refNode.namespaceURI === ns) {
bool = KEYS_NODE_FOCUSABLE_SVG.has(refNode.localName);
if (bool) {
break;
}
if (refNode?.parentNode?.namespaceURI === ns) {
refNode = refNode.parentNode;
} else {
break;
}
}
if (bool) {
return false;
}
return true;
}
if (
node.localName === 'a' &&
(node.href || node.hasAttributeNS(null, 'href'))
) {
return true;
}
}
return false;
};
/**
* Check if a node is focusable.
* NOTE: Not applied, needs fix in jsdom itself.
* @see https://github.com/whatwg/html/pull/8392
* @see https://phabricator.services.mozilla.com/D156219
* @see https://github.com/jsdom/jsdom/issues/3029
* @see https://github.com/jsdom/jsdom/issues/3464
* @param {object} node - The Element node.
* @returns {boolean} - True if the node is focusable.
*/
export const isFocusable = node => {
if (node?.nodeType !== ELEMENT_NODE) {
return false;
}
const window = node.ownerDocument.defaultView;
let refNode = node;
let res = true;
while (refNode) {
if (refNode.disabled || refNode.hasAttribute('disabled')) {
res = false;
break;
}
if (refNode.hidden || refNode.hasAttribute('hidden')) {
res = false;
}
const { contentVisibility, display, visibility } =
window.getComputedStyle(refNode);
if (
display === 'none' ||
visibility !== 'visible' ||
(contentVisibility === 'hidden' && refNode !== node)
) {
res = false;
} else {
res = true;
}
if (res && refNode?.parentNode?.nodeType === ELEMENT_NODE) {
refNode = refNode.parentNode;
} else {
break;
}
}
return res;
};
/**
* Get namespace URI.
* @param {string} ns - The namespace prefix.
* @param {object} node - The Element node.
* @returns {?string} - The namespace URI.
*/
export const getNamespaceURI = (ns, node) => {
if (typeof ns !== 'string') {
throw new TypeError(`Unexpected type ${getType(ns)}`);
} else if (!node?.nodeType) {
throw new TypeError(`Unexpected type ${getType(node)}`);
}
if (!ns || node.nodeType !== ELEMENT_NODE) {
return null;
}
const { attributes } = node;
let res;
for (const attr of attributes) {
const { name, namespaceURI, prefix, value } = attr;
if (name === `xmlns:${ns}`) {
res = value;
} else if (prefix === ns) {
res = namespaceURI;
}
if (res) {
break;
}
}
return res ?? null;
};
/**
* Check if a namespace is declared.
* @param {string} ns - The namespace.
* @param {object} node - The Element node.
* @returns {boolean} - True if the namespace is declared.
*/
export const isNamespaceDeclared = (ns = '', node = {}) => {
if (!ns || typeof ns !== 'string' || node?.nodeType !== ELEMENT_NODE) {
return false;
}
if (node.lookupNamespaceURI(ns)) {
return true;
}
const root = node.ownerDocument.documentElement;
let parent = node;
let res;
while (parent) {
res = getNamespaceURI(ns, parent);
if (res || parent === root) {
break;
}
parent = parent.parentNode;
}
return !!res;
};
/**
* Check if nodeA precedes and/or contains nodeB.
* @param {object} nodeA - The first Element node.
* @param {object} nodeB - The second Element node.
* @returns {boolean} - True if nodeA precedes nodeB.
*/
export const isPreceding = (nodeA, nodeB) => {
if (!nodeA?.nodeType) {
throw new TypeError(`Unexpected type ${getType(nodeA)}`);
} else if (!nodeB?.nodeType) {
throw new TypeError(`Unexpected type ${getType(nodeB)}`);
}
if (nodeA.nodeType !== ELEMENT_NODE || nodeB.nodeType !== ELEMENT_NODE) {
return false;
}
const posBit = nodeB.compareDocumentPosition(nodeA);
const res =
posBit & DOCUMENT_POSITION_PRECEDING || posBit & DOCUMENT_POSITION_CONTAINS;
return !!res;
};
/**
* Comparison function for sorting nodes based on document position.
* @param {object} a - The first node.
* @param {object} b - The second node.
* @returns {number} - Sort order.
*/
export const compareNodes = (a, b) => {
if (isPreceding(b, a)) {
return 1;
}
return -1;
};
/**
* Sort a collection of nodes.
* @param {Array.<object>|Set.<object>} nodes - Collection of nodes.
* @returns {Array.<object>} - Collection of sorted nodes.
*/
export const sortNodes = (nodes = []) => {
const arr = [...nodes];
if (arr.length > 1) {
arr.sort(compareNodes);
}
return arr;
};
/**
* Traverses AST nodes to find the most optimal seed selector
* (ID > Class > Tag).
* @param {Array} nodes - AST nodes to traverse.
* @param {object} [state] - The current state of the search.
* @returns {object} The search state containing the best seed.
*/
export const findBestSeed = (nodes, state = { seed: null, priority: 0 }) => {
for (const node of nodes) {
if (state.priority === 3) {
return state;
}
if (Array.isArray(node)) {
findBestSeed(node, state);
} else if (node && typeof node === 'object') {
// ID Selector (Fastest: getElementById)
if (node.type === ID_SELECTOR) {
state.seed = { type: 'id', value: node.name };
state.priority = 3;
return state;
} else if (node.type === CLASS_SELECTOR && state.priority < 2) {
// Class Selector (Faster: getElementsByClassName)
state.seed = { type: 'class', value: node.name };
state.priority = 2;
} else if (
node.type === TYPE_SELECTOR &&
state.priority < 1 &&
node.name !== '*'
) {
// Type/Tag Selector (Excludes universal '*')
state.seed = { type: 'tag', value: node.name };
state.priority = 1;
}
if (node.children) {
findBestSeed(node.children, state);
}
}
}
return state;
};
/**
* Traces the DOM tree upwards and sideways from a seed element,
* populating the allowlist with safe paths for :has() evaluation.
* @param {object} current - The starting seed element.
* @param {WeakSet} list - The WeakSet to populate.
* @param {Set} visitedAncestors - The Set to track visited nodes.
* @returns {void}
*/
export const populateHasAllowlist = (current, list, visitedAncestors) => {
list.add(current);
while (
current &&
(current.nodeType === ELEMENT_NODE ||
current.nodeType === DOCUMENT_FRAGMENT_NODE)
) {
if (visitedAncestors.has(current)) {
break;
}
visitedAncestors.add(current);
let sibling = current.previousElementSibling;
while (sibling) {
list.add(sibling);
sibling = sibling.previousElementSibling;
}
sibling = current.nextElementSibling;
while (sibling) {
list.add(sibling);
sibling = sibling.nextElementSibling;
}
current = current.parentNode;
if (current) {
list.add(current);
}
}
};
/**
* Collects all descendant elements of a given node using a TreeWalker.
* @param {Document|DocumentFragment|Element} node - The node to start from.
* @param {Document} document - The Document used to create the TreeWalker.
* @returns {Array<Element>} An array containing all descendant elements.
*/
export const collectAllDescendants = (node, document) => {
if (!node?.nodeType) {
throw new TypeError(`Unexpected type ${getType(node)}`);
}
if (document?.nodeType !== DOCUMENT_NODE) {
throw new TypeError(`Unexpected type ${getType(document)}`);
}
const walker = document.createTreeWalker(node, SHOW_ELEMENT);
const descendants = [];
let refNode = walker.nextNode();
while (refNode) {
descendants.push(refNode);
refNode = walker.nextNode();
}
return descendants;
};
/**
* Gets the traversal direction and starting twig.
* @param {Array.<object>} branch - The selector branch.
* @param {string} targetType - The target type.
* @param {boolean} hasScope - True if selector includes ':scope'.
* @param {boolean} scoped - True if traversal is scoped within target node.
* @returns {object} Object containing dir and twig properties.
*/
export const getTraversalStrategy = (branch, targetType, hasScope, scoped) => {
const branchLen = branch.length;
const firstTwig = branch[0];
if (branchLen === 1) {
return { dir: DIR_PREV, twig: firstTwig };
}
const lastTwig = branch[branchLen - 1];
const {
leaves: [{ name: firstName, type: firstType }]
} = firstTwig;
const {
leaves: [{ name: lastName, type: lastType }]
} = lastTwig;
const lastCombo = branch[branchLen - 2].combo?.name;
if (
hasScope ||
lastType === PS_ELEMENT_SELECTOR ||
lastType === ID_SELECTOR ||
lastCombo === '+' ||
lastCombo === '~'
) {
return { dir: DIR_PREV, twig: lastTwig };
} else if (firstType === ID_SELECTOR) {
return { dir: DIR_NEXT, twig: firstTwig };
} else if (firstName === '*' && firstType === TYPE_SELECTOR) {
return { dir: DIR_PREV, twig: lastTwig };
} else if (lastName === '*' && lastType === TYPE_SELECTOR) {
return { dir: DIR_NEXT, twig: firstTwig };
} else if (branchLen === 1 || branchLen === 2) {
return { dir: DIR_PREV, twig: lastTwig };
} else if (branchLen > 2 && scoped && targetType === TARGET_FIRST) {
return { dir: DIR_PREV, twig: lastTwig };
}
return { dir: DIR_NEXT, twig: firstTwig };
};