@kubb/core 5.0.0-beta.6 → 5.0.0-beta.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +17 -10
  2. package/README.md +25 -158
  3. package/dist/diagnostics-B-UZnFqP.d.ts +2906 -0
  4. package/dist/index.cjs +2497 -1071
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.ts +80 -273
  7. package/dist/index.js +2487 -1067
  8. package/dist/index.js.map +1 -1
  9. package/dist/memoryStorage-CUj1hrxa.cjs +823 -0
  10. package/dist/memoryStorage-CUj1hrxa.cjs.map +1 -0
  11. package/dist/memoryStorage-CWFzAz4o.js +714 -0
  12. package/dist/memoryStorage-CWFzAz4o.js.map +1 -0
  13. package/dist/mocks.cjs +79 -19
  14. package/dist/mocks.cjs.map +1 -1
  15. package/dist/mocks.d.ts +35 -9
  16. package/dist/mocks.js +80 -22
  17. package/dist/mocks.js.map +1 -1
  18. package/package.json +8 -28
  19. package/src/FileManager.ts +86 -64
  20. package/src/FileProcessor.ts +170 -44
  21. package/src/KubbDriver.ts +908 -0
  22. package/src/Transform.ts +75 -0
  23. package/src/constants.ts +111 -20
  24. package/src/createAdapter.ts +112 -17
  25. package/src/createKubb.ts +140 -517
  26. package/src/createRenderer.ts +43 -28
  27. package/src/createReporter.ts +134 -0
  28. package/src/createStorage.ts +36 -23
  29. package/src/defineGenerator.ts +147 -17
  30. package/src/defineParser.ts +30 -12
  31. package/src/definePlugin.ts +370 -21
  32. package/src/defineResolver.ts +402 -212
  33. package/src/diagnostics.ts +662 -0
  34. package/src/index.ts +8 -8
  35. package/src/mocks.ts +91 -20
  36. package/src/reporters/cliReporter.ts +89 -0
  37. package/src/reporters/fileReporter.ts +103 -0
  38. package/src/reporters/jsonReporter.ts +20 -0
  39. package/src/reporters/report.ts +85 -0
  40. package/src/storages/fsStorage.ts +23 -55
  41. package/src/types.ts +411 -887
  42. package/dist/PluginDriver-BkTRD2H2.js +0 -946
  43. package/dist/PluginDriver-BkTRD2H2.js.map +0 -1
  44. package/dist/PluginDriver-Cadu4ORh.cjs +0 -1037
  45. package/dist/PluginDriver-Cadu4ORh.cjs.map +0 -1
  46. package/dist/types-DVPKmzw_.d.ts +0 -2159
  47. package/src/Kubb.ts +0 -300
  48. package/src/PluginDriver.ts +0 -426
  49. package/src/defineLogger.ts +0 -19
  50. package/src/defineMiddleware.ts +0 -62
  51. package/src/devtools.ts +0 -59
  52. package/src/renderNode.ts +0 -35
  53. package/src/utils/diagnostics.ts +0 -18
  54. package/src/utils/isInputPath.ts +0 -10
  55. package/src/utils/packageJSON.ts +0 -99
  56. /package/dist/{chunk--u3MIqq1.js → chunk-C0LytTxp.js} +0 -0
@@ -1,40 +1,255 @@
1
1
  import path from 'node:path'
2
- import { camelCase, pascalCase } from '@internals/utils'
3
- import type { FileNode, InputNode, Node, OperationNode, SchemaNode } from '@kubb/ast'
4
- import { createFile, isOperationNode, isSchemaNode } from '@kubb/ast'
5
- import { PluginDriver } from './PluginDriver.ts'
6
- import type {
7
- Config,
8
- PluginFactoryOptions,
9
- ResolveBannerContext,
10
- ResolveOptionsContext,
11
- Resolver,
12
- ResolverContext,
13
- ResolverFileParams,
14
- ResolverPathParams,
15
- } from './types.ts'
2
+ import { camelCase, pascalCase, toFilePath } from '@internals/utils'
3
+ import type { FileNode, InputMeta, Node, OperationNode, SchemaNode } from '@kubb/ast'
4
+ import { operationDef, schemaDef } from '@kubb/ast'
5
+ import * as factory from '@kubb/ast/factory'
6
+ import { Diagnostics } from './diagnostics.ts'
7
+ import type { PluginFactoryOptions } from './definePlugin.ts'
8
+ import type { Config, Group, Output } from './types.ts'
9
+
10
+ /**
11
+ * Type/string pattern filter for include/exclude/override matching.
12
+ */
13
+ type PatternFilter = {
14
+ type: string
15
+ pattern: string | RegExp
16
+ }
17
+
18
+ /**
19
+ * Pattern filter with partial option overrides applied when the pattern matches.
20
+ */
21
+ type PatternOverride<TOptions> = PatternFilter & {
22
+ options: Omit<Partial<TOptions>, 'override'>
23
+ }
24
+
25
+ /**
26
+ * Context for resolving filtered options for a given operation or schema node.
27
+ *
28
+ * @internal
29
+ */
30
+ export type ResolveOptionsContext<TOptions> = {
31
+ options: TOptions
32
+ exclude?: Array<PatternFilter>
33
+ include?: Array<PatternFilter>
34
+ override?: Array<PatternOverride<TOptions>>
35
+ }
36
+
37
+ /**
38
+ * Base constraint for all plugin resolver objects.
39
+ *
40
+ * `default`, `resolveOptions`, `resolvePath`, `resolveFile`, `resolveBanner`, and `resolveFooter`
41
+ * are injected automatically by `defineResolver`. Extend this type to add custom resolution methods.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * type MyResolver = Resolver & {
46
+ * resolveName(node: SchemaNode): string
47
+ * resolveTypedName(node: SchemaNode): string
48
+ * }
49
+ * ```
50
+ */
51
+ export type Resolver = {
52
+ name: string
53
+ pluginName: string
54
+ default(name: string, type?: 'file' | 'function' | 'type' | 'const'): string
55
+ resolveOptions<TOptions>(node: Node, context: ResolveOptionsContext<TOptions>): TOptions | null
56
+ resolvePath(params: ResolverPathParams, context: ResolverContext): string
57
+ resolveFile(params: ResolverFileParams, context: ResolverContext): FileNode
58
+ resolveBanner(meta: InputMeta | undefined, context: ResolveBannerContext): string | null
59
+ resolveFooter(meta: InputMeta | undefined, context: ResolveBannerContext): string | null
60
+ }
61
+
62
+ /**
63
+ * File-specific parameters for `Resolver.resolvePath`.
64
+ *
65
+ * Pass alongside a `ResolverContext` to identify which file to resolve.
66
+ * Provide `tag` for tag-based grouping or `path` for path-based grouping.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * resolver.resolvePath(
71
+ * { baseName: 'petTypes.ts', tag: 'pets' },
72
+ * { root: '/src', output: { path: 'types' }, group: { type: 'tag' } },
73
+ * )
74
+ * // → '/src/types/pets/petTypes.ts'
75
+ * ```
76
+ */
77
+ export type ResolverPathParams = {
78
+ baseName: FileNode['baseName']
79
+ /**
80
+ * Tag value used when `group.type === 'tag'`.
81
+ */
82
+ tag?: string
83
+ /**
84
+ * Path value used when `group.type === 'path'`.
85
+ */
86
+ path?: string
87
+ }
88
+
89
+ /**
90
+ * Shared context passed as the second argument to `Resolver.resolvePath` and `Resolver.resolveFile`.
91
+ *
92
+ * Describes where on disk output is rooted, which output config is active, and the optional
93
+ * grouping strategy that controls subdirectory layout.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * const context: ResolverContext = {
98
+ * root: config.root,
99
+ * output,
100
+ * group,
101
+ * }
102
+ * ```
103
+ */
104
+ export type ResolverContext = {
105
+ root: string
106
+ output: Output
107
+ group?: Group
108
+ /**
109
+ * Plugin name used to populate `meta.pluginName` on the resolved file.
110
+ */
111
+ pluginName?: string
112
+ }
113
+
114
+ /**
115
+ * File-specific parameters for `Resolver.resolveFile`.
116
+ *
117
+ * Pass alongside a `ResolverContext` to fully describe the file to resolve.
118
+ * `tag` and `path` are used only when a matching `group` is present in the context.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * resolver.resolveFile(
123
+ * { name: 'listPets', extname: '.ts', tag: 'pets' },
124
+ * { root: '/src', output: { path: 'types' }, group: { type: 'tag' } },
125
+ * )
126
+ * // → { baseName: 'listPets.ts', path: '/src/types/pets/listPets.ts', ... }
127
+ * ```
128
+ */
129
+ export type ResolverFileParams = {
130
+ name: string
131
+ extname: FileNode['extname']
132
+ /**
133
+ * Tag value used when `group.type === 'tag'`.
134
+ */
135
+ tag?: string
136
+ /**
137
+ * Path value used when `group.type === 'path'`.
138
+ */
139
+ path?: string
140
+ }
141
+
142
+ /**
143
+ * Per-file context describing the file a banner/footer is being resolved for.
144
+ *
145
+ * Supplied by the generator (or the barrel plugin) at resolve-time and merged
146
+ * into `BannerMeta` so a `banner`/`footer` function can branch on the file kind,
147
+ * e.g. omit a `'use server'` directive on re-export files.
148
+ */
149
+ export type ResolveBannerFile = {
150
+ /**
151
+ * Full output path of the file being generated.
152
+ */
153
+ path: string
154
+ /**
155
+ * File name only, e.g. `'stocks.ts'`.
156
+ */
157
+ baseName: string
158
+ /**
159
+ * `true` for `index.ts` re-export barrels.
160
+ */
161
+ isBarrel?: boolean
162
+ /**
163
+ * `true` for group `[dir]/[dir].ts` aggregation files.
164
+ */
165
+ isAggregation?: boolean
166
+ }
167
+
168
+ /**
169
+ * Document metadata extended with per-file context, passed to a `banner`/`footer` function.
170
+ *
171
+ * Carries everything in {@link InputMeta} plus the file the banner is rendered into, so a
172
+ * single function can decide per file (e.g. skip a directive on barrel/aggregation files).
173
+ *
174
+ * @example Skip a directive on re-export files
175
+ * `banner: (meta) => (meta.isBarrel || meta.isAggregation) ? '' : "'use server'"`
176
+ */
177
+ export type BannerMeta = InputMeta & {
178
+ /**
179
+ * Full output path of the file being generated.
180
+ */
181
+ filePath: string
182
+ /**
183
+ * File name only, e.g. `'stocks.ts'`.
184
+ */
185
+ baseName: string
186
+ /**
187
+ * `true` for `index.ts` re-export barrels.
188
+ */
189
+ isBarrel: boolean
190
+ /**
191
+ * `true` for group `[dir]/[dir].ts` aggregation files.
192
+ */
193
+ isAggregation: boolean
194
+ }
195
+
196
+ /**
197
+ * Context passed to `Resolver.resolveBanner` and `Resolver.resolveFooter`.
198
+ *
199
+ * `output` is optional, since not every plugin configures a banner/footer.
200
+ * `config` carries the global Kubb config, used to derive the default Kubb banner.
201
+ * `file` carries per-file context forwarded to a `banner`/`footer` function.
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * resolver.resolveBanner(meta, { output: { banner: '// generated' }, config })
206
+ * // → '// generated'
207
+ * ```
208
+ */
209
+ export type ResolveBannerContext = {
210
+ output?: Pick<Output, 'banner' | 'footer'>
211
+ config: Config
212
+ file?: ResolveBannerFile
213
+ }
214
+
215
+ /**
216
+ * Merges document `meta` with per-file `file` context into the `BannerMeta` passed to a
217
+ * `banner`/`footer` function. Missing fields default to empty/`false` so the object shape
218
+ * is stable even when a caller (e.g. the barrel plugin) has no document metadata.
219
+ */
220
+ function buildBannerMeta({ meta, file }: { meta: InputMeta | undefined; file: ResolveBannerFile | undefined }): BannerMeta {
221
+ return {
222
+ title: meta?.title,
223
+ description: meta?.description,
224
+ version: meta?.version,
225
+ baseURL: meta?.baseURL,
226
+ circularNames: meta?.circularNames ?? [],
227
+ enumNames: meta?.enumNames ?? [],
228
+ filePath: file?.path ?? '',
229
+ baseName: file?.baseName ?? '',
230
+ isBarrel: file?.isBarrel ?? false,
231
+ isAggregation: file?.isAggregation ?? false,
232
+ }
233
+ }
16
234
 
17
235
  /**
18
236
  * Builder type for the plugin-specific resolver fields.
19
237
  *
20
238
  * `default`, `resolveOptions`, `resolvePath`, `resolveFile`, `resolveBanner`, and `resolveFooter`
21
- * are optional built-in fallbacks are injected when omitted.
239
+ * are optional, with built-in fallbacks injected when omitted.
22
240
  *
23
- * The builder receives `ctx` a reference to the fully assembled resolver so methods can
24
- * call sibling resolver methods without using `this`. Because `ctx` is captured by the closure
25
- * and the resolver is populated after the builder runs, `ctx` correctly reflects any overrides
26
- * that were applied by the builder itself.
241
+ * Methods in the returned object can call sibling resolver methods via `this`.
27
242
  */
28
- type ResolverBuilder<T extends PluginFactoryOptions> = (ctx: T['resolver']) => Omit<
243
+ type ResolverBuilder<T extends PluginFactoryOptions> = () => Omit<
29
244
  T['resolver'],
30
245
  'default' | 'resolveOptions' | 'resolvePath' | 'resolveFile' | 'resolveBanner' | 'resolveFooter' | 'name' | 'pluginName'
31
246
  > &
32
247
  Partial<Pick<T['resolver'], 'default' | 'resolveOptions' | 'resolvePath' | 'resolveFile' | 'resolveBanner' | 'resolveFooter'>> & {
33
248
  name: string
34
249
  pluginName: T['name']
35
- }
250
+ } & ThisType<T['resolver']>
36
251
 
37
- // String patterns are compiled lazily and cached the same filter is reused for every node.
252
+ // String patterns are compiled lazily and cached, so the same filter is reused for every node.
38
253
  const stringPatternCache = new Map<string, RegExp>()
39
254
 
40
255
  function testPattern(value: string, pattern: string | RegExp): boolean {
@@ -54,20 +269,12 @@ function testPattern(value: string, pattern: string | RegExp): boolean {
54
269
  * Checks if an operation matches a pattern for a given filter type (`tag`, `operationId`, `path`, `method`).
55
270
  */
56
271
  function matchesOperationPattern(node: OperationNode, type: string, pattern: string | RegExp): boolean {
57
- switch (type) {
58
- case 'tag':
59
- return node.tags.some((tag) => testPattern(tag, pattern))
60
- case 'operationId':
61
- return testPattern(node.operationId, pattern)
62
- case 'path':
63
- return testPattern(node.path, pattern)
64
- case 'method':
65
- return testPattern(node.method.toLowerCase(), pattern)
66
- case 'contentType':
67
- return node.requestBody?.content?.some((c) => testPattern(c.contentType, pattern)) ?? false
68
- default:
69
- return false
70
- }
272
+ if (type === 'tag') return node.tags.some((tag) => testPattern(tag, pattern))
273
+ if (type === 'operationId') return testPattern(node.operationId, pattern)
274
+ if (type === 'path') return node.path !== undefined && testPattern(node.path, pattern)
275
+ if (type === 'method') return node.method !== undefined && testPattern(node.method.toLowerCase(), pattern)
276
+ if (type === 'contentType') return node.requestBody?.content?.some((c) => testPattern(c.contentType, pattern)) ?? false
277
+ return false
71
278
  }
72
279
 
73
280
  /**
@@ -76,39 +283,25 @@ function matchesOperationPattern(node: OperationNode, type: string, pattern: str
76
283
  * Returns `null` when the filter type doesn't apply to schemas.
77
284
  */
78
285
  function matchesSchemaPattern(node: SchemaNode, type: string, pattern: string | RegExp): boolean | null {
79
- switch (type) {
80
- case 'schemaName':
81
- return node.name ? testPattern(node.name, pattern) : false
82
- default:
83
- return null
84
- }
286
+ if (type === 'schemaName') return node.name ? testPattern(node.name, pattern) : false
287
+ return null
85
288
  }
86
289
 
87
290
  /**
88
291
  * Default name resolver used by `defineResolver`.
89
292
  *
90
- * - `camelCase` for `function` and `file` types.
293
+ * - `camelCase` for `file`, with dotted names split into `/`-joined nested paths.
91
294
  * - `PascalCase` for `type`.
92
- * - `camelCase` for everything else.
295
+ * - `camelCase` for `function` and everything else.
93
296
  */
94
297
  function defaultResolver(name: string, type?: 'file' | 'function' | 'type' | 'const'): string {
95
- let resolvedName = camelCase(name)
96
-
97
- if (type === 'file' || type === 'function') {
98
- resolvedName = camelCase(name, {
99
- isFile: type === 'file',
100
- })
101
- }
102
-
103
- if (type === 'type') {
104
- resolvedName = pascalCase(name)
105
- }
106
-
107
- return resolvedName
298
+ if (type === 'file') return toFilePath(name)
299
+ if (type === 'type') return pascalCase(name)
300
+ return camelCase(name)
108
301
  }
109
302
 
110
303
  /**
111
- * Default option resolver applies include/exclude filters and merges matching override options.
304
+ * Default option resolver. Applies include/exclude filters and merges matching override options.
112
305
  *
113
306
  * Returns `null` when the node is filtered out by an `exclude` rule or not matched by any `include` rule.
114
307
  *
@@ -130,38 +323,32 @@ function defaultResolver(name: string, type?: 'file' | 'function' | 'type' | 'co
130
323
  * // → { enumType: 'enum' } when operationId matches
131
324
  * ```
132
325
  */
133
- export function defaultResolveOptions<TOptions>(
326
+ const resolveOptionsCache = new WeakMap<object, WeakMap<Node, { value: unknown }>>()
327
+
328
+ function computeOptions<TOptions>(
134
329
  node: Node,
135
- { options, exclude = [], include, override = [] }: ResolveOptionsContext<TOptions>,
330
+ options: TOptions,
331
+ exclude: Array<PatternFilter>,
332
+ include: Array<PatternFilter> | undefined,
333
+ override: Array<PatternOverride<TOptions>>,
136
334
  ): TOptions | null {
137
- if (isOperationNode(node)) {
138
- const isExcluded = exclude.some(({ type, pattern }) => matchesOperationPattern(node, type, pattern))
139
- if (isExcluded) {
140
- return null
141
- }
142
-
143
- if (include && !include.some(({ type, pattern }) => matchesOperationPattern(node, type, pattern))) {
144
- return null
145
- }
335
+ if (operationDef.is(node)) {
336
+ if (exclude.some(({ type, pattern }) => matchesOperationPattern(node, type, pattern))) return null
337
+ if (include && !include.some(({ type, pattern }) => matchesOperationPattern(node, type, pattern))) return null
146
338
 
147
339
  const overrideOptions = override.find(({ type, pattern }) => matchesOperationPattern(node, type, pattern))?.options
148
340
 
149
341
  return { ...options, ...overrideOptions }
150
342
  }
151
343
 
152
- if (isSchemaNode(node)) {
153
- if (exclude.some(({ type, pattern }) => matchesSchemaPattern(node, type, pattern) === true)) {
154
- return null
155
- }
156
-
344
+ if (schemaDef.is(node)) {
345
+ if (exclude.some(({ type, pattern }) => matchesSchemaPattern(node, type, pattern) === true)) return null
157
346
  if (include) {
158
347
  const results = include.map(({ type, pattern }) => matchesSchemaPattern(node, type, pattern))
159
- const applicable = results.filter((r) => r !== null)
160
- if (applicable.length > 0 && !applicable.includes(true)) {
161
- return null
162
- }
163
- }
348
+ const applicable = results.filter((result) => result !== null)
164
349
 
350
+ if (applicable.length > 0 && !applicable.includes(true)) return null
351
+ }
165
352
  const overrideOptions = override.find(({ type, pattern }) => matchesSchemaPattern(node, type, pattern) === true)?.options
166
353
 
167
354
  return { ...options, ...overrideOptions }
@@ -170,15 +357,32 @@ export function defaultResolveOptions<TOptions>(
170
357
  return options
171
358
  }
172
359
 
360
+ function defaultResolveOptions<TOptions>(node: Node, { options, exclude = [], include, override = [] }: ResolveOptionsContext<TOptions>): TOptions | null {
361
+ const optionsKey = options as object
362
+ let byOptions = resolveOptionsCache.get(optionsKey)
363
+ if (!byOptions) {
364
+ byOptions = new WeakMap()
365
+ resolveOptionsCache.set(optionsKey, byOptions)
366
+ }
367
+ const cached = byOptions.get(node)
368
+ if (cached !== undefined) return cached.value as TOptions | null
369
+
370
+ const result = computeOptions(node, options, exclude, include, override)
371
+
372
+ byOptions.set(node, { value: result })
373
+
374
+ return result
375
+ }
376
+
173
377
  /**
174
378
  * Default path resolver used by `defineResolver`.
175
379
  *
176
- * - Returns the output directory in `single` mode.
177
- * - Resolves into a tag- or path-based subdirectory when `group` and a `tag`/`path` value are provided.
178
- * - Falls back to a flat `output/baseName` path otherwise.
380
+ * - `mode: 'file'` resolves directly to `output.path` (the full file path, extension included).
381
+ * - `mode: 'directory'` (default) resolves to `output.path/{baseName}`, or into a
382
+ * subdirectory when `group` and a `tag`/`path` value are provided.
179
383
  *
180
384
  * A custom `group.name` function overrides the default subdirectory naming.
181
- * For `tag` groups the default is `${camelCase(tag)}Controller`.
385
+ * For `tag` groups the default is the camelCased tag.
182
386
  * For `path` groups the default is the first path segment after `/`.
183
387
  *
184
388
  * @example Flat output
@@ -193,7 +397,7 @@ export function defaultResolveOptions<TOptions>(
193
397
  * { baseName: 'petTypes.ts', tag: 'pets' },
194
398
  * { root: '/src', output: { path: 'types' }, group: { type: 'tag' } },
195
399
  * )
196
- * // → '/src/types/petsController/petTypes.ts'
400
+ * // → '/src/types/pets/petTypes.ts'
197
401
  * ```
198
402
  *
199
403
  * @example Path-based grouping
@@ -205,53 +409,57 @@ export function defaultResolveOptions<TOptions>(
205
409
  * // → '/src/types/pets/petTypes.ts'
206
410
  * ```
207
411
  *
208
- * @example Single-file mode
412
+ * @example Single file (`mode: 'file'`)
209
413
  * ```ts
210
414
  * defaultResolvePath(
211
- * { baseName: 'petTypes.ts', pathMode: 'single' },
212
- * { root: '/src', output: { path: 'types' } },
415
+ * { baseName: 'petTypes.ts' },
416
+ * { root: '/src', output: { path: 'types.ts', mode: 'file' } },
213
417
  * )
214
- * // → '/src/types'
418
+ * // → '/src/types.ts'
215
419
  * ```
216
420
  */
217
- export function defaultResolvePath({ baseName, pathMode, tag, path: groupPath }: ResolverPathParams, { root, output, group }: ResolverContext): string {
218
- const mode = pathMode ?? PluginDriver.getMode(path.resolve(root, output.path))
421
+ export function defaultResolvePath({ baseName, tag, path: groupPath }: ResolverPathParams, { root, output, group }: ResolverContext): string {
422
+ const mode = output.mode ?? 'directory'
219
423
 
220
- if (mode === 'single') {
424
+ if (mode === 'file') {
221
425
  return path.resolve(root, output.path)
222
426
  }
223
427
 
224
- let result: string
225
-
226
- if (group && (groupPath || tag)) {
227
- const groupValue = group.type === 'path' ? groupPath! : tag!
228
- const defaultName =
229
- group.type === 'tag'
230
- ? ({ group: g }: { group: string }) => `${camelCase(g)}Controller`
231
- : ({ group: g }: { group: string }) => {
232
- // Strip traversal components (empty, '.', '..') before taking the first meaningful segment.
233
- // When every segment is a traversal component (e.g. '../../') we fall back to '' so the
234
- // file is placed directly in the output root the boundary check below ensures safety.
235
- const segment = g.split('/').filter((s) => s !== '' && s !== '.' && s !== '..')[0]
236
- return segment ? camelCase(segment) : ''
237
- }
238
- const resolveName = group.name ?? defaultName
239
- result = path.resolve(root, output.path, resolveName({ group: groupValue }), baseName)
240
- } else {
241
- result = path.resolve(root, output.path, baseName)
242
- }
428
+ const result: string = (() => {
429
+ if (group && (groupPath || tag)) {
430
+ const groupValue = group.type === 'path' ? groupPath! : tag!
431
+ const defaultName =
432
+ group.type === 'tag'
433
+ ? ({ group: groupName }: { group: string }) => camelCase(groupName)
434
+ : ({ group: groupName }: { group: string }) => {
435
+ // Strip traversal components (empty, '.', '..') before taking the first meaningful segment.
436
+ // When every segment is a traversal component (e.g. '../../') we fall back to '' so the
437
+ // file is placed directly in the output root, and the boundary check below ensures safety.
438
+ const segment = groupName.split('/').filter((part) => part !== '' && part !== '.' && part !== '..')[0]
439
+ return segment ? camelCase(segment) : ''
440
+ }
441
+ const resolveName = group.name ?? defaultName
442
+ const groupName = resolveName({ group: groupValue })
443
+
444
+ return path.resolve(root, output.path, groupName, baseName)
445
+ }
446
+ return path.resolve(root, output.path, baseName)
447
+ })()
243
448
 
244
449
  // Ensure the resolved path stays within the configured output directory.
245
450
  // This prevents path traversal from malicious OpenAPI specs or custom group.name functions.
246
- // `result === outputDir` is intentionally permitted: it matches single-file mode paths and
247
- // edge cases where baseName resolves to the output directory itself.
451
+ // `result === outputDir` is intentionally permitted: it matches edge cases where baseName
452
+ // resolves to the output directory itself.
248
453
  const outputDir = path.resolve(root, output.path)
249
454
  const outputDirWithSep = outputDir.endsWith(path.sep) ? outputDir : `${outputDir}${path.sep}`
250
455
  if (result !== outputDir && !result.startsWith(outputDirWithSep)) {
251
- throw new Error(
252
- `[Kubb] Resolved path "${result}" is outside the output directory "${outputDir}". ` +
253
- 'This may indicate a path traversal attempt in the OpenAPI specification or a misconfigured group.name function.',
254
- )
456
+ throw new Diagnostics.Error({
457
+ code: Diagnostics.code.pathTraversal,
458
+ severity: 'error',
459
+ message: `Resolved path "${result}" is outside the output directory "${outputDir}".`,
460
+ help: 'This can stem from a path traversal in the OpenAPI specification or a misconfigured `group.name` function. Keep generated paths within the output directory.',
461
+ location: { kind: 'config' },
462
+ })
255
463
  }
256
464
 
257
465
  return result
@@ -262,41 +470,41 @@ export function defaultResolvePath({ baseName, pathMode, tag, path: groupPath }:
262
470
  *
263
471
  * Resolves a `FileNode` by combining name resolution (`resolver.default`) with
264
472
  * path resolution (`resolver.resolvePath`). The resolved file always has empty
265
- * `sources`, `imports`, and `exports` arrays consumers populate those separately.
473
+ * `sources`, `imports`, and `exports` arrays, which consumers populate separately.
266
474
  *
267
- * In `single` mode the name is omitted and the file sits directly in the output directory.
475
+ * In `mode: 'file'` the name is omitted and the file sits directly at the output path.
268
476
  *
269
477
  * @example Resolve a schema file
270
478
  * ```ts
271
- * const file = defaultResolveFile(
479
+ * const file = defaultResolveFile.call(
480
+ * resolver,
272
481
  * { name: 'pet', extname: '.ts' },
273
482
  * { root: '/src', output: { path: 'types' } },
274
- * resolver,
275
483
  * )
276
484
  * // → { baseName: 'pet.ts', path: '/src/types/pet.ts', sources: [], ... }
277
485
  * ```
278
486
  *
279
487
  * @example Resolve an operation file with tag grouping
280
488
  * ```ts
281
- * const file = defaultResolveFile(
489
+ * const file = defaultResolveFile.call(
490
+ * resolver,
282
491
  * { name: 'listPets', extname: '.ts', tag: 'pets' },
283
492
  * { root: '/src', output: { path: 'types' }, group: { type: 'tag' } },
284
- * resolver,
285
493
  * )
286
- * // → { baseName: 'listPets.ts', path: '/src/types/petsController/listPets.ts', ... }
494
+ * // → { baseName: 'listPets.ts', path: '/src/types/pets/listPets.ts', ... }
287
495
  * ```
288
496
  */
289
- export function defaultResolveFile({ name, extname, tag, path: groupPath }: ResolverFileParams, context: ResolverContext, ctx: Resolver): FileNode {
290
- const pathMode = PluginDriver.getMode(path.resolve(context.root, context.output.path))
291
- const resolvedName = pathMode === 'single' ? '' : ctx.default(name, 'file')
497
+ export function defaultResolveFile(this: Resolver, { name, extname, tag, path: groupPath }: ResolverFileParams, context: ResolverContext): FileNode {
498
+ const mode = context.output.mode ?? 'directory'
499
+ const resolvedName = mode === 'file' ? '' : this.default(name, 'file')
292
500
  const baseName = `${resolvedName}${extname}` as FileNode['baseName']
293
- const filePath = ctx.resolvePath({ baseName, pathMode, tag, path: groupPath }, context)
501
+ const filePath = this.resolvePath({ baseName, tag, path: groupPath }, context)
294
502
 
295
- return createFile({
503
+ return factory.createFile({
296
504
  path: filePath,
297
505
  baseName: path.basename(filePath) as `${string}.${string}`,
298
506
  meta: {
299
- pluginName: ctx.pluginName,
507
+ pluginName: this.pluginName,
300
508
  },
301
509
  sources: [],
302
510
  imports: [],
@@ -307,29 +515,18 @@ export function defaultResolveFile({ name, extname, tag, path: groupPath }: Reso
307
515
  /**
308
516
  * Generates the default "Generated by Kubb" banner from config and optional node metadata.
309
517
  */
310
- export function buildDefaultBanner({
311
- title,
312
- description,
313
- version,
314
- config,
315
- }: {
316
- title?: string
317
- description?: string
318
- version?: string
319
- config: Config
320
- }): string {
518
+ function buildDefaultBanner({ title, description, version, config }: { title?: string; description?: string; version?: string; config: Config }): string {
321
519
  try {
322
- let source = ''
323
- if (Array.isArray(config.input)) {
324
- const first = config.input[0]
325
- if (first && 'path' in first) {
326
- source = path.basename(first.path)
520
+ const source = (() => {
521
+ if (Array.isArray(config.input)) {
522
+ const first = config.input[0]
523
+ if (first && 'path' in first) return path.basename(first.path)
524
+ return ''
327
525
  }
328
- } else if (config.input && 'path' in config.input) {
329
- source = path.basename(config.input.path)
330
- } else if (config.input && 'data' in config.input) {
331
- source = 'text content'
332
- }
526
+ if (config.input && 'path' in config.input) return path.basename(config.input.path)
527
+ if (config.input && 'data' in config.input) return 'text content'
528
+ return ''
529
+ })()
333
530
 
334
531
  let banner = '/**\n* Generated by Kubb (https://kubb.dev/).\n* Do not edit manually.\n'
335
532
 
@@ -363,14 +560,13 @@ export function buildDefaultBanner({
363
560
  }
364
561
 
365
562
  /**
366
- * Default banner resolver returns the banner string for a generated file.
563
+ * Default banner resolver. Returns the banner string for a generated file.
367
564
  *
368
565
  * A user-supplied `output.banner` overrides the default Kubb "Generated by Kubb" notice.
369
566
  * When no `output.banner` is set, the Kubb notice is used (including `title` and `version`
370
- * from the OAS spec when a `node` is provided).
567
+ * from the document metadata when `meta` is provided).
371
568
  *
372
- * - When `output.banner` is a function and `node` is provided, returns `output.banner(node)`.
373
- * - When `output.banner` is a function and `node` is absent, falls back to the Kubb notice.
569
+ * - When `output.banner` is a function, calls it with the file's `BannerMeta` and returns the result.
374
570
  * - When `output.banner` is a string, returns it directly.
375
571
  * - When `config.output.defaultBanner` is `false`, returns `undefined`.
376
572
  * - Otherwise returns the Kubb "Generated by Kubb" notice.
@@ -381,27 +577,33 @@ export function buildDefaultBanner({
381
577
  * // → '// my banner'
382
578
  * ```
383
579
  *
384
- * @example Function banner with node
580
+ * @example Function banner with metadata
385
581
  * ```ts
386
- * defaultResolveBanner(inputNode, { output: { banner: (node) => `// v${node.version}` }, config })
582
+ * defaultResolveBanner(meta, { output: { banner: (m) => `// v${m.version}` }, config })
387
583
  * // → '// v3.0.0'
388
584
  * ```
389
585
  *
390
- * @example No user banner Kubb notice with OAS metadata
586
+ * @example Function banner skips re-export files
587
+ * ```ts
588
+ * defaultResolveBanner(meta, { output: { banner: (m) => (m.isBarrel ? '' : "'use server'") }, config, file: { path, baseName, isBarrel: true } })
589
+ * // → ''
590
+ * ```
591
+ *
592
+ * @example No user banner, Kubb notice with OAS metadata
391
593
  * ```ts
392
- * defaultResolveBanner(inputNode, { config })
594
+ * defaultResolveBanner(meta, { config })
393
595
  * // → '/** Generated by Kubb ... Title: Pet Store ... *\/'
394
596
  * ```
395
597
  *
396
598
  * @example Disabled default banner
397
599
  * ```ts
398
600
  * defaultResolveBanner(undefined, { config: { output: { defaultBanner: false }, ...config } })
399
- * // → undefined
601
+ * // → null
400
602
  * ```
401
603
  */
402
- export function defaultResolveBanner(node: InputNode | undefined, { output, config }: ResolveBannerContext): string | undefined {
604
+ export function defaultResolveBanner(meta: InputMeta | undefined, { output, config, file }: ResolveBannerContext): string | null {
403
605
  if (typeof output?.banner === 'function') {
404
- return output.banner(node)
606
+ return output.banner(buildBannerMeta({ meta, file }))
405
607
  }
406
608
 
407
609
  if (typeof output?.banner === 'string') {
@@ -409,21 +611,20 @@ export function defaultResolveBanner(node: InputNode | undefined, { output, conf
409
611
  }
410
612
 
411
613
  if (config.output.defaultBanner === false) {
412
- return undefined
614
+ return null
413
615
  }
414
616
 
415
617
  return buildDefaultBanner({
416
- title: node?.meta?.title,
417
- version: node?.meta?.version,
618
+ title: meta?.title,
619
+ version: meta?.version,
418
620
  config,
419
621
  })
420
622
  }
421
623
 
422
624
  /**
423
- * Default footer resolver returns the footer string for a generated file.
625
+ * Default footer resolver. Returns the footer string for a generated file.
424
626
  *
425
- * - When `output.footer` is a function and `node` is provided, calls it with the node.
426
- * - When `output.footer` is a function and `node` is absent, returns `undefined`.
627
+ * - When `output.footer` is a function, calls it with the file's `BannerMeta` and returns the result.
427
628
  * - When `output.footer` is a string, returns it directly.
428
629
  * - Otherwise returns `undefined`.
429
630
  *
@@ -433,89 +634,78 @@ export function defaultResolveBanner(node: InputNode | undefined, { output, conf
433
634
  * // → '// end of file'
434
635
  * ```
435
636
  *
436
- * @example Function footer with node
637
+ * @example Function footer with metadata
437
638
  * ```ts
438
- * defaultResolveFooter(inputNode, { output: { footer: (node) => `// ${node.title}` }, config })
639
+ * defaultResolveFooter(meta, { output: { footer: (m) => `// ${m.title}` }, config })
439
640
  * // → '// Pet Store'
440
641
  * ```
441
642
  */
442
- export function defaultResolveFooter(node: InputNode | undefined, { output }: ResolveBannerContext): string | undefined {
643
+ export function defaultResolveFooter(meta: InputMeta | undefined, { output, file }: ResolveBannerContext): string | null {
443
644
  if (typeof output?.footer === 'function') {
444
- return node ? output.footer(node) : undefined
645
+ return output.footer(buildBannerMeta({ meta, file }))
445
646
  }
446
647
  if (typeof output?.footer === 'string') {
447
648
  return output.footer
448
649
  }
449
- return undefined
650
+ return null
450
651
  }
451
652
 
452
653
  /**
453
- * Defines a resolver for a plugin, injecting built-in defaults for name casing,
454
- * include/exclude/override filtering, path resolution, and file construction.
654
+ * Defines a plugin resolver. The resolver is the object that decides what
655
+ * every generated symbol and file path is called. Built-in defaults handle
656
+ * name casing, include/exclude/override filtering, output path computation,
657
+ * and file construction. Supply your own to override any of them:
455
658
  *
456
- * All four defaults can be overridden by providing them in the builder function:
457
- * - `default` name casing strategy (camelCase / PascalCase)
458
- * - `resolveOptions` include/exclude/override filtering
459
- * - `resolvePath` output path computation
460
- * - `resolveFile` full `FileNode` construction
659
+ * - `default` sets the name casing strategy (camelCase or PascalCase).
660
+ * - `resolveOptions` does include/exclude/override filtering.
661
+ * - `resolvePath` computes the output path.
662
+ * - `resolveFile` builds the full `FileNode`.
663
+ * - `resolveBanner` and `resolveFooter` produce the top and bottom of file text.
461
664
  *
462
- * The builder receives `ctx` a reference to the assembled resolver so methods can
463
- * call sibling resolver methods using `ctx` instead of `this`.
665
+ * Methods in the returned object can call sibling resolver methods via `this`.
666
+ * A custom rule can delegate to a default, for example `this.default(name, 'type')`.
464
667
  *
465
668
  * @example Basic resolver with naming helpers
466
669
  * ```ts
467
- * export const resolver = defineResolver<PluginTs>((ctx) => ({
670
+ * export const resolverTs = defineResolver<PluginTs>(() => ({
468
671
  * name: 'default',
469
- * resolveName(node) {
470
- * return ctx.default(node.name, 'function')
672
+ * resolveName(name) {
673
+ * return this.default(name, 'function')
471
674
  * },
472
- * resolveTypedName(node) {
473
- * return ctx.default(node.name, 'type')
675
+ * resolveTypeName(name) {
676
+ * return this.default(name, 'type')
474
677
  * },
475
678
  * }))
476
679
  * ```
477
680
  *
478
- * @example Override resolvePath for a custom output structure
681
+ * @example Custom output path
479
682
  * ```ts
480
- * export const resolver = defineResolver<PluginTs>((_ctx) => ({
683
+ * import path from 'node:path'
684
+ *
685
+ * export const resolverTs = defineResolver<PluginTs>(() => ({
481
686
  * name: 'custom',
482
687
  * resolvePath({ baseName }, { root, output }) {
483
688
  * return path.resolve(root, output.path, 'generated', baseName)
484
689
  * },
485
690
  * }))
486
691
  * ```
487
- *
488
- * @example Use ctx.default inside a helper
489
- * ```ts
490
- * export const resolver = defineResolver<PluginTs>((ctx) => ({
491
- * name: 'default',
492
- * resolveParamName(node, param) {
493
- * return ctx.default(`${node.operationId} ${param.in} ${param.name}`, 'type')
494
- * },
495
- * }))
496
- * ```
497
692
  */
498
693
  export function defineResolver<T extends PluginFactoryOptions>(build: ResolverBuilder<T>): T['resolver'] {
499
- // Create the resolver shell first. When `build(resolver)` executes below, `resolver` is
500
- // still empty, but methods returned by the builder capture it by reference. By the time
501
- // those methods are actually called, `Object.assign` will have already populated all
502
- // properties (including any overrides from the builder itself).
503
- const resolver = {} as T['resolver']
694
+ // `resolver` is kept so the default `resolveFile` wrapper can reference the fully assembled
695
+ // object via `.call(resolver, ...)` at call-time, after the result is assigned below.
696
+ let resolver: T['resolver']
504
697
 
505
- Object.assign(resolver, {
698
+ const result = {
506
699
  default: defaultResolver,
507
700
  resolveOptions: defaultResolveOptions,
508
701
  resolvePath: defaultResolvePath,
509
- // Wire the default resolveFile implementation with a wrapper that passes resolver as ctx.
510
- // Unlike other defaults which can be assigned directly, defaultResolveFile requires the
511
- // resolver as its third parameter.
512
- resolveFile: (params: ResolverFileParams, context: ResolverContext) => defaultResolveFile(params, context, resolver as Resolver),
702
+ resolveFile: (params: ResolverFileParams, context: ResolverContext) => defaultResolveFile.call(resolver as Resolver, params, context),
513
703
  resolveBanner: defaultResolveBanner,
514
704
  resolveFooter: defaultResolveFooter,
515
- // Builder overrides are applied last. Any method in the builder can call
516
- // ctx.xxx() and will see the fully merged resolver (including its own overrides).
517
- ...build(resolver),
518
- })
705
+ ...build(),
706
+ } as T['resolver']
707
+
708
+ resolver = result
519
709
 
520
710
  return resolver
521
711
  }