@buzola/vite-plugin 0.0.3 → 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 +2 -2
  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 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAA;AAMjD,MAAM,WAAW,mBAAmB;IACnC,oFAAoF;IACpF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gGAAgG;IAChG,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAA;CAC3B;AAKD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,OAAO,GAAE,mBAAwB,GAAG,MAAM,CAuFtE"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAA;AAMjD,MAAM,WAAW,mBAAmB;IACnC;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,oFAAoF;IACpF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gGAAgG;IAChG,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAA;CAC3B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,OAAO,GAAE,mBAAwB,GAAG,MAAM,CA4FtE"}
package/dist/plugin.js CHANGED
@@ -1,10 +1,8 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { generateRouteModule } from './generator/codegen';
4
- import { scanRouteFiles } from './generator/scanner';
5
- import { buildFileRouteTree } from './generator/tree-builder';
6
- const VIRTUAL_MODULE_ID = 'virtual:buzola/routes';
7
- const RESOLVED_VIRTUAL_ID = '\0' + VIRTUAL_MODULE_ID;
3
+ import { generateRouteModule } from './generator/codegen.js';
4
+ import { scanRouteFiles } from './generator/scanner.js';
5
+ import { buildFileRouteTree } from './generator/tree-builder.js';
8
6
  /**
9
7
  * Vite plugin for Buzola page-centric routing.
10
8
  *
@@ -17,7 +15,10 @@ const RESOLVED_VIRTUAL_ID = '\0' + VIRTUAL_MODULE_ID;
17
15
  * Provides `virtual:buzola/routes` virtual module.
18
16
  */
19
17
  export function buzolaPlugin(options = {}) {
20
- const { routesDir: routesDirOption = 'src/routes', output: outputOption = 'src/buzola.gen.ts', persistentParams: persistentParamsOption, } = options;
18
+ const { name: nameOption, routesDir: routesDirOption = 'src/routes', output: outputOption = 'src/buzola.gen.ts', persistentParams: persistentParamsOption, } = options;
19
+ const pluginName = nameOption ? `buzola:${nameOption}` : 'buzola';
20
+ const virtualModuleId = nameOption ? `virtual:buzola/${nameOption}/routes` : 'virtual:buzola/routes';
21
+ const resolvedVirtualId = '\0' + virtualModuleId;
21
22
  let root;
22
23
  let routesDir;
23
24
  let outputPath;
@@ -34,7 +35,7 @@ export function buzolaPlugin(options = {}) {
34
35
  return writeIfChanged(outputPath, code);
35
36
  }
36
37
  return {
37
- name: 'buzola',
38
+ name: pluginName,
38
39
  enforce: 'pre',
39
40
  configResolved(config) {
40
41
  root = config.root;
@@ -68,12 +69,12 @@ export function buzolaPlugin(options = {}) {
68
69
  }
69
70
  },
70
71
  resolveId(id) {
71
- if (id === VIRTUAL_MODULE_ID) {
72
- return RESOLVED_VIRTUAL_ID;
72
+ if (id === virtualModuleId) {
73
+ return resolvedVirtualId;
73
74
  }
74
75
  },
75
76
  load(id) {
76
- if (id === RESOLVED_VIRTUAL_ID) {
77
+ if (id === resolvedVirtualId) {
77
78
  return `export { routes, pageRegistry } from '${outputPath.replace(/\.ts$/, '')}';\n`;
78
79
  }
79
80
  },
@@ -82,7 +83,7 @@ export function buzolaPlugin(options = {}) {
82
83
  return;
83
84
  const changed = await generate((p) => server.ssrLoadModule(p));
84
85
  if (changed) {
85
- const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID);
86
+ const mod = server.moduleGraph.getModuleById(resolvedVirtualId);
86
87
  if (mod) {
87
88
  server.moduleGraph.invalidateModule(mod);
88
89
  }
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buzola/vite-plugin",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -21,7 +21,7 @@
21
21
  "directory": "packages/vite-plugin"
22
22
  },
23
23
  "dependencies": {
24
- "@buzola/router": "^0.0.3"
24
+ "@buzola/router": "^0.0.4"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^25.3.3",
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { generateRouteModule } from '../generator/codegen'
2
+ import { generateRouteModule } from '../generator/codegen.js'
3
3
 
4
4
  describe('generateRouteModule', () => {
5
5
  it('emits BuzolaPersistentParams when persistentParams is provided', () => {
@@ -95,6 +95,58 @@ describe('generateRouteModule', () => {
95
95
  expect(output).toContain("'users/detail': '/users/detail'")
96
96
  })
97
97
 
98
+ it('generates matchPath when routePattern differs from file position', () => {
99
+ const output = generateRouteModule({
100
+ tree: [{
101
+ segment: 'login',
102
+ fullPath: '/login',
103
+ filePath: '/tmp/routes/login/_layout.tsx',
104
+ isLayout: true,
105
+ isIndex: false,
106
+ isNotFound: false,
107
+ isPathlessGroup: false,
108
+ children: [{
109
+ segment: '',
110
+ fullPath: '/login',
111
+ filePath: '/tmp/routes/login/index.tsx',
112
+ exportName: 'default',
113
+ isLayout: false,
114
+ isIndex: true,
115
+ isNotFound: false,
116
+ isPathlessGroup: false,
117
+ pageExports: [{ pageId: 'login', exportName: 'default', routePattern: '/', params: [] }],
118
+ children: [],
119
+ }],
120
+ }],
121
+ routesDir: '/tmp/routes',
122
+ outputPath: '/tmp/buzola.gen.ts',
123
+ })
124
+
125
+ expect(output).toContain("matchPath: '/'")
126
+ expect(output).toContain('isIndex: true')
127
+ })
128
+
129
+ it('does not generate matchPath when routePattern matches file position', () => {
130
+ const output = generateRouteModule({
131
+ tree: [{
132
+ segment: 'users',
133
+ fullPath: '/users',
134
+ filePath: '/tmp/routes/users.tsx',
135
+ exportName: 'default',
136
+ isLayout: false,
137
+ isIndex: false,
138
+ isNotFound: false,
139
+ isPathlessGroup: false,
140
+ pageExports: [{ pageId: 'users', exportName: 'default', routePattern: '/users', params: [] }],
141
+ children: [],
142
+ }],
143
+ routesDir: '/tmp/routes',
144
+ outputPath: '/tmp/buzola.gen.ts',
145
+ })
146
+
147
+ expect(output).not.toContain('matchPath')
148
+ })
149
+
98
150
  it('generates separate imports for default and named exports from same file', () => {
99
151
  const output = generateRouteModule({
100
152
  tree: [{
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { isRouteFile, parseDirName, parseFileName, parseRouteFile } from '../conventions'
2
+ import { isRouteFile, parseDirName, parseFileName, parseRouteFile } from '../conventions.js'
3
3
 
4
4
  describe('isRouteFile', () => {
5
5
  it('accepts .tsx files', () => {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { extractPages } from '../generator/page-extractor'
2
+ import { extractPages } from '../generator/page-extractor.js'
3
3
 
4
4
  describe('extractPages', () => {
5
5
  it('extracts default page export', async () => {
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import type { ModuleLoader } from '../generator/page-extractor'
3
- import type { ScannedFile } from '../generator/scanner'
4
- import { buildFileRouteTree } from '../generator/tree-builder'
2
+ import type { ModuleLoader } from '../generator/page-extractor.js'
3
+ import type { ScannedFile } from '../generator/scanner.js'
4
+ import { buildFileRouteTree } from '../generator/tree-builder.js'
5
5
 
6
6
  /** Module loader that returns empty modules (no page exports). */
7
7
  const emptyLoader: ModuleLoader = async () => ({})
@@ -59,9 +59,27 @@ describe('buildFileRouteTree', () => {
59
59
  { absolutePath: '/app/src/routes/(auth)/login.tsx', relativePath: '(auth)/login.tsx' },
60
60
  ]
61
61
 
62
+ const tree = await buildFileRouteTree(files, emptyLoader)
63
+ // Without a pathless group layout, the file goes under root directly
64
+ const login = tree.find(n => n.segment === 'login')
65
+ expect(login).toBeDefined()
66
+ expect(login!.fullPath).toBe('/login')
67
+ })
68
+
69
+ it('handles pathless group with layout', async () => {
70
+ const files: ScannedFile[] = [
71
+ { absolutePath: '/app/src/routes/(auth)/_layout.tsx', relativePath: '(auth)/_layout.tsx' },
72
+ { absolutePath: '/app/src/routes/(auth)/login.tsx', relativePath: '(auth)/login.tsx' },
73
+ ]
74
+
62
75
  const tree = await buildFileRouteTree(files, emptyLoader)
63
76
  const authGroup = tree.find(n => n.isPathlessGroup)
64
77
  expect(authGroup).toBeDefined()
78
+ expect(authGroup!.fullPath).toBe('/')
79
+
80
+ const login = authGroup!.children.find(n => n.segment === 'login')
81
+ expect(login).toBeDefined()
82
+ expect(login!.fullPath).toBe('/login')
65
83
  })
66
84
 
67
85
  it('uses plain file names as segments', async () => {
@@ -70,11 +88,10 @@ describe('buildFileRouteTree', () => {
70
88
  ]
71
89
 
72
90
  const tree = await buildFileRouteTree(files, emptyLoader)
73
- const users = tree.find(n => n.segment === 'users')
74
- expect(users).toBeDefined()
75
- expect(users!.children).toHaveLength(1)
76
- expect(users!.children[0].segment).toBe('detail')
77
- expect(users!.children[0].fullPath).toBe('/users/detail')
91
+ // Without a users layout, the page goes under root with full segment
92
+ const detail = tree.find(n => n.fullPath === '/users/detail')
93
+ expect(detail).toBeDefined()
94
+ expect(detail!.segment).toBe('users/detail')
78
95
  })
79
96
 
80
97
  it('derives segment from page .route() via module loader', async () => {
@@ -97,16 +114,45 @@ describe('buildFileRouteTree', () => {
97
114
  }
98
115
 
99
116
  const tree = await buildFileRouteTree(files, loader)
100
- const users = tree.find(n => n.segment === 'users')
101
- expect(users).toBeDefined()
102
- expect(users!.children).toHaveLength(1)
103
- expect(users!.children[0].segment).toBe(':userId')
104
- expect(users!.children[0].fullPath).toBe('/users/:userId')
105
- expect(users!.children[0].pageExports).toEqual([
117
+ // Tree position is file-based (/users/detail), route pattern is in pageExports
118
+ const page = tree.find(n => n.fullPath === '/users/detail')
119
+ expect(page).toBeDefined()
120
+ expect(page!.segment).toBe('users/detail')
121
+ expect(page!.pageExports).toEqual([
106
122
  { pageId: 'users/detail', exportName: 'default', routePattern: '/users/:userId', params: [{ name: 'userId', optional: false, array: false }] },
107
123
  ])
108
124
  })
109
125
 
126
+ it('derives segment from page .route() when under a layout', async () => {
127
+ const files: ScannedFile[] = [
128
+ { absolutePath: '/app/src/routes/users/_layout.tsx', relativePath: 'users/_layout.tsx' },
129
+ { absolutePath: '/app/src/routes/users/detail.tsx', relativePath: 'users/detail.tsx' },
130
+ ]
131
+
132
+ const loader: ModuleLoader = async (p) => {
133
+ if (p === '/app/src/routes/users/detail.tsx') {
134
+ return {
135
+ default: {
136
+ __buzolaPage: true,
137
+ component: () => null,
138
+ route: '/users/:userId',
139
+ paramsMeta: [{ name: 'userId', optional: false, array: false }],
140
+ },
141
+ }
142
+ }
143
+ return {}
144
+ }
145
+
146
+ const tree = await buildFileRouteTree(files, loader)
147
+ const usersLayout = tree.find(n => n.isLayout && n.segment === 'users')
148
+ expect(usersLayout).toBeDefined()
149
+ // Tree position is file-based (detail), route pattern only affects URL matching
150
+ const page = usersLayout!.children.find(n => n.fullPath === '/users/detail')
151
+ expect(page).toBeDefined()
152
+ expect(page!.segment).toBe('detail')
153
+ expect(page!.pageExports![0].routePattern).toBe('/users/:userId')
154
+ })
155
+
110
156
  it('derives route from file path when no .route()', async () => {
111
157
  const files: ScannedFile[] = [
112
158
  { absolutePath: '/app/src/routes/users/detail.tsx', relativePath: 'users/detail.tsx' },
@@ -122,11 +168,9 @@ describe('buildFileRouteTree', () => {
122
168
  })
123
169
 
124
170
  const tree = await buildFileRouteTree(files, loader)
125
- const users = tree.find(n => n.segment === 'users')
126
- expect(users).toBeDefined()
127
- expect(users!.children[0].segment).toBe('detail')
128
- expect(users!.children[0].fullPath).toBe('/users/detail')
129
- expect(users!.children[0].pageExports).toEqual([
171
+ const page = tree.find(n => n.fullPath === '/users/detail')
172
+ expect(page).toBeDefined()
173
+ expect(page!.pageExports).toEqual([
130
174
  { pageId: 'users/detail', exportName: 'default', routePattern: '/users/detail', params: [{ name: 'userId', optional: false, array: false }] },
131
175
  ])
132
176
  })
@@ -145,14 +189,143 @@ describe('buildFileRouteTree', () => {
145
189
  })
146
190
 
147
191
  const tree = await buildFileRouteTree(files, loader)
148
- const users = tree.find(n => n.segment === 'users')
149
- const index = users!.children.find(n => n.isIndex)
150
- expect(index).toBeDefined()
151
- expect(index!.pageExports).toEqual([
192
+ const page = tree.find(n => n.fullPath === '/users')
193
+ expect(page).toBeDefined()
194
+ expect(page!.pageExports).toEqual([
152
195
  { pageId: 'users', exportName: 'default', routePattern: '/users', params: [] },
153
196
  ])
154
197
  })
155
198
 
199
+ // ─── Route escaping via .route() ─────────────────────────────────────────
200
+
201
+ it('index file with .route() stays under its file-system layout', async () => {
202
+ const files: ScannedFile[] = [
203
+ { absolutePath: '/app/src/routes/login/_layout.tsx', relativePath: 'login/_layout.tsx' },
204
+ { absolutePath: '/app/src/routes/login/index.tsx', relativePath: 'login/index.tsx' },
205
+ ]
206
+
207
+ const loader: ModuleLoader = async (p) => {
208
+ if (p === '/app/src/routes/login/index.tsx') {
209
+ return {
210
+ default: {
211
+ __buzolaPage: true,
212
+ component: () => null,
213
+ route: '/login/testtest',
214
+ paramsMeta: [],
215
+ },
216
+ }
217
+ }
218
+ return {}
219
+ }
220
+
221
+ const tree = await buildFileRouteTree(files, loader)
222
+ const loginLayout = tree.find(n => n.isLayout && n.segment === 'login')
223
+ expect(loginLayout).toBeDefined()
224
+
225
+ // Page stays under login layout as index (file-hierarchy), with custom routePattern
226
+ const page = loginLayout!.children.find(n => n.isIndex)
227
+ expect(page).toBeDefined()
228
+ expect(page!.fullPath).toBe('/login')
229
+ expect(page!.pageExports![0].routePattern).toBe('/login/testtest')
230
+ })
231
+
232
+ it('index file with .route("/xx") stays under its file-system layout', async () => {
233
+ const files: ScannedFile[] = [
234
+ { absolutePath: '/app/src/routes/login/_layout.tsx', relativePath: 'login/_layout.tsx' },
235
+ { absolutePath: '/app/src/routes/login/index.tsx', relativePath: 'login/index.tsx' },
236
+ ]
237
+
238
+ const loader: ModuleLoader = async (p) => {
239
+ if (p === '/app/src/routes/login/index.tsx') {
240
+ return {
241
+ default: {
242
+ __buzolaPage: true,
243
+ component: () => null,
244
+ route: '/xx',
245
+ paramsMeta: [],
246
+ },
247
+ }
248
+ }
249
+ return {}
250
+ }
251
+
252
+ const tree = await buildFileRouteTree(files, loader)
253
+ const loginLayout = tree.find(n => n.isLayout && n.segment === 'login')
254
+ expect(loginLayout).toBeDefined()
255
+
256
+ // Page stays under login layout as index (file-hierarchy), URL is /xx
257
+ const page = loginLayout!.children.find(n => n.isIndex)
258
+ expect(page).toBeDefined()
259
+ expect(page!.fullPath).toBe('/login')
260
+ expect(page!.pageExports![0].routePattern).toBe('/xx')
261
+ })
262
+
263
+ it('index file with .route("/") stays under its file-system layout', async () => {
264
+ const files: ScannedFile[] = [
265
+ { absolutePath: '/app/src/routes/login/_layout.tsx', relativePath: 'login/_layout.tsx' },
266
+ { absolutePath: '/app/src/routes/login/index.tsx', relativePath: 'login/index.tsx' },
267
+ ]
268
+
269
+ const loader: ModuleLoader = async (p) => {
270
+ if (p === '/app/src/routes/login/index.tsx') {
271
+ return {
272
+ default: {
273
+ __buzolaPage: true,
274
+ component: () => null,
275
+ route: '/',
276
+ paramsMeta: [],
277
+ },
278
+ }
279
+ }
280
+ return {}
281
+ }
282
+
283
+ const tree = await buildFileRouteTree(files, loader)
284
+ const loginLayout = tree.find(n => n.isLayout && n.segment === 'login')
285
+ expect(loginLayout).toBeDefined()
286
+
287
+ // Page stays under login layout as index, URL is /
288
+ const page = loginLayout!.children.find(n => n.isIndex)
289
+ expect(page).toBeDefined()
290
+ expect(page!.fullPath).toBe('/login')
291
+ expect(page!.pageExports![0].routePattern).toBe('/')
292
+ })
293
+
294
+ it('index file with .route("/") under root layout keeps file-hierarchy position', async () => {
295
+ const files: ScannedFile[] = [
296
+ { absolutePath: '/app/src/routes/_layout.tsx', relativePath: '_layout.tsx' },
297
+ { absolutePath: '/app/src/routes/login/index.tsx', relativePath: 'login/index.tsx' },
298
+ ]
299
+
300
+ const loader: ModuleLoader = async (p) => {
301
+ if (p === '/app/src/routes/login/index.tsx') {
302
+ return {
303
+ default: {
304
+ __buzolaPage: true,
305
+ component: () => null,
306
+ route: '/',
307
+ paramsMeta: [],
308
+ },
309
+ }
310
+ }
311
+ return {}
312
+ }
313
+
314
+ const tree = await buildFileRouteTree(files, loader)
315
+ expect(tree).toHaveLength(1)
316
+ const rootLayout = tree[0]
317
+ expect(rootLayout.isLayout).toBe(true)
318
+
319
+ // No login layout → page is a child of root layout at file position /login
320
+ const page = rootLayout.children.find(n => n.fullPath === '/login')
321
+ expect(page).toBeDefined()
322
+ expect(page!.segment).toBe('login')
323
+ expect(page!.isIndex).toBe(false)
324
+ expect(page!.pageExports![0].routePattern).toBe('/')
325
+ })
326
+
327
+ // ─── Sorting ─────────────────────────────────────────────────────────────
328
+
156
329
  it('sorts not-found routes last', async () => {
157
330
  const files: ScannedFile[] = [
158
331
  { absolutePath: '/app/src/routes/about.tsx', relativePath: 'about.tsx' },
@@ -196,7 +369,7 @@ describe('buildFileRouteTree', () => {
196
369
  })
197
370
 
198
371
  const tree = await buildFileRouteTree(files, loader)
199
- const users = tree.find(n => n.segment === 'users')
372
+ const users = tree.find(n => n.segment === 'users' && n.filePath)
200
373
  expect(users).toBeDefined()
201
374
  expect(users!.filePath).toBe('/app/src/routes/users.tsx')
202
375
  expect(users!.exportName).toBe('default')
@@ -204,20 +377,15 @@ describe('buildFileRouteTree', () => {
204
377
  { pageId: 'users', exportName: 'default', routePattern: '/users', params: [] },
205
378
  ])
206
379
 
207
- // Named exports become children
208
- expect(users!.children).toHaveLength(2)
209
- const detail = users!.children.find(n => n.segment === 'detail')
380
+ // Named exports: /users/detail and /users/settings are also under root
381
+ // (since there's no /users layout, they're siblings)
382
+ const detail = tree.find(n => n.fullPath === '/users/detail')
210
383
  expect(detail).toBeDefined()
211
384
  expect(detail!.filePath).toBe('/app/src/routes/users.tsx')
212
385
  expect(detail!.exportName).toBe('detail')
213
- expect(detail!.fullPath).toBe('/users/detail')
214
- expect(detail!.pageExports).toEqual([
215
- { pageId: 'users/detail', exportName: 'detail', routePattern: '/users/detail', params: [{ name: 'id', optional: false, array: false }] },
216
- ])
217
386
 
218
- const settings = users!.children.find(n => n.segment === 'settings')
387
+ const settings = tree.find(n => n.fullPath === '/users/settings')
219
388
  expect(settings).toBeDefined()
220
- expect(settings!.fullPath).toBe('/users/settings')
221
389
  })
222
390
 
223
391
  it('creates sibling nodes for named exports from index files', async () => {
@@ -239,20 +407,17 @@ describe('buildFileRouteTree', () => {
239
407
  })
240
408
 
241
409
  const tree = await buildFileRouteTree(files, loader)
242
- const users = tree.find(n => n.segment === 'users')
243
- expect(users).toBeDefined()
244
410
 
245
- // Default export → index node
246
- const index = users!.children.find(n => n.isIndex)
247
- expect(index).toBeDefined()
248
- expect(index!.filePath).toBe('/app/src/routes/users/index.tsx')
411
+ // Default export → /users (index-like, placed at root without layout)
412
+ const usersPage = tree.find(n => n.fullPath === '/users' && n.filePath)
413
+ expect(usersPage).toBeDefined()
414
+ expect(usersPage!.filePath).toBe('/app/src/routes/users/index.tsx')
249
415
 
250
- // Named export → sibling of index (child of users)
251
- const detail = users!.children.find(n => n.segment === 'detail')
416
+ // Named export → /users/detail
417
+ const detail = tree.find(n => n.fullPath === '/users/detail')
252
418
  expect(detail).toBeDefined()
253
419
  expect(detail!.filePath).toBe('/app/src/routes/users/index.tsx')
254
420
  expect(detail!.exportName).toBe('detail')
255
- expect(detail!.fullPath).toBe('/users/detail')
256
421
  expect(detail!.pageExports).toEqual([
257
422
  { pageId: 'users/detail', exportName: 'detail', routePattern: '/users/detail', params: [{ name: 'id', optional: false, array: false }] },
258
423
  ])
@@ -277,17 +442,13 @@ describe('buildFileRouteTree', () => {
277
442
  })
278
443
 
279
444
  const tree = await buildFileRouteTree(files, loader)
280
- const users = tree.find(n => n.segment === 'users')
281
- expect(users).toBeDefined()
282
- expect(users!.filePath).toBeUndefined() // No default → no component
283
- expect(users!.pageExports).toBeUndefined()
284
-
285
- expect(users!.children).toHaveLength(2)
286
- expect(users!.children.find(n => n.segment === 'detail')).toBeDefined()
287
- expect(users!.children.find(n => n.segment === 'list')).toBeDefined()
445
+ const detail = tree.find(n => n.fullPath === '/users/detail')
446
+ expect(detail).toBeDefined()
447
+ const list = tree.find(n => n.fullPath === '/users/list')
448
+ expect(list).toBeDefined()
288
449
  })
289
450
 
290
- it('named export with .route() uses explicit route pattern', async () => {
451
+ it('named export with .route() keeps file-hierarchy position with custom routePattern', async () => {
291
452
  const files: ScannedFile[] = [
292
453
  { absolutePath: '/app/src/routes/users.tsx', relativePath: 'users.tsx' },
293
454
  ]
@@ -302,13 +463,13 @@ describe('buildFileRouteTree', () => {
302
463
  })
303
464
 
304
465
  const tree = await buildFileRouteTree(files, loader)
305
- const users = tree.find(n => n.segment === 'users')
306
- expect(users!.children).toHaveLength(1)
307
- expect(users!.children[0].segment).toBe(':userId')
308
- expect(users!.children[0].fullPath).toBe('/users/:userId')
466
+ // Tree position is file-based (/users/detail), route pattern in pageExports
467
+ const page = tree.find(n => n.fullPath === '/users/detail')
468
+ expect(page).toBeDefined()
469
+ expect(page!.pageExports![0].routePattern).toBe('/users/:userId')
309
470
  })
310
471
 
311
- it('throws on collision: named export vs file in subdirectory', async () => {
472
+ it('throws on collision: two pages at same path', async () => {
312
473
  const files: ScannedFile[] = [
313
474
  { absolutePath: '/app/src/routes/users.tsx', relativePath: 'users.tsx' },
314
475
  { absolutePath: '/app/src/routes/users/detail.tsx', relativePath: 'users/detail.tsx' },
@@ -336,45 +497,48 @@ describe('buildFileRouteTree', () => {
336
497
  expect(buildFileRouteTree(files, loader)).rejects.toThrow(/Route collision/)
337
498
  })
338
499
 
339
- it('throws on collision: named export from index vs file in same directory', async () => {
500
+ it('named exports from root index.tsx have correct paths (no double slashes)', async () => {
340
501
  const files: ScannedFile[] = [
341
- { absolutePath: '/app/src/routes/users/detail.tsx', relativePath: 'users/detail.tsx' },
342
- { absolutePath: '/app/src/routes/users/index.tsx', relativePath: 'users/index.tsx' },
502
+ { absolutePath: '/app/src/routes/index.tsx', relativePath: 'index.tsx' },
343
503
  ]
344
504
 
345
- const loader: ModuleLoader = async (p) => {
346
- if (p === '/app/src/routes/users/index.tsx') {
347
- return {
348
- detail: {
349
- __buzolaPage: true,
350
- component: () => null,
351
- paramsMeta: [],
352
- },
353
- }
354
- }
355
- return {
356
- default: {
357
- __buzolaPage: true,
358
- component: () => null,
359
- paramsMeta: [],
360
- },
361
- }
362
- }
505
+ const loader: ModuleLoader = async () => ({
506
+ default: {
507
+ __buzolaPage: true,
508
+ component: () => null,
509
+ paramsMeta: [],
510
+ },
511
+ dashboard: {
512
+ __buzolaPage: true,
513
+ component: () => null,
514
+ paramsMeta: [],
515
+ },
516
+ })
363
517
 
364
- expect(buildFileRouteTree(files, loader)).rejects.toThrow(/Route collision/)
518
+ const tree = await buildFileRouteTree(files, loader)
519
+
520
+ const index = tree.find(n => n.isIndex)
521
+ expect(index).toBeDefined()
522
+ expect(index!.fullPath).toBe('/')
523
+
524
+ const dashboard = tree.find(n => n.segment === 'dashboard')
525
+ expect(dashboard).toBeDefined()
526
+ expect(dashboard!.fullPath).toBe('/dashboard')
527
+ expect(dashboard!.pageExports![0].routePattern).toBe('/dashboard')
365
528
  })
366
529
 
367
530
  it('handles nested _404 within a directory', async () => {
368
531
  const files: ScannedFile[] = [
532
+ { absolutePath: '/app/src/routes/users/_layout.tsx', relativePath: 'users/_layout.tsx' },
369
533
  { absolutePath: '/app/src/routes/users/index.tsx', relativePath: 'users/index.tsx' },
370
534
  { absolutePath: '/app/src/routes/users/_404.tsx', relativePath: 'users/_404.tsx' },
371
535
  ]
372
536
 
373
537
  const tree = await buildFileRouteTree(files, emptyLoader)
374
- const users = tree.find(n => n.segment === 'users')
375
- expect(users).toBeDefined()
538
+ const usersLayout = tree.find(n => n.isLayout && n.segment === 'users')
539
+ expect(usersLayout).toBeDefined()
376
540
 
377
- const notFound = users!.children.find(n => n.isNotFound)
541
+ const notFound = usersLayout!.children.find(n => n.isNotFound)
378
542
  expect(notFound).toBeDefined()
379
543
  expect(notFound!.segment).toBe(':__notFound+')
380
544
  expect(notFound!.fullPath).toBe('/users/:__notFound+')