postcss-merge-longhand
Version:
Merge longhand properties into shorthand with PostCSS.
316 lines (294 loc) • 10.2 kB
JavaScript
import { cssWideKeywords } from './spec.js';
import cssnanoUtils from 'cssnano-utils';
import { isLengthValue } from './lengthGrammar.js';
import { closingTokens } from './valueComponents.js';
const {
TokenType,
asciiLowerCase,
closeForOpening,
decoded,
mathFunctions,
numeric,
} = cssnanoUtils;
/* Math functions resolve to a value the grammar can accept positionally, and
* anchor-size() always resolves to a length. */
const valueFunctions = new Set([...mathFunctions.keys(), 'anchor-size']);
/* These functions have a fixed result type that cannot be a length. They
* remain in valueFunctions so valid uses nested in calc() are accepted. */
const nonLengthResultFunctions = new Set([
'acos',
'asin',
'atan',
'atan2',
'cos',
'exp',
'log',
'pow',
'sign',
'sin',
'sqrt',
'tan',
]);
/* round() may lead with a <rounding-strategy> keyword, an argument that the
* math function arity table does not count. */
const roundingStrategies = new Set(['nearest', 'up', 'down', 'to-zero']);
/**
* firstArgument is 'keyword' only while the first argument of round() is a
* lone <rounding-strategy>, which the arity table does not count. In a
* calculation, operand records whether the argument so far ends with a value.
*
* @typedef {{name: string | null, expected: import('@csstools/css-tokenizer').TokenType, commas: number, hasValue: boolean, firstArgument: 'empty' | 'keyword' | 'value', calculation: boolean, operand: boolean}} FunctionFrame
*/
/**
* @typedef {{raw: string, tokens: import('@csstools/css-tokenizer').CSSToken[]}}
* Component
*/
/** @param {import('@csstools/css-tokenizer').CSSToken} token @return {string} */
export function tokenName(token) {
return asciiLowerCase(decoded(token));
}
/** @param {Component} component @return {string} */
export function componentName(component) {
const token = component.tokens[0];
if (!token) return '';
return token[0] === TokenType.Ident || token[0] === TokenType.Function
? tokenName(token)
: '';
}
/**
* Return a semantic comparison key without reserializing the component. Raw
* source is still used for output; decoding is only for classification and
* equality of case-insensitive CSS tokens.
*
* @param {Component} component
* @return {string}
*/
export function componentKey(component) {
return component.tokens
.map((token) => {
const value = numeric(token);
if (value) {
return `numeric:${token[0]}:${value.number}:${asciiLowerCase(value.unit)}`;
}
if (token[0] === TokenType.Whitespace) return 'whitespace';
if (token[0] === TokenType.Ident || token[0] === TokenType.Function) {
return `${token[0]}:${tokenName(token)}`;
}
return `${token[0]}:${token[1]}`;
})
.join('|');
}
/** @param {Component} component @return {boolean} */
export function hasCssWideKeyword(component) {
return component.tokens.some(
(token) =>
token[0] === TokenType.Ident && cssWideKeywords.has(tokenName(token))
);
}
/**
* Check function names recursively. Detailed value grammar belongs to the
* individual reducer; rejecting unknown and substitution functions here keeps
* every reducer conservative without losing raw function spelling.
*
* @param {Component} component
* @param {Set<string>} allowed
* @return {boolean}
*/
export function hasAllowedFunctions(component, allowed) {
/** @type {FunctionFrame[]} */
const stack = [];
for (const token of component.tokens) {
if (token[0] === TokenType.Function) {
const name = tokenName(token);
if (!pushFunctionFrame(stack, name, allowed)) return false;
} else if (token[0] === TokenType.Whitespace) {
continue;
} else if (token[0] === TokenType.Comma) {
if (!consumeFunctionComma(stack)) return false;
} else if (closingTokens.has(token[0])) {
if (!consumeFunctionCloser(stack, token[0])) return false;
} else {
const expected = closeForOpening(token[0]);
if (expected !== undefined) {
stack.push({
name: null,
expected,
commas: 0,
hasValue: false,
firstArgument: 'empty',
// Parentheses inside a calculation group a nested calculation.
calculation:
expected === TokenType.CloseParen &&
stack.at(-1)?.calculation === true,
operand: false,
});
} else {
const frame = stack.at(-1);
if (!frame) return false;
if (frame.calculation && !consumeCalculationToken(frame, token)) {
return false;
}
const isStrategy =
frame.name === 'round' &&
token[0] === TokenType.Ident &&
roundingStrategies.has(asciiLowerCase(decoded(token)));
if (!consumeFunctionValue(frame, isStrategy)) return false;
}
}
}
if (stack.length) return false;
return operatorsHaveRightOperands(component.tokens);
}
/** @param {FunctionFrame[]} stack @param {string} name @param {Set<string>} allowed @return {boolean} */
function pushFunctionFrame(stack, name, allowed) {
if (!allowed.has(name)) return false;
stack.push({
name,
expected: TokenType.CloseParen,
commas: 0,
hasValue: false,
firstArgument: 'empty',
calculation: mathFunctions.has(name),
operand: false,
});
return true;
}
/**
* A calculation alternates values and operators, starting and ending with a
* value: CSS math has no unary operators and no implicit juxtaposition.
*
* @param {FunctionFrame} frame
* @param {import('@csstools/css-tokenizer').CSSToken} token
* @return {boolean}
*/
function consumeCalculationToken(frame, token) {
if (token[0] === TokenType.Delim) {
if (!isMathOperator(token[1]) || !frame.operand) return false;
frame.operand = false;
return true;
}
if (frame.operand) return false;
frame.operand = true;
return true;
}
/** @param {FunctionFrame} frame @param {boolean} isStrategy @return {boolean} */
function consumeFunctionValue(frame, isStrategy) {
if (frame.commas > 0) {
// Only the first argument may be a strategy; later ones are not
// counted, so a repeated keyword would pass the arity check.
if (isStrategy) return false;
} else if (frame.firstArgument === 'keyword') {
// The keyword must stand alone, or the rest of the argument would be
// counted as a calculation.
return false;
} else if (frame.firstArgument === 'empty') {
frame.firstArgument = isStrategy ? 'keyword' : 'value';
}
frame.hasValue = true;
return true;
}
/** @param {FunctionFrame[]} stack @return {boolean} */
function consumeFunctionComma(stack) {
const frame = stack.at(-1);
if (!frame?.name || !frame.hasValue) return false;
if (frame.calculation && !frame.operand) return false;
frame.commas++;
frame.hasValue = false;
frame.operand = false;
return true;
}
/** @param {FunctionFrame[]} stack @param {import('@csstools/css-tokenizer').TokenType} type @return {boolean} */
function consumeFunctionCloser(stack, type) {
const frame = stack.pop();
if (!frame || frame.expected !== type || !frame.hasValue) return false;
if (frame.calculation && !frame.operand) return false;
const argumentCount =
frame.commas + (frame.firstArgument === 'keyword' ? 0 : 1);
if (!functionArityIsValid(frame.name, argumentCount)) return false;
const parent = stack.at(-1);
if (parent) {
// A nested value makes a leading keyword part of a larger argument.
if (parent.commas === 0) {
if (parent.firstArgument === 'keyword') return false;
parent.firstArgument = 'value';
}
if (parent.calculation) {
if (parent.operand) return false;
parent.operand = true;
}
parent.hasValue = true;
}
return true;
}
/** @param {import('@csstools/css-tokenizer').CSSToken[]} input @return {boolean} */
function operatorsHaveRightOperands(input) {
for (let index = 0; index < input.length; index++) {
const token = input[index];
if (
token[0] === TokenType.Delim &&
isMathOperator(token[1]) &&
operatorHasNoOperand(input, index)
) {
return false;
}
}
return true;
}
/** @param {string} value @return {boolean} */
function isMathOperator(value) {
return ['+', '-', '*', '/'].includes(value);
}
/** @param {import('@csstools/css-tokenizer').CSSToken[]} input @param {number} index @return {boolean} */
function operatorHasNoOperand(input, index) {
let next = index + 1;
while (input[next]?.[0] === TokenType.Whitespace) next++;
return !input[next] || closingTokens.has(input[next][0]);
}
/** @param {string | null} name @param {number} argumentCount @return {boolean} */
function functionArityIsValid(name, argumentCount) {
if (!name) return true;
const range = mathFunctions.get(name);
if (range) {
return argumentCount >= range[0] && argumentCount <= range[1];
}
if (name === 'anchor-size') return argumentCount === 1 || argumentCount === 2;
if (name === 'cubic-bezier') return argumentCount === 4;
if (name === 'steps') return argumentCount === 1 || argumentCount === 2;
if (name === 'linear') return argumentCount > 0;
return false;
}
/** @param {Component} component @return {{number: number, unit: string} | false} */
export function directNumeric(component) {
if (component.tokens.length !== 1) return false;
return numeric(component.tokens[0]);
}
/**
* @param {Component} component
* @param {{percentage: boolean, auto: boolean, nonNegative?: boolean}}
* grammar
* @return {boolean}
*/
export function isLengthComponent(component, grammar) {
if (hasCssWideKeyword(component)) return false;
if (component.tokens[0]?.[0] === TokenType.Function) {
if (nonLengthResultFunctions.has(tokenName(component.tokens[0]))) {
return false;
}
return hasAllowedFunctions(component, valueFunctions);
}
const value = directNumeric(component);
if (value) {
return isLengthValue(
value.number,
value.unit,
grammar.percentage,
grammar.nonNegative === true
);
}
return (
grammar.auto &&
component.tokens.length === 1 &&
component.tokens[0][0] === TokenType.Ident &&
tokenName(component.tokens[0]) === 'auto'
);
}