@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/lib/server.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { i as
|
|
2
|
-
import {
|
|
3
|
-
import { createContext } from "@pyreon/core";
|
|
1
|
+
import { _ as render404Page, a as detectLocaleFromHeader, c as vercelAdapter, d as netlifyAdapter, f as cloudflareAdapter, g as createServer, h as resolveConfig, i as createLocaleContext, l as staticAdapter, m as defineConfig, o as i18nRouting, p as bunAdapter, r as zeroPlugin, s as resolveAdapter, t as getZeroPluginConfig, u as nodeAdapter, v as createApp } from "./vite-plugin-y0NmCLJA.js";
|
|
2
|
+
import { i as generateRouteModule, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles, t as filePathToUrlPath } from "./fs-router-MewHc5SB.js";
|
|
4
3
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { readFile } from "node:fs/promises";
|
|
7
|
-
import { signal } from "@pyreon/reactivity";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
8
6
|
|
|
9
7
|
//#region src/isr.ts
|
|
10
8
|
/**
|
|
@@ -25,6 +23,7 @@ function createISRHandler(handler, config) {
|
|
|
25
23
|
const revalidating = /* @__PURE__ */ new Set();
|
|
26
24
|
const revalidateMs = config.revalidate * 1e3;
|
|
27
25
|
const maxEntries = Math.max(1, config.maxEntries ?? 1e3);
|
|
26
|
+
const deriveKey = typeof config.cacheKey === "function" ? (req, _url) => config.cacheKey(req) : (_req, url) => url.pathname;
|
|
28
27
|
function set(key, entry) {
|
|
29
28
|
if (cache.has(key)) cache.delete(key);
|
|
30
29
|
cache.set(key, entry);
|
|
@@ -42,12 +41,15 @@ function createISRHandler(handler, config) {
|
|
|
42
41
|
}
|
|
43
42
|
return entry;
|
|
44
43
|
}
|
|
45
|
-
async function revalidate(url) {
|
|
46
|
-
const key = url
|
|
44
|
+
async function revalidate(url, originalReq) {
|
|
45
|
+
const key = deriveKey(originalReq, url);
|
|
47
46
|
if (revalidating.has(key)) return;
|
|
48
47
|
revalidating.add(key);
|
|
49
48
|
try {
|
|
50
|
-
const res = await handler(new Request(url.href, {
|
|
49
|
+
const res = await handler(new Request(url.href, {
|
|
50
|
+
method: "GET",
|
|
51
|
+
headers: originalReq.headers
|
|
52
|
+
}));
|
|
51
53
|
const html = await res.text();
|
|
52
54
|
const headers = {};
|
|
53
55
|
res.headers.forEach((v, k) => {
|
|
@@ -65,11 +67,11 @@ function createISRHandler(handler, config) {
|
|
|
65
67
|
return async (req) => {
|
|
66
68
|
if (req.method !== "GET") return handler(req);
|
|
67
69
|
const url = new URL(req.url);
|
|
68
|
-
const key = url
|
|
70
|
+
const key = deriveKey(req, url);
|
|
69
71
|
const entry = touch(key);
|
|
70
72
|
if (entry) {
|
|
71
73
|
const age = Date.now() - entry.timestamp;
|
|
72
|
-
if (age > revalidateMs) revalidate(url);
|
|
74
|
+
if (age > revalidateMs) revalidate(url, req);
|
|
73
75
|
return new Response(entry.html, {
|
|
74
76
|
status: 200,
|
|
75
77
|
headers: {
|
|
@@ -86,7 +88,7 @@ function createISRHandler(handler, config) {
|
|
|
86
88
|
res.headers.forEach((v, k) => {
|
|
87
89
|
headers[k] = v;
|
|
88
90
|
});
|
|
89
|
-
|
|
91
|
+
set(key, {
|
|
90
92
|
html,
|
|
91
93
|
headers,
|
|
92
94
|
timestamp: Date.now()
|
|
@@ -103,428 +105,97 @@ function createISRHandler(handler, config) {
|
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
//#endregion
|
|
106
|
-
//#region src/
|
|
107
|
-
/**
|
|
108
|
-
* Validate that adapter build inputs exist before copying.
|
|
109
|
-
* Throws with a clear error message if directories are missing.
|
|
110
|
-
* @internal
|
|
111
|
-
*/
|
|
112
|
-
async function validateBuildInputs(options) {
|
|
113
|
-
const { existsSync } = await import("node:fs");
|
|
114
|
-
if (!existsSync(options.clientOutDir)) throw new Error(`[Pyreon] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`);
|
|
115
|
-
if (!existsSync(options.serverEntry)) throw new Error(`[Pyreon] Server entry not found: ${options.serverEntry}. Run "vite build --ssr" first.`);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
//#endregion
|
|
119
|
-
//#region src/adapters/bun.ts
|
|
120
|
-
/**
|
|
121
|
-
* Bun adapter — generates a standalone Bun.serve() entry.
|
|
122
|
-
*/
|
|
123
|
-
function bunAdapter() {
|
|
124
|
-
return {
|
|
125
|
-
name: "bun",
|
|
126
|
-
async build(options) {
|
|
127
|
-
await validateBuildInputs(options);
|
|
128
|
-
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
129
|
-
const { join } = await import("node:path");
|
|
130
|
-
const outDir = options.outDir;
|
|
131
|
-
await mkdir(outDir, { recursive: true });
|
|
132
|
-
await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
|
|
133
|
-
await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
|
|
134
|
-
const port = options.config.port ?? 3e3;
|
|
135
|
-
const serverEntry = `
|
|
136
|
-
const handler = (await import("./server/entry-server.js")).default
|
|
137
|
-
const clientDir = new URL("./client/", import.meta.url).pathname
|
|
138
|
-
|
|
139
|
-
Bun.serve({
|
|
140
|
-
port: ${port},
|
|
141
|
-
async fetch(req) {
|
|
142
|
-
const url = new URL(req.url)
|
|
143
|
-
|
|
144
|
-
// Try static files first
|
|
145
|
-
if (req.method === "GET") {
|
|
146
|
-
const filePath = clientDir + (url.pathname === "/" ? "index.html" : url.pathname)
|
|
147
|
-
// Prevent path traversal — ensure resolved path stays within clientDir
|
|
148
|
-
const resolved = Bun.resolveSync(filePath, ".")
|
|
149
|
-
if (!resolved.startsWith(Bun.resolveSync(clientDir, "."))) {
|
|
150
|
-
return new Response("Forbidden", { status: 403 })
|
|
151
|
-
}
|
|
152
|
-
const file = Bun.file(filePath)
|
|
153
|
-
if (await file.exists()) {
|
|
154
|
-
return new Response(file, {
|
|
155
|
-
headers: {
|
|
156
|
-
"cache-control": filePath.endsWith(".js") || filePath.endsWith(".css")
|
|
157
|
-
? "public, max-age=31536000, immutable"
|
|
158
|
-
: "public, max-age=3600",
|
|
159
|
-
},
|
|
160
|
-
})
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Fall through to SSR handler
|
|
165
|
-
return handler(req)
|
|
166
|
-
},
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
|
|
170
|
-
`.trimStart();
|
|
171
|
-
await writeFile(join(outDir, "index.ts"), serverEntry);
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
//#endregion
|
|
177
|
-
//#region src/adapters/cloudflare.ts
|
|
108
|
+
//#region src/vercel-revalidate-handler.ts
|
|
178
109
|
/**
|
|
179
|
-
*
|
|
110
|
+
* M3.1 — Drop-in Vercel revalidate webhook handler.
|
|
111
|
+
*
|
|
112
|
+
* Pre-M3.1 the `vercelAdapter.revalidate(path)` (PR I) POSTed to
|
|
113
|
+
* `/api/_pyreon-revalidate?path=...&secret=...` — a CONVENTION that users
|
|
114
|
+
* had to implement themselves. This helper scaffolds the convention:
|
|
180
115
|
*
|
|
181
|
-
*
|
|
182
|
-
* -
|
|
183
|
-
*
|
|
116
|
+
* // src/routes/api/_pyreon-revalidate.ts (or `pages/api/...` in
|
|
117
|
+
* // Next-style apps deployed to Vercel)
|
|
118
|
+
* export { vercelRevalidateHandler as default } from '@pyreon/zero/server'
|
|
184
119
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
120
|
+
* The handler validates the secret query param against
|
|
121
|
+
* `VERCEL_REVALIDATE_TOKEN`, validates the path is in the build-time
|
|
122
|
+
* revalidate manifest, and calls Vercel's `res.revalidate(path)` API.
|
|
188
123
|
*
|
|
189
|
-
*
|
|
124
|
+
* Returns a standard `(req: Request) => Response` Web API handler — works
|
|
125
|
+
* with Vercel Edge functions, Node serverless functions (via Vercel's
|
|
126
|
+
* `@vercel/node` adapter that bridges Node `req`/`res` to Web standard
|
|
127
|
+
* fetch shapes), and the in-process `mode: 'ssr'` runtime.
|
|
190
128
|
*
|
|
191
129
|
* @example
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* import { defineConfig } from "@pyreon/zero"
|
|
130
|
+
* // src/routes/api/_pyreon-revalidate.ts
|
|
131
|
+
* import { vercelRevalidateHandler } from '@pyreon/zero/server'
|
|
195
132
|
*
|
|
196
|
-
* export
|
|
197
|
-
*
|
|
133
|
+
* export const POST = vercelRevalidateHandler({
|
|
134
|
+
* // Optional — defaults to reading `_pyreon-revalidate.json` from cwd.
|
|
135
|
+
* manifestPath: './dist/_pyreon-revalidate.json',
|
|
198
136
|
* })
|
|
199
|
-
* ```
|
|
200
|
-
*/
|
|
201
|
-
function cloudflareAdapter() {
|
|
202
|
-
return {
|
|
203
|
-
name: "cloudflare",
|
|
204
|
-
async build(options) {
|
|
205
|
-
await validateBuildInputs(options);
|
|
206
|
-
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
207
|
-
const { join } = await import("node:path");
|
|
208
|
-
const outDir = options.outDir;
|
|
209
|
-
await mkdir(outDir, { recursive: true });
|
|
210
|
-
await cp(options.clientOutDir, outDir, { recursive: true });
|
|
211
|
-
await cp(join(options.serverEntry, ".."), join(outDir, "_server"), { recursive: true });
|
|
212
|
-
const workerEntry = `
|
|
213
|
-
import handler from "./_server/entry-server.js"
|
|
214
|
-
|
|
215
|
-
export default {
|
|
216
|
-
async fetch(request, env, ctx) {
|
|
217
|
-
const url = new URL(request.url)
|
|
218
|
-
|
|
219
|
-
// Let Cloudflare serve static assets (files with extensions)
|
|
220
|
-
// This check is a fallback — Pages routes static files automatically
|
|
221
|
-
const ext = url.pathname.split(".").pop()
|
|
222
|
-
if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
|
|
223
|
-
// Cloudflare Pages handles static assets automatically via its asset binding
|
|
224
|
-
// Only reach here if the file doesn't exist — fall through to SSR
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// SSR handler
|
|
228
|
-
try {
|
|
229
|
-
return await handler(request)
|
|
230
|
-
} catch (err) {
|
|
231
|
-
return new Response("Internal Server Error", { status: 500 })
|
|
232
|
-
}
|
|
233
|
-
},
|
|
234
|
-
}
|
|
235
|
-
`.trimStart();
|
|
236
|
-
await writeFile(join(outDir, "_worker.js"), workerEntry);
|
|
237
|
-
await writeFile(join(outDir, "_routes.json"), JSON.stringify({
|
|
238
|
-
version: 1,
|
|
239
|
-
include: ["/*"],
|
|
240
|
-
exclude: [
|
|
241
|
-
"/assets/*",
|
|
242
|
-
"/favicon.*",
|
|
243
|
-
"/site.webmanifest",
|
|
244
|
-
"/robots.txt",
|
|
245
|
-
"/sitemap.xml"
|
|
246
|
-
]
|
|
247
|
-
}, null, 2));
|
|
248
|
-
}
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
//#endregion
|
|
253
|
-
//#region src/adapters/netlify.ts
|
|
254
|
-
/**
|
|
255
|
-
* Netlify adapter — generates output for Netlify Functions (v2).
|
|
256
|
-
*
|
|
257
|
-
* Produces:
|
|
258
|
-
* - Client assets in `publish/` directory
|
|
259
|
-
* - `netlify/functions/ssr.mjs` — Netlify Function for SSR
|
|
260
|
-
* - `netlify.toml` — routing configuration
|
|
261
137
|
*
|
|
262
138
|
* @example
|
|
263
|
-
*
|
|
264
|
-
* //
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
139
|
+
* // Custom revalidate impl (e.g. for a self-hosted Pyreon SSR runtime
|
|
140
|
+
* // that wants build-time revalidate behavior without Vercel's
|
|
141
|
+
* // `res.revalidate()` API):
|
|
142
|
+
* export const POST = vercelRevalidateHandler({
|
|
143
|
+
* onRevalidate: async (path) => {
|
|
144
|
+
* // Clear your in-process ISR cache, emit a metrics event, etc.
|
|
145
|
+
* await myCache.invalidate(path)
|
|
146
|
+
* },
|
|
269
147
|
* })
|
|
270
|
-
* ```
|
|
271
|
-
*/
|
|
272
|
-
function netlifyAdapter() {
|
|
273
|
-
return {
|
|
274
|
-
name: "netlify",
|
|
275
|
-
async build(options) {
|
|
276
|
-
await validateBuildInputs(options);
|
|
277
|
-
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
278
|
-
const { join } = await import("node:path");
|
|
279
|
-
const outDir = options.outDir;
|
|
280
|
-
const publishDir = join(outDir, "publish");
|
|
281
|
-
const functionsDir = join(outDir, "netlify", "functions");
|
|
282
|
-
await mkdir(publishDir, { recursive: true });
|
|
283
|
-
await mkdir(functionsDir, { recursive: true });
|
|
284
|
-
await cp(options.clientOutDir, publishDir, { recursive: true });
|
|
285
|
-
await cp(join(options.serverEntry, ".."), join(functionsDir, "_server"), { recursive: true });
|
|
286
|
-
const funcEntry = `
|
|
287
|
-
import handler from "./_server/entry-server.js"
|
|
288
|
-
|
|
289
|
-
export default async function(req, context) {
|
|
290
|
-
try {
|
|
291
|
-
return await handler(req)
|
|
292
|
-
} catch (err) {
|
|
293
|
-
return new Response("Internal Server Error", { status: 500 })
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export const config = {
|
|
298
|
-
path: "/*",
|
|
299
|
-
preferStatic: true,
|
|
300
|
-
}
|
|
301
|
-
`.trimStart();
|
|
302
|
-
await writeFile(join(functionsDir, "ssr.mjs"), funcEntry);
|
|
303
|
-
const toml = `
|
|
304
|
-
[build]
|
|
305
|
-
publish = "publish"
|
|
306
|
-
functions = "netlify/functions"
|
|
307
|
-
|
|
308
|
-
[[headers]]
|
|
309
|
-
for = "/assets/*"
|
|
310
|
-
[headers.values]
|
|
311
|
-
Cache-Control = "public, max-age=31536000, immutable"
|
|
312
|
-
|
|
313
|
-
[[redirects]]
|
|
314
|
-
from = "/*"
|
|
315
|
-
to = "/.netlify/functions/ssr"
|
|
316
|
-
status = 200
|
|
317
|
-
conditions = {Role = ["admin", "user", ""]}
|
|
318
|
-
`.trimStart();
|
|
319
|
-
await writeFile(join(outDir, "netlify.toml"), toml);
|
|
320
|
-
}
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
//#endregion
|
|
325
|
-
//#region src/adapters/node.ts
|
|
326
|
-
/**
|
|
327
|
-
* Node.js adapter — generates a standalone server entry using node:http.
|
|
328
148
|
*/
|
|
329
|
-
function nodeAdapter() {
|
|
330
|
-
return {
|
|
331
|
-
name: "node",
|
|
332
|
-
async build(options) {
|
|
333
|
-
await validateBuildInputs(options);
|
|
334
|
-
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
335
|
-
const { join } = await import("node:path");
|
|
336
|
-
const outDir = options.outDir;
|
|
337
|
-
await mkdir(outDir, { recursive: true });
|
|
338
|
-
await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
|
|
339
|
-
await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
|
|
340
|
-
const port = options.config.port ?? 3e3;
|
|
341
|
-
const serverEntry = `
|
|
342
|
-
import { createServer } from "node:http"
|
|
343
|
-
import { readFile } from "node:fs/promises"
|
|
344
|
-
import { join, extname } from "node:path"
|
|
345
|
-
import { fileURLToPath } from "node:url"
|
|
346
|
-
|
|
347
|
-
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
|
348
|
-
const handler = (await import("./server/entry-server.js")).default
|
|
349
|
-
const clientDir = join(__dirname, "client")
|
|
350
|
-
|
|
351
|
-
const MIME_TYPES = {
|
|
352
|
-
".html": "text/html",
|
|
353
|
-
".js": "application/javascript",
|
|
354
|
-
".css": "text/css",
|
|
355
|
-
".json": "application/json",
|
|
356
|
-
".png": "image/png",
|
|
357
|
-
".jpg": "image/jpeg",
|
|
358
|
-
".svg": "image/svg+xml",
|
|
359
|
-
".woff2": "font/woff2",
|
|
360
|
-
".woff": "font/woff",
|
|
361
|
-
".ico": "image/x-icon",
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const server = createServer(async (req, res) => {
|
|
365
|
-
const url = new URL(req.url ?? "/", "http://localhost")
|
|
366
|
-
|
|
367
|
-
// Try to serve static files first
|
|
368
|
-
if (req.method === "GET") {
|
|
369
|
-
try {
|
|
370
|
-
const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
|
|
371
|
-
// Prevent path traversal — ensure resolved path stays within clientDir
|
|
372
|
-
const { resolve } = await import("node:path")
|
|
373
|
-
const resolved = resolve(filePath)
|
|
374
|
-
if (!resolved.startsWith(resolve(clientDir))) {
|
|
375
|
-
res.writeHead(403)
|
|
376
|
-
res.end("Forbidden")
|
|
377
|
-
return
|
|
378
|
-
}
|
|
379
|
-
const ext = extname(filePath)
|
|
380
|
-
if (ext && ext !== ".html") {
|
|
381
|
-
const data = await readFile(filePath)
|
|
382
|
-
const mime = MIME_TYPES[ext] || "application/octet-stream"
|
|
383
|
-
res.writeHead(200, {
|
|
384
|
-
"content-type": mime,
|
|
385
|
-
"cache-control": ext === ".js" || ext === ".css"
|
|
386
|
-
? "public, max-age=31536000, immutable"
|
|
387
|
-
: "public, max-age=3600",
|
|
388
|
-
})
|
|
389
|
-
res.end(data)
|
|
390
|
-
return
|
|
391
|
-
}
|
|
392
|
-
} catch {}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Fall through to SSR handler
|
|
396
|
-
const headers = {}
|
|
397
|
-
for (const [key, value] of Object.entries(req.headers)) {
|
|
398
|
-
if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const request = new Request(url.href, {
|
|
402
|
-
method: req.method,
|
|
403
|
-
headers,
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
const response = await handler(request)
|
|
407
|
-
const body = await response.text()
|
|
408
|
-
|
|
409
|
-
const responseHeaders = {}
|
|
410
|
-
response.headers.forEach((v, k) => { responseHeaders[k] = v })
|
|
411
|
-
|
|
412
|
-
res.writeHead(response.status, responseHeaders)
|
|
413
|
-
res.end(body)
|
|
414
|
-
})
|
|
415
|
-
|
|
416
|
-
server.listen(${port}, () => {
|
|
417
|
-
console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
|
|
418
|
-
})
|
|
419
|
-
`.trimStart();
|
|
420
|
-
await writeFile(join(outDir, "index.js"), serverEntry);
|
|
421
|
-
await writeFile(join(outDir, "package.json"), JSON.stringify({ type: "module" }, null, 2));
|
|
422
|
-
}
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
//#endregion
|
|
427
|
-
//#region src/adapters/static.ts
|
|
428
149
|
/**
|
|
429
|
-
*
|
|
430
|
-
*
|
|
150
|
+
* Create the Web-standard request handler. Reads the manifest once on first
|
|
151
|
+
* invocation (cached in-process) so repeated revalidations don't re-read the
|
|
152
|
+
* file. Manifest read failures cache the failure too — until next process
|
|
153
|
+
* restart, all requests get the same 500 response (signals deploy-time misconfig).
|
|
431
154
|
*/
|
|
432
|
-
function
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
155
|
+
function vercelRevalidateHandler(options = {}) {
|
|
156
|
+
const manifestPath = options.manifestPath ?? "./dist/_pyreon-revalidate.json";
|
|
157
|
+
const secretEnvVar = options.secretEnvVar ?? "VERCEL_REVALIDATE_TOKEN";
|
|
158
|
+
let cache = null;
|
|
159
|
+
return async function handler(req) {
|
|
160
|
+
if (req.method !== "POST") return new Response(`Method ${req.method} not allowed`, { status: 405 });
|
|
161
|
+
const url = new URL(req.url);
|
|
162
|
+
const path = url.searchParams.get("path");
|
|
163
|
+
const secret = url.searchParams.get("secret");
|
|
164
|
+
if (!path || !secret) return new Response("Bad Request: missing path or secret", { status: 400 });
|
|
165
|
+
const expected = process.env[secretEnvVar];
|
|
166
|
+
if (!expected) return new Response(`Server misconfigured: ${secretEnvVar} env var not set`, { status: 500 });
|
|
167
|
+
if (secret !== expected) return new Response("Forbidden: invalid secret", { status: 403 });
|
|
168
|
+
if (cache === null) try {
|
|
169
|
+
const fileContent = await readFile(resolve(process.cwd(), manifestPath), "utf-8");
|
|
170
|
+
const parsed = JSON.parse(fileContent);
|
|
171
|
+
if (typeof parsed?.revalidate !== "object" || parsed.revalidate === null) throw new Error(`Malformed revalidate manifest at ${manifestPath}: missing or non-object \`revalidate\` field`);
|
|
172
|
+
cache = { manifest: parsed };
|
|
173
|
+
} catch (err) {
|
|
174
|
+
cache = { error: err };
|
|
439
175
|
}
|
|
440
|
-
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
* Vercel adapter — generates output for Vercel's Build Output API v3.
|
|
447
|
-
*
|
|
448
|
-
* Produces a `.vercel/output` directory with:
|
|
449
|
-
* - `static/` — client-side assets (JS, CSS, images)
|
|
450
|
-
* - `functions/ssr.func/` — serverless function for SSR
|
|
451
|
-
* - `config.json` — routing configuration
|
|
452
|
-
*
|
|
453
|
-
* @example
|
|
454
|
-
* ```ts
|
|
455
|
-
* // zero.config.ts
|
|
456
|
-
* import { defineConfig } from "@pyreon/zero"
|
|
457
|
-
*
|
|
458
|
-
* export default defineConfig({
|
|
459
|
-
* adapter: "vercel",
|
|
460
|
-
* })
|
|
461
|
-
* ```
|
|
462
|
-
*/
|
|
463
|
-
function vercelAdapter() {
|
|
464
|
-
return {
|
|
465
|
-
name: "vercel",
|
|
466
|
-
async build(options) {
|
|
467
|
-
await validateBuildInputs(options);
|
|
468
|
-
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
469
|
-
const { join } = await import("node:path");
|
|
470
|
-
const vercelDir = join(options.outDir, ".vercel", "output");
|
|
471
|
-
const staticDir = join(vercelDir, "static");
|
|
472
|
-
const funcDir = join(vercelDir, "functions", "ssr.func");
|
|
473
|
-
await mkdir(staticDir, { recursive: true });
|
|
474
|
-
await mkdir(funcDir, { recursive: true });
|
|
475
|
-
await cp(options.clientOutDir, staticDir, { recursive: true });
|
|
476
|
-
await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
|
|
477
|
-
const funcEntry = `
|
|
478
|
-
export default async function handler(req) {
|
|
479
|
-
const handler = (await import("./entry-server.js")).default
|
|
480
|
-
return handler(req)
|
|
481
|
-
}
|
|
482
|
-
`.trimStart();
|
|
483
|
-
await writeFile(join(funcDir, "index.js"), funcEntry);
|
|
484
|
-
await writeFile(join(funcDir, ".vc-config.json"), JSON.stringify({
|
|
485
|
-
runtime: "nodejs20.x",
|
|
486
|
-
handler: "index.js",
|
|
487
|
-
launcherType: "Nodejs"
|
|
488
|
-
}, null, 2));
|
|
489
|
-
await writeFile(join(vercelDir, "config.json"), JSON.stringify({
|
|
490
|
-
version: 3,
|
|
491
|
-
routes: [
|
|
492
|
-
{
|
|
493
|
-
src: "/assets/(.*)",
|
|
494
|
-
headers: { "Cache-Control": "public, max-age=31536000, immutable" }
|
|
495
|
-
},
|
|
496
|
-
{
|
|
497
|
-
src: "/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)",
|
|
498
|
-
dest: "/$1"
|
|
499
|
-
},
|
|
500
|
-
{
|
|
501
|
-
src: "/(.*)",
|
|
502
|
-
dest: "/ssr"
|
|
503
|
-
}
|
|
504
|
-
]
|
|
505
|
-
}, null, 2));
|
|
176
|
+
if ("error" in cache) return new Response(`Server misconfigured: revalidate manifest at ${manifestPath} unreadable or malformed`, { status: 500 });
|
|
177
|
+
if (!Object.prototype.hasOwnProperty.call(cache.manifest.revalidate, path)) return new Response(`Path "${path}" not in revalidate manifest`, { status: 404 });
|
|
178
|
+
if (options.onRevalidate) try {
|
|
179
|
+
await options.onRevalidate(path);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return new Response(`Revalidation failed for "${path}": ${err instanceof Error ? err.message : String(err)}`, { status: 500 });
|
|
506
182
|
}
|
|
183
|
+
return new Response(JSON.stringify({
|
|
184
|
+
revalidated: true,
|
|
185
|
+
path
|
|
186
|
+
}), {
|
|
187
|
+
status: 200,
|
|
188
|
+
headers: { "Content-Type": "application/json" }
|
|
189
|
+
});
|
|
507
190
|
};
|
|
508
191
|
}
|
|
509
|
-
|
|
510
|
-
//#endregion
|
|
511
|
-
//#region src/adapters/index.ts
|
|
512
192
|
/**
|
|
513
|
-
*
|
|
514
|
-
*
|
|
193
|
+
* Reset the in-process manifest cache. Test-only — production code never
|
|
194
|
+
* reaches this. Used by unit tests to exercise the "manifest changed
|
|
195
|
+
* between requests" path without spinning up a new handler.
|
|
196
|
+
* @internal
|
|
515
197
|
*/
|
|
516
|
-
function
|
|
517
|
-
const name = config.adapter ?? "node";
|
|
518
|
-
switch (name) {
|
|
519
|
-
case "node": return nodeAdapter();
|
|
520
|
-
case "bun": return bunAdapter();
|
|
521
|
-
case "static": return staticAdapter();
|
|
522
|
-
case "vercel": return vercelAdapter();
|
|
523
|
-
case "cloudflare": return cloudflareAdapter();
|
|
524
|
-
case "netlify": return netlifyAdapter();
|
|
525
|
-
default: throw new Error(`[Pyreon] Unknown adapter: "${name}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
198
|
+
function _resetVercelRevalidateHandlerCache(handler) {}
|
|
528
199
|
|
|
529
200
|
//#endregion
|
|
530
201
|
//#region src/middleware.ts
|
|
@@ -1150,12 +821,16 @@ async function addDevBadgeToPng(pngBuffer, size) {
|
|
|
1150
821
|
//#region src/seo.ts
|
|
1151
822
|
/**
|
|
1152
823
|
* Generate a sitemap.xml string from route file paths.
|
|
824
|
+
*
|
|
825
|
+
* When `i18n` is set (PR K — passed by `seoPlugin` after reading the
|
|
826
|
+
* i18n config from `zero({ i18n: ... })`), URLs are clustered by their
|
|
827
|
+
* un-prefixed (default-locale) form and each `<url>` carries
|
|
828
|
+
* `<xhtml:link rel="alternate" hreflang="...">` siblings for every
|
|
829
|
+
* locale variant + an `x-default` entry pointing at the default locale.
|
|
1153
830
|
*/
|
|
1154
|
-
function generateSitemap(routeFiles, config) {
|
|
831
|
+
function generateSitemap(routeFiles, config, i18n) {
|
|
1155
832
|
const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
|
|
1156
|
-
|
|
1157
|
-
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
1158
|
-
${[...routeFiles.filter((f) => {
|
|
833
|
+
const clusters = clusterPathsByLocale([...routeFiles.filter((f) => {
|
|
1159
834
|
const name = f.split("/").pop()?.replace(/\.\w+$/, "");
|
|
1160
835
|
return name !== "_layout" && name !== "_error" && name !== "_loading";
|
|
1161
836
|
}).map((f) => {
|
|
@@ -1168,19 +843,141 @@ ${[...routeFiles.filter((f) => {
|
|
|
1168
843
|
path: p,
|
|
1169
844
|
changefreq,
|
|
1170
845
|
priority
|
|
1171
|
-
})), ...config.additionalPaths ?? []]
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
<priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
|
|
1176
|
-
</url>`;
|
|
1177
|
-
}).join("\n")}
|
|
846
|
+
})), ...config.additionalPaths ?? []], i18n);
|
|
847
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
848
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${i18n != null && i18n.locales.length > 0 ? " xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"" : ""}>
|
|
849
|
+
${clusters.map((cluster) => renderClusterEntry(cluster, origin, changefreq, priority, i18n)).join("\n")}
|
|
1178
850
|
</urlset>`;
|
|
1179
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* Cluster URL entries by their un-prefixed (default-locale) form.
|
|
854
|
+
*
|
|
855
|
+
* Each output cluster has:
|
|
856
|
+
* - `canonical`: the SitemapEntry that should be used as the `<url>`
|
|
857
|
+
* payload (default-locale variant; falls back to the first variant
|
|
858
|
+
* if no default-locale entry exists in the cluster).
|
|
859
|
+
* - `variantsByLocale`: Map of locale → SitemapEntry for the cluster.
|
|
860
|
+
*
|
|
861
|
+
* Without i18n, every entry becomes its own single-variant cluster.
|
|
862
|
+
*
|
|
863
|
+
* @internal — exported for unit testing.
|
|
864
|
+
*/
|
|
865
|
+
function clusterPathsByLocale(entries, i18n) {
|
|
866
|
+
if (i18n == null || i18n.locales.length === 0) return entries.map((entry) => ({
|
|
867
|
+
canonical: entry,
|
|
868
|
+
variantsByLocale: new Map([[null, entry]])
|
|
869
|
+
}));
|
|
870
|
+
const strategy = i18n.strategy ?? "prefix-except-default";
|
|
871
|
+
const { defaultLocale, locales } = i18n;
|
|
872
|
+
const byUnPrefixed = /* @__PURE__ */ new Map();
|
|
873
|
+
for (const entry of entries) {
|
|
874
|
+
const { unPrefixed, locale } = stripLocalePrefix(entry.path, locales, defaultLocale, strategy);
|
|
875
|
+
let cluster = byUnPrefixed.get(unPrefixed);
|
|
876
|
+
if (!cluster) {
|
|
877
|
+
cluster = /* @__PURE__ */ new Map();
|
|
878
|
+
byUnPrefixed.set(unPrefixed, cluster);
|
|
879
|
+
}
|
|
880
|
+
cluster.set(locale, entry);
|
|
881
|
+
}
|
|
882
|
+
const out = [];
|
|
883
|
+
for (const variantsByLocale of byUnPrefixed.values()) {
|
|
884
|
+
const canonical = variantsByLocale.get(defaultLocale) ?? variantsByLocale.get(null) ?? [...variantsByLocale.values()][0];
|
|
885
|
+
out.push({
|
|
886
|
+
canonical,
|
|
887
|
+
variantsByLocale
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
return out;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Strip the locale prefix from a path under the i18n strategy.
|
|
894
|
+
*
|
|
895
|
+
* Returns `{ unPrefixed, locale }`:
|
|
896
|
+
* - `/about` under `prefix-except-default` (default=en) → `{ unPrefixed: '/about', locale: 'en' }`
|
|
897
|
+
* - `/de/about` under either strategy → `{ unPrefixed: '/about', locale: 'de' }`
|
|
898
|
+
* - `/de` (locale root) → `{ unPrefixed: '/', locale: 'de' }`
|
|
899
|
+
* - `/about` under `prefix` → no locale match, returns `{ unPrefixed: '/about', locale: null }`
|
|
900
|
+
* (the URL doesn't fit any locale subtree — sitemap treats it as standalone).
|
|
901
|
+
*
|
|
902
|
+
* @internal — exported for unit testing.
|
|
903
|
+
*/
|
|
904
|
+
function stripLocalePrefix(path, locales, defaultLocale, strategy) {
|
|
905
|
+
for (const locale of locales) {
|
|
906
|
+
if (path === `/${locale}`) return {
|
|
907
|
+
unPrefixed: "/",
|
|
908
|
+
locale
|
|
909
|
+
};
|
|
910
|
+
if (path.startsWith(`/${locale}/`)) return {
|
|
911
|
+
unPrefixed: path.slice(`/${locale}`.length),
|
|
912
|
+
locale
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
if (strategy === "prefix-except-default") return {
|
|
916
|
+
unPrefixed: path,
|
|
917
|
+
locale: defaultLocale
|
|
918
|
+
};
|
|
919
|
+
return {
|
|
920
|
+
unPrefixed: path,
|
|
921
|
+
locale: null
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
function renderClusterEntry(cluster, origin, changefreq, priority, i18n) {
|
|
925
|
+
const { canonical, variantsByLocale } = cluster;
|
|
926
|
+
const lines = [
|
|
927
|
+
" <url>",
|
|
928
|
+
` <loc>${escapeXml$1(`${origin}${canonical.path === "/" ? "" : canonical.path}`)}</loc>`,
|
|
929
|
+
` <changefreq>${canonical.changefreq ?? changefreq}</changefreq>`,
|
|
930
|
+
` <priority>${canonical.priority ?? priority}</priority>`
|
|
931
|
+
];
|
|
932
|
+
if (canonical.lastmod) lines.push(` <lastmod>${canonical.lastmod}</lastmod>`);
|
|
933
|
+
if (i18n != null && i18n.locales.length > 0 && variantsByLocale.size > 1) {
|
|
934
|
+
for (const locale of i18n.locales) {
|
|
935
|
+
const variant = variantsByLocale.get(locale);
|
|
936
|
+
if (!variant) continue;
|
|
937
|
+
const variantLoc = `${origin}${variant.path === "/" ? "" : variant.path}`;
|
|
938
|
+
lines.push(` <xhtml:link rel="alternate" hreflang="${escapeXml$1(locale)}" href="${escapeXml$1(variantLoc)}"/>`);
|
|
939
|
+
}
|
|
940
|
+
const defaultVariant = variantsByLocale.get(i18n.defaultLocale);
|
|
941
|
+
if (defaultVariant) {
|
|
942
|
+
const defaultLoc = `${origin}${defaultVariant.path === "/" ? "" : defaultVariant.path}`;
|
|
943
|
+
lines.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml$1(defaultLoc)}"/>`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
lines.push(" </url>");
|
|
947
|
+
return lines.join("\n");
|
|
948
|
+
}
|
|
1180
949
|
function escapeXml$1(str) {
|
|
1181
950
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1182
951
|
}
|
|
1183
952
|
/**
|
|
953
|
+
* Resolve the i18n config to feed `generateSitemap` for hreflang
|
|
954
|
+
* emission. Priority order:
|
|
955
|
+
* 1. Explicit user config — `hreflang: I18nRoutingConfig` (object)
|
|
956
|
+
* 2. Auto-detect from SSG manifest — `hreflang: true` + `manifestI18n`
|
|
957
|
+
* present (only happens in SSG mode where the manifest exists)
|
|
958
|
+
* 3. Nothing — emit plain sitemap without xhtml:link siblings
|
|
959
|
+
*
|
|
960
|
+
* @internal — exported for unit testing.
|
|
961
|
+
*/
|
|
962
|
+
function resolveHreflangI18n(hreflang, manifestI18n) {
|
|
963
|
+
if (hreflang == null || hreflang === false) return void 0;
|
|
964
|
+
if (hreflang === true) return manifestI18n;
|
|
965
|
+
return hreflang;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Duck-type guard for `I18nRoutingConfig`. The SSG manifest is JSON,
|
|
969
|
+
* so the embedded i18n field could in principle be malformed if a
|
|
970
|
+
* downstream user hand-edits the manifest (don't). Validate the shape
|
|
971
|
+
* before trusting it.
|
|
972
|
+
*
|
|
973
|
+
* @internal
|
|
974
|
+
*/
|
|
975
|
+
function isI18nRoutingConfig(value) {
|
|
976
|
+
if (value == null || typeof value !== "object") return false;
|
|
977
|
+
const v = value;
|
|
978
|
+
return Array.isArray(v.locales) && v.locales.every((l) => typeof l === "string") && typeof v.defaultLocale === "string";
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
1184
981
|
* Generate a robots.txt string.
|
|
1185
982
|
*/
|
|
1186
983
|
function generateRobots(config = {}) {
|
|
@@ -1231,22 +1028,33 @@ function jsonLd(data) {
|
|
|
1231
1028
|
* pyreon(),
|
|
1232
1029
|
* zero(),
|
|
1233
1030
|
* seoPlugin({
|
|
1234
|
-
* sitemap: {
|
|
1031
|
+
* sitemap: {
|
|
1032
|
+
* origin: "https://example.com",
|
|
1033
|
+
* useSsgPaths: true, // include dynamic-route enumerations
|
|
1034
|
+
* },
|
|
1235
1035
|
* robots: { sitemap: "https://example.com/sitemap.xml" },
|
|
1236
1036
|
* }),
|
|
1237
1037
|
* ],
|
|
1238
1038
|
* }
|
|
1239
1039
|
*/
|
|
1240
1040
|
function seoPlugin(config = {}) {
|
|
1041
|
+
const useSsgPaths = config.sitemap?.useSsgPaths === true;
|
|
1042
|
+
let distDir = "";
|
|
1241
1043
|
return {
|
|
1242
1044
|
name: "pyreon-zero-seo",
|
|
1243
1045
|
apply: "build",
|
|
1046
|
+
...useSsgPaths ? { enforce: "post" } : {},
|
|
1047
|
+
configResolved(resolved) {
|
|
1048
|
+
distDir = resolve(resolved.root, resolved.build.outDir);
|
|
1049
|
+
},
|
|
1244
1050
|
async generateBundle(_, _bundle) {
|
|
1245
|
-
if (config.sitemap) {
|
|
1246
|
-
const { scanRouteFiles } = await import("./fs-router-
|
|
1051
|
+
if (config.sitemap && !useSsgPaths) {
|
|
1052
|
+
const { scanRouteFiles } = await import("./fs-router-MewHc5SB.js").then((n) => n.n);
|
|
1247
1053
|
const routesDir = `${process.cwd()}/src/routes`;
|
|
1248
1054
|
try {
|
|
1249
|
-
const
|
|
1055
|
+
const files = await scanRouteFiles(routesDir);
|
|
1056
|
+
const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, void 0);
|
|
1057
|
+
const sitemap = generateSitemap(files, config.sitemap, hreflangI18n);
|
|
1250
1058
|
this.emitFile({
|
|
1251
1059
|
type: "asset",
|
|
1252
1060
|
fileName: "sitemap.xml",
|
|
@@ -1262,6 +1070,36 @@ function seoPlugin(config = {}) {
|
|
|
1262
1070
|
source: robots
|
|
1263
1071
|
});
|
|
1264
1072
|
}
|
|
1073
|
+
},
|
|
1074
|
+
async closeBundle() {
|
|
1075
|
+
if (!config.sitemap || !useSsgPaths) return;
|
|
1076
|
+
const { scanRouteFiles } = await import("./fs-router-MewHc5SB.js").then((n) => n.n);
|
|
1077
|
+
const routesDir = `${process.cwd()}/src/routes`;
|
|
1078
|
+
const manifestPath = join(distDir, "_pyreon-ssg-paths.json");
|
|
1079
|
+
try {
|
|
1080
|
+
let ssgPaths = [];
|
|
1081
|
+
let manifestI18n;
|
|
1082
|
+
if (existsSync(manifestPath)) {
|
|
1083
|
+
const raw = await readFile(manifestPath, "utf-8");
|
|
1084
|
+
const parsed = JSON.parse(raw);
|
|
1085
|
+
if (Array.isArray(parsed.paths)) ssgPaths = parsed.paths.filter((p) => typeof p === "string").map((path) => ({ path }));
|
|
1086
|
+
if (isI18nRoutingConfig(parsed.i18n)) manifestI18n = parsed.i18n;
|
|
1087
|
+
try {
|
|
1088
|
+
await rm(manifestPath, { force: true });
|
|
1089
|
+
} catch {}
|
|
1090
|
+
}
|
|
1091
|
+
let files = [];
|
|
1092
|
+
try {
|
|
1093
|
+
files = await scanRouteFiles(routesDir);
|
|
1094
|
+
} catch {}
|
|
1095
|
+
const merged = {
|
|
1096
|
+
...config.sitemap,
|
|
1097
|
+
additionalPaths: [...ssgPaths, ...config.sitemap.additionalPaths ?? []]
|
|
1098
|
+
};
|
|
1099
|
+
const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, manifestI18n);
|
|
1100
|
+
const sitemap = generateSitemap(files, merged, hreflangI18n);
|
|
1101
|
+
await writeFile(join(distDir, "sitemap.xml"), sitemap, "utf-8");
|
|
1102
|
+
} catch {}
|
|
1265
1103
|
}
|
|
1266
1104
|
};
|
|
1267
1105
|
}
|
|
@@ -1273,7 +1111,7 @@ function seoMiddleware(config = {}) {
|
|
|
1273
1111
|
return async (ctx) => {
|
|
1274
1112
|
if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
|
|
1275
1113
|
if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
|
|
1276
|
-
const { scanRouteFiles } = await import("./fs-router-
|
|
1114
|
+
const { scanRouteFiles } = await import("./fs-router-MewHc5SB.js").then((n) => n.n);
|
|
1277
1115
|
const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
|
|
1278
1116
|
return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
|
|
1279
1117
|
} catch {}
|
|
@@ -1351,14 +1189,14 @@ function buildTextOverlaySvg(layers, width, height, locale) {
|
|
|
1351
1189
|
const lines = [];
|
|
1352
1190
|
let currentLine = "";
|
|
1353
1191
|
const estimateWidth = (s) => {
|
|
1354
|
-
let
|
|
1192
|
+
let w = 0;
|
|
1355
1193
|
for (let i = 0; i < s.length; i++) {
|
|
1356
1194
|
const code = s.charCodeAt(i);
|
|
1357
|
-
if (code >= 12288 && code <= 40959)
|
|
1358
|
-
else if (code <= 126 && "iljft!|:;.,'".includes(s[i]))
|
|
1359
|
-
else
|
|
1195
|
+
if (code >= 12288 && code <= 40959) w += fontSize * 1;
|
|
1196
|
+
else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) w += fontSize * .35;
|
|
1197
|
+
else w += fontSize * .55;
|
|
1360
1198
|
}
|
|
1361
|
-
return
|
|
1199
|
+
return w;
|
|
1362
1200
|
};
|
|
1363
1201
|
for (const word of words) {
|
|
1364
1202
|
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
@@ -1918,133 +1756,5 @@ function capitalize(s) {
|
|
|
1918
1756
|
}
|
|
1919
1757
|
|
|
1920
1758
|
//#endregion
|
|
1921
|
-
|
|
1922
|
-
/**
|
|
1923
|
-
* Detect preferred locale from Accept-Language header.
|
|
1924
|
-
*/
|
|
1925
|
-
function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
|
|
1926
|
-
if (!acceptLanguage) return defaultLocale;
|
|
1927
|
-
const preferred = acceptLanguage.split(",").map((part) => {
|
|
1928
|
-
const [lang, q] = part.trim().split(";q=");
|
|
1929
|
-
return {
|
|
1930
|
-
lang: lang?.split("-")[0]?.toLowerCase() ?? "",
|
|
1931
|
-
quality: q ? Number.parseFloat(q) : 1
|
|
1932
|
-
};
|
|
1933
|
-
}).sort((a, b) => b.quality - a.quality);
|
|
1934
|
-
for (const { lang } of preferred) if (locales.includes(lang)) return lang;
|
|
1935
|
-
return defaultLocale;
|
|
1936
|
-
}
|
|
1937
|
-
/**
|
|
1938
|
-
* Extract locale from a URL path.
|
|
1939
|
-
* Returns { locale, pathWithoutLocale }.
|
|
1940
|
-
*/
|
|
1941
|
-
function extractLocaleFromPath(path, locales, defaultLocale) {
|
|
1942
|
-
const segments = path.split("/").filter(Boolean);
|
|
1943
|
-
const firstSegment = segments[0]?.toLowerCase();
|
|
1944
|
-
if (firstSegment && locales.includes(firstSegment)) return {
|
|
1945
|
-
locale: firstSegment,
|
|
1946
|
-
pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
|
|
1947
|
-
};
|
|
1948
|
-
return {
|
|
1949
|
-
locale: defaultLocale,
|
|
1950
|
-
pathWithoutLocale: path
|
|
1951
|
-
};
|
|
1952
|
-
}
|
|
1953
|
-
/**
|
|
1954
|
-
* Build a localized path.
|
|
1955
|
-
*/
|
|
1956
|
-
function buildLocalePath(path, locale, defaultLocale, strategy) {
|
|
1957
|
-
const clean = path === "/" ? "" : path;
|
|
1958
|
-
if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
|
|
1959
|
-
return `/${locale}${clean}`;
|
|
1960
|
-
}
|
|
1961
|
-
/**
|
|
1962
|
-
* Create a LocaleContext for use in components and loaders.
|
|
1963
|
-
*/
|
|
1964
|
-
function createLocaleContext(locale, path, config) {
|
|
1965
|
-
const strategy = config.strategy ?? "prefix-except-default";
|
|
1966
|
-
return {
|
|
1967
|
-
locale,
|
|
1968
|
-
locales: config.locales,
|
|
1969
|
-
defaultLocale: config.defaultLocale,
|
|
1970
|
-
localePath(targetPath, targetLocale) {
|
|
1971
|
-
return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
|
|
1972
|
-
},
|
|
1973
|
-
alternates() {
|
|
1974
|
-
const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
|
|
1975
|
-
return config.locales.map((loc) => ({
|
|
1976
|
-
locale: loc,
|
|
1977
|
-
url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
|
|
1978
|
-
}));
|
|
1979
|
-
}
|
|
1980
|
-
};
|
|
1981
|
-
}
|
|
1982
|
-
/**
|
|
1983
|
-
* I18n routing middleware for Zero's server.
|
|
1984
|
-
*
|
|
1985
|
-
* - Detects locale from URL prefix or Accept-Language header
|
|
1986
|
-
* - Redirects root to preferred locale (when detectLocale is true)
|
|
1987
|
-
* - Sets locale context for loaders and components
|
|
1988
|
-
*
|
|
1989
|
-
* @example
|
|
1990
|
-
* ```ts
|
|
1991
|
-
* // zero.config.ts
|
|
1992
|
-
* import { i18nRouting } from "@pyreon/zero"
|
|
1993
|
-
*
|
|
1994
|
-
* export default defineConfig({
|
|
1995
|
-
* plugins: [
|
|
1996
|
-
* i18nRouting({
|
|
1997
|
-
* locales: ["en", "de", "cs"],
|
|
1998
|
-
* defaultLocale: "en",
|
|
1999
|
-
* }),
|
|
2000
|
-
* ],
|
|
2001
|
-
* })
|
|
2002
|
-
* ```
|
|
2003
|
-
*/
|
|
2004
|
-
function i18nRouting(config) {
|
|
2005
|
-
const strategy = config.strategy ?? "prefix-except-default";
|
|
2006
|
-
const detectEnabled = config.detectLocale !== false;
|
|
2007
|
-
const cookieName = config.cookieName ?? "locale";
|
|
2008
|
-
return {
|
|
2009
|
-
name: "pyreon-zero-i18n-routing",
|
|
2010
|
-
configResolved() {},
|
|
2011
|
-
configureServer(server) {
|
|
2012
|
-
server.middlewares.use((req, res, next) => {
|
|
2013
|
-
const url = req.url ?? "/";
|
|
2014
|
-
if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
|
|
2015
|
-
const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
|
|
2016
|
-
if (detectEnabled && url === "/") {
|
|
2017
|
-
const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
|
|
2018
|
-
const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
|
|
2019
|
-
const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
|
|
2020
|
-
if (strategy === "prefix" || preferred !== config.defaultLocale) {
|
|
2021
|
-
res.writeHead(302, { Location: `/${preferred}/` });
|
|
2022
|
-
res.end();
|
|
2023
|
-
return;
|
|
2024
|
-
}
|
|
2025
|
-
}
|
|
2026
|
-
req.__locale = locale;
|
|
2027
|
-
req.__localeContext = createLocaleContext(locale, url, config);
|
|
2028
|
-
localeSignal.set(locale);
|
|
2029
|
-
next();
|
|
2030
|
-
});
|
|
2031
|
-
}
|
|
2032
|
-
};
|
|
2033
|
-
}
|
|
2034
|
-
function parseCookies(header) {
|
|
2035
|
-
if (!header) return {};
|
|
2036
|
-
const result = {};
|
|
2037
|
-
for (const pair of header.split(";")) {
|
|
2038
|
-
const [key, value] = pair.trim().split("=");
|
|
2039
|
-
if (key && value) result[key] = decodeURIComponent(value);
|
|
2040
|
-
}
|
|
2041
|
-
return result;
|
|
2042
|
-
}
|
|
2043
|
-
/** @internal Context for the current locale. */
|
|
2044
|
-
const LocaleCtx = createContext("en");
|
|
2045
|
-
/** Current locale signal — set by the server middleware or client-side detection. */
|
|
2046
|
-
const localeSignal = signal("en");
|
|
2047
|
-
|
|
2048
|
-
//#endregion
|
|
2049
|
-
export { aiPlugin, bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter };
|
|
1759
|
+
export { _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
|
|
2050
1760
|
//# sourceMappingURL=server.js.map
|