postcss-discard-comments
Version:
Discard comments in your CSS files with PostCSS.
267 lines (227 loc) • 6.72 kB
JavaScript
import { TokenType } from '@csstools/css-tokenizer';
import cssnanoUtils from 'cssnano-utils';
import {
commentContents,
getTokens,
joinsIntoDifferentTokens,
} from './tokenUtils.js';
const { asciiLowerCase, calcSumFunctions, decoded, mathFunctions } =
cssnanoUtils;
/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */
/** @typedef {import('./commentRemover.js').default} CommentRemover */
// Functions whose argument grammar is <calc-sum>, requiring whitespace
// around '+' and '-' operators.
const calcSumArgumentFunctions = new Set([
...mathFunctions.keys(),
...calcSumFunctions,
]);
// calc() syntax requires whitespace around these operators.
const mathOperators = new Set(['+', '-']);
/**
* Track entry into and exit from math function argument lists, whose
* <calc-sum> grammar requires whitespace around `+` and `-`.
*
* @param {boolean[]} parens one entry per open parenthesis: whether it opened a math function
* @param {CSSToken} token
* @return {number} change in math function nesting depth
*/
function mathDepthChange(parens, token) {
const type = token[0];
if (type === TokenType.Function) {
// Function tokens may spell names with escapes; the decoded value
// resolves both.
const isMathFunction = calcSumArgumentFunctions.has(
asciiLowerCase(decoded(token))
);
parens.push(isMathFunction);
return isMathFunction ? 1 : 0;
}
if (type === TokenType.OpenParen) {
// A bare parenthesis inherits the math context but owns no depth.
parens.push(false);
} else if (type === TokenType.CloseParen && parens.pop()) {
return -1;
}
return 0;
}
/**
* Reconstruct an ordinary value with comments removed or preserved,
* normalizing whitespace and math operator spacing.
*
* @param {string} source
* @param {CommentRemover} remover
* @param {Map<string, CSSToken[]>} parserCache
* @return {string}
*/
function normalizeValue(source, remover, parserCache) {
const tokens = getTokens(source, parserCache);
let result = '';
let pendingSpace = false;
let started = false;
let mathDepth = 0;
/** @type {boolean[]} */
const parens = [];
for (const token of tokens) {
const [type, raw] = token;
mathDepth += mathDepthChange(parens, token);
if (type === TokenType.EOF) {
continue;
}
if (type === TokenType.Whitespace) {
if (started) {
pendingSpace = true;
}
continue;
}
if (type === TokenType.Comment && remover.canRemove(commentContents(raw))) {
pendingSpace = started;
continue;
}
// A removed comment must not leave operands touching a math operator.
if (mathDepth > 0 && type === TokenType.Delim && mathOperators.has(raw)) {
if (started && !result.endsWith(' ') && !result.endsWith('(')) {
result += ' ';
}
result += raw;
pendingSpace = true;
started = true;
continue;
}
// Closing punctuation and commas absorb pending whitespace; openers
// suppress a leading gap.
if (
type === TokenType.CloseParen ||
type === TokenType.CloseSquare ||
type === TokenType.Comma
) {
pendingSpace = false;
}
if (
pendingSpace &&
started &&
!result.endsWith('(') &&
!result.endsWith('[')
) {
result += ' ';
}
pendingSpace = false;
result += raw;
started = true;
}
return result;
}
/**
* Reconstruct a value with comments removed or preserved. Kept comments
* pass through byte-exact; removal decisions consult the per-document
* remover.
*
* @param {string | undefined} rawSource
* @param {CommentRemover} remover
* @param {Map<string, CSSToken[]>} parserCache
* @param {string=} separator
* @param {boolean=} preserveWhitespace
* @return {string}
*/
export function replaceComments(
rawSource,
remover,
parserCache,
separator = ' ',
preserveWhitespace = false
) {
const source = rawSource || '';
if (!source.includes('/*')) {
return source;
}
if (preserveWhitespace) {
// Custom property values keep their whitespace byte-for-byte.
let preserved = '';
for (const [type, raw] of getTokens(source, parserCache)) {
if (type === TokenType.EOF) {
continue;
}
if (
type === TokenType.Comment &&
remover.canRemove(commentContents(raw))
) {
preserved += separator;
continue;
}
preserved += raw;
}
return preserved;
}
return normalizeValue(source, remover, parserCache);
}
/**
* Reconstruct a selector with comments removed or preserved. Whitespace
* runs collapse to a single space and trim at the edges; kept comments
* pass through byte-exact.
*
* @param {string | undefined} rawSource
* @param {CommentRemover} remover
* @param {Map<string, CSSToken[]>} parserCache
* @return {string}
*/
export function replaceCommentsInSelector(rawSource, remover, parserCache) {
const source = rawSource || '';
if (!source.includes('/*')) {
return source;
}
let result = '';
let pendingSpace = false;
let started = false;
let removedCommentBefore = false;
let lastRaw = '';
for (const [type, raw] of getTokens(source, parserCache)) {
if (type === TokenType.EOF) {
continue;
}
if (type === TokenType.Whitespace) {
if (started) {
pendingSpace = true;
}
continue;
}
if (type === TokenType.Comment) {
if (remover.canRemove(commentContents(raw))) {
removedCommentBefore = true;
continue;
}
if (pendingSpace && started) {
result += ' ';
}
pendingSpace = false;
result += raw;
started = true;
lastRaw = raw;
removedCommentBefore = false;
continue;
}
// Selector-parser drops whitespace immediately before a comma when a
// preceding comment is removed. Keep that punctuation normalization
// while leaving whitespace around combinators intact.
pendingSpace &&= !(removedCommentBefore && type === TokenType.Comma);
// A removed comment is not whitespace: where fusing its neighbors would
// change their tokens, an empty comment keeps the original token stream,
// whereas a space could add a descendant combinator.
let separator = '';
if (started) {
if (pendingSpace) {
separator = ' ';
} else if (
removedCommentBefore &&
joinsIntoDifferentTokens(lastRaw, raw, parserCache)
) {
separator = '/**/';
}
}
result += separator;
pendingSpace = false;
result += raw;
started = true;
lastRaw = raw;
removedCommentBefore = false;
}
return result;
}