UNPKG

postcss-reduce-transforms

Version:

Reduce transform functions with PostCSS.

327 lines (309 loc) • 9.36 kB
import cssnanoUtils from 'cssnano-utils'; const { TokenType, asciiLowerCase, balancedTokens, decoded, numeric, tokenEnd, tokenStart, } = cssnanoUtils; const transformPropertyRegex = /[tT][rR][aA][nN][sS][fF][oO][rR][mM]$/v; /** @typedef {{open: number, close: number, name: string, args: {significant: number[]}[]}} FunctionFrame */ /** @typedef {{number: number, unit: string} | string | undefined} ParsedArgument */ /** @param {number[]} significant @param {string} value @param {readonly import('@csstools/css-tokenizer').CSSToken[]} tokens @param {NonNullable<ReturnType<typeof balancedTokens>>} structure @return {string} */ function argumentSource(significant, value, tokens, structure) { if (significant.length === 0) return ''; const first = significant[0]; const last = significant[significant.length - 1]; const end = structure.endForOpening(first); return value.slice( tokenStart(tokens[first]), end !== undefined ? tokenEnd(tokens[end]) : tokenEnd(tokens[last]) ); } /** * @param {ParsedArgument} value * @returns {value is {number: number, unit: string}} */ function isNumericArgument(value) { return typeof value === 'object'; } /** * @param {ParsedArgument} value * @returns {value is {number: number, unit: string}} */ function isUnitlessNumber(value) { return isNumericArgument(value) && value.unit === ''; } /** @param {ParsedArgument} value @return {boolean} */ function isOne(value) { return isUnitlessNumber(value) && value.number === 1; } /** * Length units from CSS Values. A zero is a zero `<length-percentage>` only * when its unit is empty, `%`, or one of these; `0deg` or an unknown unit * makes the declaration invalid. */ const lengthUnits = new Set([ 'em', 'ex', 'cap', 'ch', 'ic', 'rem', 'lh', 'rlh', 'vw', 'svw', 'lvw', 'dvw', 'vh', 'svh', 'lvh', 'dvh', 'vi', 'svi', 'lvi', 'dvi', 'vb', 'svb', 'lvb', 'dvb', 'vmin', 'svmin', 'lvmin', 'dvmin', 'vmax', 'svmax', 'lvmax', 'dvmax', 'cm', 'mm', 'q', 'in', 'pt', 'pc', 'px', ]); /** * @param {ParsedArgument} value * @return {boolean} */ function isZeroLengthPercentage(value) { if (!isNumericArgument(value) || value.number !== 0) return false; const unit = asciiLowerCase(value.unit); return unit === '' || unit === '%' || lengthUnits.has(unit); } /** * Reductions keyed by lower-cased function name. Each entry receives the * parsed arguments — `{number, unit}` for a numeric token, the source text of * a single-argument `var()`/`env()`, or `undefined` for anything else — and * returns the equivalent single-axis function with the argument index whose * source it keeps, or `undefined` when the value must stay as written. * * Where a component must be a `<number>` or `<length-percentage>`, a * dimension, percentage, or other unit makes the declaration invalid and the * reduction does not apply. * * @type {Map<string, (args: ParsedArgument[]) => [string, number] | undefined>} */ const reductions = new Map([ [ 'rotate3d', (args) => { if (args.length !== 4) return undefined; const [x, y, z] = args; if (!isUnitlessNumber(x) || !isUnitlessNumber(y) || !isUnitlessNumber(z)) return undefined; if (x.number === 1 && y.number === 0 && z.number === 0) return ['rotateX', 3]; if (x.number === 0 && y.number === 1 && z.number === 0) return ['rotateY', 3]; return undefined; }, ], [ 'scale', (args) => { if (args.length !== 2) return undefined; const [sx, sy] = args; if (isUnitlessNumber(sy)) { if (isUnitlessNumber(sx) && sx.number === sy.number) return ['scale', 0]; // scale(anything, 1) is scaleX(anything) for any valid first factor. if (sy.number === 1) return ['scaleX', 0]; } if (isUnitlessNumber(sx) && sx.number === 1) return ['scaleY', 1]; // Identical var()/env() references compute to the same value. if (typeof sx === 'string' && sx === sy) return ['scale', 0]; return undefined; }, ], [ 'scale3d', (args) => args.length === 3 && isOne(args[0]) && isOne(args[1]) ? ['scaleZ', 2] : undefined, ], [ 'translate', (args) => { if (args.length !== 2) return undefined; if (isZeroLengthPercentage(args[1])) return ['translate', 0]; if (isZeroLengthPercentage(args[0])) return ['translateY', 1]; return undefined; }, ], [ 'translate3d', (args) => { if (args.length !== 3) return undefined; if (isZeroLengthPercentage(args[0]) && isZeroLengthPercentage(args[1])) return ['translateZ', 2]; return undefined; }, ], ]); /** @param {string} value @return {string} */ function transform(value) { const structure = balancedTokens(value); if (!structure) return value; const { tokens } = structure; /** @type {FunctionFrame[]} */ const functions = []; /** @type {Map<number, FunctionFrame>} */ const functionsByOpen = new Map(); for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (token[0] === TokenType.Function) { const close = structure.endForOpening(i); if (close === undefined) continue; const frame = { open: i, close, name: decoded(token), args: [], }; functions.push(frame); functionsByOpen.set(i, frame); } } /** @type {[number, number, string][]} */ const replacements = []; for (const frame of functions.toReversed()) { frame.args = structure .topLevelSegments(frame.open + 1, frame.close) .map(({ startIndex, endIndex }) => { /** @type {number[]} */ const significant = []; for (let i = startIndex; i < endIndex; i++) { const token = tokens[i]; // A nested block is one significant token: its interior is not an // argument boundary and is not numeric. const end = structure.endForOpening(i); if (end !== undefined) { significant.push(i); i = end; continue; } if (token[0] !== TokenType.Whitespace) significant.push(i); } return { significant, }; }); /** @type {(argument: {significant: number[]}) => ParsedArgument} */ const parseArgument = (argument) => { if (argument.significant.length !== 1) return undefined; const index = argument.significant[0]; const child = functionsByOpen.get(index); if ( child && (child.name === 'var' || child.name === 'env') && child.args.length === 1 && child.args[0].significant.length === 1 ) { // Custom-property and environment variables are distinct namespaces, // so the resolving function is part of a reference's identity; names // themselves are case-sensitive. return `${child.name}(${argumentSource( child.args[0].significant, value, tokens, structure )})`; } const token = tokens[index]; if ( token[0] === TokenType.Function || structure.endForOpening(index) !== undefined ) return undefined; return numeric(token) || undefined; }; const reduction = reductions.get(asciiLowerCase(frame.name))?.( frame.args.map(parseArgument) ); if (!reduction) continue; const [targetFunction, index] = reduction; replacements.push([ tokenStart(tokens[frame.open]), tokenEnd(tokens[frame.close]), `${targetFunction}(${argumentSource( frame.args[index].significant, value, tokens, structure )})`, ]); } // An outer reduction may span an already rewritten inner function; the // outermost edit applied last wins. let result = value; for (const [start, end, text] of replacements.toSorted((a, b) => b[0] - a[0])) result = result.slice(0, start) + text + result.slice(end); return result; } /** @return {import('postcss').Plugin} */ function pluginCreator() { return { postcssPlugin: 'postcss-reduce-transforms', prepare() { const cache = new Map(); return { OnceExit(css) { css.walkDecls(transformPropertyRegex, (decl) => { const value = decl.raws.value?.value === decl.value ? (decl.raws.value.raw ?? decl.value) : decl.value; if (!value) return; if (!value.includes('(')) { assignValue(decl, value); cache.set(value, value); return; } if (cache.has(value)) { assignValue(decl, cache.get(value)); return; } const result = transform(value); assignValue(decl, result); cache.set(value, result); }); }, }; }, }; } /** @param {import('postcss').Declaration} decl @param {string} value */ function assignValue(decl, value) { decl.value = value; if (decl.raws.value?.raw) decl.raws.value = { raw: value, value }; } /** @type {true} */ pluginCreator.postcss = true; const moduleExports = pluginCreator; export { moduleExports as default, moduleExports as 'module.exports' };