create-next-style-guide 0.0.0-stage → 0.1.2

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 (61) hide show
  1. package/addons/ci/.cursor/rules/ci.mdc +11 -0
  2. package/addons/ci/docs/style-guide/ops/ci.md +7 -0
  3. package/addons/docker/.cursor/rules/docker.mdc +12 -0
  4. package/addons/docker/.dockerignore +12 -0
  5. package/addons/docker/docker/production.Dockerfile +53 -0
  6. package/addons/docker/docs/style-guide/ops/docker.md +14 -0
  7. package/addons/docs/data/tanstack-query.md +7 -0
  8. package/addons/docs/examples/api-app-crud.md +94 -0
  9. package/addons/docs/ui/icons.md +5 -0
  10. package/addons/endpoints/.cursor/rules/endpoints.mdc +17 -0
  11. package/addons/endpoints/docs/style-guide/http/endpoints.md +21 -0
  12. package/addons/endpoints/scripts/endpoints.codegen.json +7 -0
  13. package/addons/endpoints/scripts/generate-endpoints.ts +1384 -0
  14. package/addons/endpoints/src/endpoints/Types.ts +105 -0
  15. package/addons/endpoints/src/endpoints/index.ts +2 -0
  16. package/addons/endpoints/src/utils/api-error.ts +181 -0
  17. package/addons/endpoints/src/utils/api-result.ts +13 -0
  18. package/addons/endpoints/src/utils/axios-instance.ts +23 -0
  19. package/addons/endpoints/src/utils/form-data.ts +37 -0
  20. package/addons/endpoints/src/utils/search-params.ts +21 -0
  21. package/addons/eslint/.cursor/rules/eslint.mdc +12 -0
  22. package/addons/eslint/docs/style-guide/tooling/eslint.md +13 -0
  23. package/addons/eslint/eslint.config.mjs +85 -0
  24. package/addons/husky/.husky/pre-commit +2 -0
  25. package/addons/husky/docs/style-guide/tooling/husky.md +10 -0
  26. package/addons/prettier/.cursor/rules/prettier.mdc +11 -0
  27. package/addons/prettier/.prettierrc +14 -0
  28. package/addons/prettier/docs/style-guide/tooling/prettier.md +9 -0
  29. package/dist/index.js +3848 -0
  30. package/dist/index.js.map +1 -0
  31. package/package.json +38 -4
  32. package/template/.cursor/rules/components.mdc +27 -0
  33. package/template/.cursor/rules/core.mdc +16 -0
  34. package/template/.cursor/rules/forms.mdc +16 -0
  35. package/template/.env.example +6 -0
  36. package/template/AGENTS.md +38 -0
  37. package/template/README.md +28 -0
  38. package/template/docs/style-guide/README.md +11 -0
  39. package/template/docs/style-guide/http/client.md +9 -0
  40. package/template/docs/style-guide/overview/architecture.md +21 -0
  41. package/template/docs/style-guide/overview/project-structure.md +36 -0
  42. package/template/eslint.config.mjs +13 -0
  43. package/template/gitignore +39 -0
  44. package/template/next-env.d.ts +4 -0
  45. package/template/next.config.ts +7 -0
  46. package/template/package.json +27 -0
  47. package/template/src/app/_components/welcome-card/index.tsx +24 -0
  48. package/template/src/app/globals.css +84 -0
  49. package/template/src/app/layout.tsx +22 -0
  50. package/template/src/app/page.tsx +25 -0
  51. package/template/src/components/.gitkeep +0 -0
  52. package/template/src/constants/.gitkeep +0 -0
  53. package/template/src/hooks/.gitkeep +0 -0
  54. package/template/src/lib/env.ts +17 -0
  55. package/template/src/lib/http.ts +28 -0
  56. package/template/src/lib/utils.ts +6 -0
  57. package/template/src/providers/.gitkeep +0 -0
  58. package/template/src/types/.gitkeep +0 -0
  59. package/template/src/utils/.gitkeep +0 -0
  60. package/template/tsconfig.json +23 -0
  61. package/README.md +0 -3
@@ -0,0 +1,1384 @@
1
+ /**
2
+ * Generate / sync `src/endpoints/*` from an OpenAPI (Swagger) schema URL.
3
+ *
4
+ * Usage:
5
+ * pnpm endpoints:generate
6
+ * pnpm endpoints:generate --url=http://localhost:8000/api/docs/schema/?format=json
7
+ */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ /* -------------------------------------------------------------------------- */
15
+ /* Types */
16
+ /* -------------------------------------------------------------------------- */
17
+
18
+ type JsonSchema = {
19
+ type?: string | string[];
20
+ properties?: Record<string, JsonSchema>;
21
+ required?: string[];
22
+ items?: JsonSchema;
23
+ enum?: unknown[];
24
+ const?: unknown;
25
+ format?: string;
26
+ pattern?: string;
27
+ minLength?: number;
28
+ maxLength?: number;
29
+ minimum?: number;
30
+ maximum?: number;
31
+ exclusiveMinimum?: number | boolean;
32
+ exclusiveMaximum?: number | boolean;
33
+ nullable?: boolean;
34
+ description?: string;
35
+ readOnly?: boolean;
36
+ writeOnly?: boolean;
37
+ additionalProperties?: boolean | JsonSchema;
38
+ oneOf?: JsonSchema[];
39
+ anyOf?: JsonSchema[];
40
+ allOf?: JsonSchema[];
41
+ $ref?: string;
42
+ [key: string]: unknown;
43
+ };
44
+
45
+ type OpenAPI = {
46
+ openapi?: string;
47
+ paths?: Record<string, Record<string, Operation>>;
48
+ components?: { schemas?: Record<string, JsonSchema> };
49
+ };
50
+
51
+ type Operation = {
52
+ operationId?: string;
53
+ summary?: string;
54
+ description?: string;
55
+ tags?: string[];
56
+ parameters?: Parameter[];
57
+ requestBody?: {
58
+ required?: boolean;
59
+ content?: Record<string, { schema?: JsonSchema }>;
60
+ };
61
+ responses?: Record<string, { content?: Record<string, { schema?: JsonSchema }> }>;
62
+ };
63
+
64
+ type Parameter = {
65
+ name: string;
66
+ in: "query" | "path" | "header" | "cookie";
67
+ required?: boolean;
68
+ description?: string;
69
+ default?: unknown;
70
+ schema?: JsonSchema;
71
+ [key: string]: unknown;
72
+ };
73
+
74
+ type CodegenConfig = {
75
+ schemaUrl: string;
76
+ basePath: string;
77
+ outDir: string;
78
+ preserve: string[];
79
+ header: string;
80
+ };
81
+
82
+ type EndpointSpec = {
83
+ method: string;
84
+ openApiPath: string;
85
+ relativePath: string;
86
+ pathTemplate: string;
87
+ resourceRoot: string;
88
+ folderSegments: string[];
89
+ name: string;
90
+ schemaName: string;
91
+ summary?: string;
92
+ pathParams: Parameter[];
93
+ queryParams: Parameter[];
94
+ bodySchema: JsonSchema | null;
95
+ bodyRequired: boolean;
96
+ resultSchema: JsonSchema | null;
97
+ isMultipart: boolean;
98
+ hasEnvelope: boolean;
99
+ hasDefaultParams?: boolean;
100
+ };
101
+
102
+ type Manifest = {
103
+ schemaUrl: string;
104
+ files: string[];
105
+ };
106
+
107
+ /* -------------------------------------------------------------------------- */
108
+ /* Paths */
109
+ /* -------------------------------------------------------------------------- */
110
+
111
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
112
+ const ROOT = path.resolve(__dirname, "..");
113
+ const CONFIG_PATH = path.join(__dirname, "endpoints.codegen.json");
114
+ const MANIFEST_NAME = ".generated-manifest.json";
115
+
116
+ const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
117
+
118
+ /* -------------------------------------------------------------------------- */
119
+ /* CLI */
120
+ /* -------------------------------------------------------------------------- */
121
+
122
+ async function main() {
123
+ const config = await loadConfig();
124
+ const urlArg = process.argv.find((arg) => arg.startsWith("--url="));
125
+ if (urlArg) {
126
+ config.schemaUrl = urlArg.slice("--url=".length);
127
+ }
128
+
129
+ console.log(`Fetching schema: ${config.schemaUrl}`);
130
+ const openapi = await fetchSchema(config.schemaUrl);
131
+ const specs = buildSpecs(openapi, config.basePath);
132
+ console.log(`Found ${specs.length} operations`);
133
+
134
+ const outDir = path.join(ROOT, config.outDir);
135
+ const previous = await readManifest(outDir);
136
+ const written = new Set<string>();
137
+
138
+ // 1. Emit central enums file
139
+ const enumsContent = emitEnumsFile(openapi, config.header);
140
+ await writeFile(path.join(outDir, "enums.ts"), enumsContent, "utf8");
141
+ written.add("enums.ts");
142
+
143
+ const byResource = groupBy(specs, (spec) => spec.resourceRoot);
144
+
145
+ for (const [resource, resourceSpecs] of Object.entries(byResource)) {
146
+ if (config.preserve?.includes(resource)) {
147
+ continue;
148
+ }
149
+ for (const spec of resourceSpecs) {
150
+ for (const file of await writeEndpointFiles(outDir, spec, config.header, openapi)) {
151
+ written.add(file);
152
+ }
153
+ }
154
+ const indexRel = path.join(resource, "index.ts");
155
+ await writeFile(
156
+ path.join(outDir, indexRel),
157
+ emitResourceIndex(resourceSpecs, config.header),
158
+ "utf8",
159
+ );
160
+ written.add(indexRel.replaceAll("\\", "/"));
161
+ }
162
+
163
+ // 2. Emit root index.ts re-exporting enums, Types, and all resources
164
+ const allResources = await collectAllResources(outDir);
165
+ const rootIndexContent = emitRootIndex(allResources, config.header);
166
+ await writeFile(path.join(outDir, "index.ts"), rootIndexContent, "utf8");
167
+ written.add("index.ts");
168
+
169
+ const manifest: Manifest = {
170
+ schemaUrl: config.schemaUrl,
171
+ files: [...written].sort(),
172
+ };
173
+ await writeFile(
174
+ path.join(outDir, MANIFEST_NAME),
175
+ `${JSON.stringify(manifest, null, 2)}\n`,
176
+ "utf8",
177
+ );
178
+ written.add(MANIFEST_NAME);
179
+
180
+ const removed = await removeStaleGeneratedFiles(outDir, previous, written, config.preserve);
181
+ await pruneEmptyDirs(outDir, config.preserve);
182
+
183
+ console.log(`Wrote ${written.size} files`);
184
+ if (removed.length) {
185
+ console.log(`Removed ${removed.length} stale generated files`);
186
+ }
187
+
188
+ formatGeneratedWithPrettier(outDir, [...written]);
189
+ console.log("Done.");
190
+ }
191
+
192
+ function toEnumConstName(typeName: string): string {
193
+ const snake = typeName
194
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
195
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
196
+ .toUpperCase();
197
+
198
+ if (snake.endsWith("_BY")) {
199
+ return `${snake}S`;
200
+ }
201
+ if (snake.endsWith("STATUS")) {
202
+ return `${snake}ES`;
203
+ }
204
+ if (snake.endsWith("CATEGORY") || snake.endsWith("ENTITY")) {
205
+ return `${snake.slice(0, -1)}IES`;
206
+ }
207
+ if (snake.endsWith("DAY")) {
208
+ return `${snake}S`;
209
+ }
210
+ if (
211
+ snake.endsWith("S") ||
212
+ snake.endsWith("X") ||
213
+ snake.endsWith("Z") ||
214
+ snake.endsWith("CH") ||
215
+ snake.endsWith("SH")
216
+ ) {
217
+ return `${snake}ES`;
218
+ }
219
+ if (snake.endsWith("Y") && !/[AEIOU]Y$/.test(snake)) {
220
+ return `${snake.slice(0, -1)}IES`;
221
+ }
222
+ return `${snake}S`;
223
+ }
224
+
225
+ function emitEnumsFile(openapi: OpenAPI, header: string): string {
226
+ const schemas = openapi.components?.schemas ?? {};
227
+ const enumEntries = Object.entries(schemas)
228
+ .filter(([key, schema]) => key.endsWith("Enum") && Array.isArray(schema.enum))
229
+ .sort(([a], [b]) => a.localeCompare(b));
230
+
231
+ const lines: string[] = [header, ""];
232
+
233
+ if (enumEntries.length === 0) {
234
+ lines.push("export {};", "");
235
+ }
236
+
237
+ for (const [key, schema] of enumEntries) {
238
+ const typeName = key.slice(0, -"Enum".length);
239
+ const constName = toEnumConstName(typeName);
240
+ const values = (schema.enum as unknown[]).filter((v) => v !== null && v !== undefined);
241
+
242
+ if (schema.description) {
243
+ const trimmed = schema.description.trim();
244
+ if (trimmed) {
245
+ lines.push(`/**`);
246
+ for (const line of trimmed.split("\n")) {
247
+ lines.push(` * ${line.replace(/\*\//g, "*\\/")}`);
248
+ }
249
+ lines.push(` */`);
250
+ }
251
+ }
252
+
253
+ const typeUnion = values.map((val) => JSON.stringify(val)).join(" | ");
254
+ lines.push(`export type ${typeName} = ${typeUnion || "never"};`, ``);
255
+
256
+ lines.push(`export const ${constName} = [`);
257
+ for (const val of values) {
258
+ lines.push(` ${JSON.stringify(val)},`);
259
+ }
260
+ lines.push(`] as const;`, ``);
261
+ }
262
+
263
+ return `${lines.join("\n").replace(/\n+$/, "")}\n`;
264
+ }
265
+
266
+ async function collectAllResources(outDir: string): Promise<string[]> {
267
+ const entries = await readdir(outDir, { withFileTypes: true });
268
+ const resources: string[] = [];
269
+ for (const entry of entries) {
270
+ if (entry.isDirectory()) {
271
+ try {
272
+ await readFile(path.join(outDir, entry.name, "index.ts"), "utf8");
273
+ resources.push(entry.name);
274
+ } catch {
275
+ // no index.ts
276
+ }
277
+ }
278
+ }
279
+ return resources.sort();
280
+ }
281
+
282
+ function emitRootIndex(resources: string[], header: string): string {
283
+ const sources = ["./enums", "./Types", ...resources.map((res) => `./${res}`)].sort(
284
+ (a, b) => compareImportExportSources(a, b) || compareStrings(a, b),
285
+ );
286
+
287
+ const lines = [header, ""];
288
+ for (const src of sources) {
289
+ lines.push(`export * from "${src}";`);
290
+ }
291
+ lines.push("");
292
+ return lines.join("\n");
293
+ }
294
+
295
+ async function loadConfig(): Promise<CodegenConfig> {
296
+ const raw = await readFile(CONFIG_PATH, "utf8");
297
+ return JSON.parse(raw) as CodegenConfig;
298
+ }
299
+
300
+ async function fetchSchema(url: string): Promise<OpenAPI> {
301
+ const response = await fetch(url, {
302
+ headers: { Accept: "application/json" },
303
+ });
304
+ if (!response.ok) {
305
+ throw new Error(`Failed to fetch schema: ${response.status} ${response.statusText}`);
306
+ }
307
+ const contentType = response.headers.get("content-type") ?? "";
308
+ const text = await response.text();
309
+ if (contentType.includes("json") || text.trimStart().startsWith("{")) {
310
+ return JSON.parse(text) as OpenAPI;
311
+ }
312
+ throw new Error(
313
+ "Schema is not JSON. Pass `?format=json` (drf-spectacular) or an Accept: application/json URL.",
314
+ );
315
+ }
316
+
317
+ /* -------------------------------------------------------------------------- */
318
+ /* Spec building */
319
+ /* -------------------------------------------------------------------------- */
320
+
321
+ function buildSpecs(openapi: OpenAPI, basePath: string): EndpointSpec[] {
322
+ const specs: EndpointSpec[] = [];
323
+ const normalizedBase = trimSlashes(basePath);
324
+
325
+ for (const [rawPath, pathItem] of Object.entries(openapi.paths ?? {})) {
326
+ for (const [method, operation] of Object.entries(pathItem)) {
327
+ if (!HTTP_METHODS.has(method.toLowerCase())) {
328
+ continue;
329
+ }
330
+ if (!operation || typeof operation !== "object") {
331
+ continue;
332
+ }
333
+
334
+ const openApiPath = rawPath;
335
+ let relative = openApiPath;
336
+ if (normalizedBase && relative.startsWith(`/${normalizedBase}`)) {
337
+ relative = relative.slice(normalizedBase.length + 1);
338
+ } else if (normalizedBase && relative.startsWith(normalizedBase)) {
339
+ relative = relative.slice(normalizedBase.length);
340
+ }
341
+ relative = `/${trimSlashes(relative)}/`.replace(/\/+/g, "/");
342
+ if (relative === "//") {
343
+ relative = "/";
344
+ }
345
+
346
+ const segments = trimSlashes(relative)
347
+ .split("/")
348
+ .filter(Boolean)
349
+ .map((segment) => (segment.startsWith("{") ? `{${stripBraces(segment)}}` : segment));
350
+
351
+ if (segments.length === 0) {
352
+ continue;
353
+ }
354
+
355
+ const resourceRoot = pascalCase(stripBraces(segments[0]!));
356
+ const folderSegments = [
357
+ ...segments.slice(1).map((segment) => {
358
+ if (segment.startsWith("{")) {
359
+ return `{${stripBraces(segment)}}`;
360
+ }
361
+ return segment;
362
+ }),
363
+ method[0]!.toUpperCase() + method.slice(1).toLowerCase(),
364
+ ];
365
+ const name = buildOperationName(method, segments);
366
+ const schemaName = camelCase(name);
367
+
368
+ const parameters = operation.parameters ?? [];
369
+ const pathParams = parameters.filter((parameter) => parameter.in === "path");
370
+ const queryParams = parameters.filter((parameter) => parameter.in === "query");
371
+
372
+ const jsonContent = operation.requestBody?.content?.["application/json"];
373
+ const multipartContent = operation.requestBody?.content?.["multipart/form-data"];
374
+ const formContent = operation.requestBody?.content?.["application/x-www-form-urlencoded"];
375
+ const bodyContent = multipartContent ?? jsonContent ?? formContent;
376
+ const bodySchema = bodyContent?.schema ?? null;
377
+ const resolvedForBinary = bodySchema
378
+ ? createResolver(openapi.components?.schemas ?? {})(bodySchema)
379
+ : null;
380
+ // Prefer multipart when advertised, or when body has upload fields (logo/avatar)
381
+ // even if OpenAPI only lists application/json (common DRF ImageField quirk).
382
+ const isMultipart = Boolean(
383
+ (multipartContent && (!jsonContent || schemaHasBinary(resolvedForBinary))) ||
384
+ (!multipartContent && resolvedForBinary && schemaHasUploadField(resolvedForBinary)),
385
+ );
386
+
387
+ const success =
388
+ operation.responses?.["200"] ??
389
+ operation.responses?.["201"] ??
390
+ operation.responses?.["204"];
391
+ const responseSchema =
392
+ success?.content?.["application/json"]?.schema ??
393
+ success?.content?.["application/vnd.oai.openapi+json"]?.schema ??
394
+ null;
395
+
396
+ const { resultSchema, hasEnvelope } = unwrapEnvelope(responseSchema);
397
+
398
+ const resolve = createResolver(openapi.components?.schemas ?? {});
399
+ const hasDefaultParams = queryParams.some(
400
+ (p) => getParamDefaultValue(p, resolve) !== undefined,
401
+ );
402
+
403
+ specs.push({
404
+ method: method.toLowerCase(),
405
+ openApiPath,
406
+ relativePath: relative,
407
+ pathTemplate: toAxiosPath(relative),
408
+ resourceRoot,
409
+ folderSegments,
410
+ name,
411
+ schemaName,
412
+ summary: operation.summary,
413
+ pathParams,
414
+ queryParams,
415
+ bodySchema,
416
+ bodyRequired: Boolean(operation.requestBody?.required),
417
+ resultSchema,
418
+ isMultipart,
419
+ hasEnvelope,
420
+ hasDefaultParams,
421
+ });
422
+ }
423
+ }
424
+
425
+ return specs.sort((a, b) => a.name.localeCompare(b.name));
426
+ }
427
+
428
+ function buildOperationName(method: string, segments: string[]): string {
429
+ const methodPart = method[0]!.toUpperCase() + method.slice(1).toLowerCase();
430
+ const parts = segments.map((segment) => {
431
+ if (segment.startsWith("{")) {
432
+ return pascalCase(stripBraces(segment));
433
+ }
434
+ return pascalCase(segment);
435
+ });
436
+ return `${methodPart}${parts.join("")}`;
437
+ }
438
+
439
+ function unwrapEnvelope(schema: JsonSchema | null): {
440
+ resultSchema: JsonSchema | null;
441
+ hasEnvelope: boolean;
442
+ } {
443
+ if (!schema) {
444
+ return { resultSchema: null, hasEnvelope: false };
445
+ }
446
+ if (schema.$ref) {
447
+ // Keep ref; resolved later. Detect envelope by name heuristic after resolve in emitter.
448
+ return { resultSchema: schema, hasEnvelope: false };
449
+ }
450
+ if (
451
+ schema.type === "object" &&
452
+ schema.properties?.result &&
453
+ schema.properties.success &&
454
+ schema.properties.status
455
+ ) {
456
+ return { resultSchema: schema.properties.result, hasEnvelope: true };
457
+ }
458
+ return { resultSchema: schema, hasEnvelope: false };
459
+ }
460
+
461
+ function toAxiosPath(relative: string): string {
462
+ // `/users/{id}/` → `/users/${request.path.id}/`
463
+ return relative.replace(/\{([^}]+)\}/g, (_, name: string) => `\${request.path.${name}}`);
464
+ }
465
+
466
+ /* -------------------------------------------------------------------------- */
467
+ /* Emit files */
468
+ /* -------------------------------------------------------------------------- */
469
+
470
+ async function writeEndpointFiles(
471
+ outDir: string,
472
+ spec: EndpointSpec,
473
+ header: string,
474
+ openapi: OpenAPI,
475
+ ): Promise<string[]> {
476
+ const dir = path.join(outDir, spec.resourceRoot, ...spec.folderSegments);
477
+ await mkdir(dir, { recursive: true });
478
+
479
+ const typeRel = path
480
+ .join(spec.resourceRoot, ...spec.folderSegments, `${spec.name}.type.ts`)
481
+ .replaceAll("\\", "/");
482
+ const callRel = path
483
+ .join(spec.resourceRoot, ...spec.folderSegments, `${spec.name}.ts`)
484
+ .replaceAll("\\", "/");
485
+
486
+ await writeFile(path.join(outDir, typeRel), emitTypeFile(spec, header, openapi), "utf8");
487
+ await writeFile(path.join(outDir, callRel), emitCallFile(spec, header), "utf8");
488
+
489
+ return [typeRel, callRel];
490
+ }
491
+
492
+ function emitTypeFile(spec: EndpointSpec, header: string, openapi: OpenAPI): string {
493
+ const schemas = openapi.components?.schemas ?? {};
494
+ const resolve = createResolver(schemas);
495
+
496
+ const bodyResolved = spec.bodySchema ? resolve(spec.bodySchema) : null;
497
+ const resultResolved = spec.resultSchema ? resolve(spec.resultSchema) : null;
498
+
499
+ // Detect envelope after resolve
500
+ let resultSchema = resultResolved;
501
+ let hasEnvelope = spec.hasEnvelope;
502
+ if (
503
+ resultSchema &&
504
+ resultSchema.type === "object" &&
505
+ resultSchema.properties?.result &&
506
+ resultSchema.properties.success &&
507
+ resultSchema.properties.status
508
+ ) {
509
+ resultSchema = resolve(resultSchema.properties.result);
510
+ hasEnvelope = true;
511
+ }
512
+
513
+ const hasBody = Boolean(bodyResolved);
514
+ const hasQuery = spec.queryParams.length > 0;
515
+ const hasPath = spec.pathParams.length > 0 || spec.pathTemplate.includes("request.path.");
516
+
517
+ const usedEnums = new Set<string>();
518
+
519
+ const bodyTypeStr = hasBody ? emitTsType(bodyResolved!, 0, undefined, usedEnums) : "";
520
+ const resultTypeStr = resultSchema ? emitTsType(resultSchema, 0, undefined, usedEnums) : "";
521
+
522
+ const queryLines: string[] = [];
523
+ const defaultEntries: [string, unknown][] = [];
524
+ if (hasQuery) {
525
+ for (const parameter of spec.queryParams) {
526
+ const schema = parameter.schema ? resolve(parameter.schema) : { type: "string" };
527
+ const defaultValue = getParamDefaultValue(parameter, resolve);
528
+ const description =
529
+ parameter.description ?? schema.description ?? parameter.schema?.description;
530
+ const format = parameter.schema?.format ?? schema.format;
531
+
532
+ if (defaultValue !== undefined) {
533
+ defaultEntries.push([parameter.name, defaultValue]);
534
+ }
535
+
536
+ const jsDoc = formatParamJsDoc(description, defaultValue, format);
537
+ if (jsDoc.length > 0) {
538
+ queryLines.push(...jsDoc);
539
+ }
540
+ const optional = parameter.required ? "" : "?";
541
+ queryLines.push(
542
+ ` ${safeKey(parameter.name)}${optional}: ${emitTsType(schema, 1, undefined, usedEnums)};`,
543
+ );
544
+ }
545
+ }
546
+
547
+ const pathLines: string[] = [];
548
+ if (hasPath) {
549
+ const pathNames = extractPathNames(spec);
550
+ for (const name of pathNames) {
551
+ const parameter = spec.pathParams.find((item) => item.name === name);
552
+ const schema = parameter?.schema ? resolve(parameter.schema) : { type: "string" };
553
+ pathLines.push(` ${name}: ${emitTsType(schema, 1, undefined, usedEnums)};`);
554
+ }
555
+ }
556
+
557
+ const lines: string[] = [header, ""];
558
+
559
+ if (usedEnums.size > 0) {
560
+ lines.push(`import type { ${[...usedEnums].sort().join(", ")} } from "@/endpoints/enums";`);
561
+ }
562
+ const typeImports = new Set<string>(["ApiRequestData"]);
563
+ if (hasEnvelope) {
564
+ typeImports.add("ApiObjectResponse");
565
+ }
566
+ lines.push(`import type { ${[...typeImports].sort().join(", ")} } from "@/endpoints/Types";`, ``);
567
+
568
+ if (spec.summary) {
569
+ lines.push(`/** ${escapeComment(spec.summary)} */`);
570
+ } else {
571
+ lines.push(`/** \`${spec.method.toUpperCase()} ${spec.relativePath}\` */`);
572
+ }
573
+
574
+ if (hasBody) {
575
+ lines.push(`export type ${spec.name}Body = ${bodyTypeStr};`, ``);
576
+ if (!spec.name.endsWith("Request") && !spec.name.endsWith("Body")) {
577
+ lines.push(
578
+ `/** Alias of the API request body (use for assignability checks with form Zod). */`,
579
+ `export type ${spec.name}Request = ${spec.name}Body;`,
580
+ ``,
581
+ );
582
+ }
583
+ }
584
+
585
+ if (hasQuery) {
586
+ lines.push(`export type ${spec.name}Params = {`);
587
+ lines.push(...queryLines);
588
+ lines.push(`};`, ``);
589
+
590
+ if (defaultEntries.length > 0) {
591
+ lines.push(`export const ${spec.name}DefaultParams: Partial<${spec.name}Params> = {`);
592
+ for (const [key, val] of defaultEntries) {
593
+ lines.push(` ${safeKey(key)}: ${JSON.stringify(val)},`);
594
+ }
595
+ lines.push(`} as const;`, ``);
596
+ }
597
+ }
598
+
599
+ if (hasPath) {
600
+ lines.push(`export type ${spec.name}Path = {`);
601
+ lines.push(...pathLines);
602
+ lines.push(`};`, ``);
603
+ }
604
+
605
+ const paramsType = hasQuery ? `${spec.name}Params` : "undefined";
606
+ const bodyType = hasBody ? `${spec.name}Body` : "undefined";
607
+ const pathType = hasPath ? `${spec.name}Path` : "undefined";
608
+
609
+ if (hasPath) {
610
+ lines.push(`export type ${spec.name}Call = {`);
611
+ lines.push(` request: ApiRequestData<${paramsType}, ${bodyType}> & { path: ${pathType} };`);
612
+ lines.push(`};`, ``);
613
+ } else if (hasBody || hasQuery) {
614
+ lines.push(`export type ${spec.name}Call = {`);
615
+ lines.push(` request: ApiRequestData<${paramsType}, ${bodyType}>;`);
616
+ lines.push(`};`, ``);
617
+ } else {
618
+ lines.push(`export type ${spec.name}Call = {`);
619
+ lines.push(` request?: ApiRequestData;`);
620
+ lines.push(`};`, ``);
621
+ }
622
+
623
+ if (resultSchema) {
624
+ lines.push(`export type ${spec.name}Result = ${resultTypeStr};`, ``);
625
+ if (hasEnvelope) {
626
+ lines.push(`export type ${spec.name}Response = ApiObjectResponse<${spec.name}Result>;`, ``);
627
+ } else {
628
+ lines.push(`export type ${spec.name}Response = ${spec.name}Result;`, ``);
629
+ }
630
+ } else {
631
+ lines.push(`export type ${spec.name}Response = void;`, ``);
632
+ }
633
+
634
+ return `${lines.join("\n").replace(/\n+$/, "")}\n`;
635
+ }
636
+
637
+ function emitCallFile(spec: EndpointSpec, header: string): string {
638
+ const hasBody = Boolean(spec.bodySchema);
639
+ const hasQuery = spec.queryParams.length > 0;
640
+ const hasResponse = Boolean(spec.resultSchema);
641
+ const hasPath = spec.pathParams.length > 0 || spec.pathTemplate.includes("request.path.");
642
+
643
+ const lines: string[] = [header, ``, `import { api } from "@/utils/axios-instance";`];
644
+
645
+ if (spec.isMultipart) {
646
+ lines.push(`import { toApiBody } from "@/utils/form-data";`);
647
+ }
648
+
649
+ if (hasQuery) {
650
+ lines.push(`import { toSearchParams } from "@/utils/search-params";`);
651
+ }
652
+
653
+ const callTypeImports = [`${spec.name}Call`, `${spec.name}Response`];
654
+ if (spec.method === "get") {
655
+ if (hasQuery) {
656
+ callTypeImports.push(`${spec.name}Params`);
657
+ }
658
+ if (hasPath) {
659
+ callTypeImports.push(`${spec.name}Path`);
660
+ }
661
+ }
662
+
663
+ lines.push(``, emitTypeImport(callTypeImports, `./${spec.name}.type`), ``);
664
+
665
+ if (spec.summary) {
666
+ lines.push(
667
+ `/** ${escapeComment(spec.summary)} — \`${spec.method.toUpperCase()} ${spec.relativePath}\` */`,
668
+ );
669
+ } else {
670
+ lines.push(`/** \`${spec.method.toUpperCase()} ${spec.relativePath}\` */`);
671
+ }
672
+
673
+ const usesRequest =
674
+ hasBody || hasQuery || spec.pathTemplate.includes("request.path.") || spec.isMultipart;
675
+
676
+ if (usesRequest) {
677
+ lines.push(`export default async function ${spec.name}({`);
678
+ lines.push(` request,`);
679
+ lines.push(`}: ${spec.name}Call): Promise<${spec.name}Response> {`);
680
+ } else {
681
+ lines.push(`export default async function ${spec.name}(`);
682
+ lines.push(` _call: ${spec.name}Call = {},`);
683
+ lines.push(`): Promise<${spec.name}Response> {`);
684
+ }
685
+
686
+ let finalUrl: string;
687
+ if (hasQuery) {
688
+ // Keep trailing slash before query: `/clinics/?limit=8` (not `/clinics?limit=8/`).
689
+ const base = spec.pathTemplate.endsWith("/") ? spec.pathTemplate : `${spec.pathTemplate}/`;
690
+ finalUrl = `\`${base}\${toSearchParams(request?.params)}\``;
691
+ } else if (spec.pathTemplate.includes("request.path.")) {
692
+ finalUrl = `\`${spec.pathTemplate}\``;
693
+ } else {
694
+ finalUrl = `"${spec.relativePath}"`;
695
+ }
696
+
697
+ const method = spec.method;
698
+ const args: string[] = [finalUrl];
699
+ if (spec.isMultipart) {
700
+ lines.push(
701
+ ` const payload = toApiBody(request?.body as Record<string, unknown> | undefined);`,
702
+ );
703
+ args.push("payload");
704
+ } else if (hasBody && method !== "get" && method !== "delete" && method !== "head") {
705
+ args.push("request?.body");
706
+ }
707
+
708
+ const isBinaryResponse = spec.resultSchema?.format === "binary";
709
+ if (isBinaryResponse) {
710
+ if (method === "get" || method === "delete" || method === "head") {
711
+ args.push('{ responseType: "blob" }');
712
+ } else {
713
+ if (args.length === 1) {
714
+ args.push("undefined");
715
+ }
716
+ args.push('{ responseType: "blob" }');
717
+ }
718
+ }
719
+
720
+ if (hasResponse) {
721
+ lines.push(` const { data } = await api.${method}<${spec.name}Response>(${args.join(", ")});`);
722
+ lines.push(` return data;`);
723
+ } else {
724
+ lines.push(` await api.${method}(${args.join(", ")});`);
725
+ }
726
+
727
+ lines.push(`}`);
728
+
729
+ if (spec.method === "get") {
730
+ lines.push(``, emitQueryKeyFunction(spec));
731
+ }
732
+
733
+ return `${lines.join("\n")}\n`;
734
+ }
735
+
736
+ function emitQueryKeyFunction(spec: EndpointSpec): string {
737
+ const hasQuery = spec.queryParams.length > 0;
738
+ const hasPath = spec.pathParams.length > 0 || spec.pathTemplate.includes("request.path.");
739
+ const fnName = `get${spec.name}QueryKey`;
740
+
741
+ if (!hasPath && !hasQuery) {
742
+ return [
743
+ `/** TanStack Query cache key factory for \`${spec.name}\`. */`,
744
+ `export function ${fnName}() {`,
745
+ ` return ["${spec.resourceRoot}", "${spec.relativePath}"] as const;`,
746
+ `}`,
747
+ ].join("\n");
748
+ }
749
+
750
+ if (!hasPath && hasQuery) {
751
+ return [
752
+ `/** TanStack Query cache key factory for \`${spec.name}\`. */`,
753
+ `export function ${fnName}(`,
754
+ ` args?: ${spec.name}Params | ${spec.name}Call | ${spec.name}Call["request"],`,
755
+ `) {`,
756
+ ` const request =`,
757
+ ` args && typeof args === "object" && "request" in args ? args.request : args;`,
758
+ ` const params =`,
759
+ ` request && typeof request === "object" && "params" in request ? request.params : request;`,
760
+ ` return ["${spec.resourceRoot}", "${spec.relativePath}", params] as const;`,
761
+ `}`,
762
+ ].join("\n");
763
+ }
764
+
765
+ const keyPathTemplate = spec.relativePath.replace(
766
+ /\{([^}]+)\}/g,
767
+ (_, name) => `\${path?.${name} ?? ":${name}"}`,
768
+ );
769
+
770
+ const queryTypeParam = hasQuery ? `params?: ${spec.name}Params;` : "";
771
+ const argsType = [
772
+ `${spec.name}Call`,
773
+ `${spec.name}Call["request"]`,
774
+ `{ path: ${spec.name}Path; ${queryTypeParam} }`,
775
+ ].join(" | ");
776
+
777
+ const lines = [
778
+ `/** TanStack Query cache key factory for \`${spec.name}\`. */`,
779
+ `export function ${fnName}(args?: ${argsType}) {`,
780
+ ` const request =`,
781
+ ` args && typeof args === "object" && "request" in args ? args.request : args;`,
782
+ ` const path =`,
783
+ ` request && typeof request === "object" && "path" in request ? request.path : undefined;`,
784
+ ];
785
+
786
+ if (hasQuery) {
787
+ lines.push(
788
+ ` const params =`,
789
+ ` request && typeof request === "object" && "params" in request ? request.params : undefined;`,
790
+ ` return ["${spec.resourceRoot}", \`${keyPathTemplate}\`, params] as const;`,
791
+ );
792
+ } else {
793
+ lines.push(` return ["${spec.resourceRoot}", \`${keyPathTemplate}\`] as const;`);
794
+ }
795
+ lines.push(`}`);
796
+
797
+ return lines.join("\n");
798
+ }
799
+
800
+ function emitResourceIndex(specs: EndpointSpec[], header: string): string {
801
+ const lines = [header, ``];
802
+ // Match `simple-import-sort/exports`: sort by module path (not operation name).
803
+ // Path params like `{clinic_id}` sort as `clinic_id` (punctuation ignored by Collator).
804
+ const sorted = [...specs].sort((a, b) => {
805
+ const pathA = `./${a.folderSegments.join("/")}/${a.name}`;
806
+ const pathB = `./${b.folderSegments.join("/")}/${b.name}`;
807
+ return compareImportExportSources(pathA, pathB) || compareStrings(a.name, b.name);
808
+ });
809
+
810
+ for (const spec of sorted) {
811
+ const relDir = `./${spec.folderSegments.join("/")}`;
812
+ if (spec.method === "get") {
813
+ lines.push(
814
+ `export { get${spec.name}QueryKey, default as ${spec.name} } from "${relDir}/${spec.name}";`,
815
+ );
816
+ } else {
817
+ lines.push(`export { default as ${spec.name} } from "${relDir}/${spec.name}";`);
818
+ }
819
+
820
+ const typeExports = [
821
+ `${spec.name}Call`,
822
+ `${spec.name}Response`,
823
+ ...(spec.bodySchema ? [`${spec.name}Body`] : []),
824
+ ...(spec.bodySchema && !spec.name.endsWith("Request") && !spec.name.endsWith("Body")
825
+ ? [`${spec.name}Request`]
826
+ : []),
827
+ ...(spec.resultSchema ? [`${spec.name}Result`] : []),
828
+ ...(spec.queryParams.length ? [`${spec.name}Params`] : []),
829
+ ].sort(compareStrings);
830
+
831
+ lines.push(`export type {`);
832
+ for (const typeName of typeExports) {
833
+ lines.push(` ${typeName},`);
834
+ }
835
+ lines.push(`} from "${relDir}/${spec.name}.type";`);
836
+
837
+ if (spec.hasDefaultParams) {
838
+ lines.push(`export { ${spec.name}DefaultParams } from "${relDir}/${spec.name}.type";`);
839
+ }
840
+ }
841
+
842
+ lines.push(``);
843
+ return lines.join("\n");
844
+ }
845
+
846
+ /** Same path normalization + collator as `eslint-plugin-simple-import-sort`. */
847
+ const importExportCollator = new Intl.Collator("en", {
848
+ sensitivity: "base",
849
+ numeric: true,
850
+ });
851
+
852
+ function compareStrings(a: string, b: string): number {
853
+ return importExportCollator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0);
854
+ }
855
+
856
+ function compareImportExportSources(a: string, b: string): number {
857
+ const normalizedA = normalizeImportExportSource(a);
858
+ const normalizedB = normalizeImportExportSource(b);
859
+ return compareStrings(normalizedA, normalizedB) || compareStrings(a, b);
860
+ }
861
+
862
+ function normalizeImportExportSource(source: string): string {
863
+ return source
864
+ .replace(/^[./]*\.$/, "$&/")
865
+ .replace(/^[./]*\/$/, "$&,")
866
+ .replace(/[./_-]/g, (char) => {
867
+ switch (char) {
868
+ case ".":
869
+ return "_";
870
+ case "/":
871
+ return "-";
872
+ case "_":
873
+ return ".";
874
+ case "-":
875
+ return "/";
876
+ default:
877
+ return char;
878
+ }
879
+ });
880
+ }
881
+
882
+ function emitTypeImport(names: string[], from: string): string {
883
+ const sorted = [...names].sort((a, b) => a.localeCompare(b));
884
+ if (sorted.length === 1) {
885
+ return `import type { ${sorted[0]} } from "${from}";`;
886
+ }
887
+ return [`import type {`, ...sorted.map((name) => ` ${name},`), `} from "${from}";`].join("\n");
888
+ }
889
+
890
+ function formatGeneratedWithPrettier(outDir: string, relFiles: string[]) {
891
+ if (relFiles.length === 0) {
892
+ return;
893
+ }
894
+
895
+ try {
896
+ console.log("Formatting generated files with Prettier…");
897
+ execFileSync("pnpm", ["exec", "prettier", "--write", outDir], {
898
+ cwd: ROOT,
899
+ stdio: "inherit",
900
+ });
901
+ } catch {
902
+ console.log("Prettier not available — skipping format.");
903
+ }
904
+
905
+ try {
906
+ console.log("Linting & sorting generated files with ESLint…");
907
+ execFileSync("pnpm", ["exec", "eslint", "--fix", outDir], {
908
+ cwd: ROOT,
909
+ stdio: "inherit",
910
+ });
911
+ } catch {
912
+ // ignore
913
+ }
914
+ }
915
+
916
+ function extractPathNames(spec: EndpointSpec): string[] {
917
+ const fromTemplate = [...spec.pathTemplate.matchAll(/request\.path\.([A-Za-z0-9_]+)/g)].map(
918
+ (match) => match[1]!,
919
+ );
920
+ if (fromTemplate.length) {
921
+ return fromTemplate;
922
+ }
923
+ return spec.pathParams.map((parameter) => parameter.name);
924
+ }
925
+
926
+ /* -------------------------------------------------------------------------- */
927
+ /* Schema → Zod / TS types */
928
+ /* -------------------------------------------------------------------------- */
929
+
930
+ function schemaHasBinary(schema: JsonSchema | null): boolean {
931
+ if (!schema) {
932
+ return false;
933
+ }
934
+ if (schema.format === "binary") {
935
+ return true;
936
+ }
937
+ if (schema.properties) {
938
+ return Object.entries(schema.properties).some(([key, value]) =>
939
+ isUploadField(key, value) ? true : schemaHasBinary(value),
940
+ );
941
+ }
942
+ if (schema.items) {
943
+ return schemaHasBinary(schema.items);
944
+ }
945
+ if (schema.oneOf) {
946
+ return schema.oneOf.some((value) => schemaHasBinary(value));
947
+ }
948
+ if (schema.allOf) {
949
+ return schema.allOf.some((value) => schemaHasBinary(value));
950
+ }
951
+ return false;
952
+ }
953
+
954
+ /** ImageField-like props that OpenAPI often types as plain string (inputs only). */
955
+ function isUploadField(key: string, schema: JsonSchema): boolean {
956
+ if (schema.format === "uri") {
957
+ return false;
958
+ }
959
+ if (schema.format === "binary") {
960
+ return true;
961
+ }
962
+ if (key !== "logo" && key !== "avatar") {
963
+ return false;
964
+ }
965
+ const description = (schema.description ?? "").toLowerCase();
966
+ return (
967
+ description.includes("image") ||
968
+ description.includes("logo") ||
969
+ description.includes("avatar") ||
970
+ schema.type === "string" ||
971
+ !schema.type
972
+ );
973
+ }
974
+
975
+ function schemaHasUploadField(schema: JsonSchema | null): boolean {
976
+ return schemaHasBinary(schema);
977
+ }
978
+
979
+ function createResolver(schemas: Record<string, JsonSchema>) {
980
+ return function resolve(schema: JsonSchema, seen = new Set<string>()): JsonSchema {
981
+ if (schema.$ref) {
982
+ const name = schema.$ref.replace("#/components/schemas/", "");
983
+ if (seen.has(name)) {
984
+ return { type: "object", additionalProperties: true };
985
+ }
986
+ const target = schemas[name];
987
+ if (!target) {
988
+ return { type: "unknown" as unknown as string };
989
+ }
990
+ if (name.endsWith("Enum") && Array.isArray(target.enum)) {
991
+ const enumTypeName = name.slice(0, -"Enum".length);
992
+ return {
993
+ ...target,
994
+ _enumType: enumTypeName,
995
+ nullable: Boolean(schema.nullable || target.nullable),
996
+ description: schema.description ?? target.description,
997
+ };
998
+ }
999
+ seen.add(name);
1000
+ return resolve(target, seen);
1001
+ }
1002
+
1003
+ if (schema.allOf?.length) {
1004
+ const merged = mergeAllOf(schema.allOf.map((item) => resolve(item, seen)));
1005
+ return {
1006
+ ...merged,
1007
+ nullable: Boolean(schema.nullable || merged.nullable),
1008
+ readOnly: schema.readOnly ?? merged.readOnly,
1009
+ description: schema.description ?? merged.description,
1010
+ };
1011
+ }
1012
+
1013
+ if (schema.oneOf?.length || schema.anyOf?.length) {
1014
+ const variants = (schema.oneOf ?? schema.anyOf ?? []).map((item) => resolve(item, seen));
1015
+ const withoutNull = variants.filter((item) => item.type !== "null" && item.const !== null);
1016
+ const hasNull = variants.some(
1017
+ (item) => item.type === "null" || item.nullable || item.const === null,
1018
+ );
1019
+ if (withoutNull.length === 1 && hasNull) {
1020
+ return { ...withoutNull[0]!, nullable: true };
1021
+ }
1022
+ if (withoutNull.length === 1) {
1023
+ return withoutNull[0]!;
1024
+ }
1025
+ return { oneOf: withoutNull, nullable: hasNull };
1026
+ }
1027
+
1028
+ if (schema.properties) {
1029
+ const properties: Record<string, JsonSchema> = {};
1030
+ for (const [key, value] of Object.entries(schema.properties)) {
1031
+ properties[key] = resolve(value, new Set(seen));
1032
+ }
1033
+ return { ...schema, properties };
1034
+ }
1035
+
1036
+ if (schema.items) {
1037
+ return { ...schema, items: resolve(schema.items, new Set(seen)) };
1038
+ }
1039
+
1040
+ return schema;
1041
+ };
1042
+ }
1043
+
1044
+ function mergeAllOf(parts: JsonSchema[]): JsonSchema {
1045
+ if (parts.length === 1) {
1046
+ return parts[0]!;
1047
+ }
1048
+
1049
+ const enumType = parts.find((part) => (part as { _enumType?: string })._enumType) as
1050
+ { _enumType?: string } | undefined;
1051
+ const format = parts.find((part) => part.format)?.format;
1052
+
1053
+ const objectParts = parts.filter(
1054
+ (part) => part.properties && Object.keys(part.properties).length > 0,
1055
+ );
1056
+
1057
+ if (objectParts.length === 0) {
1058
+ const base =
1059
+ [...parts].reverse().find((part) => part.enum || part.type || part.oneOf) ?? parts[0]!;
1060
+ return {
1061
+ ...base,
1062
+ _enumType: enumType?._enumType,
1063
+ format: format ?? base.format,
1064
+ nullable: parts.some((part) => part.nullable) || base.nullable,
1065
+ description: parts.find((part) => part.description)?.description ?? base.description,
1066
+ };
1067
+ }
1068
+
1069
+ const properties: Record<string, JsonSchema> = {};
1070
+ const required = new Set<string>();
1071
+ let nullable = false;
1072
+
1073
+ for (const part of objectParts) {
1074
+ if (part.nullable) {
1075
+ nullable = true;
1076
+ }
1077
+ if (part.properties) {
1078
+ Object.assign(properties, part.properties);
1079
+ }
1080
+ for (const key of part.required ?? []) {
1081
+ required.add(key);
1082
+ }
1083
+ }
1084
+
1085
+ return {
1086
+ type: "object",
1087
+ properties,
1088
+ required: [...required],
1089
+ nullable,
1090
+ _enumType: enumType?._enumType,
1091
+ format,
1092
+ };
1093
+ }
1094
+
1095
+ function formatPropJsDoc(schema: JsonSchema, pad: string): string[] {
1096
+ const docLines: string[] = [];
1097
+ if (schema.description) {
1098
+ const trimmed = schema.description.trim();
1099
+ if (trimmed) {
1100
+ for (const line of trimmed.split("\n")) {
1101
+ docLines.push(` * ${line.replace(/\*\//g, "*\\/")}`);
1102
+ }
1103
+ }
1104
+ }
1105
+ if (schema.format === "date") {
1106
+ docLines.push(` * @format YYYY-MM-DD`);
1107
+ } else if (schema.format === "date-time") {
1108
+ docLines.push(` * @format ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)`);
1109
+ }
1110
+ if (schema.default !== undefined) {
1111
+ docLines.push(` * @default ${JSON.stringify(schema.default)}`);
1112
+ }
1113
+ if (docLines.length === 0) {
1114
+ return [];
1115
+ }
1116
+ return [`${pad}/**`, ...docLines.map((l) => `${pad}${l}`), `${pad} */`];
1117
+ }
1118
+
1119
+ function emitTsType(
1120
+ schema: JsonSchema,
1121
+ depth: number,
1122
+ propertyKey?: string,
1123
+ usedEnums?: Set<string>,
1124
+ ): string {
1125
+ if ((schema as { _enumType?: string })._enumType) {
1126
+ const enumType = (schema as { _enumType: string })._enumType;
1127
+ usedEnums?.add(enumType);
1128
+ return schema.nullable ? `${enumType} | null` : enumType;
1129
+ }
1130
+
1131
+ if (schema.oneOf?.length) {
1132
+ const parts = [
1133
+ ...new Set(schema.oneOf.map((item) => emitTsType(item, depth, undefined, usedEnums))),
1134
+ ];
1135
+ const union = parts.join(" | ");
1136
+ return schema.nullable ? `${union} | null` : union;
1137
+ }
1138
+
1139
+ if (schema.enum) {
1140
+ const union = schema.enum.map((value) => JSON.stringify(value)).join(" | ");
1141
+ return schema.nullable ? `${union} | null` : union;
1142
+ }
1143
+
1144
+ if (propertyKey && isUploadField(propertyKey, schema)) {
1145
+ return schema.nullable ? "File | null" : "File";
1146
+ }
1147
+
1148
+ const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
1149
+ const nullable = schema.nullable || types.includes("null");
1150
+ const primary = types.find((type) => type !== "null") ?? inferType(schema);
1151
+
1152
+ let expr: string;
1153
+ switch (primary) {
1154
+ case "string":
1155
+ expr = schema.format === "binary" ? "Blob" : "string";
1156
+ break;
1157
+ case "integer":
1158
+ case "number":
1159
+ expr = "number";
1160
+ break;
1161
+ case "boolean":
1162
+ expr = "boolean";
1163
+ break;
1164
+ case "array":
1165
+ expr = `Array<${schema.items ? emitTsType(schema.items, depth, undefined, usedEnums) : "unknown"}>`;
1166
+ break;
1167
+ case "object":
1168
+ default: {
1169
+ if (!schema.properties) {
1170
+ expr = "Record<string, unknown>";
1171
+ break;
1172
+ }
1173
+ const required = new Set(schema.required ?? []);
1174
+ const pad = " ".repeat(depth + 1);
1175
+ const close = " ".repeat(depth);
1176
+ const entries = Object.entries(schema.properties).map(([key, value]) => {
1177
+ const optional = required.has(key) ? "" : "?";
1178
+ const doc = formatPropJsDoc(value, pad);
1179
+ const fieldLine = `${pad}${safeKey(key)}${optional}: ${emitTsType(value, depth + 1, key, usedEnums)};`;
1180
+ return doc.length > 0 ? `${doc.join("\n")}\n${fieldLine}` : fieldLine;
1181
+ });
1182
+ expr = `{\n${entries.join("\n")}\n${close}}`;
1183
+ break;
1184
+ }
1185
+ }
1186
+
1187
+ return nullable ? `${expr} | null` : expr;
1188
+ }
1189
+
1190
+ function inferType(schema: JsonSchema): string {
1191
+ if (schema.properties) {
1192
+ return "object";
1193
+ }
1194
+ if (schema.items) {
1195
+ return "array";
1196
+ }
1197
+ if (schema.format === "binary") {
1198
+ return "string";
1199
+ }
1200
+ return "unknown";
1201
+ }
1202
+
1203
+ /* -------------------------------------------------------------------------- */
1204
+ /* Filesystem sync */
1205
+ /* -------------------------------------------------------------------------- */
1206
+
1207
+ async function readManifest(outDir: string): Promise<Manifest | null> {
1208
+ try {
1209
+ const raw = await readFile(path.join(outDir, MANIFEST_NAME), "utf8");
1210
+ return JSON.parse(raw) as Manifest;
1211
+ } catch {
1212
+ return null;
1213
+ }
1214
+ }
1215
+
1216
+ async function removeStaleGeneratedFiles(
1217
+ outDir: string,
1218
+ previous: Manifest | null,
1219
+ nextFiles: Set<string>,
1220
+ preserve: string[],
1221
+ ): Promise<string[]> {
1222
+ const removed: string[] = [];
1223
+ const preserveSet = new Set(preserve);
1224
+
1225
+ // Always remove previous manifest entries that disappeared
1226
+ for (const rel of previous?.files ?? []) {
1227
+ if (rel === MANIFEST_NAME) {
1228
+ continue;
1229
+ }
1230
+ if (nextFiles.has(rel)) {
1231
+ continue;
1232
+ }
1233
+ if (preserveSet.has(rel.split("/")[0]!)) {
1234
+ continue;
1235
+ }
1236
+ const full = path.join(outDir, rel);
1237
+ try {
1238
+ await rm(full, { force: true });
1239
+ removed.push(rel);
1240
+ } catch {
1241
+ // ignore
1242
+ }
1243
+ }
1244
+
1245
+ // Remove mistaken nested layouts from earlier generator bugs / pre-codegen paths
1246
+ const legacyGlobs = ["Auth/login/username", "Auth/auth", "Users/users", "Health/health"];
1247
+ for (const rel of legacyGlobs) {
1248
+ const full = path.join(outDir, rel);
1249
+ try {
1250
+ await rm(full, { recursive: true, force: true });
1251
+ removed.push(rel);
1252
+ } catch {
1253
+ // ignore
1254
+ }
1255
+ }
1256
+ return removed;
1257
+ }
1258
+
1259
+ async function pruneEmptyDirs(outDir: string, preserve: string[]) {
1260
+ const preserveSet = new Set(preserve);
1261
+
1262
+ async function walk(dir: string): Promise<boolean> {
1263
+ let entries;
1264
+ try {
1265
+ entries = await readdir(dir, { withFileTypes: true });
1266
+ } catch {
1267
+ return false;
1268
+ }
1269
+
1270
+ let keep = false;
1271
+ for (const entry of entries) {
1272
+ const full = path.join(dir, entry.name);
1273
+ const rel = path.relative(outDir, full).replaceAll("\\", "/");
1274
+ if (preserveSet.has(entry.name) || preserveSet.has(rel.split("/")[0]!)) {
1275
+ keep = true;
1276
+ continue;
1277
+ }
1278
+ if (entry.isDirectory()) {
1279
+ const childKeep = await walk(full);
1280
+ if (!childKeep) {
1281
+ await rm(full, { recursive: true, force: true });
1282
+ } else {
1283
+ keep = true;
1284
+ }
1285
+ } else {
1286
+ keep = true;
1287
+ }
1288
+ }
1289
+ return keep || dir === outDir;
1290
+ }
1291
+
1292
+ await walk(outDir);
1293
+ }
1294
+
1295
+ /* -------------------------------------------------------------------------- */
1296
+ /* Helpers */
1297
+ /* -------------------------------------------------------------------------- */
1298
+
1299
+ function groupBy<T>(items: T[], key: (item: T) => string): Record<string, T[]> {
1300
+ const result: Record<string, T[]> = {};
1301
+ for (const item of items) {
1302
+ const group = key(item);
1303
+ (result[group] ??= []).push(item);
1304
+ }
1305
+ return result;
1306
+ }
1307
+
1308
+ function trimSlashes(value: string) {
1309
+ return value.replace(/^\/+|\/+$/g, "");
1310
+ }
1311
+
1312
+ function stripBraces(value: string) {
1313
+ return value.replace(/^\{|\}$/g, "");
1314
+ }
1315
+
1316
+ function pascalCase(value: string) {
1317
+ return value
1318
+ .replace(/[^a-zA-Z0-9]+/g, " ")
1319
+ .split(" ")
1320
+ .filter(Boolean)
1321
+ .map((part) => part[0]!.toUpperCase() + part.slice(1))
1322
+ .join("");
1323
+ }
1324
+
1325
+ function camelCase(value: string) {
1326
+ const pascal = pascalCase(value);
1327
+ return pascal ? pascal[0]!.toLowerCase() + pascal.slice(1) : pascal;
1328
+ }
1329
+
1330
+ function safeKey(key: string) {
1331
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ? key : JSON.stringify(key);
1332
+ }
1333
+
1334
+ function escapeComment(value: string) {
1335
+ return value.replace(/\*\//g, "*\\/").replace(/\n/g, " ");
1336
+ }
1337
+
1338
+ function getParamDefaultValue(
1339
+ parameter: Parameter,
1340
+ resolve?: (s: JsonSchema) => JsonSchema,
1341
+ ): unknown {
1342
+ if (parameter.default !== undefined) {
1343
+ return parameter.default;
1344
+ }
1345
+ if (parameter.schema?.default !== undefined) {
1346
+ return parameter.schema.default;
1347
+ }
1348
+ if (parameter.schema && resolve) {
1349
+ const resolved = resolve(parameter.schema);
1350
+ if (resolved?.default !== undefined) {
1351
+ return resolved.default;
1352
+ }
1353
+ }
1354
+ return undefined;
1355
+ }
1356
+
1357
+ function formatParamJsDoc(description?: string, defaultValue?: unknown, format?: string): string[] {
1358
+ const docLines: string[] = [];
1359
+ if (description) {
1360
+ const trimmed = description.trim();
1361
+ if (trimmed) {
1362
+ for (const line of trimmed.split("\n")) {
1363
+ docLines.push(` * ${line.replace(/\*\//g, "*\\/")}`);
1364
+ }
1365
+ }
1366
+ }
1367
+ if (format === "date") {
1368
+ docLines.push(` * @format YYYY-MM-DD`);
1369
+ } else if (format === "date-time") {
1370
+ docLines.push(` * @format ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)`);
1371
+ }
1372
+ if (defaultValue !== undefined) {
1373
+ docLines.push(` * @default ${JSON.stringify(defaultValue)}`);
1374
+ }
1375
+ if (docLines.length === 0) {
1376
+ return [];
1377
+ }
1378
+ return [` /**`, ...docLines.map((l) => ` ${l}`), ` */`];
1379
+ }
1380
+
1381
+ main().catch((error) => {
1382
+ console.error(error);
1383
+ process.exitCode = 1;
1384
+ });