postcss-colormin
Version:
Minify colors in your CSS files with PostCSS.
241 lines (221 loc) • 7.11 kB
JavaScript
import getBrowsersList from '#getBrowsersList';
import caniuseApi from 'caniuse-api';
import { tryMinifyColor } from './minifyColor.js';
import cssnanoUtils from 'cssnano-utils';
import colorPropertiesData from './data/colorProperties.json' with { type: 'json' };
/** @import {CSSToken} from '@csstools/css-tokenizer' */
const { isSupported } = caniuseApi;
const {
applyEdits,
asciiLowerCase,
balancedTokens,
decoded,
mathFunctions,
TokenType,
tokenEnd,
tokenStart,
} = cssnanoUtils;
/** @import browserslist from 'browserslist' */
const colorProperties = new Set(colorPropertiesData);
const colorFunctionRegex = /^(?:rgb|hsl)a?$|^hwb$/v;
/*
* IE 8 & 9 do not properly handle clicks on elements
* with a `transparent` `background-color`.
*
* https://developer.mozilla.org/en-US/docs/Web/Events/click#Internet_Explorer
*/
const browsersWithTransparentBug = new Set(['ie 8', 'ie 9']);
const tokensRequiringSeparator = new Set([
TokenType.Ident,
TokenType.Function,
TokenType.URL,
TokenType.BadURL,
TokenType.Hash,
TokenType.Number,
TokenType.Dimension,
TokenType.Percentage,
]);
/**
* Fast-path check for valid CSS hex colors (CSS Color 4 § 5.2):
* Must be 3, 4, 6, or 8 hexadecimal digits.
* @param {string} value
* @return {boolean}
*/
function isHexColor(value) {
const len = value.length;
if (len !== 3 && len !== 4 && len !== 6 && len !== 8) return false;
for (let i = 0; i < len; i++) {
const code = value.charCodeAt(i);
if (
!(code >= 48 && code <= 57) &&
!(code >= 65 && code <= 70) &&
!(code >= 97 && code <= 102)
) {
return false;
}
}
return true;
}
/** @param {string} value @param {Options} options @return {string} */
function transform(value, options) {
const structure = balancedTokens(value);
if (!structure) {
return value;
}
const input = structure.tokens;
/** @type {{start:number,end:number,text:string}[]} */
const replacements = [];
/** @param {number} end @param {number} index */
function separator(end, index) {
const next = input[index + 1];
return next &&
tokensRequiringSeparator.has(next[0]) &&
end === tokenStart(next)
? ' '
: '';
}
for (let i = 0; i < input.length; i++) {
const t = input[i];
if (t[0] === TokenType.Function) {
const name = asciiLowerCase(decoded(t));
const closeIndex = structure.endForOpening(i);
if (closeIndex === undefined) {
continue;
}
if (name === 'url' || mathFunctions.has(name)) {
i = closeIndex;
continue;
}
if (colorFunctionRegex.test(name)) {
const closeToken = input[closeIndex];
const raw = value.slice(tokenStart(t), tokenEnd(closeToken));
const inputForMinify =
name + '(' + value.slice(tokenEnd(t), tokenEnd(closeToken));
const out = tryMinifyColor(inputForMinify, options);
if (out !== undefined && out !== raw) {
replacements.push({
start: tokenStart(t),
end: tokenEnd(closeToken),
text: out + separator(tokenEnd(closeToken), closeIndex),
});
}
i = closeIndex;
continue;
}
} else if (t[0] === TokenType.Ident || t[0] === TokenType.Hash) {
const dec = decoded(t);
const isHash = t[0] === TokenType.Hash;
if (isHash ? !isHexColor(dec) : dec.startsWith('#')) continue;
const candidate = isHash ? '#' + dec : dec;
const out = tryMinifyColor(candidate, options);
if (out !== undefined && out !== t[1] && out.length <= t[1].length) {
replacements.push({
start: tokenStart(t),
end: tokenEnd(t),
text: out + separator(tokenEnd(t), i),
});
}
}
}
return applyEdits(value, replacements);
}
/**
* @param {Options} options
* @param {string[]} browsers
* @return {Options}
*/
function addPluginDefaults(options, browsers) {
const defaults = {
// Does the browser support "transparent" value properly
transparent: new Set(browsers).isDisjointFrom(browsersWithTransparentBug),
// Does the browser support 4 & 8 character hex notation
alphaHex: isSupported('css-rrggbbaa', browsers),
name: true,
};
return { ...defaults, ...options };
}
/**
* @typedef {object} MinifyColorOptions
* @property {boolean} [hex]
* @property {boolean} [alphaHex]
* @property {boolean} [rgb]
* @property {boolean} [hsl]
* @property {boolean} [name]
* @property {boolean} [transparent]
* @property {boolean} [transformCustomProperties] Whether to minify colors inside custom property values (default: true)
*/
/**
* @typedef {{ overrideBrowserslist?: string | string[] }} AutoprefixerOptions
* @typedef {Pick<browserslist.Options, 'stats' | 'path' | 'env'>} BrowserslistOptions
* @typedef {MinifyColorOptions & AutoprefixerOptions & BrowserslistOptions} Options
*/
/**
* @param {Options} config
* @return {import('postcss').Plugin}
*/
function pluginCreator(config = {}) {
return {
postcssPlugin: 'postcss-colormin',
/**
* @param {import('postcss').Result & {opts: BrowserslistOptions & {file?: string}}} result
*/
prepare(result) {
const { stats, env, from, file } = result.opts || {};
const browsers = getBrowsersList(config, stats, from, file, env);
const cache = new Map();
const options = addPluginDefaults(config, browsers);
return {
/**
* @param {import('postcss').Root} css
*/
OnceExit(css) {
css.walkDecls((decl) => {
if (!decl.prop) {
return;
}
const lowerProp = asciiLowerCase(decl.prop);
if (lowerProp.startsWith('--')) {
if (
/** @type Options */ (config).transformCustomProperties ===
false
) {
return;
}
} else {
const unprefixed = lowerProp.replace(/^-\w+-/v, '');
if (
(!colorProperties.has(lowerProp) &&
!colorProperties.has(unprefixed)) ||
lowerProp === '-webkit-tap-highlight-color'
) {
return;
}
}
const rawValue =
decl.raws.value?.value === decl.value
? (decl.raws.value.raw ?? decl.value)
: decl.value;
if (!rawValue) {
return;
}
let newValue;
if (cache.has(rawValue)) {
newValue = cache.get(rawValue);
} else {
newValue = transform(rawValue, options);
cache.set(rawValue, newValue);
}
decl.value = newValue;
if (decl.raws.value?.raw) {
decl.raws.value = { raw: newValue, value: newValue };
}
});
},
};
},
};
}
/** @type {true} */
pluginCreator.postcss = true;
const moduleExports = pluginCreator;
export { moduleExports as default, moduleExports as 'module.exports' };