UNPKG

postcss-merge-longhand

Version:

Merge longhand properties into shorthand with PostCSS.

205 lines (187 loc) • 6.08 kB
import { tokenize, TokenType } from '@csstools/css-tokenizer'; import cssnanoUtils from 'cssnano-utils'; import { withoutVendorPrefix } from './vendorPrefix.js'; const { asciiLowerCase, decoded, lengthUnits } = cssnanoUtils; /** @typedef {'number' | 'length' | 'percentage' | 'length-percentage'} MathType */ /* Math constants of CSS Values 4 are numbers. */ const constants = new Set(['e', 'pi', 'infinity', '-infinity', 'nan']); /* The arity of each supported function; others fail closed. */ const arities = new Map([ ['calc', [1, 1]], ['abs', [1, 1]], ['clamp', [3, 3]], ['min', [1, Infinity]], ['max', [1, Infinity]], ['hypot', [1, Infinity]], ]); /** * @param {MathType | undefined} left * @param {MathType | undefined} right * @return {MathType | undefined} the type of a sum, which needs operands of * one type, where a length and a percentage give a length-percentage */ function sumType(left, right) { if (left === undefined || right === undefined) return undefined; if (left === right) return left; if (left === 'number' || right === 'number') return undefined; return 'length-percentage'; } /** * @param {MathType | undefined} left * @param {MathType | undefined} right * @return {MathType | undefined} the type of a product, where one operand * must be a number */ function productType(left, right) { if (left === undefined || right === undefined) return undefined; if (right === 'number') return left; return left === 'number' ? right : undefined; } /** * Types a calculation the way CSS Values 4 does for lengths. Division by a * non-number, `round()` and its relatives, and anything else this does not * model fail closed, as a user agent that lacks the feature drops them. */ class MathParser { /** @type {ReturnType<typeof tokenize>} */ #tokens; #index = 0; /** @param {string} css */ constructor(css) { this.#tokens = tokenize({ css }).filter( (part) => part[0] !== TokenType.Comment && part[0] !== TokenType.EOF ); } /** @return {TokenType | -1} the type of the next token, or -1 at the end */ #peek() { return this.#index < this.#tokens.length ? this.#tokens[this.#index][0] : -1; } /** @return {boolean} whether whitespace was skipped */ #skipSpace() { const start = this.#index; while (this.#peek() === TokenType.Whitespace) this.#index++; return this.#index > start; } /** * @param {string} operators * @return {string | undefined} the delimiter, consumed, when it is one of * `operators` */ #takeDelimiter(operators) { if (this.#peek() !== TokenType.Delim) return undefined; const value = /** @type {{value: string}} */ (this.#tokens[this.#index][4]) .value; if (!operators.includes(value)) return undefined; this.#index++; return value; } /** @return {MathType | undefined} the type of the input as one expression */ parseAll() { this.#skipSpace(); const type = this.#sum(); this.#skipSpace(); return this.#index === this.#tokens.length ? type : undefined; } /** @return {MathType | undefined} */ #sum() { let type = this.#product(); for (;;) { const start = this.#index; const spaceBefore = this.#skipSpace(); if (this.#takeDelimiter('+-') === undefined) { this.#index = start; return type; } /* CSS Values 4 needs whitespace on both sides of `+` and `-`. */ if (!spaceBefore || !this.#skipSpace()) return undefined; type = sumType(type, this.#product()); } } /** @return {MathType | undefined} */ #product() { let type = this.#factor(); for (;;) { const start = this.#index; this.#skipSpace(); const operator = this.#takeDelimiter('*/'); if (operator === undefined) { this.#index = start; return type; } this.#skipSpace(); const operand = this.#factor(); if (operator === '*') { type = productType(type, operand); } else if (operand !== 'number') { type = undefined; } } } /** @return {MathType | undefined} */ #factor() { const part = this.#tokens[this.#index]; if (part === undefined) return undefined; this.#index++; switch (part[0]) { case TokenType.Number: return 'number'; case TokenType.Percentage: return 'percentage'; case TokenType.Dimension: return lengthUnits.has( asciiLowerCase(/** @type {{unit: string}} */ (part[4]).unit) ) ? 'length' : undefined; case TokenType.Ident: return constants.has(asciiLowerCase(decoded(part))) ? 'number' : undefined; case TokenType.OpenParen: return this.#arguments(1, 1); case TokenType.Function: { const arity = arities.get( withoutVendorPrefix(asciiLowerCase(decoded(part))) ); return arity && this.#arguments(arity[0], arity[1]); } default: return undefined; } } /** * @param {number} least * @param {number} most * @return {MathType | undefined} the common type of the comma-separated * arguments, up to the closing parenthesis */ #arguments(least, most) { let count = 0; /** @type {MathType | undefined} */ let type; for (;;) { this.#skipSpace(); const argument = this.#sum(); this.#skipSpace(); type = count === 0 ? argument : sumType(type, argument); count++; if (type === undefined || count > most) return undefined; const closing = this.#peek(); this.#index++; if (closing === TokenType.CloseParen) { return count >= least ? type : undefined; } if (closing !== TokenType.Comma) return undefined; } } } /** * @param {string} token - a math function or a parenthesized expression * @return {MathType | undefined} its type; `undefined` when a user agent * rejects the expression or this module cannot tell */ export function mathType(token) { return new MathParser(token).parseAll(); }