@kubb/core 5.0.0-alpha.9 → 5.0.0-beta.10

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 (62) hide show
  1. package/README.md +13 -40
  2. package/dist/PluginDriver-Cu1Kj9S-.cjs +1075 -0
  3. package/dist/PluginDriver-Cu1Kj9S-.cjs.map +1 -0
  4. package/dist/PluginDriver-D8Z0Htid.js +978 -0
  5. package/dist/PluginDriver-D8Z0Htid.js.map +1 -0
  6. package/dist/createKubb-ALdb8lmq.d.ts +2082 -0
  7. package/dist/index.cjs +747 -1667
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.ts +175 -269
  10. package/dist/index.js +734 -1638
  11. package/dist/index.js.map +1 -1
  12. package/dist/mocks.cjs +145 -0
  13. package/dist/mocks.cjs.map +1 -0
  14. package/dist/mocks.d.ts +80 -0
  15. package/dist/mocks.js +140 -0
  16. package/dist/mocks.js.map +1 -0
  17. package/package.json +47 -60
  18. package/src/FileManager.ts +115 -0
  19. package/src/FileProcessor.ts +86 -0
  20. package/src/PluginDriver.ts +355 -561
  21. package/src/constants.ts +21 -48
  22. package/src/createAdapter.ts +88 -5
  23. package/src/createKubb.ts +1266 -0
  24. package/src/createRenderer.ts +57 -0
  25. package/src/createStorage.ts +13 -1
  26. package/src/defineGenerator.ts +160 -119
  27. package/src/defineLogger.ts +46 -5
  28. package/src/defineMiddleware.ts +62 -0
  29. package/src/defineParser.ts +44 -0
  30. package/src/definePlugin.ts +379 -0
  31. package/src/defineResolver.ts +548 -25
  32. package/src/devtools.ts +22 -15
  33. package/src/index.ts +13 -15
  34. package/src/mocks.ts +177 -0
  35. package/src/storages/fsStorage.ts +13 -8
  36. package/src/storages/memoryStorage.ts +4 -2
  37. package/src/types.ts +40 -547
  38. package/dist/PluginDriver-BkFepPdm.d.ts +0 -1054
  39. package/dist/chunk-ByKO4r7w.cjs +0 -38
  40. package/dist/hooks.cjs +0 -103
  41. package/dist/hooks.cjs.map +0 -1
  42. package/dist/hooks.d.ts +0 -77
  43. package/dist/hooks.js +0 -98
  44. package/dist/hooks.js.map +0 -1
  45. package/src/Kubb.ts +0 -224
  46. package/src/build.ts +0 -418
  47. package/src/config.ts +0 -56
  48. package/src/createPlugin.ts +0 -28
  49. package/src/hooks/index.ts +0 -4
  50. package/src/hooks/useKubb.ts +0 -143
  51. package/src/hooks/useMode.ts +0 -11
  52. package/src/hooks/usePlugin.ts +0 -11
  53. package/src/hooks/usePluginDriver.ts +0 -11
  54. package/src/utils/FunctionParams.ts +0 -155
  55. package/src/utils/TreeNode.ts +0 -215
  56. package/src/utils/diagnostics.ts +0 -15
  57. package/src/utils/executeStrategies.ts +0 -81
  58. package/src/utils/formatters.ts +0 -56
  59. package/src/utils/getBarrelFiles.ts +0 -141
  60. package/src/utils/getConfigs.ts +0 -12
  61. package/src/utils/linters.ts +0 -25
  62. package/src/utils/packageJSON.ts +0 -61
@@ -1,663 +1,457 @@
1
- import { basename, extname, resolve } from 'node:path'
2
- import { performance } from 'node:perf_hooks'
1
+ import { resolve } from 'node:path'
3
2
  import type { AsyncEventEmitter } from '@internals/utils'
4
- import { isPromiseRejectedResult, setUniqueName, transformReservedWord, ValidationPluginError } from '@internals/utils'
5
- import type { RootNode } from '@kubb/ast/types'
6
- import type { Fabric as FabricType, KubbFile } from '@kubb/fabric-core/types'
7
- import { CORE_PLUGIN_NAME, DEFAULT_STUDIO_URL } from './constants.ts'
3
+ import type { FileNode, InputNode, OperationNode, SchemaNode } from '@kubb/ast'
4
+ import { createFile } from '@kubb/ast'
5
+ import { DEFAULT_STUDIO_URL } from './constants.ts'
6
+ import type { Generator } from './defineGenerator.ts'
7
+ import type { Plugin } from './definePlugin.ts'
8
+ import { getMode } from './definePlugin.ts'
9
+ import { defineResolver } from './defineResolver.ts'
8
10
  import { openInStudio as openInStudioFn } from './devtools.ts'
11
+ import { FileManager } from './FileManager.ts'
12
+ import type { RendererFactory } from './createRenderer.ts'
9
13
 
10
14
  import type {
11
15
  Adapter,
12
16
  Config,
13
17
  DevtoolsOptions,
14
- KubbEvents,
15
- Plugin,
16
- PluginContext,
18
+ GeneratorContext,
19
+ KubbHooks,
20
+ KubbPluginSetupContext,
21
+ NormalizedPlugin,
17
22
  PluginFactoryOptions,
18
- PluginLifecycle,
19
- PluginLifecycleHooks,
20
- PluginParameter,
21
- PluginWithLifeCycle,
22
- ResolveNameParams,
23
- ResolvePathParams,
24
- UserPlugin,
23
+ Resolver,
25
24
  } from './types.ts'
26
- import { hookFirst, hookParallel, hookSeq } from './utils/executeStrategies.ts'
27
-
28
- type RequiredPluginLifecycle = Required<PluginLifecycle>
29
-
30
- export type Strategy = 'hookFirst' | 'hookForPlugin' | 'hookParallel' | 'hookSeq'
31
-
32
- type ParseResult<H extends PluginLifecycleHooks> = RequiredPluginLifecycle[H]
33
-
34
- type SafeParseResult<H extends PluginLifecycleHooks, Result = ReturnType<ParseResult<H>>> = {
35
- result: Result
36
- plugin: Plugin
37
- }
38
25
 
39
26
  // inspired by: https://github.com/rollup/rollup/blob/master/src/utils/PluginDriver.ts#
40
27
 
41
28
  type Options = {
42
- fabric: FabricType
43
- events: AsyncEventEmitter<KubbEvents>
44
- /**
45
- * @default Number.POSITIVE_INFINITY
46
- */
47
- concurrency?: number
29
+ hooks: AsyncEventEmitter<KubbHooks>
48
30
  }
49
31
 
50
- export type GetFileOptions<TOptions = object> = {
51
- name: string
52
- mode?: KubbFile.Mode
53
- extname: KubbFile.Extname
54
- pluginName: string
55
- options?: TOptions
32
+ function enforceOrder(enforce: 'pre' | 'post' | undefined): number {
33
+ return enforce === 'pre' ? -1 : enforce === 'post' ? 1 : 0
56
34
  }
57
35
 
58
- export function getMode(fileOrFolder: string | undefined | null): KubbFile.Mode {
59
- if (!fileOrFolder) {
60
- return 'split'
61
- }
62
- return extname(fileOrFolder) ? 'single' : 'split'
63
- }
64
-
65
- const hookFirstNullCheck = (state: unknown) => !!(state as SafeParseResult<'resolveName'> | null)?.result
66
-
67
36
  export class PluginDriver {
68
37
  readonly config: Config
69
38
  readonly options: Options
70
39
 
71
40
  /**
72
- * The universal `@kubb/ast` `RootNode` produced by the adapter, set by
41
+ * Returns `'single'` when `fileOrFolder` has a file extension, `'split'` otherwise.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * PluginDriver.getMode('src/gen/types.ts') // 'single'
46
+ * PluginDriver.getMode('src/gen/types') // 'split'
47
+ * ```
48
+ */
49
+ static getMode(fileOrFolder: string | undefined | null): 'single' | 'split' {
50
+ return getMode(fileOrFolder)
51
+ }
52
+
53
+ /**
54
+ * The universal `@kubb/ast` `InputNode` produced by the adapter, set by
73
55
  * the build pipeline after the adapter's `parse()` resolves.
74
56
  */
75
- rootNode: RootNode | undefined = undefined
57
+ inputNode: InputNode | undefined = undefined
76
58
  adapter: Adapter | undefined = undefined
77
59
  #studioIsOpen = false
78
60
 
79
- readonly #plugins = new Set<Plugin>()
80
- readonly #usedPluginNames: Record<string, number> = {}
61
+ /**
62
+ * Central file store for all generated files.
63
+ * Plugins should use `this.addFile()` / `this.upsertFile()` (via their context) to
64
+ * add files; this property gives direct read/write access when needed.
65
+ */
66
+ readonly fileManager = new FileManager()
67
+
68
+ readonly plugins = new Map<string, NormalizedPlugin>()
69
+
70
+ /**
71
+ * Tracks which plugins have generators registered via `addGenerator()` (event-based path).
72
+ * Used by the build loop to decide whether to emit generator events for a given plugin.
73
+ */
74
+ readonly #pluginsWithEventGenerators = new Set<string>()
75
+ readonly #resolvers = new Map<string, Resolver>()
76
+ readonly #defaultResolvers = new Map<string, Resolver>()
77
+ readonly #hookListeners = new Map<keyof KubbHooks, Set<(...args: never[]) => void | Promise<void>>>()
81
78
 
82
79
  constructor(config: Config, options: Options) {
83
80
  this.config = config
84
81
  this.options = options
85
- ;[...(config.plugins || [])].forEach((plugin) => {
86
- const parsedPlugin = this.#parse(plugin as UserPlugin)
87
-
88
- this.#plugins.add(parsedPlugin)
89
- })
90
- }
91
-
92
- get events() {
93
- return this.options.events
94
- }
95
-
96
- getContext<TOptions extends PluginFactoryOptions>(plugin: Plugin<TOptions>): PluginContext<TOptions> & Record<string, unknown> {
97
- const plugins = [...this.#plugins]
98
- const driver = this
99
-
100
- const baseContext = {
101
- fabric: this.options.fabric,
102
- config: this.config,
103
- plugin,
104
- events: this.options.events,
105
- driver: this,
106
- mode: getMode(resolve(this.config.root, this.config.output.path)),
107
- addFile: async (...files: Array<KubbFile.File>) => {
108
- await this.options.fabric.addFile(...files)
109
- },
110
- upsertFile: async (...files: Array<KubbFile.File>) => {
111
- await this.options.fabric.upsertFile(...files)
112
- },
113
- get rootNode(): RootNode | undefined {
114
- return driver.rootNode
115
- },
116
- get adapter(): Adapter | undefined {
117
- return driver.adapter
118
- },
119
- openInStudio(options?: DevtoolsOptions) {
120
- if (!driver.config.devtools || driver.#studioIsOpen) {
121
- return
122
- }
123
-
124
- if (typeof driver.config.devtools !== 'object') {
125
- throw new Error('Devtools must be an object')
126
- }
127
-
128
- if (!driver.rootNode || !driver.adapter) {
129
- throw new Error('adapter is not defined, make sure you have set the parser in kubb.config.ts')
82
+ config.plugins
83
+ .map((rawPlugin) => this.#normalizePlugin(rawPlugin as Plugin))
84
+ .filter((plugin) => {
85
+ if (typeof plugin.apply === 'function') {
86
+ return plugin.apply(config)
130
87
  }
131
-
132
- driver.#studioIsOpen = true
133
-
134
- const studioUrl = driver.config.devtools?.studioUrl ?? DEFAULT_STUDIO_URL
135
-
136
- return openInStudioFn(driver.rootNode, studioUrl, options)
137
- },
138
- } as unknown as PluginContext<TOptions>
139
-
140
- const mergedExtras: Record<string, unknown> = {}
141
- for (const p of plugins) {
142
- if (typeof p.inject === 'function') {
143
- const result = (p.inject as (this: PluginContext, context: PluginContext) => unknown).call(
144
- baseContext as unknown as PluginContext,
145
- baseContext as unknown as PluginContext,
146
- )
147
- if (result !== null && typeof result === 'object') {
148
- Object.assign(mergedExtras, result)
149
- }
150
- }
151
- }
152
-
153
- return {
154
- ...baseContext,
155
- ...mergedExtras,
156
- }
157
- }
158
-
159
- get plugins(): Array<Plugin> {
160
- return this.#getSortedPlugins()
161
- }
162
-
163
- getFile<TOptions = object>({ name, mode, extname, pluginName, options }: GetFileOptions<TOptions>): KubbFile.File<{ pluginName: string }> {
164
- const resolvedName = mode ? (mode === 'single' ? '' : this.resolveName({ name, pluginName, type: 'file' })) : name
165
-
166
- const path = this.resolvePath({
167
- baseName: `${resolvedName}${extname}` as const,
168
- mode,
169
- pluginName,
170
- options,
171
- })
172
-
173
- if (!path) {
174
- throw new Error(`Filepath should be defined for resolvedName "${resolvedName}" and pluginName "${pluginName}"`)
175
- }
176
-
177
- return {
178
- path,
179
- baseName: basename(path) as KubbFile.File['baseName'],
180
- meta: {
181
- pluginName,
182
- },
183
- sources: [],
184
- imports: [],
185
- exports: [],
186
- }
187
- }
188
-
189
- resolvePath = <TOptions = object>(params: ResolvePathParams<TOptions>): KubbFile.Path => {
190
- const root = resolve(this.config.root, this.config.output.path)
191
- const defaultPath = resolve(root, params.baseName)
192
-
193
- if (params.pluginName) {
194
- const paths = this.hookForPluginSync({
195
- pluginName: params.pluginName,
196
- hookName: 'resolvePath',
197
- parameters: [params.baseName, params.mode, params.options as object],
88
+ return true
198
89
  })
199
-
200
- return paths?.at(0) || defaultPath
201
- }
202
-
203
- const firstResult = this.hookFirstSync({
204
- hookName: 'resolvePath',
205
- parameters: [params.baseName, params.mode, params.options as object],
206
- })
207
-
208
- return firstResult?.result || defaultPath
209
- }
210
- //TODO refactor by using the order of plugins and the cache of the fileManager instead of guessing and recreating the name/path
211
- resolveName = (params: ResolveNameParams): string => {
212
- if (params.pluginName) {
213
- const names = this.hookForPluginSync({
214
- pluginName: params.pluginName,
215
- hookName: 'resolveName',
216
- parameters: [params.name.trim(), params.type],
90
+ .sort((a, b) => {
91
+ if (b.dependencies?.includes(a.name)) return -1
92
+ if (a.dependencies?.includes(b.name)) return 1
93
+ // enforce: 'pre' plugins run first, 'post' plugins run last
94
+ return enforceOrder(a.enforce) - enforceOrder(b.enforce)
217
95
  })
96
+ .forEach((plugin) => {
97
+ this.plugins.set(plugin.name, plugin)
98
+ })
99
+ }
218
100
 
219
- const uniqueNames = new Set(names)
220
-
221
- return transformReservedWord([...uniqueNames].at(0) || params.name)
222
- }
223
-
224
- const name = this.hookFirstSync({
225
- hookName: 'resolveName',
226
- parameters: [params.name.trim(), params.type],
227
- })?.result
228
-
229
- return transformReservedWord(name ?? params.name)
101
+ get hooks() {
102
+ return this.options.hooks
230
103
  }
231
104
 
232
105
  /**
233
- * Run a specific hookName for plugin x.
106
+ * Creates an `NormalizedPlugin` from a hook-style plugin and registers
107
+ * its lifecycle handlers on the `AsyncEventEmitter`.
234
108
  */
235
- async hookForPlugin<H extends PluginLifecycleHooks>({
236
- pluginName,
237
- hookName,
238
- parameters,
239
- }: {
240
- pluginName: string
241
- hookName: H
242
- parameters: PluginParameter<H>
243
- }): Promise<Array<ReturnType<ParseResult<H>> | null>> {
244
- const plugins = this.getPluginsByName(hookName, pluginName)
245
-
246
- this.events.emit('plugins:hook:progress:start', {
247
- hookName,
248
- plugins,
249
- })
250
-
251
- const items: Array<ReturnType<ParseResult<H>>> = []
252
-
253
- for (const plugin of plugins) {
254
- const result = await this.#execute<H>({
255
- strategy: 'hookFirst',
256
- hookName,
257
- parameters,
258
- plugin,
259
- })
109
+ #normalizePlugin(hookPlugin: Plugin): NormalizedPlugin {
110
+ const normalizedPlugin = {
111
+ name: hookPlugin.name,
112
+ dependencies: hookPlugin.dependencies,
113
+ enforce: hookPlugin.enforce,
114
+ options: { output: { path: '.' }, exclude: [], override: [] },
115
+ } as unknown as NormalizedPlugin
116
+
117
+ this.registerPluginHooks(hookPlugin, normalizedPlugin)
118
+ return normalizedPlugin
119
+ }
260
120
 
261
- if (result !== undefined && result !== null) {
262
- items.push(result)
121
+ /**
122
+ * Registers a hook-style plugin's lifecycle handlers on the shared `AsyncEventEmitter`.
123
+ *
124
+ * For `kubb:plugin:setup`, the registered listener wraps the globally emitted context with a
125
+ * plugin-specific one so that `addGenerator`, `setResolver`, `setTransformer`, and
126
+ * `setRenderer` all target the correct `normalizedPlugin` entry in the plugins map.
127
+ *
128
+ * All other hooks are iterated and registered directly as pass-through listeners.
129
+ * Any event key present in the global `KubbHooks` interface can be subscribed to.
130
+ *
131
+ * External tooling can subscribe to any of these events via `hooks.on(...)` to observe
132
+ * the plugin lifecycle without modifying plugin behavior.
133
+ *
134
+ * @internal
135
+ */
136
+ registerPluginHooks(hookPlugin: Plugin, normalizedPlugin: NormalizedPlugin): void {
137
+ const { hooks } = hookPlugin
138
+
139
+ if (!hooks) return
140
+
141
+ // kubb:plugin:setup gets special treatment: the globally emitted context is wrapped with
142
+ // plugin-specific implementations so that addGenerator / setResolver / etc. target
143
+ // this plugin's normalizedPlugin entry rather than being no-ops.
144
+ if (hooks['kubb:plugin:setup']) {
145
+ const setupHandler = (globalCtx: KubbPluginSetupContext) => {
146
+ const pluginCtx: KubbPluginSetupContext = {
147
+ ...globalCtx,
148
+ options: hookPlugin.options ?? {},
149
+ addGenerator: (gen) => {
150
+ this.registerGenerator(normalizedPlugin.name, gen)
151
+ },
152
+ setResolver: (resolver) => {
153
+ this.setPluginResolver(normalizedPlugin.name, resolver)
154
+ },
155
+ setTransformer: (visitor) => {
156
+ normalizedPlugin.transformer = visitor
157
+ },
158
+ setRenderer: (renderer) => {
159
+ normalizedPlugin.renderer = renderer
160
+ },
161
+ setOptions: (opts) => {
162
+ normalizedPlugin.options = { ...normalizedPlugin.options, ...opts }
163
+ },
164
+ injectFile: (userFileNode) => {
165
+ this.fileManager.add(createFile(userFileNode))
166
+ },
167
+ }
168
+ return hooks['kubb:plugin:setup']!(pluginCtx)
263
169
  }
170
+
171
+ this.hooks.on('kubb:plugin:setup', setupHandler)
172
+ this.#trackHookListener('kubb:plugin:setup', setupHandler as (...args: never[]) => void | Promise<void>)
264
173
  }
265
174
 
266
- this.events.emit('plugins:hook:progress:end', { hookName })
175
+ // All other hooks are registered as direct pass-through listeners on the shared emitter.
176
+ for (const [event, handler] of Object.entries(hooks) as Array<[keyof KubbHooks, ((...args: never[]) => void | Promise<void>) | undefined]>) {
177
+ if (event === 'kubb:plugin:setup' || !handler) continue
267
178
 
268
- return items
179
+ this.hooks.on(event, handler as never)
180
+ this.#trackHookListener(event, handler as (...args: never[]) => void | Promise<void>)
181
+ }
269
182
  }
183
+
270
184
  /**
271
- * Run a specific hookName for plugin x.
185
+ * Emits the `kubb:plugin:setup` event so that all registered hook-style plugin listeners
186
+ * can configure generators, resolvers, transformers and renderers before `buildStart` runs.
187
+ *
188
+ * Call this once from `safeBuild` before the plugin execution loop begins.
272
189
  */
190
+ async emitSetupHooks(): Promise<void> {
191
+ const noop = () => {}
273
192
 
274
- hookForPluginSync<H extends PluginLifecycleHooks>({
275
- pluginName,
276
- hookName,
277
- parameters,
278
- }: {
279
- pluginName: string
280
- hookName: H
281
- parameters: PluginParameter<H>
282
- }): Array<ReturnType<ParseResult<H>>> | null {
283
- const plugins = this.getPluginsByName(hookName, pluginName)
284
-
285
- const result = plugins
286
- .map((plugin) => {
287
- return this.#executeSync<H>({
288
- strategy: 'hookFirst',
289
- hookName,
290
- parameters,
291
- plugin,
292
- })
293
- })
294
- .filter((x): x is NonNullable<typeof x> => x !== null)
295
-
296
- return result
193
+ await this.hooks.emit('kubb:plugin:setup', {
194
+ config: this.config,
195
+ options: {},
196
+ addGenerator: noop,
197
+ setResolver: noop,
198
+ setTransformer: noop,
199
+ setRenderer: noop,
200
+ setOptions: noop,
201
+ injectFile: noop,
202
+ updateConfig: noop,
203
+ })
297
204
  }
298
205
 
299
206
  /**
300
- * Returns the first non-null result.
207
+ * Registers a generator for the given plugin on the shared event emitter.
208
+ *
209
+ * The generator's `schema`, `operation`, and `operations` methods are registered as
210
+ * listeners on `kubb:generate:schema`, `kubb:generate:operation`, and `kubb:generate:operations`
211
+ * respectively. Each listener is scoped to the owning plugin via a `ctx.plugin.name` check
212
+ * so that generators from different plugins do not cross-fire.
213
+ *
214
+ * The renderer resolution chain is: `generator.renderer → plugin.renderer → config.renderer`.
215
+ * Set `generator.renderer = null` to explicitly opt out of rendering even when the plugin
216
+ * declares a renderer.
217
+ *
218
+ * Call this method inside `addGenerator()` (in `kubb:plugin:setup`) to wire up a generator.
301
219
  */
302
- async hookFirst<H extends PluginLifecycleHooks>({
303
- hookName,
304
- parameters,
305
- skipped,
306
- }: {
307
- hookName: H
308
- parameters: PluginParameter<H>
309
- skipped?: ReadonlySet<Plugin> | null
310
- }): Promise<SafeParseResult<H>> {
311
- const plugins = this.#getSortedPlugins(hookName).filter((plugin) => {
312
- return skipped ? !skipped.has(plugin) : true
313
- })
220
+ registerGenerator(pluginName: string, gen: Generator): void {
221
+ const resolveRenderer = () => {
222
+ const plugin = this.plugins.get(pluginName)
223
+ return gen.renderer === null ? undefined : (gen.renderer ?? plugin?.renderer ?? this.config.renderer)
224
+ }
314
225
 
315
- this.events.emit('plugins:hook:progress:start', { hookName, plugins })
316
-
317
- const promises = plugins.map((plugin) => {
318
- return async () => {
319
- const value = await this.#execute<H>({
320
- strategy: 'hookFirst',
321
- hookName,
322
- parameters,
323
- plugin,
324
- })
325
-
326
- return Promise.resolve({
327
- plugin,
328
- result: value,
329
- } as SafeParseResult<H>)
226
+ if (gen.schema) {
227
+ const schemaHandler = async (node: SchemaNode, ctx: GeneratorContext) => {
228
+ if (ctx.plugin.name !== pluginName) return
229
+ const result = await gen.schema!(node, ctx)
230
+ await applyHookResult(result, this, resolveRenderer())
330
231
  }
331
- })
332
-
333
- const result = await hookFirst(promises, hookFirstNullCheck)
334
232
 
335
- this.events.emit('plugins:hook:progress:end', { hookName })
233
+ this.hooks.on('kubb:generate:schema', schemaHandler)
234
+ this.#trackHookListener('kubb:generate:schema', schemaHandler as (...args: never[]) => void | Promise<void>)
235
+ }
336
236
 
337
- return result
338
- }
237
+ if (gen.operation) {
238
+ const operationHandler = async (node: OperationNode, ctx: GeneratorContext) => {
239
+ if (ctx.plugin.name !== pluginName) return
240
+ const result = await gen.operation!(node, ctx)
241
+ await applyHookResult(result, this, resolveRenderer())
242
+ }
339
243
 
340
- /**
341
- * Returns the first non-null result.
342
- */
343
- hookFirstSync<H extends PluginLifecycleHooks>({
344
- hookName,
345
- parameters,
346
- skipped,
347
- }: {
348
- hookName: H
349
- parameters: PluginParameter<H>
350
- skipped?: ReadonlySet<Plugin> | null
351
- }): SafeParseResult<H> | null {
352
- let parseResult: SafeParseResult<H> | null = null
353
- const plugins = this.#getSortedPlugins(hookName).filter((plugin) => {
354
- return skipped ? !skipped.has(plugin) : true
355
- })
244
+ this.hooks.on('kubb:generate:operation', operationHandler)
245
+ this.#trackHookListener('kubb:generate:operation', operationHandler as (...args: never[]) => void | Promise<void>)
246
+ }
356
247
 
357
- for (const plugin of plugins) {
358
- parseResult = {
359
- result: this.#executeSync<H>({
360
- strategy: 'hookFirst',
361
- hookName,
362
- parameters,
363
- plugin,
364
- }),
365
- plugin,
366
- } as SafeParseResult<H>
367
-
368
- if (parseResult?.result != null) {
369
- break
248
+ if (gen.operations) {
249
+ const operationsHandler = async (nodes: Array<OperationNode>, ctx: GeneratorContext) => {
250
+ if (ctx.plugin.name !== pluginName) return
251
+ const result = await gen.operations!(nodes, ctx)
252
+ await applyHookResult(result, this, resolveRenderer())
370
253
  }
254
+
255
+ this.hooks.on('kubb:generate:operations', operationsHandler)
256
+ this.#trackHookListener('kubb:generate:operations', operationsHandler as (...args: never[]) => void | Promise<void>)
371
257
  }
372
258
 
373
- return parseResult
259
+ this.#pluginsWithEventGenerators.add(pluginName)
374
260
  }
375
261
 
376
262
  /**
377
- * Runs all plugins in parallel based on `this.plugin` order and `pre`/`post` settings.
263
+ * Returns `true` when at least one generator was registered for the given plugin
264
+ * via `addGenerator()` in `kubb:plugin:setup` (event-based path).
265
+ *
266
+ * Used by the build loop to decide whether to walk the AST and emit generator events
267
+ * for a plugin that has no static `plugin.generators`.
378
268
  */
379
- async hookParallel<H extends PluginLifecycleHooks, TOutput = void>({
380
- hookName,
381
- parameters,
382
- }: {
383
- hookName: H
384
- parameters?: Parameters<RequiredPluginLifecycle[H]> | undefined
385
- }): Promise<Awaited<TOutput>[]> {
386
- const plugins = this.#getSortedPlugins(hookName)
387
- this.events.emit('plugins:hook:progress:start', { hookName, plugins })
388
-
389
- const pluginStartTimes = new Map<Plugin, number>()
390
-
391
- const promises = plugins.map((plugin) => {
392
- return () => {
393
- pluginStartTimes.set(plugin, performance.now())
394
- return this.#execute({
395
- strategy: 'hookParallel',
396
- hookName,
397
- parameters,
398
- plugin,
399
- }) as Promise<TOutput>
400
- }
401
- })
402
-
403
- const results = await hookParallel(promises, this.options.concurrency)
404
-
405
- results.forEach((result, index) => {
406
- if (isPromiseRejectedResult<Error>(result)) {
407
- const plugin = this.#getSortedPlugins(hookName)[index]
408
-
409
- if (plugin) {
410
- const startTime = pluginStartTimes.get(plugin) ?? performance.now()
411
- this.events.emit('error', result.reason, {
412
- plugin,
413
- hookName,
414
- strategy: 'hookParallel',
415
- duration: Math.round(performance.now() - startTime),
416
- parameters,
417
- })
418
- }
419
- }
420
- })
421
-
422
- this.events.emit('plugins:hook:progress:end', { hookName })
423
-
424
- return results.reduce((acc, result) => {
425
- if (result.status === 'fulfilled') {
426
- acc.push(result.value)
427
- }
428
- return acc
429
- }, [] as Awaited<TOutput>[])
269
+ hasRegisteredGenerators(pluginName: string): boolean {
270
+ return this.#pluginsWithEventGenerators.has(pluginName)
430
271
  }
431
272
 
432
273
  /**
433
- * Chains plugins
274
+ * Unregisters all plugin lifecycle listeners from the shared event emitter.
275
+ * Called at the end of a build to prevent listener leaks across repeated builds.
276
+ *
277
+ * @internal
434
278
  */
435
- async hookSeq<H extends PluginLifecycleHooks>({ hookName, parameters }: { hookName: H; parameters?: PluginParameter<H> }): Promise<void> {
436
- const plugins = this.#getSortedPlugins(hookName)
437
- this.events.emit('plugins:hook:progress:start', { hookName, plugins })
438
-
439
- const promises = plugins.map((plugin) => {
440
- return () =>
441
- this.#execute({
442
- strategy: 'hookSeq',
443
- hookName,
444
- parameters,
445
- plugin,
446
- })
447
- })
448
-
449
- await hookSeq(promises)
450
-
451
- this.events.emit('plugins:hook:progress:end', { hookName })
279
+ dispose(): void {
280
+ for (const [event, handlers] of this.#hookListeners) {
281
+ for (const handler of handlers) {
282
+ this.hooks.off(event, handler as never)
283
+ }
284
+ }
285
+ this.#hookListeners.clear()
286
+ this.#pluginsWithEventGenerators.clear()
452
287
  }
453
288
 
454
- #getSortedPlugins(hookName?: keyof PluginLifecycle): Array<Plugin> {
455
- const plugins = [...this.#plugins]
456
-
457
- if (hookName) {
458
- return plugins.filter((plugin) => hookName in plugin)
289
+ #trackHookListener(event: keyof KubbHooks, handler: (...args: never[]) => void | Promise<void>): void {
290
+ let handlers = this.#hookListeners.get(event)
291
+ if (!handlers) {
292
+ handlers = new Set()
293
+ this.#hookListeners.set(event, handlers)
459
294
  }
460
- // TODO add test case for sorting with pre/post
461
-
462
- return plugins
463
- .map((plugin) => {
464
- if (plugin.pre) {
465
- let missingPlugins = plugin.pre.filter((pluginName) => !plugins.find((pluginToFind) => pluginToFind.name === pluginName))
466
-
467
- // when adapter is set, we can ignore the depends on plugin-oas, in v5 this will not be needed anymore
468
- if (missingPlugins.includes('plugin-oas') && this.adapter) {
469
- missingPlugins = missingPlugins.filter((pluginName) => pluginName !== 'plugin-oas')
470
- }
471
-
472
- if (missingPlugins.length > 0) {
473
- throw new ValidationPluginError(`The plugin '${plugin.name}' has a pre set that references missing plugins for '${missingPlugins.join(', ')}'`)
474
- }
475
- }
476
-
477
- return plugin
478
- })
479
- .sort((a, b) => {
480
- if (b.pre?.includes(a.name)) {
481
- return 1
482
- }
483
- if (b.post?.includes(a.name)) {
484
- return -1
485
- }
486
- return 0
487
- })
295
+ handlers.add(handler)
488
296
  }
489
297
 
490
- getPluginByName(pluginName: string): Plugin | undefined {
491
- const plugins = [...this.#plugins]
298
+ #createDefaultResolver(pluginName: string): Resolver {
299
+ const existingResolver = this.#defaultResolvers.get(pluginName)
300
+ if (existingResolver) {
301
+ return existingResolver
302
+ }
492
303
 
493
- return plugins.find((item) => item.name === pluginName)
304
+ const resolver = defineResolver<PluginFactoryOptions>(() => ({
305
+ name: 'default',
306
+ pluginName,
307
+ }))
308
+ this.#defaultResolvers.set(pluginName, resolver)
309
+ return resolver
494
310
  }
495
311
 
496
- getPluginsByName(hookName: keyof PluginWithLifeCycle, pluginName: string): Plugin[] {
497
- const plugins = [...this.plugins]
498
-
499
- const pluginByPluginName = plugins.filter((plugin) => hookName in plugin).filter((item) => item.name === pluginName)
500
-
501
- if (!pluginByPluginName?.length) {
502
- // fallback on the core plugin when there is no match
503
-
504
- const corePlugin = plugins.find((plugin) => plugin.name === CORE_PLUGIN_NAME && hookName in plugin)
505
-
506
- return corePlugin ? [corePlugin] : []
312
+ /**
313
+ * Merges `partial` with the plugin's default resolver and stores the result.
314
+ * Also mirrors it onto `plugin.resolver` so callers using `getPlugin(name).resolver`
315
+ * get the up-to-date resolver without going through `getResolver()`.
316
+ */
317
+ setPluginResolver(pluginName: string, partial: Partial<Resolver>): void {
318
+ const defaultResolver = this.#createDefaultResolver(pluginName)
319
+ const merged = { ...defaultResolver, ...partial }
320
+ this.#resolvers.set(pluginName, merged)
321
+ const plugin = this.plugins.get(pluginName)
322
+ if (plugin) {
323
+ plugin.resolver = merged
507
324
  }
508
-
509
- return pluginByPluginName
510
325
  }
511
326
 
512
327
  /**
513
- * Run an async plugin hook and return the result.
514
- * @param hookName Name of the plugin hook. Must be either in `PluginHooks` or `OutputPluginValueHooks`.
515
- * @param args Arguments passed to the plugin hook.
516
- * @param plugin The actual pluginObject to run.
328
+ * Returns the resolver for the given plugin.
329
+ *
330
+ * Resolution order: dynamic resolver set via `setPluginResolver` → static resolver on the
331
+ * plugin lazily created default resolver (identity name, no path transforms).
517
332
  */
518
- #emitProcessingEnd<H extends PluginLifecycleHooks>({
519
- startTime,
520
- output,
521
- strategy,
522
- hookName,
523
- plugin,
524
- parameters,
525
- }: {
526
- startTime: number
527
- output: unknown
528
- strategy: Strategy
529
- hookName: H
530
- plugin: PluginWithLifeCycle
531
- parameters: unknown[] | undefined
532
- }): void {
533
- this.events.emit('plugins:hook:processing:end', {
534
- duration: Math.round(performance.now() - startTime),
535
- parameters,
536
- output,
537
- strategy,
538
- hookName,
539
- plugin,
540
- })
333
+ getResolver<TName extends keyof Kubb.PluginRegistry>(pluginName: TName): Kubb.PluginRegistry[TName]['resolver']
334
+ getResolver<TResolver extends Resolver = Resolver>(pluginName: string): TResolver
335
+ getResolver(pluginName: string): Resolver {
336
+ return this.#resolvers.get(pluginName) ?? this.plugins.get(pluginName)?.resolver ?? this.#createDefaultResolver(pluginName)
541
337
  }
542
338
 
543
- // Implementation signature
544
- #execute<H extends PluginLifecycleHooks>({
545
- strategy,
546
- hookName,
547
- parameters,
548
- plugin,
549
- }: {
550
- strategy: Strategy
551
- hookName: H
552
- parameters: unknown[] | undefined
553
- plugin: PluginWithLifeCycle
554
- }): Promise<ReturnType<ParseResult<H>> | null> | null {
555
- const hook = plugin[hookName]
556
-
557
- if (!hook) {
558
- return null
559
- }
339
+ getContext<TOptions extends PluginFactoryOptions>(plugin: NormalizedPlugin<TOptions>): GeneratorContext<TOptions> & Record<string, unknown> {
340
+ const driver = this
560
341
 
561
- this.events.emit('plugins:hook:processing:start', {
562
- strategy,
563
- hookName,
564
- parameters,
342
+ const baseContext = {
343
+ config: driver.config,
344
+ get root(): string {
345
+ return resolve(driver.config.root, driver.config.output.path)
346
+ },
347
+ getMode(output: { path: string }): 'single' | 'split' {
348
+ return PluginDriver.getMode(resolve(driver.config.root, driver.config.output.path, output.path))
349
+ },
350
+ hooks: driver.hooks,
565
351
  plugin,
566
- })
352
+ getPlugin: driver.getPlugin.bind(driver),
353
+ requirePlugin: driver.requirePlugin.bind(driver),
354
+ getResolver: driver.getResolver.bind(driver),
355
+ driver,
356
+ addFile: async (...files: Array<FileNode>) => {
357
+ driver.fileManager.add(...files)
358
+ },
359
+ upsertFile: async (...files: Array<FileNode>) => {
360
+ driver.fileManager.upsert(...files)
361
+ },
362
+ get inputNode(): InputNode | undefined {
363
+ return driver.inputNode
364
+ },
365
+ get adapter(): Adapter | undefined {
366
+ return driver.adapter
367
+ },
368
+ get resolver() {
369
+ return driver.getResolver(plugin.name)
370
+ },
371
+ get transformer() {
372
+ return plugin.transformer
373
+ },
374
+ warn(message: string) {
375
+ driver.hooks.emit('kubb:warn', { message })
376
+ },
377
+ error(error: string | Error) {
378
+ driver.hooks.emit('kubb:error', { error: typeof error === 'string' ? new Error(error) : error })
379
+ },
380
+ info(message: string) {
381
+ driver.hooks.emit('kubb:info', { message })
382
+ },
383
+ openInStudio(options?: DevtoolsOptions) {
384
+ if (!driver.config.devtools || driver.#studioIsOpen) {
385
+ return
386
+ }
567
387
 
568
- const startTime = performance.now()
388
+ if (typeof driver.config.devtools !== 'object') {
389
+ throw new Error('Devtools must be an object')
390
+ }
569
391
 
570
- const task = (async () => {
571
- try {
572
- const output =
573
- typeof hook === 'function' ? await Promise.resolve((hook as (...args: unknown[]) => unknown).apply(this.getContext(plugin), parameters ?? [])) : hook
392
+ if (!driver.inputNode || !driver.adapter) {
393
+ throw new Error('adapter is not defined, make sure you have set the parser in kubb.config.ts')
394
+ }
574
395
 
575
- this.#emitProcessingEnd({ startTime, output, strategy, hookName, plugin, parameters })
396
+ driver.#studioIsOpen = true
576
397
 
577
- return output as ReturnType<ParseResult<H>>
578
- } catch (error) {
579
- this.events.emit('error', error as Error, {
580
- plugin,
581
- hookName,
582
- strategy,
583
- duration: Math.round(performance.now() - startTime),
584
- })
398
+ const studioUrl = driver.config.devtools?.studioUrl ?? DEFAULT_STUDIO_URL
585
399
 
586
- return null
587
- }
588
- })()
400
+ return openInStudioFn(driver.inputNode, studioUrl, options)
401
+ },
402
+ } as unknown as GeneratorContext<TOptions>
589
403
 
590
- return task
404
+ return baseContext
405
+ }
406
+
407
+ getPlugin<TName extends keyof Kubb.PluginRegistry>(pluginName: TName): Plugin<Kubb.PluginRegistry[TName]> | undefined
408
+ getPlugin<TOptions extends PluginFactoryOptions = PluginFactoryOptions>(pluginName: string): Plugin<TOptions> | undefined
409
+ getPlugin(pluginName: string): Plugin | undefined {
410
+ return this.plugins.get(pluginName)
591
411
  }
592
412
 
593
413
  /**
594
- * Run a sync plugin hook and return the result.
595
- * @param hookName Name of the plugin hook. Must be in `PluginHooks`.
596
- * @param args Arguments passed to the plugin hook.
597
- * @param plugin The actual plugin
414
+ * Like `getPlugin` but throws a descriptive error when the plugin is not found.
598
415
  */
599
- #executeSync<H extends PluginLifecycleHooks>({
600
- strategy,
601
- hookName,
602
- parameters,
603
- plugin,
604
- }: {
605
- strategy: Strategy
606
- hookName: H
607
- parameters: PluginParameter<H>
608
- plugin: PluginWithLifeCycle
609
- }): ReturnType<ParseResult<H>> | null {
610
- const hook = plugin[hookName]
611
-
612
- if (!hook) {
613
- return null
614
- }
615
-
616
- this.events.emit('plugins:hook:processing:start', {
617
- strategy,
618
- hookName,
619
- parameters,
620
- plugin,
621
- })
622
-
623
- const startTime = performance.now()
624
-
625
- try {
626
- const output =
627
- typeof hook === 'function'
628
- ? ((hook as (...args: unknown[]) => unknown).apply(this.getContext(plugin), parameters) as ReturnType<ParseResult<H>>)
629
- : (hook as ReturnType<ParseResult<H>>)
630
-
631
- this.#emitProcessingEnd({ startTime, output, strategy, hookName, plugin, parameters })
632
-
633
- return output
634
- } catch (error) {
635
- this.events.emit('error', error as Error, {
636
- plugin,
637
- hookName,
638
- strategy,
639
- duration: Math.round(performance.now() - startTime),
640
- })
641
-
642
- return null
416
+ requirePlugin<TName extends keyof Kubb.PluginRegistry>(pluginName: TName): Plugin<Kubb.PluginRegistry[TName]>
417
+ requirePlugin<TOptions extends PluginFactoryOptions = PluginFactoryOptions>(pluginName: string): Plugin<TOptions>
418
+ requirePlugin(pluginName: string): Plugin {
419
+ const plugin = this.plugins.get(pluginName)
420
+ if (!plugin) {
421
+ throw new Error(`[kubb] Plugin "${pluginName}" is required but not found. Make sure it is included in your Kubb config.`)
643
422
  }
423
+ return plugin
644
424
  }
425
+ }
645
426
 
646
- #parse(plugin: UserPlugin): Plugin {
647
- const usedPluginNames = this.#usedPluginNames
648
-
649
- setUniqueName(plugin.name, usedPluginNames)
650
-
651
- const usageCount = usedPluginNames[plugin.name]
652
- if (usageCount && usageCount > 1) {
653
- throw new ValidationPluginError(
654
- `Duplicate plugin "${plugin.name}" detected. Each plugin can only be used once. Use a different configuration instead of adding multiple instances of the same plugin.`,
655
- )
656
- }
427
+ /**
428
+ * Handles the return value of a plugin AST hook or generator method.
429
+ *
430
+ * - Renderer output → rendered via the provided `rendererFactory` (e.g. JSX), files stored in `driver.fileManager`
431
+ * - `Array<FileNode>` → added directly into `driver.fileManager`
432
+ * - `void` / `null` / `undefined` → no-op (plugin handled it via `this.upsertFile`)
433
+ *
434
+ * Pass a `rendererFactory` (e.g. `jsxRenderer` from `@kubb/renderer-jsx`) when the result
435
+ * may be a renderer element. Generators that only return `Array<FileNode>` do not need one.
436
+ */
437
+ export async function applyHookResult<TElement = unknown>(
438
+ result: TElement | Array<FileNode> | void,
439
+ driver: PluginDriver,
440
+ rendererFactory?: RendererFactory<TElement>,
441
+ ): Promise<void> {
442
+ if (!result) return
443
+
444
+ if (Array.isArray(result)) {
445
+ driver.fileManager.upsert(...(result as Array<FileNode>))
446
+ return
447
+ }
657
448
 
658
- return {
659
- install() {},
660
- ...plugin,
661
- } as unknown as Plugin
449
+ if (!rendererFactory) {
450
+ return
662
451
  }
452
+
453
+ const renderer = rendererFactory()
454
+ await renderer.render(result)
455
+ driver.fileManager.upsert(...renderer.files)
456
+ renderer.unmount()
663
457
  }