postcss-minify-selectors
Version:
Minify selectors with PostCSS.
316 lines (296 loc) • 6.54 kB
JavaScript
'use strict';
/** @typedef {import('postcss-selector-parser').Node} Node */
/** @typedef {import('postcss-selector-parser').Selector} Selector */
/** @typedef {import('postcss-selector-parser').Pseudo} Pseudo */
/**
* @typedef {object} Token
* @property {'compound'|'combinator'} kind
* @property {string} str
* @property {Node[]} [nodes]
*/
/** @typedef {[number, number, number]} Specificity */
// Pseudo-classes accepted inside a fold middle. Limited to user-action
// pseudos. Anything outside this set risks the rule-list-strict vs `:is()`
const SAFE_PSEUDO_CLASSES = new Set([
':hover',
':focus',
':active',
':visited',
':link',
]);
/**
* @param {Selector} selector
* @return {Token[]}
*/
function tokenize(selector) {
/** @type {Token[]} */
const tokens = [];
/** @type {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;
}
/**
* @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;
}
/**
* @param {Token} token
* @return {boolean}
*/
function hasNthChildOfClause(token) {
if (token.kind !== 'compound' || !token.nodes) {
return false;
}
return nodesContainNthChildOfClause(token.nodes);
}
/**
* @param {Node[]} nodes
* @return {boolean}
*/
function nodesContainNthChildOfClause(nodes) {
for (const n of nodes) {
if (n.type !== 'pseudo') {
continue;
}
if (n.value === ':nth-child' || n.value === ':nth-last-child') {
for (const child of n.nodes) {
for (const inner of child.nodes) {
if (inner.type === 'tag' && inner.value === 'of') {
return true;
}
}
}
}
for (const child of n.nodes) {
if (
child.type === 'selector' &&
nodesContainNthChildOfClause(child.nodes)
) {
return true;
}
}
}
return false;
}
/**
* @param {Token} token
* @return {boolean}
*/
function hasUnsafeForFold(token) {
if (token.kind !== 'compound' || !token.nodes) {
return false;
}
return nodesContainUnsafeForFold(token.nodes);
}
/**
* @param {Node[]} nodes
* @return {boolean}
*/
function nodesContainUnsafeForFold(nodes) {
for (const n of nodes) {
const t = n.type;
if (t === 'class' || t === 'id') {
continue;
}
if (t === 'tag') {
if (n.namespace !== undefined && n.namespace !== null) {
return true;
}
continue;
}
if (t === 'attribute') {
if (n.namespace !== undefined && n.namespace !== null) {
return true;
}
if (n.insensitive || (n.raws && 'insensitiveFlag' in n.raws)) {
return true;
}
continue;
}
if (t === 'pseudo') {
const v = n.value;
if (
v.startsWith('::') ||
v === ':before' ||
v === ':after' ||
v === ':first-letter' ||
v === ':first-line'
) {
return true;
}
if (!SAFE_PSEUDO_CLASSES.has(v)) {
return true;
}
if (n.nodes && n.nodes.length > 0) {
return true;
}
continue;
}
return true;
}
return false;
}
/**
* @param {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;
}
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 {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;
}
/**
* Sums the specificity of compound tokens in a fold middle — the divergent
* portion of a selector list, between the shared prefix and shared suffix.
*
* @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 equalSpecificity(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('');
}
module.exports = {
tokenize,
hasPseudoElementOrNesting,
hasNthChildOfClause,
hasUnsafeForFold,
specificityOf,
specificityOfMiddle,
maxChildSpecificity,
compareSpecificity,
equalSpecificity,
joinTokens,
};