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