@pyreon/zero 0.15.0 → 0.16.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 +275 -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 +634 -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 +575 -72
  22. package/lib/vite-plugin-xjWZwudX.js +2454 -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 +301 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +108 -30
  52. package/lib/vite-plugin-E4BHYvYW.js +0 -855
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/zero",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Pyreon Zero — zero-config full-stack framework powered by Pyreon and Vite",
5
5
  "license": "MIT",
6
6
  "author": "Vit Bokisch",
@@ -168,21 +168,22 @@
168
168
  "lint": "oxlint ."
169
169
  },
170
170
  "dependencies": {
171
- "@pyreon/core": "^0.15.0",
172
- "@pyreon/head": "^0.15.0",
173
- "@pyreon/meta": "^0.15.0",
174
- "@pyreon/router": "^0.15.0",
175
- "@pyreon/runtime-dom": "^0.15.0",
176
- "@pyreon/runtime-server": "^0.15.0",
177
- "@pyreon/server": "^0.15.0",
178
- "@pyreon/vite-plugin": "^0.15.0",
171
+ "@pyreon/core": "^0.16.0",
172
+ "@pyreon/head": "^0.16.0",
173
+ "@pyreon/meta": "^0.16.0",
174
+ "@pyreon/reactivity": "^0.16.0",
175
+ "@pyreon/router": "^0.16.0",
176
+ "@pyreon/runtime-dom": "^0.16.0",
177
+ "@pyreon/runtime-server": "^0.16.0",
178
+ "@pyreon/server": "^0.16.0",
179
+ "@pyreon/vite-plugin": "^0.16.0",
179
180
  "vite": "^8.0.0"
180
181
  },
181
182
  "devDependencies": {
183
+ "@pyreon/manifest": "0.13.1",
182
184
  "sharp": "^0.33.0"
183
185
  },
184
186
  "peerDependencies": {
185
- "@pyreon/reactivity": "^0.15.0",
186
187
  "sharp": "^0.33.0"
187
188
  },
188
189
  "peerDependenciesMeta": {
@@ -1,13 +1,22 @@
1
- import type { Adapter, AdapterBuildOptions } from '../types'
1
+ import type { Adapter, AdapterBuildOptions, AdapterRevalidateResult } from '../types'
2
2
  import { validateBuildInputs } from './validate'
3
3
 
4
4
  /**
5
5
  * Bun adapter — generates a standalone Bun.serve() entry.
6
+ *
7
+ * **SSG mode (PR J)**: no-op. Bun adapter exists for serving the SSR
8
+ * runtime; SSG output is already complete static HTML — serve it with
9
+ * any static-file server (`bun preview` / `bunx serve` / nginx / Caddy).
10
+ * Use `staticAdapter()` if you want explicit SSG semantics.
6
11
  */
7
12
  export function bunAdapter(): Adapter {
8
13
  return {
9
14
  name: 'bun',
10
15
  async build(options: AdapterBuildOptions) {
16
+ if (options.kind === 'ssg') {
17
+ // Bun runner has nothing to add for prerendered SSG dist.
18
+ return
19
+ }
11
20
  await validateBuildInputs(options)
12
21
  const { writeFile, cp, mkdir } = await import('node:fs/promises')
13
22
  const { join } = await import('node:path')
@@ -63,5 +72,15 @@ console.log("\\n ⚡ Zero production server running on http://localhost:${port}
63
72
 
64
73
  await writeFile(join(outDir, 'index.ts'), serverEntry)
65
74
  },
75
+ async revalidate(_path: string): Promise<AdapterRevalidateResult> {
76
+ // Self-hosted Bun has no platform-driven ISR — same shape as
77
+ // nodeAdapter. See nodeAdapter.revalidate for full rationale.
78
+ if (process.env.NODE_ENV !== 'production') {
79
+ console.warn(
80
+ '[Pyreon] bunAdapter.revalidate() is a no-op — self-hosted Bun has no platform-driven ISR. Use mode: "isr" for runtime LRU caching, or vercelAdapter / cloudflareAdapter / netlifyAdapter for platform-driven build-time ISR.',
81
+ )
82
+ }
83
+ return { regenerated: false }
84
+ },
66
85
  }
67
86
  }
@@ -1,5 +1,6 @@
1
- import type { Adapter, AdapterBuildOptions } from '../types'
1
+ import type { Adapter, AdapterBuildOptions, AdapterRevalidateResult } from '../types'
2
2
  import { validateBuildInputs } from './validate'
3
+ import { warnMissingEnv } from './warn-missing-env'
3
4
 
4
5
  /**
5
6
  * Cloudflare Pages adapter — generates output for Cloudflare Pages with Functions.
@@ -28,6 +29,34 @@ export function cloudflareAdapter(): Adapter {
28
29
  return {
29
30
  name: 'cloudflare',
30
31
  async build(options: AdapterBuildOptions) {
32
+ if (options.kind === 'ssg') {
33
+ // PR J — SSG branch. Emit Cloudflare Pages `_routes.json` with
34
+ // `include: []` + `exclude: ['/*']` — i.e. "every URL is a
35
+ // static asset, never invoke a Pages Function". Without this
36
+ // file, Pages defaults to running the worker on every request,
37
+ // which is wasteful for prerendered SSG output (and incurs
38
+ // function-invocation costs on paid plans).
39
+ //
40
+ // Reference: https://developers.cloudflare.com/pages/functions/routing/
41
+ // — `version: 1`, `include` lists URL globs that DO invoke the
42
+ // function, `exclude` lists globs that bypass it. Setting
43
+ // `include: []` makes the function unreachable; the result is
44
+ // a pure-static deploy.
45
+ //
46
+ // Deploy with: `npx wrangler pages deploy ./dist`
47
+ const { writeFile } = await import('node:fs/promises')
48
+ const { join } = await import('node:path')
49
+ const routesConfig = {
50
+ version: 1,
51
+ include: [] as string[],
52
+ exclude: ['/*'],
53
+ }
54
+ await writeFile(
55
+ join(options.outDir, '_routes.json'),
56
+ JSON.stringify(routesConfig, null, 2),
57
+ )
58
+ return
59
+ }
31
60
  await validateBuildInputs(options)
32
61
  const { writeFile, cp, mkdir } = await import('node:fs/promises')
33
62
  const { join } = await import('node:path')
@@ -80,5 +109,53 @@ export default {
80
109
 
81
110
  await writeFile(join(outDir, '_routes.json'), JSON.stringify(routesConfig, null, 2))
82
111
  },
112
+ async revalidate(path: string): Promise<AdapterRevalidateResult> {
113
+ // Cloudflare Pages ISR via Cache API delete + zone purge.
114
+ // Reads `CLOUDFLARE_ZONE_ID` and `CLOUDFLARE_API_TOKEN` from env
115
+ // (set in Workers / Pages dashboard → Variables). The zone
116
+ // purge endpoint accepts a list of URLs and removes them from
117
+ // every PoP's edge cache; the next visitor triggers a fresh
118
+ // origin fetch which rebuilds the prerendered page.
119
+ //
120
+ // Reference: https://developers.cloudflare.com/api/operations/zone-purge
121
+ const zoneId = process.env.CLOUDFLARE_ZONE_ID
122
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN
123
+ const siteUrl = process.env.CLOUDFLARE_SITE_URL
124
+ if (!zoneId || !apiToken || !siteUrl) {
125
+ // M2.4 — warn even in production (dedupe per process). See vercel.ts
126
+ // for the rationale.
127
+ const missing: string[] = []
128
+ if (!zoneId) missing.push('CLOUDFLARE_ZONE_ID')
129
+ if (!apiToken) missing.push('CLOUDFLARE_API_TOKEN')
130
+ if (!siteUrl) missing.push('CLOUDFLARE_SITE_URL')
131
+ return warnMissingEnv(
132
+ 'cloudflare',
133
+ missing,
134
+ 'Set them in Cloudflare Pages dashboard → Settings → Environment Variables. Note: Cloudflare imposes a 1000-purge-per-24h rate limit per zone — high-frequency revalidation will hit it.',
135
+ )
136
+ }
137
+ const fullUrl = `${siteUrl.replace(/\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`
138
+ try {
139
+ const res = await fetch(
140
+ `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
141
+ {
142
+ method: 'POST',
143
+ headers: {
144
+ 'Authorization': `Bearer ${apiToken}`,
145
+ 'Content-Type': 'application/json',
146
+ },
147
+ body: JSON.stringify({ files: [fullUrl] }),
148
+ },
149
+ )
150
+ return { regenerated: res.ok }
151
+ } catch (err) {
152
+ if (process.env.NODE_ENV !== 'production') {
153
+ console.warn(
154
+ `[Pyreon] cloudflareAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`,
155
+ )
156
+ }
157
+ return { regenerated: false }
158
+ }
159
+ },
83
160
  }
84
161
  }
@@ -16,11 +16,33 @@ import { vercelAdapter } from './vercel'
16
16
  /**
17
17
  * Resolve the adapter from config.
18
18
  * Returns a built-in adapter or throws if unknown.
19
+ *
20
+ * Accepts BOTH forms — the `ZeroConfig.adapter` type advertises string
21
+ * names (`'vercel'` / `'cloudflare'` / …) but the scaffolded templates
22
+ * historically emit `adapter: vercelAdapter()` (an Adapter instance via
23
+ * the named factory). Both work: a string goes through the switch lookup;
24
+ * an Adapter object (duck-typed via `name` + `build` fields) passes
25
+ * through. Pre-PR-J `resolveAdapter` was never called from production
26
+ * code so the string-vs-object mismatch was invisible; PR J wires the
27
+ * call into `ssgPlugin.closeBundle`, surfacing the contract divergence.
28
+ * The passthrough preserves both shapes without a breaking type change.
19
29
  */
20
30
  export function resolveAdapter(config: ZeroConfig): Adapter {
21
- const name = config.adapter ?? 'node'
31
+ const value = config.adapter ?? 'node'
22
32
 
23
- switch (name) {
33
+ // Passthrough for already-constructed Adapter instances. Scaffolded
34
+ // templates emit `adapter: vercelAdapter()` — detect by duck-typing
35
+ // the two required Adapter fields (`name: string` + `build: function`).
36
+ if (
37
+ typeof value === 'object'
38
+ && value !== null
39
+ && typeof (value as Adapter).name === 'string'
40
+ && typeof (value as Adapter).build === 'function'
41
+ ) {
42
+ return value as Adapter
43
+ }
44
+
45
+ switch (value) {
24
46
  case 'node':
25
47
  return nodeAdapter()
26
48
  case 'bun':
@@ -34,6 +56,6 @@ export function resolveAdapter(config: ZeroConfig): Adapter {
34
56
  case 'netlify':
35
57
  return netlifyAdapter()
36
58
  default:
37
- throw new Error(`[Pyreon] Unknown adapter: "${name}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`)
59
+ throw new Error(`[Pyreon] Unknown adapter: "${String(value)}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`)
38
60
  }
39
61
  }
@@ -1,5 +1,6 @@
1
- import type { Adapter, AdapterBuildOptions } from '../types'
1
+ import type { Adapter, AdapterBuildOptions, AdapterRevalidateResult } from '../types'
2
2
  import { validateBuildInputs } from './validate'
3
+ import { warnMissingEnv } from './warn-missing-env'
3
4
 
4
5
  /**
5
6
  * Netlify adapter — generates output for Netlify Functions (v2).
@@ -23,6 +24,31 @@ export function netlifyAdapter(): Adapter {
23
24
  return {
24
25
  name: 'netlify',
25
26
  async build(options: AdapterBuildOptions) {
27
+ if (options.kind === 'ssg') {
28
+ // PR J — SSG branch. Emit `netlify.toml` with `publish = "."`
29
+ // (the dist root) and asset-cache headers — no functions. Tells
30
+ // Netlify "this dist directory IS the publishable output, serve
31
+ // it as a static site". Without this file, Netlify falls back
32
+ // to whatever the user has at the repo root (might miss the
33
+ // dist/ direct-upload shape).
34
+ //
35
+ // PR B's `dist/_redirects` (loader-redirect manifest) is
36
+ // emitted by ssgPlugin BEFORE the adapter runs, so this branch
37
+ // doesn't need to write it. The two files coexist cleanly —
38
+ // Netlify reads both.
39
+ const { writeFile } = await import('node:fs/promises')
40
+ const { join } = await import('node:path')
41
+ const toml = `[build]
42
+ publish = "."
43
+
44
+ [[headers]]
45
+ for = "/assets/*"
46
+ [headers.values]
47
+ Cache-Control = "public, max-age=31536000, immutable"
48
+ `
49
+ await writeFile(join(options.outDir, 'netlify.toml'), toml)
50
+ return
51
+ }
26
52
  await validateBuildInputs(options)
27
53
  const { writeFile, cp, mkdir } = await import('node:fs/promises')
28
54
  const { join } = await import('node:path')
@@ -82,5 +108,41 @@ export const config = {
82
108
 
83
109
  await writeFile(join(outDir, 'netlify.toml'), toml)
84
110
  },
111
+ async revalidate(path: string): Promise<AdapterRevalidateResult> {
112
+ // Netlify ISR via Build Hook trigger. Reads
113
+ // `NETLIFY_BUILD_HOOK_URL` from env (created in Site settings →
114
+ // Build hooks). Posting to the hook triggers a partial rebuild
115
+ // — Netlify rebuilds only the pages whose source has changed
116
+ // since last deploy. The path arg is included as a `trigger_title`
117
+ // for audit traceability in the Netlify deploy log; Netlify
118
+ // doesn't accept per-path revalidation natively (the hook
119
+ // re-runs the full build).
120
+ //
121
+ // Reference: https://docs.netlify.com/configure-builds/build-hooks/
122
+ const hookUrl = process.env.NETLIFY_BUILD_HOOK_URL
123
+ if (!hookUrl) {
124
+ // M2.4 — warn even in production (dedupe per process). See vercel.ts
125
+ // for the rationale.
126
+ return warnMissingEnv(
127
+ 'netlify',
128
+ ['NETLIFY_BUILD_HOOK_URL'],
129
+ 'Create a build hook in Site settings → Build & deploy → Build hooks → Add build hook. Note: Netlify Build Hooks trigger a FULL site rebuild — the path arg is recorded as `trigger_title` for audit traceability but Netlify doesn\'t support per-page ISR natively.',
130
+ )
131
+ }
132
+ try {
133
+ const triggerTitle = `revalidate:${path}`
134
+ const res = await fetch(`${hookUrl}?trigger_title=${encodeURIComponent(triggerTitle)}`, {
135
+ method: 'POST',
136
+ })
137
+ return { regenerated: res.ok }
138
+ } catch (err) {
139
+ if (process.env.NODE_ENV !== 'production') {
140
+ console.warn(
141
+ `[Pyreon] netlifyAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`,
142
+ )
143
+ }
144
+ return { regenerated: false }
145
+ }
146
+ },
85
147
  }
86
148
  }
@@ -1,13 +1,22 @@
1
- import type { Adapter, AdapterBuildOptions } from '../types'
1
+ import type { Adapter, AdapterBuildOptions, AdapterRevalidateResult } from '../types'
2
2
  import { validateBuildInputs } from './validate'
3
3
 
4
4
  /**
5
5
  * Node.js adapter — generates a standalone server entry using node:http.
6
+ *
7
+ * **SSG mode (PR J)**: no-op. Node adapter exists for serving the SSR
8
+ * runtime; SSG output is already complete static HTML — serve it with
9
+ * any static-file server (`bun preview` / nginx / Caddy / `npx serve`).
10
+ * Use `staticAdapter()` if you want explicit SSG semantics.
6
11
  */
7
12
  export function nodeAdapter(): Adapter {
8
13
  return {
9
14
  name: 'node',
10
15
  async build(options: AdapterBuildOptions) {
16
+ if (options.kind === 'ssg') {
17
+ // Node runner has nothing to add for prerendered SSG dist.
18
+ return
19
+ }
11
20
  await validateBuildInputs(options)
12
21
  const { writeFile, cp, mkdir } = await import('node:fs/promises')
13
22
  const { join } = await import('node:path')
@@ -108,5 +117,20 @@ server.listen(${port}, () => {
108
117
  await writeFile(join(outDir, 'index.js'), serverEntry)
109
118
  await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' }, null, 2))
110
119
  },
120
+ async revalidate(_path: string): Promise<AdapterRevalidateResult> {
121
+ // Self-hosted Node has no platform-driven ISR. Real ISR support
122
+ // requires a reverse-proxy cache (nginx/varnish) + your own
123
+ // cache-purge wiring, OR mode: 'isr' for runtime LRU caching.
124
+ // This no-op preserves the Adapter API contract; user code that
125
+ // calls `adapter.revalidate(path)` against a self-hosted Node
126
+ // deploy gets the same `regenerated: false` shape as the static
127
+ // adapter, so migrating between adapters doesn't surprise.
128
+ if (process.env.NODE_ENV !== 'production') {
129
+ console.warn(
130
+ '[Pyreon] nodeAdapter.revalidate() is a no-op — self-hosted Node has no platform-driven ISR. Use mode: "isr" for runtime LRU caching, or vercelAdapter / cloudflareAdapter / netlifyAdapter for platform-driven build-time ISR.',
131
+ )
132
+ }
133
+ return { regenerated: false }
134
+ },
111
135
  }
112
136
  }
@@ -1,17 +1,42 @@
1
- import type { Adapter, AdapterBuildOptions } from '../types'
1
+ import type { Adapter, AdapterBuildOptions, AdapterRevalidateResult } from '../types'
2
2
 
3
3
  /**
4
4
  * Static adapter — just copies the client build output.
5
5
  * Used with SSG mode where all pages are pre-rendered at build time.
6
+ *
7
+ * **SSG mode (PR J)**: no-op — `outDir` already IS the dist directory
8
+ * the SSG plugin produced. Copying it onto itself would only fail. The
9
+ * static adapter is the canonical zero-overhead deploy target for
10
+ * pure-static sites.
11
+ *
12
+ * **SSR mode**: copies clientOutDir → outDir. Calling `static` with SSR
13
+ * mode is unusual — the static adapter doesn't support server-side
14
+ * execution — but preserved as a "client-only output packager".
6
15
  */
7
16
  export function staticAdapter(): Adapter {
8
17
  return {
9
18
  name: 'static',
10
19
  async build(options: AdapterBuildOptions) {
20
+ if (options.kind === 'ssg') {
21
+ // SSG dist is already at outDir — nothing to copy or rewrite.
22
+ return
23
+ }
11
24
  const { cp, mkdir } = await import('node:fs/promises')
12
25
 
13
26
  await mkdir(options.outDir, { recursive: true })
14
27
  await cp(options.clientOutDir, options.outDir, { recursive: true })
15
28
  },
29
+ async revalidate(_path: string): Promise<AdapterRevalidateResult> {
30
+ // Static hosts have no platform-driven ISR. Revalidation requires
31
+ // a full rebuild + redeploy. Returns `regenerated: false` so user
32
+ // code can branch on the no-op shape and degrade gracefully when
33
+ // migrating between adapters.
34
+ if (process.env.NODE_ENV !== 'production') {
35
+ console.warn(
36
+ '[Pyreon] staticAdapter.revalidate() is a no-op — static hosts require a full rebuild + redeploy to refresh prerendered pages. Use vercelAdapter / cloudflareAdapter / netlifyAdapter for platform-driven ISR.',
37
+ )
38
+ }
39
+ return { regenerated: false }
40
+ },
16
41
  }
17
42
  }
@@ -1,11 +1,18 @@
1
1
  import type { AdapterBuildOptions } from '../types'
2
2
 
3
3
  /**
4
- * Validate that adapter build inputs exist before copying.
4
+ * Validate that SSR-mode adapter build inputs exist before copying.
5
5
  * Throws with a clear error message if directories are missing.
6
+ *
7
+ * SSG-mode passes through unchanged — the SSG branch doesn't need a
8
+ * server entry (every page is prerendered) and `outDir` IS the dist
9
+ * directory the SSG plugin already populated. Validating it here would
10
+ * be redundant.
11
+ *
6
12
  * @internal
7
13
  */
8
14
  export async function validateBuildInputs(options: AdapterBuildOptions): Promise<void> {
15
+ if (options.kind !== 'ssr') return
9
16
  const { existsSync } = await import('node:fs')
10
17
  if (!existsSync(options.clientOutDir)) {
11
18
  throw new Error(`[Pyreon] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`)
@@ -1,5 +1,6 @@
1
- import type { Adapter, AdapterBuildOptions } from '../types'
1
+ import type { Adapter, AdapterBuildOptions, AdapterRevalidateResult } from '../types'
2
2
  import { validateBuildInputs } from './validate'
3
+ import { warnMissingEnv } from './warn-missing-env'
3
4
 
4
5
  /**
5
6
  * Vercel adapter — generates output for Vercel's Build Output API v3.
@@ -23,6 +24,36 @@ export function vercelAdapter(): Adapter {
23
24
  return {
24
25
  name: 'vercel',
25
26
  async build(options: AdapterBuildOptions) {
27
+ if (options.kind === 'ssg') {
28
+ // PR J — SSG branch. Emit Vercel Build Output API v3 STATIC
29
+ // variant: `.vercel/output/config.json` listing routes config
30
+ // for the prerendered dist; no functions (every page is
31
+ // already static). Vercel's deployer reads this config + the
32
+ // built dist content as the static asset root — no runtime SSR.
33
+ //
34
+ // We do NOT copy files into `.vercel/output/static/` — the
35
+ // standard Vercel CLI deploy flow detects the dist root
36
+ // automatically. Adapters that move files break user-side
37
+ // post-build steps (sourcemap upload, perf scripts, custom
38
+ // asset handling). Writing config.json alone is the
39
+ // minimum-impact signal "this is a prerendered site".
40
+ const { writeFile, mkdir } = await import('node:fs/promises')
41
+ const { join } = await import('node:path')
42
+ const vercelDir = join(options.outDir, '.vercel', 'output')
43
+ await mkdir(vercelDir, { recursive: true })
44
+ const config = {
45
+ version: 3,
46
+ routes: [
47
+ // Long-cache hashed assets; mirrors the SSR config above.
48
+ {
49
+ src: '/assets/(.*)',
50
+ headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
51
+ },
52
+ ],
53
+ }
54
+ await writeFile(join(vercelDir, 'config.json'), JSON.stringify(config, null, 2))
55
+ return
56
+ }
26
57
  await validateBuildInputs(options)
27
58
  const { writeFile, cp, mkdir } = await import('node:fs/promises')
28
59
  const { join } = await import('node:path')
@@ -82,5 +113,49 @@ export default async function handler(req) {
82
113
 
83
114
  await writeFile(join(vercelDir, 'config.json'), JSON.stringify(config, null, 2))
84
115
  },
116
+ async revalidate(path: string): Promise<AdapterRevalidateResult> {
117
+ // Vercel ISR API — POST to a deployment-relative
118
+ // revalidation endpoint with a secret token. Reads
119
+ // `VERCEL_DEPLOYMENT_URL` (auto-injected by Vercel runtime) and
120
+ // `VERCEL_REVALIDATE_TOKEN` (user-set in dashboard) from env.
121
+ // Mirrors Next.js's `res.revalidate()` shape — a HEAD request
122
+ // with the path + token, Vercel rebuilds the page in the
123
+ // background and serves stale-while-revalidate to subsequent
124
+ // visitors until the rebuild lands.
125
+ //
126
+ // No `regenerated: true` until Vercel acks 200 — partial-purge
127
+ // behaviour (the platform queues the regenerate but doesn't
128
+ // confirm it landed) is documented as a "false-positive
129
+ // possible" caveat in the Adapter.revalidate JSDoc.
130
+ const deploymentUrl = process.env.VERCEL_DEPLOYMENT_URL ?? process.env.VERCEL_URL
131
+ const token = process.env.VERCEL_REVALIDATE_TOKEN
132
+ if (!deploymentUrl || !token) {
133
+ // M2.4 — warn even in production (dedupe per process). Pre-fix the
134
+ // warn was DEV-gated, but production is exactly where missing env
135
+ // vars surface — CMS triggers revalidate, nothing happens, no
136
+ // signal. Now the FIRST call always warns; subsequent calls dedupe.
137
+ const missing: string[] = []
138
+ if (!deploymentUrl) missing.push('VERCEL_DEPLOYMENT_URL (or VERCEL_URL)')
139
+ if (!token) missing.push('VERCEL_REVALIDATE_TOKEN')
140
+ return warnMissingEnv(
141
+ 'vercel',
142
+ missing,
143
+ 'Set the token in Vercel project settings → Environment Variables. VERCEL_DEPLOYMENT_URL / VERCEL_URL is auto-injected by the Vercel runtime.',
144
+ )
145
+ }
146
+ const protocol = deploymentUrl.startsWith('http') ? '' : 'https://'
147
+ const url = `${protocol}${deploymentUrl}/api/_pyreon-revalidate?path=${encodeURIComponent(path)}&secret=${encodeURIComponent(token)}`
148
+ try {
149
+ const res = await fetch(url, { method: 'POST' })
150
+ return { regenerated: res.ok }
151
+ } catch (err) {
152
+ if (process.env.NODE_ENV !== 'production') {
153
+ console.warn(
154
+ `[Pyreon] vercelAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`,
155
+ )
156
+ }
157
+ return { regenerated: false }
158
+ }
159
+ },
85
160
  }
86
161
  }
@@ -0,0 +1,49 @@
1
+ import type { AdapterRevalidateResult } from '../types'
2
+
3
+ /**
4
+ * M2.4 — Loud first-call warning for missing adapter env vars.
5
+ *
6
+ * Pre-M2.4 the adapter `revalidate()` methods returned `{ regenerated: false }`
7
+ * silently in production when required env vars were missing. The dev-mode
8
+ * warning was gated on `process.env.NODE_ENV !== 'production'` — exactly the
9
+ * env condition that DEPLOYED apps run under, where users would most need
10
+ * the signal. Symptom: CMS triggers `adapter.revalidate(path)`, nothing
11
+ * happens, no console output, no failure mode reported back to the
12
+ * triggering webhook handler. The bug only surfaces when someone notices
13
+ * stale content.
14
+ *
15
+ * Fix: warn ALWAYS (regardless of NODE_ENV) on the FIRST invocation per
16
+ * `(adapterName + varSet)` combination. Dedupe via a module-level Set so
17
+ * a busy revalidation handler doesn't spam logs — one warn per process
18
+ * per missing-env-set is enough to expose the misconfiguration.
19
+ *
20
+ * Returns the canonical `{ regenerated: false }` so adapters can write
21
+ * `return warnMissingEnv(...)` as a one-liner.
22
+ *
23
+ * @internal Exposed for unit tests via `_internal.warnMissingEnv` (not yet wired) + `_warnedKeys` reset.
24
+ */
25
+ const _warnedKeys = new Set<string>()
26
+
27
+ export function warnMissingEnv(
28
+ adapterName: string,
29
+ missingVars: readonly string[],
30
+ hint: string,
31
+ ): AdapterRevalidateResult {
32
+ const key = `${adapterName}:${missingVars.join(',')}`
33
+ if (!_warnedKeys.has(key)) {
34
+ _warnedKeys.add(key)
35
+ // oxlint-disable-next-line no-console
36
+ console.warn(
37
+ `[Pyreon] ${adapterName}Adapter.revalidate() needs ${missingVars.join(' + ')} env var(s). ${hint}`,
38
+ )
39
+ }
40
+ return { regenerated: false }
41
+ }
42
+
43
+ /**
44
+ * Reset the dedup Set. Test-only — production code never reaches this.
45
+ * @internal
46
+ */
47
+ export function _resetWarnedKeys(): void {
48
+ _warnedKeys.clear()
49
+ }
package/src/app.ts CHANGED
@@ -21,6 +21,19 @@ export interface CreateAppOptions {
21
21
 
22
22
  /** Global error component. */
23
23
  errorComponent?: ComponentFn
24
+
25
+ /**
26
+ * Base URL prefix for the deployed app (e.g. `/blog/`). Forwarded to
27
+ * `createRouter({ base })` so RouterLinks render correctly prefixed
28
+ * hrefs (`<a href="/blog/about">` instead of `<a href="/about">`) and
29
+ * the router strips the prefix from incoming URLs before matching.
30
+ *
31
+ * Default: `'/'`. Pre-fix this was disconnected from `zero({ base })`
32
+ * — RouterLinks rendered un-prefixed hrefs even when Vite's asset URL
33
+ * rewriting was correctly using the prefix, causing client-side
34
+ * navigation to break against subpath deploys.
35
+ */
36
+ base?: string
24
37
  }
25
38
 
26
39
  /**
@@ -33,6 +46,7 @@ export function createApp(options: CreateAppOptions) {
33
46
  routes: options.routes,
34
47
  mode: options.routerMode ?? 'history',
35
48
  ...(options.url ? { url: options.url } : {}),
49
+ ...(options.base && options.base !== '/' ? { base: options.base } : {}),
36
50
  scrollBehavior: 'top',
37
51
  })
38
52
 
package/src/client.ts CHANGED
@@ -5,6 +5,13 @@ import { hydrateLoaderData } from '@pyreon/router'
5
5
  import { hydrateRoot, mount } from '@pyreon/runtime-dom'
6
6
  import { createApp } from './app'
7
7
 
8
+ // Vite-injected build-time constant. Defined in `vite-plugin.ts`'s
9
+ // `config()` hook from `zero({ base })`. Falls back to `'/'` for
10
+ // non-Vite builds (test environments, etc.) so the read is always
11
+ // safe. The fallback is documented intent — there's no Pyreon
12
+ // deployment outside Vite that consumes this.
13
+ declare const __ZERO_BASE__: string
14
+
8
15
  // ─── Client entry factory ───────────────────────────────────────────────────
9
16
 
10
17
  export interface StartClientOptions {
@@ -59,10 +66,21 @@ export function startClient(options: StartClientOptions) {
59
66
  const container = document.getElementById('app')
60
67
  if (!container) throw new Error('[Pyreon] Missing #app container element')
61
68
 
69
+ // Read the Vite-injected base so `createRouter({ base })` matches the
70
+ // value Vite used to rewrite asset URLs. `typeof` guard covers the
71
+ // edge case where the constant isn't defined (non-Vite test contexts);
72
+ // missing the constant in a real Vite build is impossible because the
73
+ // plugin's `config()` hook always declares it via `define`.
74
+ const base =
75
+ typeof __ZERO_BASE__ !== 'undefined' && __ZERO_BASE__ !== '/'
76
+ ? __ZERO_BASE__
77
+ : undefined
78
+
62
79
  const { App, router } = createApp({
63
80
  routes: options.routes,
64
81
  routerMode: 'history',
65
82
  ...(options.layout ? { layout: options.layout } : {}),
83
+ ...(base ? { base } : {}),
66
84
  })
67
85
 
68
86
  // ── Loader data hydration (SSR path) ───────────────────────────────────────