@thejaredwilcurt/csslop 0.0.10 → 0.0.11

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.11",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
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
+ };
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
  });
@@ -525,6 +525,102 @@ function mergeLayerRules (rules, mergeSelectorRules) {
525
525
  return result;
526
526
  }
527
527
 
528
+ /**
529
+ * Normalizes a selector string for consistent comparison by trimming
530
+ * and collapsing internal whitespace.
531
+ *
532
+ * @param {string} selector The raw selector string.
533
+ * @return {string} The normalized selector.
534
+ */
535
+ function normalizeSelector (selector) {
536
+ return selector.trim().replace(/\s+/g, ' ');
537
+ }
538
+
539
+ /**
540
+ * Removes properties from multi-selector rules when every selector in
541
+ * the rule has that property overridden by a later rule. For example,
542
+ * if `h1,h2{color:red}` is followed by `h1{color:blue}` and
543
+ * `h2{color:green}`, the `color` in the first rule is redundant and
544
+ * can be removed. If all properties are removed, the empty rule will
545
+ * be cleaned up by `removeEmptyRules`.
546
+ *
547
+ * @param {Array} rules The flat list of AST rule nodes.
548
+ * @return {Array} The rules with overridden multi-selector properties removed.
549
+ */
550
+ function removeOverriddenMultiSelectorProperties (rules) {
551
+ for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
552
+ const rule = rules[ruleIndex];
553
+ if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
554
+ continue;
555
+ }
556
+ const normalizedSelectors = rule.selectors.map(normalizeSelector);
557
+ const declarations = (rule.declarations || []).filter((declaration) => {
558
+ return declaration.type === 'declaration';
559
+ });
560
+ if (declarations.length === 0) {
561
+ continue;
562
+ }
563
+
564
+ // For each property in this multi-selector rule, check if ALL selectors
565
+ // get that property overridden in later rules
566
+ const propertiesToRemove = new Set();
567
+ for (const declaration of declarations) {
568
+ const property = declaration.property;
569
+ const allSelectorsOverridden = normalizedSelectors.every((selector) => {
570
+ return isSelectorPropertyOverriddenLater(rules, ruleIndex, selector, property);
571
+ });
572
+ if (allSelectorsOverridden) {
573
+ propertiesToRemove.add(property);
574
+ }
575
+ }
576
+
577
+ if (propertiesToRemove.size > 0) {
578
+ rule.declarations = (rule.declarations || []).filter((declaration) => {
579
+ if (declaration.type !== 'declaration') {
580
+ return true;
581
+ }
582
+ return !propertiesToRemove.has(declaration.property);
583
+ });
584
+ }
585
+ }
586
+ return rules;
587
+ }
588
+
589
+ /**
590
+ * Checks whether a given selector has a specific property overridden
591
+ * by any later rule in the stylesheet. A property is considered
592
+ * overridden if a subsequent rule contains that selector (as its only
593
+ * selector or among its selectors) and declares the same property.
594
+ *
595
+ * @param {Array} rules The full list of AST rule nodes.
596
+ * @param {number} startIndex The index of the current rule (search starts after this).
597
+ * @param {string} selector The normalized selector to check.
598
+ * @param {string} property The CSS property name to check.
599
+ * @return {boolean} True if a later rule overrides this selector+property.
600
+ */
601
+ function isSelectorPropertyOverriddenLater (rules, startIndex, selector, property) {
602
+ for (let laterIndex = startIndex + 1; laterIndex < rules.length; laterIndex++) {
603
+ const laterRule = rules[laterIndex];
604
+ if (laterRule.type !== 'rule' || !laterRule.selectors) {
605
+ continue;
606
+ }
607
+ const laterSelectors = laterRule.selectors.map(normalizeSelector);
608
+ if (!laterSelectors.includes(selector)) {
609
+ continue;
610
+ }
611
+ const laterDeclarations = (laterRule.declarations || []).filter((declaration) => {
612
+ return declaration.type === 'declaration';
613
+ });
614
+ const hasOverride = laterDeclarations.some((declaration) => {
615
+ return declaration.property === property;
616
+ });
617
+ if (hasOverride) {
618
+ return true;
619
+ }
620
+ }
621
+ return false;
622
+ }
623
+
528
624
  export {
529
625
  deduplicateKeyframes,
530
626
  expandPureNestedRules,
@@ -534,5 +630,6 @@ export {
534
630
  mergeMediaRules,
535
631
  mergeSelectorRules,
536
632
  nestFlatRules,
537
- removeEmptyRules
633
+ removeEmptyRules,
634
+ removeOverriddenMultiSelectorProperties
538
635
  };
@@ -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,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)