UNPKG

postcss-minify-font-values

Version:

Minify font declarations with PostCSS

165 lines (151 loc) • 5.38 kB
import cssnanoUtils from 'cssnano-utils'; import keywords from './keywords.js'; import minifyFamily from './minify-family.js'; const { TokenType, asciiLowerCase, balancedTokens, decoded, lengthUnits, tokenEnd, } = cssnanoUtils; // `oblique <angle>` precedes the font-size. const angleUnits = new Set(['deg', 'grad', 'rad', 'turn']); /** @import {CSSToken} from '@csstools/css-tokenizer' */ /** @param {string} name */ const isPrefixKeyword = (name) => keywords.style.has(name) || keywords.variant.has(name) || keywords.stretch.has(name) || keywords.weight.has(name); /** @param {CSSToken} token @param {string} name */ function isFontSize(token, name) { switch (token[0]) { case TokenType.Ident: return keywords.size.has(name); case TokenType.Number: return Number(decoded(token)) === 0; case TokenType.Percentage: case TokenType.Function: return true; case TokenType.Dimension: return lengthUnits.has(asciiLowerCase(token[4].unit)); default: return false; } } /** @param {CSSToken} token */ function isUnknownDimension(token) { if (token[0] !== TokenType.Dimension) return false; const unit = asciiLowerCase(token[4].unit); return !lengthUnits.has(unit) && !angleUnits.has(unit); } /** @param {CSSToken} token */ function isNumericWeight(token) { if (token[0] !== TokenType.Number) return false; const weight = Number(decoded(token)); return weight >= 1 && weight <= 1000; } /** @param {readonly CSSToken[]} input @param {number} start @return {number} */ function skipTrivia(input, start) { let index = start; while ( input[index]?.[0] === TokenType.Whitespace || input[index]?.[0] === TokenType.Comment ) index++; return index; } // Functions whose result is substituted at computed-value time, so the // shorthand cannot be split into components statically. Custom functions // (`--name()`) are substitution functions too. const substitutionFunctions = new Set(['var', 'env', 'attr', 'if', 'inherit']); /** @param {CSSToken} token */ function isSubstitution(token) { if (token[0] !== TokenType.Function) return false; const name = decoded(token); return ( name.startsWith('--') || substitutionFunctions.has(asciiLowerCase(name)) ); } /** * Locate the `font-size` and the tokens preceding it. Returns undefined when * the value cannot be minified safely (a substitution function, an unknown * dimension, repeated `bold`). * * @param {NonNullable<ReturnType<typeof balancedTokens>>} balanced * @return {{ sizeEnd: number, possibleFamilyStart: number, boldSpan?: { start: number, end: number } } | undefined} */ function scanFontPrefix(balanced) { const input = balanced.tokens; let possibleFamilyStart = -1; let sizeEnd = -1; /** @type {{ start: number, end: number } | undefined} */ let boldSpan; for (let index = 0; index < input.length; index++) { const token = input[index]; if (isSubstitution(token)) return; if (sizeEnd >= 0) continue; if (token[0] === TokenType.Whitespace || token[0] === TokenType.Comment) continue; // Locating the family from a dimension that is neither a font-size nor // an oblique angle would guess at its position. if (isUnknownDimension(token)) return; const name = token[0] === TokenType.Ident ? asciiLowerCase(decoded(token)) : ''; if (token[0] === TokenType.Ident && isPrefixKeyword(name)) { if (name === 'bold') { if (boldSpan) return; boldSpan = { start: token[2], end: tokenEnd(token) }; } possibleFamilyStart = input[skipTrivia(input, index + 1)]?.[2] ?? -1; } else if (isNumericWeight(token)) { possibleFamilyStart = input[skipTrivia(input, index + 1)]?.[2] ?? -1; } else if (isFontSize(token, name)) { sizeEnd = balanced.endForOpening(index) ?? index; } } return { sizeEnd, possibleFamilyStart, boldSpan }; } /** * Skip an optional `/ line-height` after the font-size. * * @param {NonNullable<ReturnType<typeof balancedTokens>>} balanced * @param {number} sizeEnd * @param {number} length */ function familyStartAfterSize(balanced, sizeEnd, length) { const input = balanced.tokens; let next = skipTrivia(input, sizeEnd + 1); if (input[next]?.[0] === TokenType.Delim && input[next][1] === '/') { next = skipTrivia(input, next + 1); if (input[next]) next = (balanced.endForOpening(next) ?? next) + 1; next = skipTrivia(input, next); } return input[next]?.[2] ?? length; } /** @param {string} value @param {import('../index.js').Options} opts @return {string} */ export default function minifyFont( value, opts, removeQuotes = opts.removeQuotes ) { const balanced = balancedTokens(value); if (!balanced) return value; const scan = scanFontPrefix(balanced); if (!scan) return value; const { sizeEnd, possibleFamilyStart, boldSpan } = scan; const familyStart = sizeEnd >= 0 ? familyStartAfterSize(balanced, sizeEnd, value.length) : possibleFamilyStart; if (familyStart < 0) return value; let prefix = boldSpan ? value.slice(0, boldSpan.start) + '700' + value.slice(boldSpan.end, familyStart) : value.slice(0, familyStart); if (familyStart < value.length && !prefix.endsWith(' ') && prefix) prefix += ' '; return prefix + minifyFamily(value.slice(familyStart), opts, removeQuotes); }