postcss-ordered-values
Version:
Ensure values are ordered consistently in your CSS.
293 lines (272 loc) • 10 kB
JavaScript
import cssnanoUtils from 'cssnano-utils';
import { isDimension, isFunction, isNumber, name } from './tokenize.js';
const { TokenType, asciiLowerCase, decoded, lengthUnits, mathFunctions } =
cssnanoUtils;
const timeUnits = new Set(['ms', 's']);
const angleUnits = new Set(['deg', 'grad', 'rad', 'turn']);
const trigFunctions = new Set(['sin', 'cos', 'tan']);
const inverseTrigFunctions = new Set(['asin', 'acos', 'atan']);
const numberToNumberFunctions = new Set(['pow', 'sqrt', 'log', 'exp']);
/* 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']);
const ROUNDING_STRATEGY = 'rounding-strategy';
const closers = new Set([
TokenType.CloseParen,
TokenType.CloseSquare,
TokenType.CloseCurly,
]);
/** @typedef {{name: string | null, values: string[], operators: string[], args: string[], expectOperand: boolean}} Frame */
/** @typedef {{input: import('@csstools/css-tokenizer').CSSToken[], frames: Frame[], stack: import('@csstools/css-tokenizer').TokenType[]}} ParserState */
/** @param {string} operator */
function precedence(operator) {
return operator === '+' || operator === '-' ? 1 : 2;
}
/** @param {Frame} frame */
function reduce(frame) {
const operator = frame.operators.pop();
const right = frame.values.pop();
const left = frame.values.pop();
if (!operator || right === undefined || left === undefined) return false;
if (operator === '+' || operator === '-') {
if (left !== right) return false;
frame.values.push(left);
} else if (operator === '*') {
if (left === 'number') frame.values.push(right);
else if (right === 'number') frame.values.push(left);
else return false;
} else if (right === 'number') frame.values.push(left);
else if (left === right) frame.values.push('number');
else return false;
return true;
}
/** @param {Frame} frame */
function finish(frame) {
if (frame.operators.length && !reduce(frame)) return null;
if (frame.expectOperand || frame.values.length !== 1) return null;
return frame.values[0];
}
/**
* @param {number} count
* @param {string} first
* @param {string} second
*/
function roundResult(count, first, second) {
if (count === 1) {
return first === 'number' ? 'number' : null;
}
return first === second ? first : null;
}
/** @param {Frame} frame */
function functionResult(frame) {
const fn = frame.name;
if (!fn) return null;
// A leading <rounding-strategy> is a keyword, so it is skipped by offset
// rather than copied out of the argument list.
const offset = fn === 'round' && frame.args[0] === ROUNDING_STRATEGY ? 1 : 0;
const count = frame.args.length - offset;
const range = mathFunctions.get(fn);
if (!range || count < range[0] || count > range[1]) return null;
const first = frame.args[offset];
const second = frame.args[offset + 1];
if (trigFunctions.has(fn)) {
return first === 'number' || first === 'angle' ? 'number' : null;
}
if (inverseTrigFunctions.has(fn)) {
return first === 'number' ? 'angle' : null;
}
if (fn === 'atan2') {
return first === second ? 'angle' : null;
}
if (numberToNumberFunctions.has(fn)) {
return frame.args.every((value) => value === 'number') ? 'number' : null;
}
if (fn === 'sign') {
return 'number';
}
if (fn === 'round') {
return roundResult(count, first, second);
}
return frame.args.every((value) => value === first) ? first : null;
}
/** @param {Frame[]} frames @param {string | null} value */
function addValue(frames, value) {
const frame = frames.at(-1);
if (!frame || !frame.expectOperand || value === null) return false;
frame.values.push(value);
frame.expectOperand = false;
return true;
}
/** @param {ParserState} state @param {Frame} frame @param {import('@csstools/css-tokenizer').CSSToken} token */
function consumeFunction(state, frame, token) {
const functionName = asciiLowerCase(decoded(token) ?? '');
if (!mathFunctions.has(functionName) || !frame.expectOperand) return false;
state.frames.push({
name: functionName,
values: [],
operators: [],
args: [],
expectOperand: true,
});
state.stack.push(TokenType.CloseParen);
return true;
}
/** @param {ParserState} state @param {Frame} frame */
function consumeOpenParen(state, frame) {
if (!frame.expectOperand) return false;
state.frames.push({
name: null,
values: [],
operators: [],
args: [],
expectOperand: true,
});
state.stack.push(TokenType.CloseParen);
return true;
}
/** @param {Frame} frame */
function consumeComma(frame) {
if (!frame.name) return false;
const value = finish(frame);
if (value === null) return false;
frame.args.push(value);
frame.values = [];
frame.expectOperand = true;
return true;
}
/**
* A rounding strategy is accepted only as the whole first argument of round().
* @param {Frame[]} frames @param {Frame} frame @param {import('@csstools/css-tokenizer').CSSToken} token
*/
function consumeRoundingStrategy(frames, frame, token) {
if (
frame.name !== 'round' ||
frame.args.length > 0 ||
frame.values.length > 0 ||
!roundingStrategies.has(asciiLowerCase(decoded(token)))
)
return false;
return addValue(frames, ROUNDING_STRATEGY);
}
/** @param {ParserState} state @param {Frame} frame @param {number} index @param {string} operator */
function consumeOperator(state, frame, index, operator) {
if (frame.expectOperand || frame.values.at(-1) === ROUNDING_STRATEGY)
return false;
if (
(operator === '+' || operator === '-') &&
(state.input[index - 1]?.[0] !== TokenType.Whitespace ||
state.input[index + 1]?.[0] !== TokenType.Whitespace)
)
return false;
while (
frame.operators.length &&
precedence(frame.operators.at(-1) ?? '') >= precedence(operator)
) {
if (!reduce(frame)) return false;
}
frame.operators.push(operator);
frame.expectOperand = true;
return true;
}
/** @param {ParserState} state @param {Frame} frame @param {import('@csstools/css-tokenizer').TokenType} type */
function consumeCloser(state, frame, type) {
if (state.stack.pop() !== type) return false;
const value = finish(frame);
if (value === null) return false;
state.frames.pop();
if (!state.frames.at(-1)) return false;
if (!frame.name) return addValue(state.frames, value);
frame.args.push(value);
return addValue(state.frames, functionResult(frame));
}
/** @param {ParserState} state @param {number} index */
function consumeToken(state, index) {
const token = state.input[index];
const type = token[0];
if (type === TokenType.Whitespace) return true;
if (state.frames.length === 1 && state.frames[0].values.length > 0)
return false;
const frame = state.frames.at(-1);
if (!frame) return false;
if (type === TokenType.Number) return addValue(state.frames, 'number');
if (type === TokenType.Dimension) {
const unit = asciiLowerCase(/** @type {{unit: string}} */ (token[4]).unit);
if (timeUnits.has(unit)) return addValue(state.frames, 'time');
if (angleUnits.has(unit)) return addValue(state.frames, 'angle');
// Length units collapse to one kind before arithmetic so mixed-unit
// sums such as calc(1px + 1em) resolve to a length (CSS Values 4).
if (lengthUnits.has(unit)) return addValue(state.frames, 'length');
return addValue(state.frames, `dimension:${unit}`);
}
if (type === TokenType.Function) return consumeFunction(state, frame, token);
if (type === TokenType.Ident)
return consumeRoundingStrategy(state.frames, frame, token);
if (type === TokenType.OpenParen) return consumeOpenParen(state, frame);
if (type === TokenType.Comma) return consumeComma(frame);
if (
type === TokenType.Delim &&
(token[1] === '+' ||
token[1] === '-' ||
token[1] === '*' ||
token[1] === '/')
)
return consumeOperator(state, frame, index, token[1]);
if (closers.has(type)) return consumeCloser(state, frame, type);
return false;
}
/** @param {import('@csstools/css-tokenizer').CSSToken[]} input @return {string | null} */
export function parseMath(input) {
/** @type {ParserState} */
const state = {
input,
frames: [
{ name: null, values: [], operators: [], args: [], expectOperand: true },
],
stack: [],
};
for (let index = 0; index < input.length; index++) {
if (!consumeToken(state, index)) return null;
}
if (state.stack.length || state.frames.length !== 1) return null;
return finish(state.frames[0]);
}
/** @typedef {'time' | 'angle' | 'length' | 'number' | `dimension:${string}` | null} Dimension */
/** @typedef {{isMath: boolean, dimension: Dimension, isNonNegative: boolean}} TimeClassification */
/** @param {import('./tokenize.js').Term} node @return {Dimension} */
function directDimension(node) {
if (isDimension(node)) {
const { unit } = /** @type {{unit: string}} */ (node.tokens[0][4]);
const normalizedUnit = asciiLowerCase(unit);
if (timeUnits.has(normalizedUnit)) return 'time';
if (angleUnits.has(normalizedUnit)) return 'angle';
return `dimension:${normalizedUnit}`;
}
return isNumber(node) ? 'number' : null;
}
/** @param {import('./tokenize.js').Term} node @return {TimeClassification} */
export default function classifyTime(node) {
const direct = directDimension(node);
if (direct !== null) {
if (direct !== 'time') {
return { isMath: false, dimension: direct, isNonNegative: false };
}
const { value, signCharacter } =
/** @type {{value: number, signCharacter?: string}} */ (
node.tokens[0][4]
);
return {
isMath: false,
dimension: direct,
isNonNegative: signCharacter !== '-' && value >= 0,
};
}
const isMath = isFunction(node) && mathFunctions.has(name(node));
const dimension = /** @type {Dimension} */ (
isMath ? parseMath(node.tokens) : null
);
return {
isMath,
dimension,
isNonNegative: dimension === 'time',
};
}