432 lines
12 KiB
JavaScript
432 lines
12 KiB
JavaScript
/**
|
|
* 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<object>, 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<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
|
|
};
|
|
};
|
|
|
|
/* Re-exported from css-tree. */
|
|
export { find as findAST, generate as generateCSS } from 'css-tree';
|