/** * parser.js */ /* import */ import * as cssTree from 'css-tree'; import { getType } from './utility.js'; /* constants */ import { ATTR_SELECTOR, BIT_01, BIT_02, BIT_04, BIT_08, BIT_16, BIT_32, BIT_FFFF, CLASS_SELECTOR, DUO, HEX, ID_SELECTOR, KEYS_LOGICAL, NTH, PS_CLASS_SELECTOR, PS_ELEMENT_SELECTOR, SELECTOR, SYNTAX_ERR, TYPE_SELECTOR } from './constant.js'; const AST_SORT_ORDER = new Map([ [PS_ELEMENT_SELECTOR, BIT_01], [ID_SELECTOR, BIT_02], [CLASS_SELECTOR, BIT_04], [TYPE_SELECTOR, BIT_08], [ATTR_SELECTOR, BIT_16], [PS_CLASS_SELECTOR, BIT_32] ]); const KEYS_PS_CLASS_STATE = new Set([ 'checked', 'closed', 'disabled', 'empty', 'enabled', 'in-range', 'indeterminate', 'invalid', 'open', 'out-of-range', 'placeholder-shown', 'read-only', 'read-write', 'valid' ]); const KEYS_SHADOW_HOST = new Set(['host', 'host-context']); const REG_EMPTY_PS_FUNC = /(?<=:(?:dir|has|host(?:-context)?|is|lang|not|nth-(?:last-)?(?:child|of-type)|where))\(\s+\)/g; const REG_SHADOW_PS_ELEMENT = /^part|slotted$/; const U_FFFD = '\uFFFD'; /** * Unescapes a CSS selector string. * @param {string} selector - The CSS selector to unescape. * @returns {string} The unescaped selector string. */ export const unescapeSelector = (selector = '') => { if (typeof selector === 'string' && selector.indexOf('\\', 0) >= 0) { const arr = selector.split('\\'); const selectorItems = [arr[0]]; const l = arr.length; for (let i = 1; i < l; i++) { const item = arr[i]; if (item === '' && i === l - 1) { selectorItems.push(U_FFFD); } else { const hexExists = /^([\da-f]{1,6}\s?)/i.exec(item); if (hexExists) { const [, hex] = hexExists; let str; try { const low = parseInt('D800', HEX); const high = parseInt('DFFF', HEX); const deci = parseInt(hex, HEX); if (deci === 0 || (deci >= low && deci <= high)) { str = U_FFFD; } else { str = String.fromCodePoint(deci); } } catch (e) { str = U_FFFD; } let postStr = ''; if (item.length > hex.length) { postStr = item.substring(hex.length); } selectorItems.push(`${str}${postStr}`); // whitespace } else if (/^[\n\r\f]/.test(item)) { selectorItems.push(`\\${item}`); } else { selectorItems.push(item); } } } return selectorItems.join(''); } return selector; }; /** * Preprocesses a selector string according to the specification. * @see https://drafts.csswg.org/css-syntax-3/#input-preprocessing * @param {string} value - The value to preprocess. * @returns {string} The preprocessed selector string. */ export const preprocess = value => { // Non-string values will be converted to string. if (typeof value !== 'string') { if (value === undefined || value === null) { return getType(value).toLowerCase(); } else if (Array.isArray(value)) { return value.join(','); } else if (Object.hasOwn(value, 'toString')) { return value.toString(); } else { throw new DOMException(`Invalid selector ${value}`, SYNTAX_ERR); } } let selector = value; let index = 0; while (index >= 0) { // @see https://drafts.csswg.org/selectors/#id-selectors index = selector.indexOf('#', index); if (index < 0) { break; } const preHash = selector.substring(0, index + 1); let postHash = selector.substring(index + 1); const codePoint = postHash.codePointAt(0); if (codePoint > BIT_FFFF) { const str = `\\${codePoint.toString(HEX)} `; if (postHash.length === DUO) { postHash = str; } else { postHash = `${str}${postHash.substring(DUO)}`; } } selector = `${preHash}${postHash}`; index++; } return selector .replace(/\f|\r\n?/g, '\n') .replace(/[\0\uD800-\uDFFF]|\\$/g, U_FFFD) .replace(/\x26/g, ':scope'); }; /** * Creates an Abstract Syntax Tree (AST) from a CSS selector string. * @param {string} sel - The CSS selector string. * @returns {object} The parsed AST object. */ export const parseSelector = sel => { const selector = preprocess(sel); // invalid selectors if (/^$|^\s*>|,\s*$/.test(selector)) { throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR); } try { const ast = cssTree.parse(selector, { context: 'selectorList', parseCustomProperty: true }); return cssTree.toPlainObject(ast); } catch (e) { const { message } = e; if ( /^(?:"\]"|Attribute selector [()\s,=~^$*|]+) is expected$/.test( message ) && !selector.endsWith(']') ) { const index = selector.lastIndexOf('['); const selPart = selector.substring(index); if (selPart.includes('"')) { const quotes = selPart.match(/"/g).length; if (quotes % 2) { return parseSelector(`${selector}"]`); } return parseSelector(`${selector}]`); } return parseSelector(`${selector}]`); } else if (message === '")" is expected') { // workaround for https://github.com/csstree/csstree/issues/283 if (REG_EMPTY_PS_FUNC.test(selector)) { return parseSelector(`${selector.replaceAll(REG_EMPTY_PS_FUNC, '()')}`); } else if (!selector.endsWith(')')) { return parseSelector(`${selector})`); } else { throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR); } } else { throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR); } } }; /** * Walks the provided AST to collect selector branches and gather information * about its contents. * @param {object} ast - The AST to traverse. * @returns {{branches: Array, info: object}} An object containing the selector branches and info. */ export const walkAST = (ast = {}) => { const branches = new Set(); const info = { hasForgivenPseudoFunc: false, hasHasPseudoFunc: false, hasLogicalPseudoFunc: false, hasNotPseudoFunc: false, hasNthChildOfSelector: false, hasNestedSelector: false, hasStatePseudoClass: false }; const opt = { enter(node) { switch (node.type) { case CLASS_SELECTOR: { if (/^-?\d/.test(node.name)) { throw new DOMException( `Invalid selector .${node.name}`, SYNTAX_ERR ); } break; } case ID_SELECTOR: { if (/^-?\d/.test(node.name)) { throw new DOMException( `Invalid selector #${node.name}`, SYNTAX_ERR ); } break; } case PS_CLASS_SELECTOR: { if (KEYS_LOGICAL.has(node.name)) { info.hasNestedSelector = true; info.hasLogicalPseudoFunc = true; if (node.name === 'has') { info.hasHasPseudoFunc = true; } else if (node.name === 'not') { info.hasNotPseudoFunc = true; } else { info.hasForgivenPseudoFunc = true; } } else if (KEYS_PS_CLASS_STATE.has(node.name)) { info.hasStatePseudoClass = true; } else if ( KEYS_SHADOW_HOST.has(node.name) && Array.isArray(node.children) && node.children.length ) { info.hasNestedSelector = true; } break; } case PS_ELEMENT_SELECTOR: { if (REG_SHADOW_PS_ELEMENT.test(node.name)) { info.hasNestedSelector = true; } break; } case NTH: { if (node.selector) { info.hasNestedSelector = true; info.hasNthChildOfSelector = true; } break; } case SELECTOR: { branches.add(node.children); break; } default: } } }; cssTree.walk(ast, opt); if (info.hasNestedSelector === true) { cssTree.findAll(ast, (node, item, list) => { if (list) { if (node.type === PS_CLASS_SELECTOR && KEYS_LOGICAL.has(node.name)) { const itemList = list.filter(i => { const { name, type } = i; return type === PS_CLASS_SELECTOR && KEYS_LOGICAL.has(name); }); for (const { children } of itemList) { // SelectorList for (const { children: grandChildren } of children) { // Selector for (const { children: greatGrandChildren } of grandChildren) { if (branches.has(greatGrandChildren)) { branches.delete(greatGrandChildren); } } } } } else if ( node.type === PS_CLASS_SELECTOR && KEYS_SHADOW_HOST.has(node.name) && Array.isArray(node.children) && node.children.length ) { const itemList = list.filter(i => { const { children, name, type } = i; const res = type === PS_CLASS_SELECTOR && KEYS_SHADOW_HOST.has(name) && Array.isArray(children) && children.length; return res; }); for (const { children } of itemList) { // Selector for (const { children: grandChildren } of children) { if (branches.has(grandChildren)) { branches.delete(grandChildren); } } } } else if ( node.type === PS_ELEMENT_SELECTOR && REG_SHADOW_PS_ELEMENT.test(node.name) ) { const itemList = list.filter(i => { const { name, type } = i; const res = type === PS_ELEMENT_SELECTOR && REG_SHADOW_PS_ELEMENT.test(name); return res; }); for (const { children } of itemList) { // Selector for (const { children: grandChildren } of children) { if (branches.has(grandChildren)) { branches.delete(grandChildren); } } } } else if (node.type === NTH && node.selector) { const itemList = list.filter(i => { const { selector, type } = i; const res = type === NTH && selector; return res; }); for (const { selector } of itemList) { const { children } = selector; // Selector for (const { children: grandChildren } of children) { if (branches.has(grandChildren)) { branches.delete(grandChildren); } } } } } }); } return { info, branches: [...branches] }; }; /** * Comparison function for sorting AST nodes based on specificity. * @param {object} a - The first AST node. * @param {object} b - The second AST node. * @returns {number} -1, 0 or 1, depending on the sort order. */ export const compareASTNodes = (a, b) => { const bitA = AST_SORT_ORDER.get(a.type); const bitB = AST_SORT_ORDER.get(b.type); if (bitA === bitB) { return 0; } else if (bitA > bitB) { return 1; } else { return -1; } }; /** * Sorts a collection of AST nodes based on CSS specificity rules. * @param {Array} asts - A collection of AST nodes to sort. * @returns {Array} A new array containing the sorted AST nodes. */ export const sortAST = asts => { const arr = [...asts]; if (arr.length > 1) { arr.sort(compareASTNodes); } return arr; }; /** * Parses a type selector's name, which may include a namespace prefix. * @param {string} selector - The type selector name (e.g., 'ns|E' or 'E'). * @returns {{prefix: string, localName: string}} An object with `prefix` and * `localName` properties. */ export const parseAstName = selector => { let prefix; let localName; if (selector && typeof selector === 'string') { if (selector.indexOf('|') > -1) { [prefix, localName] = selector.split('|'); } else { prefix = '*'; localName = selector; } } else { throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR); } return { prefix, localName }; }; /* Re-exported from css-tree. */ export { find as findAST, generate as generateCSS } from 'css-tree';