From 76cb9c2a39d477a64824a985ade40507e3bbade1 Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Fri, 13 Feb 2026 21:34:48 -0800 Subject: feat(vanilla): add testing infrastructure and tests (NK-wjnczv) --- .../@asamuzakjp/dom-selector/src/index.js | 353 +++ .../@asamuzakjp/dom-selector/src/js/constant.js | 129 + .../@asamuzakjp/dom-selector/src/js/finder.js | 3122 ++++++++++++++++++++ .../@asamuzakjp/dom-selector/src/js/matcher.js | 587 ++++ .../@asamuzakjp/dom-selector/src/js/parser.js | 431 +++ .../@asamuzakjp/dom-selector/src/js/utility.js | 1107 +++++++ 6 files changed, 5729 insertions(+) create mode 100644 vanilla/node_modules/@asamuzakjp/dom-selector/src/index.js create mode 100644 vanilla/node_modules/@asamuzakjp/dom-selector/src/js/constant.js create mode 100644 vanilla/node_modules/@asamuzakjp/dom-selector/src/js/finder.js create mode 100644 vanilla/node_modules/@asamuzakjp/dom-selector/src/js/matcher.js create mode 100644 vanilla/node_modules/@asamuzakjp/dom-selector/src/js/parser.js create mode 100644 vanilla/node_modules/@asamuzakjp/dom-selector/src/js/utility.js (limited to 'vanilla/node_modules/@asamuzakjp/dom-selector/src') diff --git a/vanilla/node_modules/@asamuzakjp/dom-selector/src/index.js b/vanilla/node_modules/@asamuzakjp/dom-selector/src/index.js new file mode 100644 index 0000000..8ec7b67 --- /dev/null +++ b/vanilla/node_modules/@asamuzakjp/dom-selector/src/index.js @@ -0,0 +1,353 @@ +/*! + * 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 { filterSelector, getType, initNwsapi } from './js/utility.js'; + +/* constants */ +import { + DOCUMENT_NODE, + DOCUMENT_FRAGMENT_NODE, + ELEMENT_NODE, + TARGET_ALL, + TARGET_FIRST, + TARGET_LINEAL, + TARGET_SELF +} from './js/constant.js'; +const MAX_CACHE = 1024; + +/** + * @typedef {object} CheckResult + * @property {boolean} match - The match result. + * @property {string?} pseudoElement - The pseudo-element, if any. + */ + +/* 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 { idlUtils } = opt; + this.#window = window; + this.#document = document ?? window.document; + this.#finder = new Finder(window); + this.#idlUtils = idlUtils; + this.#nwsapi = initNwsapi(window, document); + this.#cache = new LRUCache({ + max: MAX_CACHE + }); + } + + /** + * Clears the internal cache of finder results. + * @returns {void} + */ + clear = () => { + this.#finder.clearResults(true); + }; + + /** + * 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 = {}) => { + if (!node?.nodeType) { + const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`); + return this.#finder.onError(e, opt); + } else if (node.nodeType !== ELEMENT_NODE) { + const e = new this.#window.TypeError(`Unexpected node ${node.nodeName}`); + return this.#finder.onError(e, opt); + } + const document = node.ownerDocument; + if ( + document === this.#document && + document.contentType === 'text/html' && + document.documentElement && + node.parentNode + ) { + const cacheKey = `check_${selector}`; + let filterMatches = false; + if (this.#cache.has(cacheKey)) { + filterMatches = this.#cache.get(cacheKey); + } else { + filterMatches = filterSelector(selector, TARGET_SELF); + this.#cache.set(cacheKey, filterMatches); + } + if (filterMatches) { + try { + const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node; + const match = this.#nwsapi.match(selector, n); + return { + match, + pseudoElement: null + }; + } catch (e) { + // fall through + } + } + } + let res; + try { + if (this.#idlUtils) { + node = this.#idlUtils.wrapperForImpl(node); + } + opt.check = true; + opt.noexept = true; + opt.warn = false; + this.#finder.setup(selector, node, opt); + res = this.#finder.find(TARGET_SELF); + } catch (e) { + this.#finder.onError(e, opt); + } + return res; + }; + + /** + * 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, or `false` otherwise. + */ + matches = (selector, node, opt = {}) => { + if (!node?.nodeType) { + const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`); + return this.#finder.onError(e, opt); + } else if (node.nodeType !== ELEMENT_NODE) { + const e = new this.#window.TypeError(`Unexpected node ${node.nodeName}`); + return this.#finder.onError(e, opt); + } + const document = node.ownerDocument; + if ( + document === this.#document && + document.contentType === 'text/html' && + document.documentElement && + node.parentNode + ) { + const cacheKey = `matches_${selector}`; + let filterMatches = false; + if (this.#cache.has(cacheKey)) { + filterMatches = this.#cache.get(cacheKey); + } else { + filterMatches = filterSelector(selector, TARGET_SELF); + this.#cache.set(cacheKey, filterMatches); + } + if (filterMatches) { + try { + const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node; + const res = this.#nwsapi.match(selector, n); + return res; + } catch (e) { + // fall through + } + } + } + let res; + try { + if (this.#idlUtils) { + node = this.#idlUtils.wrapperForImpl(node); + } + this.#finder.setup(selector, node, opt); + const nodes = this.#finder.find(TARGET_SELF); + res = nodes.size; + } catch (e) { + this.#finder.onError(e, opt); + } + return !!res; + }; + + /** + * 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 = {}) => { + if (!node?.nodeType) { + const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`); + return this.#finder.onError(e, opt); + } else if (node.nodeType !== ELEMENT_NODE) { + const e = new this.#window.TypeError(`Unexpected node ${node.nodeName}`); + return this.#finder.onError(e, opt); + } + const document = node.ownerDocument; + if ( + document === this.#document && + document.contentType === 'text/html' && + document.documentElement && + node.parentNode + ) { + const cacheKey = `closest_${selector}`; + let filterMatches = false; + if (this.#cache.has(cacheKey)) { + filterMatches = this.#cache.get(cacheKey); + } else { + filterMatches = filterSelector(selector, TARGET_LINEAL); + this.#cache.set(cacheKey, filterMatches); + } + if (filterMatches) { + try { + const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node; + const res = this.#nwsapi.closest(selector, n); + return res; + } catch (e) { + // fall through + } + } + } + let res; + try { + if (this.#idlUtils) { + node = this.#idlUtils.wrapperForImpl(node); + } + this.#finder.setup(selector, node, opt); + const nodes = this.#finder.find(TARGET_LINEAL); + if (nodes.size) { + let refNode = node; + while (refNode) { + if (nodes.has(refNode)) { + res = refNode; + break; + } + refNode = refNode.parentNode; + } + } + } catch (e) { + this.#finder.onError(e, opt); + } + return res ?? 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 = {}) => { + if (!node?.nodeType) { + const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`); + return this.#finder.onError(e, opt); + } + /* + const document = + node.nodeType === DOCUMENT_NODE ? node : node.ownerDocument; + if ( + document === this.#document && + document.contentType === 'text/html' && + document.documentElement && + (node.nodeType !== DOCUMENT_FRAGMENT_NODE || !node.host) + ) { + const cacheKey = `querySelector_${selector}`; + let filterMatches = false; + if (this.#cache.has(cacheKey)) { + filterMatches = this.#cache.get(cacheKey); + } else { + filterMatches = filterSelector(selector, TARGET_FIRST); + this.#cache.set(cacheKey, filterMatches); + } + if (filterMatches) { + try { + const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node; + const res = this.#nwsapi.first(selector, n); + return res; + } catch (e) { + // fall through + } + } + } + */ + let res; + try { + if (this.#idlUtils) { + node = this.#idlUtils.wrapperForImpl(node); + } + this.#finder.setup(selector, node, opt); + const nodes = this.#finder.find(TARGET_FIRST); + if (nodes.size) { + [res] = [...nodes]; + } + } catch (e) { + this.#finder.onError(e, opt); + } + return res ?? 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} An array of elements, or an empty array. + */ + querySelectorAll = (selector, node, opt = {}) => { + if (!node?.nodeType) { + const e = new this.#window.TypeError(`Unexpected type ${getType(node)}`); + return this.#finder.onError(e, opt); + } + const document = + node.nodeType === DOCUMENT_NODE ? node : node.ownerDocument; + if ( + document === this.#document && + document.contentType === 'text/html' && + document.documentElement && + (node.nodeType !== DOCUMENT_FRAGMENT_NODE || !node.host) + ) { + const cacheKey = `querySelectorAll_${selector}`; + let filterMatches = false; + if (this.#cache.has(cacheKey)) { + filterMatches = this.#cache.get(cacheKey); + } else { + filterMatches = filterSelector(selector, TARGET_ALL); + this.#cache.set(cacheKey, filterMatches); + } + if (filterMatches) { + try { + const n = this.#idlUtils ? this.#idlUtils.wrapperForImpl(node) : node; + const res = this.#nwsapi.select(selector, n); + return res; + } catch (e) { + // fall through + } + } + } + let res; + try { + if (this.#idlUtils) { + node = this.#idlUtils.wrapperForImpl(node); + } + this.#finder.setup(selector, node, opt); + const nodes = this.#finder.find(TARGET_ALL); + if (nodes.size) { + res = [...nodes]; + } + } catch (e) { + this.#finder.onError(e, opt); + } + return res ?? []; + }; +} diff --git a/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/constant.js b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/constant.js new file mode 100644 index 0000000..02986ec --- /dev/null +++ b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/constant.js @@ -0,0 +1,129 @@ +/** + * constant.js + */ + +/* string */ +export const ATRULE = 'Atrule'; +export const ATTR_SELECTOR = 'AttributeSelector'; +export const CLASS_SELECTOR = 'ClassSelector'; +export const COMBINATOR = 'Combinator'; +export const IDENT = 'Identifier'; +export const ID_SELECTOR = 'IdSelector'; +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 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*\\)`; +// SUB_TYPE: attr, id, class, pseudo-class, note that [foo|=bar] is excluded +export const SUB_TYPE = '\\[[^|\\]]+\\]|[#.:][\\w-]+'; +export const SUB_TYPE_WO_PSEUDO = '\\[[^|\\]]+\\]|[#.][\\w-]+'; +// TAG_TYPE: *, tag +export const TAG_TYPE = '\\*|[A-Za-z][\\w-]*'; +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' +]); + +/* logical combination pseudo-classes */ +export const KEYS_LOGICAL = new Set(['has', 'is', 'not', 'where']); diff --git a/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/finder.js b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/finder.js new file mode 100644 index 0000000..05538d7 --- /dev/null +++ b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/finder.js @@ -0,0 +1,3122 @@ +/** + * finder.js + */ + +/* import */ +import { + matchAttributeSelector, + matchDirectionPseudoClass, + matchDisabledPseudoClass, + matchLanguagePseudoClass, + matchPseudoElementSelector, + matchReadOnlyPseudoClass, + matchTypeSelector +} from './matcher.js'; +import { + findAST, + generateCSS, + parseSelector, + sortAST, + unescapeSelector, + walkAST +} from './parser.js'; +import { + filterNodesByAnB, + findLogicalWithNestedHas, + generateException, + isCustomElement, + isFocusVisible, + isFocusableArea, + isVisible, + resolveContent, + sortNodes, + traverseNode +} from './utility.js'; + +/* constants */ +import { + ATTR_SELECTOR, + CLASS_SELECTOR, + COMBINATOR, + DOCUMENT_FRAGMENT_NODE, + ELEMENT_NODE, + FORM_PARTS, + ID_SELECTOR, + INPUT_CHECK, + INPUT_DATE, + INPUT_EDIT, + INPUT_TEXT, + KEYS_LOGICAL, + NOT_SUPPORTED_ERR, + PS_CLASS_SELECTOR, + PS_ELEMENT_SELECTOR, + SHOW_ALL, + SHOW_CONTAINER, + SYNTAX_ERR, + TARGET_ALL, + TARGET_FIRST, + TARGET_LINEAL, + TARGET_SELF, + TEXT_NODE, + TYPE_SELECTOR +} from './constant.js'; +const DIR_NEXT = 'next'; +const DIR_PREV = 'prev'; +const KEYS_FORM = new Set([...FORM_PARTS, 'fieldset', 'form']); +const KEYS_FORM_PS_VALID = new Set([...FORM_PARTS, 'form']); +const KEYS_INPUT_CHECK = new Set(INPUT_CHECK); +const KEYS_INPUT_PLACEHOLDER = new Set([...INPUT_TEXT, 'number']); +const KEYS_INPUT_RANGE = new Set([...INPUT_DATE, 'number', 'range']); +const KEYS_INPUT_REQUIRED = new Set([...INPUT_CHECK, ...INPUT_EDIT, 'file']); +const KEYS_INPUT_RESET = new Set(['button', 'reset']); +const KEYS_INPUT_SUBMIT = new Set(['image', 'submit']); +const KEYS_MODIFIER = new Set([ + 'Alt', + 'AltGraph', + 'CapsLock', + 'Control', + 'Fn', + 'FnLock', + 'Hyper', + 'Meta', + 'NumLock', + 'ScrollLock', + 'Shift', + 'Super', + 'Symbol', + 'SymbolLock' +]); +const KEYS_PS_UNCACHE = new Set([ + 'any-link', + 'defined', + 'dir', + 'link', + 'scope' +]); +const KEYS_PS_NTH_OF_TYPE = new Set([ + 'first-of-type', + 'last-of-type', + 'only-of-type' +]); + +/** + * Finder + * NOTE: #ast[i] corresponds to #nodes[i] + */ +export class Finder { + /* private fields */ + #ast; + #astCache; + #check; + #descendant; + #document; + #documentCache; + #documentURL; + #event; + #eventHandlers; + #focus; + #invalidate; + #invalidateResults; + #lastFocusVisible; + #node; + #nodeWalker; + #nodes; + #noexcept; + #pseudoElement; + #results; + #root; + #rootWalker; + #scoped; + #selector; + #shadow; + #verifyShadowHost; + #walkers; + #warn; + #window; + + /** + * constructor + * @param {object} window - The window object. + */ + constructor(window) { + this.#window = window; + this.#astCache = new WeakMap(); + this.#documentCache = new WeakMap(); + this.#event = null; + this.#focus = null; + this.#lastFocusVisible = null; + this.#eventHandlers = new Set([ + { + keys: ['focus', 'focusin'], + handler: this._handleFocusEvent + }, + { + keys: ['keydown', 'keyup'], + handler: this._handleKeyboardEvent + }, + { + keys: ['mouseover', 'mousedown', 'mouseup', 'click', 'mouseout'], + handler: this._handleMouseEvent + } + ]); + this._registerEventListeners(); + this.clearResults(true); + } + + /** + * Handles errors. + * @param {Error} e - The error object. + * @param {object} [opt] - Options. + * @param {boolean} [opt.noexcept] - If true, exceptions are not thrown. + * @throws {Error} Throws an error. + * @returns {void} + */ + onError = (e, opt = {}) => { + const noexcept = opt.noexcept ?? this.#noexcept; + if (noexcept) { + return; + } + const isDOMException = + e instanceof DOMException || e instanceof this.#window.DOMException; + if (isDOMException) { + if (e.name === NOT_SUPPORTED_ERR) { + if (this.#warn) { + console.warn(e.message); + } + return; + } + throw new this.#window.DOMException(e.message, e.name); + } + if (e.name in this.#window) { + throw new this.#window[e.name](e.message, { cause: e }); + } + throw e; + }; + + /** + * Sets up the finder. + * @param {string} selector - The CSS selector. + * @param {object} node - Document, DocumentFragment, or Element. + * @param {object} [opt] - Options. + * @param {boolean} [opt.check] - Indicates if running in internal check(). + * @param {boolean} [opt.noexcept] - If true, exceptions are not thrown. + * @param {boolean} [opt.warn] - If true, console warnings are enabled. + * @returns {object} The finder instance. + */ + setup = (selector, node, opt = {}) => { + const { check, noexcept, warn } = opt; + this.#check = !!check; + this.#noexcept = !!noexcept; + this.#warn = !!warn; + [this.#document, this.#root, this.#shadow] = resolveContent(node); + this.#documentURL = null; + this.#node = node; + this.#scoped = + this.#node !== this.#root && this.#node.nodeType === ELEMENT_NODE; + this.#selector = selector; + [this.#ast, this.#nodes] = this._correspond(selector); + this.#pseudoElement = []; + this.#walkers = new WeakMap(); + this.#nodeWalker = null; + this.#rootWalker = null; + this.#verifyShadowHost = null; + this.clearResults(); + return this; + }; + + /** + * Clear cached results. + * @param {boolean} all - clear all results + * @returns {void} + */ + clearResults = (all = false) => { + this.#invalidateResults = new WeakMap(); + if (all) { + this.#results = new WeakMap(); + } + }; + + /** + * Handles focus events. + * @private + * @param {Event} evt - The event object. + * @returns {void} + */ + _handleFocusEvent = evt => { + this.#focus = evt; + }; + + /** + * Handles keyboard events. + * @private + * @param {Event} evt - The event object. + * @returns {void} + */ + _handleKeyboardEvent = evt => { + const { key } = evt; + if (!KEYS_MODIFIER.has(key)) { + this.#event = evt; + } + }; + + /** + * Handles mouse events. + * @private + * @param {Event} evt - The event object. + * @returns {void} + */ + _handleMouseEvent = evt => { + this.#event = evt; + }; + + /** + * Registers event listeners. + * @private + * @returns {Array.} An array of return values from addEventListener. + */ + _registerEventListeners = () => { + const opt = { + capture: true, + passive: true + }; + const func = []; + for (const eventHandler of this.#eventHandlers) { + const { keys, handler } = eventHandler; + const l = keys.length; + for (let i = 0; i < l; i++) { + const key = keys[i]; + func.push(this.#window.addEventListener(key, handler, opt)); + } + } + return func; + }; + + /** + * Processes selector branches into the internal AST structure. + * @private + * @param {Array.>} branches - The branches from walkAST. + * @param {string} selector - The original selector for error reporting. + * @returns {{ast: Array, descendant: boolean}} + * An object with the AST, descendant flag. + */ + _processSelectorBranches = (branches, selector) => { + let descendant = false; + const ast = []; + const l = branches.length; + for (let i = 0; i < l; i++) { + const items = [...branches[i]]; + const branch = []; + let item = items.shift(); + if (item && item.type !== COMBINATOR) { + const leaves = new Set(); + while (item) { + if (item.type === COMBINATOR) { + const [nextItem] = items; + if (!nextItem || nextItem.type === COMBINATOR) { + const msg = `Invalid selector ${selector}`; + this.onError(generateException(msg, SYNTAX_ERR, this.#window)); + // Stop processing on invalid selector. + return { ast: [], descendant: false, invalidate: false }; + } + if (item.name === ' ' || item.name === '>') { + descendant = true; + } + branch.push({ combo: item, leaves: sortAST(leaves) }); + leaves.clear(); + } else { + 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); + } + if (items.length) { + item = items.shift(); + } else { + branch.push({ combo: null, leaves: sortAST(leaves) }); + leaves.clear(); + break; + } + } + } + ast.push({ branch, dir: null, filtered: false, find: false }); + } + return { ast, descendant }; + }; + + /** + * Corresponds AST and nodes. + * @private + * @param {string} selector - The CSS selector. + * @returns {Array.>} An array with the AST and nodes. + */ + _correspond = selector => { + const nodes = []; + this.#descendant = false; + this.#invalidate = false; + let ast; + if (this.#documentCache.has(this.#document)) { + const cachedItem = this.#documentCache.get(this.#document); + if (cachedItem && cachedItem.has(`${selector}`)) { + const item = cachedItem.get(`${selector}`); + ast = item.ast; + this.#descendant = item.descendant; + this.#invalidate = item.invalidate; + } + } + 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] = []; + } + } else { + let cssAst; + try { + cssAst = parseSelector(selector); + } catch (e) { + return this.onError(e); + } + const { branches, info } = walkAST(cssAst); + const { + hasHasPseudoFunc, + hasLogicalPseudoFunc, + hasNthChildOfSelector, + hasStatePseudoClass + } = info; + this.#invalidate = + hasHasPseudoFunc || + hasStatePseudoClass || + !!(hasLogicalPseudoFunc && hasNthChildOfSelector); + const processed = this._processSelectorBranches(branches, selector); + ast = processed.ast; + this.#descendant = processed.descendant; + let cachedItem; + if (this.#documentCache.has(this.#document)) { + cachedItem = this.#documentCache.get(this.#document); + } else { + cachedItem = new Map(); + } + cachedItem.set(`${selector}`, { + ast, + descendant: this.#descendant, + invalidate: this.#invalidate + }); + this.#documentCache.set(this.#document, cachedItem); + // Initialize nodes array for each branch. + for (let i = 0; i < ast.length; i++) { + nodes[i] = []; + } + } + return [ast, nodes]; + }; + + /** + * Creates a TreeWalker. + * @private + * @param {object} node - The Document, DocumentFragment, or Element node. + * @param {object} [opt] - Options. + * @param {boolean} [opt.force] - Force creation of a new TreeWalker. + * @param {number} [opt.whatToShow] - The NodeFilter whatToShow value. + * @returns {object} The TreeWalker object. + */ + _createTreeWalker = (node, opt = {}) => { + const { force = false, whatToShow = SHOW_CONTAINER } = opt; + if (force) { + return this.#document.createTreeWalker(node, whatToShow); + } else if (this.#walkers.has(node)) { + return this.#walkers.get(node); + } + const walker = this.#document.createTreeWalker(node, whatToShow); + this.#walkers.set(node, walker); + return walker; + }; + + /** + * Gets selector branches from cache or parses them. + * @private + * @param {object} selector - The AST. + * @returns {Array.>} The selector branches. + */ + _getSelectorBranches = selector => { + if (this.#astCache.has(selector)) { + return this.#astCache.get(selector); + } + const { branches } = walkAST(selector); + this.#astCache.set(selector, branches); + return branches; + }; + + /** + * Gets the children of a node, optionally filtered by a selector. + * @private + * @param {object} parentNode - The parent element. + * @param {Array.>} selectorBranches - The selector branches. + * @param {object} [opt] - Options. + * @returns {Array.} An array of child nodes. + */ + _getFilteredChildren = (parentNode, selectorBranches, opt = {}) => { + const children = []; + const walker = this._createTreeWalker(parentNode, { force: true }); + let childNode = walker.firstChild(); + while (childNode) { + if (selectorBranches) { + if (isVisible(childNode)) { + let isMatch = false; + const l = selectorBranches.length; + for (let i = 0; i < l; i++) { + const leaves = selectorBranches[i]; + if (this._matchLeaves(leaves, childNode, opt)) { + isMatch = true; + break; + } + } + if (isMatch) { + children.push(childNode); + } + } + } else { + children.push(childNode); + } + childNode = walker.nextSibling(); + } + return children; + }; + + /** + * Collects nth-child nodes. + * @private + * @param {object} anb - An+B options. + * @param {number} anb.a - The 'a' value. + * @param {number} anb.b - The 'b' value. + * @param {boolean} [anb.reverse] - If true, reverses the order. + * @param {object} [anb.selector] - The AST. + * @param {object} node - The Element node. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _collectNthChild = (anb, node, opt = {}) => { + const { a, b, selector } = anb; + const { parentNode } = node; + if (!parentNode) { + const matchedNode = new Set(); + if (node === this.#root && a * 1 + b * 1 === 1) { + if (selector) { + const selectorBranches = this._getSelectorBranches(selector); + const l = selectorBranches.length; + for (let i = 0; i < l; i++) { + const leaves = selectorBranches[i]; + if (this._matchLeaves(leaves, node, opt)) { + matchedNode.add(node); + break; + } + } + } else { + matchedNode.add(node); + } + } + return matchedNode; + } + const selectorBranches = selector + ? this._getSelectorBranches(selector) + : null; + const children = this._getFilteredChildren( + parentNode, + selectorBranches, + opt + ); + const matchedNodes = filterNodesByAnB(children, anb); + return new Set(matchedNodes); + }; + + /** + * Collects nth-of-type nodes. + * @private + * @param {object} anb - An+B options. + * @param {number} anb.a - The 'a' value. + * @param {number} anb.b - The 'b' value. + * @param {boolean} [anb.reverse] - If true, reverses the order. + * @param {object} node - The Element node. + * @returns {Set.} A collection of matched nodes. + */ + _collectNthOfType = (anb, node) => { + const { parentNode } = node; + if (!parentNode) { + if (node === this.#root && anb.a * 1 + anb.b * 1 === 1) { + return new Set([node]); + } + return new Set(); + } + const typedSiblings = []; + const walker = this._createTreeWalker(parentNode, { force: true }); + let sibling = walker.firstChild(); + while (sibling) { + if ( + sibling.localName === node.localName && + sibling.namespaceURI === node.namespaceURI && + sibling.prefix === node.prefix + ) { + typedSiblings.push(sibling); + } + sibling = walker.nextSibling(); + } + const matchedNodes = filterNodesByAnB(typedSiblings, anb); + return new Set(matchedNodes); + }; + + /** + * Matches An+B. + * @private + * @param {object} ast - The AST. + * @param {object} node - The Element node. + * @param {string} nthName - The name of the nth pseudo-class. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _matchAnPlusB = (ast, node, nthName, opt = {}) => { + const { + nth: { a, b, name: nthIdentName }, + selector + } = ast; + const anbMap = new Map(); + if (nthIdentName) { + if (nthIdentName === 'even') { + anbMap.set('a', 2); + anbMap.set('b', 0); + } else if (nthIdentName === 'odd') { + anbMap.set('a', 2); + anbMap.set('b', 1); + } + if (nthName.indexOf('last') > -1) { + anbMap.set('reverse', true); + } + } else { + if (typeof a === 'string' && /-?\d+/.test(a)) { + anbMap.set('a', a * 1); + } else { + anbMap.set('a', 0); + } + if (typeof b === 'string' && /-?\d+/.test(b)) { + anbMap.set('b', b * 1); + } else { + anbMap.set('b', 0); + } + if (nthName.indexOf('last') > -1) { + anbMap.set('reverse', true); + } + } + if (nthName === 'nth-child' || nthName === 'nth-last-child') { + if (selector) { + anbMap.set('selector', selector); + } + const anb = Object.fromEntries(anbMap); + const nodes = this._collectNthChild(anb, node, opt); + return nodes; + } else if (nthName === 'nth-of-type' || nthName === 'nth-last-of-type') { + const anb = Object.fromEntries(anbMap); + const nodes = this._collectNthOfType(anb, node); + return nodes; + } + return new Set(); + }; + + /** + * Matches the :has() pseudo-class function. + * @private + * @param {Array.} astLeaves - The AST leaves. + * @param {object} node - The Element node. + * @param {object} [opt] - Options. + * @returns {boolean} The result. + */ + _matchHasPseudoFunc = (astLeaves, node, opt = {}) => { + if (Array.isArray(astLeaves) && astLeaves.length) { + // Prepare a copy to avoid astLeaves being consumed. + const leaves = [...astLeaves]; + const [leaf] = leaves; + const { type: leafType } = leaf; + let combo; + if (leafType === COMBINATOR) { + combo = leaves.shift(); + } else { + combo = { + name: ' ', + type: COMBINATOR + }; + } + const twigLeaves = []; + while (leaves.length) { + const [item] = leaves; + const { type: itemType } = item; + if (itemType === COMBINATOR) { + break; + } else { + twigLeaves.push(leaves.shift()); + } + } + const twig = { + combo, + leaves: twigLeaves + }; + opt.dir = DIR_NEXT; + const nodes = this._matchCombinator(twig, node, opt); + if (nodes.size) { + if (leaves.length) { + let bool = false; + for (const nextNode of nodes) { + bool = this._matchHasPseudoFunc(leaves, nextNode, opt); + if (bool) { + break; + } + } + return bool; + } + return true; + } + } + return false; + }; + + /** + * Evaluates the :has() pseudo-class. + * @private + * @param {object} astData - The AST data. + * @param {object} node - The Element node. + * @param {object} [opt] - Options. + * @returns {?object} The matched node. + */ + _evaluateHasPseudo = (astData, node, opt) => { + const { branches } = astData; + let bool = false; + const l = branches.length; + for (let i = 0; i < l; i++) { + const leaves = branches[i]; + bool = this._matchHasPseudoFunc(leaves, node, opt); + if (bool) { + break; + } + } + if (!bool) { + return null; + } + if ( + (opt.isShadowRoot || this.#shadow) && + node.nodeType === DOCUMENT_FRAGMENT_NODE + ) { + return this.#verifyShadowHost ? node : null; + } + return node; + }; + + /** + * Matches logical pseudo-class functions. + * @private + * @param {object} astData - The AST data. + * @param {object} node - The Element node. + * @param {object} [opt] - Options. + * @returns {?object} The matched node. + */ + _matchLogicalPseudoFunc = (astData, node, opt = {}) => { + const { astName, branches, twigBranches } = astData; + // Handle :has(). + if (astName === 'has') { + return this._evaluateHasPseudo(astData, node, opt); + } + // Handle :is(), :not(), :where(). + const isShadowRoot = + (opt.isShadowRoot || this.#shadow) && + node.nodeType === DOCUMENT_FRAGMENT_NODE; + // Check for invalid shadow root. + if (isShadowRoot) { + let invalid = false; + for (const branch of branches) { + if (branch.length > 1) { + invalid = true; + break; + } else if (astName === 'not') { + const [{ type: childAstType }] = branch; + if (childAstType !== PS_CLASS_SELECTOR) { + invalid = true; + break; + } + } + } + if (invalid) { + return null; + } + } + opt.forgive = astName === 'is' || astName === 'where'; + const l = twigBranches.length; + let bool; + for (let i = 0; i < l; i++) { + const branch = twigBranches[i]; + const lastIndex = branch.length - 1; + const { leaves } = branch[lastIndex]; + bool = this._matchLeaves(leaves, node, opt); + if (bool && lastIndex > 0) { + let nextNodes = new Set([node]); + for (let j = lastIndex - 1; j >= 0; j--) { + const twig = branch[j]; + const arr = []; + opt.dir = DIR_PREV; + for (const nextNode of nextNodes) { + const m = this._matchCombinator(twig, nextNode, opt); + if (m.size) { + arr.push(...m); + } + } + if (arr.length) { + if (j === 0) { + bool = true; + } else { + nextNodes = new Set(arr); + } + } else { + bool = false; + break; + } + } + } + if (bool) { + break; + } + } + if (astName === 'not') { + if (bool) { + return null; + } + return node; + } else if (bool) { + return node; + } + return null; + }; + + /** + * match pseudo-class selector + * @private + * @see https://html.spec.whatwg.org/#pseudo-classes + * @param {object} ast - AST + * @param {object} node - Element node + * @param {object} [opt] - options + * @returns {Set.} - collection of matched nodes + */ + _matchPseudoClassSelector(ast, node, opt = {}) { + const { children: astChildren, name: astName } = ast; + const { localName, parentNode } = node; + const { forgive, warn = this.#warn } = opt; + const matched = new Set(); + // :has(), :is(), :not(), :where() + if (Array.isArray(astChildren) && KEYS_LOGICAL.has(astName)) { + if (!astChildren.length && astName !== 'is' && astName !== 'where') { + const css = generateCSS(ast); + const msg = `Invalid selector ${css}`; + return this.onError(generateException(msg, SYNTAX_ERR, this.#window)); + } + let astData; + if (this.#astCache.has(ast)) { + astData = this.#astCache.get(ast); + } else { + const { branches } = walkAST(ast); + if (astName === 'has') { + // Check for nested :has(). + let forgiven = false; + const l = astChildren.length; + for (let i = 0; i < l; i++) { + const child = astChildren[i]; + const item = findAST(child, findLogicalWithNestedHas); + if (item) { + const itemName = item.name; + if (itemName === 'is' || itemName === 'where') { + forgiven = true; + break; + } else { + const css = generateCSS(ast); + const msg = `Invalid selector ${css}`; + return this.onError( + generateException(msg, SYNTAX_ERR, this.#window) + ); + } + } + } + if (forgiven) { + return matched; + } + astData = { + astName, + branches + }; + } else { + const twigBranches = []; + const l = branches.length; + for (let i = 0; i < l; i++) { + const [...leaves] = branches[i]; + const branch = []; + const leavesSet = new Set(); + let item = leaves.shift(); + while (item) { + if (item.type === COMBINATOR) { + branch.push({ + combo: item, + leaves: [...leavesSet] + }); + leavesSet.clear(); + } else if (item) { + leavesSet.add(item); + } + if (leaves.length) { + item = leaves.shift(); + } else { + branch.push({ + combo: null, + leaves: [...leavesSet] + }); + leavesSet.clear(); + break; + } + } + twigBranches.push(branch); + } + astData = { + astName, + branches, + twigBranches + }; + this.#astCache.set(ast, astData); + } + } + const res = this._matchLogicalPseudoFunc(astData, node, opt); + if (res) { + matched.add(res); + } + } else if (Array.isArray(astChildren)) { + // :nth-child(), :nth-last-child(), nth-of-type(), :nth-last-of-type() + if (/^nth-(?:last-)?(?:child|of-type)$/.test(astName)) { + if (astChildren.length !== 1) { + const css = generateCSS(ast); + return this.onError( + generateException( + `Invalid selector ${css}`, + SYNTAX_ERR, + this.#window + ) + ); + } + const [branch] = astChildren; + const nodes = this._matchAnPlusB(branch, node, astName, opt); + return nodes; + } else { + switch (astName) { + // :dir() + case 'dir': { + if (astChildren.length !== 1) { + const css = generateCSS(ast); + return this.onError( + generateException( + `Invalid selector ${css}`, + SYNTAX_ERR, + this.#window + ) + ); + } + const [astChild] = astChildren; + const res = matchDirectionPseudoClass(astChild, node); + if (res) { + matched.add(node); + } + break; + } + // :lang() + case 'lang': { + if (!astChildren.length) { + const css = generateCSS(ast); + return this.onError( + generateException( + `Invalid selector ${css}`, + SYNTAX_ERR, + this.#window + ) + ); + } + let bool; + for (const astChild of astChildren) { + bool = matchLanguagePseudoClass(astChild, node); + if (bool) { + break; + } + } + if (bool) { + matched.add(node); + } + break; + } + // :state() + case 'state': { + if (isCustomElement(node)) { + const [{ value: stateValue }] = astChildren; + if (stateValue) { + if (node[stateValue]) { + matched.add(node); + } else { + for (const i in node) { + const prop = node[i]; + if (prop instanceof this.#window.ElementInternals) { + if (prop?.states?.has(stateValue)) { + matched.add(node); + } + break; + } + } + } + } + } + break; + } + case 'current': + case 'heading': + case 'nth-col': + case 'nth-last-col': { + if (warn) { + this.onError( + generateException( + `Unsupported pseudo-class :${astName}()`, + NOT_SUPPORTED_ERR, + this.#window + ) + ); + } + break; + } + // Ignore :host() and :host-context(). + case 'host': + case 'host-context': { + break; + } + // Deprecated in CSS Selectors 3. + case 'contains': { + if (warn) { + this.onError( + generateException( + `Unknown pseudo-class :${astName}()`, + NOT_SUPPORTED_ERR, + this.#window + ) + ); + } + break; + } + default: { + if (!forgive) { + this.onError( + generateException( + `Unknown pseudo-class :${astName}()`, + SYNTAX_ERR, + this.#window + ) + ); + } + } + } + } + } else if (KEYS_PS_NTH_OF_TYPE.has(astName)) { + if (node === this.#root) { + matched.add(node); + } else if (parentNode) { + switch (astName) { + case 'first-of-type': { + const [node1] = this._collectNthOfType( + { + a: 0, + b: 1 + }, + node + ); + if (node1) { + matched.add(node1); + } + break; + } + case 'last-of-type': { + const [node1] = this._collectNthOfType( + { + a: 0, + b: 1, + reverse: true + }, + node + ); + if (node1) { + matched.add(node1); + } + break; + } + // 'only-of-type' is handled by default. + default: { + const [node1] = this._collectNthOfType( + { + a: 0, + b: 1 + }, + node + ); + if (node1 === node) { + const [node2] = this._collectNthOfType( + { + a: 0, + b: 1, + reverse: true + }, + node + ); + if (node2 === node) { + matched.add(node); + } + } + } + } + } + } else { + switch (astName) { + case 'disabled': + case 'enabled': { + const isMatch = matchDisabledPseudoClass(astName, node); + if (isMatch) { + matched.add(node); + } + break; + } + case 'read-only': + case 'read-write': { + const isMatch = matchReadOnlyPseudoClass(astName, node); + if (isMatch) { + matched.add(node); + } + break; + } + case 'any-link': + case 'link': { + if ( + (localName === 'a' || localName === 'area') && + node.hasAttribute('href') + ) { + matched.add(node); + } + break; + } + case 'local-link': { + if ( + (localName === 'a' || localName === 'area') && + node.hasAttribute('href') + ) { + if (!this.#documentURL) { + this.#documentURL = new URL(this.#document.URL); + } + const { href, origin, pathname } = this.#documentURL; + const attrURL = new URL(node.getAttribute('href'), href); + if (attrURL.origin === origin && attrURL.pathname === pathname) { + matched.add(node); + } + } + break; + } + case 'visited': { + // prevent fingerprinting + break; + } + case 'hover': { + const { target, type } = this.#event ?? {}; + if ( + /^(?:click|mouse(?:down|over|up))$/.test(type) && + node.contains(target) + ) { + matched.add(node); + } + break; + } + case 'active': { + const { buttons, target, type } = this.#event ?? {}; + if (type === 'mousedown' && buttons & 1 && node.contains(target)) { + matched.add(node); + } + break; + } + case 'target': { + if (!this.#documentURL) { + this.#documentURL = new URL(this.#document.URL); + } + const { hash } = this.#documentURL; + if ( + node.id && + hash === `#${node.id}` && + this.#document.contains(node) + ) { + matched.add(node); + } + break; + } + case 'target-within': { + if (!this.#documentURL) { + this.#documentURL = new URL(this.#document.URL); + } + const { hash } = this.#documentURL; + if (hash) { + const id = hash.replace(/^#/, ''); + let current = this.#document.getElementById(id); + while (current) { + if (current === node) { + matched.add(node); + break; + } + current = current.parentNode; + } + } + break; + } + case 'scope': { + if (this.#node.nodeType === ELEMENT_NODE) { + if (!this.#shadow && node === this.#node) { + matched.add(node); + } + } else if (node === this.#document.documentElement) { + matched.add(node); + } + break; + } + case 'focus': { + const activeElement = this.#document.activeElement; + if (node === activeElement && isFocusableArea(node)) { + matched.add(node); + } else if (activeElement.shadowRoot) { + const activeShadowElement = activeElement.shadowRoot.activeElement; + let current = activeShadowElement; + while (current) { + if (current.nodeType === DOCUMENT_FRAGMENT_NODE) { + const { host } = current; + if (host === activeElement) { + if (isFocusableArea(node)) { + matched.add(node); + } else { + matched.add(host); + } + } + break; + } else { + current = current.parentNode; + } + } + } + break; + } + case 'focus-visible': { + if (node === this.#document.activeElement && isFocusableArea(node)) { + let bool; + if (isFocusVisible(node)) { + bool = true; + } else if (this.#focus) { + const { relatedTarget, target: focusTarget } = this.#focus; + if (focusTarget === node) { + if (isFocusVisible(relatedTarget)) { + bool = true; + } else if (this.#event) { + const { + altKey: eventAltKey, + ctrlKey: eventCtrlKey, + key: eventKey, + metaKey: eventMetaKey, + target: eventTarget, + type: eventType + } = this.#event; + // this.#event is irrelevant if eventTarget === relatedTarget + if (eventTarget === relatedTarget) { + if (this.#lastFocusVisible === null) { + bool = true; + } else if (focusTarget === this.#lastFocusVisible) { + bool = true; + } + } else if (eventKey === 'Tab') { + if ( + (eventType === 'keydown' && eventTarget !== node) || + (eventType === 'keyup' && eventTarget === node) + ) { + if (eventTarget === focusTarget) { + if (this.#lastFocusVisible === null) { + bool = true; + } else if ( + eventTarget === this.#lastFocusVisible && + relatedTarget === null + ) { + bool = true; + } + } else { + bool = true; + } + } + } else if (eventKey) { + if ( + (eventType === 'keydown' || eventType === 'keyup') && + !eventAltKey && + !eventCtrlKey && + !eventMetaKey && + eventTarget === node + ) { + bool = true; + } + } + } else if ( + relatedTarget === null || + relatedTarget === this.#lastFocusVisible + ) { + bool = true; + } + } + } + if (bool) { + this.#lastFocusVisible = node; + matched.add(node); + } else if (this.#lastFocusVisible === node) { + this.#lastFocusVisible = null; + } + } + break; + } + case 'focus-within': { + const activeElement = this.#document.activeElement; + if (node.contains(activeElement) && isFocusableArea(activeElement)) { + matched.add(node); + } else if (activeElement.shadowRoot) { + const activeShadowElement = activeElement.shadowRoot.activeElement; + if (node.contains(activeShadowElement)) { + matched.add(node); + } else { + let current = activeShadowElement; + while (current) { + if (current.nodeType === DOCUMENT_FRAGMENT_NODE) { + const { host } = current; + if (host === activeElement && node.contains(host)) { + matched.add(node); + } + break; + } else { + current = current.parentNode; + } + } + } + } + break; + } + case 'open': + case 'closed': { + if (localName === 'details' || localName === 'dialog') { + if (node.hasAttribute('open')) { + if (astName === 'open') { + matched.add(node); + } + } else if (astName === 'closed') { + matched.add(node); + } + } + break; + } + case 'placeholder-shown': { + 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; + if (localName === 'textarea') { + targetNode = node; + } else if (localName === 'input') { + if (node.hasAttribute('type')) { + if (KEYS_INPUT_PLACEHOLDER.has(node.getAttribute('type'))) { + targetNode = node; + } + } else { + targetNode = node; + } + } + if (targetNode && node.value === '') { + matched.add(node); + } + } + break; + } + case 'checked': { + const attrType = node.getAttribute('type'); + if ( + (node.checked && + localName === 'input' && + (attrType === 'checkbox' || attrType === 'radio')) || + (node.selected && localName === 'option') + ) { + matched.add(node); + } + break; + } + case 'indeterminate': { + if ( + (node.indeterminate && + localName === 'input' && + node.type === 'checkbox') || + (localName === 'progress' && !node.hasAttribute('value')) + ) { + matched.add(node); + } else if ( + localName === 'input' && + node.type === 'radio' && + !node.hasAttribute('checked') + ) { + const nodeName = node.name; + let parent = node.parentNode; + while (parent) { + if (parent.localName === 'form') { + break; + } + parent = parent.parentNode; + } + if (!parent) { + parent = this.#document.documentElement; + } + const walker = this._createTreeWalker(parent); + let refNode = traverseNode(parent, walker); + refNode = walker.firstChild(); + let checked; + while (refNode) { + if ( + refNode.localName === 'input' && + refNode.getAttribute('type') === 'radio' + ) { + if (refNode.hasAttribute('name')) { + if (refNode.getAttribute('name') === nodeName) { + checked = !!refNode.checked; + } + } else { + checked = !!refNode.checked; + } + if (checked) { + break; + } + } + refNode = walker.nextNode(); + } + if (!checked) { + matched.add(node); + } + } + break; + } + case 'default': { + // button[type="submit"], input[type="submit"], input[type="image"] + const attrType = node.getAttribute('type'); + if ( + (localName === 'button' && + !(node.hasAttribute('type') && KEYS_INPUT_RESET.has(attrType))) || + (localName === 'input' && + node.hasAttribute('type') && + KEYS_INPUT_SUBMIT.has(attrType)) + ) { + let form = node.parentNode; + while (form) { + if (form.localName === 'form') { + break; + } + form = form.parentNode; + } + if (form) { + const walker = this._createTreeWalker(form); + let refNode = traverseNode(form, walker); + refNode = walker.firstChild(); + while (refNode) { + const nodeName = refNode.localName; + const nodeAttrType = refNode.getAttribute('type'); + let m; + if (nodeName === 'button') { + m = !( + refNode.hasAttribute('type') && + KEYS_INPUT_RESET.has(nodeAttrType) + ); + } else if (nodeName === 'input') { + m = + refNode.hasAttribute('type') && + KEYS_INPUT_SUBMIT.has(nodeAttrType); + } + if (m) { + if (refNode === node) { + matched.add(node); + } + break; + } + refNode = walker.nextNode(); + } + } + // input[type="checkbox"], input[type="radio"] + } else if ( + localName === 'input' && + node.hasAttribute('type') && + node.hasAttribute('checked') && + KEYS_INPUT_CHECK.has(attrType) + ) { + matched.add(node); + // option + } else if (localName === 'option' && node.hasAttribute('selected')) { + matched.add(node); + } + break; + } + case 'valid': + case 'invalid': { + if (KEYS_FORM_PS_VALID.has(localName)) { + let valid; + if (node.checkValidity()) { + if (node.maxLength >= 0) { + if (node.maxLength >= node.value.length) { + valid = true; + } + } else { + valid = true; + } + } + if (valid) { + if (astName === 'valid') { + matched.add(node); + } + } else if (astName === 'invalid') { + matched.add(node); + } + } else if (localName === 'fieldset') { + const walker = this._createTreeWalker(node); + let refNode = traverseNode(node, walker); + refNode = walker.firstChild(); + let valid; + if (!refNode) { + valid = true; + } else { + while (refNode) { + if (KEYS_FORM_PS_VALID.has(refNode.localName)) { + if (refNode.checkValidity()) { + if (refNode.maxLength >= 0) { + valid = refNode.maxLength >= refNode.value.length; + } else { + valid = true; + } + } else { + valid = false; + } + if (!valid) { + break; + } + } + refNode = walker.nextNode(); + } + } + if (valid) { + if (astName === 'valid') { + matched.add(node); + } + } else if (astName === 'invalid') { + matched.add(node); + } + } + break; + } + case 'in-range': + case 'out-of-range': { + const attrType = node.getAttribute('type'); + if ( + localName === 'input' && + !(node.readonly || node.hasAttribute('readonly')) && + !(node.disabled || node.hasAttribute('disabled')) && + KEYS_INPUT_RANGE.has(attrType) + ) { + const flowed = + node.validity.rangeUnderflow || node.validity.rangeOverflow; + if (astName === 'out-of-range' && flowed) { + matched.add(node); + } else if ( + astName === 'in-range' && + !flowed && + (node.hasAttribute('min') || + node.hasAttribute('max') || + attrType === 'range') + ) { + matched.add(node); + } + } + break; + } + case 'required': + case 'optional': { + let required; + let optional; + if (localName === 'select' || localName === 'textarea') { + if (node.required || node.hasAttribute('required')) { + required = true; + } else { + optional = true; + } + } else if (localName === 'input') { + if (node.hasAttribute('type')) { + const attrType = node.getAttribute('type'); + if (KEYS_INPUT_REQUIRED.has(attrType)) { + if (node.required || node.hasAttribute('required')) { + required = true; + } else { + optional = true; + } + } else { + optional = true; + } + } else if (node.required || node.hasAttribute('required')) { + required = true; + } else { + optional = true; + } + } + if (astName === 'required' && required) { + matched.add(node); + } else if (astName === 'optional' && optional) { + matched.add(node); + } + break; + } + case 'root': { + if (node === this.#document.documentElement) { + matched.add(node); + } + break; + } + case 'empty': { + if (node.hasChildNodes()) { + const walker = this._createTreeWalker(node, { + force: true, + whatToShow: SHOW_ALL + }); + let refNode = walker.firstChild(); + let bool; + while (refNode) { + bool = + refNode.nodeType !== ELEMENT_NODE && + refNode.nodeType !== TEXT_NODE; + if (!bool) { + break; + } + refNode = walker.nextSibling(); + } + if (bool) { + matched.add(node); + } + } else { + matched.add(node); + } + break; + } + case 'first-child': { + if ( + (parentNode && node === parentNode.firstElementChild) || + node === this.#root + ) { + matched.add(node); + } + break; + } + case 'last-child': { + if ( + (parentNode && node === parentNode.lastElementChild) || + node === this.#root + ) { + matched.add(node); + } + break; + } + case 'only-child': { + if ( + (parentNode && + node === parentNode.firstElementChild && + node === parentNode.lastElementChild) || + node === this.#root + ) { + matched.add(node); + } + break; + } + case 'defined': { + if (node.hasAttribute('is') || localName.includes('-')) { + if (isCustomElement(node)) { + matched.add(node); + } + // NOTE: MathMLElement is not implemented in jsdom. + } else if ( + node instanceof this.#window.HTMLElement || + node instanceof this.#window.SVGElement + ) { + matched.add(node); + } + break; + } + case 'popover-open': { + if (node.popover && isVisible(node)) { + matched.add(node); + } + break; + } + // Ignore :host. + case 'host': { + break; + } + // Legacy pseudo-elements. + case 'after': + case 'before': + case 'first-letter': + case 'first-line': { + if (warn) { + this.onError( + generateException( + `Unsupported pseudo-element ::${astName}`, + NOT_SUPPORTED_ERR, + this.#window + ) + ); + } + break; + } + // Not supported. + case 'autofill': + case 'blank': + case 'buffering': + case 'current': + case 'fullscreen': + case 'future': + case 'has-slotted': + case 'heading': + case 'modal': + case 'muted': + case 'past': + case 'paused': + case 'picture-in-picture': + case 'playing': + case 'seeking': + case 'stalled': + case 'user-invalid': + case 'user-valid': + case 'volume-locked': + case '-webkit-autofill': { + if (warn) { + this.onError( + generateException( + `Unsupported pseudo-class :${astName}`, + NOT_SUPPORTED_ERR, + this.#window + ) + ); + } + break; + } + default: { + if (astName.startsWith('-webkit-')) { + if (warn) { + this.onError( + generateException( + `Unsupported pseudo-class :${astName}`, + NOT_SUPPORTED_ERR, + this.#window + ) + ); + } + } else if (!forgive) { + this.onError( + generateException( + `Unknown pseudo-class :${astName}`, + SYNTAX_ERR, + this.#window + ) + ); + } + } + } + } + return matched; + } + + /** + * Evaluates the :host() pseudo-class. + * @private + * @param {Array.} leaves - The AST leaves. + * @param {object} host - The host element. + * @param {object} ast - The original AST for error reporting. + * @returns {boolean} True if matched. + */ + _evaluateHostPseudo = (leaves, host, ast) => { + const l = leaves.length; + for (let i = 0; i < l; i++) { + const leaf = leaves[i]; + if (leaf.type === COMBINATOR) { + const css = generateCSS(ast); + const msg = `Invalid selector ${css}`; + this.onError(generateException(msg, SYNTAX_ERR, this.#window)); + return false; + } + if (!this._matchSelector(leaf, host).has(host)) { + return false; + } + } + return true; + }; + + /** + * Evaluates the :host-context() pseudo-class. + * @private + * @param {Array.} leaves - The AST leaves. + * @param {object} host - The host element. + * @param {object} ast - The original AST for error reporting. + * @returns {boolean} True if matched. + */ + _evaluateHostContextPseudo = (leaves, host, ast) => { + let parent = host; + while (parent) { + let bool; + const l = leaves.length; + for (let i = 0; i < l; i++) { + const leaf = leaves[i]; + if (leaf.type === COMBINATOR) { + const css = generateCSS(ast); + const msg = `Invalid selector ${css}`; + this.onError(generateException(msg, SYNTAX_ERR, this.#window)); + return false; + } + bool = this._matchSelector(leaf, parent).has(parent); + if (!bool) { + break; + } + } + if (bool) { + return true; + } + parent = parent.parentNode; + } + return false; + }; + + /** + * Matches shadow host pseudo-classes. + * @private + * @param {object} ast - The AST. + * @param {object} node - The DocumentFragment node. + * @returns {?object} The matched node. + */ + _matchShadowHostPseudoClass = (ast, node) => { + const { children: astChildren, name: astName } = ast; + // Handle simple pseudo-class (no arguments). + if (!Array.isArray(astChildren)) { + if (astName === 'host') { + return node; + } + const msg = `Invalid selector :${astName}`; + return this.onError(generateException(msg, SYNTAX_ERR, this.#window)); + } + // Handle functional pseudo-class like :host(...). + if (astName !== 'host' && astName !== 'host-context') { + const msg = `Invalid selector :${astName}()`; + return this.onError(generateException(msg, SYNTAX_ERR, this.#window)); + } + if (astChildren.length !== 1) { + const css = generateCSS(ast); + const msg = `Invalid selector ${css}`; + return this.onError(generateException(msg, SYNTAX_ERR, this.#window)); + } + const { host } = node; + const { branches } = walkAST(astChildren[0]); + const [branch] = branches; + const [...leaves] = branch; + let isMatch = false; + if (astName === 'host') { + isMatch = this._evaluateHostPseudo(leaves, host, ast); + // astName === 'host-context'. + } else { + isMatch = this._evaluateHostContextPseudo(leaves, host, ast); + } + return isMatch ? node : null; + }; + + /** + * Matches a selector for element nodes. + * @private + * @param {object} ast - The AST. + * @param {object} node - The Element node. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _matchSelectorForElement = (ast, node, opt = {}) => { + const { type: astType } = ast; + const astName = unescapeSelector(ast.name); + const matched = new Set(); + switch (astType) { + case ATTR_SELECTOR: { + if (matchAttributeSelector(ast, node, opt)) { + matched.add(node); + } + break; + } + case ID_SELECTOR: { + if (node.id === astName) { + matched.add(node); + } + break; + } + case CLASS_SELECTOR: { + if (node.classList.contains(astName)) { + matched.add(node); + } + break; + } + case PS_CLASS_SELECTOR: { + return this._matchPseudoClassSelector(ast, node, opt); + } + case TYPE_SELECTOR: { + if (matchTypeSelector(ast, node, opt)) { + matched.add(node); + } + break; + } + // PS_ELEMENT_SELECTOR is handled by default. + default: { + try { + if (opt.check) { + const css = generateCSS(ast); + this.#pseudoElement.push(css); + matched.add(node); + } else { + matchPseudoElementSelector(astName, astType, opt); + } + } catch (e) { + this.onError(e); + } + } + } + return matched; + }; + + /** + * Matches a selector for a shadow root. + * @private + * @param {object} ast - The AST. + * @param {object} node - The DocumentFragment node. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _matchSelectorForShadowRoot = (ast, node, opt = {}) => { + const { name: astName } = ast; + if (KEYS_LOGICAL.has(astName)) { + opt.isShadowRoot = true; + return this._matchPseudoClassSelector(ast, node, opt); + } + const matched = new Set(); + if (astName === 'host' || astName === 'host-context') { + const res = this._matchShadowHostPseudoClass(ast, node, opt); + if (res) { + this.#verifyShadowHost = true; + matched.add(res); + } + } + return matched; + }; + + /** + * Matches a selector. + * @private + * @param {object} ast - The AST. + * @param {object} node - The Document, DocumentFragment, or Element node. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _matchSelector = (ast, node, opt = {}) => { + if (node.nodeType === ELEMENT_NODE) { + return this._matchSelectorForElement(ast, node, opt); + } + if ( + this.#shadow && + node.nodeType === DOCUMENT_FRAGMENT_NODE && + ast.type === PS_CLASS_SELECTOR + ) { + return this._matchSelectorForShadowRoot(ast, node, opt); + } + return new Set(); + }; + + /** + * Matches leaves. + * @private + * @param {Array.} leaves - The AST leaves. + * @param {object} node - The node. + * @param {object} [opt] - Options. + * @returns {boolean} The result. + */ + _matchLeaves = (leaves, node, opt = {}) => { + const results = this.#invalidate ? this.#invalidateResults : this.#results; + let result = results.get(leaves); + if (result && result.has(node)) { + const { matched } = result.get(node); + return matched; + } + let cacheable = true; + if (node.nodeType === ELEMENT_NODE && KEYS_FORM.has(node.localName)) { + cacheable = false; + } + let bool; + const l = leaves.length; + for (let i = 0; i < l; i++) { + const leaf = leaves[i]; + switch (leaf.type) { + case ATTR_SELECTOR: + case ID_SELECTOR: { + cacheable = false; + break; + } + case PS_CLASS_SELECTOR: { + if (KEYS_PS_UNCACHE.has(leaf.name)) { + cacheable = false; + } + break; + } + default: { + // No action needed for other types. + } + } + bool = this._matchSelector(leaf, node, opt).has(node); + if (!bool) { + break; + } + } + if (cacheable) { + if (!result) { + result = new WeakMap(); + } + result.set(node, { + matched: bool + }); + results.set(leaves, result); + } + return bool; + }; + + /** + * Traverses all descendant nodes and collects matches. + * @private + * @param {object} baseNode - The base Element node or Element.shadowRoot. + * @param {Array.} leaves - The AST leaves. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _traverseAllDescendants = (baseNode, leaves, opt = {}) => { + const walker = this._createTreeWalker(baseNode); + traverseNode(baseNode, walker); + let currentNode = walker.firstChild(); + const nodes = new Set(); + while (currentNode) { + if (this._matchLeaves(leaves, currentNode, opt)) { + nodes.add(currentNode); + } + currentNode = walker.nextNode(); + } + return nodes; + }; + + /** + * Finds descendant nodes. + * @private + * @param {Array.} leaves - The AST leaves. + * @param {object} baseNode - The base Element node or Element.shadowRoot. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _findDescendantNodes = (leaves, baseNode, opt = {}) => { + const [leaf, ...filterLeaves] = leaves; + const { type: leafType } = leaf; + switch (leafType) { + case ID_SELECTOR: { + const canUseGetElementById = + !this.#shadow && + baseNode.nodeType === ELEMENT_NODE && + this.#root.nodeType !== ELEMENT_NODE; + if (canUseGetElementById) { + const leafName = unescapeSelector(leaf.name); + const nodes = new Set(); + const foundNode = this.#root.getElementById(leafName); + if ( + foundNode && + foundNode !== baseNode && + baseNode.contains(foundNode) + ) { + const isCompoundSelector = filterLeaves.length > 0; + if ( + !isCompoundSelector || + this._matchLeaves(filterLeaves, foundNode, opt) + ) { + nodes.add(foundNode); + } + } + return nodes; + } + // Fallback to default traversal if fast path is not applicable. + return this._traverseAllDescendants(baseNode, leaves, opt); + } + case PS_ELEMENT_SELECTOR: { + const leafName = unescapeSelector(leaf.name); + matchPseudoElementSelector(leafName, leafType, opt); + return new Set(); + } + default: { + return this._traverseAllDescendants(baseNode, leaves, opt); + } + } + }; + + /** + * Matches the descendant combinator ' '. + * @private + * @param {object} twig - The twig object. + * @param {object} node - The Element node. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _matchDescendantCombinator = (twig, node, opt = {}) => { + const { leaves } = twig; + const { parentNode } = node; + const { dir } = opt; + if (dir === DIR_NEXT) { + return this._findDescendantNodes(leaves, node, opt); + } + // DIR_PREV + const ancestors = []; + let refNode = parentNode; + while (refNode) { + if (this._matchLeaves(leaves, refNode, opt)) { + ancestors.push(refNode); + } + refNode = refNode.parentNode; + } + if (ancestors.length) { + // Reverse to maintain document order. + return new Set(ancestors.reverse()); + } + return new Set(); + }; + + /** + * Matches the child combinator '>'. + * @private + * @param {object} twig - The twig object. + * @param {object} node - The Element node. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _matchChildCombinator = (twig, node, opt = {}) => { + const { leaves } = twig; + const { dir } = opt; + const { parentNode } = node; + const matched = new Set(); + if (dir === DIR_NEXT) { + let refNode = node.firstElementChild; + while (refNode) { + if (this._matchLeaves(leaves, refNode, opt)) { + matched.add(refNode); + } + refNode = refNode.nextElementSibling; + } + } else { + // DIR_PREV + if (parentNode && this._matchLeaves(leaves, parentNode, opt)) { + matched.add(parentNode); + } + } + return matched; + }; + + /** + * Matches the adjacent sibling combinator '+'. + * @private + * @param {object} twig - The twig object. + * @param {object} node - The Element node. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _matchAdjacentSiblingCombinator = (twig, node, opt = {}) => { + const { leaves } = twig; + const { dir } = opt; + const matched = new Set(); + const refNode = + dir === DIR_NEXT ? node.nextElementSibling : node.previousElementSibling; + if (refNode && this._matchLeaves(leaves, refNode, opt)) { + matched.add(refNode); + } + return matched; + }; + + /** + * Matches the general sibling combinator '~'. + * @private + * @param {object} twig - The twig object. + * @param {object} node - The Element node. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _matchGeneralSiblingCombinator = (twig, node, opt = {}) => { + const { leaves } = twig; + const { dir } = opt; + const matched = new Set(); + let refNode = + dir === DIR_NEXT ? node.nextElementSibling : node.previousElementSibling; + while (refNode) { + if (this._matchLeaves(leaves, refNode, opt)) { + matched.add(refNode); + } + refNode = + dir === DIR_NEXT + ? refNode.nextElementSibling + : refNode.previousElementSibling; + } + return matched; + }; + + /** + * Matches a combinator. + * @private + * @param {object} twig - The twig object. + * @param {object} node - The Element node. + * @param {object} [opt] - Options. + * @returns {Set.} A collection of matched nodes. + */ + _matchCombinator = (twig, node, opt = {}) => { + const { + combo: { name: comboName } + } = twig; + switch (comboName) { + case '+': { + return this._matchAdjacentSiblingCombinator(twig, node, opt); + } + case '~': { + return this._matchGeneralSiblingCombinator(twig, node, opt); + } + case '>': { + return this._matchChildCombinator(twig, node, opt); + } + case ' ': + default: { + return this._matchDescendantCombinator(twig, node, opt); + } + } + }; + + /** + * Traverses with a TreeWalker and collects nodes matching the leaves. + * @private + * @param {TreeWalker} walker - The TreeWalker instance to use. + * @param {Array} leaves - The AST leaves to match against. + * @param {object} options - Traversal options. + * @param {Node} options.startNode - The node to start traversal from. + * @param {string} options.targetType - The type of target ('all' or 'first'). + * @param {Node} [options.boundaryNode] - The node to stop traversal at. + * @param {boolean} [options.force] - Force traversal to the next node. + * @returns {Array.} An array of matched nodes. + */ + _traverseAndCollectNodes = (walker, leaves, options) => { + const { boundaryNode, force, startNode, targetType } = options; + const collectedNodes = []; + let currentNode = traverseNode(startNode, walker, !!force); + if (!currentNode) { + return []; + } + // Adjust starting node. + if (currentNode.nodeType !== ELEMENT_NODE) { + currentNode = walker.nextNode(); + } else if (currentNode === startNode && currentNode !== this.#root) { + currentNode = walker.nextNode(); + } + const matchOpt = { + warn: this.#warn + }; + while (currentNode) { + // Stop when we reach the boundary. + if (boundaryNode) { + if (currentNode === boundaryNode) { + break; + } else if ( + targetType === TARGET_ALL && + !boundaryNode.contains(currentNode) + ) { + break; + } + } + if ( + this._matchLeaves(leaves, currentNode, matchOpt) && + currentNode !== this.#node + ) { + collectedNodes.push(currentNode); + // Stop after the first match if not collecting all. + if (targetType !== TARGET_ALL) { + break; + } + } + currentNode = walker.nextNode(); + } + return collectedNodes; + }; + + /** + * Finds matched node(s) preceding this.#node. + * @private + * @param {Array.} leaves - The AST leaves. + * @param {object} node - The node to start from. + * @param {object} opt - Options. + * @param {boolean} [opt.force] - If true, traverses only to the next node. + * @param {string} [opt.targetType] - The target type. + * @returns {Array.} A collection of matched nodes. + */ + _findPrecede = (leaves, node, opt = {}) => { + const { force, targetType } = opt; + if (!this.#rootWalker) { + this.#rootWalker = this._createTreeWalker(this.#root); + } + return this._traverseAndCollectNodes(this.#rootWalker, leaves, { + force, + targetType, + boundaryNode: this.#node, + startNode: node + }); + }; + + /** + * Finds matched node(s) in #nodeWalker. + * @private + * @param {Array.} leaves - The AST leaves. + * @param {object} node - The node to start from. + * @param {object} opt - Options. + * @param {boolean} [opt.precede] - If true, finds preceding nodes. + * @returns {Array.} A collection of matched nodes. + */ + _findNodeWalker = (leaves, node, opt = {}) => { + const { precede, ...traversalOpts } = opt; + if (precede) { + const precedeNodes = this._findPrecede(leaves, this.#root, opt); + if (precedeNodes.length) { + return precedeNodes; + } + } + if (!this.#nodeWalker) { + this.#nodeWalker = this._createTreeWalker(this.#node); + } + return this._traverseAndCollectNodes(this.#nodeWalker, leaves, { + startNode: node, + ...traversalOpts + }); + }; + + /** + * Matches the node itself. + * @private + * @param {Array} leaves - The AST leaves. + * @param {boolean} check - Indicates if running in internal check(). + * @returns {Array} An array containing [nodes, filtered, pseudoElement]. + */ + _matchSelf = (leaves, check = false) => { + const options = { check, warn: this.#warn }; + const matched = this._matchLeaves(leaves, this.#node, options); + const nodes = matched ? [this.#node] : []; + return [nodes, matched, this.#pseudoElement]; + }; + + /** + * Finds lineal nodes (self and ancestors). + * @private + * @param {Array} leaves - The AST leaves. + * @param {object} opt - Options. + * @returns {Array} An array containing [nodes, filtered]. + */ + _findLineal = (leaves, opt) => { + const { complex } = opt; + const nodes = []; + const options = { warn: this.#warn }; + const selfMatched = this._matchLeaves(leaves, this.#node, options); + if (selfMatched) { + nodes.push(this.#node); + } + if (!selfMatched || complex) { + let currentNode = this.#node.parentNode; + while (currentNode) { + if (this._matchLeaves(leaves, currentNode, options)) { + nodes.push(currentNode); + } + currentNode = currentNode.parentNode; + } + } + const filtered = nodes.length > 0; + return [nodes, filtered]; + }; + + /** + * Finds entry nodes for pseudo-element selectors. + * @private + * @param {object} leaf - The pseudo-element leaf from the AST. + * @param {Array.} filterLeaves - Leaves for compound selectors. + * @param {string} targetType - The type of target to find. + * @returns {object} The result { nodes, filtered, pending }. + */ + _findEntryNodesForPseudoElement = (leaf, filterLeaves, targetType) => { + let nodes = []; + let filtered = false; + if (targetType === TARGET_SELF && this.#check) { + const css = generateCSS(leaf); + this.#pseudoElement.push(css); + if (filterLeaves.length) { + [nodes, filtered] = this._matchSelf(filterLeaves, this.#check); + } else { + nodes.push(this.#node); + filtered = true; + } + } else { + matchPseudoElementSelector(leaf.name, leaf.type, { warn: this.#warn }); + } + return { nodes, filtered, pending: false }; + }; + + /** + * Finds entry nodes for ID selectors. + * @private + * @param {object} twig - The current twig from the AST branch. + * @param {string} targetType - The type of target to find. + * @param {object} opt - Additional options for finding nodes. + * @returns {object} The result { nodes, filtered, pending }. + */ + _findEntryNodesForId = (twig, targetType, opt) => { + const { leaves } = twig; + const [leaf, ...filterLeaves] = leaves; + const { complex, precede } = opt; + let nodes = []; + let filtered = false; + if (targetType === TARGET_SELF) { + [nodes, filtered] = this._matchSelf(leaves); + } else if (targetType === TARGET_LINEAL) { + [nodes, filtered] = this._findLineal(leaves, { complex }); + } else if ( + targetType === TARGET_FIRST && + this.#root.nodeType !== ELEMENT_NODE + ) { + const node = this.#root.getElementById(leaf.name); + if (node) { + if (filterLeaves.length) { + if (this._matchLeaves(filterLeaves, node, { warn: this.#warn })) { + nodes.push(node); + filtered = true; + } + } else { + nodes.push(node); + filtered = true; + } + } + } else { + nodes = this._findNodeWalker(leaves, this.#node, { precede, targetType }); + filtered = nodes.length > 0; + } + return { nodes, filtered, pending: false }; + }; + + /** + * Finds entry nodes for class selectors. + * @private + * @param {Array.} leaves - The AST leaves for the selector. + * @param {string} targetType - The type of target to find. + * @param {object} opt - Additional options for finding nodes. + * @returns {object} The result { nodes, filtered, pending }. + */ + _findEntryNodesForClass = (leaves, targetType, opt) => { + const { complex, precede } = opt; + let nodes = []; + let filtered = false; + if (targetType === TARGET_SELF) { + [nodes, filtered] = this._matchSelf(leaves); + } else if (targetType === TARGET_LINEAL) { + [nodes, filtered] = this._findLineal(leaves, { complex }); + } else { + nodes = this._findNodeWalker(leaves, this.#node, { precede, targetType }); + filtered = nodes.length > 0; + } + return { nodes, filtered, pending: false }; + }; + + /** + * Finds entry nodes for type selectors. + * @private + * @param {Array.} leaves - The AST leaves for the selector. + * @param {string} targetType - The type of target to find. + * @param {object} opt - Additional options for finding nodes. + * @returns {object} The result { nodes, filtered, pending }. + */ + _findEntryNodesForType = (leaves, targetType, opt) => { + const { complex, precede } = opt; + let nodes = []; + let filtered = false; + if (targetType === TARGET_SELF) { + [nodes, filtered] = this._matchSelf(leaves); + } else if (targetType === TARGET_LINEAL) { + [nodes, filtered] = this._findLineal(leaves, { complex }); + } else { + nodes = this._findNodeWalker(leaves, this.#node, { precede, targetType }); + filtered = nodes.length > 0; + } + return { nodes, filtered, pending: false }; + }; + + /** + * Finds entry nodes for other selector types (default case). + * @private + * @param {object} twig - The current twig from the AST branch. + * @param {string} targetType - The type of target to find. + * @param {object} opt - Additional options for finding nodes. + * @returns {object} The result { nodes, filtered, pending }. + */ + _findEntryNodesForOther = (twig, targetType, opt) => { + const { leaves } = twig; + const [leaf, ...filterLeaves] = leaves; + const { complex, precede } = opt; + let nodes = []; + let filtered = false; + let pending = false; + if (targetType !== TARGET_LINEAL && /host(?:-context)?/.test(leaf.name)) { + let shadowRoot = null; + if (this.#shadow && this.#node.nodeType === DOCUMENT_FRAGMENT_NODE) { + shadowRoot = this._matchShadowHostPseudoClass(leaf, this.#node); + } else if (filterLeaves.length && this.#node.nodeType === ELEMENT_NODE) { + shadowRoot = this._matchShadowHostPseudoClass( + leaf, + this.#node.shadowRoot + ); + } + if (shadowRoot) { + let bool = true; + const l = filterLeaves.length; + for (let i = 0; i < l; i++) { + const filterLeaf = filterLeaves[i]; + switch (filterLeaf.name) { + case 'host': + case 'host-context': { + const matchedNode = this._matchShadowHostPseudoClass( + filterLeaf, + shadowRoot + ); + bool = matchedNode === shadowRoot; + break; + } + case 'has': { + bool = this._matchPseudoClassSelector( + filterLeaf, + shadowRoot, + {} + ).has(shadowRoot); + break; + } + default: { + bool = false; + } + } + if (!bool) { + break; + } + } + if (bool) { + nodes.push(shadowRoot); + filtered = true; + } + } + } else if (targetType === TARGET_SELF) { + [nodes, filtered] = this._matchSelf(leaves); + } else if (targetType === TARGET_LINEAL) { + [nodes, filtered] = this._findLineal(leaves, { complex }); + } else if (targetType === TARGET_FIRST) { + nodes = this._findNodeWalker(leaves, this.#node, { precede, targetType }); + filtered = nodes.length > 0; + } else { + pending = true; + } + return { nodes, filtered, pending }; + }; + + /** + * Finds entry nodes. + * @private + * @param {object} twig - The twig object. + * @param {string} targetType - The target type. + * @param {object} [opt] - Options. + * @param {boolean} [opt.complex] - If true, the selector is complex. + * @param {string} [opt.dir] - The find direction. + * @returns {object} An object with nodes and their state. + */ + _findEntryNodes = (twig, targetType, opt = {}) => { + const { leaves } = twig; + const [leaf, ...filterLeaves] = leaves; + const { complex = false, dir = DIR_PREV } = opt; + const precede = + dir === DIR_NEXT && + this.#node.nodeType === ELEMENT_NODE && + this.#node !== this.#root; + let result; + switch (leaf.type) { + case PS_ELEMENT_SELECTOR: { + result = this._findEntryNodesForPseudoElement( + leaf, + filterLeaves, + targetType + ); + break; + } + case ID_SELECTOR: { + result = this._findEntryNodesForId(twig, targetType, { + complex, + precede + }); + break; + } + case CLASS_SELECTOR: { + result = this._findEntryNodesForClass(leaves, targetType, { + complex, + precede + }); + break; + } + case TYPE_SELECTOR: { + result = this._findEntryNodesForType(leaves, targetType, { + complex, + precede + }); + break; + } + default: { + result = this._findEntryNodesForOther(twig, targetType, { + complex, + precede + }); + } + } + return { + compound: filterLeaves.length > 0, + filtered: result.filtered, + nodes: result.nodes, + pending: result.pending + }; + }; + + /** + * Determines the direction and starting twig for a selector branch. + * @private + * @param {Array.} branch - The AST branch. + * @param {string} targetType - The type of target to find. + * @returns {object} An object with the direction and starting twig. + */ + _determineTraversalStrategy = (branch, targetType) => { + const branchLen = branch.length; + const firstTwig = branch[0]; + const lastTwig = branch[branchLen - 1]; + if (branchLen === 1) { + return { dir: DIR_PREV, twig: firstTwig }; + } + // Complex selector (branchLen > 1). + const { + leaves: [{ name: firstName, type: firstType }] + } = firstTwig; + const { + leaves: [{ name: lastName, type: lastType }] + } = lastTwig; + const { combo: firstCombo } = firstTwig; + if ( + this.#selector.includes(':scope') || + lastType === PS_ELEMENT_SELECTOR || + lastType === ID_SELECTOR + ) { + return { dir: DIR_PREV, twig: lastTwig }; + } + if (firstType === ID_SELECTOR) { + return { dir: DIR_NEXT, twig: firstTwig }; + } + if (firstName === '*' && firstType === TYPE_SELECTOR) { + return { dir: DIR_PREV, twig: lastTwig }; + } + if (lastName === '*' && lastType === TYPE_SELECTOR) { + return { dir: DIR_NEXT, twig: firstTwig }; + } + if (branchLen === 2) { + if (targetType === TARGET_FIRST) { + return { dir: DIR_PREV, twig: lastTwig }; + } + const { name: comboName } = firstCombo; + if (comboName === '+' || comboName === '~') { + return { dir: DIR_PREV, twig: lastTwig }; + } + } else if (branchLen > 2 && this.#scoped && targetType === TARGET_FIRST) { + if (lastType === TYPE_SELECTOR) { + return { dir: DIR_PREV, twig: lastTwig }; + } + let isChildOrDescendant = false; + for (const { combo } of branch) { + if (combo) { + const { name: comboName } = combo; + isChildOrDescendant = comboName === '>' || comboName === ' '; + if (!isChildOrDescendant) { + break; + } + } + } + if (isChildOrDescendant) { + return { dir: DIR_PREV, twig: lastTwig }; + } + } + // Default strategy for complex selectors. + return { dir: DIR_NEXT, twig: firstTwig }; + }; + + /** + * Processes pending items not resolved with a direct strategy. + * @private + * @param {Set.} pendingItems - The set of pending items. + */ + _processPendingItems = pendingItems => { + if (!pendingItems.size) { + return; + } + if (!this.#rootWalker) { + this.#rootWalker = this._createTreeWalker(this.#root); + } + const walker = this.#rootWalker; + let node = this.#root; + if (this.#scoped) { + node = this.#node; + } + let nextNode = traverseNode(node, walker); + while (nextNode) { + const isWithinScope = + this.#node.nodeType !== ELEMENT_NODE || + nextNode === this.#node || + this.#node.contains(nextNode); + if (isWithinScope) { + for (const pendingItem of pendingItems) { + const { leaves } = pendingItem.get('twig'); + if (this._matchLeaves(leaves, nextNode, { warn: this.#warn })) { + const index = pendingItem.get('index'); + this.#ast[index].filtered = true; + this.#ast[index].find = true; + this.#nodes[index].push(nextNode); + } + } + } else if (this.#scoped) { + break; + } + nextNode = walker.nextNode(); + } + }; + + /** + * Collects nodes. + * @private + * @param {string} targetType - The target type. + * @returns {Array.>} An array containing the AST and nodes. + */ + _collectNodes = targetType => { + const ast = this.#ast.values(); + if (targetType === TARGET_ALL || targetType === TARGET_FIRST) { + const pendingItems = new Set(); + let i = 0; + for (const { branch } of ast) { + const complex = branch.length > 1; + const { dir, twig } = this._determineTraversalStrategy( + branch, + targetType + ); + const { compound, filtered, nodes, pending } = this._findEntryNodes( + twig, + targetType, + { complex, dir } + ); + if (nodes.length) { + this.#ast[i].find = true; + this.#nodes[i] = nodes; + } else if (pending) { + pendingItems.add( + new Map([ + ['index', i], + ['twig', twig] + ]) + ); + } + this.#ast[i].dir = dir; + this.#ast[i].filtered = filtered || !compound; + i++; + } + this._processPendingItems(pendingItems); + } else { + let i = 0; + for (const { branch } of ast) { + const twig = branch[branch.length - 1]; + const complex = branch.length > 1; + const dir = DIR_PREV; + const { compound, filtered, nodes } = this._findEntryNodes( + twig, + targetType, + { complex, dir } + ); + if (nodes.length) { + this.#ast[i].find = true; + this.#nodes[i] = nodes; + } + this.#ast[i].dir = dir; + this.#ast[i].filtered = filtered || !compound; + i++; + } + } + return [this.#ast, this.#nodes]; + }; + + /** + * Gets combined nodes. + * @private + * @param {object} twig - The twig object. + * @param {object} nodes - A collection of nodes. + * @param {string} dir - The direction. + * @returns {Array.} A collection of matched nodes. + */ + _getCombinedNodes = (twig, nodes, dir) => { + const arr = []; + const options = { + dir, + warn: this.#warn + }; + for (const node of nodes) { + const matched = this._matchCombinator(twig, node, options); + if (matched.size) { + arr.push(...matched); + } + } + return arr; + }; + + /** + * Matches a node in the 'next' direction. + * @private + * @param {Array} branch - The branch. + * @param {Set.} nodes - A collection of Element nodes. + * @param {object} opt - Options. + * @param {object} opt.combo - The combo object. + * @param {number} opt.index - The index. + * @returns {?object} The matched node. + */ + _matchNodeNext = (branch, nodes, opt) => { + const { combo, index } = opt; + const { combo: nextCombo, leaves } = branch[index]; + const twig = { + combo, + leaves + }; + const nextNodes = new Set(this._getCombinedNodes(twig, nodes, DIR_NEXT)); + if (nextNodes.size) { + if (index === branch.length - 1) { + const [nextNode] = sortNodes(nextNodes); + return nextNode; + } + return this._matchNodeNext(branch, nextNodes, { + combo: nextCombo, + index: index + 1 + }); + } + return null; + }; + + /** + * Matches a node in the 'previous' direction. + * @private + * @param {Array} branch - The branch. + * @param {object} node - The Element node. + * @param {object} opt - Options. + * @param {number} opt.index - The index. + * @returns {?object} The node. + */ + _matchNodePrev = (branch, node, opt) => { + const { index } = opt; + const twig = branch[index]; + const nodes = new Set([node]); + const nextNodes = new Set(this._getCombinedNodes(twig, nodes, DIR_PREV)); + if (nextNodes.size) { + if (index === 0) { + return node; + } + let matched; + for (const nextNode of nextNodes) { + matched = this._matchNodePrev(branch, nextNode, { + index: index - 1 + }); + if (matched) { + break; + } + } + if (matched) { + return node; + } + } + return null; + }; + + /** + * Processes a complex selector branch to find all matching nodes. + * @private + * @param {Array} branch - The selector branch from the AST. + * @param {Array} entryNodes - The initial set of nodes to start from. + * @param {string} dir - The direction of traversal ('next' or 'prev'). + * @returns {Set.} A set of all matched nodes. + */ + _processComplexBranchAll = (branch, entryNodes, dir) => { + const matchedNodes = new Set(); + const branchLen = branch.length; + const lastIndex = branchLen - 1; + + if (dir === DIR_NEXT) { + const { combo: firstCombo } = branch[0]; + for (const node of entryNodes) { + let combo = firstCombo; + let nextNodes = new Set([node]); + for (let j = 1; j < branchLen; j++) { + const { combo: nextCombo, leaves } = branch[j]; + const twig = { combo, leaves }; + const nodesArr = this._getCombinedNodes(twig, nextNodes, dir); + if (nodesArr.length) { + if (j === lastIndex) { + for (const nextNode of nodesArr) { + matchedNodes.add(nextNode); + } + } + combo = nextCombo; + nextNodes = new Set(nodesArr); + } else { + // No further matches down this path. + nextNodes.clear(); + break; + } + } + } + // DIR_PREV + } else { + for (const node of entryNodes) { + let nextNodes = new Set([node]); + for (let j = lastIndex - 1; j >= 0; j--) { + const twig = branch[j]; + const nodesArr = this._getCombinedNodes(twig, nextNodes, dir); + if (nodesArr.length) { + // The entry node is the final match + if (j === 0) { + matchedNodes.add(node); + } + nextNodes = new Set(nodesArr); + } else { + // No further matches down this path. + nextNodes.clear(); + break; + } + } + } + } + return matchedNodes; + }; + + /** + * Processes a complex selector branch to find the first matching node. + * @private + * @param {Array} branch - The selector branch from the AST. + * @param {Array} entryNodes - The initial set of nodes to start from. + * @param {string} dir - The direction of traversal ('next' or 'prev'). + * @param {string} targetType - The type of search (e.g., 'first'). + * @returns {?object} The first matched node, or null. + */ + _processComplexBranchFirst = (branch, entryNodes, dir, targetType) => { + const branchLen = branch.length; + const lastIndex = branchLen - 1; + // DIR_NEXT logic for finding the first match. + if (dir === DIR_NEXT) { + const { combo: entryCombo } = branch[0]; + for (const node of entryNodes) { + const matchedNode = this._matchNodeNext(branch, new Set([node]), { + combo: entryCombo, + index: 1 + }); + if (matchedNode) { + if (this.#node.nodeType === ELEMENT_NODE) { + if ( + matchedNode !== this.#node && + this.#node.contains(matchedNode) + ) { + return matchedNode; + } + } else { + return matchedNode; + } + } + } + // Fallback logic if no direct match found. + const { leaves: entryLeaves } = branch[0]; + const [entryNode] = entryNodes; + if (this.#node.contains(entryNode)) { + let [refNode] = this._findNodeWalker(entryLeaves, entryNode, { + targetType + }); + while (refNode) { + const matchedNode = this._matchNodeNext(branch, new Set([refNode]), { + combo: entryCombo, + index: 1 + }); + if (matchedNode) { + if (this.#node.nodeType === ELEMENT_NODE) { + if ( + matchedNode !== this.#node && + this.#node.contains(matchedNode) + ) { + return matchedNode; + } + } else { + return matchedNode; + } + } + [refNode] = this._findNodeWalker(entryLeaves, refNode, { + targetType, + force: true + }); + } + } + // DIR_PREV logic for finding the first match. + } else { + for (const node of entryNodes) { + const matchedNode = this._matchNodePrev(branch, node, { + index: lastIndex - 1 + }); + if (matchedNode) { + return matchedNode; + } + } + // Fallback for TARGET_FIRST. + if (targetType === TARGET_FIRST) { + const { leaves: entryLeaves } = branch[lastIndex]; + const [entryNode] = entryNodes; + let [refNode] = this._findNodeWalker(entryLeaves, entryNode, { + targetType + }); + while (refNode) { + const matchedNode = this._matchNodePrev(branch, refNode, { + index: lastIndex - 1 + }); + if (matchedNode) { + return refNode; + } + [refNode] = this._findNodeWalker(entryLeaves, refNode, { + targetType, + force: true + }); + } + } + } + return null; + }; + + /** + * Finds matched nodes. + * @param {string} targetType - The target type. + * @returns {Set.} A collection of matched nodes. + */ + find = targetType => { + const [[...branches], collectedNodes] = this._collectNodes(targetType); + const l = branches.length; + let sort = + l > 1 && targetType === TARGET_ALL && this.#selector.includes(':scope'); + let nodes = new Set(); + for (let i = 0; i < l; i++) { + const { branch, dir, find } = branches[i]; + if (!branch.length || !find) { + continue; + } + const entryNodes = collectedNodes[i]; + const lastIndex = branch.length - 1; + // Handle simple selectors (no combinators). + if (lastIndex === 0) { + if ( + (targetType === TARGET_ALL || targetType === TARGET_FIRST) && + this.#node.nodeType === ELEMENT_NODE + ) { + for (const node of entryNodes) { + if (node !== this.#node && this.#node.contains(node)) { + nodes.add(node); + if (targetType === TARGET_FIRST) { + break; + } + } + } + } else if (targetType === TARGET_ALL) { + if (nodes.size) { + for (const node of entryNodes) { + nodes.add(node); + } + sort = true; + } else { + nodes = new Set(entryNodes); + } + } else { + if (entryNodes.length) { + nodes.add(entryNodes[0]); + } + } + // Handle complex selectors. + } else { + if (targetType === TARGET_ALL) { + const newNodes = this._processComplexBranchAll( + branch, + entryNodes, + dir + ); + if (nodes.size) { + for (const newNode of newNodes) { + nodes.add(newNode); + } + sort = true; + } else { + nodes = newNodes; + } + } else { + const matchedNode = this._processComplexBranchFirst( + branch, + entryNodes, + dir, + targetType + ); + if (matchedNode) { + nodes.add(matchedNode); + } + } + } + } + if (this.#check) { + const match = !!nodes.size; + let pseudoElement; + if (this.#pseudoElement.length) { + pseudoElement = this.#pseudoElement.join(''); + } else { + pseudoElement = null; + } + return { match, pseudoElement }; + } + if (targetType === TARGET_FIRST || targetType === TARGET_ALL) { + nodes.delete(this.#node); + } + if ((sort || targetType === TARGET_FIRST) && nodes.size > 1) { + return new Set(sortNodes(nodes)); + } + return nodes; + }; +} diff --git a/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/matcher.js b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/matcher.js new file mode 100644 index 0000000..6395560 --- /dev/null +++ b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/matcher.js @@ -0,0 +1,587 @@ +/** + * matcher.js + */ + +/* import */ +import { generateCSS, parseAstName, unescapeSelector } from './parser.js'; +import { + generateException, + getDirectionality, + getLanguageAttribute, + getType, + isContentEditable, + isCustomElement, + isNamespaceDeclared +} from './utility.js'; + +/* constants */ +import { + ALPHA_NUM, + FORM_PARTS, + IDENT, + INPUT_EDIT, + LANG_PART, + NOT_SUPPORTED_ERR, + PS_ELEMENT_SELECTOR, + STRING, + SYNTAX_ERR +} from './constant.js'; +const KEYS_FORM_PS_DISABLED = new Set([ + ...FORM_PARTS, + 'fieldset', + 'optgroup', + 'option' +]); +const KEYS_INPUT_EDIT = new Set(INPUT_EDIT); +const REG_LANG_VALID = new RegExp(`^(?:\\*-)?${ALPHA_NUM}${LANG_PART}$`, 'i'); +const REG_TAG_NAME = /[A-Z][\\w-]*/i; + +/** + * 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 {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, opt = {}) => { + const { forgive, globalObject, warn } = opt; + 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 or unknown pseudo-elements. + if (astName.startsWith('-webkit-')) { + if (warn) { + throw generateException( + `Unsupported pseudo-element ::${astName}`, + NOT_SUPPORTED_ERR, + globalObject + ); + } + // Throw an error for unknown pseudo-elements if not forgiven. + } else if (!forgive) { + 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. + * @throws {TypeError} If the AST does not contain a valid direction value. + * @returns {boolean} - True if the directionality matches, otherwise false. + */ +export const matchDirectionPseudoClass = (ast, node) => { + const { name } = ast; + // The :dir() pseudo-class requires a direction argument (e.g., "ltr"). + if (!name) { + const type = name === '' ? '(empty String)' : getType(name); + throw new TypeError(`Unexpected ast type ${type}`); + } + // Get the computed directionality of the element. + const dir = getDirectionality(node); + // Compare the expected direction with the element's actual direction. + return name === dir; +}; + +/** + * Matches the :lang() pseudo-class against an element's language. + * @see https://datatracker.ietf.org/doc/html/rfc4647#section-3.3.1 + * @param {object} ast - The AST object for the pseudo-class. + * @param {object} node - The element node to match against. + * @returns {boolean} - True if the language matches, otherwise false. + */ +export const matchLanguagePseudoClass = (ast, node) => { + const { name, type, value } = ast; + let langPattern; + // Determine the language pattern from the AST. + if (type === STRING && value) { + langPattern = value; + } else if (type === IDENT && name) { + langPattern = unescapeSelector(name); + } + // If no valid language pattern is provided, it cannot match. + if (typeof langPattern !== 'string') { + return false; + } + // Get the effective language attribute for the current node. + const elementLang = getLanguageAttribute(node); + // If the element has no language, it cannot match a specific pattern. + if (elementLang === null) { + return false; + } + // Handle the universal selector '*' for :lang. + if (langPattern === '*') { + // It matches any language unless attribute is not empty. + return elementLang !== ''; + } + // Validate the provided language pattern structure. + if (!REG_LANG_VALID.test(langPattern)) { + return false; + } + // Build a regex for extended language range matching. + let matcherRegex; + if (langPattern.indexOf('-') > -1) { + // Handle complex patterns with wildcards and sub-tags (e.g., '*-US'). + const [langMain, langSub, ...langRest] = langPattern.split('-'); + const extendedMain = + langMain === '*' ? `${ALPHA_NUM}${LANG_PART}` : `${langMain}${LANG_PART}`; + const extendedSub = `-${langSub}${LANG_PART}`; + let extendedRest = ''; + // Use a standard for loop for performance as per the rules. + for (let i = 0; i < langRest.length; i++) { + extendedRest += `-${langRest[i]}${LANG_PART}`; + } + matcherRegex = new RegExp( + `^${extendedMain}${extendedSub}${extendedRest}$`, + 'i' + ); + } else { + // Handle simple language patterns (e.g., 'en'). + matcherRegex = new RegExp(`^${langPattern}${LANG_PART}$`, 'i'); + } + // Test the element's language against the constructed regex. + return matcherRegex.test(elementLang); +}; + +/** + * Matches the :disabled and :enabled pseudo-classes. + * @param {string} astName - pseudo-class name + * @param {object} node - Element node + * @returns {boolean} - True if matched + */ +export const matchDisabledPseudoClass = (astName, node) => { + const { localName, parentNode } = node; + if ( + !KEYS_FORM_PS_DISABLED.has(localName) && + !isCustomElement(node, { formAssociated: true }) + ) { + return false; + } + let isDisabled = false; + if (node.disabled || node.hasAttribute('disabled')) { + isDisabled = true; + } else if (localName === 'option') { + if ( + parentNode && + parentNode.localName === 'optgroup' && + (parentNode.disabled || parentNode.hasAttribute('disabled')) + ) { + isDisabled = true; + } + } else if (localName !== 'optgroup') { + let current = parentNode; + while (current) { + if ( + current.localName === 'fieldset' && + (current.disabled || current.hasAttribute('disabled')) + ) { + // The first in a disabled
is not disabled. + let legend; + let element = current.firstElementChild; + while (element) { + if (element.localName === 'legend') { + legend = element; + break; + } + element = element.nextElementSibling; + } + if (!legend || !legend.contains(node)) { + isDisabled = true; + } + // Found the containing fieldset, stop searching up. + break; + } + current = current.parentNode; + } + } + if (astName === 'disabled') { + return isDisabled; + } + return !isDisabled; +}; + +/** + * Match the :read-only and :read-write pseudo-classes + * @param {string} astName - pseudo-class name + * @param {object} node - Element node + * @returns {boolean} - True if matched + */ +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 an attribute selector against an element. + * This function handles various attribute matchers like '=', '~=', '^=', etc., + * and considers namespaces and case sensitivity based on document type. + * @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. + * @returns {boolean} - True if the attribute selector matches, otherwise false. + */ +export const matchAttributeSelector = (ast, node, opt = {}) => { + const { + flags: astFlags, + matcher: astMatcher, + name: astName, + value: astValue + } = ast; + const { check, forgive, globalObject } = opt; + // Validate selector flags ('i' or 's'). + if (typeof astFlags === 'string' && !/^[is]$/i.test(astFlags) && !forgive) { + const css = generateCSS(ast); + throw generateException( + `Invalid selector ${css}`, + SYNTAX_ERR, + globalObject + ); + } + const { attributes } = node; + // An element with no attributes cannot match. + if (!attributes || !attributes.length) { + return false; + } + // Determine case sensitivity based on document type and flags. + const contentType = node.ownerDocument.contentType; + let caseInsensitive; + if (contentType === 'text/html') { + if (typeof astFlags === 'string' && /^s$/i.test(astFlags)) { + caseInsensitive = false; + } else { + caseInsensitive = true; + } + } else if (typeof astFlags === 'string' && /^i$/i.test(astFlags)) { + caseInsensitive = true; + } else { + caseInsensitive = false; + } + // Prepare the attribute name from the selector for matching. + let astAttrName = unescapeSelector(astName.name); + if (caseInsensitive) { + astAttrName = astAttrName.toLowerCase(); + } + // A set to store the values of attributes whose names match. + const attrValues = new Set(); + // Handle namespaced attribute names (e.g., [*|attr], [ns|attr]). + if (astAttrName.indexOf('|') > -1) { + const { prefix: astPrefix, localName: astLocalName } = + parseAstName(astAttrName); + for (const item of attributes) { + let { name: itemName, value: itemValue } = item; + if (caseInsensitive) { + itemName = itemName.toLowerCase(); + itemValue = itemValue.toLowerCase(); + } + switch (astPrefix) { + case '': { + if (astLocalName === itemName) { + attrValues.add(itemValue); + } + break; + } + case '*': { + if (itemName.indexOf(':') > -1) { + const [, ...restItemName] = itemName.split(':'); + const itemLocalName = restItemName.join(':').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 (itemName.indexOf(':') > -1) { + const [itemPrefix, ...restItemName] = itemName.split(':'); + const itemLocalName = restItemName.join(':').replace(/^:/, ''); + // Ignore the 'xml:lang' attribute. + if (itemPrefix === 'xml' && itemLocalName === 'lang') { + continue; + } else if ( + astPrefix === itemPrefix && + astLocalName === itemLocalName + ) { + const namespaceDeclared = isNamespaceDeclared(astPrefix, node); + if (namespaceDeclared) { + attrValues.add(itemValue); + } + } + } + } + } + } + // Handle non-namespaced attribute names. + } else { + for (let { name: itemName, value: itemValue } of attributes) { + if (caseInsensitive) { + itemName = itemName.toLowerCase(); + itemValue = itemValue.toLowerCase(); + } + if (itemName.indexOf(':') > -1) { + const [itemPrefix, ...restItemName] = itemName.split(':'); + const itemLocalName = restItemName.join(':').replace(/^:/, ''); + // The attribute is starting with ':'. + if (!itemPrefix && astAttrName === `:${itemLocalName}`) { + attrValues.add(itemValue); + // Ignore the 'xml:lang' attribute. + } else if (itemPrefix === 'xml' && itemLocalName === 'lang') { + continue; + } else if (astAttrName === itemLocalName) { + attrValues.add(itemValue); + } + } else if (astAttrName === itemName) { + attrValues.add(itemValue); + } + } + } + if (!attrValues.size) { + return false; + } + // Prepare the value from the selector's RHS for comparison. + const { name: astIdentValue, value: astStringValue } = astValue ?? {}; + let attrValue; + if (astIdentValue) { + if (caseInsensitive) { + attrValue = astIdentValue.toLowerCase(); + } else { + attrValue = astIdentValue; + } + } else if (astStringValue) { + if (caseInsensitive) { + attrValue = astStringValue.toLowerCase(); + } else { + attrValue = astStringValue; + } + } else if (astStringValue === '') { + attrValue = astStringValue; + } + // Perform the final match based on the specified matcher. + switch (astMatcher) { + case '=': { + return typeof attrValue === 'string' && attrValues.has(attrValue); + } + case '~=': { + if (attrValue && typeof attrValue === 'string') { + for (const value of attrValues) { + const item = new Set(value.split(/\s+/)); + if (item.has(attrValue)) { + return true; + } + } + } + return false; + } + case '|=': { + if (attrValue && typeof attrValue === 'string') { + for (const value of attrValues) { + if (value === attrValue || value.startsWith(`${attrValue}-`)) { + return true; + } + } + } + return false; + } + case '^=': { + if (attrValue && typeof attrValue === 'string') { + for (const value of attrValues) { + if (value.startsWith(`${attrValue}`)) { + return true; + } + } + } + return false; + } + case '$=': { + if (attrValue && typeof attrValue === 'string') { + for (const value of attrValues) { + if (value.endsWith(`${attrValue}`)) { + return true; + } + } + } + return false; + } + case '*=': { + if (attrValue && typeof attrValue === 'string') { + for (const value of attrValues) { + if (value.includes(`${attrValue}`)) { + return true; + } + } + } + return false; + } + case null: + default: { + // This case handles attribute existence checks (e.g., '[disabled]'). + return true; + } + } +}; + +/** + * match type selector + * @param {object} ast - AST + * @param {object} node - Element node + * @param {object} [opt] - options + * @param {boolean} [opt.check] - running in internal check() + * @param {boolean} [opt.forgive] - forgive undeclared namespace + * @returns {boolean} - result + */ +export const matchTypeSelector = (ast, node, opt = {}) => { + const astName = unescapeSelector(ast.name); + const { localName, namespaceURI, prefix } = node; + const { check, forgive, globalObject } = opt; + let { prefix: astPrefix, localName: astLocalName } = parseAstName( + astName, + node + ); + if ( + node.ownerDocument.contentType === 'text/html' && + (!namespaceURI || namespaceURI === 'http://www.w3.org/1999/xhtml') && + REG_TAG_NAME.test(localName) + ) { + astPrefix = astPrefix.toLowerCase(); + astLocalName = astLocalName.toLowerCase(); + } + let nodePrefix; + let nodeLocalName; + // just in case that the namespaced content is parsed as text/html + if (localName.indexOf(':') > -1) { + [nodePrefix, nodeLocalName] = localName.split(':'); + } else { + nodePrefix = prefix || ''; + nodeLocalName = localName; + } + switch (astPrefix) { + case '': { + if ( + !nodePrefix && + !namespaceURI && + (astLocalName === '*' || astLocalName === nodeLocalName) + ) { + return true; + } + return false; + } + case '*': { + if (astLocalName === '*' || astLocalName === nodeLocalName) { + return true; + } + return false; + } + 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) { + if (astLocalName === '*' || astLocalName === nodeLocalName) { + return true; + } + return false; + } else if (!forgive && !astNS) { + throw generateException( + `Undeclared namespace ${astPrefix}`, + SYNTAX_ERR, + globalObject + ); + } + return false; + } + } +}; diff --git a/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/parser.js b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/parser.js new file mode 100644 index 0000000..bf06d9f --- /dev/null +++ b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/parser.js @@ -0,0 +1,431 @@ +/** + * 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, + DUO, + HEX, + ID_SELECTOR, + KEYS_LOGICAL, + 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_CLASS_STATE = new Set([ + 'checked', + 'closed', + 'disabled', + 'empty', + 'enabled', + 'in-range', + 'indeterminate', + 'invalid', + 'open', + 'out-of-range', + 'placeholder-shown', + 'read-only', + 'read-write', + 'valid' +]); +const KEYS_SHADOW_HOST = new Set(['host', 'host-context']); +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$/; +const U_FFFD = '\uFFFD'; + +/** + * 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 { + 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}`); + // whitespace + } else if (/^[\n\r\f]/.test(item)) { + 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++; + } + return selector + .replace(/\f|\r\n?/g, '\n') + .replace(/[\0\uD800-\uDFFF]|\\$/g, U_FFFD) + .replace(/\x26/g, ':scope'); +}; + +/** + * Creates an Abstract Syntax Tree (AST) from a CSS selector string. + * @param {string} sel - The CSS selector string. + * @returns {object} The parsed AST object. + */ +export const parseSelector = sel => { + const selector = preprocess(sel); + // invalid selectors + if (/^$|^\s*>|,\s*$/.test(selector)) { + throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR); + } + try { + const ast = cssTree.parse(selector, { + context: 'selectorList', + parseCustomProperty: true + }); + return cssTree.toPlainObject(ast); + } 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. + * @returns {{branches: Array, info: object}} An object containing the selector branches and info. + */ +export const walkAST = (ast = {}) => { + const branches = new Set(); + const info = { + hasForgivenPseudoFunc: false, + hasHasPseudoFunc: false, + hasLogicalPseudoFunc: false, + hasNotPseudoFunc: false, + hasNthChildOfSelector: false, + hasNestedSelector: false, + hasStatePseudoClass: false + }; + const opt = { + enter(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: { + if (KEYS_LOGICAL.has(node.name)) { + info.hasNestedSelector = true; + info.hasLogicalPseudoFunc = true; + if (node.name === 'has') { + info.hasHasPseudoFunc = true; + } else if (node.name === 'not') { + info.hasNotPseudoFunc = true; + } else { + info.hasForgivenPseudoFunc = true; + } + } else if (KEYS_PS_CLASS_STATE.has(node.name)) { + info.hasStatePseudoClass = true; + } else if ( + KEYS_SHADOW_HOST.has(node.name) && + Array.isArray(node.children) && + node.children.length + ) { + info.hasNestedSelector = 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; + } + default: + } + } + }; + cssTree.walk(ast, opt); + if (info.hasNestedSelector === true) { + cssTree.findAll(ast, (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} asts - A collection of AST nodes to sort. + * @returns {Array} 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 + }; +}; + +/* Re-exported from css-tree. */ +export { find as findAST, generate as generateCSS } from 'css-tree'; diff --git a/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/utility.js b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/utility.js new file mode 100644 index 0000000..ce141a4 --- /dev/null +++ b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/utility.js @@ -0,0 +1,1107 @@ +/** + * utility.js + */ + +/* import */ +import nwsapi from '@asamuzakjp/nwsapi'; +import bidiFactory from 'bidi-js'; +import * as cssTree from 'css-tree'; +import isCustomElementName from 'is-potential-custom-element-name'; + +/* constants */ +import { + ATRULE, + COMBO, + COMPOUND_I, + DESCEND, + DOCUMENT_FRAGMENT_NODE, + DOCUMENT_NODE, + DOCUMENT_POSITION_CONTAINS, + DOCUMENT_POSITION_PRECEDING, + ELEMENT_NODE, + HAS_COMPOUND, + INPUT_BUTTON, + INPUT_EDIT, + INPUT_LTR, + INPUT_TEXT, + KEYS_LOGICAL, + LOGIC_COMPLEX, + LOGIC_COMPOUND, + N_TH, + PSEUDO_CLASS, + RULE, + SCOPE, + SELECTOR_LIST, + SIBLING, + TARGET_ALL, + TARGET_FIRST, + TEXT_NODE, + TYPE_FROM, + 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' +]); +const REG_EXCLUDE_BASIC = + /[|\\]|::|[^\u0021-\u007F\s]|\[\s*[\w$*=^|~-]+(?:(?:"[\w$*=^|~\s'-]+"|'[\w$*=^|~\s"-]+')?(?:\s+[\w$*=^|~-]+)+|"[^"\]]{1,255}|'[^'\]]{1,255})\s*\]|:(?:is|where)\(\s*\)/; +const REG_COMPLEX = new RegExp(`${COMPOUND_I}${COMBO}${COMPOUND_I}`, 'i'); +const REG_DESCEND = new RegExp(`${COMPOUND_I}${DESCEND}${COMPOUND_I}`, 'i'); +const REG_SIBLING = new RegExp(`${COMPOUND_I}${SIBLING}${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_IS_HTML = /^(?:application\/xhtml\+x|text\/ht)ml$/; +const REG_IS_XML = + /^(?:application\/(?:[\w\-.]+\+)?|image\/[\w\-.]+\+|text\/)xml$/; + +/** + * Manages state for extracting nested selectors from a CSS AST. + */ +class SelectorExtractor { + constructor() { + this.selectors = []; + this.isScoped = false; + } + + /** + * Walker enter function. + * @param {object} node - The AST node. + */ + enter(node) { + switch (node.type) { + case ATRULE: { + if (node.name === 'scope') { + this.isScoped = true; + } + break; + } + case SCOPE: { + const { children, type } = node.root; + const arr = []; + if (type === SELECTOR_LIST) { + for (const child of children) { + const selector = cssTree.generate(child); + arr.push(selector); + } + this.selectors.push(arr); + } + break; + } + case RULE: { + const { children, type } = node.prelude; + const arr = []; + if (type === SELECTOR_LIST) { + let hasAmp = false; + for (const child of children) { + const selector = cssTree.generate(child); + if (this.isScoped && !hasAmp) { + hasAmp = /\x26/.test(selector); + } + arr.push(selector); + } + if (this.isScoped) { + if (hasAmp) { + this.selectors.push(arr); + /* FIXME: + } else { + this.selectors = arr; + this.isScoped = false; + */ + } + } else { + this.selectors.push(arr); + } + } + } + } + } + + /** + * Walker leave function. + * @param {object} node - The AST node. + */ + leave(node) { + if (node.type === ATRULE) { + if (node.name === 'scope') { + this.isScoped = false; + } + } + } +} + +/** + * 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); +}; + +/** + * 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 => { + return 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; +}; + +/** + * Filter a list of nodes based on An+B logic + * @param {Array.} nodes - array of nodes to filter + * @param {object} anb - An+B options + * @param {number} anb.a - a + * @param {number} anb.b - b + * @param {boolean} [anb.reverse] - reverse order + * @returns {Array.} - array of matched nodes + */ +export const filterNodesByAnB = (nodes, anb) => { + const { a, b, reverse } = anb; + const processedNodes = reverse ? [...nodes].reverse() : nodes; + const l = nodes.length; + const matched = []; + if (a === 0) { + if (b > 0 && b <= l) { + matched.push(processedNodes[b - 1]); + } + return matched; + } + let startIndex = b - 1; + if (a > 0) { + while (startIndex < 0) { + startIndex += a; + } + for (let i = startIndex; i < l; i += a) { + matched.push(processedNodes[i]); + } + } else if (startIndex >= 0) { + for (let i = startIndex; i >= 0; i += a) { + matched.push(processedNodes[i]); + } + return matched.reverse(); + } + return matched; +}; + +/** + * Resolve content document, root node, and check if it's in a shadow DOM. + * @param {object} node - Document, DocumentFragment, or Element node. + * @returns {Array.} - [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. + * @returns {boolean} - True if it's a custom element. + */ +export const isCustomElement = (node, opt = {}) => { + if (!node?.nodeType) { + throw new TypeError(`Unexpected type ${getType(node)}`); + } + if (node.nodeType !== ELEMENT_NODE) { + return false; + } + const { localName, ownerDocument } = node; + const { formAssociated } = opt; + 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 ). + * @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. + * @returns {?string} - 'ltr' or 'rtl'. + */ +export const getDirectionality = node => { + if (!node?.nodeType) { + throw new TypeError(`Unexpected type ${getType(node)}`); + } + if (node.nodeType !== ELEMENT_NODE) { + return null; + } + const { dir: dirAttr, localName, parentNode } = node; + const { getEmbeddingLevels } = bidiFactory(); + if (dirAttr === 'ltr' || dirAttr === 'rtl') { + return 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)) { + return 'ltr'; + } + 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) { + const { + paragraphs: [{ level }] + } = getEmbeddingLevels(text); + if (level % 2 === 1) { + return 'rtl'; + } + } else if (parentNode) { + const { nodeType: parentNodeType } = parentNode; + if (parentNodeType === ELEMENT_NODE) { + return getDirectionality(parentNode); + } + } + } else if (localName === 'input' && node.type === 'tel') { + return 'ltr'; + } else if (localName === 'bdi') { + const text = node.textContent.trim(); + if (text) { + const { + paragraphs: [{ level }] + } = getEmbeddingLevels(text); + if (level % 2 === 1) { + return 'rtl'; + } + } + } else if (parentNode) { + if (localName === 'slot') { + const text = getSlottedTextContent(node); + if (text) { + const { + paragraphs: [{ level }] + } = getEmbeddingLevels(text); + if (level % 2 === 1) { + return 'rtl'; + } + return 'ltr'; + } + } + const { nodeType: parentNodeType } = parentNode; + if (parentNodeType === ELEMENT_NODE) { + return getDirectionality(parentNode); + } + } + return 'ltr'; +}; + +/** + * Traverses up the DOM tree to find the language attribute for a node. + * It checks for 'lang' in HTML and 'xml:lang' in XML contexts. + * @param {object} node - The starting element node. + * @returns {string|null} The language attribute value, or null if not found. + */ +export const getLanguageAttribute = node => { + if (!node?.nodeType) { + throw new TypeError(`Unexpected type ${getType(node)}`); + } + if (node.nodeType !== ELEMENT_NODE) { + return null; + } + const { contentType } = node.ownerDocument; + const isHtml = REG_IS_HTML.test(contentType); + const isXml = REG_IS_XML.test(contentType); + let isShadow = false; + // Traverse up from the current node to the root. + let current = node; + while (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')) { + return current.getAttribute('lang'); + } else if (isXml && current.hasAttribute('xml:lang')) { + return 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. + return null; + } + } + if (isShadow) { + current = current.host; + isShadow = false; + } else if (current.parentNode) { + current = current.parentNode; + } else { + break; + } + } + // No language attribute was found in the hierarchy. + return null; +}; + +/** + * 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; + } + const window = node.ownerDocument.defaultView; + const { display, visibility } = window.getComputedStyle(node); + if (display !== 'none' && visibility === 'visible') { + return true; + } + return false; +}; + +/** + * 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.|Set.} nodes - Collection of nodes. + * @returns {Array.} - Collection of sorted nodes. + */ +export const sortNodes = (nodes = []) => { + const arr = [...nodes]; + if (arr.length > 1) { + arr.sort(compareNodes); + } + return arr; +}; + +/** + * Concat an array of nested selectors into an equivalent single selector. + * @param {Array.>} selectors - [parents, children, ...]. + * @returns {string} - The concatenated selector. + */ +export const concatNestedSelectors = selectors => { + if (!Array.isArray(selectors)) { + throw new TypeError(`Unexpected type ${getType(selectors)}`); + } + let selector = ''; + if (selectors.length) { + const revSelectors = selectors.toReversed(); + let child = verifyArray(revSelectors.shift(), 'String'); + if (child.length === 1) { + [child] = child; + } + while (revSelectors.length) { + const parentArr = verifyArray(revSelectors.shift(), 'String'); + if (!parentArr.length) { + continue; + } + let parent; + if (parentArr.length === 1) { + [parent] = parentArr; + if (!/^[>~+]/.test(parent) && /[\s>~+]/.test(parent)) { + parent = `:is(${parent})`; + } + } else { + parent = `:is(${parentArr.join(', ')})`; + } + if (selector.includes('\x26')) { + selector = selector.replace(/\x26/g, parent); + } + if (Array.isArray(child)) { + const items = []; + for (let item of child) { + if (item.includes('\x26')) { + if (/^[>~+]/.test(item)) { + item = `${parent} ${item.replace(/\x26/g, parent)} ${selector}`; + } else { + item = `${item.replace(/\x26/g, parent)} ${selector}`; + } + } else { + item = `${parent} ${item} ${selector}`; + } + items.push(item.trim()); + } + selector = items.join(', '); + } else if (revSelectors.length) { + selector = `${child} ${selector}`; + } else { + if (child.includes('\x26')) { + if (/^[>~+]/.test(child)) { + selector = `${parent} ${child.replace(/\x26/g, parent)} ${selector}`; + } else { + selector = `${child.replace(/\x26/g, parent)} ${selector}`; + } + } else { + selector = `${parent} ${child} ${selector}`; + } + } + selector = selector.trim(); + if (revSelectors.length) { + child = parentArr.length > 1 ? parentArr : parent; + } else { + break; + } + } + selector = selector.replace(/\x26/g, ':scope').trim(); + } + return selector; +}; + +/** + * Extract nested selectors from CSSRule.cssText. + * @param {string} css - CSSRule.cssText. + * @returns {Array.>} - Array of nested selectors. + */ +export const extractNestedSelectors = css => { + const ast = cssTree.parse(css, { + context: 'rule' + }); + const extractor = new SelectorExtractor(); + cssTree.walk(ast, { + enter: extractor.enter.bind(extractor), + leave: extractor.leave.bind(extractor) + }); + return extractor.selectors; +}; + +/** + * Initialize nwsapi. + * @param {object} window - The Window object. + * @param {object} document - The Document object. + * @returns {object} - The nwsapi instance. + */ +export const initNwsapi = (window, document) => { + if (!window?.DOMException) { + throw new TypeError(`Unexpected global object ${getType(window)}`); + } + if (document?.nodeType !== DOCUMENT_NODE) { + document = window.document; + } + const nw = nwsapi({ + document, + DOMException: window.DOMException + }); + nw.configure({ + LOGERRORS: false + }); + return nw; +}; + +/** + * 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) => { + const isQuerySelectorType = target === TARGET_FIRST || target === TARGET_ALL; + if ( + !selector || + typeof selector !== 'string' || + /null|undefined/.test(selector) + ) { + return false; + } + // Exclude missing close square bracket. + if (selector.includes('[')) { + const index = selector.lastIndexOf('['); + const sel = selector.substring(index); + if (sel.indexOf(']') < 0) { + return false; + } + } + // Exclude various complex or unsupported selectors. + // - selectors containing '/' + // - namespaced selectors + // - escaped selectors + // - pseudo-element selectors + // - selectors containing non-ASCII + // - selectors containing control character other than whitespace + // - attribute selectors with case flag, e.g. [attr i] + // - attribute selectors with unclosed quotes + // - empty :is() or :where() + if (selector.includes('/') || REG_EXCLUDE_BASIC.test(selector)) { + return false; + } + // Include pseudo-classes that are known to work correctly. + if (selector.includes(':')) { + let complex = false; + if (target !== isQuerySelectorType) { + complex = REG_COMPLEX.test(selector); + } + if ( + isQuerySelectorType && + REG_DESCEND.test(selector) && + !REG_SIBLING.test(selector) + ) { + return false; + } else if (!isQuerySelectorType && /:has\(/.test(selector)) { + if (!complex || REG_LOGIC_HAS_COMPOUND.test(selector)) { + return false; + } + return REG_END_WITH_HAS.test(selector); + } else if (/:(?:is|not)\(/.test(selector)) { + if (complex) { + return !REG_LOGIC_COMPLEX.test(selector); + } else { + return !REG_LOGIC_COMPOUND.test(selector); + } + } else { + return !REG_WO_LOGICAL.test(selector); + } + } + return true; +}; -- cgit v1.2.3