tempest-react-sdk 0.28.1 → 0.29.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/bin/lib/alias/index.mjs +9 -3
- package/bin/lib/alias/tsconfig.mjs +48 -5
- package/bin/lib/alias/typescript.mjs +72 -2
- package/bin/lib/alias/typescript.test.mjs +120 -0
- package/bin/lib/css/extract.mjs +405 -0
- package/bin/lib/css/extract.test.mjs +304 -0
- package/bin/lib/css/fix.e2e.test.mjs +92 -1
- package/bin/lib/css/index.mjs +10 -0
- package/bin/lib/css/references.mjs +238 -0
- package/bin/lib/doctor/doctor.e2e.test.mjs +44 -0
- package/bin/tempest.mjs +146 -9
- package/package.json +1 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { analyzeCss } from "./analyze.mjs";
|
|
8
|
+
import { applyExtraction, classNamesIn, kebab, loneClass, planExtraction } from "./extract.mjs";
|
|
9
|
+
import { parseCss } from "./parse.mjs";
|
|
10
|
+
|
|
11
|
+
const SELF_DIR = resolve(dirname(new URL(import.meta.url).pathname), "..", "..");
|
|
12
|
+
const TYPESCRIPT_DIR = dirname(createRequire(import.meta.url).resolve("typescript/package.json"));
|
|
13
|
+
|
|
14
|
+
let root;
|
|
15
|
+
|
|
16
|
+
/** Write a file inside the fixture project, creating its directory. */
|
|
17
|
+
function write(rel, text) {
|
|
18
|
+
const full = join(root, rel);
|
|
19
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
20
|
+
writeFileSync(full, text);
|
|
21
|
+
return full;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ROW = `.row {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
gap: 8px;
|
|
28
|
+
}
|
|
29
|
+
.title {
|
|
30
|
+
font-weight: 600;
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A minimal project the codemod can act on: a tsconfig with the `@/*` alias, a
|
|
36
|
+
* global stylesheet that the entry imports, and the project's own TypeScript
|
|
37
|
+
* (symlinked, because the CLI resolves the compiler from the project on purpose).
|
|
38
|
+
*/
|
|
39
|
+
function project() {
|
|
40
|
+
write("package.json", JSON.stringify({ name: "fixture" }));
|
|
41
|
+
write(
|
|
42
|
+
"tsconfig.json",
|
|
43
|
+
JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } } }),
|
|
44
|
+
);
|
|
45
|
+
write("src/index.css", ":root {\n --brand: #0d6efd;\n}\n");
|
|
46
|
+
write("src/main.tsx", 'import "./index.css";\n');
|
|
47
|
+
mkdirSync(join(root, "node_modules"), { recursive: true });
|
|
48
|
+
symlinkSync(TYPESCRIPT_DIR, join(root, "node_modules", "typescript"), "dir");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Two modules declaring the same block, each used from its own component. */
|
|
52
|
+
function twoCopies() {
|
|
53
|
+
write("src/components/Card.module.css", ROW);
|
|
54
|
+
write(
|
|
55
|
+
"src/components/Card.tsx",
|
|
56
|
+
'import styles from "./Card.module.css";\n\nexport const Card = () => (\n <div className={styles.row}>\n <b className={styles.title}>x</b>\n </div>\n);\n',
|
|
57
|
+
);
|
|
58
|
+
write("src/components/List.module.css", ROW.replace(".row", ".line"));
|
|
59
|
+
write(
|
|
60
|
+
"src/components/List.tsx",
|
|
61
|
+
'import styles from "@/components/List.module.css";\n\nexport const List = () => (\n <li className={styles.line}>\n <b className={styles.title}>x</b>\n </li>\n);\n',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const plan = (options = {}) =>
|
|
66
|
+
planExtraction({
|
|
67
|
+
analysis: analyzeCss({ root, targets: ["."], selfDir: SELF_DIR }),
|
|
68
|
+
root,
|
|
69
|
+
...options,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
root = mkdtempSync(join(tmpdir(), "tempest-extract-"));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
rmSync(root, { recursive: true, force: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("helpers", () => {
|
|
81
|
+
it("recognizes a lone class selector and rejects anything else", () => {
|
|
82
|
+
expect(loneClass(".row")).toBe("row");
|
|
83
|
+
expect(loneClass(" .card-header ")).toBe("card-header");
|
|
84
|
+
expect(loneClass(".row:hover")).toBeNull();
|
|
85
|
+
expect(loneClass(".a .b")).toBeNull();
|
|
86
|
+
expect(loneClass(".a.b")).toBeNull();
|
|
87
|
+
expect(loneClass("div")).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("kebab-cases a camelCase class name", () => {
|
|
91
|
+
expect(kebab("cardHeader")).toBe("card-header");
|
|
92
|
+
expect(kebab("row")).toBe("row");
|
|
93
|
+
expect(kebab("grid_2")).toBe("grid-2");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("collects every class a sheet defines", () => {
|
|
97
|
+
const parsed = parseCss(".a .b { color: red; }\n.c:hover { color: blue; }\n");
|
|
98
|
+
expect(classNamesIn(parsed)).toEqual(new Set(["a", "b", "c"]));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("planExtraction — the happy path", () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
project();
|
|
105
|
+
twoCopies();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("plans one global class from two modules, with every call site", () => {
|
|
109
|
+
const result = plan();
|
|
110
|
+
expect(result.status).toBe("ok");
|
|
111
|
+
expect(result.target.file).toBe(join("src", "index.css"));
|
|
112
|
+
expect(result.groups).toHaveLength(1);
|
|
113
|
+
|
|
114
|
+
const [group] = result.groups;
|
|
115
|
+
expect(group.name).toBe("u-row");
|
|
116
|
+
expect(group.decls.map((d) => d.prop)).toEqual(["display", "align-items", "gap"]);
|
|
117
|
+
expect(group.occurrences.map((o) => o.className).sort()).toEqual(["line", "row"]);
|
|
118
|
+
expect(group.occurrences.flatMap((o) => o.sites)).toHaveLength(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("honors --css-prefix", () => {
|
|
122
|
+
expect(plan({ prefix: "shared-" }).groups[0].name).toBe("shared-row");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("honors an explicit --css-target", () => {
|
|
126
|
+
write("src/styles/app.css", "");
|
|
127
|
+
write("src/main.tsx", 'import "./styles/app.css";\n');
|
|
128
|
+
expect(plan({ target: "src/styles/app.css" }).target.file).toBe(
|
|
129
|
+
join("src", "styles", "app.css"),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("applyExtraction", () => {
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
project();
|
|
137
|
+
twoCopies();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("moves the rule, deletes the local copies and rewrites the JSX", () => {
|
|
141
|
+
const result = applyExtraction({ plan: plan() });
|
|
142
|
+
expect(result.moved).toBe(2);
|
|
143
|
+
expect(result.rules).toBe(1);
|
|
144
|
+
|
|
145
|
+
const global = readFileSync(join(root, "src/index.css"), "utf8");
|
|
146
|
+
expect(global).toContain(".u-row {");
|
|
147
|
+
expect(global).toContain("align-items: center;");
|
|
148
|
+
expect(global).toContain("extraído por");
|
|
149
|
+
|
|
150
|
+
const card = readFileSync(join(root, "src/components/Card.module.css"), "utf8");
|
|
151
|
+
expect(card).not.toContain(".row");
|
|
152
|
+
expect(card).toContain(".title");
|
|
153
|
+
|
|
154
|
+
// `className={styles.row}` loses the braces; the other class is untouched.
|
|
155
|
+
const tsx = readFileSync(join(root, "src/components/Card.tsx"), "utf8");
|
|
156
|
+
expect(tsx).toContain('<div className="u-row">');
|
|
157
|
+
expect(tsx).toContain("{styles.title}");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("writes nothing on a dry run", () => {
|
|
161
|
+
const before = readFileSync(join(root, "src/components/Card.tsx"), "utf8");
|
|
162
|
+
const result = applyExtraction({ plan: plan(), dryRun: true });
|
|
163
|
+
expect(result.moved).toBe(2);
|
|
164
|
+
expect(readFileSync(join(root, "src/components/Card.tsx"), "utf8")).toBe(before);
|
|
165
|
+
expect(readFileSync(join(root, "src/index.css"), "utf8")).not.toContain(".u-row");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("leaves nothing to do on a second run", () => {
|
|
169
|
+
applyExtraction({ plan: plan() });
|
|
170
|
+
expect(plan().groups).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("rewrites a class read through a string index", () => {
|
|
174
|
+
write(
|
|
175
|
+
"src/components/List.tsx",
|
|
176
|
+
'import styles from "./List.module.css";\n\nexport const List = () => <li className={styles["line"]} />;\n',
|
|
177
|
+
);
|
|
178
|
+
applyExtraction({ plan: plan() });
|
|
179
|
+
expect(readFileSync(join(root, "src/components/List.tsx"), "utf8")).toContain(
|
|
180
|
+
'className="u-row"',
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("keeps the surrounding call when the class is one argument of many", () => {
|
|
185
|
+
write(
|
|
186
|
+
"src/components/List.tsx",
|
|
187
|
+
'import { cn } from "tempest-react-sdk";\nimport styles from "./List.module.css";\n\nexport const List = ({ on }: { on: boolean }) => (\n <li className={cn(styles.line, on && styles.title)} />\n);\n',
|
|
188
|
+
);
|
|
189
|
+
applyExtraction({ plan: plan() });
|
|
190
|
+
expect(readFileSync(join(root, "src/components/List.tsx"), "utf8")).toContain(
|
|
191
|
+
'cn("u-row", on && styles.title)',
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("planExtraction — refusals", () => {
|
|
197
|
+
beforeEach(project);
|
|
198
|
+
|
|
199
|
+
/** The reason attached to the first refusal, for a fixture built by the caller. */
|
|
200
|
+
const refusal = () => plan().refusals[0]?.reason ?? "";
|
|
201
|
+
|
|
202
|
+
it("refuses a class another rule in the same module mentions", () => {
|
|
203
|
+
twoCopies();
|
|
204
|
+
write("src/components/Card.module.css", `${ROW}.row:hover {\n opacity: 0.8;\n}\n`);
|
|
205
|
+
expect(refusal()).toContain("ficaria sem sujeito");
|
|
206
|
+
expect(plan().groups).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("refuses a rule inside an at-rule", () => {
|
|
210
|
+
twoCopies();
|
|
211
|
+
write(
|
|
212
|
+
"src/components/Card.module.css",
|
|
213
|
+
`.title {\n font-weight: 600;\n}\n@media (min-width: 600px) {\n .row {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n}\n`,
|
|
214
|
+
);
|
|
215
|
+
expect(refusal()).toContain("mudaria quando a regra vale");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("refuses when the module would be left empty", () => {
|
|
219
|
+
twoCopies();
|
|
220
|
+
write(
|
|
221
|
+
"src/components/Card.module.css",
|
|
222
|
+
".row {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n",
|
|
223
|
+
);
|
|
224
|
+
expect(refusal()).toContain("única regra do módulo");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("refuses a module used through a computed key", () => {
|
|
228
|
+
twoCopies();
|
|
229
|
+
write(
|
|
230
|
+
"src/components/Card.tsx",
|
|
231
|
+
'import styles from "./Card.module.css";\n\nexport const Card = ({ k }: { k: string }) => (\n <div className={styles[k]}>\n <b className={styles.row} />\n </div>\n);\n',
|
|
232
|
+
);
|
|
233
|
+
expect(refusal()).toContain("não estática");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("refuses a module whose styles object is passed around whole", () => {
|
|
237
|
+
twoCopies();
|
|
238
|
+
write(
|
|
239
|
+
"src/components/Card.tsx",
|
|
240
|
+
'import styles from "./Card.module.css";\n\nexport const Card = () => (\n <div className={styles.row} data-keys={Object.keys(styles).length} />\n);\n',
|
|
241
|
+
);
|
|
242
|
+
expect(refusal()).toContain("usado inteiro");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("refuses a class no source file reads", () => {
|
|
246
|
+
twoCopies();
|
|
247
|
+
write(
|
|
248
|
+
"src/components/Card.tsx",
|
|
249
|
+
'import styles from "./Card.module.css";\n\nexport const Card = () => <div className={styles.title} />;\n',
|
|
250
|
+
);
|
|
251
|
+
expect(refusal()).toContain("código morto");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("needs the block in two different files", () => {
|
|
255
|
+
write(
|
|
256
|
+
"src/components/Card.module.css",
|
|
257
|
+
`${ROW}.other {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n`,
|
|
258
|
+
);
|
|
259
|
+
write(
|
|
260
|
+
"src/components/Card.tsx",
|
|
261
|
+
'import styles from "./Card.module.css";\n\nexport const Card = () => (\n <div className={styles.row}>\n <b className={styles.other} />\n <i className={styles.title} />\n </div>\n);\n',
|
|
262
|
+
);
|
|
263
|
+
expect(plan().groups).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("refuses when the new name is already taken in the global sheet", () => {
|
|
267
|
+
twoCopies();
|
|
268
|
+
write("src/index.css", ".u-row {\n display: block;\n}\n");
|
|
269
|
+
const result = plan();
|
|
270
|
+
expect(result.groups).toEqual([]);
|
|
271
|
+
expect(result.refusals[0].reason).toContain("já existe na folha global");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("reports a missing global stylesheet instead of creating one", () => {
|
|
275
|
+
twoCopies();
|
|
276
|
+
rmSync(join(root, "src/index.css"));
|
|
277
|
+
const result = plan();
|
|
278
|
+
expect(result.status).toBe("no-target");
|
|
279
|
+
expect(result.message).toContain("nenhuma folha global");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("refuses a global stylesheet nobody imports", () => {
|
|
283
|
+
twoCopies();
|
|
284
|
+
write("src/main.tsx", "export const main = 1;\n");
|
|
285
|
+
const result = plan();
|
|
286
|
+
expect(result.status).toBe("no-target");
|
|
287
|
+
expect(result.message).toContain("não é importada");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("refuses a CSS Module as the target", () => {
|
|
291
|
+
twoCopies();
|
|
292
|
+
write("src/theme.module.css", ".x { color: red; }\n");
|
|
293
|
+
const result = plan({ target: "src/theme.module.css" });
|
|
294
|
+
expect(result.status).toBe("no-target");
|
|
295
|
+
expect(result.message).toContain("é um CSS Module");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("reports a missing TypeScript instead of guessing at the call sites", () => {
|
|
299
|
+
twoCopies();
|
|
300
|
+
rmSync(join(root, "node_modules", "typescript"), { recursive: true, force: true });
|
|
301
|
+
const result = plan();
|
|
302
|
+
expect(result.status).toBe("no-typescript");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -97,3 +98,93 @@ describe("tempest fix — the CSS pass", () => {
|
|
|
97
98
|
expect(code).toBe(1);
|
|
98
99
|
});
|
|
99
100
|
});
|
|
101
|
+
|
|
102
|
+
const TYPESCRIPT_DIR = dirname(createRequire(import.meta.url).resolve("typescript/package.json"));
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A fixture with two modules declaring the same block, each read from its own
|
|
106
|
+
* component, plus the global sheet the entry imports and the project's own
|
|
107
|
+
* TypeScript — everything `--extract-css` refuses to work without.
|
|
108
|
+
*/
|
|
109
|
+
function extractableProject() {
|
|
110
|
+
const rule =
|
|
111
|
+
".row {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.title {\n font-weight: 600;\n}\n";
|
|
112
|
+
writeFileSync(
|
|
113
|
+
join(root, "package.json"),
|
|
114
|
+
JSON.stringify({ name: "fixture", devDependencies: { typescript: "^5" } }),
|
|
115
|
+
);
|
|
116
|
+
write(
|
|
117
|
+
"tsconfig.json",
|
|
118
|
+
JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } } }),
|
|
119
|
+
);
|
|
120
|
+
write("src/index.css", ":root {\n --brand: #0d6efd;\n}\n");
|
|
121
|
+
write("src/main.tsx", 'import "./index.css";\n');
|
|
122
|
+
write("src/components/Card.module.css", rule);
|
|
123
|
+
write(
|
|
124
|
+
"src/components/Card.tsx",
|
|
125
|
+
'import styles from "./Card.module.css";\n\nexport const Card = () => (\n <div className={styles.row}>\n <b className={styles.title} />\n </div>\n);\n',
|
|
126
|
+
);
|
|
127
|
+
write("src/components/List.module.css", rule.replace(".row", ".line"));
|
|
128
|
+
write(
|
|
129
|
+
"src/components/List.tsx",
|
|
130
|
+
'import styles from "@/components/List.module.css";\n\nexport const List = () => (\n <li className={styles.line}>\n <b className={styles.title} />\n </li>\n);\n',
|
|
131
|
+
);
|
|
132
|
+
mkdirSync(join(root, "node_modules"), { recursive: true });
|
|
133
|
+
symlinkSync(TYPESCRIPT_DIR, join(root, "node_modules", "typescript"), "dir");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe("tempest fix --extract-css", () => {
|
|
137
|
+
it("moves the repeated block into the global sheet and rewrites the JSX", () => {
|
|
138
|
+
extractableProject();
|
|
139
|
+
const { out } = fix(["--extract-css"]);
|
|
140
|
+
expect(out).toContain("css extract");
|
|
141
|
+
expect(out).toContain("movidas 2 regra(s)");
|
|
142
|
+
expect(readFileSync(join(root, "src/index.css"), "utf8")).toContain(".u-row {");
|
|
143
|
+
expect(readFileSync(join(root, "src/components/Card.tsx"), "utf8")).toContain(
|
|
144
|
+
'className="u-row"',
|
|
145
|
+
);
|
|
146
|
+
expect(readFileSync(join(root, "src/components/Card.module.css"), "utf8")).not.toContain(
|
|
147
|
+
".row",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("writes nothing under --dry-run", () => {
|
|
152
|
+
extractableProject();
|
|
153
|
+
const before = readFileSync(join(root, "src/components/Card.tsx"), "utf8");
|
|
154
|
+
const { out } = fix(["--extract-css", "--dry-run"]);
|
|
155
|
+
expect(out).toContain("moveria 2 regra(s)");
|
|
156
|
+
expect(readFileSync(join(root, "src/components/Card.tsx"), "utf8")).toBe(before);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("honors --css-prefix without treating its value as a path", () => {
|
|
160
|
+
extractableProject();
|
|
161
|
+
fix(["--extract-css", "--css-prefix", "shared-"]);
|
|
162
|
+
expect(readFileSync(join(root, "src/index.css"), "utf8")).toContain(".shared-row {");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("does nothing extra when the flag is absent", () => {
|
|
166
|
+
extractableProject();
|
|
167
|
+
const { out } = fix([]);
|
|
168
|
+
expect(out).not.toContain("css extract");
|
|
169
|
+
expect(readFileSync(join(root, "src/index.css"), "utf8")).not.toContain(".u-row");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("rejects --css-target without --extract-css", () => {
|
|
173
|
+
extractableProject();
|
|
174
|
+
const { out, code } = fix(["--css-target", "src/index.css"]);
|
|
175
|
+
expect(out).toContain("só valem com --extract-css");
|
|
176
|
+
expect(code).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("reports the reason instead of extracting what it cannot prove safe", () => {
|
|
180
|
+
extractableProject();
|
|
181
|
+
write(
|
|
182
|
+
"src/components/Card.module.css",
|
|
183
|
+
".row {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.row:hover {\n opacity: 0.8;\n}\n.title {\n font-weight: 600;\n}\n",
|
|
184
|
+
);
|
|
185
|
+
const { out } = fix(["--extract-css"]);
|
|
186
|
+
expect(out).toContain("não extraído");
|
|
187
|
+
expect(out).toContain("nada a extrair");
|
|
188
|
+
expect(readFileSync(join(root, "src/index.css"), "utf8")).not.toContain(".u-row");
|
|
189
|
+
});
|
|
190
|
+
});
|
package/bin/lib/css/index.mjs
CHANGED
|
@@ -3,6 +3,16 @@ export { analyzeCss, applyCssFixes } from "./analyze.mjs";
|
|
|
3
3
|
export { collectStylesheets, customPropertiesInSources, looksMinified } from "./collect.mjs";
|
|
4
4
|
export { countBySeverity, finding, SEVERITY, sortFindings } from "./findings.mjs";
|
|
5
5
|
export { fixCss } from "./fix.mjs";
|
|
6
|
+
export {
|
|
7
|
+
applyExtraction,
|
|
8
|
+
classNamesIn,
|
|
9
|
+
findGlobalSheet,
|
|
10
|
+
kebab,
|
|
11
|
+
loneClass,
|
|
12
|
+
planExtraction,
|
|
13
|
+
renderRule,
|
|
14
|
+
} from "./extract.mjs";
|
|
15
|
+
export { collectSources, findClassUses, indexClassUses, resolveSpecifier } from "./references.mjs";
|
|
6
16
|
export { maskValue, normalizeSelectors, parseCss, stripComments } from "./parse.mjs";
|
|
7
17
|
export { distance, KNOWN_AT_RULES, KNOWN_PROPERTIES, nearest } from "./properties.mjs";
|
|
8
18
|
export { matchUtility, repetitionFindings } from "./repetition.mjs";
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Where a CSS Module class is used in TypeScript.
|
|
2
|
+
//
|
|
3
|
+
// Extraction cannot be a CSS-only edit: deleting `.row` from `Card.module.css`
|
|
4
|
+
// leaves `styles.row` in `Card.tsx` evaluating to `undefined`, which renders an
|
|
5
|
+
// element with `class="undefined"` and no styling — valid code, silent damage. So
|
|
6
|
+
// the codemod only touches a class it can see **every** use of.
|
|
7
|
+
//
|
|
8
|
+
// "Every use" is decided with the project's own TypeScript compiler rather than a
|
|
9
|
+
// regex: `styles.row` inside a comment, a template literal or a string is not a
|
|
10
|
+
// use, and a regex cannot tell. When anything about a module's usage is not
|
|
11
|
+
// statically decidable — `styles[key]`, `Object.keys(styles)`, the object handed
|
|
12
|
+
// to a child as a prop — the whole module is declared opaque and nothing in it is
|
|
13
|
+
// extracted.
|
|
14
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
const SOURCE_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
18
|
+
const SKIP_DIRS = new Set([
|
|
19
|
+
"node_modules",
|
|
20
|
+
"dist",
|
|
21
|
+
"build",
|
|
22
|
+
"out",
|
|
23
|
+
"coverage",
|
|
24
|
+
".git",
|
|
25
|
+
".vite",
|
|
26
|
+
".next",
|
|
27
|
+
".turbo",
|
|
28
|
+
".cache",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/** Every source file under `root` the codemod may read. */
|
|
32
|
+
export function collectSources({ root, maxFiles = 4000 }) {
|
|
33
|
+
const out = [];
|
|
34
|
+
const walk = (dir) => {
|
|
35
|
+
if (out.length >= maxFiles) return;
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
39
|
+
} catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
44
|
+
if (entry.name.startsWith(".") && entry.isDirectory()) continue;
|
|
45
|
+
const full = join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) walk(full);
|
|
47
|
+
else if (entry.isFile() && SOURCE_EXTS.has(extname(entry.name))) out.push(full);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(root);
|
|
51
|
+
return out.sort((a, b) => a.localeCompare(b));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve an import specifier to an absolute path, honoring the project alias.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} params
|
|
58
|
+
* @param {string} params.specifier - Raw module specifier.
|
|
59
|
+
* @param {string} params.fromFile - File the import is written in.
|
|
60
|
+
* @param {{ prefix: string, baseDir: string } | null} params.alias
|
|
61
|
+
* @returns {string | null} Absolute path, or `null` when it is a bare package.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveSpecifier({ specifier, fromFile, alias }) {
|
|
64
|
+
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
65
|
+
return resolve(dirname(fromFile), specifier);
|
|
66
|
+
}
|
|
67
|
+
if (alias && specifier.startsWith(`${alias.prefix}/`)) {
|
|
68
|
+
return resolve(alias.baseDir, specifier.slice(alias.prefix.length + 1));
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Find every static use of a CSS Module's classes in one source file.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} params
|
|
77
|
+
* @param {string} params.filePath
|
|
78
|
+
* @param {string} params.text - File contents.
|
|
79
|
+
* @param {object} params.ts - The project's `typescript` module.
|
|
80
|
+
* @param {Set<string>} params.moduleFiles - Absolute paths of the CSS Modules of interest.
|
|
81
|
+
* @param {{ prefix: string, baseDir: string } | null} params.alias
|
|
82
|
+
* @returns {{
|
|
83
|
+
* uses: Array<{ module: string, className: string, start: number, end: number, line: number }>,
|
|
84
|
+
* opaque: Array<{ module: string, reason: string, line: number }>,
|
|
85
|
+
* }}
|
|
86
|
+
*/
|
|
87
|
+
export function findClassUses({ filePath, text, ts, moduleFiles, alias }) {
|
|
88
|
+
const uses = [];
|
|
89
|
+
const opaque = [];
|
|
90
|
+
const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true);
|
|
91
|
+
|
|
92
|
+
/** Local identifier → absolute CSS Module path, for the modules we care about. */
|
|
93
|
+
const bindings = new Map();
|
|
94
|
+
for (const statement of source.statements) {
|
|
95
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
96
|
+
const specifier = statement.moduleSpecifier;
|
|
97
|
+
if (!ts.isStringLiteral(specifier)) continue;
|
|
98
|
+
const target = resolveSpecifier({ specifier: specifier.text, fromFile: filePath, alias });
|
|
99
|
+
if (!target || !moduleFiles.has(target)) continue;
|
|
100
|
+
const clause = statement.importClause;
|
|
101
|
+
if (clause?.name) bindings.set(clause.name.text, target);
|
|
102
|
+
// `import * as styles from "./x.module.css"` behaves the same for our purposes.
|
|
103
|
+
if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
|
|
104
|
+
bindings.set(clause.namedBindings.name.text, target);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (bindings.size === 0) return { uses, opaque };
|
|
108
|
+
|
|
109
|
+
const lineOf = (pos) => source.getLineAndCharacterOfPosition(pos).line + 1;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Range to replace with the new class string.
|
|
113
|
+
*
|
|
114
|
+
* `className={styles.row}` widens to the whole `{…}` container so the result
|
|
115
|
+
* is `className="u-row"` rather than `className={"u-row"}` — the braces are
|
|
116
|
+
* only there because the value was an expression, and leaving them behind
|
|
117
|
+
* would make every rewritten attribute read like generated code.
|
|
118
|
+
*/
|
|
119
|
+
const replacementRange = (access) => {
|
|
120
|
+
const parent = access.parent;
|
|
121
|
+
if (
|
|
122
|
+
parent &&
|
|
123
|
+
ts.isJsxExpression(parent) &&
|
|
124
|
+
parent.expression === access &&
|
|
125
|
+
parent.parent &&
|
|
126
|
+
ts.isJsxAttribute(parent.parent)
|
|
127
|
+
) {
|
|
128
|
+
return { start: parent.getStart(source), end: parent.getEnd() };
|
|
129
|
+
}
|
|
130
|
+
return { start: access.getStart(source), end: access.getEnd() };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Record a use, or mark the module opaque when the reference is not a plain
|
|
135
|
+
* `styles.name` / `styles["name"]` read.
|
|
136
|
+
*/
|
|
137
|
+
const visit = (node) => {
|
|
138
|
+
if (ts.isIdentifier(node) && bindings.has(node.text)) {
|
|
139
|
+
const module = bindings.get(node.text);
|
|
140
|
+
const parent = node.parent;
|
|
141
|
+
const isImportName =
|
|
142
|
+
parent &&
|
|
143
|
+
(ts.isImportClause(parent) || ts.isNamespaceImport(parent)) &&
|
|
144
|
+
parent.name === node;
|
|
145
|
+
if (isImportName) return;
|
|
146
|
+
|
|
147
|
+
if (parent && ts.isPropertyAccessExpression(parent) && parent.expression === node) {
|
|
148
|
+
uses.push({
|
|
149
|
+
module,
|
|
150
|
+
className: parent.name.text,
|
|
151
|
+
...replacementRange(parent),
|
|
152
|
+
line: lineOf(parent.getStart(source)),
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
parent &&
|
|
158
|
+
ts.isElementAccessExpression(parent) &&
|
|
159
|
+
parent.expression === node &&
|
|
160
|
+
parent.argumentExpression &&
|
|
161
|
+
ts.isStringLiteralLike(parent.argumentExpression)
|
|
162
|
+
) {
|
|
163
|
+
uses.push({
|
|
164
|
+
module,
|
|
165
|
+
className: parent.argumentExpression.text,
|
|
166
|
+
...replacementRange(parent),
|
|
167
|
+
line: lineOf(parent.getStart(source)),
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
opaque.push({
|
|
172
|
+
module,
|
|
173
|
+
reason:
|
|
174
|
+
parent && ts.isElementAccessExpression(parent)
|
|
175
|
+
? "acesso dinâmico (`styles[expr]`)"
|
|
176
|
+
: "o objeto de estilos é usado inteiro, não por classe",
|
|
177
|
+
line: lineOf(node.getStart(source)),
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
ts.forEachChild(node, visit);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
ts.forEachChild(source, visit);
|
|
185
|
+
return { uses, opaque };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Index every static class use across the project.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} params
|
|
192
|
+
* @param {string} params.root
|
|
193
|
+
* @param {object} params.ts
|
|
194
|
+
* @param {Iterable<string>} params.modulePaths - Absolute paths of CSS Modules.
|
|
195
|
+
* @param {{ prefix: string, baseDir: string } | null} params.alias
|
|
196
|
+
* @returns {{
|
|
197
|
+
* byModule: Map<string, Map<string, Array<{ file: string, start: number, end: number, line: number }>>>,
|
|
198
|
+
* opaqueModules: Map<string, { file: string, reason: string, line: number }>,
|
|
199
|
+
* texts: Map<string, string>,
|
|
200
|
+
* }}
|
|
201
|
+
*/
|
|
202
|
+
export function indexClassUses({ root, ts, modulePaths, alias }) {
|
|
203
|
+
const moduleFiles = new Set(modulePaths);
|
|
204
|
+
const byModule = new Map();
|
|
205
|
+
const opaqueModules = new Map();
|
|
206
|
+
const texts = new Map();
|
|
207
|
+
|
|
208
|
+
for (const file of collectSources({ root })) {
|
|
209
|
+
let text;
|
|
210
|
+
try {
|
|
211
|
+
text = readFileSync(file, "utf8");
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!text.includes(".module.css")) continue;
|
|
216
|
+
const { uses, opaque } = findClassUses({ filePath: file, text, ts, moduleFiles, alias });
|
|
217
|
+
if (uses.length === 0 && opaque.length === 0) continue;
|
|
218
|
+
texts.set(file, text);
|
|
219
|
+
for (const entry of opaque) {
|
|
220
|
+
if (!opaqueModules.has(entry.module)) {
|
|
221
|
+
opaqueModules.set(entry.module, {
|
|
222
|
+
file: relative(root, file),
|
|
223
|
+
reason: entry.reason,
|
|
224
|
+
line: entry.line,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const use of uses) {
|
|
229
|
+
const classes = byModule.get(use.module) ?? new Map();
|
|
230
|
+
const sites = classes.get(use.className) ?? [];
|
|
231
|
+
sites.push({ file, start: use.start, end: use.end, line: use.line });
|
|
232
|
+
classes.set(use.className, sites);
|
|
233
|
+
byModule.set(use.module, classes);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { byModule, opaqueModules, texts };
|
|
238
|
+
}
|