@contractkit/plugin-typescript 0.16.1

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 (60) hide show
  1. package/.turbo/turbo-build$colon$ci.log +35 -0
  2. package/.turbo/turbo-build.log +15 -0
  3. package/.turbo/turbo-test$colon$ci.log +81 -0
  4. package/.turbo/turbo-test.log +19 -0
  5. package/CHANGELOG.md +151 -0
  6. package/README.md +153 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/clover.xml +1882 -0
  10. package/coverage/coverage-final.json +9 -0
  11. package/coverage/favicon.png +0 -0
  12. package/coverage/index.html +131 -0
  13. package/coverage/prettify.css +1 -0
  14. package/coverage/prettify.js +2 -0
  15. package/coverage/sort-arrow-sprite.png +0 -0
  16. package/coverage/sorter.js +210 -0
  17. package/coverage/src/codegen-contract.ts.html +3331 -0
  18. package/coverage/src/codegen-operation.ts.html +2530 -0
  19. package/coverage/src/codegen-plain-types.ts.html +901 -0
  20. package/coverage/src/codegen-sdk.ts.html +2797 -0
  21. package/coverage/src/index.html +206 -0
  22. package/coverage/src/index.ts.html +1360 -0
  23. package/coverage/src/path-utils.ts.html +649 -0
  24. package/coverage/src/ts-render.ts.html +592 -0
  25. package/coverage/tests/helpers.ts.html +826 -0
  26. package/coverage/tests/index.html +116 -0
  27. package/dist/codegen-contract.d.ts +56 -0
  28. package/dist/codegen-contract.d.ts.map +1 -0
  29. package/dist/codegen-operation.d.ts +25 -0
  30. package/dist/codegen-operation.d.ts.map +1 -0
  31. package/dist/codegen-plain-types.d.ts +10 -0
  32. package/dist/codegen-plain-types.d.ts.map +1 -0
  33. package/dist/codegen-sdk.d.ts +38 -0
  34. package/dist/codegen-sdk.d.ts.map +1 -0
  35. package/dist/index.d.ts +77 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +3162 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/path-utils.d.ts +15 -0
  40. package/dist/path-utils.d.ts.map +1 -0
  41. package/dist/ts-render.d.ts +20 -0
  42. package/dist/ts-render.d.ts.map +1 -0
  43. package/eslint.config.js +6 -0
  44. package/package.json +43 -0
  45. package/src/codegen-contract.ts +1082 -0
  46. package/src/codegen-operation.ts +815 -0
  47. package/src/codegen-plain-types.ts +272 -0
  48. package/src/codegen-sdk.ts +904 -0
  49. package/src/index.ts +425 -0
  50. package/src/path-utils.ts +188 -0
  51. package/src/ts-render.ts +169 -0
  52. package/tests/codegen-contract.test.ts +1004 -0
  53. package/tests/codegen-operation.test.ts +939 -0
  54. package/tests/codegen-plain-types.test.ts +636 -0
  55. package/tests/codegen-sdk.test.ts +1500 -0
  56. package/tests/codegen-server.test.ts +192 -0
  57. package/tests/helpers.ts +247 -0
  58. package/tests/pipeline.test.ts +372 -0
  59. package/tsconfig.json +9 -0
  60. package/vitest.config.ts +14 -0
package/src/index.ts ADDED
@@ -0,0 +1,425 @@
1
+ import { resolve, join, relative, dirname, basename } from 'node:path';
2
+ import { generateContract } from './codegen-contract.js';
3
+ import { generateOp } from './codegen-operation.js';
4
+ import type { ContractKitPlugin } from '@contractkit/core';
5
+ import {
6
+ generateSdk,
7
+ generateSdkOptions,
8
+ generateSdkAggregator,
9
+ deriveClientClassName,
10
+ deriveClientPropertyName,
11
+ hasPublicOperations,
12
+ } from './codegen-sdk.js';
13
+ import { generatePlainTypes } from './codegen-plain-types.js';
14
+ import {
15
+ TEMPLATE_VAR_RE,
16
+ resolveTemplate,
17
+ commonDir,
18
+ computeOpOutPath,
19
+ computeContractOutPath,
20
+ computeSdkOutPath,
21
+ computeSdkTypeOutPath,
22
+ generateBarrelFiles,
23
+ computePubliclyReachableTypes,
24
+ } from './path-utils.js';
25
+
26
+ // ─── Sub-config interfaces ─────────────────────────────────────────────────
27
+
28
+ export interface ServerConfig {
29
+ /** Directory (relative to rootDir) where server files are written. Default: rootDir. */
30
+ baseDir?: string;
31
+ /**
32
+ * When true, `output.types` emits Zod schema files (via `generateContract`).
33
+ * When false/omitted, `output.types` emits plain TypeScript interfaces.
34
+ */
35
+ zod?: boolean;
36
+ output?: {
37
+ /** Path template for Koa router files. Supports {filename}, {dir}, {area}. Default: `{filename}.router.ts`. */
38
+ routes?: string;
39
+ /**
40
+ * Path template for type/schema files. Supports {filename}, {dir}, {area}.
41
+ * Generates Zod schemas when `zod: true`, otherwise plain TypeScript interfaces.
42
+ */
43
+ types?: string;
44
+ };
45
+ /** Import path template for service implementations. Supports {module}. */
46
+ servicePathTemplate?: string;
47
+ /**
48
+ * Whether to emit handlers for operations marked `internal`. Defaults to `true` —
49
+ * the server still needs routes for internal endpoints. Set to `false` to omit them.
50
+ */
51
+ includeInternal?: boolean;
52
+ }
53
+
54
+ export interface SdkConfig {
55
+ /** Directory (relative to rootDir) where SDK files are written. Default: rootDir. */
56
+ baseDir?: string;
57
+ /** Name used for the aggregator SDK class (e.g. "homegrown" → `HomegrownSdk`). */
58
+ name?: string;
59
+ /**
60
+ * When true, `output.types` emits Zod schema files (via `generateContract`).
61
+ * When false/omitted, `output.types` emits plain TypeScript interfaces.
62
+ */
63
+ zod?: boolean;
64
+ output?: {
65
+ /** Path template for the SDK aggregator file. Supports {name}. Default: `sdk.ts`. */
66
+ sdk?: string;
67
+ /** Path template for SDK type files. Supports {filename}, {dir}, {area}. */
68
+ types?: string;
69
+ /** Path template for client class files. Supports {filename}, {dir}, {area}. */
70
+ clients?: string;
71
+ };
72
+ /**
73
+ * Whether to emit SDK methods for operations marked `internal`. Defaults to `false` —
74
+ * internal ops are omitted from the SDK so consumers don't pick them up. Set to `true`
75
+ * for an internal-use SDK that should expose them.
76
+ */
77
+ includeInternal?: boolean;
78
+ }
79
+
80
+ export interface ZodConfig {
81
+ /** Directory (relative to rootDir) where Zod schema files are written. Default: rootDir. */
82
+ baseDir?: string;
83
+ /** Output path template. Supports {filename}, {dir}. Default: `{filename}.schema.ts` alongside source. */
84
+ output?: string;
85
+ }
86
+
87
+ export interface TypesConfig {
88
+ /** Directory (relative to rootDir) where plain TypeScript type files are written. Default: rootDir. */
89
+ baseDir?: string;
90
+ /** Output path template. Supports {filename}, {dir}. Default: `{filename}.types.ts` alongside source. */
91
+ output?: string;
92
+ }
93
+
94
+ export interface TypescriptPluginConfig {
95
+ /** Generate Koa router files from `operation` declarations. */
96
+ server?: ServerConfig;
97
+ /** Generate TypeScript SDK client files from `operation` declarations. */
98
+ sdk?: SdkConfig;
99
+ /** Generate Zod schema files from `contract` declarations. */
100
+ zod?: ZodConfig;
101
+ /** Generate plain TypeScript interface/type files from `contract` declarations (no Zod runtime). */
102
+ types?: TypesConfig;
103
+ }
104
+
105
+ // ─── Server generation ─────────────────────────────────────────────────────
106
+
107
+ function runServerGeneration(
108
+ config: ServerConfig,
109
+ rootDir: string,
110
+ inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
111
+ emitFile: (outPath: string, content: string) => void,
112
+ ): void {
113
+ const serverBase = resolve(rootDir, config.baseDir ?? '.');
114
+ const modelsWithInput = inputs.modelsWithInput as Set<string>;
115
+ const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
116
+ const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
117
+ const commonRoot = commonDir(allFiles, rootDir);
118
+
119
+ // ── Types / Zod output ──
120
+ // When output.types is configured we generate type files ourselves and build a
121
+ // local modelOutPaths map so the router generator can resolve import paths.
122
+ let serverModelOutPaths = new Map<string, string>();
123
+
124
+ if (config.output?.types) {
125
+ serverModelOutPaths = new Map();
126
+
127
+ // Pass 1: register all model → outPath entries before generating content,
128
+ // so cross-file type refs resolve correctly.
129
+ const typeEntries: { ast: (typeof inputs.contractRoots)[number]; typeOutPath: string }[] = [];
130
+ for (const ast of inputs.contractRoots) {
131
+ const typeOutPath = computeContractOutPath(ast.file, serverBase, config.output.types, '.ts', commonRoot, ast.meta);
132
+ typeEntries.push({ ast, typeOutPath });
133
+ for (const model of ast.models) {
134
+ serverModelOutPaths.set(model.name, typeOutPath);
135
+ if (modelsWithInput.has(model.name)) {
136
+ serverModelOutPaths.set(`${model.name}Input`, typeOutPath);
137
+ }
138
+ if (modelsWithOutput.has(model.name)) {
139
+ serverModelOutPaths.set(`${model.name}Output`, typeOutPath);
140
+ }
141
+ }
142
+ }
143
+
144
+ // Pass 2: emit type files.
145
+ for (const { ast, typeOutPath } of typeEntries) {
146
+ const ctx = { modelOutPaths: serverModelOutPaths, currentOutPath: typeOutPath, modelsWithInput, modelsWithOutput };
147
+ const content = config.zod ? generateContract(ast, ctx) : generatePlainTypes(ast, ctx);
148
+ emitFile(typeOutPath, content);
149
+ }
150
+ }
151
+
152
+ // ── Routes output ──
153
+ for (const ast of inputs.opRoots) {
154
+ const outPath = computeOpOutPath(ast.file, serverBase, config.output?.routes, '.router.ts', commonRoot, ast.meta);
155
+ const content = generateOp(ast, {
156
+ servicePathTemplate: config.servicePathTemplate,
157
+ outPath,
158
+ modelOutPaths: serverModelOutPaths,
159
+ modelsWithInput,
160
+ modelsWithOutput,
161
+ includeInternal: config.includeInternal,
162
+ });
163
+ emitFile(outPath, content);
164
+ }
165
+ }
166
+
167
+ // ─── SDK generation ────────────────────────────────────────────────────────
168
+
169
+ function runSdkGeneration(
170
+ config: SdkConfig,
171
+ rootDir: string,
172
+ inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
173
+ emitFile: (outPath: string, content: string) => void,
174
+ ): void {
175
+ const sdkBase = config.baseDir ? resolve(rootDir, config.baseDir) : rootDir;
176
+ const sdkName = config.name;
177
+ const sdkOutput = config.output?.sdk;
178
+ const sdkEntryPath = sdkOutput
179
+ ? join(sdkBase, TEMPLATE_VAR_RE.test(sdkOutput) ? resolveTemplate(sdkOutput, { name: sdkName ?? 'sdk' }) : sdkOutput)
180
+ : join(sdkBase, 'sdk.ts');
181
+ const sdkOptionsPath = join(dirname(sdkEntryPath), 'sdk-options.ts');
182
+
183
+ const modelsWithInput = inputs.modelsWithInput as Set<string>;
184
+ const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
185
+ const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
186
+ const ckCommonRoot = commonDir(allFiles, rootDir);
187
+
188
+ let sdkModelOutPaths = new Map<string, string>();
189
+ const sdkTypePaths: string[] = [];
190
+ const sdkClientInfos: { outPath: string; className: string; propertyName: string }[] = [];
191
+
192
+ // ── SDK types ──
193
+ if (config.output?.types) {
194
+ sdkModelOutPaths = new Map<string, string>();
195
+ const publicTypes = computePubliclyReachableTypes(inputs.opRoots, inputs.contractRoots, modelsWithInput, modelsWithOutput);
196
+
197
+ const sdkContractEntries: { ast: (typeof inputs.contractRoots)[number]; typeOutPath: string }[] = [];
198
+ for (const ast of inputs.contractRoots) {
199
+ const typeOutPath = computeSdkTypeOutPath(ast.file, sdkBase, config.output.types, ckCommonRoot, ast.meta);
200
+ if (!typeOutPath) continue;
201
+ if (publicTypes !== null && !ast.models.some(m => publicTypes.has(m.name))) continue;
202
+ sdkTypePaths.push(typeOutPath);
203
+ sdkContractEntries.push({ ast, typeOutPath });
204
+ for (const model of ast.models) {
205
+ sdkModelOutPaths.set(model.name, typeOutPath);
206
+ if (modelsWithInput.has(model.name)) sdkModelOutPaths.set(`${model.name}Input`, typeOutPath);
207
+ if (modelsWithOutput.has(model.name)) sdkModelOutPaths.set(`${model.name}Output`, typeOutPath);
208
+ }
209
+ }
210
+
211
+ for (const { ast, typeOutPath } of sdkContractEntries) {
212
+ let content: string;
213
+ if (config.zod) {
214
+ content = generateContract(ast, {
215
+ modelOutPaths: sdkModelOutPaths,
216
+ currentOutPath: typeOutPath,
217
+ modelsWithInput,
218
+ modelsWithOutput,
219
+ });
220
+ } else {
221
+ let rel = relative(dirname(typeOutPath), sdkOptionsPath).replace(/\.ts$/, '.js');
222
+ if (!rel.startsWith('.')) rel = './' + rel;
223
+ content = generatePlainTypes(ast, {
224
+ modelOutPaths: sdkModelOutPaths,
225
+ currentOutPath: typeOutPath,
226
+ modelsWithInput,
227
+ modelsWithOutput,
228
+ jsonValueImportPath: rel,
229
+ });
230
+ }
231
+ emitFile(typeOutPath, content);
232
+ }
233
+ }
234
+
235
+ // ── SDK clients ──
236
+ if (config.output?.clients) {
237
+ for (const ast of inputs.opRoots) {
238
+ const sdkOutPath = computeSdkOutPath(ast.file, sdkBase, config.output.clients, ckCommonRoot, ast.meta);
239
+ if (!sdkOutPath || !hasPublicOperations(ast, config.includeInternal)) continue;
240
+ sdkClientInfos.push({
241
+ outPath: sdkOutPath,
242
+ className: deriveClientClassName(ast.file),
243
+ propertyName: deriveClientPropertyName(ast.file),
244
+ });
245
+ emitFile(
246
+ sdkOutPath,
247
+ generateSdk(ast, {
248
+ typeImportPathTemplate: undefined,
249
+ outPath: sdkOutPath,
250
+ modelOutPaths: sdkModelOutPaths,
251
+ sdkOptionsPath,
252
+ modelsWithInput,
253
+ modelsWithOutput,
254
+ includeInternal: config.includeInternal,
255
+ }),
256
+ );
257
+ }
258
+ }
259
+
260
+ // ── sdk-options.ts ──
261
+ emitFile(sdkOptionsPath, generateSdkOptions());
262
+
263
+ // ── sdk.ts aggregator ──
264
+ if (sdkClientInfos.length > 0) {
265
+ const sdkEntryDir = dirname(sdkEntryPath);
266
+ const clients = sdkClientInfos.map(c => {
267
+ let rel = relative(sdkEntryDir, c.outPath).replace(/\.ts$/, '.js');
268
+ if (!rel.startsWith('.')) rel = './' + rel;
269
+ return { className: c.className, propertyName: c.propertyName, importPath: rel };
270
+ });
271
+ const sdkOptionsRel = relative(sdkEntryDir, sdkOptionsPath).replace(/\.ts$/, '.js');
272
+ const sdkClassName = sdkName
273
+ ? sdkName
274
+ .split(/[-._\s]+/)
275
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
276
+ .join('') + 'Sdk'
277
+ : 'Sdk';
278
+ emitFile(sdkEntryPath, generateSdkAggregator(clients, sdkOptionsRel.startsWith('.') ? sdkOptionsRel : './' + sdkOptionsRel, sdkClassName));
279
+ }
280
+
281
+ // ── Barrel files ──
282
+ const sdkSrcDir = dirname(sdkEntryPath);
283
+ const sdkTypeBarrels = generateBarrelFiles(sdkTypePaths);
284
+ for (const barrel of sdkTypeBarrels) emitFile(barrel.outPath, barrel.content);
285
+
286
+ const rootExports: string[] = [`export * from './${basename(sdkOptionsPath).replace(/\.ts$/, '.js')}';`];
287
+ if (sdkClientInfos.length > 0) {
288
+ rootExports.push(`export * from './${basename(sdkEntryPath).replace(/\.ts$/, '.js')}';`);
289
+ }
290
+ for (const c of sdkClientInfos) {
291
+ let rel = relative(sdkSrcDir, c.outPath).replace(/\.ts$/, '.js');
292
+ if (!rel.startsWith('.')) rel = './' + rel;
293
+ rootExports.push(`export * from '${rel}';`);
294
+ }
295
+ for (const barrel of sdkTypeBarrels) {
296
+ let rel = relative(sdkSrcDir, barrel.outPath).replace(/\.ts$/, '.js');
297
+ if (!rel.startsWith('.')) rel = './' + rel;
298
+ rootExports.push(`export * from '${rel}';`);
299
+ }
300
+ emitFile(join(sdkSrcDir, 'index.ts'), `// Auto-generated barrel file\n${rootExports.sort().join('\n')}\n`);
301
+ }
302
+
303
+ // ─── Zod generation ────────────────────────────────────────────────────────
304
+
305
+ function runZodGeneration(
306
+ config: ZodConfig,
307
+ rootDir: string,
308
+ inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
309
+ emitFile: (outPath: string, content: string) => void,
310
+ ): void {
311
+ const zodBase = resolve(rootDir, config.baseDir ?? '.');
312
+ const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
313
+ const commonRoot = commonDir(allFiles, rootDir);
314
+ const modelsWithInput = inputs.modelsWithInput as Set<string>;
315
+ const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
316
+
317
+ // Pre-pass: register all model → outPath before generating, so cross-file imports resolve.
318
+ const modelOutPaths = new Map<string, string>();
319
+ const entries: { ast: (typeof inputs.contractRoots)[number]; outPath: string }[] = [];
320
+ for (const ast of inputs.contractRoots) {
321
+ const outPath = computeContractOutPath(ast.file, zodBase, config.output, '.schema.ts', commonRoot, ast.meta);
322
+ entries.push({ ast, outPath });
323
+ for (const model of ast.models) {
324
+ modelOutPaths.set(model.name, outPath);
325
+ if (modelsWithInput.has(model.name)) modelOutPaths.set(`${model.name}Input`, outPath);
326
+ if (modelsWithOutput.has(model.name)) modelOutPaths.set(`${model.name}Output`, outPath);
327
+ }
328
+ }
329
+
330
+ for (const { ast, outPath } of entries) {
331
+ const content = generateContract(ast, {
332
+ modelOutPaths,
333
+ currentOutPath: outPath,
334
+ modelsWithInput,
335
+ modelsWithOutput,
336
+ });
337
+ emitFile(outPath, content);
338
+ }
339
+ }
340
+
341
+ // ─── Types generation ──────────────────────────────────────────────────────
342
+
343
+ function runTypesGeneration(
344
+ config: TypesConfig,
345
+ rootDir: string,
346
+ inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
347
+ emitFile: (outPath: string, content: string) => void,
348
+ ): void {
349
+ const typesBase = resolve(rootDir, config.baseDir ?? '.');
350
+ const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
351
+ const commonRoot = commonDir(allFiles, rootDir);
352
+ const modelsWithInput = inputs.modelsWithInput as Set<string>;
353
+ const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
354
+
355
+ // Pre-pass: register all model → outPath before generating.
356
+ const modelOutPaths = new Map<string, string>();
357
+ const entries: { ast: (typeof inputs.contractRoots)[number]; outPath: string }[] = [];
358
+ for (const ast of inputs.contractRoots) {
359
+ const outPath = computeContractOutPath(ast.file, typesBase, config.output, '.types.ts', commonRoot, ast.meta);
360
+ entries.push({ ast, outPath });
361
+ for (const model of ast.models) {
362
+ modelOutPaths.set(model.name, outPath);
363
+ if (modelsWithInput.has(model.name)) modelOutPaths.set(`${model.name}Input`, outPath);
364
+ if (modelsWithOutput.has(model.name)) modelOutPaths.set(`${model.name}Output`, outPath);
365
+ }
366
+ }
367
+
368
+ for (const { ast, outPath } of entries) {
369
+ const content = generatePlainTypes(ast, {
370
+ modelOutPaths,
371
+ currentOutPath: outPath,
372
+ modelsWithInput,
373
+ modelsWithOutput,
374
+ });
375
+ emitFile(outPath, content);
376
+ }
377
+ }
378
+
379
+ // ─── Combined plugin ────────────────────────────────────────────────────────
380
+
381
+ const plugin: ContractKitPlugin = {
382
+ name: 'typescript',
383
+ cacheKey: 'typescript',
384
+ async generateTargets(inputs, ctx) {
385
+ const config = ctx.options as TypescriptPluginConfig;
386
+
387
+ if (config.server) {
388
+ runServerGeneration(config.server, ctx.rootDir, inputs, ctx.emitFile.bind(ctx));
389
+ }
390
+ if (config.sdk) {
391
+ runSdkGeneration(config.sdk, ctx.rootDir, inputs, ctx.emitFile.bind(ctx));
392
+ }
393
+ if (config.zod) {
394
+ runZodGeneration(config.zod, ctx.rootDir, inputs, ctx.emitFile.bind(ctx));
395
+ }
396
+ if (config.types) {
397
+ runTypesGeneration(config.types, ctx.rootDir, inputs, ctx.emitFile.bind(ctx));
398
+ }
399
+ },
400
+ };
401
+
402
+ export default plugin;
403
+
404
+ // ─── Factory: for programmatic use with explicit config ────────────────────
405
+
406
+ export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir: string): ContractKitPlugin {
407
+ return {
408
+ name: 'typescript',
409
+ cacheKey: `typescript:${JSON.stringify(config)}`,
410
+ async generateTargets(inputs, ctx) {
411
+ if (config.server) {
412
+ runServerGeneration(config.server, rootDir, inputs, ctx.emitFile.bind(ctx));
413
+ }
414
+ if (config.sdk) {
415
+ runSdkGeneration(config.sdk, rootDir, inputs, ctx.emitFile.bind(ctx));
416
+ }
417
+ if (config.zod) {
418
+ runZodGeneration(config.zod, rootDir, inputs, ctx.emitFile.bind(ctx));
419
+ }
420
+ if (config.types) {
421
+ runTypesGeneration(config.types, rootDir, inputs, ctx.emitFile.bind(ctx));
422
+ }
423
+ },
424
+ };
425
+ }
@@ -0,0 +1,188 @@
1
+ import { resolve, join, relative, dirname } from 'node:path';
2
+ import type { ContractRootNode, OpRootNode } from '@contractkit/core';
3
+ import { collectTypeRefs, collectPublicTypeNames } from '@contractkit/core';
4
+
5
+ export const TEMPLATE_VAR_RE = /\{\w+\}/;
6
+
7
+ export function resolveTemplate(template: string, vars: Record<string, string>): string {
8
+ return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
9
+ }
10
+
11
+ export function includesFilename(p: string): boolean {
12
+ const last = p.split('/').pop() ?? '';
13
+ return last.includes('.');
14
+ }
15
+
16
+ export function commonDir(files: string[], rootDir: string): string {
17
+ if (files.length === 0) return resolve(rootDir);
18
+ const parts = files.map(f => dirname(f).split('/'));
19
+ const first = parts[0]!;
20
+ let depth = first.length;
21
+ for (const p of parts) {
22
+ for (let i = 0; i < depth; i++) {
23
+ if (p[i] !== first[i]) {
24
+ depth = i;
25
+ break;
26
+ }
27
+ }
28
+ }
29
+ return first.slice(0, depth).join('/') || '/';
30
+ }
31
+
32
+ // ─── Server / Zod output paths ─────────────────────────────────────────────
33
+
34
+ export function computeOpOutPath(
35
+ filePath: string,
36
+ baseDir: string,
37
+ output: string | undefined,
38
+ defaultSuffix: string,
39
+ commonRoot: string,
40
+ meta: Record<string, string> = {},
41
+ ): string {
42
+ const baseName = filePath.split('/').pop()!;
43
+ const relDir = relative(commonRoot, dirname(filePath));
44
+ const filename = baseName.replace(/\.ck$/, '');
45
+ const defaultName = `${filename}${defaultSuffix}`;
46
+ const baseOutDir = resolve(baseDir);
47
+
48
+ if (output && TEMPLATE_VAR_RE.test(output)) {
49
+ const resolved = resolveTemplate(output, { filename, dir: relDir, ext: 'ck', ...meta });
50
+ if (includesFilename(resolved)) return join(baseOutDir, resolved);
51
+ return join(baseOutDir, resolved, defaultName);
52
+ }
53
+ if (output) {
54
+ if (includesFilename(output)) return join(baseOutDir, output);
55
+ return join(baseOutDir, output, relDir, defaultName);
56
+ }
57
+ return join(baseOutDir, relDir, defaultName);
58
+ }
59
+
60
+ export function computeContractOutPath(
61
+ filePath: string,
62
+ baseDir: string,
63
+ output: string | undefined,
64
+ defaultSuffix: string,
65
+ commonRoot: string,
66
+ meta: Record<string, string> = {},
67
+ ): string {
68
+ return computeOpOutPath(filePath, baseDir, output, defaultSuffix, commonRoot, meta);
69
+ }
70
+
71
+ // ─── SDK output paths ──────────────────────────────────────────────────────
72
+
73
+ export function computeSdkOutPath(
74
+ filePath: string,
75
+ rootDir: string,
76
+ clientOutput: string | undefined,
77
+ commonRoot: string,
78
+ meta: Record<string, string> = {},
79
+ ): string | null {
80
+ if (!filePath.endsWith('.ck')) return null;
81
+ const baseName = filePath.split('/').pop()!;
82
+ const defaultOutName = baseName.replace(/\.ck$/, '.client.ts');
83
+ const baseOutDir = resolve(rootDir);
84
+ const relDir = relative(commonRoot, dirname(filePath));
85
+ const filename = baseName.replace(/\.ck$/, '');
86
+
87
+ if (clientOutput && TEMPLATE_VAR_RE.test(clientOutput)) {
88
+ const resolved = resolveTemplate(clientOutput, { filename, dir: relDir, ext: 'ck', ...meta });
89
+ if (includesFilename(resolved)) return join(baseOutDir, resolved);
90
+ return join(baseOutDir, resolved, defaultOutName);
91
+ }
92
+ if (clientOutput) {
93
+ if (includesFilename(clientOutput)) return join(baseOutDir, clientOutput);
94
+ return join(baseOutDir, clientOutput, relDir, defaultOutName);
95
+ }
96
+ return join(baseOutDir, relDir, defaultOutName);
97
+ }
98
+
99
+ export function computeSdkTypeOutPath(
100
+ filePath: string,
101
+ rootDir: string,
102
+ typeOutput: string,
103
+ commonRoot: string,
104
+ meta: Record<string, string> = {},
105
+ ): string | null {
106
+ if (!filePath.endsWith('.ck')) return null;
107
+ const baseName = filePath.split('/').pop()!;
108
+ const defaultOutName = baseName.replace(/\.ck$/, '.ts');
109
+ const baseOutDir = resolve(rootDir);
110
+ const relDir = relative(commonRoot, dirname(filePath));
111
+ const filename = baseName.replace(/\.ck$/, '');
112
+
113
+ if (TEMPLATE_VAR_RE.test(typeOutput)) {
114
+ const resolved = resolveTemplate(typeOutput, { filename, dir: relDir, ext: 'ck', ...meta });
115
+ if (includesFilename(resolved)) return join(baseOutDir, resolved);
116
+ return join(baseOutDir, resolved, defaultOutName);
117
+ }
118
+ if (includesFilename(typeOutput)) return join(baseOutDir, typeOutput);
119
+ return join(baseOutDir, typeOutput, relDir, defaultOutName);
120
+ }
121
+
122
+ export function generateBarrelFiles(contractPaths: string[]): { outPath: string; content: string }[] {
123
+ const byDir = new Map<string, string[]>();
124
+ for (const outPath of contractPaths) {
125
+ const dir = dirname(outPath);
126
+ const group = byDir.get(dir) ?? [];
127
+ group.push(outPath);
128
+ byDir.set(dir, group);
129
+ }
130
+ const results: { outPath: string; content: string }[] = [];
131
+ for (const [dir, files] of byDir) {
132
+ const exports = files
133
+ .map(f => `export * from './${f.split('/').pop()!.replace(/\.ts$/, '.js')}';`)
134
+ .sort()
135
+ .join('\n');
136
+ results.push({ outPath: join(dir, 'index.ts'), content: `// Auto-generated barrel file\n${exports}\n` });
137
+ }
138
+ return results;
139
+ }
140
+
141
+ export function computePubliclyReachableTypes(
142
+ opAsts: OpRootNode[],
143
+ contractAsts: ContractRootNode[],
144
+ modelsWithInput: Set<string>,
145
+ modelsWithOutput: Set<string> = new Set(),
146
+ ): Set<string> | null {
147
+ if (opAsts.length === 0) return null;
148
+ const reachable = new Set<string>();
149
+ for (const opAst of opAsts) {
150
+ for (const name of collectPublicTypeNames(opAst, modelsWithInput, modelsWithOutput)) reachable.add(name);
151
+ }
152
+ const modelDeps = new Map<string, Set<string>>();
153
+ for (const contractAst of contractAsts) {
154
+ for (const model of contractAst.models) {
155
+ const deps = new Set<string>();
156
+ if (model.bases) for (const b of model.bases) deps.add(b);
157
+ if (model.type) collectTypeRefs(model.type, deps);
158
+ for (const field of model.fields) collectTypeRefs(field.type, deps);
159
+ modelDeps.set(model.name, deps);
160
+ }
161
+ }
162
+ const frontier = [...reachable];
163
+ while (frontier.length > 0) {
164
+ const name = frontier.pop()!;
165
+ const baseName = name.endsWith('Input') ? name.slice(0, -5) : name.endsWith('Output') ? name.slice(0, -6) : name;
166
+ for (const dep of modelDeps.get(baseName) ?? []) {
167
+ if (!reachable.has(dep)) {
168
+ reachable.add(dep);
169
+ frontier.push(dep);
170
+ }
171
+ if (modelsWithInput.has(dep)) {
172
+ const inputDep = `${dep}Input`;
173
+ if (!reachable.has(inputDep)) {
174
+ reachable.add(inputDep);
175
+ frontier.push(inputDep);
176
+ }
177
+ }
178
+ if (modelsWithOutput.has(dep)) {
179
+ const outputDep = `${dep}Output`;
180
+ if (!reachable.has(outputDep)) {
181
+ reachable.add(outputDep);
182
+ frontier.push(outputDep);
183
+ }
184
+ }
185
+ }
186
+ }
187
+ return reachable;
188
+ }