jax-hono 1.0.13 → 1.0.14

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.
@@ -1,619 +1,619 @@
1
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
2
- import { cwd } from 'node:process'
3
- import { basename, join, parse, relative } from 'node:path'
4
- import { ensureGeneratedDir, generatedDir, packageDir, projectDir } from './generated-path'
5
-
6
- const srcDir = join(cwd(), 'src')
7
- const moduleExtensions = new Set(['.ts', '.js', '.mjs', '.cjs'])
8
- const generatedHeader = `// This file is generated by jax-hono/build/generate-registry.ts.
9
- // Do not edit it directly.
10
- `
11
-
12
- type ControllerGroup = {
13
- name: string
14
- dirs: string[]
15
- suffix?: string
16
- }
17
-
18
- type PluginConfigItem =
19
- | false
20
- | {
21
- enable?: boolean
22
- package?: string
23
- options?: unknown
24
- }
25
-
26
- type PluginConfig = Record<string, PluginConfigItem>
27
-
28
- const controllerGroups: ControllerGroup[] = [
29
- { name: 'api', dirs: ['api', 'controllers/api', 'controller/api', 'modules'], suffix: '.api' },
30
- { name: 'backend', dirs: ['backend', 'controllers/backend', 'controller/backend', 'modules'], suffix: '.backend' },
31
- { name: 'open', dirs: ['open', 'controllers/open', 'controller/open', 'modules'], suffix: '.open' },
32
- { name: 'client', dirs: ['client', 'controllers/client', 'controller/client', 'modules'], suffix: '.client' },
33
- { name: 'controller', dirs: ['controllers', 'controller'] },
34
- ]
35
-
36
- const scopedControllerGroups = new Set(
37
- controllerGroups
38
- .filter((group) => group.name !== 'controller')
39
- .map((group) => group.name),
40
- )
41
-
42
- function walkFiles(dir: string): string[] {
43
- if (!existsSync(dir)) {
44
- return []
45
- }
46
-
47
- const files: string[] = []
48
-
49
- for (const item of readdirSync(dir, { withFileTypes: true })) {
50
- const itemPath = join(dir, item.name)
51
-
52
- if (item.isDirectory()) {
53
- files.push(...walkFiles(itemPath))
54
- continue
55
- }
56
-
57
- if (item.isFile() && moduleExtensions.has(parse(item.name).ext)) {
58
- files.push(itemPath)
59
- }
60
- }
61
-
62
- return files
63
- }
64
-
65
- function writeGeneratedFile(fileName: string, content: string) {
66
- const outputFile = join(generatedDir, fileName)
67
-
68
- ensureGeneratedDir()
69
-
70
- if (existsSync(outputFile) && readFileSync(outputFile, 'utf8') === content) {
71
- return
72
- }
73
-
74
- writeFileSync(outputFile, content)
75
- console.log(`Generated ${relative(projectDir, outputFile)}.`)
76
- }
77
-
78
- function toImportPath(filePath: string) {
79
- const path = filePath
80
- .replace(parse(filePath).ext, '')
81
- .replaceAll('\\', '/')
82
-
83
- return path
84
- }
85
-
86
- function toIdentifier(parts: string[]) {
87
- const value = parts
88
- .join('_')
89
- .replace(/[^a-zA-Z0-9_$]/g, '_')
90
- .replace(/^([^a-zA-Z_$])/, '_$1')
91
-
92
- return value || 'module'
93
- }
94
-
95
- function toCamelCase(value: string) {
96
- return value.replace(/[-_]+([a-zA-Z0-9])/g, (_match, char: string) => char.toUpperCase())
97
- }
98
-
99
- function toPascalCase(value: string) {
100
- const camel = toCamelCase(value)
101
-
102
- return camel.charAt(0).toUpperCase() + camel.slice(1)
103
- }
104
-
105
- function toServiceKey(name: string) {
106
- const baseName = name.endsWith('Service')
107
- ? name.slice(0, -'Service'.length)
108
- : name
109
-
110
- return baseName.replace(/^./, (char) => char.toLowerCase())
111
- }
112
-
113
- function toMiddlewareKey(exportName: string) {
114
- return exportName.endsWith('Middleware')
115
- ? exportName.slice(0, -'Middleware'.length)
116
- : exportName
117
- }
118
-
119
- function stripKnownSuffix(modulePath: string, suffixes: string[]) {
120
- for (const suffix of suffixes) {
121
- if (modulePath.endsWith(suffix)) {
122
- return modulePath.slice(0, -suffix.length)
123
- }
124
- }
125
-
126
- return modulePath
127
- }
128
-
129
- function normalizeModulePath(modulePath: string) {
130
- const parts = modulePath.split('/')
131
- const last = parts.at(-1)
132
- const parent = parts.at(-2)
133
-
134
- return last && parent && last === parent
135
- ? parts.slice(0, -1).join('/')
136
- : modulePath
137
- }
138
-
139
- function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []) {
140
- const modulePath = relative(join(srcDir, baseDir), filePath)
141
- .replace(parse(filePath).ext, '')
142
- .replaceAll('\\', '/')
143
- const withoutIndex = modulePath.endsWith('/index')
144
- ? modulePath.slice(0, -'/index'.length)
145
- : modulePath
146
-
147
- const stripped = stripKnownSuffix(withoutIndex, suffixes)
148
-
149
- return baseDir === 'modules' ? normalizeModulePath(stripped) : stripped
150
- }
151
-
152
- function uniqueSorted(values: string[]) {
153
- return [...new Set(values)].sort((left, right) => left.localeCompare(right))
154
- }
155
-
156
- function getFilesFromDirs(dirs: string[]) {
157
- return uniqueSorted(dirs.flatMap((dirName) => walkFiles(join(srcDir, dirName))))
158
- }
159
-
160
- function isFileInDir(filePath: string, dirName: string) {
161
- const rel = relative(join(srcDir, dirName), filePath)
162
-
163
- return rel && !rel.startsWith('..')
164
- }
165
-
166
- function isSuffixedModuleFile(filePath: string, suffix: string) {
167
- return relative(srcDir, filePath)
168
- .replace(parse(filePath).ext, '')
169
- .replaceAll('\\', '/')
170
- .endsWith(suffix)
171
- }
172
-
173
- function isModuleFeatureFile(filePath: string, suffix: string) {
174
- return !isFileInDir(filePath, 'modules') || isSuffixedModuleFile(filePath, suffix)
175
- }
176
-
177
- function hasDefaultExport(filePath: string) {
178
- return /\bexport\s+default\b/.test(readFileSync(filePath, 'utf8'))
179
- }
180
-
181
- function indentObject(content: string, spaces = 2) {
182
- if (!content) {
183
- return ''
184
- }
185
-
186
- const indent = ' '.repeat(spaces)
187
-
188
- return content
189
- .split('\n')
190
- .map((line) => `${indent}${line}`)
191
- .join('\n')
192
- }
193
-
194
- function buildNestedObject(entries: { path: string[]; value: string }[]): string {
195
- const groups = new Map<string, { path: string[]; value: string }[]>()
196
- const leaves: { key: string; value: string }[] = []
197
-
198
- for (const entry of entries) {
199
- const [head, ...tail] = entry.path
200
-
201
- if (!head) {
202
- continue
203
- }
204
-
205
- if (tail.length === 0) {
206
- leaves.push({ key: head, value: entry.value })
207
- continue
208
- }
209
-
210
- groups.set(head, [...(groups.get(head) ?? []), { path: tail, value: entry.value }])
211
- }
212
-
213
- const lines = [
214
- ...leaves.map((leaf) => `${toObjectKey(leaf.key)}: ${leaf.value},`),
215
- ...[...groups.entries()].map(([key, groupEntries]) => {
216
- return `${toObjectKey(key)}: {\n${indentObject(buildNestedObject(groupEntries), 2)}\n},`
217
- }),
218
- ]
219
-
220
- return lines.join('\n')
221
- }
222
-
223
- function generateControllersRegistry() {
224
- const used = new Set<string>()
225
- const imports: string[] = []
226
- const groupLines: string[] = []
227
-
228
- for (const group of controllerGroups) {
229
- const entries = getFilesFromDirs(group.dirs)
230
- .filter((filePath) => basename(filePath) !== 'index.ts')
231
- .filter((filePath) => !group.suffix || isModuleFeatureFile(filePath, group.suffix))
232
- .filter(hasDefaultExport)
233
- .map((filePath, index) => {
234
- const baseDir = group.dirs.find((dirName) => {
235
- return isFileInDir(filePath, dirName)
236
- }) ?? group.dirs[0]
237
- const modulePath = toModulePath(baseDir, filePath, group.suffix ? [group.suffix] : [])
238
- const importName = toIdentifier([group.name, modulePath, String(index), 'Controller'])
239
-
240
- return { modulePath, importName, filePath }
241
- })
242
- .filter((entry) => {
243
- if (group.name !== 'controller') {
244
- return true
245
- }
246
-
247
- const [scope] = entry.modulePath.split('/')
248
-
249
- return !scopedControllerGroups.has(scope)
250
- })
251
- .filter((entry) => {
252
- const key = `${group.name}:${entry.modulePath}`
253
-
254
- if (used.has(key)) {
255
- return false
256
- }
257
-
258
- used.add(key)
259
-
260
- return true
261
- })
262
-
263
- imports.push(...entries.map((entry) => `import ${entry.importName} from '${toImportPath(entry.filePath)}'`))
264
-
265
- if (entries.length === 0) {
266
- continue
267
- }
268
-
269
- const objectBody = indentObject(
270
- buildNestedObject(entries.map((entry) => ({
271
- path: entry.modulePath.split('/').map(toCamelCase),
272
- value: `createController(${entry.importName})`,
273
- }))),
274
- 4,
275
- )
276
-
277
- groupLines.push(` ${group.name}: {\n${objectBody}\n },`)
278
- }
279
-
280
- const helperImport = imports.length > 0
281
- ? `import { createController } from 'jax-hono/core/controller'\n`
282
- : ''
283
- const content = `${generatedHeader}
284
- ${helperImport}${imports.join('\n')}
285
-
286
- export const controllers = {
287
- ${groupLines.join('\n')}
288
- }
289
- `
290
-
291
- writeGeneratedFile('controllers.generated.ts', content)
292
- }
293
-
294
- function toObjectKey(value: string) {
295
- return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(value) ? value : JSON.stringify(value)
296
- }
297
-
298
- function generateMiddlewaresRegistry() {
299
- const files = getFilesFromDirs(['middlewares', 'middleware', 'modules'])
300
- .filter((filePath) => isModuleFeatureFile(filePath, '.middleware'))
301
- const imports: string[] = []
302
- const entries: string[] = []
303
-
304
- files.forEach((filePath, index) => {
305
- const source = readFileSync(filePath, 'utf8')
306
- const exportNames = [...source.matchAll(/\bexport\s+(?:const|function)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g)]
307
- .map((match) => match[1])
308
- .sort((left, right) => left.localeCompare(right))
309
-
310
- if (exportNames.length === 0) {
311
- return
312
- }
313
-
314
- imports.push(`import { ${exportNames.join(', ')} } from '${toImportPath(filePath)}'`)
315
- entries.push(...exportNames.map((name) => ` ${toMiddlewareKey(name)}: ${name},`))
316
- })
317
-
318
- const content = `${generatedHeader}
319
- ${imports.join('\n')}
320
-
321
- export const middlewares = {
322
- ${entries.join('\n')}
323
- }
324
- `
325
-
326
- writeGeneratedFile('middlewares.generated.ts', content)
327
- }
328
-
329
- function generateModelsRegistry() {
330
- const files = getFilesFromDirs(['models', 'model', 'modules'])
331
- .filter((filePath) => isModuleFeatureFile(filePath, '.model'))
332
- const imports: string[] = []
333
- const entries: string[] = []
334
-
335
- files.forEach((filePath) => {
336
- const source = readFileSync(filePath, 'utf8')
337
- const exportedModels = [...source.matchAll(/\bexport\s+const\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*create(?:Loose)?Model\s*\(/g)]
338
- .map((match) => match[1])
339
- .sort((left, right) => left.localeCompare(right))
340
-
341
- if (exportedModels.length === 0) {
342
- return
343
- }
344
-
345
- const modelKey = source.match(/\bexport\s+const\s+modelKey\s*=\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)['"]/)?.[1]
346
- const fileModelName = toPascalCase(toModulePath(
347
- filePath.includes(`${srcDir}/modules/`) ? 'modules' : basename(parse(filePath).dir),
348
- filePath,
349
- ['.model'],
350
- ))
351
-
352
- imports.push(`import { ${exportedModels.join(', ')} } from '${toImportPath(filePath)}'`)
353
- entries.push(...exportedModels.map((name) => {
354
- const key = exportedModels.length === 1 && modelKey
355
- ? modelKey
356
- : name === `${fileModelName}Model`
357
- ? fileModelName
358
- : name
359
-
360
- return ` ${key}: ${name},`
361
- }))
362
- })
363
-
364
- const content = `${generatedHeader}
365
- ${imports.join('\n')}
366
-
367
- export const models = {
368
- ${entries.join('\n')}
369
- }
370
-
371
- export type Models = typeof models
372
- `
373
-
374
- writeGeneratedFile('models.generated.ts', content)
375
- }
376
-
377
- function generateServicesRegistry() {
378
- const files = getFilesFromDirs(['services', 'service', 'modules'])
379
- .filter((filePath) => isModuleFeatureFile(filePath, '.service'))
380
- const imports: string[] = []
381
- const entries: string[] = []
382
-
383
- files.forEach((filePath) => {
384
- const source = readFileSync(filePath, 'utf8')
385
- const namedServiceClasses = [...source.matchAll(/\bexport\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*Service)\b/g)]
386
- .map((match) => match[1])
387
- .sort((left, right) => left.localeCompare(right))
388
- const defaultServiceClass = source.match(/\bexport\s+default\s+class(?:\s+([a-zA-Z_$][a-zA-Z0-9_$]*))?/)
389
- const defaultServiceName = defaultServiceClass
390
- ? defaultServiceClass[1] ?? `${toPascalCase(toModulePath('services', filePath))}Service`
391
- : ''
392
- const defaultImportName = defaultServiceName
393
- ? toIdentifier([defaultServiceName, 'Default'])
394
- : ''
395
-
396
- if (namedServiceClasses.length === 0 && !defaultServiceName) {
397
- return
398
- }
399
-
400
- if (defaultServiceName) {
401
- imports.push(`import ${defaultImportName} from '${toImportPath(filePath)}'`)
402
- entries.push(` ${toServiceKey(defaultServiceName)}: new ${defaultImportName}(deps),`)
403
- }
404
-
405
- if (namedServiceClasses.length > 0) {
406
- imports.push(`import { ${namedServiceClasses.join(', ')} } from '${toImportPath(filePath)}'`)
407
- entries.push(...namedServiceClasses.map((name) => ` ${toServiceKey(name)}: new ${name}(deps),`))
408
- }
409
- })
410
-
411
- const content = `${generatedHeader}
412
- ${imports.join('\n')}
413
-
414
- export function createServices(deps: any) {
415
- return {
416
- ${entries.join('\n')}
417
- }
418
- }
419
-
420
- export type Services = ReturnType<typeof createServices>
421
- `
422
-
423
- writeGeneratedFile('services.generated.ts', content)
424
- }
425
-
426
- function findBalancedObject(source: string, startIndex: number) {
427
- let depth = 0
428
- let quote: string | undefined
429
- let escaped = false
430
- let lineComment = false
431
- let blockComment = false
432
-
433
- for (let index = startIndex; index < source.length; index += 1) {
434
- const char = source[index]
435
- const next = source[index + 1]
436
-
437
- if (lineComment) {
438
- if (char === '\n') {
439
- lineComment = false
440
- }
441
- continue
442
- }
443
-
444
- if (blockComment) {
445
- if (char === '*' && next === '/') {
446
- blockComment = false
447
- index += 1
448
- }
449
- continue
450
- }
451
-
452
- if (quote) {
453
- if (escaped) {
454
- escaped = false
455
- continue
456
- }
457
-
458
- if (char === '\\') {
459
- escaped = true
460
- continue
461
- }
462
-
463
- if (char === quote) {
464
- quote = undefined
465
- }
466
- continue
467
- }
468
-
469
- if (char === '/' && next === '/') {
470
- lineComment = true
471
- index += 1
472
- continue
473
- }
474
-
475
- if (char === '/' && next === '*') {
476
- blockComment = true
477
- index += 1
478
- continue
479
- }
480
-
481
- if (char === '"' || char === '\'' || char === '`') {
482
- quote = char
483
- continue
484
- }
485
-
486
- if (char === '{') {
487
- depth += 1
488
- continue
489
- }
490
-
491
- if (char === '}') {
492
- depth -= 1
493
-
494
- if (depth === 0) {
495
- return source.slice(startIndex, index + 1)
496
- }
497
- }
498
- }
499
-
500
- return undefined
501
- }
502
-
503
- function extractPluginConfigObject(source: string) {
504
- const configCallIndex = source.indexOf('definePluginConfig')
505
- const searchStart = configCallIndex >= 0 ? configCallIndex : source.indexOf('export default')
506
- const objectStart = searchStart >= 0 ? source.indexOf('{', searchStart) : -1
507
-
508
- if (objectStart < 0) {
509
- return undefined
510
- }
511
-
512
- return findBalancedObject(source, objectStart)
513
- }
514
-
515
- function readPluginConfig(filePath: string): PluginConfig {
516
- if (!existsSync(filePath)) {
517
- return {}
518
- }
519
-
520
- const objectSource = extractPluginConfigObject(readFileSync(filePath, 'utf8'))
521
-
522
- if (!objectSource) {
523
- return {}
524
- }
525
-
526
- try {
527
- const value = new Function(`return (${objectSource})`)() as unknown
528
-
529
- return value && typeof value === 'object' && !Array.isArray(value)
530
- ? (value as PluginConfig)
531
- : {}
532
- } catch (error) {
533
- throw new Error(`Invalid plugin config ${relative(srcDir, filePath)}: ${(error as Error).message}`)
534
- }
535
- }
536
-
537
- function normalizePluginPackage(packageName: string, configFile: string) {
538
- if (!packageName.startsWith('./') && !packageName.startsWith('../')) {
539
- return packageName
540
- }
541
-
542
- const normalized = join(configFile, '..', packageName)
543
- .replaceAll('\\', '/')
544
- .replace(/\/index$/, '')
545
-
546
- return normalized
547
- }
548
-
549
- function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]) {
550
- const merged = new Map<string, Exclude<PluginConfigItem, false>>()
551
-
552
- for (const { filePath, config } of configs) {
553
- for (const [name, item] of Object.entries(config)) {
554
- if (item === false) {
555
- merged.delete(name)
556
- continue
557
- }
558
-
559
- const current = merged.get(name) ?? {}
560
- const next = { ...current, ...item }
561
-
562
- if (next.package) {
563
- next.package = normalizePluginPackage(next.package, filePath)
564
- }
565
-
566
- merged.set(name, next)
567
- }
568
- }
569
-
570
- return [...merged.entries()]
571
- .filter((entry): entry is [string, Exclude<PluginConfigItem, false> & { package: string }] => {
572
- const [, item] = entry
573
-
574
- return item.enable === true && typeof item.package === 'string' && item.package.length > 0
575
- })
576
- .sort(([left], [right]) => left.localeCompare(right))
577
- }
578
-
579
- function serializePluginOptions(options: unknown) {
580
- return options === undefined ? '' : `, options: ${JSON.stringify(options)}`
581
- }
582
-
583
- function generatePluginContextRegistry() {
584
- const enabledPlugins = mergePluginConfig([
585
- { filePath: join(packageDir, 'plugins/config.ts'), config: readPluginConfig(join(packageDir, 'plugins/config.ts')) },
586
- { filePath: join(srcDir, 'config/plugin.ts'), config: readPluginConfig(join(srcDir, 'config/plugin.ts')) },
587
- ])
588
- const imports = enabledPlugins.map(([name, item]) => {
589
- return `import ${toIdentifier(['plugin', name])} from ${JSON.stringify(item.package)}`
590
- })
591
- const moduleEntries = enabledPlugins.map(
592
- ([name, item]) =>
593
- ` { name: ${JSON.stringify(name)}, module: ${toIdentifier(['plugin', name])}${serializePluginOptions(item.options)} },`,
594
- )
595
-
596
- const content = `${generatedHeader}${imports.length > 0 ? `${imports.join('\n')}\n\n` : ''}
597
- export const plugins = {} as Record<string, unknown>
598
-
599
- export type Plugins = typeof plugins
600
-
601
- export const pluginModules = [
602
- ${moduleEntries.join('\n')}
603
- ] as const
604
- `
605
-
606
- writeGeneratedFile('plugins.generated.ts', content)
607
- }
608
-
609
- export function generateRegistry() {
610
- generateControllersRegistry()
611
- generateMiddlewaresRegistry()
612
- generateModelsRegistry()
613
- generateServicesRegistry()
614
- generatePluginContextRegistry()
615
- }
616
-
617
- if (import.meta.main) {
618
- generateRegistry()
619
- }
1
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
2
+ import { cwd } from 'node:process'
3
+ import { basename, join, parse, relative } from 'node:path'
4
+ import { ensureGeneratedDir, generatedDir, packageDir, projectDir } from './generated-path'
5
+
6
+ const srcDir = join(cwd(), 'src')
7
+ const moduleExtensions = new Set(['.ts', '.js', '.mjs', '.cjs'])
8
+ const generatedHeader = `// This file is generated by jax-hono/build/generate-registry.ts.
9
+ // Do not edit it directly.
10
+ `
11
+
12
+ type ControllerGroup = {
13
+ name: string
14
+ dirs: string[]
15
+ suffix?: string
16
+ }
17
+
18
+ type PluginConfigItem =
19
+ | false
20
+ | {
21
+ enable?: boolean
22
+ package?: string
23
+ options?: unknown
24
+ }
25
+
26
+ type PluginConfig = Record<string, PluginConfigItem>
27
+
28
+ const controllerGroups: ControllerGroup[] = [
29
+ { name: 'api', dirs: ['api', 'controllers/api', 'controller/api', 'modules'], suffix: '.api' },
30
+ { name: 'backend', dirs: ['backend', 'controllers/backend', 'controller/backend', 'modules'], suffix: '.backend' },
31
+ { name: 'open', dirs: ['open', 'controllers/open', 'controller/open', 'modules'], suffix: '.open' },
32
+ { name: 'client', dirs: ['client', 'controllers/client', 'controller/client', 'modules'], suffix: '.client' },
33
+ { name: 'controller', dirs: ['controllers', 'controller'] },
34
+ ]
35
+
36
+ const scopedControllerGroups = new Set(
37
+ controllerGroups
38
+ .filter((group) => group.name !== 'controller')
39
+ .map((group) => group.name),
40
+ )
41
+
42
+ function walkFiles(dir: string): string[] {
43
+ if (!existsSync(dir)) {
44
+ return []
45
+ }
46
+
47
+ const files: string[] = []
48
+
49
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
50
+ const itemPath = join(dir, item.name)
51
+
52
+ if (item.isDirectory()) {
53
+ files.push(...walkFiles(itemPath))
54
+ continue
55
+ }
56
+
57
+ if (item.isFile() && moduleExtensions.has(parse(item.name).ext)) {
58
+ files.push(itemPath)
59
+ }
60
+ }
61
+
62
+ return files
63
+ }
64
+
65
+ function writeGeneratedFile(fileName: string, content: string) {
66
+ const outputFile = join(generatedDir, fileName)
67
+
68
+ ensureGeneratedDir()
69
+
70
+ if (existsSync(outputFile) && readFileSync(outputFile, 'utf8') === content) {
71
+ return
72
+ }
73
+
74
+ writeFileSync(outputFile, content)
75
+ console.log(`Generated ${relative(projectDir, outputFile)}.`)
76
+ }
77
+
78
+ function toImportPath(filePath: string) {
79
+ const path = filePath
80
+ .replace(parse(filePath).ext, '')
81
+ .replaceAll('\\', '/')
82
+
83
+ return path
84
+ }
85
+
86
+ function toIdentifier(parts: string[]) {
87
+ const value = parts
88
+ .join('_')
89
+ .replace(/[^a-zA-Z0-9_$]/g, '_')
90
+ .replace(/^([^a-zA-Z_$])/, '_$1')
91
+
92
+ return value || 'module'
93
+ }
94
+
95
+ function toCamelCase(value: string) {
96
+ return value.replace(/[-_]+([a-zA-Z0-9])/g, (_match, char: string) => char.toUpperCase())
97
+ }
98
+
99
+ function toPascalCase(value: string) {
100
+ const camel = toCamelCase(value)
101
+
102
+ return camel.charAt(0).toUpperCase() + camel.slice(1)
103
+ }
104
+
105
+ function toServiceKey(name: string) {
106
+ const baseName = name.endsWith('Service')
107
+ ? name.slice(0, -'Service'.length)
108
+ : name
109
+
110
+ return baseName.replace(/^./, (char) => char.toLowerCase())
111
+ }
112
+
113
+ function toMiddlewareKey(exportName: string) {
114
+ return exportName.endsWith('Middleware')
115
+ ? exportName.slice(0, -'Middleware'.length)
116
+ : exportName
117
+ }
118
+
119
+ function stripKnownSuffix(modulePath: string, suffixes: string[]) {
120
+ for (const suffix of suffixes) {
121
+ if (modulePath.endsWith(suffix)) {
122
+ return modulePath.slice(0, -suffix.length)
123
+ }
124
+ }
125
+
126
+ return modulePath
127
+ }
128
+
129
+ function normalizeModulePath(modulePath: string) {
130
+ const parts = modulePath.split('/')
131
+ const last = parts.at(-1)
132
+ const parent = parts.at(-2)
133
+
134
+ return last && parent && last === parent
135
+ ? parts.slice(0, -1).join('/')
136
+ : modulePath
137
+ }
138
+
139
+ function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []) {
140
+ const modulePath = relative(join(srcDir, baseDir), filePath)
141
+ .replace(parse(filePath).ext, '')
142
+ .replaceAll('\\', '/')
143
+ const withoutIndex = modulePath.endsWith('/index')
144
+ ? modulePath.slice(0, -'/index'.length)
145
+ : modulePath
146
+
147
+ const stripped = stripKnownSuffix(withoutIndex, suffixes)
148
+
149
+ return baseDir === 'modules' ? normalizeModulePath(stripped) : stripped
150
+ }
151
+
152
+ function uniqueSorted(values: string[]) {
153
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right))
154
+ }
155
+
156
+ function getFilesFromDirs(dirs: string[]) {
157
+ return uniqueSorted(dirs.flatMap((dirName) => walkFiles(join(srcDir, dirName))))
158
+ }
159
+
160
+ function isFileInDir(filePath: string, dirName: string) {
161
+ const rel = relative(join(srcDir, dirName), filePath)
162
+
163
+ return rel && !rel.startsWith('..')
164
+ }
165
+
166
+ function isSuffixedModuleFile(filePath: string, suffix: string) {
167
+ return relative(srcDir, filePath)
168
+ .replace(parse(filePath).ext, '')
169
+ .replaceAll('\\', '/')
170
+ .endsWith(suffix)
171
+ }
172
+
173
+ function isModuleFeatureFile(filePath: string, suffix: string) {
174
+ return !isFileInDir(filePath, 'modules') || isSuffixedModuleFile(filePath, suffix)
175
+ }
176
+
177
+ function hasDefaultExport(filePath: string) {
178
+ return /\bexport\s+default\b/.test(readFileSync(filePath, 'utf8'))
179
+ }
180
+
181
+ function indentObject(content: string, spaces = 2) {
182
+ if (!content) {
183
+ return ''
184
+ }
185
+
186
+ const indent = ' '.repeat(spaces)
187
+
188
+ return content
189
+ .split('\n')
190
+ .map((line) => `${indent}${line}`)
191
+ .join('\n')
192
+ }
193
+
194
+ function buildNestedObject(entries: { path: string[]; value: string }[]): string {
195
+ const groups = new Map<string, { path: string[]; value: string }[]>()
196
+ const leaves: { key: string; value: string }[] = []
197
+
198
+ for (const entry of entries) {
199
+ const [head, ...tail] = entry.path
200
+
201
+ if (!head) {
202
+ continue
203
+ }
204
+
205
+ if (tail.length === 0) {
206
+ leaves.push({ key: head, value: entry.value })
207
+ continue
208
+ }
209
+
210
+ groups.set(head, [...(groups.get(head) ?? []), { path: tail, value: entry.value }])
211
+ }
212
+
213
+ const lines = [
214
+ ...leaves.map((leaf) => `${toObjectKey(leaf.key)}: ${leaf.value},`),
215
+ ...[...groups.entries()].map(([key, groupEntries]) => {
216
+ return `${toObjectKey(key)}: {\n${indentObject(buildNestedObject(groupEntries), 2)}\n},`
217
+ }),
218
+ ]
219
+
220
+ return lines.join('\n')
221
+ }
222
+
223
+ function generateControllersRegistry() {
224
+ const used = new Set<string>()
225
+ const imports: string[] = []
226
+ const groupLines: string[] = []
227
+
228
+ for (const group of controllerGroups) {
229
+ const entries = getFilesFromDirs(group.dirs)
230
+ .filter((filePath) => basename(filePath) !== 'index.ts')
231
+ .filter((filePath) => !group.suffix || isModuleFeatureFile(filePath, group.suffix))
232
+ .filter(hasDefaultExport)
233
+ .map((filePath, index) => {
234
+ const baseDir = group.dirs.find((dirName) => {
235
+ return isFileInDir(filePath, dirName)
236
+ }) ?? group.dirs[0]
237
+ const modulePath = toModulePath(baseDir, filePath, group.suffix ? [group.suffix] : [])
238
+ const importName = toIdentifier([group.name, modulePath, String(index), 'Controller'])
239
+
240
+ return { modulePath, importName, filePath }
241
+ })
242
+ .filter((entry) => {
243
+ if (group.name !== 'controller') {
244
+ return true
245
+ }
246
+
247
+ const [scope] = entry.modulePath.split('/')
248
+
249
+ return !scopedControllerGroups.has(scope)
250
+ })
251
+ .filter((entry) => {
252
+ const key = `${group.name}:${entry.modulePath}`
253
+
254
+ if (used.has(key)) {
255
+ return false
256
+ }
257
+
258
+ used.add(key)
259
+
260
+ return true
261
+ })
262
+
263
+ imports.push(...entries.map((entry) => `import ${entry.importName} from '${toImportPath(entry.filePath)}'`))
264
+
265
+ if (entries.length === 0) {
266
+ continue
267
+ }
268
+
269
+ const objectBody = indentObject(
270
+ buildNestedObject(entries.map((entry) => ({
271
+ path: entry.modulePath.split('/').map(toCamelCase),
272
+ value: `createController(${entry.importName})`,
273
+ }))),
274
+ 4,
275
+ )
276
+
277
+ groupLines.push(` ${group.name}: {\n${objectBody}\n },`)
278
+ }
279
+
280
+ const helperImport = imports.length > 0
281
+ ? `import { createController } from 'jax-hono/core/controller'\n`
282
+ : ''
283
+ const content = `${generatedHeader}
284
+ ${helperImport}${imports.join('\n')}
285
+
286
+ export const controllers = {
287
+ ${groupLines.join('\n')}
288
+ }
289
+ `
290
+
291
+ writeGeneratedFile('controllers.generated.ts', content)
292
+ }
293
+
294
+ function toObjectKey(value: string) {
295
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(value) ? value : JSON.stringify(value)
296
+ }
297
+
298
+ function generateMiddlewaresRegistry() {
299
+ const files = getFilesFromDirs(['middlewares', 'middleware', 'modules'])
300
+ .filter((filePath) => isModuleFeatureFile(filePath, '.middleware'))
301
+ const imports: string[] = []
302
+ const entries: string[] = []
303
+
304
+ files.forEach((filePath, index) => {
305
+ const source = readFileSync(filePath, 'utf8')
306
+ const exportNames = [...source.matchAll(/\bexport\s+(?:const|function)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g)]
307
+ .map((match) => match[1])
308
+ .sort((left, right) => left.localeCompare(right))
309
+
310
+ if (exportNames.length === 0) {
311
+ return
312
+ }
313
+
314
+ imports.push(`import { ${exportNames.join(', ')} } from '${toImportPath(filePath)}'`)
315
+ entries.push(...exportNames.map((name) => ` ${toMiddlewareKey(name)}: ${name},`))
316
+ })
317
+
318
+ const content = `${generatedHeader}
319
+ ${imports.join('\n')}
320
+
321
+ export const middlewares = {
322
+ ${entries.join('\n')}
323
+ }
324
+ `
325
+
326
+ writeGeneratedFile('middlewares.generated.ts', content)
327
+ }
328
+
329
+ function generateModelsRegistry() {
330
+ const files = getFilesFromDirs(['models', 'model', 'modules'])
331
+ .filter((filePath) => isModuleFeatureFile(filePath, '.model'))
332
+ const imports: string[] = []
333
+ const entries: string[] = []
334
+
335
+ files.forEach((filePath) => {
336
+ const source = readFileSync(filePath, 'utf8')
337
+ const exportedModels = [...source.matchAll(/\bexport\s+const\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*create(?:Loose)?Model\s*\(/g)]
338
+ .map((match) => match[1])
339
+ .sort((left, right) => left.localeCompare(right))
340
+
341
+ if (exportedModels.length === 0) {
342
+ return
343
+ }
344
+
345
+ const modelKey = source.match(/\bexport\s+const\s+modelKey\s*=\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)['"]/)?.[1]
346
+ const fileModelName = toPascalCase(toModulePath(
347
+ filePath.includes(`${srcDir}/modules/`) ? 'modules' : basename(parse(filePath).dir),
348
+ filePath,
349
+ ['.model'],
350
+ ))
351
+
352
+ imports.push(`import { ${exportedModels.join(', ')} } from '${toImportPath(filePath)}'`)
353
+ entries.push(...exportedModels.map((name) => {
354
+ const key = exportedModels.length === 1 && modelKey
355
+ ? modelKey
356
+ : name === `${fileModelName}Model`
357
+ ? fileModelName
358
+ : name
359
+
360
+ return ` ${key}: ${name},`
361
+ }))
362
+ })
363
+
364
+ const content = `${generatedHeader}
365
+ ${imports.join('\n')}
366
+
367
+ export const models = {
368
+ ${entries.join('\n')}
369
+ }
370
+
371
+ export type Models = typeof models
372
+ `
373
+
374
+ writeGeneratedFile('models.generated.ts', content)
375
+ }
376
+
377
+ function generateServicesRegistry() {
378
+ const files = getFilesFromDirs(['services', 'service', 'modules'])
379
+ .filter((filePath) => isModuleFeatureFile(filePath, '.service'))
380
+ const imports: string[] = []
381
+ const entries: string[] = []
382
+
383
+ files.forEach((filePath) => {
384
+ const source = readFileSync(filePath, 'utf8')
385
+ const namedServiceClasses = [...source.matchAll(/\bexport\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*Service)\b/g)]
386
+ .map((match) => match[1])
387
+ .sort((left, right) => left.localeCompare(right))
388
+ const defaultServiceClass = source.match(/\bexport\s+default\s+class(?:\s+([a-zA-Z_$][a-zA-Z0-9_$]*))?/)
389
+ const defaultServiceName = defaultServiceClass
390
+ ? defaultServiceClass[1] ?? `${toPascalCase(toModulePath('services', filePath))}Service`
391
+ : ''
392
+ const defaultImportName = defaultServiceName
393
+ ? toIdentifier([defaultServiceName, 'Default'])
394
+ : ''
395
+
396
+ if (namedServiceClasses.length === 0 && !defaultServiceName) {
397
+ return
398
+ }
399
+
400
+ if (defaultServiceName) {
401
+ imports.push(`import ${defaultImportName} from '${toImportPath(filePath)}'`)
402
+ entries.push(` ${toServiceKey(defaultServiceName)}: new ${defaultImportName}(deps),`)
403
+ }
404
+
405
+ if (namedServiceClasses.length > 0) {
406
+ imports.push(`import { ${namedServiceClasses.join(', ')} } from '${toImportPath(filePath)}'`)
407
+ entries.push(...namedServiceClasses.map((name) => ` ${toServiceKey(name)}: new ${name}(deps),`))
408
+ }
409
+ })
410
+
411
+ const content = `${generatedHeader}
412
+ ${imports.join('\n')}
413
+
414
+ export function createServices(deps: any) {
415
+ return {
416
+ ${entries.join('\n')}
417
+ }
418
+ }
419
+
420
+ export type Services = ReturnType<typeof createServices>
421
+ `
422
+
423
+ writeGeneratedFile('services.generated.ts', content)
424
+ }
425
+
426
+ function findBalancedObject(source: string, startIndex: number) {
427
+ let depth = 0
428
+ let quote: string | undefined
429
+ let escaped = false
430
+ let lineComment = false
431
+ let blockComment = false
432
+
433
+ for (let index = startIndex; index < source.length; index += 1) {
434
+ const char = source[index]
435
+ const next = source[index + 1]
436
+
437
+ if (lineComment) {
438
+ if (char === '\n') {
439
+ lineComment = false
440
+ }
441
+ continue
442
+ }
443
+
444
+ if (blockComment) {
445
+ if (char === '*' && next === '/') {
446
+ blockComment = false
447
+ index += 1
448
+ }
449
+ continue
450
+ }
451
+
452
+ if (quote) {
453
+ if (escaped) {
454
+ escaped = false
455
+ continue
456
+ }
457
+
458
+ if (char === '\\') {
459
+ escaped = true
460
+ continue
461
+ }
462
+
463
+ if (char === quote) {
464
+ quote = undefined
465
+ }
466
+ continue
467
+ }
468
+
469
+ if (char === '/' && next === '/') {
470
+ lineComment = true
471
+ index += 1
472
+ continue
473
+ }
474
+
475
+ if (char === '/' && next === '*') {
476
+ blockComment = true
477
+ index += 1
478
+ continue
479
+ }
480
+
481
+ if (char === '"' || char === '\'' || char === '`') {
482
+ quote = char
483
+ continue
484
+ }
485
+
486
+ if (char === '{') {
487
+ depth += 1
488
+ continue
489
+ }
490
+
491
+ if (char === '}') {
492
+ depth -= 1
493
+
494
+ if (depth === 0) {
495
+ return source.slice(startIndex, index + 1)
496
+ }
497
+ }
498
+ }
499
+
500
+ return undefined
501
+ }
502
+
503
+ function extractPluginConfigObject(source: string) {
504
+ const configCallIndex = source.indexOf('definePluginConfig')
505
+ const searchStart = configCallIndex >= 0 ? configCallIndex : source.indexOf('export default')
506
+ const objectStart = searchStart >= 0 ? source.indexOf('{', searchStart) : -1
507
+
508
+ if (objectStart < 0) {
509
+ return undefined
510
+ }
511
+
512
+ return findBalancedObject(source, objectStart)
513
+ }
514
+
515
+ function readPluginConfig(filePath: string): PluginConfig {
516
+ if (!existsSync(filePath)) {
517
+ return {}
518
+ }
519
+
520
+ const objectSource = extractPluginConfigObject(readFileSync(filePath, 'utf8'))
521
+
522
+ if (!objectSource) {
523
+ return {}
524
+ }
525
+
526
+ try {
527
+ const value = new Function(`return (${objectSource})`)() as unknown
528
+
529
+ return value && typeof value === 'object' && !Array.isArray(value)
530
+ ? (value as PluginConfig)
531
+ : {}
532
+ } catch (error) {
533
+ throw new Error(`Invalid plugin config ${relative(srcDir, filePath)}: ${(error as Error).message}`)
534
+ }
535
+ }
536
+
537
+ function normalizePluginPackage(packageName: string, configFile: string) {
538
+ if (!packageName.startsWith('./') && !packageName.startsWith('../')) {
539
+ return packageName
540
+ }
541
+
542
+ const normalized = join(configFile, '..', packageName)
543
+ .replaceAll('\\', '/')
544
+ .replace(/\/index$/, '')
545
+
546
+ return normalized
547
+ }
548
+
549
+ function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]) {
550
+ const merged = new Map<string, Exclude<PluginConfigItem, false>>()
551
+
552
+ for (const { filePath, config } of configs) {
553
+ for (const [name, item] of Object.entries(config)) {
554
+ if (item === false) {
555
+ merged.delete(name)
556
+ continue
557
+ }
558
+
559
+ const current = merged.get(name) ?? {}
560
+ const next = { ...current, ...item }
561
+
562
+ if (next.package) {
563
+ next.package = normalizePluginPackage(next.package, filePath)
564
+ }
565
+
566
+ merged.set(name, next)
567
+ }
568
+ }
569
+
570
+ return [...merged.entries()]
571
+ .filter((entry): entry is [string, Exclude<PluginConfigItem, false> & { package: string }] => {
572
+ const [, item] = entry
573
+
574
+ return item.enable === true && typeof item.package === 'string' && item.package.length > 0
575
+ })
576
+ .sort(([left], [right]) => left.localeCompare(right))
577
+ }
578
+
579
+ function serializePluginOptions(options: unknown) {
580
+ return options === undefined ? '' : `, options: ${JSON.stringify(options)}`
581
+ }
582
+
583
+ function generatePluginContextRegistry() {
584
+ const enabledPlugins = mergePluginConfig([
585
+ { filePath: join(packageDir, 'plugins/config.ts'), config: readPluginConfig(join(packageDir, 'plugins/config.ts')) },
586
+ { filePath: join(srcDir, 'config/plugin.ts'), config: readPluginConfig(join(srcDir, 'config/plugin.ts')) },
587
+ ])
588
+ const imports = enabledPlugins.map(([name, item]) => {
589
+ return `import ${toIdentifier(['plugin', name])} from ${JSON.stringify(item.package)}`
590
+ })
591
+ const moduleEntries = enabledPlugins.map(
592
+ ([name, item]) =>
593
+ ` { name: ${JSON.stringify(name)}, module: ${toIdentifier(['plugin', name])}${serializePluginOptions(item.options)} },`,
594
+ )
595
+
596
+ const content = `${generatedHeader}${imports.length > 0 ? `${imports.join('\n')}\n\n` : ''}
597
+ export const plugins = {} as Record<string, unknown>
598
+
599
+ export type Plugins = typeof plugins
600
+
601
+ export const pluginModules = [
602
+ ${moduleEntries.join('\n')}
603
+ ] as const
604
+ `
605
+
606
+ writeGeneratedFile('plugins.generated.ts', content)
607
+ }
608
+
609
+ export function generateRegistry() {
610
+ generateControllersRegistry()
611
+ generateMiddlewaresRegistry()
612
+ generateModelsRegistry()
613
+ generateServicesRegistry()
614
+ generatePluginContextRegistry()
615
+ }
616
+
617
+ if (import.meta.main) {
618
+ generateRegistry()
619
+ }