UNPKG

postcss-ordered-values

Version:

Ensure values are ordered consistently in your CSS.

293 lines (272 loc) • 10 kB
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', }; }