@contractkit/plugin-typescript 0.17.5 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.17.5",
3
+ "version": "0.18.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -29,8 +29,8 @@
29
29
  "@contractkit/core": "0.14.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@repo/config-eslint": "0.3.1",
33
- "@repo/config-typescript": "0.1.0"
32
+ "@repo/config-typescript": "0.1.0",
33
+ "@repo/config-eslint": "0.3.1"
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration",
@@ -57,8 +57,11 @@ function classifyBodyStrategy(op: OpOperationNode): BodyStrategy {
57
57
 
58
58
  // ─── Public entry point ────────────────────────────────────────────────────
59
59
 
60
+ /** Options shared by every SDK code-generation entry point. */
60
61
  export interface SdkCodegenOptions {
62
+ /** Template for type import paths when `modelOutPaths` is not provided. Supports `{module}` and `{base}`. */
61
63
  typeImportPathTemplate?: string;
64
+ /** Absolute path of the file currently being generated. Used to compute relative imports. */
62
65
  outPath?: string;
63
66
  /** Map from model name → absolute output file path (for cross-module type imports) */
64
67
  modelOutPaths?: Map<string, string>;
@@ -74,6 +77,12 @@ export interface SdkCodegenOptions {
74
77
  * to include them (e.g. for an internal-use SDK).
75
78
  */
76
79
  includeInternal?: boolean;
80
+ /**
81
+ * Override the generated client class name. When omitted, falls back to
82
+ * `deriveClientClassName(root.file)` (the legacy per-file name). The aggregator
83
+ * uses this to emit `<Area><Subarea>Client` for area+subarea leaf files.
84
+ */
85
+ clientClassName?: string;
77
86
  }
78
87
 
79
88
  /**
@@ -90,12 +99,18 @@ export function hasPublicOperations(root: OpRootNode, includeInternal = false):
90
99
  return false;
91
100
  }
92
101
 
102
+ /**
103
+ * Generate a complete `*.client.ts` file for one operation root: imports, the client class
104
+ * declaration, and one method per public operation. Used for top-level (no-area) files and
105
+ * for subarea-leaf files. Area-level files are NOT routed through this — their methods get
106
+ * inlined into the SDK aggregator via {@link generateClientMethods} + {@link generateSdkAggregator}.
107
+ */
93
108
  export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}): string {
94
109
  const lines: string[] = [];
95
110
  const includeInternal = options.includeInternal ?? false;
96
111
 
97
112
  const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput, includeInternal);
98
- const clientClassName = deriveClientClassName(root.file);
113
+ const clientClassName = options.clientClassName ?? deriveClientClassName(root.file);
99
114
 
100
115
  // Type-only imports
101
116
  if (types.length > 0) {
@@ -207,6 +222,35 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
207
222
  return lines.join('\n');
208
223
  }
209
224
 
225
+ /**
226
+ * Render the method-block lines for an operation file as if they were declared inside a
227
+ * client class. Returns one consolidated array of strings (each pre-indented for class body
228
+ * level, with leading blank lines between methods) plus the set of method names emitted —
229
+ * the caller uses the names to detect cross-file collisions when multiple files contribute
230
+ * to the same area-level client.
231
+ *
232
+ * Skips operations marked `internal` unless `options.includeInternal` is set.
233
+ */
234
+ export function generateClientMethods(
235
+ root: OpRootNode,
236
+ options: SdkCodegenOptions,
237
+ ): { lines: string[]; methodNames: string[] } {
238
+ const lines: string[] = [];
239
+ const methodNames: string[] = [];
240
+ const includeInternal = options.includeInternal ?? false;
241
+ for (const route of root.routes) {
242
+ for (const op of route.operations) {
243
+ const mods = resolveModifiers(route, op);
244
+ if (!includeInternal && mods.includes('internal')) continue;
245
+ lines.push('');
246
+ if (mods.includes('deprecated')) lines.push(' /** @deprecated */');
247
+ lines.push(...generateMethod(route, op, root.file, options));
248
+ methodNames.push(deriveMethodName(op, route));
249
+ }
250
+ }
251
+ return { lines, methodNames };
252
+ }
253
+
210
254
  // ─── Method generation ────────────────────────────────────────────────────
211
255
 
212
256
  function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, options: SdkCodegenOptions): string[] {
@@ -515,15 +559,59 @@ function deriveBaseName(file: string): string {
515
559
  .join('');
516
560
  }
517
561
 
562
+ /** Derive a client class name from a `.ck` file path, e.g. `users.ck` → `UsersClient`. Used for legacy flat (no-area) files. */
518
563
  export function deriveClientClassName(file: string): string {
519
564
  return `${deriveBaseName(file)}Client`;
520
565
  }
521
566
 
567
+ /** Camel-cased property name for a flat client on the SDK aggregator, e.g. `users.ck` → `users`. */
522
568
  export function deriveClientPropertyName(file: string): string {
523
569
  const base = deriveBaseName(file);
524
570
  return base.charAt(0).toLowerCase() + base.slice(1);
525
571
  }
526
572
 
573
+ /**
574
+ * Pull `area` / `subarea` from a file's `root.meta` (set via `options { keys: { ... } }`).
575
+ * Both are optional. `area` drives top-level SDK grouping; `subarea` drives nesting under
576
+ * an area's client class.
577
+ */
578
+ export function getAreaSubarea(root: OpRootNode): { area?: string; subarea?: string } {
579
+ return { area: root.meta?.area, subarea: root.meta?.subarea };
580
+ }
581
+
582
+ function pascal(value: string): string {
583
+ return value
584
+ .split(/[-_\s]+/)
585
+ .filter(Boolean)
586
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
587
+ .join('');
588
+ }
589
+
590
+ function camel(value: string): string {
591
+ const p = pascal(value);
592
+ return p.charAt(0).toLowerCase() + p.slice(1);
593
+ }
594
+
595
+ /** Class name for the area-level client, e.g. `area=identity` → `IdentityClient`. */
596
+ export function deriveAreaClientClassName(area: string): string {
597
+ return `${pascal(area)}Client`;
598
+ }
599
+
600
+ /** Property name on the SDK aggregator for an area, e.g. `area=identity` → `identity`. */
601
+ export function deriveAreaPropertyName(area: string): string {
602
+ return camel(area);
603
+ }
604
+
605
+ /** Class name for a leaf subarea client, e.g. `(identity, invitations)` → `IdentityInvitationsClient`. */
606
+ export function deriveSubareaClientClassName(area: string, subarea: string): string {
607
+ return `${pascal(area)}${pascal(subarea)}Client`;
608
+ }
609
+
610
+ /** Property name on the area client for a subarea, e.g. `subarea=invitations` → `invitations`. */
611
+ export function deriveSubareaPropertyName(subarea: string): string {
612
+ return camel(subarea);
613
+ }
614
+
527
615
  // ─── Type collection ──────────────────────────────────────────────────────
528
616
 
529
617
  function collectTypes(root: OpRootNode, modelsWithInput?: Set<string>, modelsWithOutput?: Set<string>, includeInternal = false): string[] {
@@ -869,31 +957,194 @@ export function generateSdkOptions(): string {
869
957
  ].join('\n');
870
958
  }
871
959
 
960
+ /**
961
+ * Reference to a per-file leaf client emitted to its own `*.client.ts`. Used by the
962
+ * aggregator to import the class and wire it as either a top-level `sdk.<prop>` or a
963
+ * nested `sdk.<area>.<subarea>` property.
964
+ */
872
965
  export interface SdkClientInfo {
966
+ /** Client class name (e.g. `UsersClient`, `IdentityInvitationsClient`). */
873
967
  className: string;
968
+ /** Property name to expose this client under (e.g. `users`, `invitations`). */
874
969
  propertyName: string;
970
+ /** Module specifier for the leaf file, relative to `sdk.ts` and `.js`-suffixed. */
875
971
  importPath: string;
876
972
  }
877
973
 
878
- /** Generate the sdk.ts aggregator that wraps all clients into a single Sdk class. */
879
- export function generateSdkAggregator(clients: SdkClientInfo[], sdkOptionsImportPath = './sdk-options.js', sdkClassName = 'Sdk'): string {
880
- const lines: string[] = [];
974
+ /**
975
+ * One area-level (no-subarea) `.ck` file whose methods are inlined directly into the
976
+ * generated `<Area>Client` class instead of getting a standalone `*.client.ts`.
977
+ */
978
+ export interface SdkAreaInlineFile {
979
+ /** Parsed AST. */
980
+ root: OpRootNode;
981
+ /** Codegen options for this file (must have `outPath` pointing at the SDK aggregator file so type-import paths resolve correctly). */
982
+ codegenOptions: SdkCodegenOptions;
983
+ }
984
+
985
+ /** A grouping of files that share the same `keys.area`. */
986
+ export interface SdkAreaInfo {
987
+ area: string;
988
+ /** Files for which all methods are inlined into the area client (no subarea). */
989
+ inlineFiles: SdkAreaInlineFile[];
990
+ /** Per-file leaf clients exposed as named properties on the area client. */
991
+ subareaClients: { propertyName: string; client: SdkClientInfo }[];
992
+ }
993
+
994
+ export interface SdkAggregatorInput {
995
+ /** Files with no `keys.area` — kept as flat `Sdk.<filename>` properties (legacy behavior). */
996
+ topLevelClients: SdkClientInfo[];
997
+ /** One entry per `keys.area`. */
998
+ areas: SdkAreaInfo[];
999
+ /** Path to `sdk-options.ts` to import `SdkOptions`/`createSdkFetch`/etc. from. */
1000
+ sdkOptionsImportPath?: string;
1001
+ /** Name of the top-level aggregator class. Defaults to `Sdk`. */
1002
+ sdkClassName?: string;
1003
+ }
1004
+
1005
+ /**
1006
+ * Generate the SDK aggregator (`sdk.ts`) — the entry-point file consumers import.
1007
+ *
1008
+ * Emits one `<Area>Client` class per area (combining inlined area-level methods with
1009
+ * subarea property wiring), one `class Sdk` exposing area properties + flat top-level
1010
+ * properties, plus all imports needed by the inlined methods.
1011
+ *
1012
+ * @throws if two area-level files in the same area produce the same method name.
1013
+ */
1014
+ export function generateSdkAggregator(input: SdkAggregatorInput): string {
1015
+ const sdkOptionsImportPath = input.sdkOptionsImportPath ?? './sdk-options.js';
1016
+ const sdkClassName = input.sdkClassName ?? 'Sdk';
1017
+
1018
+ // ── Pre-render inline method blocks per area (also collects type/runtime needs) ──
1019
+ const inlinedByArea = new Map<string, { lines: string[]; methodNames: Set<string> }>();
1020
+ const typesByImportPath = new Map<string, Set<string>>(); // path → set of type names
1021
+ const unresolvedTypes = new Set<string>();
1022
+ let needsJson = false;
1023
+ let needsBigIntReplacer = false;
1024
+ let needsBigIntReviver = false;
1025
+ let needsQueryString = false;
1026
+
1027
+ for (const area of input.areas) {
1028
+ const collected: string[] = [];
1029
+ const seenMethods = new Set<string>();
1030
+ for (const inline of area.inlineFiles) {
1031
+ const includeInternal = inline.codegenOptions.includeInternal ?? false;
1032
+ // Collect method names + source lines
1033
+ const { lines: methodLines, methodNames } = generateClientMethods(inline.root, inline.codegenOptions);
1034
+ for (const name of methodNames) {
1035
+ if (seenMethods.has(name)) {
1036
+ throw new Error(
1037
+ `[sdk] duplicate method '${name}' in area '${area.area}': two area-level files contribute the same method. Disambiguate via 'sdk:' or move one into a subarea.`,
1038
+ );
1039
+ }
1040
+ seenMethods.add(name);
1041
+ }
1042
+ collected.push(...methodLines);
1043
+ // Side info for imports
1044
+ if (sdkNeedsJson(inline.root, includeInternal)) needsJson = true;
1045
+ if (sdkNeedsBigIntReplacer(inline.root, includeInternal)) needsBigIntReplacer = true;
1046
+ if (sdkNeedsBigIntReviver(inline.root, includeInternal)) needsBigIntReviver = true;
1047
+ if (sdkNeedsQueryString(inline.root, includeInternal)) needsQueryString = true;
1048
+ // Type imports: rebuild what generateTypeImports would produce, but key by import path so multiple files merge
1049
+ const typesForFile = collectTypes(inline.root, inline.codegenOptions.modelsWithInput, inline.codegenOptions.modelsWithOutput, includeInternal);
1050
+ const { modelOutPaths, outPath } = inline.codegenOptions;
1051
+ if (modelOutPaths && outPath) {
1052
+ const fromDir = dirname(outPath);
1053
+ for (const t of typesForFile) {
1054
+ const typeOutPath = modelOutPaths.get(t);
1055
+ if (typeOutPath) {
1056
+ let rel = relative(fromDir, typeOutPath).replace(/\.ts$/, '.js');
1057
+ if (!rel.startsWith('.')) rel = './' + rel;
1058
+ const set = typesByImportPath.get(rel) ?? new Set();
1059
+ set.add(t);
1060
+ typesByImportPath.set(rel, set);
1061
+ } else {
1062
+ unresolvedTypes.add(t);
1063
+ }
1064
+ }
1065
+ }
1066
+ }
1067
+ inlinedByArea.set(area.area, { lines: collected, methodNames: seenMethods });
1068
+ }
881
1069
 
1070
+ // ── Imports ──
1071
+ const lines: string[] = [];
1072
+ const jsonImport = needsJson ? ', JsonValue' : '';
1073
+ lines.push(`import type { SdkFetch${jsonImport} } from '${sdkOptionsImportPath}';`);
1074
+ const valueImports: string[] = [];
1075
+ if (needsBigIntReplacer) valueImports.push('bigIntReplacer');
1076
+ if (needsBigIntReviver) valueImports.push('parseJson');
1077
+ if (needsQueryString) valueImports.push('buildQueryString');
1078
+ if (valueImports.length > 0) {
1079
+ lines.push(`import { ${valueImports.join(', ')} } from '${sdkOptionsImportPath}';`);
1080
+ }
882
1081
  lines.push(`import type { SdkOptions } from '${sdkOptionsImportPath}';`);
883
1082
  lines.push(`import { createSdkFetch } from '${sdkOptionsImportPath}';`);
884
- for (const c of clients) {
1083
+
1084
+ // Type imports inlined from area-level files
1085
+ const typeImportPaths = [...typesByImportPath.keys()].sort();
1086
+ for (const path of typeImportPaths) {
1087
+ const names = [...typesByImportPath.get(path)!].sort();
1088
+ lines.push(`import type { ${names.join(', ')} } from '${path}';`);
1089
+ }
1090
+ for (const t of [...unresolvedTypes].sort()) {
1091
+ lines.push(`import type { ${t} } from './${pascalToDotCase(t)}.js';`);
1092
+ }
1093
+
1094
+ // Leaf client imports (top-level + subarea)
1095
+ const importedClients = new Set<string>();
1096
+ const pushClientImport = (c: SdkClientInfo): void => {
1097
+ const key = `${c.className}|${c.importPath}`;
1098
+ if (importedClients.has(key)) return;
1099
+ importedClients.add(key);
885
1100
  lines.push(`import { ${c.className} } from '${c.importPath}';`);
1101
+ };
1102
+ for (const c of input.topLevelClients) pushClientImport(c);
1103
+ for (const area of input.areas) {
1104
+ for (const sc of area.subareaClients) pushClientImport(sc.client);
886
1105
  }
887
1106
  lines.push('');
888
1107
 
1108
+ // ── <Area>Client classes ──
1109
+ for (const area of input.areas) {
1110
+ const areaClassName = deriveAreaClientClassName(area.area);
1111
+ const inlined = inlinedByArea.get(area.area)!;
1112
+ lines.push(`class ${areaClassName} {`);
1113
+ // Subarea property declarations
1114
+ for (const sc of area.subareaClients) {
1115
+ lines.push(` readonly ${sc.propertyName}: ${sc.client.className};`);
1116
+ }
1117
+ if (area.subareaClients.length > 0) lines.push('');
1118
+ // Constructor
1119
+ if (inlined.lines.length > 0 || area.subareaClients.length > 0) {
1120
+ const fetchModifier = inlined.lines.length > 0 ? 'private ' : '';
1121
+ lines.push(` constructor(${fetchModifier}fetch: SdkFetch) {`);
1122
+ for (const sc of area.subareaClients) {
1123
+ lines.push(` this.${sc.propertyName} = new ${sc.client.className}(fetch);`);
1124
+ }
1125
+ lines.push(' }');
1126
+ }
1127
+ // Inlined methods (already class-body indented)
1128
+ for (const ln of inlined.lines) lines.push(ln);
1129
+ lines.push('}');
1130
+ lines.push('');
1131
+ }
1132
+
1133
+ // ── Sdk aggregator ──
889
1134
  lines.push(`export class ${sdkClassName} {`);
890
- for (const c of clients) {
1135
+ for (const area of input.areas) {
1136
+ lines.push(` readonly ${deriveAreaPropertyName(area.area)}: ${deriveAreaClientClassName(area.area)};`);
1137
+ }
1138
+ for (const c of input.topLevelClients) {
891
1139
  lines.push(` readonly ${c.propertyName}: ${c.className};`);
892
1140
  }
893
1141
  lines.push('');
894
1142
  lines.push(' constructor(options: SdkOptions) {');
895
1143
  lines.push(' const sdkFetch = options.fetch ?? createSdkFetch(options);');
896
- for (const c of clients) {
1144
+ for (const area of input.areas) {
1145
+ lines.push(` this.${deriveAreaPropertyName(area.area)} = new ${deriveAreaClientClassName(area.area)}(sdkFetch);`);
1146
+ }
1147
+ for (const c of input.topLevelClients) {
897
1148
  lines.push(` this.${c.propertyName} = new ${c.className}(sdkFetch);`);
898
1149
  }
899
1150
  lines.push(' }');
package/src/index.ts CHANGED
@@ -8,7 +8,12 @@ import {
8
8
  generateSdkAggregator,
9
9
  deriveClientClassName,
10
10
  deriveClientPropertyName,
11
+ deriveSubareaClientClassName,
12
+ deriveSubareaPropertyName,
13
+ getAreaSubarea,
11
14
  hasPublicOperations,
15
+ type SdkClientInfo,
16
+ type SdkAreaInfo,
12
17
  } from './codegen-sdk.js';
13
18
  import { generatePlainTypes } from './codegen-plain-types.js';
14
19
  import {
@@ -64,9 +69,9 @@ export interface SdkConfig {
64
69
  output?: {
65
70
  /** Path template for the SDK aggregator file. Supports {name}. Default: `sdk.ts`. */
66
71
  sdk?: string;
67
- /** Path template for SDK type files. Supports {filename}, {dir}, {area}. */
72
+ /** Path template for SDK type files. Supports {filename}, {dir}, {area}, {subarea}. */
68
73
  types?: string;
69
- /** Path template for client class files. Supports {filename}, {dir}, {area}. */
74
+ /** Path template for client class files. Supports {filename}, {dir}, {area}, {subarea}. */
70
75
  clients?: string;
71
76
  };
72
77
  /**
@@ -233,20 +238,62 @@ function runSdkGeneration(
233
238
  }
234
239
 
235
240
  // ── SDK clients ──
241
+ // Group opRoots by (area, subarea):
242
+ // - area + subarea → leaf client emitted as <Area><Subarea>Client in its own file
243
+ // - area only → no standalone file; methods inlined into <Area>Client in sdk.ts
244
+ // - neither → flat top-level client (legacy behavior)
245
+ interface AreaBucket {
246
+ leaves: { ast: typeof inputs.opRoots[number]; outPath: string; subarea: string }[];
247
+ inlineRoots: typeof inputs.opRoots[number][];
248
+ }
249
+ const areaBuckets = new Map<string, AreaBucket>();
250
+ const topLevelEntries: { ast: typeof inputs.opRoots[number]; outPath: string }[] = [];
251
+
236
252
  if (config.output?.clients) {
237
253
  for (const ast of inputs.opRoots) {
238
254
  const sdkOutPath = computeSdkOutPath(ast.file, sdkBase, config.output.clients, ckCommonRoot, ast.meta);
239
255
  if (!sdkOutPath || !hasPublicOperations(ast, config.includeInternal)) continue;
240
- sdkClientInfos.push({
241
- outPath: sdkOutPath,
242
- className: deriveClientClassName(ast.file),
243
- propertyName: deriveClientPropertyName(ast.file),
244
- });
256
+
257
+ const { area, subarea } = getAreaSubarea(ast);
258
+ if (area && subarea) {
259
+ const bucket = areaBuckets.get(area) ?? { leaves: [], inlineRoots: [] };
260
+ bucket.leaves.push({ ast, outPath: sdkOutPath, subarea });
261
+ areaBuckets.set(area, bucket);
262
+ } else if (area) {
263
+ const bucket = areaBuckets.get(area) ?? { leaves: [], inlineRoots: [] };
264
+ bucket.inlineRoots.push(ast);
265
+ areaBuckets.set(area, bucket);
266
+ } else {
267
+ topLevelEntries.push({ ast, outPath: sdkOutPath });
268
+ }
269
+ }
270
+
271
+ // Emit per-file clients for leaves (subarea) and top-level (no area). Area-only files are inlined later.
272
+ for (const { ast, outPath, subarea } of [...areaBuckets.entries()].flatMap(([area, b]) => b.leaves.map(l => ({ ...l, area })))) {
273
+ const className = deriveSubareaClientClassName((ast.meta?.area as string) ?? '', subarea);
274
+ sdkClientInfos.push({ outPath, className, propertyName: deriveSubareaPropertyName(subarea) });
275
+ emitFile(
276
+ outPath,
277
+ generateSdk(ast, {
278
+ typeImportPathTemplate: undefined,
279
+ outPath,
280
+ modelOutPaths: sdkModelOutPaths,
281
+ sdkOptionsPath,
282
+ modelsWithInput,
283
+ modelsWithOutput,
284
+ includeInternal: config.includeInternal,
285
+ clientClassName: className,
286
+ }),
287
+ );
288
+ }
289
+ for (const { ast, outPath } of topLevelEntries) {
290
+ const className = deriveClientClassName(ast.file);
291
+ sdkClientInfos.push({ outPath, className, propertyName: deriveClientPropertyName(ast.file) });
245
292
  emitFile(
246
- sdkOutPath,
293
+ outPath,
247
294
  generateSdk(ast, {
248
295
  typeImportPathTemplate: undefined,
249
- outPath: sdkOutPath,
296
+ outPath,
250
297
  modelOutPaths: sdkModelOutPaths,
251
298
  sdkOptionsPath,
252
299
  modelsWithInput,
@@ -261,21 +308,70 @@ function runSdkGeneration(
261
308
  emitFile(sdkOptionsPath, generateSdkOptions());
262
309
 
263
310
  // ── sdk.ts aggregator ──
264
- if (sdkClientInfos.length > 0) {
311
+ const hasAnything = sdkClientInfos.length > 0 || areaBuckets.size > 0;
312
+ if (hasAnything) {
265
313
  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
314
  const sdkOptionsRel = relative(sdkEntryDir, sdkOptionsPath).replace(/\.ts$/, '.js');
315
+ const sdkOptionsImportPath = sdkOptionsRel.startsWith('.') ? sdkOptionsRel : './' + sdkOptionsRel;
272
316
  const sdkClassName = sdkName
273
317
  ? sdkName
274
318
  .split(/[-._\s]+/)
275
319
  .map(s => s.charAt(0).toUpperCase() + s.slice(1))
276
320
  .join('') + 'Sdk'
277
321
  : 'Sdk';
278
- emitFile(sdkEntryPath, generateSdkAggregator(clients, sdkOptionsRel.startsWith('.') ? sdkOptionsRel : './' + sdkOptionsRel, sdkClassName));
322
+
323
+ const toClientImport = (info: { outPath: string; className: string; propertyName: string }): SdkClientInfo => {
324
+ let rel = relative(sdkEntryDir, info.outPath).replace(/\.ts$/, '.js');
325
+ if (!rel.startsWith('.')) rel = './' + rel;
326
+ return { className: info.className, propertyName: info.propertyName, importPath: rel };
327
+ };
328
+
329
+ const topLevelClients: SdkClientInfo[] = topLevelEntries.map(e => ({
330
+ className: deriveClientClassName(e.ast.file),
331
+ propertyName: deriveClientPropertyName(e.ast.file),
332
+ importPath: (() => {
333
+ const rel = relative(sdkEntryDir, e.outPath).replace(/\.ts$/, '.js');
334
+ return rel.startsWith('.') ? rel : './' + rel;
335
+ })(),
336
+ }));
337
+
338
+ const areas: SdkAreaInfo[] = [...areaBuckets.entries()]
339
+ .sort(([a], [b]) => a.localeCompare(b))
340
+ .map(([area, bucket]) => ({
341
+ area,
342
+ inlineFiles: bucket.inlineRoots.map(root => ({
343
+ root,
344
+ codegenOptions: {
345
+ typeImportPathTemplate: undefined,
346
+ outPath: sdkEntryPath,
347
+ modelOutPaths: sdkModelOutPaths,
348
+ sdkOptionsPath,
349
+ modelsWithInput,
350
+ modelsWithOutput,
351
+ includeInternal: config.includeInternal,
352
+ },
353
+ })),
354
+ subareaClients: bucket.leaves
355
+ .sort((a, b) => a.subarea.localeCompare(b.subarea))
356
+ .map(l => ({
357
+ propertyName: deriveSubareaPropertyName(l.subarea),
358
+ client: toClientImport({
359
+ outPath: l.outPath,
360
+ className: deriveSubareaClientClassName(area, l.subarea),
361
+ propertyName: deriveSubareaPropertyName(l.subarea),
362
+ }),
363
+ })),
364
+ }));
365
+
366
+ emitFile(
367
+ sdkEntryPath,
368
+ generateSdkAggregator({
369
+ topLevelClients,
370
+ areas,
371
+ sdkOptionsImportPath,
372
+ sdkClassName,
373
+ }),
374
+ );
279
375
  }
280
376
 
281
377
  // ── Barrel files ──
@@ -284,7 +380,7 @@ function runSdkGeneration(
284
380
  for (const barrel of sdkTypeBarrels) emitFile(barrel.outPath, barrel.content);
285
381
 
286
382
  const rootExports: string[] = [`export * from './${basename(sdkOptionsPath).replace(/\.ts$/, '.js')}';`];
287
- if (sdkClientInfos.length > 0) {
383
+ if (hasAnything) {
288
384
  rootExports.push(`export * from './${basename(sdkEntryPath).replace(/\.ts$/, '.js')}';`);
289
385
  }
290
386
  for (const c of sdkClientInfos) {
@@ -403,6 +499,11 @@ export default plugin;
403
499
 
404
500
  // ─── Factory: for programmatic use with explicit config ────────────────────
405
501
 
502
+ /**
503
+ * Build a `@contractkit/plugin-typescript` instance with explicit configuration, for
504
+ * programmatic use (tests, custom build scripts). Prefer the default export when loading
505
+ * via `contractkit.config.json`.
506
+ */
406
507
  export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir: string): ContractKitPlugin {
407
508
  return {
408
509
  name: 'typescript',