@pyreon/zero 0.24.4 → 0.24.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. package/src/vite-plugin.ts +0 -848
package/src/manifest.ts DELETED
@@ -1,787 +0,0 @@
1
- import { defineManifest } from '@pyreon/manifest'
2
-
3
- /**
4
- * @pyreon/zero manifest — feeds llms.txt / llms-full.txt / MCP
5
- * api-reference.ts via `bun run gen-docs`. Scope: the SSG roadmap
6
- * surface (zero(), i18n, ISR, adapters, getStaticPaths,
7
- * expandRoutesForLocales, plus the core plugin APIs that compose with
8
- * them). Other zero subpath exports (`/image`, `/font`, `/cache`, etc.)
9
- * stay in CLAUDE.md until a real consumer-side foot-gun surfaces — the
10
- * manifest is for the surface AI agents need to discover, not an
11
- * exhaustive enumeration.
12
- */
13
- export default defineManifest({
14
- name: '@pyreon/zero',
15
- title: 'Zero — Full-Stack Meta-Framework',
16
- tagline:
17
- 'Full-stack meta-framework: fs-routing, SSR/SSG/ISR/SPA, API routes, server actions, adapters, i18n',
18
- description:
19
- "Pyreon's full-stack meta-framework. Single `zero({ mode, base, ssg, i18n })` plugin chooses rendering mode (`ssg` / `ssr` / `isr` / `spa`), wires file-system routing under `src/routes/`, and composes with seo / favicon / og-image / ai / i18n-routing / csp plugins. Per-route exports for `meta`, `getStaticPaths`, `revalidate`, `validateSearch`, `loader`. Deployment via per-platform adapters (Vercel / Cloudflare Pages / Netlify / Node / Bun / static).",
20
- category: 'server',
21
- longExample: `import { defineConfig } from 'vite'
22
- import pyreon from '@pyreon/vite-plugin'
23
- import zero from '@pyreon/zero/server'
24
- import { vercelAdapter, seoPlugin, aiPlugin } from '@pyreon/zero/server'
25
-
26
- // SSG + i18n + dynamic-route × locale cross-product + hreflang sitemap.
27
- // Single zero() call wires routing, mode, base, locales, adapter; the
28
- // composed plugins (seoPlugin/aiPlugin) auto-detect i18n config from the
29
- // SSG manifest, so consumers configure locales once.
30
- export default defineConfig({
31
- plugins: [
32
- pyreon(),
33
- zero({
34
- mode: 'ssg',
35
- i18n: {
36
- locales: ['en', 'de', 'cs'],
37
- defaultLocale: 'en',
38
- strategy: 'prefix-except-default', // default-locale unprefixed for SEO
39
- },
40
- ssg: {
41
- concurrency: 8, // PR D — parallel render workers
42
- onProgress: ({ completed, total }) => console.log(\`\${completed}/\${total}\`),
43
- },
44
- adapter: vercelAdapter(), // PR J — emits .vercel/output/config.json
45
- }),
46
- seoPlugin({ sitemap: { useSsgPaths: true, hreflang: true } }),
47
- aiPlugin(),
48
- ],
49
- })
50
-
51
- // src/routes/posts/[id].tsx — dynamic route with getStaticPaths
52
- // produces 3 IDs × 3 locales = 9 prerendered HTML files under SSG+i18n.
53
- import type { GetStaticPaths } from '@pyreon/zero/server'
54
-
55
- export const getStaticPaths: GetStaticPaths<{ id: string }> = () =>
56
- POSTS.map((p) => ({ params: { id: String(p.id) } }))
57
-
58
- export const revalidate = 60 // PR I — wires platform ISR per-route (Vercel/Cloudflare/Netlify)
59
-
60
- export default function PostPage() { /* component body */ }`,
61
- features: [
62
- 'mode: ssg / ssr / isr / spa — single config field',
63
- 'i18n route duplication (prefix / prefix-except-default strategies)',
64
- 'getStaticPaths per route — dynamic-route × locale compounds at SSG',
65
- 'Per-route revalidate — wires platform ISR via Adapter.revalidate',
66
- 'Concurrent SSG render loop with onProgress callback',
67
- 'Adapter.build() auto-invoked in SSG closeBundle — emits platform routing config',
68
- 'Per-locale 404 + hreflang sitemap (auto-detects i18n config)',
69
- 'Loader-thrown redirect → _redirects manifest (Netlify/Cloudflare/Vercel)',
70
- 'Subpath / base-path single source of truth — zero({ base }) propagates to Vite + router',
71
- ],
72
- // MCP-density entries: dense summary + 6+ mistakes per flagship API.
73
- // Scope: the SSG roadmap surface (i18n, ISR, adapter, getStaticPaths,
74
- // expandRoutesForLocales) + core zero() entry. Subpath plugins
75
- // (seoPlugin, aiPlugin, faviconPlugin, ogImagePlugin) get smaller
76
- // entries — they compose with zero but aren't the i18n/SSG primary
77
- // discovery surface.
78
- api: [
79
- {
80
- name: 'zero',
81
- kind: 'function',
82
- signature:
83
- 'function zero(config?: ZeroConfig): Plugin[] // default export of @pyreon/zero/server',
84
- summary:
85
- "Top-level Vite plugin chain for @pyreon/zero. Single config object selects rendering mode (`'ssr' | 'ssg' | 'isr' | 'spa'`), subpath base (`base: '/blog/'`), SSG settings (paths, concurrency, onProgress, emit404, emitRedirects), i18n config (locales / defaultLocale / strategy), and deployment adapter. Returns `Plugin[]` because the SSG mode adds a companion `ssgPlugin()` automatically — Vite's plugins array natively flattens nested arrays so `plugins: [pyreon(), zero()]` works without spread.",
86
- example: `import zero from '@pyreon/zero/server'
87
-
88
- // SPA (default) — no special config needed
89
- plugins: [pyreon(), zero()]
90
-
91
- // SSG with auto-detected paths + i18n + adapter
92
- plugins: [pyreon(), zero({
93
- mode: 'ssg',
94
- i18n: { locales: ['en','de','cs'], defaultLocale: 'en' },
95
- adapter: vercelAdapter(),
96
- })]
97
-
98
- // Subpath deploy (e.g. served at /blog/)
99
- plugins: [pyreon(), zero({ base: '/blog/', mode: 'ssg' })]`,
100
- mistakes: [
101
- "Setting `base` in BOTH `vite.config.base` AND `zero({ base })` and expecting them to merge — user's explicit `vite.config.base` overrides the plugin-returned base. Set base ONCE via `zero({ base })`; let it propagate to Vite + router automatically",
102
- "Passing `layout` to `createApp` / `startClient` when fs-router already emits `_layout.tsx` as a parent route — double-mounts the layout. Drop the explicit option; `_layout.tsx` is the canonical layout registration",
103
- "Mixing `mode: 'ssg'` with a runtime adapter that has no SSG branch (e.g. expecting `nodeAdapter` to write platform routing config under SSG) — node/bun/static adapters no-op for SSG; use vercel/cloudflare/netlify if you need platform routing emission",
104
- "Configuring `ssg.paths` AND per-route `getStaticPaths` together for the same dynamic route — both produce the same path list and the SSG plugin renders each path TWICE (the second pass overwrites). Pick one: `ssg.paths` for top-down explicit lists, `getStaticPaths` for per-route enumerators",
105
- 'Forgetting that `mode: \'ssg\'` returns `Plugin[]` (not a single Plugin) — any downstream test code that does `plugins: [zeroPlugin().name]` instead of `plugins: zeroPlugin()` breaks',
106
- "Setting `ssg.concurrency` higher than the data layer's connection ceiling — loaders running concurrently overwhelm the upstream (db pool, external API rate limit). Default `4` is safe; raise after profiling, lower to `1` for serial-required loaders",
107
- ],
108
- seeAlso: ['I18nRoutingConfig', 'GetStaticPaths', 'Adapter', 'createISRHandler'],
109
- },
110
- {
111
- name: 'I18nRoutingConfig',
112
- kind: 'type',
113
- signature:
114
- "interface I18nRoutingConfig { locales: string[]; defaultLocale: string; strategy?: 'prefix' | 'prefix-except-default' }",
115
- summary:
116
- "Configuration shape for `zero({ i18n })`. `locales` is the supported BCP-47 list (validated against path-traversal — `..`, `/`, backslash, `.`, leading-dot, NUL chars rejected). `defaultLocale` is the canonical / SEO-primary locale. `strategy` selects URL shape — `'prefix-except-default'` (default) keeps `/about` unprefixed for the default locale + emits `/de/about` etc. for non-defaults (best for SEO-on-default-locale apps); `'prefix'` prefixes every locale including default (`/en/about`, `/de/about`) for apps with no primary locale.",
117
- example: `// Prefix-except-default (canonical SEO shape — default unprefixed)
118
- zero({ i18n: { locales: ['en','de','cs'], defaultLocale: 'en' } })
119
- // Emits: /about, /de/about, /cs/about
120
- // Default locale's index.html: dist/about/index.html (NOT dist/en/about/...)
121
-
122
- // Prefix (every locale prefixed)
123
- zero({ i18n: { locales: ['en','de','cs'], defaultLocale: 'en', strategy: 'prefix' } })
124
- // Emits: /en/about, /de/about, /cs/about
125
- // NO unprefixed /about exists`,
126
- mistakes: [
127
- "Configuring locale strings with `.`, `..`, `/`, backslash, or NUL — rejected by `validateLocale` (PR L2 guard). Common BCP-47 shapes pass: `en`, `de-AT`, `en-US`, `zh-Hans`, `pt-BR`",
128
- "Expecting `<RouterLink to='/posts/1'>` rendered inside `/de/posts` to emit `/de/posts/1` automatically — RouterLinks emit LITERAL hrefs; cross-locale navigation falls through to the default-locale route. Locale-aware navigation is a separate API (not yet shipped)",
129
- "Assuming the framework runtime-detects locale from URL prefix — it doesn't. The router matches `/de/about` to the duplicated route record; consumer code reads locale from URL parsing OR from `i18nRouting()` middleware (request-time Accept-Language detection)",
130
- "Using `prefix-except-default` and then duplicating the root `_layout.tsx` per locale — `expandRoutesForLocales` deliberately SKIPS root-layout duplication under this strategy because the unprefixed root layout already wraps locale-prefixed children via hierarchical match. Under `prefix` strategy the skip does NOT apply (no unprefixed default to inherit from)",
131
- "Single-locale `locales: ['en']` + `prefix-except-default` — short-circuits to a no-op (no other locales to prefix). Use `prefix` strategy if you want `/en/about` for SEO consistency with future multi-locale expansion",
132
- "Hand-writing per-locale routes (`src/routes/de/about.tsx`) instead of letting `expandRoutesForLocales` duplicate from a single source file — the framework's duplication wires hierarchical layouts + loader-data hydration + hreflang sitemap clustering correctly; hand-written variants miss the cross-cuts",
133
- ],
134
- seeAlso: ['zero', 'expandRoutesForLocales', 'i18nRouting'],
135
- },
136
- {
137
- name: 'expandRoutesForLocales',
138
- kind: 'function',
139
- signature:
140
- 'function expandRoutesForLocales(routes: FileRoute[], config: I18nRoutingConfig): FileRoute[] // server-only',
141
- summary:
142
- "Fans a flat route list into per-locale variants based on `I18nRoutingConfig`. Each non-default locale gets a full subtree duplicate — layouts, error boundaries, loading components, 404 pages, dynamic params (`[id]` → `:id`), catch-all routes (`[...slug]` → `:slug*`) all compose naturally with the locale prefix. Source `filePath` is preserved so the duplicated routes share the same component module; only `urlPath` / `dirPath` / `depth` change. `getStaticPaths` inherits across duplicates so dynamic-route × locale cross-products work automatically (3 IDs × 3 locales = 9 SSG outputs). Root-layout skip under `prefix-except-default` prevents double-mount.",
143
- example: `import { expandRoutesForLocales } from '@pyreon/zero/server'
144
- import { parseFileRoutes, scanRouteFiles } from '@pyreon/zero/server'
145
-
146
- const files = await scanRouteFiles('./src/routes')
147
- const baseRoutes = parseFileRoutes(files)
148
- const fileRoutes = expandRoutesForLocales(baseRoutes, {
149
- locales: ['en', 'de', 'cs'],
150
- defaultLocale: 'en',
151
- strategy: 'prefix-except-default',
152
- })
153
- // fileRoutes now contains: original routes + /de/* + /cs/* subtrees`,
154
- mistakes: [
155
- "Calling this from CLIENT code — server-only export from `@pyreon/zero/server`. Importing from `@pyreon/zero` (the client entry) gives a clear server-only error stub",
156
- "Expecting hand-written `src/routes/de/about.tsx` to compose with duplicated `/de/about` from `/about` — the helper does NOT detect collisions today; a user-defined route at `/de/profile` + locale `de` produces two records at the same urlPath (router matches first)",
157
- "Modifying the returned `FileRoute[]` and expecting `getStaticPaths` inheritance to update — the duplicates carry frozen `exports` references at duplication time; later mutations don't propagate to the SSG enumerator",
158
- "Setting `strategy: 'prefix'` and expecting `/about` (unprefixed) to ALSO render — under `prefix` every locale is prefixed; the default-locale unprefixed URL does NOT exist as a dist file. Use `prefix-except-default` if you need both",
159
- "Passing user-controlled strings as locales without validation — the helper validates against path-traversal (`..`, `/`, backslash, `.`, NUL) but does NOT validate BCP-47 shape; an invalid locale silently produces oddly-shaped URLs",
160
- ],
161
- seeAlso: ['I18nRoutingConfig', 'zero', 'parseFileRoutes'],
162
- },
163
- {
164
- name: 'GetStaticPaths',
165
- kind: 'type',
166
- signature:
167
- 'type GetStaticPaths<TParams> = () => Array<{ params: TParams }> | Promise<Array<{ params: TParams }>>',
168
- summary:
169
- 'Per-route export type for dynamic-route enumeration at SSG build time (PR A of the SSG roadmap). Route files at `src/routes/posts/[id].tsx` export `getStaticPaths` returning the concrete param values; the SSG plugin expands the URL pattern (`/posts/:id` × `[1, 2, 3]` → `/posts/1`, `/posts/2`, `/posts/3`). Sync or async return; errors during enumeration land in `PrerenderResult.errors` without aborting other routes. Catch-all routes (`[...slug].tsx`) work via `{ params: { slug: "a/b" } }` → `/blog/a/b`.',
170
- example: `import type { GetStaticPaths } from '@pyreon/zero/server'
171
-
172
- // src/routes/posts/[id].tsx
173
- export const getStaticPaths: GetStaticPaths<{ id: string }> = () =>
174
- POSTS.map((p) => ({ params: { id: String(p.id) } }))
175
-
176
- // Async loader-driven enumeration
177
- export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
178
- const posts = await db.query('SELECT slug FROM posts WHERE published = true')
179
- return posts.map((p) => ({ params: { slug: p.slug } }))
180
- }`,
181
- mistakes: [
182
- "Returning param values as numbers instead of strings (`{ id: 1 }` instead of `{ id: '1' }`) — URL segments are always strings; the type enforces this but a runtime cast (`as any`) silently produces wrong paths",
183
- "Forgetting to handle the no-i18n vs i18n cardinality — with `zero({ i18n })` the cross-product is `paths × locales`; a 100-path enumerator with 3 locales produces 300 dist files. Pair with `ssg.concurrency` to avoid serial-render blowup",
184
- "Throwing in `getStaticPaths` and expecting the build to abort — errors are CAPTURED into `PrerenderResult.errors` and the build continues for other routes. Check `dist/_pyreon-ssg-errors.json` after the build (PR G)",
185
- "Mixing `getStaticPaths` and `ssg.paths` for the same dynamic route — both produce paths and the SSG plugin renders each twice",
186
- 'Reading external state in `getStaticPaths` without await — the function is async-aware; missing await produces "[object Promise]" segments in the URL',
187
- ],
188
- seeAlso: ['zero', 'I18nRoutingConfig'],
189
- },
190
- {
191
- name: 'Adapter',
192
- kind: 'type',
193
- signature:
194
- 'interface Adapter { name: string; build?(options: AdapterBuildOptions): Promise<void>; revalidate?(path: string): Promise<AdapterRevalidateResult> }',
195
- summary:
196
- "Deployment adapter contract. `build()` is auto-invoked by SSG's `closeBundle` AFTER the path render loop (PR J) and writes platform-specific routing config: Vercel emits `.vercel/output/config.json`; Cloudflare emits `_routes.json` with zero-function `exclude: ['/*']`; Netlify emits `netlify.toml` with `publish = '.'` + asset cache headers. `revalidate(path)` is the runtime hook for build-time ISR (PR I) — Vercel POSTs to a revalidation webhook, Cloudflare purges the edge cache, Netlify triggers a Build Hook. Static / node / bun adapters no-op for SSG.",
197
- example: `import { vercelAdapter, cloudflareAdapter, netlifyAdapter, staticAdapter } from '@pyreon/zero/server'
198
-
199
- // Vercel — emits .vercel/output/config.json v3 STATIC variant
200
- plugins: [pyreon(), zero({ mode: 'ssg', adapter: vercelAdapter() })]
201
-
202
- // Cloudflare — emits _routes.json (zero-function deploy)
203
- plugins: [pyreon(), zero({ mode: 'ssg', adapter: cloudflareAdapter() })]
204
-
205
- // Netlify — emits netlify.toml with publish="." + cache headers
206
- plugins: [pyreon(), zero({ mode: 'ssg', adapter: netlifyAdapter() })]
207
-
208
- // ISR revalidation webhook handler (Vercel-side)
209
- await vercelAdapter().revalidate?.('/posts/123')
210
- // → { regenerated: true } on success`,
211
- mistakes: [
212
- "Calling `adapter.revalidate(path)` without the platform's env vars set (e.g. `VERCEL_DEPLOYMENT_URL` + `VERCEL_REVALIDATE_TOKEN`) — returns `{ regenerated: false }` with a dev-mode warning. The webhook is a no-op without credentials",
213
- 'Expecting `nodeAdapter` / `bunAdapter` to emit platform routing config under SSG — they no-op (no platform routing to configure). Use vercel/cloudflare/netlify if you need a routing config emitted',
214
- "Setting `mode: 'ssg'` + `adapter: vercelAdapter()` and ALSO writing `.vercel/output/config.json` manually — the adapter overwrites it. Pick one source of truth",
215
- 'Calling adapter methods from CLIENT code — server-only. Import from `@pyreon/zero/server`',
216
- "Forgetting that Netlify's revalidate triggers a FULL-SITE rebuild (Build Hook semantics) — Netlify doesn't expose per-page ISR. The `path` arg flows into `trigger_title` for audit logs but doesn't scope the rebuild",
217
- ],
218
- seeAlso: ['zero', 'createISRHandler', 'vercelAdapter'],
219
- },
220
- {
221
- name: 'createISRHandler',
222
- kind: 'function',
223
- signature:
224
- 'function createISRHandler(handler: (req: Request) => Promise<Response>, config: ISRConfig): ISRHandler',
225
- summary:
226
- "Runtime ISR — on-demand SSR caching with stale-while-revalidate. Wraps an SSR handler so pages are rendered on the FIRST request, cached per-URL (or per-`cacheKey`-derived key), and served stale until expiry while a background revalidate fires. The returned `ISRHandler` is still a callable `(req) => Promise<Response>` for `Bun.serve({ fetch: ... })`, but ALSO exposes imperative invalidation: `.revalidateNow(key)` drops one entry (returns `{ dropped: boolean }`), `.revalidateAll()` drops everything (when the store implements `clear()`). Pair with webhooks for CMS-driven cache busting — no stale window between content update and propagation. Distinct from build-time ISR (per-route `revalidate` export + `Adapter.revalidate`): runtime ISR caches at request time; build-time ISR triggers platform rebuilds. They can coexist: a `mode: 'isr'` app with per-route `revalidate` exports gets BOTH.",
227
- example: `import { createISRHandler, createServer } from '@pyreon/zero/server'
228
-
229
- const ssrHandler = createServer({ routes })
230
- const isr = createISRHandler(ssrHandler, { revalidate: 60 })
231
-
232
- // Use as the request handler
233
- Bun.serve({ fetch: isr })
234
-
235
- // CMS webhook: drop one entry
236
- app.post('/api/webhooks/post-updated', async (req) => {
237
- const { postId } = await req.json()
238
- const result = await isr.revalidateNow(\`/posts/\${postId}\`)
239
- return Response.json(result) // { dropped: true | false }
240
- })
241
-
242
- // Admin "purge cache" endpoint
243
- app.post('/admin/purge', async () => {
244
- await isr.revalidateAll()
245
- return new Response('ok')
246
- })`,
247
- mistakes: [
248
- 'Treating the returned handler as a plain function — it ALSO carries `.revalidateNow(key)` and `.revalidateAll()` methods. Webhook-driven invalidation is the canonical way to bust the cache; waiting for the TTL is the fallback',
249
- 'Calling `.revalidateAll()` against a store that does not implement `clear()` — throws a clear error. External stores (Redis with TTL-only) must opt in by implementing the method',
250
- 'Expecting `revalidateNow(key)` against a store without `delete?()` to physically drop the entry — returns `{ dropped: false }` honestly; such stores rely on TTL for eviction',
251
- 'Sharing the ISR handler across server instances without external cache — each server\'s in-memory cache diverges. For multi-instance deploys, swap `config.store` to a shared cache layer (Redis / Vercel KV / Cloudflare KV)',
252
- 'Setting `revalidate: 0` and expecting "never cache" — pass-through is the explicit handler call (no `createISRHandler` wrapper). Use `revalidate: Number.MAX_SAFE_INTEGER` for "cache forever, invalidate only via `revalidateNow`"',
253
- ],
254
- seeAlso: ['zero', 'Adapter', 'ISRStore', 'createMemoryStore'],
255
- },
256
- {
257
- name: 'vercelAdapter',
258
- kind: 'function',
259
- signature: 'function vercelAdapter(): Adapter',
260
- summary:
261
- 'Vercel deployment adapter. SSG branch emits `.vercel/output/config.json` v3 STATIC variant (no functions, asset cache headers). Does NOT copy files into `.vercel/output/static/` — Vercel CLI auto-detects dist. ISR `revalidate(path)` POSTs to `<VERCEL_DEPLOYMENT_URL>/api/_pyreon-revalidate?path=…&secret=<token>`; user-side webhook validates secret + calls `res.revalidate()`.',
262
- example: 'plugins: [pyreon(), zero({ mode: \'ssg\', adapter: vercelAdapter() })]',
263
- seeAlso: ['Adapter', 'zero'],
264
- },
265
- {
266
- name: 'cloudflareAdapter',
267
- kind: 'function',
268
- signature: 'function cloudflareAdapter(): Adapter',
269
- summary:
270
- "Cloudflare Pages adapter. SSG branch emits `_routes.json` with `{ version: 1, include: [], exclude: ['/*'] }` — i.e. \"every URL is static, never invoke a Pages Function\" (zero-function deploy). Without this file Pages defaults to running the worker on every request, wasting paid-plan compute. ISR `revalidate(path)` POSTs to Cloudflare's zone purge_cache API.",
271
- example: 'plugins: [pyreon(), zero({ mode: \'ssg\', adapter: cloudflareAdapter() })]',
272
- seeAlso: ['Adapter', 'zero'],
273
- },
274
- {
275
- name: 'netlifyAdapter',
276
- kind: 'function',
277
- signature: 'function netlifyAdapter(): Adapter',
278
- summary:
279
- 'Netlify adapter. SSG branch emits `netlify.toml` with `publish = "."` + `Cache-Control` headers for `/assets/*`. PR B\'s `dist/_redirects` covers loader-thrown redirects (Netlify reads the file natively). ISR `revalidate(path)` POSTs to a Build Hook URL with `trigger_title=revalidate:<path>` for audit-log traceability (Netlify queues a full-site partial rebuild — no per-page ISR API).',
280
- example: 'plugins: [pyreon(), zero({ mode: \'ssg\', adapter: netlifyAdapter() })]',
281
- seeAlso: ['Adapter', 'zero'],
282
- },
283
- {
284
- name: 'seoPlugin',
285
- kind: 'function',
286
- signature:
287
- 'function seoPlugin(config: SeoPluginConfig): Plugin // server-only',
288
- summary:
289
- "SEO plugin — emits `sitemap.xml`, `robots.txt`, JSON-LD, and hreflang cross-references. `sitemap.useSsgPaths: true` auto-detects from SSG output manifest (paths from `getStaticPaths` × locale variants flow in automatically). `sitemap.hreflang: true` auto-detects i18n config from the SSG manifest → clusters per-locale URLs into ONE `<url>` with `<xhtml:link rel='alternate' hreflang>` siblings + `x-default` entry. Falls back to fs-router walk when SSG manifest is absent.",
290
- example: `seoPlugin({
291
- sitemap: {
292
- baseUrl: 'https://example.com',
293
- useSsgPaths: true, // PR F — auto-detect SSG paths
294
- hreflang: true, // PR K — auto-detect i18n + emit cross-refs
295
- },
296
- robots: { sitemap: 'https://example.com/sitemap.xml' },
297
- })`,
298
- mistakes: [
299
- "Setting `useSsgPaths: true` in non-SSG mode — silently falls back to fs-router walk (no SSG manifest to read). Same effect as omitting the flag",
300
- "Setting `hreflang: true` without `zero({ i18n })` — emits a plain single-URL sitemap (no clustering). Configure i18n on zero() to activate hreflang",
301
- "Expecting `hreflang: I18nRoutingConfig` (explicit form) to override the SSG manifest's i18n config — explicit wins, but typically the auto-detect is the right shape. Use explicit only if you want a different locale set in the sitemap than in routing",
302
- ],
303
- seeAlso: ['aiPlugin', 'zero'],
304
- },
305
- {
306
- name: 'aiPlugin',
307
- kind: 'function',
308
- signature: 'function aiPlugin(config?: AiPluginConfig): Plugin // server-only',
309
- summary:
310
- 'AI integration plugin — generates `llms.txt`, `llms-full.txt`, and JSON-LD inference metadata at build time. Designed for sites that want to be AI-readable (search engines, model trainers, agentic crawlers). The generated files are themselves Pyreon\'s on-publish artifacts; the plugin runs `inferJsonLd` per route to extract structured data from `meta` exports.',
311
- example: 'plugins: [pyreon(), zero(), seoPlugin({ ... }), aiPlugin()]',
312
- seeAlso: ['seoPlugin', 'zero'],
313
- },
314
- {
315
- name: 'i18nRouting',
316
- kind: 'function',
317
- signature:
318
- 'function i18nRouting(config: I18nRoutingConfig): Plugin // server-only',
319
- summary:
320
- 'Vite plugin for REQUEST-TIME locale detection — Accept-Language header, cookie, root-path redirect to detected locale. Orthogonal to BUILD-TIME route duplication (`expandRoutesForLocales`); both can be used together. The plugin sets a request-context locale that components read via `createLocaleContext`.',
321
- example: `import { i18nRouting } from '@pyreon/zero/server'
322
-
323
- plugins: [pyreon(), zero({ i18n: { locales, defaultLocale } }), i18nRouting({ locales, defaultLocale })]
324
- // Same config object shape — accepts the i18n already passed to zero() if you keep one source of truth`,
325
- mistakes: [
326
- "Confusing this plugin with route duplication — they're separate concerns. `zero({ i18n })` controls BUILD-TIME duplication; `i18nRouting()` plugin controls REQUEST-TIME detection",
327
- 'Using `i18nRouting()` under SSG mode without a server runtime — request-time middleware needs a live request handler. SSG only emits static files. Use `mode: \'ssr\'` for request-time locale detection',
328
- ],
329
- seeAlso: ['zero', 'I18nRoutingConfig', 'createLocaleContext'],
330
- },
331
- {
332
- name: 'validateEnv',
333
- kind: 'function',
334
- signature:
335
- 'function validateEnv<T>(schema: T, env?: ProcessEnv): ValidatedEnv<T> // server-only',
336
- summary:
337
- "Env-variable validation with type coercion. Schema accepts primitives (`String`, `Number`, `Boolean`) for default coercion + `schema()` for custom parsers. `publicEnv()` returns a client-safe subset (no secrets). Catches missing-required-env errors at startup instead of mid-request runtime crashes.",
338
- example: `import { validateEnv, publicEnv, schema } from '@pyreon/zero/server'
339
-
340
- const env = validateEnv({
341
- PORT: 3000,
342
- DEBUG: false,
343
- API_KEY: String, // required string
344
- API_URL: schema((v) => new URL(v)),
345
- })
346
- // env.PORT → number; env.API_KEY → string; env.API_URL → URL
347
-
348
- const pub = publicEnv(env, ['API_URL']) // omit secrets`,
349
- seeAlso: ['zero'],
350
- },
351
- {
352
- name: 'cspMiddleware',
353
- kind: 'function',
354
- signature:
355
- 'function cspMiddleware(config: { directives: CspDirectives }): Middleware // server-only',
356
- summary:
357
- 'CSP (Content Security Policy) middleware — emits `Content-Security-Policy` header per request with configurable directives. Pair with `useNonce()` for inline scripts (nonce is generated per-request and embedded in CSP `script-src \'nonce-XXX\'`). Server-only; SPA mode without a request handler can\'t emit per-request nonces.',
358
- example: `import { cspMiddleware } from '@pyreon/zero/server'
359
-
360
- plugins: [pyreon(), zero({
361
- middleware: [cspMiddleware({
362
- directives: {
363
- 'default-src': ["'self'"],
364
- 'script-src': ["'self'", "'nonce-{{nonce}}'"],
365
- },
366
- })],
367
- })]`,
368
- seeAlso: ['useRequestLocals'],
369
- },
370
- {
371
- name: 'useRequestLocals',
372
- kind: 'hook',
373
- signature: 'function useRequestLocals<T = unknown>(): T',
374
- summary:
375
- 'Bridge middleware-attached request locals into the component tree. Middleware sets `ctx.locals.user = currentUser`; components call `useRequestLocals()` to read. Reactive context — locale-aware re-reads work inside `effect()` / JSX thunks.',
376
- example: `// middleware
377
- async function authMiddleware(ctx, next) {
378
- ctx.locals.user = await verifyToken(ctx.req.headers.get('authorization'))
379
- return next()
380
- }
381
-
382
- // component
383
- const { user } = useRequestLocals<{ user: User | null }>()`,
384
- seeAlso: ['cspMiddleware'],
385
- },
386
-
387
- // ─── Three-layer extensibility: Link / Image / Script ──────────────
388
- // Each component ships THREE layers: a `useX(props)` hook for full
389
- // control, a `createX(Component)` HOC for wrapping any component
390
- // with the optimization behavior, and a default `X` component that
391
- // covers the 90% case. Same pattern across all three so consumers
392
- // build mental model once. Reference: link.tsx, image.tsx, script.tsx.
393
-
394
- {
395
- name: 'Link',
396
- kind: 'component',
397
- signature:
398
- '<Link href={path} prefetch="hover" activeClass={cls}>{children}</Link>',
399
- summary:
400
- "Default navigation link built on an `<a>` tag — client-side push via `router.push()`, hover/viewport prefetch, `aria-current=\"page\"` on exact match, `activeClass` / `exactActiveClass` for nav-state styling. Built on `createLink` so consumers can swap the rendered element via `createLink(MyCustomLink)` without losing the prefetch + active-state behavior.",
401
- example: `import { Link } from '@pyreon/zero/link'
402
-
403
- <Link href="/about" prefetch="viewport" activeClass="nav-active">About</Link>
404
- <Link href="/external" external>External</Link> // target="_blank" rel="noopener noreferrer"`,
405
- mistakes: [
406
- "Using `<a href={path} onClick={() => router.push(path)}>` instead of `<Link>` — manual approach skips prefetch, active-state class merging, and the keyboard-modifier guard (Cmd+click should open new tab, not navigate in-place)",
407
- "Setting `prefetch=\"hover\"` (default) and expecting prefetch on mobile — mobile devices don't fire mouseenter; use `prefetch=\"viewport\"` for IntersectionObserver-based prefetch (or accept that touchstart triggers prefetch too)",
408
- "Passing `class` AND `activeClass` — both are MERGED via `cx` (not overridden); the user-provided `class` always applies, `activeClass` is appended when `isActive()` is true",
409
- "`<Link to={...}>` — Link uses `href`, NOT `to` (RouterLink from `@pyreon/router` uses `to`; Link from `@pyreon/zero/link` uses `href` to match HTML anchor convention)",
410
- "Expecting `external: true` to skip prefetch — `external` controls click handling (opens in new tab via `target=\"_blank\"`), not prefetch. Use `prefetch=\"none\"` if you want to skip prefetch for an internal link",
411
- "Building a custom anchor wrapper from scratch instead of using `createLink` or `useLink` — the prefetch cache, keyboard-modifier guard, active-state class composition, and SSR-safe document.head injection are non-trivial",
412
- ],
413
- seeAlso: ['useLink', 'createLink', 'prefetchRoute'],
414
- },
415
- {
416
- name: 'useLink',
417
- kind: 'hook',
418
- signature: 'function useLink(props: LinkProps): UseLinkReturn',
419
- summary:
420
- 'Composable that returns all link behavior — `{ ref, handleClick, handleMouseEnter, handleTouchStart, isActive, isExactActive, classes }`. Use when `createLink` is too opinionated (e.g. you need a `<button>` link, a card-shaped link, or want to compose with another framework primitive). Internals: hover/viewport prefetch via IntersectionObserver, keyboard-modifier guard (Cmd+click opens new tab), active/exact-active path matching, class-string composition.',
421
- example: `import { useLink } from '@pyreon/zero/link'
422
-
423
- function CardLink(props: LinkProps) {
424
- const link = useLink(props)
425
- return (
426
- <div
427
- ref={link.ref}
428
- class={() => \`card \${link.classes()}\`}
429
- onClick={link.handleClick}
430
- onMouseEnter={link.handleMouseEnter}
431
- onTouchStart={link.handleTouchStart}
432
- >
433
- {props.children}
434
- </div>
435
- )
436
- }`,
437
- mistakes: [
438
- "Reading `link.classes` as a plain string — it's a `() => string` accessor. Call it inside reactive scopes (JSX expression thunks, `class={link.classes}`) so the active class updates on route change",
439
- "Forgetting to wire `link.ref` to the root element under `prefetch=\"viewport\"` — without the ref the IntersectionObserver has nothing to observe; viewport-based prefetch never fires",
440
- "Calling `link.handleClick(e)` synchronously in the component body — handlers are meant to be JSX event props (`onClick={link.handleClick}`); synchronous invocation in the render body triggers `router.push` during render which the lint rule `no-imperative-navigate-in-render` flags",
441
- "Mixing `useLink` + a router instance from a different `RouterProvider` — `useLink` reads the nearest router context; multi-router apps need explicit context boundaries",
442
- "Treating `useLink` as setup-only (calling it conditionally inside an effect) — like all hooks, call it at the top of the component body. The ref / handlers are stable across re-renders",
443
- "Forgetting that `external: true` bypasses the click handler entirely — `useLink` still returns handlers but `handleClick`'s body short-circuits when `props.external` is true; the wrapped element should let the native anchor `target=\"_blank\"` semantics handle the rest",
444
- ],
445
- seeAlso: ['Link', 'createLink', 'UseLinkReturn'],
446
- },
447
- {
448
- name: 'createLink',
449
- kind: 'function',
450
- signature:
451
- 'function createLink(Component: (p: LinkRenderProps) => any): (props: LinkProps) => any',
452
- summary:
453
- 'HOC that wraps any component with link behavior. The wrapped component receives `LinkRenderProps` with all handlers + state pre-wired (`href`, `ref`, `onClick`, `onMouseEnter`, `onTouchStart`, `isActive`, `isExactActive`, `class`, `target`, `rel`). Use this to build styled link variants (button-links, card-links, design-system anchors) without re-implementing the prefetch + active-state machine.',
454
- example: `import { createLink } from '@pyreon/zero/link'
455
-
456
- const ButtonLink = createLink((props) => (
457
- <button
458
- ref={props.ref}
459
- class={props.class}
460
- onClick={props.onClick}
461
- onMouseEnter={props.onMouseEnter}
462
- >
463
- {props.children}
464
- </button>
465
- ))
466
-
467
- <ButtonLink href="/dashboard" activeClass="active">Dashboard</ButtonLink>`,
468
- mistakes: [
469
- "Not forwarding `props.ref` to the rendered element — the prefetch IntersectionObserver and active-state observer both need a real DOM ref to attach to",
470
- "Calling the user-provided `props.class` as a function in JSX (`class={props.class()}`) — `class` is a string-or-accessor union; pass it directly (`class={props.class}`) and let the renderer call it if needed",
471
- "Forgetting `onTouchStart` — mobile devices don't fire mouseenter; without `onTouchStart` mobile users get no prefetch benefit",
472
- "Re-rendering the wrapped component on every router event — the HOC calls `useLink` ONCE per component instance, returns stable handlers, and the route signal is reactive at the granularity of `isActive` / `classes`. Don't memoize the wrapper output manually",
473
- "Building separate wrappers for `<button>` vs `<a>` vs `<div>` instead of having ONE styled wrapper that accepts a `tag` prop — `createLink` only handles the link logic; the rendered tag choice is the consumer's structural decision",
474
- "Expecting `createLink` to handle `external: true` semantics on a non-anchor component — `target` and `rel` are forwarded as RenderProps but `<button target=\"_blank\">` does nothing; for external links rendered as buttons, the consumer must handle `window.open()` explicitly",
475
- ],
476
- seeAlso: ['Link', 'useLink', 'LinkRenderProps'],
477
- },
478
- {
479
- name: 'prefetchRoute',
480
- kind: 'function',
481
- signature: 'function prefetchRoute(href: string): void',
482
- summary:
483
- 'Imperatively prefetch a route\'s JS chunk by injecting `<link rel="prefetch">` + `<link rel="modulepreload">` into `document.head`. Deduplicates — calling twice with the same `href` is a no-op. Backed by an LRU cache (MAX 200 entries) that evicts oldest entries AND removes their DOM nodes to prevent head-bloat across long SPA sessions.',
484
- example: `import { prefetchRoute } from '@pyreon/zero/link'
485
-
486
- // On user hovering a card, prefetch the linked route's chunk
487
- <Card onMouseEnter={() => prefetchRoute('/posts/' + post.id)}>...</Card>`,
488
- seeAlso: ['Link', 'useLink'],
489
- },
490
-
491
- {
492
- name: 'Icon',
493
- kind: 'component',
494
- signature: '<Icon as={ImportedSvgComponent} | svg={rawSvgMarkupString} {...hostProps} />',
495
- summary:
496
- "Renders a FULL loaded SVG — it does NOT synthesize its own `<svg>` around hand-authored `<path>` children. You load an svg (it already contains the `<svg>` root) and Icon makes it container-sizable + theme-aware. Two source props: `as` — an imported SVG *component* (`import X from './x.svg?component'`), rendered DIRECTLY with no host wrapper (recommended; it's a real `<svg>` so container-fill is reliable); `svg` — the raw `<svg>…</svg>` *markup string* (`import x from './x.svg?raw'`), inlined via a single `<span>` host (a markup string needs a parent to mount — this one host is unavoidable for the string form). Defaults (`fill=\"currentColor\"`, `display:block;width:100%;height:100%`) are overridable — consumer props spread through and win. No fixed size → fills its container; `fill=\"currentColor\"` themes via CSS `color`. Intentionally no `useIcon` hook (an icon has no composable behaviour); two layers: `createIcon` (one component per loaded glyph) + `Icon` (one-off).",
497
- example: `import { Icon } from '@pyreon/zero'
498
- import Check from './check.svg?component'
499
- import checkRaw from './check.svg?raw'
500
-
501
- // Component form — rendered directly, no wrapper, reliable fill:
502
- <span style="width:2rem"><Icon as={Check} /></span>
503
-
504
- // Raw-markup form — inlined inside one <span> host:
505
- <span style="width:2rem"><Icon svg={checkRaw} /></span>`,
506
- mistakes: [
507
- "Expecting `<Icon>` to synthesize an `<svg>` from `<path>` children — it does NOT. Pass a loaded svg via `as` (imported `?component`) or `svg` (imported `?raw` string). Children are not the API",
508
- "Expecting `<Icon>` to size itself — it has NO intrinsic size; it fills its container. Wrap + size it (`<span style=\"width:1.5rem\">`) or use a sized flex/grid cell",
509
- "Hardcoding `fill=\"#000\"` — breaks theming. Leave the `currentColor` default; drive colour with CSS `color` so dark mode + hover work for free. Only the `as` form forwards `fill` to the real svg — the `svg`-string form's markup is opaque, so colour it via `currentColor` inside the asset",
510
- "Expecting svg-only props (`viewBox`, `fill`) to apply in the `svg`-string form — they can't reach the opaque inlined markup; only host attrs (`class`, `style`, `aria-*`, events) forward. Use the `as` form when you need to drive svg attributes",
511
- "Reaching for a `useIcon` hook — there isn't one, by design. Use `createIcon` or inline `<Icon>`; an icon has no behaviour worth a hook layer",
512
- "Preferring `svg` (raw string) for the wrapper-free guarantee — it's the opposite: `svg` ALWAYS adds a `<span>` host (unavoidable for string inlining); `as` is the zero-wrapper form",
513
- ],
514
- seeAlso: ['createIcon', 'IconProps', 'Image'],
515
- },
516
- {
517
- name: 'createIcon',
518
- kind: 'function',
519
- signature: 'function createIcon(source: string | SvgComponent): (props: SvgAttributes) => VNodeChild',
520
- summary:
521
- "Builds a reusable icon component from a LOADED svg — a raw `<svg>…</svg>` markup string (`?raw`) OR an imported SVG component (`?component`). The result is still just `<Icon>` (string → `svg` prop, component → `as` prop), so it's container-sizable + theme-aware with every prop passed through. A generated icon set is `createIcon`-per-glyph with zero per-icon boilerplate. Mirrors the `createLink`/`createImage` factory layer, minus a hook (icons have no composable behaviour).",
522
- example: `import { createIcon } from '@pyreon/zero'
523
- import StarSvg from './star.svg?component'
524
- import checkRaw from './check.svg?raw'
525
-
526
- export const Star = createIcon(StarSvg) // component → rendered directly
527
- export const Check = createIcon(checkRaw) // raw string → inlined via <span>
528
-
529
- // Sized + themed entirely by the consumer:
530
- <span style="width:48px"><Check class="text-green-600" aria-label="done" /></span>`,
531
- mistakes: [
532
- "Calling `createIcon` inside a component body — define icon components at module scope (like `createLink`/`createImage`). Re-creating the component every render defeats identity-based reconciliation",
533
- "Passing hand-built `<path>` JSX as `source` — `source` is a full loaded svg: a `?raw` markup string OR a `?component` import. It does NOT take individual shapes; the loaded asset already contains its own `<svg>` root",
534
- "Assuming the `?raw` form has no wrapper — the string form ALWAYS adds one `<span>` host (unavoidable for inlining markup). Use the `?component` form for the zero-wrapper, attribute-forwarding path",
535
- ],
536
- seeAlso: ['Icon', 'IconProps', 'createNamedIcon', 'iconsPlugin'],
537
- },
538
- {
539
- name: 'iconsPlugin',
540
- kind: 'function',
541
- signature: "iconsPlugin({ dir | sets, out?, mode?: 'inline' | 'image' }): Plugin",
542
- summary:
543
- "Vite plugin (from `@pyreon/zero/server`): point it at a folder of `*.svg` files and it writes a strictly-typed generated `icons.gen.tsx` exporting `<Icon name=\"…\" />`. Add an svg → the `name` union widens; remove one → an invalid `name` fails typecheck. The generated file calls `createNamedIcon(REGISTRY)`, so `keyof typeof REGISTRY` IS the type surface (autocomplete + real go-to-definition, zero per-app wiring — same one-touch shape as fs-router / islands auto-registry). Regenerates on add/unlink in dev (idempotent write — never rewrites identical content). **Named multi-set form** (`sets: { ui: { dir }, brand: { dir, mode } }`, mutually exclusive with `dir`): one generated file exports a strictly-typed component PER set with NAMESPACED types so they never clash — `ui` → `<UiIcon name=\"…\" />` + `type UiIconName`, `brand` → `<BrandIcon name=\"…\" />` + `type BrandIconName`; per-set binding prefixes mean two sets sharing a glyph filename don't collide. Two render modes per the colorful-vs-system split (settable per-set): `mode: 'inline'` (default — system icons; each svg inlined as raw `?raw` markup, `currentColor`-themeable, recolor via CSS `color`) and `mode: 'image'` (colorful / brand icons; each svg emitted as a static asset, rendered `<img>`, NO mutation, original colors preserved). Default `out` is `icons.gen.tsx` next to `dir` for the single-set form (`src/icons` → `src/icons.gen.tsx`) or `src/icons.gen.tsx` for the multi-set form — recommend gitignoring it (build artifact). It writes a real file (NOT a virtual module) deliberately: the published `@pyreon/zero` package can't `import` a plugin virtual module — Rolldown resolves static imports before plugin `resolveId` (the same constraint that makes islands need `hydrateIslandsAuto(registry)` with an explicit import).",
544
- example: `// vite.config.ts — single set:
545
- import { iconsPlugin } from '@pyreon/zero/server'
546
- iconsPlugin({ dir: './src/icons' })
547
- // app: import { Icon } from './icons.gen'; <Icon name="check-circle" />
548
-
549
- // Named multi-set — per-set typed components, no IconName clash:
550
- iconsPlugin({ sets: {
551
- ui: { dir: './src/icons/ui' },
552
- brand: { dir: './src/icons/brand', mode: 'image' },
553
- }})
554
- // app: import { UiIcon, BrandIcon } from './icons.gen'
555
- // <UiIcon name="arrow-left" /> <BrandIcon name="logo-mark" />`,
556
- mistakes: [
557
- "Passing BOTH `dir` and `sets` (or neither) — exactly one is required; the plugin throws `[Pyreon] iconsPlugin: provide EXACTLY ONE of dir or sets` at config time",
558
- "Using `mode: 'inline'` (default) for multicolor / brand SVGs — inline mode is for monochrome system icons you recolor via `currentColor`. A multicolor logo's hardcoded fills survive but you lose nothing by using `mode: 'image'`, which is the correct choice for no-mutation colorful assets",
559
- "Using `mode: 'image'` for icons you need to recolor — `<img>` can't be themed via CSS `color`; the svg is opaque. Recolorable system icons need `mode: 'inline'`",
560
- "Editing the generated `icons.gen.tsx` by hand — it's regenerated on every add/unlink. Add/remove `.svg` files in the set folder(s) instead; commit the gitignore entry, not the file",
561
- "Expecting a virtual `import 'virtual:zero/icons'` — there isn't one (Rolldown import-ordering constraint). The plugin writes a REAL file you import by path; that's what gives go-to-definition + zero wiring",
562
- "Pointing a set `dir` at a folder that doesn't exist yet — `scanIconDir` returns empty and the generated `*IconName` is `never` (every `name` fails typecheck). Create the folder + drop at least one `.svg` first",
563
- "Forgetting `vite/client` types — the generated file's `?raw` imports rely on Vite's ambient `*.svg?raw` module declaration; the generated file emits `/// <reference types=\"vite/client\" />` but the consuming tsconfig must still resolve `vite/client`",
564
- ],
565
- seeAlso: ['createNamedIcon', 'Icon', 'IconProps'],
566
- },
567
- {
568
- name: 'createNamedIcon',
569
- kind: 'function',
570
- signature:
571
- "function createNamedIcon<R extends Record<string, string>>(registry: R, options?: { mode?: 'inline' | 'image' }): (props: { name: keyof R & string } & …) => VNodeChild",
572
- summary:
573
- "Runtime half of `iconsPlugin` — builds a strictly-typed `<Icon name=\"…\" />` from a name→source registry. `keyof R` makes `name` a precise string union (the generated file passes a literal registry so the union infers there → autocomplete + go-to-definition). `mode: 'inline'` (default) treats each `source` as raw `<svg>` markup rendered via `Icon` (`currentColor`-themeable system icons); `mode: 'image'` treats each `source` as an asset URL rendered `<img>` with NO mutation (colorful / brand icons). Either way it stays container-filling + props-transparent. Not normally hand-called — `iconsPlugin` emits the generated file that calls it; call it directly only for a hand-maintained set.",
574
- example: `// icons.gen.tsx (auto-generated by iconsPlugin):
575
- import { createNamedIcon } from '@pyreon/zero'
576
- export const Icon = createNamedIcon({ 'check-circle': '<svg…>…</svg>' })
577
-
578
- // image mode (hand-maintained colorful set):
579
- import logo from './logo.svg' // Vite → URL
580
- export const Brand = createNamedIcon({ logo }, { mode: 'image' })
581
- <Brand name="logo" alt="Company" />`,
582
- mistakes: [
583
- "Passing a `Record<string, string>` typed loosely (e.g. `: Record<string, string>`) — that widens `keyof R` to `string` and you lose the typed `name`. Pass the object literal directly (or `as const`) so the keys infer",
584
- "Using `mode: 'image'` then expecting `fill` / svg props to apply — the `<img>` is opaque; only host attrs (`class`, `style`, `alt`, events) forward. Use `mode: 'inline'` for svg-attribute control",
585
- "Omitting `alt` in `mode: 'image'` — it defaults to `\"\"` (decorative). Pass a real `alt` for meaningful icons; screen readers skip empty-alt images",
586
- "Calling `createNamedIcon` inside a component body — define the set once at module scope (the generated file does). Re-creating it per render defeats identity-based reconciliation",
587
- ],
588
- seeAlso: ['iconsPlugin', 'Icon', 'IconProps'],
589
- },
590
- {
591
- name: 'Image',
592
- kind: 'component',
593
- signature:
594
- '<Image src={url} alt={alt} width={w} height={h} priority={false} loading="lazy" placeholder={blurUrl} />',
595
- summary:
596
- "Default optimized image — lazy loading via IntersectionObserver, automatic width/height for CLS prevention, responsive srcset, multi-format via `<picture>`, blur-up placeholder, `fetchPriority=\"high\"` for LCP images. Built on `createImage` so consumers can layer rocketstyle / custom wrappers on top via `createImage(MyStyledImage)` without losing the optimization pipeline. The `raw: true` escape hatch returns a bare `<img>` (no container, no lazy load, no aspect-ratio enforcement).",
597
- example: `import { Image } from '@pyreon/zero/image'
598
- import hero from './hero.jpg?optimize'
599
-
600
- // With imagePlugin — spreads optimized srcset + formats + dimensions
601
- <Image {...hero} alt="Hero" priority />
602
-
603
- // Manual
604
- <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
605
-
606
- // Raw mode — skip all optimization wrappers (custom layout)
607
- <Image src="/bg.jpg" alt="" width={400} height={300} raw />`,
608
- mistakes: [
609
- "Forgetting `width` + `height` — both are REQUIRED for CLS prevention. The `aspect-ratio` CSS is computed from these; omitting them produces layout shift when the image loads",
610
- "Setting `priority` on below-the-fold images — `priority` disables lazy loading AND adds `fetchPriority=\"high\"`. Reserve it for the LCP image only (typically the hero)",
611
- "Setting `loading=\"eager\"` AND `priority` — they're redundant; `priority` already implies eager. Pick one (`priority` is the LCP-marker; `loading=\"eager\"` is the no-priority eager hint)",
612
- "Using `placeholder` as a full-resolution image — it should be a tiny base64 data URI or a /placeholder.jpg (~1-2 KB). Large placeholders defeat the purpose by blocking initial paint",
613
- "Spreading `imagePlugin` output (`{...hero}`) WITHOUT `alt` — `alt` is required for accessibility AND not auto-derived by the plugin. The TypeScript type enforces this",
614
- "Wrapping `<Image>` in a `<picture>` manually for WebP/AVIF — `formats` already does this via `imagePlugin`. Manual `<picture>` defeats the optimization",
615
- ],
616
- seeAlso: ['useImage', 'createImage', 'ImageProps', 'ImageRenderProps'],
617
- },
618
- {
619
- name: 'useImage',
620
- kind: 'hook',
621
- signature: 'function useImage(props: ImageProps): UseImageReturn',
622
- summary:
623
- "Composable that returns resolved image attributes + signals — `{ containerRef, inView, loaded, src, srcSet, sizes, aspectRatio, containerStyle, imageStyle, placeholderStyle, loading, fetchPriority, handleLoad, formats, hasFormats }`. Use for full control when `createImage`'s default `<div><img/></div>` structure is wrong (e.g. `<figure>` + `<figcaption>`, custom container layouts, overlay elements). Reactive accessors (`src`, `srcSet`, `imageStyle`, `placeholderStyle`) re-evaluate on `inView()` flip — wire them as JSX expressions for fine-grained updates.",
624
- example: `import { useImage } from '@pyreon/zero/image'
625
-
626
- function FigureImage(props: ImageProps) {
627
- const img = useImage(props)
628
- return (
629
- <figure ref={img.containerRef} style={img.containerStyle}>
630
- <img
631
- src={img.src}
632
- srcSet={img.srcSet}
633
- sizes={img.sizes}
634
- alt={props.alt}
635
- width={props.width}
636
- height={props.height}
637
- loading={img.loading}
638
- onLoad={img.handleLoad}
639
- style={img.imageStyle}
640
- />
641
- <figcaption>{props.alt}</figcaption>
642
- </figure>
643
- )
644
- }`,
645
- mistakes: [
646
- "Reading `img.src` as a plain string — it's a `() => string` accessor that returns empty string until `inView()` triggers. Pass it as a JSX attribute (`src={img.src}`) so the renderer wraps it in a reactive binding",
647
- "Forgetting to wire `img.containerRef` — without the ref, IntersectionObserver has nothing to observe; lazy images never enter view, never load",
648
- "Calling `img.handleLoad()` from your own code — `handleLoad` is the `<img>`'s `onLoad` handler. Wire it as `onLoad={img.handleLoad}`; calling it manually marks the image as loaded prematurely (placeholder fades out before the image arrives)",
649
- "Spreading `useImage` return on the `<img>` directly (`<img {...img}/>`) — most fields aren't `<img>` attributes (`containerRef`, `aspectRatio`, `imageStyle`, `placeholderStyle`, `hasFormats`). Pick the fields you need",
650
- "Ignoring `img.hasFormats` — if `formats` is set, you should render a `<picture>` with per-format `<source>` elements; `img.srcSet()` returns empty string under formats mode (the format-specific srcsets live on `<source>`)",
651
- "Treating `useImage` as setup-only — like all Pyreon hooks, call it at the top of the component body. The container ref + signals are stable across re-renders",
652
- ],
653
- seeAlso: ['Image', 'createImage', 'UseImageReturn'],
654
- },
655
- {
656
- name: 'createImage',
657
- kind: 'function',
658
- signature:
659
- 'function createImage(Component: (p: ImageRenderProps) => any): (props: ImageProps) => any',
660
- summary:
661
- 'HOC that wraps any component with image optimization. The wrapped component receives `ImageRenderProps` with pre-rendered `placeholder` JSX (null when no placeholder set) + pre-rendered `image` JSX (bare `<img>` OR `<picture>` tree depending on formats), the container ref, container styles, and class. Consumer composes those pieces with whatever wrapper element / extra layout (overlay, badge, caption).',
662
- example: `import { createImage } from '@pyreon/zero/image'
663
-
664
- const FigureImage = createImage((props) => (
665
- <figure ref={props.containerRef} class={props.class} style={props.containerStyle}>
666
- {props.placeholder}
667
- {props.image}
668
- <figcaption>Caption</figcaption>
669
- </figure>
670
- ))
671
-
672
- <FigureImage src="/hero.jpg" alt="Hero" width={1200} height={630} placeholder="/blur.jpg" />`,
673
- mistakes: [
674
- "Forgetting to render `props.image` — without it, the actual `<img>` never appears in the DOM. The HOC pre-renders the bare `<img>` or `<picture>` tree; the consumer just needs to place it",
675
- "Conditionally rendering `props.placeholder` — it's already conditional (null when no `placeholder` prop set). Always render it; React/Pyreon ignore null children",
676
- "Forwarding `props.containerStyle` to a child instead of the container — the styles (aspect-ratio, position: relative, overflow: hidden) MUST apply to the element holding `props.containerRef`. Otherwise CLS prevention breaks AND IntersectionObserver observes the wrong element",
677
- "Building `placeholder` JSX from scratch — `createImage` already constructs the blur-up `<img>` with the right styles. Just render `{props.placeholder}`; don't reach into `useImage().placeholderStyle()` manually",
678
- "Passing `raw: true` to a `createImage`-wrapped component — `raw` short-circuits BEFORE `createImage`'s wrapped component runs (returns bare `<img>`). The wrapped component never receives `ImageRenderProps` in raw mode. Documented as the no-optimization escape hatch",
679
- "Re-implementing the `<picture>` switch — `props.image` already handles the formats branch. Wrapping `props.image` in another `<picture>` produces nested `<picture>` which browsers ignore (the outer wins)",
680
- ],
681
- seeAlso: ['Image', 'useImage', 'ImageRenderProps'],
682
- },
683
-
684
- {
685
- name: 'Script',
686
- kind: 'component',
687
- signature:
688
- '<Script src={url} strategy="afterHydration" id={uniqueId} async={true} onLoad={cb} onError={cb} />',
689
- summary:
690
- "Default optimized third-party script loader. Strategies: `beforeHydration` (in HTML already), `afterHydration` (inject on mount — default), `onIdle` (via `requestIdleCallback`), `onInteraction` (on first click/scroll/keydown/touchstart), `onViewport` (when sentinel enters viewport). Built on `createScript` — consumers can render loading indicators, retry buttons, or analytics-readiness gates via `createScript(MyCustom)` without re-implementing the strategy machine. Returns a 0×0 sentinel `<div>` for `onViewport` strategy, `null` otherwise.",
691
- example: `import { Script } from '@pyreon/zero/script'
692
-
693
- // Load analytics after page is interactive
694
- <Script src="https://analytics.example.com/script.js" strategy="onIdle" id="analytics" />
695
-
696
- // Load chat widget when scrolled into view
697
- <Script src="/chat-widget.js" strategy="onViewport" />
698
-
699
- // Inline script with deferred execution
700
- <Script strategy="afterHydration">{\`console.log("App hydrated!")\`}</Script>`,
701
- mistakes: [
702
- "Setting `strategy=\"onInteraction\"` for analytics that needs first-paint metrics — by definition, onInteraction loads AFTER the first user interaction; first-paint metrics from such a script are useless. Use `onIdle` for analytics that needs LCP / FCP capture",
703
- "Forgetting `id` for scripts that might mount in multiple places — without `id`, dedup doesn't fire and the script loads twice. Always provide `id` for analytics / tracking / third-party widgets",
704
- "Mixing `src` + `children` — `children` is the inline script body; `src` is the URL. If BOTH are set, `src` wins and `children` is ignored (the dom script.src takes precedence). Use one or the other",
705
- "`strategy=\"beforeHydration\"` without actually putting the `<script>` in the HTML — beforeHydration is a NO-OP marker; the script must already exist in the SSR-emitted HTML. Use SSR `<script>` tag injection in your entry-server, not `<Script>`",
706
- "Setting `async={false}` for non-critical scripts — `async={false}` blocks parser; reserve for scripts that MUST execute in order (rare for third-party). Default is true",
707
- "Expecting `onError` to fire for inline scripts — only `src`-based scripts trigger onerror via the browser. Inline scripts (`children`) execute synchronously; runtime exceptions don't propagate to `onError`",
708
- ],
709
- seeAlso: ['useScript', 'createScript', 'ScriptProps', 'ScriptStrategy'],
710
- },
711
- {
712
- name: 'useScript',
713
- kind: 'hook',
714
- signature: 'function useScript(props: ScriptProps): UseScriptReturn',
715
- summary:
716
- "Composable returning script load-state signals + sentinel ref — `{ sentinelRef, loaded, errored, pending, needsSentinel, load }`. Reactive signals (`loaded`, `errored`, `pending`) let consumers render loading indicators, retry buttons, or analytics-readiness gates without re-implementing the strategy machine. `needsSentinel` is true ONLY for `onViewport` strategy. `load()` is the imperative escape hatch (strategy normally triggers it; rarely needed).",
717
- example: `import { useScript } from '@pyreon/zero/script'
718
-
719
- function TrackedScript(props: ScriptProps) {
720
- const s = useScript(props)
721
- return (
722
- <>
723
- {() => s.pending() && <Spinner />}
724
- {() => s.errored() && <button onClick={() => location.reload()}>Retry</button>}
725
- {s.needsSentinel && <div ref={s.sentinelRef} style="width:0;height:0" />}
726
- </>
727
- )
728
- }`,
729
- mistakes: [
730
- "Reading `s.loaded` / `s.errored` / `s.pending` as booleans — they're `() => boolean` accessors. Call them inside reactive scopes (JSX thunks, `effect()`) so the UI updates when state changes",
731
- "Forgetting `s.needsSentinel` and always rendering a sentinel — non-onViewport strategies don't need one; rendering a div anyway is harmless but reads as wrong",
732
- "Calling `s.load()` in the component body — the strategy already calls it (afterHydration runs it on mount, onInteraction on first interaction, etc.). Manual `load()` typically duplicates the request (unless `id` is set for dedup)",
733
- "Wiring `s.sentinelRef` to a non-DOM element — IntersectionObserver needs a real Element. A `null` or detached ref means viewport-based load never fires",
734
- "Expecting `s.pending()` to start true for `afterHydration` — it doesn't. `afterHydration` is the synchronous-load strategy; pending only starts true for `onIdle` / `onInteraction` / `onViewport` (where the load is deferred)",
735
- "Using `s.errored()` to suppress retry-on-mount — `errored` is set when the script's onerror fires, NOT when a previous mount errored. Multi-mount apps need their own retry budget tracking",
736
- ],
737
- seeAlso: ['Script', 'createScript', 'UseScriptReturn'],
738
- },
739
- {
740
- name: 'createScript',
741
- kind: 'function',
742
- signature:
743
- 'function createScript(Component: (p: ScriptRenderProps) => any): (props: ScriptProps) => any',
744
- summary:
745
- "HOC that wraps any component with script load behavior. The wrapped component receives `ScriptRenderProps` with the sentinel ref, load-state signals (`loaded`, `errored`, `pending`), and `needsSentinel` flag. Use this to render loading indicators, retry UI, or analytics-readiness gates around the script load lifecycle.",
746
- example: `import { createScript } from '@pyreon/zero/script'
747
-
748
- const StatusScript = createScript((props) => (
749
- <div>
750
- {() => props.pending() && <span>Loading analytics...</span>}
751
- {() => props.errored() && <span>Analytics failed to load</span>}
752
- {props.needsSentinel && <div ref={props.sentinelRef} style="width:0;height:0" />}
753
- </div>
754
- ))
755
-
756
- <StatusScript src="/analytics.js" strategy="onIdle" id="analytics" />`,
757
- mistakes: [
758
- "Always rendering `<div ref={props.sentinelRef} .../>` regardless of `needsSentinel` — for non-onViewport strategies the ref is `undefined`. Gate the sentinel render on `props.needsSentinel`",
759
- "Calling `props.loaded()` / `props.errored()` / `props.pending()` outside reactive scopes — they're accessors; outside JSX thunks they capture the value at setup time and never update",
760
- "Forgetting that the wrapped component's render output doesn't affect script loading — the script load fires in `useScript`'s `onMount` regardless of what the wrapped component returns (null, div, fragment). The wrapper is purely a UI surface",
761
- "Building a custom strategy machine in the wrapped component — the strategy is already resolved by `useScript`. The wrapped component just observes the resulting signals",
762
- "Forwarding `props.sentinelRef` to multiple elements — `useIntersectionObserver` observes ONE element. Multi-ref forwarding produces undefined behavior (the last-attached element wins)",
763
- "Expecting the wrapped component to fire `onLoad` / `onError` — those callbacks are on the `ScriptProps` (passed to the OUTER component), not on the wrapped component. The wrapped component reads `props.loaded()` / `props.errored()` signals to react to the same events",
764
- ],
765
- seeAlso: ['Script', 'useScript', 'ScriptRenderProps'],
766
- },
767
- ],
768
- gotchas: [
769
- 'mode: \'ssg\' returns Plugin[] (the SSG plugin auto-attaches a companion `ssgPlugin()`); Vite\'s plugins array flattens nested arrays so `plugins: [pyreon(), zero()]` works as-is.',
770
- {
771
- label: 'i18n strategies',
772
- note: '`prefix-except-default` (default) keeps the default locale unprefixed (SEO-canonical for primary-locale apps). `prefix` prefixes every locale including default (best when no locale is primary). Switching strategies changes the dist filesystem layout — plan migration paths if you flip mid-product.',
773
- },
774
- {
775
- label: 'getStaticPaths × i18n cardinality',
776
- note: '3 IDs × 3 locales × 2 strategies of accidents = bigger SSG output than you expected. Use `ssg.concurrency` to parallelize the render; use `ssg.onProgress` to surface heartbeat lines on long builds (CI silent-stretches look hung otherwise).',
777
- },
778
- {
779
- label: 'Adapter.build invocation',
780
- note: 'Auto-invoked in SSG `closeBundle` AFTER path render. SSR-mode auto-invoke is NOT yet wired — SSR consumers handle their own server bundle.',
781
- },
782
- {
783
- label: 'Locale-aware RouterLink — not yet shipped',
784
- note: 'RouterLinks under i18n duplication emit LITERAL hrefs from their `to` prop. Cross-locale navigation falls through to the default-locale route. A locale-aware-link feature is a future PR; for now, write per-locale hrefs explicitly or use the router\'s programmatic navigation in handlers.',
785
- },
786
- ],
787
- })