@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/README.md +213 -0
- package/index.js +5 -0
- package/package.json +48 -0
- package/src/context.js +17 -0
- package/src/declarations/config.js +56 -0
- package/src/declarations/process.js +662 -0
- package/src/index.js +90 -0
- package/src/position-try.js +199 -0
- package/src/preprocess.js +27 -0
- package/src/rules/normalize.js +108 -0
- package/src/rules/optimize.js +375 -0
- package/src/rules/stringify.js +556 -0
- package/src/utilities.js +37 -0
- package/src/value/colors.js +780 -0
- package/src/value/gradients.js +121 -0
- package/src/value/math.js +281 -0
- package/src/value/minify.js +716 -0
- package/src/value/named-colors.js +159 -0
- package/src/value/shared.js +146 -0
- package/src/value/transforms.js +222 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Converts parsed CSS AST rule nodes into minified CSS strings, handling all CSS at-rule types, selectors, declarations, and nesting.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { processDeclarations } from '../declarations/process.js';
|
|
6
|
+
import { minifyValue } from '../value/minify.js';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
canUnwrapSupports,
|
|
10
|
+
normalizeMedia,
|
|
11
|
+
normalizeSupports,
|
|
12
|
+
unescapeIdent,
|
|
13
|
+
unescapeSelector
|
|
14
|
+
} from './normalize.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Renders an array of CSS declaration objects as a minified semicolon-separated string, filtering out whitespace entries.
|
|
18
|
+
*
|
|
19
|
+
* @param {Array} declarations The declaration objects to render.
|
|
20
|
+
* @return {string} A semicolon-joined "property:value" string.
|
|
21
|
+
*/
|
|
22
|
+
function stringifyDeclarations (declarations) {
|
|
23
|
+
return declarations
|
|
24
|
+
.filter((declaration) => {
|
|
25
|
+
return declaration.type !== 'whitespace';
|
|
26
|
+
})
|
|
27
|
+
.map((declaration) => {
|
|
28
|
+
return [declaration.property, ':', minifyValue(declaration)].join('');
|
|
29
|
+
})
|
|
30
|
+
.join(';');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Recursively stringifies child rules into a concatenated minified CSS string.
|
|
35
|
+
*
|
|
36
|
+
* @param {Array} rules The child AST rule nodes to stringify.
|
|
37
|
+
* @param {object} context The minification context.
|
|
38
|
+
* @return {string} The concatenated minified CSS for all child rules.
|
|
39
|
+
*/
|
|
40
|
+
function stringifyChildRules (rules, context) {
|
|
41
|
+
return (rules || []).map((childRule) => {
|
|
42
|
+
return stringifyRule(childRule, context);
|
|
43
|
+
}).join('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Processes a bare `:is()` selector by merging `:link`+`:visited` into `:any-link`,
|
|
48
|
+
* de-duplicating, sorting alphabetically, and conditionally expanding into individual
|
|
49
|
+
* selectors when all parts are simple type/universal selectors with no modifications.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} selector A minified CSS selector string.
|
|
52
|
+
* @return {Array} An array of one or more processed selector strings.
|
|
53
|
+
*/
|
|
54
|
+
function processIsSelector (selector) {
|
|
55
|
+
// Replace :is(:link,:visited) and :is(:visited,:link) with :any-link
|
|
56
|
+
selector = selector.replace(/:is\(:link,:visited\)/g, ':any-link');
|
|
57
|
+
selector = selector.replace(/:is\(:visited,:link\)/g, ':any-link');
|
|
58
|
+
// Only process bare :is() selectors (where :is() is the entire selector)
|
|
59
|
+
if (!selector.startsWith(':is(')) {
|
|
60
|
+
return [selector];
|
|
61
|
+
}
|
|
62
|
+
let depth = 0;
|
|
63
|
+
let closingIndex = -1;
|
|
64
|
+
for (let index = 4; index < selector.length; index++) {
|
|
65
|
+
if (selector[index] === '(') {
|
|
66
|
+
depth++;
|
|
67
|
+
} else if (selector[index] === ')') {
|
|
68
|
+
if (depth === 0) {
|
|
69
|
+
closingIndex = index;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
depth--;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (closingIndex !== selector.length - 1) {
|
|
76
|
+
return [selector];
|
|
77
|
+
}
|
|
78
|
+
const content = selector.slice(4, -1);
|
|
79
|
+
let parts = [];
|
|
80
|
+
let currentPart = '';
|
|
81
|
+
let parenDepth = 0;
|
|
82
|
+
for (const character of content) {
|
|
83
|
+
if (character === '(') {
|
|
84
|
+
parenDepth++;
|
|
85
|
+
} else if (character === ')') {
|
|
86
|
+
parenDepth--;
|
|
87
|
+
}
|
|
88
|
+
if (character === ',' && parenDepth === 0) {
|
|
89
|
+
parts.push(currentPart);
|
|
90
|
+
currentPart = '';
|
|
91
|
+
} else {
|
|
92
|
+
currentPart += character;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
parts.push(currentPart);
|
|
96
|
+
const originalCount = parts.length;
|
|
97
|
+
// Replace :link + :visited with :any-link
|
|
98
|
+
const hasLink = parts.includes(':link');
|
|
99
|
+
const hasVisited = parts.includes(':visited');
|
|
100
|
+
if (hasLink && hasVisited) {
|
|
101
|
+
parts = parts.filter((part) => {
|
|
102
|
+
return part !== ':link' && part !== ':visited';
|
|
103
|
+
});
|
|
104
|
+
if (!parts.includes(':any-link')) {
|
|
105
|
+
parts.push(':any-link');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// De-duplicate
|
|
109
|
+
parts = [...new Set(parts)];
|
|
110
|
+
// Sort alphabetically
|
|
111
|
+
parts.sort();
|
|
112
|
+
// Unwrap :is() with a single selector
|
|
113
|
+
if (parts.length === 1) {
|
|
114
|
+
return parts;
|
|
115
|
+
}
|
|
116
|
+
// Expand if all parts are simple type/universal selectors and no dedup/replacement occurred
|
|
117
|
+
const allSimple = parts.every((part) => {
|
|
118
|
+
return /^[a-z*][a-z0-9-]*$/i.test(part);
|
|
119
|
+
});
|
|
120
|
+
if (allSimple && parts.length === originalCount) {
|
|
121
|
+
return parts;
|
|
122
|
+
}
|
|
123
|
+
return [':is(' + parts.join(',') + ')'];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Converts a parsed CSS AST rule node into a minified CSS string, dispatching to specialized handlers for each rule type including selectors, `@media`, `@keyframes`, `@layer`, and other at-rules.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} rule The AST rule node to stringify.
|
|
130
|
+
* @param {object} context The minification context with registered custom property data.
|
|
131
|
+
* @param {boolean} nested Whether this rule is nested inside another rule, affecting spacing.
|
|
132
|
+
* @return {string} The minified CSS string for this rule, or an empty string if the rule is empty.
|
|
133
|
+
*/
|
|
134
|
+
function stringifyRule (rule, context, nested = false) {
|
|
135
|
+
if (rule.type === 'rule') {
|
|
136
|
+
let declarations = rule.declarations
|
|
137
|
+
?.filter((declaration) => {
|
|
138
|
+
return declaration.type !== 'whitespace';
|
|
139
|
+
}) || [];
|
|
140
|
+
|
|
141
|
+
// Ignore empty rules (comments-only rules are also effectively empty)
|
|
142
|
+
const isEffectivelyEmpty = (
|
|
143
|
+
declarations.length === 0 ||
|
|
144
|
+
declarations.every((declaration) => {
|
|
145
|
+
return declaration.type === 'comment' && !declaration.comment?.startsWith('!');
|
|
146
|
+
})
|
|
147
|
+
);
|
|
148
|
+
if (isEffectivelyEmpty) {
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let output = [];
|
|
153
|
+
if (rule.selectors?.length) {
|
|
154
|
+
let uniqueSelectors = [...new Set(rule.selectors)];
|
|
155
|
+
// Minify spacing within selectors (e.g. inside :is(), :where(), etc)
|
|
156
|
+
uniqueSelectors = uniqueSelectors.map((selector) => {
|
|
157
|
+
let minified = unescapeSelector(selector);
|
|
158
|
+
// Collapse whitespace to single space
|
|
159
|
+
minified = minified.replace(/\s+/g, ' ');
|
|
160
|
+
// Strip whitespace around selector combinators and commas
|
|
161
|
+
minified = minified.replace(/\s*([,>+~])\s*/g, '$1');
|
|
162
|
+
// Strip whitespace inside parentheses for pseudo-class arguments
|
|
163
|
+
minified = minified.replace(/\(\s+/g, '(').replace(/\s+\)/g, ')');
|
|
164
|
+
// Simplify :nth-child(2n+1) to :nth-child(odd)
|
|
165
|
+
minified = minified.replace(/:nth-child\(2n\s*\+\s*1\)/g, ':nth-child(odd)');
|
|
166
|
+
// Simplify :nth-child(2n+0) to :nth-child(2n)
|
|
167
|
+
minified = minified.replace(/:nth-child\(2n\s*\+\s*0\)/g, ':nth-child(2n)');
|
|
168
|
+
// Simplify :nth-child(1n) to :nth-child(n)
|
|
169
|
+
minified = minified.replace(/:nth-child\(1n\)/g, ':nth-child(n)');
|
|
170
|
+
// Remove unnecessary leading + sign from :nth-child()
|
|
171
|
+
minified = minified.replace(/:nth-child\(\+\s*(\d+)\)/g, ':nth-child($1)');
|
|
172
|
+
// Simplify :nth-child(0n+N) to :nth-child(N)
|
|
173
|
+
minified = minified.replace(/:nth-child\(0n\s*\+\s*(\d+)\)/g, ':nth-child($1)');
|
|
174
|
+
// Replace :nth-child(1) with :first-child
|
|
175
|
+
minified = minified.replace(/:nth-child\(1\)/g, ':first-child');
|
|
176
|
+
// Replace :nth-last-child(1) with :last-child
|
|
177
|
+
minified = minified.replace(/:nth-last-child\(1\)/g, ':last-child');
|
|
178
|
+
// Replace :nth-of-type(1) with :first-of-type
|
|
179
|
+
minified = minified.replace(/:nth-of-type\(1\)/g, ':first-of-type');
|
|
180
|
+
// Replace :nth-last-of-type(1) with :last-of-type
|
|
181
|
+
minified = minified.replace(/:nth-last-of-type\(1\)/g, ':last-of-type');
|
|
182
|
+
// Convert double-colon pseudo-elements to single-colon legacy form
|
|
183
|
+
minified = minified.replace(/::before/g, ':before');
|
|
184
|
+
minified = minified.replace(/::after/g, ':after');
|
|
185
|
+
|
|
186
|
+
// Strip redundant universal selector `*` when it precedes an ID, class, or attribute selector
|
|
187
|
+
minified = minified.replace(/\*([#.[])/g, '$1');
|
|
188
|
+
|
|
189
|
+
// Minify double-quoted attribute selectors: remove inner whitespace and escape when shorter
|
|
190
|
+
minified = minified.replace(/\[\s*([^=]+)\s*=\s*"(.*?)"\s*\]/g, (match, attribute, value) => {
|
|
191
|
+
// Escape special characters that require quoting, and compare lengths
|
|
192
|
+
let escaped = value.replace(/([#.:/])/g, '\\$1');
|
|
193
|
+
if (escaped.length < value.length + 2) {
|
|
194
|
+
return '[' + attribute + '=' + escaped + ']';
|
|
195
|
+
}
|
|
196
|
+
return '[' + attribute + '="' + value + '"]';
|
|
197
|
+
});
|
|
198
|
+
// Minify single-quoted attribute selectors: remove inner whitespace and escape when shorter
|
|
199
|
+
minified = minified.replace(/\[\s*([^=]+)\s*=\s*'(.*?)'\s*\]/g, (match, attribute, value) => {
|
|
200
|
+
// Escape special characters that require quoting, and compare lengths
|
|
201
|
+
let escaped = value.replace(/([#.:/])/g, '\\$1');
|
|
202
|
+
if (escaped.length < value.length + 2) {
|
|
203
|
+
return '[' + attribute + '=' + escaped + ']';
|
|
204
|
+
}
|
|
205
|
+
return '[' + attribute + '="' + value + '"]';
|
|
206
|
+
});
|
|
207
|
+
// Minify unquoted attribute selectors: quote when unescaping produces a shorter result
|
|
208
|
+
minified = minified.replace(/\[\s*([^=]+)\s*=\s*([^"'].*?)\s*\]/g, (match, attribute, value) => {
|
|
209
|
+
// Unescape special characters and compare with quoted form
|
|
210
|
+
let unescaped = value.replace(/\\([#.:/])/g, '$1');
|
|
211
|
+
if (unescaped.length + 2 < value.length) {
|
|
212
|
+
return '[' + attribute + '="' + unescaped + '"]';
|
|
213
|
+
}
|
|
214
|
+
return '[' + attribute + '=' + value + ']';
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Minify logical combinations
|
|
218
|
+
minified = minified.replace(/(?<=\b(?:button|fieldset|form|input|select|textarea)):not\(:invalid\)/g, ':valid');
|
|
219
|
+
minified = minified.replace(/:not\(:dir\(ltr\)\)/g, ':dir(rtl)');
|
|
220
|
+
minified = minified.replace(/:not\(:not\((.*?)\)\)/g, '$1');
|
|
221
|
+
minified = minified.replace(/:not\(:enabled\)/g, ':disabled');
|
|
222
|
+
minified = minified.replace(/:not\(:required\)/g, ':optional');
|
|
223
|
+
minified = minified.replace(/(^|[\s,>+~])(a|area|link)(?:\[.*?\])*(?::where\()?:not\(:link\)\)?/g, (match) => {
|
|
224
|
+
return match.replace(':not(:link)', ':visited');
|
|
225
|
+
});
|
|
226
|
+
// Remove redundant leading "& " nesting selector
|
|
227
|
+
minified = minified.replace(/^& /, '');
|
|
228
|
+
return minified;
|
|
229
|
+
});
|
|
230
|
+
uniqueSelectors = uniqueSelectors.flatMap(processIsSelector);
|
|
231
|
+
uniqueSelectors = [...new Set(uniqueSelectors)];
|
|
232
|
+
const headingSet = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
|
233
|
+
const isAllHeadings = (
|
|
234
|
+
rule.selectors.length === 6 &&
|
|
235
|
+
uniqueSelectors.length === 6 &&
|
|
236
|
+
uniqueSelectors.every((selector) => {
|
|
237
|
+
return headingSet.has(selector);
|
|
238
|
+
})
|
|
239
|
+
);
|
|
240
|
+
if (isAllHeadings) {
|
|
241
|
+
uniqueSelectors = [':heading'];
|
|
242
|
+
}
|
|
243
|
+
output.push(uniqueSelectors.join(','));
|
|
244
|
+
}
|
|
245
|
+
output.push('{');
|
|
246
|
+
|
|
247
|
+
declarations = processDeclarations(declarations, context);
|
|
248
|
+
|
|
249
|
+
// We need to properly output nested rules vs normal declarations.
|
|
250
|
+
// Wait, the processDeclarations will now correctly keep 'rule' types because we push them in processDeclarations.
|
|
251
|
+
// If we have `.foo { .bar { color: red; } }`, the inner rule is a declaration of type 'rule' with selectors: ['.bar'].
|
|
252
|
+
// The previous stringifyRule recursively calls stringifyRule.
|
|
253
|
+
|
|
254
|
+
let innerDeclarations = declarations.filter((declaration) => {
|
|
255
|
+
return declaration.type !== 'rule' && declaration.type !== 'media';
|
|
256
|
+
});
|
|
257
|
+
let nestedRules = declarations.filter((declaration) => {
|
|
258
|
+
return declaration.type === 'rule' || declaration.type === 'media';
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
let renderedDeclarations = innerDeclarations
|
|
262
|
+
.map((declaration) => {
|
|
263
|
+
if (!declaration.property) {
|
|
264
|
+
return '';
|
|
265
|
+
}
|
|
266
|
+
const property = unescapeIdent(declaration.property);
|
|
267
|
+
let value;
|
|
268
|
+
if (property.startsWith('--')) {
|
|
269
|
+
const syntax = context.registeredCustomPropertySyntax.get(property);
|
|
270
|
+
if (syntax === '"<color>"') {
|
|
271
|
+
value = minifyValue(declaration);
|
|
272
|
+
} else {
|
|
273
|
+
const rawValue = declaration.rawValue || declaration.value || '';
|
|
274
|
+
const trimmedRawValue = rawValue.trim();
|
|
275
|
+
if (trimmedRawValue === '') {
|
|
276
|
+
value = ' ';
|
|
277
|
+
// Preserve leading space for rgb() space-syntax values in custom properties
|
|
278
|
+
} else if (/^rgb\(\s*\d+\s+\d+\s+\d+\s*\)$/i.test(trimmedRawValue)) {
|
|
279
|
+
value = ' ' + trimmedRawValue;
|
|
280
|
+
} else {
|
|
281
|
+
value = trimmedRawValue;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
value = minifyValue(declaration);
|
|
286
|
+
}
|
|
287
|
+
return [property, ':', value].join('');
|
|
288
|
+
})
|
|
289
|
+
.join(';');
|
|
290
|
+
|
|
291
|
+
let renderedNested = nestedRules.map((nestedRule) => {
|
|
292
|
+
return stringifyRule(nestedRule, context, true);
|
|
293
|
+
}).join('');
|
|
294
|
+
|
|
295
|
+
output.push(renderedDeclarations);
|
|
296
|
+
if (renderedDeclarations && renderedNested && !renderedDeclarations.endsWith(';')) {
|
|
297
|
+
output.push(';');
|
|
298
|
+
}
|
|
299
|
+
output.push(renderedNested);
|
|
300
|
+
|
|
301
|
+
output.push('}');
|
|
302
|
+
return output.join('');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (rule.type === 'media') {
|
|
306
|
+
const normalizedMedia = normalizeMedia(rule.media);
|
|
307
|
+
let separator;
|
|
308
|
+
if (nested && normalizedMedia.startsWith('(')) {
|
|
309
|
+
separator = '';
|
|
310
|
+
} else {
|
|
311
|
+
separator = ' ';
|
|
312
|
+
}
|
|
313
|
+
const items = rule.rules || [];
|
|
314
|
+
const mediaDeclarations = items.filter((item) => {
|
|
315
|
+
return item.type === 'declaration' && item.property;
|
|
316
|
+
});
|
|
317
|
+
const subRules = items.filter((item) => {
|
|
318
|
+
return item.type !== 'declaration';
|
|
319
|
+
});
|
|
320
|
+
const renderedDeclarations = mediaDeclarations.map((declaration) => {
|
|
321
|
+
return [unescapeIdent(declaration.property), ':', minifyValue(declaration)].join('');
|
|
322
|
+
}).join(';');
|
|
323
|
+
const renderedRules = subRules.map((childRule) => {
|
|
324
|
+
return stringifyRule(childRule, context, false);
|
|
325
|
+
}).join('');
|
|
326
|
+
const children = [renderedDeclarations, renderedRules].filter(Boolean).join('');
|
|
327
|
+
if (!children) {
|
|
328
|
+
return '';
|
|
329
|
+
}
|
|
330
|
+
return '@media' + separator + normalizedMedia + '{' + children + '}';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (rule.type === 'starting-style') {
|
|
334
|
+
const children = stringifyChildRules(rule.rules, context);
|
|
335
|
+
if (!children) {
|
|
336
|
+
return '';
|
|
337
|
+
}
|
|
338
|
+
return '@starting-style{' + children + '}';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (rule.type === 'scope') {
|
|
342
|
+
// Collapse whitespace around "to" keyword in @scope condition
|
|
343
|
+
const scope = (rule.scope || '').trim().replace(/\s+to\s+/g, 'to ');
|
|
344
|
+
const children = stringifyChildRules(rule.rules, context);
|
|
345
|
+
if (!children) {
|
|
346
|
+
return '';
|
|
347
|
+
}
|
|
348
|
+
return '@scope' + scope + '{' + children + '}';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (rule.type === 'supports') {
|
|
352
|
+
let supports = normalizeSupports(rule.supports);
|
|
353
|
+
let children = stringifyChildRules(rule.rules, context);
|
|
354
|
+
if (!children) {
|
|
355
|
+
return '';
|
|
356
|
+
}
|
|
357
|
+
if (canUnwrapSupports(supports)) {
|
|
358
|
+
return children;
|
|
359
|
+
}
|
|
360
|
+
// Check if @supports condition has adjacent logical operators that allow tight spacing
|
|
361
|
+
const needsTightSpacing = supports.startsWith('(') && /\)(?:and|or)\s*\(/.test(supports);
|
|
362
|
+
let supportsSeparator;
|
|
363
|
+
if (needsTightSpacing) {
|
|
364
|
+
supportsSeparator = '';
|
|
365
|
+
} else {
|
|
366
|
+
supportsSeparator = ' ';
|
|
367
|
+
}
|
|
368
|
+
return '@supports' + supportsSeparator + supports + '{' + children + '}';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (rule.type === 'keyframes') {
|
|
372
|
+
let children = (rule.keyframes || [])
|
|
373
|
+
.filter((keyframe) => {
|
|
374
|
+
return keyframe.type === 'keyframe';
|
|
375
|
+
})
|
|
376
|
+
.map((keyframe) => {
|
|
377
|
+
let output = [];
|
|
378
|
+
let stopValues = keyframe.values.map((stopValue) => {
|
|
379
|
+
if (stopValue === 'from') {
|
|
380
|
+
return '0%';
|
|
381
|
+
}
|
|
382
|
+
if (stopValue === '100%') {
|
|
383
|
+
return 'to';
|
|
384
|
+
}
|
|
385
|
+
return stopValue;
|
|
386
|
+
});
|
|
387
|
+
output.push(stopValues.join(','));
|
|
388
|
+
output.push('{');
|
|
389
|
+
const renderedKeyframeDeclarations = keyframe.declarations
|
|
390
|
+
?.filter((declaration) => {
|
|
391
|
+
return declaration.type !== 'whitespace';
|
|
392
|
+
})
|
|
393
|
+
?.map((declaration) => {
|
|
394
|
+
return [declaration.property, ':', minifyValue(declaration)].join('');
|
|
395
|
+
})
|
|
396
|
+
.join(';') || '';
|
|
397
|
+
output.push(renderedKeyframeDeclarations);
|
|
398
|
+
output.push('}');
|
|
399
|
+
return output.join('');
|
|
400
|
+
}).join('');
|
|
401
|
+
if (!children) {
|
|
402
|
+
return '';
|
|
403
|
+
}
|
|
404
|
+
return '@keyframes ' + rule.name + '{' + children + '}';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (rule.type === 'font-face') {
|
|
408
|
+
let renderedDeclarations = stringifyDeclarations(rule.declarations || []);
|
|
409
|
+
if (!renderedDeclarations) {
|
|
410
|
+
return '';
|
|
411
|
+
}
|
|
412
|
+
return '@font-face{' + renderedDeclarations + '}';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (rule.type === 'charset') {
|
|
416
|
+
return '@charset ' + rule.charset + ';';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (rule.type === 'import') {
|
|
420
|
+
let importStatement = rule.import;
|
|
421
|
+
// Unwrap url() with a quoted string to just the quoted string
|
|
422
|
+
importStatement = importStatement.replace(/url\(\s*(".*?"|'.*?')\s*\)/g, '$1');
|
|
423
|
+
// Unwrap url() with an unquoted path and add quotes
|
|
424
|
+
importStatement = importStatement.replace(/url\(\s*(.*?)\s*\)/g, '"$1"');
|
|
425
|
+
// Collapse whitespace in the import statement
|
|
426
|
+
importStatement = importStatement.replace(/\s+/g, ' ').trim();
|
|
427
|
+
// Remove space immediately after the quoted URL path
|
|
428
|
+
importStatement = importStatement.replace(/^(".*?"|'.*?') /, '$1');
|
|
429
|
+
// Remove space between closing paren and next at-rule condition keyword
|
|
430
|
+
importStatement = importStatement.replace(/\) ([a-zA-Z])/g, ')$1');
|
|
431
|
+
// Minify property:value pairs inside supports() by removing whitespace around colons
|
|
432
|
+
importStatement = importStatement.replace(
|
|
433
|
+
/supports\(([^()]*)\)/g,
|
|
434
|
+
(fullMatch, content) => {
|
|
435
|
+
return 'supports(' + content.replace(/\s*:\s*/g, ':') + ')';
|
|
436
|
+
}
|
|
437
|
+
);
|
|
438
|
+
const startsWithQuote = importStatement.startsWith('"') || importStatement.startsWith('\'');
|
|
439
|
+
let importSeparator;
|
|
440
|
+
if (startsWithQuote) {
|
|
441
|
+
importSeparator = '';
|
|
442
|
+
} else {
|
|
443
|
+
importSeparator = ' ';
|
|
444
|
+
}
|
|
445
|
+
return '@import' + importSeparator + importStatement + ';';
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (rule.type === 'layer') {
|
|
449
|
+
if (rule.rules && rule.rules.length) {
|
|
450
|
+
return '@layer ' + (rule.layer || '') + '{' + stringifyChildRules(rule.rules, context) + '}';
|
|
451
|
+
} else {
|
|
452
|
+
return '@layer ' + rule.layer + ';';
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (rule.type === 'property') {
|
|
457
|
+
let renderedDeclarations = stringifyDeclarations(rule.declarations || []);
|
|
458
|
+
if (!renderedDeclarations) {
|
|
459
|
+
return '';
|
|
460
|
+
}
|
|
461
|
+
return '@property ' + rule.name + '{' + renderedDeclarations + '}';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (rule.type === 'container') {
|
|
465
|
+
// Minify @container condition: collapse whitespace and strip spaces around punctuation
|
|
466
|
+
let container = rule.container
|
|
467
|
+
.replace(/\s+/g, ' ')
|
|
468
|
+
.replace(/\s*([:,])\s*/g, '$1')
|
|
469
|
+
.replace(/\s*([=<>])\s*/g, '$1')
|
|
470
|
+
.replace(/\(\s+/g, '(')
|
|
471
|
+
.replace(/\s+\)/g, ')');
|
|
472
|
+
// Convert min-width/max-width to range syntax (e.g. min-width:768px → width>=768px)
|
|
473
|
+
container = container.replace(/min-width:(\d+px)/gi, 'width>=$1').replace(/max-width:(\d+px)/gi, 'width<=$1');
|
|
474
|
+
let children = stringifyChildRules(rule.rules, context);
|
|
475
|
+
if (!children) {
|
|
476
|
+
return '';
|
|
477
|
+
}
|
|
478
|
+
let containerSeparator;
|
|
479
|
+
if (container.startsWith('(')) {
|
|
480
|
+
containerSeparator = '';
|
|
481
|
+
} else {
|
|
482
|
+
containerSeparator = ' ';
|
|
483
|
+
}
|
|
484
|
+
return '@container' + containerSeparator + container + '{' + children + '}';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (rule.type === 'page') {
|
|
488
|
+
const trimmedSelectors = (rule.selectors || []).map((selector) => {
|
|
489
|
+
return selector.trim();
|
|
490
|
+
}).filter(Boolean);
|
|
491
|
+
const selectorString = trimmedSelectors.join(',');
|
|
492
|
+
let pageSeparator = '';
|
|
493
|
+
if (selectorString) {
|
|
494
|
+
if (selectorString.startsWith(':')) {
|
|
495
|
+
pageSeparator = '';
|
|
496
|
+
} else {
|
|
497
|
+
pageSeparator = ' ';
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const parts = (rule.declarations || [])
|
|
501
|
+
.filter((declaration) => {
|
|
502
|
+
return declaration.type !== 'whitespace';
|
|
503
|
+
})
|
|
504
|
+
.flatMap((declaration) => {
|
|
505
|
+
if (declaration.type === 'page-margin-box' && declaration.name) {
|
|
506
|
+
const innerDeclarations = (declaration.declarations || [])
|
|
507
|
+
.filter((innerDeclaration) => {
|
|
508
|
+
return innerDeclaration.type !== 'whitespace' && innerDeclaration.property;
|
|
509
|
+
})
|
|
510
|
+
.map((innerDeclaration) => {
|
|
511
|
+
return [unescapeIdent(innerDeclaration.property), ':', minifyValue(innerDeclaration)].join('');
|
|
512
|
+
})
|
|
513
|
+
.join(';');
|
|
514
|
+
if (innerDeclarations) {
|
|
515
|
+
return ['@' + declaration.name + '{' + innerDeclarations + '}'];
|
|
516
|
+
}
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
if (declaration.property) {
|
|
520
|
+
return [[unescapeIdent(declaration.property), ':', minifyValue(declaration)].join('')];
|
|
521
|
+
}
|
|
522
|
+
return [];
|
|
523
|
+
});
|
|
524
|
+
if (!parts.length) {
|
|
525
|
+
return '';
|
|
526
|
+
}
|
|
527
|
+
return '@page' + pageSeparator + selectorString + '{' + parts.join(';') + '}';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (rule.type === 'counter-style') {
|
|
531
|
+
let renderedDeclarations = stringifyDeclarations(rule.declarations || []);
|
|
532
|
+
if (!renderedDeclarations) {
|
|
533
|
+
return '';
|
|
534
|
+
}
|
|
535
|
+
return '@counter-style ' + rule.name + '{' + renderedDeclarations + '}';
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (rule.type === 'position-try') {
|
|
539
|
+
let renderedDeclarations = stringifyDeclarations(rule.declarations || []);
|
|
540
|
+
if (!renderedDeclarations) {
|
|
541
|
+
return '';
|
|
542
|
+
}
|
|
543
|
+
return '@position-try ' + rule.name + '{' + renderedDeclarations + '}';
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (rule.type === 'comment') {
|
|
547
|
+
if (rule.comment.startsWith('!')) {
|
|
548
|
+
return '/*' + rule.comment + '*/';
|
|
549
|
+
}
|
|
550
|
+
return '';
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return ''; // Ignore unknown for now
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export { stringifyRule };
|
package/src/utilities.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file General CSS utilities shared across the minification pipeline.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves a CSS Unicode escape hex string to its literal character.
|
|
7
|
+
* Returns null for control characters (code points below 0x20 or equal to 0x7F),
|
|
8
|
+
* which must remain escaped in CSS.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} hex The hex digit string (1–6 characters) from a CSS escape sequence.
|
|
11
|
+
* @return {string|null} The resolved character, or null if the code point is a control character.
|
|
12
|
+
*/
|
|
13
|
+
function resolveUnicodeEscape (hex) {
|
|
14
|
+
const codePoint = parseInt(hex, 16);
|
|
15
|
+
const isControlCharacter = codePoint < 0x20 || codePoint === 0x7f;
|
|
16
|
+
if (isControlCharacter) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return String.fromCodePoint(codePoint);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Escapes special regex metacharacters in a string so it can be safely used
|
|
24
|
+
* as a literal pattern in a RegExp constructor.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} input The string to escape.
|
|
27
|
+
* @return {string} The escaped string safe for use in a RegExp.
|
|
28
|
+
*/
|
|
29
|
+
function escapeRegexString (input) {
|
|
30
|
+
// Escape all regex metacharacters: . * + ? ^ $ { } ( ) | [ ] backslash
|
|
31
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
escapeRegexString,
|
|
36
|
+
resolveUnicodeEscape
|
|
37
|
+
};
|