tempest-react-sdk 0.77.0 → 0.78.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/alias/collect.mjs +20 -3
- package/bin/lib/alias/index.mjs +12 -1
- package/bin/lib/alias/index.test.mjs +24 -0
- package/bin/lib/css/analyze.test.mjs +21 -1
- package/bin/lib/css/collect.mjs +14 -0
- package/bin/lib/css/extract.mjs +63 -5
- package/bin/lib/css/extract.test.mjs +110 -1
- package/bin/lib/css/fix.e2e.test.mjs +50 -0
- package/bin/lib/css/index.mjs +6 -1
- package/bin/lib/fs/contain.mjs +50 -0
- package/bin/lib/icons/generate.mjs +28 -3
- package/bin/lib/icons/generate.test.mjs +57 -1
- package/bin/lib/openapi/escape.mjs +20 -6
- package/bin/lib/openapi/generate.encode.test.mjs +120 -0
- package/bin/lib/openapi/generate.hostile.test.mjs +38 -9
- package/bin/lib/openapi/generate.mjs +41 -6
- package/bin/lib/openapi/generate.test.mjs +7 -2
- package/bin/lib/openapi/schema-to-zod.mjs +5 -3
- package/bin/tempest-gen.test.mjs +188 -0
- package/bin/tempest.mjs +120 -44
- package/dist/auth/create-tempest-auth.cjs +1 -1
- package/dist/auth/create-tempest-auth.cjs.map +1 -1
- package/dist/auth/create-tempest-auth.js +186 -113
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/br/pix.cjs +1 -1
- package/dist/br/pix.cjs.map +1 -1
- package/dist/br/pix.js +30 -27
- package/dist/br/pix.js.map +1 -1
- package/dist/br.d.ts +11 -4
- package/dist/components/CodeBlock/CodeBlock.cjs +1 -1
- package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.js +1 -1
- package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
- package/dist/components/CodeBlock/tokenize.cjs +1 -1
- package/dist/components/CodeBlock/tokenize.cjs.map +1 -1
- package/dist/components/CodeBlock/tokenize.js +54 -25
- package/dist/components/CodeBlock/tokenize.js.map +1 -1
- package/dist/components/Markdown/markdown-parse.cjs +6 -6
- package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
- package/dist/components/Markdown/markdown-parse.js +91 -74
- package/dist/components/Markdown/markdown-parse.js.map +1 -1
- package/dist/http/resumable-upload.cjs +1 -1
- package/dist/http/resumable-upload.cjs.map +1 -1
- package/dist/http/resumable-upload.js +141 -105
- package/dist/http/resumable-upload.js.map +1 -1
- package/dist/http/retry.cjs +1 -1
- package/dist/http/retry.cjs.map +1 -1
- package/dist/http/retry.js +5 -6
- package/dist/http/retry.js.map +1 -1
- package/dist/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +32 -31
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/credential-headers.cjs.map +1 -1
- package/dist/sw/credential-headers.js.map +1 -1
- package/dist/sw.d.ts +46 -8
- package/dist/telemetry/app-error-reporter.cjs +1 -1
- package/dist/telemetry/app-error-reporter.cjs.map +1 -1
- package/dist/telemetry/app-error-reporter.js +1 -1
- package/dist/telemetry/app-error-reporter.js.map +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +214 -12
- package/dist/tempest-react-sdk.js +119 -119
- package/dist/utils/csv.cjs +3 -3
- package/dist/utils/csv.cjs.map +1 -1
- package/dist/utils/csv.js +11 -10
- package/dist/utils/csv.js.map +1 -1
- package/dist/{telemetry/app-error-owner.cjs → utils/owner-fingerprint.cjs} +2 -2
- package/dist/utils/owner-fingerprint.cjs.map +1 -0
- package/dist/{telemetry/app-error-owner.js → utils/owner-fingerprint.js} +3 -3
- package/dist/utils/owner-fingerprint.js.map +1 -0
- package/dist/vite/tempest-csp.cjs +2 -2
- package/dist/vite/tempest-csp.cjs.map +1 -1
- package/dist/vite/tempest-csp.js +69 -58
- package/dist/vite/tempest-csp.js.map +1 -1
- package/dist/vite.d.ts +5 -3
- package/package.json +1 -1
- package/dist/telemetry/app-error-owner.cjs.map +0 -1
- package/dist/telemetry/app-error-owner.js.map +0 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { readdirSync, statSync } from "node:fs";
|
|
3
3
|
import { extname, join, resolve } from "node:path";
|
|
4
4
|
|
|
5
|
+
import { landsInside } from "../fs/contain.mjs";
|
|
5
6
|
import { isInsideBase } from "./specifier.mjs";
|
|
6
7
|
|
|
7
8
|
const SOURCE_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
@@ -40,6 +41,13 @@ function kindOf(path) {
|
|
|
40
41
|
* the base; a target that is merely *outside* it yields nothing, so
|
|
41
42
|
* `tempest fix e2e` rewrites nothing instead of quietly rewriting `src/`.
|
|
42
43
|
*
|
|
44
|
+
* A file that resolves outside the project once symlinks are followed is dropped
|
|
45
|
+
* too: the walk descends into the base with `readdirSync`, which follows a base
|
|
46
|
+
* that is itself a link (`src -> /elsewhere`), and the codemod would otherwise
|
|
47
|
+
* rewrite files that are not the project's. Each such file is handed to
|
|
48
|
+
* `onOutside`, so the caller can say how many it left alone instead of skipping
|
|
49
|
+
* them in silence.
|
|
50
|
+
*
|
|
43
51
|
* Results are sorted so the report — and any diff a reviewer reads — is stable
|
|
44
52
|
* across runs and platforms.
|
|
45
53
|
*
|
|
@@ -47,15 +55,24 @@ function kindOf(path) {
|
|
|
47
55
|
* @param {string} params.root - Project root; relative targets resolve against it.
|
|
48
56
|
* @param {string[]} params.targets - Positional paths from the command line.
|
|
49
57
|
* @param {string} params.baseDir - Absolute alias base directory.
|
|
58
|
+
* @param {(path: string) => void} [params.onOutside] - Called once per file left
|
|
59
|
+
* out because it resolves outside the project.
|
|
50
60
|
* @returns {Array<{ path: string, kind: "source" | "style" }>}
|
|
51
61
|
*/
|
|
52
|
-
export function collectFiles({ root, targets, baseDir }) {
|
|
62
|
+
export function collectFiles({ root, targets, baseDir, onOutside = () => {} }) {
|
|
53
63
|
const out = new Map();
|
|
64
|
+
const outside = new Set();
|
|
54
65
|
|
|
55
66
|
const addFile = (path) => {
|
|
56
|
-
if (out.has(path) || !isInsideBase(path, baseDir)) return;
|
|
67
|
+
if (out.has(path) || outside.has(path) || !isInsideBase(path, baseDir)) return;
|
|
57
68
|
const kind = kindOf(path);
|
|
58
|
-
if (kind)
|
|
69
|
+
if (!kind) return;
|
|
70
|
+
if (!landsInside(root, path)) {
|
|
71
|
+
outside.add(path);
|
|
72
|
+
onOutside(path);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
out.set(path, { path, kind });
|
|
59
76
|
};
|
|
60
77
|
|
|
61
78
|
const walk = (dir) => {
|
package/bin/lib/alias/index.mjs
CHANGED
|
@@ -36,6 +36,7 @@ export { describeTypeScript, loadTypeScript, typeScriptUnavailableReason } from
|
|
|
36
36
|
* files: Array<{ path: string, changes: Array<{ from: string, to: string, line: number }> }>,
|
|
37
37
|
* total: number,
|
|
38
38
|
* errors: Array<{ path: string, message: string }>,
|
|
39
|
+
* outside?: number,
|
|
39
40
|
* }}
|
|
40
41
|
*/
|
|
41
42
|
export function aliasImports({ root, targets, dryRun = false }) {
|
|
@@ -58,7 +59,16 @@ export function aliasImports({ root, targets, dryRun = false }) {
|
|
|
58
59
|
const errors = [];
|
|
59
60
|
let total = 0;
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
let outside = 0;
|
|
63
|
+
const collected = collectFiles({
|
|
64
|
+
root,
|
|
65
|
+
targets,
|
|
66
|
+
baseDir,
|
|
67
|
+
onOutside: () => {
|
|
68
|
+
outside += 1;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
for (const { path, kind } of collected) {
|
|
62
72
|
let text;
|
|
63
73
|
try {
|
|
64
74
|
text = readFileSync(path, "utf8");
|
|
@@ -90,5 +100,6 @@ export function aliasImports({ root, targets, dryRun = false }) {
|
|
|
90
100
|
files,
|
|
91
101
|
total,
|
|
92
102
|
errors,
|
|
103
|
+
outside,
|
|
93
104
|
};
|
|
94
105
|
}
|
|
@@ -136,3 +136,27 @@ describe("aliasImports — degraded projects", () => {
|
|
|
136
136
|
expect(read("src/pages/admin/Login.tsx")).toBe(before);
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
describe("aliasImports — files outside the project", () => {
|
|
141
|
+
let outside;
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
outside = mkdtempSync(join(tmpdir(), "tempest-alias-outside-"));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
rmSync(outside, { recursive: true, force: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("does not rewrite through an alias base that is a symlink leaving the project", () => {
|
|
152
|
+
const before = `import { b } from "../b";\nexport const a = b;\n`;
|
|
153
|
+
mkdirSync(join(outside, "sub"));
|
|
154
|
+
writeFileSync(join(outside, "b.ts"), "export const b = 1;\n");
|
|
155
|
+
writeFileSync(join(outside, "sub", "a.ts"), before);
|
|
156
|
+
rmSync(join(root, "src"), { recursive: true });
|
|
157
|
+
symlinkSync(outside, join(root, "src"), "dir");
|
|
158
|
+
|
|
159
|
+
expect(run()).toMatchObject({ total: 0, files: [], outside: 2 });
|
|
160
|
+
expect(readFileSync(join(outside, "sub", "a.ts"), "utf8")).toBe(before);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
@@ -64,6 +64,26 @@ describe("analyzeCss — collection", () => {
|
|
|
64
64
|
expect(analysis.stats.files).toBe(1);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
it("skips a sheet reached through a symlink leaving the project, so fix never rewrites it", () => {
|
|
68
|
+
const outside = mkdtempSync(join(tmpdir(), "tempest-css-outside-"));
|
|
69
|
+
try {
|
|
70
|
+
const before = ".a {\n color: red;\n color: red;\n}\n";
|
|
71
|
+
writeFileSync(join(outside, "a.css"), before);
|
|
72
|
+
symlinkSync(outside, join(root, "src"), "dir");
|
|
73
|
+
|
|
74
|
+
const analysis = analyze(["src"]);
|
|
75
|
+
applyCssFixes({ analysis });
|
|
76
|
+
|
|
77
|
+
expect(analysis.stats.files).toBe(0);
|
|
78
|
+
expect(analysis.skipped[0]).toMatchObject({
|
|
79
|
+
reason: "resolves outside the project through a symlink",
|
|
80
|
+
});
|
|
81
|
+
expect(readFileSync(join(outside, "a.css"), "utf8")).toBe(before);
|
|
82
|
+
} finally {
|
|
83
|
+
rmSync(outside, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
67
87
|
it("sorts findings error → warn → info", () => {
|
|
68
88
|
write("src/a.css", ".a {\n color: ;\n}\n.b {\n color: red;\n color: red;\n}\n");
|
|
69
89
|
const severities = analyze().findings.map((f) => f.severity);
|
package/bin/lib/css/collect.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
|
|
7
|
+
import { landsInside } from "../fs/contain.mjs";
|
|
8
|
+
|
|
7
9
|
const SKIP_DIRS = new Set([
|
|
8
10
|
"node_modules",
|
|
9
11
|
"dist",
|
|
@@ -19,6 +21,9 @@ const SKIP_DIRS = new Set([
|
|
|
19
21
|
".cache",
|
|
20
22
|
]);
|
|
21
23
|
|
|
24
|
+
/** `skipped` reason for a sheet that resolves outside the project. */
|
|
25
|
+
export const OUTSIDE_PROJECT = "resolves outside the project through a symlink";
|
|
26
|
+
|
|
22
27
|
/** Above this size a stylesheet is treated as generated, not authored. */
|
|
23
28
|
const MAX_BYTES = 512 * 1024;
|
|
24
29
|
|
|
@@ -35,6 +40,11 @@ export function looksMinified(text) {
|
|
|
35
40
|
/**
|
|
36
41
|
* Collect the project's own `.css` files.
|
|
37
42
|
*
|
|
43
|
+
* A sheet that resolves outside the project once symlinks are followed is
|
|
44
|
+
* skipped with a reason: an explicit target that is a link (`tempest fix src`
|
|
45
|
+
* with `src -> /elsewhere`) is walked through it, and `fix` would rewrite sheets
|
|
46
|
+
* that are not the project's.
|
|
47
|
+
*
|
|
38
48
|
* @param {object} params
|
|
39
49
|
* @param {string} params.root - Project root; relative targets resolve against it.
|
|
40
50
|
* @param {string[]} [params.targets] - Positional paths from the command line.
|
|
@@ -62,6 +72,10 @@ export function collectStylesheets({ root, targets = ["."], maxFiles = MAX_FILES
|
|
|
62
72
|
skipped.push({ path, reason: "larger than 512 KB — treated as generated" });
|
|
63
73
|
return;
|
|
64
74
|
}
|
|
75
|
+
if (!landsInside(root, path)) {
|
|
76
|
+
skipped.push({ path, reason: OUTSIDE_PROJECT });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
65
79
|
if (/\.min\.css$/.test(path)) {
|
|
66
80
|
skipped.push({ path, reason: "minified" });
|
|
67
81
|
return;
|
package/bin/lib/css/extract.mjs
CHANGED
|
@@ -18,13 +18,17 @@
|
|
|
18
18
|
// dead and take the remaining styles down with it;
|
|
19
19
|
// - the class is only ever read as `styles.row` / `styles["row"]`, never
|
|
20
20
|
// through a computed key or by handing the whole object around;
|
|
21
|
-
// -
|
|
22
|
-
//
|
|
21
|
+
// - no declaration carries a line terminator, so the appended rule is the
|
|
22
|
+
// same lines it was in the module and cannot open a new line of its own;
|
|
23
|
+
// - the target global sheet is a regular file (not a symlink) whose realpath is
|
|
24
|
+
// inside the project, AND some source file imports it, because appending to a
|
|
25
|
+
// stylesheet nobody loads is a silent no-op;
|
|
23
26
|
// - the new global name collides with nothing already in that sheet.
|
|
24
|
-
import {
|
|
27
|
+
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
25
28
|
import { join, relative } from "node:path";
|
|
26
29
|
|
|
27
30
|
import { discoverAlias } from "../alias/discover.mjs";
|
|
31
|
+
import { landsInside, lstatOrNull } from "../fs/contain.mjs";
|
|
28
32
|
import { loadTypeScript, typeScriptUnavailableReason } from "../alias/typescript.mjs";
|
|
29
33
|
import { collectSources, indexClassUses, resolveSpecifier } from "./references.mjs";
|
|
30
34
|
import { parseCss } from "./parse.mjs";
|
|
@@ -33,6 +37,9 @@ import { declSignature } from "./semantic.mjs";
|
|
|
33
37
|
/** A repeated block is only worth extracting from this many declarations up. */
|
|
34
38
|
const MIN_DECLS = 3;
|
|
35
39
|
|
|
40
|
+
/** CSS newlines (`\n`, `\r`, `\f`) plus the two Unicode line separators. */
|
|
41
|
+
const LINE_TERMINATOR = /[\n\r\f\u2028\u2029]/;
|
|
42
|
+
|
|
36
43
|
/** Stylesheets treated as "the project's global sheet", in order of preference. */
|
|
37
44
|
const TARGET_CANDIDATES = [
|
|
38
45
|
"src/styles/globals.css",
|
|
@@ -82,7 +89,7 @@ export function classNamesIn(parsed) {
|
|
|
82
89
|
*/
|
|
83
90
|
export function findGlobalSheet({ root, explicit, alias }) {
|
|
84
91
|
const candidates = explicit ? [explicit] : TARGET_CANDIDATES;
|
|
85
|
-
const found = candidates.map((rel) => join(root, rel)).find(
|
|
92
|
+
const found = candidates.map((rel) => join(root, rel)).find((path) => lstatOrNull(path));
|
|
86
93
|
if (!found) {
|
|
87
94
|
return {
|
|
88
95
|
error: explicit
|
|
@@ -95,6 +102,9 @@ export function findGlobalSheet({ root, explicit, alias }) {
|
|
|
95
102
|
error: `${relative(root, found)} é um CSS Module — a folha alvo tem que ser global`,
|
|
96
103
|
};
|
|
97
104
|
}
|
|
105
|
+
const unsafe = unsafeTargetReason(root, found);
|
|
106
|
+
if (unsafe) return { error: `${relative(root, found)} ${unsafe} — recusado` };
|
|
107
|
+
const realFound = realpathSync(found);
|
|
98
108
|
|
|
99
109
|
const imported = collectSources({ root }).some((file) => {
|
|
100
110
|
let text;
|
|
@@ -106,7 +116,7 @@ export function findGlobalSheet({ root, explicit, alias }) {
|
|
|
106
116
|
if (!text.includes(".css")) return false;
|
|
107
117
|
for (const match of text.matchAll(/["']([^"']+\.css)["']/g)) {
|
|
108
118
|
const target = resolveSpecifier({ specifier: match[1], fromFile: file, alias });
|
|
109
|
-
if (target ===
|
|
119
|
+
if (target !== null && realpathOrNull(target) === realFound) return true;
|
|
110
120
|
}
|
|
111
121
|
return false;
|
|
112
122
|
});
|
|
@@ -118,6 +128,47 @@ export function findGlobalSheet({ root, explicit, alias }) {
|
|
|
118
128
|
return { path: found, file: relative(root, found) };
|
|
119
129
|
}
|
|
120
130
|
|
|
131
|
+
/**
|
|
132
|
+
* `realpath` that answers `null` for a missing or dangling path.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} path - Absolute path.
|
|
135
|
+
* @returns {string | null}
|
|
136
|
+
*/
|
|
137
|
+
function realpathOrNull(path) {
|
|
138
|
+
try {
|
|
139
|
+
return realpathSync(path);
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Why the candidate global sheet must not be appended to, or `null`.
|
|
147
|
+
*
|
|
148
|
+
* `writeFileSync` follows symlinks, so a `src/index.css` that is a link — or
|
|
149
|
+
* that sits under a linked folder pointing out of the project — carries the
|
|
150
|
+
* append to wherever it points: measured before this check, a link to a shell rc
|
|
151
|
+
* file got the extracted rule appended, and a custom property value with a
|
|
152
|
+
* newline in it became a line the shell ran.
|
|
153
|
+
*
|
|
154
|
+
* The rule matches the generators': the sheet itself must be a regular file
|
|
155
|
+
* (`lstat`, so a link — dangling included — is refused), and its `realpath` must
|
|
156
|
+
* land inside `realpath(root)`. A symlinked folder that stays inside the project
|
|
157
|
+
* (a monorepo's `src -> app/src`) is accepted — the write lands in the project
|
|
158
|
+
* either way, and refusing it would switch the pass off for a legitimate layout.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} root - Project root.
|
|
161
|
+
* @param {string} path - Absolute candidate path, which `lstat` sees.
|
|
162
|
+
* @returns {string | null} A phrase completing "`<file>` …", or `null`.
|
|
163
|
+
*/
|
|
164
|
+
function unsafeTargetReason(root, path) {
|
|
165
|
+
const stat = lstatOrNull(path);
|
|
166
|
+
if (stat?.isSymbolicLink()) return "é um symlink, e a escrita seguiria o link";
|
|
167
|
+
if (!stat?.isFile()) return "não é um arquivo comum";
|
|
168
|
+
if (!landsInside(root, path)) return "fica fora do projeto depois de resolver symlinks";
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
121
172
|
/** Group the analysed sheets by identical declaration block. */
|
|
122
173
|
function groupBySignature(sheets) {
|
|
123
174
|
const groups = new Map();
|
|
@@ -145,6 +196,13 @@ function occurrenceRefusal({ sheet, block, className, uses, opaqueModules }) {
|
|
|
145
196
|
}
|
|
146
197
|
if (!className) return `\`${block.prelude}\` não é uma classe simples`;
|
|
147
198
|
|
|
199
|
+
const broken = block.decls.find(
|
|
200
|
+
(d) => LINE_TERMINATOR.test(d.prop) || LINE_TERMINATOR.test(d.value),
|
|
201
|
+
);
|
|
202
|
+
if (broken) {
|
|
203
|
+
return `a declaração \`${broken.prop.split(LINE_TERMINATOR)[0]}\` tem quebra de linha — copiada pra folha global, viraria uma linha nova fora da regra`;
|
|
204
|
+
}
|
|
205
|
+
|
|
148
206
|
const mentions = sheet.parsed.blocks.filter(
|
|
149
207
|
(other) =>
|
|
150
208
|
other !== block && new RegExp(`\\.${className}(?![A-Za-z0-9_-])`).test(other.prelude),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
6
|
|
|
7
7
|
import { analyzeCss } from "./analyze.mjs";
|
|
@@ -295,6 +295,20 @@ describe("planExtraction — refusals", () => {
|
|
|
295
295
|
expect(result.message).toContain("é um CSS Module");
|
|
296
296
|
});
|
|
297
297
|
|
|
298
|
+
it("refuses a declaration with a line break instead of copying it into the global sheet", () => {
|
|
299
|
+
twoCopies();
|
|
300
|
+
const broken =
|
|
301
|
+
".row {\n --payload: x\necho INJECTED\n;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.title {\n font-weight: 600;\n}\n";
|
|
302
|
+
write("src/components/Card.module.css", broken);
|
|
303
|
+
write("src/components/List.module.css", broken.replace(".row", ".line"));
|
|
304
|
+
const result = plan();
|
|
305
|
+
expect(result.groups).toEqual([]);
|
|
306
|
+
expect(result.refusals.map((r) => r.reason)).toEqual([
|
|
307
|
+
expect.stringContaining("tem quebra de linha"),
|
|
308
|
+
expect.stringContaining("tem quebra de linha"),
|
|
309
|
+
]);
|
|
310
|
+
});
|
|
311
|
+
|
|
298
312
|
it("reports a missing TypeScript instead of guessing at the call sites", () => {
|
|
299
313
|
twoCopies();
|
|
300
314
|
rmSync(join(root, "node_modules", "typescript"), { recursive: true, force: true });
|
|
@@ -302,3 +316,98 @@ describe("planExtraction — refusals", () => {
|
|
|
302
316
|
expect(result.status).toBe("no-typescript");
|
|
303
317
|
});
|
|
304
318
|
});
|
|
319
|
+
|
|
320
|
+
describe("planExtraction — the global sheet stays inside the project (#483)", () => {
|
|
321
|
+
let outside;
|
|
322
|
+
|
|
323
|
+
beforeEach(() => {
|
|
324
|
+
project();
|
|
325
|
+
twoCopies();
|
|
326
|
+
outside = mkdtempSync(join(tmpdir(), "tempest-extract-outside-"));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
afterEach(() => {
|
|
330
|
+
rmSync(outside, { recursive: true, force: true });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/** Plan and apply, the way `tempest fix --extract-css` does. */
|
|
334
|
+
const run = () => {
|
|
335
|
+
const result = plan();
|
|
336
|
+
applyExtraction({ plan: result });
|
|
337
|
+
return result;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
it("refuses a src/index.css that is a symlink to a file outside the project", () => {
|
|
341
|
+
const victim = join(outside, "victimrc");
|
|
342
|
+
writeFileSync(victim, "echo ORIGINAL\n");
|
|
343
|
+
rmSync(join(root, "src/index.css"));
|
|
344
|
+
symlinkSync(victim, join(root, "src/index.css"));
|
|
345
|
+
|
|
346
|
+
const result = run();
|
|
347
|
+
|
|
348
|
+
expect(result.status).toBe("no-target");
|
|
349
|
+
expect(result.message).toContain("é um symlink");
|
|
350
|
+
expect(readFileSync(victim, "utf8")).toBe("echo ORIGINAL\n");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("refuses a dangling src/index.css symlink instead of falling through to another sheet", () => {
|
|
354
|
+
rmSync(join(root, "src/index.css"));
|
|
355
|
+
symlinkSync(join(outside, "not-yet.css"), join(root, "src/index.css"));
|
|
356
|
+
write("src/app.css", ":root {}\n");
|
|
357
|
+
write("src/main.tsx", 'import "./index.css";\nimport "./app.css";\n');
|
|
358
|
+
|
|
359
|
+
const result = run();
|
|
360
|
+
|
|
361
|
+
expect(result.status).toBe("no-target");
|
|
362
|
+
expect(result.message).toContain("src/index.css é um symlink");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("refuses a global sheet under a folder that is a symlink leaving the project", () => {
|
|
366
|
+
writeFileSync(join(outside, "globals.css"), ":root {}\n");
|
|
367
|
+
symlinkSync(outside, join(root, "src/styles"));
|
|
368
|
+
write("src/main.tsx", 'import "./styles/globals.css";\n');
|
|
369
|
+
|
|
370
|
+
const result = run();
|
|
371
|
+
|
|
372
|
+
expect(result.status).toBe("no-target");
|
|
373
|
+
expect(result.message).toContain("fica fora do projeto");
|
|
374
|
+
expect(readFileSync(join(outside, "globals.css"), "utf8")).toBe(":root {}\n");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Reversed on purpose. The first version of #483 refused any symlink on the
|
|
379
|
+
* path, so a monorepo layout like `src/styles -> theme` switched the pass off
|
|
380
|
+
* with a `!` and exit 0. The write lands inside the project either way; what
|
|
381
|
+
* is refused is a link on the sheet itself and a realpath that leaves the
|
|
382
|
+
* project — the same rule as `gen api`/`gen component`.
|
|
383
|
+
*/
|
|
384
|
+
it("accepts a symlinked folder that stays inside the project, as the generators do", () => {
|
|
385
|
+
write("theme/globals.css", ":root {}\n");
|
|
386
|
+
symlinkSync(join(root, "theme"), join(root, "src/styles"));
|
|
387
|
+
write("src/main.tsx", 'import "./styles/globals.css";\n');
|
|
388
|
+
|
|
389
|
+
const result = run();
|
|
390
|
+
|
|
391
|
+
expect(result.status).toBe("ok");
|
|
392
|
+
expect(result.groups).toHaveLength(1);
|
|
393
|
+
expect(readFileSync(join(root, "theme/globals.css"), "utf8")).toContain(".u-row {");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("refuses a --css-target outside the project", () => {
|
|
397
|
+
writeFileSync(join(outside, "x.css"), "");
|
|
398
|
+
const result = plan({ target: relative(root, join(outside, "x.css")) });
|
|
399
|
+
expect(result.status).toBe("no-target");
|
|
400
|
+
expect(result.message).toContain("fora do projeto");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("matches importers by realpath, so a project under a symlinked path still works", () => {
|
|
404
|
+
const linked = join(outside, "linked-project");
|
|
405
|
+
symlinkSync(root, linked);
|
|
406
|
+
const result = planExtraction({
|
|
407
|
+
analysis: analyzeCss({ root: linked, targets: ["."], selfDir: SELF_DIR }),
|
|
408
|
+
root: linked,
|
|
409
|
+
});
|
|
410
|
+
expect(result.status).toBe("ok");
|
|
411
|
+
expect(result.groups).toHaveLength(1);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
@@ -188,3 +188,53 @@ describe("tempest fix --extract-css", () => {
|
|
|
188
188
|
expect(readFileSync(join(root, "src/index.css"), "utf8")).not.toContain(".u-row");
|
|
189
189
|
});
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
describe("tempest fix — files outside the project are skipped out loud", () => {
|
|
193
|
+
let outside;
|
|
194
|
+
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
outside = mkdtempSync(join(tmpdir(), "tempest-fix-outside-"));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
rmSync(outside, { recursive: true, force: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("the CSS pass prints how many sheets it skipped", () => {
|
|
204
|
+
const before = ".a {\n color: red;\n color: red;\n}\n";
|
|
205
|
+
writeFileSync(join(outside, "a.css"), before);
|
|
206
|
+
symlinkSync(outside, join(root, "src"), "dir");
|
|
207
|
+
|
|
208
|
+
const { out } = fix(["src"]);
|
|
209
|
+
|
|
210
|
+
expect(out).toContain("! 1 arquivo(s) fora do projeto (symlink) — pulados");
|
|
211
|
+
expect(readFileSync(join(outside, "a.css"), "utf8")).toBe(before);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("the alias pass prints how many files it skipped", () => {
|
|
215
|
+
const typescript = dirname(
|
|
216
|
+
createRequire(import.meta.url).resolve("typescript/package.json"),
|
|
217
|
+
);
|
|
218
|
+
mkdirSync(join(root, "node_modules"));
|
|
219
|
+
symlinkSync(typescript, join(root, "node_modules", "typescript"), "dir");
|
|
220
|
+
write(
|
|
221
|
+
"tsconfig.json",
|
|
222
|
+
JSON.stringify({ compilerOptions: { paths: { "@/*": ["./src/*"] } } }),
|
|
223
|
+
);
|
|
224
|
+
const before = 'import { b } from "../b";\nexport const a = b;\n';
|
|
225
|
+
mkdirSync(join(outside, "sub"));
|
|
226
|
+
writeFileSync(join(outside, "b.ts"), "export const b = 1;\n");
|
|
227
|
+
writeFileSync(join(outside, "sub", "a.ts"), before);
|
|
228
|
+
symlinkSync(outside, join(root, "src"), "dir");
|
|
229
|
+
|
|
230
|
+
const result = spawnSync(process.execPath, [CLI, "fix", "--no-css"], {
|
|
231
|
+
cwd: root,
|
|
232
|
+
encoding: "utf8",
|
|
233
|
+
env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
|
|
234
|
+
});
|
|
235
|
+
const out = `${result.stdout}${result.stderr}`;
|
|
236
|
+
|
|
237
|
+
expect(out).toContain("! 2 arquivo(s) fora do projeto (symlink) — pulados");
|
|
238
|
+
expect(readFileSync(join(outside, "sub", "a.ts"), "utf8")).toBe(before);
|
|
239
|
+
});
|
|
240
|
+
});
|
package/bin/lib/css/index.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// Public surface of the CSS analysis used by `tempest doctor` and `tempest fix`.
|
|
2
2
|
export { analyzeCss, applyCssFixes } from "./analyze.mjs";
|
|
3
|
-
export {
|
|
3
|
+
export {
|
|
4
|
+
collectStylesheets,
|
|
5
|
+
customPropertiesInSources,
|
|
6
|
+
looksMinified,
|
|
7
|
+
OUTSIDE_PROJECT,
|
|
8
|
+
} from "./collect.mjs";
|
|
4
9
|
export { countBySeverity, finding, SEVERITY, sortFindings } from "./findings.mjs";
|
|
5
10
|
export { fixCss } from "./fix.mjs";
|
|
6
11
|
export {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Where a CLI write really lands. Every pass that writes into the user's project
|
|
2
|
+
// (`gen *`, `fix`'s alias and CSS passes) asks these before touching a path, so a
|
|
3
|
+
// symlink planted in a cloned repository cannot carry a write out of it.
|
|
4
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
5
|
+
import { dirname, isAbsolute, relative, sep } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `lstat` that answers `null` for a missing path instead of throwing.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} path - Absolute path.
|
|
11
|
+
* @returns {import("node:fs").Stats | null}
|
|
12
|
+
*/
|
|
13
|
+
export function lstatOrNull(path) {
|
|
14
|
+
try {
|
|
15
|
+
return lstatSync(path);
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether `path` lands inside `root` once symlinks are resolved.
|
|
23
|
+
*
|
|
24
|
+
* Resolves the nearest ancestor that exists through `realpath`, so `../x`, an
|
|
25
|
+
* absolute path and an ancestor that is a symlink to somewhere else
|
|
26
|
+
* (`src/components -> /elsewhere`) are all seen where they really land. The walk
|
|
27
|
+
* stops at the nearest ancestor `lstat` sees, so a dangling symlink on the way is
|
|
28
|
+
* not skipped over: its `realpath` throws and the path counts as outside, since
|
|
29
|
+
* `mkdir` or `writeFile` would create whatever the link names.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} root - Project root.
|
|
32
|
+
* @param {string} path - Absolute path, existing or about to be created.
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function landsInside(root, path) {
|
|
36
|
+
let existing = path;
|
|
37
|
+
while (lstatOrNull(existing) === null && dirname(existing) !== existing) {
|
|
38
|
+
existing = dirname(existing);
|
|
39
|
+
}
|
|
40
|
+
let real;
|
|
41
|
+
let realRoot;
|
|
42
|
+
try {
|
|
43
|
+
real = realpathSync(existing);
|
|
44
|
+
realRoot = realpathSync(root);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const rel = relative(realRoot, real);
|
|
49
|
+
return !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
|
|
50
|
+
}
|
|
@@ -14,19 +14,44 @@ const NAME_PATTERNS = [
|
|
|
14
14
|
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
|
15
15
|
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage"]);
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The two catalogue modules `loadIconTables` reads, relative to `dist/`.
|
|
19
|
+
*
|
|
20
|
+
* Both are pure data (`export { e as iconNames }`, `export { e as iconAliases }`)
|
|
21
|
+
* with no import of their own, which `scripts/check-dist-guards.mjs` asserts
|
|
22
|
+
* after every build.
|
|
23
|
+
*/
|
|
24
|
+
export const ICON_TABLE_MODULES = {
|
|
25
|
+
names: "icons/generated/icon-names.js",
|
|
26
|
+
aliases: "icons/generated/aliases.js",
|
|
27
|
+
};
|
|
28
|
+
|
|
17
29
|
/**
|
|
18
30
|
* Read the slug tables out of the installed SDK build.
|
|
19
31
|
*
|
|
20
32
|
* Deliberately reads `dist/` rather than embedding a copy: the CLI must agree with
|
|
21
33
|
* the `<Icon>` the app actually renders, and both come from the same install.
|
|
22
34
|
*
|
|
35
|
+
* It imports the two catalogue modules directly instead of the `dist/icons.js`
|
|
36
|
+
* barrel. Since 0.63.0 every component module imports its own stylesheet, and the
|
|
37
|
+
* barrel reaches `<IconPicker>` and its `.css`: in plain Node — which is how the
|
|
38
|
+
* `tempest` bin runs — that import died with `ERR_UNKNOWN_FILE_EXTENSION ".css"`
|
|
39
|
+
* and `gen icons` failed in every installed app (#485). Registering the
|
|
40
|
+
* `/node-css-loader` hooks would also have worked, but it changes module loading
|
|
41
|
+
* for the whole CLI process to read two arrays, and the barrel would still pull
|
|
42
|
+
* React and the component graph just to list slugs; the data modules need
|
|
43
|
+
* neither.
|
|
44
|
+
*
|
|
23
45
|
* @param {string} selfDir - Directory holding this CLI (`bin/`).
|
|
24
46
|
* @returns {Promise<{ known: Set<string>, aliases: Record<string, string> }>}
|
|
25
47
|
*/
|
|
26
48
|
export async function loadIconTables(selfDir) {
|
|
27
|
-
const dist = join(selfDir, "..", "dist"
|
|
28
|
-
const
|
|
29
|
-
|
|
49
|
+
const dist = join(selfDir, "..", "dist");
|
|
50
|
+
const [names, aliases] = await Promise.all([
|
|
51
|
+
import(pathToFileURL(join(dist, ICON_TABLE_MODULES.names)).href),
|
|
52
|
+
import(pathToFileURL(join(dist, ICON_TABLE_MODULES.aliases)).href),
|
|
53
|
+
]);
|
|
54
|
+
return { known: new Set(names.iconNames), aliases: aliases.iconAliases };
|
|
30
55
|
}
|
|
31
56
|
|
|
32
57
|
/**
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
7
|
|
|
6
8
|
import { iconAliases, iconNames } from "@/icons";
|
|
@@ -123,3 +125,57 @@ describe("renderRegistry — reserved names", () => {
|
|
|
123
125
|
expect(out).toContain(`"infinity": InfinityIcon,`);
|
|
124
126
|
});
|
|
125
127
|
});
|
|
128
|
+
|
|
129
|
+
describe("loadIconTables in plain Node (#485)", () => {
|
|
130
|
+
let pkg;
|
|
131
|
+
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
pkg = mkdtempSync(join(tmpdir(), "tempest-icon-tables-"));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
rmSync(pkg, { recursive: true, force: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Lay out a package the way `npm pack` ships it: a `dist/icons.js` barrel that
|
|
142
|
+
* imports a stylesheet, as every component module has since 0.63.0, next to the
|
|
143
|
+
* two pure data modules. Vitest would load the `.css` on its own, so the call
|
|
144
|
+
* runs in a child `node` with no loader — exactly how the `tempest` bin runs.
|
|
145
|
+
*/
|
|
146
|
+
function loadInPlainNode() {
|
|
147
|
+
const dist = join(pkg, "dist");
|
|
148
|
+
mkdirSync(join(dist, "icons", "generated"), { recursive: true });
|
|
149
|
+
mkdirSync(join(pkg, "bin"));
|
|
150
|
+
writeFileSync(join(dist, "icons", "IconPicker.css"), ".a{color:red}");
|
|
151
|
+
writeFileSync(
|
|
152
|
+
join(dist, "icons.js"),
|
|
153
|
+
'import "./icons/IconPicker.css";\nexport const iconNames = [];\nexport const iconAliases = {};\n',
|
|
154
|
+
);
|
|
155
|
+
writeFileSync(
|
|
156
|
+
join(dist, "icons", "generated", "icon-names.js"),
|
|
157
|
+
'var e = ["check", "house"];\nexport { e as iconNames };\n',
|
|
158
|
+
);
|
|
159
|
+
writeFileSync(
|
|
160
|
+
join(dist, "icons", "generated", "aliases.js"),
|
|
161
|
+
'var e = { home: "house" };\nexport { e as iconAliases };\n',
|
|
162
|
+
);
|
|
163
|
+
const generator = join(dirname(fileURLToPath(import.meta.url)), "generate.mjs");
|
|
164
|
+
const script = `const { loadIconTables } = await import(${JSON.stringify(generator)});
|
|
165
|
+
const t = await loadIconTables(${JSON.stringify(join(pkg, "bin"))});
|
|
166
|
+
console.log(JSON.stringify({ known: [...t.known], aliases: t.aliases }));`;
|
|
167
|
+
return spawnSync(process.execPath, ["--input-type=module", "-e", script], {
|
|
168
|
+
encoding: "utf8",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it("reads the tables without importing a stylesheet", () => {
|
|
173
|
+
const result = loadInPlainNode();
|
|
174
|
+
expect(result.stderr).not.toMatch(/ERR_UNKNOWN_FILE_EXTENSION/);
|
|
175
|
+
expect(result.status).toBe(0);
|
|
176
|
+
expect(JSON.parse(result.stdout)).toEqual({
|
|
177
|
+
known: ["check", "house"],
|
|
178
|
+
aliases: { home: "house" },
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|