@thejaredwilcurt/csslop 0.0.10 → 0.0.12

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.10",
5
+ "version": "0.0.12",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -26,7 +26,7 @@
26
26
  "devDependencies": {
27
27
  "@codemirror/autocomplete": "^6.20.3",
28
28
  "@codemirror/lang-css": "^6.3.1",
29
- "@codemirror/view": "^6.43.2",
29
+ "@codemirror/view": "^6.43.6",
30
30
  "@eslint/js": "^10.0.1",
31
31
  "@stylistic/eslint-plugin": "^5.10.0",
32
32
  "codemirror": "^6.0.2",
@@ -36,7 +36,7 @@
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": "^63.0.7",
39
+ "eslint-plugin-jsdoc": "^63.0.12",
40
40
  "fflate": "^0.8.3",
41
41
  "globals": "^17.7.0",
42
42
  "pretty-ms": "^9.3.0",
package/src/context.js CHANGED
@@ -14,4 +14,47 @@ function createMinifyContext () {
14
14
  };
15
15
  }
16
16
 
17
- export { createMinifyContext };
17
+ /**
18
+ * Module-level charset state used during a single synchronous minifyCSS call.
19
+ * Tracks whether the stylesheet declares a non-unicode charset, so that the
20
+ * value minifier can avoid resolving unicode escapes in non-unicode encodings.
21
+ */
22
+ let activeCharset = '';
23
+
24
+ /**
25
+ * Returns true when the active charset is a unicode-compatible encoding
26
+ * (UTF-8, UTF-16, or the default when no `@charset` is declared), meaning
27
+ * CSS unicode escapes can safely be resolved to literal characters.
28
+ *
29
+ * @return {boolean} True if the active charset supports unicode characters.
30
+ */
31
+ function isUnicodeCharset () {
32
+ if (!activeCharset) {
33
+ return true;
34
+ }
35
+ const normalized = activeCharset.toLowerCase().replace(/["']/g, '');
36
+ return normalized === 'utf-8' || normalized.startsWith('utf-16');
37
+ }
38
+
39
+ /**
40
+ * Sets the active charset for the current minification pass.
41
+ *
42
+ * @param {string} charset The `@charset` value (with quotes) from the stylesheet.
43
+ */
44
+ function setActiveCharset (charset) {
45
+ activeCharset = charset || '';
46
+ }
47
+
48
+ /**
49
+ * Clears the active charset after a minification pass completes.
50
+ */
51
+ function clearActiveCharset () {
52
+ activeCharset = '';
53
+ }
54
+
55
+ export {
56
+ clearActiveCharset,
57
+ createMinifyContext,
58
+ isUnicodeCharset,
59
+ setActiveCharset
60
+ };
@@ -20,7 +20,8 @@ const shorthandMap = {
20
20
  'border-right': ['border-right-width', 'border-right-style', 'border-right-color'],
21
21
  'border-bottom': ['border-bottom-width', 'border-bottom-style', 'border-bottom-color'],
22
22
  'border-left': ['border-left-width', 'border-left-style', 'border-left-color'],
23
- background: ['background-color', 'background-image', 'background-repeat', 'background-position', 'background-attachment'],
23
+ 'background-position': ['background-position-x', 'background-position-y'],
24
+ background: ['background-color', 'background-image', 'background-repeat', 'background-position', 'background-position-x', 'background-position-y', 'background-attachment', 'background-size', 'background-origin', 'background-clip'],
24
25
  'text-decoration': ['text-decoration-line', 'text-decoration-style', 'text-decoration-color'],
25
26
  'place-items': ['align-items', 'justify-items'],
26
27
  'place-content': ['align-content', 'justify-content'],
@@ -71,6 +71,13 @@ function getMergeProps (shorthand, longhands, declarations) {
71
71
  }
72
72
  return null;
73
73
  }
74
+ if (shorthand === 'background-position') {
75
+ const hasBothAxes = presentLonghands.includes('background-position-x') && presentLonghands.includes('background-position-y');
76
+ if (hasBothAxes) {
77
+ return presentLonghands;
78
+ }
79
+ return null;
80
+ }
74
81
  if (shorthand === 'background') {
75
82
  const hasBackgroundProp = presentLonghands.includes('background-color') || presentLonghands.includes('background-image');
76
83
  if (hasBackgroundProp) {
@@ -169,6 +176,316 @@ function canMergeVarValue (value, context) {
169
176
  });
170
177
  }
171
178
 
179
+ /**
180
+ * Resolves the background position from a value map. Prefers the combined
181
+ * `background-position` property if present, otherwise combines
182
+ * `background-position-x` and `background-position-y` into a single value.
183
+ *
184
+ * @param {Map} valueMap A map of property names to their minified values.
185
+ * @return {string|null} The resolved position string, or null if no position data is available.
186
+ */
187
+ function resolveBackgroundPosition (valueMap) {
188
+ const position = valueMap.get('background-position');
189
+ if (position) {
190
+ return position;
191
+ }
192
+ const positionX = valueMap.get('background-position-x');
193
+ const positionY = valueMap.get('background-position-y');
194
+ if (positionX && positionY) {
195
+ return positionX + ' ' + positionY;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ const BACKGROUND_POSITION_KEYWORDS = new Set(['left', 'center', 'right', 'top', 'bottom']);
201
+ const BACKGROUND_REPEAT_KEYWORDS = new Set(['repeat', 'no-repeat', 'repeat-x', 'repeat-y', 'space', 'round']);
202
+ const BACKGROUND_ATTACHMENT_KEYWORDS = new Set(['scroll', 'fixed', 'local']);
203
+ const BACKGROUND_BOX_KEYWORDS = new Set(['border-box', 'padding-box', 'content-box']);
204
+
205
+ /**
206
+ * Determines whether a token is a background image component such as `none`,
207
+ * `url(...)`, or an image-producing function like `linear-gradient(...)`.
208
+ *
209
+ * @param {string} token The token to classify.
210
+ * @return {boolean} Whether the token is a background image token.
211
+ */
212
+ function isBackgroundImageToken (token) {
213
+ if (token === 'none' || token.startsWith('url(')) {
214
+ return true;
215
+ }
216
+ if (!token.endsWith(')')) {
217
+ return false;
218
+ }
219
+ const functionNameMatch = token.match(/^([a-z-]+)\(/i);
220
+ if (!functionNameMatch) {
221
+ return false;
222
+ }
223
+ const functionName = functionNameMatch[1].toLowerCase();
224
+ return !['calc', 'min', 'max', 'clamp', 'var', 'env', 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color'].includes(functionName);
225
+ }
226
+
227
+ /**
228
+ * Determines whether a token can participate in a background-position value.
229
+ *
230
+ * @param {string} token The token to classify.
231
+ * @return {boolean} Whether the token is a valid background-position token.
232
+ */
233
+ function isBackgroundPositionToken (token) {
234
+ const lowercaseToken = token.toLowerCase();
235
+ if (BACKGROUND_POSITION_KEYWORDS.has(lowercaseToken)) {
236
+ return true;
237
+ }
238
+ if (/^[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?$/i.test(token)) {
239
+ return true;
240
+ }
241
+ return /^(?:calc|min|max|clamp|var|env)\(/i.test(token);
242
+ }
243
+
244
+ /**
245
+ * Determines whether a token is a background color component after excluding
246
+ * known image, position, repeat, attachment, and box tokens.
247
+ *
248
+ * @param {string} token The token to classify.
249
+ * @return {boolean} Whether the token is a background color token.
250
+ */
251
+ function isBackgroundColorToken (token) {
252
+ if (token === '/' || isBackgroundImageToken(token) || isBackgroundPositionToken(token)) {
253
+ return false;
254
+ }
255
+ const lowercaseToken = token.toLowerCase();
256
+ if (BACKGROUND_REPEAT_KEYWORDS.has(lowercaseToken) || BACKGROUND_ATTACHMENT_KEYWORDS.has(lowercaseToken) || BACKGROUND_BOX_KEYWORDS.has(lowercaseToken)) {
257
+ return false;
258
+ }
259
+ return /^#/i.test(token) || /^[a-z-]+$/i.test(token) || /^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(/i.test(token);
260
+ }
261
+
262
+ /**
263
+ * Splits a token like `linear-gradient(...)100%` into separate image and
264
+ * position tokens when the function output is immediately followed by a
265
+ * background-position token.
266
+ *
267
+ * @param {string} token The token to inspect.
268
+ * @return {Array} The original token, or separate image/position tokens.
269
+ */
270
+ function splitAttachedBackgroundImageToken (token) {
271
+ const lastCloseParenthesis = token.lastIndexOf(')');
272
+ if (lastCloseParenthesis === -1 || lastCloseParenthesis === token.length - 1) {
273
+ return [token];
274
+ }
275
+ const imageToken = token.slice(0, lastCloseParenthesis + 1);
276
+ const followingToken = token.slice(lastCloseParenthesis + 1);
277
+ if (!isBackgroundImageToken(imageToken) || !isBackgroundPositionToken(followingToken)) {
278
+ return [token];
279
+ }
280
+ return [imageToken, followingToken];
281
+ }
282
+
283
+ /**
284
+ * Splits a single-layer background shorthand into top-level tokens while
285
+ * respecting nested parentheses and preserving `/` as its own token.
286
+ *
287
+ * @param {string} value The background shorthand value.
288
+ * @return {Array} The extracted top-level tokens.
289
+ */
290
+ function splitBackgroundTokens (value) {
291
+ const tokens = [];
292
+ let currentToken = '';
293
+ let parenthesisDepth = 0;
294
+
295
+ for (const character of value) {
296
+ if (character === '(') {
297
+ parenthesisDepth++;
298
+ } else if (character === ')') {
299
+ parenthesisDepth--;
300
+ }
301
+
302
+ if (parenthesisDepth === 0 && character === '/') {
303
+ if (currentToken) {
304
+ tokens.push(currentToken);
305
+ currentToken = '';
306
+ }
307
+ tokens.push('/');
308
+ continue;
309
+ }
310
+
311
+ if (parenthesisDepth === 0 && /\s/.test(character)) {
312
+ if (currentToken) {
313
+ tokens.push(currentToken);
314
+ currentToken = '';
315
+ }
316
+ continue;
317
+ }
318
+
319
+ currentToken += character;
320
+ }
321
+
322
+ if (currentToken) {
323
+ tokens.push(currentToken);
324
+ }
325
+
326
+ const normalizedTokens = [];
327
+ for (const token of tokens) {
328
+ normalizedTokens.push(...splitAttachedBackgroundImageToken(token));
329
+ }
330
+ return normalizedTokens;
331
+ }
332
+
333
+ /**
334
+ * Extracts the simple image/color base from an existing background shorthand.
335
+ * Returns null for shorthands that already contain size, position, or any
336
+ * token that cannot be safely reconstructed by the background builder.
337
+ *
338
+ * @param {string} value The background shorthand value.
339
+ * @return {Map|null} A component map for safe reconstruction, or null.
340
+ */
341
+ function extractSimpleBackgroundBase (value) {
342
+ const tokens = splitBackgroundTokens(value);
343
+ const componentMap = new Map();
344
+
345
+ for (const token of tokens) {
346
+ if (token === '/') {
347
+ return null;
348
+ }
349
+ if (isBackgroundImageToken(token)) {
350
+ if (componentMap.has('background-image')) {
351
+ return null;
352
+ }
353
+ componentMap.set('background-image', token);
354
+ continue;
355
+ }
356
+ if (isBackgroundColorToken(token)) {
357
+ if (componentMap.has('background-color')) {
358
+ return null;
359
+ }
360
+ componentMap.set('background-color', token);
361
+ continue;
362
+ }
363
+ return null;
364
+ }
365
+
366
+ return componentMap;
367
+ }
368
+
369
+ /**
370
+ * Serializes normalized background components into a minified background
371
+ * shorthand value while omitting default sub-values.
372
+ *
373
+ * @param {Map} valueMap The normalized background component map.
374
+ * @param {string} importantSuffix A trailing `!important` suffix, if needed.
375
+ * @return {string|null} The minified background shorthand, or null.
376
+ */
377
+ function buildBackgroundShorthandValue (valueMap, importantSuffix) {
378
+ const color = valueMap.get('background-color');
379
+ const image = valueMap.get('background-image');
380
+ const repeat = valueMap.get('background-repeat');
381
+ const attachment = valueMap.get('background-attachment');
382
+ const size = valueMap.get('background-size');
383
+ const origin = valueMap.get('background-origin');
384
+ const clip = valueMap.get('background-clip');
385
+ const position = resolveBackgroundPosition(valueMap);
386
+
387
+ const result = [];
388
+ if (color && color !== 'transparent') {
389
+ result.push(color);
390
+ }
391
+ if (image && image !== 'none') {
392
+ result.push(image);
393
+ }
394
+ if (position && position !== '0 0' && position !== '0% 0%') {
395
+ result.push(position);
396
+ }
397
+ if (size && size !== 'auto') {
398
+ if (position && position !== '0 0' && position !== '0% 0%') {
399
+ result.push('/' + size);
400
+ } else {
401
+ result.push('0 0/' + size);
402
+ }
403
+ }
404
+ if (repeat && repeat !== 'repeat') {
405
+ result.push(repeat);
406
+ }
407
+ if (attachment && attachment !== 'scroll') {
408
+ result.push(attachment);
409
+ }
410
+ const hasNonDefaultOrigin = origin && origin !== 'padding-box';
411
+ const hasNonDefaultClip = clip && clip !== 'border-box';
412
+ if (hasNonDefaultOrigin && hasNonDefaultClip) {
413
+ result.push(origin);
414
+ result.push(clip);
415
+ } else if (hasNonDefaultOrigin || hasNonDefaultClip) {
416
+ if (origin) {
417
+ result.push(origin);
418
+ }
419
+ if (clip) {
420
+ result.push(clip);
421
+ }
422
+ }
423
+ if (!result.length) {
424
+ return null;
425
+ }
426
+ return result.join(' ') + importantSuffix;
427
+ }
428
+
429
+ /**
430
+ * Merges later background longhands into an earlier simple background shorthand
431
+ * when their combined value can be reconstructed without changing semantics.
432
+ *
433
+ * @param {Array} declarations The declarations in source order.
434
+ * @return {Array} The updated declarations with absorbed longhands.
435
+ */
436
+ function absorbBackgroundLonghandsIntoShorthand (declarations) {
437
+ const backgroundIndex = declarations.findIndex((declaration) => {
438
+ return declaration.property === 'background';
439
+ });
440
+ if (backgroundIndex === -1) {
441
+ return declarations;
442
+ }
443
+
444
+ const backgroundDeclaration = declarations[backgroundIndex];
445
+ const backgroundValue = minifyValue(backgroundDeclaration);
446
+ const backgroundIsImportant = backgroundValue.includes('!important');
447
+ const simpleBase = extractSimpleBackgroundBase(backgroundValue.replace('!important', '').trim());
448
+ if (!simpleBase) {
449
+ return declarations;
450
+ }
451
+
452
+ const absorbableProperties = new Set(shorthandMap.background.filter((property) => {
453
+ return property !== 'background';
454
+ }));
455
+ const relevantDeclarations = declarations.filter((declaration, index) => {
456
+ return index > backgroundIndex && absorbableProperties.has(declaration.property);
457
+ });
458
+ if (!relevantDeclarations.length) {
459
+ return declarations;
460
+ }
461
+
462
+ const sharesImportance = relevantDeclarations.every((declaration) => {
463
+ return minifyValue(declaration).includes('!important') === backgroundIsImportant;
464
+ });
465
+ if (!sharesImportance) {
466
+ return declarations;
467
+ }
468
+
469
+ for (const declaration of relevantDeclarations) {
470
+ simpleBase.set(declaration.property, minifyValue(declaration).replace('!important', '').trim());
471
+ }
472
+
473
+ const mergedValue = buildBackgroundShorthandValue(simpleBase, backgroundIsImportant ? '!important' : '');
474
+ if (!mergedValue) {
475
+ return declarations;
476
+ }
477
+
478
+ return declarations.flatMap((declaration, index) => {
479
+ if (index === backgroundIndex) {
480
+ return [{ ...declaration, value: mergedValue }];
481
+ }
482
+ if (index > backgroundIndex && absorbableProperties.has(declaration.property)) {
483
+ return [];
484
+ }
485
+ return [declaration];
486
+ });
487
+ }
488
+
172
489
  /**
173
490
  * Try to merge longhand properties into a shorthand.
174
491
  *
@@ -307,32 +624,17 @@ function tryMergeToShorthand (properties, declarations, shorthandName = '', cont
307
624
  return result.join(' ') + importantSuffix;
308
625
  }
309
626
 
310
- if (shorthandName === 'background') {
311
- const color = valueMap.get('background-color');
312
- const image = valueMap.get('background-image');
313
- const repeat = valueMap.get('background-repeat');
314
- const position = valueMap.get('background-position');
315
- const attachment = valueMap.get('background-attachment');
316
- const result = [];
317
- if (color && color !== 'transparent') {
318
- result.push(color);
319
- }
320
- if (image && image !== 'none') {
321
- result.push(image);
322
- }
323
- if (position && position !== '0 0' && position !== '0% 0%') {
324
- result.push(position);
325
- }
326
- if (repeat && repeat !== 'repeat') {
327
- result.push(repeat);
328
- }
329
- if (attachment && attachment !== 'scroll') {
330
- result.push(attachment);
331
- }
332
- if (!result.length) {
627
+ if (shorthandName === 'background-position') {
628
+ const positionX = valueMap.get('background-position-x');
629
+ const positionY = valueMap.get('background-position-y');
630
+ if (!positionX || !positionY) {
333
631
  return null;
334
632
  }
335
- return result.join(' ') + importantSuffix;
633
+ return positionX + ' ' + positionY + importantSuffix;
634
+ }
635
+
636
+ if (shorthandName === 'background') {
637
+ return buildBackgroundShorthandValue(valueMap, importantSuffix);
336
638
  }
337
639
 
338
640
  if (shorthandName === 'mask') {
@@ -595,6 +897,7 @@ function processDeclarations (declarations, context) {
595
897
  result = result.filter((declaration) => {
596
898
  return !propertiesToRemove.has(declaration.property);
597
899
  });
900
+ result = absorbBackgroundLonghandsIntoShorthand(result);
598
901
 
599
902
  // Try to merge remaining longhands into shorthands
600
903
  let changed = true;
@@ -654,10 +957,34 @@ function processDeclarations (declarations, context) {
654
957
  }
655
958
 
656
959
  if (newDeclarations.length) {
960
+ // Filter out intermediate shorthands whose longhands are entirely
961
+ // consumed by a higher-level shorthand created in the same iteration.
962
+ // For example, background-position (x + y) is redundant when
963
+ // background already consumed those same longhands.
964
+ const filteredDeclarations = newDeclarations.filter((declaration) => {
965
+ const longhands = shorthandMap[declaration.property];
966
+ if (!longhands) {
967
+ return true;
968
+ }
969
+ const isSubsumedByOtherShorthand = newDeclarations.some((other) => {
970
+ if (other === declaration) {
971
+ return false;
972
+ }
973
+ const otherLonghands = shorthandMap[other.property];
974
+ if (!otherLonghands) {
975
+ return false;
976
+ }
977
+ return longhands.every((longhand) => {
978
+ return otherLonghands.includes(longhand);
979
+ });
980
+ });
981
+ return !isSubsumedByOtherShorthand;
982
+ });
983
+
657
984
  result = result.filter((declaration) => {
658
985
  return !mergedProperties.has(declaration.property);
659
986
  });
660
- result = [...result, ...newDeclarations];
987
+ result = [...result, ...filteredDeclarations];
661
988
  changed = true;
662
989
  }
663
990
  }
package/src/index.js CHANGED
@@ -4,7 +4,11 @@
4
4
 
5
5
  import { parse } from '@node-projects/css-parser';
6
6
 
7
- import { createMinifyContext } from './context.js';
7
+ import {
8
+ clearActiveCharset,
9
+ createMinifyContext,
10
+ setActiveCharset
11
+ } from './context.js';
8
12
  import {
9
13
  analyzePositionTryRules,
10
14
  cleanPositionTryRules,
@@ -26,7 +30,8 @@ import {
26
30
  mergeMediaRules,
27
31
  mergeSelectorRules,
28
32
  nestFlatRules,
29
- removeEmptyRules
33
+ removeEmptyRules,
34
+ removeOverriddenMultiSelectorProperties
30
35
  } from './rules/optimize.js';
31
36
  import { stringifyRule } from './rules/stringify.js';
32
37
  import { minifyValue } from './value/minify.js';
@@ -163,6 +168,22 @@ function mergeAdjacentRulesWithIdenticalBodies (ruleStrings) {
163
168
  return result;
164
169
  }
165
170
 
171
+ /**
172
+ * Extracts the first `@charset` value from raw CSS text before parsing.
173
+ * Scans for `@charset` followed by a quoted string and semicolon.
174
+ *
175
+ * @param {string} css The raw CSS string to scan.
176
+ * @return {string} The first charset value (with quotes), or empty string if none found.
177
+ */
178
+ function detectCharset (css) {
179
+ // Match @charset followed by a quoted value and semicolon
180
+ const match = css.match(/@charset\s+(["'][^"']+["'])\s*;/i);
181
+ if (match) {
182
+ return match[1];
183
+ }
184
+ return '';
185
+ }
186
+
166
187
  /**
167
188
  * Parses, optimizes, and minifies a CSS string by applying rule merging, declaration deduplication, value compression, and dead-code elimination.
168
189
  *
@@ -179,12 +200,16 @@ export const minifyCSS = function (input) {
179
200
  let ast;
180
201
  const output = [];
181
202
 
203
+ const detectedCharset = detectCharset(source);
204
+ setActiveCharset(detectedCharset);
205
+
182
206
  try {
183
207
  ast = parse(
184
208
  preprocessDeclarationBlocks(neutralizeEscapeSequences(source)),
185
209
  { preserveFormatting: true, silent: true }
186
210
  );
187
211
  } catch {
212
+ clearActiveCharset();
188
213
  return source;
189
214
  }
190
215
 
@@ -217,7 +242,9 @@ export const minifyCSS = function (input) {
217
242
  ast.stylesheet.rules = deduplicateKeyframes(ast.stylesheet.rules);
218
243
 
219
244
  const mergedRules = mergeSelectorRules(ast.stylesheet.rules);
220
- const declarationMergedRules = mergeByDeclarations(mergedRules);
245
+ const overrideCleanedRules = removeOverriddenMultiSelectorProperties(mergedRules);
246
+ const preCleanedRules = removeEmptyRules(overrideCleanedRules);
247
+ const declarationMergedRules = mergeByDeclarations(preCleanedRules);
221
248
  const nestedRules = nestFlatRules(declarationMergedRules);
222
249
  const nonEmptyRules = removeEmptyRules(nestedRules);
223
250
  const factoredRules = factorCommonParents(nonEmptyRules);
@@ -229,8 +256,10 @@ export const minifyCSS = function (input) {
229
256
 
230
257
  const mergedOutput = mergeAdjacentRulesWithIdenticalBodies(output);
231
258
 
259
+ clearActiveCharset();
232
260
  return restoreEscapeSequences(mergedOutput.join(''));
233
261
  }
234
262
 
263
+ clearActiveCharset();
235
264
  return source;
236
265
  };
@@ -165,29 +165,33 @@ function filterUnusedPositionTry (rules, positionTryRules, positionTryUsage) {
165
165
 
166
166
  /**
167
167
  * Removes duplicate and redundant UTF-8 `@charset` rules, keeping only the first
168
- * non-UTF-8 charset declaration.
168
+ * non-UTF-8 charset declaration and moving it to the top of the document.
169
+ * Per the CSS specification, `@charset` must be the very first thing in a stylesheet.
169
170
  *
170
171
  * @param {Array} rules The top-level AST rule nodes to filter.
171
- * @return {Array} A new array of rules with redundant `@charset` entries removed.
172
+ * @return {Array} A new array of rules with the first non-UTF-8 `@charset` at the start and all others removed.
172
173
  */
173
174
  function filterRedundantCharsets (rules) {
174
- let firstCharsetFound = false;
175
+ let keptCharset = null;
175
176
 
176
- return rules.filter((rule) => {
177
- if (rule.type === 'charset') {
178
- if (!firstCharsetFound) {
179
- firstCharsetFound = true;
180
- // Strip surrounding quotes from the charset value for comparison
181
- const normalizedCharset = rule.charset?.toLowerCase().replace(/["']/g, '');
182
- if (normalizedCharset === 'utf-8') {
183
- return false;
184
- }
185
- return true;
177
+ const filtered = rules.filter((rule) => {
178
+ if (rule.type !== 'charset') {
179
+ return true;
180
+ }
181
+ if (!keptCharset) {
182
+ // Strip surrounding quotes from the charset value for comparison
183
+ const normalizedCharset = rule.charset?.toLowerCase().replace(/["']/g, '');
184
+ if (normalizedCharset !== 'utf-8') {
185
+ keptCharset = rule;
186
186
  }
187
- return false;
188
187
  }
189
- return true;
188
+ return false;
190
189
  });
190
+
191
+ if (keptCharset) {
192
+ return [keptCharset, ...filtered];
193
+ }
194
+ return filtered;
191
195
  }
192
196
 
193
197
  export {
package/src/preprocess.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * @file Preprocesses CSS declaration blocks by converting Unicode escape sequences to their literal characters before parsing.
3
3
  */
4
4
 
5
+ import { isUnicodeCharset } from './context.js';
5
6
  import { resolveUnicodeEscape } from './utilities.js';
6
7
 
7
8
  /**
@@ -91,20 +92,23 @@ function preprocessDeclarationBlocks (css) {
91
92
 
92
93
  // Match top-level declaration blocks (non-nested { ... })
93
94
  return processed.replace(/\{([^{}]*)\}/g, (match, content) => {
94
- // First, remove semicolons after comments which cause parser errors
95
- // Pattern: comment followed by optional whitespace and semicolon
96
- let processed = content.replace(/\/\*.*?\*\/\s*;/g, (commentMatch) => {
95
+ // Remove semicolons after standalone comments (between declarations) which cause parser errors.
96
+ // Only match when preceded by a semicolon, so property values like --foo: /*...*/; keep their terminator.
97
+ let processed = content.replace(/(?<=;)\s*\/\*.*?\*\/\s*;/g, (commentMatch) => {
97
98
  // Remove the trailing semicolon from comment+semicolon combinations
98
99
  return commentMatch.replace(/;$/, '');
99
100
  });
100
101
 
101
- // Then, skip quoted strings and match CSS unicode escapes (backslash + 1-6 hex digits + optional whitespace)
102
- processed = processed.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\\([0-9a-fA-F]{1,6})\s?/g, (fullMatch, hex) => {
103
- if (!hex) {
104
- return fullMatch;
105
- }
106
- return resolveUnicodeEscape(hex) ?? fullMatch;
107
- });
102
+ // Then, skip quoted strings and match CSS unicode escapes (backslash + 1-6 hex digits + optional whitespace).
103
+ // Only resolve when the charset is unicode-compatible.
104
+ if (isUnicodeCharset()) {
105
+ processed = processed.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\\([0-9a-fA-F]{1,6})\s?/g, (fullMatch, hex) => {
106
+ if (!hex) {
107
+ return fullMatch;
108
+ }
109
+ return resolveUnicodeEscape(hex) ?? fullMatch;
110
+ });
111
+ }
108
112
 
109
113
  return '{' + processed + '}';
110
114
  });
@@ -456,8 +456,7 @@ function mergeSelectorRules (rules) {
456
456
  if (rule.type === 'rule') {
457
457
  const selectorKey = rule.selectors ?
458
458
  rule.selectors.map((selector) => {
459
- // Normalize selector whitespace for consistent comparison
460
- return selector.trim().replace(/\s+/g, ' ');
459
+ return normalizeSelector(selector);
461
460
  }).sort().join(',') :
462
461
  '';
463
462
  if (selectorKey && selectorMap.has(selectorKey)) {
@@ -525,6 +524,107 @@ function mergeLayerRules (rules, mergeSelectorRules) {
525
524
  return result;
526
525
  }
527
526
 
527
+ /**
528
+ * Normalizes a selector string for consistent comparison by trimming
529
+ * and collapsing internal whitespace.
530
+ *
531
+ * @param {string} selector The raw selector string.
532
+ * @return {string} The normalized selector.
533
+ */
534
+ function normalizeSelector (selector) {
535
+ return selector
536
+ .trim()
537
+ .replace(/\s+/g, ' ')
538
+ // Convert double-colon ::before/::after to single-colon legacy form
539
+ .replace(/::before\b/g, ':before')
540
+ .replace(/::after\b/g, ':after');
541
+ }
542
+
543
+ /**
544
+ * Removes properties from multi-selector rules when every selector in
545
+ * the rule has that property overridden by a later rule. For example,
546
+ * if `h1,h2{color:red}` is followed by `h1{color:blue}` and
547
+ * `h2{color:green}`, the `color` in the first rule is redundant and
548
+ * can be removed. If all properties are removed, the empty rule will
549
+ * be cleaned up by `removeEmptyRules`.
550
+ *
551
+ * @param {Array} rules The flat list of AST rule nodes.
552
+ * @return {Array} The rules with overridden multi-selector properties removed.
553
+ */
554
+ function removeOverriddenMultiSelectorProperties (rules) {
555
+ for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
556
+ const rule = rules[ruleIndex];
557
+ if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
558
+ continue;
559
+ }
560
+ const normalizedSelectors = rule.selectors.map(normalizeSelector);
561
+ const declarations = (rule.declarations || []).filter((declaration) => {
562
+ return declaration.type === 'declaration';
563
+ });
564
+ if (declarations.length === 0) {
565
+ continue;
566
+ }
567
+
568
+ // For each property in this multi-selector rule, check if ALL selectors
569
+ // get that property overridden in later rules
570
+ const propertiesToRemove = new Set();
571
+ for (const declaration of declarations) {
572
+ const property = declaration.property;
573
+ const allSelectorsOverridden = normalizedSelectors.every((selector) => {
574
+ return isSelectorPropertyOverriddenLater(rules, ruleIndex, selector, property);
575
+ });
576
+ if (allSelectorsOverridden) {
577
+ propertiesToRemove.add(property);
578
+ }
579
+ }
580
+
581
+ if (propertiesToRemove.size > 0) {
582
+ rule.declarations = (rule.declarations || []).filter((declaration) => {
583
+ if (declaration.type !== 'declaration') {
584
+ return true;
585
+ }
586
+ return !propertiesToRemove.has(declaration.property);
587
+ });
588
+ }
589
+ }
590
+ return rules;
591
+ }
592
+
593
+ /**
594
+ * Checks whether a given selector has a specific property overridden
595
+ * by any later rule in the stylesheet. A property is considered
596
+ * overridden if a subsequent rule contains that selector (as its only
597
+ * selector or among its selectors) and declares the same property.
598
+ *
599
+ * @param {Array} rules The full list of AST rule nodes.
600
+ * @param {number} startIndex The index of the current rule (search starts after this).
601
+ * @param {string} selector The normalized selector to check.
602
+ * @param {string} property The CSS property name to check.
603
+ * @return {boolean} True if a later rule overrides this selector+property.
604
+ */
605
+ function isSelectorPropertyOverriddenLater (rules, startIndex, selector, property) {
606
+ for (let laterIndex = startIndex + 1; laterIndex < rules.length; laterIndex++) {
607
+ const laterRule = rules[laterIndex];
608
+ if (laterRule.type !== 'rule' || !laterRule.selectors) {
609
+ continue;
610
+ }
611
+ const laterSelectors = laterRule.selectors.map(normalizeSelector);
612
+ if (!laterSelectors.includes(selector)) {
613
+ continue;
614
+ }
615
+ const laterDeclarations = (laterRule.declarations || []).filter((declaration) => {
616
+ return declaration.type === 'declaration';
617
+ });
618
+ const hasOverride = laterDeclarations.some((declaration) => {
619
+ return declaration.property === property;
620
+ });
621
+ if (hasOverride) {
622
+ return true;
623
+ }
624
+ }
625
+ return false;
626
+ }
627
+
528
628
  export {
529
629
  deduplicateKeyframes,
530
630
  expandPureNestedRules,
@@ -534,5 +634,6 @@ export {
534
634
  mergeMediaRules,
535
635
  mergeSelectorRules,
536
636
  nestFlatRules,
537
- removeEmptyRules
637
+ removeEmptyRules,
638
+ removeOverriddenMultiSelectorProperties
538
639
  };
@@ -404,6 +404,26 @@ function stripLeadingZerosFromDecimals (value) {
404
404
  return value.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3');
405
405
  }
406
406
 
407
+ /**
408
+ * Processes CSS comments within a custom property value. If the value
409
+ * consists entirely of a comment, the comment is removed (producing an
410
+ * empty value). If comments appear between other tokens, their content
411
+ * is stripped but empty comment delimiters are kept as zero-width
412
+ * token separators to preserve the token sequence.
413
+ *
414
+ * @param {string} value The raw custom property value string.
415
+ * @return {string} The value with comments processed.
416
+ */
417
+ function processCustomPropertyComments (value) {
418
+ // Match values that are entirely a comment (with optional surrounding whitespace)
419
+ const commentOnlyPattern = /^\s*\/\*.*?\*\/\s*$/s;
420
+ if (commentOnlyPattern.test(value)) {
421
+ return '';
422
+ }
423
+ // Strip comment content but keep empty markers as token separators
424
+ return value.replace(/\/\*.*?\*\//g, '/**/');
425
+ }
426
+
407
427
  /**
408
428
  * Collapses whitespace in a custom property value while preserving
409
429
  * token boundaries. Each whitespace sequence is reduced to a single
@@ -488,8 +508,8 @@ function stringifyRule (rule, context, nested = false) {
488
508
 
489
509
  // Minify double-quoted attribute selectors: remove inner whitespace and escape when shorter
490
510
  minified = minified.replace(/\[\s*([^=]+)\s*=\s*"(.*?)"\s*\]/g, (match, attribute, value) => {
491
- // Escape special characters that require quoting (spaces, #, ., :, /), and compare lengths
492
- let escaped = value.replace(/([ #.:/])/g, '\\$1');
511
+ // Escape special characters that require quoting (spaces, #, ., :, /, ;), and compare lengths
512
+ let escaped = value.replace(/([ #.:/;])/g, '\\$1');
493
513
  if (escaped.length < value.length + 2) {
494
514
  return '[' + attribute + '=' + escaped + ']';
495
515
  }
@@ -497,8 +517,8 @@ function stringifyRule (rule, context, nested = false) {
497
517
  });
498
518
  // Minify single-quoted attribute selectors: remove inner whitespace and escape when shorter
499
519
  minified = minified.replace(/\[\s*([^=]+)\s*=\s*'(.*?)'\s*\]/g, (match, attribute, value) => {
500
- // Escape special characters that require quoting (spaces, #, ., :, /), and compare lengths
501
- let escaped = value.replace(/([ #.:/])/g, '\\$1');
520
+ // Escape special characters that require quoting (spaces, #, ., :, /, ;), and compare lengths
521
+ let escaped = value.replace(/([ #.:/;])/g, '\\$1');
502
522
  if (escaped.length < value.length + 2) {
503
523
  return '[' + attribute + '=' + escaped + ']';
504
524
  }
@@ -506,8 +526,8 @@ function stringifyRule (rule, context, nested = false) {
506
526
  });
507
527
  // Minify unquoted attribute selectors: quote when unescaping produces a shorter result
508
528
  minified = minified.replace(/\[\s*([^=]+)\s*=\s*([^"'].*?)\s*\]/g, (match, attribute, value) => {
509
- // Unescape special characters (spaces, #, ., :, /) and compare with quoted form
510
- let unescaped = value.replace(/\\([ #.:/])/g, '$1');
529
+ // Unescape special characters (spaces, #, ., :, /, ;) and compare with quoted form
530
+ let unescaped = value.replace(/\\([ #.:/;])/g, '$1');
511
531
  if (unescaped.length + 2 < value.length) {
512
532
  return '[' + attribute + '="' + unescaped + '"]';
513
533
  }
@@ -572,9 +592,23 @@ function stringifyRule (rule, context, nested = false) {
572
592
  value = minifyValue(declaration);
573
593
  } else {
574
594
  const rawValue = declaration.rawValue || declaration.value || '';
575
- const trimmedRawValue = rawValue.trim();
595
+ const commentProcessedValue = processCustomPropertyComments(rawValue);
596
+ const trimmedRawValue = commentProcessedValue.trim();
576
597
  if (trimmedRawValue === '') {
577
- value = ' ';
598
+ const hasExplicitValueContent = commentProcessedValue.length > 0;
599
+ // When the parser absorbs a whitespace-only value into
600
+ // rawBetween, trailing whitespace after the colon signals an
601
+ // intentionally empty custom property (e.g. `--foo: ;` sets
602
+ // the value to a space token, which differs from an absent
603
+ // value). Only apply this check when the original rawValue
604
+ // was already empty — not when it became empty after
605
+ // stripping a comment.
606
+ const originalValueWasEmpty = rawValue.trim() === '';
607
+ const colonBetween = declaration.rawBetween || '';
608
+ // Match whitespace after the colon character
609
+ const hasSpaceAfterColon = /:\s/.test(colonBetween);
610
+ const isExplicitlyEmptyValue = hasExplicitValueContent || (originalValueWasEmpty && hasSpaceAfterColon);
611
+ value = isExplicitlyEmptyValue ? ' ' : '';
578
612
  // Preserve leading space for rgb() space-syntax values in custom properties
579
613
  } else if (/^rgb\(\s*\d+\s+\d+\s+\d+\s*\)$/i.test(trimmedRawValue)) {
580
614
  value = ' ' + trimmedRawValue;
@@ -674,6 +708,13 @@ function stringifyRule (rule, context, nested = false) {
674
708
  .filter((keyframe) => {
675
709
  return keyframe.type === 'keyframe';
676
710
  })
711
+ .filter((keyframe) => {
712
+ // Skip keyframe stops that have no meaningful declarations
713
+ const meaningful = (keyframe.declarations || []).filter((declaration) => {
714
+ return declaration.type !== 'whitespace' && declaration.type !== 'comment';
715
+ });
716
+ return meaningful.length > 0;
717
+ })
677
718
  .map((keyframe) => {
678
719
  let output = [];
679
720
  let stopValues = keyframe.values.map((stopValue) => {
@@ -689,7 +730,7 @@ function stringifyRule (rule, context, nested = false) {
689
730
  output.push('{');
690
731
  const renderedKeyframeDeclarations = keyframe.declarations
691
732
  ?.filter((declaration) => {
692
- return declaration.type !== 'whitespace';
733
+ return declaration.type !== 'whitespace' && declaration.type !== 'comment';
693
734
  })
694
735
  ?.map((declaration) => {
695
736
  return [declaration.property, ':', minifyValue(declaration)].join('');
@@ -2,6 +2,11 @@
2
2
  * @file Parses and minifies CSS gradient function calls by splitting arguments, normalizing default directions, and removing redundant stop positions.
3
3
  */
4
4
 
5
+ import {
6
+ parseHex,
7
+ shortestColor
8
+ } from './colors.js';
9
+
5
10
  /**
6
11
  * Splits a gradient function's argument string at top-level commas, correctly handling nested parentheses.
7
12
  *
@@ -32,7 +37,256 @@ function splitGradientArgs (argumentString) {
32
37
  }
33
38
 
34
39
  /**
35
- * Optimizes gradient arguments by removing default direction or shape keywords and trimming redundant 0% or 100% stop positions from the first and last stops.
40
+ * Checks whether a string is a valid gradient stop position consisting of one
41
+ * or two numeric tokens with optional CSS units.
42
+ *
43
+ * @param {string} positionText The potential stop position text.
44
+ * @return {boolean} Whether the text is a valid stop position.
45
+ */
46
+ function isGradientStopPosition (positionText) {
47
+ // Match one or two numeric stop-position tokens, such as `50%`, `10px`, or `0 50%`.
48
+ return /^[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?(?:\s+[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?)?$/i.test(positionText);
49
+ }
50
+
51
+ /**
52
+ * Splits a hex color stop that has an attached position with no separating
53
+ * whitespace back into distinct color and position parts.
54
+ *
55
+ * @param {string} stop The raw gradient stop text.
56
+ * @return {object|null} Parsed `color` and `position` parts, or null.
57
+ */
58
+ function splitAttachedHexColorStop (stop) {
59
+ if (!stop.startsWith('#')) {
60
+ return null;
61
+ }
62
+
63
+ const hexLengths = [8, 6, 4, 3];
64
+ for (const hexLength of hexLengths) {
65
+ const colorLength = hexLength + 1;
66
+ if (stop.length <= colorLength) {
67
+ continue;
68
+ }
69
+
70
+ const colorCandidate = stop.slice(0, colorLength);
71
+ const positionCandidate = stop.slice(colorLength).trim();
72
+ const hexDigits = colorCandidate.slice(1);
73
+ const isHexColor = hexDigits.length === hexLength && /^[0-9a-f]+$/i.test(hexDigits);
74
+ if (!isHexColor || !isGradientStopPosition(positionCandidate)) {
75
+ continue;
76
+ }
77
+
78
+ return {
79
+ color: colorCandidate,
80
+ position: positionCandidate
81
+ };
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Splits a function-based color stop that has an attached position with no
89
+ * separating whitespace back into distinct color and position parts.
90
+ *
91
+ * @param {string} stop The raw gradient stop text.
92
+ * @return {object|null} Parsed `color` and `position` parts, or null.
93
+ */
94
+ function splitAttachedFunctionColorStop (stop) {
95
+ const lastCloseParenthesis = stop.lastIndexOf(')');
96
+ if (lastCloseParenthesis === -1 || lastCloseParenthesis === stop.length - 1) {
97
+ return null;
98
+ }
99
+
100
+ const colorCandidate = stop.slice(0, lastCloseParenthesis + 1).trim();
101
+ const positionCandidate = stop.slice(lastCloseParenthesis + 1).trim();
102
+ if (!isGradientStopPosition(positionCandidate)) {
103
+ return null;
104
+ }
105
+
106
+ return {
107
+ color: colorCandidate,
108
+ position: positionCandidate
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Normalizes a gradient stop color token to the same shortest representation
114
+ * used by the general value minifier so equivalent adjacent stops can merge.
115
+ *
116
+ * @param {string} colorToken The parsed stop color token.
117
+ * @return {string} The normalized color token.
118
+ */
119
+ function normalizeStopColorToken (colorToken) {
120
+ if (!colorToken.startsWith('#')) {
121
+ return colorToken;
122
+ }
123
+
124
+ const channels = parseHex(colorToken);
125
+ if (!channels) {
126
+ return colorToken;
127
+ }
128
+
129
+ return shortestColor(channels[0], channels[1], channels[2], channels[3]);
130
+ }
131
+
132
+ /**
133
+ * Splits a gradient color stop into its color value and optional position.
134
+ * The position is the trailing percentage/length token(s), while the color
135
+ * is everything before it. Handles colors with parentheses like rgb() and hsl().
136
+ *
137
+ * @param {string} stop A single gradient color stop string (e.g. "red 50%").
138
+ * @return {object} An object with `color` and `position` string properties.
139
+ */
140
+ function parseColorStop (stop) {
141
+ const trimmed = stop.trim();
142
+ // Match a trailing position: one or two values that are numbers with optional units
143
+ // like "50%", "10px", or "0". Captures the last position token(s) after the color.
144
+ const positionMatch = trimmed.match(/^(.+?)\s+((?:\d+(?:\.\d+)?(?:%|[a-z]+)?\s*){1,2})$/i);
145
+ if (positionMatch) {
146
+ return {
147
+ color: positionMatch[1].trim(),
148
+ position: positionMatch[2].trim()
149
+ };
150
+ }
151
+ const attachedHexStop = splitAttachedHexColorStop(trimmed);
152
+ if (attachedHexStop) {
153
+ return attachedHexStop;
154
+ }
155
+ const attachedFunctionStop = splitAttachedFunctionColorStop(trimmed);
156
+ if (attachedFunctionStop) {
157
+ return attachedFunctionStop;
158
+ }
159
+ return {
160
+ color: trimmed,
161
+ position: null
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Serializes a parsed gradient stop back into normalized CSS text, ensuring a
167
+ * separating space is preserved when a stop position is present.
168
+ *
169
+ * @param {string} stop The raw gradient stop string.
170
+ * @return {string} The normalized gradient stop string.
171
+ */
172
+ function normalizeColorStop (stop) {
173
+ const parsedStop = parseColorStop(stop);
174
+ const normalizedColor = normalizeStopColorToken(parsedStop.color);
175
+ if (parsedStop.position === null) {
176
+ return normalizedColor;
177
+ }
178
+
179
+ return normalizedColor + ' ' + parsedStop.position;
180
+ }
181
+
182
+ /**
183
+ * Groups consecutive gradient stops that share the same color value into
184
+ * arrays. Each group contains one or more stops with an identical color.
185
+ *
186
+ * @param {Array} stops An array of parsed stop objects with `color` and `position`.
187
+ * @return {Array} An array of groups, each being an array of stop objects with the same color.
188
+ */
189
+ function groupConsecutiveIdenticalStops (stops) {
190
+ const groups = [];
191
+ let currentGroup = [stops[0]];
192
+ for (let index = 1; index < stops.length; index++) {
193
+ if (stops[index].color === currentGroup[0].color) {
194
+ currentGroup.push(stops[index]);
195
+ } else {
196
+ groups.push(currentGroup);
197
+ currentGroup = [stops[index]];
198
+ }
199
+ }
200
+ groups.push(currentGroup);
201
+ return groups;
202
+ }
203
+
204
+ /**
205
+ * Combines groups of identical adjacent color stops into single stops with
206
+ * merged position ranges. Also removes implied 0% at the start and 100%
207
+ * at the end, and replaces a start position with unitless `0` when it
208
+ * matches the previous group's end position.
209
+ *
210
+ * @param {Array} args The gradient stop strings (already split by comma).
211
+ * @return {Array} The optimized gradient stop strings.
212
+ */
213
+ function combineAdjacentIdenticalStops (args) {
214
+ const stops = args.map((arg) => {
215
+ return parseColorStop(arg);
216
+ });
217
+ const hasPositions = stops.some((stop) => {
218
+ return stop.position !== null;
219
+ });
220
+ if (!hasPositions) {
221
+ return args;
222
+ }
223
+
224
+ const groups = groupConsecutiveIdenticalStops(stops);
225
+ const hasMergeableGroup = groups.some((group) => {
226
+ return group.length > 1;
227
+ });
228
+ if (!hasMergeableGroup) {
229
+ return args;
230
+ }
231
+
232
+ const result = [];
233
+ let previousEndPosition = null;
234
+
235
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
236
+ const group = groups[groupIndex];
237
+ const color = group[0].color;
238
+ const isFirstGroup = groupIndex === 0;
239
+ const isLastGroup = groupIndex === groups.length - 1;
240
+
241
+ if (group.length === 1) {
242
+ let position = group[0].position;
243
+ if (position === '0%' && isFirstGroup) {
244
+ position = null;
245
+ }
246
+ if (position === '100%' && isLastGroup) {
247
+ position = null;
248
+ }
249
+ if (position !== null && position === previousEndPosition) {
250
+ position = '0';
251
+ }
252
+ previousEndPosition = group[0].position;
253
+ result.push(position ? color + ' ' + position : color);
254
+ continue;
255
+ }
256
+
257
+ const firstPosition = group[0].position;
258
+ const lastPosition = group[group.length - 1].position;
259
+
260
+ let startPart = firstPosition;
261
+ let endPart = lastPosition;
262
+
263
+ if (startPart === '0%' && isFirstGroup) {
264
+ startPart = null;
265
+ }
266
+ if (endPart === '100%' && isLastGroup) {
267
+ endPart = null;
268
+ }
269
+ if (startPart !== null && startPart === previousEndPosition) {
270
+ startPart = '0';
271
+ }
272
+
273
+ previousEndPosition = lastPosition;
274
+
275
+ const positionParts = [startPart, endPart].filter((part) => {
276
+ return part !== null;
277
+ });
278
+ if (positionParts.length > 0) {
279
+ result.push(color + ' ' + positionParts.join(' '));
280
+ } else {
281
+ result.push(color);
282
+ }
283
+ }
284
+
285
+ return result;
286
+ }
287
+
288
+ /**
289
+ * Optimizes gradient arguments by removing default direction or shape keywords, combining adjacent identical color stops, and trimming redundant 0% or 100% stop positions from the first and last stops.
36
290
  *
37
291
  * @param {string} func The gradient function name (e.g. "linear-gradient").
38
292
  * @param {string} argsStr The raw comma-separated gradient arguments string.
@@ -42,6 +296,8 @@ function processGradientArgs (func, argsStr) {
42
296
  const args = splitGradientArgs(argsStr);
43
297
  const functionLower = func.toLowerCase();
44
298
 
299
+ let directionArgCount = 0;
300
+
45
301
  if (functionLower.includes('linear')) {
46
302
  if (args.length > 1) {
47
303
  const firstDirection = args[0].toLowerCase().replace(/\s+/g, ' ').trim();
@@ -49,18 +305,31 @@ function processGradientArgs (func, argsStr) {
49
305
  args.shift();
50
306
  } else if (firstDirection === 'to top') {
51
307
  args[0] = '0deg';
308
+ directionArgCount = 1;
52
309
  } else if (firstDirection === 'to right') {
53
310
  args[0] = '90deg';
311
+ directionArgCount = 1;
54
312
  } else if (firstDirection === 'to left') {
55
313
  args[0] = '270deg';
314
+ directionArgCount = 1;
56
315
  } else if (firstDirection === 'to top right' || firstDirection === 'to right top') {
57
316
  args[0] = '45deg';
317
+ directionArgCount = 1;
58
318
  } else if (firstDirection === 'to bottom right' || firstDirection === 'to right bottom') {
59
319
  args[0] = '135deg';
320
+ directionArgCount = 1;
60
321
  } else if (firstDirection === 'to bottom left' || firstDirection === 'to left bottom') {
61
322
  args[0] = '225deg';
323
+ directionArgCount = 1;
62
324
  } else if (firstDirection === 'to top left' || firstDirection === 'to left top') {
63
325
  args[0] = '315deg';
326
+ directionArgCount = 1;
327
+ } else {
328
+ // Check if first arg looks like a direction (angle or "to ..." keyword)
329
+ const looksLikeDirection = /^\d+(\.\d+)?deg$/i.test(firstDirection) || firstDirection.startsWith('to ');
330
+ if (looksLikeDirection) {
331
+ directionArgCount = 1;
332
+ }
64
333
  }
65
334
  }
66
335
  } else if (functionLower.includes('radial')) {
@@ -68,15 +337,34 @@ function processGradientArgs (func, argsStr) {
68
337
  const firstShape = args[0].toLowerCase().replace(/\s+/g, ' ').trim();
69
338
  if (firstShape === 'ellipse at center' || firstShape === 'circle at center') {
70
339
  args.shift();
340
+ } else {
341
+ // Check if first arg is a radial shape/size descriptor
342
+ const looksLikeShape = /\b(circle|ellipse|closest|farthest|at)\b/i.test(firstShape);
343
+ if (looksLikeShape) {
344
+ directionArgCount = 1;
345
+ }
71
346
  }
72
347
  }
73
348
  }
74
349
 
75
- if (args.length > 0) {
350
+ // Extract color stop args (everything after the direction/shape argument)
351
+ const colorStopArgs = args.slice(directionArgCount).map((arg) => {
352
+ return normalizeColorStop(arg);
353
+ });
354
+ if (colorStopArgs.length > 0) {
355
+ const normalizedStops = colorStopArgs.length >= 2 ?
356
+ combineAdjacentIdenticalStops(colorStopArgs) :
357
+ colorStopArgs;
358
+ args.splice(directionArgCount, colorStopArgs.length, ...normalizedStops);
359
+ }
360
+
361
+ if (args.length > directionArgCount) {
362
+ const firstStopIndex = directionArgCount;
363
+ const lastStopIndex = args.length - 1;
76
364
  // Remove default 0% stop position from the first gradient stop
77
- args[0] = args[0].replace(/^(.*\S)\s+0%$/, '$1');
365
+ args[firstStopIndex] = args[firstStopIndex].replace(/^(.*\S)\s+0%$/, '$1');
78
366
  // Remove default 100% stop position from the last gradient stop
79
- args[args.length - 1] = args[args.length - 1].replace(/^(.*\S)\s+100%$/, '$1');
367
+ args[lastStopIndex] = args[lastStopIndex].replace(/^(.*\S)\s+100%$/, '$1');
80
368
  }
81
369
 
82
370
  return args.join(',');
@@ -2,6 +2,7 @@
2
2
  * @file Minifies CSS declaration values by applying color conversion, math simplification, shorthand compression, and other property-specific optimizations.
3
3
  */
4
4
 
5
+ import { isUnicodeCharset } from '../context.js';
5
6
  import { resolveUnicodeEscape } from '../utilities.js';
6
7
 
7
8
  import {
@@ -177,10 +178,13 @@ function replaceOutsideStringsAndUrls (value, replacer) {
177
178
  * @return {string} The value with whitespace collapsed, quotes normalized, and unicode escapes resolved.
178
179
  */
179
180
  function normalizeWhitespaceAndQuotes (val, property) {
180
- // Unescape unicode (skip control characters — they must stay escaped in CSS strings)
181
- val = val.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
182
- return resolveUnicodeEscape(hex) ?? match;
183
- });
181
+ // Unescape unicode (skip control characters — they must stay escaped in CSS strings).
182
+ // Only resolve escapes when the charset is unicode-compatible (UTF-8/UTF-16 or default).
183
+ if (isUnicodeCharset()) {
184
+ val = val.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
185
+ return resolveUnicodeEscape(hex) ?? match;
186
+ });
187
+ }
184
188
  // Normalize single-quoted strings to double-quoted
185
189
  val = val.replace(/'((?:[^'\\]|\\.)*?)'/g, '"$1"');
186
190
 
@@ -444,7 +448,7 @@ function convertMillisecondsToSeconds (value) {
444
448
  * @return {string} The value with property-specific optimizations applied.
445
449
  */
446
450
  function applyPropertyOptimizations (val, property) {
447
- if (property === 'font-weight') {
451
+ if (property === 'font-weight' && isUnicodeCharset()) {
448
452
  // Replace font-weight keyword "bold" with its numeric equivalent
449
453
  val = val.replace(/\bbold\b/gi, '700');
450
454
  // Replace font-weight keyword "normal" with its numeric equivalent
@@ -482,6 +486,9 @@ function applyPropertyOptimizations (val, property) {
482
486
  // Replace steps() functions with their equivalent named timing-function keywords
483
487
  val = val.replace(/steps\(1,start\)/g, 'step-start');
484
488
  val = val.replace(/steps\(1,end\)/g, 'step-end');
489
+ // Restore space between step-start/step-end keyword and following token
490
+ // (the parenthesis whitespace stripping removes the space before replacement)
491
+ val = val.replace(/(step-start|step-end)(?=[a-zA-Z0-9#-])/g, '$1 ');
485
492
  }
486
493
 
487
494
  // Flex: remove " 0px" from flex shorthand (flex: 0 0 0px -> flex: 0 0)
@@ -644,6 +651,10 @@ function applyPropertyOptimizations (val, property) {
644
651
  if (normalized) {
645
652
  val = normalized;
646
653
  }
654
+ // Restore the required separator between an image function and a following
655
+ // background-position when that position is not immediately followed by `/size`.
656
+ val = val.replace(/\)((?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?)(?:\s+(?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?))?)(?!\/)/gi, ') $1');
657
+ val = val.replace(/\)\s+((?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?)(?:\s+(?:left|center|right|top|bottom|[+-]?(?:\d+|\d*\.\d+)(?:%|[a-z]+)?))?)(?=\/)/gi, ')$1');
647
658
  }
648
659
 
649
660
  if (property === 'border') {