@pyreon/zero 0.1.1 → 0.3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pyreon/zero
2
2
 
3
- Core meta-framework for building full-stack apps with [Pyreon](https://github.com/user/pyreon) and [Vite](https://vite.dev).
3
+ Core meta-framework for building full-stack apps with [Pyreon](https://github.com/pyreon/pyreon) and [Vite](https://vite.dev).
4
4
 
5
5
  ## Install
6
6
 
@@ -11,14 +11,19 @@ bun add @pyreon/zero
11
11
  ## Features
12
12
 
13
13
  - **File-based routing** — `[param]`, `[...catchAll]`, `_layout`, `_error`, `_loading`, `(groups)`
14
- - **Rendering modes** — SSR, SSG, ISR, SPA (per-route configurable)
14
+ - **Rendering modes** — SSR (streaming + string), SSG, ISR, SPA (per-route configurable via `renderMode` export)
15
+ - **API routes** — `.ts` files in `src/routes/api/` export HTTP method handlers (`GET`, `POST`, `PUT`, `DELETE`)
16
+ - **Server actions** — `defineAction()` for mutations with automatic client/server boundary detection
17
+ - **Per-route middleware** — route files export `middleware` using `@pyreon/server`'s signature
15
18
  - **Components** — `<Image>` (lazy load, srcset, blur-up), `<Link>` (prefetch, active state), `<Script>` (loading strategies)
16
19
  - **Theme** — Dark/light/system with `theme` signal, `<ThemeToggle>`, and anti-flash inline script
17
20
  - **Fonts** — Google Fonts self-hosting at build time, local fonts, size-adjusted fallbacks
18
21
  - **Image optimization** — Build-time processing via `?optimize` imports (WebP/AVIF, blur placeholders)
19
22
  - **SEO** — Sitemap, robots.txt, JSON-LD helpers (Vite plugin + dev middleware)
20
- - **Cache & security** — `cacheMiddleware()`, `securityHeaders()`, `varyEncoding()`
23
+ - **Middleware** — `cacheMiddleware()`, `securityHeaders()`, `corsMiddleware()`, `rateLimitMiddleware()`, `compressionMiddleware()`
21
24
  - **Adapters** — Node.js, Bun, static
25
+ - **Testing** — `createTestContext()`, `testMiddleware()`, `createTestApiServer()`, `createMockHandler()`
26
+ - **Dev overlay** — Styled error overlay with source-mapped stack traces for SSR errors
22
27
 
23
28
  ## Usage
24
29
 
@@ -28,7 +33,7 @@ import pyreon from "@pyreon/vite-plugin"
28
33
  import zero from "@pyreon/zero"
29
34
 
30
35
  export default {
31
- plugins: [pyreon(), zero({ mode: "ssr" })],
36
+ plugins: [pyreon(), zero({ mode: "ssr", ssr: { mode: "stream" } })],
32
37
  }
33
38
  ```
34
39
 
@@ -36,8 +41,9 @@ export default {
36
41
 
37
42
  | Export | Description |
38
43
  | --- | --- |
39
- | `@pyreon/zero` | Vite plugin, config, adapters |
44
+ | `@pyreon/zero` | Vite plugin, config, adapters, components, middleware |
40
45
  | `@pyreon/zero/client` | Client-side entry (`startClient`) |
46
+ | `@pyreon/zero/config` | `defineConfig`, `resolveConfig` |
41
47
  | `@pyreon/zero/image` | `<Image>` component |
42
48
  | `@pyreon/zero/link` | `<Link>`, `useLink`, `createLink` |
43
49
  | `@pyreon/zero/script` | `<Script>` component |
@@ -46,7 +52,12 @@ export default {
46
52
  | `@pyreon/zero/cache` | Cache and security middleware |
47
53
  | `@pyreon/zero/seo` | SEO plugin, sitemap, robots.txt |
48
54
  | `@pyreon/zero/image-plugin` | Image optimization Vite plugin |
49
- | `@pyreon/zero/config` | Config types |
55
+ | `@pyreon/zero/actions` | `defineAction`, `createActionMiddleware` |
56
+ | `@pyreon/zero/api-routes` | API route utilities and middleware |
57
+ | `@pyreon/zero/cors` | CORS middleware |
58
+ | `@pyreon/zero/rate-limit` | Rate limiting middleware |
59
+ | `@pyreon/zero/compression` | Compression middleware |
60
+ | `@pyreon/zero/testing` | Test utilities for middleware and API routes |
50
61
 
51
62
  ## License
52
63
 
@@ -1 +1 @@
1
- {"version":3,"file":"fs-router-BkbIWqek.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":";AA2BA,MAAM,mBAAmB;CAAC;CAAQ;CAAQ;CAAO;CAAM;;;;;AA4UvD,eAAsB,eAAe,WAAsC;CACzE,MAAM,EAAE,YAAY,MAAM,OAAO;CACjC,MAAM,EAAE,MAAM,aAAa,MAAM,OAAO;CAExC,MAAM,QAAkB,EAAE;CAE1B,eAAe,KAAK,KAAa;EAC/B,MAAM,UAAU,MAAM,QAAQ,KAAK,EAAE,eAAe,MAAM,CAAC;AAC3D,OAAK,MAAM,SAAS,SAAS;GAC3B,MAAM,WAAW,KAAK,KAAK,MAAM,KAAK;AACtC,OAAI,MAAM,aAAa,CACrB,OAAM,KAAK,SAAS;YACX,iBAAiB,MAAM,QAAQ,MAAM,KAAK,SAAS,IAAI,CAAC,CACjE,OAAM,KAAK,SAAS,WAAW,SAAS,CAAC;;;AAK/C,OAAM,KAAK,UAAU;AACrB,QAAO"}
1
+ {"version":3,"file":"fs-router-BkbIWqek.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, renderMode: ${mod}.renderMode }`,\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, renderMode: ${layoutMod}.renderMode }`,\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 * Generate a virtual module that maps URL patterns to their middleware exports.\n * Used by the server entry to dispatch per-route middleware.\n */\nexport function generateMiddlewareModule(\n files: string[],\n routesDir: string,\n): string {\n const routes = parseFileRoutes(files)\n const imports: string[] = []\n const entries: string[] = []\n let counter = 0\n\n for (const route of routes) {\n if (route.isLayout || route.isError || route.isLoading) continue\n const name = `_mw${counter++}`\n const fullPath = `${routesDir}/${route.filePath}`\n imports.push(`import { middleware as ${name} } from \"${fullPath}\"`)\n entries.push(\n ` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`,\n )\n }\n\n return [\n ...imports,\n '',\n `export const routeMiddleware = [`,\n entries.join(',\\n'),\n `].filter(e => e.middleware)`,\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":";AA2BA,MAAM,mBAAmB;CAAC;CAAQ;CAAQ;CAAO;CAAM;;;;;AA4WvD,eAAsB,eAAe,WAAsC;CACzE,MAAM,EAAE,YAAY,MAAM,OAAO;CACjC,MAAM,EAAE,MAAM,aAAa,MAAM,OAAO;CAExC,MAAM,QAAkB,EAAE;CAE1B,eAAe,KAAK,KAAa;EAC/B,MAAM,UAAU,MAAM,QAAQ,KAAK,EAAE,eAAe,MAAM,CAAC;AAC3D,OAAK,MAAM,SAAS,SAAS;GAC3B,MAAM,WAAW,KAAK,KAAK,MAAM,KAAK;AACtC,OAAI,MAAM,aAAa,CACrB,OAAM,KAAK,SAAS;YACX,iBAAiB,MAAM,QAAQ,MAAM,KAAK,SAAS,IAAI,CAAC,CACjE,OAAM,KAAK,SAAS,WAAW,SAAS,CAAC;;;AAK/C,OAAM,KAAK,UAAU;AACrB,QAAO"}
@@ -18,6 +18,7 @@ var __exportAll = (all, no_symbols) => {
18
18
  //#region src/fs-router.ts
19
19
  var fs_router_exports = /* @__PURE__ */ __exportAll({
20
20
  filePathToUrlPath: () => filePathToUrlPath,
21
+ generateMiddlewareModule: () => generateMiddlewareModule,
21
22
  generateRouteModule: () => generateRouteModule,
22
23
  parseFileRoutes: () => parseFileRoutes,
23
24
  scanRouteFiles: () => scanRouteFiles
@@ -182,7 +183,7 @@ function generateRouteModule(files, routesDir) {
182
183
  `${indent} component: ${comp}`,
183
184
  `${indent} loader: ${mod}.loader`,
184
185
  `${indent} beforeEnter: ${mod}.guard`,
185
- `${indent} meta: ${mod}.meta`
186
+ `${indent} meta: { ...${mod}.meta, renderMode: ${mod}.renderMode }`
186
187
  ];
187
188
  if (errorName) props.push(`${indent} errorComponent: ${mod}.error || ${errorName}`);
188
189
  else props.push(`${indent} errorComponent: ${mod}.error`);
@@ -197,7 +198,7 @@ function generateRouteModule(files, routesDir) {
197
198
  `${indent}component: ${layoutComp}`,
198
199
  `${indent}loader: ${layoutMod}.loader`,
199
200
  `${indent}beforeEnter: ${layoutMod}.guard`,
200
- `${indent}meta: ${layoutMod}.meta`
201
+ `${indent}meta: { ...${layoutMod}.meta, renderMode: ${layoutMod}.renderMode }`
201
202
  ];
202
203
  if (errorName) props.push(`${indent}errorComponent: ${errorName}`);
203
204
  if (children.length > 0) props.push(`${indent}children: [\n${children.join(",\n")}\n${indent}]`);
@@ -237,6 +238,30 @@ function generateRouteModule(files, routesDir) {
237
238
  ].join("\n");
238
239
  }
239
240
  /**
241
+ * Generate a virtual module that maps URL patterns to their middleware exports.
242
+ * Used by the server entry to dispatch per-route middleware.
243
+ */
244
+ function generateMiddlewareModule(files, routesDir) {
245
+ const routes = parseFileRoutes(files);
246
+ const imports = [];
247
+ const entries = [];
248
+ let counter = 0;
249
+ for (const route of routes) {
250
+ if (route.isLayout || route.isError || route.isLoading) continue;
251
+ const name = `_mw${counter++}`;
252
+ const fullPath = `${routesDir}/${route.filePath}`;
253
+ imports.push(`import { middleware as ${name} } from "${fullPath}"`);
254
+ entries.push(` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`);
255
+ }
256
+ return [
257
+ ...imports,
258
+ "",
259
+ `export const routeMiddleware = [`,
260
+ entries.join(",\n"),
261
+ `].filter(e => e.middleware)`
262
+ ].join("\n");
263
+ }
264
+ /**
240
265
  * Scan a directory for route files.
241
266
  * Returns paths relative to the routes directory.
242
267
  */
@@ -257,5 +282,5 @@ async function scanRouteFiles(routesDir) {
257
282
  }
258
283
 
259
284
  //#endregion
260
- export { scanRouteFiles as a, parseFileRoutes as i, fs_router_exports as n, generateRouteModule as r, filePathToUrlPath as t };
261
- //# sourceMappingURL=fs-router-jfd1QGLB.js.map
285
+ export { parseFileRoutes as a, generateRouteModule as i, fs_router_exports as n, scanRouteFiles as o, generateMiddlewareModule as r, filePathToUrlPath as t };
286
+ //# sourceMappingURL=fs-router-n4VA4lxu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-router-n4VA4lxu.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, renderMode: ${mod}.renderMode }`,\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, renderMode: ${layoutMod}.renderMode }`,\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 * Generate a virtual module that maps URL patterns to their middleware exports.\n * Used by the server entry to dispatch per-route middleware.\n */\nexport function generateMiddlewareModule(\n files: string[],\n routesDir: string,\n): string {\n const routes = parseFileRoutes(files)\n const imports: string[] = []\n const entries: string[] = []\n let counter = 0\n\n for (const route of routes) {\n if (route.isLayout || route.isError || route.isLoading) continue\n const name = `_mw${counter++}`\n const fullPath = `${routesDir}/${route.filePath}`\n imports.push(`import { middleware as ${name} } from \"${fullPath}\"`)\n entries.push(\n ` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`,\n )\n }\n\n return [\n ...imports,\n '',\n `export const routeMiddleware = [`,\n entries.join(',\\n'),\n `].filter(e => e.middleware)`,\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":";;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,MAAM,mBAAmB;CAAC;CAAQ;CAAQ;CAAO;CAAM;;;;;;;AAQvD,SAAgB,gBACd,OACA,cAA0B,OACb;AACb,QAAO,MACJ,QAAQ,MAAM,iBAAiB,MAAM,QAAQ,EAAE,SAAS,IAAI,CAAC,CAAC,CAC9D,KAAK,aAAa,cAAc,UAAU,YAAY,CAAC,CACvD,KAAK,WAAW;;AAGrB,SAAS,cAAc,UAAkB,aAAoC;CAE3E,IAAI,QAAQ;AACZ,MAAK,MAAM,OAAO,iBAChB,KAAI,MAAM,SAAS,IAAI,EAAE;AACvB,UAAQ,MAAM,MAAM,GAAG,CAAC,IAAI,OAAO;AACnC;;CAIJ,MAAM,WAAW,YAAY,MAAM;CACnC,MAAM,WAAW,aAAa;CAC9B,MAAM,UAAU,aAAa;CAC7B,MAAM,YAAY,aAAa;CAC/B,MAAM,aAAa,MAAM,SAAS,OAAO;CAGzC,MAAM,QAAQ,MAAM,MAAM,IAAI;AAC9B,OAAM,KAAK;CACX,MAAM,UAAU,MACb,QAAQ,MAAM,EAAE,EAAE,WAAW,IAAI,IAAI,EAAE,SAAS,IAAI,EAAE,CACtD,KAAK,IAAI;CAGZ,MAAM,UAAU,kBAAkB,MAAM;AAGxC,QAAO;EACL;EACA;EACA;EACA,OANY,YAAY,MAAM,IAAI,QAAQ,MAAM,IAAI,CAAC,OAAO,QAAQ,CAAC;EAOrE;EACA;EACA;EACA;EACA,YAAY;EACb;;;;;;;;;;;;;;AAeH,SAAgB,kBAAkB,UAA0B;CAC1D,MAAM,WAAW,SAAS,MAAM,IAAI;CACpC,MAAM,cAAwB,EAAE;AAEhC,MAAK,MAAM,OAAO,UAAU;AAE1B,MAAI,IAAI,WAAW,IAAI,IAAI,IAAI,SAAS,IAAI,CAAE;AAG9C,MAAI,QAAQ,aAAa,QAAQ,YAAY,QAAQ,WAAY;AAGjE,MAAI,QAAQ,QAAS;EAGrB,MAAM,WAAW,IAAI,MAAM,oBAAoB;AAC/C,MAAI,UAAU;AACZ,eAAY,KAAK,IAAI,SAAS,GAAG,GAAG;AACpC;;EAIF,MAAM,UAAU,IAAI,MAAM,cAAc;AACxC,MAAI,SAAS;AACX,eAAY,KAAK,IAAI,QAAQ,KAAK;AAClC;;AAGF,cAAY,KAAK,IAAI;;AAIvB,QADa,IAAI,YAAY,KAAK,IAAI,MACvB;;;AAIjB,SAAS,WAAW,GAAc,GAAsB;AAEtD,KAAI,EAAE,eAAe,EAAE,WAAY,QAAO,EAAE,aAAa,IAAI;AAE7D,KAAI,EAAE,aAAa,EAAE,SAAU,QAAO,EAAE,WAAW,KAAK;CAExD,MAAM,WAAW,EAAE,QAAQ,SAAS,IAAI;AAExC,KAAI,aADa,EAAE,QAAQ,SAAS,IAAI,CACb,QAAO,WAAW,IAAI;AAEjD,QAAO,EAAE,QAAQ,cAAc,EAAE,QAAQ;;AAG3C,SAAS,YAAY,UAA0B;CAC7C,MAAM,QAAQ,SAAS,MAAM,IAAI;AACjC,QAAO,MAAM,MAAM,SAAS,MAAM;;;;;AAsBpC,SAAS,iBAAiB,MAAiB,SAA4B;CACrE,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ;AACtC,KAAI,CAAC,OAAO;AACV,UAAQ;GAAE,OAAO,EAAE;GAAE,0BAAU,IAAI,KAAK;GAAE;AAC1C,OAAK,SAAS,IAAI,SAAS,MAAM;;AAEnC,QAAO;;AAGT,SAAS,YAAY,MAAiB,SAA4B;CAChE,IAAI,OAAO;AACX,KAAI,QACF,MAAK,MAAM,WAAW,QAAQ,MAAM,IAAI,CACtC,QAAO,iBAAiB,MAAM,QAAQ;AAG1C,QAAO;;AAGT,SAAS,WAAW,MAAiB,OAAkB;AACrD,KAAI,MAAM,SAAU,MAAK,SAAS;UACzB,MAAM,QAAS,MAAK,QAAQ;UAC5B,MAAM,UAAW,MAAK,UAAU;KACpC,MAAK,MAAM,KAAK,MAAM;;AAG7B,SAAS,eAAe,QAAgC;CACtD,MAAM,OAAkB;EAAE,OAAO,EAAE;EAAE,0BAAU,IAAI,KAAK;EAAE;AAC1D,MAAK,MAAM,SAAS,OAClB,YAAW,YAAY,MAAM,MAAM,QAAQ,EAAE,MAAM;AAErD,QAAO;;;;;;;AAQT,SAAgB,oBACd,OACA,WACQ;CAER,MAAM,OAAO,eADE,gBAAgB,MAAM,CACF;CACnC,MAAM,UAAoB,EAAE;CAC5B,IAAI,gBAAgB;CAEpB,SAAS,WAAW,UAAkB,aAAa,WAAmB;EACpE,MAAM,OAAO,IAAI;EACjB,MAAM,WAAW,GAAG,UAAU,GAAG;AACjC,MAAI,eAAe,UACjB,SAAQ,KAAK,UAAU,KAAK,SAAS,SAAS,GAAG;MAEjD,SAAQ,KAAK,YAAY,WAAW,MAAM,KAAK,WAAW,SAAS,GAAG;AAExE,SAAO;;CAGT,SAAS,SACP,UACA,aACA,WACQ;EACR,MAAM,OAAO,IAAI;EACjB,MAAM,WAAW,GAAG,UAAU,GAAG;EACjC,MAAM,OAAiB,EAAE;AACzB,MAAI,YAAa,MAAK,KAAK,YAAY,cAAc;AACrD,MAAI,UAAW,MAAK,KAAK,UAAU,YAAY;EAC/C,MAAM,UAAU,KAAK,SAAS,IAAI,OAAO,KAAK,KAAK,KAAK,CAAC,MAAM;AAC/D,UAAQ,KAAK,SAAS,KAAK,wBAAwB,SAAS,IAAI,QAAQ,GAAG;AAC3E,SAAO;;CAGT,SAAS,iBAAiB,UAA0B;EAClD,MAAM,OAAO,KAAK;EAClB,MAAM,WAAW,GAAG,UAAU,GAAG;AACjC,UAAQ,KAAK,eAAe,KAAK,SAAS,SAAS,GAAG;AACtD,SAAO;;CAGT,SAAS,kBACP,MACA,QACA,aACA,WACQ;EACR,MAAM,MAAM,iBAAiB,KAAK,SAAS;EAC3C,MAAM,OAAO,SAAS,KAAK,UAAU,aAAa,UAAU;EAE5D,MAAM,QAAkB;GACtB,GAAG,OAAO,UAAU,KAAK,UAAU,KAAK,QAAQ;GAChD,GAAG,OAAO,eAAe;GACzB,GAAG,OAAO,YAAY,IAAI;GAC1B,GAAG,OAAO,iBAAiB,IAAI;GAC/B,GAAG,OAAO,eAAe,IAAI,qBAAqB,IAAI;GACvD;AAED,MAAI,UACF,OAAM,KAAK,GAAG,OAAO,oBAAoB,IAAI,YAAY,YAAY;MAErE,OAAM,KAAK,GAAG,OAAO,oBAAoB,IAAI,QAAQ;AAGvD,SAAO,GAAG,OAAO,KAAK,MAAM,KAAK,MAAM,CAAC,IAAI,OAAO;;CAGrD,SAAS,eACP,MACA,UACA,QACA,WACQ;EACR,MAAM,SAAS,KAAK;EACpB,MAAM,YAAY,iBAAiB,OAAO,SAAS;EACnD,MAAM,aAAa,WAAW,OAAO,UAAU,SAAS;EAExD,MAAM,QAAkB;GACtB,GAAG,OAAO,QAAQ,KAAK,UAAU,OAAO,QAAQ;GAChD,GAAG,OAAO,aAAa;GACvB,GAAG,OAAO,UAAU,UAAU;GAC9B,GAAG,OAAO,eAAe,UAAU;GACnC,GAAG,OAAO,aAAa,UAAU,qBAAqB,UAAU;GACjE;AACD,MAAI,UACF,OAAM,KAAK,GAAG,OAAO,kBAAkB,YAAY;AAErD,MAAI,SAAS,SAAS,EACpB,OAAM,KAAK,GAAG,OAAO,eAAe,SAAS,KAAK,MAAM,CAAC,IAAI,OAAO,GAAG;AAGzE,SAAO,GAAG,OAAO,KAAK,MAAM,KAAK,MAAM,KAAK,IAAI,CAAC,KAAK,MAAM,CAAC,IAAI,OAAO;;;;;CAM1E,SAAS,aAAa,MAAiB,OAAyB;EAC9D,MAAM,SAAS,KAAK,OAAO,QAAQ,EAAE;EAErC,MAAM,YAAY,KAAK,QAAQ,WAAW,KAAK,MAAM,SAAS,GAAG;EACjE,MAAM,cAAc,KAAK,UACrB,WAAW,KAAK,QAAQ,SAAS,GACjC;EAEJ,MAAM,iBAA2B,EAAE;AACnC,OAAK,MAAM,GAAG,cAAc,KAAK,SAC/B,gBAAe,KAAK,GAAG,aAAa,WAAW,QAAQ,EAAE,CAAC;EAO5D,MAAM,cAAc,CAAC,GAJC,KAAK,MAAM,KAAK,SACpC,kBAAkB,MAAM,QAAQ,aAAa,UAAU,CACxD,EAEsC,GAAG,eAAe;AAEzD,MAAI,KAAK,OACP,QAAO,CAAC,eAAe,MAAM,aAAa,QAAQ,UAAU,CAAC;AAE/D,SAAO;;CAGT,MAAM,YAAY,aAAa,MAAM,EAAE;AAEvC,QAAO;EACL;EACA;EACA,GAAG;EACH;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,UAAU,KAAK,MAAM;EACrB;EACD,CAAC,KAAK,KAAK;;;;;;AAOd,SAAgB,yBACd,OACA,WACQ;CACR,MAAM,SAAS,gBAAgB,MAAM;CACrC,MAAM,UAAoB,EAAE;CAC5B,MAAM,UAAoB,EAAE;CAC5B,IAAI,UAAU;AAEd,MAAK,MAAM,SAAS,QAAQ;AAC1B,MAAI,MAAM,YAAY,MAAM,WAAW,MAAM,UAAW;EACxD,MAAM,OAAO,MAAM;EACnB,MAAM,WAAW,GAAG,UAAU,GAAG,MAAM;AACvC,UAAQ,KAAK,0BAA0B,KAAK,WAAW,SAAS,GAAG;AACnE,UAAQ,KACN,gBAAgB,KAAK,UAAU,MAAM,QAAQ,CAAC,gBAAgB,KAAK,IACpE;;AAGH,QAAO;EACL,GAAG;EACH;EACA;EACA,QAAQ,KAAK,MAAM;EACnB;EACD,CAAC,KAAK,KAAK;;;;;;AAOd,eAAsB,eAAe,WAAsC;CACzE,MAAM,EAAE,YAAY,MAAM,OAAO;CACjC,MAAM,EAAE,MAAM,aAAa,MAAM,OAAO;CAExC,MAAM,QAAkB,EAAE;CAE1B,eAAe,KAAK,KAAa;EAC/B,MAAM,UAAU,MAAM,QAAQ,KAAK,EAAE,eAAe,MAAM,CAAC;AAC3D,OAAK,MAAM,SAAS,SAAS;GAC3B,MAAM,WAAW,KAAK,KAAK,MAAM,KAAK;AACtC,OAAI,MAAM,aAAa,CACrB,OAAM,KAAK,SAAS;YACX,iBAAiB,MAAM,QAAQ,MAAM,KAAK,SAAS,IAAI,CAAC,CACjE,OAAM,KAAK,SAAS,WAAW,SAAS,CAAC;;;AAK/C,OAAM,KAAK,UAAU;AACrB,QAAO"}
package/lib/image.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { createRef, onMount, onUnmount } from "@pyreon/core";
2
2
  import { signal } from "@pyreon/reactivity";
3
- import { jsx, jsxs } from "@pyreon/core/jsx-runtime";
4
3
 
5
4
  //#region src/utils/use-intersection-observer.ts
6
5
  /**
@@ -26,6 +25,56 @@ function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px")
26
25
  });
27
26
  }
28
27
 
28
+ //#endregion
29
+ //#region ../../node_modules/.bun/@pyreon+core@0.7.0/node_modules/@pyreon/core/lib/jsx-runtime.js
30
+ /**
31
+ * Hyperscript function — the compiled output of JSX.
32
+ * `<div class="x">hello</div>` → `h("div", { class: "x" }, "hello")`
33
+ *
34
+ * Generic on P so TypeScript validates props match the component's signature
35
+ * at the call site, then stores the result in the loosely-typed VNode.
36
+ */
37
+ /** Shared empty props sentinel — identity-checked in mountElement to skip applyProps. */
38
+ const EMPTY_PROPS = {};
39
+ function h(type, props, ...children) {
40
+ return {
41
+ type,
42
+ props: props ?? EMPTY_PROPS,
43
+ children: normalizeChildren(children),
44
+ key: props?.key ?? null
45
+ };
46
+ }
47
+ function normalizeChildren(children) {
48
+ for (let i = 0; i < children.length; i++) if (Array.isArray(children[i])) return flattenChildren(children);
49
+ return children;
50
+ }
51
+ function flattenChildren(children) {
52
+ const result = [];
53
+ for (const child of children) if (Array.isArray(child)) result.push(...flattenChildren(child));
54
+ else result.push(child);
55
+ return result;
56
+ }
57
+ /**
58
+ * JSX automatic runtime.
59
+ *
60
+ * When tsconfig has `"jsxImportSource": "@pyreon/core"`, the TS/bundler compiler
61
+ * rewrites JSX to imports from this file automatically:
62
+ * <div class="x" /> → jsx("div", { class: "x" })
63
+ */
64
+ function jsx(type, props, key) {
65
+ const { children, ...rest } = props;
66
+ const propsWithKey = key != null ? {
67
+ ...rest,
68
+ key
69
+ } : rest;
70
+ if (typeof type === "function") return h(type, children !== void 0 ? {
71
+ ...propsWithKey,
72
+ children
73
+ } : propsWithKey);
74
+ return h(type, propsWithKey, ...children === void 0 ? [] : Array.isArray(children) ? children : [children]);
75
+ }
76
+ const jsxs = jsx;
77
+
29
78
  //#endregion
30
79
  //#region src/image.tsx
31
80
  /**
package/lib/image.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"image.js","names":[],"sources":["../src/utils/use-intersection-observer.ts","../src/image.tsx"],"sourcesContent":["import { onMount, onUnmount } from '@pyreon/core'\n\n/**\n * Observes an element and calls `onIntersect` once it enters the viewport.\n * Automatically disconnects after the first intersection.\n *\n * @param getElement - Getter for the target element (may be undefined before mount).\n * @param onIntersect - Callback fired when the element becomes visible.\n * @param rootMargin - IntersectionObserver rootMargin. Default: \"200px\".\n */\nexport function useIntersectionObserver(\n getElement: () => HTMLElement | undefined,\n onIntersect: () => void,\n rootMargin = '200px',\n) {\n onMount(() => {\n const el = getElement()\n if (!el) return undefined\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n onIntersect()\n observer.disconnect()\n }\n }\n },\n { rootMargin },\n )\n\n observer.observe(el)\n onUnmount(() => observer.disconnect())\n return undefined\n })\n}\n","import { createRef } from '@pyreon/core'\nimport { signal } from '@pyreon/reactivity'\nimport type { FormatSource } from './image-plugin'\nimport { useIntersectionObserver } from './utils/use-intersection-observer'\n\n// ─── Image optimization component ───────────────────────────────────────────\n//\n// <Image> provides:\n// - Lazy loading via IntersectionObserver (loads when near viewport)\n// - Automatic width/height to prevent CLS (Cumulative Layout Shift)\n// - Responsive srcset generation from width descriptors\n// - Multi-format support via <picture> (WebP/AVIF with fallback)\n// - Blur-up placeholder while loading\n// - Priority loading for above-the-fold images\n\nexport interface ImageProps {\n /** Image source URL. */\n src: string\n /** Alt text (required for accessibility). */\n alt: string\n /** Intrinsic width of the image. */\n width: number\n /** Intrinsic height of the image. */\n height: number\n /** Responsive sizes attribute. Default: \"100vw\" */\n sizes?: string\n /** Responsive srcset string or source array. */\n srcset?: string | ImageSource[]\n /** Per-format source sets for <picture>. Provided automatically by imagePlugin. */\n formats?: FormatSource[]\n /** Loading strategy. \"lazy\" uses IntersectionObserver, \"eager\" loads immediately. Default: \"lazy\" */\n loading?: 'lazy' | 'eager'\n /** Mark as priority (LCP image). Disables lazy loading, adds fetchpriority=\"high\". */\n priority?: boolean\n /** Low-quality placeholder image URL or base64 data URI for blur-up effect. */\n placeholder?: string\n /** CSS class name. */\n class?: string\n /** Inline styles. */\n style?: string\n /** CSS object-fit. Default: \"cover\" */\n fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'\n /** Decode async. Default: true */\n decoding?: 'sync' | 'async' | 'auto'\n}\n\nexport interface ImageSource {\n src: string\n width: number\n}\n\n/**\n * Optimized image component with lazy loading, responsive images,\n * multi-format <picture> support, and blur-up placeholders.\n *\n * @example\n * // With imagePlugin — spread the import directly\n * import hero from \"./hero.jpg?optimize\"\n * <Image {...hero} alt=\"Hero\" priority />\n *\n * @example\n * // Manual usage\n * <Image src=\"/hero.jpg\" alt=\"Hero\" width={1200} height={630} />\n */\nexport function Image(props: ImageProps) {\n const isEager = props.priority || props.loading === 'eager'\n const loaded = signal(isEager)\n const inView = signal(isEager)\n const containerRef = createRef<HTMLElement>()\n\n // Resolve srcset from string or array\n const resolvedSrcset =\n typeof props.srcset === 'string'\n ? props.srcset\n : props.srcset?.map((s) => `${s.src} ${s.width}w`).join(', ')\n\n const sizes = props.sizes ?? '100vw'\n const fit = props.fit ?? 'cover'\n const hasFormats = props.formats && props.formats.length > 0\n const aspectRatio = `${props.width} / ${props.height}`\n\n if (!isEager) {\n useIntersectionObserver(\n () => containerRef.current ?? undefined,\n () => inView.set(true),\n )\n }\n\n // Static styles (don't depend on signals)\n const containerStyle = [\n 'position: relative',\n 'overflow: hidden',\n `aspect-ratio: ${aspectRatio}`,\n `max-width: ${props.width}px`,\n 'width: 100%',\n props.style,\n ]\n .filter(Boolean)\n .join('; ')\n\n const imgEl = (\n <img\n src={() => (inView() ? props.src : '')}\n srcset={() =>\n !hasFormats && inView() && resolvedSrcset ? resolvedSrcset : ''\n }\n sizes={resolvedSrcset ? sizes : undefined}\n alt={props.alt}\n width={props.width}\n height={props.height}\n loading={isEager ? 'eager' : 'lazy'}\n decoding={props.decoding ?? 'async'}\n fetchpriority={props.priority ? 'high' : undefined}\n onload={() => loaded.set(true)}\n style={() =>\n [\n 'display: block',\n 'width: 100%',\n 'height: 100%',\n `object-fit: ${fit}`,\n 'transition: opacity 0.3s ease',\n props.placeholder && !loaded() ? 'opacity: 0' : 'opacity: 1',\n ].join('; ')\n }\n />\n )\n\n return (\n <div ref={containerRef} class={props.class} style={containerStyle}>\n {props.placeholder && (\n <img\n src={props.placeholder}\n alt=\"\"\n aria-hidden=\"true\"\n loading=\"eager\"\n style={() =>\n [\n 'position: absolute',\n 'inset: 0',\n 'width: 100%',\n 'height: 100%',\n 'object-fit: cover',\n 'filter: blur(20px)',\n 'transform: scale(1.1)',\n 'transition: opacity 0.4s ease',\n loaded() ? 'opacity: 0; pointer-events: none' : 'opacity: 1',\n ].join('; ')\n }\n />\n )}\n {hasFormats ? (\n <picture>\n {props.formats?.map((fmt) => (\n <source\n type={fmt.type}\n srcset={() => (inView() ? fmt.srcset : undefined)}\n sizes={sizes}\n />\n ))}\n {imgEl}\n </picture>\n ) : (\n imgEl\n )}\n </div>\n )\n}\n"],"mappings":";;;;;;;;;;;;;AAUA,SAAgB,wBACd,YACA,aACA,aAAa,SACb;AACA,eAAc;EACZ,MAAM,KAAK,YAAY;AACvB,MAAI,CAAC,GAAI,QAAO;EAEhB,MAAM,WAAW,IAAI,sBAClB,YAAY;AACX,QAAK,MAAM,SAAS,QAClB,KAAI,MAAM,gBAAgB;AACxB,iBAAa;AACb,aAAS,YAAY;;KAI3B,EAAE,YAAY,CACf;AAED,WAAS,QAAQ,GAAG;AACpB,kBAAgB,SAAS,YAAY,CAAC;GAEtC;;;;;;;;;;;;;;;;;;AC8BJ,SAAgB,MAAM,OAAmB;CACvC,MAAM,UAAU,MAAM,YAAY,MAAM,YAAY;CACpD,MAAM,SAAS,OAAO,QAAQ;CAC9B,MAAM,SAAS,OAAO,QAAQ;CAC9B,MAAM,eAAe,WAAwB;CAG7C,MAAM,iBACJ,OAAO,MAAM,WAAW,WACpB,MAAM,SACN,MAAM,QAAQ,KAAK,MAAM,GAAG,EAAE,IAAI,GAAG,EAAE,MAAM,GAAG,CAAC,KAAK,KAAK;CAEjE,MAAM,QAAQ,MAAM,SAAS;CAC7B,MAAM,MAAM,MAAM,OAAO;CACzB,MAAM,aAAa,MAAM,WAAW,MAAM,QAAQ,SAAS;CAC3D,MAAM,cAAc,GAAG,MAAM,MAAM,KAAK,MAAM;AAE9C,KAAI,CAAC,QACH,+BACQ,aAAa,WAAW,cACxB,OAAO,IAAI,KAAK,CACvB;CAIH,MAAM,iBAAiB;EACrB;EACA;EACA,iBAAiB;EACjB,cAAc,MAAM,MAAM;EAC1B;EACA,MAAM;EACP,CACE,OAAO,QAAQ,CACf,KAAK,KAAK;CAEb,MAAM,QACJ,oBAAC,OAAD;EACE,WAAY,QAAQ,GAAG,MAAM,MAAM;EACnC,cACE,CAAC,cAAc,QAAQ,IAAI,iBAAiB,iBAAiB;EAE/D,OAAO,iBAAiB,QAAQ;EAChC,KAAK,MAAM;EACX,OAAO,MAAM;EACb,QAAQ,MAAM;EACd,SAAS,UAAU,UAAU;EAC7B,UAAU,MAAM,YAAY;EAC5B,eAAe,MAAM,WAAW,SAAS;EACzC,cAAc,OAAO,IAAI,KAAK;EAC9B,aACE;GACE;GACA;GACA;GACA,eAAe;GACf;GACA,MAAM,eAAe,CAAC,QAAQ,GAAG,eAAe;GACjD,CAAC,KAAK,KAAK;EAEd;AAGJ,QACE,qBAAC,OAAD;EAAK,KAAK;EAAc,OAAO,MAAM;EAAO,OAAO;YAAnD,CACG,MAAM,eACL,oBAAC,OAAD;GACE,KAAK,MAAM;GACX,KAAI;GACJ,eAAY;GACZ,SAAQ;GACR,aACE;IACE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,QAAQ,GAAG,qCAAqC;IACjD,CAAC,KAAK,KAAK;GAEd,GAEH,aACC,qBAAC,WAAD,aACG,MAAM,SAAS,KAAK,QACnB,oBAAC,UAAD;GACE,MAAM,IAAI;GACV,cAAe,QAAQ,GAAG,IAAI,SAAS;GAChC;GACP,EACF,EACD,MACO,MAEV,MAEE"}
1
+ {"version":3,"file":"image.js","names":[],"sources":["../src/utils/use-intersection-observer.ts","../../../node_modules/.bun/@pyreon+core@0.7.0/node_modules/@pyreon/core/lib/jsx-runtime.js","../src/image.tsx"],"sourcesContent":["import { onMount, onUnmount } from '@pyreon/core'\n\n/**\n * Observes an element and calls `onIntersect` once it enters the viewport.\n * Automatically disconnects after the first intersection.\n *\n * @param getElement - Getter for the target element (may be undefined before mount).\n * @param onIntersect - Callback fired when the element becomes visible.\n * @param rootMargin - IntersectionObserver rootMargin. Default: \"200px\".\n */\nexport function useIntersectionObserver(\n getElement: () => HTMLElement | undefined,\n onIntersect: () => void,\n rootMargin = '200px',\n) {\n onMount(() => {\n const el = getElement()\n if (!el) return undefined\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n onIntersect()\n observer.disconnect()\n }\n }\n },\n { rootMargin },\n )\n\n observer.observe(el)\n onUnmount(() => observer.disconnect())\n return undefined\n })\n}\n","//#region src/h.ts\n/** Marker for fragment nodes — renders children without a wrapper element */\nconst Fragment = Symbol(\"Pyreon.Fragment\");\n/**\n* Hyperscript function — the compiled output of JSX.\n* `<div class=\"x\">hello</div>` → `h(\"div\", { class: \"x\" }, \"hello\")`\n*\n* Generic on P so TypeScript validates props match the component's signature\n* at the call site, then stores the result in the loosely-typed VNode.\n*/\n/** Shared empty props sentinel — identity-checked in mountElement to skip applyProps. */\nconst EMPTY_PROPS = {};\nfunction h(type, props, ...children) {\n\treturn {\n\t\ttype,\n\t\tprops: props ?? EMPTY_PROPS,\n\t\tchildren: normalizeChildren(children),\n\t\tkey: props?.key ?? null\n\t};\n}\nfunction normalizeChildren(children) {\n\tfor (let i = 0; i < children.length; i++) if (Array.isArray(children[i])) return flattenChildren(children);\n\treturn children;\n}\nfunction flattenChildren(children) {\n\tconst result = [];\n\tfor (const child of children) if (Array.isArray(child)) result.push(...flattenChildren(child));\n\telse result.push(child);\n\treturn result;\n}\n\n//#endregion\n//#region src/jsx-runtime.ts\n/**\n* JSX automatic runtime.\n*\n* When tsconfig has `\"jsxImportSource\": \"@pyreon/core\"`, the TS/bundler compiler\n* rewrites JSX to imports from this file automatically:\n* <div class=\"x\" /> → jsx(\"div\", { class: \"x\" })\n*/\nfunction jsx(type, props, key) {\n\tconst { children, ...rest } = props;\n\tconst propsWithKey = key != null ? {\n\t\t...rest,\n\t\tkey\n\t} : rest;\n\tif (typeof type === \"function\") return h(type, children !== void 0 ? {\n\t\t...propsWithKey,\n\t\tchildren\n\t} : propsWithKey);\n\treturn h(type, propsWithKey, ...children === void 0 ? [] : Array.isArray(children) ? children : [children]);\n}\nconst jsxs = jsx;\n\n//#endregion\nexport { Fragment, jsx, jsxs };\n//# sourceMappingURL=jsx-runtime.js.map","import { createRef } from '@pyreon/core'\nimport { signal } from '@pyreon/reactivity'\nimport type { FormatSource } from './image-plugin'\nimport { useIntersectionObserver } from './utils/use-intersection-observer'\n\n// ─── Image optimization component ───────────────────────────────────────────\n//\n// <Image> provides:\n// - Lazy loading via IntersectionObserver (loads when near viewport)\n// - Automatic width/height to prevent CLS (Cumulative Layout Shift)\n// - Responsive srcset generation from width descriptors\n// - Multi-format support via <picture> (WebP/AVIF with fallback)\n// - Blur-up placeholder while loading\n// - Priority loading for above-the-fold images\n\nexport interface ImageProps {\n /** Image source URL. */\n src: string\n /** Alt text (required for accessibility). */\n alt: string\n /** Intrinsic width of the image. */\n width: number\n /** Intrinsic height of the image. */\n height: number\n /** Responsive sizes attribute. Default: \"100vw\" */\n sizes?: string\n /** Responsive srcset string or source array. */\n srcset?: string | ImageSource[]\n /** Per-format source sets for <picture>. Provided automatically by imagePlugin. */\n formats?: FormatSource[]\n /** Loading strategy. \"lazy\" uses IntersectionObserver, \"eager\" loads immediately. Default: \"lazy\" */\n loading?: 'lazy' | 'eager'\n /** Mark as priority (LCP image). Disables lazy loading, adds fetchpriority=\"high\". */\n priority?: boolean\n /** Low-quality placeholder image URL or base64 data URI for blur-up effect. */\n placeholder?: string\n /** CSS class name. */\n class?: string\n /** Inline styles. */\n style?: string\n /** CSS object-fit. Default: \"cover\" */\n fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'\n /** Decode async. Default: true */\n decoding?: 'sync' | 'async' | 'auto'\n}\n\nexport interface ImageSource {\n src: string\n width: number\n}\n\n/**\n * Optimized image component with lazy loading, responsive images,\n * multi-format <picture> support, and blur-up placeholders.\n *\n * @example\n * // With imagePlugin — spread the import directly\n * import hero from \"./hero.jpg?optimize\"\n * <Image {...hero} alt=\"Hero\" priority />\n *\n * @example\n * // Manual usage\n * <Image src=\"/hero.jpg\" alt=\"Hero\" width={1200} height={630} />\n */\nexport function Image(props: ImageProps) {\n const isEager = props.priority || props.loading === 'eager'\n const loaded = signal(isEager)\n const inView = signal(isEager)\n const containerRef = createRef<HTMLElement>()\n\n // Resolve srcset from string or array\n const resolvedSrcset =\n typeof props.srcset === 'string'\n ? props.srcset\n : props.srcset?.map((s) => `${s.src} ${s.width}w`).join(', ')\n\n const sizes = props.sizes ?? '100vw'\n const fit = props.fit ?? 'cover'\n const hasFormats = props.formats && props.formats.length > 0\n const aspectRatio = `${props.width} / ${props.height}`\n\n if (!isEager) {\n useIntersectionObserver(\n () => containerRef.current ?? undefined,\n () => inView.set(true),\n )\n }\n\n // Static styles (don't depend on signals)\n const containerStyle = [\n 'position: relative',\n 'overflow: hidden',\n `aspect-ratio: ${aspectRatio}`,\n `max-width: ${props.width}px`,\n 'width: 100%',\n props.style,\n ]\n .filter(Boolean)\n .join('; ')\n\n const imgEl = (\n <img\n src={() => (inView() ? props.src : '')}\n srcset={() =>\n !hasFormats && inView() && resolvedSrcset ? resolvedSrcset : ''\n }\n sizes={resolvedSrcset ? sizes : undefined}\n alt={props.alt}\n width={props.width}\n height={props.height}\n loading={isEager ? 'eager' : 'lazy'}\n decoding={props.decoding ?? 'async'}\n fetchpriority={props.priority ? 'high' : undefined}\n onload={() => loaded.set(true)}\n style={() =>\n [\n 'display: block',\n 'width: 100%',\n 'height: 100%',\n `object-fit: ${fit}`,\n 'transition: opacity 0.3s ease',\n props.placeholder && !loaded() ? 'opacity: 0' : 'opacity: 1',\n ].join('; ')\n }\n />\n )\n\n return (\n <div ref={containerRef} class={props.class} style={containerStyle}>\n {props.placeholder && (\n <img\n src={props.placeholder}\n alt=\"\"\n aria-hidden=\"true\"\n loading=\"eager\"\n style={() =>\n [\n 'position: absolute',\n 'inset: 0',\n 'width: 100%',\n 'height: 100%',\n 'object-fit: cover',\n 'filter: blur(20px)',\n 'transform: scale(1.1)',\n 'transition: opacity 0.4s ease',\n loaded() ? 'opacity: 0; pointer-events: none' : 'opacity: 1',\n ].join('; ')\n }\n />\n )}\n {hasFormats ? (\n <picture>\n {props.formats?.map((fmt) => (\n <source\n type={fmt.type}\n srcset={() => (inView() ? fmt.srcset : undefined)}\n sizes={sizes}\n />\n ))}\n {imgEl}\n </picture>\n ) : (\n imgEl\n )}\n </div>\n )\n}\n"],"x_google_ignoreList":[1],"mappings":"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"}