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,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { describe, test } from "node:test"
|
|
3
|
+
import { transform } from "../src/index.js"
|
|
4
|
+
|
|
5
|
+
describe("transform", () => {
|
|
6
|
+
test("leave flat CSS without repeated selectors unchanged", () => {
|
|
7
|
+
const result = transform(".a {\n color: red;\n}\n\n.b {\n color: blue;\n}\n")
|
|
8
|
+
|
|
9
|
+
assert.equal(result.modified, false)
|
|
10
|
+
assert.equal(result.code, ".a {\n color: red;\n}\n\n.b {\n color: blue;\n}\n")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("nest rules with the newly available baseline", () => {
|
|
14
|
+
const result = transform(
|
|
15
|
+
".a { color: red } .a .b { color: blue }",
|
|
16
|
+
"newly-available",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
assert.equal(result.modified, true)
|
|
20
|
+
assert.equal(result.code, ".a {\n color: red;\n .b {\n color: blue;\n }\n}")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("produce a stable result on a second pass", () => {
|
|
24
|
+
const first = transform(".a { color: red } .a .b { color: blue }")
|
|
25
|
+
const second = transform(first.code)
|
|
26
|
+
|
|
27
|
+
assert.equal(second.modified, false)
|
|
28
|
+
assert.equal(second.code, first.code)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("leave existing nesting unchanged", () => {
|
|
32
|
+
const result = transform(".a { color: red; .b { color: blue } }")
|
|
33
|
+
|
|
34
|
+
assert.equal(result.modified, false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("leave keyframes unchanged", () => {
|
|
38
|
+
const result = transform("@keyframes fade { from { opacity: 0 } }")
|
|
39
|
+
|
|
40
|
+
assert.equal(result.modified, false)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { describe, test } from "node:test"
|
|
3
|
+
import { parseCss } from "../src/parser.js"
|
|
4
|
+
|
|
5
|
+
describe("parseCss", () => {
|
|
6
|
+
test("record comments", () => {
|
|
7
|
+
const { comments } = parseCss(".a { /* one */ color: red; /* two */ }")
|
|
8
|
+
|
|
9
|
+
assert.deepEqual(
|
|
10
|
+
comments.map(({ start, end }) =>
|
|
11
|
+
".a { /* one */ color: red; /* two */ }".slice(start, end),
|
|
12
|
+
),
|
|
13
|
+
["/* one */", "/* two */"],
|
|
14
|
+
)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("parse flat rules", () => {
|
|
18
|
+
const { ast } = parseCss(".a { color: red } .a .b { color: blue }")
|
|
19
|
+
|
|
20
|
+
assert.deepEqual(
|
|
21
|
+
Array.from(ast.children, (child) => child.type),
|
|
22
|
+
["Rule", "Rule"],
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("parse nested rules as raw declarations", () => {
|
|
27
|
+
const { ast } = parseCss(".a { .b { color: red } }")
|
|
28
|
+
|
|
29
|
+
assert.equal(ast.children.first.block.children.first.type, "Raw")
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("parse a custom property with a block value as a declaration", () => {
|
|
33
|
+
const { ast } = parseCss(".a { --theme: { color: red; }; }")
|
|
34
|
+
|
|
35
|
+
assert.equal(ast.children.first.block.children.first.type, "Declaration")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("parse a stray semicolon as raw", () => {
|
|
39
|
+
const { ast } = parseCss(".a { ; }")
|
|
40
|
+
|
|
41
|
+
assert.equal(ast.children.first.block.children.first.type, "Raw")
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { afterEach, beforeEach, describe, test } from "node:test"
|
|
6
|
+
import { TransformWorker, WorkerPool } from "../src/pool.js"
|
|
7
|
+
|
|
8
|
+
const WORKER_PATH = path.join(process.cwd(), "src", "worker.js")
|
|
9
|
+
const SILENT_WORKER_PATH = path.join(
|
|
10
|
+
process.cwd(),
|
|
11
|
+
"tests",
|
|
12
|
+
"fixtures",
|
|
13
|
+
"silent-worker.js",
|
|
14
|
+
)
|
|
15
|
+
const DEEP_AT_RULE_CSS = `${"@media all {".repeat(10_000)}.a { } .a .b { color: red }${"}".repeat(10_000)}`
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Collect the workers a pool used while processing files.
|
|
19
|
+
*
|
|
20
|
+
* @returns {{fileProcessor: Function, usedWorkers: Set<TransformWorker>}} Stub processor
|
|
21
|
+
*/
|
|
22
|
+
function createRecordingProcessor() {
|
|
23
|
+
const usedWorkers = new Set()
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
usedWorkers,
|
|
27
|
+
async fileProcessor(filePath, options, worker) {
|
|
28
|
+
usedWorkers.add(worker)
|
|
29
|
+
|
|
30
|
+
return { modified: false, error: false }
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("TransformWorker", () => {
|
|
36
|
+
let tempDir
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cssupgrade-pool-"))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
44
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("transform a file in a worker thread", async () => {
|
|
49
|
+
const filePath = path.join(tempDir, "legacy.css")
|
|
50
|
+
fs.writeFileSync(filePath, ".a { color: red } .a .b { color: blue }")
|
|
51
|
+
const worker = new TransformWorker(WORKER_PATH)
|
|
52
|
+
|
|
53
|
+
const message = await worker.transform({
|
|
54
|
+
filePath,
|
|
55
|
+
baseline: "widely-available",
|
|
56
|
+
includeOriginal: true,
|
|
57
|
+
})
|
|
58
|
+
await worker.terminate()
|
|
59
|
+
|
|
60
|
+
assert.equal(message.success, true, "reports success")
|
|
61
|
+
assert.equal(message.result.modified, true, "reports the change")
|
|
62
|
+
assert.equal(
|
|
63
|
+
message.result.code,
|
|
64
|
+
".a {\n color: red;\n .b {\n color: blue;\n }\n}",
|
|
65
|
+
"transforms the code",
|
|
66
|
+
)
|
|
67
|
+
assert.match(message.result.original, /\.a \.b/, "returns the original source")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("omit the original source when it is not requested", async () => {
|
|
71
|
+
const filePath = path.join(tempDir, "legacy.css")
|
|
72
|
+
fs.writeFileSync(filePath, ".a { color: red } .a .b { color: blue }")
|
|
73
|
+
const worker = new TransformWorker(WORKER_PATH)
|
|
74
|
+
|
|
75
|
+
const message = await worker.transform({
|
|
76
|
+
filePath,
|
|
77
|
+
baseline: "widely-available",
|
|
78
|
+
includeOriginal: false,
|
|
79
|
+
})
|
|
80
|
+
await worker.terminate()
|
|
81
|
+
|
|
82
|
+
assert.equal(message.result.modified, true, "reports the change")
|
|
83
|
+
assert.equal(
|
|
84
|
+
message.result.code,
|
|
85
|
+
".a {\n color: red;\n .b {\n color: blue;\n }\n}",
|
|
86
|
+
"transforms the code",
|
|
87
|
+
)
|
|
88
|
+
assert.equal(message.result.original, undefined, "omits the original source")
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("report files the worker cannot read", async () => {
|
|
92
|
+
const worker = new TransformWorker(WORKER_PATH)
|
|
93
|
+
|
|
94
|
+
const message = await worker.transform({
|
|
95
|
+
filePath: path.join(tempDir, "missing.css"),
|
|
96
|
+
baseline: "widely-available",
|
|
97
|
+
includeOriginal: true,
|
|
98
|
+
})
|
|
99
|
+
await worker.terminate()
|
|
100
|
+
|
|
101
|
+
assert.equal(message.success, false, "reports a failure")
|
|
102
|
+
assert.ok(message.error instanceof Error, "restores an error object")
|
|
103
|
+
assert.match(message.error.message, /ENOENT/, "reports the read failure")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("report the transform error of a file", async () => {
|
|
107
|
+
const filePath = path.join(tempDir, "broken.css")
|
|
108
|
+
fs.writeFileSync(filePath, DEEP_AT_RULE_CSS)
|
|
109
|
+
const worker = new TransformWorker(WORKER_PATH)
|
|
110
|
+
|
|
111
|
+
const message = await worker.transform({
|
|
112
|
+
filePath,
|
|
113
|
+
baseline: "widely-available",
|
|
114
|
+
includeOriginal: false,
|
|
115
|
+
})
|
|
116
|
+
await worker.terminate()
|
|
117
|
+
|
|
118
|
+
assert.equal(message.success, false, "reports a failure")
|
|
119
|
+
assert.equal(message.error.name, "RangeError", "keeps the error name")
|
|
120
|
+
assert.match(
|
|
121
|
+
message.error.message,
|
|
122
|
+
/Maximum call stack size exceeded/,
|
|
123
|
+
"keeps the transform error message",
|
|
124
|
+
)
|
|
125
|
+
assert.match(message.error.stack, /\n\s*at /, "keeps the stack trace")
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test("reject pending requests when the worker stops", async () => {
|
|
129
|
+
const worker = new TransformWorker(SILENT_WORKER_PATH)
|
|
130
|
+
const pending = worker.transform({
|
|
131
|
+
filePath: "unused.css",
|
|
132
|
+
baseline: "widely-available",
|
|
133
|
+
includeOriginal: false,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
await worker.terminate()
|
|
137
|
+
|
|
138
|
+
await assert.rejects(
|
|
139
|
+
pending,
|
|
140
|
+
{ name: "Error", message: /^Worker stopped with exit code \d+$/ },
|
|
141
|
+
"rejects the request",
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("reject a second request while one is in flight", async () => {
|
|
146
|
+
const worker = new TransformWorker(SILENT_WORKER_PATH)
|
|
147
|
+
const inFlight = worker.transform({
|
|
148
|
+
filePath: "unused.css",
|
|
149
|
+
baseline: "widely-available",
|
|
150
|
+
includeOriginal: false,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
await assert.rejects(
|
|
154
|
+
worker.transform({
|
|
155
|
+
filePath: "unused.css",
|
|
156
|
+
baseline: "widely-available",
|
|
157
|
+
includeOriginal: false,
|
|
158
|
+
}),
|
|
159
|
+
{ message: "Worker already transformed a file" },
|
|
160
|
+
"rejects the second request",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
await worker.terminate()
|
|
164
|
+
await assert.rejects(inFlight, "rejects the request still in flight")
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test("reject requests sent to a stopped worker", async () => {
|
|
168
|
+
const worker = new TransformWorker(SILENT_WORKER_PATH)
|
|
169
|
+
await worker.terminate()
|
|
170
|
+
|
|
171
|
+
await assert.rejects(
|
|
172
|
+
worker.transform({
|
|
173
|
+
filePath: "unused.css",
|
|
174
|
+
baseline: "widely-available",
|
|
175
|
+
includeOriginal: false,
|
|
176
|
+
}),
|
|
177
|
+
{ name: "Error", message: /^Worker stopped with exit code \d+$/ },
|
|
178
|
+
"rejects the request",
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("reject requests when the worker module fails to load", async () => {
|
|
183
|
+
const worker = new TransformWorker(path.join(tempDir, "missing-worker.js"))
|
|
184
|
+
|
|
185
|
+
await assert.rejects(
|
|
186
|
+
worker.transform({
|
|
187
|
+
filePath: "unused.css",
|
|
188
|
+
baseline: "widely-available",
|
|
189
|
+
includeOriginal: false,
|
|
190
|
+
}),
|
|
191
|
+
{ code: "MODULE_NOT_FOUND" },
|
|
192
|
+
"rejects the request",
|
|
193
|
+
)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe("WorkerPool", () => {
|
|
198
|
+
test("process files with a limited number of workers", async () => {
|
|
199
|
+
const files = ["a.css", "b.css", "c.css", "d.css"]
|
|
200
|
+
const { fileProcessor, usedWorkers } = createRecordingProcessor()
|
|
201
|
+
const pool = new WorkerPool(fileProcessor, SILENT_WORKER_PATH, 2)
|
|
202
|
+
|
|
203
|
+
const results = await pool.processFiles(files, {})
|
|
204
|
+
|
|
205
|
+
assert.equal(results.length, files.length, "returns one result per file")
|
|
206
|
+
assert.deepEqual(
|
|
207
|
+
results.map((result) => result.modified),
|
|
208
|
+
[false, false, false, false],
|
|
209
|
+
"keeps the input order",
|
|
210
|
+
)
|
|
211
|
+
assert.equal(usedWorkers.size, 2, "reuses workers across files")
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test("replace a worker that stopped", async () => {
|
|
215
|
+
const files = ["a.css", "b.css"]
|
|
216
|
+
const usedWorkers = new Set()
|
|
217
|
+
|
|
218
|
+
async function stopAfterFile(filePath, options, worker) {
|
|
219
|
+
usedWorkers.add(worker)
|
|
220
|
+
await worker.terminate()
|
|
221
|
+
|
|
222
|
+
return { modified: false, error: false }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const pool = new WorkerPool(stopAfterFile, SILENT_WORKER_PATH, 1)
|
|
226
|
+
|
|
227
|
+
const results = await pool.processFiles(files, {})
|
|
228
|
+
|
|
229
|
+
assert.equal(results.length, files.length, "processes every file")
|
|
230
|
+
assert.equal(usedWorkers.size, 2, "spawns a replacement worker")
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test("process no files without spawning a worker", async () => {
|
|
234
|
+
const { fileProcessor, usedWorkers } = createRecordingProcessor()
|
|
235
|
+
const pool = new WorkerPool(fileProcessor, SILENT_WORKER_PATH, 2)
|
|
236
|
+
|
|
237
|
+
const results = await pool.processFiles([], {})
|
|
238
|
+
|
|
239
|
+
assert.deepEqual(results, [], "returns no results")
|
|
240
|
+
assert.equal(usedWorkers.size, 0, "spawns no worker")
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { describe, test } from "node:test"
|
|
3
|
+
import * as newlyAvailable from "../src/newlyAvailable.js"
|
|
4
|
+
import { matchesPrefilter, prefilters } from "../src/prefilters.js"
|
|
5
|
+
import * as widelyAvailable from "../src/widelyAvailable.js"
|
|
6
|
+
|
|
7
|
+
describe("prefilters", () => {
|
|
8
|
+
test("accept CSS that can contain nesting", () => {
|
|
9
|
+
assert.equal(matchesPrefilter("cssNesting", ".a { .b { color: red } }"), true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test("reject CSS with a single block", () => {
|
|
13
|
+
assert.equal(matchesPrefilter("cssNesting", ".a { color: red }"), false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("declare a pre-filter for every transformer", () => {
|
|
17
|
+
const names = [...Object.keys(widelyAvailable), ...Object.keys(newlyAvailable)]
|
|
18
|
+
|
|
19
|
+
assert.deepEqual(
|
|
20
|
+
names.filter((name) => !prefilters.has(name)),
|
|
21
|
+
[],
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
})
|