postcss-discard-duplicates
Version:
Discard duplicate rules in your CSS files with PostCSS.
383 lines (342 loc) • 8.85 kB
JavaScript
import cssnanoUtils from 'cssnano-utils';
const { isAnonymousLayer, isImportantComment } = cssnanoUtils;
// Identical preludes apply their contents under the same condition; other
// at-rules have semantics this plugin does not model.
const conditionalAtRules = new Set(['media', 'supports', 'container']);
/**
* Structural view over the postcss node kinds compared by `equals` and its
* helpers.
* @typedef {{
* type: string,
* important?: boolean,
* raws: { before?: string, afterName?: string },
* selector?: string,
* name?: string,
* params?: string,
* prop?: string,
* value?: string,
* nodes?: import('postcss').ChildNode[],
* }} ComparableNode
*/
/**
* @typedef {Map<string, import('postcss').AnyNode | import('postcss').AnyNode[]>} SeenNodes
*/
/**
* Declarations already kept in a container, shared by every sibling at-rule
* whose conditions are identical.
* @typedef {{
* decls?: SeenNodes,
* ruleDecls?: Map<string, SeenNodes>,
* children?: Map<string, Scope>,
* }} Scope
*/
/**
* @param {Scope} scope
* @param {string} key
* @return {Scope}
*/
function childScope(scope, key) {
const children = (scope.children ??= new Map());
let child = children.get(key);
if (!child) {
child = {};
children.set(key, child);
}
return child;
}
/**
* @param {string | undefined} value
* @return {string | undefined}
*/
function trimValue(value) {
return value ? value.trim() : value;
}
/**
* @param {import('postcss').AnyNode} nodeA
* @param {import('postcss').AnyNode} nodeB
* @return {boolean}
*/
function equals(nodeA, nodeB) {
const a = /** @type {ComparableNode} */ (nodeA);
const b = /** @type {ComparableNode} */ (nodeB);
if (a.type !== b.type) {
return false;
}
if (a.important !== b.important) {
return false;
}
if ((a.raws && !b.raws) || (!a.raws && b.raws)) {
return false;
}
if (!equalsNodeProperties(a, b)) {
return false;
}
return equalsChildren(a, b);
}
/**
* @param {ComparableNode} a
* @param {ComparableNode} b
* @return {boolean}
*/
function equalsNodeProperties(a, b) {
switch (a.type) {
case 'rule':
return a.selector === b.selector;
case 'atrule':
return equalsAtRule(a, b);
case 'decl':
return equalsDeclaration(a, b);
default:
return true;
}
}
/**
* @param {ComparableNode} a
* @param {ComparableNode} b
* @return {boolean}
*/
function equalsAtRule(a, b) {
if (a.name !== b.name || a.params !== b.params) {
return false;
}
if (a.raws && trimValue(a.raws.before) !== trimValue(b.raws.before)) {
return false;
}
return !(
a.raws && trimValue(a.raws.afterName) !== trimValue(b.raws.afterName)
);
}
/**
* @param {ComparableNode} a
* @param {ComparableNode} b
* @return {boolean}
*/
function equalsDeclaration(a, b) {
if (a.prop !== b.prop || a.value !== b.value) {
return false;
}
return !(a.raws && trimValue(a.raws.before) !== trimValue(b.raws.before));
}
/**
* @param {ComparableNode} a
* @param {ComparableNode} b
* @return {boolean}
*/
function equalsChildren(a, b) {
if (a.nodes && b.nodes) {
if (a.nodes.length !== b.nodes.length) {
return false;
}
for (let i = 0; i < a.nodes.length; i++) {
if (!equals(a.nodes[i], b.nodes[i])) {
return false;
}
}
}
return true;
}
/**
* @param {import('postcss').AnyNode | import('postcss').AnyNode[]} existing
* @param {import('postcss').AnyNode} node
* @return {boolean}
*/
function hasEqual(existing, node) {
if (Array.isArray(existing)) {
for (let i = 0; i < existing.length; i++) {
if (equals(existing[i], node)) {
return true;
}
}
return false;
}
return equals(existing, node);
}
/**
* @param {Map<string, import('postcss').AnyNode | import('postcss').AnyNode[]>} map
* @param {string} key
* @param {import('postcss').AnyNode} node
* @return {void}
*/
function addToSeen(map, key, node) {
const existing = map.get(key);
if (!existing) {
map.set(key, node);
} else if (Array.isArray(existing)) {
existing.push(node);
} else {
map.set(key, [existing, node]);
}
}
/**
* @param {import('postcss').Rule} rule
* @param {Map<string, SeenNodes>} seenRuleDecls
* @return {void}
*/
function dedupeRule(rule, seenRuleDecls) {
let isSubsequent = true;
let declMap = seenRuleDecls.get(rule.selector);
if (!declMap) {
isSubsequent = false;
declMap = new Map();
seenRuleDecls.set(rule.selector, declMap);
}
let hasContainers = false;
const { nodes } = rule;
if (nodes) {
for (let i = nodes.length - 1; i >= 0; i--) {
const child = nodes[i];
if (child.type === 'decl') {
const existing = declMap.get(child.prop);
if (existing && hasEqual(existing, child)) {
child.remove();
} else {
addToSeen(declMap, child.prop, child);
}
} else if (child.type === 'rule' || child.type === 'atrule') {
hasContainers = true;
}
}
}
if (isSubsequent && !hasContent(rule)) {
rule.remove();
} else if (hasContainers) {
dedupe(rule);
}
}
/**
* @param {import('postcss').Declaration} decl
* @param {SeenNodes} seenDecls
* @return {void}
*/
function dedupeDecl(decl, seenDecls) {
const existing = seenDecls.get(decl.prop);
if (existing && hasEqual(existing, decl)) {
decl.remove();
} else {
addToSeen(seenDecls, decl.prop, decl);
}
}
/**
* Comments starting with `/*!` are preserved by minifiers, so a container
* that holds one is not discarded with its emptied contents.
* @param {import('postcss').Rule | import('postcss').AtRule} container
* @return {boolean} whether the block holds anything but ordinary comments
*/
function hasContent(container) {
return Boolean(
container.nodes?.some(
(node) => node.type !== 'comment' || isImportantComment(node.text)
)
);
}
/**
* The first appearance of a named layer fixes its place in the layer order,
* so a block declaring one cannot be dropped in favor of a later copy.
* @param {import('postcss').AtRule} atrule
* @return {boolean}
*/
function declaresLayer(atrule) {
let found = false;
atrule.walkAtRules(/^layer$/iv, () => {
found = true;
return false;
});
return found;
}
/**
* @param {import('postcss').AtRule} atrule
* @param {SeenNodes} seenAtRules
* @param {Scope} scope
* @return {void}
*/
function dedupeAtRule(atrule, seenAtRules, scope) {
const name = atrule.name.toLowerCase();
// Blocks of one named layer form a single layer; anonymous ones are distinct.
if (name === 'layer') {
if (atrule.nodes) {
dedupe(
atrule,
isAnonymousLayer(atrule)
? undefined
: childScope(scope, `layer\0${atrule.params}`)
);
}
return;
}
if (atrule.nodes) {
if (conditionalAtRules.has(name)) {
// An emptied conditional group has no effect.
const hadContent = hasContent(atrule);
dedupe(atrule, childScope(scope, `${name}\0${atrule.params}`));
if (hadContent && !hasContent(atrule)) {
atrule.remove();
return;
}
} else {
dedupe(atrule);
}
}
// An imported style sheet may declare layers, so even an identical earlier
// @import can fix a layer's place in the layer order.
if (name === 'import') {
return;
}
const existing = seenAtRules.get(name);
if (existing && hasEqual(existing, atrule) && !declaresLayer(atrule)) {
atrule.remove();
} else {
addToSeen(seenAtRules, name, atrule);
}
}
/**
* @param {import('postcss').AnyNode} container
* @param {Scope} [scope]
* @return {void}
*/
function dedupe(container, scope = {}) {
const { nodes } =
/** @type {import('postcss').Container<import('postcss').ChildNode>} */ (
container
);
if (!nodes || nodes.length === 0) {
return;
}
const children = nodes.slice();
/** @type {SeenNodes | undefined} */
let seenAtRules;
for (let i = children.length - 1; i >= 0; i--) {
const node = children[i];
if (!node.parent) {
continue;
}
switch (node.type) {
case 'decl':
dedupeDecl(node, (scope.decls ??= new Map()));
break;
case 'rule':
dedupeRule(node, (scope.ruleDecls ??= new Map()));
break;
case 'atrule':
dedupeAtRule(node, (seenAtRules ??= new Map()), scope);
break;
}
}
}
/**
* @return {import('postcss').Plugin}
*/
function pluginCreator() {
return {
postcssPlugin: 'postcss-discard-duplicates',
/**
* @param {import('postcss').Root} css
*/
OnceExit(css) {
dedupe(css);
},
};
}
/** @type {true} */
pluginCreator.postcss = true;
const moduleExports = pluginCreator;
export { moduleExports as default, moduleExports as 'module.exports' };