UNPKG

cssnano

Version:

A modular CSS minifier, built on top of the PostCSS ecosystem.

280 lines (266 loc) • 9.79 kB
import LastWriteIndex from './lastWriteIndex.js'; import RepeatScan from './repeatScan.js'; import { filterRuleIntersections } from './declarations.js'; import { isKeyframes } from './sameContainer.js'; /** @import {ChildNode, Container, Declaration, Rule} from 'postcss' */ /** @import MergeState from './mergeState.js' */ /** * A rule made only of declarations. Comments and nested rules would be * stranded or reordered by moving declarations out of the rule. * * @param {ChildNode} node * @return {node is Rule} */ export const isDeclarationRule = (node) => node.type === 'rule' && node.nodes.length > 0 && node.nodes.every((child) => child.type === 'decl'); /** * The positions in `later` of declarations that repeat every one of * `declarations`, or null when a repeat does not stand first for its * property, since removing it would then change what `later` sets. * * @param {Declaration[]} declarations * @param {Declaration[]} later * @return {Set<number> | null} */ export function claimRepeats(declarations, later) { const { intersection, claimedIndices } = filterRuleIntersections( declarations, declarations, later ); return intersection.length === declarations.length ? claimedIndices : null; } /** @param {Iterable<string>} selectors */ function listLength(selectors) { let total = 0; for (const selector of selectors) total += 1 + selector.length; return total; } /** * Removes `rules` from `parent` with one pass over its children. * * @param {Container} parent * @param {Set<Rule>} rules */ function detach(parent, rules) { if (rules.size === 0) return; const kept = /** @type {ChildNode[]} */ (parent.nodes).filter( (node) => !rules.has(/** @type {Rule} */ (node)) ); parent.removeAll(); parent.append(kept); } /** * @typedef {object} Group * @property {Rule} rule * @property {number} position * @property {Declaration[]} declarations * @property {number[]} keys declaration keys, in order * @property {Set<string> | null} selectors listed selectors, built on demand * @property {number} bytes declaration bytes, each with its separator, or -1 */ /** * The selectors of `later` that `group` does not list yet, and the bytes a * join saves: the declarations that leave `later`, less the selectors the * group takes. A later rule that keeps no declaration is removed with its * selector and braces, so that join always shortens the output. * * @param {Group} group * @param {Rule} later * @param {MergeState} mergeState * @return {{added: string[], gain: number}} */ function joinGain(group, later, mergeState) { if (group.bytes < 0) { group.bytes = group.declarations.reduce( (total, declaration) => total + String(declaration).length + 1, 0 ); } const listed = (group.selectors ??= new Set( mergeState.meta(group.rule).selectors )); const laterSelectors = mergeState.meta(later).selectors; const added = [...new Set(laterSelectors)].filter( (selector) => !listed.has(selector) ); const removed = group.declarations.length === later.nodes.length ? group.bytes + listLength(laterSelectors) : group.bytes; return { added, gain: removed - listLength(added) }; } /** * A later rule that repeats all declarations of an earlier rule adds its * selectors to that rule, and gives up the repeated declarations, when no * node between them sets a property they also set: the earlier rule then * applies the declarations to the same elements as before. The earlier * rule, the group, keeps its position and its declarations, so it can take * further rules. * * @param {Container} parent * @param {MergeState} mergeState * @return {boolean} */ export default function joinByDeclarations(parent, mergeState) { if (isKeyframes(parent)) return false; const nodes = /** @type {ChildNode[]} */ (parent.nodes).slice(); const keyLists = nodes.map((node) => isDeclarationRule(node) ? mergeState.declarationKeysOf(node) : null ); // Rules can only join where they share a declaration. const scan = new RepeatScan(); for (const [i, own] of keyLists.entries()) { if (!own) continue; for (const key of own.keySet) scan.see(key, i); } if (scan.start === Infinity) return false; const index = new LastWriteIndex(); const keysOf = mergeState.elementKeysOf; // Groups by their first declaration, in document order: a rule that // repeats a group's declarations repeats that one too. /** @type {Map<number, Group[]>} */ const groups = new Map(); // Rules whose selector lists grew; their text is written once at the end, // since rewriting a long list on every join would cost quadratic time. /** @type {Set<Rule>} */ const grown = new Set(); // Rules that gave up every declaration, detached together at the end: // removing each one splices the parent's array, which takes quadratic time. /** @type {Set<Rule>} */ const emptied = new Set(); for (let position = scan.start; position < nodes.length; position++) { const node = nodes[position]; if (keyLists[position]) { const rule = /** @type {Rule} */ (node); const outcome = joinIntoGroup(rule); if (outcome === 'emptied') continue; // A trimmed rule was forgotten, so its keys are derived anew. register(rule, position, mergeState.declarationKeysOf(rule).keys); } index.recordNode(node, position, keysOf); } for (const rule of grown) { rule.selector = mergeState.meta(rule).selectorText(); } detach(parent, emptied); return grown.size > 0; /** * @param {Rule} rule * @param {number} position * @param {number[]} keys */ function register(rule, position, keys) { /** @type {Group} */ const group = { rule, position, declarations: /** @type {Declaration[]} */ (rule.nodes), keys, selectors: null, bytes: -1, }; const same = groups.get(keys[0]); if (same) same.push(group); else groups.set(keys[0], [group]); } /** * @param {Rule} later * @return {'emptied' | 'trimmed' | null} whether the rule gave up all its * declarations, some of them, or joined no group */ function joinIntoGroup(later) { const laterDeclarations = /** @type {Declaration[]} */ (later.nodes); const laterSet = mergeState.declarationKeysOf(later).keySet; // Every declaration of `later`, claimed or not, is written by `later`. const laterKeys = keysOf(later); /** @type {{group: Group, claimed: Set<number>, added: string[], gain: number} | null} */ let best = null; for (const id of laterSet) { const candidates = groups.get(id) ?? []; for (let i = candidates.length - 1; i >= 0; i--) { const group = candidates[i]; // A write since a group to the property of its first declaration // rules out that group and every earlier one, which only cross more. if ( index.conflictsSince( group.declarations.slice(0, 1), group.position, laterKeys ) ) break; if ( group.declarations.length > laterDeclarations.length || !group.keys.every((key) => laterSet.has(key)) ) { continue; } const { added, gain } = joinGain(group, later, mergeState); // On a tie, the nearest group has the least to cross. if ( gain <= 0 || (best && (gain < best.gain || (gain === best.gain && group.position < best.group.position))) ) { continue; } const claimedIndices = claimRepeats( group.declarations, laterDeclarations ); if (!claimedIndices) continue; const claimed = [...claimedIndices].map((j) => laterDeclarations[j]); if (index.conflictsSince(claimed, group.position, laterKeys)) continue; if (!mergeState.canMerge(group.rule, later)) continue; best = { group, claimed: claimedIndices, added, gain }; } } if (!best) return null; const { group, claimed, added } = best; const listed = keysOf(group.rule) !== null; const gained = mergeState.addSelectors(group.rule, added); for (const selector of added) group.selectors?.add(selector); if (listed) recordGained(group, gained); grown.add(group.rule); const outcome = withdraw(later, laterDeclarations, claimed); mergeState.forget(later); mergeState.markCompatible(group.rule); return outcome; } /** * The group's declarations already stand at its position for the elements it * listed, so only the elements its new selectors bring are recorded. Writes * to unknown elements are recorded already, so only a listed group calls it. * * @param {Group} group * @param {readonly string[] | null} gained null when the group's elements * became unknown */ function recordGained(group, gained) { if (gained?.length === 0) return; for (const declaration of group.declarations) index.record(declaration, group.position, gained); } /** * @param {Rule} later * @param {Declaration[]} declarations * @param {Set<number>} claimed indices of the declarations that leave * @return {'emptied' | 'trimmed'} */ function withdraw(later, declarations, claimed) { // The group's writes already stand at its position, so the declarations // that leave `later` record nothing. if (claimed.size === declarations.length) { emptied.add(later); return 'emptied'; } // Removing shifts positions, so the nodes are picked first. const leaving = [...claimed].map((j) => declarations[j]); for (const declaration of leaving) declaration.remove(); return 'trimmed'; } }