@kubb/plugin-ts
Advanced tools
+124
-61
@@ -807,2 +807,50 @@ import { t as __name } from "./chunk--u3MIqq1.js"; | ||
| //#region ../../internals/shared/src/operation.ts | ||
| /** | ||
| * Maps a content type to the PascalCase suffix used to name per-content-type variants | ||
| * (e.g. `application/json` → `Json`, `application/xml` → `Xml`, `multipart/form-data` → `FormData`). | ||
| */ | ||
| function getContentTypeSuffix(contentType) { | ||
| const baseType = contentType.split(";")[0].trim(); | ||
| if (baseType === "application/json") return "Json"; | ||
| if (baseType === "multipart/form-data") return "FormData"; | ||
| if (baseType === "application/x-www-form-urlencoded") return "FormUrlEncoded"; | ||
| const parts = (baseType.split("/").pop() ?? baseType).split(/[^a-zA-Z0-9]+/).filter(Boolean); | ||
| if (parts.length === 0) return "Unknown"; | ||
| return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(""); | ||
| } | ||
| /** | ||
| * Appends a content-type suffix to a base name, keeping a trailing `Data` segment last | ||
| * (e.g. `AddPetData` + `Json` → `AddPetJsonData`, `AddPetStatus200` + `Xml` → `AddPetStatus200Xml`). | ||
| */ | ||
| function getPerContentTypeName(baseName, suffix) { | ||
| if (baseName.endsWith("Data")) return suffix.endsWith("Data") ? baseName.slice(0, -4) + suffix : `${baseName.slice(0, -4)}${suffix}Data`; | ||
| return baseName + suffix; | ||
| } | ||
| /** | ||
| * Resolves per-content-type variant names for a set of content entries, deduplicating suffix | ||
| * collisions with a numeric counter. Entries without a schema are skipped. The returned `suffix` is | ||
| * the final (possibly counter-augmented) value, so callers can derive parallel names in another | ||
| * namespace (e.g. plugin-faker deriving the matching plugin-ts type name). | ||
| */ | ||
| function resolveContentTypeVariants(entries, baseName) { | ||
| const usedNames = /* @__PURE__ */ new Set(); | ||
| return entries.filter((entry) => entry.schema).map((entry) => { | ||
| const baseSuffix = getContentTypeSuffix(entry.contentType); | ||
| let suffix = baseSuffix; | ||
| let name = getPerContentTypeName(baseName, suffix); | ||
| let counter = 2; | ||
| while (usedNames.has(name)) { | ||
| suffix = `${baseSuffix}${counter++}`; | ||
| name = getPerContentTypeName(baseName, suffix); | ||
| } | ||
| usedNames.add(name); | ||
| return { | ||
| name, | ||
| suffix, | ||
| schema: entry.schema, | ||
| keysToOmit: entry.keysToOmit, | ||
| contentType: entry.contentType | ||
| }; | ||
| }); | ||
| } | ||
| function getOperationParameters(node, options = {}) { | ||
@@ -818,2 +866,35 @@ const params = ast.caseParams(node.parameters, options.paramsCasing); | ||
| //#endregion | ||
| //#region ../../internals/shared/src/group.ts | ||
| /** | ||
| * Builds the `group` config a Kubb plugin passes to `ctx.setOptions`, applying the | ||
| * shared default naming so every plugin groups output consistently: | ||
| * | ||
| * - `path` groups use the second path segment (`/pet/findByStatus` → `pet`). | ||
| * - other groups use `${camelCase(group)}${suffix}` (e.g. `petController`). | ||
| * | ||
| * Returns `null` when grouping is disabled, matching the per-plugin convention. | ||
| * | ||
| * @param group - The user-supplied group option, or `undefined` to disable grouping. | ||
| * @param options.suffix - Appended to non-`path` group names, e.g. `'Controller'` or `'Requests'`. | ||
| * @param options.honorName - When `true`, a user-provided `group.name` overrides the default namer. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * createGroupConfig(group, { suffix: 'Controller' }) // plugin-ts, plugin-zod | ||
| * createGroupConfig(group, { suffix: 'Controller', honorName: true }) // plugin-faker, plugin-client, … | ||
| * createGroupConfig(group, { suffix: 'Requests', honorName: true }) // plugin-cypress, plugin-mcp | ||
| * ``` | ||
| */ | ||
| function createGroupConfig(group, options) { | ||
| if (!group) return null; | ||
| const defaultName = (ctx) => { | ||
| if (group.type === "path") return `${ctx.group.split("/")[1]}`; | ||
| return `${camelCase(ctx.group)}${options.suffix}`; | ||
| }; | ||
| return { | ||
| ...group, | ||
| name: options.honorName && group.name ? group.name : defaultName | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/utils.ts | ||
@@ -940,3 +1021,3 @@ /** | ||
| function buildResponseUnion(node, { resolver }) { | ||
| const responsesWithSchema = node.responses.filter((res) => res.content?.[0]?.schema); | ||
| const responsesWithSchema = node.responses.filter((res) => res.content?.some((entry) => entry.schema)); | ||
| if (responsesWithSchema.length === 0) return null; | ||
@@ -1114,15 +1195,2 @@ return ast.createSchema({ | ||
| //#region src/generators/typeGenerator.tsx | ||
| function getContentTypeSuffix(contentType) { | ||
| const baseType = contentType.split(";")[0].trim(); | ||
| if (baseType === "application/json") return "Json"; | ||
| if (baseType === "multipart/form-data") return "FormData"; | ||
| if (baseType === "application/x-www-form-urlencoded") return "FormUrlEncoded"; | ||
| const parts = (baseType.split("/").pop() ?? baseType).split(/[^a-zA-Z0-9]+/).filter(Boolean); | ||
| if (parts.length === 0) return "Unknown"; | ||
| return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(""); | ||
| } | ||
| function getPerContentTypeName(dataName, suffix) { | ||
| if (dataName.endsWith("Data")) return suffix.endsWith("Data") ? dataName.slice(0, -4) + suffix : `${dataName.slice(0, -4)}${suffix}Data`; | ||
| return dataName + suffix; | ||
| } | ||
| /** | ||
@@ -1287,2 +1355,24 @@ * Built-in generator for `@kubb/plugin-ts`. Emits one TypeScript file per | ||
| } | ||
| /** | ||
| * Emits an individual type per content type plus a union alias under `baseName`. | ||
| * Shared by the request body and multi-content-type responses. | ||
| */ | ||
| function buildContentTypeVariants(entries, baseName, decorate) { | ||
| const variants = resolveContentTypeVariants(entries, baseName); | ||
| const unionSchema = ast.createSchema({ | ||
| type: "union", | ||
| members: variants.map((variant) => ast.createSchema({ | ||
| type: "ref", | ||
| name: variant.name | ||
| })) | ||
| }); | ||
| return /* @__PURE__ */ jsxs(Fragment, { children: [variants.map((variant) => renderSchemaType({ | ||
| schema: decorate ? decorate(variant.schema) : variant.schema, | ||
| name: variant.name, | ||
| keysToOmit: variant.keysToOmit | ||
| })), renderSchemaType({ | ||
| schema: unionSchema, | ||
| name: baseName | ||
| })] }); | ||
| } | ||
| const paramTypes = params.map((param) => renderSchemaType({ | ||
@@ -1307,40 +1397,18 @@ schema: param.schema, | ||
| } | ||
| const dataName = resolver.resolveDataName(node); | ||
| const usedNames = /* @__PURE__ */ new Set(); | ||
| const individualItems = requestBodyContent.filter((entry) => entry.schema).map((entry) => { | ||
| const baseSuffix = getContentTypeSuffix(entry.contentType); | ||
| let individualName = getPerContentTypeName(dataName, baseSuffix); | ||
| let counter = 2; | ||
| while (usedNames.has(individualName)) individualName = getPerContentTypeName(dataName, `${baseSuffix}${counter++}`); | ||
| usedNames.add(individualName); | ||
| return { | ||
| name: individualName, | ||
| rendered: renderSchemaType({ | ||
| schema: { | ||
| ...entry.schema, | ||
| description: node.requestBody.description ?? entry.schema.description | ||
| }, | ||
| name: individualName, | ||
| keysToOmit: entry.keysToOmit | ||
| }) | ||
| }; | ||
| }); | ||
| const unionType = renderSchemaType({ | ||
| schema: ast.createSchema({ | ||
| type: "union", | ||
| members: individualItems.map((item) => ast.createSchema({ | ||
| type: "ref", | ||
| name: item.name | ||
| })) | ||
| }), | ||
| name: dataName | ||
| }); | ||
| return /* @__PURE__ */ jsxs(Fragment, { children: [individualItems.map((item) => item.rendered), unionType] }); | ||
| return buildContentTypeVariants(requestBodyContent, resolver.resolveDataName(node), (schema) => ({ | ||
| ...schema, | ||
| description: node.requestBody.description ?? schema.description | ||
| })); | ||
| } | ||
| const requestType = buildRequestType(); | ||
| const responseTypes = node.responses.map((res) => renderSchemaType({ | ||
| schema: res.content?.[0]?.schema ?? null, | ||
| name: resolver.resolveResponseStatusName(node, res.statusCode), | ||
| keysToOmit: res.content?.[0]?.keysToOmit | ||
| })); | ||
| const responseTypes = node.responses.map((res) => { | ||
| const variants = (res.content ?? []).filter((entry) => entry.schema); | ||
| if (variants.length > 1) return buildContentTypeVariants(variants, resolver.resolveResponseStatusName(node, res.statusCode)); | ||
| const primary = variants[0] ?? res.content?.[0]; | ||
| return renderSchemaType({ | ||
| schema: primary?.schema ?? null, | ||
| name: resolver.resolveResponseStatusName(node, res.statusCode), | ||
| keysToOmit: primary?.keysToOmit | ||
| }); | ||
| }); | ||
| const dataType = renderSchemaType({ | ||
@@ -1358,9 +1426,10 @@ schema: buildData({ | ||
| function buildResponseType() { | ||
| if (!node.responses.some((res) => res.content?.[0]?.schema)) return null; | ||
| const hasSchema = (res) => (res.content ?? []).some((entry) => entry.schema); | ||
| if (!node.responses.some(hasSchema)) return null; | ||
| const responseName = resolver.resolveResponseName(node); | ||
| const responsesWithSchema = node.responses.filter((res) => res.content?.[0]?.schema); | ||
| if (new Set(responsesWithSchema.flatMap((res) => res.content?.[0]?.schema ? adapter.getImports(res.content[0].schema, (schemaName) => ({ | ||
| const responsesWithSchema = node.responses.filter(hasSchema); | ||
| if (new Set(responsesWithSchema.flatMap((res) => (res.content ?? []).flatMap((entry) => entry.schema ? adapter.getImports(entry.schema, (schemaName) => ({ | ||
| name: resolveImportName(schemaName), | ||
| path: "" | ||
| })).flatMap((imp) => Array.isArray(imp.name) ? imp.name : [imp.name]) : [])).has(responseName)) return null; | ||
| })).flatMap((imp) => Array.isArray(imp.name) ? imp.name : [imp.name]) : []))).has(responseName)) return null; | ||
| return renderSchemaType({ | ||
@@ -1509,9 +1578,3 @@ schema: { | ||
| }, group, exclude = [], include, override = [], enumType = "asConst", enumTypeSuffix = "Key", enumKeyCasing = "none", optionalType = "questionToken", arrayType = "array", syntaxType = "type", paramsCasing, printer, resolver: userResolver, transformer: userTransformer, generators: userGenerators = [] } = options; | ||
| const groupConfig = group ? { | ||
| ...group, | ||
| name: (ctx) => { | ||
| if (group.type === "path") return `${ctx.group.split("/")[1]}`; | ||
| return `${camelCase(ctx.group)}Controller`; | ||
| } | ||
| } : null; | ||
| const groupConfig = createGroupConfig(group, { suffix: "Controller" }); | ||
| return { | ||
@@ -1518,0 +1581,0 @@ name: pluginTsName, |
+5
-5
| { | ||
| "name": "@kubb/plugin-ts", | ||
| "version": "5.0.0-beta.30", | ||
| "version": "5.0.0-beta.31", | ||
| "description": "Generate TypeScript types, interfaces, and enums from your OpenAPI specification. The foundational plugin that powers type safety across the entire Kubb ecosystem.", | ||
@@ -49,5 +49,5 @@ "keywords": [ | ||
| "dependencies": { | ||
| "@kubb/core": "5.0.0-beta.29", | ||
| "@kubb/parser-ts": "5.0.0-beta.29", | ||
| "@kubb/renderer-jsx": "5.0.0-beta.29", | ||
| "@kubb/core": "5.0.0-beta.31", | ||
| "@kubb/parser-ts": "5.0.0-beta.31", | ||
| "@kubb/renderer-jsx": "5.0.0-beta.31", | ||
| "remeda": "^2.34.1", | ||
@@ -61,3 +61,3 @@ "typescript": "^6.0.3" | ||
| "peerDependencies": { | ||
| "@kubb/renderer-jsx": "5.0.0-beta.29" | ||
| "@kubb/renderer-jsx": "5.0.0-beta.31" | ||
| }, | ||
@@ -64,0 +64,0 @@ "size-limit": [ |
@@ -0,1 +1,2 @@ | ||
| import { resolveContentTypeVariants } from '@internals/shared' | ||
| import { ast, defineGenerator } from '@kubb/core' | ||
@@ -9,20 +10,2 @@ import { File, jsxRendererSync } from '@kubb/renderer-jsx' | ||
| function getContentTypeSuffix(contentType: string): string { | ||
| const baseType = contentType.split(';')[0]!.trim() | ||
| if (baseType === 'application/json') return 'Json' | ||
| if (baseType === 'multipart/form-data') return 'FormData' | ||
| if (baseType === 'application/x-www-form-urlencoded') return 'FormUrlEncoded' | ||
| const subtype = baseType.split('/').pop() ?? baseType | ||
| const parts = subtype.split(/[^a-zA-Z0-9]+/).filter(Boolean) | ||
| if (parts.length === 0) return 'Unknown' | ||
| return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('') | ||
| } | ||
| function getPerContentTypeName(dataName: string, suffix: string): string { | ||
| if (dataName.endsWith('Data')) { | ||
| return suffix.endsWith('Data') ? dataName.slice(0, -4) + suffix : `${dataName.slice(0, -4)}${suffix}Data` | ||
| } | ||
| return dataName + suffix | ||
| } | ||
| /** | ||
@@ -172,2 +155,30 @@ * Built-in generator for `@kubb/plugin-ts`. Emits one TypeScript file per | ||
| /** | ||
| * Emits an individual type per content type plus a union alias under `baseName`. | ||
| * Shared by the request body and multi-content-type responses. | ||
| */ | ||
| function buildContentTypeVariants( | ||
| entries: Array<{ contentType: string; schema?: ast.SchemaNode | null; keysToOmit?: Array<string> | null }>, | ||
| baseName: string, | ||
| decorate?: (schema: ast.SchemaNode) => ast.SchemaNode, | ||
| ) { | ||
| const variants = resolveContentTypeVariants(entries, baseName) | ||
| const unionSchema = ast.createSchema({ | ||
| type: 'union', | ||
| members: variants.map((variant) => ast.createSchema({ type: 'ref', name: variant.name })), | ||
| }) | ||
| return ( | ||
| <> | ||
| {variants.map((variant) => | ||
| renderSchemaType({ | ||
| schema: decorate ? decorate(variant.schema) : variant.schema, | ||
| name: variant.name, | ||
| keysToOmit: variant.keysToOmit, | ||
| }), | ||
| )} | ||
| {renderSchemaType({ schema: unionSchema, name: baseName })} | ||
| </> | ||
| ) | ||
| } | ||
| const paramTypes = params.map((param) => | ||
@@ -197,37 +208,6 @@ renderSchemaType({ | ||
| // Multiple content types — generate individual types + union alias | ||
| const dataName = resolver.resolveDataName(node) | ||
| const usedNames = new Set<string>() | ||
| const individualItems = requestBodyContent | ||
| .filter((entry) => entry.schema) | ||
| .map((entry) => { | ||
| const baseSuffix = getContentTypeSuffix(entry.contentType) | ||
| let individualName = getPerContentTypeName(dataName, baseSuffix) | ||
| let counter = 2 | ||
| while (usedNames.has(individualName)) { | ||
| individualName = getPerContentTypeName(dataName, `${baseSuffix}${counter++}`) | ||
| } | ||
| usedNames.add(individualName) | ||
| return { | ||
| name: individualName, | ||
| rendered: renderSchemaType({ | ||
| schema: { | ||
| ...entry.schema!, | ||
| description: node.requestBody!.description ?? entry.schema!.description, | ||
| }, | ||
| name: individualName, | ||
| keysToOmit: entry.keysToOmit, | ||
| }), | ||
| } | ||
| }) | ||
| const unionSchema = ast.createSchema({ | ||
| type: 'union', | ||
| members: individualItems.map((item) => ast.createSchema({ type: 'ref', name: item.name })), | ||
| }) | ||
| const unionType = renderSchemaType({ schema: unionSchema, name: dataName }) | ||
| return ( | ||
| <> | ||
| {individualItems.map((item) => item.rendered)} | ||
| {unionType} | ||
| </> | ||
| ) | ||
| return buildContentTypeVariants(requestBodyContent, resolver.resolveDataName(node), (schema) => ({ | ||
| ...schema, | ||
| description: node.requestBody!.description ?? schema.description, | ||
| })) | ||
| } | ||
@@ -237,9 +217,15 @@ | ||
| const responseTypes = node.responses.map((res) => | ||
| renderSchemaType({ | ||
| schema: res.content?.[0]?.schema ?? null, | ||
| const responseTypes = node.responses.map((res) => { | ||
| const variants = (res.content ?? []).filter((entry) => entry.schema) | ||
| // Multiple content types for a single status code — generate a union of the variants. | ||
| if (variants.length > 1) { | ||
| return buildContentTypeVariants(variants, resolver.resolveResponseStatusName(node, res.statusCode)) | ||
| } | ||
| const primary = variants[0] ?? res.content?.[0] | ||
| return renderSchemaType({ | ||
| schema: primary?.schema ?? null, | ||
| name: resolver.resolveResponseStatusName(node, res.statusCode), | ||
| keysToOmit: res.content?.[0]?.keysToOmit, | ||
| }), | ||
| ) | ||
| keysToOmit: primary?.keysToOmit, | ||
| }) | ||
| }) | ||
@@ -257,3 +243,4 @@ const dataType = renderSchemaType({ | ||
| function buildResponseType() { | ||
| if (!node.responses.some((res) => res.content?.[0]?.schema)) { | ||
| const hasSchema = (res: ast.ResponseNode) => (res.content ?? []).some((entry) => entry.schema) | ||
| if (!node.responses.some(hasSchema)) { | ||
| return null | ||
@@ -264,13 +251,15 @@ } | ||
| const responsesWithSchema = node.responses.filter((res) => res.content?.[0]?.schema) | ||
| const responsesWithSchema = node.responses.filter(hasSchema) | ||
| const importedNames = new Set( | ||
| responsesWithSchema.flatMap((res) => | ||
| res.content?.[0]?.schema | ||
| ? adapter | ||
| .getImports(res.content[0].schema, (schemaName) => ({ | ||
| name: resolveImportName(schemaName), | ||
| path: '', | ||
| })) | ||
| .flatMap((imp) => (Array.isArray(imp.name) ? imp.name : [imp.name])) | ||
| : [], | ||
| (res.content ?? []).flatMap((entry) => | ||
| entry.schema | ||
| ? adapter | ||
| .getImports(entry.schema, (schemaName) => ({ | ||
| name: resolveImportName(schemaName), | ||
| path: '', | ||
| })) | ||
| .flatMap((imp) => (Array.isArray(imp.name) ? imp.name : [imp.name])) | ||
| : [], | ||
| ), | ||
| ), | ||
@@ -277,0 +266,0 @@ ) |
+3
-13
@@ -1,3 +0,3 @@ | ||
| import { camelCase } from '@internals/utils' | ||
| import { definePlugin, type Group } from '@kubb/core' | ||
| import { createGroupConfig } from '@internals/shared' | ||
| import { definePlugin } from '@kubb/core' | ||
| import { typeGenerator } from './generators/typeGenerator.tsx' | ||
@@ -57,13 +57,3 @@ import { resolverTs } from './resolvers/resolverTs.ts' | ||
| const groupConfig = group | ||
| ? ({ | ||
| ...group, | ||
| name: (ctx) => { | ||
| if (group.type === 'path') { | ||
| return `${ctx.group.split('/')[1]}` | ||
| } | ||
| return `${camelCase(ctx.group)}Controller` | ||
| }, | ||
| } satisfies Group) | ||
| : null | ||
| const groupConfig = createGroupConfig(group, { suffix: 'Controller' }) | ||
@@ -70,0 +60,0 @@ return { |
+1
-1
@@ -130,3 +130,3 @@ import { jsStringEscape, stringify } from '@internals/utils' | ||
| export function buildResponseUnion(node: ast.OperationNode, { resolver }: BuildOperationSchemaOptions): ast.SchemaNode | null { | ||
| const responsesWithSchema = node.responses.filter((res) => res.content?.[0]?.schema) | ||
| const responsesWithSchema = node.responses.filter((res) => res.content?.some((entry) => entry.schema)) | ||
@@ -133,0 +133,0 @@ if (responsesWithSchema.length === 0) { |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
575429
3.16%6424
1.68%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated