tempest-react-sdk 0.76.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/README.md +2 -2
- package/bin/create-tempest-app.e2e.test.mjs +112 -0
- package/bin/create-tempest-app.mjs +69 -28
- 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/component/generate.mjs +191 -0
- package/bin/lib/component/generate.test.mjs +336 -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 +224 -0
- package/bin/lib/openapi/generate.encode.test.mjs +120 -0
- package/bin/lib/openapi/generate.hostile.test.mjs +621 -0
- package/bin/lib/openapi/generate.mjs +129 -18
- package/bin/lib/openapi/generate.test.mjs +7 -2
- package/bin/lib/openapi/schema-to-zod.mjs +43 -4
- package/bin/tempest-gen.test.mjs +188 -0
- package/bin/tempest.mjs +263 -6
- 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 +203 -58
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/auth/refresh-queue.cjs +1 -1
- package/dist/auth/refresh-queue.cjs.map +1 -1
- package/dist/auth/refresh-queue.js +13 -6
- package/dist/auth/refresh-queue.js.map +1 -1
- package/dist/br/pix.cjs +1 -1
- package/dist/br/pix.cjs.map +1 -1
- package/dist/br/pix.js +85 -72
- package/dist/br/pix.js.map +1 -1
- package/dist/br.d.ts +19 -4
- package/dist/components/Chat/ChatAttachments.cjs +1 -1
- package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
- package/dist/components/Chat/ChatAttachments.js +46 -38
- package/dist/components/Chat/ChatAttachments.js.map +1 -1
- 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 +7 -5
- package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
- package/dist/components/Markdown/markdown-parse.js +171 -90
- package/dist/components/Markdown/markdown-parse.js.map +1 -1
- package/dist/components/Toast/Toast.module.cjs +1 -1
- package/dist/components/Toast/Toast.module.cjs.map +1 -1
- package/dist/components/Toast/Toast.module.js +7 -3
- package/dist/components/Toast/Toast.module.js.map +1 -1
- package/dist/components/Toast/ToastProvider.cjs +1 -1
- package/dist/components/Toast/ToastProvider.cjs.map +1 -1
- package/dist/components/Toast/ToastProvider.js +77 -51
- package/dist/components/Toast/ToastProvider.js.map +1 -1
- package/dist/data/create-data-provider.cjs +1 -1
- package/dist/data/create-data-provider.cjs.map +1 -1
- package/dist/data/create-data-provider.js +32 -21
- package/dist/data/create-data-provider.js.map +1 -1
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +81 -73
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/errors.cjs +1 -1
- package/dist/http/errors.cjs.map +1 -1
- package/dist/http/errors.js +39 -35
- package/dist/http/errors.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/oauth/callback.cjs +2 -0
- package/dist/oauth/callback.cjs.map +1 -0
- package/dist/oauth/callback.js +42 -0
- package/dist/oauth/callback.js.map +1 -0
- package/dist/oauth/use-oauth-callback.cjs +1 -1
- package/dist/oauth/use-oauth-callback.cjs.map +1 -1
- package/dist/oauth/use-oauth-callback.js +13 -20
- package/dist/oauth/use-oauth-callback.js.map +1 -1
- package/dist/push/use-push-subscription.cjs +1 -1
- package/dist/push/use-push-subscription.cjs.map +1 -1
- package/dist/push/use-push-subscription.js +42 -42
- package/dist/push/use-push-subscription.js.map +1 -1
- package/dist/push/web-push-client.cjs +1 -1
- package/dist/push/web-push-client.cjs.map +1 -1
- package/dist/push/web-push-client.js +23 -3
- package/dist/push/web-push-client.js.map +1 -1
- package/dist/query/offline-persistence.cjs +1 -1
- package/dist/query/offline-persistence.cjs.map +1 -1
- package/dist/query/offline-persistence.js +68 -23
- package/dist/query/offline-persistence.js.map +1 -1
- package/dist/styles/component/Toast.css +6 -1
- package/dist/styles/feedback.css +6 -1
- package/dist/styles.css +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 +272 -60
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache-inspect.cjs.map +1 -1
- package/dist/sw/cache-inspect.js.map +1 -1
- package/dist/sw/cache.cjs +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js +91 -64
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw/credential-headers.cjs +2 -0
- package/dist/sw/credential-headers.cjs.map +1 -0
- package/dist/sw/credential-headers.js +17 -0
- package/dist/sw/credential-headers.js.map +1 -0
- package/dist/sw/session-data.cjs +2 -0
- package/dist/sw/session-data.cjs.map +1 -0
- package/dist/sw/session-data.js +17 -0
- package/dist/sw/session-data.js.map +1 -0
- package/dist/sw.cjs +1 -1
- package/dist/sw.d.ts +309 -23
- package/dist/sw.js +7 -5
- package/dist/telemetry/app-error-queue.cjs +1 -1
- package/dist/telemetry/app-error-queue.cjs.map +1 -1
- package/dist/telemetry/app-error-queue.js +19 -14
- package/dist/telemetry/app-error-queue.js.map +1 -1
- 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 +75 -34
- package/dist/telemetry/app-error-reporter.js.map +1 -1
- package/dist/telemetry/app-error-transport.cjs +1 -1
- package/dist/telemetry/app-error-transport.cjs.map +1 -1
- package/dist/telemetry/app-error-transport.js +16 -3
- package/dist/telemetry/app-error-transport.js.map +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +1183 -64
- package/dist/tempest-react-sdk.js +160 -156
- package/dist/utils/csv.cjs +3 -3
- package/dist/utils/csv.cjs.map +1 -1
- package/dist/utils/csv.js +18 -14
- package/dist/utils/csv.js.map +1 -1
- package/dist/utils/owner-fingerprint.cjs +2 -0
- package/dist/utils/owner-fingerprint.cjs.map +1 -0
- package/dist/utils/owner-fingerprint.js +18 -0
- package/dist/utils/owner-fingerprint.js.map +1 -0
- package/dist/utils/safe-url.cjs +2 -0
- package/dist/utils/safe-url.cjs.map +1 -0
- package/dist/utils/safe-url.js +39 -0
- package/dist/utils/safe-url.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 +130 -64
- package/dist/vite/tempest-csp.js.map +1 -1
- package/dist/vite.d.ts +23 -5
- package/dist/webrtc/peer-link.cjs +1 -1
- package/dist/webrtc/peer-link.cjs.map +1 -1
- package/dist/webrtc/peer-link.js +2 -10
- package/dist/webrtc/peer-link.js.map +1 -1
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +110 -72
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/package.json +1 -1
- package/template-pwa/src/pwa.tsx +22 -0
- package/template-pwa/src/sw.ts +27 -14
- package/dist/components/Markdown/markdown-url.cjs +0 -2
- package/dist/components/Markdown/markdown-url.cjs.map +0 -1
- package/dist/components/Markdown/markdown-url.js +0 -28
- package/dist/components/Markdown/markdown-url.js.map +0 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-aware escaping for text copied from an OpenAPI document into generated
|
|
3
|
+
* TypeScript.
|
|
4
|
+
*
|
|
5
|
+
* The spec is input the developer does not control — a partner API, a shared
|
|
6
|
+
* backend, a document fetched over the network — and the generated files are
|
|
7
|
+
* imported by the app. Every spec string therefore reaches the output through one
|
|
8
|
+
* of these functions, chosen by the syntactic position it lands in:
|
|
9
|
+
*
|
|
10
|
+
* - identifier position (schema names, path parameters) → {@link toIdentifier},
|
|
11
|
+
* which maps a name that is not a TypeScript identifier (`user-id`,
|
|
12
|
+
* `user.Response`) to one that is. The mapped name only ever appears in
|
|
13
|
+
* identifier position; wherever the name matters at runtime — the URL
|
|
14
|
+
* placeholder, an object key — the spec's original is used, escaped. Two
|
|
15
|
+
* names that map to the same identifier are refused by
|
|
16
|
+
* {@link assertNoCollision}, never silently merged;
|
|
17
|
+
* - string position → `JSON.stringify` at the call site;
|
|
18
|
+
* - doc comment → {@link commentText};
|
|
19
|
+
* - URL template literal → {@link urlTemplate}.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Words that parse as an identifier but cannot name a type alias, a `const` or a
|
|
26
|
+
* parameter in a generated module. Ported from the ECMAScript reserved words,
|
|
27
|
+
* `eval` and `arguments` (which strict mode — every class body — forbids as a
|
|
28
|
+
* binding), and the TypeScript predefined type names that `type X = ...`
|
|
29
|
+
* rejects.
|
|
30
|
+
*/
|
|
31
|
+
export const RESERVED_WORDS = new Set([
|
|
32
|
+
"any",
|
|
33
|
+
"arguments",
|
|
34
|
+
"await",
|
|
35
|
+
"bigint",
|
|
36
|
+
"boolean",
|
|
37
|
+
"break",
|
|
38
|
+
"case",
|
|
39
|
+
"catch",
|
|
40
|
+
"class",
|
|
41
|
+
"const",
|
|
42
|
+
"continue",
|
|
43
|
+
"debugger",
|
|
44
|
+
"default",
|
|
45
|
+
"delete",
|
|
46
|
+
"do",
|
|
47
|
+
"else",
|
|
48
|
+
"enum",
|
|
49
|
+
"eval",
|
|
50
|
+
"export",
|
|
51
|
+
"extends",
|
|
52
|
+
"false",
|
|
53
|
+
"finally",
|
|
54
|
+
"for",
|
|
55
|
+
"function",
|
|
56
|
+
"if",
|
|
57
|
+
"implements",
|
|
58
|
+
"import",
|
|
59
|
+
"in",
|
|
60
|
+
"instanceof",
|
|
61
|
+
"interface",
|
|
62
|
+
"let",
|
|
63
|
+
"never",
|
|
64
|
+
"new",
|
|
65
|
+
"null",
|
|
66
|
+
"number",
|
|
67
|
+
"object",
|
|
68
|
+
"package",
|
|
69
|
+
"private",
|
|
70
|
+
"protected",
|
|
71
|
+
"public",
|
|
72
|
+
"return",
|
|
73
|
+
"static",
|
|
74
|
+
"string",
|
|
75
|
+
"super",
|
|
76
|
+
"switch",
|
|
77
|
+
"symbol",
|
|
78
|
+
"this",
|
|
79
|
+
"throw",
|
|
80
|
+
"true",
|
|
81
|
+
"try",
|
|
82
|
+
"typeof",
|
|
83
|
+
"undefined",
|
|
84
|
+
"unknown",
|
|
85
|
+
"var",
|
|
86
|
+
"void",
|
|
87
|
+
"while",
|
|
88
|
+
"with",
|
|
89
|
+
"yield",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Map a spec name to a valid, non-reserved TypeScript identifier.
|
|
94
|
+
*
|
|
95
|
+
* A name that already is one is returned unchanged, so a spec that generated
|
|
96
|
+
* fine before keeps generating the same code. Otherwise the name is split into
|
|
97
|
+
* its alphanumeric words and joined in `camel` case (parameters: `user-id` →
|
|
98
|
+
* `userId`) or `pascal` case (schemas and types: `user.Response` →
|
|
99
|
+
* `UserResponse`). A reserved word, or a rebuilt name that starts with a digit
|
|
100
|
+
* or is reserved, gets a `_` prefix (`string` → `_string`, `2fa` → `_2fa`). Only `[A-Za-z0-9_$]`
|
|
101
|
+
* survives, so no spec text can reach the output through this path.
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} name - The spec string about to land in identifier position.
|
|
104
|
+
* @param {"camel"|"pascal"} style - Case for a name that has to be rebuilt.
|
|
105
|
+
* @param {string} what - What the name is, for the error ("schema name").
|
|
106
|
+
* @returns {string} The identifier.
|
|
107
|
+
* @throws {Error} When the name has no alphanumeric character to build one from.
|
|
108
|
+
*/
|
|
109
|
+
export function toIdentifier(name, style, what) {
|
|
110
|
+
if (typeof name === "string" && IDENTIFIER.test(name)) {
|
|
111
|
+
return RESERVED_WORDS.has(name) ? `_${name}` : name;
|
|
112
|
+
}
|
|
113
|
+
const words = typeof name === "string" ? (name.match(/[A-Za-z0-9]+/g) ?? []) : [];
|
|
114
|
+
if (words.length === 0) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`OpenAPI ${what} ${JSON.stringify(name)} has no letter or digit to build a TypeScript identifier from. Rename it in the spec.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const joined = words
|
|
120
|
+
.map((w, i) =>
|
|
121
|
+
i === 0 && style === "camel"
|
|
122
|
+
? w.charAt(0).toLowerCase() + w.slice(1)
|
|
123
|
+
: w.charAt(0).toUpperCase() + w.slice(1),
|
|
124
|
+
)
|
|
125
|
+
.join("");
|
|
126
|
+
return /^[0-9]/.test(joined) || RESERVED_WORDS.has(joined) ? `_${joined}` : joined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Throw when two entries map to the same identifier.
|
|
131
|
+
*
|
|
132
|
+
* Compared per entry, not per name: a path parameter literally called `body`
|
|
133
|
+
* maps to itself and still collides with the `body` argument, which a
|
|
134
|
+
* name-versus-mapped-name comparison would miss.
|
|
135
|
+
*
|
|
136
|
+
* @param {Iterable<[string, string]>} entries - `[label, identifier]` pairs; the
|
|
137
|
+
* label is the spec's original name, or a description of a generated name.
|
|
138
|
+
* @param {string} what - What the names are, for the error ("schema names").
|
|
139
|
+
* @throws {Error} Listing both labels and the identifier they share.
|
|
140
|
+
*/
|
|
141
|
+
export function assertNoCollision(entries, what) {
|
|
142
|
+
const seen = new Map();
|
|
143
|
+
for (const [label, id] of entries) {
|
|
144
|
+
if (seen.has(id)) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`OpenAPI ${what} ${JSON.stringify(seen.get(id))} and ${JSON.stringify(label)} both map to the TypeScript identifier "${id}". Rename one of them in the spec.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
seen.set(id, label);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Make spec text safe inside a `/** ... *\/` block comment.
|
|
155
|
+
*
|
|
156
|
+
* Line terminators collapse to a space, so the text cannot start a new line of
|
|
157
|
+
* the comment, and every `*\/` is broken as `*\\/` so it cannot close it.
|
|
158
|
+
*
|
|
159
|
+
* @param {unknown} text - The spec text (tag, summary, path).
|
|
160
|
+
* @returns {string} The neutralized single-line text.
|
|
161
|
+
*/
|
|
162
|
+
export function commentText(text) {
|
|
163
|
+
return String(text)
|
|
164
|
+
.replace(/[\r\n\u2028\u2029]+/g, " ")
|
|
165
|
+
.replace(/\*\//g, "*\\/");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Escape a static fragment for the inside of a template literal.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} text - Literal text from the path.
|
|
172
|
+
* @returns {string} The text with `\`, `` ` `` and `${` escaped.
|
|
173
|
+
*/
|
|
174
|
+
function templateText(text) {
|
|
175
|
+
return text.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build the URL expression for an operation path.
|
|
180
|
+
*
|
|
181
|
+
* Static segments are escaped for the template literal; each `{name}` placeholder
|
|
182
|
+
* is matched by the spec's original name against the declared path parameters
|
|
183
|
+
* and becomes `${encodeIdSegment(identifier)}`, the variable that parameter was
|
|
184
|
+
* mapped to passed through the SDK's segment encoder (`{user-id}` →
|
|
185
|
+
* `${encodeIdSegment(userId)}`). A path without placeholders is emitted as a
|
|
186
|
+
* JSON string literal.
|
|
187
|
+
*
|
|
188
|
+
* The encoder is what keeps a runtime value inside its own segment: interpolated
|
|
189
|
+
* raw, `"../../account"` turned `DELETE /api/comments/{id}` into
|
|
190
|
+
* `DELETE /account` carrying the Bearer, and `"7?role=admin"` injected a query
|
|
191
|
+
* string (#499 — the class #438 closed for the data provider). Encoded, `/`, `?`,
|
|
192
|
+
* `#` and `%` stay in the segment, and `null`, `undefined`, `""`, `"."` and
|
|
193
|
+
* `".."` throw before the request is built.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} path - The OpenAPI path key ("/users/{user-id}").
|
|
196
|
+
* @param {Map<string, string>} declared - Original name → argument expression of
|
|
197
|
+
* each of the operation's `in: "path"` params: the identifier, or the
|
|
198
|
+
* identifier with a type assertion when its type is wider than
|
|
199
|
+
* `encodeIdSegment` accepts.
|
|
200
|
+
* @returns {string} A TypeScript expression evaluating to the URL.
|
|
201
|
+
* @throws {Error} When a placeholder is not a declared path parameter — there is
|
|
202
|
+
* no variable it could safely map to.
|
|
203
|
+
*/
|
|
204
|
+
export function urlTemplate(path, declared) {
|
|
205
|
+
if (declared.size === 0) return JSON.stringify(path);
|
|
206
|
+
let out = "";
|
|
207
|
+
let last = 0;
|
|
208
|
+
for (const match of path.matchAll(/\{([^}]*)\}/g)) {
|
|
209
|
+
const name = match[1];
|
|
210
|
+
if (!declared.has(name)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`OpenAPI path ${JSON.stringify(path)}: placeholder ${JSON.stringify(name)} is not a declared path parameter.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
out +=
|
|
216
|
+
templateText(path.slice(last, match.index)) +
|
|
217
|
+
"${encodeIdSegment(" +
|
|
218
|
+
declared.get(name) +
|
|
219
|
+
")}";
|
|
220
|
+
last = match.index + match[0].length;
|
|
221
|
+
}
|
|
222
|
+
out += templateText(path.slice(last));
|
|
223
|
+
return "`" + out + "`";
|
|
224
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { encodeIdSegment } from "../../../src/data";
|
|
5
|
+
import { createApiClient } from "../../../src/http";
|
|
6
|
+
import { generate } from "./generate.mjs";
|
|
7
|
+
|
|
8
|
+
const BASE = "https://api.example.test";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Transpile one generated `service.ts` and load it as CommonJS, resolving
|
|
12
|
+
* `tempest-react-sdk` to the SDK's real source — so the call goes through the
|
|
13
|
+
* genuine `encodeIdSegment` and `createApiClient`, not a stub of either.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} source - The generated `service.ts` contents.
|
|
16
|
+
* @returns {Record<string, unknown>} The module's exports.
|
|
17
|
+
*/
|
|
18
|
+
function load(source) {
|
|
19
|
+
const { outputText } = ts.transpileModule(source, {
|
|
20
|
+
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
|
21
|
+
});
|
|
22
|
+
const module = { exports: {} };
|
|
23
|
+
const require = (id) => {
|
|
24
|
+
if (id === "tempest-react-sdk") return { encodeIdSegment };
|
|
25
|
+
throw new Error(`unexpected import ${id}`);
|
|
26
|
+
};
|
|
27
|
+
new Function("exports", "require", "module", outputText)(module.exports, require, module);
|
|
28
|
+
return module.exports;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generate a one-route `comments` service (`GET`/`DELETE /api/comments/{id}`)
|
|
33
|
+
* bound to a real `ApiClient` whose `fetcher` records every request.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} [schema] - The `id` path parameter's schema.
|
|
36
|
+
* @param {string} [baseURL] - The client's base URL.
|
|
37
|
+
* @returns {{ service: Record<string, Function>, seen: string[], source: string }}
|
|
38
|
+
*/
|
|
39
|
+
function commentsService(schema = { type: "string" }, baseURL = BASE) {
|
|
40
|
+
const op = (operationId) => ({
|
|
41
|
+
operationId,
|
|
42
|
+
tags: ["comments"],
|
|
43
|
+
parameters: [{ name: "id", in: "path", required: true, schema }],
|
|
44
|
+
responses: { 204: { description: "x" } },
|
|
45
|
+
});
|
|
46
|
+
const { files } = generate({
|
|
47
|
+
openapi: "3.1.0",
|
|
48
|
+
info: { title: "t", version: "1" },
|
|
49
|
+
paths: { "/api/comments/{id}": { get: op("getComment"), delete: op("deleteComment") } },
|
|
50
|
+
});
|
|
51
|
+
const source = files["comments/service.ts"];
|
|
52
|
+
const seen = [];
|
|
53
|
+
const api = createApiClient({
|
|
54
|
+
baseURL,
|
|
55
|
+
getToken: () => "DUMMY_TOKEN",
|
|
56
|
+
fetcher: async (url, init) => {
|
|
57
|
+
seen.push(`${init?.method ?? "GET"} ${String(url)}`);
|
|
58
|
+
return new Response(null, { status: 204 });
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const { CommentsService } = load(source);
|
|
62
|
+
return { service: new CommentsService(api), seen, source };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("generate — path params are encoded into one segment (#499, as #438 did for the data provider)", () => {
|
|
66
|
+
it.each([
|
|
67
|
+
["../../account", "..%2F..%2Faccount"],
|
|
68
|
+
["../x", "..%2Fx"],
|
|
69
|
+
["%2e%2e/%2e%2e/account", "%252e%252e%2F%252e%252e%2Faccount"],
|
|
70
|
+
["7?a=b", "7%3Fa%3Db"],
|
|
71
|
+
["7?role=admin", "7%3Frole%3Dadmin"],
|
|
72
|
+
["7#x", "7%23x"],
|
|
73
|
+
])("a hostile id %j stays one segment under /api/comments", async (id, segment) => {
|
|
74
|
+
const { service, seen } = commentsService();
|
|
75
|
+
await service.deleteComment(id);
|
|
76
|
+
expect(seen).toEqual([`DELETE ${BASE}/api/comments/${segment}`]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("a base URL prefix cannot be escaped with extra dot-segments", async () => {
|
|
80
|
+
const { service, seen } = commentsService({ type: "string" }, `${BASE}/v1`);
|
|
81
|
+
await service.deleteComment("../../../account");
|
|
82
|
+
expect(seen).toEqual([`DELETE ${BASE}/v1/api/comments/..%2F..%2F..%2Faccount`]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it.each(["", ".", ".."])("the dot-segment id %j throws before any request", async (id) => {
|
|
86
|
+
const { service, seen } = commentsService();
|
|
87
|
+
await expect(service.deleteComment(id)).rejects.toThrow(TypeError);
|
|
88
|
+
expect(seen).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("null and undefined from an untyped caller throw instead of becoming a literal segment", async () => {
|
|
92
|
+
const { service, seen } = commentsService();
|
|
93
|
+
await expect(service.getComment(null)).rejects.toThrow(TypeError);
|
|
94
|
+
await expect(service.getComment(undefined)).rejects.toThrow(TypeError);
|
|
95
|
+
expect(seen).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("a numeric id is still sent as its decimal segment", async () => {
|
|
99
|
+
const { service, seen, source } = commentsService({ type: "integer" });
|
|
100
|
+
expect(source).toContain("async getComment(id: number): Promise<void>");
|
|
101
|
+
await service.getComment(7);
|
|
102
|
+
expect(seen).toEqual([`GET ${BASE}/api/comments/7`]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
["a boolean", { type: "boolean" }, true, "true"],
|
|
107
|
+
["an array", { type: "array", items: { type: "string" } }, ["a", "b/c"], "a%2Cb%2Fc"],
|
|
108
|
+
["a nullable string", { type: ["string", "null"] }, "a/b", "a%2Fb"],
|
|
109
|
+
])("%s path param is encoded too", async (_kind, schema, value, segment) => {
|
|
110
|
+
const { service, seen } = commentsService(schema);
|
|
111
|
+
await service.getComment(value);
|
|
112
|
+
expect(seen).toEqual([`GET ${BASE}/api/comments/${segment}`]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("a nullable path param still rejects null at runtime", async () => {
|
|
116
|
+
const { service, seen } = commentsService({ type: ["string", "null"] });
|
|
117
|
+
await expect(service.getComment(null)).rejects.toThrow(TypeError);
|
|
118
|
+
expect(seen).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|