@thejaredwilcurt/csslop 0.0.1

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/src/index.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @file CSS minification entry point.
3
+ */
4
+
5
+ import { parse } from '@node-projects/css-parser';
6
+
7
+ import { createMinifyContext } from './context.js';
8
+ import {
9
+ analyzePositionTryRules,
10
+ cleanPositionTryRules,
11
+ collectRuleMetadata,
12
+ filterRedundantCharsets,
13
+ filterUnusedPositionTry
14
+ } from './position-try.js';
15
+ import { preprocessDeclarationBlocks } from './preprocess.js';
16
+ import {
17
+ deduplicateKeyframes,
18
+ expandPureNestedRules,
19
+ mergeByDeclarations,
20
+ mergeLayerRules,
21
+ mergeMediaRules,
22
+ mergeSelectorRules,
23
+ nestFlatRules
24
+ } from './rules/optimize.js';
25
+ import { stringifyRule } from './rules/stringify.js';
26
+ import { minifyValue } from './value/minify.js';
27
+
28
+ /**
29
+ * Parses, optimizes, and minifies a CSS string by applying rule merging, declaration deduplication, value compression, and dead-code elimination.
30
+ *
31
+ * @param {string} input The raw CSS string to minify.
32
+ * @return {string} The fully minified CSS string, or the original input if parsing fails.
33
+ */
34
+ export const minifyCSS = function (input) {
35
+ let source;
36
+ if (typeof input === 'string') {
37
+ source = input;
38
+ } else {
39
+ source = String(input ?? '');
40
+ }
41
+ let ast;
42
+ const output = [];
43
+
44
+ try {
45
+ ast = parse(preprocessDeclarationBlocks(source), { preserveFormatting: true });
46
+ } catch {
47
+ return source;
48
+ }
49
+
50
+ const context = createMinifyContext();
51
+
52
+ if (ast?.stylesheet?.rules) {
53
+ const {
54
+ positionTryRules,
55
+ positionTryUsage
56
+ } = collectRuleMetadata(ast.stylesheet.rules, context);
57
+
58
+ analyzePositionTryRules(
59
+ ast.stylesheet.rules,
60
+ minifyValue,
61
+ positionTryRules,
62
+ positionTryUsage
63
+ );
64
+ cleanPositionTryRules(ast.stylesheet.rules);
65
+
66
+ ast.stylesheet.rules = filterUnusedPositionTry(
67
+ ast.stylesheet.rules,
68
+ positionTryRules,
69
+ positionTryUsage
70
+ );
71
+ ast.stylesheet.rules = filterRedundantCharsets(ast.stylesheet.rules);
72
+
73
+ ast.stylesheet.rules = expandPureNestedRules(ast.stylesheet.rules);
74
+ ast.stylesheet.rules = mergeLayerRules(ast.stylesheet.rules, mergeSelectorRules);
75
+ ast.stylesheet.rules = mergeMediaRules(ast.stylesheet.rules, mergeSelectorRules);
76
+ ast.stylesheet.rules = deduplicateKeyframes(ast.stylesheet.rules);
77
+
78
+ const mergedRules = mergeSelectorRules(ast.stylesheet.rules);
79
+ const declarationMergedRules = mergeByDeclarations(mergedRules);
80
+ const finalRules = nestFlatRules(declarationMergedRules);
81
+
82
+ for (const rule of finalRules) {
83
+ output.push(stringifyRule(rule, context));
84
+ }
85
+
86
+ return output.join('');
87
+ }
88
+
89
+ return source;
90
+ };
@@ -0,0 +1,199 @@
1
+ /**
2
+ * @file Handles `@position-try` rule analysis, usage tracking, and dead-rule elimination during CSS minification.
3
+ */
4
+
5
+ /**
6
+ * Scans top-level rules to register `@property` custom properties in the context and collect `@position-try` rule declarations and initial usage counts.
7
+ *
8
+ * @param {Array} rules The top-level AST rule nodes to scan.
9
+ * @param {object} context The minification context to populate with custom property registrations.
10
+ * @return {object} An object with positionTryRules Map and positionTryUsage Map.
11
+ */
12
+ function collectRuleMetadata (rules, context) {
13
+ const positionTryRules = new Map();
14
+ const positionTryUsage = new Map();
15
+
16
+ for (const rule of rules) {
17
+ if (rule.type === 'property' && rule.name) {
18
+ context.registeredCustomProperties.add(rule.name);
19
+ const syntaxDeclaration = (rule.declarations || []).find((declaration) => {
20
+ return declaration.type !== 'whitespace' && declaration.property === 'syntax';
21
+ });
22
+ if (syntaxDeclaration?.value) {
23
+ context.registeredCustomPropertySyntax.set(rule.name, syntaxDeclaration.value);
24
+ }
25
+ }
26
+ if (rule.type === 'position-try') {
27
+ const declarations = (rule.declarations || []).filter((declaration) => {
28
+ return declaration.type !== 'whitespace';
29
+ });
30
+ if (declarations.length > 0) {
31
+ positionTryRules.set(rule.name, declarations);
32
+ positionTryUsage.set(rule.name, 0);
33
+ }
34
+ }
35
+ }
36
+
37
+ return {
38
+ positionTryRules,
39
+ positionTryUsage
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Walks rules to resolve position-try-fallbacks references, replacing simple single-declaration patterns with built-in keywords like flip-block and tracking usage counts.
45
+ *
46
+ * @param {Array} rules The AST rule nodes to analyze.
47
+ * @param {function(object): string} minifyValue The value minification function for resolving declaration values.
48
+ * @param {Map} positionTryRules Map of `@position-try` names to their declaration arrays.
49
+ * @param {Map} positionTryUsage Map of `@position-try` names to their reference counts.
50
+ */
51
+ function analyzePositionTryRules (rules, minifyValue, positionTryRules, positionTryUsage) {
52
+ for (const rule of rules) {
53
+ if (rule.type === 'rule') {
54
+ let basePositionArea = null;
55
+ for (const declaration of rule.declarations || []) {
56
+ if (declaration.type !== 'whitespace' && declaration.property === 'position-area') {
57
+ basePositionArea = minifyValue(declaration);
58
+ }
59
+ }
60
+
61
+ for (const declaration of rule.declarations || []) {
62
+ const isPositionTryProperty = (
63
+ declaration.type !== 'whitespace' &&
64
+ (declaration.property === 'position-try-fallbacks' || declaration.property === 'position-try')
65
+ );
66
+ if (!isPositionTryProperty) {
67
+ continue;
68
+ }
69
+
70
+ const minifiedValue = minifyValue(declaration);
71
+ const parts = minifiedValue.split(',').map((segment) => {
72
+ return segment.trim();
73
+ });
74
+ const replacedParts = [];
75
+
76
+ for (const part of parts) {
77
+ if (!part.startsWith('--')) {
78
+ replacedParts.push(part);
79
+ continue;
80
+ }
81
+
82
+ if (!positionTryRules.has(part)) {
83
+ replacedParts.push(part);
84
+ continue;
85
+ }
86
+
87
+ const tryDeclarations = positionTryRules.get(part);
88
+ const isSinglePositionArea = (
89
+ tryDeclarations.length === 1 &&
90
+ tryDeclarations[0].property === 'position-area' &&
91
+ basePositionArea
92
+ );
93
+
94
+ if (isSinglePositionArea) {
95
+ const tryValue = minifyValue(tryDeclarations[0]);
96
+ const isVerticalFlip = (
97
+ (basePositionArea === 'top' && tryValue === 'bottom') ||
98
+ (basePositionArea === 'bottom' && tryValue === 'top')
99
+ );
100
+
101
+ if (isVerticalFlip && parts.length === 1) {
102
+ replacedParts.push('flip-block');
103
+ continue;
104
+ }
105
+ }
106
+
107
+ replacedParts.push(part);
108
+ const currentUsage = positionTryUsage.get(part);
109
+ positionTryUsage.set(part, currentUsage + 1);
110
+ }
111
+
112
+ declaration.value = replacedParts.join(',');
113
+ }
114
+ } else if (rule.rules) {
115
+ analyzePositionTryRules(rule.rules, minifyValue, positionTryRules, positionTryUsage);
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Removes empty position-try-fallbacks and position-try declarations left with blank values after analysis inlined their references.
122
+ *
123
+ * @param {Array} rules The AST rule nodes to clean in place.
124
+ */
125
+ function cleanPositionTryRules (rules) {
126
+ for (const rule of rules) {
127
+ if (rule.type === 'rule') {
128
+ rule.declarations = (rule.declarations || []).filter((declaration) => {
129
+ const isPositionTryProperty = (
130
+ declaration.type !== 'whitespace' &&
131
+ (declaration.property === 'position-try-fallbacks' || declaration.property === 'position-try')
132
+ );
133
+ if (isPositionTryProperty) {
134
+ return declaration.value !== '';
135
+ }
136
+ return true;
137
+ });
138
+ } else if (rule.rules) {
139
+ cleanPositionTryRules(rule.rules);
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Filters out unused `@position-try` rules whose references were fully inlined.
146
+ *
147
+ * @param {Array} rules The top-level AST rule nodes to filter.
148
+ * @param {Map} positionTryRules Map of `@position-try` names to their declaration arrays.
149
+ * @param {Map} positionTryUsage Map of `@position-try` names to their reference counts.
150
+ * @return {Array} A new array of rules with unused `@position-try` entries removed.
151
+ */
152
+ function filterUnusedPositionTry (rules, positionTryRules, positionTryUsage) {
153
+ return rules.filter((rule) => {
154
+ if (rule.type === 'position-try') {
155
+ if (!positionTryRules.has(rule.name)) {
156
+ return false;
157
+ }
158
+ if (positionTryUsage.get(rule.name) === 0) {
159
+ return false;
160
+ }
161
+ }
162
+ return true;
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Removes duplicate and redundant UTF-8 `@charset` rules, keeping only the first
168
+ * non-UTF-8 charset declaration.
169
+ *
170
+ * @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
+ */
173
+ function filterRedundantCharsets (rules) {
174
+ let firstCharsetFound = false;
175
+
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;
186
+ }
187
+ return false;
188
+ }
189
+ return true;
190
+ });
191
+ }
192
+
193
+ export {
194
+ analyzePositionTryRules,
195
+ cleanPositionTryRules,
196
+ collectRuleMetadata,
197
+ filterRedundantCharsets,
198
+ filterUnusedPositionTry
199
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file Preprocesses CSS declaration blocks by converting Unicode escape sequences to their literal characters before parsing.
3
+ */
4
+
5
+ import { resolveUnicodeEscape } from './utilities.js';
6
+
7
+ /**
8
+ * Converts CSS Unicode escape sequences inside declaration blocks to their literal character equivalents, while preserving control characters that must remain escaped.
9
+ *
10
+ * @param {string} css The raw CSS string to preprocess.
11
+ * @return {string} The CSS string with printable Unicode escapes resolved inside declaration blocks.
12
+ */
13
+ function preprocessDeclarationBlocks (css) {
14
+ // Match top-level declaration blocks (non-nested { ... })
15
+ return css.replace(/\{([^{}]*)\}/g, (match, content) => {
16
+ // Skip quoted strings, then match CSS unicode escapes (backslash + 1-6 hex digits + optional whitespace)
17
+ const processed = content.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\\([0-9a-fA-F]{1,6})\s?/g, (fullMatch, hex) => {
18
+ if (!hex) {
19
+ return fullMatch;
20
+ }
21
+ return resolveUnicodeEscape(hex) ?? fullMatch;
22
+ });
23
+ return '{' + processed + '}';
24
+ });
25
+ }
26
+
27
+ export { preprocessDeclarationBlocks };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @file Normalizes CSS selectors, `@media` queries, and `@supports` conditions by unescaping identifiers and collapsing whitespace.
3
+ */
4
+
5
+ import { resolveUnicodeEscape } from '../utilities.js';
6
+
7
+ /**
8
+ * Converts CSS Unicode escape sequences in an identifier to their literal characters, preserving control characters that must remain escaped.
9
+ *
10
+ * @param {string} identifier The CSS identifier string to unescape.
11
+ * @return {string} The identifier with printable Unicode escapes resolved.
12
+ */
13
+ function unescapeIdent (identifier) {
14
+ // Match CSS unicode escapes: backslash + 1-6 hex digits + optional trailing whitespace
15
+ return identifier.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
16
+ return resolveUnicodeEscape(hex) ?? match;
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Converts CSS Unicode escape sequences in a selector to literal characters, preserving escapes that are syntactically required such as leading digits after class or ID selectors.
22
+ *
23
+ * @param {string} selector The CSS selector string to unescape.
24
+ * @return {string} The selector with safe Unicode escapes resolved.
25
+ */
26
+ function unescapeSelector (selector) {
27
+ // Match CSS unicode escapes: backslash + 1-6 hex digits + optional trailing whitespace
28
+ return selector.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex, offset) => {
29
+ const character = resolveUnicodeEscape(hex);
30
+ if (character === null) {
31
+ return match;
32
+ }
33
+ let precedingCharacter;
34
+ if (offset > 0) {
35
+ precedingCharacter = selector[offset - 1];
36
+ } else {
37
+ precedingCharacter = '';
38
+ }
39
+ // Check if the unescaped character is a digit that would form an invalid start of a class/id name
40
+ const isLeadingDigitAfterSelector = (
41
+ /[0-9]/.test(character) &&
42
+ (offset === 0 || precedingCharacter === '.' || precedingCharacter === '#')
43
+ );
44
+ if (isLeadingDigitAfterSelector) {
45
+ return match;
46
+ }
47
+ return character;
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Normalizes a `@media` query string by collapsing whitespace, stripping the default "all and" prefix, and converting min/max-width to range syntax.
53
+ *
54
+ * @param {string} media The raw `@media` query string.
55
+ * @return {string} The normalized and minified media query.
56
+ */
57
+ function normalizeMedia (media) {
58
+ // Collapse whitespace, strip spaces around punctuation, and remove the redundant "all and" prefix
59
+ media = media.replace(/\s+/g, ' ').replace(/\s*([:,])\s*/g, '$1').replace(/\s*([=<>])\s*/g, '$1').replace(/\(\s+/g, '(').replace(/\s+\)/g, ')').replace(/\b(?:all and )/gi, '');
60
+ // Convert min-width/max-width to range syntax (e.g. min-width:768px → width>=768px)
61
+ media = media.replace(/min-width:(\d+[a-z%]*)/gi, 'width>=$1').replace(/max-width:(\d+[a-z%]*)/gi, 'width<=$1');
62
+ media = media.replace(/min-height:(\d+[a-z%]*)/gi, 'height>=$1').replace(/max-height:(\d+[a-z%]*)/gi, 'height<=$1');
63
+ // Combine adjacent min+max range queries into a single range expression: (width>=X) and (width<=Y) → (X<=width<=Y)
64
+ media = media.replace(
65
+ /\((\w+)>=(\d+[a-z%]*)\)\s+and\s+\((\w+)<=(\d+[a-z%]*)\)/gi,
66
+ (fullMatch, minProperty, minValue, maxProperty, maxValue) => {
67
+ if (minProperty === maxProperty) {
68
+ return '(' + minValue + '<=' + minProperty + '<=' + maxValue + ')';
69
+ }
70
+ return fullMatch;
71
+ }
72
+ );
73
+ return media;
74
+ }
75
+
76
+ /**
77
+ * Normalizes a `@supports` condition string by collapsing whitespace, trimming, and standardizing spacing around logical operators.
78
+ *
79
+ * @param {string} supports The raw `@supports` condition string.
80
+ * @return {string} The normalized `@supports` condition.
81
+ */
82
+ function normalizeSupports (supports) {
83
+ // Collapse whitespace and strip spaces around punctuation
84
+ supports = supports.replace(/\s+/g, ' ').replace(/\s*([:,])\s*/g, '$1').replace(/\s*([=<>])\s*/g, '$1').replace(/\(\s+/g, '(').replace(/\s+\)/g, ')').trim();
85
+ supports = supports.replace(/\s+and\s+/g, ' and ').replace(/\s+or\s+/g, ' or ').replace(/\s+not\s+/g, ' not ');
86
+ // Compact logical operator spacing: ") and (" → ")and ("
87
+ supports = supports.replace(/\)\s*and\s*\(/g, ')and (');
88
+ supports = supports.replace(/\)\s*or\s*\(/g, ')or (');
89
+ return supports;
90
+ }
91
+
92
+ /**
93
+ * Checks if a `@supports` condition tests for universally supported features like display:grid or display:flex, allowing the `@supports` wrapper to be safely removed.
94
+ *
95
+ * @param {string} supports The normalized `@supports` condition string.
96
+ * @return {boolean} True if the `@supports` block can be unwrapped.
97
+ */
98
+ function canUnwrapSupports (supports) {
99
+ return supports === '(display:grid)' || supports === '(display:flex)';
100
+ }
101
+
102
+ export {
103
+ canUnwrapSupports,
104
+ normalizeMedia,
105
+ normalizeSupports,
106
+ unescapeIdent,
107
+ unescapeSelector
108
+ };