@pyreon/zero 0.21.0 → 0.23.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/README.md +211 -57
- package/lib/_chunks/app-BbPT0Y5M.js +36 -0
- package/lib/{fs-router-Bacdhsq-.js → _chunks/fs-router-DvBlRzmP.js} +21 -5
- package/lib/_chunks/use-intersection-observer-C6opeplh.js +29 -0
- package/lib/actions.js +24 -3
- package/lib/ai.js +1 -102
- package/lib/client.js +3 -33
- package/lib/csp.js +12 -9
- package/lib/favicon.js +1 -1
- package/lib/font.js +1 -1
- package/lib/image-plugin.js +1 -1
- package/lib/image.js +3 -27
- package/lib/index.js +8 -1085
- package/lib/link.js +3 -27
- package/lib/meta.js +1 -25
- package/lib/script.js +2 -26
- package/lib/seo.js +4 -4
- package/lib/server.js +275 -2129
- package/lib/testing.js +1 -69
- package/lib/theme.js +52 -22
- package/lib/types/config.d.ts +115 -0
- package/lib/types/csp.d.ts +9 -1
- package/lib/types/index.d.ts +120 -1
- package/lib/types/server.d.ts +192 -17
- package/lib/types/theme.d.ts +11 -2
- package/package.json +10 -10
- package/src/actions.ts +43 -5
- package/src/adapters/bun.ts +35 -7
- package/src/adapters/cloudflare.ts +17 -12
- package/src/adapters/netlify.ts +7 -1
- package/src/adapters/node.ts +33 -6
- package/src/adapters/vercel.ts +25 -4
- package/src/csp.ts +10 -7
- package/src/fs-router.ts +2 -1
- package/src/isr.ts +256 -51
- package/src/manifest.ts +23 -10
- package/src/server.ts +2 -1
- package/src/ssg-plugin.ts +27 -7
- package/src/theme.tsx +94 -38
- package/src/types.ts +76 -0
- package/lib/api-routes-CMsLztoj.js +0 -148
- package/lib/fs-router-3xzp-4Wj.js +0 -32
- package/lib/rolldown-runtime-CjeV3_4I.js +0 -18
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @pyreon/zero
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Zero-config full-stack meta-framework — file-system routing, SSR/SSG/ISR/SPA, deploy adapters.
|
|
4
|
+
|
|
5
|
+
`@pyreon/zero` wraps `@pyreon/server` + `@pyreon/router` + `@pyreon/head` + `@pyreon/vite-plugin` into a single Vite plugin and a conventions-based project layout: `src/routes/` is the route tree (`[param]`, `[...catchAll]`, `_layout`, `_404`, `_loading`, `_error`, `(groups)`), per-file `export const { loader, meta, middleware, getStaticPaths, revalidate, renderMode }` opts into capabilities, and `mode: 'ssr' | 'ssg' | 'isr' | 'spa'` picks the rendering strategy. Production builds run through one of six deploy adapters (Vercel / Cloudflare Pages / Netlify / Node / Bun / static). The main entry is **client-safe**; server-only APIs live at `@pyreon/zero/server`.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -8,70 +10,222 @@ Core meta-framework for building full-stack apps with [Pyreon](https://github.co
|
|
|
8
10
|
bun add @pyreon/zero
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- **Rendering modes** — SSR (streaming + string), SSG, ISR, SPA (per-route configurable via `renderMode` export)
|
|
15
|
-
- **API routes** — `.ts` files in `src/routes/api/` export HTTP method handlers (`GET`, `POST`, `PUT`, `DELETE`)
|
|
16
|
-
- **Server actions** — `defineAction()` for mutations with automatic client/server boundary detection
|
|
17
|
-
- **Per-route middleware** — route files export `middleware` using `@pyreon/server`'s signature
|
|
18
|
-
- **Components** — `<Image>` (lazy load, srcset, blur-up), `<Link>` (prefetch, active state), `<Script>` (loading strategies)
|
|
19
|
-
- **Theme** — Dark/light/system with `theme` signal, `<ThemeToggle>`, and anti-flash inline script
|
|
20
|
-
- **Fonts** — Google Fonts self-hosting at build time, local fonts, size-adjusted fallbacks
|
|
21
|
-
- **Image optimization** — Build-time processing via `?optimize` imports (WebP/AVIF, blur placeholders). Type the custom queries with one line — `/// <reference types="@pyreon/zero/image-types" />` — which ships ambient `declare module "*?optimize"` / `"*?component"` / `"*?raw"` reusing the plugin's own `ProcessedImage`.
|
|
22
|
-
- **SEO** — Sitemap, robots.txt, JSON-LD helpers (Vite plugin + dev middleware)
|
|
23
|
-
- **Middleware** — `cacheMiddleware()`, `securityHeaders()`, `corsMiddleware()`, `rateLimitMiddleware()`, `compressionMiddleware()`
|
|
24
|
-
- **Adapters** — Node.js, Bun, static, Vercel, Cloudflare Pages, Netlify Functions
|
|
25
|
-
- **Testing** — `createTestContext()`, `testMiddleware()`, `createTestApiServer()`, `createMockHandler()`
|
|
26
|
-
- **Dev overlay** — Styled error overlay with source-mapped stack traces for SSR errors
|
|
27
|
-
- **CSP middleware** — `cspMiddleware({ directives })` with `useNonce()` for inline scripts
|
|
28
|
-
- **Env validation** — `validateEnv()` with type coercion, `schema()` for custom parsers, `publicEnv()`
|
|
29
|
-
- **Request logging** — `loggerMiddleware()` with structured output
|
|
30
|
-
- **AI integration** — `aiPlugin()` for llms.txt, JSON-LD inference, AI plugin manifest
|
|
31
|
-
- **useRequestLocals()** — Bridge middleware locals into components
|
|
32
|
-
- **Locale-aware favicons** — Per-locale favicon generation from source SVG/PNG
|
|
33
|
-
- **OG image generation** — Build-time Open Graph image rendering
|
|
34
|
-
- **Reactive favicon** — Theme-synced light/dark favicon switching
|
|
35
|
-
- **Client-safe entry** — `@pyreon/zero` = client-safe, `@pyreon/zero/server` = server-only
|
|
36
|
-
|
|
37
|
-
## Usage
|
|
13
|
+
`sharp` is an optional peer dep — install it (`bun add -D sharp`) only if you use `imagePlugin` / `faviconPlugin` / `ogImagePlugin`.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
38
16
|
|
|
39
17
|
```ts
|
|
40
18
|
// vite.config.ts
|
|
19
|
+
import { defineConfig } from 'vite'
|
|
41
20
|
import pyreon from '@pyreon/vite-plugin'
|
|
42
|
-
import zero from '@pyreon/zero'
|
|
21
|
+
import zero from '@pyreon/zero/server'
|
|
22
|
+
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
plugins: [
|
|
25
|
+
pyreon({ islands: true }),
|
|
26
|
+
zero({
|
|
27
|
+
mode: 'ssr', // 'ssr' | 'ssg' | 'isr' | 'spa'
|
|
28
|
+
ssr: { mode: 'stream' }, // 'string' | 'stream'
|
|
29
|
+
adapter: 'node', // 'vercel' | 'cloudflare' | 'netlify' | 'node' | 'bun' | 'static'
|
|
30
|
+
}),
|
|
31
|
+
],
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
// src/routes/index.tsx — the homepage
|
|
37
|
+
import { useLoaderData } from '@pyreon/router'
|
|
43
38
|
|
|
44
|
-
export
|
|
45
|
-
|
|
39
|
+
export const loader = async () => fetch('/api/hello').then((r) => r.json())
|
|
40
|
+
|
|
41
|
+
export default function Home() {
|
|
42
|
+
const data = useLoaderData<{ message: string }>()
|
|
43
|
+
return <h1>{data.message}</h1>
|
|
46
44
|
}
|
|
47
45
|
```
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
|
67
|
-
|
|
68
|
-
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
47
|
+
```tsx
|
|
48
|
+
// src/routes/_layout.tsx — wraps every route in this subtree
|
|
49
|
+
import { Link, Meta } from '@pyreon/zero'
|
|
50
|
+
|
|
51
|
+
export default function Layout({ children }) {
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<Meta title="My App" description="..." />
|
|
55
|
+
<nav><Link to="/">Home</Link> <Link to="/posts">Posts</Link></nav>
|
|
56
|
+
<main>{children}</main>
|
|
57
|
+
</>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## File-system routing
|
|
63
|
+
|
|
64
|
+
| File | Role |
|
|
65
|
+
|----------------------------|-------------------------------------------------------------------|
|
|
66
|
+
| `src/routes/index.tsx` | `/` — homepage |
|
|
67
|
+
| `src/routes/about.tsx` | `/about` |
|
|
68
|
+
| `src/routes/[id].tsx` | `/:id` — dynamic param |
|
|
69
|
+
| `src/routes/[...slug].tsx` | `/*` — catch-all |
|
|
70
|
+
| `src/routes/_layout.tsx` | Wraps the whole subtree |
|
|
71
|
+
| `src/routes/_404.tsx` | Not-found page (auto-emitted as `dist/404.html` in SSG) |
|
|
72
|
+
| `src/routes/_error.tsx` | Route-level error boundary |
|
|
73
|
+
| `src/routes/_loading.tsx` | Loader-in-flight component |
|
|
74
|
+
| `src/routes/(group)/x.tsx` | `/x` — group prefix is stripped from the URL |
|
|
75
|
+
| `src/routes/api/*.ts` | API routes — `export function GET / POST / PUT / DELETE / …` |
|
|
76
|
+
|
|
77
|
+
Each route file may also export `loader`, `meta`, `middleware`, `guard`, `getStaticPaths`, `revalidate`, and `renderMode`.
|
|
78
|
+
|
|
79
|
+
## Rendering modes
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
zero({ mode: 'ssr' }) // server-rendered per request (default)
|
|
83
|
+
zero({ mode: 'ssg' }) // prerender every static path at build time → dist/<path>/index.html
|
|
84
|
+
zero({ mode: 'isr' }) // SSR + in-memory LRU cache, on-demand revalidation
|
|
85
|
+
zero({ mode: 'spa' }) // client-only — single dist/index.html shell
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Per-route override: `export const renderMode = 'ssg'`.
|
|
89
|
+
|
|
90
|
+
## SSG
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
// src/routes/posts/[slug].tsx — enumerate static paths at build time
|
|
94
|
+
import type { GetStaticPaths } from '@pyreon/zero/server'
|
|
95
|
+
|
|
96
|
+
export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
|
|
97
|
+
const posts = await loadAllPosts()
|
|
98
|
+
return posts.map((p) => ({ params: { slug: p.slug } }))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const revalidate = 3600 // optional — build-time ISR (per platform adapter)
|
|
102
|
+
|
|
103
|
+
export const loader = ({ params }) => fetchPost(params.slug)
|
|
104
|
+
export default function Post() { /* ... */ }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
SSG features (all on by default; opt out via `ssg: { ... }`):
|
|
108
|
+
|
|
109
|
+
- `_404.tsx` → `dist/404.html` (per-locale variants if i18n configured)
|
|
110
|
+
- Loader-thrown `redirect()` → `dist/_redirects` (Netlify/Cloudflare) + `_redirects.json` (Vercel)
|
|
111
|
+
- Sitemap auto-emit (via `seoPlugin({ sitemap: { useSsgPaths: true } })`)
|
|
112
|
+
- Concurrent rendering (`ssg.concurrency`, default 4) + per-path `onProgress` callbacks
|
|
113
|
+
- Render-error fallback via `ssg.onPathError`; structured `_pyreon-ssg-errors.json` artifact
|
|
114
|
+
- Path-collision detection (loud build failure on duplicate URLs)
|
|
115
|
+
|
|
116
|
+
## ISR
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
zero({ mode: 'isr', isr: { revalidate: 60, maxEntries: 1000 } })
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
In-memory LRU SSR cache with TTL revalidation. **Default keys cache by `url.pathname` only** — for auth-gated pages, supply `cacheKey: (req) => …` that varies on session cookie / user-id header to avoid serving one user's HTML to another.
|
|
123
|
+
|
|
124
|
+
## Built-in components
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
import { Image, Link, Script, Meta, Icon, createIcon, createNamedIcon, ThemeToggle } from '@pyreon/zero'
|
|
128
|
+
|
|
129
|
+
<Image src="/hero.jpg" width={1200} height={600} placeholder="blur" />
|
|
130
|
+
<Link to="/about" prefetch="intent">About</Link>
|
|
131
|
+
<Script strategy="afterInteractive" src="https://analytics.example.com/script.js" />
|
|
132
|
+
<Meta title="..." description="..." />
|
|
133
|
+
<Icon as={MyIconSvgComponent} /> {/* loaded via `?component` */}
|
|
134
|
+
<ThemeToggle /> {/* light/dark/system mode */}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`<Image>` ships with `imagePlugin` (build-time WebP/AVIF + blur/color placeholders). `<Link>` is `@pyreon/router`'s `RouterLink` re-exported. `<Meta>` writes via `@pyreon/head`.
|
|
138
|
+
|
|
139
|
+
## Vite plugins (server-only)
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { faviconPlugin, iconsPlugin, ogImagePlugin, seoPlugin, aiPlugin } from '@pyreon/zero/server'
|
|
143
|
+
|
|
144
|
+
// vite.config.ts
|
|
145
|
+
plugins: [
|
|
146
|
+
zero({ /* ... */ }),
|
|
147
|
+
iconsPlugin({
|
|
148
|
+
sets: {
|
|
149
|
+
ui: { dir: './src/icons/ui' },
|
|
150
|
+
brand: { dir: './src/icons/brand', mode: 'image' },
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
faviconPlugin({ source: './src/favicon.svg' }),
|
|
154
|
+
ogImagePlugin({ templates: { default: { /* ... */ } } }),
|
|
155
|
+
seoPlugin({ sitemap: { useSsgPaths: true }, robots: true }),
|
|
156
|
+
aiPlugin(), // generates llms.txt + JSON-LD inference + AI plugin manifest
|
|
157
|
+
]
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Deploy adapters
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import { vercelAdapter, cloudflareAdapter, netlifyAdapter, nodeAdapter, bunAdapter, staticAdapter } from '@pyreon/zero/server'
|
|
164
|
+
|
|
165
|
+
zero({ adapter: vercelAdapter() })
|
|
166
|
+
// or by string id:
|
|
167
|
+
zero({ adapter: 'cloudflare' })
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Each adapter writes its own platform config (`.vercel/output/config.json`, `_routes.json`, `netlify.toml`, etc.) during `closeBundle`. Adapters with revalidation support (`vercel` / `cloudflare` / `netlify`) implement `Adapter.revalidate(path)` — pair with `vercelRevalidateHandler` for the canonical webhook scaffold.
|
|
171
|
+
|
|
172
|
+
## Server middleware
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { compose } from '@pyreon/zero/server'
|
|
176
|
+
import { cspMiddleware, useNonce } from '@pyreon/zero/csp'
|
|
177
|
+
import { loggerMiddleware } from '@pyreon/zero/logger'
|
|
178
|
+
import { corsMiddleware } from '@pyreon/zero/cors'
|
|
179
|
+
import { rateLimitMiddleware } from '@pyreon/zero/rate-limit'
|
|
180
|
+
import { compressionMiddleware } from '@pyreon/zero/compression'
|
|
181
|
+
|
|
182
|
+
const handler = compose([
|
|
183
|
+
loggerMiddleware(),
|
|
184
|
+
corsMiddleware({ origin: 'https://app.example.com' }),
|
|
185
|
+
rateLimitMiddleware({ windowMs: 60_000, max: 100 }),
|
|
186
|
+
cspMiddleware({ directives: { 'script-src': ["'self'", "'nonce-{nonce}'"] } }),
|
|
187
|
+
compressionMiddleware(),
|
|
188
|
+
])
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## i18n routing
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
zero({
|
|
195
|
+
i18n: { locales: ['en', 'de', 'cs'], defaultLocale: 'en', strategy: 'prefix-except-default' },
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Routes are duplicated per locale at build time. `prefix-except-default` keeps the default locale unprefixed (`/about`) and prefixes others (`/de/about`); `prefix` prefixes every locale including the default. Loader context + sitemap hreflang siblings + per-locale `_404.tsx` all compose automatically.
|
|
200
|
+
|
|
201
|
+
## Subpath exports (server-only)
|
|
202
|
+
|
|
203
|
+
| Subpath | Notes |
|
|
204
|
+
|-------------------------------|--------------------------------------------------------------------------------------|
|
|
205
|
+
| `@pyreon/zero/server` | `createServer`, `createApp`, `createISRHandler`, adapters, plugins, `vercelRevalidateHandler` |
|
|
206
|
+
| `@pyreon/zero/client` | `startClient`, `hydrateIslands*` re-exports |
|
|
207
|
+
| `@pyreon/zero/config` | `defineConfig`, `resolveConfig` |
|
|
208
|
+
| `@pyreon/zero/env` | `validateEnv`, `publicEnv`, `schema` |
|
|
209
|
+
| `@pyreon/zero/middleware` | Generic `Middleware` helpers |
|
|
210
|
+
| `@pyreon/zero/testing` | `createTestContext`, `testMiddleware`, `createTestApiServer` |
|
|
211
|
+
|
|
212
|
+
The main entry (`@pyreon/zero`) re-exports browser-safe pieces only — components, theme, i18n helpers. Server APIs imported from the main entry throw a clear error pointing at the right subpath.
|
|
213
|
+
|
|
214
|
+
## Gotchas
|
|
215
|
+
|
|
216
|
+
- `@pyreon/zero` ≠ `@pyreon/zero/server` — the main entry is client-safe. Server plugins (`faviconPlugin`, `seoPlugin`, `createServer`) MUST be imported from `/server`. Importing them from the main entry throws at module-load with a pointer to the right path.
|
|
217
|
+
- ISR with auth-gated pages needs `cacheKey: (req) => …` that varies on session — the default keys by `url.pathname` only and will serve one user's HTML to another.
|
|
218
|
+
- `_404.tsx` rendered HTML is emitted by SSG, but **static hosts must be configured to serve it** for unmatched URLs (most managed hosts do this by convention; bare S3 / nginx / Caddy need explicit per-locale `try_files` / `[[redirects]]`).
|
|
219
|
+
- `getStaticPaths` / `revalidate` literal-extraction skips re-exports + non-literal expressions. Inline the value (`export const revalidate = 60`), don't reference a const.
|
|
220
|
+
- `sharp` is optional. Without it, `imagePlugin` falls back to a soft warning in dev and a HARD `vite build` error in prod (never silently ships an image-broken site).
|
|
221
|
+
- Never pass `layout` to `startClient` when using fs-router's `_layout.tsx` convention — the route tree already wraps every page in the layout, and the explicit option double-mounts.
|
|
222
|
+
|
|
223
|
+
## Documentation
|
|
224
|
+
|
|
225
|
+
Full docs: [docs.pyreon.dev/docs/zero](https://docs.pyreon.dev/docs/zero) (or `docs/docs/zero.md` in this repo).
|
|
226
|
+
|
|
227
|
+
SSG-specific guide: [docs.pyreon.dev/docs/ssg](https://docs.pyreon.dev/docs/ssg).
|
|
74
228
|
|
|
75
229
|
## License
|
|
76
230
|
|
|
77
|
-
|
|
231
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Fragment, h } from "@pyreon/core";
|
|
2
|
+
import { RouterProvider, RouterView, createRouter } from "@pyreon/router";
|
|
3
|
+
import { HeadProvider } from "@pyreon/head";
|
|
4
|
+
|
|
5
|
+
//#region src/app.ts
|
|
6
|
+
/**
|
|
7
|
+
* Create a full Zero app — assembles router, head provider, and root layout.
|
|
8
|
+
*
|
|
9
|
+
* Used internally by entry-server and entry-client.
|
|
10
|
+
*/
|
|
11
|
+
function createApp(options) {
|
|
12
|
+
const router = createRouter({
|
|
13
|
+
routes: options.routes,
|
|
14
|
+
mode: options.routerMode ?? "history",
|
|
15
|
+
...options.url ? { url: options.url } : {},
|
|
16
|
+
...options.base && options.base !== "/" ? { base: options.base } : {},
|
|
17
|
+
scrollBehavior: "top"
|
|
18
|
+
});
|
|
19
|
+
const hasLayoutInRoutes = options.layout !== void 0 && options.routes.some((r) => r.component === options.layout);
|
|
20
|
+
if (hasLayoutInRoutes && process.env.NODE_ENV !== "production") console.warn("[Pyreon] `createApp({ layout })` was passed a component that is ALSO a parent route in the matched chain (likely an fs-router `_layout.tsx`). The explicit `layout` option is being ignored to prevent double-mount. Remove the `layout` argument from `createApp`/`startClient` — the fs-router-emitted route handles it.");
|
|
21
|
+
const Layout = hasLayoutInRoutes ? DefaultLayout : options.layout ?? DefaultLayout;
|
|
22
|
+
function App() {
|
|
23
|
+
return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
App,
|
|
27
|
+
router
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function DefaultLayout(props) {
|
|
31
|
+
return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
export { createApp as t };
|
|
36
|
+
//# sourceMappingURL=app-BbPT0Y5M.js.map
|
|
@@ -1,7 +1,23 @@
|
|
|
1
|
-
import { t as __exportAll } from "./rolldown-runtime-CjeV3_4I.js";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
|
|
4
|
+
//#region \0rolldown/runtime.js
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __exportAll = (all, no_symbols) => {
|
|
7
|
+
let target = {};
|
|
8
|
+
for (var name in all) {
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
if (!no_symbols) {
|
|
15
|
+
__defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
16
|
+
}
|
|
17
|
+
return target;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
5
21
|
//#region src/fs-router.ts
|
|
6
22
|
var fs_router_exports = /* @__PURE__ */ __exportAll({
|
|
7
23
|
detectRouteExports: () => detectRouteExports,
|
|
@@ -752,7 +768,7 @@ function generateRouteModuleFromRoutes(routes, routesDir, options) {
|
|
|
752
768
|
if (errorName) opts.push(`error: ${errorName}`);
|
|
753
769
|
opts.push(`hmrId: ${JSON.stringify(fullPath)}`);
|
|
754
770
|
const optsStr = `, { ${opts.join(", ")} }`;
|
|
755
|
-
imports.push(`const ${name} = lazy(() => import(
|
|
771
|
+
imports.push(`const ${name} = lazy(() => import(${JSON.stringify(fullPath)})${optsStr})`);
|
|
756
772
|
return name;
|
|
757
773
|
}
|
|
758
774
|
/**
|
|
@@ -969,7 +985,7 @@ async function scanRouteFiles(routesDir) {
|
|
|
969
985
|
*/
|
|
970
986
|
async function scanRouteFilesWithExports(routesDir, defaultMode = "ssr") {
|
|
971
987
|
const { readFile } = await import("node:fs/promises");
|
|
972
|
-
const { isApiRoute } = await import("
|
|
988
|
+
const { isApiRoute } = await import("../api-routes.js");
|
|
973
989
|
const files = (await scanRouteFiles(routesDir)).filter((f) => !isApiRoute(f));
|
|
974
990
|
const exportsMap = /* @__PURE__ */ new Map();
|
|
975
991
|
await Promise.all(files.map(async (filePath) => {
|
|
@@ -984,5 +1000,5 @@ async function scanRouteFilesWithExports(routesDir, defaultMode = "ssr") {
|
|
|
984
1000
|
}
|
|
985
1001
|
|
|
986
1002
|
//#endregion
|
|
987
|
-
export { generateRouteModuleFromRoutes as a, scanRouteFilesWithExports as c, generateRouteModule as i, fs_router_exports as n, parseFileRoutes as o, generateMiddlewareModule as r, scanRouteFiles as s, filePathToUrlPath as t };
|
|
988
|
-
//# sourceMappingURL=fs-router-
|
|
1003
|
+
export { generateRouteModuleFromRoutes as a, scanRouteFilesWithExports as c, generateRouteModule as i, __exportAll as l, fs_router_exports as n, parseFileRoutes as o, generateMiddlewareModule as r, scanRouteFiles as s, filePathToUrlPath as t };
|
|
1004
|
+
//# sourceMappingURL=fs-router-DvBlRzmP.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { onMount, onUnmount } from "@pyreon/core";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/use-intersection-observer.ts
|
|
4
|
+
/**
|
|
5
|
+
* Observes an element and calls `onIntersect` once it enters the viewport.
|
|
6
|
+
* Automatically disconnects after the first intersection.
|
|
7
|
+
*
|
|
8
|
+
* @param getElement - Getter for the target element (may be undefined before mount).
|
|
9
|
+
* @param onIntersect - Callback fired when the element becomes visible.
|
|
10
|
+
* @param rootMargin - IntersectionObserver rootMargin. Default: "200px".
|
|
11
|
+
*/
|
|
12
|
+
function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px") {
|
|
13
|
+
onMount(() => {
|
|
14
|
+
const el = getElement();
|
|
15
|
+
if (!el) return void 0;
|
|
16
|
+
const observer = new IntersectionObserver((entries) => {
|
|
17
|
+
for (const entry of entries) if (entry.isIntersecting) {
|
|
18
|
+
onIntersect();
|
|
19
|
+
observer.disconnect();
|
|
20
|
+
}
|
|
21
|
+
}, { rootMargin });
|
|
22
|
+
observer.observe(el);
|
|
23
|
+
onUnmount(() => observer.disconnect());
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
export { useIntersectionObserver as t };
|
|
29
|
+
//# sourceMappingURL=use-intersection-observer-C6opeplh.js.map
|
package/lib/actions.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
//#region src/actions.ts
|
|
2
|
+
/**
|
|
3
|
+
* Module-level registry of every `defineAction()` call. Lookup is by the
|
|
4
|
+
* `action_<uuid>` string the client sends in `POST /_zero/actions/<id>`.
|
|
5
|
+
*
|
|
6
|
+
* **HMR caveat (dev-only):** the registry uses fresh `crypto.randomUUID()`
|
|
7
|
+
* per `defineAction()` invocation. When Vite hot-replaces a module that
|
|
8
|
+
* calls `defineAction()`, the module re-runs and a NEW entry is inserted
|
|
9
|
+
* — the OLD entry stays in the Map until the dev process exits. Each
|
|
10
|
+
* entry holds `{ id, handler }` (~80 bytes). Bounded by the count of
|
|
11
|
+
* distinct UUIDs minted in the session; a realistic dev session sees
|
|
12
|
+
* <50 entries, so total dev-memory cost stays under ~5KB. Production
|
|
13
|
+
* registers each module exactly once at startup — no leak. A
|
|
14
|
+
* FinalizationRegistry-based purge is tracked as a follow-up; the
|
|
15
|
+
* current cost is too small to justify the WeakRef/finalizer complexity.
|
|
16
|
+
*/
|
|
2
17
|
const actionRegistry = /* @__PURE__ */ new Map();
|
|
3
18
|
/**
|
|
4
19
|
* Define a server action. Returns a callable function that:
|
|
@@ -73,12 +88,17 @@ function createActionMiddleware() {
|
|
|
73
88
|
};
|
|
74
89
|
}
|
|
75
90
|
async function executeAction(action, req) {
|
|
91
|
+
const contentType = req.headers.get("content-type") ?? "";
|
|
92
|
+
let formData = null;
|
|
93
|
+
let json = null;
|
|
76
94
|
try {
|
|
77
|
-
const contentType = req.headers.get("content-type") ?? "";
|
|
78
|
-
let formData = null;
|
|
79
|
-
let json = null;
|
|
80
95
|
if (contentType.includes("application/json")) json = await req.json();
|
|
81
96
|
else if (contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded")) formData = await req.formData();
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error("[Pyreon Action] failed to parse request body:", err);
|
|
99
|
+
return Response.json({ error: "Invalid request body" }, { status: 400 });
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
82
102
|
const result = await action.handler({
|
|
83
103
|
request: req,
|
|
84
104
|
formData,
|
|
@@ -87,6 +107,7 @@ async function executeAction(action, req) {
|
|
|
87
107
|
});
|
|
88
108
|
return Response.json(result ?? null);
|
|
89
109
|
} catch (err) {
|
|
110
|
+
console.error("[Pyreon Action] handler failed:", err);
|
|
90
111
|
const message = err instanceof Error ? err.message : "Internal server error";
|
|
91
112
|
return Response.json({ error: message }, { status: 500 });
|
|
92
113
|
}
|
package/lib/ai.js
CHANGED
|
@@ -1,106 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
const ROUTE_EXTENSIONS = [
|
|
3
|
-
".tsx",
|
|
4
|
-
".jsx",
|
|
5
|
-
".ts",
|
|
6
|
-
".js"
|
|
7
|
-
];
|
|
8
|
-
/**
|
|
9
|
-
* Parse a set of file paths (relative to routes dir) into FileRoute objects.
|
|
10
|
-
*
|
|
11
|
-
* @param files Array of file paths like ["index.tsx", "users/[id].tsx"]
|
|
12
|
-
* @param defaultMode Default rendering mode from config
|
|
13
|
-
* @param exportsMap Optional map of filePath → detected exports. When
|
|
14
|
-
* provided, the resulting FileRoute objects carry export info that the
|
|
15
|
-
* code generator uses to optimize imports (skip metadata namespace
|
|
16
|
-
* imports for routes that only export `default`).
|
|
17
|
-
*/
|
|
18
|
-
function parseFileRoutes(files, defaultMode = "ssr", exportsMap) {
|
|
19
|
-
return files.filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext))).map((filePath) => {
|
|
20
|
-
const route = parseFilePath(filePath, defaultMode);
|
|
21
|
-
const exp = exportsMap?.get(filePath);
|
|
22
|
-
return exp ? {
|
|
23
|
-
...route,
|
|
24
|
-
exports: exp
|
|
25
|
-
} : route;
|
|
26
|
-
}).sort(sortRoutes);
|
|
27
|
-
}
|
|
28
|
-
function parseFilePath(filePath, defaultMode) {
|
|
29
|
-
let route = filePath;
|
|
30
|
-
for (const ext of ROUTE_EXTENSIONS) if (route.endsWith(ext)) {
|
|
31
|
-
route = route.slice(0, -ext.length);
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
const fileName = getFileName(route);
|
|
35
|
-
const isLayout = fileName === "_layout";
|
|
36
|
-
const isError = fileName === "_error";
|
|
37
|
-
const isLoading = fileName === "_loading";
|
|
38
|
-
const isNotFound = fileName === "_404" || fileName === "_not-found";
|
|
39
|
-
const isCatchAll = route.includes("[...");
|
|
40
|
-
const parts = route.split("/");
|
|
41
|
-
parts.pop();
|
|
42
|
-
const dirPath = parts.filter((s) => !(s.startsWith("(") && s.endsWith(")"))).join("/");
|
|
43
|
-
const urlPath = filePathToUrlPath(route);
|
|
44
|
-
return {
|
|
45
|
-
filePath,
|
|
46
|
-
urlPath,
|
|
47
|
-
dirPath,
|
|
48
|
-
depth: urlPath === "/" ? 0 : urlPath.split("/").filter(Boolean).length,
|
|
49
|
-
isLayout,
|
|
50
|
-
isError,
|
|
51
|
-
isLoading,
|
|
52
|
-
isNotFound,
|
|
53
|
-
isCatchAll,
|
|
54
|
-
renderMode: defaultMode
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Convert a file path (without extension) to a URL path pattern.
|
|
59
|
-
*
|
|
60
|
-
* Examples:
|
|
61
|
-
* "index" → "/"
|
|
62
|
-
* "about" → "/about"
|
|
63
|
-
* "users/index" → "/users"
|
|
64
|
-
* "users/[id]" → "/users/:id"
|
|
65
|
-
* "blog/[...slug]" → "/blog/:slug*"
|
|
66
|
-
* "(auth)/login" → "/login" (group stripped)
|
|
67
|
-
* "_layout" → "/" (layout marker)
|
|
68
|
-
*/
|
|
69
|
-
function filePathToUrlPath(filePath) {
|
|
70
|
-
const segments = filePath.split("/");
|
|
71
|
-
const urlSegments = [];
|
|
72
|
-
for (const seg of segments) {
|
|
73
|
-
if (seg.startsWith("(") && seg.endsWith(")")) continue;
|
|
74
|
-
if (seg === "_layout" || seg === "_error" || seg === "_loading" || seg === "_404" || seg === "_not-found") continue;
|
|
75
|
-
if (seg === "index") continue;
|
|
76
|
-
const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
|
|
77
|
-
if (catchAll) {
|
|
78
|
-
urlSegments.push(`:${catchAll[1]}*`);
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
const dynamic = seg.match(/^\[(\w+)\]$/);
|
|
82
|
-
if (dynamic) {
|
|
83
|
-
urlSegments.push(`:${dynamic[1]}`);
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
urlSegments.push(seg);
|
|
87
|
-
}
|
|
88
|
-
return `/${urlSegments.join("/")}` || "/";
|
|
89
|
-
}
|
|
90
|
-
/** Sort routes: static before dynamic, catch-all last. */
|
|
91
|
-
function sortRoutes(a, b) {
|
|
92
|
-
if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1;
|
|
93
|
-
if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1;
|
|
94
|
-
const aDynamic = a.urlPath.includes(":");
|
|
95
|
-
if (aDynamic !== b.urlPath.includes(":")) return aDynamic ? 1 : -1;
|
|
96
|
-
return a.urlPath.localeCompare(b.urlPath);
|
|
97
|
-
}
|
|
98
|
-
function getFileName(filePath) {
|
|
99
|
-
const parts = filePath.split("/");
|
|
100
|
-
return parts[parts.length - 1] ?? "";
|
|
101
|
-
}
|
|
1
|
+
import { o as parseFileRoutes } from "./_chunks/fs-router-DvBlRzmP.js";
|
|
102
2
|
|
|
103
|
-
//#endregion
|
|
104
3
|
//#region src/ai.ts
|
|
105
4
|
/**
|
|
106
5
|
* Generate llms.txt content from route files and config.
|
package/lib/client.js
CHANGED
|
@@ -1,38 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { t as createApp } from "./_chunks/app-BbPT0Y5M.js";
|
|
2
|
+
import { h } from "@pyreon/core";
|
|
3
|
+
import { hydrateLoaderData } from "@pyreon/router";
|
|
3
4
|
import { hydrateRoot, mount } from "@pyreon/runtime-dom";
|
|
4
|
-
import { HeadProvider } from "@pyreon/head";
|
|
5
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
|
-
...options.url ? { url: options.url } : {},
|
|
17
|
-
...options.base && options.base !== "/" ? { base: options.base } : {},
|
|
18
|
-
scrollBehavior: "top"
|
|
19
|
-
});
|
|
20
|
-
const hasLayoutInRoutes = options.layout !== void 0 && options.routes.some((r) => r.component === options.layout);
|
|
21
|
-
if (hasLayoutInRoutes && process.env.NODE_ENV !== "production") console.warn("[Pyreon] `createApp({ layout })` was passed a component that is ALSO a parent route in the matched chain (likely an fs-router `_layout.tsx`). The explicit `layout` option is being ignored to prevent double-mount. Remove the `layout` argument from `createApp`/`startClient` — the fs-router-emitted route handles it.");
|
|
22
|
-
const Layout = hasLayoutInRoutes ? DefaultLayout : options.layout ?? DefaultLayout;
|
|
23
|
-
function App() {
|
|
24
|
-
return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
|
|
25
|
-
}
|
|
26
|
-
return {
|
|
27
|
-
App,
|
|
28
|
-
router
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
function DefaultLayout(props) {
|
|
32
|
-
return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
//#endregion
|
|
36
6
|
//#region src/client.ts
|
|
37
7
|
/**
|
|
38
8
|
* Start the client-side app — hydrates SSR content or mounts fresh for SPA.
|
package/lib/csp.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { useRequestLocals } from "@pyreon/server";
|
|
2
2
|
|
|
3
3
|
//#region src/csp.ts
|
|
4
|
-
/** Client-side fallback nonce (dev server, SPA). */
|
|
5
|
-
let _clientNonce = "";
|
|
6
4
|
/**
|
|
7
5
|
* Read the current CSP nonce in a component.
|
|
8
6
|
*
|
|
9
7
|
* SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
|
|
10
8
|
* system — fully isolated between concurrent requests via AsyncLocalStorage.
|
|
11
|
-
*
|
|
9
|
+
*
|
|
10
|
+
* Returns `''` outside an active request context (client-side after
|
|
11
|
+
* hydration, dev preview, or any render path that bypassed the CSP
|
|
12
|
+
* middleware). Nonces are SSR-only by design: a client-side nonce
|
|
13
|
+
* mirrored from the last SSR request is a cross-request bleed waiting
|
|
14
|
+
* to happen, and a build-time-baked nonce would defeat the entire CSP
|
|
15
|
+
* mechanism. If you need a script-tag nonce, render the script during
|
|
16
|
+
* SSR through `useNonce()` so the value the browser sees IS the value
|
|
17
|
+
* the response's `Content-Security-Policy` header authorized.
|
|
12
18
|
*
|
|
13
19
|
* @example
|
|
14
20
|
* ```tsx
|
|
@@ -23,7 +29,7 @@ let _clientNonce = "";
|
|
|
23
29
|
function useNonce() {
|
|
24
30
|
const locals = useRequestLocals();
|
|
25
31
|
if (locals.cspNonce) return locals.cspNonce;
|
|
26
|
-
return
|
|
32
|
+
return "";
|
|
27
33
|
}
|
|
28
34
|
const DIRECTIVE_MAP = {
|
|
29
35
|
defaultSrc: "default-src",
|
|
@@ -112,12 +118,9 @@ function cspMiddleware(config) {
|
|
|
112
118
|
const headerName = config.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";
|
|
113
119
|
const staticHeader = Object.values(config.directives).some((v) => Array.isArray(v) && v.includes("'nonce'")) ? null : buildCspHeader(config.directives);
|
|
114
120
|
return (ctx) => {
|
|
115
|
-
if (staticHeader)
|
|
116
|
-
|
|
117
|
-
ctx.headers.set(headerName, staticHeader);
|
|
118
|
-
} else {
|
|
121
|
+
if (staticHeader) ctx.headers.set(headerName, staticHeader);
|
|
122
|
+
else {
|
|
119
123
|
const nonce = generateNonce();
|
|
120
|
-
_clientNonce = nonce;
|
|
121
124
|
ctx.locals.cspNonce = nonce;
|
|
122
125
|
ctx.headers.set(headerName, buildCspHeader(config.directives, nonce));
|
|
123
126
|
}
|