@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.
- package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
- package/lib/client.js +4 -1
- package/lib/env.js +6 -6
- package/lib/font.js +3 -3
- package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
- package/lib/i18n-routing.js +112 -1
- package/lib/image.js +140 -58
- package/lib/index.js +252 -82
- package/lib/og-image.js +5 -5
- package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
- package/lib/script.js +114 -25
- package/lib/seo.js +186 -15
- package/lib/server.js +274 -564
- package/lib/types/config.d.ts +307 -3
- package/lib/types/env.d.ts +2 -2
- package/lib/types/i18n-routing.d.ts +193 -2
- package/lib/types/image.d.ts +105 -5
- package/lib/types/index.d.ts +666 -182
- package/lib/types/script.d.ts +78 -6
- package/lib/types/seo.d.ts +128 -4
- package/lib/types/server.d.ts +607 -72
- package/lib/vite-plugin-y0NmCLJA.js +2476 -0
- package/package.json +11 -10
- package/src/adapters/bun.ts +20 -1
- package/src/adapters/cloudflare.ts +78 -1
- package/src/adapters/index.ts +25 -3
- package/src/adapters/netlify.ts +63 -1
- package/src/adapters/node.ts +25 -1
- package/src/adapters/static.ts +26 -1
- package/src/adapters/validate.ts +8 -1
- package/src/adapters/vercel.ts +76 -1
- package/src/adapters/warn-missing-env.ts +49 -0
- package/src/app.ts +14 -0
- package/src/client.ts +18 -0
- package/src/entry-server.ts +55 -5
- package/src/env.ts +7 -7
- package/src/font.ts +3 -3
- package/src/fs-router.ts +72 -3
- package/src/i18n-routing.ts +246 -12
- package/src/image.tsx +242 -91
- package/src/index.ts +4 -4
- package/src/isr.ts +24 -6
- package/src/manifest.ts +675 -0
- package/src/og-image.ts +5 -5
- package/src/script.tsx +159 -36
- package/src/seo.ts +346 -15
- package/src/server.ts +10 -2
- package/src/ssg-plugin.ts +1211 -54
- package/src/types.ts +333 -10
- package/src/vercel-revalidate-handler.ts +204 -0
- package/src/vite-plugin.ts +171 -41
- 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.
|
|
3
|
+
"version": "0.18.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.
|
|
172
|
-
"@pyreon/head": "^0.
|
|
173
|
-
"@pyreon/meta": "^0.
|
|
174
|
-
"@pyreon/
|
|
175
|
-
"@pyreon/
|
|
176
|
-
"@pyreon/runtime-
|
|
177
|
-
"@pyreon/server": "^0.
|
|
178
|
-
"@pyreon/
|
|
171
|
+
"@pyreon/core": "^0.18.0",
|
|
172
|
+
"@pyreon/head": "^0.18.0",
|
|
173
|
+
"@pyreon/meta": "^0.18.0",
|
|
174
|
+
"@pyreon/reactivity": "^0.18.0",
|
|
175
|
+
"@pyreon/router": "^0.18.0",
|
|
176
|
+
"@pyreon/runtime-dom": "^0.18.0",
|
|
177
|
+
"@pyreon/runtime-server": "^0.18.0",
|
|
178
|
+
"@pyreon/server": "^0.18.0",
|
|
179
|
+
"@pyreon/vite-plugin": "^0.18.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": {
|
package/src/adapters/bun.ts
CHANGED
|
@@ -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
|
}
|
package/src/adapters/index.ts
CHANGED
|
@@ -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
|
|
31
|
+
const value = config.adapter ?? 'node'
|
|
22
32
|
|
|
23
|
-
|
|
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: "${
|
|
59
|
+
throw new Error(`[Pyreon] Unknown adapter: "${String(value)}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`)
|
|
38
60
|
}
|
|
39
61
|
}
|
package/src/adapters/netlify.ts
CHANGED
|
@@ -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
|
}
|
package/src/adapters/node.ts
CHANGED
|
@@ -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
|
}
|
package/src/adapters/static.ts
CHANGED
|
@@ -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
|
}
|
package/src/adapters/validate.ts
CHANGED
|
@@ -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.`)
|
package/src/adapters/vercel.ts
CHANGED
|
@@ -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) ───────────────────────────────────────
|