@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.
- package/dist/__tests__/codegen.test.js +49 -1
- package/dist/__tests__/codegen.test.js.map +1 -1
- package/dist/__tests__/conventions.test.js +1 -1
- package/dist/__tests__/conventions.test.js.map +1 -1
- package/dist/__tests__/page-extractor.test.js +1 -1
- package/dist/__tests__/page-extractor.test.js.map +1 -1
- package/dist/__tests__/tree-builder.test.js +217 -78
- package/dist/__tests__/tree-builder.test.js.map +1 -1
- package/dist/generator/codegen.d.ts +1 -5
- package/dist/generator/codegen.d.ts.map +1 -1
- package/dist/generator/codegen.js +7 -9
- package/dist/generator/codegen.js.map +1 -1
- package/dist/generator/scanner.js +1 -1
- package/dist/generator/scanner.js.map +1 -1
- package/dist/generator/tree-builder.d.ts +7 -3
- package/dist/generator/tree-builder.d.ts.map +1 -1
- package/dist/generator/tree-builder.js +275 -188
- package/dist/generator/tree-builder.js.map +1 -1
- package/dist/index.d.ts +12 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts +9 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +12 -11
- package/dist/plugin.js.map +1 -1
- package/package.json +11 -3
- package/src/__tests__/codegen.test.ts +53 -1
- package/src/__tests__/conventions.test.ts +1 -1
- package/src/__tests__/page-extractor.test.ts +1 -1
- package/src/__tests__/tree-builder.test.ts +245 -81
- package/src/generator/codegen.ts +9 -11
- package/src/generator/scanner.ts +1 -1
- package/src/generator/tree-builder.ts +318 -224
- package/src/index.ts +12 -12
- package/src/plugin.ts +23 -12
package/src/generator/codegen.ts
CHANGED
|
@@ -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[]
|
|
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
|
|
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
|
-
}
|
package/src/generator/scanner.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
let
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
350
|
+
for (const ref of layoutRefs) {
|
|
351
|
+
if (!isPathPrefixOrEqual(ref.node.fullPath, routePattern)) continue
|
|
92
352
|
|
|
93
|
-
if
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
295
|
-
} catch {
|
|
296
|
-
|
|
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
|
|
344
|
-
if (node.isNotFound) return
|
|
345
|
-
if (node.segment.includes(':') && node.segment.includes('+')) return
|
|
346
|
-
if (node.segment.includes(':')) return
|
|
347
|
-
return
|
|
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
|
-
|
|
353
|
-
return `${
|
|
446
|
+
if (!parent || parent === '/') return `/${child}`
|
|
447
|
+
return `${parent}/${child}`
|
|
354
448
|
}
|