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