diff options
Diffstat (limited to 'vanilla/node_modules/@asamuzakjp/dom-selector/src/js/utility.js')
| -rw-r--r-- | vanilla/node_modules/@asamuzakjp/dom-selector/src/js/utility.js | 1107 |
1 files changed, 1107 insertions, 0 deletions
diff --git a/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/utility.js b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/utility.js new file mode 100644 index 0000000..ce141a4 --- /dev/null +++ b/vanilla/node_modules/@asamuzakjp/dom-selector/src/js/utility.js @@ -0,0 +1,1107 @@ +/** + * utility.js + */ + +/* import */ +import nwsapi from '@asamuzakjp/nwsapi'; +import bidiFactory from 'bidi-js'; +import * as cssTree from 'css-tree'; +import isCustomElementName from 'is-potential-custom-element-name'; + +/* constants */ +import { + ATRULE, + COMBO, + COMPOUND_I, + DESCEND, + DOCUMENT_FRAGMENT_NODE, + DOCUMENT_NODE, + DOCUMENT_POSITION_CONTAINS, + DOCUMENT_POSITION_PRECEDING, + ELEMENT_NODE, + HAS_COMPOUND, + INPUT_BUTTON, + INPUT_EDIT, + INPUT_LTR, + INPUT_TEXT, + KEYS_LOGICAL, + LOGIC_COMPLEX, + LOGIC_COMPOUND, + N_TH, + PSEUDO_CLASS, + RULE, + SCOPE, + SELECTOR_LIST, + SIBLING, + TARGET_ALL, + TARGET_FIRST, + TEXT_NODE, + TYPE_FROM, + TYPE_TO +} from './constant.js'; +const KEYS_DIR_AUTO = new Set([...INPUT_BUTTON, ...INPUT_TEXT, 'hidden']); +const KEYS_DIR_LTR = new Set(INPUT_LTR); +const KEYS_INPUT_EDIT = new Set(INPUT_EDIT); +const KEYS_NODE_DIR_EXCLUDE = new Set(['bdi', 'script', 'style', 'textarea']); +const KEYS_NODE_FOCUSABLE = new Set(['button', 'select', 'textarea']); +const KEYS_NODE_FOCUSABLE_SVG = new Set([ + 'clipPath', + 'defs', + 'desc', + 'linearGradient', + 'marker', + 'mask', + 'metadata', + 'pattern', + 'radialGradient', + 'script', + 'style', + 'symbol', + 'title' +]); +const REG_EXCLUDE_BASIC = + /[|\\]|::|[^\u0021-\u007F\s]|\[\s*[\w$*=^|~-]+(?:(?:"[\w$*=^|~\s'-]+"|'[\w$*=^|~\s"-]+')?(?:\s+[\w$*=^|~-]+)+|"[^"\]]{1,255}|'[^'\]]{1,255})\s*\]|:(?:is|where)\(\s*\)/; +const REG_COMPLEX = new RegExp(`${COMPOUND_I}${COMBO}${COMPOUND_I}`, 'i'); +const REG_DESCEND = new RegExp(`${COMPOUND_I}${DESCEND}${COMPOUND_I}`, 'i'); +const REG_SIBLING = new RegExp(`${COMPOUND_I}${SIBLING}${COMPOUND_I}`, 'i'); +const REG_LOGIC_COMPLEX = new RegExp( + `:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPLEX})` +); +const REG_LOGIC_COMPOUND = new RegExp( + `:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPOUND})` +); +const REG_LOGIC_HAS_COMPOUND = new RegExp( + `:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPOUND}|${HAS_COMPOUND})` +); +const REG_END_WITH_HAS = new RegExp(`:${HAS_COMPOUND}$`); +const REG_WO_LOGICAL = new RegExp(`:(?!${PSEUDO_CLASS}|${N_TH})`); +const REG_IS_HTML = /^(?:application\/xhtml\+x|text\/ht)ml$/; +const REG_IS_XML = + /^(?:application\/(?:[\w\-.]+\+)?|image\/[\w\-.]+\+|text\/)xml$/; + +/** + * Manages state for extracting nested selectors from a CSS AST. + */ +class SelectorExtractor { + constructor() { + this.selectors = []; + this.isScoped = false; + } + + /** + * Walker enter function. + * @param {object} node - The AST node. + */ + enter(node) { + switch (node.type) { + case ATRULE: { + if (node.name === 'scope') { + this.isScoped = true; + } + break; + } + case SCOPE: { + const { children, type } = node.root; + const arr = []; + if (type === SELECTOR_LIST) { + for (const child of children) { + const selector = cssTree.generate(child); + arr.push(selector); + } + this.selectors.push(arr); + } + break; + } + case RULE: { + const { children, type } = node.prelude; + const arr = []; + if (type === SELECTOR_LIST) { + let hasAmp = false; + for (const child of children) { + const selector = cssTree.generate(child); + if (this.isScoped && !hasAmp) { + hasAmp = /\x26/.test(selector); + } + arr.push(selector); + } + if (this.isScoped) { + if (hasAmp) { + this.selectors.push(arr); + /* FIXME: + } else { + this.selectors = arr; + this.isScoped = false; + */ + } + } else { + this.selectors.push(arr); + } + } + } + } + } + + /** + * Walker leave function. + * @param {object} node - The AST node. + */ + leave(node) { + if (node.type === ATRULE) { + if (node.name === 'scope') { + this.isScoped = false; + } + } + } +} + +/** + * Get type of an object. + * @param {object} o - Object to check. + * @returns {string} - Type of the object. + */ +export const getType = o => + Object.prototype.toString.call(o).slice(TYPE_FROM, TYPE_TO); + +/** + * Verify array contents. + * @param {Array} arr - The array. + * @param {string} type - Expected type, e.g. 'String'. + * @throws {TypeError} - Throws if array or its items are of unexpected type. + * @returns {Array} - The verified array. + */ +export const verifyArray = (arr, type) => { + if (!Array.isArray(arr)) { + throw new TypeError(`Unexpected type ${getType(arr)}`); + } + if (typeof type !== 'string') { + throw new TypeError(`Unexpected type ${getType(type)}`); + } + for (const item of arr) { + if (getType(item) !== type) { + throw new TypeError(`Unexpected type ${getType(item)}`); + } + } + return arr; +}; + +/** + * Generate a DOMException. + * @param {string} msg - The error message. + * @param {string} name - The error name. + * @param {object} globalObject - The global object (e.g., window). + * @returns {DOMException} The generated DOMException object. + */ +export const generateException = (msg, name, globalObject = globalThis) => { + return new globalObject.DOMException(msg, name); +}; + +/** + * Find a nested :has() pseudo-class. + * @param {object} leaf - The AST leaf to check. + * @returns {?object} The leaf if it's :has, otherwise null. + */ +export const findNestedHas = leaf => { + return leaf.name === 'has'; +}; + +/** + * Find a logical pseudo-class that contains a nested :has(). + * @param {object} leaf - The AST leaf to check. + * @returns {?object} The leaf if it matches, otherwise null. + */ +export const findLogicalWithNestedHas = leaf => { + if (KEYS_LOGICAL.has(leaf.name) && cssTree.find(leaf, findNestedHas)) { + return leaf; + } + return null; +}; + +/** + * Filter a list of nodes based on An+B logic + * @param {Array.<object>} nodes - array of nodes to filter + * @param {object} anb - An+B options + * @param {number} anb.a - a + * @param {number} anb.b - b + * @param {boolean} [anb.reverse] - reverse order + * @returns {Array.<object>} - array of matched nodes + */ +export const filterNodesByAnB = (nodes, anb) => { + const { a, b, reverse } = anb; + const processedNodes = reverse ? [...nodes].reverse() : nodes; + const l = nodes.length; + const matched = []; + if (a === 0) { + if (b > 0 && b <= l) { + matched.push(processedNodes[b - 1]); + } + return matched; + } + let startIndex = b - 1; + if (a > 0) { + while (startIndex < 0) { + startIndex += a; + } + for (let i = startIndex; i < l; i += a) { + matched.push(processedNodes[i]); + } + } else if (startIndex >= 0) { + for (let i = startIndex; i >= 0; i += a) { + matched.push(processedNodes[i]); + } + return matched.reverse(); + } + return matched; +}; + +/** + * Resolve content document, root node, and check if it's in a shadow DOM. + * @param {object} node - Document, DocumentFragment, or Element node. + * @returns {Array.<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. + * @returns {boolean} - True if it's a custom element. + */ +export const isCustomElement = (node, opt = {}) => { + if (!node?.nodeType) { + throw new TypeError(`Unexpected type ${getType(node)}`); + } + if (node.nodeType !== ELEMENT_NODE) { + return false; + } + const { localName, ownerDocument } = node; + const { formAssociated } = opt; + const window = ownerDocument.defaultView; + let elmConstructor; + const attr = node.getAttribute('is'); + if (attr) { + elmConstructor = + isCustomElementName(attr) && window.customElements.get(attr); + } else { + elmConstructor = + isCustomElementName(localName) && window.customElements.get(localName); + } + if (elmConstructor) { + if (formAssociated) { + return !!elmConstructor.formAssociated; + } + return true; + } + return false; +}; + +/** + * Get slotted text content. + * @param {object} node - The Element node (likely a <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. + * @returns {?string} - 'ltr' or 'rtl'. + */ +export const getDirectionality = node => { + if (!node?.nodeType) { + throw new TypeError(`Unexpected type ${getType(node)}`); + } + if (node.nodeType !== ELEMENT_NODE) { + return null; + } + const { dir: dirAttr, localName, parentNode } = node; + const { getEmbeddingLevels } = bidiFactory(); + if (dirAttr === 'ltr' || dirAttr === 'rtl') { + return dirAttr; + } else if (dirAttr === 'auto') { + let text = ''; + switch (localName) { + case 'input': { + if (!node.type || KEYS_DIR_AUTO.has(node.type)) { + text = node.value; + } else if (KEYS_DIR_LTR.has(node.type)) { + return 'ltr'; + } + break; + } + case 'slot': { + text = getSlottedTextContent(node); + break; + } + case 'textarea': { + text = node.value; + break; + } + default: { + const items = [].slice.call(node.childNodes); + for (const item of items) { + const { + dir: itemDir, + localName: itemLocalName, + nodeType: itemNodeType, + textContent: itemTextContent + } = item; + if (itemNodeType === TEXT_NODE) { + text = itemTextContent.trim(); + } else if ( + itemNodeType === ELEMENT_NODE && + !KEYS_NODE_DIR_EXCLUDE.has(itemLocalName) && + (!itemDir || (itemDir !== 'ltr' && itemDir !== 'rtl')) + ) { + if (itemLocalName === 'slot') { + text = getSlottedTextContent(item); + } else { + text = itemTextContent.trim(); + } + } + if (text) { + break; + } + } + } + } + if (text) { + const { + paragraphs: [{ level }] + } = getEmbeddingLevels(text); + if (level % 2 === 1) { + return 'rtl'; + } + } else if (parentNode) { + const { nodeType: parentNodeType } = parentNode; + if (parentNodeType === ELEMENT_NODE) { + return getDirectionality(parentNode); + } + } + } else if (localName === 'input' && node.type === 'tel') { + return 'ltr'; + } else if (localName === 'bdi') { + const text = node.textContent.trim(); + if (text) { + const { + paragraphs: [{ level }] + } = getEmbeddingLevels(text); + if (level % 2 === 1) { + return 'rtl'; + } + } + } else if (parentNode) { + if (localName === 'slot') { + const text = getSlottedTextContent(node); + if (text) { + const { + paragraphs: [{ level }] + } = getEmbeddingLevels(text); + if (level % 2 === 1) { + return 'rtl'; + } + return 'ltr'; + } + } + const { nodeType: parentNodeType } = parentNode; + if (parentNodeType === ELEMENT_NODE) { + return getDirectionality(parentNode); + } + } + return 'ltr'; +}; + +/** + * Traverses up the DOM tree to find the language attribute for a node. + * It checks for 'lang' in HTML and 'xml:lang' in XML contexts. + * @param {object} node - The starting element node. + * @returns {string|null} The language attribute value, or null if not found. + */ +export const getLanguageAttribute = node => { + if (!node?.nodeType) { + throw new TypeError(`Unexpected type ${getType(node)}`); + } + if (node.nodeType !== ELEMENT_NODE) { + return null; + } + const { contentType } = node.ownerDocument; + const isHtml = REG_IS_HTML.test(contentType); + const isXml = REG_IS_XML.test(contentType); + let isShadow = false; + // Traverse up from the current node to the root. + let current = node; + while (current) { + // Check if the current node is an element. + switch (current.nodeType) { + case ELEMENT_NODE: { + // Check for and return the language attribute if present. + if (isHtml && current.hasAttribute('lang')) { + return current.getAttribute('lang'); + } else if (isXml && current.hasAttribute('xml:lang')) { + return current.getAttribute('xml:lang'); + } + break; + } + case DOCUMENT_FRAGMENT_NODE: { + // Continue traversal if the current node is a shadow root. + if (current.host) { + isShadow = true; + } + break; + } + case DOCUMENT_NODE: + default: { + // Stop if we reach the root document node. + return null; + } + } + if (isShadow) { + current = current.host; + isShadow = false; + } else if (current.parentNode) { + current = current.parentNode; + } else { + break; + } + } + // No language attribute was found in the hierarchy. + return null; +}; + +/** + * Check if content is editable. + * NOTE: Not implemented in jsdom https://github.com/jsdom/jsdom/issues/1670 + * @param {object} node - The Element node. + * @returns {boolean} - True if content is editable. + */ +export const isContentEditable = node => { + if (!node?.nodeType) { + throw new TypeError(`Unexpected type ${getType(node)}`); + } + if (node.nodeType !== ELEMENT_NODE) { + return false; + } + if (typeof node.isContentEditable === 'boolean') { + return node.isContentEditable; + } else if (node.ownerDocument.designMode === 'on') { + return true; + } else { + let attr; + if (node.hasAttribute('contenteditable')) { + attr = node.getAttribute('contenteditable'); + } else { + attr = 'inherit'; + } + switch (attr) { + case '': + case 'true': { + return true; + } + case 'plaintext-only': { + // FIXME: + // @see https://github.com/w3c/editing/issues/470 + // @see https://github.com/whatwg/html/issues/10651 + return true; + } + case 'false': { + return false; + } + default: { + if (node?.parentNode?.nodeType === ELEMENT_NODE) { + return isContentEditable(node.parentNode); + } + return false; + } + } + } +}; + +/** + * Check if a node is visible. + * @param {object} node - The Element node. + * @returns {boolean} - True if the node is visible. + */ +export const isVisible = node => { + if (node?.nodeType !== ELEMENT_NODE) { + return false; + } + const window = node.ownerDocument.defaultView; + const { display, visibility } = window.getComputedStyle(node); + if (display !== 'none' && visibility === 'visible') { + return true; + } + return false; +}; + +/** + * Check if focus is visible on the node. + * @param {object} node - The Element node. + * @returns {boolean} - True if focus is visible. + */ +export const isFocusVisible = node => { + if (node?.nodeType !== ELEMENT_NODE) { + return false; + } + const { localName, type } = node; + switch (localName) { + case 'input': { + if (!type || KEYS_INPUT_EDIT.has(type)) { + return true; + } + return false; + } + case 'textarea': { + return true; + } + default: { + return isContentEditable(node); + } + } +}; + +/** + * Check if an area is focusable. + * @param {object} node - The Element node. + * @returns {boolean} - True if the area is focusable. + */ +export const isFocusableArea = node => { + if (node?.nodeType !== ELEMENT_NODE) { + return false; + } + if (!node.isConnected) { + return false; + } + const window = node.ownerDocument.defaultView; + if (node instanceof window.HTMLElement) { + if (Number.isInteger(parseInt(node.getAttribute('tabindex')))) { + return true; + } + if (isContentEditable(node)) { + return true; + } + const { localName, parentNode } = node; + switch (localName) { + case 'a': { + if (node.href || node.hasAttribute('href')) { + return true; + } + return false; + } + case 'iframe': { + return true; + } + case 'input': { + if ( + node.disabled || + node.hasAttribute('disabled') || + node.hidden || + node.hasAttribute('hidden') + ) { + return false; + } + return true; + } + case 'summary': { + if (parentNode.localName === 'details') { + let child = parentNode.firstElementChild; + let bool = false; + while (child) { + if (child.localName === 'summary') { + bool = child === node; + break; + } + child = child.nextElementSibling; + } + return bool; + } + return false; + } + default: { + if ( + KEYS_NODE_FOCUSABLE.has(localName) && + !(node.disabled || node.hasAttribute('disabled')) + ) { + return true; + } + } + } + } else if (node instanceof window.SVGElement) { + if (Number.isInteger(parseInt(node.getAttributeNS(null, 'tabindex')))) { + const ns = 'http://www.w3.org/2000/svg'; + let bool; + let refNode = node; + while (refNode.namespaceURI === ns) { + bool = KEYS_NODE_FOCUSABLE_SVG.has(refNode.localName); + if (bool) { + break; + } + if (refNode?.parentNode?.namespaceURI === ns) { + refNode = refNode.parentNode; + } else { + break; + } + } + if (bool) { + return false; + } + return true; + } + if ( + node.localName === 'a' && + (node.href || node.hasAttributeNS(null, 'href')) + ) { + return true; + } + } + return false; +}; + +/** + * Check if a node is focusable. + * NOTE: Not applied, needs fix in jsdom itself. + * @see https://github.com/whatwg/html/pull/8392 + * @see https://phabricator.services.mozilla.com/D156219 + * @see https://github.com/jsdom/jsdom/issues/3029 + * @see https://github.com/jsdom/jsdom/issues/3464 + * @param {object} node - The Element node. + * @returns {boolean} - True if the node is focusable. + */ +export const isFocusable = node => { + if (node?.nodeType !== ELEMENT_NODE) { + return false; + } + const window = node.ownerDocument.defaultView; + let refNode = node; + let res = true; + while (refNode) { + if (refNode.disabled || refNode.hasAttribute('disabled')) { + res = false; + break; + } + if (refNode.hidden || refNode.hasAttribute('hidden')) { + res = false; + } + const { contentVisibility, display, visibility } = + window.getComputedStyle(refNode); + if ( + display === 'none' || + visibility !== 'visible' || + (contentVisibility === 'hidden' && refNode !== node) + ) { + res = false; + } else { + res = true; + } + if (res && refNode?.parentNode?.nodeType === ELEMENT_NODE) { + refNode = refNode.parentNode; + } else { + break; + } + } + return res; +}; + +/** + * Get namespace URI. + * @param {string} ns - The namespace prefix. + * @param {object} node - The Element node. + * @returns {?string} - The namespace URI. + */ +export const getNamespaceURI = (ns, node) => { + if (typeof ns !== 'string') { + throw new TypeError(`Unexpected type ${getType(ns)}`); + } else if (!node?.nodeType) { + throw new TypeError(`Unexpected type ${getType(node)}`); + } + if (!ns || node.nodeType !== ELEMENT_NODE) { + return null; + } + const { attributes } = node; + let res; + for (const attr of attributes) { + const { name, namespaceURI, prefix, value } = attr; + if (name === `xmlns:${ns}`) { + res = value; + } else if (prefix === ns) { + res = namespaceURI; + } + if (res) { + break; + } + } + return res ?? null; +}; + +/** + * Check if a namespace is declared. + * @param {string} ns - The namespace. + * @param {object} node - The Element node. + * @returns {boolean} - True if the namespace is declared. + */ +export const isNamespaceDeclared = (ns = '', node = {}) => { + if (!ns || typeof ns !== 'string' || node?.nodeType !== ELEMENT_NODE) { + return false; + } + if (node.lookupNamespaceURI(ns)) { + return true; + } + const root = node.ownerDocument.documentElement; + let parent = node; + let res; + while (parent) { + res = getNamespaceURI(ns, parent); + if (res || parent === root) { + break; + } + parent = parent.parentNode; + } + return !!res; +}; + +/** + * Check if nodeA precedes and/or contains nodeB. + * @param {object} nodeA - The first Element node. + * @param {object} nodeB - The second Element node. + * @returns {boolean} - True if nodeA precedes nodeB. + */ +export const isPreceding = (nodeA, nodeB) => { + if (!nodeA?.nodeType) { + throw new TypeError(`Unexpected type ${getType(nodeA)}`); + } else if (!nodeB?.nodeType) { + throw new TypeError(`Unexpected type ${getType(nodeB)}`); + } + if (nodeA.nodeType !== ELEMENT_NODE || nodeB.nodeType !== ELEMENT_NODE) { + return false; + } + const posBit = nodeB.compareDocumentPosition(nodeA); + const res = + posBit & DOCUMENT_POSITION_PRECEDING || posBit & DOCUMENT_POSITION_CONTAINS; + return !!res; +}; + +/** + * Comparison function for sorting nodes based on document position. + * @param {object} a - The first node. + * @param {object} b - The second node. + * @returns {number} - Sort order. + */ +export const compareNodes = (a, b) => { + if (isPreceding(b, a)) { + return 1; + } + return -1; +}; + +/** + * Sort a collection of nodes. + * @param {Array.<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; +}; + +/** + * Concat an array of nested selectors into an equivalent single selector. + * @param {Array.<Array.<string>>} selectors - [parents, children, ...]. + * @returns {string} - The concatenated selector. + */ +export const concatNestedSelectors = selectors => { + if (!Array.isArray(selectors)) { + throw new TypeError(`Unexpected type ${getType(selectors)}`); + } + let selector = ''; + if (selectors.length) { + const revSelectors = selectors.toReversed(); + let child = verifyArray(revSelectors.shift(), 'String'); + if (child.length === 1) { + [child] = child; + } + while (revSelectors.length) { + const parentArr = verifyArray(revSelectors.shift(), 'String'); + if (!parentArr.length) { + continue; + } + let parent; + if (parentArr.length === 1) { + [parent] = parentArr; + if (!/^[>~+]/.test(parent) && /[\s>~+]/.test(parent)) { + parent = `:is(${parent})`; + } + } else { + parent = `:is(${parentArr.join(', ')})`; + } + if (selector.includes('\x26')) { + selector = selector.replace(/\x26/g, parent); + } + if (Array.isArray(child)) { + const items = []; + for (let item of child) { + if (item.includes('\x26')) { + if (/^[>~+]/.test(item)) { + item = `${parent} ${item.replace(/\x26/g, parent)} ${selector}`; + } else { + item = `${item.replace(/\x26/g, parent)} ${selector}`; + } + } else { + item = `${parent} ${item} ${selector}`; + } + items.push(item.trim()); + } + selector = items.join(', '); + } else if (revSelectors.length) { + selector = `${child} ${selector}`; + } else { + if (child.includes('\x26')) { + if (/^[>~+]/.test(child)) { + selector = `${parent} ${child.replace(/\x26/g, parent)} ${selector}`; + } else { + selector = `${child.replace(/\x26/g, parent)} ${selector}`; + } + } else { + selector = `${parent} ${child} ${selector}`; + } + } + selector = selector.trim(); + if (revSelectors.length) { + child = parentArr.length > 1 ? parentArr : parent; + } else { + break; + } + } + selector = selector.replace(/\x26/g, ':scope').trim(); + } + return selector; +}; + +/** + * Extract nested selectors from CSSRule.cssText. + * @param {string} css - CSSRule.cssText. + * @returns {Array.<Array.<string>>} - Array of nested selectors. + */ +export const extractNestedSelectors = css => { + const ast = cssTree.parse(css, { + context: 'rule' + }); + const extractor = new SelectorExtractor(); + cssTree.walk(ast, { + enter: extractor.enter.bind(extractor), + leave: extractor.leave.bind(extractor) + }); + return extractor.selectors; +}; + +/** + * Initialize nwsapi. + * @param {object} window - The Window object. + * @param {object} document - The Document object. + * @returns {object} - The nwsapi instance. + */ +export const initNwsapi = (window, document) => { + if (!window?.DOMException) { + throw new TypeError(`Unexpected global object ${getType(window)}`); + } + if (document?.nodeType !== DOCUMENT_NODE) { + document = window.document; + } + const nw = nwsapi({ + document, + DOMException: window.DOMException + }); + nw.configure({ + LOGERRORS: false + }); + return nw; +}; + +/** + * Filter a selector for use with nwsapi. + * @param {string} selector - The selector string. + * @param {string} target - The target type. + * @returns {boolean} - True if the selector is valid for nwsapi. + */ +export const filterSelector = (selector, target) => { + const isQuerySelectorType = target === TARGET_FIRST || target === TARGET_ALL; + if ( + !selector || + typeof selector !== 'string' || + /null|undefined/.test(selector) + ) { + return false; + } + // Exclude missing close square bracket. + if (selector.includes('[')) { + const index = selector.lastIndexOf('['); + const sel = selector.substring(index); + if (sel.indexOf(']') < 0) { + return false; + } + } + // Exclude various complex or unsupported selectors. + // - selectors containing '/' + // - namespaced selectors + // - escaped selectors + // - pseudo-element selectors + // - selectors containing non-ASCII + // - selectors containing control character other than whitespace + // - attribute selectors with case flag, e.g. [attr i] + // - attribute selectors with unclosed quotes + // - empty :is() or :where() + if (selector.includes('/') || REG_EXCLUDE_BASIC.test(selector)) { + return false; + } + // Include pseudo-classes that are known to work correctly. + if (selector.includes(':')) { + let complex = false; + if (target !== isQuerySelectorType) { + complex = REG_COMPLEX.test(selector); + } + if ( + isQuerySelectorType && + REG_DESCEND.test(selector) && + !REG_SIBLING.test(selector) + ) { + return false; + } else if (!isQuerySelectorType && /:has\(/.test(selector)) { + if (!complex || REG_LOGIC_HAS_COMPOUND.test(selector)) { + return false; + } + return REG_END_WITH_HAS.test(selector); + } else if (/:(?:is|not)\(/.test(selector)) { + if (complex) { + return !REG_LOGIC_COMPLEX.test(selector); + } else { + return !REG_LOGIC_COMPOUND.test(selector); + } + } else { + return !REG_WO_LOGICAL.test(selector); + } + } + return true; +}; |
