@pyreon/zero 0.1.1 → 0.2.0

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 (57) hide show
  1. package/README.md +17 -6
  2. package/lib/fs-router-BkbIWqek.js.map +1 -1
  3. package/lib/{fs-router-jfd1QGLB.js → fs-router-n4VA4lxu.js} +29 -4
  4. package/lib/fs-router-n4VA4lxu.js.map +1 -0
  5. package/lib/image.js +50 -1
  6. package/lib/image.js.map +1 -1
  7. package/lib/index.js +651 -11
  8. package/lib/index.js.map +1 -1
  9. package/lib/link.js +49 -1
  10. package/lib/link.js.map +1 -1
  11. package/lib/script.js +49 -1
  12. package/lib/script.js.map +1 -1
  13. package/lib/theme.js +50 -1
  14. package/lib/theme.js.map +1 -1
  15. package/lib/types/actions.d.ts +57 -0
  16. package/lib/types/actions.d.ts.map +1 -0
  17. package/lib/types/api-routes.d.ts +66 -0
  18. package/lib/types/api-routes.d.ts.map +1 -0
  19. package/lib/types/compression.d.ts +33 -0
  20. package/lib/types/compression.d.ts.map +1 -0
  21. package/lib/types/cors.d.ts +32 -0
  22. package/lib/types/cors.d.ts.map +1 -0
  23. package/lib/types/entry-server.d.ts +10 -2
  24. package/lib/types/entry-server.d.ts.map +1 -1
  25. package/lib/types/error-overlay.d.ts +6 -0
  26. package/lib/types/error-overlay.d.ts.map +1 -0
  27. package/lib/types/fs-router.d.ts +5 -0
  28. package/lib/types/fs-router.d.ts.map +1 -1
  29. package/lib/types/image.d.ts +1 -1
  30. package/lib/types/image.d.ts.map +1 -1
  31. package/lib/types/index.d.ts +12 -2
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/rate-limit.d.ts +34 -0
  34. package/lib/types/rate-limit.d.ts.map +1 -0
  35. package/lib/types/script.d.ts +1 -1
  36. package/lib/types/script.d.ts.map +1 -1
  37. package/lib/types/testing.d.ts +85 -0
  38. package/lib/types/testing.d.ts.map +1 -0
  39. package/lib/types/theme.d.ts +1 -1
  40. package/lib/types/theme.d.ts.map +1 -1
  41. package/lib/types/types.d.ts +5 -0
  42. package/lib/types/types.d.ts.map +1 -1
  43. package/lib/types/vite-plugin.d.ts.map +1 -1
  44. package/package.json +40 -9
  45. package/src/actions.ts +168 -0
  46. package/src/api-routes.ts +233 -0
  47. package/src/compression.ts +107 -0
  48. package/src/cors.ts +102 -0
  49. package/src/entry-server.ts +62 -7
  50. package/src/error-overlay.ts +121 -0
  51. package/src/fs-router.ts +34 -2
  52. package/src/index.ts +37 -0
  53. package/src/rate-limit.ts +122 -0
  54. package/src/testing.ts +150 -0
  55. package/src/types.ts +8 -0
  56. package/src/vite-plugin.ts +75 -10
  57. package/lib/fs-router-jfd1QGLB.js.map +0 -1
package/src/fs-router.ts CHANGED
@@ -263,7 +263,7 @@ export function generateRouteModule(
263
263
  `${indent} component: ${comp}`,
264
264
  `${indent} loader: ${mod}.loader`,
265
265
  `${indent} beforeEnter: ${mod}.guard`,
266
- `${indent} meta: ${mod}.meta`,
266
+ `${indent} meta: { ...${mod}.meta, renderMode: ${mod}.renderMode }`,
267
267
  ]
268
268
 
269
269
  if (errorName) {
@@ -290,7 +290,7 @@ export function generateRouteModule(
290
290
  `${indent}component: ${layoutComp}`,
291
291
  `${indent}loader: ${layoutMod}.loader`,
292
292
  `${indent}beforeEnter: ${layoutMod}.guard`,
293
- `${indent}meta: ${layoutMod}.meta`,
293
+ `${indent}meta: { ...${layoutMod}.meta, renderMode: ${layoutMod}.renderMode }`,
294
294
  ]
295
295
  if (errorName) {
296
296
  props.push(`${indent}errorComponent: ${errorName}`)
@@ -353,6 +353,38 @@ export function generateRouteModule(
353
353
  ].join('\n')
354
354
  }
355
355
 
356
+ /**
357
+ * Generate a virtual module that maps URL patterns to their middleware exports.
358
+ * Used by the server entry to dispatch per-route middleware.
359
+ */
360
+ export function generateMiddlewareModule(
361
+ files: string[],
362
+ routesDir: string,
363
+ ): string {
364
+ const routes = parseFileRoutes(files)
365
+ const imports: string[] = []
366
+ const entries: string[] = []
367
+ let counter = 0
368
+
369
+ for (const route of routes) {
370
+ if (route.isLayout || route.isError || route.isLoading) continue
371
+ const name = `_mw${counter++}`
372
+ const fullPath = `${routesDir}/${route.filePath}`
373
+ imports.push(`import { middleware as ${name} } from "${fullPath}"`)
374
+ entries.push(
375
+ ` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`,
376
+ )
377
+ }
378
+
379
+ return [
380
+ ...imports,
381
+ '',
382
+ `export const routeMiddleware = [`,
383
+ entries.join(',\n'),
384
+ `].filter(e => e.middleware)`,
385
+ ].join('\n')
386
+ }
387
+
356
388
  /**
357
389
  * Scan a directory for route files.
358
390
  * Returns paths relative to the routes directory.
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export { zeroPlugin as default } from './vite-plugin'
13
13
 
14
14
  export {
15
15
  filePathToUrlPath,
16
+ generateMiddlewareModule,
16
17
  generateRouteModule,
17
18
  parseFileRoutes,
18
19
  scanRouteFiles,
@@ -104,6 +105,41 @@ export {
104
105
  seoPlugin,
105
106
  } from './seo'
106
107
 
108
+ // ─── API routes ──────────────────────────────────────────────────────────────
109
+
110
+ export type {
111
+ ApiContext,
112
+ ApiHandler,
113
+ ApiRouteEntry,
114
+ ApiRouteModule,
115
+ HttpMethod,
116
+ } from './api-routes'
117
+ export { createApiMiddleware, generateApiRouteModule } from './api-routes'
118
+
119
+ // ─── CORS ────────────────────────────────────────────────────────────────────
120
+
121
+ export type { CorsConfig } from './cors'
122
+ export { corsMiddleware } from './cors'
123
+
124
+ // ─── Rate limiting ──────────────────────────────────────────────────────────
125
+
126
+ export type { RateLimitConfig } from './rate-limit'
127
+ export { rateLimitMiddleware } from './rate-limit'
128
+
129
+ // ─── Compression ────────────────────────────────────────────────────────────
130
+
131
+ export type { CompressionConfig } from './compression'
132
+ export {
133
+ compressionMiddleware,
134
+ compressResponse,
135
+ isCompressible,
136
+ } from './compression'
137
+
138
+ // ─── Actions ─────────────────────────────────────────────────────────────────
139
+
140
+ export type { Action, ActionContext, ActionHandler } from './actions'
141
+ export { createActionMiddleware, defineAction } from './actions'
142
+
107
143
  // ─── Types ───────────────────────────────────────────────────────────────────
108
144
 
109
145
  export type {
@@ -114,6 +150,7 @@ export type {
114
150
  LoaderContext,
115
151
  RenderMode,
116
152
  RouteMeta,
153
+ RouteMiddlewareEntry,
117
154
  RouteModule,
118
155
  ZeroConfig,
119
156
  } from './types'
@@ -0,0 +1,122 @@
1
+ import type { Middleware, MiddlewareContext } from '@pyreon/server'
2
+
3
+ // ─── Rate limiting middleware ───────────────────────────────────────────────
4
+
5
+ export interface RateLimitConfig {
6
+ /** Maximum requests per window. Default: `100` */
7
+ max?: number
8
+ /** Time window in seconds. Default: `60` */
9
+ window?: number
10
+ /** Function to extract the client identifier. Default: IP from headers. */
11
+ keyFn?: (ctx: MiddlewareContext) => string
12
+ /** Custom response when rate limited. */
13
+ onLimit?: (ctx: MiddlewareContext) => Response
14
+ /** URL patterns to rate limit (glob-style). Default: all paths. */
15
+ include?: string[]
16
+ /** URL patterns to exclude from rate limiting. */
17
+ exclude?: string[]
18
+ }
19
+
20
+ interface RateLimitEntry {
21
+ count: number
22
+ resetAt: number
23
+ }
24
+
25
+ /**
26
+ * Rate limiting middleware — limits requests per client within a time window.
27
+ * Uses an in-memory store (suitable for single-instance deployments).
28
+ *
29
+ * @example
30
+ * import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
31
+ *
32
+ * // 100 requests per minute (default)
33
+ * rateLimitMiddleware()
34
+ *
35
+ * // Strict API rate limiting
36
+ * rateLimitMiddleware({
37
+ * max: 20,
38
+ * window: 60,
39
+ * include: ["/api/*"],
40
+ * })
41
+ */
42
+ export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
43
+ const {
44
+ max = 100,
45
+ window: windowSec = 60,
46
+ keyFn = defaultKeyFn,
47
+ onLimit,
48
+ include,
49
+ exclude,
50
+ } = config
51
+
52
+ const windowMs = windowSec * 1000
53
+ const store = new Map<string, RateLimitEntry>()
54
+
55
+ // Periodic cleanup of expired entries
56
+ const cleanupInterval = setInterval(() => {
57
+ const now = Date.now()
58
+ for (const [key, entry] of store) {
59
+ if (entry.resetAt <= now) store.delete(key)
60
+ }
61
+ }, windowMs)
62
+
63
+ // Allow GC to clean up the interval
64
+ if (typeof cleanupInterval === 'object' && 'unref' in cleanupInterval) {
65
+ cleanupInterval.unref()
66
+ }
67
+
68
+ return (ctx: MiddlewareContext) => {
69
+ // Check include/exclude patterns
70
+ if (include && !include.some((p) => matchSimpleGlob(p, ctx.path))) return
71
+ if (exclude?.some((p) => matchSimpleGlob(p, ctx.path))) return
72
+
73
+ const key = keyFn(ctx)
74
+ const now = Date.now()
75
+ let entry = store.get(key)
76
+
77
+ if (!entry || entry.resetAt <= now) {
78
+ entry = { count: 0, resetAt: now + windowMs }
79
+ store.set(key, entry)
80
+ }
81
+
82
+ entry.count++
83
+ const remaining = Math.max(0, max - entry.count)
84
+ const resetSeconds = Math.ceil((entry.resetAt - now) / 1000)
85
+
86
+ // Set rate limit headers on all responses
87
+ ctx.headers.set('X-RateLimit-Limit', String(max))
88
+ ctx.headers.set('X-RateLimit-Remaining', String(remaining))
89
+ ctx.headers.set('X-RateLimit-Reset', String(resetSeconds))
90
+
91
+ if (entry.count > max) {
92
+ if (onLimit) return onLimit(ctx)
93
+
94
+ return new Response(JSON.stringify({ error: 'Too many requests' }), {
95
+ status: 429,
96
+ headers: {
97
+ 'Content-Type': 'application/json',
98
+ 'Retry-After': String(resetSeconds),
99
+ 'X-RateLimit-Limit': String(max),
100
+ 'X-RateLimit-Remaining': '0',
101
+ 'X-RateLimit-Reset': String(resetSeconds),
102
+ },
103
+ })
104
+ }
105
+ }
106
+ }
107
+
108
+ function defaultKeyFn(ctx: MiddlewareContext): string {
109
+ return (
110
+ ctx.req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
111
+ ctx.req.headers.get('x-real-ip') ??
112
+ 'unknown'
113
+ )
114
+ }
115
+
116
+ /** Simple glob matching for path patterns. Supports trailing `*`. */
117
+ function matchSimpleGlob(pattern: string, path: string): boolean {
118
+ if (pattern.endsWith('/*')) {
119
+ return path.startsWith(pattern.slice(0, -1))
120
+ }
121
+ return pattern === path
122
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,150 @@
1
+ import type { Middleware, MiddlewareContext } from '@pyreon/server'
2
+ import type { ApiHandler, ApiRouteEntry } from './api-routes'
3
+ import { createApiMiddleware } from './api-routes'
4
+
5
+ // ─── Test helpers for Zero applications ─────────────────────────────────────
6
+
7
+ /**
8
+ * Create a mock MiddlewareContext for testing middleware.
9
+ *
10
+ * @example
11
+ * import { createTestContext } from "@pyreon/zero/testing"
12
+ *
13
+ * const ctx = createTestContext("/api/posts", { method: "POST", body: { title: "Hello" } })
14
+ * const result = await myMiddleware(ctx)
15
+ */
16
+ export function createTestContext(
17
+ path: string,
18
+ options: {
19
+ method?: string
20
+ headers?: Record<string, string>
21
+ body?: unknown
22
+ } = {},
23
+ ): MiddlewareContext {
24
+ const { method = 'GET', headers = {}, body } = options
25
+ const url = new URL(`http://localhost${path}`)
26
+
27
+ const requestHeaders: Record<string, string> = { ...headers }
28
+ let requestBody: string | undefined
29
+
30
+ if (body !== undefined) {
31
+ requestHeaders['Content-Type'] = 'application/json'
32
+ requestBody = JSON.stringify(body)
33
+ }
34
+
35
+ const req = new Request(url.toString(), {
36
+ method,
37
+ headers: requestHeaders,
38
+ body: requestBody,
39
+ })
40
+
41
+ return {
42
+ req,
43
+ url,
44
+ path,
45
+ headers: new Headers(),
46
+ locals: {},
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Test a middleware by running it with a mock context and returning
52
+ * the result along with the response headers it set.
53
+ *
54
+ * @example
55
+ * import { testMiddleware } from "@pyreon/zero/testing"
56
+ *
57
+ * const { response, headers } = await testMiddleware(
58
+ * corsMiddleware({ origin: "*" }),
59
+ * "/api/posts"
60
+ * )
61
+ * expect(headers.get("Access-Control-Allow-Origin")).toBe("*")
62
+ */
63
+ export async function testMiddleware(
64
+ middleware: Middleware,
65
+ path: string,
66
+ options: {
67
+ method?: string
68
+ headers?: Record<string, string>
69
+ body?: unknown
70
+ } = {},
71
+ ): Promise<{ response: Response | undefined; headers: Headers }> {
72
+ const ctx = createTestContext(path, options)
73
+ const response = (await middleware(ctx)) as Response | undefined
74
+ return { response, headers: ctx.headers }
75
+ }
76
+
77
+ /**
78
+ * Create a test server for API routes. Returns a function that
79
+ * accepts Request objects and dispatches to the correct handler.
80
+ *
81
+ * @example
82
+ * import { createTestApiServer } from "@pyreon/zero/testing"
83
+ *
84
+ * const server = createTestApiServer([
85
+ * { pattern: "/api/posts", module: postsApi },
86
+ * { pattern: "/api/posts/:id", module: postByIdApi },
87
+ * ])
88
+ *
89
+ * const response = await server.request("/api/posts")
90
+ * expect(response.status).toBe(200)
91
+ *
92
+ * const data = await server.request("/api/posts", { method: "POST", body: { title: "Hi" } })
93
+ * expect(data.status).toBe(201)
94
+ */
95
+ export function createTestApiServer(routes: ApiRouteEntry[]) {
96
+ const middleware = createApiMiddleware(routes)
97
+
98
+ return {
99
+ async request(
100
+ path: string,
101
+ options: {
102
+ method?: string
103
+ headers?: Record<string, string>
104
+ body?: unknown
105
+ } = {},
106
+ ): Promise<Response> {
107
+ const ctx = createTestContext(path, options)
108
+ const result = await middleware(ctx)
109
+ if (!result) {
110
+ return new Response('Not Found', { status: 404 })
111
+ }
112
+ return result
113
+ },
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Create a mock API handler for testing.
119
+ * Records all calls and returns a configurable response.
120
+ *
121
+ * @example
122
+ * import { createMockHandler } from "@pyreon/zero/testing"
123
+ *
124
+ * const handler = createMockHandler({ status: 200, body: { ok: true } })
125
+ * // ... use handler in your API route module
126
+ * expect(handler.calls).toHaveLength(1)
127
+ * expect(handler.calls[0].params).toEqual({ id: "123" })
128
+ */
129
+ export function createMockHandler(
130
+ responseConfig: {
131
+ status?: number
132
+ body?: unknown
133
+ headers?: Record<string, string>
134
+ } = {},
135
+ ): ApiHandler & {
136
+ calls: Array<{ path: string; params: Record<string, string> }>
137
+ } {
138
+ const { status = 200, body = null, headers = {} } = responseConfig
139
+ const calls: Array<{ path: string; params: Record<string, string> }> = []
140
+
141
+ const handler: ApiHandler = (ctx) => {
142
+ calls.push({ path: ctx.path, params: ctx.params })
143
+ return new Response(JSON.stringify(body), {
144
+ status,
145
+ headers: { 'Content-Type': 'application/json', ...headers },
146
+ })
147
+ }
148
+
149
+ return Object.assign(handler, { calls })
150
+ }
package/src/types.ts CHANGED
@@ -111,6 +111,14 @@ export interface FileRoute {
111
111
  renderMode: RenderMode
112
112
  }
113
113
 
114
+ // ─── Route middleware ────────────────────────────────────────────────────
115
+
116
+ /** Entry mapping a URL pattern to its route-level middleware. */
117
+ export interface RouteMiddlewareEntry {
118
+ pattern: string
119
+ middleware: Middleware | Middleware[]
120
+ }
121
+
114
122
  // ─── Adapter ─────────────────────────────────────────────────────────────────
115
123
 
116
124
  export interface Adapter {
@@ -1,11 +1,23 @@
1
1
  import type { Plugin } from 'vite'
2
+ import { generateApiRouteModule } from './api-routes'
2
3
  import { resolveConfig } from './config'
3
- import { generateRouteModule, scanRouteFiles } from './fs-router'
4
+ import { renderErrorOverlay } from './error-overlay'
5
+ import {
6
+ generateMiddlewareModule,
7
+ generateRouteModule,
8
+ scanRouteFiles,
9
+ } from './fs-router'
4
10
  import type { ZeroConfig } from './types'
5
11
 
6
12
  const VIRTUAL_ROUTES_ID = 'virtual:zero/routes'
7
13
  const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`
8
14
 
15
+ const VIRTUAL_MIDDLEWARE_ID = 'virtual:zero/route-middleware'
16
+ const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`
17
+
18
+ const VIRTUAL_API_ROUTES_ID = 'virtual:zero/api-routes'
19
+ const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`
20
+
9
21
  /**
10
22
  * Zero Vite plugin — adds file-based routing and zero-config conventions
11
23
  * on top of @pyreon/vite-plugin.
@@ -35,9 +47,9 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
35
47
  },
36
48
 
37
49
  resolveId(id) {
38
- if (id === VIRTUAL_ROUTES_ID) {
39
- return RESOLVED_VIRTUAL_ROUTES_ID
40
- }
50
+ if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID
51
+ if (id === VIRTUAL_MIDDLEWARE_ID) return RESOLVED_VIRTUAL_MIDDLEWARE_ID
52
+ if (id === VIRTUAL_API_ROUTES_ID) return RESOLVED_VIRTUAL_API_ROUTES_ID
41
53
  },
42
54
 
43
55
  async load(id) {
@@ -49,25 +61,78 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
49
61
  return `export const routes = []`
50
62
  }
51
63
  }
64
+
65
+ if (id === RESOLVED_VIRTUAL_MIDDLEWARE_ID) {
66
+ try {
67
+ const files = await scanRouteFiles(routesDir)
68
+ return generateMiddlewareModule(files, routesDir)
69
+ } catch (_err) {
70
+ return `export const routeMiddleware = []`
71
+ }
72
+ }
73
+
74
+ if (id === RESOLVED_VIRTUAL_API_ROUTES_ID) {
75
+ try {
76
+ const files = await scanRouteFiles(routesDir)
77
+ return generateApiRouteModule(files, routesDir)
78
+ } catch (_err) {
79
+ return `export const apiRoutes = []`
80
+ }
81
+ }
52
82
  },
53
83
 
54
84
  configureServer(server) {
85
+ // SSR error overlay — intercept HTML requests and catch SSR errors
86
+ // This runs as a late middleware (return function) so it wraps
87
+ // Vite's own SSR handling and catches rendering failures.
88
+ server.middlewares.use((req, res, next) => {
89
+ const accept = req.headers.accept ?? ''
90
+ if (!accept.includes('text/html')) return next()
91
+
92
+ // Monkey-patch res.end to catch errors from SSR rendering
93
+ const originalEnd = res.end.bind(res)
94
+ let errored = false
95
+
96
+ const handleError = (err: unknown) => {
97
+ if (errored) return
98
+ errored = true
99
+ const error = err instanceof Error ? err : new Error(String(err))
100
+ server.ssrFixStacktrace(error)
101
+ const html = renderErrorOverlay(error)
102
+ res.statusCode = 500
103
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
104
+ res.setHeader('Content-Length', Buffer.byteLength(html))
105
+ originalEnd(html)
106
+ }
107
+
108
+ res.on('error', handleError)
109
+
110
+ // Wrap next() in try/catch to handle synchronous errors
111
+ try {
112
+ next()
113
+ } catch (err) {
114
+ handleError(err)
115
+ }
116
+ })
117
+
55
118
  // Watch routes directory for changes
56
119
  server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`)
57
120
 
58
- // Invalidate virtual module when route files change
121
+ // Invalidate virtual modules when route files change
59
122
  server.watcher.on('all', (event, path) => {
60
123
  if (
61
124
  path.startsWith(routesDir) &&
62
125
  (event === 'add' || event === 'unlink')
63
126
  ) {
64
- const mod = server.moduleGraph.getModuleById(
127
+ for (const resolvedId of [
65
128
  RESOLVED_VIRTUAL_ROUTES_ID,
66
- )
67
- if (mod) {
68
- server.moduleGraph.invalidateModule(mod)
69
- server.ws.send({ type: 'full-reload' })
129
+ RESOLVED_VIRTUAL_MIDDLEWARE_ID,
130
+ RESOLVED_VIRTUAL_API_ROUTES_ID,
131
+ ]) {
132
+ const mod = server.moduleGraph.getModuleById(resolvedId)
133
+ if (mod) server.moduleGraph.invalidateModule(mod)
70
134
  }
135
+ server.ws.send({ type: 'full-reload' })
71
136
  }
72
137
  })
73
138
  },
@@ -1 +0,0 @@
1
- {"version":3,"file":"fs-router-jfd1QGLB.js","names":[],"sources":["../src/fs-router.ts"],"sourcesContent":["import type { FileRoute, RenderMode } from './types'\n\n// ─── File-system route conventions ──────────────────────────────────────────\n//\n// src/routes/\n// _layout.tsx → layout for all routes\n// index.tsx → /\n// about.tsx → /about\n// users/\n// _layout.tsx → layout for /users/*\n// _loading.tsx → loading fallback for /users/*\n// _error.tsx → error boundary for /users/*\n// index.tsx → /users\n// [id].tsx → /users/:id\n// [id]/\n// settings.tsx → /users/:id/settings\n// blog/\n// [...slug].tsx → /blog/* (catch-all)\n//\n// Conventions:\n// [param] → dynamic segment → :param\n// [...param] → catch-all → :param*\n// _layout → layout wrapper (not a route itself)\n// _error → error component\n// _loading → loading component\n// (group) → route group (directory ignored in URL)\n\nconst ROUTE_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js']\n\n/**\n * Parse a set of file paths (relative to routes dir) into FileRoute objects.\n *\n * @param files Array of file paths like [\"index.tsx\", \"users/[id].tsx\"]\n * @param defaultMode Default rendering mode from config\n */\nexport function parseFileRoutes(\n files: string[],\n defaultMode: RenderMode = 'ssr',\n): FileRoute[] {\n return files\n .filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext)))\n .map((filePath) => parseFilePath(filePath, defaultMode))\n .sort(sortRoutes)\n}\n\nfunction parseFilePath(filePath: string, defaultMode: RenderMode): FileRoute {\n // Remove extension\n let route = filePath\n for (const ext of ROUTE_EXTENSIONS) {\n if (route.endsWith(ext)) {\n route = route.slice(0, -ext.length)\n break\n }\n }\n\n const fileName = getFileName(route)\n const isLayout = fileName === '_layout'\n const isError = fileName === '_error'\n const isLoading = fileName === '_loading'\n const isCatchAll = route.includes('[...')\n\n // Get directory path (strip groups for consistent grouping)\n const parts = route.split('/')\n parts.pop() // remove filename\n const dirPath = parts\n .filter((s) => !(s.startsWith('(') && s.endsWith(')')))\n .join('/')\n\n // Convert file path to URL pattern\n const urlPath = filePathToUrlPath(route)\n const depth = urlPath === '/' ? 0 : urlPath.split('/').filter(Boolean).length\n\n return {\n filePath,\n urlPath,\n dirPath,\n depth,\n isLayout,\n isError,\n isLoading,\n isCatchAll,\n renderMode: defaultMode,\n }\n}\n\n/**\n * Convert a file path (without extension) to a URL path pattern.\n *\n * Examples:\n * \"index\" → \"/\"\n * \"about\" → \"/about\"\n * \"users/index\" → \"/users\"\n * \"users/[id]\" → \"/users/:id\"\n * \"blog/[...slug]\" → \"/blog/:slug*\"\n * \"(auth)/login\" → \"/login\" (group stripped)\n * \"_layout\" → \"/\" (layout marker)\n */\nexport function filePathToUrlPath(filePath: string): string {\n const segments = filePath.split('/')\n const urlSegments: string[] = []\n\n for (const seg of segments) {\n // Skip route groups \"(name)\"\n if (seg.startsWith('(') && seg.endsWith(')')) continue\n\n // Skip special files\n if (seg === '_layout' || seg === '_error' || seg === '_loading') continue\n\n // \"index\" maps to the parent path\n if (seg === 'index') continue\n\n // Catch-all: [...param] → :param*\n const catchAll = seg.match(/^\\[\\.\\.\\.(\\w+)\\]$/)\n if (catchAll) {\n urlSegments.push(`:${catchAll[1]}*`)\n continue\n }\n\n // Dynamic: [param] → :param\n const dynamic = seg.match(/^\\[(\\w+)\\]$/)\n if (dynamic) {\n urlSegments.push(`:${dynamic[1]}`)\n continue\n }\n\n urlSegments.push(seg)\n }\n\n const path = `/${urlSegments.join('/')}`\n return path || '/'\n}\n\n/** Sort routes: static before dynamic, catch-all last. */\nfunction sortRoutes(a: FileRoute, b: FileRoute): number {\n // Catch-all routes go last\n if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1\n // Layouts go first within same depth\n if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1\n // Static segments before dynamic\n const aDynamic = a.urlPath.includes(':')\n const bDynamic = b.urlPath.includes(':')\n if (aDynamic !== bDynamic) return aDynamic ? 1 : -1\n // Alphabetical\n return a.urlPath.localeCompare(b.urlPath)\n}\n\nfunction getFileName(filePath: string): string {\n const parts = filePath.split('/')\n return parts[parts.length - 1] ?? ''\n}\n\n// ─── Route generation (for Vite plugin) ─────────────────────────────────────\n\n/** Internal tree node for building nested route structures. */\ninterface RouteNode {\n /** Page routes at this directory level. */\n pages: FileRoute[]\n /** Layout file for this directory (if any). */\n layout?: FileRoute\n /** Error boundary file (if any). */\n error?: FileRoute\n /** Loading fallback file (if any). */\n loading?: FileRoute\n /** Child directories. */\n children: Map<string, RouteNode>\n}\n\n/**\n * Group flat file routes into a directory tree.\n */\nfunction getOrCreateChild(node: RouteNode, segment: string): RouteNode {\n let child = node.children.get(segment)\n if (!child) {\n child = { pages: [], children: new Map() }\n node.children.set(segment, child)\n }\n return child\n}\n\nfunction resolveNode(root: RouteNode, dirPath: string): RouteNode {\n let node = root\n if (dirPath) {\n for (const segment of dirPath.split('/')) {\n node = getOrCreateChild(node, segment)\n }\n }\n return node\n}\n\nfunction placeRoute(node: RouteNode, route: FileRoute) {\n if (route.isLayout) node.layout = route\n else if (route.isError) node.error = route\n else if (route.isLoading) node.loading = route\n else node.pages.push(route)\n}\n\nfunction buildRouteTree(routes: FileRoute[]): RouteNode {\n const root: RouteNode = { pages: [], children: new Map() }\n for (const route of routes) {\n placeRoute(resolveNode(root, route.dirPath), route)\n }\n return root\n}\n\n/**\n * Generate a virtual module that exports a nested route tree.\n * Wires up layouts as parent routes with children, loaders, guards,\n * error/loading components, middleware, and meta from route module exports.\n */\nexport function generateRouteModule(\n files: string[],\n routesDir: string,\n): string {\n const routes = parseFileRoutes(files)\n const tree = buildRouteTree(routes)\n const imports: string[] = []\n let importCounter = 0\n\n function nextImport(filePath: string, exportName = 'default'): string {\n const name = `_${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n if (exportName === 'default') {\n imports.push(`import ${name} from \"${fullPath}\"`)\n } else {\n imports.push(`import { ${exportName} as ${name} } from \"${fullPath}\"`)\n }\n return name\n }\n\n function nextLazy(\n filePath: string,\n loadingName?: string,\n errorName?: string,\n ): string {\n const name = `_${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n const opts: string[] = []\n if (loadingName) opts.push(`loading: ${loadingName}`)\n if (errorName) opts.push(`error: ${errorName}`)\n const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''\n imports.push(`const ${name} = lazy(() => import(\"${fullPath}\")${optsStr})`)\n return name\n }\n\n function nextModuleImport(filePath: string): string {\n const name = `_m${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n imports.push(`import * as ${name} from \"${fullPath}\"`)\n return name\n }\n\n function generatePageRoute(\n page: FileRoute,\n indent: string,\n loadingName: string | undefined,\n errorName: string | undefined,\n ): string {\n const mod = nextModuleImport(page.filePath)\n const comp = nextLazy(page.filePath, loadingName, errorName)\n\n const props: string[] = [\n `${indent} path: ${JSON.stringify(page.urlPath)}`,\n `${indent} component: ${comp}`,\n `${indent} loader: ${mod}.loader`,\n `${indent} beforeEnter: ${mod}.guard`,\n `${indent} meta: ${mod}.meta`,\n ]\n\n if (errorName) {\n props.push(`${indent} errorComponent: ${mod}.error || ${errorName}`)\n } else {\n props.push(`${indent} errorComponent: ${mod}.error`)\n }\n\n return `${indent}{\\n${props.join(',\\n')}\\n${indent}}`\n }\n\n function wrapWithLayout(\n node: RouteNode,\n children: string[],\n indent: string,\n errorName: string | undefined,\n ): string {\n const layout = node.layout as FileRoute\n const layoutMod = nextModuleImport(layout.filePath)\n const layoutComp = nextImport(layout.filePath, 'layout')\n\n const props: string[] = [\n `${indent}path: ${JSON.stringify(layout.urlPath)}`,\n `${indent}component: ${layoutComp}`,\n `${indent}loader: ${layoutMod}.loader`,\n `${indent}beforeEnter: ${layoutMod}.guard`,\n `${indent}meta: ${layoutMod}.meta`,\n ]\n if (errorName) {\n props.push(`${indent}errorComponent: ${errorName}`)\n }\n if (children.length > 0) {\n props.push(`${indent}children: [\\n${children.join(',\\n')}\\n${indent}]`)\n }\n\n return `${indent}{\\n${props.map((p) => ` ${p}`).join(',\\n')}\\n${indent}}`\n }\n\n /**\n * Generate route definitions for a tree node.\n */\n function generateNode(node: RouteNode, depth: number): string[] {\n const indent = ' '.repeat(depth + 1)\n\n const errorName = node.error ? nextImport(node.error.filePath) : undefined\n const loadingName = node.loading\n ? nextImport(node.loading.filePath)\n : undefined\n\n const childRouteDefs: string[] = []\n for (const [, childNode] of node.children) {\n childRouteDefs.push(...generateNode(childNode, depth + 1))\n }\n\n const pageRouteDefs = node.pages.map((page) =>\n generatePageRoute(page, indent, loadingName, errorName),\n )\n\n const allChildren = [...pageRouteDefs, ...childRouteDefs]\n\n if (node.layout) {\n return [wrapWithLayout(node, allChildren, indent, errorName)]\n }\n return allChildren\n }\n\n const routeDefs = generateNode(tree, 0)\n\n return [\n `import { lazy } from \"@pyreon/router\"`,\n '',\n ...imports,\n '',\n // Filter out undefined properties at runtime\n `function clean(routes) {`,\n ` return routes.map(r => {`,\n ` const c = {}`,\n ` for (const k in r) if (r[k] !== undefined) c[k] = r[k]`,\n ` if (c.children) c.children = clean(c.children)`,\n ` return c`,\n ` })`,\n `}`,\n '',\n `export const routes = clean([`,\n routeDefs.join(',\\n'),\n `])`,\n ].join('\\n')\n}\n\n/**\n * Scan a directory for route files.\n * Returns paths relative to the routes directory.\n */\nexport async function scanRouteFiles(routesDir: string): Promise<string[]> {\n const { readdir } = await import('node:fs/promises')\n const { join, relative } = await import('node:path')\n\n const files: string[] = []\n\n async function walk(dir: string) {\n const entries = await readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n const fullPath = join(dir, entry.name)\n if (entry.isDirectory()) {\n await walk(fullPath)\n } else if (ROUTE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {\n files.push(relative(routesDir, fullPath))\n }\n }\n }\n\n await walk(routesDir)\n return files\n}\n"],"mappings":"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"}