@pyreon/zero 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
  2. package/lib/client.js +4 -1
  3. package/lib/env.js +6 -6
  4. package/lib/font.js +3 -3
  5. package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
  6. package/lib/i18n-routing.js +112 -1
  7. package/lib/image.js +140 -58
  8. package/lib/index.js +252 -82
  9. package/lib/og-image.js +5 -5
  10. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  11. package/lib/script.js +114 -25
  12. package/lib/seo.js +186 -15
  13. package/lib/server.js +274 -564
  14. package/lib/types/config.d.ts +307 -3
  15. package/lib/types/env.d.ts +2 -2
  16. package/lib/types/i18n-routing.d.ts +193 -2
  17. package/lib/types/image.d.ts +105 -5
  18. package/lib/types/index.d.ts +666 -182
  19. package/lib/types/script.d.ts +78 -6
  20. package/lib/types/seo.d.ts +128 -4
  21. package/lib/types/server.d.ts +607 -72
  22. package/lib/vite-plugin-y0NmCLJA.js +2476 -0
  23. package/package.json +11 -10
  24. package/src/adapters/bun.ts +20 -1
  25. package/src/adapters/cloudflare.ts +78 -1
  26. package/src/adapters/index.ts +25 -3
  27. package/src/adapters/netlify.ts +63 -1
  28. package/src/adapters/node.ts +25 -1
  29. package/src/adapters/static.ts +26 -1
  30. package/src/adapters/validate.ts +8 -1
  31. package/src/adapters/vercel.ts +76 -1
  32. package/src/adapters/warn-missing-env.ts +49 -0
  33. package/src/app.ts +14 -0
  34. package/src/client.ts +18 -0
  35. package/src/entry-server.ts +55 -5
  36. package/src/env.ts +7 -7
  37. package/src/font.ts +3 -3
  38. package/src/fs-router.ts +72 -3
  39. package/src/i18n-routing.ts +246 -12
  40. package/src/image.tsx +242 -91
  41. package/src/index.ts +4 -4
  42. package/src/isr.ts +24 -6
  43. package/src/manifest.ts +675 -0
  44. package/src/og-image.ts +5 -5
  45. package/src/script.tsx +159 -36
  46. package/src/seo.ts +346 -15
  47. package/src/server.ts +10 -2
  48. package/src/ssg-plugin.ts +1211 -54
  49. package/src/types.ts +333 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +171 -41
  52. package/lib/vite-plugin-E4BHYvYW.js +0 -855
@@ -0,0 +1,675 @@
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(options: { handler: Handler; cacheTtl?: number; ... }): Handler',
225
+ summary:
226
+ "Runtime ISR — on-demand SSR caching with TTL. Wraps an SSR handler so pages are rendered on the FIRST request, cached for `cacheTtl` ms (default 60s), and served stale until expiry. 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
+ // Wrap createServer's handler with ISR cache
230
+ const ssrHandler = createServer({ routes })
231
+ const isrHandler = createISRHandler({
232
+ handler: ssrHandler,
233
+ cacheTtl: 60_000, // serve cached HTML for 60s
234
+ })
235
+
236
+ export default isrHandler`,
237
+ mistakes: [
238
+ 'Setting `cacheTtl: 0` and expecting "never cache" — pass-through is the explicit handler call (no `createISRHandler` wrapper). `cacheTtl: 0` is a degenerate state',
239
+ "Sharing the ISR handler across server instances without external cache — each server's in-memory cache diverges. For multi-instance deploys, swap to a shared cache layer (Redis adapter not built in; user-side concern)",
240
+ ],
241
+ seeAlso: ['zero', 'Adapter'],
242
+ },
243
+ {
244
+ name: 'vercelAdapter',
245
+ kind: 'function',
246
+ signature: 'function vercelAdapter(): Adapter',
247
+ summary:
248
+ '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()`.',
249
+ example: 'plugins: [pyreon(), zero({ mode: \'ssg\', adapter: vercelAdapter() })]',
250
+ seeAlso: ['Adapter', 'zero'],
251
+ },
252
+ {
253
+ name: 'cloudflareAdapter',
254
+ kind: 'function',
255
+ signature: 'function cloudflareAdapter(): Adapter',
256
+ summary:
257
+ "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.",
258
+ example: 'plugins: [pyreon(), zero({ mode: \'ssg\', adapter: cloudflareAdapter() })]',
259
+ seeAlso: ['Adapter', 'zero'],
260
+ },
261
+ {
262
+ name: 'netlifyAdapter',
263
+ kind: 'function',
264
+ signature: 'function netlifyAdapter(): Adapter',
265
+ summary:
266
+ '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).',
267
+ example: 'plugins: [pyreon(), zero({ mode: \'ssg\', adapter: netlifyAdapter() })]',
268
+ seeAlso: ['Adapter', 'zero'],
269
+ },
270
+ {
271
+ name: 'seoPlugin',
272
+ kind: 'function',
273
+ signature:
274
+ 'function seoPlugin(config: SeoPluginConfig): Plugin // server-only',
275
+ summary:
276
+ "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.",
277
+ example: `seoPlugin({
278
+ sitemap: {
279
+ baseUrl: 'https://example.com',
280
+ useSsgPaths: true, // PR F — auto-detect SSG paths
281
+ hreflang: true, // PR K — auto-detect i18n + emit cross-refs
282
+ },
283
+ robots: { sitemap: 'https://example.com/sitemap.xml' },
284
+ })`,
285
+ mistakes: [
286
+ "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",
287
+ "Setting `hreflang: true` without `zero({ i18n })` — emits a plain single-URL sitemap (no clustering). Configure i18n on zero() to activate hreflang",
288
+ "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",
289
+ ],
290
+ seeAlso: ['aiPlugin', 'zero'],
291
+ },
292
+ {
293
+ name: 'aiPlugin',
294
+ kind: 'function',
295
+ signature: 'function aiPlugin(config?: AiPluginConfig): Plugin // server-only',
296
+ summary:
297
+ '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.',
298
+ example: 'plugins: [pyreon(), zero(), seoPlugin({ ... }), aiPlugin()]',
299
+ seeAlso: ['seoPlugin', 'zero'],
300
+ },
301
+ {
302
+ name: 'i18nRouting',
303
+ kind: 'function',
304
+ signature:
305
+ 'function i18nRouting(config: I18nRoutingConfig): Plugin // server-only',
306
+ summary:
307
+ '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`.',
308
+ example: `import { i18nRouting } from '@pyreon/zero/server'
309
+
310
+ plugins: [pyreon(), zero({ i18n: { locales, defaultLocale } }), i18nRouting({ locales, defaultLocale })]
311
+ // Same config object shape — accepts the i18n already passed to zero() if you keep one source of truth`,
312
+ mistakes: [
313
+ "Confusing this plugin with route duplication — they're separate concerns. `zero({ i18n })` controls BUILD-TIME duplication; `i18nRouting()` plugin controls REQUEST-TIME detection",
314
+ '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',
315
+ ],
316
+ seeAlso: ['zero', 'I18nRoutingConfig', 'createLocaleContext'],
317
+ },
318
+ {
319
+ name: 'validateEnv',
320
+ kind: 'function',
321
+ signature:
322
+ 'function validateEnv<T>(schema: T, env?: ProcessEnv): ValidatedEnv<T> // server-only',
323
+ summary:
324
+ "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.",
325
+ example: `import { validateEnv, publicEnv, schema } from '@pyreon/zero/server'
326
+
327
+ const env = validateEnv({
328
+ PORT: 3000,
329
+ DEBUG: false,
330
+ API_KEY: String, // required string
331
+ API_URL: schema((v) => new URL(v)),
332
+ })
333
+ // env.PORT → number; env.API_KEY → string; env.API_URL → URL
334
+
335
+ const pub = publicEnv(env, ['API_URL']) // omit secrets`,
336
+ seeAlso: ['zero'],
337
+ },
338
+ {
339
+ name: 'cspMiddleware',
340
+ kind: 'function',
341
+ signature:
342
+ 'function cspMiddleware(config: { directives: CspDirectives }): Middleware // server-only',
343
+ summary:
344
+ '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.',
345
+ example: `import { cspMiddleware } from '@pyreon/zero/server'
346
+
347
+ plugins: [pyreon(), zero({
348
+ middleware: [cspMiddleware({
349
+ directives: {
350
+ 'default-src': ["'self'"],
351
+ 'script-src': ["'self'", "'nonce-{{nonce}}'"],
352
+ },
353
+ })],
354
+ })]`,
355
+ seeAlso: ['useRequestLocals'],
356
+ },
357
+ {
358
+ name: 'useRequestLocals',
359
+ kind: 'hook',
360
+ signature: 'function useRequestLocals<T = unknown>(): T',
361
+ summary:
362
+ '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.',
363
+ example: `// middleware
364
+ async function authMiddleware(ctx, next) {
365
+ ctx.locals.user = await verifyToken(ctx.req.headers.get('authorization'))
366
+ return next()
367
+ }
368
+
369
+ // component
370
+ const { user } = useRequestLocals<{ user: User | null }>()`,
371
+ seeAlso: ['cspMiddleware'],
372
+ },
373
+
374
+ // ─── Three-layer extensibility: Link / Image / Script ──────────────
375
+ // Each component ships THREE layers: a `useX(props)` hook for full
376
+ // control, a `createX(Component)` HOC for wrapping any component
377
+ // with the optimization behavior, and a default `X` component that
378
+ // covers the 90% case. Same pattern across all three so consumers
379
+ // build mental model once. Reference: link.tsx, image.tsx, script.tsx.
380
+
381
+ {
382
+ name: 'Link',
383
+ kind: 'component',
384
+ signature:
385
+ '<Link href={path} prefetch="hover" activeClass={cls}>{children}</Link>',
386
+ summary:
387
+ "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.",
388
+ example: `import { Link } from '@pyreon/zero/link'
389
+
390
+ <Link href="/about" prefetch="viewport" activeClass="nav-active">About</Link>
391
+ <Link href="/external" external>External</Link> // target="_blank" rel="noopener noreferrer"`,
392
+ mistakes: [
393
+ "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)",
394
+ "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)",
395
+ "Passing `class` AND `activeClass` — both are MERGED via `cx` (not overridden); the user-provided `class` always applies, `activeClass` is appended when `isActive()` is true",
396
+ "`<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)",
397
+ "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",
398
+ "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",
399
+ ],
400
+ seeAlso: ['useLink', 'createLink', 'prefetchRoute'],
401
+ },
402
+ {
403
+ name: 'useLink',
404
+ kind: 'hook',
405
+ signature: 'function useLink(props: LinkProps): UseLinkReturn',
406
+ summary:
407
+ '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.',
408
+ example: `import { useLink } from '@pyreon/zero/link'
409
+
410
+ function CardLink(props: LinkProps) {
411
+ const link = useLink(props)
412
+ return (
413
+ <div
414
+ ref={link.ref}
415
+ class={() => \`card \${link.classes()}\`}
416
+ onClick={link.handleClick}
417
+ onMouseEnter={link.handleMouseEnter}
418
+ onTouchStart={link.handleTouchStart}
419
+ >
420
+ {props.children}
421
+ </div>
422
+ )
423
+ }`,
424
+ mistakes: [
425
+ "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",
426
+ "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",
427
+ "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",
428
+ "Mixing `useLink` + a router instance from a different `RouterProvider` — `useLink` reads the nearest router context; multi-router apps need explicit context boundaries",
429
+ "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",
430
+ "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",
431
+ ],
432
+ seeAlso: ['Link', 'createLink', 'UseLinkReturn'],
433
+ },
434
+ {
435
+ name: 'createLink',
436
+ kind: 'function',
437
+ signature:
438
+ 'function createLink(Component: (p: LinkRenderProps) => any): (props: LinkProps) => any',
439
+ summary:
440
+ '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.',
441
+ example: `import { createLink } from '@pyreon/zero/link'
442
+
443
+ const ButtonLink = createLink((props) => (
444
+ <button
445
+ ref={props.ref}
446
+ class={props.class}
447
+ onClick={props.onClick}
448
+ onMouseEnter={props.onMouseEnter}
449
+ >
450
+ {props.children}
451
+ </button>
452
+ ))
453
+
454
+ <ButtonLink href="/dashboard" activeClass="active">Dashboard</ButtonLink>`,
455
+ mistakes: [
456
+ "Not forwarding `props.ref` to the rendered element — the prefetch IntersectionObserver and active-state observer both need a real DOM ref to attach to",
457
+ "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",
458
+ "Forgetting `onTouchStart` — mobile devices don't fire mouseenter; without `onTouchStart` mobile users get no prefetch benefit",
459
+ "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",
460
+ "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",
461
+ "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",
462
+ ],
463
+ seeAlso: ['Link', 'useLink', 'LinkRenderProps'],
464
+ },
465
+ {
466
+ name: 'prefetchRoute',
467
+ kind: 'function',
468
+ signature: 'function prefetchRoute(href: string): void',
469
+ summary:
470
+ '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.',
471
+ example: `import { prefetchRoute } from '@pyreon/zero/link'
472
+
473
+ // On user hovering a card, prefetch the linked route's chunk
474
+ <Card onMouseEnter={() => prefetchRoute('/posts/' + post.id)}>...</Card>`,
475
+ seeAlso: ['Link', 'useLink'],
476
+ },
477
+
478
+ {
479
+ name: 'Image',
480
+ kind: 'component',
481
+ signature:
482
+ '<Image src={url} alt={alt} width={w} height={h} priority={false} loading="lazy" placeholder={blurUrl} />',
483
+ summary:
484
+ "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).",
485
+ example: `import { Image } from '@pyreon/zero/image'
486
+ import hero from './hero.jpg?optimize'
487
+
488
+ // With imagePlugin — spreads optimized srcset + formats + dimensions
489
+ <Image {...hero} alt="Hero" priority />
490
+
491
+ // Manual
492
+ <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
493
+
494
+ // Raw mode — skip all optimization wrappers (custom layout)
495
+ <Image src="/bg.jpg" alt="" width={400} height={300} raw />`,
496
+ mistakes: [
497
+ "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",
498
+ "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)",
499
+ "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)",
500
+ "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",
501
+ "Spreading `imagePlugin` output (`{...hero}`) WITHOUT `alt` — `alt` is required for accessibility AND not auto-derived by the plugin. The TypeScript type enforces this",
502
+ "Wrapping `<Image>` in a `<picture>` manually for WebP/AVIF — `formats` already does this via `imagePlugin`. Manual `<picture>` defeats the optimization",
503
+ ],
504
+ seeAlso: ['useImage', 'createImage', 'ImageProps', 'ImageRenderProps'],
505
+ },
506
+ {
507
+ name: 'useImage',
508
+ kind: 'hook',
509
+ signature: 'function useImage(props: ImageProps): UseImageReturn',
510
+ summary:
511
+ "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.",
512
+ example: `import { useImage } from '@pyreon/zero/image'
513
+
514
+ function FigureImage(props: ImageProps) {
515
+ const img = useImage(props)
516
+ return (
517
+ <figure ref={img.containerRef} style={img.containerStyle}>
518
+ <img
519
+ src={img.src}
520
+ srcSet={img.srcSet}
521
+ sizes={img.sizes}
522
+ alt={props.alt}
523
+ width={props.width}
524
+ height={props.height}
525
+ loading={img.loading}
526
+ onLoad={img.handleLoad}
527
+ style={img.imageStyle}
528
+ />
529
+ <figcaption>{props.alt}</figcaption>
530
+ </figure>
531
+ )
532
+ }`,
533
+ mistakes: [
534
+ "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",
535
+ "Forgetting to wire `img.containerRef` — without the ref, IntersectionObserver has nothing to observe; lazy images never enter view, never load",
536
+ "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)",
537
+ "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",
538
+ "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>`)",
539
+ "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",
540
+ ],
541
+ seeAlso: ['Image', 'createImage', 'UseImageReturn'],
542
+ },
543
+ {
544
+ name: 'createImage',
545
+ kind: 'function',
546
+ signature:
547
+ 'function createImage(Component: (p: ImageRenderProps) => any): (props: ImageProps) => any',
548
+ summary:
549
+ '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).',
550
+ example: `import { createImage } from '@pyreon/zero/image'
551
+
552
+ const FigureImage = createImage((props) => (
553
+ <figure ref={props.containerRef} class={props.class} style={props.containerStyle}>
554
+ {props.placeholder}
555
+ {props.image}
556
+ <figcaption>Caption</figcaption>
557
+ </figure>
558
+ ))
559
+
560
+ <FigureImage src="/hero.jpg" alt="Hero" width={1200} height={630} placeholder="/blur.jpg" />`,
561
+ mistakes: [
562
+ "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",
563
+ "Conditionally rendering `props.placeholder` — it's already conditional (null when no `placeholder` prop set). Always render it; React/Pyreon ignore null children",
564
+ "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",
565
+ "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",
566
+ "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",
567
+ "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)",
568
+ ],
569
+ seeAlso: ['Image', 'useImage', 'ImageRenderProps'],
570
+ },
571
+
572
+ {
573
+ name: 'Script',
574
+ kind: 'component',
575
+ signature:
576
+ '<Script src={url} strategy="afterHydration" id={uniqueId} async={true} onLoad={cb} onError={cb} />',
577
+ summary:
578
+ "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.",
579
+ example: `import { Script } from '@pyreon/zero/script'
580
+
581
+ // Load analytics after page is interactive
582
+ <Script src="https://analytics.example.com/script.js" strategy="onIdle" id="analytics" />
583
+
584
+ // Load chat widget when scrolled into view
585
+ <Script src="/chat-widget.js" strategy="onViewport" />
586
+
587
+ // Inline script with deferred execution
588
+ <Script strategy="afterHydration">{\`console.log("App hydrated!")\`}</Script>`,
589
+ mistakes: [
590
+ "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",
591
+ "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",
592
+ "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",
593
+ "`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>`",
594
+ "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",
595
+ "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`",
596
+ ],
597
+ seeAlso: ['useScript', 'createScript', 'ScriptProps', 'ScriptStrategy'],
598
+ },
599
+ {
600
+ name: 'useScript',
601
+ kind: 'hook',
602
+ signature: 'function useScript(props: ScriptProps): UseScriptReturn',
603
+ summary:
604
+ "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).",
605
+ example: `import { useScript } from '@pyreon/zero/script'
606
+
607
+ function TrackedScript(props: ScriptProps) {
608
+ const s = useScript(props)
609
+ return (
610
+ <>
611
+ {() => s.pending() && <Spinner />}
612
+ {() => s.errored() && <button onClick={() => location.reload()}>Retry</button>}
613
+ {s.needsSentinel && <div ref={s.sentinelRef} style="width:0;height:0" />}
614
+ </>
615
+ )
616
+ }`,
617
+ mistakes: [
618
+ "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",
619
+ "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",
620
+ "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)",
621
+ "Wiring `s.sentinelRef` to a non-DOM element — IntersectionObserver needs a real Element. A `null` or detached ref means viewport-based load never fires",
622
+ "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)",
623
+ "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",
624
+ ],
625
+ seeAlso: ['Script', 'createScript', 'UseScriptReturn'],
626
+ },
627
+ {
628
+ name: 'createScript',
629
+ kind: 'function',
630
+ signature:
631
+ 'function createScript(Component: (p: ScriptRenderProps) => any): (props: ScriptProps) => any',
632
+ summary:
633
+ "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.",
634
+ example: `import { createScript } from '@pyreon/zero/script'
635
+
636
+ const StatusScript = createScript((props) => (
637
+ <div>
638
+ {() => props.pending() && <span>Loading analytics...</span>}
639
+ {() => props.errored() && <span>Analytics failed to load</span>}
640
+ {props.needsSentinel && <div ref={props.sentinelRef} style="width:0;height:0" />}
641
+ </div>
642
+ ))
643
+
644
+ <StatusScript src="/analytics.js" strategy="onIdle" id="analytics" />`,
645
+ mistakes: [
646
+ "Always rendering `<div ref={props.sentinelRef} .../>` regardless of `needsSentinel` — for non-onViewport strategies the ref is `undefined`. Gate the sentinel render on `props.needsSentinel`",
647
+ "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",
648
+ "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",
649
+ "Building a custom strategy machine in the wrapped component — the strategy is already resolved by `useScript`. The wrapped component just observes the resulting signals",
650
+ "Forwarding `props.sentinelRef` to multiple elements — `useIntersectionObserver` observes ONE element. Multi-ref forwarding produces undefined behavior (the last-attached element wins)",
651
+ "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",
652
+ ],
653
+ seeAlso: ['Script', 'useScript', 'ScriptRenderProps'],
654
+ },
655
+ ],
656
+ gotchas: [
657
+ '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.',
658
+ {
659
+ label: 'i18n strategies',
660
+ 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.',
661
+ },
662
+ {
663
+ label: 'getStaticPaths × i18n cardinality',
664
+ 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).',
665
+ },
666
+ {
667
+ label: 'Adapter.build invocation',
668
+ note: 'Auto-invoked in SSG `closeBundle` AFTER path render. SSR-mode auto-invoke is NOT yet wired — SSR consumers handle their own server bundle.',
669
+ },
670
+ {
671
+ label: 'Locale-aware RouterLink — not yet shipped',
672
+ 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.',
673
+ },
674
+ ],
675
+ })