@buzola/vite-plugin 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 (56) 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 +121 -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__/tree-builder.test.d.ts +2 -0
  14. package/dist/__tests__/tree-builder.test.d.ts.map +1 -0
  15. package/dist/__tests__/tree-builder.test.js +335 -0
  16. package/dist/__tests__/tree-builder.test.js.map +1 -0
  17. package/dist/conventions.d.ts +53 -0
  18. package/dist/conventions.d.ts.map +1 -0
  19. package/dist/conventions.js +82 -0
  20. package/dist/conventions.js.map +1 -0
  21. package/dist/generator/codegen.d.ts +23 -0
  22. package/dist/generator/codegen.d.ts.map +1 -0
  23. package/dist/generator/codegen.js +164 -0
  24. package/dist/generator/codegen.js.map +1 -0
  25. package/dist/generator/page-extractor.d.ts +21 -0
  26. package/dist/generator/page-extractor.d.ts.map +1 -0
  27. package/dist/generator/page-extractor.js +23 -0
  28. package/dist/generator/page-extractor.js.map +1 -0
  29. package/dist/generator/scanner.d.ts +11 -0
  30. package/dist/generator/scanner.d.ts.map +1 -0
  31. package/dist/generator/scanner.js +34 -0
  32. package/dist/generator/scanner.js.map +1 -0
  33. package/dist/generator/tree-builder.d.ts +50 -0
  34. package/dist/generator/tree-builder.d.ts.map +1 -0
  35. package/dist/generator/tree-builder.js +273 -0
  36. package/dist/generator/tree-builder.js.map +1 -0
  37. package/dist/index.d.ts +13 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +7 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/plugin.d.ts +26 -0
  42. package/dist/plugin.d.ts.map +1 -0
  43. package/dist/plugin.js +101 -0
  44. package/dist/plugin.js.map +1 -0
  45. package/package.json +24 -0
  46. package/src/__tests__/codegen.test.ts +134 -0
  47. package/src/__tests__/conventions.test.ts +93 -0
  48. package/src/__tests__/page-extractor.test.ts +95 -0
  49. package/src/__tests__/tree-builder.test.ts +390 -0
  50. package/src/conventions.ts +116 -0
  51. package/src/generator/codegen.ts +214 -0
  52. package/src/generator/page-extractor.ts +48 -0
  53. package/src/generator/scanner.ts +45 -0
  54. package/src/generator/tree-builder.ts +354 -0
  55. package/src/index.ts +17 -0
  56. package/src/plugin.ts +132 -0
@@ -0,0 +1,214 @@
1
+ import * as path from 'node:path'
2
+ import type { FileRouteNode } from './tree-builder'
3
+
4
+ export interface CodegenOptions {
5
+ /** The route tree from file scanning. */
6
+ tree: FileRouteNode[]
7
+ /** Absolute path to the routes directory. */
8
+ routesDir: string
9
+ /** Absolute path to the output file. */
10
+ outputPath: string
11
+ /** Parameter names that should be persistent across navigations. */
12
+ persistentParams?: string[]
13
+ }
14
+
15
+ /**
16
+ * Generate the buzola.gen.ts file with:
17
+ * 1. Module augmentation for BuzolaPageMap (type safety)
18
+ * 2. Page registry (page ID → route pattern)
19
+ * 3. Route tree construction code
20
+ */
21
+ export function generateRouteModule(options: CodegenOptions): string {
22
+ const { tree, outputPath } = options
23
+ const outputDir = path.dirname(outputPath)
24
+ let importCounter = 0
25
+
26
+ const lines: string[] = []
27
+
28
+ lines.push('// This file is auto-generated by @buzola/vite-plugin. Do not edit.')
29
+ lines.push('/* eslint-disable */')
30
+ lines.push('')
31
+
32
+ // Framework imports first — needed by lazy component declarations below
33
+ lines.push(`import { lazy, type ComponentType } from 'react';`)
34
+ lines.push(`import { buildRouteTree } from '@buzola/router';`)
35
+ lines.push(`import type { RouteConfig } from '@buzola/router';`)
36
+ lines.push('')
37
+
38
+ // Collect route imports — importsByKey maps "filePath::exportName" → import info
39
+ const imports: { varName: string; filePath: string; isLazy: boolean; exportName: string }[] = []
40
+ const importsByKey = new Map<string, { varName: string; isLazy: boolean }>()
41
+ collectImports(tree, outputDir, imports, importsByKey, () => importCounter++)
42
+
43
+ // Generate route imports
44
+ for (const imp of imports) {
45
+ if (imp.isLazy) {
46
+ lines.push(
47
+ `const ${imp.varName} = () => import('${imp.filePath}').then(m => ({ default: m.${imp.exportName}.component })) as Promise<{ default: ComponentType }>;`,
48
+ )
49
+ } else {
50
+ if (imp.exportName !== 'default') {
51
+ lines.push(`import { ${imp.exportName} as ${imp.varName} } from '${imp.filePath}';`)
52
+ } else {
53
+ lines.push(`import ${imp.varName} from '${imp.filePath}';`)
54
+ }
55
+ }
56
+ }
57
+
58
+ lines.push('')
59
+
60
+ // Collect page info for type augmentation and registry
61
+ const pages = collectPages(tree)
62
+
63
+ // Generate page map type augmentation
64
+ lines.push('// Module augmentation for type-safe page-centric routing')
65
+ lines.push(`declare module '@buzola/router' {`)
66
+ lines.push(' interface BuzolaPageMap {')
67
+ for (const page of pages) {
68
+ if (page.params.length === 0) {
69
+ lines.push(` '${page.pageId}': {};`)
70
+ } else {
71
+ const paramEntries = page.params.map(p => {
72
+ const type = p.array ? 'string[]' : 'string'
73
+ return p.optional ? `${p.name}?: ${type}` : `${p.name}: ${type}`
74
+ }).join('; ')
75
+ lines.push(` '${page.pageId}': { ${paramEntries} };`)
76
+ }
77
+ }
78
+ lines.push(' }')
79
+ if (options.persistentParams && options.persistentParams.length > 0) {
80
+ lines.push(' interface BuzolaPersistentParams {')
81
+ for (const param of options.persistentParams) {
82
+ lines.push(` ${param}: true;`)
83
+ }
84
+ lines.push(' }')
85
+ }
86
+ lines.push('}')
87
+ lines.push('')
88
+
89
+ // Generate page registry
90
+ lines.push('// Page registry — page ID → route pattern')
91
+ lines.push('export const pageRegistry: Record<string, string> = {')
92
+ for (const page of pages) {
93
+ lines.push(` '${page.pageId}': '${page.routePattern}',`)
94
+ }
95
+ lines.push('};')
96
+ lines.push('')
97
+
98
+ // Generate route tree config
99
+ lines.push('const routeConfigs: RouteConfig[] = [')
100
+ generateTreeConfig(tree, lines, importsByKey, 1)
101
+ lines.push('];')
102
+ lines.push('')
103
+ lines.push('export const routes = buildRouteTree(routeConfigs);')
104
+ lines.push('')
105
+
106
+ return lines.join('\n')
107
+ }
108
+
109
+ interface PageInfo {
110
+ pageId: string
111
+ routePattern: string
112
+ params: { name: string; optional: boolean; array: boolean }[]
113
+ }
114
+
115
+ /**
116
+ * Collect page info from the file route tree for codegen.
117
+ */
118
+ function collectPages(nodes: FileRouteNode[], parentPath = ''): PageInfo[] {
119
+ const result: PageInfo[] = []
120
+
121
+ for (const node of nodes) {
122
+ if (node.filePath && !node.isLayout && !node.isNotFound) {
123
+ if (node.pageExports) {
124
+ for (const exp of node.pageExports) {
125
+ result.push({
126
+ pageId: exp.pageId,
127
+ routePattern: exp.routePattern,
128
+ params: exp.params,
129
+ })
130
+ }
131
+ }
132
+ }
133
+
134
+ if (node.children.length > 0) {
135
+ result.push(...collectPages(node.children, node.fullPath))
136
+ }
137
+ }
138
+
139
+ return result
140
+ }
141
+
142
+ function importKey(filePath: string, exportName?: string): string {
143
+ return `${filePath}::${exportName ?? 'default'}`
144
+ }
145
+
146
+ function collectImports(
147
+ nodes: FileRouteNode[],
148
+ outputDir: string,
149
+ imports: { varName: string; filePath: string; isLazy: boolean; exportName: string }[],
150
+ importsByKey: Map<string, { varName: string; isLazy: boolean }>,
151
+ nextId: () => number,
152
+ ): void {
153
+ for (const node of nodes) {
154
+ if (node.filePath) {
155
+ const key = importKey(node.filePath, node.exportName)
156
+ if (!importsByKey.has(key)) {
157
+ const relativePath = path.relative(outputDir, node.filePath)
158
+ const importPath = relativePath.startsWith('.') ? relativePath : `./${relativePath}`
159
+ // Strip extension for import
160
+ const cleanPath = importPath.replace(/\.(tsx?|jsx?)$/, '')
161
+ const varName = `Route${nextId()}`
162
+ const isLazy = !node.isLayout // Layouts are eagerly loaded, pages are lazy
163
+ const exportName = node.exportName ?? 'default'
164
+ imports.push({ varName, filePath: cleanPath, isLazy, exportName })
165
+ importsByKey.set(key, { varName, isLazy })
166
+ }
167
+ }
168
+
169
+ collectImports(node.children, outputDir, imports, importsByKey, nextId)
170
+ }
171
+ }
172
+
173
+ function generateTreeConfig(
174
+ nodes: FileRouteNode[],
175
+ lines: string[],
176
+ importsByKey: Map<string, { varName: string; isLazy: boolean }>,
177
+ indent: number,
178
+ ): void {
179
+ const pad = ' '.repeat(indent)
180
+
181
+ for (const node of nodes) {
182
+ const imp = node.filePath ? importsByKey.get(importKey(node.filePath, node.exportName)) : undefined
183
+
184
+ lines.push(`${pad}{`)
185
+ lines.push(`${pad} path: '${node.segment ? `/${node.segment}` : '/'}',`)
186
+
187
+ if (imp) {
188
+ if (imp.isLazy) {
189
+ lines.push(`${pad} component: lazy(${imp.varName}),`)
190
+ } else {
191
+ lines.push(`${pad} component: ${imp.varName},`)
192
+ }
193
+ }
194
+
195
+ if (node.isLayout) lines.push(`${pad} isLayout: true,`)
196
+ if (node.isIndex) lines.push(`${pad} isIndex: true,`)
197
+ if (node.isPathlessGroup) lines.push(`${pad} isPathlessGroup: true,`)
198
+
199
+ if (node.children.length > 0) {
200
+ lines.push(`${pad} children: [`)
201
+ generateTreeConfig(node.children, lines, importsByKey, indent + 2)
202
+ lines.push(`${pad} ],`)
203
+ }
204
+
205
+ lines.push(`${pad}},`)
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Generate the virtual module content for `virtual:buzola/routes`.
211
+ */
212
+ export function generateVirtualModule(outputPath: string): string {
213
+ return `export { routes, pageRegistry } from '${outputPath.replace(/\.ts$/, '')}';\n`
214
+ }
@@ -0,0 +1,48 @@
1
+ export interface ExtractedPage {
2
+ /** Export name (e.g., "detail", "list"). "default" for default exports. */
3
+ exportName: string
4
+ /** Explicit route pattern from .route('...'), if present. */
5
+ route?: string
6
+ /** Whether this is a default export. */
7
+ isDefault: boolean
8
+ /** Extracted param info from .params(z.object({...})). */
9
+ params: ExtractedParam[]
10
+ }
11
+
12
+ export interface ExtractedParam {
13
+ name: string
14
+ optional: boolean
15
+ array: boolean
16
+ }
17
+
18
+ export type ModuleLoader = (absolutePath: string) => Promise<Record<string, unknown>>
19
+
20
+ function isPageDefinition(value: unknown): value is {
21
+ __buzolaPage: true
22
+ route?: string
23
+ paramsMeta: { name: string; optional: boolean; array: boolean }[]
24
+ } {
25
+ return value != null && typeof value === 'object' && '__buzolaPage' in value && (value as any).__buzolaPage === true
26
+ }
27
+
28
+ /**
29
+ * Extract createPage() metadata by loading the module and inspecting exports.
30
+ */
31
+ export async function extractPages(absolutePath: string, loadModule: ModuleLoader): Promise<ExtractedPage[]> {
32
+ const mod = await loadModule(absolutePath)
33
+ const pages: ExtractedPage[] = []
34
+
35
+ for (const [key, value] of Object.entries(mod)) {
36
+ if (!isPageDefinition(value)) continue
37
+
38
+ const exportName = key === 'default' ? 'default' : key
39
+ pages.push({
40
+ exportName,
41
+ isDefault: key === 'default',
42
+ route: value.route,
43
+ params: value.paramsMeta ?? [],
44
+ })
45
+ }
46
+
47
+ return pages
48
+ }
@@ -0,0 +1,45 @@
1
+ import * as fs from 'node:fs'
2
+ import * as path from 'node:path'
3
+ import { isRouteFile } from '../conventions'
4
+
5
+ export interface ScannedFile {
6
+ /** Absolute path to the file. */
7
+ absolutePath: string
8
+ /** Path relative to the routes directory. */
9
+ relativePath: string
10
+ }
11
+
12
+ /**
13
+ * Scan a directory recursively for route files.
14
+ */
15
+ export function scanRouteFiles(routesDir: string): ScannedFile[] {
16
+ const files: ScannedFile[] = []
17
+
18
+ function scan(dir: string): void {
19
+ if (!fs.existsSync(dir)) return
20
+
21
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
22
+
23
+ for (const entry of entries) {
24
+ const fullPath = path.join(dir, entry.name)
25
+
26
+ if (entry.isDirectory()) {
27
+ // Skip node_modules and hidden directories
28
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
29
+ scan(fullPath)
30
+ } else if (entry.isFile() && isRouteFile(entry.name)) {
31
+ files.push({
32
+ absolutePath: fullPath,
33
+ relativePath: path.relative(routesDir, fullPath),
34
+ })
35
+ }
36
+ }
37
+ }
38
+
39
+ scan(routesDir)
40
+
41
+ // Sort for deterministic output
42
+ files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
43
+
44
+ return files
45
+ }
@@ -0,0 +1,354 @@
1
+ import * as path from 'node:path'
2
+ import { parseDirName, parseFileName } from '../conventions'
3
+ import type { ExtractedPage, ModuleLoader } from './page-extractor'
4
+ import { extractPages } from './page-extractor'
5
+ import type { ScannedFile } from './scanner'
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 file-based 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
+ /**
48
+ * Build a route tree from scanned files.
49
+ * Loads each module via moduleLoader to extract createPage() metadata.
50
+ */
51
+ export async function buildFileRouteTree(files: ScannedFile[], moduleLoader: ModuleLoader): Promise<FileRouteNode[]> {
52
+ const root: FileRouteNode = {
53
+ segment: '',
54
+ fullPath: '/',
55
+ isLayout: false,
56
+ isIndex: false,
57
+ isNotFound: false,
58
+ isPathlessGroup: false,
59
+ children: [],
60
+ }
61
+
62
+ for (const file of files) {
63
+ await insertFile(root, file, moduleLoader)
64
+ }
65
+
66
+ sortChildren(root)
67
+
68
+ if (root.isLayout && root.filePath) {
69
+ return [root]
70
+ }
71
+
72
+ return root.children
73
+ }
74
+
75
+ async function insertFile(root: FileRouteNode, file: ScannedFile, moduleLoader: ModuleLoader): Promise<void> {
76
+ const ext = path.extname(file.relativePath)
77
+ const withoutExt = file.relativePath.slice(0, -ext.length)
78
+ const parts = withoutExt.split(path.sep)
79
+ const fileName = parts.pop()!
80
+ const dirs = parts
81
+
82
+ let current = root
83
+ let currentPath = ''
84
+
85
+ for (const dir of dirs) {
86
+ const dirInfo = parseDirName(dir)
87
+ const segmentPath = dirInfo.isPathlessGroup
88
+ ? currentPath
89
+ : joinPath(currentPath, dirInfo.segment)
90
+
91
+ let child = current.children.find(c => c.segment === dirInfo.segment && c.isPathlessGroup === dirInfo.isPathlessGroup && c.isLayout)
92
+
93
+ if (!child) {
94
+ child = current.children.find(c => c.segment === dirInfo.segment && c.isPathlessGroup === dirInfo.isPathlessGroup && !c.filePath)
95
+ }
96
+
97
+ if (!child) {
98
+ child = {
99
+ segment: dirInfo.segment,
100
+ fullPath: segmentPath || '/',
101
+ isLayout: false,
102
+ isIndex: false,
103
+ isNotFound: false,
104
+ isPathlessGroup: dirInfo.isPathlessGroup,
105
+ children: [],
106
+ }
107
+ current.children.push(child)
108
+ }
109
+
110
+ current = child
111
+ currentPath = segmentPath
112
+ }
113
+
114
+ const fileInfo = parseFileName(fileName)
115
+
116
+ if (fileInfo.isLayout) {
117
+ current.isLayout = true
118
+ current.filePath = file.absolutePath
119
+ } else if (fileInfo.isNotFound) {
120
+ const catchAllSegment = ':__notFound+'
121
+ const fullPath = joinPath(currentPath, catchAllSegment)
122
+ const pageId = dirs.length === 0 ? '404' : `${dirs.join('/')}/404`
123
+ const pageExports: PageExportInfo[] = [{
124
+ pageId,
125
+ exportName: 'default',
126
+ routePattern: fullPath,
127
+ params: [{ name: '__notFound', optional: false, array: false }],
128
+ }]
129
+ const notFoundNode: FileRouteNode = {
130
+ segment: catchAllSegment,
131
+ fullPath,
132
+ filePath: file.absolutePath,
133
+ isLayout: false,
134
+ isIndex: false,
135
+ isNotFound: true,
136
+ isPathlessGroup: false,
137
+ pageExports,
138
+ children: [],
139
+ }
140
+ current.children.push(notFoundNode)
141
+ } else if (fileInfo.isIndex) {
142
+ const fullPath = currentPath || '/'
143
+ const allExports = await extractPageExportsFromFile(file, dirs, '', true, currentPath, moduleLoader)
144
+ const defaultExports = allExports?.filter(e => e.exportName === 'default')
145
+ const namedExports = allExports?.filter(e => e.exportName !== 'default') ?? []
146
+
147
+ const hasDefault = defaultExports != null && defaultExports.length > 0
148
+ const indexNode: FileRouteNode = {
149
+ segment: '',
150
+ fullPath,
151
+ filePath: hasDefault ? file.absolutePath : undefined,
152
+ exportName: hasDefault ? 'default' : undefined,
153
+ isLayout: false,
154
+ isIndex: true,
155
+ isNotFound: false,
156
+ isPathlessGroup: false,
157
+ pageExports: hasDefault ? defaultExports : undefined,
158
+ children: [],
159
+ }
160
+ current.children.push(indexNode)
161
+
162
+ // Named exports become siblings (children of current, not of the index node)
163
+ for (const exp of namedExports) {
164
+ const namedSegment = exp.routePattern ? (computeRelativeSegment(currentPath, exp.routePattern) ?? exp.exportName) : exp.exportName
165
+ checkCollision(current.children, namedSegment, file.absolutePath, exp.exportName)
166
+ current.children.push({
167
+ segment: namedSegment,
168
+ fullPath: joinPath(currentPath, namedSegment),
169
+ filePath: file.absolutePath,
170
+ exportName: exp.exportName,
171
+ isLayout: false,
172
+ isIndex: false,
173
+ isNotFound: false,
174
+ isPathlessGroup: false,
175
+ pageExports: [exp],
176
+ children: [],
177
+ })
178
+ }
179
+ } else {
180
+ const allExports = await extractPageExportsFromFile(file, dirs, fileName, false, currentPath, moduleLoader)
181
+ const defaultExports = allExports?.filter(e => e.exportName === 'default')
182
+ const namedExports = allExports?.filter(e => e.exportName !== 'default') ?? []
183
+
184
+ const hasDefault = defaultExports != null && defaultExports.length > 0
185
+ const routeSegment = hasDefault ? deriveSegmentFromPageExports(defaultExports, currentPath) : undefined
186
+ const segment = routeSegment ?? fileInfo.segment
187
+ const fullPath = joinPath(currentPath, segment)
188
+
189
+ if (hasDefault) {
190
+ checkCollision(current.children, segment, file.absolutePath, 'default')
191
+ }
192
+
193
+ // Reuse existing container node (created by a previous file's named exports) if available
194
+ let routeNode = !hasDefault ? current.children.find(c => c.segment === segment && !c.filePath && !c.isLayout && !c.isPathlessGroup) : undefined
195
+ if (!routeNode) {
196
+ routeNode = {
197
+ segment,
198
+ fullPath,
199
+ filePath: hasDefault ? file.absolutePath : undefined,
200
+ exportName: hasDefault ? 'default' : undefined,
201
+ isLayout: false,
202
+ isIndex: false,
203
+ isNotFound: false,
204
+ isPathlessGroup: false,
205
+ pageExports: hasDefault ? defaultExports : undefined,
206
+ children: [],
207
+ }
208
+ current.children.push(routeNode)
209
+ }
210
+
211
+ // Named exports become children (equivalent to files in a subdirectory)
212
+ for (const exp of namedExports) {
213
+ const namedSegment = exp.routePattern ? (computeRelativeSegment(fullPath, exp.routePattern) ?? exp.exportName) : exp.exportName
214
+ checkCollision(routeNode.children, namedSegment, file.absolutePath, exp.exportName)
215
+ routeNode.children.push({
216
+ segment: namedSegment,
217
+ fullPath: joinPath(fullPath, namedSegment),
218
+ filePath: file.absolutePath,
219
+ exportName: exp.exportName,
220
+ isLayout: false,
221
+ isIndex: false,
222
+ isNotFound: false,
223
+ isPathlessGroup: false,
224
+ pageExports: [exp],
225
+ children: [],
226
+ })
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Derive page ID from file path and export name.
233
+ *
234
+ * Examples:
235
+ * - File: project.tsx, export: detail → "project/detail"
236
+ * - File: project.tsx, export: default → "project"
237
+ * - File: project/detail.tsx, export: default → "project/detail"
238
+ * - File: index.tsx, export: default → "index"
239
+ */
240
+ function derivePageId(dirs: string[], fileName: string, isIndex: boolean, exportName: string): string {
241
+ const segments = [...dirs]
242
+
243
+ if (isIndex) {
244
+ if (segments.length === 0) {
245
+ return exportName === 'default' ? 'index' : `index/${exportName}`
246
+ }
247
+ const base = segments.join('/')
248
+ return exportName === 'default' ? base : `${base}/${exportName}`
249
+ }
250
+
251
+ segments.push(fileName)
252
+ const base = segments.join('/')
253
+
254
+ return exportName === 'default' ? base : `${base}/${exportName}`
255
+ }
256
+
257
+ /**
258
+ * Compute the relative route segment from a page's .route() pattern and parent path.
259
+ */
260
+ function computeRelativeSegment(parentPath: string, routePattern: string): string | undefined {
261
+ const prefix = parentPath === '/' ? '/' : parentPath + '/'
262
+
263
+ if (routePattern.startsWith(prefix)) {
264
+ return routePattern.slice(prefix.length)
265
+ }
266
+
267
+ if (routePattern === parentPath) {
268
+ return undefined
269
+ }
270
+
271
+ const lastSlash = routePattern.lastIndexOf('/')
272
+ return lastSlash >= 0 ? routePattern.slice(lastSlash + 1) : routePattern
273
+ }
274
+
275
+ function deriveSegmentFromPageExports(
276
+ pageExports: PageExportInfo[] | undefined,
277
+ parentPath: string,
278
+ ): string | undefined {
279
+ if (!pageExports || pageExports.length === 0) return undefined
280
+ const firstRoute = pageExports[0].routePattern
281
+ return computeRelativeSegment(parentPath, firstRoute)
282
+ }
283
+
284
+ async function extractPageExportsFromFile(
285
+ file: ScannedFile,
286
+ dirs: string[],
287
+ fileName: string,
288
+ isIndex: boolean,
289
+ parentPath: string,
290
+ moduleLoader: ModuleLoader,
291
+ ): Promise<PageExportInfo[] | undefined> {
292
+ let pages: ExtractedPage[]
293
+ try {
294
+ pages = await extractPages(file.absolutePath, moduleLoader)
295
+ } catch {
296
+ return undefined
297
+ }
298
+
299
+ if (pages.length === 0) return undefined
300
+
301
+ const result: PageExportInfo[] = []
302
+
303
+ for (const page of pages) {
304
+ const pageId = derivePageId(dirs, fileName, isIndex, page.exportName)
305
+ // If no explicit .route(), derive from file path — all params become query params
306
+ const basePath = isIndex ? (parentPath || '/') : joinPath(parentPath, fileName)
307
+ const routePattern = page.route ?? (page.isDefault ? basePath : joinPath(basePath, page.exportName))
308
+
309
+ result.push({
310
+ pageId,
311
+ exportName: page.exportName,
312
+ routePattern,
313
+ params: page.params,
314
+ })
315
+ }
316
+
317
+ return result.length > 0 ? result : undefined
318
+ }
319
+
320
+ function checkCollision(siblings: FileRouteNode[], segment: string, filePath: string, exportName: string): void {
321
+ const existing = siblings.find(c => c.segment === segment && !c.isLayout && !c.isPathlessGroup)
322
+ if (!existing || !existing.filePath) return
323
+
324
+ const existingDesc = existing.exportName && existing.exportName !== 'default'
325
+ ? `named export '${existing.exportName}' in '${existing.filePath}'`
326
+ : `'${existing.filePath}'`
327
+ const newDesc = exportName !== 'default'
328
+ ? `named export '${exportName}' in '${filePath}'`
329
+ : `'${filePath}'`
330
+
331
+ throw new Error(`Route collision: ${newDesc} conflicts with ${existingDesc} — both resolve to segment '${segment}'`)
332
+ }
333
+
334
+ function sortChildren(node: FileRouteNode): void {
335
+ for (const child of node.children) {
336
+ sortChildren(child)
337
+ }
338
+
339
+ node.children.sort((a, b) => sortWeight(a) - sortWeight(b))
340
+ }
341
+
342
+ function sortWeight(node: FileRouteNode): number {
343
+ if (node.isIndex) return 0
344
+ if (node.isNotFound) return 4
345
+ if (node.segment.includes(':') && node.segment.includes('+')) return 3
346
+ if (node.segment.includes(':')) return 2
347
+ return 1
348
+ }
349
+
350
+ function joinPath(parent: string, child: string): string {
351
+ if (!child) return parent || '/'
352
+ const base = parent || ''
353
+ return `${base}/${child}`
354
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { buzolaPlugin } from './plugin'
2
+ export type { BuzolaPluginOptions } from './plugin'
3
+
4
+ export { scanRouteFiles } from './generator/scanner'
5
+ export type { ScannedFile } from './generator/scanner'
6
+
7
+ export { buildFileRouteTree } from './generator/tree-builder'
8
+ export type { FileRouteNode, PageExportInfo } from './generator/tree-builder'
9
+
10
+ export { generateRouteModule } from './generator/codegen'
11
+ export type { CodegenOptions } from './generator/codegen'
12
+
13
+ export { extractPages } from './generator/page-extractor'
14
+ export type { ExtractedPage, ExtractedParam, ModuleLoader } from './generator/page-extractor'
15
+
16
+ export { isRouteFile, parseDirName, parseFileName, parseRouteFile } from './conventions'
17
+ export type { ParsedRouteFile } from './conventions'