cssnano
Version:
A modular CSS minifier, built on top of the PostCSS ecosystem.
148 lines (134 loc) • 5.3 kB
JavaScript
import cssnanoUtils from '#utils';
const { TokenType, asciiLowerCase, decoded } = cssnanoUtils;
/** @import {CSSToken} from '@csstools/css-tokenizer' */
/** @type {(source: string) => {tokens: readonly CSSToken[], endForOpening(index: number): number | undefined, topLevelSegments(start: number, end: number): {startIndex: number, endIndex: number}[]} | undefined} */
const getBalancedTokens = cssnanoUtils.balancedTokens;
const animationTransitionRegex =
/^(?:-[A-Za-z0-9_]+-)?(?:[aA][nN][iI][mM][aA][tT][iI][oO][nN]|[tT][rR][aA][nN][sS][iI][tT][iI][oO][nN])(?:-[tT][iI][mM][iI][nN][gG]-[fF][uU][nN][cC][tT][iI][oO][nN])?$/v;
/* Works because toString() normalizes the formatting,
so comparing the string forms behaves the same as number equality*/
const conversions = new Map([
[[0.25, 0.1, 0.25, 1].toString(), 'ease'],
[[0, 0, 1, 1].toString(), 'linear'],
[[0.42, 0, 1, 1].toString(), 'ease-in'],
[[0, 0, 0.58, 1].toString(), 'ease-out'],
[[0.42, 0, 0.58, 1].toString(), 'ease-in-out'],
]);
/** @param {readonly CSSToken[]} input @param {{startIndex: number, endIndex: number}} segment @return {CSSToken | undefined} */
function singleToken(input, segment) {
/** @type {CSSToken | undefined} */
let result;
for (let index = segment.startIndex; index < segment.endIndex; index++) {
const token = input[index];
if (token[0] === TokenType.Whitespace || token[0] === TokenType.Comment)
continue;
if (result) return;
result = token;
}
return result;
}
/** @param {string} value @param {CSSToken} token @return {string} */
function tokenSource(value, token) {
return value.slice(token[2], token[3] + 1);
}
/** @param {string} value @param {readonly CSSToken[]} input @param {NonNullable<ReturnType<typeof getBalancedTokens>>} structure @param {number} index @param {number} end @return {string | null} */
function reduceFunction(value, input, structure, index, end) {
const name = asciiLowerCase(decoded(input[index]));
const segments = structure.topLevelSegments(index + 1, end);
if (name === 'cubic-bezier' && segments.length === 4) {
const values = segments.map((segment) => {
const token = singleToken(input, segment);
return token?.[0] === TokenType.Number
? /** @type {{value: number}} */ (token[4]).value
: Number.NaN;
});
return values.every((number) => !Number.isNaN(number))
? (conversions.get(values.toString()) ?? null)
: null;
}
if (name !== 'steps' || segments.length !== 2) return null;
const count = singleToken(input, segments[0]);
if (
!count ||
count[0] !== TokenType.Number ||
!Number.isInteger(/** @type {{value: number}} */ (count[4]).value) ||
/** @type {{value: number}} */ (count[4]).value < 1
)
return null;
const position = singleToken(input, segments[1]);
if (!position || position[0] !== TokenType.Ident) return null;
const positionName = asciiLowerCase(decoded(position));
const countValue = /** @type {{value: number}} */ (count[4]).value;
if (countValue === 1) {
if (positionName === 'start' || positionName === 'jump-start')
return 'step-start';
if (positionName === 'end' || positionName === 'jump-end')
return 'step-end';
}
return positionName === 'end' || positionName === 'jump-end'
? `steps(${tokenSource(value, count)})`
: null;
}
/**
* @param {string} value
* @return {string}
*/
function transform(value) {
const structure = getBalancedTokens(value);
if (!structure) return value;
const { tokens: input } = structure;
/** @type {{start:number,end:number,text:string}[]} */
const edits = [];
for (let index = 0; index < input.length; index++) {
if (input[index][0] !== TokenType.Function) continue;
const end = structure.endForOpening(index);
if (end === undefined) continue;
const replacement = reduceFunction(value, input, structure, index, end);
if (replacement)
edits.push({
start: input[index][2],
end: input[end][3] + 1,
text: replacement,
});
}
let result = value;
for (const edit of edits.toSorted((a, b) => b.start - a.start))
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
return result;
}
/**
* @return {import('postcss').Plugin}
*/
function pluginCreator() {
return {
postcssPlugin: 'postcss-normalize-timing-functions',
/**
* @param {import('postcss').Root} css
*/
OnceExit(css) {
const cache = new Map();
css.walkDecls(animationTransitionRegex, (decl) => {
const value =
decl.raws.value?.value === decl.value
? (decl.raws.value.raw ?? decl.value)
: decl.value;
if (cache.has(value)) {
assignValue(decl, cache.get(value));
return;
}
const result = transform(value);
assignValue(decl, result);
cache.set(value, result);
});
},
};
}
/** @param {import('postcss').Declaration} decl @param {string} value */
function assignValue(decl, value) {
decl.value = value;
if (decl.raws.value?.raw) decl.raws.value = { raw: value, value };
}
/** @type {true} */
pluginCreator.postcss = true;
const moduleExports = pluginCreator;
export { moduleExports as default, moduleExports as 'module.exports' };