cssupgrade 0.0.0-stage → 2026.0.0
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/.claude-plugin/plugin.json +22 -0
- package/.editorconfig +27 -0
- package/.gitattributes +1 -0
- package/.github/agents/superjoe.agent.md +65 -0
- package/.github/copilot-instructions.md +32 -0
- package/.github/dependabot.yml +10 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/pr.yml +27 -0
- package/.github/workflows/release.yml +22 -0
- package/.idea/cssupgrade.iml +14 -0
- package/.idea/inspectionProfiles/Project_Default.xml +28 -0
- package/.idea/inspectionProfiles/profiles_settings.xml +6 -0
- package/.idea/misc.xml +4 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/.oxfmtrc.json +3 -0
- package/.pre-commit-config.yaml +52 -0
- package/.pre-commit-hooks.yaml +8 -0
- package/AGENTS.md +54 -0
- package/CONTRIBUTING.md +40 -0
- package/LICENSE +24 -0
- package/README.md +173 -2
- package/bin/cssupgrade.js +320 -0
- package/images/logo-dark.svg +10 -0
- package/images/logo-light.svg +10 -0
- package/package.json +34 -4
- package/skills/cssupgrade/SKILL.md +16 -0
- package/src/index.js +53 -0
- package/src/newlyAvailable.js +1 -0
- package/src/parser.js +43 -0
- package/src/pool.js +165 -0
- package/src/prefilters.js +29 -0
- package/src/widelyAvailable/cssNesting.js +918 -0
- package/src/widelyAvailable.js +1 -0
- package/src/worker.js +31 -0
- package/tests/baseline.test.js +51 -0
- package/tests/cli.test.js +229 -0
- package/tests/css-nesting.test.js +838 -0
- package/tests/fixtures/silent-worker.js +5 -0
- package/tests/general.test.js +42 -0
- package/tests/parser.test.js +43 -0
- package/tests/pool.test.js +242 -0
- package/tests/prefilters.test.js +24 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import { parseCss } from "../parser.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} TransformResult
|
|
5
|
+
* @property {string} code - Transformed CSS source.
|
|
6
|
+
* @property {boolean} modified - Whether the transformer changed the source.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} SourceRange
|
|
11
|
+
* @property {number} start - Offset of the first character.
|
|
12
|
+
* @property {number} end - Offset after the last character.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {[number, number, number]} Specificity
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* At-rules whose blocks contain style rules that can be nested.
|
|
21
|
+
*/
|
|
22
|
+
const nestedBlockAtRules = new Set([
|
|
23
|
+
"-moz-document",
|
|
24
|
+
"container",
|
|
25
|
+
"document",
|
|
26
|
+
"layer",
|
|
27
|
+
"media",
|
|
28
|
+
"scope",
|
|
29
|
+
"starting-style",
|
|
30
|
+
"supports",
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pseudo-classes whose argument is a selector list and whose specificity is the
|
|
35
|
+
* specificity of the most specific selector in that list.
|
|
36
|
+
*/
|
|
37
|
+
const selectorListPseudoClasses = new Set([
|
|
38
|
+
"-moz-any",
|
|
39
|
+
"-ms-any",
|
|
40
|
+
"-webkit-any",
|
|
41
|
+
"any",
|
|
42
|
+
"has",
|
|
43
|
+
"host",
|
|
44
|
+
"host-context",
|
|
45
|
+
"is",
|
|
46
|
+
"matches",
|
|
47
|
+
"not",
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pseudo-classes that select the nth element and accept an optional selector list.
|
|
52
|
+
*/
|
|
53
|
+
const nthPseudoClasses = new Set(["nth-child", "nth-last-child"])
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Functional pseudo-classes whose argument is not a selector list.
|
|
57
|
+
*/
|
|
58
|
+
const plainFunctionalPseudoClasses = new Set(["dir", "lang", "state"])
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Text that can follow a parent selector when a child selector extends it.
|
|
62
|
+
*/
|
|
63
|
+
const selectorContinuationPattern = /^[\s>+~.#:[]/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Relative combinator at the start of a nested selector.
|
|
67
|
+
*/
|
|
68
|
+
const relativeCombinatorPattern = /^[>+~]/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Leading whitespace of the first selector in a nested selector.
|
|
72
|
+
*/
|
|
73
|
+
const leadingWhitespacePattern = /^\s/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Add two specificity values.
|
|
77
|
+
*
|
|
78
|
+
* @param {Specificity} left - First value.
|
|
79
|
+
* @param {Specificity} right - Second value.
|
|
80
|
+
* @returns {Specificity} Sum of both values.
|
|
81
|
+
*/
|
|
82
|
+
function addSpecificity(left, right) {
|
|
83
|
+
return [left[0] + right[0], left[1] + right[1], left[2] + right[2]]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Compare two specificity values.
|
|
88
|
+
*
|
|
89
|
+
* @param {Specificity} left - First value.
|
|
90
|
+
* @param {Specificity} right - Second value.
|
|
91
|
+
* @returns {boolean} True when both values are equal.
|
|
92
|
+
*/
|
|
93
|
+
function isEqualSpecificity(left, right) {
|
|
94
|
+
return left.every((value, index) => value === right[index])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Pick the larger of two specificity values.
|
|
99
|
+
*
|
|
100
|
+
* @param {Specificity} left - First value.
|
|
101
|
+
* @param {Specificity} right - Second value.
|
|
102
|
+
* @returns {Specificity} The larger value.
|
|
103
|
+
*/
|
|
104
|
+
function maximumSpecificity(left, right) {
|
|
105
|
+
switch (true) {
|
|
106
|
+
case left[0] !== right[0]:
|
|
107
|
+
return left[0] > right[0] ? left : right
|
|
108
|
+
case left[1] !== right[1]:
|
|
109
|
+
return left[1] > right[1] ? left : right
|
|
110
|
+
default:
|
|
111
|
+
return left[2] >= right[2] ? left : right
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pick the largest specificity value, or null when one value is unknown.
|
|
117
|
+
*
|
|
118
|
+
* @param {Array<Specificity | null>} values - Specificity values.
|
|
119
|
+
* @returns {Specificity | null} Largest value, or null when unknown.
|
|
120
|
+
*/
|
|
121
|
+
function combineMaximumSpecificity(values) {
|
|
122
|
+
switch (true) {
|
|
123
|
+
case values.some((value) => value === null):
|
|
124
|
+
return null
|
|
125
|
+
default:
|
|
126
|
+
return values.reduce(maximumSpecificity)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Calculate the specificity of a selector list.
|
|
132
|
+
*
|
|
133
|
+
* @param {import("css-tree").CssNode} selectorList - Parsed selector list.
|
|
134
|
+
* @returns {Specificity | null} Specificity of the most specific selector.
|
|
135
|
+
*/
|
|
136
|
+
function calculateSelectorListSpecificity(selectorList) {
|
|
137
|
+
return combineMaximumSpecificity(
|
|
138
|
+
Array.from(selectorList.children, calculateSpecificity),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Calculate the specificity of the selector argument of a pseudo-class.
|
|
144
|
+
*
|
|
145
|
+
* `:is()` and `:not()` take a selector list, while `:host()` takes one selector.
|
|
146
|
+
*
|
|
147
|
+
* @param {import("css-tree").CssNode} node - Parsed pseudo-class.
|
|
148
|
+
* @returns {Specificity | null} Specificity of the argument.
|
|
149
|
+
*/
|
|
150
|
+
function calculateSelectorArgumentSpecificity(node) {
|
|
151
|
+
const arguments_ = [...node.children].filter(
|
|
152
|
+
(child) => child.type === "SelectorList" || child.type === "Selector",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
switch (arguments_.length) {
|
|
156
|
+
case 0:
|
|
157
|
+
return null
|
|
158
|
+
default:
|
|
159
|
+
return combineMaximumSpecificity(
|
|
160
|
+
arguments_.map((child) =>
|
|
161
|
+
child.type === "SelectorList"
|
|
162
|
+
? calculateSelectorListSpecificity(child)
|
|
163
|
+
: calculateSpecificity(child),
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Calculate the specificity of the optional selector list of an nth pseudo-class.
|
|
171
|
+
*
|
|
172
|
+
* @param {import("css-tree").CssNode} node - Parsed pseudo-class.
|
|
173
|
+
* @returns {Specificity | null} Specificity of the selector list argument.
|
|
174
|
+
*/
|
|
175
|
+
function calculateNthSelectorSpecificity(node) {
|
|
176
|
+
const nth = [...node.children].find((child) => child.type === "Nth")
|
|
177
|
+
|
|
178
|
+
switch (nth?.selector) {
|
|
179
|
+
case undefined:
|
|
180
|
+
case null:
|
|
181
|
+
return [0, 0, 0]
|
|
182
|
+
default:
|
|
183
|
+
return calculateSelectorListSpecificity(nth.selector)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Calculate the specificity of a functional pseudo-class.
|
|
189
|
+
*
|
|
190
|
+
* @param {import("css-tree").CssNode} node - Parsed pseudo-class.
|
|
191
|
+
* @returns {Specificity | null} Specificity of the pseudo-class.
|
|
192
|
+
*/
|
|
193
|
+
function calculatePseudoClassSpecificity(node) {
|
|
194
|
+
const name = node.name.toLowerCase()
|
|
195
|
+
|
|
196
|
+
switch (true) {
|
|
197
|
+
case name === "where":
|
|
198
|
+
return [0, 0, 0]
|
|
199
|
+
case node.children === null:
|
|
200
|
+
return [0, 1, 0]
|
|
201
|
+
case selectorListPseudoClasses.has(name):
|
|
202
|
+
return calculateSelectorArgumentSpecificity(node)
|
|
203
|
+
case nthPseudoClasses.has(name): {
|
|
204
|
+
const argumentSpecificity = calculateNthSelectorSpecificity(node)
|
|
205
|
+
|
|
206
|
+
switch (argumentSpecificity) {
|
|
207
|
+
case null:
|
|
208
|
+
return null
|
|
209
|
+
default:
|
|
210
|
+
return addSpecificity([0, 1, 0], argumentSpecificity)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
case plainFunctionalPseudoClasses.has(name):
|
|
214
|
+
return [0, 1, 0]
|
|
215
|
+
default:
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Calculate the specificity contribution of one selector node.
|
|
222
|
+
*
|
|
223
|
+
* @param {import("css-tree").CssNode} node - Parsed selector node.
|
|
224
|
+
* @returns {Specificity | null} Specificity of the node, or null when unknown.
|
|
225
|
+
*/
|
|
226
|
+
function calculateNodeSpecificity(node) {
|
|
227
|
+
switch (node.type) {
|
|
228
|
+
case "Combinator":
|
|
229
|
+
return [0, 0, 0]
|
|
230
|
+
case "TypeSelector":
|
|
231
|
+
return node.name === "*" ? [0, 0, 0] : [0, 0, 1]
|
|
232
|
+
case "IdSelector":
|
|
233
|
+
return [1, 0, 0]
|
|
234
|
+
case "ClassSelector":
|
|
235
|
+
case "AttributeSelector":
|
|
236
|
+
return [0, 1, 0]
|
|
237
|
+
case "PseudoElementSelector":
|
|
238
|
+
return [0, 0, 1]
|
|
239
|
+
case "PseudoClassSelector":
|
|
240
|
+
return calculatePseudoClassSpecificity(node)
|
|
241
|
+
default:
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Calculate the specificity of a selector.
|
|
248
|
+
*
|
|
249
|
+
* @param {import("css-tree").CssNode} selector - Parsed selector.
|
|
250
|
+
* @returns {Specificity | null} Specificity, or null when a node is unknown.
|
|
251
|
+
*/
|
|
252
|
+
function calculateSpecificity(selector) {
|
|
253
|
+
return [...selector.children].reduce(
|
|
254
|
+
(specificity, node) => {
|
|
255
|
+
switch (specificity) {
|
|
256
|
+
case null:
|
|
257
|
+
return null
|
|
258
|
+
default: {
|
|
259
|
+
const nodeSpecificity = calculateNodeSpecificity(node)
|
|
260
|
+
|
|
261
|
+
switch (nodeSpecificity) {
|
|
262
|
+
case null:
|
|
263
|
+
return null
|
|
264
|
+
default:
|
|
265
|
+
return addSpecificity(specificity, nodeSpecificity)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
[0, 0, 0],
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Check whether a rule only holds declarations.
|
|
276
|
+
*
|
|
277
|
+
* @param {import("css-tree").CssNode} rule - Syntax tree node.
|
|
278
|
+
* @returns {boolean} True when the node is a style rule with declarations only.
|
|
279
|
+
*/
|
|
280
|
+
function isPlainRule(rule) {
|
|
281
|
+
return (
|
|
282
|
+
rule.type === "Rule" &&
|
|
283
|
+
rule.prelude.type === "SelectorList" &&
|
|
284
|
+
[...rule.block.children].every((child) => child.type === "Declaration")
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Read the source text of every selector in a rule.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} code - CSS source text.
|
|
292
|
+
* @param {import("css-tree").CssNode} rule - Style rule.
|
|
293
|
+
* @returns {string[]} Selector texts.
|
|
294
|
+
*/
|
|
295
|
+
function extractSelectorTexts(code, rule) {
|
|
296
|
+
return Array.from(rule.prelude.children, (selector) =>
|
|
297
|
+
code.slice(selector.loc.start.offset, selector.loc.end.offset).trim(),
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Check whether a rule selects a pseudo-element.
|
|
303
|
+
*
|
|
304
|
+
* @param {import("css-tree").CssNode} rule - Style rule.
|
|
305
|
+
* @returns {boolean} True when a selector contains a pseudo-element.
|
|
306
|
+
*/
|
|
307
|
+
function hasPseudoElement(rule) {
|
|
308
|
+
return [...rule.prelude.children].some((selector) =>
|
|
309
|
+
[...selector.children].some((node) => node.type === "PseudoElementSelector"),
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Check whether a rule holds a comment outside its declaration block.
|
|
315
|
+
*
|
|
316
|
+
* The block renderer keeps comments inside the block. A comment in the
|
|
317
|
+
* selector or between the selector and the block would be lost when the rule
|
|
318
|
+
* is rendered again.
|
|
319
|
+
*
|
|
320
|
+
* @param {SourceRange[]} comments - Comment ranges.
|
|
321
|
+
* @param {import("css-tree").CssNode} rule - Style rule.
|
|
322
|
+
* @returns {boolean} True when a comment lies outside the declaration block.
|
|
323
|
+
*/
|
|
324
|
+
function hasCommentOutsideBlock(comments, rule) {
|
|
325
|
+
return comments.some(
|
|
326
|
+
(comment) =>
|
|
327
|
+
comment.start >= rule.loc.start.offset &&
|
|
328
|
+
comment.end <= rule.loc.end.offset &&
|
|
329
|
+
(comment.start < rule.block.loc.start.offset ||
|
|
330
|
+
comment.end > rule.block.loc.end.offset),
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Match a parent selector against the start of a child selector.
|
|
336
|
+
*
|
|
337
|
+
* @param {string} parentSelector - Parent selector text.
|
|
338
|
+
* @param {string} childSelector - Child selector text.
|
|
339
|
+
* @returns {string | null} Remaining child selector text, or null without a match.
|
|
340
|
+
*/
|
|
341
|
+
function matchSelectorPrefix(parentSelector, childSelector) {
|
|
342
|
+
switch (true) {
|
|
343
|
+
case !childSelector.startsWith(parentSelector):
|
|
344
|
+
return null
|
|
345
|
+
default: {
|
|
346
|
+
const remainder = childSelector.slice(parentSelector.length)
|
|
347
|
+
|
|
348
|
+
switch (true) {
|
|
349
|
+
case remainder.trim() === "":
|
|
350
|
+
return null
|
|
351
|
+
case !selectorContinuationPattern.test(remainder):
|
|
352
|
+
return null
|
|
353
|
+
default:
|
|
354
|
+
return remainder
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Render the remainder of a child selector as a nested selector.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} remainder - Child selector text after the parent selector.
|
|
364
|
+
* @returns {string} Nested selector.
|
|
365
|
+
*/
|
|
366
|
+
function renderNestedSelector(remainder) {
|
|
367
|
+
const text = remainder.trim()
|
|
368
|
+
|
|
369
|
+
switch (true) {
|
|
370
|
+
case relativeCombinatorPattern.test(text):
|
|
371
|
+
return `& ${text}`
|
|
372
|
+
case leadingWhitespacePattern.test(remainder):
|
|
373
|
+
return text
|
|
374
|
+
default:
|
|
375
|
+
return `&${text}`
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Build the nested selector list that replaces a child selector list.
|
|
381
|
+
*
|
|
382
|
+
* @param {string} code - CSS source text.
|
|
383
|
+
* @param {import("css-tree").CssNode} parent - Parent style rule.
|
|
384
|
+
* @param {import("css-tree").CssNode} child - Child style rule.
|
|
385
|
+
* @param {SourceRange[]} comments - Comment ranges.
|
|
386
|
+
* @returns {string | null} Nested selector list, or null when the rules cannot nest.
|
|
387
|
+
*/
|
|
388
|
+
function findNestedPrelude(code, parent, child, comments) {
|
|
389
|
+
switch (true) {
|
|
390
|
+
case hasCommentOutsideBlock(comments, parent):
|
|
391
|
+
return null
|
|
392
|
+
case hasCommentOutsideBlock(comments, child):
|
|
393
|
+
return null
|
|
394
|
+
case hasPseudoElement(parent):
|
|
395
|
+
return null
|
|
396
|
+
default:
|
|
397
|
+
break
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const parentSelectors = extractSelectorTexts(code, parent)
|
|
401
|
+
const childSelectors = extractSelectorTexts(code, child)
|
|
402
|
+
|
|
403
|
+
switch (true) {
|
|
404
|
+
case new Set(parentSelectors).size !== parentSelectors.length:
|
|
405
|
+
return null
|
|
406
|
+
case new Set(childSelectors).size !== childSelectors.length:
|
|
407
|
+
return null
|
|
408
|
+
default:
|
|
409
|
+
break
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const parentSpecificities = Array.from(parent.prelude.children, calculateSpecificity)
|
|
413
|
+
|
|
414
|
+
switch (true) {
|
|
415
|
+
case parentSpecificities.some((specificity) => specificity === null):
|
|
416
|
+
return null
|
|
417
|
+
case !parentSpecificities.every((specificity) =>
|
|
418
|
+
isEqualSpecificity(specificity, parentSpecificities[0]),
|
|
419
|
+
):
|
|
420
|
+
return null
|
|
421
|
+
default:
|
|
422
|
+
break
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const matches = childSelectors.map((selector) => ({
|
|
426
|
+
selector,
|
|
427
|
+
parents: parentSelectors
|
|
428
|
+
.map((parentSelector) => ({
|
|
429
|
+
parentSelector,
|
|
430
|
+
remainder: matchSelectorPrefix(parentSelector, selector),
|
|
431
|
+
}))
|
|
432
|
+
.filter(({ remainder }) => remainder !== null),
|
|
433
|
+
}))
|
|
434
|
+
|
|
435
|
+
switch (true) {
|
|
436
|
+
case matches.some(({ parents }) => parents.length !== 1):
|
|
437
|
+
return null
|
|
438
|
+
default:
|
|
439
|
+
break
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const suffixes = parentSelectors.map((parentSelector) => ({
|
|
443
|
+
parentSelector,
|
|
444
|
+
remainders: matches
|
|
445
|
+
.filter(({ parents }) => parents[0].parentSelector === parentSelector)
|
|
446
|
+
.map(({ parents }) => parents[0].remainder),
|
|
447
|
+
}))
|
|
448
|
+
const remainders = suffixes[0].remainders
|
|
449
|
+
|
|
450
|
+
switch (true) {
|
|
451
|
+
case suffixes.some(
|
|
452
|
+
({ remainders: candidate }) =>
|
|
453
|
+
candidate.length !== remainders.length ||
|
|
454
|
+
candidate.some(
|
|
455
|
+
(remainder, index) => remainder.trim() !== remainders[index].trim(),
|
|
456
|
+
),
|
|
457
|
+
):
|
|
458
|
+
return null
|
|
459
|
+
default:
|
|
460
|
+
return remainders.map(renderNestedSelector).join(", ")
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Rewrites flat stylesheets to use CSS nesting.
|
|
466
|
+
*
|
|
467
|
+
* Only adjacent rules are nested, so the cascade order stays the same. Rules
|
|
468
|
+
* without a nested child keep their original text.
|
|
469
|
+
*/
|
|
470
|
+
class NestingModernizer {
|
|
471
|
+
#ast
|
|
472
|
+
#code
|
|
473
|
+
#comments
|
|
474
|
+
#indentUnit
|
|
475
|
+
#lineEnding
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* @param {string} code - CSS source text.
|
|
479
|
+
* @param {import("css-tree").CssNode} ast - Parsed CSS syntax tree.
|
|
480
|
+
* @param {SourceRange[]} comments - Comment ranges.
|
|
481
|
+
*/
|
|
482
|
+
constructor(code, ast, comments) {
|
|
483
|
+
this.#code = code
|
|
484
|
+
this.#ast = ast
|
|
485
|
+
this.#comments = comments
|
|
486
|
+
this.#indentUnit = detectIndentUnit(code)
|
|
487
|
+
this.#lineEnding = code.includes("\r\n") ? "\r\n" : "\n"
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Nest adjacent rules that extend the selector of an earlier rule.
|
|
492
|
+
*
|
|
493
|
+
* @returns {string} Transformed CSS source.
|
|
494
|
+
*/
|
|
495
|
+
nest() {
|
|
496
|
+
return this.#renderChildren(this.#ast.children, 0, this.#code.length)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Render the children of a stylesheet or at-rule block.
|
|
501
|
+
*
|
|
502
|
+
* @param {Iterable<import("css-tree").CssNode>} children - Child nodes.
|
|
503
|
+
* @param {number} contentStart - Offset of the first character inside the block.
|
|
504
|
+
* @param {number} contentEnd - Offset of the closing brace, or the source end.
|
|
505
|
+
* @returns {string} Rendered block content.
|
|
506
|
+
*/
|
|
507
|
+
#renderChildren(children, contentStart, contentEnd) {
|
|
508
|
+
const rendered = this.#groupChildren([...children]).reduce(
|
|
509
|
+
(state, item) => {
|
|
510
|
+
const range = this.#itemRange(item)
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
text:
|
|
514
|
+
state.text +
|
|
515
|
+
this.#code.slice(state.cursor, range.start) +
|
|
516
|
+
this.#renderItem(item),
|
|
517
|
+
cursor: range.end,
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
{ text: "", cursor: contentStart },
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
return rendered.text + this.#code.slice(rendered.cursor, contentEnd)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Group adjacent rules that can be nested.
|
|
528
|
+
*
|
|
529
|
+
* @param {import("css-tree").CssNode[]} children - Child nodes.
|
|
530
|
+
* @returns {Array<{kind: string, node?: import("css-tree").CssNode, group?: Object}>} Items to render.
|
|
531
|
+
*/
|
|
532
|
+
#groupChildren(children) {
|
|
533
|
+
return children.reduce((state, child) => this.#groupChild(state, child), {
|
|
534
|
+
items: [],
|
|
535
|
+
stack: [],
|
|
536
|
+
lastRuleEnd: null,
|
|
537
|
+
}).items
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Add one child node to the grouping state.
|
|
542
|
+
*
|
|
543
|
+
* @param {Object} state - Grouping state.
|
|
544
|
+
* @param {import("css-tree").CssNode} child - Child node.
|
|
545
|
+
* @returns {Object} Updated grouping state.
|
|
546
|
+
*/
|
|
547
|
+
#groupChild(state, child) {
|
|
548
|
+
const match = this.#findMatch(state, child)
|
|
549
|
+
|
|
550
|
+
switch (true) {
|
|
551
|
+
case match !== null: {
|
|
552
|
+
const group = {
|
|
553
|
+
node: child,
|
|
554
|
+
prelude: match.prelude,
|
|
555
|
+
children: [],
|
|
556
|
+
leadingComments: this.#commentsBetween(
|
|
557
|
+
state.lastRuleEnd,
|
|
558
|
+
child.loc.start.offset,
|
|
559
|
+
),
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
match.group.children.push(group)
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
items: state.items,
|
|
566
|
+
stack: [...state.stack.slice(0, match.stackLength), group],
|
|
567
|
+
lastRuleEnd: child.loc.end.offset,
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
default:
|
|
571
|
+
break
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
switch (isPlainRule(child)) {
|
|
575
|
+
case true: {
|
|
576
|
+
const group = {
|
|
577
|
+
node: child,
|
|
578
|
+
prelude: extractSelectorTexts(this.#code, child).join(", "),
|
|
579
|
+
children: [],
|
|
580
|
+
leadingComments: [],
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
items: [...state.items, { kind: "group", group }],
|
|
585
|
+
stack: [group],
|
|
586
|
+
lastRuleEnd: child.loc.end.offset,
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
default:
|
|
590
|
+
return {
|
|
591
|
+
items: [...state.items, { kind: "node", node: child }],
|
|
592
|
+
stack: [],
|
|
593
|
+
lastRuleEnd: null,
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Find the deepest group that can absorb a child rule.
|
|
600
|
+
*
|
|
601
|
+
* @param {Object} state - Grouping state.
|
|
602
|
+
* @param {import("css-tree").CssNode} child - Child node.
|
|
603
|
+
* @returns {{group: Object, prelude: string, stackLength: number} | null} Matching group.
|
|
604
|
+
*/
|
|
605
|
+
#findMatch(state, child) {
|
|
606
|
+
switch (true) {
|
|
607
|
+
case state.lastRuleEnd === null:
|
|
608
|
+
return null
|
|
609
|
+
case !isPlainRule(child):
|
|
610
|
+
return null
|
|
611
|
+
default:
|
|
612
|
+
break
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const index = state.stack.findLastIndex(
|
|
616
|
+
(group) =>
|
|
617
|
+
findNestedPrelude(this.#code, group.node, child, this.#comments) !== null,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
switch (index) {
|
|
621
|
+
case -1:
|
|
622
|
+
return null
|
|
623
|
+
default:
|
|
624
|
+
return {
|
|
625
|
+
group: state.stack[index],
|
|
626
|
+
prelude: findNestedPrelude(
|
|
627
|
+
this.#code,
|
|
628
|
+
state.stack[index].node,
|
|
629
|
+
child,
|
|
630
|
+
this.#comments,
|
|
631
|
+
),
|
|
632
|
+
stackLength: index + 1,
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Read the source range a grouped item replaces.
|
|
639
|
+
*
|
|
640
|
+
* @param {Object} item - Group or untouched node.
|
|
641
|
+
* @returns {{start: number, end: number}} Source range of the item.
|
|
642
|
+
*/
|
|
643
|
+
#itemRange(item) {
|
|
644
|
+
switch (item.kind) {
|
|
645
|
+
case "group":
|
|
646
|
+
return {
|
|
647
|
+
start: item.group.node.loc.start.offset,
|
|
648
|
+
end: findGroupEnd(item.group),
|
|
649
|
+
}
|
|
650
|
+
default:
|
|
651
|
+
return {
|
|
652
|
+
start: item.node.loc.start.offset,
|
|
653
|
+
end: item.node.loc.end.offset,
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Render one grouped item.
|
|
660
|
+
*
|
|
661
|
+
* @param {Object} item - Group or untouched node.
|
|
662
|
+
* @returns {string} Rendered item.
|
|
663
|
+
*/
|
|
664
|
+
#renderItem(item) {
|
|
665
|
+
switch (item.kind) {
|
|
666
|
+
case "group":
|
|
667
|
+
return this.#renderGroupItem(item.group)
|
|
668
|
+
default:
|
|
669
|
+
return this.#renderNode(item.node)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Render a group, or its original text when it did not absorb a child.
|
|
675
|
+
*
|
|
676
|
+
* @param {Object} group - Group to render.
|
|
677
|
+
* @returns {string} Rendered group.
|
|
678
|
+
*/
|
|
679
|
+
#renderGroupItem(group) {
|
|
680
|
+
switch (group.children.length) {
|
|
681
|
+
case 0:
|
|
682
|
+
return this.#code.slice(group.node.loc.start.offset, group.node.loc.end.offset)
|
|
683
|
+
default:
|
|
684
|
+
return this.#renderGroup(group)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Render a rule with its nested children.
|
|
690
|
+
*
|
|
691
|
+
* @param {Object} group - Group to render.
|
|
692
|
+
* @returns {string} Rendered rule.
|
|
693
|
+
*/
|
|
694
|
+
#renderGroup(group) {
|
|
695
|
+
const baseIndent = this.#lineIndent(group.node.loc.start.offset)
|
|
696
|
+
const rendered = this.#renderRule(group, baseIndent)
|
|
697
|
+
|
|
698
|
+
return rendered.slice(baseIndent.length)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Render a rule and its descendants at the given indentation.
|
|
703
|
+
*
|
|
704
|
+
* @param {Object} group - Group to render.
|
|
705
|
+
* @param {string} indent - Indentation of the rule.
|
|
706
|
+
* @returns {string} Rendered rule.
|
|
707
|
+
*/
|
|
708
|
+
#renderRule(group, indent) {
|
|
709
|
+
const childIndent = indent + this.#indentUnit
|
|
710
|
+
|
|
711
|
+
return [
|
|
712
|
+
`${indent}${group.prelude} {`,
|
|
713
|
+
...this.#renderBlockEntries(group.node.block, childIndent),
|
|
714
|
+
...group.children.flatMap((child) => [
|
|
715
|
+
...child.leadingComments.map(
|
|
716
|
+
(comment) => `${childIndent}${this.#commentText(comment)}`,
|
|
717
|
+
),
|
|
718
|
+
this.#renderRule(child, childIndent),
|
|
719
|
+
]),
|
|
720
|
+
`${indent}}`,
|
|
721
|
+
].join(this.#lineEnding)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Render the declarations and comments of a block.
|
|
726
|
+
*
|
|
727
|
+
* @param {import("css-tree").CssNode} block - Declaration block.
|
|
728
|
+
* @param {string} indent - Indentation of the entries.
|
|
729
|
+
* @returns {string[]} Rendered entries.
|
|
730
|
+
*/
|
|
731
|
+
#renderBlockEntries(block, indent) {
|
|
732
|
+
const declarations = [...block.children]
|
|
733
|
+
const standaloneComments = this.#comments.filter(
|
|
734
|
+
(comment) =>
|
|
735
|
+
comment.start >= block.loc.start.offset &&
|
|
736
|
+
comment.end <= block.loc.end.offset &&
|
|
737
|
+
!declarations.some(
|
|
738
|
+
(declaration) =>
|
|
739
|
+
comment.start >= declaration.loc.start.offset &&
|
|
740
|
+
comment.end <= declaration.loc.end.offset,
|
|
741
|
+
),
|
|
742
|
+
)
|
|
743
|
+
const entries = [
|
|
744
|
+
...declarations.map((declaration) => ({
|
|
745
|
+
start: declaration.loc.start.offset,
|
|
746
|
+
text: this.#declarationText(declaration),
|
|
747
|
+
})),
|
|
748
|
+
...standaloneComments.map((comment) => ({
|
|
749
|
+
start: comment.start,
|
|
750
|
+
text: this.#commentText(comment),
|
|
751
|
+
})),
|
|
752
|
+
]
|
|
753
|
+
|
|
754
|
+
return entries
|
|
755
|
+
.toSorted((left, right) => left.start - right.start)
|
|
756
|
+
.map((entry) => `${indent}${entry.text}`)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Render a declaration with a terminating semicolon.
|
|
761
|
+
*
|
|
762
|
+
* @param {import("css-tree").CssNode} declaration - Declaration node.
|
|
763
|
+
* @returns {string} Declaration text.
|
|
764
|
+
*/
|
|
765
|
+
#declarationText(declaration) {
|
|
766
|
+
return `${this.#code
|
|
767
|
+
.slice(declaration.loc.start.offset, declaration.loc.end.offset)
|
|
768
|
+
.trim()};`
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Render a comment.
|
|
773
|
+
*
|
|
774
|
+
* @param {SourceRange} comment - Comment range.
|
|
775
|
+
* @returns {string} Comment text.
|
|
776
|
+
*/
|
|
777
|
+
#commentText(comment) {
|
|
778
|
+
return this.#code.slice(comment.start, comment.end).trim()
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Collect comments inside a range.
|
|
783
|
+
*
|
|
784
|
+
* @param {number} start - First offset to inspect.
|
|
785
|
+
* @param {number} end - Offset after the last character to inspect.
|
|
786
|
+
* @returns {SourceRange[]} Comments inside the range.
|
|
787
|
+
*/
|
|
788
|
+
#commentsBetween(start, end) {
|
|
789
|
+
return this.#comments.filter(
|
|
790
|
+
(comment) => comment.start >= start && comment.end <= end,
|
|
791
|
+
)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Render an untouched node, descending into nestable at-rule blocks.
|
|
796
|
+
*
|
|
797
|
+
* @param {import("css-tree").CssNode} node - Syntax tree node.
|
|
798
|
+
* @returns {string} Rendered node.
|
|
799
|
+
*/
|
|
800
|
+
#renderNode(node) {
|
|
801
|
+
switch (node.type) {
|
|
802
|
+
case "Atrule":
|
|
803
|
+
return this.#renderAtRule(node)
|
|
804
|
+
default:
|
|
805
|
+
return this.#code.slice(node.loc.start.offset, node.loc.end.offset)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Render an at-rule and nest the rules of a block that holds style rules.
|
|
811
|
+
*
|
|
812
|
+
* @param {import("css-tree").CssNode} atrule - At-rule node.
|
|
813
|
+
* @returns {string} Rendered at-rule.
|
|
814
|
+
*/
|
|
815
|
+
#renderAtRule(atrule) {
|
|
816
|
+
switch (true) {
|
|
817
|
+
case atrule.block === null:
|
|
818
|
+
return this.#code.slice(atrule.loc.start.offset, atrule.loc.end.offset)
|
|
819
|
+
case !nestedBlockAtRules.has(atrule.name.toLowerCase()):
|
|
820
|
+
return this.#code.slice(atrule.loc.start.offset, atrule.loc.end.offset)
|
|
821
|
+
default: {
|
|
822
|
+
const blockStart = atrule.block.loc.start.offset
|
|
823
|
+
const closeBrace = this.#findClosingBrace(atrule.block)
|
|
824
|
+
|
|
825
|
+
return (
|
|
826
|
+
this.#code.slice(atrule.loc.start.offset, blockStart + 1) +
|
|
827
|
+
this.#renderChildren(atrule.block.children, blockStart + 1, closeBrace) +
|
|
828
|
+
this.#code.slice(closeBrace, atrule.loc.end.offset)
|
|
829
|
+
)
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Find the closing brace of a block.
|
|
836
|
+
*
|
|
837
|
+
* An unterminated block ends after its last child, so a final brace belongs
|
|
838
|
+
* to that child instead of the block.
|
|
839
|
+
*
|
|
840
|
+
* @param {import("css-tree").CssNode} block - Block node.
|
|
841
|
+
* @returns {number} Offset of the closing brace, or the block end.
|
|
842
|
+
*/
|
|
843
|
+
#findClosingBrace(block) {
|
|
844
|
+
const blockEnd = block.loc.end.offset
|
|
845
|
+
const lastChild = [...block.children].at(-1)
|
|
846
|
+
|
|
847
|
+
switch (true) {
|
|
848
|
+
case this.#code[blockEnd - 1] !== "}":
|
|
849
|
+
return blockEnd
|
|
850
|
+
case lastChild === undefined:
|
|
851
|
+
return blockEnd - 1
|
|
852
|
+
case blockEnd - 1 >= lastChild.loc.end.offset:
|
|
853
|
+
return blockEnd - 1
|
|
854
|
+
default:
|
|
855
|
+
return blockEnd
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Read the indentation of the line that holds an offset.
|
|
861
|
+
*
|
|
862
|
+
* @param {number} offset - Offset inside the line.
|
|
863
|
+
* @returns {string} Leading whitespace of the line, or an empty string.
|
|
864
|
+
*/
|
|
865
|
+
#lineIndent(offset) {
|
|
866
|
+
const lineStart = this.#code.lastIndexOf("\n", offset - 1) + 1
|
|
867
|
+
const prefix = this.#code.slice(lineStart, offset)
|
|
868
|
+
|
|
869
|
+
return /^[ \t]*$/.test(prefix) ? prefix : ""
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Detect the indentation unit used by the source text.
|
|
875
|
+
*
|
|
876
|
+
* @param {string} code - CSS source text.
|
|
877
|
+
* @returns {string} One level of indentation.
|
|
878
|
+
*/
|
|
879
|
+
function detectIndentUnit(code) {
|
|
880
|
+
const match = /^([ \t]+)\S/m.exec(code)
|
|
881
|
+
|
|
882
|
+
switch (match) {
|
|
883
|
+
case null:
|
|
884
|
+
return " "
|
|
885
|
+
default:
|
|
886
|
+
return match[1]
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Find the source end of the last rule in a group.
|
|
892
|
+
*
|
|
893
|
+
* @param {Object} group - Group to inspect.
|
|
894
|
+
* @returns {number} Offset after the last nested rule.
|
|
895
|
+
*/
|
|
896
|
+
function findGroupEnd(group) {
|
|
897
|
+
switch (group.children.length) {
|
|
898
|
+
case 0:
|
|
899
|
+
return group.node.loc.end.offset
|
|
900
|
+
default:
|
|
901
|
+
return findGroupEnd(group.children[group.children.length - 1])
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Nest adjacent rules that extend the selector of an earlier rule.
|
|
907
|
+
*
|
|
908
|
+
* @param {string} code - CSS source text.
|
|
909
|
+
* @returns {TransformResult} Nested CSS.
|
|
910
|
+
*/
|
|
911
|
+
export function cssNesting(code) {
|
|
912
|
+
const { ast, comments } = parseCss(code)
|
|
913
|
+
const nextCode = new NestingModernizer(code, ast, comments).nest()
|
|
914
|
+
|
|
915
|
+
return { code: nextCode, modified: nextCode !== code }
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
cssNesting.baselineDate = new Date(Date.UTC(2023, 11, 11))
|