@buzola/vite-plugin 0.0.1 → 0.0.4

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 (36) hide show
  1. package/dist/__tests__/codegen.test.js +49 -1
  2. package/dist/__tests__/codegen.test.js.map +1 -1
  3. package/dist/__tests__/conventions.test.js +1 -1
  4. package/dist/__tests__/conventions.test.js.map +1 -1
  5. package/dist/__tests__/page-extractor.test.js +1 -1
  6. package/dist/__tests__/page-extractor.test.js.map +1 -1
  7. package/dist/__tests__/tree-builder.test.js +217 -78
  8. package/dist/__tests__/tree-builder.test.js.map +1 -1
  9. package/dist/generator/codegen.d.ts +1 -5
  10. package/dist/generator/codegen.d.ts.map +1 -1
  11. package/dist/generator/codegen.js +7 -9
  12. package/dist/generator/codegen.js.map +1 -1
  13. package/dist/generator/scanner.js +1 -1
  14. package/dist/generator/scanner.js.map +1 -1
  15. package/dist/generator/tree-builder.d.ts +7 -3
  16. package/dist/generator/tree-builder.d.ts.map +1 -1
  17. package/dist/generator/tree-builder.js +275 -188
  18. package/dist/generator/tree-builder.js.map +1 -1
  19. package/dist/index.d.ts +12 -12
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +6 -6
  22. package/dist/index.js.map +1 -1
  23. package/dist/plugin.d.ts +9 -0
  24. package/dist/plugin.d.ts.map +1 -1
  25. package/dist/plugin.js +12 -11
  26. package/dist/plugin.js.map +1 -1
  27. package/package.json +11 -3
  28. package/src/__tests__/codegen.test.ts +53 -1
  29. package/src/__tests__/conventions.test.ts +1 -1
  30. package/src/__tests__/page-extractor.test.ts +1 -1
  31. package/src/__tests__/tree-builder.test.ts +245 -81
  32. package/src/generator/codegen.ts +9 -11
  33. package/src/generator/scanner.ts +1 -1
  34. package/src/generator/tree-builder.ts +318 -224
  35. package/src/index.ts +12 -12
  36. package/src/plugin.ts +23 -12
@@ -1,5 +1,5 @@
1
1
  import * as path from 'node:path'
2
- import type { FileRouteNode } from './tree-builder'
2
+ import type { FileRouteNode } from './tree-builder.js'
3
3
 
4
4
  export interface CodegenOptions {
5
5
  /** The route tree from file scanning. */
@@ -115,7 +115,7 @@ interface PageInfo {
115
115
  /**
116
116
  * Collect page info from the file route tree for codegen.
117
117
  */
118
- function collectPages(nodes: FileRouteNode[], parentPath = ''): PageInfo[] {
118
+ function collectPages(nodes: FileRouteNode[]): PageInfo[] {
119
119
  const result: PageInfo[] = []
120
120
 
121
121
  for (const node of nodes) {
@@ -132,7 +132,7 @@ function collectPages(nodes: FileRouteNode[], parentPath = ''): PageInfo[] {
132
132
  }
133
133
 
134
134
  if (node.children.length > 0) {
135
- result.push(...collectPages(node.children, node.fullPath))
135
+ result.push(...collectPages(node.children))
136
136
  }
137
137
  }
138
138
 
@@ -154,7 +154,7 @@ function collectImports(
154
154
  if (node.filePath) {
155
155
  const key = importKey(node.filePath, node.exportName)
156
156
  if (!importsByKey.has(key)) {
157
- const relativePath = path.relative(outputDir, node.filePath)
157
+ const relativePath = path.relative(outputDir, node.filePath).replaceAll('\\', '/')
158
158
  const importPath = relativePath.startsWith('.') ? relativePath : `./${relativePath}`
159
159
  // Strip extension for import
160
160
  const cleanPath = importPath.replace(/\.(tsx?|jsx?)$/, '')
@@ -184,6 +184,11 @@ function generateTreeConfig(
184
184
  lines.push(`${pad}{`)
185
185
  lines.push(`${pad} path: '${node.segment ? `/${node.segment}` : '/'}',`)
186
186
 
187
+ // Emit matchPath when page has a custom route pattern that differs from file-hierarchy position
188
+ if (node.pageExports && node.pageExports.length > 0 && node.pageExports[0].routePattern !== node.fullPath) {
189
+ lines.push(`${pad} matchPath: '${node.pageExports[0].routePattern}',`)
190
+ }
191
+
187
192
  if (imp) {
188
193
  if (imp.isLazy) {
189
194
  lines.push(`${pad} component: lazy(${imp.varName}),`)
@@ -205,10 +210,3 @@ function generateTreeConfig(
205
210
  lines.push(`${pad}},`)
206
211
  }
207
212
  }
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
- }
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs'
2
2
  import * as path from 'node:path'
3
- import { isRouteFile } from '../conventions'
3
+ import { isRouteFile } from '../conventions.js'
4
4
 
5
5
  export interface ScannedFile {
6
6
  /** Absolute path to the file. */
@@ -1,8 +1,8 @@
1
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'
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
6
 
7
7
  /**
8
8
  * Page export info attached to a file route node.
@@ -19,7 +19,7 @@ export interface PageExportInfo {
19
19
  }
20
20
 
21
21
  /**
22
- * A node in the file-based route tree (before compilation into RouteNode).
22
+ * A node in the route tree (before compilation into RouteNode).
23
23
  */
24
24
  export interface FileRouteNode {
25
25
  /** Route path segment (from file name, or derived from .route() pattern). */
@@ -44,11 +44,150 @@ export interface FileRouteNode {
44
44
  children: FileRouteNode[]
45
45
  }
46
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
+
47
68
  /**
48
69
  * Build a route tree from scanned files.
49
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).
50
75
  */
51
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 entries: RouteEntry[] = []
85
+
86
+ for (const file of files) {
87
+ const ext = path.extname(file.relativePath)
88
+ const withoutExt = file.relativePath.slice(0, -ext.length)
89
+ const parts = withoutExt.split(path.sep)
90
+ const fileName = parts.pop()!
91
+ const dirs = parts
92
+ const fileInfo = parseFileName(fileName)
93
+
94
+ // Compute fileScope (URL path from directory structure, skipping pathless groups)
95
+ // and pathlessGroups chain
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
+ // Detect pathless group layout: immediate parent dir is a pathless group
113
+ const lastDir = dirs.length > 0 ? dirs[dirs.length - 1] : null
114
+ const isPathlessGroupLayout = lastDir != null && parseDirName(lastDir).isPathlessGroup
115
+
116
+ entries.push({
117
+ routePattern: fileScope,
118
+ treePosition: fileScope,
119
+ filePath: file.absolutePath,
120
+ exportName: 'default',
121
+ kind: 'layout',
122
+ params: [],
123
+ fileScope,
124
+ pathlessGroups,
125
+ pathlessGroupId: isPathlessGroupLayout ? groupPath : undefined,
126
+ })
127
+ } else if (fileInfo.isNotFound) {
128
+ const catchAllPattern = fileScope === '/' ? '/:__notFound+' : `${fileScope}/:__notFound+`
129
+ const pageId = dirs.length === 0 ? '404' : `${dirs.join('/')}/404`
130
+ entries.push({
131
+ routePattern: catchAllPattern,
132
+ treePosition: catchAllPattern,
133
+ filePath: file.absolutePath,
134
+ exportName: 'default',
135
+ kind: 'notFound',
136
+ pageId,
137
+ params: [{ name: '__notFound', optional: false, array: false }],
138
+ fileScope,
139
+ pathlessGroups,
140
+ })
141
+ } else {
142
+ const isIndex = fileInfo.isIndex
143
+ const basePath = isIndex ? fileScope : joinPath(fileScope, fileName)
144
+
145
+ const pages = await safeExtractPages(file, moduleLoader)
146
+
147
+ if (pages.length === 0) {
148
+ // No createPage() — plain component
149
+ entries.push({
150
+ routePattern: basePath,
151
+ treePosition: basePath,
152
+ filePath: file.absolutePath,
153
+ exportName: 'default',
154
+ kind: 'page',
155
+ params: [],
156
+ fileScope,
157
+ pathlessGroups,
158
+ })
159
+ } else {
160
+ for (const page of pages) {
161
+ const pageId = derivePageId(dirs, fileName, isIndex, page.exportName)
162
+ const defaultPattern = page.isDefault ? basePath : joinPath(basePath, page.exportName)
163
+ const routePattern = page.route ?? defaultPattern
164
+ entries.push({
165
+ routePattern,
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
+ }
179
+
180
+ return entries
181
+ }
182
+
183
+ // ─── Phase 2: Build tree from flat entries ──────────────────────────────────
184
+
185
+ interface LayoutRef {
186
+ node: FileRouteNode
187
+ pathlessGroupId?: string
188
+ }
189
+
190
+ function buildTreeFromEntries(entries: RouteEntry[]): FileRouteNode[] {
52
191
  const root: FileRouteNode = {
53
192
  segment: '',
54
193
  fullPath: '/',
@@ -59,10 +198,110 @@ export async function buildFileRouteTree(files: ScannedFile[], moduleLoader: Mod
59
198
  children: [],
60
199
  }
61
200
 
62
- for (const file of files) {
63
- await insertFile(root, file, moduleLoader)
201
+ const layouts = entries.filter(e => e.kind === 'layout')
202
+ const pages = entries.filter(e => e.kind !== 'layout')
203
+
204
+ // Sort layouts: shorter routePattern first, non-pathless before pathless
205
+ layouts.sort((a, b) => {
206
+ if (a.routePattern.length !== b.routePattern.length) return a.routePattern.length - b.routePattern.length
207
+ if (a.pathlessGroupId && !b.pathlessGroupId) return 1
208
+ if (!a.pathlessGroupId && b.pathlessGroupId) return -1
209
+ return 0
210
+ })
211
+
212
+ // Insert layouts into tree
213
+ // Track all layout nodes for page assignment
214
+ const layoutRefs: LayoutRef[] = [{ node: root }]
215
+
216
+ for (const layout of layouts) {
217
+ if (layout.pathlessGroupId) {
218
+ // Pathless group layout — find parent by walking layoutRefs, respecting pathless group chain
219
+ const parent = findParentForPathlessGroupLayout(layoutRefs, layout)
220
+ const node: FileRouteNode = {
221
+ segment: '',
222
+ fullPath: layout.treePosition,
223
+ filePath: layout.filePath,
224
+ isLayout: true,
225
+ isIndex: false,
226
+ isNotFound: false,
227
+ isPathlessGroup: true,
228
+ children: [],
229
+ }
230
+ parent.node.children.push(node)
231
+ layoutRefs.push({ node, pathlessGroupId: layout.pathlessGroupId })
232
+ } else if (layout.treePosition === '/' && !root.filePath) {
233
+ // Root layout
234
+ root.isLayout = true
235
+ root.filePath = layout.filePath
236
+ } else if (layout.treePosition === '/') {
237
+ // Additional layout at root (shouldn't happen normally, but handle)
238
+ root.isLayout = true
239
+ root.filePath = layout.filePath
240
+ } else {
241
+ // Regular layout — find parent by prefix matching
242
+ const parent = findDeepestLayoutParent(layoutRefs, layout.treePosition, layout.pathlessGroups)
243
+ const segment = computeSegmentFromParent(parent.node.fullPath, layout.treePosition)
244
+ const node: FileRouteNode = {
245
+ segment,
246
+ fullPath: layout.treePosition,
247
+ filePath: layout.filePath,
248
+ isLayout: true,
249
+ isIndex: false,
250
+ isNotFound: false,
251
+ isPathlessGroup: false,
252
+ children: [],
253
+ }
254
+ parent.node.children.push(node)
255
+ layoutRefs.push({ node })
256
+ }
64
257
  }
65
258
 
259
+ // Insert pages into tree (using file-hierarchy position, not route pattern)
260
+ for (const entry of pages) {
261
+ const parent = findDeepestLayoutParent(layoutRefs, entry.treePosition, entry.pathlessGroups)
262
+ const isIndex = entry.treePosition === parent.node.fullPath
263
+ const segment = isIndex ? '' : computeSegmentFromParent(parent.node.fullPath, entry.treePosition)
264
+
265
+ const pageExports: PageExportInfo[] | undefined = entry.pageId
266
+ ? [{
267
+ pageId: entry.pageId,
268
+ exportName: entry.exportName,
269
+ routePattern: entry.routePattern,
270
+ params: entry.params,
271
+ }]
272
+ : undefined
273
+
274
+ // For named exports from the same file as an existing node, check collision
275
+ if (!isIndex) {
276
+ checkCollision(parent.node.children, segment, entry.filePath, entry.exportName)
277
+ }
278
+
279
+ // Try to reuse an existing container node (e.g., from a directory that has no _layout.tsx)
280
+ const existing = !isIndex
281
+ ? parent.node.children.find(c => c.segment === segment && !c.filePath && !c.isLayout && !c.isPathlessGroup)
282
+ : undefined
283
+
284
+ if (existing) {
285
+ existing.filePath = entry.filePath
286
+ existing.exportName = entry.exportName
287
+ existing.pageExports = pageExports
288
+ } else {
289
+ parent.node.children.push({
290
+ segment,
291
+ fullPath: entry.treePosition,
292
+ filePath: entry.filePath,
293
+ exportName: entry.exportName,
294
+ isLayout: false,
295
+ isIndex,
296
+ isNotFound: entry.kind === 'notFound',
297
+ isPathlessGroup: false,
298
+ pageExports,
299
+ children: [],
300
+ })
301
+ }
302
+ }
303
+
304
+ // Sort children recursively
66
305
  sortChildren(root)
67
306
 
68
307
  if (root.isLayout && root.filePath) {
@@ -72,170 +311,74 @@ export async function buildFileRouteTree(files: ScannedFile[], moduleLoader: Mod
72
311
  return root.children
73
312
  }
74
313
 
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
314
+ /**
315
+ * Find the parent layout for a pathless group layout.
316
+ * The parent is the deepest layout at the same routePattern level,
317
+ * considering the pathless group nesting chain.
318
+ */
319
+ function findParentForPathlessGroupLayout(layoutRefs: LayoutRef[], entry: RouteEntry): LayoutRef {
320
+ // The parent is the layout whose pathlessGroupId is the previous entry in the chain,
321
+ // or the deepest non-pathless layout at the same routePattern if this is the first group.
322
+ const groupChain = entry.pathlessGroups
323
+ const thisGroupIndex = groupChain.indexOf(entry.pathlessGroupId!)
324
+
325
+ if (thisGroupIndex > 0) {
326
+ // Has a parent pathless group — find it
327
+ const parentGroupId = groupChain[thisGroupIndex - 1]
328
+ const parentRef = layoutRefs.find(r => r.pathlessGroupId === parentGroupId)
329
+ if (parentRef) return parentRef
330
+ }
81
331
 
82
- let current = root
83
- let currentPath = ''
332
+ // First pathless group in chain (or parent not found) — find the deepest regular layout at prefix
333
+ let best = layoutRefs[0] // root
334
+ for (const ref of layoutRefs) {
335
+ if (ref.pathlessGroupId) continue // skip other pathless groups
336
+ if (isPathPrefixOrEqual(ref.node.fullPath, entry.routePattern) && ref.node.fullPath.length >= best.node.fullPath.length) {
337
+ best = ref
338
+ }
339
+ }
340
+ return best
341
+ }
84
342
 
85
- for (const dir of dirs) {
86
- const dirInfo = parseDirName(dir)
87
- const segmentPath = dirInfo.isPathlessGroup
88
- ? currentPath
89
- : joinPath(currentPath, dirInfo.segment)
343
+ /**
344
+ * Find the deepest layout whose routePattern is a prefix of the given pattern.
345
+ * Respects pathless group membership.
346
+ */
347
+ function findDeepestLayoutParent(layoutRefs: LayoutRef[], routePattern: string, pathlessGroups: string[]): LayoutRef {
348
+ let best = layoutRefs[0] // root
90
349
 
91
- let child = current.children.find(c => c.segment === dirInfo.segment && c.isPathlessGroup === dirInfo.isPathlessGroup && c.isLayout)
350
+ for (const ref of layoutRefs) {
351
+ if (!isPathPrefixOrEqual(ref.node.fullPath, routePattern)) continue
92
352
 
93
- if (!child) {
94
- child = current.children.find(c => c.segment === dirInfo.segment && c.isPathlessGroup === dirInfo.isPathlessGroup && !c.filePath)
95
- }
353
+ // For pathless groups, only match if the entry belongs to this group
354
+ if (ref.pathlessGroupId && !pathlessGroups.includes(ref.pathlessGroupId)) continue
96
355
 
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)
356
+ // Prefer deeper matches. For ties (pathless groups at same depth), prefer pathless group
357
+ // that the entry belongs to over non-pathless.
358
+ if (ref.node.fullPath.length > best.node.fullPath.length) {
359
+ best = ref
360
+ } else if (ref.node.fullPath.length === best.node.fullPath.length && ref.pathlessGroupId && pathlessGroups.includes(ref.pathlessGroupId)) {
361
+ best = ref
108
362
  }
109
-
110
- current = child
111
- currentPath = segmentPath
112
363
  }
113
364
 
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
- }
365
+ return best
366
+ }
192
367
 
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
- }
368
+ function isPathPrefixOrEqual(prefix: string, path: string): boolean {
369
+ if (prefix === '/') return true
370
+ return path === prefix || path.startsWith(prefix + '/')
371
+ }
210
372
 
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
- }
373
+ function computeSegmentFromParent(parentFullPath: string, childFullPath: string): string {
374
+ if (parentFullPath === '/') return childFullPath.slice(1)
375
+ return childFullPath.slice(parentFullPath.length + 1)
229
376
  }
230
377
 
378
+ // ─── Helpers ────────────────────────────────────────────────────────────────
379
+
231
380
  /**
232
381
  * 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
382
  */
240
383
  function derivePageId(dirs: string[], fileName: string, isIndex: boolean, exportName: string): string {
241
384
  const segments = [...dirs]
@@ -254,67 +397,13 @@ function derivePageId(dirs: string[], fileName: string, isIndex: boolean, export
254
397
  return exportName === 'default' ? base : `${base}/${exportName}`
255
398
  }
256
399
 
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[]
400
+ async function safeExtractPages(file: ScannedFile, moduleLoader: ModuleLoader): Promise<ExtractedPage[]> {
293
401
  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
- })
402
+ return await extractPages(file.absolutePath, moduleLoader)
403
+ } catch (error) {
404
+ console.warn(`[buzola] Failed to extract pages from ${file.relativePath}:`, error instanceof Error ? error.message : error)
405
+ return []
315
406
  }
316
-
317
- return result.length > 0 ? result : undefined
318
407
  }
319
408
 
320
409
  function checkCollision(siblings: FileRouteNode[], segment: string, filePath: string, exportName: string): void {
@@ -335,20 +424,25 @@ function sortChildren(node: FileRouteNode): void {
335
424
  for (const child of node.children) {
336
425
  sortChildren(child)
337
426
  }
338
-
339
427
  node.children.sort((a, b) => sortWeight(a) - sortWeight(b))
340
428
  }
341
429
 
430
+ const SORT_INDEX = 0
431
+ const SORT_STATIC = 1
432
+ const SORT_DYNAMIC = 2
433
+ const SORT_CATCHALL = 3
434
+ const SORT_NOT_FOUND = 4
435
+
342
436
  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
437
+ if (node.isIndex) return SORT_INDEX
438
+ if (node.isNotFound) return SORT_NOT_FOUND
439
+ if (node.segment.includes(':') && node.segment.includes('+')) return SORT_CATCHALL
440
+ if (node.segment.includes(':')) return SORT_DYNAMIC
441
+ return SORT_STATIC
348
442
  }
349
443
 
350
444
  function joinPath(parent: string, child: string): string {
351
445
  if (!child) return parent || '/'
352
- const base = parent || ''
353
- return `${base}/${child}`
446
+ if (!parent || parent === '/') return `/${child}`
447
+ return `${parent}/${child}`
354
448
  }