tempest-react-sdk 0.28.1 → 0.29.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/bin/lib/css/extract.mjs +406 -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/tempest.mjs +129 -7
- package/package.json +1 -1
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// `tempest fix --extract-css` — move a declaration block that repeats across CSS
|
|
2
|
+
// Modules into the project's global stylesheet, and rewrite the JSX that pointed
|
|
3
|
+
// at the local copies.
|
|
4
|
+
//
|
|
5
|
+
// Opt-in, and it stays opt-in. The other passes of `fix` remove things that are
|
|
6
|
+
// provably dead; this one makes a **design** decision — that N screens should now
|
|
7
|
+
// share one class, and therefore change together. That is a call about coupling,
|
|
8
|
+
// so it happens only when somebody asks for it by name.
|
|
9
|
+
//
|
|
10
|
+
// Everything here is refusal-first. A group is extracted only when every one of
|
|
11
|
+
// these holds, and each refusal is reported with its reason rather than skipped
|
|
12
|
+
// in silence:
|
|
13
|
+
//
|
|
14
|
+
// - every occurrence is a lone class selector (`.row`), outside any at-rule;
|
|
15
|
+
// - no other rule in that module mentions the class (a `:hover`, a descendant
|
|
16
|
+
// selector or a `@media` override would stay behind and lose its subject);
|
|
17
|
+
// - the module keeps at least one other rule, so its import does not become
|
|
18
|
+
// dead and take the remaining styles down with it;
|
|
19
|
+
// - the class is only ever read as `styles.row` / `styles["row"]`, never
|
|
20
|
+
// through a computed key or by handing the whole object around;
|
|
21
|
+
// - the target global sheet exists AND some source file imports it, because
|
|
22
|
+
// appending to a stylesheet nobody loads is a silent no-op;
|
|
23
|
+
// - the new global name collides with nothing already in that sheet.
|
|
24
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { join, relative } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { discoverAlias } from "../alias/discover.mjs";
|
|
28
|
+
import { loadTypeScript } from "../alias/typescript.mjs";
|
|
29
|
+
import { collectSources, indexClassUses, resolveSpecifier } from "./references.mjs";
|
|
30
|
+
import { parseCss } from "./parse.mjs";
|
|
31
|
+
import { declSignature } from "./semantic.mjs";
|
|
32
|
+
|
|
33
|
+
/** A repeated block is only worth extracting from this many declarations up. */
|
|
34
|
+
const MIN_DECLS = 3;
|
|
35
|
+
|
|
36
|
+
/** Stylesheets treated as "the project's global sheet", in order of preference. */
|
|
37
|
+
const TARGET_CANDIDATES = [
|
|
38
|
+
"src/styles/globals.css",
|
|
39
|
+
"src/styles/global.css",
|
|
40
|
+
"src/globals.css",
|
|
41
|
+
"src/global.css",
|
|
42
|
+
"src/index.css",
|
|
43
|
+
"src/app.css",
|
|
44
|
+
"app/globals.css",
|
|
45
|
+
"styles/globals.css",
|
|
46
|
+
"index.css",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** `.row` → `row`, or `null` when the selector is anything more than one class. */
|
|
50
|
+
export function loneClass(prelude) {
|
|
51
|
+
const match = /^\.([A-Za-z_][A-Za-z0-9_-]*)$/.exec(prelude.trim());
|
|
52
|
+
return match ? match[1] : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `cardHeader` → `card-header`. */
|
|
56
|
+
export function kebab(name) {
|
|
57
|
+
return name
|
|
58
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
59
|
+
.replace(/[_\s]+/g, "-")
|
|
60
|
+
.toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Class names a stylesheet already defines, at any depth. */
|
|
64
|
+
export function classNamesIn(parsed) {
|
|
65
|
+
const names = new Set();
|
|
66
|
+
for (const block of parsed.blocks) {
|
|
67
|
+
for (const match of block.prelude.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) {
|
|
68
|
+
names.add(match[1]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return names;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pick the global stylesheet to append to, and prove somebody imports it.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} params
|
|
78
|
+
* @param {string} params.root
|
|
79
|
+
* @param {string} [params.explicit] - Path from `--css-target`.
|
|
80
|
+
* @param {{ prefix: string, baseDir: string } | null} params.alias
|
|
81
|
+
* @returns {{ path: string, file: string } | { error: string }}
|
|
82
|
+
*/
|
|
83
|
+
export function findGlobalSheet({ root, explicit, alias }) {
|
|
84
|
+
const candidates = explicit ? [explicit] : TARGET_CANDIDATES;
|
|
85
|
+
const found = candidates.map((rel) => join(root, rel)).find(existsSync);
|
|
86
|
+
if (!found) {
|
|
87
|
+
return {
|
|
88
|
+
error: explicit
|
|
89
|
+
? `--css-target "${explicit}" não existe`
|
|
90
|
+
: `nenhuma folha global encontrada (procurei ${TARGET_CANDIDATES.join(", ")}) — passe --css-target <arquivo>`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (found.endsWith(".module.css")) {
|
|
94
|
+
return {
|
|
95
|
+
error: `${relative(root, found)} é um CSS Module — a folha alvo tem que ser global`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const imported = collectSources({ root }).some((file) => {
|
|
100
|
+
let text;
|
|
101
|
+
try {
|
|
102
|
+
text = readFileSync(file, "utf8");
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
if (!text.includes(".css")) return false;
|
|
107
|
+
for (const match of text.matchAll(/["']([^"']+\.css)["']/g)) {
|
|
108
|
+
const target = resolveSpecifier({ specifier: match[1], fromFile: file, alias });
|
|
109
|
+
if (target === found) return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
});
|
|
113
|
+
if (!imported) {
|
|
114
|
+
return {
|
|
115
|
+
error: `${relative(root, found)} não é importada por nenhum arquivo do projeto — o que fosse movido pra lá não carregaria`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return { path: found, file: relative(root, found) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Group the analysed sheets by identical declaration block. */
|
|
122
|
+
function groupBySignature(sheets) {
|
|
123
|
+
const groups = new Map();
|
|
124
|
+
for (const sheet of sheets) {
|
|
125
|
+
if (!sheet.isModule) continue;
|
|
126
|
+
for (const block of sheet.parsed.blocks) {
|
|
127
|
+
if (block.kind !== "rule" || block.decls.length < MIN_DECLS) continue;
|
|
128
|
+
const signature = declSignature(block);
|
|
129
|
+
const group = groups.get(signature) ?? { signature, entries: [] };
|
|
130
|
+
group.entries.push({ sheet, block });
|
|
131
|
+
groups.set(signature, group);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...groups.values()];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Why this occurrence cannot be extracted, or `null` when it can.
|
|
139
|
+
*
|
|
140
|
+
* @returns {string | null}
|
|
141
|
+
*/
|
|
142
|
+
function occurrenceRefusal({ sheet, block, className, uses, opaqueModules }) {
|
|
143
|
+
if (block.context.length > 0) {
|
|
144
|
+
return `está dentro de \`${block.context.join(" › ")}\` — mover pra fora mudaria quando a regra vale`;
|
|
145
|
+
}
|
|
146
|
+
if (!className) return `\`${block.prelude}\` não é uma classe simples`;
|
|
147
|
+
|
|
148
|
+
const mentions = sheet.parsed.blocks.filter(
|
|
149
|
+
(other) =>
|
|
150
|
+
other !== block && new RegExp(`\\.${className}(?![A-Za-z0-9_-])`).test(other.prelude),
|
|
151
|
+
);
|
|
152
|
+
if (mentions.length > 0) {
|
|
153
|
+
return `outra regra na mesma folha usa \`.${className}\` (linha ${mentions[0].line}) e ficaria sem sujeito`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const remaining = sheet.parsed.blocks.filter(
|
|
157
|
+
(other) => other !== block && other.kind === "rule",
|
|
158
|
+
);
|
|
159
|
+
if (remaining.length === 0) {
|
|
160
|
+
return "é a única regra do módulo — o import viraria código morto e levaria a folha inteira";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const opaque = opaqueModules.get(sheet.path);
|
|
164
|
+
if (opaque) {
|
|
165
|
+
return `o módulo é usado de forma não estática em ${opaque.file}:${opaque.line} — ${opaque.reason}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!uses || uses.length === 0) {
|
|
169
|
+
return `nenhum \`styles.${className}\` no código — a classe já é código morto, e mover não conserta isso`;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build the extraction plan.
|
|
176
|
+
*
|
|
177
|
+
* @param {object} params
|
|
178
|
+
* @param {ReturnType<import("./analyze.mjs").analyzeCss>} params.analysis
|
|
179
|
+
* @param {string} params.root
|
|
180
|
+
* @param {string} [params.target] - Path from `--css-target`.
|
|
181
|
+
* @param {string} [params.prefix] - Prefix for the new global class names.
|
|
182
|
+
* @returns {{
|
|
183
|
+
* status: "ok" | "no-typescript" | "no-target",
|
|
184
|
+
* message?: string,
|
|
185
|
+
* target?: { path: string, file: string },
|
|
186
|
+
* groups: Array<object>,
|
|
187
|
+
* refusals: Array<{ file: string, line: number, reason: string }>,
|
|
188
|
+
* }}
|
|
189
|
+
*/
|
|
190
|
+
export function planExtraction({ analysis, root, target, prefix = "u-" }) {
|
|
191
|
+
const ts = loadTypeScript(root);
|
|
192
|
+
if (!ts) {
|
|
193
|
+
return {
|
|
194
|
+
status: "no-typescript",
|
|
195
|
+
message:
|
|
196
|
+
"typescript não instalado — a reescrita do TSX precisa do compilador do projeto",
|
|
197
|
+
groups: [],
|
|
198
|
+
refusals: [],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const alias = discoverAlias({ root, ts });
|
|
202
|
+
const sheet = findGlobalSheet({ root, explicit: target, alias });
|
|
203
|
+
if (sheet.error) {
|
|
204
|
+
return { status: "no-target", message: sheet.error, groups: [], refusals: [] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const modulePaths = analysis.sheets.filter((s) => s.isModule).map((s) => s.path);
|
|
208
|
+
const { byModule, opaqueModules } = indexClassUses({ root, ts, modulePaths, alias });
|
|
209
|
+
|
|
210
|
+
const targetText = readFileSync(sheet.path, "utf8");
|
|
211
|
+
const taken = classNamesIn(parseCss(targetText));
|
|
212
|
+
const groups = [];
|
|
213
|
+
const refusals = [];
|
|
214
|
+
|
|
215
|
+
for (const group of groupBySignature(analysis.sheets)) {
|
|
216
|
+
if (group.entries.length < 2) continue;
|
|
217
|
+
|
|
218
|
+
const eligible = [];
|
|
219
|
+
for (const entry of group.entries) {
|
|
220
|
+
const className = loneClass(entry.block.prelude);
|
|
221
|
+
const uses = className ? byModule.get(entry.sheet.path)?.get(className) : null;
|
|
222
|
+
const refusal = occurrenceRefusal({ ...entry, className, uses, opaqueModules });
|
|
223
|
+
if (refusal) {
|
|
224
|
+
refusals.push({ file: entry.sheet.file, line: entry.block.line, reason: refusal });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
eligible.push({ ...entry, className, uses });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const files = new Set(eligible.map((e) => e.sheet.file));
|
|
231
|
+
if (eligible.length < 2 || files.size < 2) continue;
|
|
232
|
+
|
|
233
|
+
const name = `${prefix}${kebab(commonName(eligible))}`;
|
|
234
|
+
if (taken.has(name)) {
|
|
235
|
+
refusals.push({
|
|
236
|
+
file: sheet.file,
|
|
237
|
+
line: 1,
|
|
238
|
+
reason: `\`.${name}\` já existe na folha global — renomeie a classe local ou use --css-prefix`,
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
taken.add(name);
|
|
243
|
+
|
|
244
|
+
groups.push({
|
|
245
|
+
name,
|
|
246
|
+
decls: eligible[0].block.decls.map((d) => ({ prop: d.prop, value: d.value })),
|
|
247
|
+
occurrences: eligible.map((e) => ({
|
|
248
|
+
path: e.sheet.path,
|
|
249
|
+
file: e.sheet.file,
|
|
250
|
+
line: e.block.line,
|
|
251
|
+
className: e.className,
|
|
252
|
+
start: e.block.start,
|
|
253
|
+
end: e.block.end,
|
|
254
|
+
sites: e.uses.map((u) => ({
|
|
255
|
+
path: u.file,
|
|
256
|
+
file: relative(root, u.file),
|
|
257
|
+
line: u.line,
|
|
258
|
+
start: u.start,
|
|
259
|
+
end: u.end,
|
|
260
|
+
})),
|
|
261
|
+
})),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { status: "ok", target: sheet, groups, refusals };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The name for the extracted class: the local name the codebase already uses
|
|
270
|
+
* most, by modules first and call sites second.
|
|
271
|
+
*
|
|
272
|
+
* A tie is the normal case — the copies live in different modules precisely
|
|
273
|
+
* because nobody agreed on a name — so the order of the occurrences breaks it,
|
|
274
|
+
* which keeps the result deterministic across runs. The chosen name is printed
|
|
275
|
+
* before anything is written; `--css-prefix` is there for when it needs a
|
|
276
|
+
* namespace rather than a different word.
|
|
277
|
+
*/
|
|
278
|
+
function commonName(eligible) {
|
|
279
|
+
const counts = new Map();
|
|
280
|
+
for (const entry of eligible) {
|
|
281
|
+
const current = counts.get(entry.className) ?? { modules: 0, sites: 0 };
|
|
282
|
+
counts.set(entry.className, {
|
|
283
|
+
modules: current.modules + 1,
|
|
284
|
+
sites: current.sites + entry.uses.length,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return [...counts.entries()].sort(
|
|
288
|
+
(a, b) => b[1].modules - a[1].modules || b[1].sites - a[1].sites,
|
|
289
|
+
)[0][0];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Render the extracted rule for the global sheet. */
|
|
293
|
+
export function renderRule({ name, decls, occurrences }) {
|
|
294
|
+
const body = decls.map((d) => ` ${d.prop}: ${d.value};`).join("\n");
|
|
295
|
+
return [
|
|
296
|
+
`/* extraído por \`tempest fix --extract-css\` de ${occurrences.length} CSS Modules */`,
|
|
297
|
+
`.${name} {`,
|
|
298
|
+
body,
|
|
299
|
+
"}",
|
|
300
|
+
"",
|
|
301
|
+
].join("\n");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Grow a removal range over the leading indentation and the trailing newline.
|
|
306
|
+
* Same rule as the dedupe pass, so a removed rule leaves no blank indented line.
|
|
307
|
+
*/
|
|
308
|
+
function expandRange(text, start, end) {
|
|
309
|
+
let from = start;
|
|
310
|
+
while (from > 0 && (text[from - 1] === " " || text[from - 1] === "\t")) from -= 1;
|
|
311
|
+
if (from > 0 && text[from - 1] !== "\n") from = start;
|
|
312
|
+
let to = end;
|
|
313
|
+
while (to < text.length && (text[to] === " " || text[to] === "\t")) to += 1;
|
|
314
|
+
if (text[to] === "\r") to += 1;
|
|
315
|
+
if (text[to] === "\n") to += 1;
|
|
316
|
+
return { start: from, end: to };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Apply text edits back-to-front. */
|
|
320
|
+
function splice(text, edits) {
|
|
321
|
+
let out = text;
|
|
322
|
+
for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
|
|
323
|
+
out = out.slice(0, edit.start) + (edit.text ?? "") + out.slice(edit.end);
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Apply an extraction plan to disk.
|
|
330
|
+
*
|
|
331
|
+
* @param {object} params
|
|
332
|
+
* @param {ReturnType<typeof planExtraction>} params.plan
|
|
333
|
+
* @param {boolean} [params.dryRun]
|
|
334
|
+
* @returns {{
|
|
335
|
+
* moved: number,
|
|
336
|
+
* rules: number,
|
|
337
|
+
* files: Array<{ file: string, changes: string[] }>,
|
|
338
|
+
* errors: Array<{ file: string, message: string }>,
|
|
339
|
+
* }}
|
|
340
|
+
*/
|
|
341
|
+
export function applyExtraction({ plan, dryRun = false }) {
|
|
342
|
+
const result = { moved: 0, rules: 0, files: [], errors: [] };
|
|
343
|
+
if (plan.status !== "ok" || plan.groups.length === 0) return result;
|
|
344
|
+
|
|
345
|
+
const edits = new Map();
|
|
346
|
+
const changes = new Map();
|
|
347
|
+
const record = (file, message) => {
|
|
348
|
+
changes.set(file, [...(changes.get(file) ?? []), message]);
|
|
349
|
+
};
|
|
350
|
+
const push = (path, edit) => {
|
|
351
|
+
edits.set(path, [...(edits.get(path) ?? []), edit]);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
let appended = "";
|
|
355
|
+
for (const group of plan.groups) {
|
|
356
|
+
appended += `\n${renderRule(group)}`;
|
|
357
|
+
result.rules += 1;
|
|
358
|
+
for (const occurrence of group.occurrences) {
|
|
359
|
+
let text;
|
|
360
|
+
try {
|
|
361
|
+
text = readFileSync(occurrence.path, "utf8");
|
|
362
|
+
} catch (err) {
|
|
363
|
+
result.errors.push({
|
|
364
|
+
file: occurrence.file,
|
|
365
|
+
message: String(err?.message ?? err),
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
push(occurrence.path, expandRange(text, occurrence.start, occurrence.end));
|
|
370
|
+
record(
|
|
371
|
+
occurrence.file,
|
|
372
|
+
`removida \`.${occurrence.className}\` (linha ${occurrence.line}) → \`.${group.name}\` em ${plan.target.file}`,
|
|
373
|
+
);
|
|
374
|
+
result.moved += 1;
|
|
375
|
+
for (const site of occurrence.sites) {
|
|
376
|
+
push(site.path, { start: site.start, end: site.end, text: `"${group.name}"` });
|
|
377
|
+
record(
|
|
378
|
+
site.file,
|
|
379
|
+
`\`styles.${occurrence.className}\` → \`"${group.name}"\` (linha ${site.line})`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!dryRun) {
|
|
386
|
+
for (const [path, fileEdits] of edits) {
|
|
387
|
+
let text;
|
|
388
|
+
try {
|
|
389
|
+
text = readFileSync(path, "utf8");
|
|
390
|
+
writeFileSync(path, splice(text, fileEdits));
|
|
391
|
+
} catch (err) {
|
|
392
|
+
result.errors.push({ file: path, message: String(err?.message ?? err) });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const current = readFileSync(plan.target.path, "utf8");
|
|
397
|
+
writeFileSync(plan.target.path, `${current.replace(/\s*$/, "\n")}${appended}`);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
result.errors.push({ file: plan.target.file, message: String(err?.message ?? err) });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
record(plan.target.file, `+${result.rules} classe(s) global(is)`);
|
|
403
|
+
|
|
404
|
+
result.files = [...changes.entries()].map(([file, list]) => ({ file, changes: list }));
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
@@ -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
|
+
}
|
package/bin/tempest.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
import { aliasImports } from "./lib/alias/index.mjs";
|
|
15
15
|
import { loadTypeScript } from "./lib/alias/typescript.mjs";
|
|
16
16
|
import { readTsconfig } from "./lib/alias/tsconfig.mjs";
|
|
17
|
-
import { analyzeCss, applyCssFixes } from "./lib/css/index.mjs";
|
|
17
|
+
import { analyzeCss, applyCssFixes, applyExtraction, planExtraction } from "./lib/css/index.mjs";
|
|
18
18
|
import { checkLucide } from "./lib/doctor/lucide.mjs";
|
|
19
19
|
import { generateRegistry, loadIconTables } from "./lib/icons/generate.mjs";
|
|
20
20
|
import { generate } from "./lib/openapi/generate.mjs";
|
|
@@ -814,7 +814,36 @@ function lint(args) {
|
|
|
814
814
|
}
|
|
815
815
|
|
|
816
816
|
/** Flags `fix` owns itself; anything else is rejected rather than forwarded. */
|
|
817
|
-
const FIX_FLAGS = new Set(["--no-alias", "--no-css", "--dry-run"]);
|
|
817
|
+
const FIX_FLAGS = new Set(["--no-alias", "--no-css", "--dry-run", "--extract-css"]);
|
|
818
|
+
|
|
819
|
+
/** Flags of `fix` that consume the next argument as their value. */
|
|
820
|
+
const FIX_VALUE_FLAGS = new Set(["--css-target", "--css-prefix"]);
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Split the `fix` argv into flags, flag values and positional paths.
|
|
824
|
+
*
|
|
825
|
+
* `splitArgs` cannot do this: it classifies by leading `-`, so the value of
|
|
826
|
+
* `--css-target src/index.css` would land in the path list and `fix` would run
|
|
827
|
+
* against a single stylesheet instead of the project.
|
|
828
|
+
*
|
|
829
|
+
* @param {string[]} args
|
|
830
|
+
* @returns {{ flags: string[], values: Record<string, string>, paths: string[] }}
|
|
831
|
+
*/
|
|
832
|
+
function parseFixArgs(args) {
|
|
833
|
+
const flags = [];
|
|
834
|
+
const values = {};
|
|
835
|
+
const paths = [];
|
|
836
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
837
|
+
const arg = args[i];
|
|
838
|
+
if (FIX_VALUE_FLAGS.has(arg)) {
|
|
839
|
+
values[arg] = args[i + 1] ?? "";
|
|
840
|
+
i += 1;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
(arg.startsWith("-") ? flags : paths).push(arg);
|
|
844
|
+
}
|
|
845
|
+
return { flags, values, paths };
|
|
846
|
+
}
|
|
818
847
|
|
|
819
848
|
/**
|
|
820
849
|
* Print the alias pass result.
|
|
@@ -875,9 +904,12 @@ function severityMark(severity) {
|
|
|
875
904
|
* @param {object} params
|
|
876
905
|
* @param {string[]} params.targets - Positional paths from the command line.
|
|
877
906
|
* @param {boolean} params.dryRun
|
|
907
|
+
* @param {boolean} [params.extract] - Run the opt-in cross-file extraction codemod.
|
|
908
|
+
* @param {string} [params.target] - Global stylesheet to extract into.
|
|
909
|
+
* @param {string} [params.prefix] - Prefix for the extracted class names.
|
|
878
910
|
* @returns {number} Exit status contribution.
|
|
879
911
|
*/
|
|
880
|
-
function cssPass({ targets, dryRun }) {
|
|
912
|
+
function cssPass({ targets, dryRun, extract = false, target, prefix }) {
|
|
881
913
|
console.log(
|
|
882
914
|
`${c.dim}→ css (dedupe declarations · drop dead rules)${dryRun ? " [dry-run]" : ""}${c.reset}`,
|
|
883
915
|
);
|
|
@@ -939,20 +971,95 @@ function cssPass({ targets, dryRun }) {
|
|
|
939
971
|
` ${c.red}✗ ${analysis.counts.error} CSS syntax error(s)${c.reset} ${c.dim}— those files were not touched; fix them and run again${c.reset}`,
|
|
940
972
|
);
|
|
941
973
|
}
|
|
942
|
-
|
|
974
|
+
|
|
975
|
+
const extractStatus = extract
|
|
976
|
+
? extractPass({ targets, dryRun, target, prefix, dedupedFiles: result.files.length })
|
|
977
|
+
: 0;
|
|
978
|
+
|
|
979
|
+
return result.errors.length || analysis.counts.error || extractStatus ? 1 : 0;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* The opt-in extraction codemod: a block repeated across CSS Modules becomes one
|
|
984
|
+
* class in the project's global stylesheet, and the `styles.x` that pointed at
|
|
985
|
+
* the local copies become the new class name.
|
|
986
|
+
*
|
|
987
|
+
* The analysis is re-run from disk rather than reused: the dedupe pass that just
|
|
988
|
+
* finished may have rewritten these same files, and every edit here is a splice at
|
|
989
|
+
* a recorded offset. Reusing the stale parse would splice at positions that no
|
|
990
|
+
* longer exist.
|
|
991
|
+
*
|
|
992
|
+
* @param {object} params
|
|
993
|
+
* @param {string[]} params.targets
|
|
994
|
+
* @param {boolean} params.dryRun
|
|
995
|
+
* @param {string} [params.target]
|
|
996
|
+
* @param {string} [params.prefix]
|
|
997
|
+
* @param {number} params.dedupedFiles - Files the dedupe pass touched, for the re-read note.
|
|
998
|
+
* @returns {number} Exit status contribution.
|
|
999
|
+
*/
|
|
1000
|
+
function extractPass({ targets, dryRun, target, prefix, dedupedFiles }) {
|
|
1001
|
+
console.log(
|
|
1002
|
+
`${c.dim}→ css extract (bloco repetido → classe global)${dryRun ? " [dry-run]" : ""}${c.reset}`,
|
|
1003
|
+
);
|
|
1004
|
+
let plan;
|
|
1005
|
+
try {
|
|
1006
|
+
const fresh = analyzeCss({ root: ROOT, targets, selfDir: SELF_DIR });
|
|
1007
|
+
if (dedupedFiles > 0 && !dryRun) {
|
|
1008
|
+
console.log(` ${c.dim}re-analisado depois da passada de dedupe${c.reset}`);
|
|
1009
|
+
}
|
|
1010
|
+
plan = planExtraction({ analysis: fresh, root: ROOT, target, prefix });
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
console.log(
|
|
1013
|
+
` ${c.red}✗ extraction failed${c.reset} ${c.dim}${err?.message ?? err}${c.reset}`,
|
|
1014
|
+
);
|
|
1015
|
+
return 1;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (plan.status !== "ok") {
|
|
1019
|
+
console.log(` ${c.yellow}! ${plan.message}${c.reset}`);
|
|
1020
|
+
return 0;
|
|
1021
|
+
}
|
|
1022
|
+
for (const refusal of plan.refusals) {
|
|
1023
|
+
console.log(
|
|
1024
|
+
` [${c.yellow}!${c.reset}] ${refusal.file}:${refusal.line} ${c.dim}não extraído — ${refusal.reason}${c.reset}`,
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
if (plan.groups.length === 0) {
|
|
1028
|
+
console.log(` ${c.dim}nada a extrair${c.reset}`);
|
|
1029
|
+
return 0;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const result = applyExtraction({ plan, dryRun });
|
|
1033
|
+
for (const file of result.files) {
|
|
1034
|
+
console.log(` ${file.file} ${c.dim}${file.changes.length}${c.reset}`);
|
|
1035
|
+
for (const change of file.changes) console.log(` ${c.dim}${change}${c.reset}`);
|
|
1036
|
+
}
|
|
1037
|
+
for (const err of result.errors) {
|
|
1038
|
+
console.log(` ${c.red}✗ ${err.file}${c.reset} ${c.dim}${err.message}${c.reset}`);
|
|
1039
|
+
}
|
|
1040
|
+
const verb = dryRun ? "moveria" : "movidas";
|
|
1041
|
+
console.log(
|
|
1042
|
+
` ${c.green}✓${c.reset} ${verb} ${result.moved} regra(s) local(is) para ${result.rules} classe(s) em ${plan.target.file}`,
|
|
1043
|
+
);
|
|
1044
|
+
return result.errors.length ? 1 : 0;
|
|
943
1045
|
}
|
|
944
1046
|
|
|
945
1047
|
function fix(args) {
|
|
946
|
-
const { flags, paths } =
|
|
1048
|
+
const { flags, values, paths } = parseFixArgs(args);
|
|
947
1049
|
const unknown = flags.filter((f) => !FIX_FLAGS.has(f));
|
|
948
1050
|
if (unknown.length) {
|
|
949
1051
|
console.error(
|
|
950
|
-
`${c.red}✗ Unknown flag for fix: ${unknown.join(", ")}${c.reset} — supported: ${[...FIX_FLAGS].join(", ")}`,
|
|
1052
|
+
`${c.red}✗ Unknown flag for fix: ${unknown.join(", ")}${c.reset} — supported: ${[...FIX_FLAGS, ...FIX_VALUE_FLAGS].join(", ")}`,
|
|
951
1053
|
);
|
|
952
1054
|
return 1;
|
|
953
1055
|
}
|
|
954
1056
|
const targets = paths.length ? paths : ["."];
|
|
955
1057
|
const dryRun = flags.includes("--dry-run");
|
|
1058
|
+
const extract = flags.includes("--extract-css");
|
|
1059
|
+
if (!extract && (values["--css-target"] || values["--css-prefix"])) {
|
|
1060
|
+
console.error(`${c.red}✗ --css-target/--css-prefix só valem com --extract-css.${c.reset}`);
|
|
1061
|
+
return 1;
|
|
1062
|
+
}
|
|
956
1063
|
|
|
957
1064
|
let aliasStatus = 0;
|
|
958
1065
|
if (!flags.includes("--no-alias")) {
|
|
@@ -962,7 +1069,15 @@ function fix(args) {
|
|
|
962
1069
|
aliasStatus = result.status === "error" ? 1 : 0;
|
|
963
1070
|
}
|
|
964
1071
|
|
|
965
|
-
const cssStatus = flags.includes("--no-css")
|
|
1072
|
+
const cssStatus = flags.includes("--no-css")
|
|
1073
|
+
? 0
|
|
1074
|
+
: cssPass({
|
|
1075
|
+
targets,
|
|
1076
|
+
dryRun,
|
|
1077
|
+
extract,
|
|
1078
|
+
target: values["--css-target"],
|
|
1079
|
+
prefix: values["--css-prefix"],
|
|
1080
|
+
});
|
|
966
1081
|
|
|
967
1082
|
if (dryRun) return aliasStatus || cssStatus;
|
|
968
1083
|
|
|
@@ -1125,6 +1240,13 @@ ${c.bold}fix options${c.reset}
|
|
|
1125
1240
|
write nothing
|
|
1126
1241
|
--no-alias Skip the alias pass
|
|
1127
1242
|
--no-css Skip the CSS pass (analysis and dead-code removal)
|
|
1243
|
+
--extract-css Move a declaration block repeated across CSS Modules into the
|
|
1244
|
+
project's global stylesheet and rewrite the styles.x reads that
|
|
1245
|
+
pointed at the local copies (opt-in — it changes what couples
|
|
1246
|
+
to what). Refuses anything it cannot prove safe, with a reason
|
|
1247
|
+
--css-target <f> Global stylesheet to extract into (default: the first of
|
|
1248
|
+
src/styles/globals.css, src/globals.css, src/index.css, …)
|
|
1249
|
+
--css-prefix <p> Prefix for extracted class names (default: u-)
|
|
1128
1250
|
|
|
1129
1251
|
${c.bold}Options${c.reset}
|
|
1130
1252
|
-h, --help Show this help
|