tempest-react-sdk 0.76.0 → 0.77.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 (142) 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/component/generate.mjs +191 -0
  5. package/bin/lib/component/generate.test.mjs +336 -0
  6. package/bin/lib/openapi/escape.mjs +210 -0
  7. package/bin/lib/openapi/generate.hostile.test.mjs +592 -0
  8. package/bin/lib/openapi/generate.mjs +93 -17
  9. package/bin/lib/openapi/schema-to-zod.mjs +41 -4
  10. package/bin/tempest.mjs +184 -3
  11. package/dist/auth/create-tempest-auth.cjs +1 -1
  12. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  13. package/dist/auth/create-tempest-auth.js +127 -55
  14. package/dist/auth/create-tempest-auth.js.map +1 -1
  15. package/dist/auth/refresh-queue.cjs +1 -1
  16. package/dist/auth/refresh-queue.cjs.map +1 -1
  17. package/dist/auth/refresh-queue.js +13 -6
  18. package/dist/auth/refresh-queue.js.map +1 -1
  19. package/dist/br/pix.cjs +1 -1
  20. package/dist/br/pix.cjs.map +1 -1
  21. package/dist/br/pix.js +72 -62
  22. package/dist/br/pix.js.map +1 -1
  23. package/dist/br.d.ts +12 -4
  24. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  25. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  26. package/dist/components/Chat/ChatAttachments.js +46 -38
  27. package/dist/components/Chat/ChatAttachments.js.map +1 -1
  28. package/dist/components/Markdown/markdown-parse.cjs +7 -5
  29. package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
  30. package/dist/components/Markdown/markdown-parse.js +151 -87
  31. package/dist/components/Markdown/markdown-parse.js.map +1 -1
  32. package/dist/components/Toast/Toast.module.cjs +1 -1
  33. package/dist/components/Toast/Toast.module.cjs.map +1 -1
  34. package/dist/components/Toast/Toast.module.js +7 -3
  35. package/dist/components/Toast/Toast.module.js.map +1 -1
  36. package/dist/components/Toast/ToastProvider.cjs +1 -1
  37. package/dist/components/Toast/ToastProvider.cjs.map +1 -1
  38. package/dist/components/Toast/ToastProvider.js +77 -51
  39. package/dist/components/Toast/ToastProvider.js.map +1 -1
  40. package/dist/data/create-data-provider.cjs +1 -1
  41. package/dist/data/create-data-provider.cjs.map +1 -1
  42. package/dist/data/create-data-provider.js +32 -21
  43. package/dist/data/create-data-provider.js.map +1 -1
  44. package/dist/http/api-client.cjs +1 -1
  45. package/dist/http/api-client.cjs.map +1 -1
  46. package/dist/http/api-client.js +81 -73
  47. package/dist/http/api-client.js.map +1 -1
  48. package/dist/http/errors.cjs +1 -1
  49. package/dist/http/errors.cjs.map +1 -1
  50. package/dist/http/errors.js +39 -35
  51. package/dist/http/errors.js.map +1 -1
  52. package/dist/oauth/callback.cjs +2 -0
  53. package/dist/oauth/callback.cjs.map +1 -0
  54. package/dist/oauth/callback.js +42 -0
  55. package/dist/oauth/callback.js.map +1 -0
  56. package/dist/oauth/use-oauth-callback.cjs +1 -1
  57. package/dist/oauth/use-oauth-callback.cjs.map +1 -1
  58. package/dist/oauth/use-oauth-callback.js +13 -20
  59. package/dist/oauth/use-oauth-callback.js.map +1 -1
  60. package/dist/push/use-push-subscription.cjs +1 -1
  61. package/dist/push/use-push-subscription.cjs.map +1 -1
  62. package/dist/push/use-push-subscription.js +42 -42
  63. package/dist/push/use-push-subscription.js.map +1 -1
  64. package/dist/push/web-push-client.cjs +1 -1
  65. package/dist/push/web-push-client.cjs.map +1 -1
  66. package/dist/push/web-push-client.js +23 -3
  67. package/dist/push/web-push-client.js.map +1 -1
  68. package/dist/query/offline-persistence.cjs +1 -1
  69. package/dist/query/offline-persistence.cjs.map +1 -1
  70. package/dist/query/offline-persistence.js +68 -23
  71. package/dist/query/offline-persistence.js.map +1 -1
  72. package/dist/styles/component/Toast.css +6 -1
  73. package/dist/styles/feedback.css +6 -1
  74. package/dist/styles.css +1 -1
  75. package/dist/sw/background-sync.cjs +1 -1
  76. package/dist/sw/background-sync.cjs.map +1 -1
  77. package/dist/sw/background-sync.js +271 -60
  78. package/dist/sw/background-sync.js.map +1 -1
  79. package/dist/sw/cache-inspect.cjs.map +1 -1
  80. package/dist/sw/cache-inspect.js.map +1 -1
  81. package/dist/sw/cache.cjs +1 -1
  82. package/dist/sw/cache.cjs.map +1 -1
  83. package/dist/sw/cache.js +91 -64
  84. package/dist/sw/cache.js.map +1 -1
  85. package/dist/sw/credential-headers.cjs +2 -0
  86. package/dist/sw/credential-headers.cjs.map +1 -0
  87. package/dist/sw/credential-headers.js +17 -0
  88. package/dist/sw/credential-headers.js.map +1 -0
  89. package/dist/sw/session-data.cjs +2 -0
  90. package/dist/sw/session-data.cjs.map +1 -0
  91. package/dist/sw/session-data.js +17 -0
  92. package/dist/sw/session-data.js.map +1 -0
  93. package/dist/sw.cjs +1 -1
  94. package/dist/sw.d.ts +271 -23
  95. package/dist/sw.js +7 -5
  96. package/dist/telemetry/app-error-owner.cjs +2 -0
  97. package/dist/telemetry/app-error-owner.cjs.map +1 -0
  98. package/dist/telemetry/app-error-owner.js +18 -0
  99. package/dist/telemetry/app-error-owner.js.map +1 -0
  100. package/dist/telemetry/app-error-queue.cjs +1 -1
  101. package/dist/telemetry/app-error-queue.cjs.map +1 -1
  102. package/dist/telemetry/app-error-queue.js +19 -14
  103. package/dist/telemetry/app-error-queue.js.map +1 -1
  104. package/dist/telemetry/app-error-reporter.cjs +1 -1
  105. package/dist/telemetry/app-error-reporter.cjs.map +1 -1
  106. package/dist/telemetry/app-error-reporter.js +75 -34
  107. package/dist/telemetry/app-error-reporter.js.map +1 -1
  108. package/dist/telemetry/app-error-transport.cjs +1 -1
  109. package/dist/telemetry/app-error-transport.cjs.map +1 -1
  110. package/dist/telemetry/app-error-transport.js +16 -3
  111. package/dist/telemetry/app-error-transport.js.map +1 -1
  112. package/dist/tempest-react-sdk.cjs +1 -1
  113. package/dist/tempest-react-sdk.d.ts +979 -62
  114. package/dist/tempest-react-sdk.js +160 -156
  115. package/dist/utils/csv.cjs +3 -3
  116. package/dist/utils/csv.cjs.map +1 -1
  117. package/dist/utils/csv.js +16 -13
  118. package/dist/utils/csv.js.map +1 -1
  119. package/dist/utils/safe-url.cjs +2 -0
  120. package/dist/utils/safe-url.cjs.map +1 -0
  121. package/dist/utils/safe-url.js +39 -0
  122. package/dist/utils/safe-url.js.map +1 -0
  123. package/dist/vite/tempest-csp.cjs +2 -2
  124. package/dist/vite/tempest-csp.cjs.map +1 -1
  125. package/dist/vite/tempest-csp.js +118 -63
  126. package/dist/vite/tempest-csp.js.map +1 -1
  127. package/dist/vite.d.ts +21 -5
  128. package/dist/webrtc/peer-link.cjs +1 -1
  129. package/dist/webrtc/peer-link.cjs.map +1 -1
  130. package/dist/webrtc/peer-link.js +2 -10
  131. package/dist/webrtc/peer-link.js.map +1 -1
  132. package/dist/webrtc/peer-mesh.cjs +1 -1
  133. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  134. package/dist/webrtc/peer-mesh.js +110 -72
  135. package/dist/webrtc/peer-mesh.js.map +1 -1
  136. package/package.json +1 -1
  137. package/template-pwa/src/pwa.tsx +22 -0
  138. package/template-pwa/src/sw.ts +27 -14
  139. package/dist/components/Markdown/markdown-url.cjs +0 -2
  140. package/dist/components/Markdown/markdown-url.cjs.map +0 -1
  141. package/dist/components/Markdown/markdown-url.js +0 -28
  142. package/dist/components/Markdown/markdown-url.js.map +0 -1
@@ -0,0 +1,336 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ import { ESLint } from "eslint";
17
+ import ts from "typescript";
18
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
19
+
20
+ import { componentFiles, normalizeComponentName } from "./generate.mjs";
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url));
23
+ const REPO = resolve(HERE, "..", "..", "..");
24
+ const CLI = join(REPO, "bin", "tempest.mjs");
25
+ const TEMPLATE = join(REPO, "template");
26
+ /**
27
+ * Component names that collide with something a generated file declares or
28
+ * imports: React built-in hooks (`use<Name>` would be `useMemo`), the `React`
29
+ * namespace, and globals the component function would shadow.
30
+ */
31
+ const COLLIDING = [
32
+ "UserCard",
33
+ "Memo",
34
+ "State",
35
+ "Effect",
36
+ "Callback",
37
+ "LayoutEffect",
38
+ "React",
39
+ "ReactMemo",
40
+ "UseMemo",
41
+ "Object",
42
+ "Promise",
43
+ "Error",
44
+ ];
45
+ const FILES = ["constants.ts", "hooks.ts", "index.tsx", "styles.module.css", "utils.ts"];
46
+
47
+ describe("normalizeComponentName", () => {
48
+ it.each([
49
+ ["user card", "UserCard"],
50
+ ["user-card", "UserCard"],
51
+ ["user_card", "UserCard"],
52
+ ["userCard", "UserCard"],
53
+ ["UserCard", "UserCard"],
54
+ [" button ", "Button"],
55
+ ["api client", "ApiClient"],
56
+ ["APIClient", "APIClient"],
57
+ ["card2 header", "Card2Header"],
58
+ ])("%j → %s", (raw, expected) => {
59
+ expect(normalizeComponentName(raw)).toBe(expected);
60
+ });
61
+
62
+ it.each([
63
+ ["", /Missing component name/],
64
+ [" ", /Missing component name/],
65
+ ["2fa", /must start with a letter/],
66
+ ["-card", /must start with a letter/],
67
+ ["user/card", /use ASCII letters, digits/],
68
+ ["../evil", /use ASCII letters, digits/],
69
+ ['card"; alert(1)', /use ASCII letters, digits/],
70
+ ["cartão", /use ASCII letters, digits/],
71
+ ["con", /reserved device name on Windows/],
72
+ ["NUL", /reserved device name on Windows/],
73
+ ["aux", /reserved device name on Windows/],
74
+ ["prn", /reserved device name on Windows/],
75
+ ["com1", /reserved device name on Windows/],
76
+ ["Lpt9", /reserved device name on Windows/],
77
+ ])("refuses %j", (raw, message) => {
78
+ expect(() => normalizeComponentName(raw)).toThrow(message);
79
+ });
80
+ });
81
+
82
+ describe("componentFiles", () => {
83
+ it("emits exactly the five files the issue asks for", () => {
84
+ const { name, files } = componentFiles("user card");
85
+ expect(name).toBe("UserCard");
86
+ expect(Object.keys(files).sort()).toEqual(FILES);
87
+ });
88
+
89
+ it("wires every file into index.tsx so none starts as dead code", () => {
90
+ const { files } = componentFiles("UserCard");
91
+ expect(files["index.tsx"]).toContain('from "./hooks"');
92
+ expect(files["index.tsx"]).toContain('from "./utils"');
93
+ expect(files["index.tsx"]).toContain('from "./styles.module.css"');
94
+ expect(files["hooks.ts"]).toContain('from "./constants"');
95
+ });
96
+
97
+ it("renames the hook when use<Name> is a React built-in hook", () => {
98
+ expect(componentFiles("Memo").files["hooks.ts"]).toContain(
99
+ "export function useMemoComponent(",
100
+ );
101
+ expect(componentFiles("State").files["index.tsx"]).toContain("useStateComponent(title)");
102
+ expect(componentFiles("Card").files["hooks.ts"]).toContain("export function useCard(");
103
+ });
104
+
105
+ it("writes no comment and no single-quoted string", () => {
106
+ const { files } = componentFiles("UserCard");
107
+ for (const [file, contents] of Object.entries(files)) {
108
+ expect(contents, file).not.toMatch(/\/\/|\/\*/);
109
+ expect(contents, file).not.toContain("'");
110
+ }
111
+ });
112
+
113
+ it.each(COLLIDING)(
114
+ "passes the app template's ESLint config as generated for %s",
115
+ async (componentName) => {
116
+ const dir = mkdtempSync(join(tmpdir(), "tempest-component-lint-"));
117
+ try {
118
+ const { files } = componentFiles(componentName);
119
+ for (const [file, contents] of Object.entries(files)) {
120
+ writeFileSync(join(dir, file), contents);
121
+ }
122
+ const eslint = new ESLint({
123
+ cwd: dir,
124
+ overrideConfigFile: join(TEMPLATE, "eslint.config.js"),
125
+ });
126
+ const results = await eslint.lintFiles(["*.ts", "*.tsx"]);
127
+ const messages = results.flatMap((r) =>
128
+ r.messages.map((m) => `${r.filePath}:${m.line} ${m.ruleId} ${m.message}`),
129
+ );
130
+ expect(results).toHaveLength(4);
131
+ expect(messages).toEqual([]);
132
+ } finally {
133
+ rmSync(dir, { recursive: true, force: true });
134
+ }
135
+ },
136
+ );
137
+
138
+ it.each(COLLIDING)(
139
+ "type-checks %s under the app template's strict tsconfig",
140
+ (componentName) => {
141
+ const { files } = componentFiles(componentName);
142
+ const tsconfig = ts.parseConfigFileTextToJson(
143
+ "tsconfig.json",
144
+ readFileSync(join(TEMPLATE, "tsconfig.json"), "utf8"),
145
+ ).config;
146
+ const { options } = ts.convertCompilerOptionsFromJson(
147
+ { ...tsconfig.compilerOptions, types: [], paths: undefined },
148
+ REPO,
149
+ );
150
+ const root = join(REPO, "__virtual_component__");
151
+ const virtual = new Map(
152
+ Object.entries(files)
153
+ .filter(([file]) => !file.endsWith(".css"))
154
+ .map(([file, contents]) => [join(root, file), contents]),
155
+ );
156
+ virtual.set(
157
+ join(root, "css-modules.d.ts"),
158
+ 'declare module "*.module.css" {\n const classes: Readonly<Record<string, string>>;\n export default classes;\n}\n',
159
+ );
160
+ const host = ts.createCompilerHost(options);
161
+ const readFile = host.readFile.bind(host);
162
+ const fileExists = host.fileExists.bind(host);
163
+ host.readFile = (f) => virtual.get(f) ?? readFile(f);
164
+ host.fileExists = (f) => virtual.has(f) || fileExists(f);
165
+ const directoryExists = host.directoryExists?.bind(host);
166
+ host.directoryExists = (d) => d === root || (directoryExists?.(d) ?? true);
167
+ const getSourceFile = host.getSourceFile.bind(host);
168
+ host.getSourceFile = (f, lang) =>
169
+ virtual.has(f)
170
+ ? ts.createSourceFile(f, virtual.get(f), lang)
171
+ : getSourceFile(f, lang);
172
+ const program = ts.createProgram([...virtual.keys()], options, host);
173
+ const diagnostics = ts
174
+ .getPreEmitDiagnostics(program)
175
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
176
+ expect(diagnostics).toEqual([]);
177
+ },
178
+ );
179
+ });
180
+
181
+ describe("tempest gen component (CLI)", () => {
182
+ let cwd;
183
+
184
+ beforeEach(() => {
185
+ cwd = mkdtempSync(join(tmpdir(), "tempest-component-cli-"));
186
+ });
187
+
188
+ afterEach(() => {
189
+ rmSync(cwd, { recursive: true, force: true });
190
+ });
191
+
192
+ /**
193
+ * Run the `tempest` CLI in the temporary project.
194
+ *
195
+ * @param {string[]} args - CLI arguments.
196
+ * @returns {{ status: number | null, out: string }}
197
+ */
198
+ function tempest(...args) {
199
+ const result = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8" });
200
+ return { status: result.status, out: result.stdout + result.stderr };
201
+ }
202
+
203
+ it("creates src/components/<Name>/ with the five files by default", () => {
204
+ const { status, out } = tempest("gen", "component", "user card");
205
+ expect(status).toBe(0);
206
+ expect(readdirSync(join(cwd, "src", "components", "UserCard")).sort()).toEqual(FILES);
207
+ expect(out).toContain('import { UserCard } from "@/components/UserCard";');
208
+ });
209
+
210
+ it("accepts `tempest new component`, the spelling from the issue", () => {
211
+ const { status } = tempest("new", "component", "Button");
212
+ expect(status).toBe(0);
213
+ expect(existsSync(join(cwd, "src", "components", "Button", "index.tsx"))).toBe(true);
214
+ });
215
+
216
+ it("refuses another `new` target instead of growing a second generator name", () => {
217
+ const { status, out } = tempest("new", "api");
218
+ expect(status).toBe(1);
219
+ expect(out).toMatch(/Unknown new target: api/);
220
+ });
221
+
222
+ it("honors --out", () => {
223
+ const { status, out } = tempest("gen", "component", "Card", "--out", "src/ui");
224
+ expect(status).toBe(0);
225
+ expect(existsSync(join(cwd, "src", "ui", "Card", "hooks.ts"))).toBe(true);
226
+ expect(out).toContain('from "@/ui/Card"');
227
+ });
228
+
229
+ it("refuses to touch an existing folder without --force", () => {
230
+ const dir = join(cwd, "src", "components", "Card");
231
+ mkdirSync(dir, { recursive: true });
232
+ writeFileSync(join(dir, "index.tsx"), "mine");
233
+
234
+ const { status, out } = tempest("gen", "component", "Card");
235
+
236
+ expect(status).toBe(1);
237
+ expect(out).toMatch(/already exists.*--force/);
238
+ expect(readFileSync(join(dir, "index.tsx"), "utf8")).toBe("mine");
239
+ });
240
+
241
+ it("--force rewrites the five files and leaves the rest of the folder alone", () => {
242
+ const dir = join(cwd, "src", "components", "Card");
243
+ mkdirSync(dir, { recursive: true });
244
+ writeFileSync(join(dir, "index.tsx"), "mine");
245
+ writeFileSync(join(dir, "Card.test.tsx"), "keep");
246
+
247
+ const { status } = tempest("gen", "component", "Card", "--force");
248
+
249
+ expect(status).toBe(0);
250
+ expect(readFileSync(join(dir, "index.tsx"), "utf8")).toContain("export function Card(");
251
+ expect(readFileSync(join(dir, "Card.test.tsx"), "utf8")).toBe("keep");
252
+ });
253
+
254
+ it("refuses to write through a symlinked folder even with --force", () => {
255
+ const outside = join(cwd, "outside");
256
+ mkdirSync(outside);
257
+ mkdirSync(join(cwd, "src", "components"), { recursive: true });
258
+ symlinkSync(outside, join(cwd, "src", "components", "Card"));
259
+
260
+ const { status, out } = tempest("gen", "component", "Card", "--force");
261
+
262
+ expect(status).toBe(1);
263
+ expect(out).toMatch(/symlink/);
264
+ expect(readdirSync(outside)).toEqual([]);
265
+ });
266
+
267
+ it.each([
268
+ [["gen", "component", "Card", "--out", "../x"], /outside the project/],
269
+ [["gen", "component", "Card", "--out", "/tmp/tempest-outside-abs"], /outside the project/],
270
+ [["gen", "component", "Card", "--out", "--force"], /--out needs a folder/],
271
+ [["gen", "component", "Card", "--out"], /--out needs a folder/],
272
+ [["gen", "component", "Card", "--out", ""], /--out needs a folder/],
273
+ ])("refuses an --out of %j", (args, message) => {
274
+ const { status, out } = tempest(...args);
275
+ expect(status).toBe(1);
276
+ expect(out).toMatch(message);
277
+ expect(existsSync(join(cwd, "src"))).toBe(false);
278
+ expect(existsSync(join(cwd, "--force"))).toBe(false);
279
+ expect(existsSync(join(dirname(cwd), "x"))).toBe(false);
280
+ expect(existsSync("/tmp/tempest-outside-abs")).toBe(false);
281
+ });
282
+
283
+ it("refuses an --out whose existing ancestor is a symlink leaving the project", () => {
284
+ const outside = mkdtempSync(join(tmpdir(), "tempest-component-outside-"));
285
+ try {
286
+ mkdirSync(join(cwd, "src"));
287
+ symlinkSync(outside, join(cwd, "src", "components"));
288
+
289
+ const { status, out } = tempest("gen", "component", "Card");
290
+
291
+ expect(status).toBe(1);
292
+ expect(out).toMatch(/outside the project/);
293
+ expect(readdirSync(outside)).toEqual([]);
294
+ } finally {
295
+ rmSync(outside, { recursive: true, force: true });
296
+ }
297
+ });
298
+
299
+ it("--force validates all five targets before writing any of them", () => {
300
+ const dir = join(cwd, "src", "components", "Card");
301
+ mkdirSync(join(dir, "utils.ts"), { recursive: true });
302
+ writeFileSync(join(dir, "index.tsx"), "mine");
303
+
304
+ const { status, out } = tempest("gen", "component", "Card", "--force");
305
+
306
+ expect(status).toBe(1);
307
+ expect(out).toMatch(/utils\.ts is not a regular file/);
308
+ expect(readFileSync(join(dir, "index.tsx"), "utf8")).toBe("mine");
309
+ expect(readdirSync(dir).sort()).toEqual(["index.tsx", "utils.ts"]);
310
+ });
311
+
312
+ it("prints the path with a warning instead of a wrong import outside src/", () => {
313
+ const { status, out } = tempest("gen", "component", "Card", "--out", "lib/ui");
314
+ expect(status).toBe(0);
315
+ expect(out).not.toMatch(/import \{ Card \}/);
316
+ expect(out).toMatch(/lib\/ui\/Card.*outside src\/.*@\//s);
317
+ });
318
+
319
+ it("keeps the @/ hint for a folder whose name merely starts with two dots", () => {
320
+ const { status, out } = tempest("gen", "component", "Card", "--out", "src/..ui");
321
+ expect(status).toBe(0);
322
+ expect(out).toContain('import { Card } from "@/..ui/Card";');
323
+ });
324
+
325
+ it.each([
326
+ [["gen", "component"], /Missing component name/],
327
+ [["gen", "component", "2fa"], /must start with a letter/],
328
+ [["gen", "component", "user", "card"], /Expected one component name, got 2/],
329
+ [["gen", "component", "Card", "--dry-run"], /Unknown option: --dry-run/],
330
+ ])("fails with a clear message for %j", (args, message) => {
331
+ const { status, out } = tempest(...args);
332
+ expect(status).toBe(1);
333
+ expect(out).toMatch(message);
334
+ expect(existsSync(join(cwd, "src"))).toBe(false);
335
+ });
336
+ });
@@ -0,0 +1,210 @@
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 `${identifier}`, the variable that parameter was mapped to
184
+ * (`{user-id}` → `${userId}`). A path without placeholders is emitted as a JSON
185
+ * string literal.
186
+ *
187
+ * @param {string} path - The OpenAPI path key ("/users/{user-id}").
188
+ * @param {Map<string, string>} declared - Original name → identifier of each of
189
+ * the operation's `in: "path"` params.
190
+ * @returns {string} A TypeScript expression evaluating to the URL.
191
+ * @throws {Error} When a placeholder is not a declared path parameter — there is
192
+ * no variable it could safely map to.
193
+ */
194
+ export function urlTemplate(path, declared) {
195
+ if (declared.size === 0) return JSON.stringify(path);
196
+ let out = "";
197
+ let last = 0;
198
+ for (const match of path.matchAll(/\{([^}]*)\}/g)) {
199
+ const name = match[1];
200
+ if (!declared.has(name)) {
201
+ throw new Error(
202
+ `OpenAPI path ${JSON.stringify(path)}: placeholder ${JSON.stringify(name)} is not a declared path parameter.`,
203
+ );
204
+ }
205
+ out += templateText(path.slice(last, match.index)) + "${" + declared.get(name) + "}";
206
+ last = match.index + match[0].length;
207
+ }
208
+ out += templateText(path.slice(last));
209
+ return "`" + out + "`";
210
+ }