UNPKG

postcss-merge-rules

Version:

Merge CSS rules with PostCSS.

466 lines (441 loc) • 16.4 kB
import cssnanoUtils from 'cssnano-utils'; import { canMerge, partialMerge } from './merge.js'; import { declarationIsEqual, propertyNameKey, sameDeclarationsAndOrder, } from './declarations.js'; import { isConflictingProp } from './propertyRelations.js'; import { getDecls, getMeta } from './rule-meta.js'; import { mergeParents } from './rule-rewrite.js'; import runWorklist from './worklist.js'; const { sameParent } = cssnanoUtils; /** @import {Rule} from 'postcss' */ /** @import {RuleMeta} from './rule-meta.js' */ /** @typedef {{previous: Rule | null, replacements: Rule[], next: Rule | null, movedAcrossParents: boolean, kind: 'equal-declaration' | 'equal-selector' | 'partial'}} MutationOutcome */ /** @param {Rule | null} rule @param {import('postcss').Container<import('postcss').ChildNode>} container */ function isDescendant(rule, container) { /** @type {import('postcss').Container<import('postcss').ChildNode> | undefined} */ let parent = rule ? getParent(rule) : undefined; for ( ; parent; parent = /** @type {import('postcss').Container<import('postcss').ChildNode> | undefined} */ ( parent.parent ) ) { if (parent === container) return true; } return false; } /** @param {Rule | import('postcss').Container<import('postcss').ChildNode>} node @return {import('postcss').Container<import('postcss').ChildNode> | undefined} */ function getParent(node) { return /** @type {import('postcss').Container<import('postcss').ChildNode> | undefined} */ ( node.parent ); } /** @param {Map<import('postcss').Container, {first: Rule | null, last: Rule | null}>} captured @param {Rule[]} replaced @param {'first'|'last'} edge */ function replacedBoundary(captured, replaced, edge) { for (const boundary of captured.values()) { const rule = boundary[edge]; if (rule && replaced.includes(rule)) return true; } return false; } /** @param {import('postcss').Container} parent */ function ancestors(parent) { /** @type {import('postcss').Container<import('postcss').ChildNode>[]} */ const result = []; /** @type {import('postcss').Container<import('postcss').ChildNode> | undefined} */ let container = parent; for (; container; container = getParent(container)) result.push(container); return result; } /** * @param {string[]} browsers * @param {Map<string, boolean>} compatibilityCache * @param {WeakSet<Rule>} ruleCache * @param {WeakMap<Rule, RuleMeta>} ruleMeta * @return {{ run: (root: import('postcss').Root) => void }} */ export default function selectorMerger( browsers, compatibilityCache, ruleCache, ruleMeta ) { /** @typedef {RuleMeta & {selectorKey: string, contentKey: string, declarationIds: number[], declarationIdSet: Set<number>, previous: Rule | null, next: Rule | null, active: boolean, version: number, sourceOrder: number}} ActiveMeta */ /** @type {WeakMap<Rule, ActiveMeta>} */ const active = new WeakMap(); /** @type {Map<string, number>} */ const declarationIds = new Map(); let nextDeclarationId = 0; let nextSourceOrder = 0; /** @typedef {{first: Rule | null, last: Rule | null}} Boundary */ /** @type {WeakMap<import('postcss').Container, Boundary>} */ const boundaries = new WeakMap(); /** @param {import('postcss').Declaration} declaration */ function getDeclarationId(declaration) { const key = `${propertyNameKey(declaration.prop)}:${declaration.value}:${declaration.important}`; let id = declarationIds.get(key); if (id === undefined) { id = nextDeclarationId++; declarationIds.set(key, id); } return id; } /** @param {Rule} rule @param {number} [sourceOrder] */ function refresh(rule, sourceOrder) { const previous = active.get(rule); const base = getMeta(rule, ruleMeta); const ids = base.declarations.map(getDeclarationId); /** @type {ActiveMeta} */ const meta = Object.assign(base, { selectorKey: base.selectors.join(','), contentKey: `${base.selectors.join(',')}|${ids.join(',')}`, declarationIds: ids, declarationIdSet: new Set(ids), previous: previous?.previous ?? null, next: previous?.next ?? null, active: true, version: (previous?.version ?? 0) + 1, sourceOrder: sourceOrder ?? previous?.sourceOrder ?? nextSourceOrder++, }); active.set(rule, meta); return meta; } /** @param {Rule} first @param {Rule} second */ function hasPossibleSharedDeclaration(first, second) { const a = active.get(first); const b = active.get(second); if (!a?.active || !b?.active) return false; const structuralRewrite = (a.declarations.length === 0 && b.declarations.length === 0) || (first.parent !== second.parent && sameParent(first, second)); if (a.selectorKey === b.selectorKey || structuralRewrite) return true; const smaller = a.declarationIds.length <= b.declarationIds.length ? a : b; const larger = smaller === a ? b.declarationIdSet : a.declarationIdSet; for (const id of smaller.declarationIds) { if (larger.has(id)) return true; } return false; } /** @param {Rule} first @param {Rule} second */ function estimatedBenefit(first, second) { const a = active.get(first); const b = active.get(second); if (!a || !b) return 0; if (a.selectorKey === b.selectorKey) return a.declarationIds.length + b.declarationIds.length; let benefit = 0; const smaller = a.declarationIds.length <= b.declarationIds.length ? a : b; const larger = smaller === a ? b.declarationIdSet : a.declarationIdSet; for (const id of smaller.declarationIdSet) { if (larger.has(id)) benefit++; } return benefit; } /** @param {Rule} rule */ function detach(rule) { const meta = active.get(rule); if (!meta?.active) return; const { previous, next } = meta; let container = getParent(rule); for (; container; container = getParent(container)) { const boundary = boundaries.get(container); if (!boundary) continue; if (boundary.first === rule) boundary.first = next && isDescendant(next, container) ? next : null; if (boundary.last === rule) boundary.last = previous && isDescendant(previous, container) ? previous : null; } if (previous) { const previousMeta = active.get(previous); if (previousMeta) previousMeta.next = next; } if (next) { const nextMeta = active.get(next); if (nextMeta) nextMeta.previous = previous; } meta.active = false; } /** @param {Rule[]} rules */ function captureBoundaries(rules) { const captured = new Map(); for (const rule of rules) { for ( let container = getParent(rule); container; container = getParent(container) ) { const boundary = boundaries.get(container); if (boundary && !captured.has(container)) captured.set(container, { first: boundary.first, last: boundary.last, }); } } return captured; } /** @param {Rule | null} previous @param {Rule | null} next */ function unlink(previous, next) { if (previous) { const previousMeta = active.get(previous); if (previousMeta) previousMeta.next = next; } if (next) { const nextMeta = active.get(next); if (nextMeta) nextMeta.previous = previous; } } /** @param {Rule} rule @param {import('postcss').Container} oldParent @param {import('postcss').Container} newParent */ function repairMove(rule, oldParent, newParent) { const meta = active.get(rule); if (!meta?.active) return; const { previous, next } = meta; unlink(previous, next); for (const ancestor of ancestors(oldParent)) { const boundary = boundaries.get(ancestor); if (!boundary) continue; if (boundary.first === rule) boundary.first = next && isDescendant(next, ancestor) ? next : null; if (boundary.last === rule) boundary.last = previous && isDescendant(previous, ancestor) ? previous : null; } linkMovedRule(rule, meta, newParent); } /** @param {Rule} rule @param {ActiveMeta} meta @param {import('postcss').Container} newParent */ function linkMovedRule(rule, meta, newParent) { const destinationLast = boundaries.get(newParent)?.last ?? null; meta.previous = destinationLast; meta.next = destinationLast ? (active.get(destinationLast)?.next ?? null) : null; if (destinationLast) { const destinationMeta = active.get(destinationLast); if (destinationMeta) destinationMeta.next = rule; } if (meta.next) { const nextMeta = active.get(meta.next); if (nextMeta) nextMeta.previous = rule; } for (const ancestor of ancestors(newParent)) { const boundary = boundaries.get(ancestor); if (!boundary) continue; boundary.first ??= rule; boundary.last = rule; } } /** @param {import('postcss').Container<import('postcss').ChildNode>} container @param {{previous: Rule | null}} state */ function indexContainer(container, state) { let first = null; let last = null; for (const node of container.nodes ?? []) { if (node.type === 'rule') { const rule = /** @type {Rule} */ (node); const meta = refresh(rule); meta.previous = state.previous; if (state.previous) /** @type {ActiveMeta} */ (active.get(state.previous)).next = rule; meta.next = null; first ??= rule; state.previous = rule; last = rule; indexContainer(rule, state); const nested = boundaries.get(rule); if (nested?.last) last = nested.last; state.previous = last; boundaries.set(rule, { first: rule, last }); continue; } if ('nodes' in node && node.nodes) { indexContainer(node, state); const nested = boundaries.get(node); if (nested?.first) { if (!first) first = nested.first; last = nested.last; } } } boundaries.set(container, { first, last }); } /** @param {import('postcss').Root} root */ function seed(root) { indexContainer(root, { previous: null }); return boundaries.get(root)?.first ?? null; } /** @param {Rule} first @param {Rule} second @return {MutationOutcome | null} */ function mergeMatchingDeclarations(first, second) { if ( !first.nodes.every((node) => node.type === 'decl') || !second.nodes.every((node) => node.type === 'decl') || !sameDeclarationsAndOrder( getMeta(second, ruleMeta).declarations, getMeta(first, ruleMeta).declarations ) ) return null; const previous = active.get(first)?.previous ?? null; const next = active.get(second)?.next ?? null; const metaSecond = getMeta(second, ruleMeta); metaSecond.selectors = [ ...getMeta(first, ruleMeta).selectors, ...metaSecond.selectors, ]; second.selector = metaSecond.selectors.join(','); detach(first); first.remove(); ruleMeta?.delete(first); refresh(second); ruleCache?.add(second); return { previous, replacements: [second], next, movedAcrossParents: false, kind: 'equal-declaration', }; } /** @param {Rule} first @param {Rule} second @return {MutationOutcome | null} */ function mergeMatchingSelectors(first, second) { if ( getMeta(first, ruleMeta).selectors.join(',') !== getMeta(second, ruleMeta).selectors.join(',') ) return null; const previous = active.get(first)?.previous ?? null; const next = active.get(second)?.next ?? null; const cachedDecls = getMeta(first, ruleMeta).declarations; second.walk((node) => { if (node.type === 'decl') { // A declaration from `second` is redundant only when the last // declaration of `first` that can set the same property already has // an identical value. Otherwise it revives an overridden value or // overrides intermediate declarations, so appending it is required // to preserve the cascade result. const lastConflicting = cachedDecls.findLast((decl) => isConflictingProp(decl.prop, node.prop) ); if (lastConflicting && declarationIsEqual(lastConflicting, node)) { node.remove(); return; } cachedDecls.push(node); } first.append(node); }); getMeta(first, ruleMeta).declarations = getDecls(first); detach(second); second.remove(); ruleMeta?.delete(second); refresh(first); return { previous, replacements: [first], next, movedAcrossParents: false, kind: 'equal-selector', }; } /** @param {Rule[]} replacements @param {Rule | null} previous @param {Rule | null} next @param {number | undefined} sourceOrder */ function linkReplacements(replacements, previous, next, sourceOrder) { let prior = previous; for (const replacement of replacements) { const meta = refresh(replacement, sourceOrder); meta.previous = prior; if (prior) /** @type {ActiveMeta} */ (active.get(prior)).next = replacement; prior = replacement; } if (prior) /** @type {ActiveMeta} */ (active.get(prior)).next = next; if (next) /** @type {ActiveMeta} */ (active.get(next)).previous = prior; } /** @param {Rule} replacement @param {'first'|'last'} edge */ function updateAncestorBoundaries(replacement, edge) { for ( let container = getParent(replacement); container; container = getParent(container) ) { const boundary = boundaries.get(container); if (boundary) boundary[edge] = replacement; } } /** @param {ReturnType<typeof partialMerge>} outcome @param {Map<import('postcss').Container, Boundary>} captured @param {boolean} movedAcrossParents @return {MutationOutcome | null} */ function installPartialMerge(outcome, captured, movedAcrossParents) { if (!outcome.replacements.length) return null; const firstWasBoundary = !movedAcrossParents && replacedBoundary(captured, outcome.replaced, 'first'); const lastWasBoundary = !movedAcrossParents && replacedBoundary(captured, outcome.replaced, 'last'); const previous = active.get(outcome.replaced[0])?.previous ?? null; const lastReplaced = /** @type {Rule} */ (outcome.replaced.at(-1)); const next = active.get(lastReplaced)?.next ?? null; const sourceOrder = active.get(outcome.replaced[0])?.sourceOrder; for (const rule of outcome.replaced) { detach(rule); ruleMeta?.delete(rule); } linkReplacements(outcome.replacements, previous, next, sourceOrder); const firstReplacement = outcome.replacements[0]; const lastReplacement = /** @type {Rule} */ (outcome.replacements.at(-1)); if (firstWasBoundary && firstReplacement.parent) updateAncestorBoundaries(firstReplacement, 'first'); if (lastWasBoundary && lastReplacement.parent) updateAncestorBoundaries(lastReplacement, 'last'); return { previous, replacements: outcome.replacements, next, movedAcrossParents, kind: 'partial', }; } /** @param {{first: Rule, second: Rule, firstVersion: number, secondVersion: number}} candidate */ function isCurrentCandidate(candidate) { const firstMeta = active.get(candidate.first); const secondMeta = active.get(candidate.second); return Boolean( firstMeta?.active && secondMeta?.active && firstMeta.next === candidate.second && firstMeta.version === candidate.firstVersion && secondMeta.version === candidate.secondVersion ); } return { run(root) { runWorklist(root, { active, hasPossibleSharedDeclaration, estimatedBenefit, seed, isCurrentCandidate, canMerge: (first, second) => canMerge( first, second, browsers, compatibilityCache, ruleCache, ruleMeta ), mergeParents, repairMove, mergeMatchingDeclarations, mergeMatchingSelectors, captureBoundaries, partialMerge: (first, second) => partialMerge(first, second, ruleCache, ruleMeta), installPartialMerge, refresh, }); }, }; }