@pyreon/zero 0.12.9 → 0.12.11

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/src/fs-router.ts CHANGED
@@ -1,4 +1,6 @@
1
- import type { FileRoute, RenderMode } from './types'
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { FileRoute, RenderMode, RouteFileExports } from './types'
2
4
 
3
5
  // ─── File-system route conventions ──────────────────────────────────────────
4
6
  //
@@ -30,16 +32,759 @@ import type { FileRoute, RenderMode } from './types'
30
32
 
31
33
  const ROUTE_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js']
32
34
 
35
+ /** Names whose top-level export presence we care about. */
36
+ const ROUTE_EXPORT_NAMES = [
37
+ 'loader',
38
+ 'guard',
39
+ 'meta',
40
+ 'renderMode',
41
+ 'error',
42
+ 'middleware',
43
+ ] as const
44
+
45
+ type RouteExportName = (typeof ROUTE_EXPORT_NAMES)[number]
46
+
47
+ /**
48
+ * Detect which optional metadata exports a route file source declares.
49
+ *
50
+ * Walks the source character-by-character, tracking string-literal and
51
+ * comment state, then collects top-level `export …` statements. This is
52
+ * more accurate than regex (no false matches inside string literals,
53
+ * template literals, or comments) and lighter than a full AST parse
54
+ * (no oxc/babel dependency, ~1µs per file).
55
+ *
56
+ * Recognizes:
57
+ * • `export const NAME = …`
58
+ * • `export let NAME = …`
59
+ * • `export var NAME = …`
60
+ * • `export function NAME(…)`
61
+ * • `export async function NAME(…)`
62
+ * • `export { NAME }` and `export { localName as NAME }`
63
+ * • `export { NAME } from '…'` (re-export)
64
+ *
65
+ * Names checked: loader, guard, meta, renderMode, error, middleware.
66
+ */
67
+ export function detectRouteExports(source: string): RouteFileExports {
68
+ const found = new Set<RouteExportName>()
69
+ const tokens = scanTopLevelExportTokens(source)
70
+
71
+ for (const tok of tokens) {
72
+ if (tok.kind === 'declaration') {
73
+ // `export const NAME` / `export function NAME`
74
+ if ((ROUTE_EXPORT_NAMES as readonly string[]).includes(tok.name)) {
75
+ found.add(tok.name as RouteExportName)
76
+ }
77
+ } else {
78
+ // `export { localName as exportedName, ... }`
79
+ for (const name of tok.names) {
80
+ if ((ROUTE_EXPORT_NAMES as readonly string[]).includes(name)) {
81
+ found.add(name as RouteExportName)
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ // Capture literal `meta` and `renderMode` initializers when present
88
+ // so the route generator can inline them and avoid forcing a static
89
+ // import of the entire route module just to read the metadata.
90
+ // Strip any trailing `as const` / `satisfies T` type assertions —
91
+ // the generated routes module is plain JS, not TS.
92
+ //
93
+ // We then run `isPureLiteral()` to make sure the captured expression
94
+ // doesn't reference any free identifiers (e.g. `meta = { title: foo }`
95
+ // where `foo` is a const declared elsewhere in the file). Inlining
96
+ // such an expression into the routes module would produce a runtime
97
+ // ReferenceError, so we drop the literal and let the generator fall
98
+ // back to a static module import in those cases.
99
+ const rawMeta = found.has('meta') ? extractLiteralExport(source, 'meta') : undefined
100
+ const rawRenderMode = found.has('renderMode')
101
+ ? extractLiteralExport(source, 'renderMode')
102
+ : undefined
103
+ const cleanMeta = rawMeta !== undefined ? stripTypeAssertions(rawMeta) : undefined
104
+ const cleanRenderMode =
105
+ rawRenderMode !== undefined ? stripTypeAssertions(rawRenderMode) : undefined
106
+ const metaLiteral = cleanMeta !== undefined && isPureLiteral(cleanMeta) ? cleanMeta : undefined
107
+ const renderModeLiteral =
108
+ cleanRenderMode !== undefined && isPureLiteral(cleanRenderMode) ? cleanRenderMode : undefined
109
+
110
+ return {
111
+ hasLoader: found.has('loader'),
112
+ hasGuard: found.has('guard'),
113
+ hasMeta: found.has('meta'),
114
+ hasRenderMode: found.has('renderMode'),
115
+ hasError: found.has('error'),
116
+ hasMiddleware: found.has('middleware'),
117
+ ...(metaLiteral !== undefined ? { metaLiteral } : {}),
118
+ ...(renderModeLiteral !== undefined ? { renderModeLiteral } : {}),
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Extract the literal initializer of an `export const NAME = …` statement
124
+ * as a raw text slice — used by the route generator to inline `meta` and
125
+ * `renderMode` values into the generated routes module.
126
+ *
127
+ * Walks the source character-by-character respecting strings, template
128
+ * literals, comments, and brace/bracket/paren nesting. The slice runs
129
+ * from the first non-whitespace character after `=` to the matching
130
+ * end-of-expression terminator (`;`, newline at depth 0, or top-level
131
+ * `export`). Whatever the slice contains is handed to V8 verbatim by
132
+ * embedding it inside `{ … }` in the generated module — which means
133
+ * the original source must already be valid JavaScript (which it is,
134
+ * since the route file compiles).
135
+ *
136
+ * Returns `undefined` when extraction fails for any reason — the
137
+ * generator falls back to a static module import in that case.
138
+ */
139
+ function extractLiteralExport(source: string, name: string): string | undefined {
140
+ // Find `export const NAME = ` at top level. Reuse the same
141
+ // string/comment/depth tracking as the token scanner so we don't
142
+ // false-match inside literals.
143
+ const len = source.length
144
+ let i = 0
145
+ let depth = 0
146
+
147
+ const isIdCont = (c: string) => /[A-Za-z0-9_$]/.test(c)
148
+ const skipWs = (p: number): number => {
149
+ while (p < len && /\s/.test(source[p] as string)) p++
150
+ return p
151
+ }
152
+
153
+ while (i < len) {
154
+ const ch = source[i] as string
155
+ const next = source[i + 1] ?? ''
156
+
157
+ // Skip comments
158
+ if (ch === '/' && next === '/') {
159
+ while (i < len && source[i] !== '\n') i++
160
+ continue
161
+ }
162
+ if (ch === '/' && next === '*') {
163
+ i += 2
164
+ while (i < len - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++
165
+ i += 2
166
+ continue
167
+ }
168
+
169
+ // Skip string literals
170
+ if (ch === '"' || ch === "'") {
171
+ const quote = ch
172
+ i++
173
+ while (i < len && source[i] !== quote) {
174
+ if (source[i] === '\\') i += 2
175
+ else i++
176
+ }
177
+ i++
178
+ continue
179
+ }
180
+ if (ch === '`') {
181
+ i++
182
+ while (i < len && source[i] !== '`') {
183
+ if (source[i] === '\\') {
184
+ i += 2
185
+ continue
186
+ }
187
+ if (source[i] === '$' && source[i + 1] === '{') {
188
+ i += 2
189
+ let exprDepth = 1
190
+ while (i < len && exprDepth > 0) {
191
+ const c = source[i] as string
192
+ if (c === '{') exprDepth++
193
+ else if (c === '}') exprDepth--
194
+ if (exprDepth === 0) {
195
+ i++
196
+ break
197
+ }
198
+ i++
199
+ }
200
+ continue
201
+ }
202
+ i++
203
+ }
204
+ i++
205
+ continue
206
+ }
207
+
208
+ // Brace depth tracking
209
+ if (ch === '{') {
210
+ depth++
211
+ i++
212
+ continue
213
+ }
214
+ if (ch === '}') {
215
+ depth--
216
+ i++
217
+ continue
218
+ }
219
+
220
+ // Look for `export const NAME = …` at depth 0
221
+ if (depth === 0 && ch === 'e') {
222
+ const afterExport = source.slice(i, i + 6) === 'export' && !isIdCont(source[i + 6] ?? '')
223
+ if (afterExport) {
224
+ let p = skipWs(i + 6)
225
+ if (source.slice(p, p + 5) === 'const' && !isIdCont(source[p + 5] ?? '')) {
226
+ p = skipWs(p + 5)
227
+ // Check that the identifier matches our target name
228
+ if (
229
+ source.slice(p, p + name.length) === name &&
230
+ !isIdCont(source[p + name.length] ?? '')
231
+ ) {
232
+ p = skipWs(p + name.length)
233
+ if (source[p] === '=') {
234
+ p = skipWs(p + 1)
235
+ return readExpressionUntilEnd(source, p)
236
+ }
237
+ }
238
+ }
239
+ i = i + 6
240
+ continue
241
+ }
242
+ }
243
+
244
+ i++
245
+ }
246
+
247
+ return undefined
248
+ }
249
+
250
+ /**
251
+ * Read a JavaScript expression starting at `start` and return the raw
252
+ * text up to (but not including) its end. The end is whichever comes
253
+ * first of:
254
+ * • a `;` at depth 0
255
+ * • a newline at depth 0 that is not inside a string/template
256
+ * • the next top-level `export` / `const` / `function` keyword
257
+ * • end of file
258
+ *
259
+ * Tracks `()`, `[]`, and `{}` nesting plus string/template/comment
260
+ * state so depth-0 boundaries are detected correctly even for nested
261
+ * objects, arrays, and tagged templates.
262
+ */
263
+ function readExpressionUntilEnd(source: string, start: number): string | undefined {
264
+ const len = source.length
265
+ let i = start
266
+ let depth = 0 // combined paren/bracket/brace depth
267
+
268
+ while (i < len) {
269
+ const ch = source[i] as string
270
+ const next = source[i + 1] ?? ''
271
+
272
+ // End conditions at depth 0
273
+ if (depth === 0) {
274
+ if (ch === ';') return source.slice(start, i).trim() || undefined
275
+ if (ch === '\n') {
276
+ // Allow trailing whitespace/comma but stop at the newline.
277
+ // Some authors close objects on the same line, others span
278
+ // them across lines — the depth check above handles the
279
+ // multi-line case so a depth-0 newline really is the end.
280
+ const trimmed = source.slice(start, i).trim()
281
+ if (trimmed.length === 0) {
282
+ i++
283
+ continue
284
+ }
285
+ return trimmed
286
+ }
287
+ }
288
+
289
+ // Skip comments
290
+ if (ch === '/' && next === '/') {
291
+ while (i < len && source[i] !== '\n') i++
292
+ continue
293
+ }
294
+ if (ch === '/' && next === '*') {
295
+ i += 2
296
+ while (i < len - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++
297
+ i += 2
298
+ continue
299
+ }
300
+
301
+ // Skip strings
302
+ if (ch === '"' || ch === "'") {
303
+ const quote = ch
304
+ i++
305
+ while (i < len && source[i] !== quote) {
306
+ if (source[i] === '\\') i += 2
307
+ else i++
308
+ }
309
+ i++
310
+ continue
311
+ }
312
+ if (ch === '`') {
313
+ i++
314
+ while (i < len && source[i] !== '`') {
315
+ if (source[i] === '\\') {
316
+ i += 2
317
+ continue
318
+ }
319
+ if (source[i] === '$' && source[i + 1] === '{') {
320
+ i += 2
321
+ let exprDepth = 1
322
+ while (i < len && exprDepth > 0) {
323
+ const c = source[i] as string
324
+ if (c === '{') exprDepth++
325
+ else if (c === '}') exprDepth--
326
+ if (exprDepth === 0) {
327
+ i++
328
+ break
329
+ }
330
+ i++
331
+ }
332
+ continue
333
+ }
334
+ i++
335
+ }
336
+ i++
337
+ continue
338
+ }
339
+
340
+ // Track depth across all bracket families
341
+ if (ch === '{' || ch === '[' || ch === '(') {
342
+ depth++
343
+ i++
344
+ continue
345
+ }
346
+ if (ch === '}' || ch === ']' || ch === ')') {
347
+ depth--
348
+ if (depth < 0) {
349
+ // We ran past our scope without seeing a terminator. The
350
+ // expression must have ended right before this closer.
351
+ return source.slice(start, i).trim() || undefined
352
+ }
353
+ i++
354
+ continue
355
+ }
356
+
357
+ i++
358
+ }
359
+
360
+ // Hit EOF without an explicit terminator — return whatever we have
361
+ // if it looks plausible, otherwise undefined.
362
+ const trimmed = source.slice(start).trim()
363
+ return trimmed.length > 0 ? trimmed : undefined
364
+ }
365
+
366
+ /**
367
+ * True if `text` is a pure JS literal — only string/number/boolean/null
368
+ * literals plus the structural punctuation needed to compose them into
369
+ * objects, arrays, and tuples. Identifiers, operators, function calls,
370
+ * template-literal expression slots, and references to other names all
371
+ * disqualify the expression.
372
+ *
373
+ * Walks the source character-by-character, tracking string/template/
374
+ * comment state. Inside a string or template head (no `${}` slot) every
375
+ * character is fine; outside strings, only the structural symbols
376
+ * `{}[](),:` plus whitespace, digits, the literal keywords `true`,
377
+ * `false`, `null`, and `-` (for negative numbers) are allowed.
378
+ *
379
+ * The check is conservative on purpose — anything fancier than a flat
380
+ * literal falls back to the static-import path, which still works,
381
+ * just at the cost of one un-split chunk.
382
+ */
383
+ function isPureLiteral(text: string): boolean {
384
+ const len = text.length
385
+ let i = 0
386
+
387
+ while (i < len) {
388
+ const ch = text[i] as string
389
+
390
+ // Strings — anything inside is literal data
391
+ if (ch === '"' || ch === "'") {
392
+ const quote = ch
393
+ i++
394
+ while (i < len && text[i] !== quote) {
395
+ if (text[i] === '\\') i += 2
396
+ else i++
397
+ }
398
+ i++
399
+ continue
400
+ }
401
+
402
+ // Template literals — only allowed if they contain no ${} slots
403
+ if (ch === '`') {
404
+ i++
405
+ while (i < len && text[i] !== '`') {
406
+ if (text[i] === '\\') {
407
+ i += 2
408
+ continue
409
+ }
410
+ if (text[i] === '$' && text[i + 1] === '{') {
411
+ // Template with an expression slot — not a pure literal
412
+ return false
413
+ }
414
+ i++
415
+ }
416
+ i++
417
+ continue
418
+ }
419
+
420
+ // Whitespace + structural punctuation are fine
421
+ if (/\s/.test(ch)) {
422
+ i++
423
+ continue
424
+ }
425
+ if (ch === '{' || ch === '}' || ch === '[' || ch === ']' || ch === ',' || ch === ':') {
426
+ i++
427
+ continue
428
+ }
429
+
430
+ // Number literals (including leading - and 0x/0b/0o)
431
+ if (/[0-9]/.test(ch) || (ch === '-' && /[0-9]/.test(text[i + 1] ?? ''))) {
432
+ while (i < len && /[0-9a-fA-Fxob.eE+\-_]/.test(text[i] as string)) i++
433
+ continue
434
+ }
435
+
436
+ // Allowed bare identifiers — only the literal keywords
437
+ if (text.slice(i, i + 4) === 'true' && !isIdContChar(text[i + 4] ?? '')) {
438
+ i += 4
439
+ continue
440
+ }
441
+ if (text.slice(i, i + 5) === 'false' && !isIdContChar(text[i + 5] ?? '')) {
442
+ i += 5
443
+ continue
444
+ }
445
+ if (text.slice(i, i + 4) === 'null' && !isIdContChar(text[i + 4] ?? '')) {
446
+ i += 4
447
+ continue
448
+ }
449
+ if (text.slice(i, i + 9) === 'undefined' && !isIdContChar(text[i + 9] ?? '')) {
450
+ i += 9
451
+ continue
452
+ }
453
+
454
+ // Property keys can be unquoted identifiers — they're followed by `:`.
455
+ // Walk over the identifier; if the next non-whitespace char is `:`,
456
+ // accept it as a key. Otherwise the identifier is a free reference
457
+ // and the expression isn't pure.
458
+ if (/[A-Za-z_$]/.test(ch)) {
459
+ let end = i + 1
460
+ while (end < len && isIdContChar(text[end] as string)) end++
461
+ let after = end
462
+ while (after < len && /\s/.test(text[after] as string)) after++
463
+ if (text[after] === ':') {
464
+ // unquoted property key — fine
465
+ i = end
466
+ continue
467
+ }
468
+ return false
469
+ }
470
+
471
+ // Anything else (operators, parens for function calls, etc.) → not pure
472
+ return false
473
+ }
474
+
475
+ return true
476
+ }
477
+
478
+ function isIdContChar(c: string): boolean {
479
+ return /[A-Za-z0-9_$]/.test(c)
480
+ }
481
+
482
+ /**
483
+ * Strip TypeScript type-only suffixes (`as const`, `as SomeType`,
484
+ * `satisfies SomeType`) from a literal expression so the generated
485
+ * JS module is syntactically valid.
486
+ *
487
+ * The route file is TypeScript so authors freely write
488
+ * `export const renderMode = 'ssg' as const` — but the generated
489
+ * `virtual:zero/routes` module is JavaScript and can't keep the cast.
490
+ * Strip from the rightmost top-level `as` or `satisfies` keyword.
491
+ */
492
+ export function stripTypeAssertions(literal: string): string {
493
+ let result = literal.trim()
494
+
495
+ // Walk from the right at depth 0, find the LAST occurrence of
496
+ // ` as ` or ` satisfies ` and cut everything to the right of it.
497
+ // We use a depth-aware right-to-left scan because the literal can
498
+ // contain `as`/`satisfies` inside nested objects (e.g. a string
499
+ // value `'satisfies the schema'` should be left untouched).
500
+ let depth = 0
501
+ for (let i = result.length - 1; i > 0; i--) {
502
+ const ch = result[i] as string
503
+ if (ch === ')' || ch === ']' || ch === '}') depth++
504
+ else if (ch === '(' || ch === '[' || ch === '{') depth--
505
+
506
+ if (depth !== 0) continue
507
+
508
+ // Check for ` as ` boundary
509
+ if (
510
+ i >= 4 &&
511
+ result[i - 3] === ' ' &&
512
+ result[i - 2] === 'a' &&
513
+ result[i - 1] === 's' &&
514
+ result[i] === ' '
515
+ ) {
516
+ result = result.slice(0, i - 3).trim()
517
+ i = result.length
518
+ depth = 0
519
+ continue
520
+ }
521
+ // Check for ` satisfies ` boundary
522
+ if (
523
+ i >= 11 &&
524
+ result.slice(i - 10, i + 1) === ' satisfies '
525
+ ) {
526
+ result = result.slice(0, i - 10).trim()
527
+ i = result.length
528
+ depth = 0
529
+ continue
530
+ }
531
+ }
532
+
533
+ return result
534
+ }
535
+
536
+ /**
537
+ * Lightweight tokenizer for the export forms detectRouteExports cares about.
538
+ * Returns an array of either:
539
+ * • `{ kind: 'declaration', name }` — `export const NAME = …`
540
+ * • `{ kind: 'list', names }` — `export { NAME, other as NAME2 }`
541
+ *
542
+ * Only top-level statements (brace depth 0) are considered. String literals,
543
+ * template literals, and comments are skipped so their contents can't trigger
544
+ * false matches.
545
+ */
546
+ type ExportToken =
547
+ | { kind: 'declaration'; name: string }
548
+ | { kind: 'list'; names: string[] }
549
+
550
+ function scanTopLevelExportTokens(source: string): ExportToken[] {
551
+ const tokens: ExportToken[] = []
552
+ const len = source.length
553
+ let i = 0
554
+ let depth = 0 // brace depth — we only care about top-level (depth 0)
555
+
556
+ // Identifier characters used to skip past names and to validate that
557
+ // a match isn't a substring of a longer identifier.
558
+ const isIdStart = (c: string) => /[A-Za-z_$]/.test(c)
559
+ const isIdCont = (c: string) => /[A-Za-z0-9_$]/.test(c)
560
+
561
+ // Read an identifier starting at position p; returns [name, nextPos] or null.
562
+ const readIdentifier = (p: number): [string, number] | null => {
563
+ if (p >= len || !isIdStart(source[p] as string)) return null
564
+ let end = p + 1
565
+ while (end < len && isIdCont(source[end] as string)) end++
566
+ return [source.slice(p, end), end]
567
+ }
568
+
569
+ // Skip whitespace including newlines.
570
+ const skipWs = (p: number): number => {
571
+ while (p < len && /\s/.test(source[p] as string)) p++
572
+ return p
573
+ }
574
+
575
+ // Match the literal `keyword` at position p, requiring an identifier
576
+ // boundary on both sides. Returns nextPos or -1.
577
+ const matchKeyword = (p: number, keyword: string): number => {
578
+ if (source.slice(p, p + keyword.length) !== keyword) return -1
579
+ const after = p + keyword.length
580
+ if (after < len && isIdCont(source[after] as string)) return -1
581
+ if (p > 0 && isIdCont(source[p - 1] as string)) return -1
582
+ return after
583
+ }
584
+
585
+ while (i < len) {
586
+ const ch = source[i] as string
587
+ const next = source[i + 1] ?? ''
588
+
589
+ // ── Comments ──────────────────────────────────────────────────────
590
+ if (ch === '/' && next === '/') {
591
+ // Line comment — skip to newline
592
+ while (i < len && source[i] !== '\n') i++
593
+ continue
594
+ }
595
+ if (ch === '/' && next === '*') {
596
+ // Block comment — skip to closing */
597
+ i += 2
598
+ while (i < len - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++
599
+ i += 2
600
+ continue
601
+ }
602
+
603
+ // ── String / template literals ────────────────────────────────────
604
+ if (ch === '"' || ch === "'") {
605
+ const quote = ch
606
+ i++
607
+ while (i < len && source[i] !== quote) {
608
+ if (source[i] === '\\') i += 2
609
+ else i++
610
+ }
611
+ i++
612
+ continue
613
+ }
614
+ if (ch === '`') {
615
+ // Template literal — skip to closing backtick, handling ${...} blocks
616
+ i++
617
+ while (i < len && source[i] !== '`') {
618
+ if (source[i] === '\\') {
619
+ i += 2
620
+ continue
621
+ }
622
+ if (source[i] === '$' && source[i + 1] === '{') {
623
+ // Skip a balanced ${ ... } expression
624
+ i += 2
625
+ let exprDepth = 1
626
+ while (i < len && exprDepth > 0) {
627
+ const c = source[i] as string
628
+ if (c === '{') exprDepth++
629
+ else if (c === '}') exprDepth--
630
+ if (exprDepth === 0) {
631
+ i++
632
+ break
633
+ }
634
+ i++
635
+ }
636
+ continue
637
+ }
638
+ i++
639
+ }
640
+ i++
641
+ continue
642
+ }
643
+
644
+ // ── Brace depth tracking ──────────────────────────────────────────
645
+ if (ch === '{') {
646
+ depth++
647
+ i++
648
+ continue
649
+ }
650
+ if (ch === '}') {
651
+ depth--
652
+ i++
653
+ continue
654
+ }
655
+
656
+ // ── `export …` at top level ──────────────────────────────────────
657
+ if (depth === 0 && ch === 'e') {
658
+ const afterExport = matchKeyword(i, 'export')
659
+ if (afterExport > 0) {
660
+ // Found `export` token at top level. Look at what follows.
661
+ let p = skipWs(afterExport)
662
+
663
+ // `export default …` — not a named export we care about
664
+ const afterDefault = matchKeyword(p, 'default')
665
+ if (afterDefault > 0) {
666
+ i = afterDefault
667
+ continue
668
+ }
669
+
670
+ // `export { … }` (export list, possibly followed by `from '…'`)
671
+ if (source[p] === '{') {
672
+ p++
673
+ const names: string[] = []
674
+ while (p < len && source[p] !== '}') {
675
+ p = skipWs(p)
676
+ if (source[p] === '}') break
677
+ const id = readIdentifier(p)
678
+ if (!id) {
679
+ p++
680
+ continue
681
+ }
682
+ const [first, afterFirst] = id
683
+ // `localName as exportedName` — the EXPORTED name is what counts
684
+ let exportedName = first
685
+ const afterFirstWs = skipWs(afterFirst)
686
+ const afterAs = matchKeyword(afterFirstWs, 'as')
687
+ if (afterAs > 0) {
688
+ const aliasStart = skipWs(afterAs)
689
+ const alias = readIdentifier(aliasStart)
690
+ if (alias) {
691
+ exportedName = alias[0]
692
+ p = alias[1]
693
+ } else {
694
+ p = afterFirst
695
+ }
696
+ } else {
697
+ p = afterFirst
698
+ }
699
+ names.push(exportedName)
700
+ p = skipWs(p)
701
+ if (source[p] === ',') p++
702
+ }
703
+ tokens.push({ kind: 'list', names })
704
+ i = p + 1 // past closing brace
705
+ continue
706
+ }
707
+
708
+ // `export async function NAME …`
709
+ const afterAsync = matchKeyword(p, 'async')
710
+ if (afterAsync > 0) p = skipWs(afterAsync)
711
+
712
+ // `export const | let | var | function NAME …`
713
+ let foundDecl = false
714
+ for (const kw of ['const', 'let', 'var', 'function'] as const) {
715
+ const afterKw = matchKeyword(p, kw)
716
+ if (afterKw > 0) {
717
+ const nameStart = skipWs(afterKw)
718
+ const id = readIdentifier(nameStart)
719
+ if (id) {
720
+ tokens.push({ kind: 'declaration', name: id[0] })
721
+ i = id[1] // advance past the identifier we just consumed
722
+ foundDecl = true
723
+ break
724
+ }
725
+ }
726
+ }
727
+ // If we couldn't recognize a declaration form, advance past `export`
728
+ // so the outer loop doesn't re-match the same token forever.
729
+ if (!foundDecl) i = afterExport
730
+ continue
731
+ }
732
+ }
733
+
734
+ i++
735
+ }
736
+
737
+ return tokens
738
+ }
739
+
740
+ /** All-false exports record. Used when source detection fails. */
741
+ const EMPTY_EXPORTS: RouteFileExports = {
742
+ hasLoader: false,
743
+ hasGuard: false,
744
+ hasMeta: false,
745
+ hasRenderMode: false,
746
+ hasError: false,
747
+ hasMiddleware: false,
748
+ }
749
+
750
+ /**
751
+ * True if a route file declares ANY metadata export.
752
+ * Used by the code generator to decide whether to emit a static
753
+ * `import * as mod` (for metadata access) instead of lazy().
754
+ */
755
+ export function hasAnyMetaExport(exports: RouteFileExports): boolean {
756
+ return (
757
+ exports.hasLoader ||
758
+ exports.hasGuard ||
759
+ exports.hasMeta ||
760
+ exports.hasRenderMode ||
761
+ exports.hasError ||
762
+ exports.hasMiddleware
763
+ )
764
+ }
765
+
33
766
  /**
34
767
  * Parse a set of file paths (relative to routes dir) into FileRoute objects.
35
768
  *
36
769
  * @param files Array of file paths like ["index.tsx", "users/[id].tsx"]
37
770
  * @param defaultMode Default rendering mode from config
771
+ * @param exportsMap Optional map of filePath → detected exports. When
772
+ * provided, the resulting FileRoute objects carry export info that the
773
+ * code generator uses to optimize imports (skip metadata namespace
774
+ * imports for routes that only export `default`).
38
775
  */
39
- export function parseFileRoutes(files: string[], defaultMode: RenderMode = 'ssr'): FileRoute[] {
776
+ export function parseFileRoutes(
777
+ files: string[],
778
+ defaultMode: RenderMode = 'ssr',
779
+ exportsMap?: Map<string, RouteFileExports>,
780
+ ): FileRoute[] {
40
781
  return files
41
782
  .filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext)))
42
- .map((filePath) => parseFilePath(filePath, defaultMode))
783
+ .map((filePath) => {
784
+ const route = parseFilePath(filePath, defaultMode)
785
+ const exp = exportsMap?.get(filePath)
786
+ return exp ? { ...route, exports: exp } : route
787
+ })
43
788
  .sort(sortRoutes)
44
789
  }
45
790
 
@@ -214,8 +959,7 @@ export interface GenerateRouteModuleOptions {
214
959
  /**
215
960
  * When true, skip lazy() for route components and use static imports.
216
961
  * Use for SSG/prerender mode where all routes are rendered at build time
217
- * and code splitting provides no benefit. Avoids Rolldown warnings about
218
- * static + dynamic imports of the same module.
962
+ * and code splitting provides no benefit at request time.
219
963
  */
220
964
  staticImports?: boolean
221
965
  }
@@ -225,11 +969,52 @@ export function generateRouteModule(
225
969
  routesDir: string,
226
970
  options?: GenerateRouteModuleOptions,
227
971
  ): string {
228
- const routes = parseFileRoutes(files)
972
+ // Synchronously read each route file's source and detect its optional
973
+ // metadata exports. This produces the optimal shape every time:
974
+ // • `lazy(() => import(...))` for routes with no metadata
975
+ // • Direct `mod.loader`/`.guard`/`.meta` for routes with metadata
976
+ // • Zero `IMPORT_IS_UNDEFINED` and zero `INEFFECTIVE_DYNAMIC_IMPORT` warnings
977
+ //
978
+ // If a file can't be read (e.g. caller passing synthetic paths), the
979
+ // FileRoute gets EMPTY_EXPORTS — the generator emits the same lazy()
980
+ // shape used for routes that genuinely have no metadata. Callers that
981
+ // need metadata wiring with synthetic paths should use
982
+ // `generateRouteModuleFromRoutes()` directly with explicit exports.
983
+ const exportsMap = new Map<string, RouteFileExports>()
984
+ for (const filePath of files) {
985
+ if (!ROUTE_EXTENSIONS.some((ext) => filePath.endsWith(ext))) continue
986
+ try {
987
+ const source = readFileSync(join(routesDir, filePath), 'utf-8')
988
+ exportsMap.set(filePath, detectRouteExports(source))
989
+ } catch {
990
+ exportsMap.set(filePath, EMPTY_EXPORTS)
991
+ }
992
+ }
993
+ return generateRouteModuleFromRoutes(
994
+ parseFileRoutes(files, undefined, exportsMap),
995
+ routesDir,
996
+ options,
997
+ )
998
+ }
999
+
1000
+ /**
1001
+ * Lower-level entry point that accepts pre-parsed FileRoute[] (so callers
1002
+ * can attach `.exports` info from source detection). Use this when you've
1003
+ * already read the files and want optimal output.
1004
+ */
1005
+ export function generateRouteModuleFromRoutes(
1006
+ routes: FileRoute[],
1007
+ routesDir: string,
1008
+ options?: GenerateRouteModuleOptions,
1009
+ ): string {
229
1010
  const tree = buildRouteTree(routes)
230
1011
  const imports: string[] = []
231
1012
  let importCounter = 0
232
- const useStaticImports = options?.staticImports ?? false
1013
+ const useStaticOnly = options?.staticImports ?? false
1014
+
1015
+ // Track whether we need lazy() at all (omitted in static-only mode and
1016
+ // when there are no routes that use it).
1017
+ let needsLazyImport = false
233
1018
 
234
1019
  function nextImport(filePath: string, exportName = 'default'): string {
235
1020
  const name = `_${importCounter++}`
@@ -242,31 +1027,45 @@ export function generateRouteModule(
242
1027
  return name
243
1028
  }
244
1029
 
245
- function nextLazy(filePath: string, loadingName?: string, errorName?: string): string {
246
- const name = `_${importCounter++}`
1030
+ function nextModuleImport(filePath: string): string {
1031
+ const name = `_m${importCounter++}`
247
1032
  const fullPath = `${routesDir}/${filePath}`
248
-
249
- if (useStaticImports) {
250
- // SSG mode: static import avoids Rolldown warnings about
251
- // static + dynamic imports of the same module
252
- imports.push(`import ${name} from "${fullPath}"`)
253
- } else {
254
- const opts: string[] = []
255
- if (loadingName) opts.push(`loading: ${loadingName}`)
256
- if (errorName) opts.push(`error: ${errorName}`)
257
- const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''
258
- imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`)
259
- }
1033
+ imports.push(`import * as ${name} from "${fullPath}"`)
260
1034
  return name
261
1035
  }
262
1036
 
263
- function nextModuleImport(filePath: string): string {
264
- const name = `_m${importCounter++}`
1037
+ function nextLazy(filePath: string, loadingName?: string, errorName?: string): string {
1038
+ const name = `_${importCounter++}`
265
1039
  const fullPath = `${routesDir}/${filePath}`
266
- imports.push(`import * as ${name} from "${fullPath}"`)
1040
+ needsLazyImport = true
1041
+ const opts: string[] = []
1042
+ if (loadingName) opts.push(`loading: ${loadingName}`)
1043
+ if (errorName) opts.push(`error: ${errorName}`)
1044
+ const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''
1045
+ imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`)
267
1046
  return name
268
1047
  }
269
1048
 
1049
+ /**
1050
+ * Emit a `meta: { ... }` prop using the literal initializers captured
1051
+ * from the route file source. Either or both of `metaLiteral` and
1052
+ * `renderModeLiteral` may be present; the result is always a single
1053
+ * inline object literal.
1054
+ */
1055
+ function emitInlineMeta(exp: RouteFileExports, props: string[], indent: string): void {
1056
+ if (!exp.hasMeta && !exp.hasRenderMode) return
1057
+ const parts: string[] = []
1058
+ if (exp.hasMeta && exp.metaLiteral !== undefined) {
1059
+ parts.push(`...(${exp.metaLiteral})`)
1060
+ }
1061
+ if (exp.hasRenderMode && exp.renderModeLiteral !== undefined) {
1062
+ parts.push(`renderMode: ${exp.renderModeLiteral}`)
1063
+ }
1064
+ if (parts.length > 0) {
1065
+ props.push(`${indent} meta: { ${parts.join(', ')} }`)
1066
+ }
1067
+ }
1068
+
270
1069
  function generatePageRoute(
271
1070
  page: FileRoute,
272
1071
  indent: string,
@@ -274,22 +1073,120 @@ export function generateRouteModule(
274
1073
  errorName: string | undefined,
275
1074
  notFoundName: string | undefined,
276
1075
  ): string {
277
- const mod = nextModuleImport(page.filePath)
278
- const comp = nextLazy(page.filePath, loadingName, errorName)
1076
+ const exp = page.exports ?? EMPTY_EXPORTS
1077
+ const props: string[] = [`${indent} path: ${JSON.stringify(page.urlPath)}`]
1078
+ const hasMeta = hasAnyMetaExport(exp)
279
1079
 
280
- const props: string[] = [
281
- `${indent} path: ${JSON.stringify(page.urlPath)}`,
282
- `${indent} component: ${comp}`,
283
- `${indent} loader: ${mod}.loader`,
284
- `${indent} beforeEnter: ${mod}.guard`,
285
- `${indent} meta: { ...${mod}.meta, renderMode: ${mod}.renderMode }`,
286
- ]
1080
+ if (useStaticOnly) {
1081
+ // SSG / static mode: bundle everything synchronously, no lazy().
1082
+ if (hasMeta) {
1083
+ // Single namespace import covers component AND metadata.
1084
+ const mod = nextModuleImport(page.filePath)
1085
+ props.push(`${indent} component: ${mod}.default`)
1086
+ if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
1087
+ if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
1088
+ if (exp.hasMeta || exp.hasRenderMode) {
1089
+ const metaParts: string[] = []
1090
+ if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
1091
+ if (exp.hasRenderMode) metaParts.push(`renderMode: ${mod}.renderMode`)
1092
+ props.push(`${indent} meta: { ${metaParts.join(', ')} }`)
1093
+ }
1094
+ if (errorName) {
1095
+ const errorRef = exp.hasError ? `${mod}.error || ${errorName}` : errorName
1096
+ props.push(`${indent} errorComponent: ${errorRef}`)
1097
+ }
1098
+ } else {
1099
+ // No metadata — single static default import.
1100
+ const comp = nextImport(page.filePath, 'default')
1101
+ props.push(`${indent} component: ${comp}`)
1102
+ if (errorName) props.push(`${indent} errorComponent: ${errorName}`)
1103
+ }
1104
+ } else {
1105
+ // SSR/SPA mode: prefer lazy() for code splitting wherever possible.
1106
+ //
1107
+ // Three cases, in order of preference:
1108
+ // 1. metaLiteral / renderModeLiteral are extracted AND there's
1109
+ // no loader/guard/error/middleware → fully lazy. Component
1110
+ // is `lazy()`'d, metadata is inlined as a literal in the
1111
+ // generated module. The route file's entire dependency
1112
+ // graph chunks separately.
1113
+ // 2. metaLiteral / renderModeLiteral are extracted but a
1114
+ // function-shaped export (loader/guard/error/middleware)
1115
+ // is also present → mixed: component still lazy, metadata
1116
+ // inlined, function exports come from a static `import * as`.
1117
+ // The static import shares the chunk with the lazy chunk
1118
+ // via Rolldown's deduplication.
1119
+ // 3. No literal extraction succeeded → fall back to the previous
1120
+ // pessimistic shape: single namespace import covering both
1121
+ // component and metadata.
1122
+ const inlineableMeta =
1123
+ (!exp.hasMeta || exp.metaLiteral !== undefined) &&
1124
+ (!exp.hasRenderMode || exp.renderModeLiteral !== undefined)
1125
+ const needsFunctionExports = exp.hasLoader || exp.hasGuard || exp.hasError
287
1126
 
288
- // Only emit errorComponent when there's an actual _error file in scope
289
- // or the route module exports an error component. Avoids referencing
290
- // undefined .error exports that produce noisy bundler warnings.
291
- if (errorName) {
292
- props.push(`${indent} errorComponent: ${mod}.error || ${errorName}`)
1127
+ if (hasMeta && inlineableMeta && !needsFunctionExports) {
1128
+ // Optimal path component lazy, metadata inlined.
1129
+ const comp = nextLazy(page.filePath, loadingName, errorName)
1130
+ props.push(`${indent} component: ${comp}`)
1131
+ emitInlineMeta(exp, props, indent)
1132
+ if (errorName) props.push(`${indent} errorComponent: ${errorName}`)
1133
+ } else if (hasMeta && inlineableMeta) {
1134
+ // Mixed — metadata is inlinable but the route also exports
1135
+ // function-shaped values (loader/guard/error). Wrap them as
1136
+ // lazy thunks so the route file's full dependency tree stays
1137
+ // out of the main bundle: each thunk calls the same dynamic
1138
+ // import as the lazy() component, and Rolldown deduplicates
1139
+ // them into one chunk. Inlining the literal metadata is what
1140
+ // makes this safe — without it, the meta access would force
1141
+ // a static import that would collide with the dynamic one.
1142
+ const comp = nextLazy(page.filePath, loadingName, errorName)
1143
+ const fullPath = `${routesDir}/${page.filePath}`
1144
+ props.push(`${indent} component: ${comp}`)
1145
+ if (exp.hasLoader) {
1146
+ props.push(
1147
+ `${indent} loader: (ctx) => import("${fullPath}").then((m) => m.loader(ctx))`,
1148
+ )
1149
+ }
1150
+ if (exp.hasGuard) {
1151
+ props.push(
1152
+ `${indent} beforeEnter: (to, from) => import("${fullPath}").then((m) => m.guard(to, from))`,
1153
+ )
1154
+ }
1155
+ emitInlineMeta(exp, props, indent)
1156
+ if (errorName) {
1157
+ // For error components we can't easily await — pass the lazy
1158
+ // thunk through `lazy()` so the router resolves it like any
1159
+ // other lazy component when an error fires.
1160
+ const errorRef = exp.hasError
1161
+ ? `lazy(() => import("${fullPath}").then((m) => ({ default: m.error })))`
1162
+ : errorName
1163
+ if (exp.hasError) needsLazyImport = true
1164
+ props.push(`${indent} errorComponent: ${errorRef}`)
1165
+ }
1166
+ } else if (hasMeta) {
1167
+ // Fallback — metadata couldn't be extracted as a literal (e.g.
1168
+ // computed values, references to other declarations). Fall
1169
+ // back to the pessimistic single-namespace-import shape.
1170
+ const mod = nextModuleImport(page.filePath)
1171
+ props.push(`${indent} component: ${mod}.default`)
1172
+ if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
1173
+ if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
1174
+ if (exp.hasMeta || exp.hasRenderMode) {
1175
+ const metaParts: string[] = []
1176
+ if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
1177
+ if (exp.hasRenderMode) metaParts.push(`renderMode: ${mod}.renderMode`)
1178
+ props.push(`${indent} meta: { ${metaParts.join(', ')} }`)
1179
+ }
1180
+ if (errorName) {
1181
+ const errorRef = exp.hasError ? `${mod}.error || ${errorName}` : errorName
1182
+ props.push(`${indent} errorComponent: ${errorRef}`)
1183
+ }
1184
+ } else {
1185
+ // No metadata at all — pure lazy() for code splitting.
1186
+ const comp = nextLazy(page.filePath, loadingName, errorName)
1187
+ props.push(`${indent} component: ${comp}`)
1188
+ if (errorName) props.push(`${indent} errorComponent: ${errorName}`)
1189
+ }
293
1190
  }
294
1191
 
295
1192
  if (notFoundName) {
@@ -307,16 +1204,41 @@ export function generateRouteModule(
307
1204
  notFoundName: string | undefined,
308
1205
  ): string {
309
1206
  const layout = node.layout as FileRoute
310
- const layoutMod = nextModuleImport(layout.filePath)
311
- const layoutComp = nextImport(layout.filePath, 'layout')
1207
+ const exp = layout.exports ?? EMPTY_EXPORTS
1208
+ const hasMeta = hasAnyMetaExport(exp)
1209
+
1210
+ // Decide between two import shapes:
1211
+ // • Layout HAS metadata exports → single `import * as mod` for both
1212
+ // the layout component (mod.layout) AND metadata. One import.
1213
+ // • Layout has NO metadata → just `import { layout as _N }`. One import.
1214
+ let layoutComp: string
1215
+ let layoutMod: string | undefined
1216
+
1217
+ if (hasMeta) {
1218
+ // Single namespace import covers both component and metadata.
1219
+ layoutMod = nextModuleImport(layout.filePath)
1220
+ layoutComp = `${layoutMod}.layout`
1221
+ } else {
1222
+ // No metadata — named `layout` import is enough.
1223
+ layoutComp = nextImport(layout.filePath, 'layout')
1224
+ }
312
1225
 
313
1226
  const props: string[] = [
314
1227
  `${indent}path: ${JSON.stringify(layout.urlPath)}`,
315
1228
  `${indent}component: ${layoutComp}`,
316
- `${indent}loader: ${layoutMod}.loader`,
317
- `${indent}beforeEnter: ${layoutMod}.guard`,
318
- `${indent}meta: { ...${layoutMod}.meta, renderMode: ${layoutMod}.renderMode }`,
319
1229
  ]
1230
+
1231
+ if (layoutMod !== undefined) {
1232
+ if (exp.hasLoader) props.push(`${indent}loader: ${layoutMod}.loader`)
1233
+ if (exp.hasGuard) props.push(`${indent}beforeEnter: ${layoutMod}.guard`)
1234
+ if (exp.hasMeta || exp.hasRenderMode) {
1235
+ const metaParts: string[] = []
1236
+ if (exp.hasMeta) metaParts.push(`...${layoutMod}.meta`)
1237
+ if (exp.hasRenderMode) metaParts.push(`renderMode: ${layoutMod}.renderMode`)
1238
+ props.push(`${indent}meta: { ${metaParts.join(', ')} }`)
1239
+ }
1240
+ }
1241
+
320
1242
  if (errorName) {
321
1243
  props.push(`${indent}errorComponent: ${errorName}`)
322
1244
  }
@@ -359,11 +1281,11 @@ export function generateRouteModule(
359
1281
 
360
1282
  const routeDefs = generateNode(tree, 0)
361
1283
 
362
- return [
363
- `import { lazy } from "@pyreon/router"`,
364
- '',
365
- ...imports,
366
- '',
1284
+ const lines: string[] = []
1285
+ if (needsLazyImport) lines.push(`import { lazy } from "@pyreon/router"`, '')
1286
+ lines.push(...imports, '')
1287
+
1288
+ lines.push(
367
1289
  // Filter out undefined properties at runtime
368
1290
  `function clean(routes) {`,
369
1291
  ` return routes.map(r => {`,
@@ -377,7 +1299,9 @@ export function generateRouteModule(
377
1299
  `export const routes = clean([`,
378
1300
  routeDefs.join(',\n'),
379
1301
  `])`,
380
- ].join('\n')
1302
+ )
1303
+
1304
+ return lines.join('\n')
381
1305
  }
382
1306
 
383
1307
  /**
@@ -413,7 +1337,7 @@ export function generateMiddlewareModule(files: string[], routesDir: string): st
413
1337
  */
414
1338
  export async function scanRouteFiles(routesDir: string): Promise<string[]> {
415
1339
  const { readdir } = await import('node:fs/promises')
416
- const { join, relative } = await import('node:path')
1340
+ const { relative } = await import('node:path')
417
1341
 
418
1342
  const files: string[] = []
419
1343
 
@@ -432,3 +1356,38 @@ export async function scanRouteFiles(routesDir: string): Promise<string[]> {
432
1356
  await walk(routesDir)
433
1357
  return files
434
1358
  }
1359
+
1360
+ /**
1361
+ * Scan route files AND read each one to detect optional metadata exports
1362
+ * (loader, guard, meta, renderMode, error, middleware).
1363
+ *
1364
+ * Returns FileRoute[] with `.exports` populated, ready to feed into
1365
+ * `generateRouteModuleFromRoutes()` for optimal output:
1366
+ * • lazy() for components without metadata (best code splitting)
1367
+ * • Direct property access for components with metadata (no _pick)
1368
+ * • No spurious IMPORT_IS_UNDEFINED warnings
1369
+ */
1370
+ export async function scanRouteFilesWithExports(
1371
+ routesDir: string,
1372
+ defaultMode: RenderMode = 'ssr',
1373
+ ): Promise<FileRoute[]> {
1374
+ const { readFile } = await import('node:fs/promises')
1375
+
1376
+ const files = await scanRouteFiles(routesDir)
1377
+ const exportsMap = new Map<string, RouteFileExports>()
1378
+
1379
+ await Promise.all(
1380
+ files.map(async (filePath) => {
1381
+ try {
1382
+ const source = await readFile(join(routesDir, filePath), 'utf-8')
1383
+ exportsMap.set(filePath, detectRouteExports(source))
1384
+ } catch {
1385
+ // File can't be read — generator treats this as no metadata
1386
+ // and emits the optimal lazy() shape.
1387
+ exportsMap.set(filePath, EMPTY_EXPORTS)
1388
+ }
1389
+ }),
1390
+ )
1391
+
1392
+ return parseFileRoutes(files, defaultMode, exportsMap)
1393
+ }