UNPKG

postcss-convert-values

Version:

Convert values with PostCSS (e.g. ms -> s)

356 lines (329 loc) • 9.77 kB
import cssnanoUtils from 'cssnano-utils'; import convert, { formatNumber, requiresZeroUnit, roundToPrecision, } from './convert.js'; import { numericSource } from './numericSource.js'; const { TokenType, calcSumFunctions, closeForOpening, decoded, lengthUnits, mathFunctions, tokens, } = cssnanoUtils; /* Functions in which a zero cannot lose its unit. Every math function qualifies * because `calc(0%)` and `calc(0)` differ in type; the rest are listed per * grammar, e.g. conic-gradient stops take an <angle-percentage>, which no * unitless zero represents, while linear-gradient stops take a * <length-percentage>, which it does. */ const functionsPreservingZeroUnits = new Set([ ...mathFunctions.keys(), ...calcSumFunctions, 'anchor', 'anchor-size', 'contrast-color', 'view', 'color-mix', 'hsl', 'hsla', 'hwb', 'linear', 'conic-gradient', 'repeating-conic-gradient', 'cross-fade', 'rgb', 'rgba', 'oklch', 'oklab', 'lab', 'lch', 'color', 'palette-mix', ]); /** @typedef {Parameters<typeof convert>[2]} ConvertOptions */ /** @typedef {{precision?: false | number, transformCustomProperties?: boolean} & ConvertOptions & { overrideBrowserslist?: string | string[] } & import('browserslist').Options} Options */ /** @typedef {{keepZeroPercent: boolean, keepZeroLength: boolean, clampAlpha: boolean, isAlpha: boolean, isFont: boolean}} ReplacementFlags */ /** * @param {string} unit * @param {string} lowerCasedUnit * @param {Options} opts * @param {boolean} keepZeroPercent * @param {boolean} keepZeroLength * @return {string} */ function formatZero( unit, lowerCasedUnit, opts, keepZeroPercent, keepZeroLength ) { if (requiresZeroUnit(lowerCasedUnit)) { return convert(0, unit, opts); } if (unit === '%') { return keepZeroPercent ? '0%' : '0'; } if (lengthUnits.has(lowerCasedUnit)) { return keepZeroLength ? '0' + unit : '0'; } return '0'; } /** @param {number} number @param {string} unit @param {string} raw @param {Options} opts @param {boolean} keepZeroPercent @param {boolean} keepZeroLength @param {boolean} hasDecimal @return {string} */ function parseNumber( number, unit, raw, opts, keepZeroPercent, keepZeroLength, hasDecimal ) { if (raw.includes('\\')) return raw; const lowerCasedUnit = unit.toLowerCase(); if ( unit !== '' && unit !== '%' && !lengthUnits.has(lowerCasedUnit) && !requiresZeroUnit(lowerCasedUnit) ) return raw; let num = number; if ( typeof opts.precision === 'number' && opts.precision >= 0 && (hasDecimal || !Number.isInteger(num)) ) { num = roundToPrecision(num, opts.precision); } if (num === 0) { return formatZero( unit, lowerCasedUnit, opts, keepZeroPercent, keepZeroLength ); } // An exponent is never a valid <integer>, so a bare number written as an // integer may sit where only <integer> is allowed. Dimensions and numbers // already written as non-integers cannot. if (unit === '') { const writtenAsInteger = !raw.includes('.') && !raw.toLowerCase().includes('e'); return formatNumber(num, !writtenAsInteger); } return convert(num, unit, opts); } /** @param {string} value @param {number} number @param {string} unit @param {Options} opts @param {boolean} [clamp] @return {string} */ function clampOpacity(value, number, unit, opts, clamp = true) { if (unit === '%') { if (clamp) { if (number >= 100) return '1'; if (number <= 0) return '0'; } let decimalNumber = Number((number / 100).toPrecision(15)); if (typeof opts.precision === 'number' && opts.precision >= 0) { decimalNumber = roundToPrecision(decimalNumber, opts.precision); } const decimal = formatNumber(decimalNumber); return decimal.length < value.length ? decimal : value; } if (clamp) { if (number > 1) return '1'; if (number < 0) return '0'; } return value; } /** @param {string} str @return {string} */ export function stripVendorPrefix(str) { if (str.charCodeAt(0) !== 45 /* '-' */) return str; if (str.startsWith('-webkit-')) return str.slice(8); if (str.startsWith('-moz-')) return str.slice(5); if (str.startsWith('-ms-')) return str.slice(4); if (str.startsWith('-o-')) return str.slice(3); return str; } /** * @param {string} replacement * @param {string} raw * @param {boolean} isAlpha * @param {boolean} isConvertibleZero * @return {boolean} */ function shouldReplace(replacement, raw, isAlpha, isConvertibleZero) { if (replacement === raw) return false; if (replacement.length < raw.length) return true; return (isAlpha || isConvertibleZero) && replacement.length <= raw.length; } class FrameStack { /** @type {string[]} */ #closeStack = []; #keepZeroPercent; #keepZeroLength; #keepZeroUnitsDepth = -1; #skippedDepth = -1; /** * @param {boolean} keepZeroPercent * @param {boolean} keepZeroLength */ constructor(keepZeroPercent, keepZeroLength) { this.#keepZeroPercent = keepZeroPercent; this.#keepZeroLength = keepZeroLength; } get isTopLevel() { return this.#closeStack.length === 0; } get isSkipped() { return this.#skippedDepth !== -1; } get keepZeroPercent() { return this.#keepZeroPercent || this.#keepZeroUnitsDepth !== -1; } get keepZeroLength() { return this.#keepZeroLength || this.#keepZeroUnitsDepth !== -1; } /** * @param {ReturnType<typeof tokens>[number]} token * @return {boolean} */ advance(token) { const type = token[0]; if (type === TokenType.Function) { const name = stripVendorPrefix(decoded(token).toLowerCase()); const keepInFunction = functionsPreservingZeroUnits.has(name); const depth = this.#closeStack.length + 1; this.#closeStack.push(TokenType.CloseParen); if (keepInFunction && this.#keepZeroUnitsDepth === -1) { this.#keepZeroUnitsDepth = depth; } if (name === 'url' && this.#skippedDepth === -1) { this.#skippedDepth = depth; } return true; } if ( type === TokenType.OpenParen || type === TokenType.OpenSquare || type === TokenType.OpenCurly ) { const close = closeForOpening(type); if (close !== undefined) this.#closeStack.push(close); return true; } if ( type === TokenType.CloseParen || type === TokenType.CloseSquare || type === TokenType.CloseCurly ) { if ( this.#closeStack.length > 0 && this.#closeStack[this.#closeStack.length - 1] === type ) { const depth = this.#closeStack.length; if (depth === this.#keepZeroUnitsDepth) this.#keepZeroUnitsDepth = -1; if (depth === this.#skippedDepth) this.#skippedDepth = -1; this.#closeStack.pop(); } return true; } return false; } } /** @typedef {{index: number, start: number, end: number, raw: string, number: number, unit: string, hasDecimal: boolean}} NumericSource */ /** * @param {NumericSource} source * @param {boolean} isLineHeight * @param {FrameStack} frames * @param {Options} opts * @param {ReplacementFlags} flags * @return {{start: number, end: number, text: string} | undefined} */ function processNumericToken(source, isLineHeight, frames, opts, flags) { const currentKeepZeroPercent = frames.keepZeroPercent || isLineHeight; const currentKeepZeroLength = frames.keepZeroLength || isLineHeight; const converted = parseNumber( source.number, source.unit, source.raw, opts, currentKeepZeroPercent, currentKeepZeroLength, source.hasDecimal ); const isAlpha = flags.isAlpha && frames.isTopLevel; const replacement = isAlpha ? clampOpacity( converted, source.number, source.unit, opts, flags.clampAlpha ) : converted; const isConvertibleZero = source.number === 0 && requiresZeroUnit(source.unit.toLowerCase()); if (shouldReplace(replacement, source.raw, isAlpha, isConvertibleZero)) { return { start: source.start, end: source.end, text: replacement, }; } return undefined; } /** * @param {string} value * @param {ReplacementFlags} flags * @param {Options} opts * @return {{start: number, end: number, text: string}[]} */ export function findReplacements(value, flags, opts) { /** @type {{start: number, end: number, text: string}[]} */ const replacements = []; const frames = new FrameStack(flags.keepZeroPercent, flags.keepZeroLength); let afterFontSlash = false; const input = tokens(value, { unicodeRangesAllowed: true }); for (let index = 0; index < input.length; index++) { const token = input[index]; if (frames.advance(token)) { if (frames.isTopLevel && afterFontSlash) afterFontSlash = false; continue; } if (frames.isSkipped) continue; const type = token[0]; if (frames.isTopLevel) { if (type === TokenType.Whitespace || type === TokenType.Comment) { continue; } if (flags.isFont && type === TokenType.Delim && token[1] === '/') { afterFontSlash = true; continue; } } const wasAfterFontSlash = frames.isTopLevel && afterFontSlash; if (afterFontSlash) afterFontSlash = false; if ( type !== TokenType.Number && type !== TokenType.Dimension && type !== TokenType.Percentage ) { continue; } const source = numericSource(input, index); if (!source) continue; index = source.index; const edit = processNumericToken( source, wasAfterFontSlash, frames, opts, flags ); if (edit) replacements.push(edit); } return replacements; }