@buzola/codegen 0.0.1

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 (76) hide show
  1. package/dist/__tests__/codegen.test.d.ts +2 -0
  2. package/dist/__tests__/codegen.test.d.ts.map +1 -0
  3. package/dist/__tests__/codegen.test.js +208 -0
  4. package/dist/__tests__/codegen.test.js.map +1 -0
  5. package/dist/__tests__/conventions.test.d.ts +2 -0
  6. package/dist/__tests__/conventions.test.d.ts.map +1 -0
  7. package/dist/__tests__/conventions.test.js +80 -0
  8. package/dist/__tests__/conventions.test.js.map +1 -0
  9. package/dist/__tests__/page-extractor.test.d.ts +2 -0
  10. package/dist/__tests__/page-extractor.test.d.ts.map +1 -0
  11. package/dist/__tests__/page-extractor.test.js +82 -0
  12. package/dist/__tests__/page-extractor.test.js.map +1 -0
  13. package/dist/__tests__/resolve.test.d.ts +2 -0
  14. package/dist/__tests__/resolve.test.d.ts.map +1 -0
  15. package/dist/__tests__/resolve.test.js +110 -0
  16. package/dist/__tests__/resolve.test.js.map +1 -0
  17. package/dist/__tests__/tree-builder.test.d.ts +2 -0
  18. package/dist/__tests__/tree-builder.test.d.ts.map +1 -0
  19. package/dist/__tests__/tree-builder.test.js +474 -0
  20. package/dist/__tests__/tree-builder.test.js.map +1 -0
  21. package/dist/cli.d.ts +3 -0
  22. package/dist/cli.d.ts.map +1 -0
  23. package/dist/cli.js +40 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/config.d.ts +20 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +20 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/conventions.d.ts +53 -0
  30. package/dist/conventions.d.ts.map +1 -0
  31. package/dist/conventions.js +82 -0
  32. package/dist/conventions.js.map +1 -0
  33. package/dist/generate.d.ts +28 -0
  34. package/dist/generate.d.ts.map +1 -0
  35. package/dist/generate.js +46 -0
  36. package/dist/generate.js.map +1 -0
  37. package/dist/generator/codegen.d.ts +19 -0
  38. package/dist/generator/codegen.d.ts.map +1 -0
  39. package/dist/generator/codegen.js +162 -0
  40. package/dist/generator/codegen.js.map +1 -0
  41. package/dist/generator/page-extractor.d.ts +21 -0
  42. package/dist/generator/page-extractor.d.ts.map +1 -0
  43. package/dist/generator/page-extractor.js +23 -0
  44. package/dist/generator/page-extractor.js.map +1 -0
  45. package/dist/generator/scanner.d.ts +11 -0
  46. package/dist/generator/scanner.d.ts.map +1 -0
  47. package/dist/generator/scanner.js +34 -0
  48. package/dist/generator/scanner.js.map +1 -0
  49. package/dist/generator/tree-builder.d.ts +54 -0
  50. package/dist/generator/tree-builder.d.ts.map +1 -0
  51. package/dist/generator/tree-builder.js +355 -0
  52. package/dist/generator/tree-builder.js.map +1 -0
  53. package/dist/index.d.ts +17 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +9 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/resolve.d.ts +49 -0
  58. package/dist/resolve.d.ts.map +1 -0
  59. package/dist/resolve.js +41 -0
  60. package/dist/resolve.js.map +1 -0
  61. package/package.json +34 -0
  62. package/src/__tests__/codegen.test.ts +229 -0
  63. package/src/__tests__/conventions.test.ts +93 -0
  64. package/src/__tests__/page-extractor.test.ts +95 -0
  65. package/src/__tests__/resolve.test.ts +120 -0
  66. package/src/__tests__/tree-builder.test.ts +554 -0
  67. package/src/cli.ts +43 -0
  68. package/src/config.ts +35 -0
  69. package/src/conventions.ts +116 -0
  70. package/src/generate.ts +67 -0
  71. package/src/generator/codegen.ts +213 -0
  72. package/src/generator/page-extractor.ts +48 -0
  73. package/src/generator/scanner.ts +45 -0
  74. package/src/generator/tree-builder.ts +449 -0
  75. package/src/index.ts +23 -0
  76. package/src/resolve.ts +75 -0
@@ -0,0 +1,449 @@
1
+ import * as path from 'node:path'
2
+ import { parseDirName, parseFileName } from '../conventions.js'
3
+ import type { ExtractedPage, ModuleLoader } from './page-extractor.js'
4
+ import { extractPages } from './page-extractor.js'
5
+ import type { ScannedFile } from './scanner.js'
6
+
7
+ /**
8
+ * Page export info attached to a file route node.
9
+ */
10
+ export interface PageExportInfo {
11
+ /** Page ID (e.g., "project/detail"). */
12
+ pageId: string
13
+ /** Export name (e.g., "default", "detail"). */
14
+ exportName: string
15
+ /** Route pattern (e.g., "/project/:id"). */
16
+ routePattern: string
17
+ /** Param info extracted from the page schema. */
18
+ params: { name: string; optional: boolean; array: boolean }[]
19
+ }
20
+
21
+ /**
22
+ * A node in the route tree (before compilation into RouteNode).
23
+ */
24
+ export interface FileRouteNode {
25
+ /** Route path segment (from file name, or derived from .route() pattern). */
26
+ segment: string
27
+ /** Full path from root. */
28
+ fullPath: string
29
+ /** Absolute file path to the component. */
30
+ filePath?: string
31
+ /** Which export to use from the file ("default" or a named export). */
32
+ exportName?: string
33
+ /** Whether this is a layout. */
34
+ isLayout: boolean
35
+ /** Whether this is an index route. */
36
+ isIndex: boolean
37
+ /** Whether this is a not-found catch-all. */
38
+ isNotFound: boolean
39
+ /** Whether this is a pathless group. */
40
+ isPathlessGroup: boolean
41
+ /** Page exports discovered in this file. */
42
+ pageExports?: PageExportInfo[]
43
+ /** Children nodes. */
44
+ children: FileRouteNode[]
45
+ }
46
+
47
+ // ─── Flat route entry (intermediate representation) ─────────────────────────
48
+
49
+ interface RouteEntry {
50
+ routePattern: string
51
+ /** File-hierarchy-based position for tree building (may differ from routePattern when .route() is used). */
52
+ treePosition: string
53
+ filePath: string
54
+ exportName: string
55
+ kind: 'page' | 'layout' | 'notFound'
56
+ pageId?: string
57
+ params: { name: string; optional: boolean; array: boolean }[]
58
+ /** File-system-derived URL path (ignoring pathless groups). */
59
+ fileScope: string
60
+ /** Chain of pathless group directory paths this file is nested under. */
61
+ pathlessGroups: string[]
62
+ /** For pathless group layouts: the directory path of the group. */
63
+ pathlessGroupId?: string
64
+ }
65
+
66
+ // ─── Public API ─────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Build a route tree from scanned files.
70
+ * Loads each module via moduleLoader to extract createPage() metadata.
71
+ *
72
+ * Two-phase approach:
73
+ * 1. Extract flat route entries from all files.
74
+ * 2. Build the tree using route-pattern prefix matching (not file hierarchy).
75
+ */
76
+ export async function buildFileRouteTree(files: ScannedFile[], moduleLoader: ModuleLoader): Promise<FileRouteNode[]> {
77
+ const entries = await extractEntries(files, moduleLoader)
78
+ return buildTreeFromEntries(entries)
79
+ }
80
+
81
+ // ─── Phase 1: Extract flat route entries ────────────────────────────────────
82
+
83
+ async function extractEntries(files: ScannedFile[], moduleLoader: ModuleLoader): Promise<RouteEntry[]> {
84
+ const perFile = await Promise.all(files.map(file => extractFileEntries(file, moduleLoader)))
85
+ return perFile.flat()
86
+ }
87
+
88
+ async function extractFileEntries(file: ScannedFile, moduleLoader: ModuleLoader): Promise<RouteEntry[]> {
89
+ const ext = path.extname(file.relativePath)
90
+ const withoutExt = file.relativePath.slice(0, -ext.length)
91
+ const parts = withoutExt.split(path.sep)
92
+ const fileName = parts.pop()!
93
+ const dirs = parts
94
+ const fileInfo = parseFileName(fileName)
95
+
96
+ let fileScope = ''
97
+ const pathlessGroups: string[] = []
98
+ let groupPath = ''
99
+
100
+ for (const dir of dirs) {
101
+ groupPath = groupPath ? `${groupPath}/${dir}` : dir
102
+ const dirInfo = parseDirName(dir)
103
+ if (dirInfo.isPathlessGroup) {
104
+ pathlessGroups.push(groupPath)
105
+ } else {
106
+ fileScope = joinPath(fileScope, dirInfo.segment)
107
+ }
108
+ }
109
+ if (!fileScope) fileScope = '/'
110
+
111
+ if (fileInfo.isLayout) {
112
+ const lastDir = dirs.length > 0 ? dirs[dirs.length - 1] : null
113
+ const isPathlessGroupLayout = lastDir != null && parseDirName(lastDir).isPathlessGroup
114
+
115
+ return [{
116
+ routePattern: fileScope,
117
+ treePosition: fileScope,
118
+ filePath: file.absolutePath,
119
+ exportName: 'default',
120
+ kind: 'layout',
121
+ params: [],
122
+ fileScope,
123
+ pathlessGroups,
124
+ pathlessGroupId: isPathlessGroupLayout ? groupPath : undefined,
125
+ }]
126
+ }
127
+
128
+ if (fileInfo.isNotFound) {
129
+ const catchAllPattern = fileScope === '/' ? '/:__notFound+' : `${fileScope}/:__notFound+`
130
+ const pageId = dirs.length === 0 ? '404' : `${dirs.join('/')}/404`
131
+ return [{
132
+ routePattern: catchAllPattern,
133
+ treePosition: catchAllPattern,
134
+ filePath: file.absolutePath,
135
+ exportName: 'default',
136
+ kind: 'notFound',
137
+ pageId,
138
+ params: [{ name: '__notFound', optional: false, array: false }],
139
+ fileScope,
140
+ pathlessGroups,
141
+ }]
142
+ }
143
+
144
+ const isIndex = fileInfo.isIndex
145
+ const basePath = isIndex ? fileScope : joinPath(fileScope, fileName)
146
+ const pages = await safeExtractPages(file, moduleLoader)
147
+
148
+ if (pages.length === 0) {
149
+ return [{
150
+ routePattern: basePath,
151
+ treePosition: basePath,
152
+ filePath: file.absolutePath,
153
+ exportName: 'default',
154
+ kind: 'page',
155
+ params: [],
156
+ fileScope,
157
+ pathlessGroups,
158
+ }]
159
+ }
160
+
161
+ return pages.map(page => {
162
+ const pageId = derivePageId(dirs, fileName, isIndex, page.exportName)
163
+ const defaultPattern = page.isDefault ? basePath : joinPath(basePath, page.exportName)
164
+ return {
165
+ routePattern: page.route ?? defaultPattern,
166
+ treePosition: defaultPattern,
167
+ filePath: file.absolutePath,
168
+ exportName: page.exportName,
169
+ kind: 'page',
170
+ pageId,
171
+ params: page.params,
172
+ fileScope,
173
+ pathlessGroups,
174
+ }
175
+ })
176
+ }
177
+
178
+ // ─── Phase 2: Build tree from flat entries ──────────────────────────────────
179
+
180
+ interface LayoutRef {
181
+ node: FileRouteNode
182
+ pathlessGroupId?: string
183
+ }
184
+
185
+ function buildTreeFromEntries(entries: RouteEntry[]): FileRouteNode[] {
186
+ const root: FileRouteNode = {
187
+ segment: '',
188
+ fullPath: '/',
189
+ isLayout: false,
190
+ isIndex: false,
191
+ isNotFound: false,
192
+ isPathlessGroup: false,
193
+ children: [],
194
+ }
195
+
196
+ const layouts = entries.filter(e => e.kind === 'layout')
197
+ const pages = entries.filter(e => e.kind !== 'layout')
198
+
199
+ // Sort layouts: shorter routePattern first, non-pathless before pathless
200
+ layouts.sort((a, b) => {
201
+ if (a.routePattern.length !== b.routePattern.length) return a.routePattern.length - b.routePattern.length
202
+ if (a.pathlessGroupId && !b.pathlessGroupId) return 1
203
+ if (!a.pathlessGroupId && b.pathlessGroupId) return -1
204
+ return 0
205
+ })
206
+
207
+ // Insert layouts into tree
208
+ // Track all layout nodes for page assignment
209
+ const layoutRefs: LayoutRef[] = [{ node: root }]
210
+
211
+ for (const layout of layouts) {
212
+ if (layout.pathlessGroupId) {
213
+ // Pathless group layout — find parent by walking layoutRefs, respecting pathless group chain
214
+ const parent = findParentForPathlessGroupLayout(layoutRefs, layout)
215
+ const node: FileRouteNode = {
216
+ segment: '',
217
+ fullPath: layout.treePosition,
218
+ filePath: layout.filePath,
219
+ isLayout: true,
220
+ isIndex: false,
221
+ isNotFound: false,
222
+ isPathlessGroup: true,
223
+ children: [],
224
+ }
225
+ parent.node.children.push(node)
226
+ layoutRefs.push({ node, pathlessGroupId: layout.pathlessGroupId })
227
+ } else if (layout.treePosition === '/' && !root.filePath) {
228
+ // Root layout
229
+ root.isLayout = true
230
+ root.filePath = layout.filePath
231
+ } else if (layout.treePosition === '/') {
232
+ // Additional layout at root (shouldn't happen normally, but handle)
233
+ root.isLayout = true
234
+ root.filePath = layout.filePath
235
+ } else {
236
+ // Regular layout — find parent by prefix matching
237
+ const parent = findDeepestLayoutParent(layoutRefs, layout.treePosition, layout.pathlessGroups)
238
+ const segment = computeSegmentFromParent(parent.node.fullPath, layout.treePosition)
239
+ const node: FileRouteNode = {
240
+ segment,
241
+ fullPath: layout.treePosition,
242
+ filePath: layout.filePath,
243
+ isLayout: true,
244
+ isIndex: false,
245
+ isNotFound: false,
246
+ isPathlessGroup: false,
247
+ children: [],
248
+ }
249
+ parent.node.children.push(node)
250
+ layoutRefs.push({ node })
251
+ }
252
+ }
253
+
254
+ // Insert pages into tree (using file-hierarchy position, not route pattern)
255
+ for (const entry of pages) {
256
+ const parent = findDeepestLayoutParent(layoutRefs, entry.treePosition, entry.pathlessGroups)
257
+ const isIndex = entry.treePosition === parent.node.fullPath
258
+ const segment = isIndex ? '' : computeSegmentFromParent(parent.node.fullPath, entry.treePosition)
259
+
260
+ const pageExports: PageExportInfo[] | undefined = entry.pageId
261
+ ? [{
262
+ pageId: entry.pageId,
263
+ exportName: entry.exportName,
264
+ routePattern: entry.routePattern,
265
+ params: entry.params,
266
+ }]
267
+ : undefined
268
+
269
+ // For named exports from the same file as an existing node, check collision
270
+ if (!isIndex) {
271
+ checkCollision(parent.node.children, segment, entry.filePath, entry.exportName)
272
+ }
273
+
274
+ // Try to reuse an existing container node (e.g., from a directory that has no _layout.tsx)
275
+ const existing = !isIndex
276
+ ? parent.node.children.find(c => c.segment === segment && !c.filePath && !c.isLayout && !c.isPathlessGroup)
277
+ : undefined
278
+
279
+ if (existing) {
280
+ existing.filePath = entry.filePath
281
+ existing.exportName = entry.exportName
282
+ existing.pageExports = pageExports
283
+ } else {
284
+ parent.node.children.push({
285
+ segment,
286
+ fullPath: entry.treePosition,
287
+ filePath: entry.filePath,
288
+ exportName: entry.exportName,
289
+ isLayout: false,
290
+ isIndex,
291
+ isNotFound: entry.kind === 'notFound',
292
+ isPathlessGroup: false,
293
+ pageExports,
294
+ children: [],
295
+ })
296
+ }
297
+ }
298
+
299
+ // Sort children recursively
300
+ sortChildren(root)
301
+
302
+ if (root.isLayout && root.filePath) {
303
+ return [root]
304
+ }
305
+
306
+ return root.children
307
+ }
308
+
309
+ /**
310
+ * Find the parent layout for a pathless group layout.
311
+ * The parent is the deepest layout at the same routePattern level,
312
+ * considering the pathless group nesting chain.
313
+ */
314
+ function findParentForPathlessGroupLayout(layoutRefs: LayoutRef[], entry: RouteEntry): LayoutRef {
315
+ // The parent is the layout whose pathlessGroupId is the previous entry in the chain,
316
+ // or the deepest non-pathless layout at the same routePattern if this is the first group.
317
+ const groupChain = entry.pathlessGroups
318
+ const thisGroupIndex = groupChain.indexOf(entry.pathlessGroupId!)
319
+
320
+ if (thisGroupIndex > 0) {
321
+ // Has a parent pathless group — find it
322
+ const parentGroupId = groupChain[thisGroupIndex - 1]
323
+ const parentRef = layoutRefs.find(r => r.pathlessGroupId === parentGroupId)
324
+ if (parentRef) return parentRef
325
+ }
326
+
327
+ // First pathless group in chain (or parent not found) — find the deepest regular layout at prefix
328
+ let best = layoutRefs[0] // root
329
+ for (const ref of layoutRefs) {
330
+ if (ref.pathlessGroupId) continue // skip other pathless groups
331
+ if (isPathPrefixOrEqual(ref.node.fullPath, entry.routePattern) && ref.node.fullPath.length >= best.node.fullPath.length) {
332
+ best = ref
333
+ }
334
+ }
335
+ return best
336
+ }
337
+
338
+ /**
339
+ * Find the deepest layout whose routePattern is a prefix of the given pattern.
340
+ * Respects pathless group membership.
341
+ */
342
+ function findDeepestLayoutParent(layoutRefs: LayoutRef[], routePattern: string, pathlessGroups: string[]): LayoutRef {
343
+ let best = layoutRefs[0] // root
344
+
345
+ for (const ref of layoutRefs) {
346
+ if (!isPathPrefixOrEqual(ref.node.fullPath, routePattern)) continue
347
+
348
+ // For pathless groups, only match if the entry belongs to this group
349
+ if (ref.pathlessGroupId && !pathlessGroups.includes(ref.pathlessGroupId)) continue
350
+
351
+ // Prefer deeper matches. For ties (pathless groups at same depth), prefer pathless group
352
+ // that the entry belongs to over non-pathless.
353
+ if (ref.node.fullPath.length > best.node.fullPath.length) {
354
+ best = ref
355
+ } else if (ref.node.fullPath.length === best.node.fullPath.length && ref.pathlessGroupId && pathlessGroups.includes(ref.pathlessGroupId)) {
356
+ best = ref
357
+ }
358
+ }
359
+
360
+ return best
361
+ }
362
+
363
+ function isPathPrefixOrEqual(prefix: string, path: string): boolean {
364
+ if (prefix === '/') return true
365
+ return path === prefix || path.startsWith(prefix + '/')
366
+ }
367
+
368
+ function computeSegmentFromParent(parentFullPath: string, childFullPath: string): string {
369
+ if (parentFullPath === '/') return childFullPath.slice(1)
370
+ return childFullPath.slice(parentFullPath.length + 1)
371
+ }
372
+
373
+ // ─── Helpers ────────────────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * Derive page ID from file path and export name.
377
+ */
378
+ function derivePageId(dirs: string[], fileName: string, isIndex: boolean, exportName: string): string {
379
+ const segments = [...dirs]
380
+
381
+ if (isIndex) {
382
+ if (segments.length === 0) {
383
+ return exportName === 'default' ? 'index' : `index/${exportName}`
384
+ }
385
+ const base = segments.join('/')
386
+ return exportName === 'default' ? base : `${base}/${exportName}`
387
+ }
388
+
389
+ segments.push(fileName)
390
+ const base = segments.join('/')
391
+
392
+ return exportName === 'default' ? base : `${base}/${exportName}`
393
+ }
394
+
395
+ async function safeExtractPages(file: ScannedFile, moduleLoader: ModuleLoader): Promise<ExtractedPage[]> {
396
+ try {
397
+ return await extractPages(file.absolutePath, moduleLoader)
398
+ } catch (error) {
399
+ const message = error instanceof Error ? error.message : String(error)
400
+ const stack = error instanceof Error && error.stack ? `\n${error.stack}` : ''
401
+ console.warn(
402
+ `[buzola] Failed to extract page metadata from "${file.relativePath}". `
403
+ + `The file will be treated as a plain component (no typed params, no loader, no custom route). `
404
+ + `Cause: ${message}${stack}`,
405
+ )
406
+ return []
407
+ }
408
+ }
409
+
410
+ function checkCollision(siblings: FileRouteNode[], segment: string, filePath: string, exportName: string): void {
411
+ const existing = siblings.find(c => c.segment === segment && !c.isLayout && !c.isPathlessGroup)
412
+ if (!existing || !existing.filePath) return
413
+
414
+ const existingDesc = existing.exportName && existing.exportName !== 'default'
415
+ ? `named export '${existing.exportName}' in '${existing.filePath}'`
416
+ : `'${existing.filePath}'`
417
+ const newDesc = exportName !== 'default'
418
+ ? `named export '${exportName}' in '${filePath}'`
419
+ : `'${filePath}'`
420
+
421
+ throw new Error(`Route collision: ${newDesc} conflicts with ${existingDesc} — both resolve to segment '${segment}'`)
422
+ }
423
+
424
+ function sortChildren(node: FileRouteNode): void {
425
+ for (const child of node.children) {
426
+ sortChildren(child)
427
+ }
428
+ node.children.sort((a, b) => sortWeight(a) - sortWeight(b))
429
+ }
430
+
431
+ const SORT_INDEX = 0
432
+ const SORT_STATIC = 1
433
+ const SORT_DYNAMIC = 2
434
+ const SORT_CATCHALL = 3
435
+ const SORT_NOT_FOUND = 4
436
+
437
+ function sortWeight(node: FileRouteNode): number {
438
+ if (node.isIndex) return SORT_INDEX
439
+ if (node.isNotFound) return SORT_NOT_FOUND
440
+ if (node.segment.includes(':') && node.segment.includes('+')) return SORT_CATCHALL
441
+ if (node.segment.includes(':')) return SORT_DYNAMIC
442
+ return SORT_STATIC
443
+ }
444
+
445
+ function joinPath(parent: string, child: string): string {
446
+ if (!child) return parent || '/'
447
+ if (!parent || parent === '/') return `/${child}`
448
+ return `${parent}/${child}`
449
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { generate } from './generate.js'
2
+ export type { GenerateOptions } from './generate.js'
3
+
4
+ export { loadConfig } from './config.js'
5
+ export type { BuzolaConfig } from './config.js'
6
+
7
+ export { buildVirtualSource, BUZOLA_NAMESPACE, resolveOptions, resolveVirtualModuleId, VIRTUAL_ID_PATTERN, VIRTUAL_PREFIX } from './resolve.js'
8
+ export type { BasePluginOptions, ResolvedOptions, ResolveOptionsInput, VirtualModuleId } from './resolve.js'
9
+
10
+ export { scanRouteFiles } from './generator/scanner.js'
11
+ export type { ScannedFile } from './generator/scanner.js'
12
+
13
+ export { buildFileRouteTree } from './generator/tree-builder.js'
14
+ export type { FileRouteNode, PageExportInfo } from './generator/tree-builder.js'
15
+
16
+ export { generateRouteModule } from './generator/codegen.js'
17
+ export type { CodegenOptions } from './generator/codegen.js'
18
+
19
+ export { extractPages } from './generator/page-extractor.js'
20
+ export type { ExtractedPage, ExtractedParam, ModuleLoader } from './generator/page-extractor.js'
21
+
22
+ export { isRouteFile, parseDirName, parseFileName, parseRouteFile } from './conventions.js'
23
+ export type { ParsedRouteFile } from './conventions.js'
package/src/resolve.ts ADDED
@@ -0,0 +1,75 @@
1
+ import * as path from 'node:path'
2
+ import type { BuzolaConfig } from './config.js'
3
+ import { loadConfig } from './config.js'
4
+ import type { GenerateOptions } from './generate.js'
5
+
6
+ export interface ResolveOptionsInput extends BuzolaConfig {
7
+ /** Project root used to resolve relative paths and locate `buzola.config.*`. Defaults to `process.cwd()`. */
8
+ root?: string
9
+ }
10
+
11
+ /** Shared base for bundler-plugin option types. */
12
+ export interface BasePluginOptions extends ResolveOptionsInput {
13
+ /**
14
+ * Unique name for this plugin instance. Required when registering multiple
15
+ * entrypoints in the same bundler config. Affects the plugin name
16
+ * (`buzola:${name}`) and the virtual module ID (`virtual:buzola/${name}/routes`).
17
+ *
18
+ * When omitted, the plugin uses `buzola` as its name and
19
+ * `virtual:buzola/routes` as the virtual module ID.
20
+ */
21
+ name?: string
22
+ }
23
+
24
+ export interface ResolvedOptions extends GenerateOptions {
25
+ root: string
26
+ }
27
+
28
+ /**
29
+ * Resolve plugin/CLI input against `buzola.config.*` and built-in defaults.
30
+ * Returns absolute paths plus the resolved project root.
31
+ */
32
+ export async function resolveOptions(input: ResolveOptionsInput = {}): Promise<ResolvedOptions> {
33
+ const root = path.resolve(input.root ?? process.cwd())
34
+ const fileConfig = await loadConfig(root)
35
+ return {
36
+ root,
37
+ routesDir: path.resolve(root, input.routesDir ?? fileConfig.routesDir ?? 'src/routes'),
38
+ outputPath: path.resolve(root, input.output ?? fileConfig.output ?? 'src/buzola.gen.ts'),
39
+ persistentParams: input.persistentParams ?? fileConfig.persistentParams,
40
+ }
41
+ }
42
+
43
+ /** Namespace owned by every Buzola plugin instance. */
44
+ export const BUZOLA_NAMESPACE = 'buzola'
45
+ /** Prefix shared by every Buzola virtual module ID. */
46
+ export const VIRTUAL_PREFIX = 'virtual:buzola/'
47
+ /** Regex matching every Buzola virtual module ID, suitable for bundler `filter` options. */
48
+ export const VIRTUAL_ID_PATTERN = /^virtual:buzola\//
49
+
50
+ export interface VirtualModuleId {
51
+ /** Plugin name (e.g. `buzola` or `buzola:admin`). */
52
+ pluginName: string
53
+ /** Virtual module ID (e.g. `virtual:buzola/routes` or `virtual:buzola/admin/routes`). */
54
+ id: string
55
+ }
56
+
57
+ /**
58
+ * Derive plugin name and virtual module ID. `name` distinguishes multiple
59
+ * entrypoints in the same bundler config.
60
+ */
61
+ export function resolveVirtualModuleId(name: string | undefined): VirtualModuleId {
62
+ return {
63
+ pluginName: name ? `${BUZOLA_NAMESPACE}:${name}` : BUZOLA_NAMESPACE,
64
+ id: name ? `${VIRTUAL_PREFIX}${name}/routes` : `${VIRTUAL_PREFIX}routes`,
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Build the virtual module source — re-exports `routes` and `pageRegistry`
70
+ * from the generated file at `outputPath`.
71
+ */
72
+ export function buildVirtualSource(outputPath: string): string {
73
+ const importPath = outputPath.replace(/\.ts$/, '')
74
+ return `export { routes, pageRegistry } from ${JSON.stringify(importPath)};\n`
75
+ }