@pyreon/zero 0.24.4 → 0.24.6
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/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- package/src/vite-plugin.ts +0 -848
package/src/fs-router.ts
DELETED
|
@@ -1,1519 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import type { FileRoute, RenderMode, RouteFileExports } from './types'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Return type of a route file's `getStaticPaths()` export. Each entry
|
|
7
|
-
* supplies one set of concrete values for the route's dynamic segments;
|
|
8
|
-
* the SSG plugin expands the route's URL pattern with these params and
|
|
9
|
-
* renders one HTML file per entry.
|
|
10
|
-
*
|
|
11
|
-
* @example
|
|
12
|
-
* ```tsx
|
|
13
|
-
* // src/routes/posts/[id].tsx
|
|
14
|
-
* import type { GetStaticPaths } from '@pyreon/zero/server'
|
|
15
|
-
*
|
|
16
|
-
* export const getStaticPaths: GetStaticPaths<{ id: string }> = async () => {
|
|
17
|
-
* const posts = await fetch('https://api.example.com/posts').then(r => r.json())
|
|
18
|
-
* return posts.map((p) => ({ params: { id: p.slug } }))
|
|
19
|
-
* }
|
|
20
|
-
*
|
|
21
|
-
* export default function Post() { ... }
|
|
22
|
-
* ```
|
|
23
|
-
*
|
|
24
|
-
* For catch-all routes (`/blog/[...slug].tsx`), pass the full path through
|
|
25
|
-
* the catch-all param: `{ params: { slug: 'a/b' } }` → `/blog/a/b`.
|
|
26
|
-
*/
|
|
27
|
-
export type GetStaticPaths<
|
|
28
|
-
TParams extends Record<string, string> = Record<string, string>,
|
|
29
|
-
> = () =>
|
|
30
|
-
| Array<{ params: TParams }>
|
|
31
|
-
| Promise<Array<{ params: TParams }>>
|
|
32
|
-
|
|
33
|
-
// ─── File-system route conventions ──────────────────────────────────────────
|
|
34
|
-
//
|
|
35
|
-
// src/routes/
|
|
36
|
-
// _layout.tsx → layout for all routes
|
|
37
|
-
// index.tsx → /
|
|
38
|
-
// about.tsx → /about
|
|
39
|
-
// users/
|
|
40
|
-
// _layout.tsx → layout for /users/*
|
|
41
|
-
// _loading.tsx → loading fallback for /users/*
|
|
42
|
-
// _error.tsx → error boundary for /users/*
|
|
43
|
-
// index.tsx → /users
|
|
44
|
-
// [id].tsx → /users/:id
|
|
45
|
-
// [id]/
|
|
46
|
-
// settings.tsx → /users/:id/settings
|
|
47
|
-
// blog/
|
|
48
|
-
// [...slug].tsx → /blog/* (catch-all)
|
|
49
|
-
//
|
|
50
|
-
// Conventions:
|
|
51
|
-
// [param] → dynamic segment → :param
|
|
52
|
-
// [...param] → catch-all → :param*
|
|
53
|
-
// _layout → layout wrapper — must use <RouterView /> to render child routes
|
|
54
|
-
// (props.children is NOT passed — the router handles nesting)
|
|
55
|
-
// _error → error component
|
|
56
|
-
// _loading → loading component
|
|
57
|
-
// _404 → not-found component (renders on 404)
|
|
58
|
-
// _not-found → alias for _404
|
|
59
|
-
// (group) → route group (directory ignored in URL)
|
|
60
|
-
|
|
61
|
-
const ROUTE_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js']
|
|
62
|
-
|
|
63
|
-
/** Names whose top-level export presence we care about. */
|
|
64
|
-
const ROUTE_EXPORT_NAMES = [
|
|
65
|
-
'loader',
|
|
66
|
-
'guard',
|
|
67
|
-
'meta',
|
|
68
|
-
'renderMode',
|
|
69
|
-
'error',
|
|
70
|
-
'middleware',
|
|
71
|
-
'loaderKey',
|
|
72
|
-
'gcTime',
|
|
73
|
-
'getStaticPaths',
|
|
74
|
-
'revalidate',
|
|
75
|
-
] as const
|
|
76
|
-
|
|
77
|
-
type RouteExportName = (typeof ROUTE_EXPORT_NAMES)[number]
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Detect which optional metadata exports a route file source declares.
|
|
81
|
-
*
|
|
82
|
-
* Walks the source character-by-character, tracking string-literal and
|
|
83
|
-
* comment state, then collects top-level `export …` statements. This is
|
|
84
|
-
* more accurate than regex (no false matches inside string literals,
|
|
85
|
-
* template literals, or comments) and lighter than a full AST parse
|
|
86
|
-
* (no oxc/babel dependency, ~1µs per file).
|
|
87
|
-
*
|
|
88
|
-
* Recognizes:
|
|
89
|
-
* • `export const NAME = …`
|
|
90
|
-
* • `export let NAME = …`
|
|
91
|
-
* • `export var NAME = …`
|
|
92
|
-
* • `export function NAME(…)`
|
|
93
|
-
* • `export async function NAME(…)`
|
|
94
|
-
* • `export { NAME }` and `export { localName as NAME }`
|
|
95
|
-
* • `export { NAME } from '…'` (re-export)
|
|
96
|
-
*
|
|
97
|
-
* Names checked: loader, guard, meta, renderMode, error, middleware.
|
|
98
|
-
*/
|
|
99
|
-
export function detectRouteExports(source: string): RouteFileExports {
|
|
100
|
-
const found = new Set<RouteExportName>()
|
|
101
|
-
const tokens = scanTopLevelExportTokens(source)
|
|
102
|
-
|
|
103
|
-
for (const tok of tokens) {
|
|
104
|
-
if (tok.kind === 'declaration') {
|
|
105
|
-
// `export const NAME` / `export function NAME`
|
|
106
|
-
if ((ROUTE_EXPORT_NAMES as readonly string[]).includes(tok.name)) {
|
|
107
|
-
found.add(tok.name as RouteExportName)
|
|
108
|
-
}
|
|
109
|
-
} else {
|
|
110
|
-
// `export { localName as exportedName, ... }`
|
|
111
|
-
for (const name of tok.names) {
|
|
112
|
-
if ((ROUTE_EXPORT_NAMES as readonly string[]).includes(name)) {
|
|
113
|
-
found.add(name as RouteExportName)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Capture literal `meta` and `renderMode` initializers when present
|
|
120
|
-
// so the route generator can inline them and avoid forcing a static
|
|
121
|
-
// import of the entire route module just to read the metadata.
|
|
122
|
-
// Strip any trailing `as const` / `satisfies T` type assertions —
|
|
123
|
-
// the generated routes module is plain JS, not TS.
|
|
124
|
-
//
|
|
125
|
-
// We then run `isPureLiteral()` to make sure the captured expression
|
|
126
|
-
// doesn't reference any free identifiers (e.g. `meta = { title: foo }`
|
|
127
|
-
// where `foo` is a const declared elsewhere in the file). Inlining
|
|
128
|
-
// such an expression into the routes module would produce a runtime
|
|
129
|
-
// ReferenceError, so we drop the literal and let the generator fall
|
|
130
|
-
// back to a static module import in those cases.
|
|
131
|
-
const rawMeta = found.has('meta') ? extractLiteralExport(source, 'meta') : undefined
|
|
132
|
-
const rawRenderMode = found.has('renderMode')
|
|
133
|
-
? extractLiteralExport(source, 'renderMode')
|
|
134
|
-
: undefined
|
|
135
|
-
// PR I — capture `revalidate` as a literal so the build-time ISR
|
|
136
|
-
// manifest (`dist/_pyreon-revalidate.json`) can be emitted from the
|
|
137
|
-
// SSG plugin without loading the route module. The route generator
|
|
138
|
-
// does NOT inline `revalidate` into the route record — it's a build-
|
|
139
|
-
// time-only concern that adapters consume via the manifest.
|
|
140
|
-
const rawRevalidate = found.has('revalidate')
|
|
141
|
-
? extractLiteralExport(source, 'revalidate')
|
|
142
|
-
: undefined
|
|
143
|
-
const cleanMeta = rawMeta !== undefined ? stripTypeAssertions(rawMeta) : undefined
|
|
144
|
-
const cleanRenderMode =
|
|
145
|
-
rawRenderMode !== undefined ? stripTypeAssertions(rawRenderMode) : undefined
|
|
146
|
-
const cleanRevalidate =
|
|
147
|
-
rawRevalidate !== undefined ? stripTypeAssertions(rawRevalidate) : undefined
|
|
148
|
-
const metaLiteral = cleanMeta !== undefined && isPureLiteral(cleanMeta) ? cleanMeta : undefined
|
|
149
|
-
const renderModeLiteral =
|
|
150
|
-
cleanRenderMode !== undefined && isPureLiteral(cleanRenderMode) ? cleanRenderMode : undefined
|
|
151
|
-
// `revalidate` literals are number (`60`) or boolean (`false`) — never
|
|
152
|
-
// an object/array — so `isPureLiteral` is overkill. Keep the same
|
|
153
|
-
// safety check for defense-in-depth.
|
|
154
|
-
const revalidateLiteral =
|
|
155
|
-
cleanRevalidate !== undefined && isPureLiteral(cleanRevalidate) ? cleanRevalidate : undefined
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
hasLoader: found.has('loader'),
|
|
159
|
-
hasGuard: found.has('guard'),
|
|
160
|
-
hasMeta: found.has('meta'),
|
|
161
|
-
hasRenderMode: found.has('renderMode'),
|
|
162
|
-
hasError: found.has('error'),
|
|
163
|
-
hasMiddleware: found.has('middleware'),
|
|
164
|
-
hasLoaderKey: found.has('loaderKey'),
|
|
165
|
-
hasGcTime: found.has('gcTime'),
|
|
166
|
-
hasGetStaticPaths: found.has('getStaticPaths'),
|
|
167
|
-
hasRevalidate: found.has('revalidate'),
|
|
168
|
-
...(metaLiteral !== undefined ? { metaLiteral } : {}),
|
|
169
|
-
...(renderModeLiteral !== undefined ? { renderModeLiteral } : {}),
|
|
170
|
-
...(revalidateLiteral !== undefined ? { revalidateLiteral } : {}),
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Extract the literal initializer of an `export const NAME = …` statement
|
|
176
|
-
* as a raw text slice — used by the route generator to inline `meta` and
|
|
177
|
-
* `renderMode` values into the generated routes module.
|
|
178
|
-
*
|
|
179
|
-
* Walks the source character-by-character respecting strings, template
|
|
180
|
-
* literals, comments, and brace/bracket/paren nesting. The slice runs
|
|
181
|
-
* from the first non-whitespace character after `=` to the matching
|
|
182
|
-
* end-of-expression terminator (`;`, newline at depth 0, or top-level
|
|
183
|
-
* `export`). Whatever the slice contains is handed to V8 verbatim by
|
|
184
|
-
* embedding it inside `{ … }` in the generated module — which means
|
|
185
|
-
* the original source must already be valid JavaScript (which it is,
|
|
186
|
-
* since the route file compiles).
|
|
187
|
-
*
|
|
188
|
-
* Returns `undefined` when extraction fails for any reason — the
|
|
189
|
-
* generator falls back to a static module import in that case.
|
|
190
|
-
*/
|
|
191
|
-
function extractLiteralExport(source: string, name: string): string | undefined {
|
|
192
|
-
// Find `export const NAME = ` at top level. Reuse the same
|
|
193
|
-
// string/comment/depth tracking as the token scanner so we don't
|
|
194
|
-
// false-match inside literals.
|
|
195
|
-
const len = source.length
|
|
196
|
-
let i = 0
|
|
197
|
-
let depth = 0
|
|
198
|
-
|
|
199
|
-
const isIdCont = (c: string) => /[A-Za-z0-9_$]/.test(c)
|
|
200
|
-
const skipWs = (p: number): number => {
|
|
201
|
-
while (p < len && /\s/.test(source[p] as string)) p++
|
|
202
|
-
return p
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
while (i < len) {
|
|
206
|
-
const ch = source[i] as string
|
|
207
|
-
const next = source[i + 1] ?? ''
|
|
208
|
-
|
|
209
|
-
// Skip comments
|
|
210
|
-
if (ch === '/' && next === '/') {
|
|
211
|
-
while (i < len && source[i] !== '\n') i++
|
|
212
|
-
continue
|
|
213
|
-
}
|
|
214
|
-
if (ch === '/' && next === '*') {
|
|
215
|
-
i += 2
|
|
216
|
-
while (i < len - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++
|
|
217
|
-
i += 2
|
|
218
|
-
continue
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Skip string literals
|
|
222
|
-
if (ch === '"' || ch === "'") {
|
|
223
|
-
const quote = ch
|
|
224
|
-
i++
|
|
225
|
-
while (i < len && source[i] !== quote) {
|
|
226
|
-
if (source[i] === '\\') i += 2
|
|
227
|
-
else i++
|
|
228
|
-
}
|
|
229
|
-
i++
|
|
230
|
-
continue
|
|
231
|
-
}
|
|
232
|
-
if (ch === '`') {
|
|
233
|
-
i++
|
|
234
|
-
while (i < len && source[i] !== '`') {
|
|
235
|
-
if (source[i] === '\\') {
|
|
236
|
-
i += 2
|
|
237
|
-
continue
|
|
238
|
-
}
|
|
239
|
-
if (source[i] === '$' && source[i + 1] === '{') {
|
|
240
|
-
i += 2
|
|
241
|
-
let exprDepth = 1
|
|
242
|
-
while (i < len && exprDepth > 0) {
|
|
243
|
-
const c = source[i] as string
|
|
244
|
-
if (c === '{') exprDepth++
|
|
245
|
-
else if (c === '}') exprDepth--
|
|
246
|
-
if (exprDepth === 0) {
|
|
247
|
-
i++
|
|
248
|
-
break
|
|
249
|
-
}
|
|
250
|
-
i++
|
|
251
|
-
}
|
|
252
|
-
continue
|
|
253
|
-
}
|
|
254
|
-
i++
|
|
255
|
-
}
|
|
256
|
-
i++
|
|
257
|
-
continue
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Brace depth tracking
|
|
261
|
-
if (ch === '{') {
|
|
262
|
-
depth++
|
|
263
|
-
i++
|
|
264
|
-
continue
|
|
265
|
-
}
|
|
266
|
-
if (ch === '}') {
|
|
267
|
-
depth--
|
|
268
|
-
i++
|
|
269
|
-
continue
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Look for `export const NAME = …` at depth 0
|
|
273
|
-
if (depth === 0 && ch === 'e') {
|
|
274
|
-
const afterExport = source.slice(i, i + 6) === 'export' && !isIdCont(source[i + 6] ?? '')
|
|
275
|
-
if (afterExport) {
|
|
276
|
-
let p = skipWs(i + 6)
|
|
277
|
-
if (source.slice(p, p + 5) === 'const' && !isIdCont(source[p + 5] ?? '')) {
|
|
278
|
-
p = skipWs(p + 5)
|
|
279
|
-
// Check that the identifier matches our target name
|
|
280
|
-
if (
|
|
281
|
-
source.slice(p, p + name.length) === name &&
|
|
282
|
-
!isIdCont(source[p + name.length] ?? '')
|
|
283
|
-
) {
|
|
284
|
-
p = skipWs(p + name.length)
|
|
285
|
-
if (source[p] === '=') {
|
|
286
|
-
p = skipWs(p + 1)
|
|
287
|
-
return readExpressionUntilEnd(source, p)
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
i = i + 6
|
|
292
|
-
continue
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
i++
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
return undefined
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Read a JavaScript expression starting at `start` and return the raw
|
|
304
|
-
* text up to (but not including) its end. The end is whichever comes
|
|
305
|
-
* first of:
|
|
306
|
-
* • a `;` at depth 0
|
|
307
|
-
* • a newline at depth 0 that is not inside a string/template
|
|
308
|
-
* • the next top-level `export` / `const` / `function` keyword
|
|
309
|
-
* • end of file
|
|
310
|
-
*
|
|
311
|
-
* Tracks `()`, `[]`, and `{}` nesting plus string/template/comment
|
|
312
|
-
* state so depth-0 boundaries are detected correctly even for nested
|
|
313
|
-
* objects, arrays, and tagged templates.
|
|
314
|
-
*/
|
|
315
|
-
function readExpressionUntilEnd(source: string, start: number): string | undefined {
|
|
316
|
-
const len = source.length
|
|
317
|
-
let i = start
|
|
318
|
-
let depth = 0 // combined paren/bracket/brace depth
|
|
319
|
-
|
|
320
|
-
while (i < len) {
|
|
321
|
-
const ch = source[i] as string
|
|
322
|
-
const next = source[i + 1] ?? ''
|
|
323
|
-
|
|
324
|
-
// End conditions at depth 0
|
|
325
|
-
if (depth === 0) {
|
|
326
|
-
if (ch === ';') return source.slice(start, i).trim() || undefined
|
|
327
|
-
if (ch === '\n') {
|
|
328
|
-
// Allow trailing whitespace/comma but stop at the newline.
|
|
329
|
-
// Some authors close objects on the same line, others span
|
|
330
|
-
// them across lines — the depth check above handles the
|
|
331
|
-
// multi-line case so a depth-0 newline really is the end.
|
|
332
|
-
const trimmed = source.slice(start, i).trim()
|
|
333
|
-
if (trimmed.length === 0) {
|
|
334
|
-
i++
|
|
335
|
-
continue
|
|
336
|
-
}
|
|
337
|
-
return trimmed
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Skip comments
|
|
342
|
-
if (ch === '/' && next === '/') {
|
|
343
|
-
while (i < len && source[i] !== '\n') i++
|
|
344
|
-
continue
|
|
345
|
-
}
|
|
346
|
-
if (ch === '/' && next === '*') {
|
|
347
|
-
i += 2
|
|
348
|
-
while (i < len - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++
|
|
349
|
-
i += 2
|
|
350
|
-
continue
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Skip strings
|
|
354
|
-
if (ch === '"' || ch === "'") {
|
|
355
|
-
const quote = ch
|
|
356
|
-
i++
|
|
357
|
-
while (i < len && source[i] !== quote) {
|
|
358
|
-
if (source[i] === '\\') i += 2
|
|
359
|
-
else i++
|
|
360
|
-
}
|
|
361
|
-
i++
|
|
362
|
-
continue
|
|
363
|
-
}
|
|
364
|
-
if (ch === '`') {
|
|
365
|
-
i++
|
|
366
|
-
while (i < len && source[i] !== '`') {
|
|
367
|
-
if (source[i] === '\\') {
|
|
368
|
-
i += 2
|
|
369
|
-
continue
|
|
370
|
-
}
|
|
371
|
-
if (source[i] === '$' && source[i + 1] === '{') {
|
|
372
|
-
i += 2
|
|
373
|
-
let exprDepth = 1
|
|
374
|
-
while (i < len && exprDepth > 0) {
|
|
375
|
-
const c = source[i] as string
|
|
376
|
-
if (c === '{') exprDepth++
|
|
377
|
-
else if (c === '}') exprDepth--
|
|
378
|
-
if (exprDepth === 0) {
|
|
379
|
-
i++
|
|
380
|
-
break
|
|
381
|
-
}
|
|
382
|
-
i++
|
|
383
|
-
}
|
|
384
|
-
continue
|
|
385
|
-
}
|
|
386
|
-
i++
|
|
387
|
-
}
|
|
388
|
-
i++
|
|
389
|
-
continue
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Track depth across all bracket families
|
|
393
|
-
if (ch === '{' || ch === '[' || ch === '(') {
|
|
394
|
-
depth++
|
|
395
|
-
i++
|
|
396
|
-
continue
|
|
397
|
-
}
|
|
398
|
-
if (ch === '}' || ch === ']' || ch === ')') {
|
|
399
|
-
depth--
|
|
400
|
-
if (depth < 0) {
|
|
401
|
-
// We ran past our scope without seeing a terminator. The
|
|
402
|
-
// expression must have ended right before this closer.
|
|
403
|
-
return source.slice(start, i).trim() || undefined
|
|
404
|
-
}
|
|
405
|
-
i++
|
|
406
|
-
continue
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
i++
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Hit EOF without an explicit terminator — return whatever we have
|
|
413
|
-
// if it looks plausible, otherwise undefined.
|
|
414
|
-
const trimmed = source.slice(start).trim()
|
|
415
|
-
return trimmed.length > 0 ? trimmed : undefined
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* True if `text` is a pure JS literal — only string/number/boolean/null
|
|
420
|
-
* literals plus the structural punctuation needed to compose them into
|
|
421
|
-
* objects, arrays, and tuples. Identifiers, operators, function calls,
|
|
422
|
-
* template-literal expression slots, and references to other names all
|
|
423
|
-
* disqualify the expression.
|
|
424
|
-
*
|
|
425
|
-
* Walks the source character-by-character, tracking string/template/
|
|
426
|
-
* comment state. Inside a string or template head (no `${}` slot) every
|
|
427
|
-
* character is fine; outside strings, only the structural symbols
|
|
428
|
-
* `{}[](),:` plus whitespace, digits, the literal keywords `true`,
|
|
429
|
-
* `false`, `null`, and `-` (for negative numbers) are allowed.
|
|
430
|
-
*
|
|
431
|
-
* The check is conservative on purpose — anything fancier than a flat
|
|
432
|
-
* literal falls back to the static-import path, which still works,
|
|
433
|
-
* just at the cost of one un-split chunk.
|
|
434
|
-
*/
|
|
435
|
-
function isPureLiteral(text: string): boolean {
|
|
436
|
-
const len = text.length
|
|
437
|
-
let i = 0
|
|
438
|
-
|
|
439
|
-
while (i < len) {
|
|
440
|
-
const ch = text[i] as string
|
|
441
|
-
|
|
442
|
-
// Strings — anything inside is literal data
|
|
443
|
-
if (ch === '"' || ch === "'") {
|
|
444
|
-
const quote = ch
|
|
445
|
-
i++
|
|
446
|
-
while (i < len && text[i] !== quote) {
|
|
447
|
-
if (text[i] === '\\') i += 2
|
|
448
|
-
else i++
|
|
449
|
-
}
|
|
450
|
-
i++
|
|
451
|
-
continue
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// Template literals — only allowed if they contain no ${} slots
|
|
455
|
-
if (ch === '`') {
|
|
456
|
-
i++
|
|
457
|
-
while (i < len && text[i] !== '`') {
|
|
458
|
-
if (text[i] === '\\') {
|
|
459
|
-
i += 2
|
|
460
|
-
continue
|
|
461
|
-
}
|
|
462
|
-
if (text[i] === '$' && text[i + 1] === '{') {
|
|
463
|
-
// Template with an expression slot — not a pure literal
|
|
464
|
-
return false
|
|
465
|
-
}
|
|
466
|
-
i++
|
|
467
|
-
}
|
|
468
|
-
i++
|
|
469
|
-
continue
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Whitespace + structural punctuation are fine
|
|
473
|
-
if (/\s/.test(ch)) {
|
|
474
|
-
i++
|
|
475
|
-
continue
|
|
476
|
-
}
|
|
477
|
-
if (ch === '{' || ch === '}' || ch === '[' || ch === ']' || ch === ',' || ch === ':') {
|
|
478
|
-
i++
|
|
479
|
-
continue
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Number literals (including leading - and 0x/0b/0o)
|
|
483
|
-
if (/[0-9]/.test(ch) || (ch === '-' && /[0-9]/.test(text[i + 1] ?? ''))) {
|
|
484
|
-
while (i < len && /[0-9a-fA-Fxob.eE+\-_]/.test(text[i] as string)) i++
|
|
485
|
-
continue
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Allowed bare identifiers — only the literal keywords
|
|
489
|
-
if (text.slice(i, i + 4) === 'true' && !isIdContChar(text[i + 4] ?? '')) {
|
|
490
|
-
i += 4
|
|
491
|
-
continue
|
|
492
|
-
}
|
|
493
|
-
if (text.slice(i, i + 5) === 'false' && !isIdContChar(text[i + 5] ?? '')) {
|
|
494
|
-
i += 5
|
|
495
|
-
continue
|
|
496
|
-
}
|
|
497
|
-
if (text.slice(i, i + 4) === 'null' && !isIdContChar(text[i + 4] ?? '')) {
|
|
498
|
-
i += 4
|
|
499
|
-
continue
|
|
500
|
-
}
|
|
501
|
-
if (text.slice(i, i + 9) === 'undefined' && !isIdContChar(text[i + 9] ?? '')) {
|
|
502
|
-
i += 9
|
|
503
|
-
continue
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// Property keys can be unquoted identifiers — they're followed by `:`.
|
|
507
|
-
// Walk over the identifier; if the next non-whitespace char is `:`,
|
|
508
|
-
// accept it as a key. Otherwise the identifier is a free reference
|
|
509
|
-
// and the expression isn't pure.
|
|
510
|
-
if (/[A-Za-z_$]/.test(ch)) {
|
|
511
|
-
let end = i + 1
|
|
512
|
-
while (end < len && isIdContChar(text[end] as string)) end++
|
|
513
|
-
let after = end
|
|
514
|
-
while (after < len && /\s/.test(text[after] as string)) after++
|
|
515
|
-
if (text[after] === ':') {
|
|
516
|
-
// unquoted property key — fine
|
|
517
|
-
i = end
|
|
518
|
-
continue
|
|
519
|
-
}
|
|
520
|
-
return false
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// Anything else (operators, parens for function calls, etc.) → not pure
|
|
524
|
-
return false
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
return true
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
function isIdContChar(c: string): boolean {
|
|
531
|
-
return /[A-Za-z0-9_$]/.test(c)
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Strip TypeScript type-only suffixes (`as const`, `as SomeType`,
|
|
536
|
-
* `satisfies SomeType`) from a literal expression so the generated
|
|
537
|
-
* JS module is syntactically valid.
|
|
538
|
-
*
|
|
539
|
-
* The route file is TypeScript so authors freely write
|
|
540
|
-
* `export const renderMode = 'ssg' as const` — but the generated
|
|
541
|
-
* `virtual:zero/routes` module is JavaScript and can't keep the cast.
|
|
542
|
-
* Strip from the rightmost top-level `as` or `satisfies` keyword.
|
|
543
|
-
*/
|
|
544
|
-
export function stripTypeAssertions(literal: string): string {
|
|
545
|
-
let result = literal.trim()
|
|
546
|
-
|
|
547
|
-
// Walk from the right at depth 0, find the LAST occurrence of
|
|
548
|
-
// ` as ` or ` satisfies ` and cut everything to the right of it.
|
|
549
|
-
// We use a depth-aware right-to-left scan because the literal can
|
|
550
|
-
// contain `as`/`satisfies` inside nested objects (e.g. a string
|
|
551
|
-
// value `'satisfies the schema'` should be left untouched).
|
|
552
|
-
let depth = 0
|
|
553
|
-
for (let i = result.length - 1; i > 0; i--) {
|
|
554
|
-
const ch = result[i] as string
|
|
555
|
-
if (ch === ')' || ch === ']' || ch === '}') depth++
|
|
556
|
-
else if (ch === '(' || ch === '[' || ch === '{') depth--
|
|
557
|
-
|
|
558
|
-
if (depth !== 0) continue
|
|
559
|
-
|
|
560
|
-
// Check for ` as ` boundary
|
|
561
|
-
if (
|
|
562
|
-
i >= 4 &&
|
|
563
|
-
result[i - 3] === ' ' &&
|
|
564
|
-
result[i - 2] === 'a' &&
|
|
565
|
-
result[i - 1] === 's' &&
|
|
566
|
-
result[i] === ' '
|
|
567
|
-
) {
|
|
568
|
-
result = result.slice(0, i - 3).trim()
|
|
569
|
-
i = result.length
|
|
570
|
-
depth = 0
|
|
571
|
-
continue
|
|
572
|
-
}
|
|
573
|
-
// Check for ` satisfies ` boundary
|
|
574
|
-
if (
|
|
575
|
-
i >= 11 &&
|
|
576
|
-
result.slice(i - 10, i + 1) === ' satisfies '
|
|
577
|
-
) {
|
|
578
|
-
result = result.slice(0, i - 10).trim()
|
|
579
|
-
i = result.length
|
|
580
|
-
depth = 0
|
|
581
|
-
continue
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
return result
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* Lightweight tokenizer for the export forms detectRouteExports cares about.
|
|
590
|
-
* Returns an array of either:
|
|
591
|
-
* • `{ kind: 'declaration', name }` — `export const NAME = …`
|
|
592
|
-
* • `{ kind: 'list', names }` — `export { NAME, other as NAME2 }`
|
|
593
|
-
*
|
|
594
|
-
* Only top-level statements (brace depth 0) are considered. String literals,
|
|
595
|
-
* template literals, and comments are skipped so their contents can't trigger
|
|
596
|
-
* false matches.
|
|
597
|
-
*/
|
|
598
|
-
type ExportToken =
|
|
599
|
-
| { kind: 'declaration'; name: string }
|
|
600
|
-
| { kind: 'list'; names: string[] }
|
|
601
|
-
|
|
602
|
-
function scanTopLevelExportTokens(source: string): ExportToken[] {
|
|
603
|
-
const tokens: ExportToken[] = []
|
|
604
|
-
const len = source.length
|
|
605
|
-
let i = 0
|
|
606
|
-
let depth = 0 // brace depth — we only care about top-level (depth 0)
|
|
607
|
-
|
|
608
|
-
// Identifier characters used to skip past names and to validate that
|
|
609
|
-
// a match isn't a substring of a longer identifier.
|
|
610
|
-
const isIdStart = (c: string) => /[A-Za-z_$]/.test(c)
|
|
611
|
-
const isIdCont = (c: string) => /[A-Za-z0-9_$]/.test(c)
|
|
612
|
-
|
|
613
|
-
// Read an identifier starting at position p; returns [name, nextPos] or null.
|
|
614
|
-
const readIdentifier = (p: number): [string, number] | null => {
|
|
615
|
-
if (p >= len || !isIdStart(source[p] as string)) return null
|
|
616
|
-
let end = p + 1
|
|
617
|
-
while (end < len && isIdCont(source[end] as string)) end++
|
|
618
|
-
return [source.slice(p, end), end]
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// Skip whitespace including newlines.
|
|
622
|
-
const skipWs = (p: number): number => {
|
|
623
|
-
while (p < len && /\s/.test(source[p] as string)) p++
|
|
624
|
-
return p
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Match the literal `keyword` at position p, requiring an identifier
|
|
628
|
-
// boundary on both sides. Returns nextPos or -1.
|
|
629
|
-
const matchKeyword = (p: number, keyword: string): number => {
|
|
630
|
-
if (source.slice(p, p + keyword.length) !== keyword) return -1
|
|
631
|
-
const after = p + keyword.length
|
|
632
|
-
if (after < len && isIdCont(source[after] as string)) return -1
|
|
633
|
-
if (p > 0 && isIdCont(source[p - 1] as string)) return -1
|
|
634
|
-
return after
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
while (i < len) {
|
|
638
|
-
const ch = source[i] as string
|
|
639
|
-
const next = source[i + 1] ?? ''
|
|
640
|
-
|
|
641
|
-
// ── Comments ──────────────────────────────────────────────────────
|
|
642
|
-
if (ch === '/' && next === '/') {
|
|
643
|
-
// Line comment — skip to newline
|
|
644
|
-
while (i < len && source[i] !== '\n') i++
|
|
645
|
-
continue
|
|
646
|
-
}
|
|
647
|
-
if (ch === '/' && next === '*') {
|
|
648
|
-
// Block comment — skip to closing */
|
|
649
|
-
i += 2
|
|
650
|
-
while (i < len - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++
|
|
651
|
-
i += 2
|
|
652
|
-
continue
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// ── String / template literals ────────────────────────────────────
|
|
656
|
-
if (ch === '"' || ch === "'") {
|
|
657
|
-
const quote = ch
|
|
658
|
-
i++
|
|
659
|
-
while (i < len && source[i] !== quote) {
|
|
660
|
-
if (source[i] === '\\') i += 2
|
|
661
|
-
else i++
|
|
662
|
-
}
|
|
663
|
-
i++
|
|
664
|
-
continue
|
|
665
|
-
}
|
|
666
|
-
if (ch === '`') {
|
|
667
|
-
// Template literal — skip to closing backtick, handling ${...} blocks
|
|
668
|
-
i++
|
|
669
|
-
while (i < len && source[i] !== '`') {
|
|
670
|
-
if (source[i] === '\\') {
|
|
671
|
-
i += 2
|
|
672
|
-
continue
|
|
673
|
-
}
|
|
674
|
-
if (source[i] === '$' && source[i + 1] === '{') {
|
|
675
|
-
// Skip a balanced ${ ... } expression
|
|
676
|
-
i += 2
|
|
677
|
-
let exprDepth = 1
|
|
678
|
-
while (i < len && exprDepth > 0) {
|
|
679
|
-
const c = source[i] as string
|
|
680
|
-
if (c === '{') exprDepth++
|
|
681
|
-
else if (c === '}') exprDepth--
|
|
682
|
-
if (exprDepth === 0) {
|
|
683
|
-
i++
|
|
684
|
-
break
|
|
685
|
-
}
|
|
686
|
-
i++
|
|
687
|
-
}
|
|
688
|
-
continue
|
|
689
|
-
}
|
|
690
|
-
i++
|
|
691
|
-
}
|
|
692
|
-
i++
|
|
693
|
-
continue
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// ── Brace depth tracking ──────────────────────────────────────────
|
|
697
|
-
if (ch === '{') {
|
|
698
|
-
depth++
|
|
699
|
-
i++
|
|
700
|
-
continue
|
|
701
|
-
}
|
|
702
|
-
if (ch === '}') {
|
|
703
|
-
depth--
|
|
704
|
-
i++
|
|
705
|
-
continue
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// ── `export …` at top level ──────────────────────────────────────
|
|
709
|
-
if (depth === 0 && ch === 'e') {
|
|
710
|
-
const afterExport = matchKeyword(i, 'export')
|
|
711
|
-
if (afterExport > 0) {
|
|
712
|
-
// Found `export` token at top level. Look at what follows.
|
|
713
|
-
let p = skipWs(afterExport)
|
|
714
|
-
|
|
715
|
-
// `export default …` — not a named export we care about
|
|
716
|
-
const afterDefault = matchKeyword(p, 'default')
|
|
717
|
-
if (afterDefault > 0) {
|
|
718
|
-
i = afterDefault
|
|
719
|
-
continue
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// `export { … }` (export list, possibly followed by `from '…'`)
|
|
723
|
-
if (source[p] === '{') {
|
|
724
|
-
p++
|
|
725
|
-
const names: string[] = []
|
|
726
|
-
while (p < len && source[p] !== '}') {
|
|
727
|
-
p = skipWs(p)
|
|
728
|
-
if (source[p] === '}') break
|
|
729
|
-
const id = readIdentifier(p)
|
|
730
|
-
if (!id) {
|
|
731
|
-
p++
|
|
732
|
-
continue
|
|
733
|
-
}
|
|
734
|
-
const [first, afterFirst] = id
|
|
735
|
-
// `localName as exportedName` — the EXPORTED name is what counts
|
|
736
|
-
let exportedName = first
|
|
737
|
-
const afterFirstWs = skipWs(afterFirst)
|
|
738
|
-
const afterAs = matchKeyword(afterFirstWs, 'as')
|
|
739
|
-
if (afterAs > 0) {
|
|
740
|
-
const aliasStart = skipWs(afterAs)
|
|
741
|
-
const alias = readIdentifier(aliasStart)
|
|
742
|
-
if (alias) {
|
|
743
|
-
exportedName = alias[0]
|
|
744
|
-
p = alias[1]
|
|
745
|
-
} else {
|
|
746
|
-
p = afterFirst
|
|
747
|
-
}
|
|
748
|
-
} else {
|
|
749
|
-
p = afterFirst
|
|
750
|
-
}
|
|
751
|
-
names.push(exportedName)
|
|
752
|
-
p = skipWs(p)
|
|
753
|
-
if (source[p] === ',') p++
|
|
754
|
-
}
|
|
755
|
-
tokens.push({ kind: 'list', names })
|
|
756
|
-
i = p + 1 // past closing brace
|
|
757
|
-
continue
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// `export async function NAME …`
|
|
761
|
-
const afterAsync = matchKeyword(p, 'async')
|
|
762
|
-
if (afterAsync > 0) p = skipWs(afterAsync)
|
|
763
|
-
|
|
764
|
-
// `export const | let | var | function NAME …`
|
|
765
|
-
let foundDecl = false
|
|
766
|
-
for (const kw of ['const', 'let', 'var', 'function'] as const) {
|
|
767
|
-
const afterKw = matchKeyword(p, kw)
|
|
768
|
-
if (afterKw > 0) {
|
|
769
|
-
const nameStart = skipWs(afterKw)
|
|
770
|
-
const id = readIdentifier(nameStart)
|
|
771
|
-
if (id) {
|
|
772
|
-
tokens.push({ kind: 'declaration', name: id[0] })
|
|
773
|
-
i = id[1] // advance past the identifier we just consumed
|
|
774
|
-
foundDecl = true
|
|
775
|
-
break
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
// If we couldn't recognize a declaration form, advance past `export`
|
|
780
|
-
// so the outer loop doesn't re-match the same token forever.
|
|
781
|
-
if (!foundDecl) i = afterExport
|
|
782
|
-
continue
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
i++
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
return tokens
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/** All-false exports record. Used when source detection fails. */
|
|
793
|
-
const EMPTY_EXPORTS: RouteFileExports = {
|
|
794
|
-
hasLoader: false,
|
|
795
|
-
hasGuard: false,
|
|
796
|
-
hasMeta: false,
|
|
797
|
-
hasRenderMode: false,
|
|
798
|
-
hasError: false,
|
|
799
|
-
hasMiddleware: false,
|
|
800
|
-
hasLoaderKey: false,
|
|
801
|
-
hasGcTime: false,
|
|
802
|
-
hasGetStaticPaths: false,
|
|
803
|
-
hasRevalidate: false,
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
/**
|
|
807
|
-
* True if a route file declares ANY metadata export.
|
|
808
|
-
* Used by the code generator to decide whether to emit a static
|
|
809
|
-
* `import * as mod` (for metadata access) instead of lazy().
|
|
810
|
-
*/
|
|
811
|
-
export function hasAnyMetaExport(exports: RouteFileExports): boolean {
|
|
812
|
-
return (
|
|
813
|
-
exports.hasLoader ||
|
|
814
|
-
exports.hasGuard ||
|
|
815
|
-
exports.hasMeta ||
|
|
816
|
-
exports.hasRenderMode ||
|
|
817
|
-
exports.hasError ||
|
|
818
|
-
exports.hasMiddleware ||
|
|
819
|
-
exports.hasLoaderKey ||
|
|
820
|
-
exports.hasGcTime ||
|
|
821
|
-
exports.hasGetStaticPaths
|
|
822
|
-
)
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
/**
|
|
826
|
-
* Parse a set of file paths (relative to routes dir) into FileRoute objects.
|
|
827
|
-
*
|
|
828
|
-
* @param files Array of file paths like ["index.tsx", "users/[id].tsx"]
|
|
829
|
-
* @param defaultMode Default rendering mode from config
|
|
830
|
-
* @param exportsMap Optional map of filePath → detected exports. When
|
|
831
|
-
* provided, the resulting FileRoute objects carry export info that the
|
|
832
|
-
* code generator uses to optimize imports (skip metadata namespace
|
|
833
|
-
* imports for routes that only export `default`).
|
|
834
|
-
*/
|
|
835
|
-
export function parseFileRoutes(
|
|
836
|
-
files: string[],
|
|
837
|
-
defaultMode: RenderMode = 'ssr',
|
|
838
|
-
exportsMap?: Map<string, RouteFileExports>,
|
|
839
|
-
): FileRoute[] {
|
|
840
|
-
return files
|
|
841
|
-
.filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext)))
|
|
842
|
-
.map((filePath) => {
|
|
843
|
-
const route = parseFilePath(filePath, defaultMode)
|
|
844
|
-
const exp = exportsMap?.get(filePath)
|
|
845
|
-
return exp ? { ...route, exports: exp } : route
|
|
846
|
-
})
|
|
847
|
-
.sort(sortRoutes)
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
function parseFilePath(filePath: string, defaultMode: RenderMode): FileRoute {
|
|
851
|
-
// Remove extension
|
|
852
|
-
let route = filePath
|
|
853
|
-
for (const ext of ROUTE_EXTENSIONS) {
|
|
854
|
-
if (route.endsWith(ext)) {
|
|
855
|
-
route = route.slice(0, -ext.length)
|
|
856
|
-
break
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
const fileName = getFileName(route)
|
|
861
|
-
const isLayout = fileName === '_layout'
|
|
862
|
-
const isError = fileName === '_error'
|
|
863
|
-
const isLoading = fileName === '_loading'
|
|
864
|
-
const isNotFound = fileName === '_404' || fileName === '_not-found'
|
|
865
|
-
const isCatchAll = route.includes('[...')
|
|
866
|
-
|
|
867
|
-
// Get directory path (strip groups for consistent grouping)
|
|
868
|
-
const parts = route.split('/')
|
|
869
|
-
parts.pop() // remove filename
|
|
870
|
-
const dirPath = parts.filter((s) => !(s.startsWith('(') && s.endsWith(')'))).join('/')
|
|
871
|
-
|
|
872
|
-
// Convert file path to URL pattern
|
|
873
|
-
const urlPath = filePathToUrlPath(route)
|
|
874
|
-
const depth = urlPath === '/' ? 0 : urlPath.split('/').filter(Boolean).length
|
|
875
|
-
|
|
876
|
-
return {
|
|
877
|
-
filePath,
|
|
878
|
-
urlPath,
|
|
879
|
-
dirPath,
|
|
880
|
-
depth,
|
|
881
|
-
isLayout,
|
|
882
|
-
isError,
|
|
883
|
-
isLoading,
|
|
884
|
-
isNotFound,
|
|
885
|
-
isCatchAll,
|
|
886
|
-
renderMode: defaultMode,
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
/**
|
|
891
|
-
* Convert a file path (without extension) to a URL path pattern.
|
|
892
|
-
*
|
|
893
|
-
* Examples:
|
|
894
|
-
* "index" → "/"
|
|
895
|
-
* "about" → "/about"
|
|
896
|
-
* "users/index" → "/users"
|
|
897
|
-
* "users/[id]" → "/users/:id"
|
|
898
|
-
* "blog/[...slug]" → "/blog/:slug*"
|
|
899
|
-
* "(auth)/login" → "/login" (group stripped)
|
|
900
|
-
* "_layout" → "/" (layout marker)
|
|
901
|
-
*/
|
|
902
|
-
export function filePathToUrlPath(filePath: string): string {
|
|
903
|
-
const segments = filePath.split('/')
|
|
904
|
-
const urlSegments: string[] = []
|
|
905
|
-
|
|
906
|
-
for (const seg of segments) {
|
|
907
|
-
// Skip route groups "(name)"
|
|
908
|
-
if (seg.startsWith('(') && seg.endsWith(')')) continue
|
|
909
|
-
|
|
910
|
-
// Skip special files
|
|
911
|
-
if (seg === '_layout' || seg === '_error' || seg === '_loading' || seg === '_404' || seg === '_not-found') continue
|
|
912
|
-
|
|
913
|
-
// "index" maps to the parent path
|
|
914
|
-
if (seg === 'index') continue
|
|
915
|
-
|
|
916
|
-
// Catch-all: [...param] → :param*
|
|
917
|
-
const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/)
|
|
918
|
-
if (catchAll) {
|
|
919
|
-
urlSegments.push(`:${catchAll[1]}*`)
|
|
920
|
-
continue
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
// Dynamic: [param] → :param
|
|
924
|
-
const dynamic = seg.match(/^\[(\w+)\]$/)
|
|
925
|
-
if (dynamic) {
|
|
926
|
-
urlSegments.push(`:${dynamic[1]}`)
|
|
927
|
-
continue
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
urlSegments.push(seg)
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
const path = `/${urlSegments.join('/')}`
|
|
934
|
-
return path || '/'
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
/** Sort routes: static before dynamic, catch-all last. */
|
|
938
|
-
function sortRoutes(a: FileRoute, b: FileRoute): number {
|
|
939
|
-
// Catch-all routes go last
|
|
940
|
-
if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1
|
|
941
|
-
// Layouts go first within same depth
|
|
942
|
-
if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1
|
|
943
|
-
// Static segments before dynamic
|
|
944
|
-
const aDynamic = a.urlPath.includes(':')
|
|
945
|
-
const bDynamic = b.urlPath.includes(':')
|
|
946
|
-
if (aDynamic !== bDynamic) return aDynamic ? 1 : -1
|
|
947
|
-
// Alphabetical
|
|
948
|
-
return a.urlPath.localeCompare(b.urlPath)
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
function getFileName(filePath: string): string {
|
|
952
|
-
const parts = filePath.split('/')
|
|
953
|
-
return parts[parts.length - 1] ?? ''
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// ─── Route generation (for Vite plugin) ─────────────────────────────────────
|
|
957
|
-
|
|
958
|
-
/** Internal tree node for building nested route structures. */
|
|
959
|
-
interface RouteNode {
|
|
960
|
-
/** Page routes at this directory level. */
|
|
961
|
-
pages: FileRoute[]
|
|
962
|
-
/** Layout file for this directory (if any). */
|
|
963
|
-
layout?: FileRoute
|
|
964
|
-
/** Error boundary file (if any). */
|
|
965
|
-
error?: FileRoute
|
|
966
|
-
/** Loading fallback file (if any). */
|
|
967
|
-
loading?: FileRoute
|
|
968
|
-
/** Not-found (404) file (if any). */
|
|
969
|
-
notFound?: FileRoute
|
|
970
|
-
/** Child directories. */
|
|
971
|
-
children: Map<string, RouteNode>
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
/**
|
|
975
|
-
* Group flat file routes into a directory tree.
|
|
976
|
-
*/
|
|
977
|
-
function getOrCreateChild(node: RouteNode, segment: string): RouteNode {
|
|
978
|
-
let child = node.children.get(segment)
|
|
979
|
-
if (!child) {
|
|
980
|
-
child = { pages: [], children: new Map() }
|
|
981
|
-
node.children.set(segment, child)
|
|
982
|
-
}
|
|
983
|
-
return child
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
function resolveNode(root: RouteNode, dirPath: string): RouteNode {
|
|
987
|
-
let node = root
|
|
988
|
-
if (dirPath) {
|
|
989
|
-
for (const segment of dirPath.split('/')) {
|
|
990
|
-
node = getOrCreateChild(node, segment)
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
return node
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
function placeRoute(node: RouteNode, route: FileRoute) {
|
|
997
|
-
if (route.isLayout) node.layout = route
|
|
998
|
-
else if (route.isError) node.error = route
|
|
999
|
-
else if (route.isLoading) node.loading = route
|
|
1000
|
-
else if (route.isNotFound) node.notFound = route
|
|
1001
|
-
else node.pages.push(route)
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
function buildRouteTree(routes: FileRoute[]): RouteNode {
|
|
1005
|
-
const root: RouteNode = { pages: [], children: new Map() }
|
|
1006
|
-
for (const route of routes) {
|
|
1007
|
-
placeRoute(resolveNode(root, route.dirPath), route)
|
|
1008
|
-
}
|
|
1009
|
-
return root
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
/**
|
|
1013
|
-
* Generate a virtual module that exports a nested route tree.
|
|
1014
|
-
* Wires up layouts as parent routes with children, loaders, guards,
|
|
1015
|
-
* error/loading components, middleware, and meta from route module exports.
|
|
1016
|
-
*/
|
|
1017
|
-
export interface GenerateRouteModuleOptions {
|
|
1018
|
-
/**
|
|
1019
|
-
* When true, skip lazy() for route components and use static imports.
|
|
1020
|
-
* Use for SSG/prerender mode where all routes are rendered at build time
|
|
1021
|
-
* and code splitting provides no benefit at request time.
|
|
1022
|
-
*/
|
|
1023
|
-
staticImports?: boolean
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
export function generateRouteModule(
|
|
1027
|
-
files: string[],
|
|
1028
|
-
routesDir: string,
|
|
1029
|
-
options?: GenerateRouteModuleOptions,
|
|
1030
|
-
): string {
|
|
1031
|
-
// Synchronously read each route file's source and detect its optional
|
|
1032
|
-
// metadata exports. This produces the optimal shape every time:
|
|
1033
|
-
// • `lazy(() => import(...))` for routes with no metadata
|
|
1034
|
-
// • Direct `mod.loader`/`.guard`/`.meta` for routes with metadata
|
|
1035
|
-
// • Zero `IMPORT_IS_UNDEFINED` and zero `INEFFECTIVE_DYNAMIC_IMPORT` warnings
|
|
1036
|
-
//
|
|
1037
|
-
// If a file can't be read (e.g. caller passing synthetic paths), the
|
|
1038
|
-
// FileRoute gets EMPTY_EXPORTS — the generator emits the same lazy()
|
|
1039
|
-
// shape used for routes that genuinely have no metadata. Callers that
|
|
1040
|
-
// need metadata wiring with synthetic paths should use
|
|
1041
|
-
// `generateRouteModuleFromRoutes()` directly with explicit exports.
|
|
1042
|
-
const exportsMap = new Map<string, RouteFileExports>()
|
|
1043
|
-
for (const filePath of files) {
|
|
1044
|
-
if (!ROUTE_EXTENSIONS.some((ext) => filePath.endsWith(ext))) continue
|
|
1045
|
-
try {
|
|
1046
|
-
const source = readFileSync(join(routesDir, filePath), 'utf-8')
|
|
1047
|
-
exportsMap.set(filePath, detectRouteExports(source))
|
|
1048
|
-
} catch {
|
|
1049
|
-
exportsMap.set(filePath, EMPTY_EXPORTS)
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
return generateRouteModuleFromRoutes(
|
|
1053
|
-
parseFileRoutes(files, undefined, exportsMap),
|
|
1054
|
-
routesDir,
|
|
1055
|
-
options,
|
|
1056
|
-
)
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
/**
|
|
1060
|
-
* Lower-level entry point that accepts pre-parsed FileRoute[] (so callers
|
|
1061
|
-
* can attach `.exports` info from source detection). Use this when you've
|
|
1062
|
-
* already read the files and want optimal output.
|
|
1063
|
-
*/
|
|
1064
|
-
export function generateRouteModuleFromRoutes(
|
|
1065
|
-
routes: FileRoute[],
|
|
1066
|
-
routesDir: string,
|
|
1067
|
-
options?: GenerateRouteModuleOptions,
|
|
1068
|
-
): string {
|
|
1069
|
-
const tree = buildRouteTree(routes)
|
|
1070
|
-
const imports: string[] = []
|
|
1071
|
-
let importCounter = 0
|
|
1072
|
-
const useStaticOnly = options?.staticImports ?? false
|
|
1073
|
-
|
|
1074
|
-
// Track whether we need lazy() at all (omitted in static-only mode and
|
|
1075
|
-
// when there are no routes that use it).
|
|
1076
|
-
let needsLazyImport = false
|
|
1077
|
-
|
|
1078
|
-
function nextImport(filePath: string, exportName = 'default'): string {
|
|
1079
|
-
const name = `_${importCounter++}`
|
|
1080
|
-
const fullPath = `${routesDir}/${filePath}`
|
|
1081
|
-
if (exportName === 'default') {
|
|
1082
|
-
imports.push(`import ${name} from "${fullPath}"`)
|
|
1083
|
-
} else {
|
|
1084
|
-
imports.push(`import { ${exportName} as ${name} } from "${fullPath}"`)
|
|
1085
|
-
}
|
|
1086
|
-
return name
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
function nextModuleImport(filePath: string): string {
|
|
1090
|
-
const name = `_m${importCounter++}`
|
|
1091
|
-
const fullPath = `${routesDir}/${filePath}`
|
|
1092
|
-
imports.push(`import * as ${name} from "${fullPath}"`)
|
|
1093
|
-
return name
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
function nextLazy(filePath: string, loadingName?: string, errorName?: string): string {
|
|
1097
|
-
const name = `_${importCounter++}`
|
|
1098
|
-
const fullPath = `${routesDir}/${filePath}`
|
|
1099
|
-
needsLazyImport = true
|
|
1100
|
-
const opts: string[] = []
|
|
1101
|
-
if (loadingName) opts.push(`loading: ${loadingName}`)
|
|
1102
|
-
if (errorName) opts.push(`error: ${errorName}`)
|
|
1103
|
-
// `hmrId` lets `@pyreon/router`'s dev HMR coordinator map a
|
|
1104
|
-
// hot-updated module back to its route record(s) for an in-place
|
|
1105
|
-
// component swap (no page reload, signals preserved). Inert in
|
|
1106
|
-
// production — the coordinator is only registered in a dev browser,
|
|
1107
|
-
// so `_hmrId` is dead metadata once built.
|
|
1108
|
-
opts.push(`hmrId: ${JSON.stringify(fullPath)}`)
|
|
1109
|
-
const optsStr = `, { ${opts.join(', ')} }`
|
|
1110
|
-
// JSON.stringify for safe-embed — matches the `hmrId` line above.
|
|
1111
|
-
imports.push(`const ${name} = lazy(() => import(${JSON.stringify(fullPath)})${optsStr})`)
|
|
1112
|
-
return name
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
/**
|
|
1116
|
-
* Emit a `meta: { ... }` prop using the literal initializers captured
|
|
1117
|
-
* from the route file source. Either or both of `metaLiteral` and
|
|
1118
|
-
* `renderModeLiteral` may be present; the result is always a single
|
|
1119
|
-
* inline object literal.
|
|
1120
|
-
*/
|
|
1121
|
-
function emitInlineMeta(exp: RouteFileExports, props: string[], indent: string): void {
|
|
1122
|
-
if (!exp.hasMeta && !exp.hasRenderMode) return
|
|
1123
|
-
const parts: string[] = []
|
|
1124
|
-
if (exp.hasMeta && exp.metaLiteral !== undefined) {
|
|
1125
|
-
parts.push(`...(${exp.metaLiteral})`)
|
|
1126
|
-
}
|
|
1127
|
-
if (exp.hasRenderMode && exp.renderModeLiteral !== undefined) {
|
|
1128
|
-
parts.push(`renderMode: ${exp.renderModeLiteral}`)
|
|
1129
|
-
}
|
|
1130
|
-
if (parts.length > 0) {
|
|
1131
|
-
props.push(`${indent} meta: { ${parts.join(', ')} }`)
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
function generatePageRoute(
|
|
1136
|
-
page: FileRoute,
|
|
1137
|
-
indent: string,
|
|
1138
|
-
loadingName: string | undefined,
|
|
1139
|
-
errorName: string | undefined,
|
|
1140
|
-
notFoundName: string | undefined,
|
|
1141
|
-
): string {
|
|
1142
|
-
const exp = page.exports ?? EMPTY_EXPORTS
|
|
1143
|
-
const props: string[] = [`${indent} path: ${JSON.stringify(page.urlPath)}`]
|
|
1144
|
-
const hasMeta = hasAnyMetaExport(exp)
|
|
1145
|
-
|
|
1146
|
-
if (useStaticOnly) {
|
|
1147
|
-
// SSG / static mode: bundle everything synchronously, no lazy().
|
|
1148
|
-
if (hasMeta) {
|
|
1149
|
-
// Single namespace import covers component AND metadata.
|
|
1150
|
-
const mod = nextModuleImport(page.filePath)
|
|
1151
|
-
props.push(`${indent} component: ${mod}.default`)
|
|
1152
|
-
if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
|
|
1153
|
-
if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
|
|
1154
|
-
if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1155
|
-
if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1156
|
-
if (exp.hasGetStaticPaths)
|
|
1157
|
-
props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
|
|
1158
|
-
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1159
|
-
const metaParts: string[] = []
|
|
1160
|
-
if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
|
|
1161
|
-
if (exp.hasRenderMode) metaParts.push(`renderMode: ${mod}.renderMode`)
|
|
1162
|
-
props.push(`${indent} meta: { ${metaParts.join(', ')} }`)
|
|
1163
|
-
}
|
|
1164
|
-
if (errorName) {
|
|
1165
|
-
const errorRef = exp.hasError ? `${mod}.error || ${errorName}` : errorName
|
|
1166
|
-
props.push(`${indent} errorComponent: ${errorRef}`)
|
|
1167
|
-
}
|
|
1168
|
-
} else {
|
|
1169
|
-
// No metadata — single static default import.
|
|
1170
|
-
const comp = nextImport(page.filePath, 'default')
|
|
1171
|
-
props.push(`${indent} component: ${comp}`)
|
|
1172
|
-
if (errorName) props.push(`${indent} errorComponent: ${errorName}`)
|
|
1173
|
-
}
|
|
1174
|
-
} else {
|
|
1175
|
-
// SSR/SPA mode: prefer lazy() for code splitting wherever possible.
|
|
1176
|
-
//
|
|
1177
|
-
// Three cases, in order of preference:
|
|
1178
|
-
// 1. metaLiteral / renderModeLiteral are extracted AND there's
|
|
1179
|
-
// no loader/guard/error/middleware → fully lazy. Component
|
|
1180
|
-
// is `lazy()`'d, metadata is inlined as a literal in the
|
|
1181
|
-
// generated module. The route file's entire dependency
|
|
1182
|
-
// graph chunks separately.
|
|
1183
|
-
// 2. metaLiteral / renderModeLiteral are extracted but a
|
|
1184
|
-
// function-shaped export (loader/guard/error/middleware)
|
|
1185
|
-
// is also present → mixed: component still lazy, metadata
|
|
1186
|
-
// inlined, function exports come from a static `import * as`.
|
|
1187
|
-
// The static import shares the chunk with the lazy chunk
|
|
1188
|
-
// via Rolldown's deduplication.
|
|
1189
|
-
// 3. No literal extraction succeeded → fall back to the previous
|
|
1190
|
-
// pessimistic shape: single namespace import covering both
|
|
1191
|
-
// component and metadata.
|
|
1192
|
-
const inlineableMeta =
|
|
1193
|
-
(!exp.hasMeta || exp.metaLiteral !== undefined) &&
|
|
1194
|
-
(!exp.hasRenderMode || exp.renderModeLiteral !== undefined)
|
|
1195
|
-
// getStaticPaths is a build-time export consumed by the SSG plugin's
|
|
1196
|
-
// path-resolution phase. Like loader/guard/error, it can't be inlined
|
|
1197
|
-
// as a literal — we need the actual function reference. Force the
|
|
1198
|
-
// generator into the mixed branch (case 2) when present so a namespace
|
|
1199
|
-
// import is emitted and `mod.getStaticPaths` lands on the route record.
|
|
1200
|
-
const needsFunctionExports =
|
|
1201
|
-
exp.hasLoader || exp.hasGuard || exp.hasError || exp.hasGetStaticPaths
|
|
1202
|
-
|
|
1203
|
-
if (hasMeta && inlineableMeta && !needsFunctionExports) {
|
|
1204
|
-
// Optimal path — component lazy, metadata inlined.
|
|
1205
|
-
const comp = nextLazy(page.filePath, loadingName, errorName)
|
|
1206
|
-
props.push(`${indent} component: ${comp}`)
|
|
1207
|
-
emitInlineMeta(exp, props, indent)
|
|
1208
|
-
if (errorName) props.push(`${indent} errorComponent: ${errorName}`)
|
|
1209
|
-
} else if (hasMeta && inlineableMeta) {
|
|
1210
|
-
// Mixed — metadata is inlinable but the route also exports
|
|
1211
|
-
// function-shaped values (loader/guard/error). Wrap them as
|
|
1212
|
-
// lazy thunks so the route file's full dependency tree stays
|
|
1213
|
-
// out of the main bundle: each thunk calls the same dynamic
|
|
1214
|
-
// import as the lazy() component, and Rolldown deduplicates
|
|
1215
|
-
// them into one chunk. Inlining the literal metadata is what
|
|
1216
|
-
// makes this safe — without it, the meta access would force
|
|
1217
|
-
// a static import that would collide with the dynamic one.
|
|
1218
|
-
const comp = nextLazy(page.filePath, loadingName, errorName)
|
|
1219
|
-
const fullPath = `${routesDir}/${page.filePath}`
|
|
1220
|
-
props.push(`${indent} component: ${comp}`)
|
|
1221
|
-
if (exp.hasLoader) {
|
|
1222
|
-
props.push(
|
|
1223
|
-
`${indent} loader: (ctx) => import("${fullPath}").then((m) => m.loader(ctx))`,
|
|
1224
|
-
)
|
|
1225
|
-
}
|
|
1226
|
-
if (exp.hasGuard) {
|
|
1227
|
-
props.push(
|
|
1228
|
-
`${indent} beforeEnter: (to, from) => import("${fullPath}").then((m) => m.guard(to, from))`,
|
|
1229
|
-
)
|
|
1230
|
-
}
|
|
1231
|
-
if (exp.hasLoaderKey) {
|
|
1232
|
-
// loaderKey runs SYNCHRONOUSLY during the cache-key check; can't be
|
|
1233
|
-
// routed through a dynamic import. Inline a `mod.loaderKey` lookup
|
|
1234
|
-
// via the same namespace-import pattern as the metadata path. Rolldown
|
|
1235
|
-
// will share the chunk with the lazy() component thunk.
|
|
1236
|
-
const mod = nextModuleImport(page.filePath)
|
|
1237
|
-
props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1238
|
-
}
|
|
1239
|
-
if (exp.hasGcTime) {
|
|
1240
|
-
const mod = nextModuleImport(page.filePath)
|
|
1241
|
-
props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1242
|
-
}
|
|
1243
|
-
if (exp.hasGetStaticPaths) {
|
|
1244
|
-
// getStaticPaths runs at SSG build time (not request time), so
|
|
1245
|
-
// routing it through a dynamic import is fine — but going through
|
|
1246
|
-
// a namespace import keeps it consistent with loaderKey/gcTime
|
|
1247
|
-
// and avoids per-call import overhead during the SSG enumeration
|
|
1248
|
-
// phase.
|
|
1249
|
-
const mod = nextModuleImport(page.filePath)
|
|
1250
|
-
props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
|
|
1251
|
-
}
|
|
1252
|
-
emitInlineMeta(exp, props, indent)
|
|
1253
|
-
if (errorName) {
|
|
1254
|
-
// For error components we can't easily await — pass the lazy
|
|
1255
|
-
// thunk through `lazy()` so the router resolves it like any
|
|
1256
|
-
// other lazy component when an error fires.
|
|
1257
|
-
const errorRef = exp.hasError
|
|
1258
|
-
? `lazy(() => import("${fullPath}").then((m) => ({ default: m.error })))`
|
|
1259
|
-
: errorName
|
|
1260
|
-
if (exp.hasError) needsLazyImport = true
|
|
1261
|
-
props.push(`${indent} errorComponent: ${errorRef}`)
|
|
1262
|
-
}
|
|
1263
|
-
} else if (hasMeta) {
|
|
1264
|
-
// Fallback — metadata couldn't be extracted as a literal (e.g.
|
|
1265
|
-
// computed values, references to other declarations). Fall
|
|
1266
|
-
// back to the pessimistic single-namespace-import shape.
|
|
1267
|
-
const mod = nextModuleImport(page.filePath)
|
|
1268
|
-
props.push(`${indent} component: ${mod}.default`)
|
|
1269
|
-
if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
|
|
1270
|
-
if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
|
|
1271
|
-
if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1272
|
-
if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1273
|
-
if (exp.hasGetStaticPaths)
|
|
1274
|
-
props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
|
|
1275
|
-
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1276
|
-
const metaParts: string[] = []
|
|
1277
|
-
if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
|
|
1278
|
-
if (exp.hasRenderMode) metaParts.push(`renderMode: ${mod}.renderMode`)
|
|
1279
|
-
props.push(`${indent} meta: { ${metaParts.join(', ')} }`)
|
|
1280
|
-
}
|
|
1281
|
-
if (errorName) {
|
|
1282
|
-
const errorRef = exp.hasError ? `${mod}.error || ${errorName}` : errorName
|
|
1283
|
-
props.push(`${indent} errorComponent: ${errorRef}`)
|
|
1284
|
-
}
|
|
1285
|
-
} else {
|
|
1286
|
-
// No metadata at all — pure lazy() for code splitting.
|
|
1287
|
-
const comp = nextLazy(page.filePath, loadingName, errorName)
|
|
1288
|
-
props.push(`${indent} component: ${comp}`)
|
|
1289
|
-
if (errorName) props.push(`${indent} errorComponent: ${errorName}`)
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
if (notFoundName) {
|
|
1294
|
-
props.push(`${indent} notFoundComponent: ${notFoundName}`)
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
return `${indent}{\n${props.join(',\n')}\n${indent}}`
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
function wrapWithLayout(
|
|
1301
|
-
node: RouteNode,
|
|
1302
|
-
children: string[],
|
|
1303
|
-
indent: string,
|
|
1304
|
-
errorName: string | undefined,
|
|
1305
|
-
notFoundName: string | undefined,
|
|
1306
|
-
): string {
|
|
1307
|
-
const layout = node.layout as FileRoute
|
|
1308
|
-
const exp = layout.exports ?? EMPTY_EXPORTS
|
|
1309
|
-
const hasMeta = hasAnyMetaExport(exp)
|
|
1310
|
-
|
|
1311
|
-
// Decide between two import shapes:
|
|
1312
|
-
// • Layout HAS metadata exports → single `import * as mod` for both
|
|
1313
|
-
// the layout component (mod.layout) AND metadata. One import.
|
|
1314
|
-
// • Layout has NO metadata → just `import { layout as _N }`. One import.
|
|
1315
|
-
let layoutComp: string
|
|
1316
|
-
let layoutMod: string | undefined
|
|
1317
|
-
|
|
1318
|
-
if (hasMeta) {
|
|
1319
|
-
// Single namespace import covers both component and metadata.
|
|
1320
|
-
layoutMod = nextModuleImport(layout.filePath)
|
|
1321
|
-
layoutComp = `${layoutMod}.layout`
|
|
1322
|
-
} else {
|
|
1323
|
-
// No metadata — named `layout` import is enough.
|
|
1324
|
-
layoutComp = nextImport(layout.filePath, 'layout')
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
const props: string[] = [
|
|
1328
|
-
`${indent}path: ${JSON.stringify(layout.urlPath)}`,
|
|
1329
|
-
`${indent}component: ${layoutComp}`,
|
|
1330
|
-
]
|
|
1331
|
-
|
|
1332
|
-
if (layoutMod !== undefined) {
|
|
1333
|
-
if (exp.hasLoader) props.push(`${indent}loader: ${layoutMod}.loader`)
|
|
1334
|
-
if (exp.hasGuard) props.push(`${indent}beforeEnter: ${layoutMod}.guard`)
|
|
1335
|
-
if (exp.hasLoaderKey) props.push(`${indent}loaderKey: ${layoutMod}.loaderKey`)
|
|
1336
|
-
if (exp.hasGcTime) props.push(`${indent}gcTime: ${layoutMod}.gcTime`)
|
|
1337
|
-
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1338
|
-
const metaParts: string[] = []
|
|
1339
|
-
if (exp.hasMeta) metaParts.push(`...${layoutMod}.meta`)
|
|
1340
|
-
if (exp.hasRenderMode) metaParts.push(`renderMode: ${layoutMod}.renderMode`)
|
|
1341
|
-
props.push(`${indent}meta: { ${metaParts.join(', ')} }`)
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
if (errorName) {
|
|
1346
|
-
props.push(`${indent}errorComponent: ${errorName}`)
|
|
1347
|
-
}
|
|
1348
|
-
if (notFoundName) {
|
|
1349
|
-
props.push(`${indent}notFoundComponent: ${notFoundName}`)
|
|
1350
|
-
}
|
|
1351
|
-
if (children.length > 0) {
|
|
1352
|
-
props.push(`${indent}children: [\n${children.join(',\n')}\n${indent}]`)
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
return `${indent}{\n${props.map((p) => ` ${p}`).join(',\n')}\n${indent}}`
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
/**
|
|
1359
|
-
* Generate route definitions for a tree node.
|
|
1360
|
-
*/
|
|
1361
|
-
function generateNode(node: RouteNode, depth: number): string[] {
|
|
1362
|
-
const indent = ' '.repeat(depth + 1)
|
|
1363
|
-
|
|
1364
|
-
const errorName = node.error ? nextImport(node.error.filePath) : undefined
|
|
1365
|
-
const loadingName = node.loading ? nextImport(node.loading.filePath) : undefined
|
|
1366
|
-
const notFoundName = node.notFound ? nextImport(node.notFound.filePath) : undefined
|
|
1367
|
-
|
|
1368
|
-
const childRouteDefs: string[] = []
|
|
1369
|
-
for (const [, childNode] of node.children) {
|
|
1370
|
-
childRouteDefs.push(...generateNode(childNode, depth + 1))
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
const pageRouteDefs = node.pages.map((page) =>
|
|
1374
|
-
generatePageRoute(page, indent, loadingName, errorName, notFoundName),
|
|
1375
|
-
)
|
|
1376
|
-
|
|
1377
|
-
const allChildren = [...pageRouteDefs, ...childRouteDefs]
|
|
1378
|
-
|
|
1379
|
-
if (node.layout) {
|
|
1380
|
-
return [wrapWithLayout(node, allChildren, indent, errorName, notFoundName)]
|
|
1381
|
-
}
|
|
1382
|
-
return allChildren
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
const routeDefs = generateNode(tree, 0)
|
|
1386
|
-
|
|
1387
|
-
const lines: string[] = []
|
|
1388
|
-
if (needsLazyImport) lines.push(`import { lazy } from "@pyreon/router"`, '')
|
|
1389
|
-
lines.push(...imports, '')
|
|
1390
|
-
|
|
1391
|
-
lines.push(
|
|
1392
|
-
// Filter out undefined properties at runtime
|
|
1393
|
-
`function clean(routes) {`,
|
|
1394
|
-
` return routes.map(r => {`,
|
|
1395
|
-
` const c = {}`,
|
|
1396
|
-
` for (const k in r) if (r[k] !== undefined) c[k] = r[k]`,
|
|
1397
|
-
` if (c.children) c.children = clean(c.children)`,
|
|
1398
|
-
` return c`,
|
|
1399
|
-
` })`,
|
|
1400
|
-
`}`,
|
|
1401
|
-
'',
|
|
1402
|
-
`export const routes = clean([`,
|
|
1403
|
-
routeDefs.join(',\n'),
|
|
1404
|
-
`])`,
|
|
1405
|
-
)
|
|
1406
|
-
|
|
1407
|
-
return lines.join('\n')
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
/**
|
|
1411
|
-
* Generate a virtual module that maps URL patterns to their middleware exports.
|
|
1412
|
-
* Used by the server entry to dispatch per-route middleware.
|
|
1413
|
-
*
|
|
1414
|
-
* Detects whether each route file actually exports `middleware` (via
|
|
1415
|
-
* `detectRouteExports` source scanning) and only emits an import for files
|
|
1416
|
-
* that do. The `lazy()` import path tolerates missing exports, but the SSG
|
|
1417
|
-
* static-import path fails Rolldown's missing-export check at build time —
|
|
1418
|
-
* skipping no-middleware files keeps both paths working.
|
|
1419
|
-
*/
|
|
1420
|
-
export function generateMiddlewareModule(files: string[], routesDir: string): string {
|
|
1421
|
-
const routes = parseFileRoutes(files)
|
|
1422
|
-
const imports: string[] = []
|
|
1423
|
-
const entries: string[] = []
|
|
1424
|
-
let counter = 0
|
|
1425
|
-
|
|
1426
|
-
for (const route of routes) {
|
|
1427
|
-
if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue
|
|
1428
|
-
let hasMw = false
|
|
1429
|
-
try {
|
|
1430
|
-
const source = readFileSync(`${routesDir}/${route.filePath}`, 'utf-8')
|
|
1431
|
-
hasMw = detectRouteExports(source).hasMiddleware
|
|
1432
|
-
} catch {
|
|
1433
|
-
// File can't be read — skip; the SSR runtime falls back gracefully.
|
|
1434
|
-
}
|
|
1435
|
-
if (!hasMw) continue
|
|
1436
|
-
const name = `_mw${counter++}`
|
|
1437
|
-
const fullPath = `${routesDir}/${route.filePath}`
|
|
1438
|
-
imports.push(`import { middleware as ${name} } from "${fullPath}"`)
|
|
1439
|
-
entries.push(` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`)
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
return [
|
|
1443
|
-
...imports,
|
|
1444
|
-
'',
|
|
1445
|
-
`export const routeMiddleware = [`,
|
|
1446
|
-
entries.join(',\n'),
|
|
1447
|
-
`].filter(e => e.middleware)`,
|
|
1448
|
-
].join('\n')
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
/**
|
|
1452
|
-
* Scan a directory for route files.
|
|
1453
|
-
* Returns paths relative to the routes directory.
|
|
1454
|
-
*/
|
|
1455
|
-
export async function scanRouteFiles(routesDir: string): Promise<string[]> {
|
|
1456
|
-
const { readdir } = await import('node:fs/promises')
|
|
1457
|
-
const { relative } = await import('node:path')
|
|
1458
|
-
|
|
1459
|
-
const files: string[] = []
|
|
1460
|
-
|
|
1461
|
-
async function walk(dir: string) {
|
|
1462
|
-
const entries = await readdir(dir, { withFileTypes: true })
|
|
1463
|
-
for (const entry of entries) {
|
|
1464
|
-
const fullPath = join(dir, entry.name)
|
|
1465
|
-
if (entry.isDirectory()) {
|
|
1466
|
-
await walk(fullPath)
|
|
1467
|
-
} else if (ROUTE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
|
|
1468
|
-
files.push(relative(routesDir, fullPath))
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
await walk(routesDir)
|
|
1474
|
-
return files
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
/**
|
|
1478
|
-
* Scan route files AND read each one to detect optional metadata exports
|
|
1479
|
-
* (loader, guard, meta, renderMode, error, middleware).
|
|
1480
|
-
*
|
|
1481
|
-
* Returns FileRoute[] with `.exports` populated, ready to feed into
|
|
1482
|
-
* `generateRouteModuleFromRoutes()` for optimal output:
|
|
1483
|
-
* • lazy() for components without metadata (best code splitting)
|
|
1484
|
-
* • Direct property access for components with metadata (no _pick)
|
|
1485
|
-
* • No spurious IMPORT_IS_UNDEFINED warnings
|
|
1486
|
-
*/
|
|
1487
|
-
export async function scanRouteFilesWithExports(
|
|
1488
|
-
routesDir: string,
|
|
1489
|
-
defaultMode: RenderMode = 'ssr',
|
|
1490
|
-
): Promise<FileRoute[]> {
|
|
1491
|
-
const { readFile } = await import('node:fs/promises')
|
|
1492
|
-
const { isApiRoute } = await import('./api-routes')
|
|
1493
|
-
|
|
1494
|
-
// Api routes (`api/**/*.ts`) live in the same routes tree but are served by
|
|
1495
|
-
// a separate virtual module (`virtual:zero/api-routes`). Page-route
|
|
1496
|
-
// generation MUST skip them — they export named HTTP method handlers
|
|
1497
|
-
// (`GET`/`POST`/...), not a default page component, so the SSG `staticImports`
|
|
1498
|
-
// mode would emit `import _N from "api/posts.ts"` and fail Rolldown's
|
|
1499
|
-
// missing-export check at build time. The bug only surfaced under SSG
|
|
1500
|
-
// because the regular lazy()-mode `import()` doesn't fail on missing
|
|
1501
|
-
// default exports.
|
|
1502
|
-
const files = (await scanRouteFiles(routesDir)).filter((f) => !isApiRoute(f))
|
|
1503
|
-
const exportsMap = new Map<string, RouteFileExports>()
|
|
1504
|
-
|
|
1505
|
-
await Promise.all(
|
|
1506
|
-
files.map(async (filePath) => {
|
|
1507
|
-
try {
|
|
1508
|
-
const source = await readFile(join(routesDir, filePath), 'utf-8')
|
|
1509
|
-
exportsMap.set(filePath, detectRouteExports(source))
|
|
1510
|
-
} catch {
|
|
1511
|
-
// File can't be read — generator treats this as no metadata
|
|
1512
|
-
// and emits the optimal lazy() shape.
|
|
1513
|
-
exportsMap.set(filePath, EMPTY_EXPORTS)
|
|
1514
|
-
}
|
|
1515
|
-
}),
|
|
1516
|
-
)
|
|
1517
|
-
|
|
1518
|
-
return parseFileRoutes(files, defaultMode, exportsMap)
|
|
1519
|
-
}
|