Skip to content

Commit

Permalink
feat(postcss-colormin): Output 4 and 8 digit hex colors (#1109)
Browse files Browse the repository at this point in the history
* Output 4 and 8 digit hex colors

* Check browser support by caniuse-api

* Update specs

* Update postcss-colormin specs

* Tweak integration tests

* Rename colours to minifyColor

* Deduplicate packages

* Fix `minifyColor` import

* Rebase

* Fix lock file

* Fix linter error

* Improve resulting alpha precition

* Lossless conversion
  • Loading branch information
Vlad Shilov committed May 28, 2021
1 parent f2a8a1a commit 3168399
Show file tree
Hide file tree
Showing 15 changed files with 215 additions and 78 deletions.
3 changes: 3 additions & 0 deletions package.json
Expand Up @@ -79,6 +79,9 @@
"chrome58": [
"Chrome 58"
],
"chrome62": [
"Chrome 62"
],
"edge15": [
"Edge 15"
],
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/postcss-colormin/package.json
Expand Up @@ -31,7 +31,8 @@
"repository": "cssnano/cssnano",
"dependencies": {
"browserslist": "^4.16.6",
"colord": "^2.0.0",
"caniuse-api": "^3.0.0",
"colord": "^2.0.1",
"postcss-value-parser": "^4.1.0"
},
"bugs": {
Expand Down
20 changes: 17 additions & 3 deletions packages/postcss-colormin/src/__tests__/index.js
Expand Up @@ -204,7 +204,7 @@ test(
test(
'should not mangle percentage based rgba values',
processCSS(
'h1{color:rgba(50%,50%,50%,0.5)}',
'h1{color:rgba(50%, 50%, 50%, 0.5)}',
'h1{color:hsla(0, 0%, 50%, 0.5)}'
)
);
Expand All @@ -223,15 +223,15 @@ test(
'should add extra spaces when converting rgb',
processCSS(
'h1{background:linear-gradient(rgb(50, 50, 50)0%,blue 100%)}',
'h1{background:linear-gradient(#323232 0%,#00f 100%)}'
'h1{background:linear-gradient(#323232 0%,blue 100%)}'
)
);

test(
'should add extra spaces when converting rgb (2)',
processCSS(
'h1{background:linear-gradient(rgba(0,0,0,0)0%, blue 100%)}',
'h1{background:linear-gradient(transparent 0%, #00f 100%)}'
'h1{background:linear-gradient(transparent 0%, blue 100%)}'
)
);

Expand Down Expand Up @@ -321,3 +321,17 @@ test(
'should respect CSS variables',
passthroughCSS('div{background-color:rgba(51,153,255,var(--tw-bg-opacity))}')
);

test(
'should convert long color to 8-digit hex when supported',
processCSS('h1{color:rgba(100% 50% 0% / 50%)}', 'h1{color:#ff800080}', {
env: 'chrome62',
})
);

test(
'should convert long color to 4-digit hex when supported',
processCSS('h1{color:hsla(0 100% 50% / 40%)}', 'h1{color:#f006}', {
env: 'chrome62',
})
);
@@ -1,4 +1,4 @@
import min from '../colours';
import min from '../minifyColor';

function isEqual(input, output) {
return () => expect(min(input)).toBe(output);
Expand All @@ -20,7 +20,7 @@ test(
test('should convert hsl to keyword', isEqual('hsl(0, 100%, 50%)', 'red'));

test(
'should convert fully oqaque hsl to keyword',
'should convert fully opaque hsl to keyword',
isEqual('hsla(0, 100%, 50%, 1)', 'red')
);

Expand Down Expand Up @@ -94,7 +94,7 @@ test(

test(
'should convert signed numbers (2)',
isEqual('hsla(-400,50%,10%,.5)', 'rgba(38, 13, 30, 0.5)')
isEqual('hsla(-400, 50%, 10%, 0.5)', 'rgba(38, 13, 30, 0.5)')
);

test(
Expand All @@ -104,7 +104,7 @@ test(

test(
'should convert percentage based rgba values (2)',
isEqual('rgba(50%,50%,50%,0.5)', 'hsla(0, 0%, 50%, 0.5)')
isEqual('rgba(50%, 50%, 50%, 0.5)', 'hsla(0, 0%, 50%, 0.5)')
);

test(
Expand All @@ -119,7 +119,7 @@ test(

test(
'should convert percentage based rgba values (5)',
isEqual('rgba(100%,64.7%,0%,.5)', 'rgba(255, 165, 0, 0.5)')
isEqual('rgba(100%, 64.7%, 0%, .5)', 'rgba(255, 165, 0, 0.5)')
);

test(
Expand All @@ -144,3 +144,28 @@ test('should pass through if not recognised', () => {
expect(min('Unrecognised')).toBe('Unrecognised');
expect(min('inherit')).toBe('inherit');
});

test('should convert to hex4', () => {
expect(min('#aabbcc33', { supportsAlphaHex: true })).toBe('#abc3');
expect(min('transparent', { supportsAlphaHex: true })).toBe('#0000');
expect(min('rgb(119,119,119,0.2)', { supportsAlphaHex: true })).toBe('#7773');
expect(min('hsla(0,0%,100%,.4)', { supportsAlphaHex: true })).toBe('#fff6');
});

test('should convert to hex8', () => {
expect(min('rgba(128, 128, 128, 0.5)', { supportsAlphaHex: true })).toBe(
'#80808080'
);
expect(min('hsla(180, 100%, 50%, 0.5)', { supportsAlphaHex: true })).toBe(
'#00ffff80'
);
});

test('should not convert to alpha hex since the conversion is not lossless', () => {
expect(min('rgba(0, 0, 0, 0.075)', { supportsAlphaHex: true })).toBe(
'rgba(0, 0, 0, 0.075)'
);
expect(min('hsla(0, 0%, 50%, 0.515)', { supportsAlphaHex: true })).toBe(
'hsla(0, 0%, 50%, 0.515)'
);
});
45 changes: 0 additions & 45 deletions packages/postcss-colormin/src/colours.js

This file was deleted.

20 changes: 13 additions & 7 deletions packages/postcss-colormin/src/index.js
@@ -1,6 +1,7 @@
import browserslist from 'browserslist';
import { isSupported } from 'caniuse-api';
import valueParser, { stringify } from 'postcss-value-parser';
import colormin from './colours';
import minifyColor from './minifyColor';

function walk(parent, callback) {
parent.nodes.forEach((node, index) => {
Expand Down Expand Up @@ -31,15 +32,15 @@ function isMathFunctionNode(node) {
return ['calc', 'min', 'max', 'clamp'].includes(node.value.toLowerCase());
}

function transform(value, isLegacy) {
function transform(value, options) {
const parsed = valueParser(value);

walk(parsed, (node, index, parent) => {
if (node.type === 'function') {
if (/^(rgb|hsl)a?$/i.test(node.value)) {
const { value: originalValue } = node;

node.value = colormin(stringify(node), isLegacy);
node.value = minifyColor(stringify(node), options);
node.type = 'word';

const next = parent.nodes[index + 1];
Expand All @@ -58,7 +59,7 @@ function transform(value, isLegacy) {
return false;
}
} else if (node.type === 'word') {
node.value = colormin(node.value, isLegacy);
node.value = minifyColor(node.value, options);
}
});

Expand All @@ -76,7 +77,12 @@ function pluginCreator() {
path: __dirname,
env: resultOpts.env,
});
const isLegacy = browsers.some(hasTransparentBug);

const options = {
supportsTransparent: browsers.some(hasTransparentBug) === false,
supportsAlphaHex: isSupported('css-rrggbbaa', browsers),
};

const cache = {};

return {
Expand All @@ -96,15 +102,15 @@ function pluginCreator() {
return;
}

const cacheKey = value + '|' + isLegacy;
const cacheKey = JSON.stringify({ value, options, browsers });

if (cache[cacheKey]) {
decl.value = cache[cacheKey];

return;
}

const newValue = transform(value, isLegacy);
const newValue = transform(value, options);

decl.value = newValue;
cache[cacheKey] = newValue;
Expand Down
73 changes: 73 additions & 0 deletions packages/postcss-colormin/src/lib/color.js
@@ -0,0 +1,73 @@
import { colord, extend, getFormat } from 'colord';
import namesPlugin from 'colord/plugins/names';
import getShortestString from './getShortestString';

let minifierPlugin = (Colord) => {
/**
* Shortens a color to 3 or 4 digit hexadecimal string if it's possible.
* Returns the original (6 or 8 digit) hex if the it can't be shortened.
*/
Colord.prototype.toShortHex = function ({ formatAlpha }) {
let hex = this.toHex();
let [, r1, r2, g1, g2, b1, b2, a1, a2] = hex.split('');

// Check if the string can be shorten
if (r1 === r2 && g1 === g2 && b1 === b2) {
if (this.alpha() === 1) {
// Express as 3 digit hexadecimal string if the color doesn't have an alpha channel
return '#' + r1 + g1 + b1;
} else if (formatAlpha && a1 === a2) {
// Format 4 digit hex
return '#' + r1 + g1 + b1 + a1;
}
}

return hex;
};

/**
* Returns the shortest representation of a color.
*/
Colord.prototype.toShortString = function ({
supportsTransparent,
supportsAlphaHex,
}) {
let { r, g, b } = this.toRgb();
let a = this.alpha();

// RGB[A] and HSL[A] functional notations
let options = [
this.toRgbString(), // e.g. "rgb(128, 128, 128)" or "rgba(128, 128, 128, 0.5)"
this.toHslString(), // e.g. "hsl(180, 50%, 50%)" or "hsla(180, 50%, 50%, 0.5)"
];

// Hexadecimal notations
if (supportsAlphaHex && a < 1) {
let alphaHex = this.toShortHex({ formatAlpha: true }); // e.g. "#7777" or "#80808080"

// Output 4 or 8 digit hex only if the color conversion is lossless
if (colord(alphaHex).alpha() === a) {
options.push(alphaHex);
}
} else if (a === 1) {
options.push(this.toShortHex({ formatAlpha: false })); // e.g. "#777" or "#808080"
}

// CSS keyword
if (supportsTransparent && r === 0 && g === 0 && b === 0 && a === 0) {
options.push('transparent');
} else if (a === 1) {
let name = this.toName(); // e.g. "gray"
if (name) {
options.push(name);
}
}

// Find the shortest option available
return getShortestString(options);
};
};

extend([namesPlugin, minifierPlugin]);

export { colord as process, getFormat };
16 changes: 16 additions & 0 deletions packages/postcss-colormin/src/lib/getShortestString.js
@@ -0,0 +1,16 @@
/**
* Returns the shortest string in array
*/
const getShortestString = (strings) => {
let shortest = null;

for (let string of strings) {
if (shortest === null || string.length < shortest.length) {
shortest = string;
}
}

return shortest;
};

export default getShortestString;
7 changes: 0 additions & 7 deletions packages/postcss-colormin/src/lib/toShorthand.js

This file was deleted.

31 changes: 31 additions & 0 deletions packages/postcss-colormin/src/minifyColor.js
@@ -0,0 +1,31 @@
import { process } from './lib/color';
import getShortestString from './lib/getShortestString';

/**
* Performs color value minification
*
* @param {string} input - CSS value
* @param {boolean} options.supportsAlphaHex - Does the browser support 4 & 8 character hex notation
* @param {boolean} options.supportsTransparent – Does the browser support "transparent" value properly
*/
export default function minifyColor(input, options = {}) {
const settings = {
supportsAlphaHex: false,
supportsTransparent: true,
...options,
};

const instance = process(input);

if (instance.isValid()) {
// Try to shorten the string if it is a valid CSS color value.
// Fall back to the original input if it's smaller or has equal length/
return getShortestString([
input.toLowerCase(),
instance.toShortString(settings),
]);
} else {
// Possibly malformed, so pass through
return input;
}
}

0 comments on commit 3168399

Please sign in to comment.