···11+---
22+'@hey-api/openapi-ts': minor
33+---
44+55+**plugin(sdk)**: **BREAKING**: Structure API
66+77+### Structure API
88+99+The [SDK plugin](https://heyapi.dev/openapi-ts/plugins/sdk) now implements the Structure API, enabling more complex structures and fixing several known issues.
1010+1111+Some Structure APIs are incompatible with the previous configuration, most notably the `methodNameBuilder` function, which accepted the operation object as an argument. You can read the [SDK Output](https://heyapi.dev/openapi-ts/plugins/sdk#output) section to familiarize yourself with the Structure API.
1212+1313+Please [open an issue](https://github.com/hey-api/openapi-ts/issues) if you're unable to migrate your configuration to the new syntax.
+5
.changeset/tiny-clouds-dress.md
···11+---
22+'@hey-api/codegen-core': minor
33+---
44+55+**core**: Structure API
···11+---
22+'@hey-api/openapi-ts': minor
33+---
44+55+**plugin(@angular/common)**: **BREAKING**: Structure API
66+77+### Structure API
88+99+The [Angular plugin](https://heyapi.dev/openapi-ts/plugins/angular) now implements the Structure API, enabling more complex structures and fixing several known issues.
1010+1111+Some Structure APIs are incompatible with the previous configuration, most notably the `methodNameBuilder` function, which accepted the operation object as an argument. You can read the [SDK Output](https://heyapi.dev/openapi-ts/plugins/sdk#output) section to familiarize yourself with the Structure API.
1212+1313+Please [open an issue](https://github.com/hey-api/openapi-ts/issues) if you're unable to migrate your configuration to the new syntax.
···7788## Foreword
991010-Hey API is building an OpenAPI to TypeScript code generator ecosystem. It’s trusted by thousands of companies – from YC startups to Fortune 500 enterprises – and powers products used by millions worldwide.
1010+Hey API is building an OpenAPI to TypeScript code generator ecosystem. It's trusted by thousands of companies – from YC startups to Fortune 500 enterprises – and powers products used by millions worldwide.
11111212-We welcome contributors of all backgrounds and experience levels. Whether you’re fixing a typo or building a new feature, your input matters. If you need guidance, help with technical writing, or want to bring a feature idea to life, we’re here to support you.
1212+We welcome contributors of all backgrounds and experience levels. Whether you're fixing a typo or building a new feature, your input matters. If you need guidance, help with technical writing, or want to bring a feature idea to life, we're here to support you.
13131414::: tip
1515···39394040Ready to write some code? We have dedicated guides to help you [build](/openapi-ts/community/contributing/building), [develop](/openapi-ts/community/contributing/developing), and [test](/openapi-ts/community/contributing/testing) your feature before it's released.
41414242-We are excited to see what you’ll contribute!
4242+We are excited to see what you'll contribute!
+30
docs/openapi-ts/configuration/output.md
···286286287287:::
288288289289+## File Header
290290+291291+The generated output includes a notice in every file warning that any modifications will be lost when the files are regenerated. You can customize or disable this notice using the `header` option.
292292+293293+::: code-group
294294+295295+<!-- prettier-ignore-start -->
296296+```js [config]
297297+export default {
298298+ input: 'hey-api/backend', // sign up at app.heyapi.dev
299299+ output: {
300300+ header: [
301301+ '/* eslint-disable */', // [!code ++]
302302+ '// This file is auto-generated by @hey-api/openapi-ts', // [!code ++]
303303+ ],
304304+ path: 'src/client',
305305+ },
306306+};
307307+```
308308+<!-- prettier-ignore-end -->
309309+310310+```ts [example]
311311+/* eslint-disable */
312312+// This file is auto-generated by @hey-api/openapi-ts
313313+314314+/** ... */
315315+```
316316+317317+:::
318318+289319## TSConfig Path
290320291321We use the [TSConfig file](https://www.typescriptlang.org/tsconfig/) to generate output matching your project's settings. By default, we attempt to find a TSConfig file starting from the location of the `@hey-api/openapi-ts` configuration file and traversing up.
+2-2
docs/openapi-ts/configuration/parser.md
···494494495495## Hooks
496496497497-Hooks affect runtime behavior but aren’t tied to any single plugin. They can be configured globally via `hooks` or per plugin through the `~hooks` property.
497497+Hooks affect runtime behavior but aren't tied to any single plugin. They can be configured globally via `hooks` or per plugin through the `~hooks` property.
498498499499::: code-group
500500···527527528528### Operations {#hooks-operations}
529529530530-Each operation has a list of classifiers that can include `query`, `mutation`, both, or none. Plugins may use these values to decide whether to generate specific output. For example, you usually don’t want to generate [TanStack Query options](/openapi-ts/plugins/tanstack-query#queries) for PATCH operations.
530530+Each operation has a list of classifiers that can include `query`, `mutation`, both, or none. Plugins may use these values to decide whether to generate specific output. For example, you usually don't want to generate [TanStack Query options](/openapi-ts/plugins/tanstack-query#queries) for PATCH operations.
531531532532#### Query operations {#hooks-query-operations}
533533
+8
docs/openapi-ts/migrating.md
···13131414The [Resolvers API](/openapi-ts/plugins/concepts/resolvers) has been simplified and expanded to provide a more consistent behavior across plugins. You can view a few common examples on the [Resolvers](/openapi-ts/plugins/concepts/resolvers) page.
15151616+### Structure API
1717+1818+The [SDK plugin](/openapi-ts/plugins/sdk) and [Angular plugin](/openapi-ts/plugins/angular) now implement the Structure API, enabling more complex structures and fixing several known issues.
1919+2020+Some Structure APIs are incompatible with the previous configuration, most notably the `methodNameBuilder` function, which accepted the operation object as an argument. You can read the [SDK Output](/openapi-ts/plugins/sdk#output) section to familiarize yourself with the Structure API.
2121+2222+Please [open an issue](https://github.com/hey-api/openapi-ts/issues) if you're unable to migrate your configuration to the new syntax.
2323+1624## v0.89.0
17251826### Prefer named exports
+173-85
docs/openapi-ts/plugins/sdk.md
···11---
22-title: SDK
33-description: Learn about files generated with @hey-api/openapi-ts.
22+title: SDK Plugin
33+description: Generate SDKs from OpenAPI with the SDK plugin for openapi-ts. Fully compatible with validators, transformers, and all core features.
44---
5566-# SDKs
66+# SDK
77+88+### About
99+1010+The SDK plugin generates a high-level, ergonomic API layer on top of the low-level HTTP client.
71188-SDKs are located in the `sdk.gen.ts` file. SDKs are abstractions on top of clients and serve the same purpose. By default, `@hey-api/openapi-ts` will generate a flat SDK layer. Your choice to use SDKs depends on personal preferences and bundle size considerations.
1212+It exposes typed functions or classes for each operation, with built-in auth handling and optional request and response validation.
9131010-### Flat SDKs
1414+## Features
1515+1616+- high-level SDK layer on top of the HTTP client
1717+- typed functions or classes per operation
1818+- built-in authentication handling
1919+- optional request and response validation
11201212-This is the default setting. Flat SDKs support tree-shaking and can lead to reduced bundle size over duplicated client calls. The function names are generated from operation IDs or operation location.
2121+## Installation
13221414-### Class SDKs
2323+In your [configuration](/openapi-ts/get-started), add `@hey-api/sdk` to your plugins and you'll be ready to generate SDK artifacts. :tada:
15241616-Class SDKs do not support tree-shaking which will lead to increased bundle sizes, but some people prefer this option for syntax reasons. The class names are generated from operation tags and method names are generated from operation IDs or operation location.
2525+```js
2626+export default {
2727+ input: 'hey-api/backend', // sign up at app.heyapi.dev
2828+ output: 'src/client',
2929+ plugins: [
3030+ // ...other plugins
3131+ '@hey-api/sdk', // [!code ++]
3232+ ],
3333+};
3434+```
17351818-### No SDKs
3636+## Output
19372020-If you prefer to use clients directly or do not need the SDK layer, define `plugins` manually and omit the `@hey-api/sdk` plugin. Type support for clients is currently limited due to popularity of other options. If you'd like to use this option and need better types, please [open an issue](https://github.com/hey-api/openapi-ts/issues).
3838+The SDK plugin supports a wide range of configuration options. This guide focuses on two main SDK formats: tree-shakeable functions and instantiable classes, but you can apply the same concepts to create more advanced configurations.
21392222-## Configuration
4040+## Flat
23412424-You can modify the contents of `sdk.gen.ts` by configuring the `@hey-api/sdk` plugin. Note that you must specify the default plugins to preserve the default output.
4242+This is the default setting. Flat SDKs support tree-shaking, which can lead to a reduced bundle size. You select flat mode by setting `operations.strategy` to `flat`.
25432644::: code-group
27452828-```js [flat]
4646+```ts [example]
4747+import type { AddPetData } from './types.gen';
4848+4949+export const addPet = (options: Options<AddPetData>) => {
5050+ /** ... */
5151+};
5252+```
5353+5454+```js [config]
2955export default {
3056 input: 'hey-api/backend', // sign up at app.heyapi.dev
3157 output: 'src/client',
3258 plugins: [
3359 // ...other plugins
3460 {
3535- asClass: false, // default // [!code ++]
3661 name: '@hey-api/sdk',
6262+ operations: {
6363+ strategy: 'flat', // [!code ++]
6464+ },
3765 },
3866 ],
3967};
4068```
41694242-```js [class]
7070+:::
7171+7272+## Instance
7373+7474+Class SDKs do not support tree-shaking, which results in a larger bundle size, but you may prefer their syntax. You select class mode by setting `operations.strategy` to `single`.
7575+7676+::: code-group
7777+7878+```ts [example]
7979+import type { AddPetData } from './types.gen';
8080+8181+export class Sdk extends HeyApiClient {
8282+ public addPet(options: Options<AddPetData>) {
8383+ /** ... */
8484+ }
8585+}
8686+```
8787+8888+```js [config]
4389export default {
4490 input: 'hey-api/backend', // sign up at app.heyapi.dev
4591 output: 'src/client',
4692 plugins: [
4793 // ...other plugins
4894 {
4949- asClass: true, // [!code ++]
5095 name: '@hey-api/sdk',
9696+ operations: {
9797+ strategy: 'single', // [!code ++]
9898+ },
5199 },
52100 ],
53101};
54102```
551035656-```js [none]
104104+:::
105105+106106+### Name
107107+108108+As shown above, by default our SDK class is called `Sdk`. The first thing you'll likely want to do is change this to your preferred name, which you can do using `operation.containerName`.
109109+110110+::: code-group
111111+112112+```ts [example]
113113+import { client } from './client.gen';
114114+import type { AddPetData, AddPetErrors, AddPetResponses } from './types.gen';
115115+116116+export class PetStore extends HeyApiClient {
117117+ // [!code ++]
118118+ /** ... */
119119+}
120120+```
121121+122122+```js [config]
57123export default {
58124 input: 'hey-api/backend', // sign up at app.heyapi.dev
59125 output: 'src/client',
60126 plugins: [
6161- '@hey-api/typescript',
6262- '@hey-api/sdk', // [!code --]
127127+ // ...other plugins
128128+ {
129129+ name: '@hey-api/sdk',
130130+ operations: {
131131+ containerName: 'PetStore', // [!code ++]
132132+ strategy: 'single',
133133+ },
134134+ },
63135 ],
64136};
65137```
6613867139:::
681406969-## Output
141141+### Structure
701427171-Below are different outputs depending on your chosen style. No SDKs approach will not generate the `sdk.gen.ts` file.
143143+While we try to infer the SDK structure from `operationId` fields, you'll likely want to customize it further. You can do this using `operations.nesting`.
721447373-::: code-group
145145+Similar to the `operations.strategy` option, we provide a few presets. However, you gain the most control by providing your own function.
741467575-```ts [flat]
7676-import type { Options } from './client';
7777-import { client as _heyApiClient } from './client.gen';
7878-import type { AddPetData, AddPetError, AddPetResponse } from './types.gen';
147147+To demonstrate the power of this feature, let's nest a few endpoints inside a `Pet` class and rename them. Our original `addPet()` method will now become `pet.add()`. Notice that we use the built-in `OperationPath.fromOperationId()` helper to handle the remaining operations.
791488080-export const addPet = (options: Options<AddPetData>) =>
8181- (options?.client ?? _heyApiClient).post<AddPetResponse, AddPetError>({
8282- url: '/pet',
8383- ...options,
8484- });
8585-```
149149+::: code-group
861508787-```ts [class]
8888-import type { Options } from './client';
8989-import { client as _heyApiClient } from './client.gen';
9090-import type { AddPetData, AddPetError, AddPetResponse } from './types.gen';
151151+```ts [example]
152152+import { client } from './client.gen';
153153+import type { AddPetData, AddPetErrors, AddPetResponses } from './types.gen';
154154+155155+export class Pet extends HeyApiClient {
156156+ public add(options: Options<PostPetData>) {
157157+ // [!code ++]
158158+ /** ... */
159159+ }
160160+}
911619292-export class PetService {
9393- public static addPet(options: Options<AddPetData>) {
9494- return (options?.client ?? _heyApiClient).post<AddPetResponse, AddPetError>(
9595- {
9696- url: '/pet',
9797- ...options,
9898- },
9999- );
162162+export class PetStore extends HeyApiClient {
163163+ get pet(): Pet {
164164+ // [!code ++]
165165+ /** ... */
100166 }
101167}
102168```
103169170170+<!-- prettier-ignore-start -->
171171+```js [config]
172172+export default {
173173+ input: 'hey-api/backend', // sign up at app.heyapi.dev
174174+ output: 'src/client',
175175+ plugins: [
176176+ // ...other plugins
177177+ {
178178+ name: '@hey-api/sdk',
179179+ operations: {
180180+ containerName: 'PetStore',
181181+ nesting(operation) {
182182+ if (operation.path === '/pet/{petId}' || operation.path === '/pet') { // [!code ++]
183183+ return ['pet', operation.operationId?.replace(/Pet/, '') // [!code ++]
184184+ || operation.method.toLocaleLowerCase()]; // [!code ++]
185185+ } // [!code ++]
186186+ return OperationPath.fromOperationId()(operation); // [!code ++]
187187+ },
188188+ strategy: 'single',
189189+ },
190190+ },
191191+ ],
192192+};
193193+```
194194+<!-- prettier-ignore-end -->
195195+104196:::
105197106106-## Usage
198198+## Auth
107199108108-This is how you'd make the same request using each approach.
200200+Most APIs require some form of authentication, which is why the SDK plugin provides built-in auth mechanisms by default. All you need to do is return the data from the `auth()` function, and the SDK will handle serialization and encoding for you. There are several ways to do this, for example on the client instance.
109201110202::: code-group
111203112112-```ts [flat]
113113-import { addPet } from './client/sdk.gen';
204204+```ts [example]
205205+import { client } from './client.gen';
114206115115-addPet({
116116- body: {
117117- name: 'Kitty',
207207+client.setConfig({
208208+ auth() {
209209+ return '<token>';
118210 },
119211});
120212```
121213122122-```ts [class]
123123-import { PetService } from './client/sdk.gen';
124124-125125-PetService.addPet({
126126- body: {
127127- name: 'Kitty',
128128- },
129129-});
214214+```js [config]
215215+export default {
216216+ input: 'hey-api/backend', // sign up at app.heyapi.dev
217217+ output: 'src/client',
218218+ plugins: [
219219+ // ...other plugins
220220+ {
221221+ auth: true, // [!code ++]
222222+ name: '@hey-api/sdk',
223223+ },
224224+ ],
225225+};
130226```
131227132132-```ts [none]
133133-import { client } from './client/client';
134134-135135-client.post({
136136- body: {
137137- name: 'Kitty',
138138- },
139139- url: '/pet',
140140-});
141141-```
228228+:::
142229230230+::: info
231231+The SDK plugin currently supports only the `bearer` and `basic` auth schemes. [Open an issue](https://github.com/hey-api/openapi-ts/issues) if you'd like support for additional mechanisms.
143232:::
144233145234## Validators
146235147147-There are two ways to configure validators. If you only want to add validators to your SDKs, set `sdk.validator` to a validator plugin name. This will implicitly add the selected plugin with default values.
236236+Validating data at runtime comes with a performance cost, which is why it's not enabled by default. To enable validation, set `validator` to `zod` or one of the available [validator plugins](/openapi-ts/validators). This will implicitly add the selected plugin with default values.
148237149149-For a more granular approach, add a validator plugin and set `sdk.validator` to the plugin name or `true` to automatically select a plugin. Until you customize the validator plugin, both approaches will produce the same default output.
238238+For a more granular approach, manually add a validator plugin and set `validator` to the plugin name or `true` to automatically select a compatible plugin. Until you customize the validator plugin, both approaches will produce the same default output.
150239151240::: code-group
152241153153-```js [sdk]
154154-export default {
155155- input: 'hey-api/backend', // sign up at app.heyapi.dev
156156- output: 'src/client',
157157- plugins: [
158158- {
159159- name: '@hey-api/sdk',
160160- validator: 'zod', // [!code ++]
161161- },
162162- ],
163163-};
242242+```ts [example]
243243+import * as v from 'valibot';
244244+245245+export const addPet = (options: Options<AddPetData>) =>
246246+ (options.client ?? client).post<AddPetResponses, AddPetErrors>({
247247+ requestValidator: async (data) => await v.parseAsync(vAddPetData, data), // [!code ++]
248248+ responseValidator: async (data) =>
249249+ await v.parseAsync(vAddPetResponse, data), // [!code ++]
250250+ /** ... */
251251+ });
164252```
165253166166-```js [validator]
254254+```js [config]
167255export default {
168256 input: 'hey-api/backend', // sign up at app.heyapi.dev
169257 output: 'src/client',
170258 plugins: [
171259 {
172260 name: '@hey-api/sdk',
173173- validator: true, // or 'zod' // [!code ++]
261261+ validator: true, // or 'valibot' // [!code ++]
174262 },
175263 {
176176- name: 'zod', // [!code ++]
264264+ name: 'valibot', // customize (optional) // [!code ++]
177265 // other options
178266 },
179267 ],
···3232export interface ImportMember {
3333 /** Whether this import is type-only. */
3434 isTypeOnly: boolean;
3535- /** Import flavor. */
3636- kind: BindingKind;
3735 /**
3836 * The name this symbol will have locally in this file.
3937 * This is where aliasing is applied:
···4745 sourceName: string;
4846}
49475050-export type ImportModule = Pick<ImportMember, 'isTypeOnly'> & {
5151- /** Source file. */
5252- from: File;
5353- /** List of symbols imported from this module. */
5454- imports: Array<ImportMember>;
5555- /** Namespace import: `import * as name from 'module'`. Mutually exclusive with `imports`. */
5656- namespaceImport?: string;
5757-};
4848+export type ImportModule = Pick<ImportMember, 'isTypeOnly'> &
4949+ Pick<Partial<ImportMember>, 'localName'> & {
5050+ /** Source file. */
5151+ from: File;
5252+ /** List of symbols imported from this module. */
5353+ imports: Array<ImportMember>;
5454+ /** Import flavor. */
5555+ kind: BindingKind;
5656+ };
···2233import type { ExportModule, ImportModule } from '../bindings';
44import { fileBrand } from '../brands';
55-import { debug } from '../debug';
65import type { Language } from '../languages/types';
66+import { log } from '../log';
77import type { INode } from '../nodes/node';
88-import type { NameScopes } from '../planner/types';
88+import type { NameScopes } from '../planner/scope';
99import type { IProject } from '../project/types';
1010import type { Renderer } from '../renderer';
1111import type { IFileIn } from './types';
12121313-export class File {
1313+export class File<Node extends INode = INode> {
1414 /**
1515 * Exports from this file.
1616 */
···4242 /**
4343 * Syntax nodes contained in this file.
4444 */
4545- private _nodes: Array<INode> = [];
4545+ private _nodes: Array<Node> = [];
4646 /**
4747 * Renderer assigned to this file.
4848 */
···8181 * Read-only accessor for the file extension.
8282 */
8383 get extension(): string | undefined {
8484+ if (this.external) return;
8485 if (this._extension) return this._extension;
8586 const language = this.language;
8687 const extension = language ? this.project.extensions[language] : undefined;
···135136 const name = this._logicalFilePath.split('/').pop();
136137 if (name) return name;
137138 const message = `File ${this.toString()} has no name`;
138138- debug(message, 'file');
139139+ log.debug(message, 'file');
139140 throw new Error(message);
140141 }
141142142143 /**
143144 * Syntax nodes contained in this file.
144145 */
145145- get nodes(): ReadonlyArray<INode> {
146146+ get nodes(): ReadonlyArray<Node> {
146147 return [...this._nodes];
147148 }
148149···170171 /**
171172 * Add a syntax node to the file.
172173 */
173173- addNode(node: INode): void {
174174+ addNode(node: Node): void {
174175 this._nodes.push(node);
175176 node.file = this;
176177 }
+17-3
packages/codegen-core/src/index.ts
···55 ImportModule,
66} from './bindings';
77export { nodeBrand, symbolBrand } from './brands';
88-export { debug } from './debug';
98export type {
109 IProjectRenderMeta as ProjectRenderMeta,
1110 ISymbolMeta as SymbolMeta,
···2019 Language,
2120 NameConflictResolvers,
2221} from './languages/types';
2323-export type { AstContext } from './nodes/context';
2424-export type { INode as Node } from './nodes/node';
2222+export { log } from './log';
2323+export type {
2424+ INode as Node,
2525+ NodeName,
2626+ NodeNameSanitizer,
2727+ NodeRelationship,
2828+ NodeScope,
2929+} from './nodes/node';
2530export type { IOutput as Output } from './output';
2631export {
2732 simpleNameConflictResolver,
···3641export { fromRef, fromRefs, isRef, ref, refs } from './refs/refs';
3742export type { FromRef, FromRefs, Ref, Refs } from './refs/types';
3843export type { RenderContext, Renderer } from './renderer';
4444+export { StructureModel } from './structure/model';
4545+export { StructureNode } from './structure/node';
4646+export type {
4747+ StructureInsert,
4848+ StructureItem,
4949+ StructureLocation,
5050+ StructureShell,
5151+ StructureShellResult,
5252+} from './structure/types';
3953export { Symbol } from './symbols/symbol';
4054export type {
4155 BindingKind,
+115
packages/codegen-core/src/log.ts
···11+import colors from 'ansi-colors';
22+// @ts-expect-error
33+import colorSupport from 'color-support';
44+55+colors.enabled = colorSupport().hasBasic;
66+77+/**
88+ * Accepts a value or a readonly array of values of type T.
99+ */
1010+export type MaybeArray<T> = T | ReadonlyArray<T>;
1111+1212+/**
1313+ * Accepts a value or a function returning a value.
1414+ */
1515+export type MaybeFunc<T extends (...args: Array<any>) => any> =
1616+ | T
1717+ | ReturnType<T>;
1818+1919+const DEBUG_NAMESPACE = 'heyapi';
2020+2121+const NO_WARNINGS = /^(1|true|yes|on)$/i.test(
2222+ process.env.HEYAPI_DISABLE_WARNINGS ?? '',
2323+);
2424+2525+const DebugGroups = {
2626+ analyzer: colors.greenBright,
2727+ dsl: colors.cyanBright,
2828+ file: colors.yellowBright,
2929+ registry: colors.blueBright,
3030+ symbol: colors.magentaBright,
3131+} as const;
3232+3333+const WarnGroups = {
3434+ deprecated: colors.magentaBright,
3535+} as const;
3636+3737+let cachedDebugGroups: Set<string> | undefined;
3838+function getDebugGroups(): Set<string> {
3939+ if (cachedDebugGroups) return cachedDebugGroups;
4040+4141+ const value = process.env.DEBUG;
4242+ cachedDebugGroups = new Set(
4343+ value ? value.split(',').map((x) => x.trim().toLowerCase()) : [],
4444+ );
4545+4646+ return cachedDebugGroups;
4747+}
4848+4949+/**
5050+ * Tracks which deprecations have been shown to avoid spam.
5151+ */
5252+const shownDeprecations = new Set<string>();
5353+5454+function debug(message: string, group: keyof typeof DebugGroups) {
5555+ const groups = getDebugGroups();
5656+ if (
5757+ !(
5858+ groups.has('*') ||
5959+ groups.has(`${DEBUG_NAMESPACE}:*`) ||
6060+ groups.has(`${DEBUG_NAMESPACE}:${group}`) ||
6161+ groups.has(group)
6262+ )
6363+ ) {
6464+ return;
6565+ }
6666+6767+ const color = DebugGroups[group] ?? colors.whiteBright;
6868+ const prefix = color(`${DEBUG_NAMESPACE}:${group}`);
6969+7070+ console.debug(`${prefix} ${message}`);
7171+}
7272+7373+function warn(message: string, group: keyof typeof WarnGroups) {
7474+ if (NO_WARNINGS) return;
7575+7676+ const color = WarnGroups[group] ?? colors.yellowBright;
7777+7878+ console.warn(color(`${message}`));
7979+}
8080+8181+function warnDeprecated({
8282+ context,
8383+ field,
8484+ replacement,
8585+}: {
8686+ context?: string;
8787+ field: string;
8888+ replacement?: MaybeFunc<(field: string) => MaybeArray<string>>;
8989+}) {
9090+ const key = context
9191+ ? `${context}:${field}:${JSON.stringify(replacement)}`
9292+ : `${field}:${JSON.stringify(replacement)}`;
9393+9494+ if (shownDeprecations.has(key)) return;
9595+ shownDeprecations.add(key);
9696+9797+ let message = `\`${field}\` is deprecated.`;
9898+9999+ if (replacement) {
100100+ const reps =
101101+ typeof replacement === 'function' ? replacement(field) : replacement;
102102+ const repArray = reps instanceof Array ? reps : [reps];
103103+ const repString = repArray.map((r) => `\`${r}\``).join(' or ');
104104+ message += ` Use ${repString} instead.`;
105105+ }
106106+107107+ const prefix = context ? `[${context}] ` : '';
108108+ warn(`${prefix}${message}`, 'deprecated');
109109+}
110110+111111+export const log = {
112112+ debug,
113113+ warn,
114114+ warnDeprecated,
115115+};
-11
packages/codegen-core/src/nodes/context.d.ts
···11-import type { INode } from './node';
22-33-/**
44- * Context passed to `.toAst()` methods.
55- */
66-export type AstContext = {
77- /**
88- * Returns the canonical node for accessing the provided node.
99- */
1010- getAccess<T extends INode>(node: T): T;
1111-};
+29-6
packages/codegen-core/src/nodes/node.d.ts
···11import type { File } from '../files/file';
22import type { Language } from '../languages/types';
33import type { IAnalysisContext } from '../planner/types';
44+import type { Ref } from '../refs/types';
45import type { Symbol } from '../symbols/symbol';
55-import type { AstContext } from './context';
66+77+export type MaybeRef<T> = T | Ref<T>;
88+99+export type NodeName = MaybeRef<Symbol | string | number>;
1010+1111+export type NodeNameSanitizer = (name: string) => string;
1212+1313+export type NodeRelationship = 'container' | 'reference';
1414+1515+export type NodeScope = 'type' | 'value';
616717export interface INode<T = unknown> {
818 /** Perform semantic analysis. */
919 analyze(ctx: IAnalysisContext): void;
2020+ /** Create a shallow copy of this node. */
2121+ clone(): this;
1022 /** Whether this node is exported from its file. */
1123 exported?: boolean;
1224 /** The file this node belongs to. */
1325 file?: File;
1426 /** The programming language associated with this node */
1527 language: Language;
1616- /** Parent node in the syntax tree. */
1717- parent?: INode;
1818- /** Root node of the syntax tree. */
1919- root?: INode;
2828+ /** The display name of this node. */
2929+ readonly name: Ref<NodeName> & {
3030+ set(value: NodeName): void;
3131+ toString(): string;
3232+ };
3333+ /** Optional function to sanitize the node name. */
3434+ readonly nameSanitizer?: NodeNameSanitizer;
3535+ /** Whether this node is a root node in the file. */
3636+ root?: boolean;
3737+ /** The scope of this node. */
3838+ scope?: NodeScope;
3939+ /** Semantic children in the structure hierarchy. */
4040+ structuralChildren?: Map<INode, NodeRelationship>;
4141+ /** Semantic parents in the structure hierarchy. */
4242+ structuralParents?: Map<INode, NodeRelationship>;
2043 /** The symbol associated with this node. */
2144 symbol?: Symbol;
2245 /** Convert this node into AST representation. */
2323- toAst(ctx: AstContext): T;
4646+ toAst(): T;
2447 /** Brand used for renderer dispatch. */
2548 readonly '~brand': string;
2649}
+73-19
packages/codegen-core/src/planner/analyzer.ts
···11import { isNodeRef, isSymbolRef } from '../guards';
22-import type { INode } from '../nodes/node';
22+import type { INode, NodeRelationship } from '../nodes/node';
33import { fromRef, isRef, ref } from '../refs/refs';
44import type { Ref } from '../refs/types';
55import type { Symbol } from '../symbols/symbol';
66-import type { IAnalysisContext, Input, NameScopes, Scope } from './types';
66+import type { NameScopes, Scope } from './scope';
77+import { createScope } from './scope';
88+import type { IAnalysisContext, Input } from './types';
7988-const createScope = (parent?: Scope): Scope => ({
99- children: [],
1010- localNames: new Map(),
1111- parent,
1212- symbols: [],
1313-});
1010+export class AnalysisContext implements IAnalysisContext {
1111+ /**
1212+ * Stack of parent nodes during analysis.
1313+ *
1414+ * The top of the stack is the current semantic container.
1515+ */
1616+ private _parentStack: Array<INode> = [];
14171515-export class AnalysisContext implements IAnalysisContext {
1818+ scope: Scope;
1619 scopes: Scope = createScope();
1720 symbol?: Symbol;
1818- scope: Scope = this.scopes;
19212020- constructor(symbol?: Symbol) {
2121- this.symbol = symbol;
2222+ constructor(node: INode) {
2323+ this._parentStack.push(node);
2424+ this.scope = this.scopes;
2525+ this.symbol = node.symbol;
2626+ }
2727+2828+ /**
2929+ * Get the current semantic parent (top of stack).
3030+ */
3131+ get currentParent(): INode | undefined {
3232+ return this._parentStack[this._parentStack.length - 1];
3333+ }
3434+3535+ /**
3636+ * Register a child node under the current parent.
3737+ */
3838+ addChild(child: INode, relationship: NodeRelationship = 'container'): void {
3939+ const parent = this.currentParent;
4040+ if (!parent) return;
4141+4242+ if (!parent.structuralChildren) {
4343+ parent.structuralChildren = new Map();
4444+ }
4545+ parent.structuralChildren.set(child, relationship);
4646+4747+ if (!child.structuralParents) {
4848+ child.structuralParents = new Map();
4949+ }
5050+ child.structuralParents.set(parent, relationship);
2251 }
23522453 addDependency(symbol: Ref<Symbol>): void {
···2857 }
29583059 analyze(input: Input): void {
3131- const v = isRef(input) ? input : ref(input);
3232- if (isSymbolRef(v)) {
3333- this.addDependency(v);
3434- } else if (isNodeRef(v)) {
3535- fromRef(v).analyze(this);
6060+ const value = isRef(input) ? input : ref(input);
6161+ if (isSymbolRef(value)) {
6262+ const symbol = fromRef(value);
6363+ // avoid adding self as child
6464+ if (symbol.node && this.currentParent !== symbol.node) {
6565+ this.addChild(symbol.node, 'reference');
6666+ }
6767+ this.addDependency(value);
6868+ } else if (isNodeRef(value)) {
6969+ const node = fromRef(value);
7070+ this.addChild(node, 'container');
7171+ this.pushParent(node);
7272+ node.analyze(this);
7373+ this.popParent();
3674 }
3775 }
3876···5795 return names;
5896 }
59979898+ /**
9999+ * Pop the current semantic parent.
100100+ * Call this when exiting a container node.
101101+ */
102102+ popParent(): void {
103103+ this._parentStack.pop();
104104+ }
105105+60106 popScope(): void {
61107 this.scope = this.scope.parent ?? this.scope;
62108 }
63109110110+ /**
111111+ * Push a node as the current semantic parent.
112112+ */
113113+ pushParent(node: INode): void {
114114+ this._parentStack.push(node);
115115+ }
116116+64117 pushScope(): void {
6565- const scope = createScope(this.scope);
118118+ const scope = createScope({ parent: this.scope });
66119 this.scope.children.push(scope);
67120 this.scope = scope;
68121 }
···90143 const cached = this.nodeCache.get(node);
91144 if (cached) return cached;
921459393- const ctx = new AnalysisContext(node.symbol);
146146+ node.root = true;
147147+ const ctx = new AnalysisContext(node);
94148 node.analyze(ctx);
9514996150 this.nodeCache.set(node, ctx);
···11+import type { Ref } from '../refs/types';
22+import type { Symbol } from '../symbols/symbol';
33+import type { SymbolKind } from '../symbols/types';
44+55+export type NameScopes = Map<string, Set<SymbolKind>>;
66+77+export type Scope = {
88+ /** Child scopes. */
99+ children: Array<Scope>;
1010+ /** Resolved names in this scope. */
1111+ localNames: NameScopes;
1212+ /** Parent scope, if any. */
1313+ parent?: Scope;
1414+ /** Symbols registered in this scope. */
1515+ symbols: Array<Ref<Symbol>>;
1616+};
1717+1818+export type AssignOptions = {
1919+ /** The primary scope in which to assign a symbol's final name. */
2020+ scope: Scope;
2121+ /** Additional scopes to update as side effects when assigning a symbol's final name. */
2222+ scopesToUpdate: ReadonlyArray<Scope>;
2323+};
2424+2525+export const createScope = (
2626+ args: {
2727+ localNames?: NameScopes;
2828+ parent?: Scope;
2929+ } = {},
3030+): Scope => ({
3131+ children: [],
3232+ localNames: args.localNames || new Map(),
3333+ parent: args.parent,
3434+ symbols: [],
3535+});
+1-21
packages/codegen-core/src/planner/types.d.ts
···11import type { Ref } from '../refs/types';
22import type { Symbol } from '../symbols/symbol';
33-import type { SymbolKind } from '../symbols/types';
44-55-export type AssignOptions = {
66- /** The primary scope in which to assign a symbol's final name. */
77- scope: NameScopes;
88- /** Additional scopes to update as side effects when assigning a symbol's final name. */
99- scopesToUpdate: ReadonlyArray<NameScopes>;
1010-};
33+import type { NameScopes, Scope } from './scope';
114125export type Input = Ref<object> | object | string | number | undefined;
1313-1414-export type NameScopes = Map<string, Set<SymbolKind>>;
156167export type NameConflictResolver = (args: {
178 attempt: number;
189 baseName: string;
1910}) => string | null;
2020-2121-export type Scope = {
2222- /** Child scopes. */
2323- children: Array<Scope>;
2424- /** Resolved names in this scope. */
2525- localNames: NameScopes;
2626- /** Parent scope, if any. */
2727- parent?: Scope;
2828- /** Symbols registered in this scope. */
2929- symbols: Array<Ref<Symbol>>;
3030-};
31113212export interface IAnalysisContext {
3313 /** Register a dependency on another symbol. */
+2-13
packages/codegen-core/src/project/project.ts
···55import { defaultExtensions } from '../languages/extensions';
66import { defaultNameConflictResolvers } from '../languages/resolvers';
77import type { Extensions, NameConflictResolvers } from '../languages/types';
88-import type { AstContext } from '../nodes/context';
98import { NodeRegistry } from '../nodes/registry';
109import type { IOutput } from '../output';
1110import { Planner } from '../planner/planner';
···6160 render(meta?: IProjectRenderMeta): ReadonlyArray<IOutput> {
6261 new Planner(this).plan(meta);
6362 const files: Array<IOutput> = [];
6464- const astContext: AstContext = {
6565- getAccess(node) {
6666- return node;
6767- },
6868- };
6963 for (const file of this.files.registered()) {
7070- if (file.finalPath && file.renderer) {
7171- const content = file.renderer.render({
7272- astContext,
7373- file,
7474- meta,
7575- project: this,
7676- });
6464+ if (!file.external && file.finalPath && file.renderer) {
6565+ const content = file.renderer.render({ file, meta, project: this });
7766 files.push({ content, path: file.finalPath });
7867 }
7968 }
+12-4
packages/codegen-core/src/refs/refs.ts
···11-import type { FromRefs, Ref, Refs } from './types';
11+import type { FromRef, FromRefs, Ref, Refs } from './types';
2233/**
44 * Wraps a single value in a Ref object.
55+ *
66+ * If the value is already a Ref, returns it as-is (idempotent).
57 *
68 * @example
79 * ```ts
810 * const r = ref(123); // { '~ref': 123 }
911 * console.log(r['~ref']); // 123
1212+ *
1313+ * const r2 = ref(r); // { '~ref': 123 } (not double-wrapped)
1014 * ```
1115 */
1212-export const ref = <T>(value: T): Ref<T> => ({ '~ref': value });
1616+export const ref = <T>(value: T): Ref<T> => {
1717+ if (isRef(value)) {
1818+ return value as Ref<T>;
1919+ }
2020+ return { '~ref': value } as Ref<T>;
2121+};
13221423/**
1524 * Converts a plain object to an object of Refs (deep, per property).
···4251 */
4352export const fromRef = <T extends Ref<unknown> | undefined>(
4453 ref: T,
4545-): T extends Ref<infer U> ? U : undefined =>
4646- ref?.['~ref'] as T extends Ref<infer U> ? U : undefined;
5454+): FromRef<T> => ref?.['~ref'] as FromRef<T>;
47554856/**
4957 * Converts an object of Refs back to a plain object (unwraps all refs).
+3-3
packages/codegen-core/src/refs/types.d.ts
···88 * console.log(num['~ref']); // 42
99 * ```
1010 */
1111-export type Ref<T> = { '~ref': T };
1111+export type Ref<T> = T extends { ['~ref']: unknown } ? T : { '~ref': T };
12121313/**
1414 * Maps every property of `T` to a `Ref` of that property.
···3333 * type N = FromRef<{ '~ref': number }>; // number
3434 * ```
3535 */
3636-export type FromRef<T> = T extends Ref<infer V> ? V : T;
3636+export type FromRef<T> = T extends { '~ref': infer U } ? U : T;
37373838/**
3939 * Maps every property of a Ref-wrapped object back to its plain value.
···4646 * ```
4747 */
4848export type FromRefs<T> = {
4949- [K in keyof T]: T[K] extends Ref<infer V> ? V : T[K];
4949+ [K in keyof T]: T[K] extends Ref<infer U> ? U : T[K];
5050};
+4-8
packages/codegen-core/src/renderer.d.ts
···11import type { IProjectRenderMeta } from './extensions';
22import type { File } from './files/file';
33-import type { AstContext } from './nodes/context';
33+import type { INode } from './nodes/node';
44import type { IProject } from './project/types';
5566-export interface RenderContext {
77- /**
88- * The context passed to `.toAst()` methods.
99- */
1010- astContext: AstContext;
66+export interface RenderContext<Node extends INode = INode> {
117 /**
128 * The current file.
139 */
1414- file: File;
1010+ file: File<Node>;
1511 /**
1612 * Arbitrary metadata.
1713 */
···2622 /** Renders the given file. */
2723 render(ctx: RenderContext): string;
2824 /** Returns whether this renderer can render the given file. */
2929- supports(ctx: Omit<RenderContext, 'astContext'>): boolean;
2525+ supports(ctx: RenderContext): boolean;
3026}
+90
packages/codegen-core/src/structure/model.ts
···11+import { StructureNode } from './node';
22+import type { StructureInsert } from './types';
33+44+export class StructureModel {
55+ /** Root nodes mapped by their names. */
66+ private _roots: Map<string, StructureNode> = new Map();
77+ /** Node for data without a specific root. */
88+ private _virtualRoot?: StructureNode;
99+1010+ /**
1111+ * Get all root nodes.
1212+ */
1313+ get roots(): ReadonlyArray<StructureNode> {
1414+ const roots = Array.from(this._roots.values());
1515+ if (this._virtualRoot) roots.unshift(this._virtualRoot);
1616+ return roots;
1717+ }
1818+1919+ /**
2020+ * Insert data into the structure.
2121+ */
2222+ insert(args: StructureInsert): void {
2323+ const { data, locations, source } = args;
2424+ for (const location of locations) {
2525+ const { path, shell } = location;
2626+ const fullPath = path.filter((s): s is string => Boolean(s));
2727+ const segments = fullPath.slice(0, -1);
2828+ const name = fullPath[fullPath.length - 1];
2929+3030+ if (!name) {
3131+ throw new Error('Cannot insert data without path.');
3232+ }
3333+3434+ let cursor: StructureNode | null = null;
3535+3636+ for (const segment of segments) {
3737+ if (!cursor) {
3838+ cursor = this.root(segment);
3939+ } else {
4040+ cursor = cursor.child(segment);
4141+ }
4242+4343+ if (shell && !cursor.shell) {
4444+ cursor.shell = shell;
4545+ cursor.shellSource = source;
4646+ }
4747+ }
4848+4949+ if (!cursor) {
5050+ cursor = this.root(null);
5151+ }
5252+5353+ cursor.items.push({ data, location: fullPath, source });
5454+ }
5555+ }
5656+5757+ /**
5858+ * Gets or creates a root by name.
5959+ *
6060+ * If the root doesn't exist, it's created automatically.
6161+ *
6262+ * @param name - The name of the root
6363+ * @returns The root instance
6464+ */
6565+ root(name: string | null): StructureNode {
6666+ if (!name) {
6767+ return (this._virtualRoot ??= new StructureNode('', undefined, {
6868+ virtual: true,
6969+ }));
7070+ }
7171+ if (!this._roots.has(name)) {
7272+ this._roots.set(name, new StructureNode(name));
7373+ }
7474+ return this._roots.get(name)!;
7575+ }
7676+7777+ /**
7878+ * Walk all nodes in the structure (depth-first, post-order).
7979+ *
8080+ * @returns Generator of all structure nodes
8181+ */
8282+ *walk(): Generator<StructureNode> {
8383+ if (this._virtualRoot) {
8484+ yield* this._virtualRoot.walk();
8585+ }
8686+ for (const root of this._roots.values()) {
8787+ yield* root.walk();
8888+ }
8989+ }
9090+}
+93
packages/codegen-core/src/structure/node.ts
···11+import type { StructureItem, StructureShell } from './types';
22+33+export class StructureNode {
44+ /** Nested nodes within this node. */
55+ children: Map<string, StructureNode> = new Map();
66+ /** Items contained in this node. */
77+ items: Array<StructureItem> = [];
88+ /** The name of this node (e.g., "Users", "Accounts"). */
99+ name: string;
1010+ /** Parent node in the hierarchy. Undefined if this is the root node. */
1111+ parent?: StructureNode;
1212+ /** Shell claimed for this node. */
1313+ shell?: StructureShell;
1414+ /** Source of the claimed shell. */
1515+ shellSource?: symbol;
1616+ /** True if this is a virtual root. */
1717+ virtual: boolean;
1818+1919+ constructor(
2020+ name: string,
2121+ parent?: StructureNode,
2222+ options?: {
2323+ virtual?: boolean;
2424+ },
2525+ ) {
2626+ this.name = name;
2727+ this.parent = parent;
2828+ this.virtual = options?.virtual ?? false;
2929+ }
3030+3131+ get isRoot(): boolean {
3232+ return !this.parent;
3333+ }
3434+3535+ /**
3636+ * Gets or creates a child node.
3737+ *
3838+ * If the child doesn't exist, it's created automatically.
3939+ *
4040+ * @param name - The name of the child node
4141+ * @returns The child node instance
4242+ */
4343+ child(name: string): StructureNode {
4444+ if (!this.children.has(name)) {
4545+ this.children.set(name, new StructureNode(name, this));
4646+ }
4747+ return this.children.get(name)!;
4848+ }
4949+5050+ /**
5151+ * Gets the full path of this node in the hierarchy.
5252+ *
5353+ * @returns An array of node names from the root to this node
5454+ */
5555+ getPath(): ReadonlyArray<string> {
5656+ const path: Array<string> = [];
5757+ // eslint-disable-next-line @typescript-eslint/no-this-alias
5858+ let cursor: StructureNode | undefined = this;
5959+ while (cursor) {
6060+ path.unshift(cursor.name);
6161+ cursor = cursor.parent;
6262+ }
6363+ return path;
6464+ }
6565+6666+ /**
6767+ * Yields items from a specific source with typed data.
6868+ *
6969+ * @param source - The source symbol to filter by
7070+ * @returns Generator of items from that source
7171+ */
7272+ *itemsFrom<T = unknown>(
7373+ source: symbol,
7474+ ): Generator<StructureItem & { data: T }> {
7575+ for (const item of this.items) {
7676+ if (item.source === source) {
7777+ yield item as StructureItem & { data: T };
7878+ }
7979+ }
8080+ }
8181+8282+ /**
8383+ * Walk all nodes in the structure (depth-first, post-order).
8484+ *
8585+ * @returns Generator of all structure nodes
8686+ */
8787+ *walk(): Generator<StructureNode> {
8888+ for (const node of this.children.values()) {
8989+ yield* node.walk();
9090+ }
9191+ yield this;
9292+ }
9393+}
+33
packages/codegen-core/src/structure/types.d.ts
···11+import type { INode } from '../nodes/node';
22+import type { StructureNode } from './node';
33+44+export interface StructureInsert {
55+ /** Inserted data. */
66+ data: unknown;
77+ /** Locations where the data should be inserted. */
88+ locations: ReadonlyArray<StructureLocation>;
99+ /** Source of the inserted data. */
1010+ source: symbol;
1111+}
1212+1313+export interface StructureItem
1414+ extends Pick<StructureInsert, 'data' | 'source'> {
1515+ /** Location of this item within the structure. */
1616+ location: ReadonlyArray<string>;
1717+}
1818+1919+export interface StructureLocation {
2020+ /** Path within the structure where the data should be inserted. */
2121+ path: ReadonlyArray<string>;
2222+ /** Shell to apply at this location. */
2323+ shell?: StructureShell;
2424+}
2525+2626+export interface StructureShell {
2727+ define: (node: StructureNode) => StructureShellResult;
2828+}
2929+3030+export interface StructureShellResult {
3131+ dependencies?: Array<INode>;
3232+ node: INode;
3333+}
+20-42
packages/codegen-core/src/symbols/symbol.ts
···11import { symbolBrand } from '../brands';
22-import { debug } from '../debug';
32import type { ISymbolMeta } from '../extensions';
43import type { File } from '../files/file';
44+import { log } from '../log';
55import type { INode } from '../nodes/node';
66-import type {
77- BindingKind,
88- ISymbolIn,
99- SymbolKind,
1010- SymbolNameSanitizer,
1111-} from './types';
66+import type { BindingKind, ISymbolIn, SymbolKind } from './types';
1271313-export class Symbol {
88+export class Symbol<Node extends INode = INode> {
149 /**
1510 * Canonical symbol this stub resolves to, if any.
1611 *
···7974 */
8075 private _name: string;
8176 /**
8282- * Optional function to sanitize the symbol name.
8383- *
8484- * @default undefined
8585- */
8686- private _nameSanitizer?: SymbolNameSanitizer;
8787- /**
8877 * Node that defines this symbol.
8978 */
9090- private _node?: INode;
7979+ private _node?: Node;
91809281 /** Brand used for identifying symbols. */
9382 readonly '~brand' = symbolBrand;
···153142 get finalName(): string {
154143 if (!this.canonical._finalName) {
155144 const message = `Symbol finalName has not been resolved yet for ${this.canonical.toString()}`;
156156- debug(message, 'symbol');
145145+ log.debug(message, 'symbol');
157146 throw new Error(message);
158147 }
159148 return this.canonical._finalName;
···202191 }
203192204193 /**
205205- * Optional function to sanitize the symbol name.
206206- */
207207- get nameSanitizer(): SymbolNameSanitizer | undefined {
208208- return this.canonical._nameSanitizer;
209209- }
210210-211211- /**
212194 * Read‑only accessor for the defining node.
213195 */
214214- get node(): INode | undefined {
215215- return this.canonical._node;
196196+ get node(): Node | undefined {
197197+ return this.canonical._node as Node | undefined;
216198 }
217199218200 /**
···256238 this.assertCanonical();
257239 if (this._file && this._file !== file) {
258240 const message = `Symbol ${this.canonical.toString()} is already assigned to a different file.`;
259259- debug(message, 'symbol');
241241+ log.debug(message, 'symbol');
260242 throw new Error(message);
261243 }
262244 this._file = file;
···271253 this.assertCanonical();
272254 if (this._finalName && this._finalName !== name) {
273255 const message = `Symbol finalName has already been resolved for ${this.canonical.toString()}.`;
274274- debug(message, 'symbol');
256256+ log.debug(message, 'symbol');
275257 throw new Error(message);
276258 }
277259 this._finalName = name;
···308290 }
309291310292 /**
311311- * Sets a custom function to sanitize the symbol's name.
312312- *
313313- * @param fn — The name sanitizer function to apply.
314314- */
315315- setNameSanitizer(fn: SymbolNameSanitizer): void {
316316- this.assertCanonical();
317317- this._nameSanitizer = fn;
318318- }
319319-320320- /**
321293 * Binds the node that defines this symbol.
322294 *
323295 * This may only be set once.
324296 */
325325- setNode(node: INode): void {
297297+ setNode(node: Node): void {
326298 this.assertCanonical();
327299 if (this._node && this._node !== node) {
328300 const message = `Symbol ${this.canonical.toString()} is already bound to a different node.`;
329329- debug(message, 'symbol');
330330- throw new Error(message);
301301+ log.debug(message, 'symbol');
302302+ // TODO: symbol <> node relationship needs to be refactor to 1:many
303303+ // disabled in the meantime or it would throw
304304+ // throw new Error(message);
331305 }
332306 this._node = node;
333307 node.symbol = this;
···337311 * Returns a debug‑friendly string representation identifying the symbol.
338312 */
339313 toString(): string {
340340- return `[Symbol ${this.name}#${this.id}]`;
314314+ const canonical = this.canonical;
315315+ if (canonical._finalName && canonical._finalName !== canonical._name) {
316316+ return `[Symbol ${canonical._name} → ${canonical._finalName}#${canonical.id}]`;
317317+ }
318318+ return `[Symbol ${canonical._name}#${canonical.id}]`;
341319 }
342320343321 /**
···353331 private assertCanonical(): void {
354332 if (this._canonical && this._canonical !== this) {
355333 const message = `Illegal mutation of stub symbol ${this.toString()} → canonical: ${this._canonical.toString()}`;
356356- debug(message, 'symbol');
334334+ log.debug(message, 'symbol');
357335 throw new Error(message);
358336 }
359337 }
-2
packages/codegen-core/src/symbols/types.d.ts
···1414 | 'type'
1515 | 'var';
16161717-export type SymbolNameSanitizer = (name: string) => string;
1818-1917export type ISymbolIn = {
2018 /**
2119 * Array of file names (without extensions) from which this symbol is re-exported.
···2121 suffix: '.gen',
2222 },
2323 format: null,
2424+ header: '// This file is auto-generated by @hey-api/openapi-ts',
2425 indexFile: true,
2526 lint: null,
2627 path: '',
+11-8
packages/openapi-ts/src/config/utils/config.ts
···33 ? Record<string, any>
44 : Extract<T, Record<string, any>>;
5566-type NotArray<T> = T extends any[] ? never : T;
77-type NotFunction<T> = T extends (...args: any[]) => any ? never : T;
66+type NotArray<T> = T extends Array<any> ? never : T;
77+type NotFunction<T> = T extends (...args: Array<any>) => any ? never : T;
88type PlainObject<T> = T extends object
99 ? NotFunction<T> extends never
1010 ? never
···1717 boolean: T extends boolean
1818 ? (value: boolean) => Partial<ObjectType<T>>
1919 : never;
2020- function: T extends (...args: any[]) => any
2121- ? (value: (...args: any[]) => any) => Partial<ObjectType<T>>
2020+ function: T extends (...args: Array<any>) => any
2121+ ? (value: (...args: Array<any>) => any) => Partial<ObjectType<T>>
2222 : never;
2323 number: T extends number ? (value: number) => Partial<ObjectType<T>> : never;
2424 object?: PlainObject<T> extends never
···3535type IsObjectOnly<T> = T extends Record<string, any> | undefined
3636 ? Extract<
3737 T,
3838- string | boolean | number | ((...args: any[]) => any)
3838+ string | boolean | number | ((...args: Array<any>) => any)
3939 > extends never
4040 ? true
4141 : false
···4747 | string
4848 | boolean
4949 | number
5050- | ((...args: any[]) => any)
5050+ | ((...args: Array<any>) => any)
5151 | Record<string, any>,
5252>(
5353 args: {
···9999 case 'function':
100100 if (mappers && 'function' in mappers) {
101101 const mapper = mappers.function as (
102102- value: (...args: any[]) => any,
102102+ value: (...args: Array<any>) => any,
103103 ) => Record<string, any>;
104104- result = mergeResult(result, mapper(value as (...args: any[]) => any));
104104+ result = mergeResult(
105105+ result,
106106+ mapper(value as (...args: Array<any>) => any),
107107+ );
105108 }
106109 break;
107110 case 'number':
+1
packages/openapi-ts/src/index.ts
···8888export { defaultPaginationKeywords } from '~/config/parser';
8989export { defaultPlugins } from '~/config/plugins';
9090export type { IR } from '~/ir/types';
9191+export { OperationPath, OperationStrategy } from '~/openApi/shared/locations';
9192export type {
9293 OpenApi,
9394 OpenApiMetaObject,
+3-5
packages/openapi-ts/src/ir/context.ts
···33import type { Package } from '~/config/utils/package';
44import { packageFactory } from '~/config/utils/package';
55import type { Graph } from '~/graph';
66-import { buildName } from '~/openApi/shared/utils/name';
76import type { PluginConfigMap } from '~/plugins/config';
87import { PluginInstance } from '~/plugins/shared/utils/instance';
98import type { PluginNames } from '~/plugins/types';
109import { TypeScriptRenderer } from '~/ts-dsl';
1110import type { Config } from '~/types/config';
1211import type { Logger } from '~/utils/logger';
1212+import { applyNaming } from '~/utils/naming';
1313import { resolveRef } from '~/utils/ref';
14141515import type { IR } from './types';
···7272 this.gen = new Project({
7373 defaultFileName: 'index',
7474 fileName: (base) => {
7575- const name = buildName({
7676- config: config.output.fileName,
7777- name: base,
7878- });
7575+ const name = applyNaming(base, config.output.fileName);
7976 const { suffix } = config.output.fileName;
8077 if (!suffix) {
8178 return name;
···9188 : undefined,
9289 renderers: [
9390 new TypeScriptRenderer({
9191+ header: config.output.header,
9492 preferExportAll: config.output.preferExportAll,
9593 preferFileExtension: config.output.importFileExtension || undefined,
9694 resolveModuleName: config.output.resolveModuleName,
···11-/**
22- * Sanitizes namespace identifiers so they are valid TypeScript identifiers of a certain form.
33- *
44- * 1: Remove any leading characters that are illegal as starting character of a typescript identifier.
55- * 2: Replace illegal characters in remaining part of type name with hyphen (-).
66- *
77- * Step 1 should perhaps instead also replace illegal characters with underscore, or prefix with it, like sanitizeEnumName
88- * does. The way this is now one could perhaps end up removing all characters, if all are illegal start characters. It
99- * would be sort of a breaking change to do so, though, previously generated code might change then.
1010- *
1111- * JavaScript identifier regexp pattern retrieved from https://developer.mozilla.org/docs/Web/JavaScript/Reference/Lexical_grammar#identifiers
1212- *
1313- * The output of this is expected to be converted to PascalCase
1414- *
1515- * @deprecated
1616- */
1717-export const sanitizeNamespaceIdentifier = (name: string) =>
1818- name
1919- .replace(/^[^\p{ID_Start}]+/u, '')
2020- .replace(/[^$\u200c\u200d\p{ID_Continue}]/gu, '-')
2121- .replace(/[$+]/g, '-');
···11import type { Context } from '~/ir/context';
22import { createOperationKey } from '~/ir/operation';
33-import { sanitizeNamespaceIdentifier } from '~/openApi/common/parser/sanitize';
44-import { stringCase } from '~/utils/stringCase';
33+import { toCase } from '~/utils/naming';
5465import type { State } from '../types/state';
76···1716] as const;
18171918/**
1919+ * Sanitizes namespace identifiers so they are valid TypeScript identifiers of a certain form.
2020+ *
2121+ * 1: Remove any leading characters that are illegal as starting character of a typescript identifier.
2222+ * 2: Replace illegal characters in remaining part of type name with hyphen (-).
2323+ *
2424+ * Step 1 should perhaps instead also replace illegal characters with underscore, or prefix with it, like sanitizeEnumName
2525+ * does. The way this is now one could perhaps end up removing all characters, if all are illegal start characters. It
2626+ * would be sort of a breaking change to do so, though, previously generated code might change then.
2727+ *
2828+ * JavaScript identifier regexp pattern retrieved from https://developer.mozilla.org/docs/Web/JavaScript/Reference/Lexical_grammar#identifiers
2929+ *
3030+ * The output of this is expected to be converted to PascalCase
3131+ *
3232+ * @deprecated
3333+ */
3434+export const sanitizeNamespaceIdentifier = (name: string) =>
3535+ name
3636+ .replace(/^[^\p{ID_Start}]+/u, '')
3737+ .replace(/[^$\u200c\u200d\p{ID_Continue}]/gu, '-')
3838+ .replace(/[$+]/g, '-');
3939+4040+/**
2041 * Returns an operation ID to use across the application. By default, we try
2142 * to use the provided ID. If it's not provided or the SDK is configured
2243 * to exclude it, we generate operation ID from its location.
4444+ *
4545+ * @deprecated
2346 */
2447export const operationToId = ({
2548 context,
···4770 if (
4871 id &&
4972 (!context.config.plugins['@hey-api/sdk'] ||
5050- context.config.plugins['@hey-api/sdk'].config.operationId)
7373+ // TODO: needs to be refactored...
7474+ (context.config.plugins['@hey-api/sdk'].config.operations &&
7575+ typeof context.config.plugins['@hey-api/sdk'].config.operations !==
7676+ 'function' &&
7777+ typeof context.config.plugins['@hey-api/sdk'].config.operations ===
7878+ 'object' &&
7979+ context.config.plugins['@hey-api/sdk'].config.operations.nesting ===
8080+ 'operationId'))
5181 ) {
5252- result = stringCase({
5353- case: targetCase,
5454- value: sanitizeNamespaceIdentifier(id),
5555- });
8282+ result = toCase(sanitizeNamespaceIdentifier(id), targetCase);
5683 } else {
5784 const pathWithoutPlaceholders = path
5885 .replace(/{(.*?)}/g, 'by-$1')
5986 // replace slashes with hyphens for camelcase method at the end
6087 .replace(/[/:+]/g, '-');
61886262- result = stringCase({
6363- case: targetCase,
6464- value: `${method}-${pathWithoutPlaceholders}`,
6565- });
8989+ result = toCase(`${method}-${pathWithoutPlaceholders}`, targetCase);
6690 }
67916892 if (count > 1) {
···11+export { resolveHttpRequests } from './config';
22+export { resolveHttpRequestsStrategy } from './resolve';
33+export type { HttpRequestsConfig, UserHttpRequestsConfig } from './types';
···11+export { resolveHttpResources } from './config';
22+export { resolveHttpResourcesStrategy } from './resolve';
33+export type { HttpResourcesConfig, UserHttpResourcesConfig } from './types';
···11+import type { OperationsStrategy } from '~/openApi/shared/locations';
12import type { DefinePlugin, Plugin } from '~/plugins';
22-import type { StringName } from '~/types/case';
33+44+import type {
55+ HttpRequestsConfig,
66+ UserHttpRequestsConfig,
77+} from './httpRequests';
88+import type {
99+ HttpResourcesConfig,
1010+ UserHttpResourcesConfig,
1111+} from './httpResources';
312413export type UserConfig = Plugin.Name<'@angular/common'> &
514 Plugin.Hooks & {
···1322 /**
1423 * Options for generating HTTP Request instances.
1524 *
1616- * @default true
2525+ * @default 'flat'
1726 */
1818- httpRequests?:
1919- | boolean
2020- | {
2121- /**
2222- * Whether to generate the resource as a class.
2323- *
2424- * @default false
2525- */
2626- asClass?: boolean;
2727- /**
2828- * Builds the class name for the generated resource.
2929- * By default, the class name is suffixed with "Resources".
3030- */
3131- classNameBuilder?: StringName;
3232- /**
3333- * Whether or not to create HTTP Request instances.
3434- *
3535- * @default true
3636- */
3737- enabled?: boolean;
3838- /**
3939- * Builds the method name for the generated resource.
4040- *
4141- * By default, the operation id is used, if `asClass` is false, the method is also suffixed with "Resource".
4242- */
4343- methodNameBuilder?: (operation: IR.OperationObject) => string;
4444- };
2727+ httpRequests?: boolean | OperationsStrategy | UserHttpRequestsConfig;
4528 /**
4629 * Options for generating HTTP resource APIs.
4730 *
4848- * @default true
3131+ * @default 'flat'
4932 */
5050- httpResources?:
5151- | boolean
5252- | {
5353- /**
5454- * Whether to generate the resource as a class.
5555- * @default false
5656- */
5757- asClass?: boolean;
5858- /**
5959- * Builds the class name for the generated resource.
6060- * By default, the class name is suffixed with "Resources".
6161- */
6262- classNameBuilder?: StringName;
6363- /**
6464- * Whether or not to create HTTP resource APIs.
6565- *
6666- * @default true
6767- */
6868- enabled?: boolean;
6969- /**
7070- * Builds the method name for the generated resource.
7171- *
7272- * By default, the operation id is used, if `asClass` is false, the method is also suffixed with "Resource".
7373- */
7474- methodNameBuilder?: (operation: IR.OperationObject) => string;
7575- };
3333+ httpResources?: boolean | OperationsStrategy | UserHttpResourcesConfig;
7634 };
77357836export type Config = Plugin.Name<'@angular/common'> &
···8745 /**
8846 * Options for generating HTTP Request instances.
8947 */
9090- httpRequests: {
9191- /**
9292- * Whether to generate the resource as a class.
9393- *
9494- * @default false
9595- */
9696- asClass: boolean;
9797- /**
9898- * Builds the class name for the generated resource.
9999- * By default, the class name is suffixed with "Resources".
100100- */
101101- classNameBuilder: StringName;
102102- /**
103103- * Whether or not to create HTTP Request instances.
104104- *
105105- * @default true
106106- */
107107- enabled: boolean;
108108- /**
109109- * Builds the method name for the generated resource.
110110- * By default, the operation id is used, if `asClass` is false, the method is also suffixed with "Resource".
111111- */
112112- methodNameBuilder: (operation: IR.OperationObject) => string;
113113- };
4848+ httpRequests: HttpRequestsConfig;
11449 /**
11550 * Options for generating HTTP resource APIs.
11651 */
117117- httpResources: {
118118- /**
119119- * Whether to generate the resource as a class.
120120- *
121121- * @default false
122122- */
123123- asClass: boolean;
124124- /**
125125- * Builds the class name for the generated resource.
126126- * By default, the class name is suffixed with "Resources".
127127- */
128128- classNameBuilder: StringName;
129129- /**
130130- * Whether or not to create HTTP resource APIs.
131131- *
132132- * @default true
133133- */
134134- enabled: boolean;
135135- /**
136136- * Builds the method name for the generated resource.
137137- * By default, the operation id is used, if `asClass` is false, the method is also suffixed with "Resource".
138138- */
139139- methodNameBuilder: (operation: IR.OperationObject) => string;
140140- };
5252+ httpResources: HttpResourcesConfig;
14153 };
1425414355export type AngularCommonPlugin = DefinePlugin<UserConfig, Config>;
···11+export { resolveOperations } from './config';
22+export { resolveStrategy } from './resolve';
33+export type { OperationsConfig, UserOperationsConfig } from './types';
···11import type { IR } from '~/ir/types';
22import type { PluginInstance } from '~/plugins/shared/utils/instance';
33+import { toCase } from '~/utils/naming';
34import { refToName } from '~/utils/ref';
44-import { stringCase } from '~/utils/stringCase';
5566import type { Field } from '../../client-core/bundle/params';
77···8383 } else if (operation.body.schema.$ref) {
8484 // alias body for more ergonomic naming, e.g. user if the type is User
8585 const name = refToName(operation.body.schema.$ref);
8686- const key = stringCase({ case: 'camelCase', value: name });
8686+ const key = toCase(name, 'camelCase');
8787 addParameter(key, 'body');
8888 } else {
8989 addParameter('body', 'body');
···157157 }
158158 } else if (operation.body.schema.$ref) {
159159 const value = refToName(operation.body.schema.$ref);
160160- const originalName = stringCase({ case: 'camelCase', value });
160160+ const originalName = toCase(value, 'camelCase');
161161 const name = conflicts.has(originalName)
162162 ? `${location}_${originalName}`
163163 : originalName;
···11-import type { IR } from '~/ir/types';
11+import type { OperationsStrategy } from '~/openApi/shared/locations';
22import type { DefinePlugin, Plugin } from '~/plugins';
33import type { PluginClientNames, PluginValidatorNames } from '~/plugins/types';
44-import type { StringName } from '~/types/case';
44+import type { NameTransformer } from '~/utils/naming';
55+66+import type { OperationsConfig, UserOperationsConfig } from './operations';
5768export type UserConfig = Plugin.Name<'@hey-api/sdk'> &
79 Plugin.Hooks & {
810 /**
99- * Group operation methods into classes? When enabled, you can select which
1010- * classes to export with `sdk.include` and/or transform their names with
1111- * `sdk.classNameBuilder`.
1212- *
1313- * Note that by enabling this option, your SDKs will **NOT**
1414- * support {@link https://developer.mozilla.org/docs/Glossary/Tree_shaking tree-shaking}.
1515- * For this reason, it is disabled by default.
1616- *
1717- * @default false
1818- */
1919- asClass?: boolean;
2020- /**
2111 * Should the generated functions contain auth mechanisms? You may want to
2212 * disable this option if you're handling auth yourself or defining it
2313 * globally on the client and want to reduce the size of generated code.
···2616 */
2717 auth?: boolean;
2818 /**
2929- * Customize the generated class names. The name variable is obtained from
3030- * your OpenAPI specification tags or `instance` value.
3131- *
3232- * This option has no effect if `sdk.asClass` is `false`.
3333- */
3434- classNameBuilder?: StringName;
3535- /**
3636- * How should we structure your SDK? By default, we try to infer the ideal
3737- * structure using `operationId` keywords. If you prefer a flatter structure,
3838- * you can set `classStructure` to `off` to disable this behavior.
3939- *
4040- * @default 'auto'
4141- */
4242- classStructure?: 'auto' | 'off';
4343- /**
4419 * Use an internal client instance to send HTTP requests? This is useful if
4520 * you don't want to manually pass the client to each SDK function.
4621 *
···6035 */
6136 exportFromIndex?: boolean;
6237 /**
6363- * Include only service classes with names matching regular expression
3838+ * Define the structure of generated SDK operations.
6439 *
6565- * This option has no effect if `sdk.asClass` is `false`.
6666- */
6767- include?: string;
6868- /**
6969- * Set `instance` to create an instantiable SDK. Using `true` will use the
7070- * default instance name; in practice, you want to define your own by passing
7171- * a string value.
4040+ * String shorthand:
4141+ * - `'byTags'` – one container per operation tag
4242+ * - `'flat'` – standalone functions, no container
4343+ * - `'single'` – all operations in a single container
4444+ * - custom function for full control
7245 *
7373- * @default false
7474- */
7575- instance?: string | boolean;
7676- /**
7777- * Customise the name of methods within the service. By default,
7878- * {@link IR.OperationObject.id} is used.
7979- */
8080- methodNameBuilder?: (operation: IR.OperationObject) => string;
8181- // TODO: parser - rename operationId option to something like inferId?: boolean
8282- /**
8383- * Use operation ID to generate operation names?
4646+ * Use the object form for advanced configuration.
8447 *
8585- * @default true
4848+ * @default 'flat'
8649 */
8787- operationId?: boolean;
5050+ operations?: OperationsStrategy | UserOperationsConfig;
8851 /**
8952 * Define how request parameters are structured in generated SDK methods.
9053 *
···161124 // DEPRECATED OPTIONS BELOW
162125163126 /**
164164- * **This feature works only with the legacy parser**
165165- *
166166- * Filter endpoints to be included in the generated SDK. The provided
167167- * string should be a regular expression where matched results will be
168168- * included in the output. The input pattern this string will be tested
169169- * against is `{method} {path}`. For example, you can match
170170- * `POST /api/v1/foo` with `^POST /api/v1/foo$`.
171171- *
172172- * @deprecated
173173- */
174174- // eslint-disable-next-line typescript-sort-keys/interface
175175- filter?: string;
176176- /**
177177- * Define shape of returned value from service calls
178178- *
179179- * @deprecated
180180- * @default 'body'
181181- */
182182- response?: 'body' | 'response';
183183- };
184184-185185-export type Config = Plugin.Name<'@hey-api/sdk'> &
186186- Plugin.Hooks & {
187187- /**
188127 * Group operation methods into classes? When enabled, you can select which
189128 * classes to export with `sdk.include` and/or transform their names with
190129 * `sdk.classNameBuilder`.
···193132 * support {@link https://developer.mozilla.org/docs/Glossary/Tree_shaking tree-shaking}.
194133 * For this reason, it is disabled by default.
195134 *
135135+ * @deprecated Use `operations: { strategy: "byTags" }` or `operations: { strategy: "single" }` instead.
196136 * @default false
197137 */
198198- asClass: boolean;
199199- /**
200200- * Should the generated functions contain auth mechanisms? You may want to
201201- * disable this option if you're handling auth yourself or defining it
202202- * globally on the client and want to reduce the size of generated code.
203203- *
204204- * @default true
205205- */
206206- auth: boolean;
138138+ // eslint-disable-next-line typescript-sort-keys/interface
139139+ asClass?: boolean;
207140 /**
208141 * Customize the generated class names. The name variable is obtained from
209142 * your OpenAPI specification tags or `instance` value.
210143 *
211144 * This option has no effect if `sdk.asClass` is `false`.
145145+ *
146146+ * @deprecated Use `operations: { containerName: "..." }` instead.
212147 */
213213- classNameBuilder: StringName;
148148+ classNameBuilder?: NameTransformer;
214149 /**
215150 * How should we structure your SDK? By default, we try to infer the ideal
216151 * structure using `operationId` keywords. If you prefer a flatter structure,
217152 * you can set `classStructure` to `off` to disable this behavior.
218153 *
154154+ * @deprecated Use `operations: { nesting: "operationId" }` or `operations: { nesting: "id" }` instead.
219155 * @default 'auto'
220156 */
221221- classStructure: 'auto' | 'off';
157157+ classStructure?: 'auto' | 'off';
158158+ /**
159159+ * Set `instance` to create an instantiable SDK. Using `true` will use the
160160+ * default instance name; in practice, you want to define your own by passing
161161+ * a string value.
162162+ *
163163+ * @deprecated Use `operations: { strategy: "single", containerName: "Name", methods: "instance" }` instead.
164164+ * @default false
165165+ */
166166+ instance?: string | boolean;
167167+ /**
168168+ * Customise the name of methods within the service. By default,
169169+ * `operation.id` is used.
170170+ *
171171+ * @deprecated Use `operations: { methodName: "..." }` instead.
172172+ */
173173+ methodNameBuilder?: NameTransformer;
174174+ /**
175175+ * Use operation ID to generate operation names?
176176+ *
177177+ * @deprecated Use `operations: { nesting: "operationId" }` or `operations: { nesting: "id" }` instead.
178178+ * @default true
179179+ */
180180+ operationId?: boolean;
181181+ /**
182182+ * Define shape of returned value from service calls
183183+ *
184184+ * @deprecated
185185+ * @default 'body'
186186+ */
187187+ response?: 'body' | 'response';
188188+ };
189189+190190+export type Config = Plugin.Name<'@hey-api/sdk'> &
191191+ Plugin.Hooks & {
192192+ /**
193193+ * Should the generated functions contain auth mechanisms? You may want to
194194+ * disable this option if you're handling auth yourself or defining it
195195+ * globally on the client and want to reduce the size of generated code.
196196+ *
197197+ * @default true
198198+ */
199199+ auth: boolean;
222200 /**
223201 * Use an internal client instance to send HTTP requests? This is useful if
224202 * you don't want to manually pass the client to each SDK function.
···239217 */
240218 exportFromIndex: boolean;
241219 /**
242242- * Include only service classes with names matching regular expression
243243- *
244244- * This option has no effect if `sdk.asClass` is `false`.
245245- */
246246- include: string | undefined;
247247- /**
248248- * Set `instance` to create an instantiable SDK. Using `true` will use the
249249- * default instance name; in practice, you want to define your own by passing
250250- * a string value.
251251- */
252252- instance: string;
253253- /**
254254- * Customise the name of methods within the service. By default,
255255- * {@link IR.OperationObject.id} is used.
256256- */
257257- methodNameBuilder?: (operation: IR.OperationObject) => string;
258258- // TODO: parser - rename operationId option to something like inferId?: boolean
259259- /**
260260- * Use operation ID to generate operation names?
261261- *
262262- * @default true
220220+ * Define the structure of generated SDK operations.
263221 */
264264- operationId: boolean;
222222+ operations: OperationsConfig;
265223 /**
266224 * Define how request parameters are structured in generated SDK methods.
267225 *
···317275 // DEPRECATED OPTIONS BELOW
318276319277 /**
320320- * **This feature works only with the legacy parser**
321321- *
322322- * Filter endpoints to be included in the generated SDK. The provided
323323- * string should be a regular expression where matched results will be
324324- * included in the output. The input pattern this string will be tested
325325- * against is `{method} {path}`. For example, you can match
326326- * `POST /api/v1/foo` with `^POST /api/v1/foo$`.
327327- *
328328- * @deprecated
329329- */
330330- // eslint-disable-next-line typescript-sort-keys/interface
331331- filter?: string;
332332- /**
333278 * Define shape of returned value from service calls
334279 *
335280 * @deprecated
336281 * @default 'body'
337282 */
283283+ // eslint-disable-next-line typescript-sort-keys/interface
338284 response: 'body' | 'response';
339285 };
340286
···11+import type { BindingKind, NodeScope, Symbol } from '@hey-api/codegen-core';
22+import { isSymbol } from '@hey-api/codegen-core';
33+import type ts from 'typescript';
44+55+import { $, TypeScriptRenderer } from '~/ts-dsl';
66+77+import type { TsDsl } from '../base';
88+import type { CallArgs } from '../expr/call';
99+1010+export type NodeChain = ReadonlyArray<TsDsl>;
1111+1212+export interface AccessOptions {
1313+ /** The access context. */
1414+ context?: 'example';
1515+ /** Enable debug mode. */
1616+ debug?: boolean;
1717+ /** Transform function for each node in the access chain. */
1818+ transform?: (node: TsDsl, index: number, chain: NodeChain) => TsDsl;
1919+}
2020+2121+export type AccessResult = ReturnType<
2222+ typeof $.expr | typeof $.attr | typeof $.call | typeof $.new
2323+>;
2424+2525+export interface ExampleOptions {
2626+ /** Import kind for the root node. */
2727+ importKind?: BindingKind;
2828+ /** Import name for the root node. */
2929+ importName?: string;
3030+ /** Setup to run before calling the example. */
3131+ importSetup?:
3232+ | TsDsl<ts.Expression>
3333+ | ((imp: TsDsl<ts.Expression>) => TsDsl<ts.Expression>);
3434+ /** Module to import from. */
3535+ moduleName: string;
3636+ /** Example request payload. */
3737+ payload?: CallArgs | CallArgs[number];
3838+ /** Variable name for setup node. */
3939+ setupName?: string;
4040+}
4141+4242+function accessChainToNode<T = AccessResult>(accessChain: NodeChain): T {
4343+ let result!: AccessResult;
4444+ accessChain.forEach((node, index) => {
4545+ if (index === 0) {
4646+ // assume correct node
4747+ result = node as typeof result;
4848+ } else {
4949+ result = result.attr(node.name);
5050+ }
5151+ });
5252+ return result as T;
5353+}
5454+5555+function getAccessChainForNode(node: TsDsl): NodeChain {
5656+ const structuralChain = [...getStructuralChainForNode(node, new Set())];
5757+ const accessChain = structuralToAccessChain(structuralChain);
5858+ if (accessChain.length === 0) {
5959+ // I _think_ this should not happen, but it does and this fix works for now.
6060+ // I assume this will cause issues with imports in some cases, investigate
6161+ // when it actually happens.
6262+ return [node.clone()];
6363+ }
6464+ return accessChain.map((node) => node.clone());
6565+}
6666+6767+function getScope(node: TsDsl): NodeScope {
6868+ return node.scope ?? 'value';
6969+}
7070+7171+function getStructuralChainForNode(
7272+ node: TsDsl,
7373+ visited: Set<TsDsl>,
7474+): NodeChain {
7575+ if (visited.has(node)) return [];
7676+ visited.add(node);
7777+7878+ if (isStopNode(node)) return [];
7979+8080+ if (node.structuralParents) {
8181+ for (const [parent] of node.structuralParents) {
8282+ if (getScope(parent) !== getScope(node)) continue;
8383+8484+ const chain = getStructuralChainForNode(parent, visited);
8585+ if (chain.length > 0) return [...chain, node];
8686+ }
8787+ }
8888+8989+ if (!node.root) return [];
9090+9191+ return [node];
9292+}
9393+9494+function isAccessorNode(node: TsDsl): boolean {
9595+ return (
9696+ node['~dsl'] === 'FieldTsDsl' ||
9797+ node['~dsl'] === 'GetterTsDsl' ||
9898+ node['~dsl'] === 'MethodTsDsl'
9999+ );
100100+}
101101+102102+function isStopNode(node: TsDsl): boolean {
103103+ return node['~dsl'] === 'FuncTsDsl' || node['~dsl'] === 'TemplateTsDsl';
104104+}
105105+106106+/**
107107+ * Fold a structural chain to an access chain by removing
108108+ * non-accessor nodes.
109109+ */
110110+function structuralToAccessChain(structuralChain: NodeChain): NodeChain {
111111+ const accessChain: Array<TsDsl> = [];
112112+ structuralChain.forEach((node, index) => {
113113+ // assume first node is always included
114114+ if (index === 0) {
115115+ accessChain.push(node);
116116+ } else if (isAccessorNode(node)) {
117117+ accessChain.push(node);
118118+ }
119119+ });
120120+ return accessChain;
121121+}
122122+123123+function transformAccessChain(
124124+ accessChain: NodeChain,
125125+ options: AccessOptions = {},
126126+): NodeChain {
127127+ return accessChain.map((node, index) => {
128128+ const transformedNode = options.transform?.(node, index, accessChain);
129129+ if (transformedNode) return transformedNode;
130130+ const accessNode = node.toAccessNode?.(node, options, {
131131+ chain: accessChain,
132132+ index,
133133+ isLeaf: index === accessChain.length - 1,
134134+ isRoot: index === 0,
135135+ length: accessChain.length,
136136+ });
137137+ if (accessNode) return accessNode;
138138+ if (index === 0) {
139139+ if (node['~dsl'] === 'ClassTsDsl') {
140140+ const nextNode = accessChain[index + 1];
141141+ if (nextNode && isAccessorNode(nextNode)) {
142142+ if ((nextNode as ReturnType<typeof $.field>).hasModifier('static')) {
143143+ return $(node.name);
144144+ }
145145+ }
146146+ return $.new(node.name).args();
147147+ }
148148+ return $(node.name);
149149+ }
150150+ return node;
151151+ });
152152+}
153153+154154+export class TsDslContext {
155155+ /**
156156+ * Build an expression for accessing the node.
157157+ *
158158+ * @param node - The node or symbol to build access for
159159+ * @param options - Access options
160160+ * @returns Expression for accessing the node
161161+ *
162162+ * @example
163163+ * ```ts
164164+ * ctx.access(node); // → Expression for accessing the node
165165+ * ```
166166+ */
167167+ access<T = AccessResult>(
168168+ node: TsDsl | Symbol<TsDsl>,
169169+ options?: AccessOptions,
170170+ ): T {
171171+ const n = isSymbol(node) ? node.node : node;
172172+ if (!n) {
173173+ throw new Error(`Symbol ${node.name} is not resolved to a node.`);
174174+ }
175175+ const accessChain = getAccessChainForNode(n);
176176+ const finalChain = transformAccessChain(accessChain, options);
177177+ return accessChainToNode<T>(finalChain);
178178+ }
179179+180180+ /**
181181+ * Build an example.
182182+ *
183183+ * @param node - The node to generate an example for
184184+ * @param options - Example options
185185+ * @returns Full example string
186186+ *
187187+ * @example
188188+ * ```ts
189189+ * ctx.example(node, { moduleName: 'my-sdk' }); // → Full example string
190190+ * ```
191191+ */
192192+ example(
193193+ node: TsDsl,
194194+ options: ExampleOptions | undefined,
195195+ astOptions?: Parameters<typeof TypeScriptRenderer.astToString>[0],
196196+ ): string {
197197+ if (astOptions) {
198198+ return TypeScriptRenderer.astToString(astOptions);
199199+ }
200200+201201+ if (!options) {
202202+ throw new Error('Example options are required.');
203203+ }
204204+205205+ const accessChain = getAccessChainForNode(node);
206206+ if (options.importName) {
207207+ accessChain[0]!.name.set(options.importName);
208208+ }
209209+ const importNode = $(accessChain[0]!.name.toString()); // must store name before transform
210210+ const finalChain = transformAccessChain(accessChain, {
211211+ context: 'example',
212212+ });
213213+214214+ const setupNode = options.importSetup
215215+ ? typeof options.importSetup === 'function'
216216+ ? options.importSetup(importNode)
217217+ : options.importSetup
218218+ : (finalChain[0]! as TsDsl<ts.Expression>);
219219+ const setupName = options.setupName;
220220+ const payload =
221221+ options.payload instanceof Array
222222+ ? options.payload
223223+ : options.payload
224224+ ? [options.payload]
225225+ : [];
226226+227227+ let nodes: Array<TsDsl> = [];
228228+ if (setupName) {
229229+ nodes = [
230230+ $.const(setupName).assign(setupNode),
231231+ accessChainToNode([$(setupName), ...finalChain.slice(1)]).call(
232232+ ...payload,
233233+ ),
234234+ ];
235235+ } else {
236236+ nodes = [
237237+ accessChainToNode([setupNode, ...finalChain.slice(1)]).call(...payload),
238238+ ];
239239+ }
240240+241241+ const localName = importNode.name.toString();
242242+ return TypeScriptRenderer.astToString({
243243+ imports: [
244244+ [
245245+ {
246246+ imports:
247247+ !options.importKind || options.importKind === 'named'
248248+ ? [
249249+ {
250250+ isTypeOnly: false,
251251+ localName,
252252+ sourceName: localName,
253253+ },
254254+ ]
255255+ : [],
256256+ isTypeOnly: false,
257257+ kind: options.importKind ?? 'named',
258258+ localName: options.importKind !== 'named' ? localName : undefined,
259259+ modulePath: options.moduleName,
260260+ },
261261+ ],
262262+ ],
263263+ nodes,
264264+ trailingNewline: false,
265265+ });
266266+ }
267267+}
+4
packages/openapi-ts/src/ts-dsl/utils/factories.ts
···22import type { AttrCtor } from '../expr/attr';
33import type { AwaitCtor } from '../expr/await';
44import type { CallCtor } from '../expr/call';
55+import type { NewCtor } from '../expr/new';
56import type { TypeOfExprCtor } from '../expr/typeof';
67import type { ReturnCtor } from '../stmt/return';
78import type { TypeExprCtor } from '../type/expr';
···44454546 /** Factory for creating function or method call expressions (e.g. `fn(arg)`). */
4647 call: createFactory<CallCtor>('call'),
4848+4949+ /** Factory for creating new expressions (e.g. `new ClassName()`). */
5050+ new: createFactory<NewCtor>('new'),
47514852 /** Factory for creating return statements. */
4953 return: createFactory<ReturnCtor>('return'),
···11-import type { NameConflictResolver } from '@hey-api/codegen-core';
11+import type {
22+ NameConflictResolver,
33+ RenderContext,
44+} from '@hey-api/codegen-core';
25import type ts from 'typescript';
3644-import type { StringCase, StringName } from './case';
77+import type { Casing, NameTransformer } from '~/utils/naming';
88+99+import type { MaybeArray, MaybeFunc } from './utils';
510611export type Formatters = 'biome' | 'prettier';
712···9141015type ImportFileExtensions = '.js' | '.ts';
11161717+type Header = MaybeFunc<
1818+ (ctx: RenderContext) => MaybeArray<string> | null | undefined
1919+>;
2020+1221export type UserOutput = {
1322 /**
1423 * Defines casing of the output fields. By default, we preserve `input`
···1625 *
1726 * @default undefined
1827 */
1919- case?: StringCase;
2828+ case?: Casing;
2029 /**
2130 * Clean the `output` folder on every run? If disabled, this folder may
2231 * be used to store additional files. The default option is `true` to
···3443 * @default '{{name}}'
3544 */
3645 fileName?:
3737- | StringName
4646+ | NameTransformer
3847 | {
3948 /**
4049 * The casing convention to use for generated file names.
4150 *
4251 * @default 'preserve'
4352 */
4444- case?: StringCase;
5353+ case?: Casing;
4554 /**
4655 * Custom naming pattern for generated file names.
4756 *
4857 * @default '{{name}}'
4958 */
5050- name?: StringName;
5959+ name?: NameTransformer;
5160 /**
5261 * Suffix to append to file names (before the extension). For example,
5362 * with a suffix of `.gen`, `example.ts` becomes `example.gen.ts`.
···6776 * @default null
6877 */
6978 format?: Formatters | null;
7979+ /**
8080+ * Text to include at the top of every generated file.
8181+ */
8282+ header?: Header;
7083 /**
7184 * If specified, this will be the file extension used when importing
7285 * other modules. By default, we don't add a file extension and let the
···128141 * Defines casing of the output fields. By default, we preserve `input`
129142 * values as data transforms incur a performance penalty at runtime.
130143 */
131131- case: StringCase | undefined;
144144+ case: Casing | undefined;
132145 /**
133146 * Clean the `output` folder on every run? If disabled, this folder may
134147 * be used to store additional files. The default option is `true` to
···146159 /**
147160 * The casing convention to use for generated file names.
148161 */
149149- case: StringCase;
162162+ case: Casing;
150163 /**
151164 * Custom naming pattern for generated file names.
152165 */
153153- name: StringName;
166166+ name: NameTransformer;
154167 /**
155168 * Suffix to append to file names (before the extension). For example,
156169 * with a suffix of `.gen`, `example.ts` becomes `example.gen.ts`.
···167180 * Which formatter to use to process output folder?
168181 */
169182 format: Formatters | null;
183183+ /**
184184+ * Text to include at the top of every generated file.
185185+ */
186186+ header: Header;
170187 /**
171188 * If specified, this will be the file extension used when importing
172189 * other modules. By default, we don't add a file extension and let the
+15-16
packages/openapi-ts/src/types/parser.d.ts
···77 OpenApiSchemaObject,
88} from '~/openApi/types';
99import type { Hooks } from '~/parser/types/hooks';
1010-1111-import type { StringCase, StringName } from './case';
1010+import type { Casing, NameTransformer } from '~/utils/naming';
12111312type EnumsMode = 'inline' | 'root';
1413···7372 *
7473 * @default 'PascalCase'
7574 */
7676- case?: StringCase;
7575+ case?: Casing;
7776 /**
7877 * Whether to transform all enums.
7978 *
···9291 *
9392 * @default '{{name}}Enum'
9493 */
9595- name?: StringName;
9494+ name?: NameTransformer;
9695 };
9796 /**
9897 * By default, any object schema with a missing `required` keyword is
···137136 * @default '{{name}}Writable'
138137 */
139138 requests?:
140140- | StringName
139139+ | NameTransformer
141140 | {
142141 /**
143142 * The casing convention to use for generated names.
144143 *
145144 * @default 'preserve'
146145 */
147147- case?: StringCase;
146146+ case?: Casing;
148147 /**
149148 * Customize the generated name of schemas used in requests or
150149 * containing write-only fields.
151150 *
152151 * @default '{{name}}Writable'
153152 */
154154- name?: StringName;
153153+ name?: NameTransformer;
155154 };
156155 /**
157156 * Configuration for generated response-specific schemas.
···163162 * @default '{{name}}'
164163 */
165164 responses?:
166166- | StringName
165165+ | NameTransformer
167166 | {
168167 /**
169168 * The casing convention to use for generated names.
170169 *
171170 * @default 'preserve'
172171 */
173173- case?: StringCase;
172172+ case?: Casing;
174173 /**
175174 * Customize the generated name of schemas used in responses or
176175 * containing read-only fields. We default to the original name
···178177 *
179178 * @default '{{name}}'
180179 */
181181- name?: StringName;
180180+ name?: NameTransformer;
182181 };
183182 };
184183 };
···250249 *
251250 * @default 'PascalCase'
252251 */
253253- case: StringCase;
252252+ case: Casing;
254253 /**
255254 * Whether to transform all enums.
256255 *
···269268 *
270269 * @default '{{name}}Enum'
271270 */
272272- name: StringName;
271271+ name: NameTransformer;
273272 };
274273 /**
275274 * By default, any object schema with a missing `required` keyword is
···309308 *
310309 * @default 'preserve'
311310 */
312312- case: StringCase;
311311+ case: Casing;
313312 /**
314313 * Customize the generated name of schemas used in requests or
315314 * containing write-only fields.
316315 *
317316 * @default '{{name}}Writable'
318317 */
319319- name: StringName;
318318+ name: NameTransformer;
320319 };
321320 /**
322321 * Configuration for generated response-specific schemas.
···327326 *
328327 * @default 'preserve'
329328 */
330330- case: StringCase;
329329+ case: Casing;
331330 /**
332331 * Customize the generated name of schemas used in responses or
333332 * containing read-only fields. We default to the original name
···335334 *
336335 * @default '{{name}}'
337336 */
338338- name: StringName;
337337+ name: NameTransformer;
339338 };
340339 };
341340 };
+8-1
packages/openapi-ts/src/types/utils.d.ts
···99 * Recursively makes all non-function properties optional.
1010 */
1111export type DeepPartial<T> = {
1212- [K in keyof T]?: T[K] extends (...args: any[]) => any
1212+ [K in keyof T]?: T[K] extends (...args: Array<any>) => any
1313 ? T[K]
1414 : T[K] extends object
1515 ? DeepPartial<T[K]>
···2525 * Accepts a value or a readonly array of values of type T.
2626 */
2727export type MaybeArray<T> = T | ReadonlyArray<T>;
2828+2929+/**
3030+ * Accepts a value or a function returning a value.
3131+ */
3232+export type MaybeFunc<T extends (...args: Array<any>) => any> =
3333+ | T
3434+ | ReturnType<T>;
28352936/**
3037 * Converts all top-level Array properties to ReadonlyArray (shallow).