@kubb/plugin-ts 5.0.0-alpha.23 → 5.0.0-alpha.25

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": "@kubb/plugin-ts",
3
- "version": "5.0.0-alpha.23",
3
+ "version": "5.0.0-alpha.25",
4
4
  "description": "TypeScript code generation plugin for Kubb, transforming OpenAPI schemas into TypeScript interfaces, types, and utility functions.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -51,10 +51,10 @@
51
51
  "dependencies": {
52
52
  "@kubb/fabric-core": "0.15.1",
53
53
  "@kubb/react-fabric": "0.15.1",
54
- "remeda": "^2.33.6",
54
+ "remeda": "^2.33.7",
55
55
  "typescript": "5.9.3",
56
- "@kubb/ast": "5.0.0-alpha.23",
57
- "@kubb/core": "5.0.0-alpha.23"
56
+ "@kubb/ast": "5.0.0-alpha.25",
57
+ "@kubb/core": "5.0.0-alpha.25"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@kubb/react-fabric": "0.15.1"
@@ -35,17 +35,12 @@ export function getEnumNames({
35
35
  }): {
36
36
  enumName: string
37
37
  typeName: string
38
- /**
39
- * The PascalCase name that `$ref` importers will use to reference this enum type.
40
- * For `asConst`/`asPascalConst` this differs from `typeName` (which has a `Key` suffix).
41
- */
42
- refName: string
43
38
  } {
44
39
  const resolved = resolver.default(node.name!, 'type')
45
40
  const enumName = enumType === 'asPascalConst' ? resolved : camelCase(node.name!)
46
41
  const typeName = ENUM_TYPES_WITH_KEY_SUFFIX.has(enumType) ? resolver.resolveEnumKeyName(node, enumTypeSuffix) : resolved
47
42
 
48
- return { enumName, typeName, refName: resolved }
43
+ return { enumName, typeName }
49
44
  }
50
45
 
51
46
  /**
@@ -67,7 +62,7 @@ export function Enum({ node, enumType, enumTypeSuffix, enumKeyCasing, resolver }
67
62
  typeName,
68
63
  enums: (node.namedEnumValues?.map((v) => [trimQuotes(v.name.toString()), v.value]) ??
69
64
  node.enumValues?.filter((v): v is NonNullable<typeof v> => v !== null && v !== undefined).map((v) => [trimQuotes(v.toString()), v]) ??
70
- []) as unknown as Array<[string, string]>,
65
+ []) as Array<[string | number, string | number | boolean]>,
71
66
  type: enumType,
72
67
  enumKeyCasing,
73
68
  })
@@ -18,6 +18,12 @@ type Props = {
18
18
  resolver: PluginTs['resolver']
19
19
  description?: string
20
20
  keysToOmit?: string[]
21
+ /**
22
+ * Names of top-level schemas that are enums.
23
+ * Used so the printer's `ref` handler can use the suffixed type name (e.g. `StatusKey`)
24
+ * instead of the plain PascalCase name (e.g. `Status`) when resolving `$ref` enum targets.
25
+ */
26
+ enumSchemaNames?: Set<string>
21
27
  }
22
28
 
23
29
  export function Type({
@@ -32,6 +38,7 @@ export function Type({
32
38
  enumKeyCasing,
33
39
  description,
34
40
  resolver,
41
+ enumSchemaNames,
35
42
  }: Props): FabricReactNode {
36
43
  const resolvedDescription = description || node?.description
37
44
  const enumSchemaNodes = collect<EnumSchemaNode>(node, {
@@ -51,6 +58,7 @@ export function Type({
51
58
  description: resolvedDescription,
52
59
  keysToOmit,
53
60
  resolver,
61
+ enumSchemaNames,
54
62
  })
55
63
  const output = printer.print(node)
56
64
 
package/src/constants.ts CHANGED
@@ -27,3 +27,13 @@ export const ENUM_TYPES_WITH_RUNTIME_VALUE = new Set<EnumType | undefined>(['enu
27
27
  * `enumType` values whose type declaration is type-only (no runtime value emitted for the type alias).
28
28
  */
29
29
  export const ENUM_TYPES_WITH_TYPE_ONLY = new Set<EnumType | undefined>(['asConst', 'asPascalConst', 'literal', undefined] as const)
30
+
31
+ /**
32
+ * Ordering priority for function parameters: lower = sorted earlier.
33
+ */
34
+ export const PARAM_RANK = {
35
+ required: 0,
36
+ optional: 1,
37
+ withDefault: 2,
38
+ rest: 3,
39
+ } as const
package/src/factory.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { camelCase, pascalCase, screamingSnakeCase, snakeCase } from '@internals/utils'
2
+ import { syncSchemaRef } from '@kubb/ast'
3
+ import type { ArraySchemaNode, SchemaNode } from '@kubb/ast/types'
2
4
  import { isNumber, sortBy } from 'remeda'
3
5
  import ts from 'typescript'
6
+ import { OPTIONAL_ADDS_UNDEFINED } from './constants.ts'
4
7
 
5
8
  const { SyntaxKind, factory } = ts
6
9
 
@@ -249,7 +252,7 @@ export function createTypeDeclaration({
249
252
  }) {
250
253
  if (syntax === 'interface' && 'members' in type) {
251
254
  const node = createInterfaceDeclaration({
252
- members: type.members as Array<ts.TypeElement>,
255
+ members: [...(type as ts.TypeLiteralNode).members],
253
256
  modifiers: isExportable ? [modifiers.export] : [],
254
257
  name,
255
258
  typeParameters: undefined,
@@ -692,3 +695,121 @@ export const createTypeOperatorNode = factory.createTypeOperatorNode
692
695
  export const createPrefixUnaryExpression = factory.createPrefixUnaryExpression
693
696
 
694
697
  export { SyntaxKind }
698
+
699
+ // ─── Printer helpers ──────────────────────────────────────────────────────────
700
+
701
+ /**
702
+ * Converts a primitive const value to a TypeScript literal type node.
703
+ * Handles negative numbers via a prefix unary expression.
704
+ */
705
+ export function constToTypeNode(value: string | number | boolean, format: 'string' | 'number' | 'boolean'): ts.TypeNode | undefined {
706
+ if (format === 'boolean') {
707
+ return createLiteralTypeNode(value === true ? createTrue() : createFalse())
708
+ }
709
+ if (format === 'number' && typeof value === 'number') {
710
+ if (value < 0) {
711
+ return createLiteralTypeNode(createPrefixUnaryExpression(SyntaxKind.MinusToken, createNumericLiteral(Math.abs(value))))
712
+ }
713
+ return createLiteralTypeNode(createNumericLiteral(value))
714
+ }
715
+ return createLiteralTypeNode(createStringLiteral(String(value)))
716
+ }
717
+
718
+ /**
719
+ * Returns a `Date` reference type node when `representation` is `'date'`, otherwise falls back to `string`.
720
+ */
721
+ export function dateOrStringNode(node: { representation?: string }): ts.TypeNode {
722
+ return node.representation === 'date' ? createTypeReferenceNode(createIdentifier('Date')) : keywordTypeNodes.string
723
+ }
724
+
725
+ /**
726
+ * Maps an array of `SchemaNode`s through the printer, filtering out `null` and `undefined` results.
727
+ */
728
+ export function buildMemberNodes(members: Array<SchemaNode> | undefined, print: (node: SchemaNode) => ts.TypeNode | null | undefined): Array<ts.TypeNode> {
729
+ return (members ?? []).map(print).filter(Boolean)
730
+ }
731
+
732
+ /**
733
+ * Builds a TypeScript tuple type node from an array schema's `items`,
734
+ * applying min/max slice and optional/rest element rules.
735
+ */
736
+ export function buildTupleNode(node: ArraySchemaNode, print: (node: SchemaNode) => ts.TypeNode | null | undefined): ts.TypeNode | undefined {
737
+ let items = (node.items ?? []).map(print).filter(Boolean)
738
+
739
+ const restNode = node.rest ? (print(node.rest) ?? undefined) : undefined
740
+ const { min, max } = node
741
+
742
+ if (max !== undefined) {
743
+ items = items.slice(0, max)
744
+ if (items.length < max && restNode) {
745
+ items = [...items, ...Array(max - items.length).fill(restNode)]
746
+ }
747
+ }
748
+
749
+ if (min !== undefined) {
750
+ items = items.map((item, i) => (i >= min ? createOptionalTypeNode(item) : item))
751
+ }
752
+
753
+ if (max === undefined && restNode) {
754
+ items.push(createRestTypeNode(createArrayTypeNode(restNode)))
755
+ }
756
+
757
+ return createTupleTypeNode(items)
758
+ }
759
+
760
+ /**
761
+ * Applies `nullable` and optional/nullish `| undefined` union modifiers to a property's resolved base type.
762
+ */
763
+ export function buildPropertyType(
764
+ schema: SchemaNode,
765
+ baseType: ts.TypeNode,
766
+ optionalType: 'questionToken' | 'undefined' | 'questionTokenAndUndefined',
767
+ ): ts.TypeNode {
768
+ const addsUndefined = OPTIONAL_ADDS_UNDEFINED.has(optionalType)
769
+ const meta = syncSchemaRef(schema)
770
+
771
+ let type = baseType
772
+
773
+ if (meta.nullable) {
774
+ type = createUnionDeclaration({ nodes: [type, keywordTypeNodes.null] })
775
+ }
776
+
777
+ if ((meta.nullish || meta.optional) && addsUndefined) {
778
+ type = createUnionDeclaration({ nodes: [type, keywordTypeNodes.undefined] })
779
+ }
780
+
781
+ return type
782
+ }
783
+
784
+ /**
785
+ * Creates TypeScript index signatures for `additionalProperties` and `patternProperties` on an object schema node.
786
+ */
787
+ export function buildIndexSignatures(
788
+ node: { additionalProperties?: SchemaNode | boolean; patternProperties?: Record<string, SchemaNode> },
789
+ propertyCount: number,
790
+ print: (node: SchemaNode) => ts.TypeNode | null | undefined,
791
+ ): Array<ts.TypeElement> {
792
+ const elements: Array<ts.TypeElement> = []
793
+
794
+ if (node.additionalProperties && node.additionalProperties !== true) {
795
+ const additionalType = print(node.additionalProperties) ?? keywordTypeNodes.unknown
796
+
797
+ elements.push(createIndexSignature(propertyCount > 0 ? keywordTypeNodes.unknown : additionalType))
798
+ } else if (node.additionalProperties === true) {
799
+ elements.push(createIndexSignature(keywordTypeNodes.unknown))
800
+ }
801
+
802
+ if (node.patternProperties) {
803
+ const first = Object.values(node.patternProperties)[0]
804
+ if (first) {
805
+ let patternType = print(first) ?? keywordTypeNodes.unknown
806
+
807
+ if (first.nullable) {
808
+ patternType = createUnionDeclaration({ nodes: [patternType, keywordTypeNodes.null] })
809
+ }
810
+ elements.push(createIndexSignature(patternType))
811
+ }
812
+ }
813
+
814
+ return elements
815
+ }
@@ -11,46 +11,115 @@ import { buildData, buildResponses, buildResponseUnion } from '../utils.ts'
11
11
  export const typeGenerator = defineGenerator<PluginTs>({
12
12
  name: 'typescript',
13
13
  type: 'react',
14
+ Schema({ node, adapter, options, config, resolver }) {
15
+ const { enumType, enumTypeSuffix, enumKeyCasing, syntaxType, optionalType, arrayType, output, group, transformers = [] } = options
16
+
17
+ const transformedNode = transform(node, composeTransformers(...transformers))
18
+
19
+ if (!transformedNode.name) {
20
+ return
21
+ }
22
+
23
+ const root = path.resolve(config.root, config.output.path)
24
+ const mode = getMode(path.resolve(root, output.path))
25
+ // Build a set of schema names that are enums so the ref handler and getImports
26
+ // callback can use the suffixed type name (e.g. `StatusKey`) for those refs.
27
+ const enumSchemaNames = new Set((adapter.rootNode?.schemas ?? []).filter((s) => narrowSchema(s, schemaTypes.enum) && s.name).map((s) => s.name!))
28
+
29
+ function resolveImportName(schemaName: string): string {
30
+ if (ENUM_TYPES_WITH_KEY_SUFFIX.has(enumType) && enumTypeSuffix && enumSchemaNames.has(schemaName)) {
31
+ return resolver.resolveEnumKeyName({ name: schemaName }, enumTypeSuffix)
32
+ }
33
+ return resolver.default(schemaName, 'type')
34
+ }
35
+
36
+ const imports = adapter.getImports(transformedNode, (schemaName) => ({
37
+ name: resolveImportName(schemaName),
38
+ path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group }).path,
39
+ }))
40
+
41
+ const isEnumSchema = !!narrowSchema(transformedNode, schemaTypes.enum)
42
+
43
+ const meta = {
44
+ name:
45
+ ENUM_TYPES_WITH_KEY_SUFFIX.has(enumType) && isEnumSchema
46
+ ? resolver.resolveEnumKeyName(transformedNode, enumTypeSuffix)
47
+ : resolver.resolveName(transformedNode.name),
48
+ file: resolver.resolveFile({ name: transformedNode.name, extname: '.ts' }, { root, output, group }),
49
+ } as const
50
+
51
+ return (
52
+ <File
53
+ baseName={meta.file.baseName}
54
+ path={meta.file.path}
55
+ meta={meta.file.meta}
56
+ banner={resolver.resolveBanner(adapter.rootNode, { output, config })}
57
+ footer={resolver.resolveFooter(adapter.rootNode, { output, config })}
58
+ >
59
+ {mode === 'split' &&
60
+ imports.map((imp) => (
61
+ <File.Import key={[transformedNode.name, imp.path, imp.isTypeOnly].join('-')} root={meta.file.path} path={imp.path} name={imp.name} isTypeOnly />
62
+ ))}
63
+ <Type
64
+ name={meta.name}
65
+ node={transformedNode}
66
+ enumType={enumType}
67
+ enumTypeSuffix={enumTypeSuffix}
68
+ enumKeyCasing={enumKeyCasing}
69
+ optionalType={optionalType}
70
+ arrayType={arrayType}
71
+ syntaxType={syntaxType}
72
+ resolver={resolver}
73
+ enumSchemaNames={enumSchemaNames}
74
+ />
75
+ </File>
76
+ )
77
+ },
14
78
  Operation({ node, adapter, options, config, resolver }) {
15
79
  const { enumType, enumTypeSuffix, enumKeyCasing, optionalType, arrayType, syntaxType, paramsCasing, group, output, transformers = [] } = options
16
80
 
81
+ const transformedNode = transform(node, composeTransformers(...transformers))
82
+
17
83
  const root = path.resolve(config.root, config.output.path)
18
84
  const mode = getMode(path.resolve(root, output.path))
19
85
 
20
- const file = resolver.resolveFile({ name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path }, { root, output, group })
21
-
22
- const params = caseParams(node.parameters, paramsCasing)
23
-
24
- function renderSchemaType({
25
- node: schemaNode,
26
- name,
27
- description,
28
- keysToOmit,
29
- }: {
30
- node: SchemaNode | null
31
- name: string
32
- description?: string
33
- keysToOmit?: Array<string>
34
- }) {
35
- if (!schemaNode) {
36
- return null
86
+ const params = caseParams(transformedNode.parameters, paramsCasing)
87
+
88
+ const meta = {
89
+ file: resolver.resolveFile(
90
+ { name: transformedNode.operationId, extname: '.ts', tag: transformedNode.tags[0] ?? 'default', path: transformedNode.path },
91
+ { root, output, group },
92
+ ),
93
+ } as const
94
+
95
+ // Build a set of schema names that are enums so the ref handler and getImports
96
+ // callback can use the suffixed type name (e.g. `StatusKey`) for those refs.
97
+ const enumSchemaNames = new Set((adapter.rootNode?.schemas ?? []).filter((s) => narrowSchema(s, schemaTypes.enum) && s.name).map((s) => s.name!))
98
+
99
+ function resolveImportName(schemaName: string): string {
100
+ if (ENUM_TYPES_WITH_KEY_SUFFIX.has(enumType) && enumTypeSuffix && enumSchemaNames.has(schemaName)) {
101
+ return resolver.resolveEnumKeyName({ name: schemaName }, enumTypeSuffix)
37
102
  }
103
+ return resolver.default(schemaName, 'type')
104
+ }
38
105
 
39
- const transformedNode = transform(schemaNode, composeTransformers(...transformers))
106
+ function renderSchemaType({ schema, name, keysToOmit }: { schema: SchemaNode | null; name: string; keysToOmit?: Array<string> }) {
107
+ if (!schema) return null
40
108
 
41
- const imports = adapter.getImports(transformedNode, (schemaName) => ({
42
- name: resolver.default(schemaName, 'type'),
109
+ const imports = adapter.getImports(schema, (schemaName) => ({
110
+ name: resolveImportName(schemaName),
43
111
  path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group }).path,
44
112
  }))
45
113
 
46
114
  return (
47
115
  <>
48
116
  {mode === 'split' &&
49
- imports.map((imp) => <File.Import key={[name, imp.path, imp.isTypeOnly].join('-')} root={file.path} path={imp.path} name={imp.name} isTypeOnly />)}
117
+ imports.map((imp) => (
118
+ <File.Import key={[name, imp.path, imp.isTypeOnly].join('-')} root={meta.file.path} path={imp.path} name={imp.name} isTypeOnly />
119
+ ))}
50
120
  <Type
51
121
  name={name}
52
- node={transformedNode}
53
- description={description}
122
+ node={schema}
54
123
  enumType={enumType}
55
124
  enumTypeSuffix={enumTypeSuffix}
56
125
  enumKeyCasing={enumKeyCasing}
@@ -59,6 +128,7 @@ export const typeGenerator = defineGenerator<PluginTs>({
59
128
  syntaxType={syntaxType}
60
129
  resolver={resolver}
61
130
  keysToOmit={keysToOmit}
131
+ enumSchemaNames={enumSchemaNames}
62
132
  />
63
133
  </>
64
134
  )
@@ -66,50 +136,55 @@ export const typeGenerator = defineGenerator<PluginTs>({
66
136
 
67
137
  const paramTypes = params.map((param) =>
68
138
  renderSchemaType({
69
- node: param.schema,
70
- name: resolver.resolveParamName(node, param),
139
+ schema: param.schema,
140
+ name: resolver.resolveParamName(transformedNode, param),
71
141
  }),
72
142
  )
73
143
 
74
- const requestType = node.requestBody?.schema
144
+ const requestType = transformedNode.requestBody?.schema
75
145
  ? renderSchemaType({
76
- node: node.requestBody.schema,
77
- name: resolver.resolveDataName(node),
78
- description: node.requestBody.description ?? node.requestBody.schema.description,
79
- keysToOmit: node.requestBody.keysToOmit,
146
+ schema: {
147
+ ...transformedNode.requestBody.schema,
148
+ description: transformedNode.requestBody.description ?? transformedNode.requestBody.schema.description,
149
+ },
150
+ name: resolver.resolveDataName(transformedNode),
151
+ keysToOmit: transformedNode.requestBody.keysToOmit,
80
152
  })
81
153
  : null
82
154
 
83
- const responseTypes = node.responses.map((res) =>
155
+ const responseTypes = transformedNode.responses.map((res) =>
84
156
  renderSchemaType({
85
- node: res.schema,
86
- name: resolver.resolveResponseStatusName(node, res.statusCode),
87
- description: res.description,
157
+ schema: res.schema,
158
+ name: resolver.resolveResponseStatusName(transformedNode, res.statusCode),
88
159
  keysToOmit: res.keysToOmit,
89
160
  }),
90
161
  )
91
162
 
92
163
  const dataType = renderSchemaType({
93
- node: buildData({ node: { ...node, parameters: params }, resolver }),
94
- name: resolver.resolveRequestConfigName(node),
164
+ schema: buildData({ ...transformedNode, parameters: params }, { resolver }),
165
+ name: resolver.resolveRequestConfigName(transformedNode),
95
166
  })
96
167
 
97
168
  const responsesType = renderSchemaType({
98
- node: buildResponses({ node, resolver }),
99
- name: resolver.resolveResponsesName(node),
169
+ schema: buildResponses(transformedNode, { resolver }),
170
+ name: resolver.resolveResponsesName(transformedNode),
100
171
  })
101
172
 
102
173
  const responseType = renderSchemaType({
103
- node: buildResponseUnion({ node, resolver }),
104
- name: resolver.resolveResponseName(node),
105
- description: 'Union of all possible responses',
174
+ schema: transformedNode.responses.some((res) => res.schema)
175
+ ? {
176
+ ...buildResponseUnion(transformedNode, { resolver })!,
177
+ description: 'Union of all possible responses',
178
+ }
179
+ : null,
180
+ name: resolver.resolveResponseName(transformedNode),
106
181
  })
107
182
 
108
183
  return (
109
184
  <File
110
- baseName={file.baseName}
111
- path={file.path}
112
- meta={file.meta}
185
+ baseName={meta.file.baseName}
186
+ path={meta.file.path}
187
+ meta={meta.file.meta}
113
188
  banner={resolver.resolveBanner(adapter.rootNode, { output, config })}
114
189
  footer={resolver.resolveFooter(adapter.rootNode, { output, config })}
115
190
  >
@@ -122,56 +197,4 @@ export const typeGenerator = defineGenerator<PluginTs>({
122
197
  </File>
123
198
  )
124
199
  },
125
- Schema({ node, adapter, options, config, resolver }) {
126
- const { enumType, enumTypeSuffix, enumKeyCasing, syntaxType, optionalType, arrayType, output, group, transformers = [] } = options
127
-
128
- const root = path.resolve(config.root, config.output.path)
129
- const mode = getMode(path.resolve(root, output.path))
130
-
131
- if (!node.name) {
132
- return
133
- }
134
-
135
- const transformedNode = transform(node, composeTransformers(...transformers))
136
-
137
- const imports = adapter.getImports(transformedNode, (schemaName) => ({
138
- name: resolver.default(schemaName, 'type'),
139
- path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group }).path,
140
- }))
141
-
142
- const isEnumSchema = !!narrowSchema(node, schemaTypes.enum)
143
-
144
- const name = ENUM_TYPES_WITH_KEY_SUFFIX.has(enumType) && isEnumSchema ? resolver.resolveEnumKeyName(node, enumTypeSuffix) : resolver.resolveName(node.name)
145
-
146
- const type = {
147
- name,
148
- file: resolver.resolveFile({ name: node.name, extname: '.ts' }, { root, output, group }),
149
- } as const
150
-
151
- return (
152
- <File
153
- baseName={type.file.baseName}
154
- path={type.file.path}
155
- meta={type.file.meta}
156
- banner={resolver.resolveBanner(adapter.rootNode, { output, config })}
157
- footer={resolver.resolveFooter(adapter.rootNode, { output, config })}
158
- >
159
- {mode === 'split' &&
160
- imports.map((imp) => (
161
- <File.Import key={[node.name, imp.path, imp.isTypeOnly].join('-')} root={type.file.path} path={imp.path} name={imp.name} isTypeOnly />
162
- ))}
163
- <Type
164
- name={type.name}
165
- node={transformedNode}
166
- enumType={enumType}
167
- enumTypeSuffix={enumTypeSuffix}
168
- enumKeyCasing={enumKeyCasing}
169
- optionalType={optionalType}
170
- arrayType={arrayType}
171
- syntaxType={syntaxType}
172
- resolver={resolver}
173
- />
174
- </File>
175
- )
176
- },
177
200
  })