@thejaredwilcurt/csslop 0.0.42 → 0.0.44

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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@thejaredwilcurt/csslop",
3
3
  "main": "index.js",
4
4
  "type": "module",
5
- "version": "0.0.42",
5
+ "version": "0.0.44",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/pre/start.js",
@@ -35,7 +35,7 @@
35
35
  "eslint-config-tjw-import-x": "^1.0.1",
36
36
  "eslint-config-tjw-jsdoc": "^2.0.1",
37
37
  "eslint-plugin-import-x": "^4.17.0",
38
- "eslint-plugin-jsdoc": "^65.3.8",
38
+ "eslint-plugin-jsdoc": "^65.3.11",
39
39
  "fflate": "^0.8.3",
40
40
  "globals": "^17.13.0",
41
41
  "pretty-ms": "^10.0.0",
@@ -57,31 +57,104 @@ function formatOklch (L, C, H, alpha) {
57
57
  }
58
58
 
59
59
  /**
60
- * Detect which channel indices have 'none' in a raw color function string.
60
+ * Splits the simple, non-nested components of a static color function.
61
61
  *
62
- * @param {string} rawColorStr The raw CSS color function string (e.g. "rgb(none 0 0)").
63
- * @return {Array} An array of zero-based channel indices where 'none' was found.
62
+ * @param {string} argumentText The text inside a color function's parentheses.
63
+ * @return {Array} The whitespace, comma, and slash-delimited components.
64
64
  */
65
- function findNoneChannels (rawColorStr) {
65
+ function splitSimpleColorComponents (argumentText) {
66
+ // Split CSS color components at whitespace, commas, or the alpha slash.
67
+ return argumentText.trim().split(/[\s,/]+/).filter((part) => {
68
+ return part.length > 0;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Collects the zero-based positions of missing color components.
74
+ *
75
+ * @param {Array} components The parsed color components.
76
+ * @param {number} startIndex The component index represented by the first item.
77
+ * @return {Array} The channel indices whose value is `none`.
78
+ */
79
+ function collectNoneChannelIndices (components, startIndex) {
66
80
  const indices = [];
67
- // Match rgb/rgba/hsl/hsla/hwb function calls and extract their arguments
68
- const functionMatch = rawColorStr.match(/\b(?:rgba?|hsla?|hwb)\(([^)]*)\)/i);
69
- if (functionMatch) {
70
- // Split arguments on whitespace, commas, or slash separators
71
- const parts = functionMatch[1].trim().split(/[\s,/]+/).map((part) => {
72
- return part.trim();
73
- }).filter((part) => {
74
- return part.length > 0;
75
- });
76
- parts.forEach((part, index) => {
77
- if (part.toLowerCase() === 'none') {
78
- indices.push(index);
79
- }
80
- });
81
+ for (let index = 0; index < components.length; index++) {
82
+ if (components[index].toLowerCase() === 'none') {
83
+ indices.push(startIndex + index);
84
+ }
81
85
  }
82
86
  return indices;
83
87
  }
84
88
 
89
+ /**
90
+ * Finds the first CSS whitespace character in a string.
91
+ *
92
+ * @param {string} text The text to inspect.
93
+ * @return {number} The first whitespace index, or -1 when there is none.
94
+ */
95
+ function findFirstWhitespaceIndex (text) {
96
+ for (let index = 0; index < text.length; index++) {
97
+ if (' \t\n\r\f'.includes(text[index])) {
98
+ return index;
99
+ }
100
+ }
101
+ return -1;
102
+ }
103
+
104
+ /**
105
+ * Detect which channel indices have `none` in a raw static color function.
106
+ *
107
+ * @param {string} rawColorString The raw CSS color function string.
108
+ * @return {Array} An array of zero-based channel indices where `none` was found.
109
+ */
110
+ function findNoneChannels (rawColorString) {
111
+ const openParenthesisIndex = rawColorString.indexOf('(');
112
+ const closeParenthesisIndex = rawColorString.lastIndexOf(')');
113
+ if (openParenthesisIndex === -1 || closeParenthesisIndex <= openParenthesisIndex) {
114
+ return [];
115
+ }
116
+
117
+ const functionName = rawColorString.slice(0, openParenthesisIndex).trim().toLowerCase();
118
+ const argumentText = rawColorString.slice(openParenthesisIndex + 1, closeParenthesisIndex).trim();
119
+ if (['rgb', 'rgba', 'hsl', 'hsla', 'hwb'].includes(functionName)) {
120
+ return collectNoneChannelIndices(splitSimpleColorComponents(argumentText), 0);
121
+ }
122
+
123
+ if (functionName !== 'color') {
124
+ return [];
125
+ }
126
+
127
+ const firstComponentEnd = findFirstWhitespaceIndex(argumentText);
128
+ if (firstComponentEnd === -1 || argumentText.slice(0, firstComponentEnd).toLowerCase() !== 'srgb') {
129
+ return [];
130
+ }
131
+ const channelText = argumentText.slice(firstComponentEnd).trim();
132
+ return collectNoneChannelIndices(splitSimpleColorComponents(channelText), 0);
133
+ }
134
+
135
+ /**
136
+ * Fills missing interpolation components from the matching component in the
137
+ * other color before either color is premultiplied by alpha.
138
+ *
139
+ * @param {object} firstColor The first parsed color-mix argument.
140
+ * @param {object} secondColor The second parsed color-mix argument.
141
+ * @return {void}
142
+ */
143
+ function fillMissingColorChannels (firstColor, secondColor) {
144
+ const firstMissingChannels = findNoneChannels(firstColor.raw);
145
+ const secondMissingChannels = findNoneChannels(secondColor.raw);
146
+ for (const channelIndex of firstMissingChannels) {
147
+ if (channelIndex < firstColor.color.length) {
148
+ firstColor.color[channelIndex] = secondColor.color[channelIndex];
149
+ }
150
+ }
151
+ for (const channelIndex of secondMissingChannels) {
152
+ if (channelIndex < secondColor.color.length) {
153
+ secondColor.color[channelIndex] = firstColor.color[channelIndex];
154
+ }
155
+ }
156
+ }
157
+
85
158
  /**
86
159
  * Evaluate an N-color (3+) color-mix() expression. Returns a minified CSS color string or null.
87
160
  *
@@ -208,17 +281,26 @@ function normalizeUnresolvableNColorMix (colorSpace, parsedArgs) {
208
281
  * @return {string} A hex color string.
209
282
  */
210
283
  function mixNColorsSrgb (colors, weights, alphaMultiplier) {
211
- let r = 0;
212
- let g = 0;
213
- let b = 0;
214
- let alpha = 0;
284
+ let premultipliedRed = 0;
285
+ let premultipliedGreen = 0;
286
+ let premultipliedBlue = 0;
287
+ let interpolatedAlpha = 0;
215
288
  for (let i = 0; i < colors.length; i++) {
216
- r += colors[i][0] * weights[i];
217
- g += colors[i][1] * weights[i];
218
- b += colors[i][2] * weights[i];
219
- alpha += colors[i][3] * weights[i];
289
+ const weightedAlpha = colors[i][3] * weights[i];
290
+ premultipliedRed += colors[i][0] * weightedAlpha;
291
+ premultipliedGreen += colors[i][1] * weightedAlpha;
292
+ premultipliedBlue += colors[i][2] * weightedAlpha;
293
+ interpolatedAlpha += weightedAlpha;
220
294
  }
221
- return rgbaToHex(Math.round(r), Math.round(g), Math.round(b), alpha * alphaMultiplier);
295
+ if (interpolatedAlpha === 0) {
296
+ return rgbaToHex(0, 0, 0, 0);
297
+ }
298
+ return rgbaToHex(
299
+ Math.round(premultipliedRed / interpolatedAlpha),
300
+ Math.round(premultipliedGreen / interpolatedAlpha),
301
+ Math.round(premultipliedBlue / interpolatedAlpha),
302
+ interpolatedAlpha * alphaMultiplier
303
+ );
222
304
  }
223
305
 
224
306
  /**
@@ -236,16 +318,28 @@ function mixNColorsOklab (colors, weights, alphaMultiplier) {
236
318
  let L = 0;
237
319
  let a = 0;
238
320
  let b = 0;
239
- let alpha = 0;
321
+ let interpolatedAlpha = 0;
240
322
  for (let i = 0; i < oklabValues.length; i++) {
241
- L += oklabValues[i].L * weights[i];
242
- a += oklabValues[i].a * weights[i];
243
- b += oklabValues[i].b * weights[i];
244
- alpha += colors[i][3] * weights[i];
245
- }
246
- alpha *= alphaMultiplier;
247
- const rgb = oklabToRgb(L, a, b);
248
- return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha >= 1 ? 1 : alpha);
323
+ const weightedAlpha = colors[i][3] * weights[i];
324
+ L += oklabValues[i].L * weightedAlpha;
325
+ a += oklabValues[i].a * weightedAlpha;
326
+ b += oklabValues[i].b * weightedAlpha;
327
+ interpolatedAlpha += weightedAlpha;
328
+ }
329
+ if (interpolatedAlpha === 0) {
330
+ return rgbaToHex(0, 0, 0, 0);
331
+ }
332
+ const rgb = oklabToRgb(
333
+ L / interpolatedAlpha,
334
+ a / interpolatedAlpha,
335
+ b / interpolatedAlpha
336
+ );
337
+ return rgbaToHex(
338
+ rgb[0],
339
+ rgb[1],
340
+ rgb[2],
341
+ interpolatedAlpha * alphaMultiplier
342
+ );
249
343
  }
250
344
 
251
345
  /**
@@ -329,19 +423,7 @@ function evaluateColorMix (expr) {
329
423
  return rgbaToHex(parsed1.color[0], parsed1.color[1], parsed1.color[2], parsed1.color[3]);
330
424
  }
331
425
 
332
- // CSS spec: 'none' channels are missing — fill from the other color before mixing
333
- const nones1 = findNoneChannels(parsed1.raw);
334
- const nones2 = findNoneChannels(parsed2.raw);
335
- for (const idx of nones1) {
336
- if (idx < parsed1.color.length) {
337
- parsed1.color[idx] = parsed2.color[idx];
338
- }
339
- }
340
- for (const idx of nones2) {
341
- if (idx < parsed2.color.length) {
342
- parsed2.color[idx] = parsed1.color[idx];
343
- }
344
- }
426
+ fillMissingColorChannels(parsed1, parsed2);
345
427
 
346
428
  const t1 = p1 / (p1 + p2);
347
429
  const t2 = p2 / (p1 + p2);
@@ -349,37 +431,44 @@ function evaluateColorMix (expr) {
349
431
  const [r2, g2, b2, a2] = parsed2.color;
350
432
 
351
433
  if (colorSpace === 'srgb') {
352
- const r = Math.round(r1 * t1 + r2 * t2);
353
- const g = Math.round(g1 * t1 + g2 * t2);
354
- const b = Math.round(b1 * t1 + b2 * t2);
355
- const a = (a1 * t1 + a2 * t2) * alphaMultiplier;
356
- return rgbaToHex(r, g, b, a);
434
+ return mixNColorsSrgb(
435
+ [parsed1.color, parsed2.color],
436
+ [t1, t2],
437
+ alphaMultiplier
438
+ );
357
439
  }
358
440
 
359
441
  if (colorSpace === 'oklab') {
360
442
  const lab1 = rgbToOklab(r1, g1, b1);
361
443
  const lab2 = rgbToOklab(r2, g2, b2);
362
- const L = lab1.L * t1 + lab2.L * t2;
363
- const a = lab1.a * t1 + lab2.a * t2;
364
- const b = lab1.b * t1 + lab2.b * t2;
365
- const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
366
- // Check if result fits in sRGB gamut
367
- const rgb = oklabToRgb(L, a, b);
368
- if (alpha >= 1) {
369
- return rgbaToHex(rgb[0], rgb[1], rgb[2], 1);
444
+ const firstWeightedAlpha = a1 * t1;
445
+ const secondWeightedAlpha = a2 * t2;
446
+ const interpolatedAlpha = firstWeightedAlpha + secondWeightedAlpha;
447
+ if (interpolatedAlpha === 0) {
448
+ return rgbaToHex(0, 0, 0, 0);
370
449
  }
371
- return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha);
450
+ const L = (lab1.L * firstWeightedAlpha + lab2.L * secondWeightedAlpha) / interpolatedAlpha;
451
+ const a = (lab1.a * firstWeightedAlpha + lab2.a * secondWeightedAlpha) / interpolatedAlpha;
452
+ const b = (lab1.b * firstWeightedAlpha + lab2.b * secondWeightedAlpha) / interpolatedAlpha;
453
+ const rgb = oklabToRgb(L, a, b);
454
+ return rgbaToHex(rgb[0], rgb[1], rgb[2], interpolatedAlpha * alphaMultiplier);
372
455
  }
373
456
 
374
457
  if (colorSpace === 'oklch') {
375
458
  const lch1 = rgbToOklch(r1, g1, b1);
376
459
  const lch2 = rgbToOklch(r2, g2, b2);
377
- const L = lch1.L * t1 + lch2.L * t2;
378
- const C = lch1.C * t1 + lch2.C * t2;
379
- const H = interpolateHueShorter(lch1.H, lch2.H, t2);
380
- const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
460
+ const firstWeightedAlpha = a1 * t1;
461
+ const secondWeightedAlpha = a2 * t2;
462
+ const interpolatedAlpha = firstWeightedAlpha + secondWeightedAlpha;
463
+ if (interpolatedAlpha === 0) {
464
+ return rgbaToHex(0, 0, 0, 0);
465
+ }
466
+ const L = (lch1.L * firstWeightedAlpha + lch2.L * secondWeightedAlpha) / interpolatedAlpha;
467
+ const C = (lch1.C * firstWeightedAlpha + lch2.C * secondWeightedAlpha) / interpolatedAlpha;
468
+ const H = interpolateHueShorter(lch1.H, lch2.H, secondWeightedAlpha / interpolatedAlpha);
469
+ const alpha = interpolatedAlpha * alphaMultiplier;
381
470
  // In-gamut results have an exact sRGB equivalent, which is always shorter than oklch()
382
- const hex = convertOklchToHex(L, C, H, alpha >= 1 ? 1 : alpha);
471
+ const hex = convertOklchToHex(L, C, H, alpha);
383
472
  if (hex) {
384
473
  return hex;
385
474
  }
@@ -302,6 +302,29 @@ function parseHex (hex) {
302
302
  return null;
303
303
  }
304
304
 
305
+ /**
306
+ * Matches a static `color(srgb ...)` function. The three color components may
307
+ * be unit-interval numbers or percentages, followed by an optional alpha.
308
+ *
309
+ * @type {RegExp}
310
+ */
311
+ const SRGB_COLOR_FUNCTION_PATTERN = /^color\(\s*srgb\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/;
312
+
313
+ /**
314
+ * Converts one `color(srgb ...)` component to the 0-255 range.
315
+ *
316
+ * @param {string} component A unit-interval number or percentage.
317
+ * @return {number|null} The channel value, or null when invalid.
318
+ */
319
+ function parseSrgbColorComponent (component) {
320
+ const numericValue = parseFloat(component);
321
+ if (!Number.isFinite(numericValue)) {
322
+ return null;
323
+ }
324
+ const normalizedValue = component.endsWith('%') ? numericValue / 100 : numericValue;
325
+ return Math.round(normalizedValue * 255);
326
+ }
327
+
305
328
  /**
306
329
  * Parse a CSS color string to [r, g, b, a] (r,g,b: 0-255, a: 0-1) or null if unresolvable.
307
330
  *
@@ -326,8 +349,20 @@ function parseColor (colorString) {
326
349
  return parseHex(normalized);
327
350
  }
328
351
 
352
+ // color(srgb ...) uses unit-interval color channels rather than rgb's 0-255 channels.
353
+ let match = normalized.match(SRGB_COLOR_FUNCTION_PATTERN);
354
+ if (match) {
355
+ const red = parseSrgbColorComponent(match[1]);
356
+ const green = parseSrgbColorComponent(match[2]);
357
+ const blue = parseSrgbColorComponent(match[3]);
358
+ if (red === null || green === null || blue === null) {
359
+ return null;
360
+ }
361
+ return [red, green, blue, Math.max(0, Math.min(1, parseAlphaString(match[4])))];
362
+ }
363
+
329
364
  // rgb() / rgba() space syntax
330
- let match = normalized.match(/^rgba?\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/);
365
+ match = normalized.match(/^rgba?\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/);
331
366
  if (match) {
332
367
  const r = Math.round(parseFloat(match[1]));
333
368
  const g = Math.round(parseFloat(match[2]));
package/src/value/math.js CHANGED
@@ -144,9 +144,58 @@ function rewriteMathFunctionContents (rewriteExpression, value) {
144
144
  return result + value.slice(index);
145
145
  }
146
146
 
147
+ /**
148
+ * Matches a number written with a decimal point that begins a token of its
149
+ * own, capturing its sign, the digits before its decimal point, and the digits
150
+ * after it. The lookbehind keeps the match from starting partway through an
151
+ * identifier (`--x0.5`) or another number, and the lookahead keeps it from
152
+ * matching a fragment of a dotted name such as `0.5.png`.
153
+ *
154
+ * @type {RegExp}
155
+ */
156
+ const DECIMAL_NUMBER = /(?<![\w.-])([+-]?)(\d*)\.(\d+)(?![\d.])/g;
157
+
158
+ /**
159
+ * Writes a decimal number in the fewest characters that state the same value,
160
+ * dropping the zeros before its whole part (`0.25` → `.25`) and after its
161
+ * fraction (`1.50` → `1.5`), along with a decimal point left with no fraction
162
+ * behind it (`2.0` → `2`). The digits that remain are kept as written, so no
163
+ * precision is lost.
164
+ *
165
+ * @param {string} sign The sign written before the number, empty when there is none.
166
+ * @param {string} wholeDigits The digits written before the decimal point.
167
+ * @param {string} fractionDigits The digits written after the decimal point.
168
+ * @return {string} The number written as compactly as it can be.
169
+ */
170
+ function shortenDecimalNumber (sign, wholeDigits, fractionDigits) {
171
+ // Leading zeros of the whole part
172
+ const wholePart = wholeDigits.replace(/^0+/, '');
173
+ // Trailing zeros of the fraction
174
+ const fractionPart = fractionDigits.replace(/0+$/, '');
175
+ if (!fractionPart) {
176
+ return sign + (wholePart || '0');
177
+ }
178
+ return sign + wholePart + '.' + fractionPart;
179
+ }
180
+
181
+ /**
182
+ * Shortens every decimal number in the contents of a math function. Numbers
183
+ * that follow an operator written without whitespace (`x*0.25s`) are out of
184
+ * reach of the general number shortening, which only looks past a separator.
185
+ *
186
+ * @param {string} expression The contents of a math function.
187
+ * @return {string} The expression with every decimal number written as compactly as it can be.
188
+ */
189
+ function shortenDecimalNumbers (expression) {
190
+ return expression.replace(DECIMAL_NUMBER, (match, sign, wholeDigits, fractionDigits) => {
191
+ return shortenDecimalNumber(sign, wholeDigits, fractionDigits);
192
+ });
193
+ }
194
+
147
195
  /**
148
196
  * Matches a multiplication by a decimal below one, capturing the digits after
149
- * its decimal point and the unit written on it. The trailing lookahead keeps
197
+ * its decimal point and the unit written on it (so a multiplier with a unit
198
+ * can be recognized and left alone). The trailing lookahead keeps
150
199
  * the match away from numbers that only begin this way, such as the exponent
151
200
  * of `.5e3` or a unit name that is still being read.
152
201
  *
@@ -205,28 +254,47 @@ function reciprocalOfDecimalFraction (fractionDigits) {
205
254
  }
206
255
 
207
256
  /**
208
- * Rewrites a multiplication by a decimal below one as a division by the whole
209
- * number that decimal is the reciprocal of, which is never longer to write and
210
- * usually shorter (`calc(var(--x)*.25)` → `calc(var(--x)/4)`). A unit written
211
- * on the multiplier moves onto the divisor, since only how the operand is
212
- * stated changes.
257
+ * Builds the division that scales a value exactly as multiplying it by a
258
+ * unitless decimal below one does, when that division is no longer to write
259
+ * than the multiplication. A multiplier carrying a unit is never rewritten:
260
+ * dividing by a dimension inverts its unit in the type of the result
261
+ * (`x*.25s` is a time, while `x/4s` is per unit of time), so the two would not
262
+ * be equivalent.
263
+ *
264
+ * @param {string} fractionDigits The digits written after the decimal point of the multiplier.
265
+ * @param {string} unit The unit written on the multiplier, empty when it is unitless.
266
+ * @return {string|null} The equivalent division, or null when there is none worth using.
267
+ */
268
+ function divisionEquivalentToFractionalMultiplier (fractionDigits, unit) {
269
+ if (unit) {
270
+ return null;
271
+ }
272
+ const divisor = reciprocalOfDecimalFraction(fractionDigits);
273
+ if (divisor === null) {
274
+ return null;
275
+ }
276
+ const division = '/' + divisor;
277
+ // Compared against the shortest the multiplication could have been written
278
+ const multiplication = '*.' + fractionDigits;
279
+ if (division.length > multiplication.length) {
280
+ return null;
281
+ }
282
+ return division;
283
+ }
284
+
285
+ /**
286
+ * Rewrites a multiplication by a unitless decimal below one as a division by
287
+ * the whole number that decimal is the reciprocal of, which is never longer to
288
+ * write and usually shorter (`calc(var(--x)*.25)` → `calc(var(--x)/4)`).
289
+ * Multipliers with a unit are left as written, since dividing by a dimension
290
+ * would invert its unit.
213
291
  *
214
292
  * @param {string} expression The contents of a math function.
215
293
  * @return {string} The expression with reciprocal multipliers stated as divisions.
216
294
  */
217
295
  function convertReciprocalMultiplicationToDivision (expression) {
218
296
  return expression.replace(FRACTIONAL_MULTIPLIER, (match, fractionDigits, unit) => {
219
- const divisor = reciprocalOfDecimalFraction(fractionDigits);
220
- if (divisor === null) {
221
- return match;
222
- }
223
- const division = '/' + divisor + unit;
224
- // Compared against the shortest the multiplication could have been written
225
- const multiplication = '*.' + fractionDigits + unit;
226
- if (division.length > multiplication.length) {
227
- return match;
228
- }
229
- return division;
297
+ return divisionEquivalentToFractionalMultiplier(fractionDigits, unit) ?? match;
230
298
  });
231
299
  }
232
300
 
@@ -596,7 +664,8 @@ const RESOLVABLE_MATH_FUNCTION = new RegExp(
596
664
  * @return {string} The value with its math functions simplified where possible.
597
665
  */
598
666
  function simplifyMathFunctionsOnce (value, property) {
599
- let result = rewriteMathFunctionContents(convertReciprocalMultiplicationToDivision, value);
667
+ let result = rewriteMathFunctionContents(shortenDecimalNumbers, value);
668
+ result = rewriteMathFunctionContents(convertReciprocalMultiplicationToDivision, result);
600
669
 
601
670
  // Unwrap calc(1 / (1 / x)) → x (double-reciprocal identity)
602
671
  result = result.replace(/calc\(\s*1\s*\/\s*\(\s*1\s*\/\s*([^()]+)\s*\)\s*\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {