@thejaredwilcurt/csslop 0.0.19 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/rules/selectors.js +71 -8
- package/src/value/color-mix.js +6 -0
- package/src/value/colors.js +15 -0
- package/src/value/minify.js +96 -0
- package/src/value/shared.js +31 -0
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@thejaredwilcurt/csslop",
|
|
3
3
|
"main": "index.js",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.20",
|
|
6
6
|
"description": "Experimental CSS minification",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"prestart": "node ./scripts/prestart.js",
|
|
@@ -26,22 +26,22 @@
|
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@codemirror/autocomplete": "^6.20.3",
|
|
28
28
|
"@codemirror/lang-css": "^6.3.1",
|
|
29
|
-
"@codemirror/view": "^6.43.
|
|
29
|
+
"@codemirror/view": "^6.43.8",
|
|
30
30
|
"@eslint/js": "^10.0.1",
|
|
31
31
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
32
32
|
"codemirror": "^6.0.2",
|
|
33
33
|
"css-minify-tests": "github:keithamus/css-minify-tests",
|
|
34
|
-
"eslint": "^10.8.
|
|
34
|
+
"eslint": "^10.8.1",
|
|
35
35
|
"eslint-config-tjw-base": "^5.0.0",
|
|
36
36
|
"eslint-config-tjw-import-x": "^1.0.1",
|
|
37
37
|
"eslint-config-tjw-jsdoc": "^2.0.1",
|
|
38
38
|
"eslint-plugin-import-x": "^4.17.0",
|
|
39
|
-
"eslint-plugin-jsdoc": "^
|
|
39
|
+
"eslint-plugin-jsdoc": "^64.0.1",
|
|
40
40
|
"fflate": "^0.8.3",
|
|
41
|
-
"globals": "^17.
|
|
41
|
+
"globals": "^17.9.0",
|
|
42
42
|
"pretty-ms": "^9.3.0",
|
|
43
43
|
"real-world-css-libraries": "^1.0.5",
|
|
44
|
-
"vite": "^8.2.
|
|
44
|
+
"vite": "^8.2.1"
|
|
45
45
|
},
|
|
46
46
|
"author": "The Jared Wilcurt",
|
|
47
47
|
"homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
|
package/src/rules/selectors.js
CHANGED
|
@@ -173,10 +173,77 @@ function mergeAdjacentWherePseudoClasses (selector) {
|
|
|
173
173
|
return result;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Matches a compound selector built exclusively from long-established simple
|
|
178
|
+
* selectors: an optional type or universal selector, followed by any number of
|
|
179
|
+
* id and class selectors. Anything else (pseudo-classes, pseudo-elements,
|
|
180
|
+
* attribute matchers, combinators, descendant sequences) is excluded, because
|
|
181
|
+
* those may be unrecognized by a browser and `:is()` forgiving parsing is what
|
|
182
|
+
* keeps the remaining selectors in the rule alive.
|
|
183
|
+
*
|
|
184
|
+
* @type {RegExp}
|
|
185
|
+
*/
|
|
186
|
+
const BROWSER_SAFE_COMPOUND_SELECTOR = /^(?:\*|[a-zA-Z][a-zA-Z0-9_-]*)?(?:[#.][a-zA-Z_-][a-zA-Z0-9_-]*)*$/;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Matches every id or class selector within a compound selector, used to count
|
|
190
|
+
* each one's specificity contribution.
|
|
191
|
+
*
|
|
192
|
+
* @type {RegExp}
|
|
193
|
+
*/
|
|
194
|
+
const ID_OR_CLASS_SELECTOR = /[#.][a-zA-Z_-][a-zA-Z0-9_-]*/g;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Computes the specificity of a compound selector known to consist only of
|
|
198
|
+
* type, universal, id, and class selectors, as an "ids,classes,types" key.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} compoundSelector A browser-safe compound selector.
|
|
201
|
+
* @return {string} The specificity key for equality comparison.
|
|
202
|
+
*/
|
|
203
|
+
function getSimpleCompoundSpecificityKey (compoundSelector) {
|
|
204
|
+
const idsAndClasses = compoundSelector.match(ID_OR_CLASS_SELECTOR) || [];
|
|
205
|
+
const identifierCount = idsAndClasses.filter((selector) => {
|
|
206
|
+
return selector.startsWith('#');
|
|
207
|
+
}).length;
|
|
208
|
+
const classCount = idsAndClasses.length - identifierCount;
|
|
209
|
+
// Whatever precedes the first id/class is the type or universal selector, if any
|
|
210
|
+
const typePortion = compoundSelector.split(/[#.]/)[0];
|
|
211
|
+
const typeCount = typePortion && typePortion !== '*' ? 1 : 0;
|
|
212
|
+
return identifierCount + ',' + classCount + ',' + typeCount;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Determines whether a `:is()` selector list can be decomposed into a plain
|
|
217
|
+
* comma-separated selector list. `:is()` applies the highest specificity of its
|
|
218
|
+
* arguments to every match, so decomposing is only equivalent when all
|
|
219
|
+
* arguments share one specificity. It also parses forgivingly, so every
|
|
220
|
+
* argument must additionally be a selector every browser understands.
|
|
221
|
+
*
|
|
222
|
+
* @param {Array} parts The selector strings inside the `:is()`.
|
|
223
|
+
* @return {boolean} True when the `:is()` wrapper can be dropped.
|
|
224
|
+
*/
|
|
225
|
+
function canDecomposeIsSelector (parts) {
|
|
226
|
+
if (parts.length < 2) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
const allBrowserSafe = parts.every((part) => {
|
|
230
|
+
return part !== '' && BROWSER_SAFE_COMPOUND_SELECTOR.test(part);
|
|
231
|
+
});
|
|
232
|
+
if (!allBrowserSafe) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const specificityKeys = parts.map((part) => {
|
|
236
|
+
return getSimpleCompoundSpecificityKey(part);
|
|
237
|
+
});
|
|
238
|
+
return specificityKeys.every((key) => {
|
|
239
|
+
return key === specificityKeys[0];
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
176
243
|
/**
|
|
177
244
|
* Processes a bare `:is()` selector by merging `:link`+`:visited` into `:any-link`,
|
|
178
|
-
* de-duplicating, sorting alphabetically, and
|
|
179
|
-
*
|
|
245
|
+
* de-duplicating, sorting alphabetically, and decomposing into individual selectors
|
|
246
|
+
* when the remaining parts are browser-safe and share one level of specificity.
|
|
180
247
|
*
|
|
181
248
|
* @param {string} selector A minified CSS selector string.
|
|
182
249
|
* @return {Array} An array of one or more processed selector strings.
|
|
@@ -223,7 +290,6 @@ function processIsSelector (selector) {
|
|
|
223
290
|
}
|
|
224
291
|
}
|
|
225
292
|
parts.push(currentPart);
|
|
226
|
-
const originalCount = parts.length;
|
|
227
293
|
// Replace :link + :visited with :any-link
|
|
228
294
|
const hasLink = parts.includes(':link');
|
|
229
295
|
const hasVisited = parts.includes(':visited');
|
|
@@ -243,11 +309,8 @@ function processIsSelector (selector) {
|
|
|
243
309
|
if (parts.length === 1) {
|
|
244
310
|
return parts;
|
|
245
311
|
}
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
return /^[a-z*][a-z0-9-]*$/i.test(part);
|
|
249
|
-
});
|
|
250
|
-
if (allSimple && parts.length === originalCount) {
|
|
312
|
+
// Drop the :is() wrapper when the parts are equivalent as a plain selector list
|
|
313
|
+
if (canDecomposeIsSelector(parts)) {
|
|
251
314
|
return parts;
|
|
252
315
|
}
|
|
253
316
|
return [':is(' + parts.join(',') + ')'];
|
package/src/value/color-mix.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
convertOklchToHex,
|
|
6
7
|
oklabToRgb,
|
|
7
8
|
parseColor,
|
|
8
9
|
rgbToOklab,
|
|
@@ -372,6 +373,11 @@ function evaluateColorMix (expr) {
|
|
|
372
373
|
const C = lch1.C * t1 + lch2.C * t2;
|
|
373
374
|
const H = interpolateHueShorter(lch1.H, lch2.H, t2);
|
|
374
375
|
const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
|
|
376
|
+
// In-gamut results have an exact sRGB equivalent, which is always shorter than oklch()
|
|
377
|
+
const hex = convertOklchToHex(L, C, H, alpha >= 1 ? 1 : alpha);
|
|
378
|
+
if (hex) {
|
|
379
|
+
return hex;
|
|
380
|
+
}
|
|
375
381
|
return formatOklch(L, C, H, alpha);
|
|
376
382
|
}
|
|
377
383
|
|
package/src/value/colors.js
CHANGED
|
@@ -508,6 +508,20 @@ function convertOklabToHex (L, a, b, alpha) {
|
|
|
508
508
|
return rgbaToHex(r, g, bl, alpha !== undefined ? alpha : 1);
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Convert a standalone oklch() value to hex if it fits in the sRGB gamut; returns null if out-of-gamut.
|
|
513
|
+
*
|
|
514
|
+
* @param {number} L The OKLCH lightness component, 0 to 1.
|
|
515
|
+
* @param {number} C The OKLCH chroma component.
|
|
516
|
+
* @param {number} H The OKLCH hue angle in degrees.
|
|
517
|
+
* @param {number} alpha The alpha value from 0 to 1.
|
|
518
|
+
* @return {string|null} A hex color string, or null if the color is outside the sRGB gamut.
|
|
519
|
+
*/
|
|
520
|
+
function convertOklchToHex (L, C, H, alpha) {
|
|
521
|
+
const lab = oklchToOklab(L, C, H);
|
|
522
|
+
return convertOklabToHex(lab.L, lab.a, lab.b, alpha);
|
|
523
|
+
}
|
|
524
|
+
|
|
511
525
|
export {
|
|
512
526
|
hslToRgbChannels,
|
|
513
527
|
rgbaToHex,
|
|
@@ -517,6 +531,7 @@ export {
|
|
|
517
531
|
parseHex,
|
|
518
532
|
convertLabToHex,
|
|
519
533
|
convertOklabToHex,
|
|
534
|
+
convertOklchToHex,
|
|
520
535
|
shortestColor,
|
|
521
536
|
srgbToOklab,
|
|
522
537
|
oklabToSrgb,
|
package/src/value/minify.js
CHANGED
|
@@ -9,6 +9,7 @@ import { evaluateColorMix } from './color-mix.js';
|
|
|
9
9
|
import {
|
|
10
10
|
convertLabToHex,
|
|
11
11
|
convertOklabToHex,
|
|
12
|
+
convertOklchToHex,
|
|
12
13
|
hslToRgbChannels,
|
|
13
14
|
hwbToRgbChannels,
|
|
14
15
|
parseHex,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
collapseShorthandParts,
|
|
32
33
|
normalizeScaleComponent,
|
|
33
34
|
parseAlphaString,
|
|
35
|
+
parseAngleToDegrees,
|
|
34
36
|
roundCompactNumber
|
|
35
37
|
} from './shared.js';
|
|
36
38
|
import { findMatchingParenthesis } from './syntax.js';
|
|
@@ -336,6 +338,97 @@ function normalizeWhitespaceAndQuotes (val, property) {
|
|
|
336
338
|
return val;
|
|
337
339
|
}
|
|
338
340
|
|
|
341
|
+
/**
|
|
342
|
+
* The OKLCH chroma value that `100%` resolves to, per CSS Color Level 4.
|
|
343
|
+
*
|
|
344
|
+
* @type {number}
|
|
345
|
+
*/
|
|
346
|
+
const OKLCH_CHROMA_PERCENT_REFERENCE = 0.4;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Regex matching an `oklch()` function with three space-separated components
|
|
350
|
+
* and an optional slash-delimited alpha. Lightness and chroma accept numbers
|
|
351
|
+
* or percentages, hue accepts a number with an optional CSS angle unit, and
|
|
352
|
+
* every component accepts the `none` keyword.
|
|
353
|
+
*
|
|
354
|
+
* @type {RegExp}
|
|
355
|
+
*/
|
|
356
|
+
const OKLCH_FUNCTION_PATTERN = new RegExp(
|
|
357
|
+
'\\boklch\\(\\s*' +
|
|
358
|
+
'(none|-?(?:\\d+|\\d*\\.\\d+)%?)\\s+' +
|
|
359
|
+
'(none|-?(?:\\d+|\\d*\\.\\d+)%?)\\s+' +
|
|
360
|
+
'(none|-?(?:\\d+|\\d*\\.\\d+)(?:deg|grad|rad|turn)?)' +
|
|
361
|
+
'(?:\\s*/\\s*(none|-?(?:\\d+|\\d*\\.\\d+)%?))?' +
|
|
362
|
+
'\\s*\\)',
|
|
363
|
+
'gi'
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Parses an OKLCH lightness or chroma component into its numeric value.
|
|
368
|
+
* Missing components (`none`) resolve to zero, and percentages are scaled
|
|
369
|
+
* against the reference value for that component.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} token The raw component token.
|
|
372
|
+
* @param {number} percentReference The value that `100%` represents for this component.
|
|
373
|
+
* @return {number|null} The numeric component value, or null when unparsable.
|
|
374
|
+
*/
|
|
375
|
+
function parseOklchComponent (token, percentReference) {
|
|
376
|
+
const normalized = token.trim().toLowerCase();
|
|
377
|
+
if (normalized === 'none') {
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
const numeric = parseFloat(normalized);
|
|
381
|
+
if (!Number.isFinite(numeric)) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
if (normalized.endsWith('%')) {
|
|
385
|
+
return numeric / 100 * percentReference;
|
|
386
|
+
}
|
|
387
|
+
return numeric;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Parses an OKLCH hue component into degrees, treating `none` as zero.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} token The raw hue token, optionally carrying an angle unit.
|
|
394
|
+
* @return {number|null} The hue in degrees, or null when unparsable.
|
|
395
|
+
*/
|
|
396
|
+
function parseOklchHue (token) {
|
|
397
|
+
const normalized = token.trim().toLowerCase();
|
|
398
|
+
if (normalized === 'none') {
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
return parseAngleToDegrees(normalized);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Converts `oklch()` colors that fall inside the sRGB gamut to their hex
|
|
406
|
+
* equivalent when that is shorter. Out-of-gamut colors have no sRGB
|
|
407
|
+
* representation, so they are left in their native color space.
|
|
408
|
+
*
|
|
409
|
+
* @param {string} value The CSS value string that may contain oklch() colors.
|
|
410
|
+
* @return {string} The value with in-gamut oklch() colors replaced by hex.
|
|
411
|
+
*/
|
|
412
|
+
function convertOklchFunctionsToHex (value) {
|
|
413
|
+
return value.replace(OKLCH_FUNCTION_PATTERN, (match, lightnessToken, chromaToken, hueToken, alphaToken) => {
|
|
414
|
+
const lightness = parseOklchComponent(lightnessToken, 1);
|
|
415
|
+
const chroma = parseOklchComponent(chromaToken, OKLCH_CHROMA_PERCENT_REFERENCE);
|
|
416
|
+
const hue = parseOklchHue(hueToken);
|
|
417
|
+
if (lightness === null || chroma === null || hue === null) {
|
|
418
|
+
return match;
|
|
419
|
+
}
|
|
420
|
+
const alpha = alphaToken?.trim().toLowerCase() === 'none' ? 0 : parseAlphaString(alphaToken);
|
|
421
|
+
const hex = convertOklchToHex(lightness, chroma, hue, alpha);
|
|
422
|
+
if (!hex) {
|
|
423
|
+
return match; // out-of-gamut: keep native oklch form
|
|
424
|
+
}
|
|
425
|
+
if (hex.length < match.length) {
|
|
426
|
+
return hex;
|
|
427
|
+
}
|
|
428
|
+
return match;
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
339
432
|
/**
|
|
340
433
|
* Converts CSS color functions (rgb, hsl, hwb, oklab, color-mix, etc.) to their
|
|
341
434
|
* shortest hex equivalents and applies hex shortening.
|
|
@@ -378,6 +471,9 @@ function convertColorsToHex (val) {
|
|
|
378
471
|
return match;
|
|
379
472
|
});
|
|
380
473
|
|
|
474
|
+
// Convert in-gamut oklch() to hex before precision rounding, so the full authored precision is used
|
|
475
|
+
val = convertOklchFunctionsToHex(val);
|
|
476
|
+
|
|
381
477
|
// Minify whitespace and numeric precision inside wide-gamut and functional color notations
|
|
382
478
|
val = val.replace(/\b(oklab|oklch|lch|lab|color|hwb)\((.*?)\)/gi, (match, func, inner) => {
|
|
383
479
|
// Collapse whitespace to single space
|
package/src/value/shared.js
CHANGED
|
@@ -113,6 +113,36 @@ function parseAlphaString (alphaStr, fallback = 1) {
|
|
|
113
113
|
return parseFloat(alphaStr);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Conversion factors from each CSS angle unit to degrees.
|
|
118
|
+
*
|
|
119
|
+
* @type {{[key: string]: number}}
|
|
120
|
+
*/
|
|
121
|
+
const ANGLE_UNIT_TO_DEGREES = {
|
|
122
|
+
deg: 1,
|
|
123
|
+
grad: 360 / 400,
|
|
124
|
+
rad: 180 / Math.PI,
|
|
125
|
+
turn: 360
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parses a CSS angle token (e.g. "90", "90deg", ".25turn") into degrees.
|
|
130
|
+
* Unitless values are treated as degrees, per the CSS Color specification's
|
|
131
|
+
* handling of hue components.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} angleToken The angle token, with or without a unit suffix.
|
|
134
|
+
* @return {number|null} The angle in degrees, or null if the token is not a valid angle.
|
|
135
|
+
*/
|
|
136
|
+
function parseAngleToDegrees (angleToken) {
|
|
137
|
+
// Capture the numeric portion and an optional CSS angle unit suffix
|
|
138
|
+
const match = String(angleToken).trim().match(/^(-?(?:\d+|\d*\.\d+))(deg|grad|rad|turn)?$/i);
|
|
139
|
+
if (!match) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const unit = match[2] ? match[2].toLowerCase() : 'deg';
|
|
143
|
+
return parseFloat(match[1]) * ANGLE_UNIT_TO_DEGREES[unit];
|
|
144
|
+
}
|
|
145
|
+
|
|
116
146
|
/**
|
|
117
147
|
* Collapses redundant CSS shorthand parts using the standard box-model
|
|
118
148
|
* reduction rules: 4-value → 3-value → 2-value → 1-value.
|
|
@@ -142,5 +172,6 @@ export {
|
|
|
142
172
|
formatDimension,
|
|
143
173
|
normalizeScaleComponent,
|
|
144
174
|
parseAlphaString,
|
|
175
|
+
parseAngleToDegrees,
|
|
145
176
|
roundCompactNumber
|
|
146
177
|
};
|