UNPKG

cssnano

Version:

A modular CSS minifier, built on top of the PostCSS ecosystem.

374 lines (339 loc) • 11.7 kB
import cssnanoUtils from '#utils'; import { tokenize, TokenType } from '@csstools/css-tokenizer'; const { asciiLowerCase, decoded, endsWithEscapingBackslash, mathFunctions, tokens: tokenizeValue, } = cssnanoUtils; const atrule = 'atrule'; const decl = 'decl'; const rule = 'rule'; const gridRowsPropertyRegex = /^(?:grid|grid-template|grid-template-areas)$/iv; const variableFunctions = new Set(['var', 'env', 'constant']); const ieHackRegex = /[ \t\n\r\f]*(\\9)[ \t\n\r\f]*/v; const whitespaceRegex = /[ \t\n\r\f]/gv; const plainSeparatorRegex = /^[ \t\n\r\f]*:[ \t\n\r\f]*$/v; /** @param {import('@csstools/css-tokenizer').TokenType} type */ function isOpeningToken(type) { return ( type === TokenType.OpenParen || type === TokenType.OpenSquare || type === TokenType.OpenCurly ); } /** @param {import('@csstools/css-tokenizer').TokenType} type */ function isClosingToken(type) { return ( type === TokenType.CloseParen || type === TokenType.CloseSquare || type === TokenType.CloseCurly ); } /** @param {import('@csstools/css-tokenizer').CSSToken | undefined} token */ function isComma(token) { return token?.[0] === TokenType.Comma; } /** @param {import('@csstools/css-tokenizer').CSSToken | undefined} token */ function isSlash(token) { return token?.[0] === TokenType.Delim && token[1] === '/'; } /** @param {import('@csstools/css-tokenizer').CSSToken | undefined} previous @param {import('@csstools/css-tokenizer').CSSToken | undefined} next */ function removesWhitespace(previous, next) { return ( previous?.[0] === TokenType.Function || previous?.[0] === TokenType.OpenParen || next?.[0] === TokenType.CloseParen ); } /** * @param {import('@csstools/css-tokenizer').CSSToken[]} tokens * @param {number} index * @param {{ math?: boolean, variable?: boolean }[]} stack * @return {string} */ function whitespaceReplacement(tokens, index, stack) { const previous = tokens[index - 1]; const next = tokens[index + 1]; const context = stack[stack.length - 1]; if (previous && endsWithEscapingBackslash(previous[1])) return tokens[index][1]; const besideFunctionBoundary = removesWhitespace(previous, next); const besideComma = isComma(previous) || isComma(next); const besideSlash = !context?.math && (isSlash(previous) || isSlash(next)); const variableTrailingFallback = context?.variable && previous?.[0] === TokenType.Comma && next?.[0] === TokenType.CloseParen; const isBoundary = !previous || !next; return !variableTrailingFallback && (isBoundary || besideFunctionBoundary || besideComma || besideSlash) ? '' : ' '; } /** * @param {string} value * @param {[number, number, string][]} replacements * @return {string} */ function applyReplacements(value, replacements) { if (!replacements.length) return value; const pieces = []; let start = 0; for (const [from, to, replacement] of replacements) { if (from > start) pieces.push(value.slice(start, from)); pieces.push(replacement); start = to; } if (start < value.length) { pieces.push(value.slice(start)); } return pieces.join(''); } /** * Trim and collapse the spaces and tabs between the names of one grid row. * Rows with a backslash are kept as authored, because the whitespace after an * escape may belong to the escape or to the name. An unclosed string or a * whitespace-only row is also kept. * @param {string} raw * @return {string} */ function trimGridRow(raw) { const quote = raw[0]; if (raw.length < 2 || raw[raw.length - 1] !== quote || raw.includes('\\')) { return raw; } const names = raw .slice(1, -1) .split(/[ \t]+/v) .filter(Boolean); if (!names.length) return raw; return quote + names.join(' ') + quote; } /** * Normalize directly from source-backed tokenizer spans. The stack mirrors the * legacy walk: math descendants receive special delimiter treatment, while * variable functions trim whitespace around the name and comma delimiters. * * @param {string} value * @param {boolean} trimRows whether top-level strings are grid area rows * @return {string} */ function reduceWhitespaces(value, trimRows) { const tokens = [...tokenize({ css: value })].filter( (token) => token[0] !== TokenType.EOF ); /** @type {{ math?: boolean, variable?: boolean }[]} */ const stack = []; /** @type {[number, number, string][]} */ const replacements = []; for (let index = 0; index < tokens.length; index++) { const token = tokens[index]; const type = token[0]; if (type === TokenType.Function) { const name = asciiLowerCase(decoded(token)); const isVariable = variableFunctions.has(name); const parentContext = stack[stack.length - 1]; stack.push({ math: Boolean(parentContext?.math || mathFunctions.has(name)), variable: isVariable, }); continue; } if (isOpeningToken(type)) { const parentContext = stack[stack.length - 1]; stack.push({ math: Boolean(parentContext?.math), variable: Boolean(parentContext?.variable), }); continue; } if (isClosingToken(type)) { stack.pop(); continue; } if (trimRows && type === TokenType.String && stack.length === 0) { const row = trimGridRow(token[1]); if (row !== token[1]) { replacements.push([token[2], token[3] + 1, row]); } continue; } if (type !== TokenType.Whitespace) continue; const replacement = whitespaceReplacement(tokens, index, stack); if (replacement !== token[1]) { replacements.push([token[2], token[3] + 1, replacement]); } } return applyReplacements(value, replacements); } /** * Drop whole whitespace tokens; character-level replacement would reach * preserved comments. * @param {string} between * @return {string} */ function trimSeparator(between) { if (plainSeparatorRegex.test(between)) return ':'; return tokenizeValue(between) .filter(([type]) => type !== TokenType.Whitespace) .map(([, raw]) => raw) .join(''); } /** * Trim only the parser-consumed whitespace run around the colon (CSS * Variables 1). * @param {string} between * @return {string} */ function trimCustomPropertySeparator(between) { if (plainSeparatorRegex.test(between)) return ':'; const tokens = tokenizeValue(between).filter( ([type]) => type !== TokenType.EOF ); let start = 0; while (tokens[start]?.[0] === TokenType.Whitespace) start++; if (tokens[start]?.[0] === TokenType.Colon) { // The colon stays; only its surrounding parser-consumed whitespace runs // may go. start++; while (tokens[start]?.[0] === TokenType.Whitespace) start++; return ( ':' + tokens .slice(start) .map(([, raw]) => raw) .join('') ); } // A kept comment before the colon interrupts the parser-consumed run; // leave the authored separator untouched. return between; } /** * Collapses multi-character whitespace runs in at-rule afterName to a single * space while preserving comments. * @param {string} afterName * @return {string} */ function normalizeAfterName(afterName) { if (!afterName || afterName === ' ') return afterName; if (!afterName.includes('/*')) return ' '; let result = ''; for (const [type, raw] of tokenizeValue(afterName)) { result += type === TokenType.Whitespace ? ' ' : raw; } return result; } /** * Top-level strings in grid-template-areas, grid-template and grid are area * rows, so their values get a separate cache. * @param {import('postcss').Declaration} node * @param {Map<string, string>} cache * @param {Map<string, string>} gridRowsCache * @return {void} */ function trimDeclaration(node, cache, gridRowsCache) { // Ensure that !important values do not have any excess whitespace if (node.important) { node.raws.important = '!important'; } // Remove whitespaces around ie 9 hack const rawValue = node.raws.value; const hasMatchingRaw = Boolean( node.raws?.value?.raw && rawValue?.value === node.value ); const value = ( rawValue?.value === node.value ? rawValue.raw : node.value ).replace(ieHackRegex, '$1'); const trimRows = gridRowsPropertyRegex.test(node.prop); const valueCache = trimRows ? gridRowsCache : cache; let result; if (valueCache.has(value)) { result = /** @type {string} **/ (valueCache.get(value)); node.value = result; } else { result = reduceWhitespaces(value, trimRows); // Trim whitespace inside functions & dividers node.value = result; valueCache.set(value, result); } if (hasMatchingRaw) { node.raws.value = { raw: result, value: result }; } // Remove extra semicolons and whitespace before the declaration if (node.raws.before) { const prev = node.prev(); if (prev && prev.type !== rule) { node.raws.before = node.raws.before.replaceAll(';', ''); } } // The separator raw can also carry a preserved comment, which must survive. node.raws.between = trimSeparator(node.raws.between || ':'); node.raws.semicolon = false; } /** * @return {import('postcss').Plugin} */ function pluginCreator() { return { postcssPlugin: 'postcss-normalize-whitespace', /** * @param {import('postcss').Root} css */ OnceExit(css) { const declarationCache = new Map(); const gridRowsCache = new Map(); css.walk((node) => { const { type } = node; if ([decl, rule, atrule].includes(type) && node.raws.before) { node.raws.before = node.raws.before.replace(whitespaceRegex, ''); } if (type === decl) { if (!node.prop.startsWith('--')) { trimDeclaration(node, declarationCache, gridRowsCache); } else if (node.value.trim() !== '') { // Custom properties skip value normalization, so only the // parser-consumed separator run is trimmed. node.raws.between = trimCustomPropertySeparator( node.raws.between || ':' ); node.raws.semicolon = false; } } else if (type === rule || type === atrule) { // When the last declaration has no trailing semicolon and its // value ends in an escape sequence consuming whitespace (e.g. // `\9` written as `\` followed by a literal tab), the parser // attributes the escaped code point to the rule's trailing // raw instead of the declaration's value. Reattach the single // character the backslash escapes before discarding the rest // of that raw, or the escape is left dangling and becomes a // valid escape of whatever follows it in the output (`}`, or // even a `;` inserted as a terminator, since only a newline or // end of input is not a valid escape target). const last = node.last; if ( last && last.type === decl && endsWithEscapingBackslash(last.value) && node.raws.after ) { last.value += node.raws.after[0]; } node.raws.between = node.raws.after = ''; node.raws.semicolon = false; if (type === atrule && node.raws.afterName !== undefined) { node.raws.afterName = normalizeAfterName(node.raws.afterName); } } }); // Remove final newline css.raws.after = ''; }, }; } /** @type {true} */ pluginCreator.postcss = true; const moduleExports = pluginCreator; export { moduleExports as default, moduleExports as 'module.exports' };