tempest-react-sdk 0.27.0 → 0.28.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/README.md +24 -0
- package/bin/lib/css/analyze.mjs +149 -0
- package/bin/lib/css/analyze.test.mjs +136 -0
- package/bin/lib/css/collect.mjs +152 -0
- package/bin/lib/css/findings.mjs +79 -0
- package/bin/lib/css/fix.e2e.test.mjs +99 -0
- package/bin/lib/css/fix.mjs +138 -0
- package/bin/lib/css/fix.test.mjs +108 -0
- package/bin/lib/css/index.mjs +16 -0
- package/bin/lib/css/parse.mjs +398 -0
- package/bin/lib/css/parse.test.mjs +146 -0
- package/bin/lib/css/properties.mjs +399 -0
- package/bin/lib/css/repetition.mjs +213 -0
- package/bin/lib/css/repetition.test.mjs +166 -0
- package/bin/lib/css/semantic.mjs +327 -0
- package/bin/lib/css/semantic.test.mjs +201 -0
- package/bin/lib/css/tokens.mjs +119 -0
- package/bin/lib/doctor/doctor.e2e.test.mjs +45 -0
- package/bin/tempest.mjs +169 -7
- package/dist/charts/scales.cjs +2 -0
- package/dist/charts/scales.cjs.map +1 -0
- package/dist/charts/scales.js +29 -0
- package/dist/charts/scales.js.map +1 -0
- package/dist/charts.cjs +1 -1
- package/dist/charts.d.ts +91 -0
- package/dist/charts.js +9 -8
- package/dist/components/BottomNavigation/BottomNavigation.module.cjs.map +1 -1
- package/dist/components/BottomNavigation/BottomNavigation.module.js.map +1 -1
- package/dist/components/Carousel/Carousel.module.cjs.map +1 -1
- package/dist/components/Carousel/Carousel.module.js.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.cjs +3 -0
- package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.js +53 -0
- package/dist/components/CodeBlock/CodeBlock.js.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.module.cjs +2 -0
- package/dist/components/CodeBlock/CodeBlock.module.cjs.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.module.js +29 -0
- package/dist/components/CodeBlock/CodeBlock.module.js.map +1 -0
- package/dist/components/CodeBlock/tokenize.cjs +3 -0
- package/dist/components/CodeBlock/tokenize.cjs.map +1 -0
- package/dist/components/CodeBlock/tokenize.js +267 -0
- package/dist/components/CodeBlock/tokenize.js.map +1 -0
- package/dist/components/Navbar/Navbar.module.cjs.map +1 -1
- package/dist/components/Navbar/Navbar.module.js.map +1 -1
- package/dist/components/QRCode/QRCode.cjs +2 -0
- package/dist/components/QRCode/QRCode.cjs.map +1 -0
- package/dist/components/QRCode/QRCode.js +49 -0
- package/dist/components/QRCode/QRCode.js.map +1 -0
- package/dist/components/QRCode/QRCode.module.cjs +2 -0
- package/dist/components/QRCode/QRCode.module.cjs.map +1 -0
- package/dist/components/QRCode/QRCode.module.js +9 -0
- package/dist/components/QRCode/QRCode.module.js.map +1 -0
- package/dist/components/QRCode/qr-encode.cjs +2 -0
- package/dist/components/QRCode/qr-encode.cjs.map +1 -0
- package/dist/components/QRCode/qr-encode.js +295 -0
- package/dist/components/QRCode/qr-encode.js.map +1 -0
- package/dist/components/QRCode/qr-tables.cjs +2 -0
- package/dist/components/QRCode/qr-tables.cjs.map +1 -0
- package/dist/components/QRCode/qr-tables.js +366 -0
- package/dist/components/QRCode/qr-tables.js.map +1 -0
- package/dist/components/ScrollArea/ScrollArea.cjs +1 -1
- package/dist/components/ScrollArea/ScrollArea.cjs.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.js +22 -16
- package/dist/components/ScrollArea/ScrollArea.js.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.module.cjs.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.module.js.map +1 -1
- package/dist/components/Sparkline/Sparkline.cjs +2 -0
- package/dist/components/Sparkline/Sparkline.cjs.map +1 -0
- package/dist/components/Sparkline/Sparkline.js +64 -0
- package/dist/components/Sparkline/Sparkline.js.map +1 -0
- package/dist/components/Sparkline/Sparkline.module.cjs +2 -0
- package/dist/components/Sparkline/Sparkline.module.cjs.map +1 -0
- package/dist/components/Sparkline/Sparkline.module.js +10 -0
- package/dist/components/Sparkline/Sparkline.module.js.map +1 -0
- package/dist/components/Sparkline/sparkline-geometry.cjs +2 -0
- package/dist/components/Sparkline/sparkline-geometry.cjs.map +1 -0
- package/dist/components/Sparkline/sparkline-geometry.js +56 -0
- package/dist/components/Sparkline/sparkline-geometry.js.map +1 -0
- package/dist/components/Table/Table.cjs +1 -1
- package/dist/components/Table/Table.cjs.map +1 -1
- package/dist/components/Table/Table.js +37 -30
- package/dist/components/Table/Table.js.map +1 -1
- package/dist/components/Table/Table.module.cjs.map +1 -1
- package/dist/components/Table/Table.module.js.map +1 -1
- package/dist/components/VirtualList/VirtualList.cjs +1 -1
- package/dist/components/VirtualList/VirtualList.cjs.map +1 -1
- package/dist/components/VirtualList/VirtualList.js +13 -11
- package/dist/components/VirtualList/VirtualList.js.map +1 -1
- package/dist/components/VirtualList/VirtualList.module.cjs.map +1 -1
- package/dist/components/VirtualList/VirtualList.module.js.map +1 -1
- package/dist/hooks/use-scroll-overflow.cjs +2 -0
- package/dist/hooks/use-scroll-overflow.cjs.map +1 -0
- package/dist/hooks/use-scroll-overflow.js +22 -0
- package/dist/hooks/use-scroll-overflow.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +445 -2
- package/dist/tempest-react-sdk.js +197 -190
- package/dist/theme/create-theme.cjs +3 -3
- package/dist/theme/create-theme.cjs.map +1 -1
- package/dist/theme/create-theme.js +52 -28
- package/dist/theme/create-theme.js.map +1 -1
- package/dist/theme/data-viz-ramps.cjs +2 -0
- package/dist/theme/data-viz-ramps.cjs.map +1 -0
- package/dist/theme/data-viz-ramps.js +58 -0
- package/dist/theme/data-viz-ramps.js.map +1 -0
- 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.
|
|
@@ -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
|
+
});
|