cssupgrade 0.0.0-stage → 2026.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/.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 +37 -0
- package/.github/workflows/pr.yml +28 -0
- package/.github/workflows/release.yml +22 -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,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command, Option } from "commander"
|
|
4
|
+
import { diffLines } from "diff"
|
|
5
|
+
import { once } from "events"
|
|
6
|
+
import fs from "fs/promises"
|
|
7
|
+
import process from "node:process"
|
|
8
|
+
import pkg from "../package.json" with { type: "json" }
|
|
9
|
+
import { transform } from "../src/index.js"
|
|
10
|
+
import { WorkerPool } from "../src/pool.js"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* CLI tool for cssupgrade.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Process a file using a worker thread.
|
|
18
|
+
* @param {string} filePath - Path to the file to process.
|
|
19
|
+
* @param {Object} options - Processing options.
|
|
20
|
+
* @param {string} options.baseline - Baseline level for transformations.
|
|
21
|
+
* @param {boolean} options.check - Whether to only check for changes.
|
|
22
|
+
* @param {boolean} options.write - Whether to write changes to file.
|
|
23
|
+
* @param {boolean} options.verbose - The verbosity level for logging.
|
|
24
|
+
* @param {import("../src/pool.js").TransformWorker} worker - Worker that transforms the file.
|
|
25
|
+
* @returns {Promise<{modified: boolean, error: boolean}>} Result of processing.
|
|
26
|
+
*/
|
|
27
|
+
async function processFile(filePath, options, worker) {
|
|
28
|
+
// Validate that the provided path exists and is a file.
|
|
29
|
+
try {
|
|
30
|
+
const stats = await fs.stat(filePath)
|
|
31
|
+
if (!stats.isFile()) {
|
|
32
|
+
console.error(`Error: '${filePath}' is not a file`)
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error(`Error: Cannot access '${filePath}': ${error.message}`)
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// The worker only ships the original source when a diff can be printed.
|
|
42
|
+
const showsDiff = options.check || !options.write
|
|
43
|
+
const workerResult = await worker.transform({
|
|
44
|
+
filePath,
|
|
45
|
+
baseline: options.baseline,
|
|
46
|
+
includeOriginal: showsDiff,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
if (!workerResult.success) {
|
|
50
|
+
if (options.verbose) console.error(workerResult.error)
|
|
51
|
+
console.error(
|
|
52
|
+
`\x1b[31m✗\x1b[0m Error: ${filePath}: ${workerResult.error.message}`,
|
|
53
|
+
)
|
|
54
|
+
return { modified: false, error: true }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = workerResult.result
|
|
58
|
+
|
|
59
|
+
if (result.modified) {
|
|
60
|
+
if (showsDiff) {
|
|
61
|
+
displayDiff(filePath, result.original, result.code)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (options.write) {
|
|
65
|
+
await fs.writeFile(filePath, result.code, "utf8")
|
|
66
|
+
if (!options.check) {
|
|
67
|
+
console.info(`\x1b[32m✓\x1b[0m ${filePath}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { modified: true, error: false }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Show unmodified files unless in check-only mode
|
|
75
|
+
if (!options.check) {
|
|
76
|
+
console.debug(` ${filePath}`)
|
|
77
|
+
}
|
|
78
|
+
return { modified: false, error: false }
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(`\x1b[31m✗\x1b[0m Error: ${filePath}: ${error.message}`)
|
|
81
|
+
return { modified: false, error: true }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Display a diff between original and modified code.
|
|
87
|
+
* @param {string} filePath - Path to the file being displayed.
|
|
88
|
+
* @param {string} original - Original code.
|
|
89
|
+
* @param {string} modified - Modified code.
|
|
90
|
+
*/
|
|
91
|
+
function displayDiff(filePath, original, modified) {
|
|
92
|
+
const changes = diffLines(original, modified).filter(
|
|
93
|
+
({ added, removed }) => added || removed,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
console.group(`\x1b[31m✗\x1b[0m ${filePath}`)
|
|
97
|
+
for (const { added, value } of changes) {
|
|
98
|
+
const prefix = added ? ` \x1b[32m+ ` : ` \x1b[31m- `
|
|
99
|
+
for (const line of value.split("\n")) {
|
|
100
|
+
if (line.trim() !== "") {
|
|
101
|
+
console.info(`${prefix}${line}\x1b[0m`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.groupEnd()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Process stdin using the configured transformation options.
|
|
110
|
+
* @param {Object} options - Processing options.
|
|
111
|
+
* @param {string} options.baseline - Baseline level for transformations.
|
|
112
|
+
* @param {boolean} options.check - Whether to only check for changes.
|
|
113
|
+
* @param {boolean} options.write - Whether to write changes to file.
|
|
114
|
+
* @param {boolean} options.verbose - The verbosity level for logging.
|
|
115
|
+
* @returns {Promise<void>} Complete when stdin processing finishes.
|
|
116
|
+
*/
|
|
117
|
+
async function processStdin(options) {
|
|
118
|
+
if (options.write) {
|
|
119
|
+
console.error("Error: '--write' cannot be used with stdin")
|
|
120
|
+
process.exit(1)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const code = await readStdin()
|
|
125
|
+
const result = transform(code, options.baseline)
|
|
126
|
+
|
|
127
|
+
if (options.check) {
|
|
128
|
+
if (result.modified) {
|
|
129
|
+
process.exit(1)
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
process.stdout.write(result.code)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (options.verbose) console.error(error)
|
|
137
|
+
console.error(`\x1b[31m✗\x1b[0m Error: stdin: ${error.message}`)
|
|
138
|
+
process.exit(128)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read source code from stdin.
|
|
144
|
+
* @returns {Promise<string>} Source code from stdin.
|
|
145
|
+
*/
|
|
146
|
+
async function readStdin() {
|
|
147
|
+
process.stdin.setEncoding("utf8")
|
|
148
|
+
let code = ""
|
|
149
|
+
|
|
150
|
+
process.stdin.on("data", (chunk) => {
|
|
151
|
+
code += chunk
|
|
152
|
+
})
|
|
153
|
+
await once(process.stdin, "end")
|
|
154
|
+
return code
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Identify a result that changed the file.
|
|
159
|
+
* @param {{modified: boolean}} result - Result of processing one file.
|
|
160
|
+
* @returns {boolean} True when the file changed.
|
|
161
|
+
*/
|
|
162
|
+
function isModified({ modified }) {
|
|
163
|
+
return modified
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Identify a result that failed.
|
|
168
|
+
* @param {{error: boolean}} result - Result of processing one file.
|
|
169
|
+
* @returns {boolean} True when the file failed to process.
|
|
170
|
+
*/
|
|
171
|
+
function isFailed({ error }) {
|
|
172
|
+
return error
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Describe a number of files.
|
|
177
|
+
* @param {number} count - Number of files.
|
|
178
|
+
* @returns {string} Count followed by a singular or plural noun.
|
|
179
|
+
*/
|
|
180
|
+
function formatFileCount(count) {
|
|
181
|
+
return `${count} file${count === 1 ? "" : "s"}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Orchestrates the CLI application.
|
|
186
|
+
*/
|
|
187
|
+
class CLIRunner {
|
|
188
|
+
constructor(workerPath) {
|
|
189
|
+
this.workerPool = new WorkerPool(processFile, workerPath)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Process files and report results.
|
|
194
|
+
* @param {string[]} patterns - File paths to process (no globbing).
|
|
195
|
+
* @param {Object} options - Processing options.
|
|
196
|
+
*/
|
|
197
|
+
async run(patterns, options) {
|
|
198
|
+
switch (this.#getInputMode(patterns)) {
|
|
199
|
+
case "stdin":
|
|
200
|
+
await processStdin(options)
|
|
201
|
+
return
|
|
202
|
+
case "mixed":
|
|
203
|
+
console.error("Error: '-' cannot be combined with file paths")
|
|
204
|
+
return process.exit(1)
|
|
205
|
+
default:
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
console.time("Processing")
|
|
210
|
+
// Hand CLI-provided names directly to the worker pool; file validation occurs in processFile.
|
|
211
|
+
const results = await this.workerPool.processFiles(patterns, options)
|
|
212
|
+
console.timeEnd("Processing")
|
|
213
|
+
|
|
214
|
+
this.#reportSummary(results, options)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Classify the input mode selected by CLI arguments.
|
|
219
|
+
* @param {string[]} patterns - File paths to process.
|
|
220
|
+
* @returns {"files" | "stdin" | "mixed"} Selected input mode.
|
|
221
|
+
*/
|
|
222
|
+
#getInputMode(patterns) {
|
|
223
|
+
const stdinCount = patterns.filter((pattern) => pattern === "-").length
|
|
224
|
+
|
|
225
|
+
switch (stdinCount) {
|
|
226
|
+
case 0:
|
|
227
|
+
return "files"
|
|
228
|
+
case 1:
|
|
229
|
+
return patterns.length === 1 ? "stdin" : "mixed"
|
|
230
|
+
default:
|
|
231
|
+
return "mixed"
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Report the outcome of a file-processing run.
|
|
237
|
+
*
|
|
238
|
+
* @param {Array<{modified: boolean, error: boolean}>} results - Result per file.
|
|
239
|
+
* @param {Object} options - Processing options.
|
|
240
|
+
*/
|
|
241
|
+
#reportSummary(results, options) {
|
|
242
|
+
const modifiedCount = results.filter(isModified).length
|
|
243
|
+
const errorCount = results.filter(isFailed).length
|
|
244
|
+
|
|
245
|
+
console.info("")
|
|
246
|
+
|
|
247
|
+
switch (modifiedCount) {
|
|
248
|
+
case 0:
|
|
249
|
+
if (errorCount === 0) {
|
|
250
|
+
console.info("All files are up to date")
|
|
251
|
+
}
|
|
252
|
+
break
|
|
253
|
+
default:
|
|
254
|
+
this.#reportUpgrades(modifiedCount, options)
|
|
255
|
+
break
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (errorCount > 0) {
|
|
259
|
+
console.info(`${formatFileCount(errorCount)} failed to process`)
|
|
260
|
+
// Errors take precedence over the --check flag.
|
|
261
|
+
process.exit(128)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (options.check && modifiedCount > 0) {
|
|
265
|
+
process.exit(1)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Report how many files were upgraded.
|
|
271
|
+
*
|
|
272
|
+
* @param {number} modifiedCount - Number of files with changes.
|
|
273
|
+
* @param {Object} options - Processing options.
|
|
274
|
+
*/
|
|
275
|
+
#reportUpgrades(modifiedCount, options) {
|
|
276
|
+
switch (true) {
|
|
277
|
+
case options.check:
|
|
278
|
+
console.info(
|
|
279
|
+
`${formatFileCount(modifiedCount)} need${modifiedCount === 1 ? "s" : ""} upgrading`,
|
|
280
|
+
)
|
|
281
|
+
if (options.write) {
|
|
282
|
+
console.info("Changes have been written")
|
|
283
|
+
}
|
|
284
|
+
break
|
|
285
|
+
case options.write:
|
|
286
|
+
console.info(`✓ ${formatFileCount(modifiedCount)} upgraded`)
|
|
287
|
+
break
|
|
288
|
+
default:
|
|
289
|
+
console.info(`${formatFileCount(modifiedCount)} would be upgraded`)
|
|
290
|
+
break
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Initialize CLI
|
|
296
|
+
const program = new Command()
|
|
297
|
+
const cliRunner = new CLIRunner(new URL("../src/worker.js", import.meta.url))
|
|
298
|
+
|
|
299
|
+
program
|
|
300
|
+
.name("cssupgrade")
|
|
301
|
+
.description("Auto-upgrade your CSS syntax")
|
|
302
|
+
.version(pkg.version)
|
|
303
|
+
.argument("<files...>", "Files or directories to process, or '-' to read from stdin")
|
|
304
|
+
.addOption(
|
|
305
|
+
new Option("--baseline <level>", "Set baseline level for transformations")
|
|
306
|
+
.choices(["widely-available", "newly-available"])
|
|
307
|
+
.default("widely-available"),
|
|
308
|
+
)
|
|
309
|
+
.option("--verbose, -v", "Show more detailed output.", false)
|
|
310
|
+
.option(
|
|
311
|
+
"--check",
|
|
312
|
+
"Report which files need upgrading and exit with code 1 if any do",
|
|
313
|
+
false,
|
|
314
|
+
)
|
|
315
|
+
.option("--write", "Write changes to files", false)
|
|
316
|
+
.action(async (files, options) => {
|
|
317
|
+
await cliRunner.run(files, options)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
program.parse()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="320" font-family="Segoe UI, system-ui, sans-serif" viewBox="0 0 1280 640">
|
|
2
|
+
<circle cx="260" cy="320" r="200" fill="#2965f1"/>
|
|
3
|
+
<path fill="none" stroke="#ffffff" stroke-width="48" d="m167 409 95-95 95 95m-190 -95 95-95 95 95m415 50"/>
|
|
4
|
+
<text x="241" y="148" fill="#c9d1d9" font-size="75" font-weight="bold" transform="matrix(1.6 0 0 1.6 120 120)">
|
|
5
|
+
CSSupgrade
|
|
6
|
+
</text>
|
|
7
|
+
<text x="241" y="192" fill="#c9d1d9" font-size="32" font-style="italic" transform="matrix(1.6 0 0 1.6 120 120)">
|
|
8
|
+
Auto-upgrade your CSS syntax
|
|
9
|
+
</text>
|
|
10
|
+
</svg>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="320" font-family="Segoe UI, system-ui, sans-serif" viewBox="0 0 1280 640">
|
|
2
|
+
<circle cx="260" cy="320" r="200" fill="#2965f1"/>
|
|
3
|
+
<path fill="none" stroke="#ffffff" stroke-width="48" d="m167 409 95-95 95 95m-190 -95 95-95 95 95m415 50"/>
|
|
4
|
+
<text x="241" y="148" font-size="75" font-weight="bold" transform="matrix(1.6 0 0 1.6 120 120)">
|
|
5
|
+
CSSupgrade
|
|
6
|
+
</text>
|
|
7
|
+
<text x="241" y="192" font-size="32" font-style="italic" transform="matrix(1.6 0 0 1.6 120 120)">
|
|
8
|
+
Auto-upgrade your CSS syntax
|
|
9
|
+
</text>
|
|
10
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cssupgrade",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2026.0.1",
|
|
4
|
+
"description": "Auto-upgrade your CSS syntax",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cssupgrade": "./bin/cssupgrade.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=lcov.info --test-coverage-exclude=tests/**/* tests/*.test.js tests/**/*.test.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"css",
|
|
15
|
+
"modernize",
|
|
16
|
+
"upgrade",
|
|
17
|
+
"codemod",
|
|
18
|
+
"ast",
|
|
19
|
+
"transform",
|
|
20
|
+
"nesting"
|
|
21
|
+
],
|
|
22
|
+
"author": "Johannes Maron",
|
|
23
|
+
"license": "BSD-2-Clause",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"commander": ">=14.0.2",
|
|
26
|
+
"css-tree": "^3.2.1",
|
|
27
|
+
"diff": ">=8.0.2"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=24.0.0"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/codingjoe/cssupgrade.git"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cssupgrade
|
|
3
|
+
description: CSSupgrade auto-updates CSS syntax to new features based on browser support. Use when the user asks to modernize, upgrade, or clean up CSS syntax.
|
|
4
|
+
allowed-tools: Bash(npx -y cssupgrade *) Read
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Use the CLI with `npx` on files or standard input:
|
|
8
|
+
|
|
9
|
+
```console
|
|
10
|
+
npx -y cssupgrade [--baseline <newly-available|widely-available>] [--check] [--write] <files|->
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- Use `--check` to preview changes and fail when updates are needed.
|
|
14
|
+
- Use `-` to read from standard input and write the transformed code to standard output.
|
|
15
|
+
- Use `--write` to apply updates in place for file inputs.
|
|
16
|
+
- Use `--baseline` to choose `newly-available` or `widely-available` (default).
|
package/src/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as newlyAvailable from "./newlyAvailable.js"
|
|
2
|
+
import { matchesPrefilter } from "./prefilters.js"
|
|
3
|
+
import * as widelyAvailable from "./widelyAvailable.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} TransformResult
|
|
7
|
+
* @property {string} code - Transformed CSS source.
|
|
8
|
+
* @property {boolean} modified - Whether the source changed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {function(string): TransformResult} Transformer
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {[string, Transformer]} NamedTransformer
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Apply every transformer that passes its pre-filter, then repeat on the result.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} code - CSS source text.
|
|
23
|
+
* @param {NamedTransformer[]} transformers - Transformers to apply.
|
|
24
|
+
* @param {boolean} globalModified - Whether an earlier pass changed the source.
|
|
25
|
+
* @returns {TransformResult} Transformed source.
|
|
26
|
+
*/
|
|
27
|
+
function applyTransformersRecursively(code, transformers, globalModified = false) {
|
|
28
|
+
const passResult = transformers.reduce(
|
|
29
|
+
(result, [name, transformer]) =>
|
|
30
|
+
matchesPrefilter(name, result.code) ? transformer(result.code) : result,
|
|
31
|
+
{ code, modified: false },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return passResult.modified
|
|
35
|
+
? applyTransformersRecursively(passResult.code, transformers, true)
|
|
36
|
+
: { code: passResult.code, modified: globalModified }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Transform CSS using the transformers of the selected baseline.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} code - CSS source text.
|
|
43
|
+
* @param {string} [baseline] - Baseline level ('widely-available' or 'newly-available').
|
|
44
|
+
* @returns {TransformResult} Transformed source.
|
|
45
|
+
*/
|
|
46
|
+
export function transform(code, baseline = "widely-available") {
|
|
47
|
+
const transformers =
|
|
48
|
+
baseline === "newly-available"
|
|
49
|
+
? { ...widelyAvailable, ...newlyAvailable }
|
|
50
|
+
: widelyAvailable
|
|
51
|
+
|
|
52
|
+
return applyTransformersRecursively(code, Object.entries(transformers))
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as csstree from "css-tree"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} SourceRange
|
|
5
|
+
* @property {number} start - Offset of the first character.
|
|
6
|
+
* @property {number} end - Offset after the last character.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} ParsedCss
|
|
11
|
+
* @property {import("css-tree").CssNode} ast - Parsed CSS syntax tree.
|
|
12
|
+
* @property {SourceRange[]} comments - Comment ranges in source order.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Record the range of every comment in the source text.
|
|
17
|
+
*
|
|
18
|
+
* @param {SourceRange[]} comments - Collected comment ranges.
|
|
19
|
+
* @param {string} _value - Comment text without delimiters.
|
|
20
|
+
* @param {{start: {offset: number}, end: {offset: number}}} loc - Comment location.
|
|
21
|
+
* @returns {void}
|
|
22
|
+
*/
|
|
23
|
+
function recordComment(comments, _value, loc) {
|
|
24
|
+
comments.push({ start: loc.start.offset, end: loc.end.offset })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse CSS and collect its comments, which the syntax tree does not include.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} css - CSS source text.
|
|
31
|
+
* @returns {ParsedCss} Syntax tree and comment ranges.
|
|
32
|
+
*/
|
|
33
|
+
export function parseCss(css) {
|
|
34
|
+
const comments = []
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
ast: csstree.parse(css, {
|
|
38
|
+
positions: true,
|
|
39
|
+
onComment: recordComment.bind(null, comments),
|
|
40
|
+
}),
|
|
41
|
+
comments,
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/pool.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import os from "os"
|
|
2
|
+
import { Worker } from "worker_threads"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {(filePath: string, options: Object, worker: TransformWorker) => Promise<{modified: boolean, error: boolean}>} FileProcessorFunction
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Restore an error posted by a transform worker.
|
|
10
|
+
*
|
|
11
|
+
* @param {{name: string, message: string, stack: string | undefined}} errorData - Plain error data.
|
|
12
|
+
* @returns {Error} Error carrying the worker name, message, and stack.
|
|
13
|
+
*/
|
|
14
|
+
function reviveError({ name, message, stack }) {
|
|
15
|
+
return Object.assign(new Error(message), { name, stack })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Loading the transformation pipeline takes tens of milliseconds, so one worker
|
|
20
|
+
* transforms many files instead of being spawned for a single file.
|
|
21
|
+
*/
|
|
22
|
+
export class TransformWorker {
|
|
23
|
+
#worker
|
|
24
|
+
#pending = null
|
|
25
|
+
#stopError = null
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string | URL} workerPath - Path of the worker thread module
|
|
29
|
+
*/
|
|
30
|
+
constructor(workerPath) {
|
|
31
|
+
this.#worker = new Worker(workerPath)
|
|
32
|
+
this.#worker.on("message", (message) => this.#settle(message))
|
|
33
|
+
this.#worker.on("error", (error) => this.#stop(error))
|
|
34
|
+
this.#worker.on("exit", (code) =>
|
|
35
|
+
this.#stop(new Error(`Worker stopped with exit code ${code}`)),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get stopped() {
|
|
40
|
+
return this.#stopError !== null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A stopped thread drops posted messages without a response, so requests fail
|
|
45
|
+
* immediately instead of waiting for a reply that never comes. One request runs
|
|
46
|
+
* at a time, so a second request fails loudly instead of losing the first.
|
|
47
|
+
*
|
|
48
|
+
* @param {{filePath: string, baseline: string, includeOriginal: boolean}} request - File to transform
|
|
49
|
+
* @returns {Promise<Object>} Worker response message
|
|
50
|
+
*/
|
|
51
|
+
transform(request) {
|
|
52
|
+
if (this.#stopError) {
|
|
53
|
+
return Promise.reject(this.#stopError)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (this.#pending) {
|
|
57
|
+
return Promise.reject(new Error("Worker already transformed a file"))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
this.#pending = { resolve, reject }
|
|
62
|
+
this.#worker.postMessage(request)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @returns {Promise<number>} Exit code of the stopped thread
|
|
68
|
+
*/
|
|
69
|
+
terminate() {
|
|
70
|
+
return this.#worker.terminate()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {Object} message - Worker response message
|
|
75
|
+
*/
|
|
76
|
+
#settle(message) {
|
|
77
|
+
this.#pending?.resolve(
|
|
78
|
+
message.success ? message : { ...message, error: reviveError(message.error) },
|
|
79
|
+
)
|
|
80
|
+
this.#pending = null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {Error} error - Reason the worker stopped
|
|
85
|
+
*/
|
|
86
|
+
#stop(error) {
|
|
87
|
+
this.#stopError ??= error
|
|
88
|
+
this.#pending?.reject(error)
|
|
89
|
+
this.#pending = null
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Each worker loads the transformation pipeline once and then processes files
|
|
95
|
+
* until the queue is empty. A worker that stopped is replaced before it takes on
|
|
96
|
+
* the next file, so one stopped thread cannot fail the remaining files.
|
|
97
|
+
*/
|
|
98
|
+
export class WorkerPool {
|
|
99
|
+
#fileProcessor
|
|
100
|
+
#workerPath
|
|
101
|
+
#maxWorkers
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {FileProcessorFunction} fileProcessor - Processor that handles one file
|
|
105
|
+
* @param {string | URL} workerPath - Path of the worker thread module
|
|
106
|
+
* @param {number} [maxWorkers] - Upper bound of concurrent workers
|
|
107
|
+
*/
|
|
108
|
+
constructor(fileProcessor, workerPath, maxWorkers = os.availableParallelism()) {
|
|
109
|
+
this.#fileProcessor = fileProcessor
|
|
110
|
+
this.#workerPath = workerPath
|
|
111
|
+
this.#maxWorkers = maxWorkers
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The results keep the input order of the given files.
|
|
116
|
+
*
|
|
117
|
+
* @param {string[]} files - Files to process
|
|
118
|
+
* @param {Object} options - Processing options
|
|
119
|
+
* @returns {Promise<Array<{modified: boolean, error: boolean}>>} Result per file
|
|
120
|
+
*/
|
|
121
|
+
async processFiles(files, options) {
|
|
122
|
+
const results = new Array(files.length)
|
|
123
|
+
let fileIndex = 0
|
|
124
|
+
|
|
125
|
+
const processQueue = async () => {
|
|
126
|
+
let worker = new TransformWorker(this.#workerPath)
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
while (fileIndex < files.length) {
|
|
130
|
+
const currentIndex = fileIndex++
|
|
131
|
+
results[currentIndex] = await this.#fileProcessor(
|
|
132
|
+
files[currentIndex],
|
|
133
|
+
options,
|
|
134
|
+
worker,
|
|
135
|
+
)
|
|
136
|
+
worker = await this.#replaceStoppedWorker(worker)
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
await worker.terminate()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const workerCount = Math.min(this.#maxWorkers, files.length)
|
|
144
|
+
const queues = Array.from({ length: workerCount }, processQueue)
|
|
145
|
+
await Promise.all(queues)
|
|
146
|
+
|
|
147
|
+
return results
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Provide a running worker for the next file.
|
|
152
|
+
*
|
|
153
|
+
* @param {TransformWorker} worker - Worker that handled the previous file
|
|
154
|
+
* @returns {Promise<TransformWorker>} The worker itself, or a replacement
|
|
155
|
+
*/
|
|
156
|
+
async #replaceStoppedWorker(worker) {
|
|
157
|
+
if (!worker.stopped) {
|
|
158
|
+
return worker
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await worker.terminate()
|
|
162
|
+
|
|
163
|
+
return new TransformWorker(this.#workerPath)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source text conditions that decide whether a transformer can match.
|
|
3
|
+
*
|
|
4
|
+
* Every transformer parses the whole source, which dominates the runtime of
|
|
5
|
+
* this package. A transformer can only match when its pattern occurs in the
|
|
6
|
+
* source text, so testing the text first skips the parse for transformations
|
|
7
|
+
* that cannot apply.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Conditions a transformer needs in the source text. A transformer runs when all
|
|
12
|
+
* of its conditions match. Each condition must be necessary: when the source
|
|
13
|
+
* text fails a condition, the transformer cannot change the tree.
|
|
14
|
+
*
|
|
15
|
+
* @type {Map<string, RegExp[]>}
|
|
16
|
+
*/
|
|
17
|
+
export const prefilters = new Map([
|
|
18
|
+
// Nesting needs two rules, so the source holds at least two blocks.
|
|
19
|
+
["cssNesting", [/\{[\s\S]*\{/]],
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} name - Transformer name
|
|
24
|
+
* @param {string} code - Source text of the current transformation pass
|
|
25
|
+
* @returns {boolean} True when the transformer needs to run
|
|
26
|
+
*/
|
|
27
|
+
export function matchesPrefilter(name, code) {
|
|
28
|
+
return prefilters.get(name).every((condition) => condition.test(code))
|
|
29
|
+
}
|