postcss-minify-selectors
Advanced tools
| 'use strict'; | ||
| // Folds sibling selectors in a comma-separated list by factoring out a common | ||
| // prefix and/or suffix into a shared `:is()` group. | ||
| // | ||
| // The specificity guard below is what keeps the rewrite safe w.r.t. the | ||
| // cascade: `:is(a, b)` takes the max specificity of its arguments, so folding | ||
| // selectors whose divergent parts have different specificities would silently | ||
| // change which rule wins at match time. | ||
| /** | ||
| * @typedef {object} Token | ||
| * @property {'compound'|'combinator'} kind | ||
| * @property {string} str | ||
| * @property {import('postcss-selector-parser').Node[]} [nodes] | ||
| */ | ||
| /** | ||
| * @param {import('postcss-selector-parser').Selector} selector | ||
| * @return {Token[]} | ||
| */ | ||
| function tokenize(selector) { | ||
| /** @type {Token[]} */ | ||
| const tokens = []; | ||
| /** @type {import('postcss-selector-parser').Node[]} */ | ||
| let bucket = []; | ||
| const flush = () => { | ||
| if (bucket.length) { | ||
| tokens.push({ | ||
| kind: 'compound', | ||
| str: bucket.map((n) => String(n)).join(''), | ||
| nodes: bucket, | ||
| }); | ||
| bucket = []; | ||
| } | ||
| }; | ||
| for (const node of selector.nodes) { | ||
| if (node.type === 'combinator') { | ||
| flush(); | ||
| tokens.push({ kind: 'combinator', str: String(node) }); | ||
| } else { | ||
| bucket.push(node); | ||
| } | ||
| } | ||
| flush(); | ||
| return tokens; | ||
| } | ||
| /** | ||
| * Pseudo-elements cannot live inside `:is()`; nesting `&` would change meaning. | ||
| * | ||
| * @param {Token} token | ||
| * @return {boolean} | ||
| */ | ||
| function hasPseudoElementOrNesting(token) { | ||
| if (token.kind !== 'compound' || !token.nodes) { | ||
| return false; | ||
| } | ||
| for (const n of token.nodes) { | ||
| if (n.type === 'nesting') { | ||
| return true; | ||
| } | ||
| if (n.type === 'pseudo') { | ||
| const v = n.value; | ||
| if (v.startsWith('::')) { | ||
| return true; | ||
| } | ||
| if ( | ||
| v === ':before' || | ||
| v === ':after' || | ||
| v === ':first-letter' || | ||
| v === ':first-line' | ||
| ) { | ||
| return true; | ||
| } | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| /** @typedef {[number, number, number]} Specificity */ | ||
| /** | ||
| * Specificity contribution of a list of simple-selector nodes, following the | ||
| * nested-specificity rules for `:is()`, `:not()`, `:has()`, and `:where()`. | ||
| * | ||
| * @param {import('postcss-selector-parser').Node[]} nodes | ||
| * @return {Specificity} | ||
| */ | ||
| function specificityOf(nodes) { | ||
| let id = 0; | ||
| let cls = 0; | ||
| let type = 0; | ||
| for (const n of nodes) { | ||
| if (n.type === 'id') { | ||
| id++; | ||
| } else if (n.type === 'class' || n.type === 'attribute') { | ||
| cls++; | ||
| } else if (n.type === 'pseudo') { | ||
| const v = n.value; | ||
| if (v.startsWith('::')) { | ||
| type++; | ||
| continue; | ||
| } | ||
| if (v === ':where') { | ||
| continue; | ||
| } | ||
| if (v === ':is' || v === ':matches' || v === ':not' || v === ':has') { | ||
| const s = maxChildSpecificity(n); | ||
| id += s[0]; | ||
| cls += s[1]; | ||
| type += s[2]; | ||
| continue; | ||
| } | ||
| // Legacy single-colon pseudo-elements count as type. | ||
| if ( | ||
| v === ':before' || | ||
| v === ':after' || | ||
| v === ':first-letter' || | ||
| v === ':first-line' | ||
| ) { | ||
| type++; | ||
| } else { | ||
| cls++; | ||
| } | ||
| } else if (n.type === 'tag') { | ||
| type++; | ||
| } | ||
| } | ||
| return [id, cls, type]; | ||
| } | ||
| /** | ||
| * @param {import('postcss-selector-parser').Pseudo} pseudo | ||
| * @return {Specificity} | ||
| */ | ||
| function maxChildSpecificity(pseudo) { | ||
| /** @type {Specificity} */ | ||
| let best = [0, 0, 0]; | ||
| for (const child of pseudo.nodes) { | ||
| if (child.type !== 'selector') { | ||
| continue; | ||
| } | ||
| const s = specificityOf(child.nodes); | ||
| if (compareSpecificity(s, best) > 0) { | ||
| best = s; | ||
| } | ||
| } | ||
| return best; | ||
| } | ||
| /** | ||
| * @param {Token[]} middle | ||
| * @return {Specificity} | ||
| */ | ||
| function specificityOfMiddle(middle) { | ||
| let id = 0; | ||
| let cls = 0; | ||
| let type = 0; | ||
| for (const token of middle) { | ||
| if (token.kind !== 'compound' || !token.nodes) { | ||
| continue; | ||
| } | ||
| const s = specificityOf(token.nodes); | ||
| id += s[0]; | ||
| cls += s[1]; | ||
| type += s[2]; | ||
| } | ||
| return [id, cls, type]; | ||
| } | ||
| /** | ||
| * @param {Specificity} a | ||
| * @param {Specificity} b | ||
| * @return {number} | ||
| */ | ||
| function compareSpecificity(a, b) { | ||
| return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; | ||
| } | ||
| /** | ||
| * @param {Specificity} a | ||
| * @param {Specificity} b | ||
| * @return {boolean} | ||
| */ | ||
| function sameSpecificity(a, b) { | ||
| return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; | ||
| } | ||
| /** | ||
| * @param {Token[]} tokens | ||
| * @return {string} | ||
| */ | ||
| function joinTokens(tokens) { | ||
| return tokens.map((t) => t.str).join(''); | ||
| } | ||
| /** | ||
| * Returns the folded selector list string if beneficial, otherwise null. | ||
| * | ||
| * @param {import('postcss-selector-parser').Root} root | ||
| * @return {string | null} | ||
| */ | ||
| function tryFold(root) { | ||
| const selectors = /** @type {import('postcss-selector-parser').Selector[]} */ ( | ||
| root.nodes.filter((n) => n.type === 'selector') | ||
| ); | ||
| if (selectors.length < 2) { | ||
| return null; | ||
| } | ||
| const tokenLists = selectors.map(tokenize); | ||
| if (tokenLists.some((t) => t.length === 0)) { | ||
| return null; | ||
| } | ||
| let prefix = 0; | ||
| const minLen = Math.min(...tokenLists.map((t) => t.length)); | ||
| while (prefix < minLen) { | ||
| const ref = tokenLists[0][prefix]; | ||
| const allMatch = tokenLists.every( | ||
| (t) => t[prefix].kind === ref.kind && t[prefix].str === ref.str | ||
| ); | ||
| if (!allMatch) { | ||
| break; | ||
| } | ||
| prefix++; | ||
| } | ||
| let suffix = 0; | ||
| while (suffix < minLen - prefix) { | ||
| const refIdx = tokenLists[0].length - 1 - suffix; | ||
| const ref = tokenLists[0][refIdx]; | ||
| const allMatch = tokenLists.every((t) => { | ||
| const idx = t.length - 1 - suffix; | ||
| return idx >= prefix && t[idx].kind === ref.kind && t[idx].str === ref.str; | ||
| }); | ||
| if (!allMatch) { | ||
| break; | ||
| } | ||
| suffix++; | ||
| } | ||
| // Each prefix/suffix boundary must land on a combinator — otherwise | ||
| // splicing `:is(...)` in would place it adjacent to a type/class token | ||
| // in the prefix/suffix and silently change what the selector matches. | ||
| const firstTokens = tokenLists[0]; | ||
| while (prefix > 0 && firstTokens[prefix - 1].kind !== 'combinator') { | ||
| prefix--; | ||
| } | ||
| while (suffix > 0 && firstTokens[firstTokens.length - suffix].kind !== 'combinator') { | ||
| suffix--; | ||
| } | ||
| if (prefix === 0 && suffix === 0) { | ||
| return null; | ||
| } | ||
| const middles = tokenLists.map((t) => t.slice(prefix, t.length - suffix)); | ||
| if (middles.some((m) => m.length === 0)) { | ||
| return null; | ||
| } | ||
| for (const middle of middles) { | ||
| for (const token of middle) { | ||
| if (hasPseudoElementOrNesting(token)) { | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
| const firstSpec = specificityOfMiddle(middles[0]); | ||
| for (let i = 1; i < middles.length; i++) { | ||
| if (!sameSpecificity(firstSpec, specificityOfMiddle(middles[i]))) { | ||
| return null; | ||
| } | ||
| } | ||
| const middleStrs = []; | ||
| const seen = new Set(); | ||
| for (const m of middles) { | ||
| const s = joinTokens(m); | ||
| if (!seen.has(s)) { | ||
| seen.add(s); | ||
| middleStrs.push(s); | ||
| } | ||
| } | ||
| if (middleStrs.length < 2) { | ||
| return null; | ||
| } | ||
| const prefixStr = joinTokens(firstTokens.slice(0, prefix)); | ||
| const suffixStr = joinTokens(firstTokens.slice(firstTokens.length - suffix)); | ||
| const folded = `${prefixStr}:is(${middleStrs.join(',')})${suffixStr}`; | ||
| const original = selectors.map((s) => String(s)).join(','); | ||
| if (folded.length >= original.length) { | ||
| return null; | ||
| } | ||
| return folded; | ||
| } | ||
| module.exports = tryFold; |
| export = tryFold; | ||
| /** | ||
| * Returns the folded selector list string if beneficial, otherwise null. | ||
| * | ||
| * @param {import('postcss-selector-parser').Root} root | ||
| * @return {string | null} | ||
| */ | ||
| declare function tryFold(root: import("postcss-selector-parser").Root): string | null; | ||
| declare namespace tryFold { | ||
| export { Token, Specificity }; | ||
| } | ||
| type Token = { | ||
| kind: "compound" | "combinator"; | ||
| str: string; | ||
| nodes?: import("postcss-selector-parser").Node[] | undefined; | ||
| }; | ||
| type Specificity = [number, number, number]; | ||
| //# sourceMappingURL=foldToIs.d.ts.map |
| {"version":3,"file":"foldToIs.d.ts","sourceRoot":"","sources":["../../src/lib/foldToIs.js"],"names":[],"mappings":";AAuMA;;;;;GAKG;AACH,+BAHW,OAAO,yBAAyB,EAAE,IAAI,GACrC,MAAM,GAAG,IAAI,CAsGxB;;;;;UArSa,UAAU,GAAC,YAAY;SACvB,MAAM;;;mBAqEN,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC"} |
+6
-3
| { | ||
| "name": "postcss-minify-selectors", | ||
| "version": "7.0.6", | ||
| "version": "7.1.0", | ||
| "description": "Minify selectors with PostCSS.", | ||
@@ -29,2 +29,4 @@ "main": "src/index.js", | ||
| "dependencies": { | ||
| "browserslist": "^4.28.1", | ||
| "caniuse-api": "^3.0.0", | ||
| "cssesc": "^3.0.0", | ||
@@ -40,8 +42,9 @@ "postcss-selector-parser": "^7.1.1" | ||
| "devDependencies": { | ||
| "@types/caniuse-api": "^3.0.6", | ||
| "@types/cssesc": "^3.0.2", | ||
| "postcss": "^8.5.6" | ||
| "postcss": "^8.5.10" | ||
| }, | ||
| "peerDependencies": { | ||
| "postcss": "^8.4.32" | ||
| "postcss": "^8.5.10" | ||
| } | ||
| } |
+39
-0
@@ -29,2 +29,41 @@ # [postcss][postcss]-minify-selectors | ||
| ## Options | ||
| ### `sort` | ||
| Type: `boolean` | ||
| Default: `true` | ||
| Alphabetically sort selectors within a comma-separated list. | ||
| ### `convertToIs` | ||
| Type: `boolean` | ||
| Default: `true` | ||
| Factor a shared prefix and/or suffix in a comma-separated selector list into | ||
| a single `:is(...)` group when the result is strictly shorter and safe with | ||
| respect to the cascade. The rewrite only applies when every variable part | ||
| has the same CSS specificity (so the cascade isn't silently altered) and | ||
| contains no pseudo-elements. It is automatically skipped when the configured | ||
| `browserslist` target doesn't support `:is()`. | ||
| #### Input | ||
| ```css | ||
| section h1, article h1, aside h1, nav h1 { font-size: 25px } | ||
| ``` | ||
| #### Output | ||
| ```css | ||
| :is(article,aside,nav,section) h1{font-size:25px} | ||
| ``` | ||
| ### Browserslist | ||
| The plugin reads the `browserslist` configuration from the host project by | ||
| default. You can override with `overrideBrowserslist`, `stats`, `env`, or | ||
| `path` — the same options accepted by `autoprefixer` and `postcss-merge-rules`. | ||
| ## Usage | ||
@@ -31,0 +70,0 @@ |
+92
-50
| 'use strict'; | ||
| const { dirname } = require('path'); | ||
| const browserslist = require('browserslist'); | ||
| const { isSupported } = require('caniuse-api'); | ||
| const parser = require('postcss-selector-parser'); | ||
| const canUnquote = require('./lib/canUnquote.js'); | ||
| const foldToIs = require('./lib/foldToIs.js'); | ||
@@ -200,70 +204,108 @@ const pseudoElements = new Set([ | ||
| /** @typedef {object} Options | ||
| * @property {boolean} [sort=true] | ||
| /** | ||
| * @typedef {{ overrideBrowserslist?: string | string[] }} AutoprefixerOptions | ||
| * @typedef {Pick<browserslist.Options, 'stats' | 'path' | 'env'>} BrowserslistOptions | ||
| */ | ||
| /** | ||
| * @type {import('postcss').PluginCreator<void>} | ||
| * @typedef {object} OwnOptions | ||
| * @property {boolean} [sort=true] | ||
| * @property {boolean} [convertToIs=true] Factor shared prefixes/suffixes in a | ||
| * comma-separated selector list into `:is(...)` when it produces shorter | ||
| * output and is safe with respect to cascade specificity. Automatically | ||
| * skipped when the configured browserslist target doesn't support `:is()`. | ||
| */ | ||
| /** @typedef {OwnOptions & AutoprefixerOptions & BrowserslistOptions} Options */ | ||
| /** | ||
| * @type {import('postcss').PluginCreator<Options>} | ||
| * @param {Options} opts | ||
| * @return {import('postcss').Plugin} | ||
| */ | ||
| function pluginCreator(opts = { sort: true }) { | ||
| function pluginCreator(opts) { | ||
| const resolved = { sort: true, convertToIs: true, ...(opts || {}) }; | ||
| return { | ||
| postcssPlugin: 'postcss-minify-selectors', | ||
| OnceExit(css) { | ||
| const cache = new Map(); | ||
| const processor = parser((selectors) => { | ||
| const uniqueSelectors = new Set(); | ||
| /** | ||
| * @param {import('postcss').Result & {opts: BrowserslistOptions & {file?: string}}} result | ||
| */ | ||
| prepare(result) { | ||
| let isFoldEnabled = resolved.convertToIs !== false; | ||
| if (isFoldEnabled) { | ||
| const { stats, env, from, file } = result.opts || {}; | ||
| const browsers = browserslist(resolved.overrideBrowserslist, { | ||
| stats: resolved.stats || stats, | ||
| path: resolved.path || dirname(from || file || __filename), | ||
| env: resolved.env || env, | ||
| }); | ||
| isFoldEnabled = isSupported('css-matches-pseudo', browsers); | ||
| } | ||
| selectors.walk((sel) => { | ||
| // Trim whitespace around the value | ||
| sel.spaces.before = sel.spaces.after = ''; | ||
| const reducer = reducers.get(sel.type); | ||
| if (reducer !== undefined) { | ||
| reducer(sel); | ||
| return; | ||
| } | ||
| return { | ||
| OnceExit(css) { | ||
| const cache = new Map(); | ||
| const processor = parser((selectors) => { | ||
| const uniqueSelectors = new Set(); | ||
| const toString = String(sel); | ||
| selectors.walk((sel) => { | ||
| // Trim whitespace around the value | ||
| sel.spaces.before = sel.spaces.after = ''; | ||
| const reducer = reducers.get(sel.type); | ||
| if (reducer !== undefined) { | ||
| reducer(sel); | ||
| return; | ||
| } | ||
| if ( | ||
| sel.type === 'selector' && | ||
| sel.parent && | ||
| sel.parent.type !== 'pseudo' | ||
| ) { | ||
| if (!uniqueSelectors.has(toString)) { | ||
| uniqueSelectors.add(toString); | ||
| } else { | ||
| sel.remove(); | ||
| const toString = String(sel); | ||
| if ( | ||
| sel.type === 'selector' && | ||
| sel.parent && | ||
| sel.parent.type !== 'pseudo' | ||
| ) { | ||
| if (!uniqueSelectors.has(toString)) { | ||
| uniqueSelectors.add(toString); | ||
| } else { | ||
| sel.remove(); | ||
| } | ||
| } | ||
| }); | ||
| if (resolved.sort) { | ||
| selectors.nodes.sort(); | ||
| } | ||
| } | ||
| }); | ||
| if (opts.sort) { | ||
| selectors.nodes.sort(); | ||
| } | ||
| }); | ||
| if (isFoldEnabled) { | ||
| const folded = foldToIs(selectors); | ||
| if (folded !== null) { | ||
| selectors.nodes = parser().astSync(folded).nodes; | ||
| } | ||
| } | ||
| }); | ||
| css.walkRules((rule) => { | ||
| const selector = | ||
| rule.raws.selector && rule.raws.selector.value === rule.selector | ||
| ? rule.raws.selector.raw | ||
| : rule.selector; | ||
| css.walkRules((rule) => { | ||
| const selector = | ||
| rule.raws.selector && rule.raws.selector.value === rule.selector | ||
| ? rule.raws.selector.raw | ||
| : rule.selector; | ||
| // If the selector ends with a ':' it is likely a part of a custom mixin, | ||
| // so just pass through. | ||
| if (selector[selector.length - 1] === ':') { | ||
| return; | ||
| } | ||
| // If the selector ends with a ':' it is likely a part of a custom | ||
| // mixin, so just pass through. | ||
| if (selector[selector.length - 1] === ':') { | ||
| return; | ||
| } | ||
| if (cache.has(selector)) { | ||
| rule.selector = cache.get(selector); | ||
| if (cache.has(selector)) { | ||
| rule.selector = cache.get(selector); | ||
| return; | ||
| } | ||
| return; | ||
| } | ||
| const optimizedSelector = processor.processSync(selector); | ||
| const optimizedSelector = processor.processSync(selector); | ||
| rule.selector = optimizedSelector; | ||
| cache.set(selector, optimizedSelector); | ||
| }); | ||
| rule.selector = optimizedSelector; | ||
| cache.set(selector, optimizedSelector); | ||
| }); | ||
| }, | ||
| }; | ||
| }, | ||
@@ -270,0 +312,0 @@ }; |
+29
-6
| export = pluginCreator; | ||
| /** @typedef {object} Options | ||
| * @property {boolean} [sort=true] | ||
| /** | ||
| * @typedef {{ overrideBrowserslist?: string | string[] }} AutoprefixerOptions | ||
| * @typedef {Pick<browserslist.Options, 'stats' | 'path' | 'env'>} BrowserslistOptions | ||
| */ | ||
| /** | ||
| * @type {import('postcss').PluginCreator<void>} | ||
| * @typedef {object} OwnOptions | ||
| * @property {boolean} [sort=true] | ||
| * @property {boolean} [convertToIs=true] Factor shared prefixes/suffixes in a | ||
| * comma-separated selector list into `:is(...)` when it produces shorter | ||
| * output and is safe with respect to cascade specificity. Automatically | ||
| * skipped when the configured browserslist target doesn't support `:is()`. | ||
| */ | ||
| /** @typedef {OwnOptions & AutoprefixerOptions & BrowserslistOptions} Options */ | ||
| /** | ||
| * @type {import('postcss').PluginCreator<Options>} | ||
| * @param {Options} opts | ||
| * @return {import('postcss').Plugin} | ||
| */ | ||
| declare function pluginCreator(opts?: Options): import("postcss").Plugin; | ||
| declare function pluginCreator(opts: Options): import("postcss").Plugin; | ||
| declare namespace pluginCreator { | ||
| export { postcss, Options }; | ||
| export { postcss, AutoprefixerOptions, BrowserslistOptions, OwnOptions, Options }; | ||
| } | ||
| declare var postcss: true; | ||
| type Options = { | ||
| type AutoprefixerOptions = { | ||
| overrideBrowserslist?: string | string[]; | ||
| }; | ||
| type BrowserslistOptions = Pick<browserslist.Options, "stats" | "path" | "env">; | ||
| type OwnOptions = { | ||
| sort?: boolean | undefined; | ||
| /** | ||
| * Factor shared prefixes/suffixes in a | ||
| * comma-separated selector list into `:is(...)` when it produces shorter | ||
| * output and is safe with respect to cascade specificity. Automatically | ||
| * skipped when the configured browserslist target doesn't support `:is()`. | ||
| */ | ||
| convertToIs?: boolean | undefined; | ||
| }; | ||
| type Options = OwnOptions & AutoprefixerOptions & BrowserslistOptions; | ||
| import browserslist = require("browserslist"); | ||
| //# sourceMappingURL=index.d.ts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":";AAuMA;;GAEG;AACH;;;;GAIG;AACH,sCAHW,OAAO,GACN,OAAO,SAAS,EAAE,MAAM,CAgEnC"} | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":";AA2MA;;;GAGG;AAEH;;;;;;;GAOG;AAEH,gFAAgF;AAEhF;;;;GAIG;AACH,qCAHW,OAAO,GACN,OAAO,SAAS,EAAE,MAAM,CAyFnC;;;;;2BA3GY;IAAE,oBAAoB,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAAE;2BAC5C,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;;;;;;;;;;;eAYnD,UAAU,GAAG,mBAAmB,GAAG,mBAAmB"} |
23246
117.09%12
33.33%634
128.88%82
90.7%5
66.67%3
50%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added