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.
@@ -0,0 +1,405 @@
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, typeScriptUnavailableReason } 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: `a reescrita do TSX precisa do compilador do projeto: ${typeScriptUnavailableReason(root)}`,
196
+ groups: [],
197
+ refusals: [],
198
+ };
199
+ }
200
+ const alias = discoverAlias({ root, ts });
201
+ const sheet = findGlobalSheet({ root, explicit: target, alias });
202
+ if (sheet.error) {
203
+ return { status: "no-target", message: sheet.error, groups: [], refusals: [] };
204
+ }
205
+
206
+ const modulePaths = analysis.sheets.filter((s) => s.isModule).map((s) => s.path);
207
+ const { byModule, opaqueModules } = indexClassUses({ root, ts, modulePaths, alias });
208
+
209
+ const targetText = readFileSync(sheet.path, "utf8");
210
+ const taken = classNamesIn(parseCss(targetText));
211
+ const groups = [];
212
+ const refusals = [];
213
+
214
+ for (const group of groupBySignature(analysis.sheets)) {
215
+ if (group.entries.length < 2) continue;
216
+
217
+ const eligible = [];
218
+ for (const entry of group.entries) {
219
+ const className = loneClass(entry.block.prelude);
220
+ const uses = className ? byModule.get(entry.sheet.path)?.get(className) : null;
221
+ const refusal = occurrenceRefusal({ ...entry, className, uses, opaqueModules });
222
+ if (refusal) {
223
+ refusals.push({ file: entry.sheet.file, line: entry.block.line, reason: refusal });
224
+ continue;
225
+ }
226
+ eligible.push({ ...entry, className, uses });
227
+ }
228
+
229
+ const files = new Set(eligible.map((e) => e.sheet.file));
230
+ if (eligible.length < 2 || files.size < 2) continue;
231
+
232
+ const name = `${prefix}${kebab(commonName(eligible))}`;
233
+ if (taken.has(name)) {
234
+ refusals.push({
235
+ file: sheet.file,
236
+ line: 1,
237
+ reason: `\`.${name}\` já existe na folha global — renomeie a classe local ou use --css-prefix`,
238
+ });
239
+ continue;
240
+ }
241
+ taken.add(name);
242
+
243
+ groups.push({
244
+ name,
245
+ decls: eligible[0].block.decls.map((d) => ({ prop: d.prop, value: d.value })),
246
+ occurrences: eligible.map((e) => ({
247
+ path: e.sheet.path,
248
+ file: e.sheet.file,
249
+ line: e.block.line,
250
+ className: e.className,
251
+ start: e.block.start,
252
+ end: e.block.end,
253
+ sites: e.uses.map((u) => ({
254
+ path: u.file,
255
+ file: relative(root, u.file),
256
+ line: u.line,
257
+ start: u.start,
258
+ end: u.end,
259
+ })),
260
+ })),
261
+ });
262
+ }
263
+
264
+ return { status: "ok", target: sheet, groups, refusals };
265
+ }
266
+
267
+ /**
268
+ * The name for the extracted class: the local name the codebase already uses
269
+ * most, by modules first and call sites second.
270
+ *
271
+ * A tie is the normal case — the copies live in different modules precisely
272
+ * because nobody agreed on a name — so the order of the occurrences breaks it,
273
+ * which keeps the result deterministic across runs. The chosen name is printed
274
+ * before anything is written; `--css-prefix` is there for when it needs a
275
+ * namespace rather than a different word.
276
+ */
277
+ function commonName(eligible) {
278
+ const counts = new Map();
279
+ for (const entry of eligible) {
280
+ const current = counts.get(entry.className) ?? { modules: 0, sites: 0 };
281
+ counts.set(entry.className, {
282
+ modules: current.modules + 1,
283
+ sites: current.sites + entry.uses.length,
284
+ });
285
+ }
286
+ return [...counts.entries()].sort(
287
+ (a, b) => b[1].modules - a[1].modules || b[1].sites - a[1].sites,
288
+ )[0][0];
289
+ }
290
+
291
+ /** Render the extracted rule for the global sheet. */
292
+ export function renderRule({ name, decls, occurrences }) {
293
+ const body = decls.map((d) => ` ${d.prop}: ${d.value};`).join("\n");
294
+ return [
295
+ `/* extraído por \`tempest fix --extract-css\` de ${occurrences.length} CSS Modules */`,
296
+ `.${name} {`,
297
+ body,
298
+ "}",
299
+ "",
300
+ ].join("\n");
301
+ }
302
+
303
+ /**
304
+ * Grow a removal range over the leading indentation and the trailing newline.
305
+ * Same rule as the dedupe pass, so a removed rule leaves no blank indented line.
306
+ */
307
+ function expandRange(text, start, end) {
308
+ let from = start;
309
+ while (from > 0 && (text[from - 1] === " " || text[from - 1] === "\t")) from -= 1;
310
+ if (from > 0 && text[from - 1] !== "\n") from = start;
311
+ let to = end;
312
+ while (to < text.length && (text[to] === " " || text[to] === "\t")) to += 1;
313
+ if (text[to] === "\r") to += 1;
314
+ if (text[to] === "\n") to += 1;
315
+ return { start: from, end: to };
316
+ }
317
+
318
+ /** Apply text edits back-to-front. */
319
+ function splice(text, edits) {
320
+ let out = text;
321
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
322
+ out = out.slice(0, edit.start) + (edit.text ?? "") + out.slice(edit.end);
323
+ }
324
+ return out;
325
+ }
326
+
327
+ /**
328
+ * Apply an extraction plan to disk.
329
+ *
330
+ * @param {object} params
331
+ * @param {ReturnType<typeof planExtraction>} params.plan
332
+ * @param {boolean} [params.dryRun]
333
+ * @returns {{
334
+ * moved: number,
335
+ * rules: number,
336
+ * files: Array<{ file: string, changes: string[] }>,
337
+ * errors: Array<{ file: string, message: string }>,
338
+ * }}
339
+ */
340
+ export function applyExtraction({ plan, dryRun = false }) {
341
+ const result = { moved: 0, rules: 0, files: [], errors: [] };
342
+ if (plan.status !== "ok" || plan.groups.length === 0) return result;
343
+
344
+ const edits = new Map();
345
+ const changes = new Map();
346
+ const record = (file, message) => {
347
+ changes.set(file, [...(changes.get(file) ?? []), message]);
348
+ };
349
+ const push = (path, edit) => {
350
+ edits.set(path, [...(edits.get(path) ?? []), edit]);
351
+ };
352
+
353
+ let appended = "";
354
+ for (const group of plan.groups) {
355
+ appended += `\n${renderRule(group)}`;
356
+ result.rules += 1;
357
+ for (const occurrence of group.occurrences) {
358
+ let text;
359
+ try {
360
+ text = readFileSync(occurrence.path, "utf8");
361
+ } catch (err) {
362
+ result.errors.push({
363
+ file: occurrence.file,
364
+ message: String(err?.message ?? err),
365
+ });
366
+ continue;
367
+ }
368
+ push(occurrence.path, expandRange(text, occurrence.start, occurrence.end));
369
+ record(
370
+ occurrence.file,
371
+ `removida \`.${occurrence.className}\` (linha ${occurrence.line}) → \`.${group.name}\` em ${plan.target.file}`,
372
+ );
373
+ result.moved += 1;
374
+ for (const site of occurrence.sites) {
375
+ push(site.path, { start: site.start, end: site.end, text: `"${group.name}"` });
376
+ record(
377
+ site.file,
378
+ `\`styles.${occurrence.className}\` → \`"${group.name}"\` (linha ${site.line})`,
379
+ );
380
+ }
381
+ }
382
+ }
383
+
384
+ if (!dryRun) {
385
+ for (const [path, fileEdits] of edits) {
386
+ let text;
387
+ try {
388
+ text = readFileSync(path, "utf8");
389
+ writeFileSync(path, splice(text, fileEdits));
390
+ } catch (err) {
391
+ result.errors.push({ file: path, message: String(err?.message ?? err) });
392
+ }
393
+ }
394
+ try {
395
+ const current = readFileSync(plan.target.path, "utf8");
396
+ writeFileSync(plan.target.path, `${current.replace(/\s*$/, "\n")}${appended}`);
397
+ } catch (err) {
398
+ result.errors.push({ file: plan.target.file, message: String(err?.message ?? err) });
399
+ }
400
+ }
401
+ record(plan.target.file, `+${result.rules} classe(s) global(is)`);
402
+
403
+ result.files = [...changes.entries()].map(([file, list]) => ({ file, changes: list }));
404
+ return result;
405
+ }