@pyreon/zero 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/lib/cache.js +80 -0
- package/lib/cache.js.map +1 -0
- package/lib/client.js +58 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +35 -0
- package/lib/config.js.map +1 -0
- package/lib/font.js +251 -0
- package/lib/font.js.map +1 -0
- package/lib/fs-router-BkbIWqek.js +30 -0
- package/lib/fs-router-BkbIWqek.js.map +1 -0
- package/lib/fs-router-jfd1QGLB.js +261 -0
- package/lib/fs-router-jfd1QGLB.js.map +1 -0
- package/lib/image-plugin.js +289 -0
- package/lib/image-plugin.js.map +1 -0
- package/lib/image.js +113 -0
- package/lib/image.js.map +1 -0
- package/lib/index.js +1665 -0
- package/lib/index.js.map +1 -0
- package/lib/link.js +186 -0
- package/lib/link.js.map +1 -0
- package/lib/script.js +102 -0
- package/lib/script.js.map +1 -0
- package/lib/seo.js +136 -0
- package/lib/seo.js.map +1 -0
- package/lib/theme.js +165 -0
- package/lib/theme.js.map +1 -0
- package/lib/types/adapters/bun.d.ts +6 -0
- package/lib/types/adapters/bun.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +10 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/node.d.ts +6 -0
- package/lib/types/adapters/node.d.ts.map +1 -0
- package/lib/types/adapters/static.d.ts +7 -0
- package/lib/types/adapters/static.d.ts.map +1 -0
- package/lib/types/app.d.ts +24 -0
- package/lib/types/app.d.ts.map +1 -0
- package/lib/types/cache.d.ts +54 -0
- package/lib/types/cache.d.ts.map +1 -0
- package/lib/types/client.d.ts +19 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/config.d.ts +18 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/entry-server.d.ts +26 -0
- package/lib/types/entry-server.d.ts.map +1 -0
- package/lib/types/font.d.ts +119 -0
- package/lib/types/font.d.ts.map +1 -0
- package/lib/types/fs-router.d.ts +33 -0
- package/lib/types/fs-router.d.ts.map +1 -0
- package/lib/types/image-plugin.d.ts +79 -0
- package/lib/types/image-plugin.d.ts.map +1 -0
- package/lib/types/image.d.ts +50 -0
- package/lib/types/image.d.ts.map +1 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/isr.d.ts +9 -0
- package/lib/types/isr.d.ts.map +1 -0
- package/lib/types/link.d.ts +116 -0
- package/lib/types/link.d.ts.map +1 -0
- package/lib/types/script.d.ts +34 -0
- package/lib/types/script.d.ts.map +1 -0
- package/lib/types/seo.d.ts +88 -0
- package/lib/types/seo.d.ts.map +1 -0
- package/lib/types/theme.d.ts +38 -0
- package/lib/types/theme.d.ts.map +1 -0
- package/lib/types/types.d.ts +104 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/utils/use-intersection-observer.d.ts +10 -0
- package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
- package/lib/types/utils/with-headers.d.ts +6 -0
- package/lib/types/utils/with-headers.d.ts.map +1 -0
- package/lib/types/vite-plugin.d.ts +17 -0
- package/lib/types/vite-plugin.d.ts.map +1 -0
- package/package.json +100 -0
- package/src/adapters/bun.ts +65 -0
- package/src/adapters/index.ts +29 -0
- package/src/adapters/node.ts +113 -0
- package/src/adapters/static.ts +17 -0
- package/src/app.ts +62 -0
- package/src/cache.ts +149 -0
- package/src/client.ts +43 -0
- package/src/config.ts +36 -0
- package/src/entry-server.ts +51 -0
- package/src/font.ts +461 -0
- package/src/fs-router.ts +380 -0
- package/src/image-plugin.ts +452 -0
- package/src/image.tsx +167 -0
- package/src/index.ts +119 -0
- package/src/isr.ts +95 -0
- package/src/link.tsx +266 -0
- package/src/script.tsx +133 -0
- package/src/seo.ts +281 -0
- package/src/sharp.d.ts +20 -0
- package/src/theme.tsx +162 -0
- package/src/types.ts +130 -0
- package/src/utils/use-intersection-observer.ts +36 -0
- package/src/utils/with-headers.ts +16 -0
- package/src/vite-plugin.ts +92 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Vit Bokisch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @pyreon/zero
|
|
2
|
+
|
|
3
|
+
Core meta-framework for building full-stack apps with [Pyreon](https://github.com/user/pyreon) and [Vite](https://vite.dev).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @pyreon/zero
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **File-based routing** — `[param]`, `[...catchAll]`, `_layout`, `_error`, `_loading`, `(groups)`
|
|
14
|
+
- **Rendering modes** — SSR, SSG, ISR, SPA (per-route configurable)
|
|
15
|
+
- **Components** — `<Image>` (lazy load, srcset, blur-up), `<Link>` (prefetch, active state), `<Script>` (loading strategies)
|
|
16
|
+
- **Theme** — Dark/light/system with `theme` signal, `<ThemeToggle>`, and anti-flash inline script
|
|
17
|
+
- **Fonts** — Google Fonts self-hosting at build time, local fonts, size-adjusted fallbacks
|
|
18
|
+
- **Image optimization** — Build-time processing via `?optimize` imports (WebP/AVIF, blur placeholders)
|
|
19
|
+
- **SEO** — Sitemap, robots.txt, JSON-LD helpers (Vite plugin + dev middleware)
|
|
20
|
+
- **Cache & security** — `cacheMiddleware()`, `securityHeaders()`, `varyEncoding()`
|
|
21
|
+
- **Adapters** — Node.js, Bun, static
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// vite.config.ts
|
|
27
|
+
import pyreon from "@pyreon/vite-plugin"
|
|
28
|
+
import zero from "@pyreon/zero"
|
|
29
|
+
|
|
30
|
+
export default {
|
|
31
|
+
plugins: [pyreon(), zero({ mode: "ssr" })],
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Subpath Exports
|
|
36
|
+
|
|
37
|
+
| Export | Description |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `@pyreon/zero` | Vite plugin, config, adapters |
|
|
40
|
+
| `@pyreon/zero/client` | Client-side entry (`startClient`) |
|
|
41
|
+
| `@pyreon/zero/image` | `<Image>` component |
|
|
42
|
+
| `@pyreon/zero/link` | `<Link>`, `useLink`, `createLink` |
|
|
43
|
+
| `@pyreon/zero/script` | `<Script>` component |
|
|
44
|
+
| `@pyreon/zero/theme` | Theme system and `<ThemeToggle>` |
|
|
45
|
+
| `@pyreon/zero/font` | Font optimization plugin |
|
|
46
|
+
| `@pyreon/zero/cache` | Cache and security middleware |
|
|
47
|
+
| `@pyreon/zero/seo` | SEO plugin, sitemap, robots.txt |
|
|
48
|
+
| `@pyreon/zero/image-plugin` | Image optimization Vite plugin |
|
|
49
|
+
| `@pyreon/zero/config` | Config types |
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
[MIT](LICENSE)
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//#region src/cache.ts
|
|
2
|
+
const HASHED_ASSET = /\.[a-f0-9]{8,}\.\w+$/;
|
|
3
|
+
const STATIC_EXT = /\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm|ogg|mp3|wav)$/i;
|
|
4
|
+
const SCRIPT_EXT = /\.(js|css|mjs)$/i;
|
|
5
|
+
/** @internal Exported for testing */
|
|
6
|
+
function matchGlob(pattern, path) {
|
|
7
|
+
const regex = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
8
|
+
return new RegExp(`^${regex}$`).test(path);
|
|
9
|
+
}
|
|
10
|
+
function resolveControl(path, immutableDuration, staticDuration, pageDuration, swr) {
|
|
11
|
+
if (HASHED_ASSET.test(path)) return `public, max-age=${immutableDuration}, immutable`;
|
|
12
|
+
if (SCRIPT_EXT.test(path)) return `public, max-age=3600, stale-while-revalidate=${swr}`;
|
|
13
|
+
if (STATIC_EXT.test(path)) return `public, max-age=${staticDuration}, stale-while-revalidate=${swr}`;
|
|
14
|
+
if (pageDuration > 0) return `public, max-age=${pageDuration}, stale-while-revalidate=${swr}`;
|
|
15
|
+
return "no-cache";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Cache control middleware for Zero.
|
|
19
|
+
* Sets Cache-Control headers on the response based on asset type.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* import { cacheMiddleware } from "@pyreon/zero/cache"
|
|
23
|
+
*
|
|
24
|
+
* export default createHandler({
|
|
25
|
+
* routes,
|
|
26
|
+
* middleware: [
|
|
27
|
+
* cacheMiddleware({
|
|
28
|
+
* pages: 60,
|
|
29
|
+
* staleWhileRevalidate: 300,
|
|
30
|
+
* rules: [
|
|
31
|
+
* { match: "/api/*", control: "no-store" },
|
|
32
|
+
* ],
|
|
33
|
+
* }),
|
|
34
|
+
* ],
|
|
35
|
+
* })
|
|
36
|
+
*/
|
|
37
|
+
function cacheMiddleware(config = {}) {
|
|
38
|
+
const immutableDuration = config.immutable ?? 31536e3;
|
|
39
|
+
const staticDuration = config.static ?? 86400;
|
|
40
|
+
const pageDuration = config.pages ?? 0;
|
|
41
|
+
const swr = config.staleWhileRevalidate ?? 60;
|
|
42
|
+
const rules = config.rules ?? [];
|
|
43
|
+
return (ctx) => {
|
|
44
|
+
const path = ctx.url.pathname;
|
|
45
|
+
for (const rule of rules) if (matchGlob(rule.match, path)) {
|
|
46
|
+
ctx.headers.set("Cache-Control", rule.control);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const control = resolveControl(path, immutableDuration, staticDuration, pageDuration, swr);
|
|
50
|
+
ctx.headers.set("Cache-Control", control);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Security headers middleware.
|
|
55
|
+
* Adds common security headers to all responses.
|
|
56
|
+
*/
|
|
57
|
+
function securityHeaders() {
|
|
58
|
+
return (ctx) => {
|
|
59
|
+
ctx.headers.set("X-Content-Type-Options", "nosniff");
|
|
60
|
+
ctx.headers.set("X-Frame-Options", "DENY");
|
|
61
|
+
ctx.headers.set("X-XSS-Protection", "1; mode=block");
|
|
62
|
+
ctx.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
63
|
+
ctx.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Compression detection middleware.
|
|
68
|
+
* Sets Vary: Accept-Encoding header so caches can serve compressed variants.
|
|
69
|
+
* Actual compression is handled by the runtime (Bun/Node) or reverse proxy.
|
|
70
|
+
*/
|
|
71
|
+
function varyEncoding() {
|
|
72
|
+
return (ctx) => {
|
|
73
|
+
const existing = ctx.headers.get("Vary");
|
|
74
|
+
if (!existing?.includes("Accept-Encoding")) ctx.headers.set("Vary", existing ? `${existing}, Accept-Encoding` : "Accept-Encoding");
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
export { cacheMiddleware, matchGlob, securityHeaders, varyEncoding };
|
|
80
|
+
//# sourceMappingURL=cache.js.map
|
package/lib/cache.js.map
ADDED
|
@@ -0,0 +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 =\n /\\.(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(\n path,\n immutableDuration,\n staticDuration,\n pageDuration,\n swr,\n )\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(\n 'Permissions-Policy',\n 'camera=(), microphone=(), geolocation=()',\n )\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(\n 'Vary',\n existing ? `${existing}, Accept-Encoding` : 'Accept-Encoding',\n )\n }\n }\n}\n"],"mappings":";AAiCA,MAAM,eAAe;AACrB,MAAM,aACJ;AACF,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,eACd,MACA,mBACA,gBACA,cACA,IACD;AACD,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,IACV,sBACA,2CACD;;;;;;;;AASL,SAAgB,eAA2B;AACzC,SAAQ,QAA2B;EACjC,MAAM,WAAW,IAAI,QAAQ,IAAI,OAAO;AACxC,MAAI,CAAC,UAAU,SAAS,kBAAkB,CACxC,KAAI,QAAQ,IACV,QACA,WAAW,GAAG,SAAS,qBAAqB,kBAC7C"}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Fragment, h } from "@pyreon/core";
|
|
2
|
+
import { hydrateRoot, mount } from "@pyreon/runtime-dom";
|
|
3
|
+
import { HeadProvider } from "@pyreon/head";
|
|
4
|
+
import { RouterProvider, RouterView, createRouter } from "@pyreon/router";
|
|
5
|
+
|
|
6
|
+
//#region src/app.ts
|
|
7
|
+
/**
|
|
8
|
+
* Create a full Zero app — assembles router, head provider, and root layout.
|
|
9
|
+
*
|
|
10
|
+
* Used internally by entry-server and entry-client.
|
|
11
|
+
*/
|
|
12
|
+
function createApp(options) {
|
|
13
|
+
const router = createRouter({
|
|
14
|
+
routes: options.routes,
|
|
15
|
+
mode: options.routerMode ?? "history",
|
|
16
|
+
url: options.url,
|
|
17
|
+
scrollBehavior: "top"
|
|
18
|
+
});
|
|
19
|
+
const Layout = options.layout ?? DefaultLayout;
|
|
20
|
+
function App() {
|
|
21
|
+
return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
App,
|
|
25
|
+
router
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function DefaultLayout(props) {
|
|
29
|
+
return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/client.ts
|
|
34
|
+
/**
|
|
35
|
+
* Start the client-side app — hydrates SSR content or mounts fresh for SPA.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* import { routes } from "virtual:zero/routes"
|
|
39
|
+
* import { startClient } from "@pyreon/zero/client"
|
|
40
|
+
*
|
|
41
|
+
* startClient({ routes })
|
|
42
|
+
*/
|
|
43
|
+
function startClient(options) {
|
|
44
|
+
const container = document.getElementById("app");
|
|
45
|
+
if (!container) throw new Error("[zero] Missing #app container element");
|
|
46
|
+
const { App } = createApp({
|
|
47
|
+
routes: options.routes,
|
|
48
|
+
routerMode: "history",
|
|
49
|
+
layout: options.layout
|
|
50
|
+
});
|
|
51
|
+
const vnode = h(App, null);
|
|
52
|
+
if (container.childNodes.length > 0) return hydrateRoot(container, vnode);
|
|
53
|
+
return mount(vnode, container);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//#endregion
|
|
57
|
+
export { startClient };
|
|
58
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +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 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(\n Fragment,\n null,\n ...(Array.isArray(props.children) ? props.children : [props.children]),\n )\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 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,KAAK,QAAQ;EACb,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,EACL,UACA,MACA,GAAI,MAAM,QAAQ,MAAM,SAAS,GAAG,MAAM,WAAW,CAAC,MAAM,SAAS,CACtE;;;;;;;;;;;;;;ACpCH,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,QAAQ,QAAQ;EACjB,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
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/config.ts
|
|
2
|
+
/**
|
|
3
|
+
* Define a Zero configuration.
|
|
4
|
+
* Used in `zero.config.ts` at the project root.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* import { defineConfig } from "@pyreon/zero/config"
|
|
8
|
+
*
|
|
9
|
+
* export default defineConfig({
|
|
10
|
+
* mode: "ssr",
|
|
11
|
+
* ssr: { mode: "stream" },
|
|
12
|
+
* port: 3000,
|
|
13
|
+
* })
|
|
14
|
+
*/
|
|
15
|
+
function defineConfig(config) {
|
|
16
|
+
return config;
|
|
17
|
+
}
|
|
18
|
+
/** Merge user config with defaults. */
|
|
19
|
+
function resolveConfig(userConfig = {}) {
|
|
20
|
+
return {
|
|
21
|
+
mode: "ssr",
|
|
22
|
+
base: "/",
|
|
23
|
+
port: 3e3,
|
|
24
|
+
adapter: "node",
|
|
25
|
+
...userConfig,
|
|
26
|
+
ssr: {
|
|
27
|
+
mode: "string",
|
|
28
|
+
...userConfig.ssr
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { defineConfig, resolveConfig };
|
|
35
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +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'>> &\n 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,EAEhB;AACX,QAAO;EACL,MAAM;EACN,MAAM;EACN,MAAM;EACN,SAAS;EACT,GAAG;EACH,KAAK;GACH,MAAM;GACN,GAAG,WAAW;GACf;EACF"}
|
package/lib/font.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
//#region src/font.ts
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a GoogleFontInput (string or object) into a ResolvedFont.
|
|
4
|
+
*/
|
|
5
|
+
function resolveGoogleFont(input) {
|
|
6
|
+
if (typeof input === "string") return parseGoogleFamily(input);
|
|
7
|
+
if (input.variable) return {
|
|
8
|
+
family: input.family,
|
|
9
|
+
italic: input.italic ?? false,
|
|
10
|
+
variable: true,
|
|
11
|
+
weightRange: input.weightRange
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
family: input.family,
|
|
15
|
+
italic: input.italic ?? false,
|
|
16
|
+
variable: false,
|
|
17
|
+
weights: input.weights
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parse Google Fonts family string shorthand.
|
|
22
|
+
*
|
|
23
|
+
* Static weights: "Inter:wght@400;500;700"
|
|
24
|
+
* Variable range: "Inter:wght@100..900"
|
|
25
|
+
* Variable with italic: "Inter:ital,wght@100..900"
|
|
26
|
+
*/
|
|
27
|
+
function parseGoogleFamily(input) {
|
|
28
|
+
const [familyPart, spec] = input.split(":");
|
|
29
|
+
const family = familyPart?.trim();
|
|
30
|
+
let italic = false;
|
|
31
|
+
if (spec) {
|
|
32
|
+
italic = spec.includes("ital");
|
|
33
|
+
const rangeMatch = spec.match(/wght@(\d+)\.\.(\d+)/);
|
|
34
|
+
if (rangeMatch) return {
|
|
35
|
+
family,
|
|
36
|
+
italic,
|
|
37
|
+
variable: true,
|
|
38
|
+
weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])]
|
|
39
|
+
};
|
|
40
|
+
const weightMatch = spec.match(/wght@([\d;]+)/);
|
|
41
|
+
if (weightMatch) return {
|
|
42
|
+
family,
|
|
43
|
+
italic,
|
|
44
|
+
variable: false,
|
|
45
|
+
weights: weightMatch[1]?.split(";").map(Number)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
family,
|
|
50
|
+
italic,
|
|
51
|
+
variable: false,
|
|
52
|
+
weights: [400]
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Generate a Google Fonts CSS URL.
|
|
57
|
+
*/
|
|
58
|
+
function googleFontsUrl(families, display = "swap") {
|
|
59
|
+
return `https://fonts.googleapis.com/css2?${families.map((f) => {
|
|
60
|
+
const axes = f.italic ? "ital,wght" : "wght";
|
|
61
|
+
const name = f.family.replace(/ /g, "+");
|
|
62
|
+
if (f.variable) {
|
|
63
|
+
const range = `${f.weightRange[0]}..${f.weightRange[1]}`;
|
|
64
|
+
return `family=${name}:${axes}@${f.italic ? `0,${range};1,${range}` : range}`;
|
|
65
|
+
}
|
|
66
|
+
return `family=${name}:${axes}@${f.weights.map((w) => f.italic ? `0,${w};1,${w}` : String(w)).join(";")}`;
|
|
67
|
+
}).join("&")}&display=${display}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Generate @font-face CSS for local fonts.
|
|
71
|
+
*/
|
|
72
|
+
function localFontFaces(fonts, display) {
|
|
73
|
+
return fonts.map((f) => `@font-face {
|
|
74
|
+
font-family: "${f.family}";
|
|
75
|
+
src: url("${f.src}");
|
|
76
|
+
font-weight: ${f.weight ?? "400"};
|
|
77
|
+
font-style: ${f.style ?? "normal"};
|
|
78
|
+
font-display: ${f.display ?? display};
|
|
79
|
+
}`).join("\n\n");
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Generate size-adjusted fallback @font-face declarations to reduce CLS.
|
|
83
|
+
*/
|
|
84
|
+
function fallbackFontFaces(fallbacks) {
|
|
85
|
+
return Object.entries(fallbacks).map(([family, metrics]) => {
|
|
86
|
+
const overrides = [];
|
|
87
|
+
if (metrics.sizeAdjust != null) overrides.push(` size-adjust: ${metrics.sizeAdjust * 100}%;`);
|
|
88
|
+
if (metrics.ascentOverride != null) overrides.push(` ascent-override: ${metrics.ascentOverride}%;`);
|
|
89
|
+
if (metrics.descentOverride != null) overrides.push(` descent-override: ${metrics.descentOverride}%;`);
|
|
90
|
+
if (metrics.lineGapOverride != null) overrides.push(` line-gap-override: ${metrics.lineGapOverride}%;`);
|
|
91
|
+
return `@font-face {
|
|
92
|
+
font-family: "${family} Fallback";
|
|
93
|
+
src: local("${metrics.fallback}");
|
|
94
|
+
${overrides.join("\n")}
|
|
95
|
+
}`;
|
|
96
|
+
}).join("\n\n");
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Generate preload link tags for critical font files.
|
|
100
|
+
*/
|
|
101
|
+
function preloadTags(fonts) {
|
|
102
|
+
return fonts.map((f) => {
|
|
103
|
+
const ext = f.src.split(".").pop();
|
|
104
|
+
const type = ext === "woff2" ? "font/woff2" : ext === "woff" ? "font/woff" : ext === "ttf" ? "font/ttf" : "font/otf";
|
|
105
|
+
return `<link rel="preload" href="${f.src}" as="font" type="${type}" crossorigin>`;
|
|
106
|
+
}).join("\n");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Download Google Fonts CSS with woff2 user agent.
|
|
110
|
+
*/
|
|
111
|
+
async function downloadGoogleFontsCSS(url) {
|
|
112
|
+
const response = await fetch(url, { headers: { "User-Agent": "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" } });
|
|
113
|
+
if (!response.ok) throw new Error(`Failed to fetch Google Fonts CSS: ${response.status}`);
|
|
114
|
+
return response.text();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Download a font file.
|
|
118
|
+
*/
|
|
119
|
+
async function downloadFontFile(url) {
|
|
120
|
+
const response = await fetch(url);
|
|
121
|
+
if (!response.ok) throw new Error(`Failed to download font: ${url}`);
|
|
122
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
123
|
+
return Buffer.from(arrayBuffer);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Extract font file URLs from Google Fonts CSS.
|
|
127
|
+
*/
|
|
128
|
+
function extractFontUrls(css) {
|
|
129
|
+
const urls = [];
|
|
130
|
+
for (const match of css.matchAll(/url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)/g)) if (match[1]) urls.push(match[1]);
|
|
131
|
+
return urls;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Self-host Google Fonts: download CSS + font files, rewrite URLs to local paths.
|
|
135
|
+
*/
|
|
136
|
+
async function selfHostFonts(cssUrl, fontsSubDir) {
|
|
137
|
+
const css = await downloadGoogleFontsCSS(cssUrl);
|
|
138
|
+
const fontUrls = extractFontUrls(css);
|
|
139
|
+
const fontFiles = [];
|
|
140
|
+
let rewrittenCss = css;
|
|
141
|
+
for (const url of fontUrls) {
|
|
142
|
+
const fileName = url.split("/").at(-1)?.split("?")[0] ?? "font";
|
|
143
|
+
const content = await downloadFontFile(url);
|
|
144
|
+
fontFiles.push({
|
|
145
|
+
name: fileName,
|
|
146
|
+
content
|
|
147
|
+
});
|
|
148
|
+
rewrittenCss = rewrittenCss.replace(url, `/${fontsSubDir}/${fileName}`);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
css: rewrittenCss,
|
|
152
|
+
fontFiles
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Zero font optimization Vite plugin.
|
|
157
|
+
*
|
|
158
|
+
* Dev mode: injects Google Fonts CDN link for fast startup.
|
|
159
|
+
* Build mode: downloads and self-hosts fonts for maximum performance + privacy.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* import { fontPlugin } from "@pyreon/zero/font"
|
|
163
|
+
*
|
|
164
|
+
* export default {
|
|
165
|
+
* plugins: [
|
|
166
|
+
* pyreon(),
|
|
167
|
+
* zero(),
|
|
168
|
+
* fontPlugin({
|
|
169
|
+
* google: ["Inter:wght@400;500;600;700", "JetBrains Mono:wght@400"],
|
|
170
|
+
* fallbacks: {
|
|
171
|
+
* "Inter": { fallback: "Arial", sizeAdjust: 1.07, ascentOverride: 90 },
|
|
172
|
+
* },
|
|
173
|
+
* }),
|
|
174
|
+
* ],
|
|
175
|
+
* }
|
|
176
|
+
*/
|
|
177
|
+
function fontPlugin(config = {}) {
|
|
178
|
+
const display = config.display ?? "swap";
|
|
179
|
+
const shouldPreload = config.preload !== false;
|
|
180
|
+
const shouldSelfHost = config.selfHost !== false;
|
|
181
|
+
const googleFamilies = (config.google ?? []).map(resolveGoogleFont);
|
|
182
|
+
let isBuild = false;
|
|
183
|
+
let selfHostedCSS = "";
|
|
184
|
+
let selfHostedFontFiles = [];
|
|
185
|
+
return {
|
|
186
|
+
name: "pyreon-zero-fonts",
|
|
187
|
+
configResolved(resolvedConfig) {
|
|
188
|
+
isBuild = resolvedConfig.command === "build";
|
|
189
|
+
},
|
|
190
|
+
async buildStart() {
|
|
191
|
+
if (isBuild && shouldSelfHost && googleFamilies.length > 0) {
|
|
192
|
+
const cssUrl = googleFontsUrl(googleFamilies, display);
|
|
193
|
+
try {
|
|
194
|
+
const result = await selfHostFonts(cssUrl, "assets/fonts");
|
|
195
|
+
selfHostedCSS = result.css;
|
|
196
|
+
selfHostedFontFiles = result.fontFiles;
|
|
197
|
+
} catch {}
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
generateBundle() {
|
|
201
|
+
for (const file of selfHostedFontFiles) this.emitFile({
|
|
202
|
+
type: "asset",
|
|
203
|
+
fileName: `assets/fonts/${file.name}`,
|
|
204
|
+
source: file.content
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
transformIndexHtml(html) {
|
|
208
|
+
const tags = [];
|
|
209
|
+
collectGoogleFontTags(tags, {
|
|
210
|
+
isBuild,
|
|
211
|
+
selfHostedCSS,
|
|
212
|
+
selfHostedFontFiles,
|
|
213
|
+
shouldPreload,
|
|
214
|
+
googleFamilies,
|
|
215
|
+
display
|
|
216
|
+
});
|
|
217
|
+
collectLocalFontTags(tags, config, shouldPreload, display);
|
|
218
|
+
if (tags.length === 0) return html;
|
|
219
|
+
return html.replace("</head>", `${tags.join("\n")}\n</head>`);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function collectGoogleFontTags(tags, opts) {
|
|
224
|
+
if (opts.isBuild && opts.selfHostedCSS) {
|
|
225
|
+
tags.push(`<style>${opts.selfHostedCSS}</style>`);
|
|
226
|
+
if (opts.shouldPreload) for (const file of opts.selfHostedFontFiles.slice(0, opts.googleFamilies.length)) {
|
|
227
|
+
const type = file.name.split(".").pop() === "woff2" ? "font/woff2" : "font/woff";
|
|
228
|
+
tags.push(`<link rel="preload" href="/assets/fonts/${file.name}" as="font" type="${type}" crossorigin>`);
|
|
229
|
+
}
|
|
230
|
+
} else if (opts.googleFamilies.length > 0) {
|
|
231
|
+
const cssUrl = googleFontsUrl(opts.googleFamilies, opts.display);
|
|
232
|
+
tags.push(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
|
233
|
+
tags.push(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`);
|
|
234
|
+
tags.push(`<link rel="stylesheet" href="${cssUrl}">`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function collectLocalFontTags(tags, config, shouldPreload, display) {
|
|
238
|
+
if (shouldPreload && config.local?.length) tags.push(preloadTags(config.local));
|
|
239
|
+
if (config.local?.length) tags.push(`<style>${localFontFaces(config.local, display)}</style>`);
|
|
240
|
+
if (config.fallbacks && Object.keys(config.fallbacks).length > 0) tags.push(`<style>${fallbackFontFaces(config.fallbacks)}</style>`);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Generate CSS variables for font families.
|
|
244
|
+
*/
|
|
245
|
+
function fontVariables(families) {
|
|
246
|
+
return `:root {\n${Object.entries(families).map(([key, value]) => ` --font-${key}: ${value};`).join("\n")}\n}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
//#endregion
|
|
250
|
+
export { fontPlugin, fontVariables, googleFontsUrl, parseGoogleFamily, resolveGoogleFont };
|
|
251
|
+
//# sourceMappingURL=font.js.map
|
package/lib/font.js.map
ADDED
|
@@ -0,0 +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 [familyPart, spec] = input.split(':')\n const family = familyPart?.trim()\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) {\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) {\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(\n families: ResolvedFont[],\n display: FontDisplay = 'swap',\n): 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\n .map((w) => (f.italic ? `0,${w};1,${w}` : String(w)))\n .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)\n 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(\n 0,\n opts.googleFamilies.length,\n )) {\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(\n `<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>`,\n )\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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"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/fs-router.ts
|
|
2
|
+
const ROUTE_EXTENSIONS = [
|
|
3
|
+
".tsx",
|
|
4
|
+
".jsx",
|
|
5
|
+
".ts",
|
|
6
|
+
".js"
|
|
7
|
+
];
|
|
8
|
+
/**
|
|
9
|
+
* Scan a directory for route files.
|
|
10
|
+
* Returns paths relative to the routes directory.
|
|
11
|
+
*/
|
|
12
|
+
async function scanRouteFiles(routesDir) {
|
|
13
|
+
const { readdir } = await import("node:fs/promises");
|
|
14
|
+
const { join, relative } = await import("node:path");
|
|
15
|
+
const files = [];
|
|
16
|
+
async function walk(dir) {
|
|
17
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
const fullPath = join(dir, entry.name);
|
|
20
|
+
if (entry.isDirectory()) await walk(fullPath);
|
|
21
|
+
else if (ROUTE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) files.push(relative(routesDir, fullPath));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
await walk(routesDir);
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
export { scanRouteFiles };
|
|
30
|
+
//# sourceMappingURL=fs-router-BkbIWqek.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fs-router-BkbIWqek.js","names":[],"sources":["../src/fs-router.ts"],"sourcesContent":["import type { FileRoute, RenderMode } from './types'\n\n// ─── File-system route conventions ──────────────────────────────────────────\n//\n// src/routes/\n// _layout.tsx → layout for all routes\n// index.tsx → /\n// about.tsx → /about\n// users/\n// _layout.tsx → layout for /users/*\n// _loading.tsx → loading fallback for /users/*\n// _error.tsx → error boundary for /users/*\n// index.tsx → /users\n// [id].tsx → /users/:id\n// [id]/\n// settings.tsx → /users/:id/settings\n// blog/\n// [...slug].tsx → /blog/* (catch-all)\n//\n// Conventions:\n// [param] → dynamic segment → :param\n// [...param] → catch-all → :param*\n// _layout → layout wrapper (not a route itself)\n// _error → error component\n// _loading → loading component\n// (group) → route group (directory ignored in URL)\n\nconst ROUTE_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js']\n\n/**\n * Parse a set of file paths (relative to routes dir) into FileRoute objects.\n *\n * @param files Array of file paths like [\"index.tsx\", \"users/[id].tsx\"]\n * @param defaultMode Default rendering mode from config\n */\nexport function parseFileRoutes(\n files: string[],\n defaultMode: RenderMode = 'ssr',\n): FileRoute[] {\n return files\n .filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext)))\n .map((filePath) => parseFilePath(filePath, defaultMode))\n .sort(sortRoutes)\n}\n\nfunction parseFilePath(filePath: string, defaultMode: RenderMode): FileRoute {\n // Remove extension\n let route = filePath\n for (const ext of ROUTE_EXTENSIONS) {\n if (route.endsWith(ext)) {\n route = route.slice(0, -ext.length)\n break\n }\n }\n\n const fileName = getFileName(route)\n const isLayout = fileName === '_layout'\n const isError = fileName === '_error'\n const isLoading = fileName === '_loading'\n const isCatchAll = route.includes('[...')\n\n // Get directory path (strip groups for consistent grouping)\n const parts = route.split('/')\n parts.pop() // remove filename\n const dirPath = parts\n .filter((s) => !(s.startsWith('(') && s.endsWith(')')))\n .join('/')\n\n // Convert file path to URL pattern\n const urlPath = filePathToUrlPath(route)\n const depth = urlPath === '/' ? 0 : urlPath.split('/').filter(Boolean).length\n\n return {\n filePath,\n urlPath,\n dirPath,\n depth,\n isLayout,\n isError,\n isLoading,\n isCatchAll,\n renderMode: defaultMode,\n }\n}\n\n/**\n * Convert a file path (without extension) to a URL path pattern.\n *\n * Examples:\n * \"index\" → \"/\"\n * \"about\" → \"/about\"\n * \"users/index\" → \"/users\"\n * \"users/[id]\" → \"/users/:id\"\n * \"blog/[...slug]\" → \"/blog/:slug*\"\n * \"(auth)/login\" → \"/login\" (group stripped)\n * \"_layout\" → \"/\" (layout marker)\n */\nexport function filePathToUrlPath(filePath: string): string {\n const segments = filePath.split('/')\n const urlSegments: string[] = []\n\n for (const seg of segments) {\n // Skip route groups \"(name)\"\n if (seg.startsWith('(') && seg.endsWith(')')) continue\n\n // Skip special files\n if (seg === '_layout' || seg === '_error' || seg === '_loading') continue\n\n // \"index\" maps to the parent path\n if (seg === 'index') continue\n\n // Catch-all: [...param] → :param*\n const catchAll = seg.match(/^\\[\\.\\.\\.(\\w+)\\]$/)\n if (catchAll) {\n urlSegments.push(`:${catchAll[1]}*`)\n continue\n }\n\n // Dynamic: [param] → :param\n const dynamic = seg.match(/^\\[(\\w+)\\]$/)\n if (dynamic) {\n urlSegments.push(`:${dynamic[1]}`)\n continue\n }\n\n urlSegments.push(seg)\n }\n\n const path = `/${urlSegments.join('/')}`\n return path || '/'\n}\n\n/** Sort routes: static before dynamic, catch-all last. */\nfunction sortRoutes(a: FileRoute, b: FileRoute): number {\n // Catch-all routes go last\n if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1\n // Layouts go first within same depth\n if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1\n // Static segments before dynamic\n const aDynamic = a.urlPath.includes(':')\n const bDynamic = b.urlPath.includes(':')\n if (aDynamic !== bDynamic) return aDynamic ? 1 : -1\n // Alphabetical\n return a.urlPath.localeCompare(b.urlPath)\n}\n\nfunction getFileName(filePath: string): string {\n const parts = filePath.split('/')\n return parts[parts.length - 1] ?? ''\n}\n\n// ─── Route generation (for Vite plugin) ─────────────────────────────────────\n\n/** Internal tree node for building nested route structures. */\ninterface RouteNode {\n /** Page routes at this directory level. */\n pages: FileRoute[]\n /** Layout file for this directory (if any). */\n layout?: FileRoute\n /** Error boundary file (if any). */\n error?: FileRoute\n /** Loading fallback file (if any). */\n loading?: FileRoute\n /** Child directories. */\n children: Map<string, RouteNode>\n}\n\n/**\n * Group flat file routes into a directory tree.\n */\nfunction getOrCreateChild(node: RouteNode, segment: string): RouteNode {\n let child = node.children.get(segment)\n if (!child) {\n child = { pages: [], children: new Map() }\n node.children.set(segment, child)\n }\n return child\n}\n\nfunction resolveNode(root: RouteNode, dirPath: string): RouteNode {\n let node = root\n if (dirPath) {\n for (const segment of dirPath.split('/')) {\n node = getOrCreateChild(node, segment)\n }\n }\n return node\n}\n\nfunction placeRoute(node: RouteNode, route: FileRoute) {\n if (route.isLayout) node.layout = route\n else if (route.isError) node.error = route\n else if (route.isLoading) node.loading = route\n else node.pages.push(route)\n}\n\nfunction buildRouteTree(routes: FileRoute[]): RouteNode {\n const root: RouteNode = { pages: [], children: new Map() }\n for (const route of routes) {\n placeRoute(resolveNode(root, route.dirPath), route)\n }\n return root\n}\n\n/**\n * Generate a virtual module that exports a nested route tree.\n * Wires up layouts as parent routes with children, loaders, guards,\n * error/loading components, middleware, and meta from route module exports.\n */\nexport function generateRouteModule(\n files: string[],\n routesDir: string,\n): string {\n const routes = parseFileRoutes(files)\n const tree = buildRouteTree(routes)\n const imports: string[] = []\n let importCounter = 0\n\n function nextImport(filePath: string, exportName = 'default'): string {\n const name = `_${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n if (exportName === 'default') {\n imports.push(`import ${name} from \"${fullPath}\"`)\n } else {\n imports.push(`import { ${exportName} as ${name} } from \"${fullPath}\"`)\n }\n return name\n }\n\n function nextLazy(\n filePath: string,\n loadingName?: string,\n errorName?: string,\n ): string {\n const name = `_${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n const opts: string[] = []\n if (loadingName) opts.push(`loading: ${loadingName}`)\n if (errorName) opts.push(`error: ${errorName}`)\n const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''\n imports.push(`const ${name} = lazy(() => import(\"${fullPath}\")${optsStr})`)\n return name\n }\n\n function nextModuleImport(filePath: string): string {\n const name = `_m${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n imports.push(`import * as ${name} from \"${fullPath}\"`)\n return name\n }\n\n function generatePageRoute(\n page: FileRoute,\n indent: string,\n loadingName: string | undefined,\n errorName: string | undefined,\n ): string {\n const mod = nextModuleImport(page.filePath)\n const comp = nextLazy(page.filePath, loadingName, errorName)\n\n const props: string[] = [\n `${indent} path: ${JSON.stringify(page.urlPath)}`,\n `${indent} component: ${comp}`,\n `${indent} loader: ${mod}.loader`,\n `${indent} beforeEnter: ${mod}.guard`,\n `${indent} meta: ${mod}.meta`,\n ]\n\n if (errorName) {\n props.push(`${indent} errorComponent: ${mod}.error || ${errorName}`)\n } else {\n props.push(`${indent} errorComponent: ${mod}.error`)\n }\n\n return `${indent}{\\n${props.join(',\\n')}\\n${indent}}`\n }\n\n function wrapWithLayout(\n node: RouteNode,\n children: string[],\n indent: string,\n errorName: string | undefined,\n ): string {\n const layout = node.layout as FileRoute\n const layoutMod = nextModuleImport(layout.filePath)\n const layoutComp = nextImport(layout.filePath, 'layout')\n\n const props: string[] = [\n `${indent}path: ${JSON.stringify(layout.urlPath)}`,\n `${indent}component: ${layoutComp}`,\n `${indent}loader: ${layoutMod}.loader`,\n `${indent}beforeEnter: ${layoutMod}.guard`,\n `${indent}meta: ${layoutMod}.meta`,\n ]\n if (errorName) {\n props.push(`${indent}errorComponent: ${errorName}`)\n }\n if (children.length > 0) {\n props.push(`${indent}children: [\\n${children.join(',\\n')}\\n${indent}]`)\n }\n\n return `${indent}{\\n${props.map((p) => ` ${p}`).join(',\\n')}\\n${indent}}`\n }\n\n /**\n * Generate route definitions for a tree node.\n */\n function generateNode(node: RouteNode, depth: number): string[] {\n const indent = ' '.repeat(depth + 1)\n\n const errorName = node.error ? nextImport(node.error.filePath) : undefined\n const loadingName = node.loading\n ? nextImport(node.loading.filePath)\n : undefined\n\n const childRouteDefs: string[] = []\n for (const [, childNode] of node.children) {\n childRouteDefs.push(...generateNode(childNode, depth + 1))\n }\n\n const pageRouteDefs = node.pages.map((page) =>\n generatePageRoute(page, indent, loadingName, errorName),\n )\n\n const allChildren = [...pageRouteDefs, ...childRouteDefs]\n\n if (node.layout) {\n return [wrapWithLayout(node, allChildren, indent, errorName)]\n }\n return allChildren\n }\n\n const routeDefs = generateNode(tree, 0)\n\n return [\n `import { lazy } from \"@pyreon/router\"`,\n '',\n ...imports,\n '',\n // Filter out undefined properties at runtime\n `function clean(routes) {`,\n ` return routes.map(r => {`,\n ` const c = {}`,\n ` for (const k in r) if (r[k] !== undefined) c[k] = r[k]`,\n ` if (c.children) c.children = clean(c.children)`,\n ` return c`,\n ` })`,\n `}`,\n '',\n `export const routes = clean([`,\n routeDefs.join(',\\n'),\n `])`,\n ].join('\\n')\n}\n\n/**\n * Scan a directory for route files.\n * Returns paths relative to the routes directory.\n */\nexport async function scanRouteFiles(routesDir: string): Promise<string[]> {\n const { readdir } = await import('node:fs/promises')\n const { join, relative } = await import('node:path')\n\n const files: string[] = []\n\n async function walk(dir: string) {\n const entries = await readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n const fullPath = join(dir, entry.name)\n if (entry.isDirectory()) {\n await walk(fullPath)\n } else if (ROUTE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {\n files.push(relative(routesDir, fullPath))\n }\n }\n }\n\n await walk(routesDir)\n return files\n}\n"],"mappings":";AA2BA,MAAM,mBAAmB;CAAC;CAAQ;CAAQ;CAAO;CAAM;;;;;AA4UvD,eAAsB,eAAe,WAAsC;CACzE,MAAM,EAAE,YAAY,MAAM,OAAO;CACjC,MAAM,EAAE,MAAM,aAAa,MAAM,OAAO;CAExC,MAAM,QAAkB,EAAE;CAE1B,eAAe,KAAK,KAAa;EAC/B,MAAM,UAAU,MAAM,QAAQ,KAAK,EAAE,eAAe,MAAM,CAAC;AAC3D,OAAK,MAAM,SAAS,SAAS;GAC3B,MAAM,WAAW,KAAK,KAAK,MAAM,KAAK;AACtC,OAAI,MAAM,aAAa,CACrB,OAAM,KAAK,SAAS;YACX,iBAAiB,MAAM,QAAQ,MAAM,KAAK,SAAS,IAAI,CAAC,CACjE,OAAM,KAAK,SAAS,WAAW,SAAS,CAAC;;;AAK/C,OAAM,KAAK,UAAU;AACrB,QAAO"}
|