tempest-react-sdk 0.27.0 → 0.28.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.
Files changed (111) hide show
  1. package/README.md +26 -2
  2. package/bin/lib/css/analyze.mjs +149 -0
  3. package/bin/lib/css/analyze.test.mjs +136 -0
  4. package/bin/lib/css/collect.mjs +152 -0
  5. package/bin/lib/css/findings.mjs +79 -0
  6. package/bin/lib/css/fix.e2e.test.mjs +99 -0
  7. package/bin/lib/css/fix.mjs +138 -0
  8. package/bin/lib/css/fix.test.mjs +108 -0
  9. package/bin/lib/css/index.mjs +16 -0
  10. package/bin/lib/css/parse.mjs +398 -0
  11. package/bin/lib/css/parse.test.mjs +146 -0
  12. package/bin/lib/css/properties.mjs +399 -0
  13. package/bin/lib/css/repetition.mjs +213 -0
  14. package/bin/lib/css/repetition.test.mjs +166 -0
  15. package/bin/lib/css/semantic.mjs +327 -0
  16. package/bin/lib/css/semantic.test.mjs +201 -0
  17. package/bin/lib/css/tokens.mjs +119 -0
  18. package/bin/lib/doctor/doctor.e2e.test.mjs +45 -0
  19. package/bin/tempest.mjs +169 -7
  20. package/dist/charts/scales.cjs +2 -0
  21. package/dist/charts/scales.cjs.map +1 -0
  22. package/dist/charts/scales.js +29 -0
  23. package/dist/charts/scales.js.map +1 -0
  24. package/dist/charts.cjs +1 -1
  25. package/dist/charts.d.ts +91 -0
  26. package/dist/charts.js +9 -8
  27. package/dist/components/BottomNavigation/BottomNavigation.module.cjs.map +1 -1
  28. package/dist/components/BottomNavigation/BottomNavigation.module.js.map +1 -1
  29. package/dist/components/Carousel/Carousel.module.cjs.map +1 -1
  30. package/dist/components/Carousel/Carousel.module.js.map +1 -1
  31. package/dist/components/CodeBlock/CodeBlock.cjs +3 -0
  32. package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -0
  33. package/dist/components/CodeBlock/CodeBlock.js +53 -0
  34. package/dist/components/CodeBlock/CodeBlock.js.map +1 -0
  35. package/dist/components/CodeBlock/CodeBlock.module.cjs +2 -0
  36. package/dist/components/CodeBlock/CodeBlock.module.cjs.map +1 -0
  37. package/dist/components/CodeBlock/CodeBlock.module.js +29 -0
  38. package/dist/components/CodeBlock/CodeBlock.module.js.map +1 -0
  39. package/dist/components/CodeBlock/tokenize.cjs +3 -0
  40. package/dist/components/CodeBlock/tokenize.cjs.map +1 -0
  41. package/dist/components/CodeBlock/tokenize.js +267 -0
  42. package/dist/components/CodeBlock/tokenize.js.map +1 -0
  43. package/dist/components/Navbar/Navbar.module.cjs.map +1 -1
  44. package/dist/components/Navbar/Navbar.module.js.map +1 -1
  45. package/dist/components/QRCode/QRCode.cjs +2 -0
  46. package/dist/components/QRCode/QRCode.cjs.map +1 -0
  47. package/dist/components/QRCode/QRCode.js +49 -0
  48. package/dist/components/QRCode/QRCode.js.map +1 -0
  49. package/dist/components/QRCode/QRCode.module.cjs +2 -0
  50. package/dist/components/QRCode/QRCode.module.cjs.map +1 -0
  51. package/dist/components/QRCode/QRCode.module.js +9 -0
  52. package/dist/components/QRCode/QRCode.module.js.map +1 -0
  53. package/dist/components/QRCode/qr-encode.cjs +2 -0
  54. package/dist/components/QRCode/qr-encode.cjs.map +1 -0
  55. package/dist/components/QRCode/qr-encode.js +295 -0
  56. package/dist/components/QRCode/qr-encode.js.map +1 -0
  57. package/dist/components/QRCode/qr-tables.cjs +2 -0
  58. package/dist/components/QRCode/qr-tables.cjs.map +1 -0
  59. package/dist/components/QRCode/qr-tables.js +366 -0
  60. package/dist/components/QRCode/qr-tables.js.map +1 -0
  61. package/dist/components/ScrollArea/ScrollArea.cjs +1 -1
  62. package/dist/components/ScrollArea/ScrollArea.cjs.map +1 -1
  63. package/dist/components/ScrollArea/ScrollArea.js +22 -16
  64. package/dist/components/ScrollArea/ScrollArea.js.map +1 -1
  65. package/dist/components/ScrollArea/ScrollArea.module.cjs.map +1 -1
  66. package/dist/components/ScrollArea/ScrollArea.module.js.map +1 -1
  67. package/dist/components/Sparkline/Sparkline.cjs +2 -0
  68. package/dist/components/Sparkline/Sparkline.cjs.map +1 -0
  69. package/dist/components/Sparkline/Sparkline.js +64 -0
  70. package/dist/components/Sparkline/Sparkline.js.map +1 -0
  71. package/dist/components/Sparkline/Sparkline.module.cjs +2 -0
  72. package/dist/components/Sparkline/Sparkline.module.cjs.map +1 -0
  73. package/dist/components/Sparkline/Sparkline.module.js +10 -0
  74. package/dist/components/Sparkline/Sparkline.module.js.map +1 -0
  75. package/dist/components/Sparkline/sparkline-geometry.cjs +2 -0
  76. package/dist/components/Sparkline/sparkline-geometry.cjs.map +1 -0
  77. package/dist/components/Sparkline/sparkline-geometry.js +56 -0
  78. package/dist/components/Sparkline/sparkline-geometry.js.map +1 -0
  79. package/dist/components/Table/Table.cjs +1 -1
  80. package/dist/components/Table/Table.cjs.map +1 -1
  81. package/dist/components/Table/Table.js +37 -30
  82. package/dist/components/Table/Table.js.map +1 -1
  83. package/dist/components/Table/Table.module.cjs.map +1 -1
  84. package/dist/components/Table/Table.module.js.map +1 -1
  85. package/dist/components/VirtualList/VirtualList.cjs +1 -1
  86. package/dist/components/VirtualList/VirtualList.cjs.map +1 -1
  87. package/dist/components/VirtualList/VirtualList.js +13 -11
  88. package/dist/components/VirtualList/VirtualList.js.map +1 -1
  89. package/dist/components/VirtualList/VirtualList.module.cjs.map +1 -1
  90. package/dist/components/VirtualList/VirtualList.module.js.map +1 -1
  91. package/dist/hooks/use-scroll-overflow.cjs +2 -0
  92. package/dist/hooks/use-scroll-overflow.cjs.map +1 -0
  93. package/dist/hooks/use-scroll-overflow.js +22 -0
  94. package/dist/hooks/use-scroll-overflow.js.map +1 -0
  95. package/dist/push/utils.cjs +1 -1
  96. package/dist/push/utils.cjs.map +1 -1
  97. package/dist/push/utils.js +1 -1
  98. package/dist/push/utils.js.map +1 -1
  99. package/dist/styles.css +1 -1
  100. package/dist/tempest-react-sdk.cjs +1 -1
  101. package/dist/tempest-react-sdk.d.ts +450 -2
  102. package/dist/tempest-react-sdk.js +197 -190
  103. package/dist/theme/create-theme.cjs +3 -3
  104. package/dist/theme/create-theme.cjs.map +1 -1
  105. package/dist/theme/create-theme.js +52 -28
  106. package/dist/theme/create-theme.js.map +1 -1
  107. package/dist/theme/data-viz-ramps.cjs +2 -0
  108. package/dist/theme/data-viz-ramps.cjs.map +1 -0
  109. package/dist/theme/data-viz-ramps.js +58 -0
  110. package/dist/theme/data-viz-ramps.js.map +1 -0
  111. package/package.json +2 -1
package/README.md CHANGED
@@ -31,6 +31,7 @@ The goal is to start every new React frontend with the same opinionated foundati
31
31
  - [CSS import](#css-import)
32
32
  - [What's inside](#whats-inside)
33
33
  - [Scaffold a new app](#scaffold-a-new-app)
34
+ - [Project CLI — `tempest` (doctor, fix, CSS analysis)](#project-cli--tempest)
34
35
  - [App foundation (routing, state, providers, Vite)](#app-foundation)
35
36
  - [Architecture overview](#architecture-overview)
36
37
  - [Quickstart — wiring the app providers](#quickstart--wiring-the-app-providers)
@@ -341,6 +342,29 @@ Each generated file demonstrates one SDK capability. Full walkthrough: **[scaffo
341
342
 
342
343
  ---
343
344
 
345
+ ## Project CLI — `tempest`
346
+
347
+ The package ships a second `bin`, **`tempest`**, for day-to-day project health — it needs no config and works on any React + Vite app, SDK or not:
348
+
349
+ ```bash
350
+ npx tempest doctor # health-check: deps, TypeScript, Vite, tooling, env, CSS
351
+ npx tempest fix # relative imports → @/ · dead CSS · ESLint --fix · Prettier
352
+ npx tempest fix --dry-run # the full report, writing nothing
353
+ npx tempest lint # ESLint, report only
354
+ npx tempest gen api <spec> # Zod schemas + types + service classes from OpenAPI
355
+ npx tempest gen icons # static icon registry from the slugs your source uses
356
+ ```
357
+
358
+ **CSS is analyzed too** — ESLint does not read `.css` and Prettier only reformats it, so broken CSS normally goes through untouched. `doctor` and `fix` parse every stylesheet (CSS Modules included) and report:
359
+
360
+ - **syntax the browser silently drops** — a missing `;` that swallows the next declarations, `color: ;`, an unclosed block, an unterminated comment or string;
361
+ - **valid CSS that is still wrong** — a declaration the next one kills, a selector declared twice, `bacground-color`, `@medai`, a `--tempest-*` token that does not exist, a `var()` nobody defines and that has no fallback;
362
+ - **when a global class beats the repeated local one** — the same declaration block in N CSS Modules, including the cases `utilities.css` already covers (`.tempest-row`, `.tempest-stack`, …). CSS Modules cannot see this for you: scoping makes the duplication invisible by design.
363
+
364
+ `tempest fix` removes only what is provably dead (a declaration repeated with the identical value, a rule that repeats an earlier one exactly, an empty rule in a plain sheet) — always the **earlier** copy, since CSS is last-wins. Everything else is reported and left to you. Details: **[CLI docs (PT)](https://mauriciobenjamin700.github.io/tempest-react-sdk/cli/)** · **[EN](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/cli/)**.
365
+
366
+ ---
367
+
344
368
  ## App foundation
345
369
 
346
370
  Beyond UI blocks, the SDK ships an opinionated **application foundation** so every Tempest frontend wires Vite, routing, state and cache the same way. These are also what the scaffold above generates.
@@ -1081,7 +1105,7 @@ export function PushToggle() {
1081
1105
  const push = usePushSubscription({
1082
1106
  vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
1083
1107
  onSubscribe: (subscription) => api.post("/webpush/subscribe", { body: subscription }),
1084
- onUnsubscribe: () => api.delete("/webpush/my"),
1108
+ onUnsubscribe: (sub) => api.delete("/webpush/subscribe", { body: { endpoint: sub.endpoint } }),
1085
1109
  });
1086
1110
 
1087
1111
  if (!push.supported) return <p>Push não suportado neste navegador.</p>;
@@ -1106,7 +1130,7 @@ import { WebPushClient } from "tempest-react-sdk";
1106
1130
  const push = new WebPushClient({
1107
1131
  vapidPublicKey: VAPID_PUBLIC_KEY,
1108
1132
  onSubscribe: (sub) => api.post("/webpush/subscribe", { body: sub }),
1109
- onUnsubscribe: () => api.delete("/webpush/my"),
1133
+ onUnsubscribe: (sub) => api.delete("/webpush/subscribe", { body: { endpoint: sub.endpoint } }),
1110
1134
  });
1111
1135
 
1112
1136
  await push.subscribe();
@@ -0,0 +1,149 @@
1
+ // The whole CSS analysis in one call: collect, parse, diagnose per file,
2
+ // diagnose across files. `doctor` renders the summary, `fix` applies the subset
3
+ // that is provably safe, and both read the same result — there is no second
4
+ // implementation that can disagree with the first.
5
+ import { readFileSync, writeFileSync } from "node:fs";
6
+ import { relative } from "node:path";
7
+
8
+ import { collectStylesheets, customPropertiesInSources, looksMinified } from "./collect.mjs";
9
+ import { countBySeverity, sortFindings } from "./findings.mjs";
10
+ import { fixCss } from "./fix.mjs";
11
+ import { parseCss } from "./parse.mjs";
12
+ import { repetitionFindings } from "./repetition.mjs";
13
+ import { analyzeParsed, definedCustomProperties } from "./semantic.mjs";
14
+ import { loadTokens } from "./tokens.mjs";
15
+
16
+ /**
17
+ * Analyze every stylesheet under `targets`.
18
+ *
19
+ * @param {object} params
20
+ * @param {string} params.root - Project root.
21
+ * @param {string[]} [params.targets] - Positional paths from the command line.
22
+ * @param {string} params.selfDir - Directory of the running CLI, to locate the token table.
23
+ * @param {number} [params.maxFiles]
24
+ * @returns {{
25
+ * sheets: Array<{ path: string, file: string, text: string, isModule: boolean, parsed: object }>,
26
+ * findings: Array<object>,
27
+ * groups: Array<object>,
28
+ * skipped: Array<{ file: string, reason: string }>,
29
+ * truncated: boolean,
30
+ * counts: { error: number, warn: number, info: number },
31
+ * stats: { files: number, rules: number, declarations: number },
32
+ * tokens: { source: string | null, names: Set<string>, byValue: Map<string, string[]>, utilities: Set<string> },
33
+ * }}
34
+ */
35
+ export function analyzeCss({ root, targets = ["."], selfDir, maxFiles }) {
36
+ const tokens = loadTokens({ root, selfDir });
37
+ const collected = collectStylesheets({ root, targets, maxFiles });
38
+ const skipped = collected.skipped.map((s) => ({
39
+ file: relative(root, s.path),
40
+ reason: s.reason,
41
+ }));
42
+
43
+ const sheets = [];
44
+ for (const path of collected.files) {
45
+ let text;
46
+ try {
47
+ text = readFileSync(path, "utf8");
48
+ } catch (err) {
49
+ skipped.push({ file: relative(root, path), reason: String(err?.message ?? err) });
50
+ continue;
51
+ }
52
+ if (looksMinified(text)) {
53
+ skipped.push({ file: relative(root, path), reason: "minified" });
54
+ continue;
55
+ }
56
+ sheets.push({
57
+ path,
58
+ file: relative(root, path),
59
+ text,
60
+ isModule: path.endsWith(".module.css"),
61
+ parsed: parseCss(text),
62
+ });
63
+ }
64
+
65
+ const definedVars = new Set(tokens.names);
66
+ for (const sheet of sheets) {
67
+ for (const name of definedCustomProperties(sheet.parsed)) definedVars.add(name);
68
+ }
69
+ if (sheets.length > 0) {
70
+ for (const name of customPropertiesInSources({ root })) definedVars.add(name);
71
+ }
72
+
73
+ const findings = [];
74
+ let rules = 0;
75
+ let declarations = 0;
76
+ for (const sheet of sheets) {
77
+ findings.push(
78
+ ...analyzeParsed({
79
+ file: sheet.file,
80
+ parsed: sheet.parsed,
81
+ tokens,
82
+ definedVars,
83
+ isModule: sheet.isModule,
84
+ }),
85
+ );
86
+ for (const block of sheet.parsed.blocks) {
87
+ if (block.kind === "rule") rules += 1;
88
+ declarations += block.decls.length;
89
+ }
90
+ }
91
+
92
+ const repetition = repetitionFindings({ sheets, utilities: tokens.utilities });
93
+ findings.push(...repetition.findings);
94
+
95
+ const sorted = sortFindings(findings);
96
+ return {
97
+ sheets,
98
+ findings: sorted,
99
+ groups: repetition.groups,
100
+ skipped,
101
+ truncated: collected.truncated,
102
+ counts: countBySeverity(sorted),
103
+ stats: { files: sheets.length, rules, declarations },
104
+ tokens,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Apply the safe subset of the analysis to disk.
110
+ *
111
+ * A sheet with a syntax error is left untouched — `fixCss` refuses it, because an
112
+ * offset taken from a sheet the parser had to guess its way through is not an
113
+ * offset worth splicing. Fix the error, run again.
114
+ *
115
+ * @param {object} params
116
+ * @param {ReturnType<typeof analyzeCss>} params.analysis
117
+ * @param {boolean} [params.dryRun] - Report the edits without writing them.
118
+ * @returns {{
119
+ * files: Array<{ file: string, changes: Array<{ code: string, line: number, message: string }> }>,
120
+ * total: number,
121
+ * errors: Array<{ file: string, message: string }>,
122
+ * }}
123
+ */
124
+ export function applyCssFixes({ analysis, dryRun = false }) {
125
+ const files = [];
126
+ const errors = [];
127
+ let total = 0;
128
+
129
+ for (const sheet of analysis.sheets) {
130
+ const result = fixCss({
131
+ text: sheet.text,
132
+ parsed: sheet.parsed,
133
+ isModule: sheet.isModule,
134
+ });
135
+ if (result.changes.length === 0) continue;
136
+ if (!dryRun) {
137
+ try {
138
+ writeFileSync(sheet.path, result.text);
139
+ } catch (err) {
140
+ errors.push({ file: sheet.file, message: String(err?.message ?? err) });
141
+ continue;
142
+ }
143
+ }
144
+ files.push({ file: sheet.file, changes: result.changes });
145
+ total += result.changes.length;
146
+ }
147
+
148
+ return { files, total, errors };
149
+ }
@@ -0,0 +1,136 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+
6
+ import { analyzeCss, applyCssFixes } from "./analyze.mjs";
7
+
8
+ let root;
9
+ const SELF_DIR = join(dirname(new URL(import.meta.url).pathname), "..", "..");
10
+
11
+ /** Write a file inside the fixture project, creating its directory. */
12
+ function write(rel, text) {
13
+ const full = join(root, rel);
14
+ mkdirSync(dirname(full), { recursive: true });
15
+ writeFileSync(full, text);
16
+ return full;
17
+ }
18
+
19
+ const analyze = (targets = ["."]) => analyzeCss({ root, targets, selfDir: SELF_DIR });
20
+ const codesFor = (analysis, file) =>
21
+ analysis.findings.filter((f) => f.file === file).map((f) => f.code);
22
+
23
+ beforeEach(() => {
24
+ root = mkdtempSync(join(tmpdir(), "tempest-css-"));
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(root, { recursive: true, force: true });
29
+ });
30
+
31
+ describe("analyzeCss — collection", () => {
32
+ it("reports nothing when the project has no stylesheet", () => {
33
+ const analysis = analyze();
34
+ expect(analysis.stats.files).toBe(0);
35
+ expect(analysis.findings).toEqual([]);
36
+ });
37
+
38
+ it("walks the project and reports paths relative to the root", () => {
39
+ write("src/a.css", ".a { color: red; color: red; }\n");
40
+ const analysis = analyze();
41
+ expect(analysis.stats).toMatchObject({ files: 1, rules: 1, declarations: 2 });
42
+ expect(analysis.findings[0].file).toBe(join("src", "a.css"));
43
+ });
44
+
45
+ it("skips node_modules, dist and other generated trees", () => {
46
+ write("node_modules/pkg/x.css", ".a {}\n");
47
+ write("dist/x.css", ".a {}\n");
48
+ write("coverage/x.css", ".a {}\n");
49
+ write("src/x.css", ".a { color: red; }\n");
50
+ expect(analyze().stats.files).toBe(1);
51
+ });
52
+
53
+ it("skips a minified sheet and says so", () => {
54
+ write("src/vendor.min.css", ".a{color:red}\n");
55
+ const analysis = analyze();
56
+ expect(analysis.stats.files).toBe(0);
57
+ expect(analysis.skipped[0]).toMatchObject({ reason: "minified" });
58
+ });
59
+
60
+ it("honors a single-file target", () => {
61
+ write("src/a.css", ".a { color: red; color: red; }\n");
62
+ write("src/b.css", ".b { color: red; color: red; }\n");
63
+ const analysis = analyze([join("src", "a.css")]);
64
+ expect(analysis.stats.files).toBe(1);
65
+ });
66
+
67
+ it("sorts findings error → warn → info", () => {
68
+ write("src/a.css", ".a {\n color: ;\n}\n.b {\n color: red;\n color: red;\n}\n");
69
+ const severities = analyze().findings.map((f) => f.severity);
70
+ expect(severities).toEqual(["error", "warn"]);
71
+ expect(analyze().counts).toMatchObject({ error: 1, warn: 1 });
72
+ });
73
+ });
74
+
75
+ describe("analyzeCss — project-wide knowledge", () => {
76
+ it("accepts a custom property defined in another stylesheet", () => {
77
+ write("src/tokens.css", ":root {\n --brand: #123456;\n}\n");
78
+ write("src/use.css", ".a {\n color: var(--brand);\n}\n");
79
+ expect(codesFor(analyze(), join("src", "use.css"))).toEqual([]);
80
+ });
81
+
82
+ it("accepts a custom property only the TypeScript sets", () => {
83
+ write("src/app.ts", 'el.style.setProperty("--row-height", "40px");\n');
84
+ write("src/use.css", ".a {\n height: var(--row-height);\n}\n");
85
+ expect(codesFor(analyze(), join("src", "use.css"))).toEqual([]);
86
+ });
87
+
88
+ it("still flags a variable nothing anywhere defines", () => {
89
+ write("src/use.css", ".a {\n height: var(--nowhere);\n}\n");
90
+ expect(codesFor(analyze(), join("src", "use.css"))).toEqual(["undefined-var"]);
91
+ });
92
+
93
+ it("finds a block repeated across CSS Modules", () => {
94
+ const row = ".row {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n";
95
+ write("src/A.module.css", row);
96
+ write("src/B.module.css", row);
97
+ write("src/C.module.css", row);
98
+ const codes = analyze().findings.map((f) => f.code);
99
+ expect(codes).toContain("global-candidate");
100
+ });
101
+ });
102
+
103
+ describe("applyCssFixes", () => {
104
+ it("writes the deduped sheet and reports each change", () => {
105
+ const file = write("src/a.css", ".a {\n color: red;\n color: red;\n}\n.gone {\n}\n");
106
+ const result = applyCssFixes({ analysis: analyze() });
107
+ expect(result.total).toBe(2);
108
+ expect(result.files[0].changes.map((c) => c.code)).toEqual([
109
+ "duplicate-declaration",
110
+ "empty-rule",
111
+ ]);
112
+ expect(readFileSync(file, "utf8")).toBe(".a {\n color: red;\n}\n");
113
+ });
114
+
115
+ it("writes nothing on a dry run", () => {
116
+ const before = ".a {\n color: red;\n color: red;\n}\n";
117
+ const file = write("src/a.css", before);
118
+ const result = applyCssFixes({ analysis: analyze(), dryRun: true });
119
+ expect(result.total).toBe(1);
120
+ expect(readFileSync(file, "utf8")).toBe(before);
121
+ });
122
+
123
+ it("leaves a sheet with a syntax error untouched", () => {
124
+ const before = ".a {\n color: red;\n color: red;\n";
125
+ const file = write("src/a.css", before);
126
+ const result = applyCssFixes({ analysis: analyze() });
127
+ expect(result.total).toBe(0);
128
+ expect(readFileSync(file, "utf8")).toBe(before);
129
+ });
130
+
131
+ it("is idempotent — a second run finds nothing to do", () => {
132
+ write("src/a.css", ".a {\n color: red;\n color: red;\n}\n");
133
+ applyCssFixes({ analysis: analyze() });
134
+ expect(applyCssFixes({ analysis: analyze() }).total).toBe(0);
135
+ });
136
+ });
@@ -0,0 +1,152 @@
1
+ // Find the stylesheets to analyze — and, just as important, the ones to leave
2
+ // alone: a vendored or already-minified sheet is not the author's code, and
3
+ // reporting 400 findings in `normalize.min.css` buries the two that are theirs.
4
+ import { readdirSync, readFileSync, statSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+
7
+ const SKIP_DIRS = new Set([
8
+ "node_modules",
9
+ "dist",
10
+ "build",
11
+ "out",
12
+ "coverage",
13
+ "public",
14
+ "vendor",
15
+ ".git",
16
+ ".vite",
17
+ ".next",
18
+ ".turbo",
19
+ ".cache",
20
+ ]);
21
+
22
+ /** Above this size a stylesheet is treated as generated, not authored. */
23
+ const MAX_BYTES = 512 * 1024;
24
+
25
+ /** Cap on files opened, so `tempest doctor` stays fast on a huge tree. */
26
+ const MAX_FILES = 600;
27
+
28
+ /** True when a sheet looks minified: few newlines for a lot of bytes. */
29
+ export function looksMinified(text) {
30
+ if (text.length < 2000) return false;
31
+ const lines = text.split("\n").length;
32
+ return text.length / lines > 400;
33
+ }
34
+
35
+ /**
36
+ * Collect the project's own `.css` files.
37
+ *
38
+ * @param {object} params
39
+ * @param {string} params.root - Project root; relative targets resolve against it.
40
+ * @param {string[]} [params.targets] - Positional paths from the command line.
41
+ * @param {number} [params.maxFiles]
42
+ * @returns {{ files: string[], skipped: Array<{ path: string, reason: string }>, truncated: boolean }}
43
+ */
44
+ export function collectStylesheets({ root, targets = ["."], maxFiles = MAX_FILES }) {
45
+ const files = new Set();
46
+ const skipped = [];
47
+ let truncated = false;
48
+
49
+ const consider = (path) => {
50
+ if (!path.endsWith(".css") || files.has(path)) return;
51
+ if (files.size >= maxFiles) {
52
+ truncated = true;
53
+ return;
54
+ }
55
+ let size;
56
+ try {
57
+ size = statSync(path).size;
58
+ } catch {
59
+ return;
60
+ }
61
+ if (size > MAX_BYTES) {
62
+ skipped.push({ path, reason: "larger than 512 KB — treated as generated" });
63
+ return;
64
+ }
65
+ if (/\.min\.css$/.test(path)) {
66
+ skipped.push({ path, reason: "minified" });
67
+ return;
68
+ }
69
+ files.add(path);
70
+ };
71
+
72
+ const walk = (dir) => {
73
+ let entries;
74
+ try {
75
+ entries = readdirSync(dir, { withFileTypes: true });
76
+ } catch {
77
+ return;
78
+ }
79
+ for (const entry of entries) {
80
+ if (SKIP_DIRS.has(entry.name)) continue;
81
+ if (entry.name.startsWith(".") && entry.isDirectory()) continue;
82
+ const full = join(dir, entry.name);
83
+ if (entry.isDirectory()) walk(full);
84
+ else if (entry.isFile()) consider(full);
85
+ }
86
+ };
87
+
88
+ for (const target of targets) {
89
+ const full = resolve(root, target);
90
+ let stat;
91
+ try {
92
+ stat = statSync(full);
93
+ } catch {
94
+ continue;
95
+ }
96
+ if (stat.isDirectory()) walk(full);
97
+ else if (stat.isFile()) consider(full);
98
+ }
99
+
100
+ return { files: [...files].sort((a, b) => a.localeCompare(b)), skipped, truncated };
101
+ }
102
+
103
+ /**
104
+ * Custom-property names mentioned anywhere in the project's TS/JS/HTML.
105
+ *
106
+ * Without this the "never defined" check reports every token an app sets from
107
+ * JavaScript — `style.setProperty("--row-height", …)` is how a CSS variable is
108
+ * meant to be driven at runtime, and calling that a bug would be the loudest
109
+ * false positive in the tool.
110
+ *
111
+ * @param {object} params
112
+ * @param {string} params.root
113
+ * @param {number} [params.maxFiles]
114
+ * @returns {Set<string>}
115
+ */
116
+ export function customPropertiesInSources({ root, maxFiles = 1500 }) {
117
+ const found = new Set();
118
+ const exts = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".html", ".svg"];
119
+ let budget = maxFiles;
120
+
121
+ const walk = (dir) => {
122
+ if (budget <= 0) return;
123
+ let entries;
124
+ try {
125
+ entries = readdirSync(dir, { withFileTypes: true });
126
+ } catch {
127
+ return;
128
+ }
129
+ for (const entry of entries) {
130
+ if (budget <= 0) return;
131
+ if (SKIP_DIRS.has(entry.name)) continue;
132
+ if (entry.name.startsWith(".") && entry.isDirectory()) continue;
133
+ const full = join(dir, entry.name);
134
+ if (entry.isDirectory()) {
135
+ walk(full);
136
+ continue;
137
+ }
138
+ if (!exts.some((ext) => entry.name.endsWith(ext))) continue;
139
+ budget -= 1;
140
+ let text;
141
+ try {
142
+ text = readFileSync(full, "utf8");
143
+ } catch {
144
+ continue;
145
+ }
146
+ for (const match of text.matchAll(/(--[a-zA-Z][a-zA-Z0-9_-]*)/g)) found.add(match[1]);
147
+ }
148
+ };
149
+
150
+ walk(root);
151
+ return found;
152
+ }
@@ -0,0 +1,79 @@
1
+ // The finding vocabulary: every code the CSS analysis can emit, and how loud it
2
+ // is. Kept in one table so `doctor`, `fix` and the tests agree on what counts as
3
+ // broken CSS versus a suggestion somebody may reasonably decline.
4
+ //
5
+ // `error` means the browser drops something the author wrote. `warn` means the
6
+ // CSS is valid but a declaration is dead, shadowed or pointing at a name that
7
+ // does not exist. `info` is advice — a token or a shared class that would say
8
+ // the same thing better — and never fails a command.
9
+
10
+ /** Severity of each finding code. */
11
+ export const SEVERITY = {
12
+ "unterminated-comment": "error",
13
+ "unterminated-string": "error",
14
+ "unterminated-paren": "error",
15
+ "unclosed-block": "error",
16
+ "unexpected-brace": "error",
17
+ "missing-brace": "error",
18
+ "missing-colon": "error",
19
+ "missing-property": "error",
20
+ "missing-semicolon": "error",
21
+ "empty-value": "error",
22
+ "empty-selector": "error",
23
+ "declaration-outside-rule": "error",
24
+ "unknown-at-rule": "warn",
25
+ "unknown-property": "warn",
26
+ "unknown-token": "warn",
27
+ "undefined-var": "warn",
28
+ "duplicate-declaration": "warn",
29
+ "overridden-declaration": "warn",
30
+ "duplicate-selector": "warn",
31
+ "duplicate-rule": "warn",
32
+ "empty-rule": "warn",
33
+ "hardcoded-token-value": "info",
34
+ "global-candidate": "info",
35
+ "utility-candidate": "info",
36
+ };
37
+
38
+ /**
39
+ * Build a finding.
40
+ *
41
+ * @param {string} code - A key of {@link SEVERITY}.
42
+ * @param {object} params
43
+ * @param {string} params.file - Path as it should be reported (project-relative).
44
+ * @param {number} params.line - 1-based line.
45
+ * @param {string} params.message - What is wrong, and what to do about it.
46
+ * @param {boolean} [params.fixable] - Whether `tempest fix` rewrites it.
47
+ * @param {object} [params.extra] - Extra fields carried through to the reporter.
48
+ * @returns {{ code: string, severity: string, file: string, line: number, message: string, fixable: boolean }}
49
+ */
50
+ export function finding(code, { file, line, message, fixable = false, extra = {} }) {
51
+ return {
52
+ code,
53
+ severity: SEVERITY[code] ?? "warn",
54
+ file,
55
+ line,
56
+ message,
57
+ fixable,
58
+ ...extra,
59
+ };
60
+ }
61
+
62
+ /** Sort findings by severity, then file, then line — the reporting order. */
63
+ export function sortFindings(findings) {
64
+ const rank = { error: 0, warn: 1, info: 2 };
65
+ return [...findings].sort(
66
+ (a, b) =>
67
+ (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3) ||
68
+ a.file.localeCompare(b.file) ||
69
+ a.line - b.line ||
70
+ a.code.localeCompare(b.code),
71
+ );
72
+ }
73
+
74
+ /** Count findings per severity. */
75
+ export function countBySeverity(findings) {
76
+ const counts = { error: 0, warn: 0, info: 0 };
77
+ for (const f of findings) counts[f.severity] = (counts[f.severity] ?? 0) + 1;
78
+ return counts;
79
+ }
@@ -0,0 +1,99 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+
8
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "tempest.mjs");
9
+
10
+ let root;
11
+
12
+ beforeEach(() => {
13
+ root = mkdtempSync(join(tmpdir(), "tempest-fix-css-"));
14
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "fixture" }));
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(root, { recursive: true, force: true });
19
+ });
20
+
21
+ /** Write a file under the fixture, creating parent directories. */
22
+ function write(rel, contents) {
23
+ const path = join(root, rel);
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ writeFileSync(path, contents);
26
+ return path;
27
+ }
28
+
29
+ /**
30
+ * Run `tempest fix` in the fixture.
31
+ *
32
+ * `--no-alias` keeps the run to the CSS pass: the alias pass needs TypeScript and
33
+ * a `paths` entry, neither of which this fixture has, and its "skipping" notice
34
+ * would be the loudest line in the output.
35
+ */
36
+ function fix(args = []) {
37
+ const result = spawnSync(process.execPath, [CLI, "fix", "--no-alias", ...args], {
38
+ cwd: root,
39
+ encoding: "utf8",
40
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
41
+ });
42
+ return { code: result.status, out: `${result.stdout}${result.stderr}` };
43
+ }
44
+
45
+ describe("tempest fix — the CSS pass", () => {
46
+ it("removes dead declarations and reports each one", () => {
47
+ const file = write("src/app.css", ".a {\n color: red;\n color: red;\n}\n");
48
+ const { out } = fix();
49
+ expect(out).toContain("→ css");
50
+ expect(out).toContain("removed duplicate `color`");
51
+ expect(readFileSync(file, "utf8")).toBe(".a {\n color: red;\n}\n");
52
+ });
53
+
54
+ it("writes nothing under --dry-run", () => {
55
+ const before = ".a {\n color: red;\n color: red;\n}\n";
56
+ const file = write("src/app.css", before);
57
+ const { out } = fix(["--dry-run"]);
58
+ expect(out).toContain("would remove 1");
59
+ expect(readFileSync(file, "utf8")).toBe(before);
60
+ });
61
+
62
+ it("lists the findings a human has to resolve", () => {
63
+ write("src/app.css", ".a {\n color: red;\n color: blue;\n}\n");
64
+ const { out } = fix(["--dry-run"]);
65
+ expect(out).toContain("overrides");
66
+ expect(out).toContain("nothing to remove");
67
+ });
68
+
69
+ it("refuses to write to a sheet with a syntax error, and exits non-zero", () => {
70
+ const before = ".a {\n color: red;\n color: red;\n";
71
+ const file = write("src/app.css", before);
72
+ const { out, code } = fix();
73
+ expect(out).toContain("CSS syntax error(s)");
74
+ expect(readFileSync(file, "utf8")).toBe(before);
75
+ expect(code).toBe(1);
76
+ });
77
+
78
+ it("honors a path argument", () => {
79
+ const touched = write("src/a.css", ".a {\n color: red;\n color: red;\n}\n");
80
+ const other = write("src/b.css", ".b {\n color: red;\n color: red;\n}\n");
81
+ fix([join("src", "a.css")]);
82
+ expect(readFileSync(touched, "utf8")).toBe(".a {\n color: red;\n}\n");
83
+ expect(readFileSync(other, "utf8")).toContain("color: red;\n color: red;");
84
+ });
85
+
86
+ it("skips the pass entirely with --no-css", () => {
87
+ const before = ".a {\n color: red;\n color: red;\n}\n";
88
+ const file = write("src/app.css", before);
89
+ const { out } = fix(["--no-css", "--dry-run"]);
90
+ expect(out).not.toContain("→ css");
91
+ expect(readFileSync(file, "utf8")).toBe(before);
92
+ });
93
+
94
+ it("rejects an unknown flag instead of forwarding it", () => {
95
+ const { out, code } = fix(["--no-such-flag"]);
96
+ expect(out).toContain("Unknown flag for fix");
97
+ expect(code).toBe(1);
98
+ });
99
+ });