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.
Files changed (173) hide show
  1. package/README.md +2 -2
  2. package/bin/create-tempest-app.e2e.test.mjs +112 -0
  3. package/bin/create-tempest-app.mjs +69 -28
  4. package/bin/lib/alias/collect.mjs +20 -3
  5. package/bin/lib/alias/index.mjs +12 -1
  6. package/bin/lib/alias/index.test.mjs +24 -0
  7. package/bin/lib/component/generate.mjs +191 -0
  8. package/bin/lib/component/generate.test.mjs +336 -0
  9. package/bin/lib/css/analyze.test.mjs +21 -1
  10. package/bin/lib/css/collect.mjs +14 -0
  11. package/bin/lib/css/extract.mjs +63 -5
  12. package/bin/lib/css/extract.test.mjs +110 -1
  13. package/bin/lib/css/fix.e2e.test.mjs +50 -0
  14. package/bin/lib/css/index.mjs +6 -1
  15. package/bin/lib/fs/contain.mjs +50 -0
  16. package/bin/lib/icons/generate.mjs +28 -3
  17. package/bin/lib/icons/generate.test.mjs +57 -1
  18. package/bin/lib/openapi/escape.mjs +224 -0
  19. package/bin/lib/openapi/generate.encode.test.mjs +120 -0
  20. package/bin/lib/openapi/generate.hostile.test.mjs +621 -0
  21. package/bin/lib/openapi/generate.mjs +129 -18
  22. package/bin/lib/openapi/generate.test.mjs +7 -2
  23. package/bin/lib/openapi/schema-to-zod.mjs +43 -4
  24. package/bin/tempest-gen.test.mjs +188 -0
  25. package/bin/tempest.mjs +263 -6
  26. package/dist/auth/create-tempest-auth.cjs +1 -1
  27. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  28. package/dist/auth/create-tempest-auth.js +203 -58
  29. package/dist/auth/create-tempest-auth.js.map +1 -1
  30. package/dist/auth/refresh-queue.cjs +1 -1
  31. package/dist/auth/refresh-queue.cjs.map +1 -1
  32. package/dist/auth/refresh-queue.js +13 -6
  33. package/dist/auth/refresh-queue.js.map +1 -1
  34. package/dist/br/pix.cjs +1 -1
  35. package/dist/br/pix.cjs.map +1 -1
  36. package/dist/br/pix.js +85 -72
  37. package/dist/br/pix.js.map +1 -1
  38. package/dist/br.d.ts +19 -4
  39. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  40. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  41. package/dist/components/Chat/ChatAttachments.js +46 -38
  42. package/dist/components/Chat/ChatAttachments.js.map +1 -1
  43. package/dist/components/CodeBlock/CodeBlock.cjs +1 -1
  44. package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
  45. package/dist/components/CodeBlock/CodeBlock.js +1 -1
  46. package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
  47. package/dist/components/CodeBlock/tokenize.cjs +1 -1
  48. package/dist/components/CodeBlock/tokenize.cjs.map +1 -1
  49. package/dist/components/CodeBlock/tokenize.js +54 -25
  50. package/dist/components/CodeBlock/tokenize.js.map +1 -1
  51. package/dist/components/Markdown/markdown-parse.cjs +7 -5
  52. package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
  53. package/dist/components/Markdown/markdown-parse.js +171 -90
  54. package/dist/components/Markdown/markdown-parse.js.map +1 -1
  55. package/dist/components/Toast/Toast.module.cjs +1 -1
  56. package/dist/components/Toast/Toast.module.cjs.map +1 -1
  57. package/dist/components/Toast/Toast.module.js +7 -3
  58. package/dist/components/Toast/Toast.module.js.map +1 -1
  59. package/dist/components/Toast/ToastProvider.cjs +1 -1
  60. package/dist/components/Toast/ToastProvider.cjs.map +1 -1
  61. package/dist/components/Toast/ToastProvider.js +77 -51
  62. package/dist/components/Toast/ToastProvider.js.map +1 -1
  63. package/dist/data/create-data-provider.cjs +1 -1
  64. package/dist/data/create-data-provider.cjs.map +1 -1
  65. package/dist/data/create-data-provider.js +32 -21
  66. package/dist/data/create-data-provider.js.map +1 -1
  67. package/dist/http/api-client.cjs +1 -1
  68. package/dist/http/api-client.cjs.map +1 -1
  69. package/dist/http/api-client.js +81 -73
  70. package/dist/http/api-client.js.map +1 -1
  71. package/dist/http/errors.cjs +1 -1
  72. package/dist/http/errors.cjs.map +1 -1
  73. package/dist/http/errors.js +39 -35
  74. package/dist/http/errors.js.map +1 -1
  75. package/dist/http/resumable-upload.cjs +1 -1
  76. package/dist/http/resumable-upload.cjs.map +1 -1
  77. package/dist/http/resumable-upload.js +141 -105
  78. package/dist/http/resumable-upload.js.map +1 -1
  79. package/dist/http/retry.cjs +1 -1
  80. package/dist/http/retry.cjs.map +1 -1
  81. package/dist/http/retry.js +5 -6
  82. package/dist/http/retry.js.map +1 -1
  83. package/dist/oauth/callback.cjs +2 -0
  84. package/dist/oauth/callback.cjs.map +1 -0
  85. package/dist/oauth/callback.js +42 -0
  86. package/dist/oauth/callback.js.map +1 -0
  87. package/dist/oauth/use-oauth-callback.cjs +1 -1
  88. package/dist/oauth/use-oauth-callback.cjs.map +1 -1
  89. package/dist/oauth/use-oauth-callback.js +13 -20
  90. package/dist/oauth/use-oauth-callback.js.map +1 -1
  91. package/dist/push/use-push-subscription.cjs +1 -1
  92. package/dist/push/use-push-subscription.cjs.map +1 -1
  93. package/dist/push/use-push-subscription.js +42 -42
  94. package/dist/push/use-push-subscription.js.map +1 -1
  95. package/dist/push/web-push-client.cjs +1 -1
  96. package/dist/push/web-push-client.cjs.map +1 -1
  97. package/dist/push/web-push-client.js +23 -3
  98. package/dist/push/web-push-client.js.map +1 -1
  99. package/dist/query/offline-persistence.cjs +1 -1
  100. package/dist/query/offline-persistence.cjs.map +1 -1
  101. package/dist/query/offline-persistence.js +68 -23
  102. package/dist/query/offline-persistence.js.map +1 -1
  103. package/dist/styles/component/Toast.css +6 -1
  104. package/dist/styles/feedback.css +6 -1
  105. package/dist/styles.css +1 -1
  106. package/dist/sw/background-sync.cjs +1 -1
  107. package/dist/sw/background-sync.cjs.map +1 -1
  108. package/dist/sw/background-sync.js +272 -60
  109. package/dist/sw/background-sync.js.map +1 -1
  110. package/dist/sw/cache-inspect.cjs.map +1 -1
  111. package/dist/sw/cache-inspect.js.map +1 -1
  112. package/dist/sw/cache.cjs +1 -1
  113. package/dist/sw/cache.cjs.map +1 -1
  114. package/dist/sw/cache.js +91 -64
  115. package/dist/sw/cache.js.map +1 -1
  116. package/dist/sw/credential-headers.cjs +2 -0
  117. package/dist/sw/credential-headers.cjs.map +1 -0
  118. package/dist/sw/credential-headers.js +17 -0
  119. package/dist/sw/credential-headers.js.map +1 -0
  120. package/dist/sw/session-data.cjs +2 -0
  121. package/dist/sw/session-data.cjs.map +1 -0
  122. package/dist/sw/session-data.js +17 -0
  123. package/dist/sw/session-data.js.map +1 -0
  124. package/dist/sw.cjs +1 -1
  125. package/dist/sw.d.ts +309 -23
  126. package/dist/sw.js +7 -5
  127. package/dist/telemetry/app-error-queue.cjs +1 -1
  128. package/dist/telemetry/app-error-queue.cjs.map +1 -1
  129. package/dist/telemetry/app-error-queue.js +19 -14
  130. package/dist/telemetry/app-error-queue.js.map +1 -1
  131. package/dist/telemetry/app-error-reporter.cjs +1 -1
  132. package/dist/telemetry/app-error-reporter.cjs.map +1 -1
  133. package/dist/telemetry/app-error-reporter.js +75 -34
  134. package/dist/telemetry/app-error-reporter.js.map +1 -1
  135. package/dist/telemetry/app-error-transport.cjs +1 -1
  136. package/dist/telemetry/app-error-transport.cjs.map +1 -1
  137. package/dist/telemetry/app-error-transport.js +16 -3
  138. package/dist/telemetry/app-error-transport.js.map +1 -1
  139. package/dist/tempest-react-sdk.cjs +1 -1
  140. package/dist/tempest-react-sdk.d.ts +1183 -64
  141. package/dist/tempest-react-sdk.js +160 -156
  142. package/dist/utils/csv.cjs +3 -3
  143. package/dist/utils/csv.cjs.map +1 -1
  144. package/dist/utils/csv.js +18 -14
  145. package/dist/utils/csv.js.map +1 -1
  146. package/dist/utils/owner-fingerprint.cjs +2 -0
  147. package/dist/utils/owner-fingerprint.cjs.map +1 -0
  148. package/dist/utils/owner-fingerprint.js +18 -0
  149. package/dist/utils/owner-fingerprint.js.map +1 -0
  150. package/dist/utils/safe-url.cjs +2 -0
  151. package/dist/utils/safe-url.cjs.map +1 -0
  152. package/dist/utils/safe-url.js +39 -0
  153. package/dist/utils/safe-url.js.map +1 -0
  154. package/dist/vite/tempest-csp.cjs +2 -2
  155. package/dist/vite/tempest-csp.cjs.map +1 -1
  156. package/dist/vite/tempest-csp.js +130 -64
  157. package/dist/vite/tempest-csp.js.map +1 -1
  158. package/dist/vite.d.ts +23 -5
  159. package/dist/webrtc/peer-link.cjs +1 -1
  160. package/dist/webrtc/peer-link.cjs.map +1 -1
  161. package/dist/webrtc/peer-link.js +2 -10
  162. package/dist/webrtc/peer-link.js.map +1 -1
  163. package/dist/webrtc/peer-mesh.cjs +1 -1
  164. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  165. package/dist/webrtc/peer-mesh.js +110 -72
  166. package/dist/webrtc/peer-mesh.js.map +1 -1
  167. package/package.json +1 -1
  168. package/template-pwa/src/pwa.tsx +22 -0
  169. package/template-pwa/src/sw.ts +27 -14
  170. package/dist/components/Markdown/markdown-url.cjs +0 -2
  171. package/dist/components/Markdown/markdown-url.cjs.map +0 -1
  172. package/dist/components/Markdown/markdown-url.js +0 -28
  173. 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
+ });
@@ -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 { collectStylesheets, customPropertiesInSources, looksMinified } from "./collect.mjs";
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", "icons.js");
28
- const mod = await import(pathToFileURL(dist).href);
29
- return { known: new Set(mod.iconNames), aliases: mod.iconAliases };
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
+ });