@thejaredwilcurt/csslop 0.0.44 → 0.0.46
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 +1 -1
- package/package.json +4 -4
- package/src/declarations/border.js +47 -33
- package/src/declarations/process.js +2 -2
- package/src/index.js +6 -33
- package/src/parser/source-search.js +7 -2
- package/src/rules/optimize.js +170 -2
- package/src/rules/selectors.js +41 -0
- package/src/rules/stringify.js +75 -1
- package/src/rules/validate.js +145 -0
- package/src/value/font.js +111 -0
- package/src/value/minify.js +32 -26
- package/src/value/tokens.js +38 -1
package/README.md
CHANGED
|
@@ -296,7 +296,7 @@ Two different cases:
|
|
|
296
296
|
1. `git add -A && git commit -m "Updated tests"`
|
|
297
297
|
1. Then run `npm t` to see if any tests fail
|
|
298
298
|
1. If they fail, give an AI this prompt:
|
|
299
|
-
* **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Any test fails related to idempotency should be handled last. Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. When all done, run the `beep` command to alert me you finished.
|
|
299
|
+
* **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Any test fails related to idempotency should be handled last. DO NOT touch anything related to real-world tests (including `npm run idem` and `npm run real`). Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. When all done, run the `beep` command to alert me you finished.
|
|
300
300
|
1. Verify only code in the `src` folder was modified
|
|
301
301
|
1. Verify `npm t` passes with a 100% score
|
|
302
302
|
1. Run `npm run lint`, if anything fails, have the AI fix it.
|
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.
|
|
5
|
+
"version": "0.0.46",
|
|
6
6
|
"description": "Experimental CSS minification",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"prestart": "node ./scripts/pre/start.js",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"proto": "proto pin node latest --resolve --tool-native && proto pin npm latest --resolve --tool-native && git status"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@codemirror/autocomplete": "^6.20.
|
|
26
|
+
"@codemirror/autocomplete": "^6.20.4",
|
|
27
27
|
"@codemirror/lang-css": "^6.3.1",
|
|
28
|
-
"@codemirror/view": "^6.43.
|
|
28
|
+
"@codemirror/view": "^6.43.15",
|
|
29
29
|
"@eslint/js": "^10.0.1",
|
|
30
30
|
"@node-projects/css-parser": "^5.2.0",
|
|
31
31
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"eslint-config-tjw-import-x": "^1.0.1",
|
|
36
36
|
"eslint-config-tjw-jsdoc": "^2.0.1",
|
|
37
37
|
"eslint-plugin-import-x": "^4.17.0",
|
|
38
|
-
"eslint-plugin-jsdoc": "^65.
|
|
38
|
+
"eslint-plugin-jsdoc": "^65.4.0",
|
|
39
39
|
"fflate": "^0.8.3",
|
|
40
40
|
"globals": "^17.13.0",
|
|
41
41
|
"pretty-ms": "^10.0.0",
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
import { splitTopLevelComponents } from '../parser/source-search.js';
|
|
6
6
|
import { minifyValue } from '../value/minify.js';
|
|
7
7
|
import { namedColors } from '../value/named-colors.js';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
collapseShorthandParts,
|
|
10
|
+
hasSubstitutedParts
|
|
11
|
+
} from '../value/shared.js';
|
|
9
12
|
|
|
10
13
|
import {
|
|
11
14
|
BORDER_EDGE_PROPERTIES,
|
|
@@ -107,16 +110,14 @@ function findLastDeclarationIndex (declarations, property) {
|
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
/**
|
|
110
|
-
* Rewrites a
|
|
111
|
-
*
|
|
112
|
-
* by a `border-color` override that restores the per-edge colors. The rewrite is
|
|
113
|
-
* only applied when the resulting pair is shorter than the three longhands.
|
|
113
|
+
* Rewrites a border trio into a shorthand and overrides for components that
|
|
114
|
+
* differ by edge. Only shorter rewrites with uniform importance are used.
|
|
114
115
|
*
|
|
115
116
|
* @param {Array} declarations The declarations of a single rule.
|
|
116
117
|
* @param {object} context The minification context with the stylesheet's reset properties.
|
|
117
118
|
* @return {Array} The declarations, with the trio rewritten when it is shorter.
|
|
118
119
|
*/
|
|
119
|
-
function
|
|
120
|
+
function collapseBorderTrioWithPerEdgeOverrides (declarations, context) {
|
|
120
121
|
// The `border` shorthand also resets `border-image`, so the trio stays as it
|
|
121
122
|
// is when another rule of the stylesheet relies on that value.
|
|
122
123
|
if (resetsPropertyDeclaredElsewhere('border', collectDeclaredProperties(declarations), context)) {
|
|
@@ -132,6 +133,18 @@ function collapseBorderTrioWithPerEdgeColor (declarations, context) {
|
|
|
132
133
|
if (!hasWholeTrio) {
|
|
133
134
|
return declarations;
|
|
134
135
|
}
|
|
136
|
+
const hasCompetingBorderWrites = declarations.some((declaration) => {
|
|
137
|
+
const property = declaration.property || '';
|
|
138
|
+
if (BORDER_TRIO_PROPERTIES.includes(property)) {
|
|
139
|
+
return declarations.filter((entry) => {
|
|
140
|
+
return entry.property === property;
|
|
141
|
+
}).length !== 1;
|
|
142
|
+
}
|
|
143
|
+
return property === 'border' || (property.startsWith('border-') && !property.startsWith('border-image'));
|
|
144
|
+
});
|
|
145
|
+
if (hasCompetingBorderWrites) {
|
|
146
|
+
return declarations;
|
|
147
|
+
}
|
|
135
148
|
|
|
136
149
|
const trioValues = trioIndexes.map((index) => {
|
|
137
150
|
return minifyValue(declarations[index]);
|
|
@@ -150,28 +163,38 @@ function collapseBorderTrioWithPerEdgeColor (declarations, context) {
|
|
|
150
163
|
const [width, style, color] = trioValues.map((value) => {
|
|
151
164
|
return value.replace('!important', '').trim();
|
|
152
165
|
});
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
return
|
|
156
|
-
};
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
isSingleComponent(style) &&
|
|
160
|
-
colorComponents.length > 1
|
|
161
|
-
);
|
|
162
|
-
if (!canBuildShorthand) {
|
|
166
|
+
const componentsByProperty = [width, style, color].map(splitTopLevelComponents);
|
|
167
|
+
const hasPerEdgeComponents = componentsByProperty.some((components) => {
|
|
168
|
+
return components.length > 1;
|
|
169
|
+
});
|
|
170
|
+
const allComponents = componentsByProperty.flat();
|
|
171
|
+
if (!hasPerEdgeComponents || hasSubstitutedParts(allComponents)) {
|
|
163
172
|
return declarations;
|
|
164
173
|
}
|
|
165
|
-
const usesCssWideKeyword =
|
|
174
|
+
const usesCssWideKeyword = allComponents.some((value) => {
|
|
166
175
|
return CSS_WIDE_KEYWORDS.has(value.toLowerCase());
|
|
167
176
|
});
|
|
168
177
|
if (usesCssWideKeyword) {
|
|
169
178
|
return declarations;
|
|
170
179
|
}
|
|
171
180
|
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
181
|
+
const [widthComponents, styleComponents, colorComponents] = componentsByProperty;
|
|
182
|
+
const borderValue = buildBorderShorthandValue(
|
|
183
|
+
widthComponents.length === 1 ? width : '',
|
|
184
|
+
styleComponents.length === 1 ? style : '',
|
|
185
|
+
colorComponents[0]
|
|
186
|
+
) + importantSuffix;
|
|
187
|
+
const overrides = BORDER_TRIO_PROPERTIES.flatMap((property, index) => {
|
|
188
|
+
if (componentsByProperty[index].length === 1) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
return [{
|
|
192
|
+
property,
|
|
193
|
+
value: trioValues[index]
|
|
194
|
+
}];
|
|
195
|
+
});
|
|
196
|
+
const replacement = [{ property: 'border', value: borderValue }, ...overrides];
|
|
197
|
+
const rewrittenLength = measureDeclarations(replacement);
|
|
175
198
|
const longhandLength = (
|
|
176
199
|
'border-width:' + trioValues[0] +
|
|
177
200
|
';border-style:' + trioValues[1] +
|
|
@@ -185,16 +208,7 @@ function collapseBorderTrioWithPerEdgeColor (declarations, context) {
|
|
|
185
208
|
const insertionIndex = Math.min(...trioIndexes);
|
|
186
209
|
return declarations.flatMap((declaration, index) => {
|
|
187
210
|
if (index === insertionIndex) {
|
|
188
|
-
return
|
|
189
|
-
{
|
|
190
|
-
property: 'border',
|
|
191
|
-
value: borderValue
|
|
192
|
-
},
|
|
193
|
-
{
|
|
194
|
-
property: 'border-color',
|
|
195
|
-
value: colorValue
|
|
196
|
-
}
|
|
197
|
-
];
|
|
211
|
+
return replacement;
|
|
198
212
|
}
|
|
199
213
|
if (replacedIndexes.has(index)) {
|
|
200
214
|
return [];
|
|
@@ -216,8 +230,8 @@ function classifyBorderEdgeComponent (component) {
|
|
|
216
230
|
if (BORDER_STYLE_KEYWORDS.has(keyword)) {
|
|
217
231
|
return 'border-style';
|
|
218
232
|
}
|
|
219
|
-
// Match a number
|
|
220
|
-
const isLength = /^[+-]?(?:\d+|\d*\.\d+)[a-z]*$/i.test(component);
|
|
233
|
+
// Match a number, optional exponent, and optional length unit.
|
|
234
|
+
const isLength = /^[+-]?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?[a-z]*$/i.test(component);
|
|
221
235
|
if (BORDER_WIDTH_KEYWORDS.has(keyword) || isLength || LENGTH_FUNCTION_PATTERN.test(component)) {
|
|
222
236
|
return 'border-width';
|
|
223
237
|
}
|
|
@@ -411,6 +425,6 @@ function splitBorderEdgesIntoTrio (declarations) {
|
|
|
411
425
|
}
|
|
412
426
|
|
|
413
427
|
export {
|
|
414
|
-
|
|
428
|
+
collapseBorderTrioWithPerEdgeOverrides,
|
|
415
429
|
splitBorderEdgesIntoTrio
|
|
416
430
|
};
|
|
@@ -7,7 +7,7 @@ import { hasInvalidQuotesCount } from '../value/quotes.js';
|
|
|
7
7
|
|
|
8
8
|
import { absorbBackgroundLonghandsIntoShorthand } from './background.js';
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
collapseBorderTrioWithPerEdgeOverrides,
|
|
11
11
|
splitBorderEdgesIntoTrio
|
|
12
12
|
} from './border.js';
|
|
13
13
|
import {
|
|
@@ -449,7 +449,7 @@ function processDeclarations (declarations, context) {
|
|
|
449
449
|
result = foldLonghandOverridesIntoShorthands(result, context);
|
|
450
450
|
result = hoistCssWideKeywordsIntoShorthands(result, context);
|
|
451
451
|
result = splitBorderEdgesIntoTrio(result);
|
|
452
|
-
result =
|
|
452
|
+
result = collapseBorderTrioWithPerEdgeOverrides(result, context);
|
|
453
453
|
|
|
454
454
|
return orderDeclarations(result);
|
|
455
455
|
}
|
package/src/index.js
CHANGED
|
@@ -23,24 +23,13 @@ import {
|
|
|
23
23
|
import { preprocessDeclarationBlocks } from './preprocess.js';
|
|
24
24
|
import { combineLanguageSelectors } from './rules/lang.js';
|
|
25
25
|
import { normalizeRuleSelectors } from './rules/normalize.js';
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
expandPureNestedRules,
|
|
29
|
-
factorCommonParents,
|
|
30
|
-
mergeByDeclarations,
|
|
31
|
-
mergeIdenticalNestedRules,
|
|
32
|
-
mergeLayerRules,
|
|
33
|
-
mergeMediaRules,
|
|
34
|
-
mergeSelectorRules,
|
|
35
|
-
nestFlatRules,
|
|
36
|
-
removeEmptyRules,
|
|
37
|
-
removeFullyOverriddenSelectors,
|
|
38
|
-
removeOverriddenMultiSelectorProperties
|
|
39
|
-
} from './rules/optimize.js';
|
|
26
|
+
import { optimizeRuleList } from './rules/optimize.js';
|
|
27
|
+
import { combineLegacyAnySelectors } from './rules/selectors.js';
|
|
40
28
|
import {
|
|
41
29
|
removeRedundantLayerStatementSemicolon,
|
|
42
30
|
stringifyRule
|
|
43
31
|
} from './rules/stringify.js';
|
|
32
|
+
import { removeInvalidSelectorRules } from './rules/validate.js';
|
|
44
33
|
import {
|
|
45
34
|
clearMinifiedValueCache,
|
|
46
35
|
minifyValue
|
|
@@ -64,7 +53,7 @@ function deduplicateSelectorList (selectorList) {
|
|
|
64
53
|
seen.add(selector);
|
|
65
54
|
return true;
|
|
66
55
|
});
|
|
67
|
-
return combineLanguageSelectors(unique).join(',');
|
|
56
|
+
return combineLanguageSelectors(combineLegacyAnySelectors(unique)).join(',');
|
|
68
57
|
}
|
|
69
58
|
|
|
70
59
|
/**
|
|
@@ -199,6 +188,7 @@ export const minifyCSS = function (input) {
|
|
|
199
188
|
const context = createMinifyContext();
|
|
200
189
|
|
|
201
190
|
if (ast?.stylesheet?.rules) {
|
|
191
|
+
ast.stylesheet.rules = removeInvalidSelectorRules(ast.stylesheet.rules);
|
|
202
192
|
// Every later pass reads selectors, so they are written the one canonical
|
|
203
193
|
// way before any of them runs.
|
|
204
194
|
normalizeRuleSelectors(ast.stylesheet.rules);
|
|
@@ -227,24 +217,7 @@ export const minifyCSS = function (input) {
|
|
|
227
217
|
);
|
|
228
218
|
ast.stylesheet.rules = filterRedundantCharsets(ast.stylesheet.rules);
|
|
229
219
|
|
|
230
|
-
|
|
231
|
-
ast.stylesheet.rules = mergeLayerRules(ast.stylesheet.rules, mergeSelectorRules);
|
|
232
|
-
ast.stylesheet.rules = mergeMediaRules(ast.stylesheet.rules, mergeSelectorRules);
|
|
233
|
-
ast.stylesheet.rules = deduplicateKeyframes(ast.stylesheet.rules);
|
|
234
|
-
|
|
235
|
-
const mergedRules = mergeSelectorRules(ast.stylesheet.rules);
|
|
236
|
-
const overrideCleanedRules = removeOverriddenMultiSelectorProperties(mergedRules);
|
|
237
|
-
const selectorPrunedRules = removeFullyOverriddenSelectors(overrideCleanedRules);
|
|
238
|
-
const preCleanedRules = removeEmptyRules(selectorPrunedRules);
|
|
239
|
-
const declarationMergedRules = mergeByDeclarations(preCleanedRules);
|
|
240
|
-
const nestedRules = nestFlatRules(declarationMergedRules);
|
|
241
|
-
const nonEmptyRules = removeEmptyRules(nestedRules);
|
|
242
|
-
// Nesting is what reveals that two rules style the same thing: until their
|
|
243
|
-
// descendants have moved inside them, the rules only share a declaration.
|
|
244
|
-
const nestingMergedRules = mergeByDeclarations(nonEmptyRules);
|
|
245
|
-
const factoredRules = factorCommonParents(nestingMergedRules);
|
|
246
|
-
const nestedFinalRules = nestFlatRules(factoredRules);
|
|
247
|
-
const finalRules = mergeIdenticalNestedRules(nestedFinalRules);
|
|
220
|
+
const finalRules = optimizeRuleList(ast.stylesheet.rules);
|
|
248
221
|
|
|
249
222
|
for (const rule of finalRules) {
|
|
250
223
|
output.push(stringifyRule(rule, context));
|
|
@@ -162,8 +162,8 @@ function startsUrlToken (source, index) {
|
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
164
|
* Finds the first of several delimiter characters that is written at the top
|
|
165
|
-
* level of the text, meaning it is not escaped, quoted,
|
|
166
|
-
* group.
|
|
165
|
+
* level of the text, meaning it is not escaped, quoted, inside a comment, or
|
|
166
|
+
* nested inside a group.
|
|
167
167
|
*
|
|
168
168
|
* @param {string} source The raw CSS text being scanned.
|
|
169
169
|
* @param {string} delimiters The delimiter characters to look for.
|
|
@@ -174,6 +174,11 @@ function findTopLevelDelimiter (source, delimiters, startIndex) {
|
|
|
174
174
|
let index = startIndex;
|
|
175
175
|
while (index < source.length) {
|
|
176
176
|
const character = source[index];
|
|
177
|
+
if (character === '/' && source[index + 1] === '*') {
|
|
178
|
+
const closingIndex = source.indexOf('*/', index + 2);
|
|
179
|
+
index = closingIndex === -1 ? source.length : closingIndex + 2;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
177
182
|
if (character === '\\') {
|
|
178
183
|
index += 2;
|
|
179
184
|
continue;
|
package/src/rules/optimize.js
CHANGED
|
@@ -11,12 +11,17 @@ import {
|
|
|
11
11
|
findMatchingParenthesis,
|
|
12
12
|
splitTopLevelCommaList
|
|
13
13
|
} from '../parser/source-search.js';
|
|
14
|
+
import { tokenizeCssValue } from '../parser/tokenizer.js';
|
|
14
15
|
import { escapeRegexString } from '../utilities.js';
|
|
15
16
|
|
|
16
17
|
import {
|
|
17
18
|
normalizeLayerNames,
|
|
18
19
|
normalizeMedia
|
|
19
20
|
} from './normalize.js';
|
|
21
|
+
import {
|
|
22
|
+
containsPseudoElement,
|
|
23
|
+
isUniversallyRecognizableSelector
|
|
24
|
+
} from './selectors.js';
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* Reports whether an entry of a rule's body is itself a nested rule rather
|
|
@@ -44,13 +49,67 @@ function expandNestedRuleBodies (rules) {
|
|
|
44
49
|
if (rule.type !== 'rule' || !(rule.declarations || []).some(isNestedRule)) {
|
|
45
50
|
return rule;
|
|
46
51
|
}
|
|
52
|
+
const declarations = expandPureNestedRules(rule.declarations);
|
|
53
|
+
const canFlattenSelf = selectorsShareSpecificity(rule.selectors) && !rule.selectors.some(containsPseudoElement);
|
|
54
|
+
let encounteredNestedContent = false;
|
|
55
|
+
const flattened = declarations.flatMap((entry) => {
|
|
56
|
+
const isSelfRule = entry.type === 'rule' && entry.selectors?.length === 1 && entry.selectors[0].trim() === '&';
|
|
57
|
+
if (canFlattenSelf && isSelfRule && !encounteredNestedContent) {
|
|
58
|
+
return entry.declarations || [];
|
|
59
|
+
}
|
|
60
|
+
if (entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment') {
|
|
61
|
+
encounteredNestedContent = true;
|
|
62
|
+
}
|
|
63
|
+
if (entry.type === 'rule' && rule.selectors.length === 1) {
|
|
64
|
+
entry.selectors = entry.selectors.map((selector) => {
|
|
65
|
+
return shortenRepeatedNestingSelector(rule.selectors[0], selector);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return [entry];
|
|
69
|
+
});
|
|
47
70
|
return {
|
|
48
71
|
...rule,
|
|
49
|
-
declarations:
|
|
72
|
+
declarations: flattened
|
|
50
73
|
};
|
|
51
74
|
});
|
|
52
75
|
}
|
|
53
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Replaces repetitions of a simple class or ID beside a leading nesting
|
|
79
|
+
* selector. Token comparisons keep longer names and escaped text intact.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} parentSelector The sole parent selector.
|
|
82
|
+
* @param {string} childSelector The nested selector.
|
|
83
|
+
* @return {string} The shorter equivalent selector.
|
|
84
|
+
*/
|
|
85
|
+
function shortenRepeatedNestingSelector (parentSelector, childSelector) {
|
|
86
|
+
const parentTokens = tokenizeCssValue(parentSelector);
|
|
87
|
+
const isClass = parentTokens.length === 2 && parentTokens[0].text === '.' && parentTokens[1].type === 'ident';
|
|
88
|
+
const isIdentifier = parentTokens.length === 1 && parentTokens[0].type === 'hash';
|
|
89
|
+
if (!isClass && !isIdentifier) {
|
|
90
|
+
return childSelector;
|
|
91
|
+
}
|
|
92
|
+
const childTokens = tokenizeCssValue(childSelector);
|
|
93
|
+
if (childTokens[0]?.text !== '&') {
|
|
94
|
+
return childSelector;
|
|
95
|
+
}
|
|
96
|
+
let index = 1;
|
|
97
|
+
let result = '&';
|
|
98
|
+
while (index < childTokens.length) {
|
|
99
|
+
const matchesParent = parentTokens.every((token, position) => {
|
|
100
|
+
return childTokens[index + position]?.text === token.text;
|
|
101
|
+
});
|
|
102
|
+
if (!matchesParent) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
result += '&';
|
|
106
|
+
index += parentTokens.length;
|
|
107
|
+
}
|
|
108
|
+
return result + childTokens.slice(index).map((token) => {
|
|
109
|
+
return token.text;
|
|
110
|
+
}).join('');
|
|
111
|
+
}
|
|
112
|
+
|
|
54
113
|
/**
|
|
55
114
|
* Reports whether a nested child selector still means the same thing once it
|
|
56
115
|
* is written out in full under its parent.
|
|
@@ -203,6 +262,9 @@ function createNestedSelectorMatcher (parentSelector) {
|
|
|
203
262
|
|
|
204
263
|
return (childSelector) => {
|
|
205
264
|
const child = childSelector.trim();
|
|
265
|
+
if (child === parent) {
|
|
266
|
+
return '&';
|
|
267
|
+
}
|
|
206
268
|
if (child.startsWith(parent + ':') || child.startsWith(parent + '::')) {
|
|
207
269
|
return '&' + child.slice(parent.length);
|
|
208
270
|
}
|
|
@@ -437,6 +499,26 @@ function factorCommonParents (rules) {
|
|
|
437
499
|
let index = 0;
|
|
438
500
|
while (index < rules.length) {
|
|
439
501
|
const rule = rules[index];
|
|
502
|
+
if (rule.type === 'rule' && rule.selectors?.length > 1) {
|
|
503
|
+
const candidateParent = extractLeadingCompound(rule.selectors[0]) || rule.selectors[0];
|
|
504
|
+
if (candidateParent) {
|
|
505
|
+
const matchNestedSelector = createNestedSelectorMatcher(candidateParent);
|
|
506
|
+
const childSelectors = rule.selectors.map(matchNestedSelector);
|
|
507
|
+
const allMatch = childSelectors.every((selector) => {
|
|
508
|
+
return selector !== null;
|
|
509
|
+
});
|
|
510
|
+
const wrapperLength = candidateParent.length + 2 + childSelectors.join(',').length;
|
|
511
|
+
if (allMatch && !containsPseudoElement(candidateParent) && wrapperLength < rule.selectors.join(',').length) {
|
|
512
|
+
result.push({
|
|
513
|
+
type: 'rule',
|
|
514
|
+
selectors: [candidateParent],
|
|
515
|
+
declarations: [{ ...rule, selectors: childSelectors }]
|
|
516
|
+
});
|
|
517
|
+
index++;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
440
522
|
// Only single-selector style rules can act as a factoring candidate.
|
|
441
523
|
if (rule.type !== 'rule' || rule.selectors?.length !== 1) {
|
|
442
524
|
result.push(rule);
|
|
@@ -491,6 +573,42 @@ function factorCommonParents (rules) {
|
|
|
491
573
|
return result;
|
|
492
574
|
}
|
|
493
575
|
|
|
576
|
+
/**
|
|
577
|
+
* Shares a descendant prefix through `:is()` when a nesting wrapper did not
|
|
578
|
+
* save enough space to factor the parent out of the selector list.
|
|
579
|
+
*
|
|
580
|
+
* @param {object} rule The style rule whose selector list may be shortened.
|
|
581
|
+
*/
|
|
582
|
+
function factorSelectorBranches (rule) {
|
|
583
|
+
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
for (const parent of [...rule.selectors]) {
|
|
587
|
+
const prefix = parent + ' ';
|
|
588
|
+
const branches = rule.selectors.filter((selector) => {
|
|
589
|
+
return selector.startsWith(prefix);
|
|
590
|
+
});
|
|
591
|
+
const children = branches.map((selector) => {
|
|
592
|
+
return selector.slice(prefix.length);
|
|
593
|
+
});
|
|
594
|
+
if (children.length < 2 || !children.every(isUniversallyRecognizableSelector) || !selectorsShareSpecificity(children)) {
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const grouped = prefix + ':is(' + children.sort().join(',') + ')';
|
|
598
|
+
if (grouped.length >= branches.join(',').length) {
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const branchSet = new Set(branches);
|
|
602
|
+
const firstIndex = rule.selectors.indexOf(branches[0]);
|
|
603
|
+
rule.selectors = rule.selectors.flatMap((selector, index) => {
|
|
604
|
+
if (index === firstIndex) {
|
|
605
|
+
return [grouped];
|
|
606
|
+
}
|
|
607
|
+
return branchSet.has(selector) ? [] : [selector];
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
494
612
|
/**
|
|
495
613
|
* Merges adjacent `@media` rules that share an identical normalized query string and deduplicates their child selector rules.
|
|
496
614
|
*
|
|
@@ -1469,7 +1587,7 @@ function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPos
|
|
|
1469
1587
|
* @param {Array} rules The AST rule nodes to merge.
|
|
1470
1588
|
* @return {Array} A new array of rules with same-selector rules combined.
|
|
1471
1589
|
*/
|
|
1472
|
-
function
|
|
1590
|
+
function mergeSelectorRulePass (rules) {
|
|
1473
1591
|
// A rule merged at the later of the two positions moves to the end of the
|
|
1474
1592
|
// output. Its old slot is emptied instead of spliced out so that every
|
|
1475
1593
|
// recorded position stays valid, and the position map locates that slot
|
|
@@ -1524,6 +1642,24 @@ function mergeSelectorRules (rules) {
|
|
|
1524
1642
|
});
|
|
1525
1643
|
}
|
|
1526
1644
|
|
|
1645
|
+
/**
|
|
1646
|
+
* Reconsiders blocked pairs after merges relocate the rules that blocked them.
|
|
1647
|
+
* Each productive pass removes a rule, so this reaches a fixed point without
|
|
1648
|
+
* rerunning parsing or unrelated optimizations.
|
|
1649
|
+
*
|
|
1650
|
+
* @param {Array} rules The sibling rules to merge.
|
|
1651
|
+
* @return {Array} Rules with all currently safe same-selector merges.
|
|
1652
|
+
*/
|
|
1653
|
+
function mergeSelectorRules (rules) {
|
|
1654
|
+
let previousRules = rules;
|
|
1655
|
+
let mergedRules = mergeSelectorRulePass(previousRules);
|
|
1656
|
+
while (mergedRules.length < previousRules.length) {
|
|
1657
|
+
previousRules = mergedRules;
|
|
1658
|
+
mergedRules = mergeSelectorRulePass(previousRules);
|
|
1659
|
+
}
|
|
1660
|
+
return mergedRules;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1527
1663
|
/**
|
|
1528
1664
|
* Merges `@layer` blocks with the same name by combining their child rules, deduplicates layer statements, and merges selector rules within each block.
|
|
1529
1665
|
*
|
|
@@ -1847,6 +1983,37 @@ function removeOverriddenMultiSelectorProperties (rules) {
|
|
|
1847
1983
|
return rules;
|
|
1848
1984
|
}
|
|
1849
1985
|
|
|
1986
|
+
/**
|
|
1987
|
+
* Applies the structural optimization pipeline to a stylesheet or grouping
|
|
1988
|
+
* block, so merging at-rules does not leave their children unoptimized.
|
|
1989
|
+
*
|
|
1990
|
+
* @param {Array} rules The sibling AST entries.
|
|
1991
|
+
* @return {Array} The optimized sibling entries.
|
|
1992
|
+
*/
|
|
1993
|
+
function optimizeRuleList (rules) {
|
|
1994
|
+
let result = expandPureNestedRules(rules);
|
|
1995
|
+
result = mergeLayerRules(result, mergeSelectorRules);
|
|
1996
|
+
result = mergeMediaRules(result, mergeSelectorRules);
|
|
1997
|
+
result = deduplicateKeyframes(result);
|
|
1998
|
+
for (const rule of result) {
|
|
1999
|
+
if (rule.rules) {
|
|
2000
|
+
rule.rules = optimizeRuleList(rule.rules);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
result = mergeSelectorRules(result);
|
|
2004
|
+
result = removeOverriddenMultiSelectorProperties(result);
|
|
2005
|
+
result = removeFullyOverriddenSelectors(result);
|
|
2006
|
+
result = removeEmptyRules(result);
|
|
2007
|
+
result = mergeByDeclarations(result);
|
|
2008
|
+
result = nestFlatRules(result);
|
|
2009
|
+
result = removeEmptyRules(result);
|
|
2010
|
+
result = mergeByDeclarations(result);
|
|
2011
|
+
result = factorCommonParents(result);
|
|
2012
|
+
result = nestFlatRules(result);
|
|
2013
|
+
result.forEach(factorSelectorBranches);
|
|
2014
|
+
return mergeIdenticalNestedRules(result);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
1850
2017
|
export {
|
|
1851
2018
|
deduplicateKeyframes,
|
|
1852
2019
|
expandPureNestedRules,
|
|
@@ -1857,6 +2024,7 @@ export {
|
|
|
1857
2024
|
mergeMediaRules,
|
|
1858
2025
|
mergeSelectorRules,
|
|
1859
2026
|
nestFlatRules,
|
|
2027
|
+
optimizeRuleList,
|
|
1860
2028
|
removeEmptyRules,
|
|
1861
2029
|
removeFullyOverriddenSelectors,
|
|
1862
2030
|
removeOverriddenMultiSelectorProperties
|
package/src/rules/selectors.js
CHANGED
|
@@ -719,9 +719,50 @@ function flattenNestingParentIsSelector (selector) {
|
|
|
719
719
|
return parts;
|
|
720
720
|
}
|
|
721
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Replaces matching legacy any-functions only when multiple vendor variants
|
|
724
|
+
* supply the same selector. A lone vendor-specific selector stays scoped to
|
|
725
|
+
* the browsers that understand it.
|
|
726
|
+
*
|
|
727
|
+
* @param {Array} selectors Selectors with identical declarations.
|
|
728
|
+
* @return {Array} The selectors with redundant legacy forms combined.
|
|
729
|
+
*/
|
|
730
|
+
function combineLegacyAnySelectors (selectors) {
|
|
731
|
+
const legacyFunctions = [':-moz-any(', ':-webkit-any(', ':matches('];
|
|
732
|
+
const groups = new Map();
|
|
733
|
+
const entries = selectors.map((selector) => {
|
|
734
|
+
let canonical = selector;
|
|
735
|
+
const variants = new Set();
|
|
736
|
+
for (const functionCall of legacyFunctions) {
|
|
737
|
+
let position = findNextFunctionCallOutsideStrings(canonical, functionCall, 0);
|
|
738
|
+
while (position !== -1) {
|
|
739
|
+
variants.add(functionCall);
|
|
740
|
+
canonical = canonical.slice(0, position) + ':is(' + canonical.slice(position + functionCall.length);
|
|
741
|
+
position = findNextFunctionCallOutsideStrings(canonical, functionCall, position + 4);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
let group = groups.get(canonical);
|
|
745
|
+
if (!group) {
|
|
746
|
+
group = new Set();
|
|
747
|
+
groups.set(canonical, group);
|
|
748
|
+
}
|
|
749
|
+
const variant = variants.size === 1 ? [...variants][0] : null;
|
|
750
|
+
if (variant) {
|
|
751
|
+
group.add(variant);
|
|
752
|
+
}
|
|
753
|
+
return { selector, canonical, variant };
|
|
754
|
+
});
|
|
755
|
+
return [...new Set(entries.map((entry) => {
|
|
756
|
+
return entry.variant && groups.get(entry.canonical).size > 1 ? entry.canonical : entry.selector;
|
|
757
|
+
}))];
|
|
758
|
+
}
|
|
759
|
+
|
|
722
760
|
export {
|
|
761
|
+
combineLegacyAnySelectors,
|
|
762
|
+
containsPseudoElement,
|
|
723
763
|
findNextFunctionCallOutsideStrings,
|
|
724
764
|
flattenNestingParentIsSelector,
|
|
765
|
+
isUniversallyRecognizableSelector,
|
|
725
766
|
mergeAdjacentWherePseudoClasses,
|
|
726
767
|
processIsSelector
|
|
727
768
|
};
|
package/src/rules/stringify.js
CHANGED
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { processDeclarations } from '../declarations/process.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
skipParenthesizedGroup,
|
|
8
|
+
skipQuotedString,
|
|
9
|
+
splitTopLevelCommaList,
|
|
10
|
+
startsUrlToken
|
|
11
|
+
} from '../parser/source-search.js';
|
|
12
|
+
import { tokenizeCssValue } from '../parser/tokenizer.js';
|
|
7
13
|
import { minifyValueForOutput } from '../value/minify.js';
|
|
14
|
+
import { elideRedundantSeparators } from '../value/tokens.js';
|
|
8
15
|
|
|
9
16
|
import {
|
|
10
17
|
collapseCustomPropertyWhitespace,
|
|
@@ -24,6 +31,7 @@ import {
|
|
|
24
31
|
} from './normalize.js';
|
|
25
32
|
import { resolvePropertyDescriptors } from './property.js';
|
|
26
33
|
import {
|
|
34
|
+
combineLegacyAnySelectors,
|
|
27
35
|
flattenNestingParentIsSelector,
|
|
28
36
|
mergeAdjacentWherePseudoClasses,
|
|
29
37
|
processIsSelector
|
|
@@ -110,6 +118,67 @@ function minifyFunctionPrelude (prelude) {
|
|
|
110
118
|
return functionName + '(' + minifiedParameters.join(',') + ')';
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Removes comments between namespace tokens while leaving strings, URLs, and
|
|
123
|
+
* escaped characters intact.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} prelude The namespace prelude as written.
|
|
126
|
+
* @return {string} The prelude with comments replaced by separators.
|
|
127
|
+
*/
|
|
128
|
+
function removeNamespaceComments (prelude) {
|
|
129
|
+
let result = '';
|
|
130
|
+
let index = 0;
|
|
131
|
+
while (index < prelude.length) {
|
|
132
|
+
if (prelude.startsWith('/*', index)) {
|
|
133
|
+
const closingIndex = prelude.indexOf('*/', index + 2);
|
|
134
|
+
index = closingIndex === -1 ? prelude.length : closingIndex + 2;
|
|
135
|
+
result += ' ';
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
let end = index + 1;
|
|
139
|
+
if (prelude[index] === '"' || prelude[index] === '\'') {
|
|
140
|
+
end = skipQuotedString(prelude, index);
|
|
141
|
+
} else if (prelude[index] === '\\') {
|
|
142
|
+
end = Math.min(index + 2, prelude.length);
|
|
143
|
+
} else if (startsUrlToken(prelude, index)) {
|
|
144
|
+
end = skipParenthesizedGroup(prelude, index + 3);
|
|
145
|
+
}
|
|
146
|
+
result += prelude.slice(index, end);
|
|
147
|
+
index = end;
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Unwraps quoted namespace URLs and removes token separators without changing
|
|
154
|
+
* whitespace or escapes inside the namespace name's string.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} prelude The optional namespace prefix and its URL.
|
|
157
|
+
* @return {string} The minified namespace statement.
|
|
158
|
+
*/
|
|
159
|
+
function stringifyNamespace (prelude) {
|
|
160
|
+
const tokens = tokenizeCssValue(removeNamespaceComments(prelude)).filter((token) => {
|
|
161
|
+
return token.type !== 'whitespace';
|
|
162
|
+
});
|
|
163
|
+
const parts = ['@namespace'];
|
|
164
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
165
|
+
const token = tokens[index];
|
|
166
|
+
const isQuotedUrl = (
|
|
167
|
+
token.type === 'function' &&
|
|
168
|
+
token.text.toLowerCase() === 'url(' &&
|
|
169
|
+
tokens[index + 1]?.type === 'string' &&
|
|
170
|
+
tokens[index + 2]?.text === ')'
|
|
171
|
+
);
|
|
172
|
+
if (isQuotedUrl) {
|
|
173
|
+
parts.push(tokens[index + 1].text);
|
|
174
|
+
index += 2;
|
|
175
|
+
} else {
|
|
176
|
+
parts.push(token.text);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return elideRedundantSeparators(parts.join(' ')) + ';';
|
|
180
|
+
}
|
|
181
|
+
|
|
113
182
|
/**
|
|
114
183
|
* Stringifies a generic `at-rule` AST node into minified CSS, with specialized
|
|
115
184
|
* prelude minification for `@function` rules and generic whitespace handling
|
|
@@ -257,6 +326,7 @@ function stringifyRule (rule, context) {
|
|
|
257
326
|
minified = minifyLanguageSelector(minified);
|
|
258
327
|
return minified;
|
|
259
328
|
});
|
|
329
|
+
uniqueSelectors = combineLegacyAnySelectors(uniqueSelectors);
|
|
260
330
|
// When this rule is a nesting parent, its whole selector list is treated
|
|
261
331
|
// as :is() for the children's specificity, so a top-level :is() can be
|
|
262
332
|
// safely lifted into the list without altering specificity.
|
|
@@ -464,6 +534,10 @@ function stringifyRule (rule, context) {
|
|
|
464
534
|
return '@charset ' + rule.charset + ';';
|
|
465
535
|
}
|
|
466
536
|
|
|
537
|
+
if (rule.type === 'namespace') {
|
|
538
|
+
return stringifyNamespace(rule.namespace);
|
|
539
|
+
}
|
|
540
|
+
|
|
467
541
|
if (rule.type === 'import') {
|
|
468
542
|
let importStatement = rule.import;
|
|
469
543
|
// Unwrap url() with a quoted string to just the quoted string
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Rejects malformed selector syntax before rules can be merged.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
findMatchingBracket,
|
|
7
|
+
findMatchingParenthesis,
|
|
8
|
+
splitTopLevelCommaList
|
|
9
|
+
} from '../parser/source-search.js';
|
|
10
|
+
import { tokenizeCssValue } from '../parser/tokenizer.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reads an attribute name, optional matcher, value, and case flag.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} attribute The contents of the brackets.
|
|
16
|
+
* @return {boolean} Whether the attribute has a valid token sequence.
|
|
17
|
+
*/
|
|
18
|
+
function isValidAttribute (attribute) {
|
|
19
|
+
const tokens = tokenizeCssValue(attribute).filter((token) => {
|
|
20
|
+
return token.type !== 'whitespace';
|
|
21
|
+
});
|
|
22
|
+
let index = 0;
|
|
23
|
+
if ((tokens[0]?.type === 'ident' || tokens[0]?.text === '*') && tokens[1]?.text === '|') {
|
|
24
|
+
index = 2;
|
|
25
|
+
} else if (tokens[0]?.text === '|') {
|
|
26
|
+
index = 1;
|
|
27
|
+
}
|
|
28
|
+
if (tokens[index]?.type !== 'ident') {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
index++;
|
|
32
|
+
if (index === tokens.length) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (['~', '|', '^', '$', '*'].includes(tokens[index]?.text)) {
|
|
36
|
+
index++;
|
|
37
|
+
}
|
|
38
|
+
if (tokens[index]?.text !== '=') {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
index++;
|
|
42
|
+
if (tokens[index]?.type !== 'ident' && tokens[index]?.type !== 'string') {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
index++;
|
|
46
|
+
if (tokens[index]?.type === 'ident' && ['i', 's'].includes(tokens[index].text.toLowerCase())) {
|
|
47
|
+
index++;
|
|
48
|
+
}
|
|
49
|
+
return index === tokens.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Checks that each language range is a single identifier or quoted string.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} argumentsText The language function's contents.
|
|
56
|
+
* @return {boolean} Whether every argument has valid syntax.
|
|
57
|
+
*/
|
|
58
|
+
function isValidLanguageList (argumentsText) {
|
|
59
|
+
const argumentsList = splitTopLevelCommaList(argumentsText);
|
|
60
|
+
return argumentsList.length > 0 && argumentsList.every((argument) => {
|
|
61
|
+
const tokens = tokenizeCssValue(argument.trim());
|
|
62
|
+
return tokens.length === 1 && (tokens[0].type === 'ident' || tokens[0].type === 'string');
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Detects invalid tokens without interpreting data inside strings, escapes,
|
|
68
|
+
* or forgiving selector lists as syntax of the surrounding selector.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} selector A single selector.
|
|
71
|
+
* @return {boolean} Whether its checked syntax is valid.
|
|
72
|
+
*/
|
|
73
|
+
function isValidSelector (selector) {
|
|
74
|
+
const tokens = tokenizeCssValue(selector);
|
|
75
|
+
let offset = 0;
|
|
76
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
77
|
+
const token = tokens[index];
|
|
78
|
+
if (token.text === '[' || token.type === 'function') {
|
|
79
|
+
const isAttribute = token.text === '[';
|
|
80
|
+
const openingIndex = isAttribute ? offset : offset + token.text.length - 1;
|
|
81
|
+
const closingIndex = isAttribute ?
|
|
82
|
+
findMatchingBracket(selector, openingIndex) :
|
|
83
|
+
findMatchingParenthesis(selector, openingIndex);
|
|
84
|
+
if (closingIndex === -1) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const inner = selector.slice(openingIndex + 1, closingIndex);
|
|
88
|
+
const functionName = token.text.slice(0, -1).toLowerCase();
|
|
89
|
+
if (isAttribute && !isValidAttribute(inner)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (functionName === 'lang' && !isValidLanguageList(inner)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (functionName === 'not' || functionName === 'has') {
|
|
96
|
+
if (!splitTopLevelCommaList(inner).every(isValidSelector)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
while (offset < closingIndex) {
|
|
101
|
+
offset += tokens[index].text.length;
|
|
102
|
+
index++;
|
|
103
|
+
}
|
|
104
|
+
offset += tokens[index].text.length;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (token.text === '|' && tokens[index + 1]?.text === '|') {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (token.text === ':') {
|
|
111
|
+
let followingIndex = index + 1;
|
|
112
|
+
if (tokens[followingIndex]?.text === ':') {
|
|
113
|
+
followingIndex++;
|
|
114
|
+
}
|
|
115
|
+
const following = tokens[followingIndex];
|
|
116
|
+
if (following?.type !== 'ident' && following?.type !== 'function') {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
offset += token.text.length;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Removes an entire unforgiving selector list if any branch is malformed.
|
|
127
|
+
*
|
|
128
|
+
* @param {Array} rules Stylesheet or nested body entries.
|
|
129
|
+
* @return {Array} Entries that cannot poison a valid rule during merging.
|
|
130
|
+
*/
|
|
131
|
+
function removeInvalidSelectorRules (rules) {
|
|
132
|
+
return rules.filter((rule) => {
|
|
133
|
+
return !rule.selectors || rule.selectors.every(isValidSelector);
|
|
134
|
+
}).map((rule) => {
|
|
135
|
+
if (rule.rules) {
|
|
136
|
+
rule.rules = removeInvalidSelectorRules(rule.rules);
|
|
137
|
+
}
|
|
138
|
+
if (rule.declarations) {
|
|
139
|
+
rule.declarations = removeInvalidSelectorRules(rule.declarations);
|
|
140
|
+
}
|
|
141
|
+
return rule;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { removeInvalidSelectorRules };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Token-aware font shorthand minification.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CSS_WIDE_KEYWORDS } from '../declarations/config.js';
|
|
6
|
+
import { tokenizeCssValue } from '../parser/tokenizer.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keywords that would change a quoted family into a generic family.
|
|
10
|
+
*
|
|
11
|
+
* @type {Set<string>}
|
|
12
|
+
*/
|
|
13
|
+
const GENERIC_FAMILY_NAMES = new Set([
|
|
14
|
+
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
|
|
15
|
+
'math', 'emoji', 'fangsong', 'ui-serif', 'ui-sans-serif', 'ui-monospace',
|
|
16
|
+
'ui-rounded'
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Removes family quotes only when their contents form ordinary identifiers.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} text A family string token.
|
|
23
|
+
* @return {string} The shortest safe spelling.
|
|
24
|
+
*/
|
|
25
|
+
function shortenFamilyString (text) {
|
|
26
|
+
const inner = text.slice(1, -1);
|
|
27
|
+
if (inner.includes('\\') || inner.trim() !== inner) {
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
const tokens = tokenizeCssValue(inner);
|
|
31
|
+
const isIdentifierList = tokens.length && tokens.every((token) => {
|
|
32
|
+
if (token.type === 'whitespace') {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const keyword = token.text.toLowerCase();
|
|
36
|
+
return token.type === 'ident' && !CSS_WIDE_KEYWORDS.has(keyword) && keyword !== 'default';
|
|
37
|
+
});
|
|
38
|
+
if (!isIdentifierList || GENERIC_FAMILY_NAMES.has(inner.toLowerCase())) {
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
return inner;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Locates a size before rewriting optional components. A substituted or
|
|
46
|
+
* calculated prefix is left intact because its component boundaries may not
|
|
47
|
+
* be knowable until the browser resolves it.
|
|
48
|
+
*
|
|
49
|
+
* @param {Array} tokens The font tokens.
|
|
50
|
+
* @return {number} The size index, or -1 if the prefix is ambiguous.
|
|
51
|
+
*/
|
|
52
|
+
function findFontSizeIndex (tokens) {
|
|
53
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
54
|
+
const token = tokens[index];
|
|
55
|
+
if (token.type === 'function') {
|
|
56
|
+
return -1;
|
|
57
|
+
}
|
|
58
|
+
// An oblique font style may state an angle before the font size.
|
|
59
|
+
const isAngle = token.type === 'dimension' && /(?:deg|grad|rad|turn)$/i.test(token.text);
|
|
60
|
+
if (token.type === 'dimension' && !isAngle) {
|
|
61
|
+
return index;
|
|
62
|
+
}
|
|
63
|
+
// Match absolute and relative size keywords, not family-name substrings.
|
|
64
|
+
const isSizeKeyword = token.type === 'ident' &&
|
|
65
|
+
/^(?:xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large|smaller|larger)$/i.test(token.text);
|
|
66
|
+
if (isSizeKeyword || token.type === 'percentage' || (token.type === 'number' && Number(token.text) === 0)) {
|
|
67
|
+
return index;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return -1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Normalizes optional font components before the size, leaving the family and
|
|
75
|
+
* line height in their respective grammar positions.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} value The font shorthand.
|
|
78
|
+
* @return {string} The minified shorthand.
|
|
79
|
+
*/
|
|
80
|
+
function minifyFontShorthand (value) {
|
|
81
|
+
const tokens = tokenizeCssValue(value);
|
|
82
|
+
const sizeIndex = findFontSizeIndex(tokens);
|
|
83
|
+
if (sizeIndex === -1) {
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
return tokens.map((token, index) => {
|
|
87
|
+
if (index < sizeIndex) {
|
|
88
|
+
if (token.type === 'ident') {
|
|
89
|
+
const keyword = token.text.toLowerCase();
|
|
90
|
+
if (keyword === 'normal') {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
if (keyword === 'bold') {
|
|
94
|
+
return '700';
|
|
95
|
+
}
|
|
96
|
+
} else if (token.type === 'number' && token.text === '400') {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (index > sizeIndex && token.type === 'string') {
|
|
101
|
+
const family = shortenFamilyString(token.text);
|
|
102
|
+
if (family !== token.text && tokens[index - 1]?.type !== 'whitespace' && tokens[index - 1]?.text !== ',') {
|
|
103
|
+
return ' ' + family;
|
|
104
|
+
}
|
|
105
|
+
return family;
|
|
106
|
+
}
|
|
107
|
+
return token.text;
|
|
108
|
+
}).join('').trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export { minifyFontShorthand };
|
package/src/value/minify.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
rgbaToHex,
|
|
23
23
|
shortestColor
|
|
24
24
|
} from './colors.js';
|
|
25
|
+
import { minifyFontShorthand } from './font.js';
|
|
25
26
|
import { minifyGradients } from './gradients.js';
|
|
26
27
|
import { simplifyEquivalentLightDarkFunctions } from './light-dark.js';
|
|
27
28
|
import {
|
|
@@ -45,7 +46,10 @@ import {
|
|
|
45
46
|
roundCompactNumber
|
|
46
47
|
} from './shared.js';
|
|
47
48
|
import { minifySvgDataUri } from './svg-data-uri.js';
|
|
48
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
elideRedundantSeparators,
|
|
51
|
+
shortenDimensionNumbers
|
|
52
|
+
} from './tokens.js';
|
|
49
53
|
import { minifyTransformValue } from './transforms.js';
|
|
50
54
|
import { optimizeUnicodeRange } from './unicode-range.js';
|
|
51
55
|
|
|
@@ -480,7 +484,7 @@ function normalizeWhitespaceAndQuotes (val, property) {
|
|
|
480
484
|
});
|
|
481
485
|
|
|
482
486
|
// Strip quotes from simple strings (like "Custom", "image.png"), but not for content where quoted strings are semantically distinct
|
|
483
|
-
if (property !== 'content' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
|
|
487
|
+
if (property !== 'content' && property !== 'font' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
|
|
484
488
|
// Match a boundary (start, whitespace, comma, open-paren), then a quoted simple value (alphanumeric, dots, slashes, hyphens), then a boundary lookahead
|
|
485
489
|
val = val.replace(/(^|\s|,|\()("|')([a-zA-Z0-9_./-]+)\2(?=\s|,|$|\)|!)/g, (match, before, quote, inner) => {
|
|
486
490
|
// Keep quotes around CSS generic font-family keywords — unquoted they mean something different
|
|
@@ -988,6 +992,11 @@ const LENGTHY_INITIAL_KEYWORDS = new Map([
|
|
|
988
992
|
['background-clip', 'border-box'],
|
|
989
993
|
['background-origin', 'padding-box'],
|
|
990
994
|
['border-collapse', 'separate'],
|
|
995
|
+
['border-color', 'currentcolor'],
|
|
996
|
+
['border-top-color', 'currentcolor'],
|
|
997
|
+
['border-right-color', 'currentcolor'],
|
|
998
|
+
['border-bottom-color', 'currentcolor'],
|
|
999
|
+
['border-left-color', 'currentcolor'],
|
|
991
1000
|
['box-sizing', 'content-box'],
|
|
992
1001
|
['mask-clip', 'border-box'],
|
|
993
1002
|
['mask-origin', 'border-box'],
|
|
@@ -1115,7 +1124,7 @@ const BORDER_STYLE_KEYWORD_SOURCE = 'solid|dashed|dotted|double|groove|ridge|ins
|
|
|
1115
1124
|
*
|
|
1116
1125
|
* @type {string}
|
|
1117
1126
|
*/
|
|
1118
|
-
const BORDER_WIDTH_SOURCE = 'thin|medium|thick|[+-]?(?:\\d+|\\d*\\.\\d+)[a-z]*|(?:calc|min|max|clamp)\\([^()]*\\)';
|
|
1127
|
+
const BORDER_WIDTH_SOURCE = 'thin|medium|thick|[+-]?(?:\\d+|\\d*\\.\\d+)(?:e[+-]?\\d+)?[a-z]*|(?:calc|min|max|clamp)\\([^()]*\\)';
|
|
1119
1128
|
|
|
1120
1129
|
/**
|
|
1121
1130
|
* Matches a `border` value that states its line style in front of its line
|
|
@@ -1408,10 +1417,12 @@ function applyPropertyOptimizations (val, property, urlTokens) {
|
|
|
1408
1417
|
return rgbaToHex(r, r, r, parseAlphaString(alphaStr));
|
|
1409
1418
|
});
|
|
1410
1419
|
|
|
1411
|
-
//
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1420
|
+
// Font families may contain color names, but never color values.
|
|
1421
|
+
if (property !== 'font' && property !== 'font-family') {
|
|
1422
|
+
val = replaceOutsideStringsAndUrls(val, (segment) => {
|
|
1423
|
+
return shortenColorValues(segment);
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1415
1426
|
|
|
1416
1427
|
if (!PUNCTUATED_COMPONENT_PROPERTIES.has(property)) {
|
|
1417
1428
|
// Restore space after close-paren when followed by an alphanumeric, hash, or hyphen
|
|
@@ -1420,17 +1431,7 @@ function applyPropertyOptimizations (val, property, urlTokens) {
|
|
|
1420
1431
|
val = restoreSpaceBeforeMathOperators(val);
|
|
1421
1432
|
|
|
1422
1433
|
if (property === 'font') {
|
|
1423
|
-
|
|
1424
|
-
const parts = val.split(/\s+/);
|
|
1425
|
-
// Find the font-size part: contains a digit and a recognized CSS length/percentage unit
|
|
1426
|
-
const sizeIndex = parts.findIndex((part) => {
|
|
1427
|
-
return /\d/.test(part) && /(?:px|em|rem|%|pt|pc|vw|vh|vmin|vmax|ch|ex|cm|mm|in|lh|rlh)/i.test(part);
|
|
1428
|
-
});
|
|
1429
|
-
if (sizeIndex > 0) {
|
|
1430
|
-
val = [...parts.slice(0, sizeIndex).filter((part) => {
|
|
1431
|
-
return part !== 'normal' && part !== '400';
|
|
1432
|
-
}), ...parts.slice(sizeIndex)].join(' ');
|
|
1433
|
-
}
|
|
1434
|
+
val = minifyFontShorthand(val);
|
|
1434
1435
|
}
|
|
1435
1436
|
|
|
1436
1437
|
if (property === 'background' && val !== 'none') {
|
|
@@ -1577,18 +1578,22 @@ function computeMinifiedValue (declaration) {
|
|
|
1577
1578
|
// character budget a number is written within
|
|
1578
1579
|
if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
|
|
1579
1580
|
const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
|
|
1580
|
-
|
|
1581
|
+
const scientificValue = isCustomProperty(declaration.property) ? val : shortenDimensionNumbers(val);
|
|
1582
|
+
const roundedNumber = formatResolvedNumber(rawNumber);
|
|
1583
|
+
const losesPrecision = Number(roundedNumber) !== Number(rawNumber);
|
|
1584
|
+
// Preserve tiny dimensions whose significant digits would otherwise be
|
|
1585
|
+
// rounded away before their shorter exponent form can be considered.
|
|
1586
|
+
val = losesPrecision && scientificValue !== val ? scientificValue : roundedNumber + rawUnit;
|
|
1581
1587
|
}
|
|
1582
1588
|
|
|
1583
1589
|
val = lowercaseHexColors(val);
|
|
1584
1590
|
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
});
|
|
1591
|
+
if (declaration.property !== 'font' && declaration.property !== 'font-family') {
|
|
1592
|
+
val = convertColorsToHex(val);
|
|
1593
|
+
val = replaceOutsideStringsAndUrls(val, (segment) => {
|
|
1594
|
+
return shortenColorValues(segment);
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1592
1597
|
|
|
1593
1598
|
// Collapse light-dark() when both normalized branches are identical
|
|
1594
1599
|
val = simplifyEquivalentLightDarkFunctions(val);
|
|
@@ -1627,6 +1632,7 @@ function computeMinifiedValue (declaration) {
|
|
|
1627
1632
|
!isCustomProperty(declaration.property)
|
|
1628
1633
|
);
|
|
1629
1634
|
if (elidesRedundantSeparators) {
|
|
1635
|
+
val = shortenDimensionNumbers(val);
|
|
1630
1636
|
// A property that punctuates its own components already had the whitespace
|
|
1631
1637
|
// after its closing parentheses taken out, and only the separators its
|
|
1632
1638
|
// grammar still needs put back, so those are the ones left alone here.
|
package/src/value/tokens.js
CHANGED
|
@@ -141,4 +141,41 @@ function elideRedundantSeparators (value, elidesAfterSelfEndingTokens = true) {
|
|
|
141
141
|
}).join('');
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Shortens decimal dimension and percentage tokens without changing their
|
|
146
|
+
* digits. Unitless numbers stay untouched because integer grammars reject
|
|
147
|
+
* scientific notation.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} value The CSS value.
|
|
150
|
+
* @return {string} The value with shorter numeric spellings.
|
|
151
|
+
*/
|
|
152
|
+
function shortenDimensionNumbers (value) {
|
|
153
|
+
return tokenizeCssValue(value).map((token) => {
|
|
154
|
+
if (token.type !== 'dimension' && token.type !== 'percentage') {
|
|
155
|
+
return token.text;
|
|
156
|
+
}
|
|
157
|
+
// Capture a decimal coefficient and its unit, excluding existing exponents.
|
|
158
|
+
const match = token.text.match(/^([+-]?)(\d*\.\d+|\d+)([%a-z]+)$/i);
|
|
159
|
+
if (!match) {
|
|
160
|
+
return token.text;
|
|
161
|
+
}
|
|
162
|
+
const [, sign, coefficient, unit] = match;
|
|
163
|
+
const decimalIndex = coefficient.indexOf('.');
|
|
164
|
+
const fractionalDigits = decimalIndex === -1 ? 0 : coefficient.length - decimalIndex - 1;
|
|
165
|
+
const digits = coefficient.replace('.', '');
|
|
166
|
+
// Leading zeros carry no magnitude; trailing zeros move into the exponent.
|
|
167
|
+
const significantDigits = digits.replace(/^0+/, '').replace(/0+$/, '');
|
|
168
|
+
if (!significantDigits) {
|
|
169
|
+
return token.text;
|
|
170
|
+
}
|
|
171
|
+
const trailingZeros = digits.length - digits.replace(/0+$/, '').length;
|
|
172
|
+
const exponent = trailingZeros - fractionalDigits;
|
|
173
|
+
const scientific = sign + significantDigits + 'e' + exponent + unit;
|
|
174
|
+
return scientific.length < token.text.length ? scientific : token.text;
|
|
175
|
+
}).join('');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export {
|
|
179
|
+
elideRedundantSeparators,
|
|
180
|
+
shortenDimensionNumbers
|
|
181
|
+
};
|