UNPKG

cssnano

Version:

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

328 lines (316 loc) • 11 kB
/** @import {CSSToken} from '@csstools/css-tokenizer' */ /** @import {SourceEdit, PositionValue} from './lib/positions.js' */ import cssnanoUtils from '#utils'; import isKnownColor from './isKnownColor.js'; import { isPositionZeroUnit, zeroPositionEdits } from './lib/positions.js'; const { TokenType, applyEdits, asciiLowerCase, decoded, numeric, tokenEnd } = cssnanoUtils; /** @type {typeof cssnanoUtils.balancedTokens} */ const balancedTokens = cssnanoUtils.balancedTokens; const directions = new Map([ ['top', '0deg'], ['right', '90deg'], ['bottom', '180deg'], ['left', '270deg'], ]); const gradientNames = new Set([ 'linear-gradient', 'repeating-linear-gradient', '-webkit-linear-gradient', '-webkit-repeating-linear-gradient', 'radial-gradient', 'repeating-radial-gradient', 'conic-gradient', 'repeating-conic-gradient', '-webkit-radial-gradient', '-webkit-repeating-radial-gradient', ]); const variableFunctions = new Set(['var', 'env']); // Only these functions can compute a position, so any other leading function // such as `rgb()` or `var()` cannot be one. const positionFunctions = new Set(['calc', 'clamp', 'max', 'min']); const gradientSourceRegex = /[gG][rR][aA][dD][iI][eE][nN][tT]/v; // Identifiers that may start a gradient line, shape or size specification, so // the argument carrying one is never a colour stop slot. const lineKeywords = new Set([ 'at', 'bottom', 'center', 'circle', 'closest-corner', 'closest-side', 'ellipse', 'farthest-corner', 'farthest-side', 'from', 'in', 'left', 'right', 'to', 'top', ]); /** * A gradient stop before fixup: the argument it came from, the extent of its * colour, and the position tokens following that colour. * * @typedef {object} ColorStop * @property {number} argIndex Comma-separated argument the stop came from. * @property {number} colorEnd Offset just past the colour in the declaration value. * @property {readonly number[]} position Positions following the colour. */ /** * Significant tokens of a range, skipping whitespace, comments and the contents * of any balanced frame the range opens. * * @param {readonly CSSToken[]} input * @param {NonNullable<ReturnType<typeof balancedTokens>>} structure * @param {{startIndex: number, endIndex: number}} range * @return {number[]} Indexes of the significant tokens in `input`. */ function significant(input, structure, range) { const result = []; for (let index = range.startIndex; index < range.endIndex; index++) { const token = input[index]; if (token[0] !== TokenType.Whitespace && token[0] !== TokenType.Comment) { result.push(index); } const frameEnd = structure.endForOpening(index); if (frameEnd !== undefined) index = frameEnd; } return result; } /** * Whether an argument starts a line, shape or size specification instead of a * colour. Numbers cannot start a colour, and every other token here has to be * an identifier the gradient grammar reserves for its first argument. * * @param {readonly CSSToken[]} input * @param {readonly number[]} parts Offsets of an argument's significant tokens. * @return {boolean} */ function startsLineSpecification(input, parts) { const token = input[parts[0]]; if (!token) return false; if ( token[0] === TokenType.Dimension || token[0] === TokenType.Number || token[0] === TokenType.Percentage ) { return true; } return ( token[0] === TokenType.Ident && lineKeywords.has(asciiLowerCase(decoded(token))) ); } /** * Offset just past the leading colour of an argument, so a functional colour * such as `rgb(0 0 0 / 50%)` ends at its closing parenthesis. * * @param {readonly CSSToken[]} input * @param {NonNullable<ReturnType<typeof balancedTokens>>} structure * @param {readonly number[]} parts Offsets of an argument's significant tokens. * @return {number} */ function colorEnd(input, structure, parts) { const leading = parts[0]; const component = input[leading]; if (component[0] !== TokenType.Function) return component[3]; const closing = structure.endForOpening(leading); return closing === undefined ? component[3] : input[closing][3]; } /** * A `to <side>` line specification is an angle in fewer bytes. * * @param {readonly CSSToken[]} input * @param {readonly number[]} parts Significant tokens of the first argument. * @return {SourceEdit | undefined} */ function lineDirectionEdit(input, parts) { if (parts.length !== 2) return undefined; const from = input[parts[0]]; const side = input[parts[1]]; if ( from[0] !== TokenType.Ident || side[0] !== TokenType.Ident || asciiLowerCase(decoded(from)) !== 'to' ) return undefined; const direction = directions.get(asciiLowerCase(decoded(side))); if (!direction) return undefined; return { start: from[2], end: tokenEnd(side), text: direction }; } /** * Collect the colour stops of one gradient argument list, the count of leading * arguments naming the line or shape instead of a stop, and the positions that * colour stop fixup clamps to a zero. * * @param {string} source * @param {readonly CSSToken[]} input * @param {NonNullable<ReturnType<typeof balancedTokens>>} structure * @param {{startIndex: number, endIndex: number}[]} args * @param {boolean} linear Whether the line specification may hold an angle. * @param {boolean} conic Whether the stop positions are angles. * @return {{stops: ColorStop[], lineSpecifications: number, zeroEdits: Map<number, SourceEdit>, directionEdit: SourceEdit | undefined}} */ function collectColorStops(source, input, structure, args, linear, conic) { /** @type {ColorStop[]} */ const stops = []; /** @type {Map<number, SourceEdit>} */ const zeroEdits = new Map(); /** @type {SourceEdit | undefined} */ let directionEdit; // A stop may only drop its start position when nothing but these arguments // precede it. let lineSpecifications = 0; /** @type {PositionValue | undefined} */ let largest; for (const [argIndex, range] of args.entries()) { const parts = significant(input, structure, range); if (!parts.length) continue; if (linear && argIndex === 0) directionEdit = lineDirectionEdit(input, parts); const end = colorEnd(input, structure, parts); const leading = input[parts[0]]; const color = asciiLowerCase(source.slice(leading[2], end + 1)); const isColor = isKnownColor(color) || (leading[0] === TokenType.Function && !positionFunctions.has(asciiLowerCase(decoded(leading)))); const position = isColor ? parts.slice(1) : parts; if ( !isColor && argIndex === lineSpecifications && startsLineSpecification(input, parts) ) lineSpecifications = argIndex + 1; if (isColor) { // A stop without a position sits at the running maximum, which starts at zero. if (!position.length && !largest) largest = { number: 0, unit: '%' }; stops.push({ argIndex, colorEnd: end, position }); } // Positions before the first stop belong to the line specification. if (!stops.length) continue; largest = zeroPositionEdits(input, position, largest, zeroEdits, conic); } return { stops, lineSpecifications, zeroEdits, directionEdit }; } /** * The first colour stop defaults to the zero position and the last one to 100%, * so either can drop a position already spelling its default. A single-stop * gradient holds one stop in both slots, hence the slots by index and the * zero edit dropped in favour of the wider removal. * * @param {readonly CSSToken[]} input * @param {readonly ColorStop[]} stops * @param {number} lineSpecifications * @param {number} args * @param {Map<number, SourceEdit>} zeroEdits * @param {boolean} conic * @return {SourceEdit[]} */ function boundaryStopEdits( input, stops, lineSpecifications, args, zeroEdits, conic ) { /** @type {SourceEdit[]} */ const edits = []; for (const slot of new Set([0, stops.length - 1])) { const stop = stops[slot]; if (!stop || stop.position.length !== 1) continue; const [index] = stop.position; const position = input[index]; const value = numeric(position); if (!value) continue; let removable = false; if (slot === 0 && stop.argIndex === lineSpecifications) { removable = value.number === 0 && isPositionZeroUnit(value.unit, conic); } if ( slot === stops.length - 1 && stop.argIndex === args - 1 && value.number === 100 && asciiLowerCase(value.unit) === '%' ) { removable = true; } if (!removable) continue; // Removing the position from the colour onwards also covers a zero edit // emitted for the same token. zeroEdits.delete(index); edits.push({ start: stop.colorEnd + 1, end: tokenEnd(position), text: '', }); } return edits; } /** * @param {import('postcss').Declaration} decl */ function optimise(decl) { const source = decl.value; if (!source || !gradientSourceRegex.test(source)) return; const structure = balancedTokens(source); if (!structure) return; const { tokens: input } = structure; if ( input.some( (token) => token[0] === TokenType.Function && variableFunctions.has(asciiLowerCase(decoded(token))) ) ) return; /** @type {SourceEdit[]} */ const replacements = []; // Nested gradients are visited in token order; their argument ranges come // from the shared delimiter map. for (let index = 0; index < input.length; index++) { const token = input[index]; if (token[0] !== TokenType.Function) continue; const name = asciiLowerCase(decoded(token)); if (!gradientNames.has(name)) continue; const end = structure.endForOpening(index); if (end === undefined) continue; const args = structure.topLevelSegments(index + 1, end); // The legacy prefixed syntax has no `to <side>` form, where an angle // would also point in the opposite sense. const linear = name.includes('linear') && !name.startsWith('-webkit-'); const conic = name.includes('conic'); const { stops, lineSpecifications, zeroEdits, directionEdit } = collectColorStops(source, input, structure, args, linear, conic); if (directionEdit) replacements.push(directionEdit); replacements.push( ...boundaryStopEdits( input, stops, lineSpecifications, args.length, zeroEdits, conic ), ...zeroEdits.values() ); } // With nothing to rewrite, neither the edit pass nor the write can change the // declaration. if (replacements.length) decl.value = applyEdits(source, replacements); } /** @return {import('postcss').Plugin} */ function pluginCreator() { return { postcssPlugin: 'postcss-minify-gradients', OnceExit(css) { css.walkDecls(optimise); }, }; } /** @type {true} */ pluginCreator.postcss = true; export { pluginCreator as default, pluginCreator as 'module.exports' };