diff --git a/package-lock.json b/package-lock.json index 4d34c7f..c01ebb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,10 +15,12 @@ "specificity": "bin/cli.js" }, "devDependencies": { + "@projectwallace/css-parser": "^0.14.10", "benchmark": "^2.1.4", "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" } @@ -477,6 +479,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", @@ -780,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", @@ -1401,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", @@ -1680,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", @@ -2107,6 +2150,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", @@ -2324,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", @@ -2740,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", @@ -2912,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 1674a61..5e20309 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,10 +91,12 @@ }, "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", "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 new file mode 100644 index 0000000..ea2d3ad --- /dev/null +++ b/src/core/adapters/csstree.js @@ -0,0 +1,144 @@ +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..7c20e4d --- /dev/null +++ b/src/core/adapters/postcss.js @@ -0,0 +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'; + } + }, + + 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..e8fbbb6 --- /dev/null +++ b/src/core/adapters/projectwallace.js @@ -0,0 +1,104 @@ +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 || []; + }, + + 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) { + 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) { + 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) { + 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) { + 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..c58113f --- /dev/null +++ b/src/core/calculate-generic.js @@ -0,0 +1,189 @@ +/** + * @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..4896094 100644 --- a/src/core/calculate.js +++ b/src/core/calculate.js @@ -1,6 +1,7 @@ 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) => { @@ -9,183 +10,7 @@ const calculateForAST = (selectorAST) => { 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; - } - }); - + const { a, b, c } = calculateForSelector(selectorAST, csstreeWalker); return new Specificity({ a, b, c }, selectorAST); }; 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..d7e562c --- /dev/null +++ b/test/postcss.js @@ -0,0 +1,67 @@ +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..6f1bfdf --- /dev/null +++ b/test/projectwallace.js @@ -0,0 +1,68 @@ +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); }); }