🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

postcss-minify-selectors

Package Overview
Dependencies
Maintainers
8
Versions
75
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

postcss-minify-selectors - npm Package Compare versions

Comparing version
7.0.6
to
7.1.0
+308
src/lib/foldToIs.js
'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"
}
}

@@ -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 @@

'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 @@ };

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"}