@pyreon/zero 0.24.5 → 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.
Files changed (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. 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
- }