@pyreon/zero 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,35 +29,35 @@ bun add @pyreon/zero
29
29
 
30
30
  ```ts
31
31
  // vite.config.ts
32
- import pyreon from "@pyreon/vite-plugin"
33
- import zero from "@pyreon/zero"
32
+ import pyreon from '@pyreon/vite-plugin'
33
+ import zero from '@pyreon/zero'
34
34
 
35
35
  export default {
36
- plugins: [pyreon(), zero({ mode: "ssr", ssr: { mode: "stream" } })],
36
+ plugins: [pyreon(), zero({ mode: 'ssr', ssr: { mode: 'stream' } })],
37
37
  }
38
38
  ```
39
39
 
40
40
  ## Subpath Exports
41
41
 
42
- | Export | Description |
43
- | --- | --- |
44
- | `@pyreon/zero` | Vite plugin, config, adapters, components, middleware |
45
- | `@pyreon/zero/client` | Client-side entry (`startClient`) |
46
- | `@pyreon/zero/config` | `defineConfig`, `resolveConfig` |
47
- | `@pyreon/zero/image` | `<Image>` component |
48
- | `@pyreon/zero/link` | `<Link>`, `useLink`, `createLink` |
49
- | `@pyreon/zero/script` | `<Script>` component |
50
- | `@pyreon/zero/theme` | Theme system and `<ThemeToggle>` |
51
- | `@pyreon/zero/font` | Font optimization plugin |
52
- | `@pyreon/zero/cache` | Cache and security middleware |
53
- | `@pyreon/zero/seo` | SEO plugin, sitemap, robots.txt |
54
- | `@pyreon/zero/image-plugin` | Image optimization Vite plugin |
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 |
42
+ | Export | Description |
43
+ | --------------------------- | ----------------------------------------------------- |
44
+ | `@pyreon/zero` | Vite plugin, config, adapters, components, middleware |
45
+ | `@pyreon/zero/client` | Client-side entry (`startClient`) |
46
+ | `@pyreon/zero/config` | `defineConfig`, `resolveConfig` |
47
+ | `@pyreon/zero/image` | `<Image>` component |
48
+ | `@pyreon/zero/link` | `<Link>`, `useLink`, `createLink` |
49
+ | `@pyreon/zero/script` | `<Script>` component |
50
+ | `@pyreon/zero/theme` | Theme system and `<ThemeToggle>` |
51
+ | `@pyreon/zero/font` | Font optimization plugin |
52
+ | `@pyreon/zero/cache` | Cache and security middleware |
53
+ | `@pyreon/zero/seo` | SEO plugin, sitemap, robots.txt |
54
+ | `@pyreon/zero/image-plugin` | Image optimization Vite plugin |
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 |
61
61
 
62
62
  ## License
63
63
 
package/lib/cache.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cache.js","names":[],"sources":["../src/cache.ts"],"sourcesContent":["import type { Middleware, MiddlewareContext } from \"@pyreon/server\"\n\n// ─── Cache control middleware ───────────────────────────────────────────────\n//\n// Smart caching middleware that sets appropriate cache headers based on\n// asset type, URL patterns, and build hashes.\n//\n// Strategies:\n// - Immutable: hashed assets (JS/CSS bundles) — cached forever\n// - Static: images, fonts, media — long cache with revalidation\n// - Dynamic: HTML pages — short or no cache, stale-while-revalidate\n// - API: JSON responses — no cache by default\n\nexport interface CacheConfig {\n /** Cache duration for immutable hashed assets (seconds). Default: 31536000 (1 year) */\n immutable?: number\n /** Cache duration for static assets like images/fonts (seconds). Default: 86400 (1 day) */\n static?: number\n /** Cache duration for pages (seconds). Default: 0 (no cache) */\n pages?: number\n /** Stale-while-revalidate window for pages (seconds). Default: 60 */\n staleWhileRevalidate?: number\n /** Custom rules by URL pattern. */\n rules?: CacheRule[]\n}\n\nexport interface CacheRule {\n /** URL pattern to match (glob-style). e.g. \"/api/*\" */\n match: string\n /** Cache-Control header value. */\n control: string\n}\n\nconst HASHED_ASSET = /\\.[a-f0-9]{8,}\\.\\w+$/\nconst STATIC_EXT = /\\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm|ogg|mp3|wav)$/i\nconst SCRIPT_EXT = /\\.(js|css|mjs)$/i\n\n/** @internal Exported for testing */\nexport function matchGlob(pattern: string, path: string): boolean {\n // Escape regex special chars, then convert glob wildcards\n const escaped = pattern.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\")\n const regex = escaped.replace(/\\*/g, \".*\").replace(/\\?/g, \".\")\n return new RegExp(`^${regex}$`).test(path)\n}\n\nfunction resolveControl(\n path: string,\n immutableDuration: number,\n staticDuration: number,\n pageDuration: number,\n swr: number,\n): string {\n if (HASHED_ASSET.test(path)) {\n return `public, max-age=${immutableDuration}, immutable`\n }\n if (SCRIPT_EXT.test(path)) {\n return `public, max-age=3600, stale-while-revalidate=${swr}`\n }\n if (STATIC_EXT.test(path)) {\n return `public, max-age=${staticDuration}, stale-while-revalidate=${swr}`\n }\n if (pageDuration > 0) {\n return `public, max-age=${pageDuration}, stale-while-revalidate=${swr}`\n }\n return \"no-cache\"\n}\n\n/**\n * Cache control middleware for Zero.\n * Sets Cache-Control headers on the response based on asset type.\n *\n * @example\n * import { cacheMiddleware } from \"@pyreon/zero/cache\"\n *\n * export default createHandler({\n * routes,\n * middleware: [\n * cacheMiddleware({\n * pages: 60,\n * staleWhileRevalidate: 300,\n * rules: [\n * { match: \"/api/*\", control: \"no-store\" },\n * ],\n * }),\n * ],\n * })\n */\nexport function cacheMiddleware(config: CacheConfig = {}): Middleware {\n const immutableDuration = config.immutable ?? 31536000\n const staticDuration = config.static ?? 86400\n const pageDuration = config.pages ?? 0\n const swr = config.staleWhileRevalidate ?? 60\n const rules = config.rules ?? []\n\n return (ctx: MiddlewareContext) => {\n const path = ctx.url.pathname\n\n for (const rule of rules) {\n if (matchGlob(rule.match, path)) {\n ctx.headers.set(\"Cache-Control\", rule.control)\n return\n }\n }\n\n const control = resolveControl(path, immutableDuration, staticDuration, pageDuration, swr)\n ctx.headers.set(\"Cache-Control\", control)\n }\n}\n\n/**\n * Security headers middleware.\n * Adds common security headers to all responses.\n */\nexport function securityHeaders(): Middleware {\n return (ctx: MiddlewareContext) => {\n ctx.headers.set(\"X-Content-Type-Options\", \"nosniff\")\n ctx.headers.set(\"X-Frame-Options\", \"DENY\")\n ctx.headers.set(\"X-XSS-Protection\", \"1; mode=block\")\n ctx.headers.set(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n ctx.headers.set(\"Permissions-Policy\", \"camera=(), microphone=(), geolocation=()\")\n }\n}\n\n/**\n * Compression detection middleware.\n * Sets Vary: Accept-Encoding header so caches can serve compressed variants.\n * Actual compression is handled by the runtime (Bun/Node) or reverse proxy.\n */\nexport function varyEncoding(): Middleware {\n return (ctx: MiddlewareContext) => {\n const existing = ctx.headers.get(\"Vary\")\n if (!existing?.includes(\"Accept-Encoding\")) {\n ctx.headers.set(\"Vary\", existing ? `${existing}, Accept-Encoding` : \"Accept-Encoding\")\n }\n }\n}\n"],"mappings":";AAiCA,MAAM,eAAe;AACrB,MAAM,aAAa;AACnB,MAAM,aAAa;;AAGnB,SAAgB,UAAU,SAAiB,MAAuB;CAGhE,MAAM,QADU,QAAQ,QAAQ,qBAAqB,OAAO,CACtC,QAAQ,OAAO,KAAK,CAAC,QAAQ,OAAO,IAAI;AAC9D,QAAO,IAAI,OAAO,IAAI,MAAM,GAAG,CAAC,KAAK,KAAK;;AAG5C,SAAS,eACP,MACA,mBACA,gBACA,cACA,KACQ;AACR,KAAI,aAAa,KAAK,KAAK,CACzB,QAAO,mBAAmB,kBAAkB;AAE9C,KAAI,WAAW,KAAK,KAAK,CACvB,QAAO,gDAAgD;AAEzD,KAAI,WAAW,KAAK,KAAK,CACvB,QAAO,mBAAmB,eAAe,2BAA2B;AAEtE,KAAI,eAAe,EACjB,QAAO,mBAAmB,aAAa,2BAA2B;AAEpE,QAAO;;;;;;;;;;;;;;;;;;;;;;AAuBT,SAAgB,gBAAgB,SAAsB,EAAE,EAAc;CACpE,MAAM,oBAAoB,OAAO,aAAa;CAC9C,MAAM,iBAAiB,OAAO,UAAU;CACxC,MAAM,eAAe,OAAO,SAAS;CACrC,MAAM,MAAM,OAAO,wBAAwB;CAC3C,MAAM,QAAQ,OAAO,SAAS,EAAE;AAEhC,SAAQ,QAA2B;EACjC,MAAM,OAAO,IAAI,IAAI;AAErB,OAAK,MAAM,QAAQ,MACjB,KAAI,UAAU,KAAK,OAAO,KAAK,EAAE;AAC/B,OAAI,QAAQ,IAAI,iBAAiB,KAAK,QAAQ;AAC9C;;EAIJ,MAAM,UAAU,eAAe,MAAM,mBAAmB,gBAAgB,cAAc,IAAI;AAC1F,MAAI,QAAQ,IAAI,iBAAiB,QAAQ;;;;;;;AAQ7C,SAAgB,kBAA8B;AAC5C,SAAQ,QAA2B;AACjC,MAAI,QAAQ,IAAI,0BAA0B,UAAU;AACpD,MAAI,QAAQ,IAAI,mBAAmB,OAAO;AAC1C,MAAI,QAAQ,IAAI,oBAAoB,gBAAgB;AACpD,MAAI,QAAQ,IAAI,mBAAmB,kCAAkC;AACrE,MAAI,QAAQ,IAAI,sBAAsB,2CAA2C;;;;;;;;AASrF,SAAgB,eAA2B;AACzC,SAAQ,QAA2B;EACjC,MAAM,WAAW,IAAI,QAAQ,IAAI,OAAO;AACxC,MAAI,CAAC,UAAU,SAAS,kBAAkB,CACxC,KAAI,QAAQ,IAAI,QAAQ,WAAW,GAAG,SAAS,qBAAqB,kBAAkB"}
1
+ {"version":3,"file":"cache.js","names":[],"sources":["../src/cache.ts"],"sourcesContent":["import type { Middleware, MiddlewareContext } from '@pyreon/server'\n\n// ─── Cache control middleware ───────────────────────────────────────────────\n//\n// Smart caching middleware that sets appropriate cache headers based on\n// asset type, URL patterns, and build hashes.\n//\n// Strategies:\n// - Immutable: hashed assets (JS/CSS bundles) — cached forever\n// - Static: images, fonts, media — long cache with revalidation\n// - Dynamic: HTML pages — short or no cache, stale-while-revalidate\n// - API: JSON responses — no cache by default\n\nexport interface CacheConfig {\n /** Cache duration for immutable hashed assets (seconds). Default: 31536000 (1 year) */\n immutable?: number\n /** Cache duration for static assets like images/fonts (seconds). Default: 86400 (1 day) */\n static?: number\n /** Cache duration for pages (seconds). Default: 0 (no cache) */\n pages?: number\n /** Stale-while-revalidate window for pages (seconds). Default: 60 */\n staleWhileRevalidate?: number\n /** Custom rules by URL pattern. */\n rules?: CacheRule[]\n}\n\nexport interface CacheRule {\n /** URL pattern to match (glob-style). e.g. \"/api/*\" */\n match: string\n /** Cache-Control header value. */\n control: string\n}\n\nconst HASHED_ASSET = /\\.[a-f0-9]{8,}\\.\\w+$/\nconst STATIC_EXT = /\\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm|ogg|mp3|wav)$/i\nconst SCRIPT_EXT = /\\.(js|css|mjs)$/i\n\n/** @internal Exported for testing */\nexport function matchGlob(pattern: string, path: string): boolean {\n // Escape regex special chars, then convert glob wildcards\n const escaped = pattern.replace(/[.+^${}()|[\\]\\\\]/g, '\\\\$&')\n const regex = escaped.replace(/\\*/g, '.*').replace(/\\?/g, '.')\n return new RegExp(`^${regex}$`).test(path)\n}\n\nfunction resolveControl(\n path: string,\n immutableDuration: number,\n staticDuration: number,\n pageDuration: number,\n swr: number,\n): string {\n if (HASHED_ASSET.test(path)) {\n return `public, max-age=${immutableDuration}, immutable`\n }\n if (SCRIPT_EXT.test(path)) {\n return `public, max-age=3600, stale-while-revalidate=${swr}`\n }\n if (STATIC_EXT.test(path)) {\n return `public, max-age=${staticDuration}, stale-while-revalidate=${swr}`\n }\n if (pageDuration > 0) {\n return `public, max-age=${pageDuration}, stale-while-revalidate=${swr}`\n }\n return 'no-cache'\n}\n\n/**\n * Cache control middleware for Zero.\n * Sets Cache-Control headers on the response based on asset type.\n *\n * @example\n * import { cacheMiddleware } from \"@pyreon/zero/cache\"\n *\n * export default createHandler({\n * routes,\n * middleware: [\n * cacheMiddleware({\n * pages: 60,\n * staleWhileRevalidate: 300,\n * rules: [\n * { match: \"/api/*\", control: \"no-store\" },\n * ],\n * }),\n * ],\n * })\n */\nexport function cacheMiddleware(config: CacheConfig = {}): Middleware {\n const immutableDuration = config.immutable ?? 31536000\n const staticDuration = config.static ?? 86400\n const pageDuration = config.pages ?? 0\n const swr = config.staleWhileRevalidate ?? 60\n const rules = config.rules ?? []\n\n return (ctx: MiddlewareContext) => {\n const path = ctx.url.pathname\n\n for (const rule of rules) {\n if (matchGlob(rule.match, path)) {\n ctx.headers.set('Cache-Control', rule.control)\n return\n }\n }\n\n const control = resolveControl(path, immutableDuration, staticDuration, pageDuration, swr)\n ctx.headers.set('Cache-Control', control)\n }\n}\n\n/**\n * Security headers middleware.\n * Adds common security headers to all responses.\n */\nexport function securityHeaders(): Middleware {\n return (ctx: MiddlewareContext) => {\n ctx.headers.set('X-Content-Type-Options', 'nosniff')\n ctx.headers.set('X-Frame-Options', 'DENY')\n ctx.headers.set('X-XSS-Protection', '1; mode=block')\n ctx.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')\n ctx.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')\n }\n}\n\n/**\n * Compression detection middleware.\n * Sets Vary: Accept-Encoding header so caches can serve compressed variants.\n * Actual compression is handled by the runtime (Bun/Node) or reverse proxy.\n */\nexport function varyEncoding(): Middleware {\n return (ctx: MiddlewareContext) => {\n const existing = ctx.headers.get('Vary')\n if (!existing?.includes('Accept-Encoding')) {\n ctx.headers.set('Vary', existing ? `${existing}, Accept-Encoding` : 'Accept-Encoding')\n }\n }\n}\n"],"mappings":";AAiCA,MAAM,eAAe;AACrB,MAAM,aAAa;AACnB,MAAM,aAAa;;AAGnB,SAAgB,UAAU,SAAiB,MAAuB;CAGhE,MAAM,QADU,QAAQ,QAAQ,qBAAqB,OAAO,CACtC,QAAQ,OAAO,KAAK,CAAC,QAAQ,OAAO,IAAI;AAC9D,QAAO,IAAI,OAAO,IAAI,MAAM,GAAG,CAAC,KAAK,KAAK;;AAG5C,SAAS,eACP,MACA,mBACA,gBACA,cACA,KACQ;AACR,KAAI,aAAa,KAAK,KAAK,CACzB,QAAO,mBAAmB,kBAAkB;AAE9C,KAAI,WAAW,KAAK,KAAK,CACvB,QAAO,gDAAgD;AAEzD,KAAI,WAAW,KAAK,KAAK,CACvB,QAAO,mBAAmB,eAAe,2BAA2B;AAEtE,KAAI,eAAe,EACjB,QAAO,mBAAmB,aAAa,2BAA2B;AAEpE,QAAO;;;;;;;;;;;;;;;;;;;;;;AAuBT,SAAgB,gBAAgB,SAAsB,EAAE,EAAc;CACpE,MAAM,oBAAoB,OAAO,aAAa;CAC9C,MAAM,iBAAiB,OAAO,UAAU;CACxC,MAAM,eAAe,OAAO,SAAS;CACrC,MAAM,MAAM,OAAO,wBAAwB;CAC3C,MAAM,QAAQ,OAAO,SAAS,EAAE;AAEhC,SAAQ,QAA2B;EACjC,MAAM,OAAO,IAAI,IAAI;AAErB,OAAK,MAAM,QAAQ,MACjB,KAAI,UAAU,KAAK,OAAO,KAAK,EAAE;AAC/B,OAAI,QAAQ,IAAI,iBAAiB,KAAK,QAAQ;AAC9C;;EAIJ,MAAM,UAAU,eAAe,MAAM,mBAAmB,gBAAgB,cAAc,IAAI;AAC1F,MAAI,QAAQ,IAAI,iBAAiB,QAAQ;;;;;;;AAQ7C,SAAgB,kBAA8B;AAC5C,SAAQ,QAA2B;AACjC,MAAI,QAAQ,IAAI,0BAA0B,UAAU;AACpD,MAAI,QAAQ,IAAI,mBAAmB,OAAO;AAC1C,MAAI,QAAQ,IAAI,oBAAoB,gBAAgB;AACpD,MAAI,QAAQ,IAAI,mBAAmB,kCAAkC;AACrE,MAAI,QAAQ,IAAI,sBAAsB,2CAA2C;;;;;;;;AASrF,SAAgB,eAA2B;AACzC,SAAQ,QAA2B;EACjC,MAAM,WAAW,IAAI,QAAQ,IAAI,OAAO;AACxC,MAAI,CAAC,UAAU,SAAS,kBAAkB,CACxC,KAAI,QAAQ,IAAI,QAAQ,WAAW,GAAG,SAAS,qBAAqB,kBAAkB"}
package/lib/client.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":[],"sources":["../src/app.ts","../src/client.ts"],"sourcesContent":["import type { ComponentFn, Props } from \"@pyreon/core\"\nimport { Fragment, h } from \"@pyreon/core\"\nimport { HeadProvider } from \"@pyreon/head\"\nimport type { RouteRecord } from \"@pyreon/router\"\nimport { createRouter, RouterProvider, RouterView } from \"@pyreon/router\"\n\n// ─── App assembly ────────────────────────────────────────────────────────────\n\nexport interface CreateAppOptions {\n /** Route definitions (from file-based routing or manual). */\n routes: RouteRecord[]\n\n /** Router mode. Default: \"history\" for SSR, \"hash\" for SPA. */\n routerMode?: \"hash\" | \"history\"\n\n /** Initial URL for SSR. */\n url?: string\n\n /** Root layout component wrapping all routes. */\n layout?: ComponentFn\n\n /** Global error component. */\n errorComponent?: ComponentFn\n}\n\n/**\n * Create a full Zero app — assembles router, head provider, and root layout.\n *\n * Used internally by entry-server and entry-client.\n */\nexport function createApp(options: CreateAppOptions) {\n const router = createRouter({\n routes: options.routes,\n mode: options.routerMode ?? \"history\",\n ...(options.url ? { url: options.url } : {}),\n scrollBehavior: \"top\",\n })\n\n const Layout = options.layout ?? DefaultLayout\n\n function App() {\n return h(\n HeadProvider,\n null,\n h(\n RouterProvider as ComponentFn<Props>,\n { router },\n h(Layout, null, h(RouterView as ComponentFn<Props>, null)),\n ),\n )\n }\n\n return { App, router }\n}\n\nfunction DefaultLayout(props: Props) {\n return h(Fragment, null, ...(Array.isArray(props.children) ? props.children : [props.children]))\n}\n","import type { ComponentFn } from \"@pyreon/core\"\nimport { h } from \"@pyreon/core\"\nimport type { RouteRecord } from \"@pyreon/router\"\nimport { hydrateRoot, mount } from \"@pyreon/runtime-dom\"\nimport { createApp } from \"./app\"\n\n// ─── Client entry factory ───────────────────────────────────────────────────\n\nexport interface StartClientOptions {\n /** Route definitions. */\n routes: RouteRecord[]\n /** Root layout component. */\n layout?: ComponentFn\n}\n\n/**\n * Start the client-side app — hydrates SSR content or mounts fresh for SPA.\n *\n * @example\n * import { routes } from \"virtual:zero/routes\"\n * import { startClient } from \"@pyreon/zero/client\"\n *\n * startClient({ routes })\n */\nexport function startClient(options: StartClientOptions) {\n const container = document.getElementById(\"app\")\n if (!container) throw new Error(\"[zero] Missing #app container element\")\n\n const { App } = createApp({\n routes: options.routes,\n routerMode: \"history\",\n ...(options.layout ? { layout: options.layout } : {}),\n })\n\n const vnode = h(App, null)\n\n // If container has SSR content, hydrate. Otherwise mount fresh.\n if (container.childNodes.length > 0) {\n return hydrateRoot(container, vnode)\n }\n\n return mount(vnode, container)\n}\n"],"mappings":";;;;;;;;;;;AA8BA,SAAgB,UAAU,SAA2B;CACnD,MAAM,SAAS,aAAa;EAC1B,QAAQ,QAAQ;EAChB,MAAM,QAAQ,cAAc;EAC5B,GAAI,QAAQ,MAAM,EAAE,KAAK,QAAQ,KAAK,GAAG,EAAE;EAC3C,gBAAgB;EACjB,CAAC;CAEF,MAAM,SAAS,QAAQ,UAAU;CAEjC,SAAS,MAAM;AACb,SAAO,EACL,cACA,MACA,EACE,gBACA,EAAE,QAAQ,EACV,EAAE,QAAQ,MAAM,EAAE,YAAkC,KAAK,CAAC,CAC3D,CACF;;AAGH,QAAO;EAAE;EAAK;EAAQ;;AAGxB,SAAS,cAAc,OAAc;AACnC,QAAO,EAAE,UAAU,MAAM,GAAI,MAAM,QAAQ,MAAM,SAAS,GAAG,MAAM,WAAW,CAAC,MAAM,SAAS,CAAE;;;;;;;;;;;;;;AChClG,SAAgB,YAAY,SAA6B;CACvD,MAAM,YAAY,SAAS,eAAe,MAAM;AAChD,KAAI,CAAC,UAAW,OAAM,IAAI,MAAM,wCAAwC;CAExE,MAAM,EAAE,QAAQ,UAAU;EACxB,QAAQ,QAAQ;EAChB,YAAY;EACZ,GAAI,QAAQ,SAAS,EAAE,QAAQ,QAAQ,QAAQ,GAAG,EAAE;EACrD,CAAC;CAEF,MAAM,QAAQ,EAAE,KAAK,KAAK;AAG1B,KAAI,UAAU,WAAW,SAAS,EAChC,QAAO,YAAY,WAAW,MAAM;AAGtC,QAAO,MAAM,OAAO,UAAU"}
1
+ {"version":3,"file":"client.js","names":[],"sources":["../src/app.ts","../src/client.ts"],"sourcesContent":["import type { ComponentFn, Props } from '@pyreon/core'\nimport { Fragment, h } from '@pyreon/core'\nimport { HeadProvider } from '@pyreon/head'\nimport type { RouteRecord } from '@pyreon/router'\nimport { createRouter, RouterProvider, RouterView } from '@pyreon/router'\n\n// ─── App assembly ────────────────────────────────────────────────────────────\n\nexport interface CreateAppOptions {\n /** Route definitions (from file-based routing or manual). */\n routes: RouteRecord[]\n\n /** Router mode. Default: \"history\" for SSR, \"hash\" for SPA. */\n routerMode?: 'hash' | 'history'\n\n /** Initial URL for SSR. */\n url?: string\n\n /** Root layout component wrapping all routes. */\n layout?: ComponentFn\n\n /** Global error component. */\n errorComponent?: ComponentFn\n}\n\n/**\n * Create a full Zero app — assembles router, head provider, and root layout.\n *\n * Used internally by entry-server and entry-client.\n */\nexport function createApp(options: CreateAppOptions) {\n const router = createRouter({\n routes: options.routes,\n mode: options.routerMode ?? 'history',\n ...(options.url ? { url: options.url } : {}),\n scrollBehavior: 'top',\n })\n\n const Layout = options.layout ?? DefaultLayout\n\n function App() {\n return h(\n HeadProvider,\n null,\n h(\n RouterProvider as ComponentFn<Props>,\n { router },\n h(Layout, null, h(RouterView as ComponentFn<Props>, null)),\n ),\n )\n }\n\n return { App, router }\n}\n\nfunction DefaultLayout(props: Props) {\n return h(Fragment, null, ...(Array.isArray(props.children) ? props.children : [props.children]))\n}\n","import type { ComponentFn } from '@pyreon/core'\nimport { h } from '@pyreon/core'\nimport type { RouteRecord } from '@pyreon/router'\nimport { hydrateRoot, mount } from '@pyreon/runtime-dom'\nimport { createApp } from './app'\n\n// ─── Client entry factory ───────────────────────────────────────────────────\n\nexport interface StartClientOptions {\n /** Route definitions. */\n routes: RouteRecord[]\n /** Root layout component. */\n layout?: ComponentFn\n}\n\n/**\n * Start the client-side app — hydrates SSR content or mounts fresh for SPA.\n *\n * @example\n * import { routes } from \"virtual:zero/routes\"\n * import { startClient } from \"@pyreon/zero/client\"\n *\n * startClient({ routes })\n */\nexport function startClient(options: StartClientOptions) {\n const container = document.getElementById('app')\n if (!container) throw new Error('[zero] Missing #app container element')\n\n const { App } = createApp({\n routes: options.routes,\n routerMode: 'history',\n ...(options.layout ? { layout: options.layout } : {}),\n })\n\n const vnode = h(App, null)\n\n // If container has SSR content, hydrate. Otherwise mount fresh.\n if (container.childNodes.length > 0) {\n return hydrateRoot(container, vnode)\n }\n\n return mount(vnode, container)\n}\n"],"mappings":";;;;;;;;;;;AA8BA,SAAgB,UAAU,SAA2B;CACnD,MAAM,SAAS,aAAa;EAC1B,QAAQ,QAAQ;EAChB,MAAM,QAAQ,cAAc;EAC5B,GAAI,QAAQ,MAAM,EAAE,KAAK,QAAQ,KAAK,GAAG,EAAE;EAC3C,gBAAgB;EACjB,CAAC;CAEF,MAAM,SAAS,QAAQ,UAAU;CAEjC,SAAS,MAAM;AACb,SAAO,EACL,cACA,MACA,EACE,gBACA,EAAE,QAAQ,EACV,EAAE,QAAQ,MAAM,EAAE,YAAkC,KAAK,CAAC,CAC3D,CACF;;AAGH,QAAO;EAAE;EAAK;EAAQ;;AAGxB,SAAS,cAAc,OAAc;AACnC,QAAO,EAAE,UAAU,MAAM,GAAI,MAAM,QAAQ,MAAM,SAAS,GAAG,MAAM,WAAW,CAAC,MAAM,SAAS,CAAE;;;;;;;;;;;;;;AChClG,SAAgB,YAAY,SAA6B;CACvD,MAAM,YAAY,SAAS,eAAe,MAAM;AAChD,KAAI,CAAC,UAAW,OAAM,IAAI,MAAM,wCAAwC;CAExE,MAAM,EAAE,QAAQ,UAAU;EACxB,QAAQ,QAAQ;EAChB,YAAY;EACZ,GAAI,QAAQ,SAAS,EAAE,QAAQ,QAAQ,QAAQ,GAAG,EAAE;EACrD,CAAC;CAEF,MAAM,QAAQ,EAAE,KAAK,KAAK;AAG1B,KAAI,UAAU,WAAW,SAAS,EAChC,QAAO,YAAY,WAAW,MAAM;AAGtC,QAAO,MAAM,OAAO,UAAU"}
package/lib/config.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","names":[],"sources":["../src/config.ts"],"sourcesContent":["import type { ZeroConfig } from \"./types\"\n\n/**\n * Define a Zero configuration.\n * Used in `zero.config.ts` at the project root.\n *\n * @example\n * import { defineConfig } from \"@pyreon/zero/config\"\n *\n * export default defineConfig({\n * mode: \"ssr\",\n * ssr: { mode: \"stream\" },\n * port: 3000,\n * })\n */\nexport function defineConfig(config: ZeroConfig): ZeroConfig {\n return config\n}\n\n/** Merge user config with defaults. */\nexport function resolveConfig(\n userConfig: ZeroConfig = {},\n): Required<Pick<ZeroConfig, \"mode\" | \"base\" | \"port\" | \"adapter\">> & ZeroConfig {\n return {\n mode: \"ssr\",\n base: \"/\",\n port: 3000,\n adapter: \"node\",\n ...userConfig,\n ssr: {\n mode: \"string\",\n ...userConfig.ssr,\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,aAAa,QAAgC;AAC3D,QAAO;;;AAIT,SAAgB,cACd,aAAyB,EAAE,EACoD;AAC/E,QAAO;EACL,MAAM;EACN,MAAM;EACN,MAAM;EACN,SAAS;EACT,GAAG;EACH,KAAK;GACH,MAAM;GACN,GAAG,WAAW;GACf;EACF"}
1
+ {"version":3,"file":"config.js","names":[],"sources":["../src/config.ts"],"sourcesContent":["import type { ZeroConfig } from './types'\n\n/**\n * Define a Zero configuration.\n * Used in `zero.config.ts` at the project root.\n *\n * @example\n * import { defineConfig } from \"@pyreon/zero/config\"\n *\n * export default defineConfig({\n * mode: \"ssr\",\n * ssr: { mode: \"stream\" },\n * port: 3000,\n * })\n */\nexport function defineConfig(config: ZeroConfig): ZeroConfig {\n return config\n}\n\n/** Merge user config with defaults. */\nexport function resolveConfig(\n userConfig: ZeroConfig = {},\n): Required<Pick<ZeroConfig, 'mode' | 'base' | 'port' | 'adapter'>> & ZeroConfig {\n return {\n mode: 'ssr',\n base: '/',\n port: 3000,\n adapter: 'node',\n ...userConfig,\n ssr: {\n mode: 'string',\n ...userConfig.ssr,\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,aAAa,QAAgC;AAC3D,QAAO;;;AAIT,SAAgB,cACd,aAAyB,EAAE,EACoD;AAC/E,QAAO;EACL,MAAM;EACN,MAAM;EACN,MAAM;EACN,SAAS;EACT,GAAG;EACH,KAAK;GACH,MAAM;GACN,GAAG,WAAW;GACf;EACF"}
package/lib/font.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"font.js","names":[],"sources":["../src/font.ts"],"sourcesContent":["import type { Plugin } from \"vite\"\n\n// ─── Font optimization ──────────────────────────────────────────────────────\n//\n// Zero provides automatic font optimization:\n// - Downloads and self-hosts Google Fonts at build time (privacy + performance)\n// - Falls back to CDN link in dev mode (for fast dev startup)\n// - Injects preconnect/preload hints into the HTML\n// - Sets font-display: swap to prevent FOIT (Flash of Invisible Text)\n// - Generates optimized @font-face declarations\n// - Size-adjusted fallback fonts to reduce CLS\n\nexport interface FontConfig {\n /**\n * Google Fonts families.\n *\n * Accepts both string shorthand and structured objects:\n * - String: \"Inter:wght@400;500;700\" or \"Inter:wght@100..900\"\n * - Object: { family: \"Inter\", weights: [400, 500, 700] }\n * - Variable: { family: \"Inter\", variable: true, weightRange: [100, 900] }\n */\n google?: GoogleFontInput[]\n /** Local font files. */\n local?: LocalFont[]\n /** Default font-display strategy. Default: \"swap\" */\n display?: FontDisplay\n /** Preload critical fonts. Default: true */\n preload?: boolean\n /** Self-host Google Fonts at build time. Default: true */\n selfHost?: boolean\n /** Fallback font metrics for reducing CLS. */\n fallbacks?: Record<string, FallbackMetrics>\n}\n\n/** Static Google Font config. */\nexport interface GoogleFontStatic {\n family: string\n weights: number[]\n italic?: boolean\n variable?: false\n}\n\n/** Variable Google Font config. */\nexport interface GoogleFontVariable {\n family: string\n /** Weight range as [min, max] tuple. e.g. [100, 900] */\n weightRange: [number, number]\n italic?: boolean\n variable: true\n}\n\n/** Google font input: structured object or string shorthand. */\nexport type GoogleFontInput = GoogleFontStatic | GoogleFontVariable | string\n\nexport interface LocalFont {\n family: string\n src: string\n /** Single weight (400) or variable range (\"100 900\"). */\n weight?: number | `${number} ${number}`\n style?: \"normal\" | \"italic\"\n display?: FontDisplay\n}\n\nexport type FontDisplay = \"auto\" | \"block\" | \"swap\" | \"fallback\" | \"optional\"\n\n/** Metrics for generating size-adjusted fallback fonts to reduce CLS. */\nexport interface FallbackMetrics {\n /** The fallback font to adjust. e.g. \"Arial\", \"Georgia\" */\n fallback: string\n /** Size adjustment factor. e.g. 1.05 */\n sizeAdjust?: number\n /** Ascent override percentage. e.g. 90 */\n ascentOverride?: number\n /** Descent override percentage. e.g. 22 */\n descentOverride?: number\n /** Line gap override percentage. e.g. 0 */\n lineGapOverride?: number\n}\n\ninterface ResolvedFontBase {\n family: string\n italic: boolean\n}\n\ninterface StaticFont extends ResolvedFontBase {\n variable: false\n weights: number[]\n}\n\ninterface VariableFont extends ResolvedFontBase {\n variable: true\n weightRange: [number, number]\n}\n\ntype ResolvedFont = StaticFont | VariableFont\n\n/**\n * Normalize a GoogleFontInput (string or object) into a ResolvedFont.\n */\nexport function resolveGoogleFont(input: GoogleFontInput): ResolvedFont {\n if (typeof input === \"string\") {\n return parseGoogleFamily(input)\n }\n\n if (input.variable) {\n return {\n family: input.family,\n italic: input.italic ?? false,\n variable: true,\n weightRange: input.weightRange,\n }\n }\n\n return {\n family: input.family,\n italic: input.italic ?? false,\n variable: false,\n weights: input.weights,\n }\n}\n\n/**\n * Parse Google Fonts family string shorthand.\n *\n * Static weights: \"Inter:wght@400;500;700\"\n * Variable range: \"Inter:wght@100..900\"\n * Variable with italic: \"Inter:ital,wght@100..900\"\n */\nexport function parseGoogleFamily(input: string): ResolvedFont {\n const parts = input.split(\":\")\n const family = (parts[0] ?? \"\").trim()\n const spec = parts[1]\n let italic = false\n\n if (spec) {\n italic = spec.includes(\"ital\")\n\n // Variable font range syntax: wght@100..900\n const rangeMatch = spec.match(/wght@(\\d+)\\.\\.(\\d+)/)\n if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {\n return {\n family,\n italic,\n variable: true,\n weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])],\n }\n }\n\n // Static weights: wght@400;500;700\n const weightMatch = spec.match(/wght@([\\d;]+)/)\n if (weightMatch && weightMatch[1]) {\n return {\n family,\n italic,\n variable: false,\n weights: weightMatch[1].split(\";\").map(Number),\n }\n }\n }\n\n return { family, italic, variable: false, weights: [400] }\n}\n\n/**\n * Generate a Google Fonts CSS URL.\n */\nexport function googleFontsUrl(families: ResolvedFont[], display: FontDisplay = \"swap\"): string {\n const params = families\n .map((f) => {\n const axes = f.italic ? \"ital,wght\" : \"wght\"\n const name = f.family.replace(/ /g, \"+\")\n\n if (f.variable) {\n const range = `${f.weightRange[0]}..${f.weightRange[1]}`\n const value = f.italic ? `0,${range};1,${range}` : range\n return `family=${name}:${axes}@${value}`\n }\n\n const values = f.weights.map((w) => (f.italic ? `0,${w};1,${w}` : String(w))).join(\";\")\n return `family=${name}:${axes}@${values}`\n })\n .join(\"&\")\n\n return `https://fonts.googleapis.com/css2?${params}&display=${display}`\n}\n\n/**\n * Generate @font-face CSS for local fonts.\n */\nfunction localFontFaces(fonts: LocalFont[], display: FontDisplay): string {\n return fonts\n .map(\n (f) => `@font-face {\n font-family: \"${f.family}\";\n src: url(\"${f.src}\");\n font-weight: ${f.weight ?? \"400\"};\n font-style: ${f.style ?? \"normal\"};\n font-display: ${f.display ?? display};\n}`,\n )\n .join(\"\\n\\n\")\n}\n\n/**\n * Generate size-adjusted fallback @font-face declarations to reduce CLS.\n */\nfunction fallbackFontFaces(fallbacks: Record<string, FallbackMetrics>): string {\n return Object.entries(fallbacks)\n .map(([family, metrics]) => {\n const overrides: string[] = []\n if (metrics.sizeAdjust != null) overrides.push(` size-adjust: ${metrics.sizeAdjust * 100}%;`)\n if (metrics.ascentOverride != null)\n overrides.push(` ascent-override: ${metrics.ascentOverride}%;`)\n if (metrics.descentOverride != null)\n overrides.push(` descent-override: ${metrics.descentOverride}%;`)\n if (metrics.lineGapOverride != null)\n overrides.push(` line-gap-override: ${metrics.lineGapOverride}%;`)\n\n return `@font-face {\n font-family: \"${family} Fallback\";\n src: local(\"${metrics.fallback}\");\n${overrides.join(\"\\n\")}\n}`\n })\n .join(\"\\n\\n\")\n}\n\n/**\n * Generate preload link tags for critical font files.\n */\nfunction preloadTags(fonts: LocalFont[]): string {\n return fonts\n .map((f) => {\n const ext = f.src.split(\".\").pop()\n const type =\n ext === \"woff2\"\n ? \"font/woff2\"\n : ext === \"woff\"\n ? \"font/woff\"\n : ext === \"ttf\"\n ? \"font/ttf\"\n : \"font/otf\"\n return `<link rel=\"preload\" href=\"${f.src}\" as=\"font\" type=\"${type}\" crossorigin>`\n })\n .join(\"\\n\")\n}\n\n/**\n * Download Google Fonts CSS with woff2 user agent.\n */\nasync function downloadGoogleFontsCSS(url: string): Promise<string> {\n const response = await fetch(url, {\n headers: {\n \"User-Agent\":\n \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\n },\n })\n if (!response.ok) {\n throw new Error(`Failed to fetch Google Fonts CSS: ${response.status}`)\n }\n return response.text()\n}\n\n/**\n * Download a font file.\n */\nasync function downloadFontFile(url: string): Promise<Buffer> {\n const response = await fetch(url)\n if (!response.ok) throw new Error(`Failed to download font: ${url}`)\n const arrayBuffer = await response.arrayBuffer()\n return Buffer.from(arrayBuffer)\n}\n\n/**\n * Extract font file URLs from Google Fonts CSS.\n */\nfunction extractFontUrls(css: string): string[] {\n const urls: string[] = []\n const regex = /url\\((https:\\/\\/fonts\\.gstatic\\.com\\/[^)]+)\\)/g\n for (const match of css.matchAll(regex)) {\n if (match[1]) urls.push(match[1])\n }\n return urls\n}\n\n/**\n * Self-host Google Fonts: download CSS + font files, rewrite URLs to local paths.\n */\nasync function selfHostFonts(\n cssUrl: string,\n fontsSubDir: string,\n): Promise<{\n css: string\n fontFiles: Array<{ name: string; content: Buffer }>\n}> {\n const css = await downloadGoogleFontsCSS(cssUrl)\n const fontUrls = extractFontUrls(css)\n const fontFiles: Array<{ name: string; content: Buffer }> = []\n\n let rewrittenCss = css\n\n for (const url of fontUrls) {\n const urlParts = url.split(\"/\")\n const fileName = urlParts.at(-1)?.split(\"?\")[0] ?? \"font\"\n const content = await downloadFontFile(url)\n\n fontFiles.push({ name: fileName, content })\n rewrittenCss = rewrittenCss.replace(url, `/${fontsSubDir}/${fileName}`)\n }\n\n return { css: rewrittenCss, fontFiles }\n}\n\n/**\n * Zero font optimization Vite plugin.\n *\n * Dev mode: injects Google Fonts CDN link for fast startup.\n * Build mode: downloads and self-hosts fonts for maximum performance + privacy.\n *\n * @example\n * import { fontPlugin } from \"@pyreon/zero/font\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * fontPlugin({\n * google: [\"Inter:wght@400;500;600;700\", \"JetBrains Mono:wght@400\"],\n * fallbacks: {\n * \"Inter\": { fallback: \"Arial\", sizeAdjust: 1.07, ascentOverride: 90 },\n * },\n * }),\n * ],\n * }\n */\nexport function fontPlugin(config: FontConfig = {}): Plugin {\n const display = config.display ?? \"swap\"\n const shouldPreload = config.preload !== false\n const shouldSelfHost = config.selfHost !== false\n const googleFamilies = (config.google ?? []).map(resolveGoogleFont)\n\n let isBuild = false\n let selfHostedCSS = \"\"\n let selfHostedFontFiles: Array<{ name: string; content: Buffer }> = []\n\n return {\n name: \"pyreon-zero-fonts\",\n\n configResolved(resolvedConfig) {\n isBuild = resolvedConfig.command === \"build\"\n },\n\n async buildStart() {\n if (isBuild && shouldSelfHost && googleFamilies.length > 0) {\n const cssUrl = googleFontsUrl(googleFamilies, display)\n try {\n const result = await selfHostFonts(cssUrl, \"assets/fonts\")\n selfHostedCSS = result.css\n selfHostedFontFiles = result.fontFiles\n } catch {\n // Self-hosting failed — fall back to CDN link\n }\n }\n },\n\n generateBundle() {\n // Emit self-hosted font files as assets\n for (const file of selfHostedFontFiles) {\n this.emitFile({\n type: \"asset\",\n fileName: `assets/fonts/${file.name}`,\n source: file.content,\n })\n }\n },\n\n transformIndexHtml(html) {\n const tags: string[] = []\n\n collectGoogleFontTags(tags, {\n isBuild,\n selfHostedCSS,\n selfHostedFontFiles,\n shouldPreload,\n googleFamilies,\n display,\n })\n collectLocalFontTags(tags, config, shouldPreload, display)\n\n if (tags.length === 0) return html\n return html.replace(\"</head>\", `${tags.join(\"\\n\")}\\n</head>`)\n },\n }\n}\n\nfunction collectGoogleFontTags(\n tags: string[],\n opts: {\n isBuild: boolean\n selfHostedCSS: string\n selfHostedFontFiles: Array<{ name: string; content: Buffer }>\n shouldPreload: boolean\n googleFamilies: ResolvedFont[]\n display: FontDisplay\n },\n) {\n if (opts.isBuild && opts.selfHostedCSS) {\n tags.push(`<style>${opts.selfHostedCSS}</style>`)\n if (opts.shouldPreload) {\n for (const file of opts.selfHostedFontFiles.slice(0, opts.googleFamilies.length)) {\n const ext = file.name.split(\".\").pop()\n const type = ext === \"woff2\" ? \"font/woff2\" : \"font/woff\"\n tags.push(\n `<link rel=\"preload\" href=\"/assets/fonts/${file.name}\" as=\"font\" type=\"${type}\" crossorigin>`,\n )\n }\n }\n } else if (opts.googleFamilies.length > 0) {\n const cssUrl = googleFontsUrl(opts.googleFamilies, opts.display)\n tags.push(`<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">`)\n tags.push(`<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>`)\n tags.push(`<link rel=\"stylesheet\" href=\"${cssUrl}\">`)\n }\n}\n\nfunction collectLocalFontTags(\n tags: string[],\n config: FontConfig,\n shouldPreload: boolean,\n display: FontDisplay,\n) {\n if (shouldPreload && config.local?.length) {\n tags.push(preloadTags(config.local))\n }\n if (config.local?.length) {\n tags.push(`<style>${localFontFaces(config.local, display)}</style>`)\n }\n if (config.fallbacks && Object.keys(config.fallbacks).length > 0) {\n tags.push(`<style>${fallbackFontFaces(config.fallbacks)}</style>`)\n }\n}\n\n/**\n * Generate CSS variables for font families.\n */\nexport function fontVariables(families: Record<string, string>): string {\n const vars = Object.entries(families)\n .map(([key, value]) => ` --font-${key}: ${value};`)\n .join(\"\\n\")\n return `:root {\\n${vars}\\n}`\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"font.js","names":[],"sources":["../src/font.ts"],"sourcesContent":["import type { Plugin } from 'vite'\n\n// ─── Font optimization ──────────────────────────────────────────────────────\n//\n// Zero provides automatic font optimization:\n// - Downloads and self-hosts Google Fonts at build time (privacy + performance)\n// - Falls back to CDN link in dev mode (for fast dev startup)\n// - Injects preconnect/preload hints into the HTML\n// - Sets font-display: swap to prevent FOIT (Flash of Invisible Text)\n// - Generates optimized @font-face declarations\n// - Size-adjusted fallback fonts to reduce CLS\n\nexport interface FontConfig {\n /**\n * Google Fonts families.\n *\n * Accepts both string shorthand and structured objects:\n * - String: \"Inter:wght@400;500;700\" or \"Inter:wght@100..900\"\n * - Object: { family: \"Inter\", weights: [400, 500, 700] }\n * - Variable: { family: \"Inter\", variable: true, weightRange: [100, 900] }\n */\n google?: GoogleFontInput[]\n /** Local font files. */\n local?: LocalFont[]\n /** Default font-display strategy. Default: \"swap\" */\n display?: FontDisplay\n /** Preload critical fonts. Default: true */\n preload?: boolean\n /** Self-host Google Fonts at build time. Default: true */\n selfHost?: boolean\n /** Fallback font metrics for reducing CLS. */\n fallbacks?: Record<string, FallbackMetrics>\n}\n\n/** Static Google Font config. */\nexport interface GoogleFontStatic {\n family: string\n weights: number[]\n italic?: boolean\n variable?: false\n}\n\n/** Variable Google Font config. */\nexport interface GoogleFontVariable {\n family: string\n /** Weight range as [min, max] tuple. e.g. [100, 900] */\n weightRange: [number, number]\n italic?: boolean\n variable: true\n}\n\n/** Google font input: structured object or string shorthand. */\nexport type GoogleFontInput = GoogleFontStatic | GoogleFontVariable | string\n\nexport interface LocalFont {\n family: string\n src: string\n /** Single weight (400) or variable range (\"100 900\"). */\n weight?: number | `${number} ${number}`\n style?: 'normal' | 'italic'\n display?: FontDisplay\n}\n\nexport type FontDisplay = 'auto' | 'block' | 'swap' | 'fallback' | 'optional'\n\n/** Metrics for generating size-adjusted fallback fonts to reduce CLS. */\nexport interface FallbackMetrics {\n /** The fallback font to adjust. e.g. \"Arial\", \"Georgia\" */\n fallback: string\n /** Size adjustment factor. e.g. 1.05 */\n sizeAdjust?: number\n /** Ascent override percentage. e.g. 90 */\n ascentOverride?: number\n /** Descent override percentage. e.g. 22 */\n descentOverride?: number\n /** Line gap override percentage. e.g. 0 */\n lineGapOverride?: number\n}\n\ninterface ResolvedFontBase {\n family: string\n italic: boolean\n}\n\ninterface StaticFont extends ResolvedFontBase {\n variable: false\n weights: number[]\n}\n\ninterface VariableFont extends ResolvedFontBase {\n variable: true\n weightRange: [number, number]\n}\n\ntype ResolvedFont = StaticFont | VariableFont\n\n/**\n * Normalize a GoogleFontInput (string or object) into a ResolvedFont.\n */\nexport function resolveGoogleFont(input: GoogleFontInput): ResolvedFont {\n if (typeof input === 'string') {\n return parseGoogleFamily(input)\n }\n\n if (input.variable) {\n return {\n family: input.family,\n italic: input.italic ?? false,\n variable: true,\n weightRange: input.weightRange,\n }\n }\n\n return {\n family: input.family,\n italic: input.italic ?? false,\n variable: false,\n weights: input.weights,\n }\n}\n\n/**\n * Parse Google Fonts family string shorthand.\n *\n * Static weights: \"Inter:wght@400;500;700\"\n * Variable range: \"Inter:wght@100..900\"\n * Variable with italic: \"Inter:ital,wght@100..900\"\n */\nexport function parseGoogleFamily(input: string): ResolvedFont {\n const parts = input.split(':')\n const family = (parts[0] ?? '').trim()\n const spec = parts[1]\n let italic = false\n\n if (spec) {\n italic = spec.includes('ital')\n\n // Variable font range syntax: wght@100..900\n const rangeMatch = spec.match(/wght@(\\d+)\\.\\.(\\d+)/)\n if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {\n return {\n family,\n italic,\n variable: true,\n weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])],\n }\n }\n\n // Static weights: wght@400;500;700\n const weightMatch = spec.match(/wght@([\\d;]+)/)\n if (weightMatch && weightMatch[1]) {\n return {\n family,\n italic,\n variable: false,\n weights: weightMatch[1].split(';').map(Number),\n }\n }\n }\n\n return { family, italic, variable: false, weights: [400] }\n}\n\n/**\n * Generate a Google Fonts CSS URL.\n */\nexport function googleFontsUrl(families: ResolvedFont[], display: FontDisplay = 'swap'): string {\n const params = families\n .map((f) => {\n const axes = f.italic ? 'ital,wght' : 'wght'\n const name = f.family.replace(/ /g, '+')\n\n if (f.variable) {\n const range = `${f.weightRange[0]}..${f.weightRange[1]}`\n const value = f.italic ? `0,${range};1,${range}` : range\n return `family=${name}:${axes}@${value}`\n }\n\n const values = f.weights.map((w) => (f.italic ? `0,${w};1,${w}` : String(w))).join(';')\n return `family=${name}:${axes}@${values}`\n })\n .join('&')\n\n return `https://fonts.googleapis.com/css2?${params}&display=${display}`\n}\n\n/**\n * Generate @font-face CSS for local fonts.\n */\nfunction localFontFaces(fonts: LocalFont[], display: FontDisplay): string {\n return fonts\n .map(\n (f) => `@font-face {\n font-family: \"${f.family}\";\n src: url(\"${f.src}\");\n font-weight: ${f.weight ?? '400'};\n font-style: ${f.style ?? 'normal'};\n font-display: ${f.display ?? display};\n}`,\n )\n .join('\\n\\n')\n}\n\n/**\n * Generate size-adjusted fallback @font-face declarations to reduce CLS.\n */\nfunction fallbackFontFaces(fallbacks: Record<string, FallbackMetrics>): string {\n return Object.entries(fallbacks)\n .map(([family, metrics]) => {\n const overrides: string[] = []\n if (metrics.sizeAdjust != null) overrides.push(` size-adjust: ${metrics.sizeAdjust * 100}%;`)\n if (metrics.ascentOverride != null)\n overrides.push(` ascent-override: ${metrics.ascentOverride}%;`)\n if (metrics.descentOverride != null)\n overrides.push(` descent-override: ${metrics.descentOverride}%;`)\n if (metrics.lineGapOverride != null)\n overrides.push(` line-gap-override: ${metrics.lineGapOverride}%;`)\n\n return `@font-face {\n font-family: \"${family} Fallback\";\n src: local(\"${metrics.fallback}\");\n${overrides.join('\\n')}\n}`\n })\n .join('\\n\\n')\n}\n\n/**\n * Generate preload link tags for critical font files.\n */\nfunction preloadTags(fonts: LocalFont[]): string {\n return fonts\n .map((f) => {\n const ext = f.src.split('.').pop()\n const type =\n ext === 'woff2'\n ? 'font/woff2'\n : ext === 'woff'\n ? 'font/woff'\n : ext === 'ttf'\n ? 'font/ttf'\n : 'font/otf'\n return `<link rel=\"preload\" href=\"${f.src}\" as=\"font\" type=\"${type}\" crossorigin>`\n })\n .join('\\n')\n}\n\n/**\n * Download Google Fonts CSS with woff2 user agent.\n */\nasync function downloadGoogleFontsCSS(url: string): Promise<string> {\n const response = await fetch(url, {\n headers: {\n 'User-Agent':\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n },\n })\n if (!response.ok) {\n throw new Error(`Failed to fetch Google Fonts CSS: ${response.status}`)\n }\n return response.text()\n}\n\n/**\n * Download a font file.\n */\nasync function downloadFontFile(url: string): Promise<Buffer> {\n const response = await fetch(url)\n if (!response.ok) throw new Error(`Failed to download font: ${url}`)\n const arrayBuffer = await response.arrayBuffer()\n return Buffer.from(arrayBuffer)\n}\n\n/**\n * Extract font file URLs from Google Fonts CSS.\n */\nfunction extractFontUrls(css: string): string[] {\n const urls: string[] = []\n const regex = /url\\((https:\\/\\/fonts\\.gstatic\\.com\\/[^)]+)\\)/g\n for (const match of css.matchAll(regex)) {\n if (match[1]) urls.push(match[1])\n }\n return urls\n}\n\n/**\n * Self-host Google Fonts: download CSS + font files, rewrite URLs to local paths.\n */\nasync function selfHostFonts(\n cssUrl: string,\n fontsSubDir: string,\n): Promise<{\n css: string\n fontFiles: Array<{ name: string; content: Buffer }>\n}> {\n const css = await downloadGoogleFontsCSS(cssUrl)\n const fontUrls = extractFontUrls(css)\n const fontFiles: Array<{ name: string; content: Buffer }> = []\n\n let rewrittenCss = css\n\n for (const url of fontUrls) {\n const urlParts = url.split('/')\n const fileName = urlParts.at(-1)?.split('?')[0] ?? 'font'\n const content = await downloadFontFile(url)\n\n fontFiles.push({ name: fileName, content })\n rewrittenCss = rewrittenCss.replace(url, `/${fontsSubDir}/${fileName}`)\n }\n\n return { css: rewrittenCss, fontFiles }\n}\n\n/**\n * Zero font optimization Vite plugin.\n *\n * Dev mode: injects Google Fonts CDN link for fast startup.\n * Build mode: downloads and self-hosts fonts for maximum performance + privacy.\n *\n * @example\n * import { fontPlugin } from \"@pyreon/zero/font\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * fontPlugin({\n * google: [\"Inter:wght@400;500;600;700\", \"JetBrains Mono:wght@400\"],\n * fallbacks: {\n * \"Inter\": { fallback: \"Arial\", sizeAdjust: 1.07, ascentOverride: 90 },\n * },\n * }),\n * ],\n * }\n */\nexport function fontPlugin(config: FontConfig = {}): Plugin {\n const display = config.display ?? 'swap'\n const shouldPreload = config.preload !== false\n const shouldSelfHost = config.selfHost !== false\n const googleFamilies = (config.google ?? []).map(resolveGoogleFont)\n\n let isBuild = false\n let selfHostedCSS = ''\n let selfHostedFontFiles: Array<{ name: string; content: Buffer }> = []\n\n return {\n name: 'pyreon-zero-fonts',\n\n configResolved(resolvedConfig) {\n isBuild = resolvedConfig.command === 'build'\n },\n\n async buildStart() {\n if (isBuild && shouldSelfHost && googleFamilies.length > 0) {\n const cssUrl = googleFontsUrl(googleFamilies, display)\n try {\n const result = await selfHostFonts(cssUrl, 'assets/fonts')\n selfHostedCSS = result.css\n selfHostedFontFiles = result.fontFiles\n } catch {\n // Self-hosting failed — fall back to CDN link\n }\n }\n },\n\n generateBundle() {\n // Emit self-hosted font files as assets\n for (const file of selfHostedFontFiles) {\n this.emitFile({\n type: 'asset',\n fileName: `assets/fonts/${file.name}`,\n source: file.content,\n })\n }\n },\n\n transformIndexHtml(html) {\n const tags: string[] = []\n\n collectGoogleFontTags(tags, {\n isBuild,\n selfHostedCSS,\n selfHostedFontFiles,\n shouldPreload,\n googleFamilies,\n display,\n })\n collectLocalFontTags(tags, config, shouldPreload, display)\n\n if (tags.length === 0) return html\n return html.replace('</head>', `${tags.join('\\n')}\\n</head>`)\n },\n }\n}\n\nfunction collectGoogleFontTags(\n tags: string[],\n opts: {\n isBuild: boolean\n selfHostedCSS: string\n selfHostedFontFiles: Array<{ name: string; content: Buffer }>\n shouldPreload: boolean\n googleFamilies: ResolvedFont[]\n display: FontDisplay\n },\n) {\n if (opts.isBuild && opts.selfHostedCSS) {\n tags.push(`<style>${opts.selfHostedCSS}</style>`)\n if (opts.shouldPreload) {\n for (const file of opts.selfHostedFontFiles.slice(0, opts.googleFamilies.length)) {\n const ext = file.name.split('.').pop()\n const type = ext === 'woff2' ? 'font/woff2' : 'font/woff'\n tags.push(\n `<link rel=\"preload\" href=\"/assets/fonts/${file.name}\" as=\"font\" type=\"${type}\" crossorigin>`,\n )\n }\n }\n } else if (opts.googleFamilies.length > 0) {\n const cssUrl = googleFontsUrl(opts.googleFamilies, opts.display)\n tags.push(`<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">`)\n tags.push(`<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>`)\n tags.push(`<link rel=\"stylesheet\" href=\"${cssUrl}\">`)\n }\n}\n\nfunction collectLocalFontTags(\n tags: string[],\n config: FontConfig,\n shouldPreload: boolean,\n display: FontDisplay,\n) {\n if (shouldPreload && config.local?.length) {\n tags.push(preloadTags(config.local))\n }\n if (config.local?.length) {\n tags.push(`<style>${localFontFaces(config.local, display)}</style>`)\n }\n if (config.fallbacks && Object.keys(config.fallbacks).length > 0) {\n tags.push(`<style>${fallbackFontFaces(config.fallbacks)}</style>`)\n }\n}\n\n/**\n * Generate CSS variables for font families.\n */\nexport function fontVariables(families: Record<string, string>): string {\n const vars = Object.entries(families)\n .map(([key, value]) => ` --font-${key}: ${value};`)\n .join('\\n')\n return `:root {\\n${vars}\\n}`\n}\n"],"mappings":"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"}
@@ -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(files: string[], defaultMode: RenderMode = \"ssr\"): 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.filter((s) => !(s.startsWith(\"(\") && s.endsWith(\")\"))).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(files: string[], routesDir: string): 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(filePath: string, loadingName?: string, errorName?: string): 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 ? nextImport(node.loading.filePath) : 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(files: string[], routesDir: string): 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(` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`)\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;;;;;AAyVvD,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(files: string[], defaultMode: RenderMode = 'ssr'): 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.filter((s) => !(s.startsWith('(') && s.endsWith(')'))).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(files: string[], routesDir: string): 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(filePath: string, loadingName?: string, errorName?: string): 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 ? nextImport(node.loading.filePath) : 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(files: string[], routesDir: string): 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(` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`)\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;;;;;AAyVvD,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 +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(files: string[], defaultMode: RenderMode = \"ssr\"): 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.filter((s) => !(s.startsWith(\"(\") && s.endsWith(\")\"))).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(files: string[], routesDir: string): 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(filePath: string, loadingName?: string, errorName?: string): 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 ? nextImport(node.loading.filePath) : 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(files: string[], routesDir: string): 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(` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`)\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":"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"}
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(files: string[], defaultMode: RenderMode = 'ssr'): 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.filter((s) => !(s.startsWith('(') && s.endsWith(')'))).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(files: string[], routesDir: string): 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(filePath: string, loadingName?: string, errorName?: string): 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 ? nextImport(node.loading.filePath) : 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(files: string[], routesDir: string): 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(` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`)\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":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"image-plugin.js","names":[],"sources":["../src/image-plugin.ts"],"sourcesContent":["import { existsSync } from \"node:fs\"\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\"\nimport { basename, extname, join } from \"node:path\"\nimport type { Plugin } from \"vite\"\n\nlet sharpWarned = false\nfunction warnSharpMissing() {\n if (sharpWarned) return\n sharpWarned = true\n // biome-ignore lint/suspicious/noConsole: intentional build-time warning\n console.warn(\n \"\\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\\n\",\n )\n}\n\n// ─── Image processing Vite plugin ──────────────────────────────────────────\n//\n// Processes images at build time:\n// - Generates multiple sizes for responsive srcset\n// - Converts to modern formats (WebP, AVIF)\n// - Creates tiny blur placeholders (base64 inline)\n// - Outputs optimized images to the build directory\n//\n// Usage in code:\n// import heroImg from \"./hero.jpg?optimize\"\n// // → { src, srcset, width, height, placeholder }\n//\n// Or use the component helper:\n// import { Image } from \"@pyreon/zero/image\"\n// <Image src=\"/hero.jpg\" width={1920} height={1080} optimize />\n\nexport interface ImagePluginConfig {\n /** Output directory for processed images. Default: \"assets/img\" */\n outDir?: string\n /** Default widths for responsive images. Default: [640, 1024, 1920] */\n widths?: number[]\n /** Output formats. Default: [\"webp\"] */\n formats?: ImageFormat[]\n /** Quality for lossy formats (1-100). Default: 80 */\n quality?: number\n /** Blur placeholder size in px. Default: 16 */\n placeholderSize?: number\n /** File patterns to process. Default: /\\.(jpe?g|png|webp|avif)$/i */\n include?: RegExp\n}\n\nexport type ImageFormat = \"webp\" | \"avif\" | \"jpeg\" | \"png\"\n\n/** Per-format source set for <picture> <source> elements. */\nexport interface FormatSource {\n /** MIME type. e.g. \"image/webp\", \"image/avif\" */\n type: string\n /** srcset string for this format. e.g. \"/img-640.webp 640w, /img-1920.webp 1920w\" */\n srcset: string\n}\n\nexport interface ProcessedImage {\n /** Fallback source path (last format, largest width). */\n src: string\n /** Fallback srcset string (last format). */\n srcset: string\n /** Intrinsic width. */\n width: number\n /** Intrinsic height. */\n height: number\n /** Base64 blur placeholder data URI. */\n placeholder: string\n /** Per-format source sets for <picture> element. Ordered by priority (best format first). */\n formats: FormatSource[]\n /** Flat list of all sources. */\n sources: Array<{ src: string; width: number; format: string }>\n}\n\nconst IMAGE_EXT_RE = /\\.(jpe?g|png|webp|avif)$/i\n\n/**\n * Zero image processing Vite plugin.\n *\n * Transforms image imports with query params into optimized responsive images:\n *\n * @example\n * // vite.config.ts\n * import { imagePlugin } from \"@pyreon/zero/image-plugin\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * imagePlugin({ widths: [480, 960, 1440], quality: 85 }),\n * ],\n * }\n *\n * @example\n * // In a component — import with ?optimize\n * import hero from \"./images/hero.jpg?optimize\"\n * // hero = { src, srcset, width, height, placeholder }\n *\n * <Image {...hero} alt=\"Hero\" priority />\n */\nexport function imagePlugin(config: ImagePluginConfig = {}): Plugin {\n const defaultWidths = config.widths ?? [640, 1024, 1920]\n const defaultFormats = config.formats ?? [\"webp\"]\n const quality = config.quality ?? 80\n const placeholderSize = config.placeholderSize ?? 16\n const outSubDir = config.outDir ?? \"assets/img\"\n const include = config.include ?? IMAGE_EXT_RE\n\n let root = \"\"\n let outDir = \"\"\n let isBuild = false\n\n return {\n name: \"pyreon-zero-images\",\n enforce: \"pre\",\n\n configResolved(resolvedConfig) {\n root = resolvedConfig.root\n outDir = resolvedConfig.build.outDir\n isBuild = resolvedConfig.command === \"build\"\n },\n\n async resolveId(id) {\n // Handle ?optimize query on image imports\n if (id.includes(\"?optimize\") && include.test(id.split(\"?\")[0]!)) {\n return `\\0virtual:zero-image:${id}`\n }\n return null\n },\n\n async load(id) {\n if (!id.startsWith(\"\\0virtual:zero-image:\")) return null\n\n const rawPath = id.replace(\"\\0virtual:zero-image:\", \"\").split(\"?\")[0] ?? id\n const absPath = rawPath.startsWith(\"/\") ? join(root, \"public\", rawPath) : rawPath\n\n if (!isBuild) {\n const result = await loadDevImage(absPath, rawPath, placeholderSize)\n return `export default ${JSON.stringify(result)}`\n }\n\n const processed = await processImage(absPath, {\n widths: defaultWidths,\n formats: defaultFormats,\n quality,\n placeholderSize,\n outSubDir,\n outDir: join(root, outDir),\n })\n\n await emitProcessedSources(processed, outSubDir, this)\n rebuildFormatSrcsets(processed, absPath)\n\n return `export default ${JSON.stringify(processed)}`\n },\n }\n}\n\nasync function loadDevImage(\n absPath: string,\n rawPath: string,\n placeholderSize: number,\n): Promise<ProcessedImage> {\n const metadata = await getImageMetadata(absPath)\n const publicPath = rawPath.startsWith(\"/\") ? rawPath : `/@fs/${absPath}`\n\n return {\n src: publicPath,\n srcset: \"\",\n width: metadata.width,\n height: metadata.height,\n placeholder: await generateBlurPlaceholder(absPath, placeholderSize),\n formats: [],\n sources: [{ src: publicPath, width: metadata.width, format: \"original\" }],\n }\n}\n\nasync function emitProcessedSources(\n processed: ProcessedImage,\n outSubDir: string,\n ctx: {\n emitFile: (f: { type: \"asset\"; fileName: string; source: Uint8Array }) => void\n },\n) {\n for (const source of processed.sources) {\n const fileName = join(outSubDir, basename(source.src))\n const content = await readFile(source.src)\n ctx.emitFile({ type: \"asset\", fileName, source: content })\n source.src = `/${fileName}`\n }\n}\n\nfunction rebuildFormatSrcsets(processed: ProcessedImage, fallbackPath: string) {\n const formatGroups = new Map<string, string[]>()\n for (const s of processed.sources) {\n let group = formatGroups.get(s.format)\n if (!group) {\n group = []\n formatGroups.set(s.format, group)\n }\n group.push(`${s.src} ${s.width}w`)\n }\n processed.formats = [...formatGroups.entries()].map(([fmt, entries]) => ({\n type: `image/${fmt}`,\n srcset: entries.join(\", \"),\n }))\n\n const lastFormat = processed.formats.at(-1)\n processed.srcset = lastFormat?.srcset ?? \"\"\n processed.src = processed.sources.at(-1)?.src ?? fallbackPath\n}\n\n// ─── Image processing utilities ─────────────────────────────────────────────\n\ninterface ProcessOptions {\n widths: number[]\n formats: ImageFormat[]\n quality: number\n placeholderSize: number\n outSubDir: string\n outDir: string\n}\n\nasync function processImage(absPath: string, opts: ProcessOptions): Promise<ProcessedImage> {\n const metadata = await getImageMetadata(absPath)\n const ext = extname(absPath)\n const name = basename(absPath, ext)\n const sources: Array<{ src: string; width: number; format: string }> = []\n\n // Ensure output directory exists\n const processedDir = join(opts.outDir, opts.outSubDir)\n if (!existsSync(processedDir)) {\n await mkdir(processedDir, { recursive: true })\n }\n\n // Generate resized variants — iterate formats first so sources are grouped by format\n for (const format of opts.formats) {\n for (const targetWidth of opts.widths) {\n // Don't upscale\n const width = Math.min(targetWidth, metadata.width)\n const outName = `${name}-${width}.${format}`\n const outPath = join(processedDir, outName)\n\n await resizeImage(absPath, outPath, width, format, opts.quality)\n sources.push({ src: outPath, width, format })\n }\n }\n\n // Build per-format source sets for <picture>\n const formatGroups = new Map<string, Array<{ src: string; width: number }>>()\n for (const s of sources) {\n let group = formatGroups.get(s.format)\n if (!group) {\n group = []\n formatGroups.set(s.format, group)\n }\n group.push({ src: s.src, width: s.width })\n }\n\n const formats: FormatSource[] = [...formatGroups.entries()].map(([fmt, group]) => ({\n type: `image/${fmt === \"jpeg\" ? \"jpeg\" : fmt}`,\n srcset: group.map((s) => `${s.src} ${s.width}w`).join(\", \"),\n }))\n\n // Fallback: last format's srcset\n const fallbackFormat = formats[formats.length - 1]\n const fallbackSources = formatGroups.get([...formatGroups.keys()].pop()!)!\n\n // Generate blur placeholder\n const placeholder = await generateBlurPlaceholder(absPath, opts.placeholderSize)\n\n return {\n src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,\n srcset: fallbackFormat?.srcset ?? \"\",\n width: metadata.width,\n height: metadata.height,\n placeholder,\n formats,\n sources,\n }\n}\n\ninterface ImageMetadata {\n width: number\n height: number\n format: string\n}\n\n/**\n * Read basic image metadata.\n * Uses minimal binary header parsing — no external dependencies.\n */\nasync function getImageMetadata(absPath: string): Promise<ImageMetadata> {\n const buffer = await readFile(absPath)\n const ext = extname(absPath).toLowerCase()\n\n if (ext === \".png\") {\n // PNG: width at bytes 16-19, height at 20-23 (big-endian)\n const width = buffer.readUInt32BE(16)\n const height = buffer.readUInt32BE(20)\n return { width, height, format: \"png\" }\n }\n\n if (ext === \".jpg\" || ext === \".jpeg\") {\n // JPEG: scan for SOF markers\n const dimensions = parseJpegDimensions(buffer)\n return { ...dimensions, format: \"jpeg\" }\n }\n\n if (ext === \".webp\") {\n // WebP: VP8 header\n const dimensions = parseWebPDimensions(buffer)\n return { ...dimensions, format: \"webp\" }\n }\n\n // Fallback\n return { width: 0, height: 0, format: ext.slice(1) }\n}\n\n/** @internal Exported for testing */\nexport function parseJpegDimensions(buffer: Buffer): {\n width: number\n height: number\n} {\n let offset = 2 // Skip SOI marker\n while (offset < buffer.length) {\n if (buffer[offset] !== 0xff) break\n const marker = buffer[offset + 1]!\n // SOF markers (0xC0-0xCF except 0xC4, 0xC8, 0xCC)\n if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {\n const height = buffer.readUInt16BE(offset + 5)\n const width = buffer.readUInt16BE(offset + 7)\n return { width, height }\n }\n const length = buffer.readUInt16BE(offset + 2)\n offset += 2 + length\n }\n return { width: 0, height: 0 }\n}\n\n/** @internal Exported for testing */\nexport function parseWebPDimensions(buffer: Buffer): {\n width: number\n height: number\n} {\n // RIFF header: bytes 0-3 = \"RIFF\", 8-11 = \"WEBP\"\n const chunk = buffer.toString(\"ascii\", 12, 16)\n if (chunk === \"VP8 \") {\n // Lossy VP8\n const width = buffer.readUInt16LE(26) & 0x3fff\n const height = buffer.readUInt16LE(28) & 0x3fff\n return { width, height }\n }\n if (chunk === \"VP8L\") {\n // Lossless VP8L\n const bits = buffer.readUInt32LE(21)\n const width = (bits & 0x3fff) + 1\n const height = ((bits >> 14) & 0x3fff) + 1\n return { width, height }\n }\n if (chunk === \"VP8X\") {\n // Extended VP8X\n const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xffffff)\n const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xffffff)\n return { width, height }\n }\n return { width: 0, height: 0 }\n}\n\n/**\n * Resize an image using native platform capabilities.\n * Uses sharp if available, falls back to canvas API.\n */\nasync function resizeImage(\n input: string,\n output: string,\n width: number,\n format: ImageFormat,\n quality: number,\n): Promise<void> {\n try {\n // Try sharp (the standard Node.js image processing library)\n const sharp = await import(\"sharp\").then((m) => m.default ?? m)\n let pipeline = sharp(input).resize(width)\n\n switch (format) {\n case \"webp\":\n pipeline = pipeline.webp({ quality })\n break\n case \"avif\":\n pipeline = pipeline.avif({ quality })\n break\n case \"jpeg\":\n pipeline = pipeline.jpeg({ quality, mozjpeg: true })\n break\n case \"png\":\n pipeline = pipeline.png({ compressionLevel: 9 })\n break\n }\n\n await pipeline.toFile(output)\n } catch {\n // sharp not available — copy original as fallback\n warnSharpMissing()\n const content = await readFile(input)\n await writeFile(output, content)\n }\n}\n\n/**\n * Generate a tiny blur placeholder as a base64 data URI.\n */\nasync function generateBlurPlaceholder(input: string, size: number): Promise<string> {\n try {\n const sharp = await import(\"sharp\").then((m) => m.default ?? m)\n const buffer = await sharp(input)\n .resize(size, size, { fit: \"inside\" })\n .blur(2)\n .webp({ quality: 20 })\n .toBuffer()\n\n return `data:image/webp;base64,${buffer.toString(\"base64\")}`\n } catch {\n // sharp not available — return a transparent placeholder\n return \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E\"\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"image-plugin.js","names":[],"sources":["../src/image-plugin.ts"],"sourcesContent":["import { existsSync } from 'node:fs'\nimport { mkdir, readFile, writeFile } from 'node:fs/promises'\nimport { basename, extname, join } from 'node:path'\nimport type { Plugin } from 'vite'\n\nlet sharpWarned = false\nfunction warnSharpMissing() {\n if (sharpWarned) return\n sharpWarned = true\n // biome-ignore lint/suspicious/noConsole: intentional build-time warning\n console.warn(\n '\\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\\n',\n )\n}\n\n// ─── Image processing Vite plugin ──────────────────────────────────────────\n//\n// Processes images at build time:\n// - Generates multiple sizes for responsive srcset\n// - Converts to modern formats (WebP, AVIF)\n// - Creates tiny blur placeholders (base64 inline)\n// - Outputs optimized images to the build directory\n//\n// Usage in code:\n// import heroImg from \"./hero.jpg?optimize\"\n// // → { src, srcset, width, height, placeholder }\n//\n// Or use the component helper:\n// import { Image } from \"@pyreon/zero/image\"\n// <Image src=\"/hero.jpg\" width={1920} height={1080} optimize />\n\nexport interface ImagePluginConfig {\n /** Output directory for processed images. Default: \"assets/img\" */\n outDir?: string\n /** Default widths for responsive images. Default: [640, 1024, 1920] */\n widths?: number[]\n /** Output formats. Default: [\"webp\"] */\n formats?: ImageFormat[]\n /** Quality for lossy formats (1-100). Default: 80 */\n quality?: number\n /** Blur placeholder size in px. Default: 16 */\n placeholderSize?: number\n /** File patterns to process. Default: /\\.(jpe?g|png|webp|avif)$/i */\n include?: RegExp\n}\n\nexport type ImageFormat = 'webp' | 'avif' | 'jpeg' | 'png'\n\n/** Per-format source set for <picture> <source> elements. */\nexport interface FormatSource {\n /** MIME type. e.g. \"image/webp\", \"image/avif\" */\n type: string\n /** srcset string for this format. e.g. \"/img-640.webp 640w, /img-1920.webp 1920w\" */\n srcset: string\n}\n\nexport interface ProcessedImage {\n /** Fallback source path (last format, largest width). */\n src: string\n /** Fallback srcset string (last format). */\n srcset: string\n /** Intrinsic width. */\n width: number\n /** Intrinsic height. */\n height: number\n /** Base64 blur placeholder data URI. */\n placeholder: string\n /** Per-format source sets for <picture> element. Ordered by priority (best format first). */\n formats: FormatSource[]\n /** Flat list of all sources. */\n sources: Array<{ src: string; width: number; format: string }>\n}\n\nconst IMAGE_EXT_RE = /\\.(jpe?g|png|webp|avif)$/i\n\n/**\n * Zero image processing Vite plugin.\n *\n * Transforms image imports with query params into optimized responsive images:\n *\n * @example\n * // vite.config.ts\n * import { imagePlugin } from \"@pyreon/zero/image-plugin\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * imagePlugin({ widths: [480, 960, 1440], quality: 85 }),\n * ],\n * }\n *\n * @example\n * // In a component — import with ?optimize\n * import hero from \"./images/hero.jpg?optimize\"\n * // hero = { src, srcset, width, height, placeholder }\n *\n * <Image {...hero} alt=\"Hero\" priority />\n */\nexport function imagePlugin(config: ImagePluginConfig = {}): Plugin {\n const defaultWidths = config.widths ?? [640, 1024, 1920]\n const defaultFormats = config.formats ?? ['webp']\n const quality = config.quality ?? 80\n const placeholderSize = config.placeholderSize ?? 16\n const outSubDir = config.outDir ?? 'assets/img'\n const include = config.include ?? IMAGE_EXT_RE\n\n let root = ''\n let outDir = ''\n let isBuild = false\n\n return {\n name: 'pyreon-zero-images',\n enforce: 'pre',\n\n configResolved(resolvedConfig) {\n root = resolvedConfig.root\n outDir = resolvedConfig.build.outDir\n isBuild = resolvedConfig.command === 'build'\n },\n\n async resolveId(id) {\n // Handle ?optimize query on image imports\n if (id.includes('?optimize') && include.test(id.split('?')[0]!)) {\n return `\\0virtual:zero-image:${id}`\n }\n return null\n },\n\n async load(id) {\n if (!id.startsWith('\\0virtual:zero-image:')) return null\n\n const rawPath = id.replace('\\0virtual:zero-image:', '').split('?')[0] ?? id\n const absPath = rawPath.startsWith('/') ? join(root, 'public', rawPath) : rawPath\n\n if (!isBuild) {\n const result = await loadDevImage(absPath, rawPath, placeholderSize)\n return `export default ${JSON.stringify(result)}`\n }\n\n const processed = await processImage(absPath, {\n widths: defaultWidths,\n formats: defaultFormats,\n quality,\n placeholderSize,\n outSubDir,\n outDir: join(root, outDir),\n })\n\n await emitProcessedSources(processed, outSubDir, this)\n rebuildFormatSrcsets(processed, absPath)\n\n return `export default ${JSON.stringify(processed)}`\n },\n }\n}\n\nasync function loadDevImage(\n absPath: string,\n rawPath: string,\n placeholderSize: number,\n): Promise<ProcessedImage> {\n const metadata = await getImageMetadata(absPath)\n const publicPath = rawPath.startsWith('/') ? rawPath : `/@fs/${absPath}`\n\n return {\n src: publicPath,\n srcset: '',\n width: metadata.width,\n height: metadata.height,\n placeholder: await generateBlurPlaceholder(absPath, placeholderSize),\n formats: [],\n sources: [{ src: publicPath, width: metadata.width, format: 'original' }],\n }\n}\n\nasync function emitProcessedSources(\n processed: ProcessedImage,\n outSubDir: string,\n ctx: {\n emitFile: (f: { type: 'asset'; fileName: string; source: Uint8Array }) => void\n },\n) {\n for (const source of processed.sources) {\n const fileName = join(outSubDir, basename(source.src))\n const content = await readFile(source.src)\n ctx.emitFile({ type: 'asset', fileName, source: content })\n source.src = `/${fileName}`\n }\n}\n\nfunction rebuildFormatSrcsets(processed: ProcessedImage, fallbackPath: string) {\n const formatGroups = new Map<string, string[]>()\n for (const s of processed.sources) {\n let group = formatGroups.get(s.format)\n if (!group) {\n group = []\n formatGroups.set(s.format, group)\n }\n group.push(`${s.src} ${s.width}w`)\n }\n processed.formats = [...formatGroups.entries()].map(([fmt, entries]) => ({\n type: `image/${fmt}`,\n srcset: entries.join(', '),\n }))\n\n const lastFormat = processed.formats.at(-1)\n processed.srcset = lastFormat?.srcset ?? ''\n processed.src = processed.sources.at(-1)?.src ?? fallbackPath\n}\n\n// ─── Image processing utilities ─────────────────────────────────────────────\n\ninterface ProcessOptions {\n widths: number[]\n formats: ImageFormat[]\n quality: number\n placeholderSize: number\n outSubDir: string\n outDir: string\n}\n\nasync function processImage(absPath: string, opts: ProcessOptions): Promise<ProcessedImage> {\n const metadata = await getImageMetadata(absPath)\n const ext = extname(absPath)\n const name = basename(absPath, ext)\n const sources: Array<{ src: string; width: number; format: string }> = []\n\n // Ensure output directory exists\n const processedDir = join(opts.outDir, opts.outSubDir)\n if (!existsSync(processedDir)) {\n await mkdir(processedDir, { recursive: true })\n }\n\n // Generate resized variants — iterate formats first so sources are grouped by format\n for (const format of opts.formats) {\n for (const targetWidth of opts.widths) {\n // Don't upscale\n const width = Math.min(targetWidth, metadata.width)\n const outName = `${name}-${width}.${format}`\n const outPath = join(processedDir, outName)\n\n await resizeImage(absPath, outPath, width, format, opts.quality)\n sources.push({ src: outPath, width, format })\n }\n }\n\n // Build per-format source sets for <picture>\n const formatGroups = new Map<string, Array<{ src: string; width: number }>>()\n for (const s of sources) {\n let group = formatGroups.get(s.format)\n if (!group) {\n group = []\n formatGroups.set(s.format, group)\n }\n group.push({ src: s.src, width: s.width })\n }\n\n const formats: FormatSource[] = [...formatGroups.entries()].map(([fmt, group]) => ({\n type: `image/${fmt === 'jpeg' ? 'jpeg' : fmt}`,\n srcset: group.map((s) => `${s.src} ${s.width}w`).join(', '),\n }))\n\n // Fallback: last format's srcset\n const fallbackFormat = formats[formats.length - 1]\n const fallbackSources = formatGroups.get([...formatGroups.keys()].pop()!)!\n\n // Generate blur placeholder\n const placeholder = await generateBlurPlaceholder(absPath, opts.placeholderSize)\n\n return {\n src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,\n srcset: fallbackFormat?.srcset ?? '',\n width: metadata.width,\n height: metadata.height,\n placeholder,\n formats,\n sources,\n }\n}\n\ninterface ImageMetadata {\n width: number\n height: number\n format: string\n}\n\n/**\n * Read basic image metadata.\n * Uses minimal binary header parsing — no external dependencies.\n */\nasync function getImageMetadata(absPath: string): Promise<ImageMetadata> {\n const buffer = await readFile(absPath)\n const ext = extname(absPath).toLowerCase()\n\n if (ext === '.png') {\n // PNG: width at bytes 16-19, height at 20-23 (big-endian)\n const width = buffer.readUInt32BE(16)\n const height = buffer.readUInt32BE(20)\n return { width, height, format: 'png' }\n }\n\n if (ext === '.jpg' || ext === '.jpeg') {\n // JPEG: scan for SOF markers\n const dimensions = parseJpegDimensions(buffer)\n return { ...dimensions, format: 'jpeg' }\n }\n\n if (ext === '.webp') {\n // WebP: VP8 header\n const dimensions = parseWebPDimensions(buffer)\n return { ...dimensions, format: 'webp' }\n }\n\n // Fallback\n return { width: 0, height: 0, format: ext.slice(1) }\n}\n\n/** @internal Exported for testing */\nexport function parseJpegDimensions(buffer: Buffer): {\n width: number\n height: number\n} {\n let offset = 2 // Skip SOI marker\n while (offset < buffer.length) {\n if (buffer[offset] !== 0xff) break\n const marker = buffer[offset + 1]!\n // SOF markers (0xC0-0xCF except 0xC4, 0xC8, 0xCC)\n if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {\n const height = buffer.readUInt16BE(offset + 5)\n const width = buffer.readUInt16BE(offset + 7)\n return { width, height }\n }\n const length = buffer.readUInt16BE(offset + 2)\n offset += 2 + length\n }\n return { width: 0, height: 0 }\n}\n\n/** @internal Exported for testing */\nexport function parseWebPDimensions(buffer: Buffer): {\n width: number\n height: number\n} {\n // RIFF header: bytes 0-3 = \"RIFF\", 8-11 = \"WEBP\"\n const chunk = buffer.toString('ascii', 12, 16)\n if (chunk === 'VP8 ') {\n // Lossy VP8\n const width = buffer.readUInt16LE(26) & 0x3fff\n const height = buffer.readUInt16LE(28) & 0x3fff\n return { width, height }\n }\n if (chunk === 'VP8L') {\n // Lossless VP8L\n const bits = buffer.readUInt32LE(21)\n const width = (bits & 0x3fff) + 1\n const height = ((bits >> 14) & 0x3fff) + 1\n return { width, height }\n }\n if (chunk === 'VP8X') {\n // Extended VP8X\n const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xffffff)\n const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xffffff)\n return { width, height }\n }\n return { width: 0, height: 0 }\n}\n\n/**\n * Resize an image using native platform capabilities.\n * Uses sharp if available, falls back to canvas API.\n */\nasync function resizeImage(\n input: string,\n output: string,\n width: number,\n format: ImageFormat,\n quality: number,\n): Promise<void> {\n try {\n // Try sharp (the standard Node.js image processing library)\n const sharp = await import('sharp').then((m) => m.default ?? m)\n let pipeline = sharp(input).resize(width)\n\n switch (format) {\n case 'webp':\n pipeline = pipeline.webp({ quality })\n break\n case 'avif':\n pipeline = pipeline.avif({ quality })\n break\n case 'jpeg':\n pipeline = pipeline.jpeg({ quality, mozjpeg: true })\n break\n case 'png':\n pipeline = pipeline.png({ compressionLevel: 9 })\n break\n }\n\n await pipeline.toFile(output)\n } catch {\n // sharp not available — copy original as fallback\n warnSharpMissing()\n const content = await readFile(input)\n await writeFile(output, content)\n }\n}\n\n/**\n * Generate a tiny blur placeholder as a base64 data URI.\n */\nasync function generateBlurPlaceholder(input: string, size: number): Promise<string> {\n try {\n const sharp = await import('sharp').then((m) => m.default ?? m)\n const buffer = await sharp(input)\n .resize(size, size, { fit: 'inside' })\n .blur(2)\n .webp({ quality: 20 })\n .toBuffer()\n\n return `data:image/webp;base64,${buffer.toString('base64')}`\n } catch {\n // sharp not available — return a transparent placeholder\n return \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E\"\n }\n}\n"],"mappings":"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"}
package/lib/image.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"image.js","names":[],"sources":["../src/utils/use-intersection-observer.ts","../../../core/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 type { VNodeChild } from \"@pyreon/core\"\nimport { 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): VNodeChild {\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={() => (!hasFormats && inView() && resolvedSrcset ? resolvedSrcset : \"\")}\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 ?? \"\") : \"\")}\n sizes={sizes}\n />\n ))}\n {imgEl}\n </picture>\n ) : (\n imgEl\n )}\n </div>\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"image.js","names":[],"sources":["../src/utils/use-intersection-observer.ts","../../../core/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 type { VNodeChild } from '@pyreon/core'\nimport { 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): VNodeChild {\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={() => (!hasFormats && inView() && resolvedSrcset ? resolvedSrcset : '')}\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 ?? '') : '')}\n sizes={sizes}\n />\n ))}\n {imgEl}\n </picture>\n ) : (\n imgEl\n )}\n </div>\n )\n}\n"],"mappings":"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"}