cssnano
Version:
A modular CSS minifier, built on top of the PostCSS ecosystem.
348 lines (323 loc) • 10.7 kB
JavaScript
import cssnanoUtils from '#utils';
import { LayerRegistry, compareAtRulePriority } from './lib/layerRegistry.js';
import {
getBody,
getConditionContainer,
parseAtRuleName,
VENDOR_PREFIX,
} from './lib/grammar.js';
import { scanDeclarations } from './lib/protectedNames.js';
import { createReplacement, rewriteDeclaration } from './lib/valueRewriter.js';
const { asciiLowerCase } = cssnanoUtils;
/**
* @typedef {Array<{ name: string, copies: AtRuleEntry[] }>} Group names that
* are defined in the same at-rules and containers with the same body
*
* @typedef {{
* rule: import('postcss').AtRule,
* body?: string,
* layerPriority: number[],
* documentIndex: number,
* family: string,
* containerId: number,
* parsed: { isString: boolean, isReservedName: boolean, key: string, tokenText: string }
* }} AtRuleEntry
*/
/**
* @param {AtRuleEntry[]} entries
* @return {AtRuleEntry}
*/
function highestPriority(entries) {
let best = entries[0];
for (const entry of entries) {
if (compareAtRulePriority(best, entry) < 0) {
best = entry;
}
}
return best;
}
/**
* Every name of a group behaves alike, so the cascade priority does not
* matter for correctness; the shortest spelling minimizes output size and
* priority breaks ties.
*
* @param {AtRuleEntry[]} entries
* @return {AtRuleEntry}
*/
function shortestSpelling(entries) {
const shortest = Math.min(
...entries.map(({ parsed }) => parsed.tokenText.length)
);
return highestPriority(
entries.filter(({ parsed }) => parsed.tokenText.length === shortest)
);
}
/**
* Removes repeat definitions of one name that share a body and a condition
* container, keeping the one that wins the cascade: the kept definition is
* present whenever a removed one is.
*
* @param {AtRuleEntry[]} copies definitions of one name
* @param {Set<import('postcss').AtRule>} removals
* @return {AtRuleEntry[]} the definitions that remain
*/
function collapseSameNameCopies(copies, removals) {
if (copies.length < 2) {
return copies;
}
for (const group of Map.groupBy(copies, placementOf).values()) {
const body = getBody(group[0]);
if (group.length < 2 || !group.every((copy) => getBody(copy) === body)) {
continue;
}
const survivor = highestPriority(group);
for (const copy of group) {
if (copy !== survivor) {
removals.add(copy.rule);
}
}
}
return copies.filter((copy) => !removals.has(copy.rule));
}
/**
* @param {AtRuleEntry} entry
* @return {string} the at-rule family and condition container defining it
*/
function placementOf(entry) {
return `${entry.family}@${entry.containerId}`;
}
/**
* Makes each name of a group of interchangeable names, except the one that
* wins the cascade, refer to the winner. A name that substituted text may
* spell keeps its definition and is preferred as the merge target.
*
* @param {Group} group
* @param {Set<string>} protectedNames
* @param {Map<string, import('./lib/valueRewriter.js').Replacement>} renames
* @param {Set<import('postcss').AtRule>} removals
* @return {void}
*/
function mergeInterchangeable(group, protectedNames, renames, removals) {
const protectedMembers = group.filter(({ name }) => protectedNames.has(name));
const targetCandidates = (
protectedMembers.length > 0 ? protectedMembers : group
).flatMap(({ copies }) => copies);
// An identifier reference is shorter than a string and more widely
// supported as a keyframes name.
const identifiers = targetCandidates.filter(({ parsed }) => !parsed.isString);
const target = shortestSpelling(
identifiers.length > 0 ? identifiers : targetCandidates
);
const replacement = createReplacement(target.parsed);
for (const { name, copies } of group) {
if (name === target.parsed.key || protectedNames.has(name)) {
continue;
}
renames.set(name, replacement);
for (const copy of copies) {
removals.add(copy.rule);
}
}
}
/**
* Finds the groups of interchangeable names of one namespace and removes
* repeat definitions of a single name. Two names are interchangeable when
* their definitions, in cascade order, sit in the same at-rule families and
* condition containers with the same bodies: whichever definition a browser
* picks for one name, it picks the same body for the other. This lets
* `@-webkit-keyframes` and `@keyframes` pairs merge although their bodies
* differ. A string and an ident of one value are one name. Bodies are
* serialized only for names that share where they are defined.
*
* @param {AtRuleEntry[]} entries
* @param {Set<import('postcss').AtRule>} removals
* @return {Group[]} groups of two or more names
*/
function findInterchangeableGroups(entries, removals) {
if (entries.length < 2) {
return [];
}
/** @type {Group} */
const candidates = [];
for (const [name, all] of Map.groupBy(entries, ({ parsed }) => parsed.key)) {
const copies = collapseSameNameCopies(all, removals);
if (!copies.some(({ parsed }) => parsed.isReservedName)) {
candidates.push({ name, copies: copies.toSorted(compareAtRulePriority) });
}
}
/** @type {Group[]} */
const groups = [];
const bySites = Map.groupBy(candidates, ({ copies }) =>
copies.map(placementOf).join(' ')
);
for (const sameSites of bySites.values()) {
if (sameSites.length < 2) {
continue;
}
const byBodies = Map.groupBy(sameSites, ({ copies }) =>
JSON.stringify(copies.map(getBody))
);
for (const sameBodies of byBodies.values()) {
if (sameBodies.length > 1) {
groups.push(sameBodies);
}
}
}
return groups;
}
/**
* @param {string} family lowercase at-rule name
* @return {'keyframes' | 'counter-style' | undefined}
*/
function namespaceOf(family) {
// Only the prefixes whose animation properties the rewriter recognizes
// qualify; a reference to any other family would be left dangling.
const unprefixed = family.replace(VENDOR_PREFIX, '');
return unprefixed === 'keyframes' || unprefixed === 'counter-style'
? unprefixed
: undefined;
}
/**
* @param {import('postcss').Root} css
* @return {void}
*/
function mergeAtRules(css) {
const layerRegistry = new LayerRegistry();
/** @type {Map<'keyframes' | 'counter-style', AtRuleEntry[]>} */
const namespaces = new Map([
['keyframes', []],
['counter-style', []],
]);
/** @type {Map<import('postcss').Container, number>} */
const containerIds = new Map();
/** @type {import('postcss').AtRule[]} */
const functionRules = [];
/** @type {import('postcss').AtRule[]} */
const parameterRules = [];
/** @type {import('postcss').AtRule[]} */
const conditionRules = [];
let documentIndex = 0;
css.walkAtRules((atRule) => {
const family = asciiLowerCase(atRule.name);
if (family === 'layer') {
layerRegistry.declareLayerAtRule(atRule);
return;
}
if (family === 'function') {
functionRules.push(atRule);
return;
}
// Mixin parameter defaults and @apply arguments are substituted text.
if (family === 'mixin' || family === 'apply') {
parameterRules.push(atRule);
return;
}
if (family === 'container' || family === 'when' || family === 'else') {
conditionRules.push(atRule);
return;
}
const namespace = namespaceOf(family);
const params = atRule.raws?.between
? atRule.params + atRule.raws.between
: atRule.params;
const parsed = namespace && parseAtRuleName(params, family);
// A statement without a block defines nothing.
if (!namespace || !atRule.nodes || !parsed) {
return;
}
const entries = /** @type {AtRuleEntry[]} */ (namespaces.get(namespace));
const container = getConditionContainer(atRule);
let containerId = containerIds.get(container);
if (containerId === undefined) {
containerId = containerIds.size;
containerIds.set(container, containerId);
}
entries.push({
rule: atRule,
parsed,
family,
containerId,
layerPriority: layerRegistry.getPriority(atRule),
documentIndex: documentIndex++,
});
});
if ([...namespaces.values()].every((entries) => entries.length < 2)) {
return;
}
/** @type {Set<import('postcss').AtRule>} */
const removals = new Set();
const groupsByNamespace = new Map(
[...namespaces].map(([namespace, entries]) => [
namespace,
findInterchangeableGroups(entries, removals),
])
);
const groupedNames = new Set(
[...groupsByNamespace.values()].flatMap((groups) =>
groups.flatMap((group) => group.map(({ name }) => name))
)
);
// The protection scan is needed only to merge names, never to remove
// repeat definitions of one name.
const { protectedNames, references } =
groupedNames.size > 0
? scanDeclarations(
css,
groupedNames,
functionRules,
parameterRules,
conditionRules
)
: { protectedNames: new Set(), references: [] };
/** @type {Map<string, Map<string, import('./lib/valueRewriter.js').Replacement>>} */
const renames = new Map();
for (const [namespace, groups] of groupsByNamespace) {
/** @type {Map<string, import('./lib/valueRewriter.js').Replacement>} */
const namespaceRenames = new Map();
for (const group of groups) {
mergeInterchangeable(group, protectedNames, namespaceRenames, removals);
}
renames.set(namespace, namespaceRenames);
}
if (removals.size === 0) {
return;
}
// Removing a repeat definition of one name leaves no reference to respell.
if ([...renames.values()].some((namespaceRenames) => namespaceRenames.size)) {
rewriteReferences(references, renames);
}
for (const node of removals) {
node.remove();
}
}
/**
* @param {import('./lib/protectedNames.js').Reference[]} references
* @param {Map<string, Map<string, import('./lib/valueRewriter.js').Replacement>>} renames
* @return {void}
*/
function rewriteReferences(references, renames) {
for (const { decl, classification, value } of references) {
const namespaceRenames = renames.get(classification.namespace);
if (namespaceRenames?.size) {
rewriteDeclaration(decl, classification.kind, namespaceRenames, value);
}
}
}
/**
* @return {import('postcss').Plugin}
*/
function pluginCreator() {
return {
postcssPlugin: 'postcss-merge-idents',
/**
* @param {import('postcss').Root} css
*/
OnceExit(css) {
mergeAtRules(css);
},
};
}
/** @type {true} */
pluginCreator.postcss = true;
const moduleExports = pluginCreator;
export { moduleExports as default, moduleExports as 'module.exports' };