@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 +1 -1
- package/src/context.js +44 -1
- package/src/index.js +32 -3
- package/src/position-try.js +19 -15
- package/src/preprocess.js +14 -10
- package/src/rules/optimize.js +98 -1
- package/src/rules/stringify.js +50 -9
- package/src/value/minify.js +12 -5
package/package.json
CHANGED
package/src/context.js
CHANGED
|
@@ -14,4 +14,47 @@ function createMinifyContext () {
|
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
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 {
|
|
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
|
|
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
|
};
|
package/src/position-try.js
CHANGED
|
@@ -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
|
|
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
|
|
175
|
+
let keptCharset = null;
|
|
175
176
|
|
|
176
|
-
|
|
177
|
-
if (rule.type
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
let processed = content.replace(
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
});
|
package/src/rules/optimize.js
CHANGED
|
@@ -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
|
};
|
package/src/rules/stringify.js
CHANGED
|
@@ -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, #, ., :,
|
|
492
|
-
let escaped = value.replace(/([
|
|
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, #, ., :,
|
|
501
|
-
let escaped = value.replace(/([
|
|
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, #, ., :,
|
|
510
|
-
let unescaped = value.replace(/\\([
|
|
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
|
|
595
|
+
const commentProcessedValue = processCustomPropertyComments(rawValue);
|
|
596
|
+
const trimmedRawValue = commentProcessedValue.trim();
|
|
576
597
|
if (trimmedRawValue === '') {
|
|
577
|
-
|
|
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('');
|
package/src/value/minify.js
CHANGED
|
@@ -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
|
-
|
|
182
|
-
|
|
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)
|