@pyreon/zero 0.12.3 → 0.12.4
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/index.js +314 -4047
- package/lib/index.js.map +1 -1
- package/lib/meta.js +22 -48
- package/lib/meta.js.map +1 -1
- package/lib/server.js +1534 -0
- package/lib/server.js.map +1 -0
- package/lib/types/index.d.ts +173 -1540
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/meta.d.ts +11 -46
- package/lib/types/meta.d.ts.map +1 -1
- package/lib/types/server.d.ts +455 -0
- package/lib/types/server.d.ts.map +1 -0
- package/package.json +15 -10
- package/src/index.ts +19 -182
- package/src/meta.tsx +37 -3
- package/src/server.ts +70 -0
- package/lib/fs-router-Dil4IKZR.js +0 -290
- package/lib/fs-router-Dil4IKZR.js.map +0 -1
package/lib/index.js
CHANGED
|
@@ -1,1094 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Fragment, createContext, createRef, h, onMount, onUnmount } from "@pyreon/core";
|
|
3
|
-
import { HeadProvider, useHead } from "@pyreon/head";
|
|
4
|
-
import { RouterProvider, RouterView, createRouter, useRouter } from "@pyreon/router";
|
|
5
|
-
import { createHandler, useRequestLocals } from "@pyreon/server";
|
|
6
|
-
import { renderToString } from "@pyreon/runtime-server";
|
|
7
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
8
|
-
import { basename, extname, join } from "node:path";
|
|
1
|
+
import { createContext, createRef, onMount, onUnmount } from "@pyreon/core";
|
|
9
2
|
import { effect, signal } from "@pyreon/reactivity";
|
|
10
|
-
import {
|
|
3
|
+
import { useRouter } from "@pyreon/router";
|
|
4
|
+
import { useHead } from "@pyreon/head";
|
|
11
5
|
|
|
12
|
-
//#region src/app.ts
|
|
13
|
-
/**
|
|
14
|
-
* Create a full Zero app — assembles router, head provider, and root layout.
|
|
15
|
-
*
|
|
16
|
-
* Used internally by entry-server and entry-client.
|
|
17
|
-
*/
|
|
18
|
-
function createApp(options) {
|
|
19
|
-
const router = createRouter({
|
|
20
|
-
routes: options.routes,
|
|
21
|
-
mode: options.routerMode ?? "history",
|
|
22
|
-
...options.url ? { url: options.url } : {},
|
|
23
|
-
scrollBehavior: "top"
|
|
24
|
-
});
|
|
25
|
-
const Layout = options.layout ?? DefaultLayout;
|
|
26
|
-
function App() {
|
|
27
|
-
return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
|
|
28
|
-
}
|
|
29
|
-
return {
|
|
30
|
-
App,
|
|
31
|
-
router
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
function DefaultLayout(props) {
|
|
35
|
-
return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
//#endregion
|
|
39
|
-
//#region src/api-routes.ts
|
|
40
|
-
/**
|
|
41
|
-
* Match a URL path against an API route pattern.
|
|
42
|
-
* Returns extracted params or null if no match.
|
|
43
|
-
*/
|
|
44
|
-
function matchApiRoute(pattern, path) {
|
|
45
|
-
const patternParts = pattern.split("/").filter(Boolean);
|
|
46
|
-
const pathParts = path.split("/").filter(Boolean);
|
|
47
|
-
const params = {};
|
|
48
|
-
for (let i = 0; i < patternParts.length; i++) {
|
|
49
|
-
const pp = patternParts[i];
|
|
50
|
-
if (!pp) continue;
|
|
51
|
-
if (pp.endsWith("*")) {
|
|
52
|
-
const paramName = pp.slice(1, -1);
|
|
53
|
-
params[paramName] = pathParts.slice(i).join("/");
|
|
54
|
-
return params;
|
|
55
|
-
}
|
|
56
|
-
if (i >= pathParts.length) return null;
|
|
57
|
-
if (pp.startsWith(":")) {
|
|
58
|
-
params[pp.slice(1)] = pathParts[i];
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (pp !== pathParts[i]) return null;
|
|
62
|
-
}
|
|
63
|
-
return patternParts.length === pathParts.length ? params : null;
|
|
64
|
-
}
|
|
65
|
-
const HTTP_METHODS = [
|
|
66
|
-
"GET",
|
|
67
|
-
"POST",
|
|
68
|
-
"PUT",
|
|
69
|
-
"PATCH",
|
|
70
|
-
"DELETE",
|
|
71
|
-
"HEAD",
|
|
72
|
-
"OPTIONS"
|
|
73
|
-
];
|
|
74
|
-
/**
|
|
75
|
-
* Create a middleware that dispatches API route requests.
|
|
76
|
-
* API routes are matched by URL pattern and HTTP method.
|
|
77
|
-
*/
|
|
78
|
-
function createApiMiddleware(routes) {
|
|
79
|
-
return async (ctx) => {
|
|
80
|
-
for (const route of routes) {
|
|
81
|
-
const params = matchApiRoute(route.pattern, ctx.path);
|
|
82
|
-
if (!params) continue;
|
|
83
|
-
const method = ctx.req.method.toUpperCase();
|
|
84
|
-
const handler = route.module[method];
|
|
85
|
-
if (!handler) {
|
|
86
|
-
const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(", ");
|
|
87
|
-
return new Response(null, {
|
|
88
|
-
status: 405,
|
|
89
|
-
headers: {
|
|
90
|
-
Allow: allowed,
|
|
91
|
-
"Content-Type": "application/json"
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
return handler({
|
|
96
|
-
request: ctx.req,
|
|
97
|
-
url: ctx.url,
|
|
98
|
-
path: ctx.path,
|
|
99
|
-
params,
|
|
100
|
-
headers: ctx.req.headers
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Detect whether a route file is an API route.
|
|
107
|
-
* API routes are `.ts` or `.js` files inside an `api/` directory.
|
|
108
|
-
*/
|
|
109
|
-
function isApiRoute(filePath) {
|
|
110
|
-
const normalized = filePath.replace(/\\/g, "/");
|
|
111
|
-
return normalized.startsWith("api/") && (normalized.endsWith(".ts") || normalized.endsWith(".js")) && !normalized.endsWith(".tsx") && !normalized.endsWith(".jsx");
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Convert an API route file path to a URL pattern.
|
|
115
|
-
*
|
|
116
|
-
* Examples:
|
|
117
|
-
* "api/posts.ts" → "/api/posts"
|
|
118
|
-
* "api/posts/index.ts" → "/api/posts"
|
|
119
|
-
* "api/posts/[id].ts" → "/api/posts/:id"
|
|
120
|
-
* "api/[...path].ts" → "/api/:path*"
|
|
121
|
-
*/
|
|
122
|
-
function apiFilePathToPattern(filePath) {
|
|
123
|
-
let route = filePath;
|
|
124
|
-
for (const ext of [".ts", ".js"]) if (route.endsWith(ext)) {
|
|
125
|
-
route = route.slice(0, -ext.length);
|
|
126
|
-
break;
|
|
127
|
-
}
|
|
128
|
-
const segments = route.split("/");
|
|
129
|
-
const urlSegments = [];
|
|
130
|
-
for (const seg of segments) {
|
|
131
|
-
if (seg === "index") continue;
|
|
132
|
-
const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
|
|
133
|
-
if (catchAll) {
|
|
134
|
-
urlSegments.push(`:${catchAll[1]}*`);
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
const dynamic = seg.match(/^\[(\w+)\]$/);
|
|
138
|
-
if (dynamic) {
|
|
139
|
-
urlSegments.push(`:${dynamic[1]}`);
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
urlSegments.push(seg);
|
|
143
|
-
}
|
|
144
|
-
return `/${urlSegments.join("/")}`;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Generate a virtual module that exports API route entries.
|
|
148
|
-
* Each entry maps a URL pattern to a module with HTTP method handlers.
|
|
149
|
-
*/
|
|
150
|
-
function generateApiRouteModule(files, routesDir) {
|
|
151
|
-
const apiFiles = files.filter(isApiRoute);
|
|
152
|
-
if (apiFiles.length === 0) return "export const apiRoutes = []\n";
|
|
153
|
-
const imports = [];
|
|
154
|
-
const entries = [];
|
|
155
|
-
for (let i = 0; i < apiFiles.length; i++) {
|
|
156
|
-
const name = `_api${i}`;
|
|
157
|
-
const file = apiFiles[i];
|
|
158
|
-
if (!file) continue;
|
|
159
|
-
const fullPath = `${routesDir}/${file}`;
|
|
160
|
-
const pattern = apiFilePathToPattern(file);
|
|
161
|
-
imports.push(`import * as ${name} from "${fullPath}"`);
|
|
162
|
-
entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`);
|
|
163
|
-
}
|
|
164
|
-
return [
|
|
165
|
-
...imports,
|
|
166
|
-
"",
|
|
167
|
-
"export const apiRoutes = [",
|
|
168
|
-
entries.join(",\n"),
|
|
169
|
-
"]"
|
|
170
|
-
].join("\n");
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
//#endregion
|
|
174
|
-
//#region src/not-found.ts
|
|
175
|
-
const DEFAULT_404_BODY = "<h1>404 — Not Found</h1><p>The page you requested does not exist.</p>";
|
|
176
|
-
/**
|
|
177
|
-
* Render a 404 component to a full HTML string.
|
|
178
|
-
* If no component is provided, returns a default 404 page.
|
|
179
|
-
*/
|
|
180
|
-
async function render404Page(component, template) {
|
|
181
|
-
let body;
|
|
182
|
-
if (component) body = await renderToString(h(component, null));
|
|
183
|
-
else body = DEFAULT_404_BODY;
|
|
184
|
-
if (template?.includes("<!--pyreon-app-->")) return template.replace("<!--pyreon-app-->", body);
|
|
185
|
-
return `<!DOCTYPE html>
|
|
186
|
-
<html lang="en">
|
|
187
|
-
<head>
|
|
188
|
-
<meta charset="UTF-8">
|
|
189
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
190
|
-
<title>404 — Not Found</title>
|
|
191
|
-
</head>
|
|
192
|
-
<body>
|
|
193
|
-
${body}
|
|
194
|
-
</body>
|
|
195
|
-
</html>`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
//#endregion
|
|
199
|
-
//#region src/entry-server.ts
|
|
200
|
-
/**
|
|
201
|
-
* Create a middleware that dispatches per-route middleware based on URL pattern matching.
|
|
202
|
-
*/
|
|
203
|
-
function createRouteMiddlewareDispatcher(entries) {
|
|
204
|
-
return async (ctx) => {
|
|
205
|
-
for (const entry of entries) if (matchPattern(entry.pattern, ctx.path)) {
|
|
206
|
-
const mw = Array.isArray(entry.middleware) ? entry.middleware : [entry.middleware];
|
|
207
|
-
for (const fn of mw) {
|
|
208
|
-
const result = await fn(ctx);
|
|
209
|
-
if (result) return result;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* URL pattern matcher supporting :param and :param* segments.
|
|
216
|
-
*
|
|
217
|
-
* Rules:
|
|
218
|
-
* - Static segments must match exactly
|
|
219
|
-
* - `:param` matches a single path segment
|
|
220
|
-
* - `:param*` matches all remaining segments (must be last, and path must
|
|
221
|
-
* have matched all preceding segments)
|
|
222
|
-
* - Path length must match pattern length (unless catch-all)
|
|
223
|
-
*/
|
|
224
|
-
function matchPattern(pattern, path) {
|
|
225
|
-
const patternParts = pattern.split("/").filter(Boolean);
|
|
226
|
-
const pathParts = path.split("/").filter(Boolean);
|
|
227
|
-
for (let i = 0; i < patternParts.length; i++) {
|
|
228
|
-
const pp = patternParts[i];
|
|
229
|
-
if (pp.endsWith("*")) return i <= pathParts.length;
|
|
230
|
-
if (i >= pathParts.length) return false;
|
|
231
|
-
if (pp.startsWith(":")) continue;
|
|
232
|
-
if (pp !== pathParts[i]) return false;
|
|
233
|
-
}
|
|
234
|
-
return patternParts.length === pathParts.length;
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Create the SSR request handler for production.
|
|
238
|
-
*
|
|
239
|
-
* @example
|
|
240
|
-
* import { routes } from "virtual:zero/routes"
|
|
241
|
-
* import { routeMiddleware } from "virtual:zero/route-middleware"
|
|
242
|
-
* import { createServer } from "@pyreon/zero"
|
|
243
|
-
*
|
|
244
|
-
* export default createServer({ routes, routeMiddleware, apiRoutes })
|
|
245
|
-
*/
|
|
246
|
-
function createServer(options) {
|
|
247
|
-
const config = options.config ?? {};
|
|
248
|
-
const allMiddleware = [];
|
|
249
|
-
if (options.apiRoutes?.length) allMiddleware.push(createApiMiddleware(options.apiRoutes));
|
|
250
|
-
if (options.routeMiddleware?.length) allMiddleware.push(createRouteMiddlewareDispatcher(options.routeMiddleware));
|
|
251
|
-
allMiddleware.push(...config.middleware ?? []);
|
|
252
|
-
allMiddleware.push(...options.middleware ?? []);
|
|
253
|
-
const { App } = createApp({
|
|
254
|
-
routes: options.routes,
|
|
255
|
-
routerMode: "history"
|
|
256
|
-
});
|
|
257
|
-
const handler = createHandler({
|
|
258
|
-
App,
|
|
259
|
-
routes: options.routes,
|
|
260
|
-
middleware: allMiddleware,
|
|
261
|
-
mode: config.ssr?.mode ?? "string",
|
|
262
|
-
...options.template ? { template: options.template } : {},
|
|
263
|
-
...options.clientEntry ? { clientEntry: options.clientEntry } : {}
|
|
264
|
-
});
|
|
265
|
-
if (!options.notFoundComponent) return handler;
|
|
266
|
-
const NotFound = options.notFoundComponent;
|
|
267
|
-
const routePatterns = flattenRoutePatterns$1(options.routes);
|
|
268
|
-
return async (req) => {
|
|
269
|
-
const pathname = new URL(req.url).pathname;
|
|
270
|
-
if (!routePatterns.some((pattern) => matchPattern(pattern, pathname))) {
|
|
271
|
-
const fullHtml = await render404Page(NotFound, options.template);
|
|
272
|
-
return new Response(fullHtml, {
|
|
273
|
-
status: 404,
|
|
274
|
-
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
return handler(req);
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
/** Extract all URL patterns from a nested route tree. */
|
|
281
|
-
function flattenRoutePatterns$1(routes, prefix = "") {
|
|
282
|
-
const patterns = [];
|
|
283
|
-
for (const route of routes) {
|
|
284
|
-
const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
|
|
285
|
-
patterns.push(fullPath);
|
|
286
|
-
if (route.children) patterns.push(...flattenRoutePatterns$1(route.children, fullPath));
|
|
287
|
-
}
|
|
288
|
-
return patterns;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
//#endregion
|
|
292
|
-
//#region src/config.ts
|
|
293
|
-
/**
|
|
294
|
-
* Define a Zero configuration.
|
|
295
|
-
* Used in `zero.config.ts` at the project root.
|
|
296
|
-
*
|
|
297
|
-
* @example
|
|
298
|
-
* import { defineConfig } from "@pyreon/zero/config"
|
|
299
|
-
*
|
|
300
|
-
* export default defineConfig({
|
|
301
|
-
* mode: "ssr",
|
|
302
|
-
* ssr: { mode: "stream" },
|
|
303
|
-
* port: 3000,
|
|
304
|
-
* })
|
|
305
|
-
*/
|
|
306
|
-
function defineConfig(config) {
|
|
307
|
-
return config;
|
|
308
|
-
}
|
|
309
|
-
/** Merge user config with defaults. */
|
|
310
|
-
function resolveConfig(userConfig = {}) {
|
|
311
|
-
return {
|
|
312
|
-
mode: "ssr",
|
|
313
|
-
base: "/",
|
|
314
|
-
port: 3e3,
|
|
315
|
-
adapter: "node",
|
|
316
|
-
...userConfig,
|
|
317
|
-
ssr: {
|
|
318
|
-
mode: "string",
|
|
319
|
-
...userConfig.ssr
|
|
320
|
-
}
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
//#endregion
|
|
325
|
-
//#region src/error-overlay.ts
|
|
326
|
-
/**
|
|
327
|
-
* Dev-only error overlay for SSR/loader errors.
|
|
328
|
-
* Renders a styled HTML page with the error stack trace.
|
|
329
|
-
*/
|
|
330
|
-
function renderErrorOverlay(error) {
|
|
331
|
-
return `<!DOCTYPE html>
|
|
332
|
-
<html lang="en">
|
|
333
|
-
<head>
|
|
334
|
-
<meta charset="UTF-8">
|
|
335
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
336
|
-
<title>SSR Error — Pyreon Zero</title>
|
|
337
|
-
<style>
|
|
338
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
339
|
-
body {
|
|
340
|
-
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
|
|
341
|
-
background: #1a1a2e;
|
|
342
|
-
color: #e0e0e0;
|
|
343
|
-
min-height: 100vh;
|
|
344
|
-
padding: 2rem;
|
|
345
|
-
}
|
|
346
|
-
.overlay {
|
|
347
|
-
max-width: 900px;
|
|
348
|
-
margin: 0 auto;
|
|
349
|
-
}
|
|
350
|
-
.header {
|
|
351
|
-
display: flex;
|
|
352
|
-
align-items: center;
|
|
353
|
-
gap: 0.75rem;
|
|
354
|
-
margin-bottom: 1.5rem;
|
|
355
|
-
}
|
|
356
|
-
.badge {
|
|
357
|
-
background: #e74c3c;
|
|
358
|
-
color: white;
|
|
359
|
-
padding: 0.25rem 0.75rem;
|
|
360
|
-
border-radius: 4px;
|
|
361
|
-
font-size: 0.75rem;
|
|
362
|
-
font-weight: 600;
|
|
363
|
-
text-transform: uppercase;
|
|
364
|
-
letter-spacing: 0.05em;
|
|
365
|
-
}
|
|
366
|
-
.label {
|
|
367
|
-
color: #888;
|
|
368
|
-
font-size: 0.85rem;
|
|
369
|
-
}
|
|
370
|
-
.message {
|
|
371
|
-
font-size: 1.25rem;
|
|
372
|
-
color: #ff6b6b;
|
|
373
|
-
margin-bottom: 1.5rem;
|
|
374
|
-
line-height: 1.5;
|
|
375
|
-
word-break: break-word;
|
|
376
|
-
}
|
|
377
|
-
.stack {
|
|
378
|
-
background: #16213e;
|
|
379
|
-
border: 1px solid #2a2a4a;
|
|
380
|
-
border-radius: 8px;
|
|
381
|
-
padding: 1.25rem;
|
|
382
|
-
overflow-x: auto;
|
|
383
|
-
font-size: 0.8rem;
|
|
384
|
-
line-height: 1.7;
|
|
385
|
-
white-space: pre-wrap;
|
|
386
|
-
word-break: break-all;
|
|
387
|
-
}
|
|
388
|
-
.stack .at { color: #888; }
|
|
389
|
-
.stack .file { color: #4ecdc4; }
|
|
390
|
-
.hint {
|
|
391
|
-
margin-top: 1.5rem;
|
|
392
|
-
padding: 1rem;
|
|
393
|
-
background: #1e2a45;
|
|
394
|
-
border-radius: 6px;
|
|
395
|
-
border-left: 3px solid #3498db;
|
|
396
|
-
font-size: 0.8rem;
|
|
397
|
-
color: #aaa;
|
|
398
|
-
line-height: 1.5;
|
|
399
|
-
}
|
|
400
|
-
</style>
|
|
401
|
-
</head>
|
|
402
|
-
<body>
|
|
403
|
-
<div class="overlay">
|
|
404
|
-
<div class="header">
|
|
405
|
-
<span class="badge">SSR Error</span>
|
|
406
|
-
<span class="label">Pyreon Zero — Dev Mode</span>
|
|
407
|
-
</div>
|
|
408
|
-
<div class="message">${escapeHtml(error.message || "Unknown error")}</div>
|
|
409
|
-
<pre class="stack">${formatStack(escapeHtml(error.stack || ""))}</pre>
|
|
410
|
-
<div class="hint">
|
|
411
|
-
This error occurred during server-side rendering. Check the terminal for
|
|
412
|
-
the full stack trace. This overlay is only shown in development.
|
|
413
|
-
</div>
|
|
414
|
-
</div>
|
|
415
|
-
</body>
|
|
416
|
-
</html>`;
|
|
417
|
-
}
|
|
418
|
-
function escapeHtml(str) {
|
|
419
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
420
|
-
}
|
|
421
|
-
function formatStack(stack) {
|
|
422
|
-
return stack.split("\n").map((line) => {
|
|
423
|
-
if (line.includes("at ")) {
|
|
424
|
-
const fileMatch = line.match(/\(([^)]+)\)/);
|
|
425
|
-
if (fileMatch) return line.replace(fileMatch[0], `(<span class="file">${fileMatch[1]}</span>)`);
|
|
426
|
-
}
|
|
427
|
-
return line;
|
|
428
|
-
}).join("\n");
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
//#endregion
|
|
432
|
-
//#region src/vite-plugin.ts
|
|
433
|
-
/**
|
|
434
|
-
* Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
|
|
435
|
-
* Returns package names to exclude from Vite's dep optimizer.
|
|
436
|
-
*/
|
|
437
|
-
function scanPyreonPackages(root) {
|
|
438
|
-
const pyreonDir = join(root, "node_modules", "@pyreon");
|
|
439
|
-
if (!existsSync(pyreonDir)) return [];
|
|
440
|
-
try {
|
|
441
|
-
return readdirSync(pyreonDir).filter((name) => !name.startsWith(".")).map((name) => `@pyreon/${name}`);
|
|
442
|
-
} catch {
|
|
443
|
-
return [];
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
|
|
447
|
-
const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
|
|
448
|
-
const VIRTUAL_MIDDLEWARE_ID = "virtual:zero/route-middleware";
|
|
449
|
-
const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
|
|
450
|
-
const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
|
|
451
|
-
const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
|
|
452
|
-
/**
|
|
453
|
-
* Zero Vite plugin — adds file-based routing and zero-config conventions
|
|
454
|
-
* on top of @pyreon/vite-plugin.
|
|
455
|
-
*
|
|
456
|
-
* @example
|
|
457
|
-
* // vite.config.ts
|
|
458
|
-
* import pyreon from "@pyreon/vite-plugin"
|
|
459
|
-
* import zero from "@pyreon/zero"
|
|
460
|
-
*
|
|
461
|
-
* export default {
|
|
462
|
-
* plugins: [pyreon(), zero()],
|
|
463
|
-
* }
|
|
464
|
-
*/
|
|
465
|
-
function zeroPlugin(userConfig = {}) {
|
|
466
|
-
const config = resolveConfig(userConfig);
|
|
467
|
-
let routesDir;
|
|
468
|
-
let root;
|
|
469
|
-
return {
|
|
470
|
-
name: "pyreon-zero",
|
|
471
|
-
enforce: "pre",
|
|
472
|
-
_zeroConfig: userConfig,
|
|
473
|
-
configResolved(resolvedConfig) {
|
|
474
|
-
root = resolvedConfig.root;
|
|
475
|
-
routesDir = `${root}/src/routes`;
|
|
476
|
-
},
|
|
477
|
-
resolveId(id) {
|
|
478
|
-
if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID;
|
|
479
|
-
if (id === VIRTUAL_MIDDLEWARE_ID) return RESOLVED_VIRTUAL_MIDDLEWARE_ID;
|
|
480
|
-
if (id === VIRTUAL_API_ROUTES_ID) return RESOLVED_VIRTUAL_API_ROUTES_ID;
|
|
481
|
-
},
|
|
482
|
-
async load(id) {
|
|
483
|
-
if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
|
|
484
|
-
return generateRouteModule(await scanRouteFiles(routesDir), routesDir, { staticImports: config.mode === "ssg" });
|
|
485
|
-
} catch (_err) {
|
|
486
|
-
return `export const routes = []`;
|
|
487
|
-
}
|
|
488
|
-
if (id === RESOLVED_VIRTUAL_MIDDLEWARE_ID) try {
|
|
489
|
-
return generateMiddlewareModule(await scanRouteFiles(routesDir), routesDir);
|
|
490
|
-
} catch (_err) {
|
|
491
|
-
return `export const routeMiddleware = []`;
|
|
492
|
-
}
|
|
493
|
-
if (id === RESOLVED_VIRTUAL_API_ROUTES_ID) try {
|
|
494
|
-
return generateApiRouteModule(await scanRouteFiles(routesDir), routesDir);
|
|
495
|
-
} catch (_err) {
|
|
496
|
-
return `export const apiRoutes = []`;
|
|
497
|
-
}
|
|
498
|
-
},
|
|
499
|
-
configureServer(server) {
|
|
500
|
-
server.middlewares.use((req, res, next) => {
|
|
501
|
-
const accept = req.headers.accept ?? "";
|
|
502
|
-
if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
|
|
503
|
-
const pathname = req.url?.split("?")[0] ?? "/";
|
|
504
|
-
if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
|
|
505
|
-
if (/\.\w+$/.test(pathname)) return next();
|
|
506
|
-
handle404(server, routesDir, pathname, res).then((handled) => {
|
|
507
|
-
if (!handled) next();
|
|
508
|
-
}, (err) => {
|
|
509
|
-
console.error("[zero] Error in 404 handler:", err);
|
|
510
|
-
next();
|
|
511
|
-
});
|
|
512
|
-
});
|
|
513
|
-
server.middlewares.use((req, res, next) => {
|
|
514
|
-
if (!(req.headers.accept ?? "").includes("text/html")) return next();
|
|
515
|
-
const originalEnd = res.end.bind(res);
|
|
516
|
-
let errored = false;
|
|
517
|
-
const handleError = (err) => {
|
|
518
|
-
if (errored) return;
|
|
519
|
-
errored = true;
|
|
520
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
521
|
-
server.ssrFixStacktrace(error);
|
|
522
|
-
const html = renderErrorOverlay(error);
|
|
523
|
-
res.statusCode = 500;
|
|
524
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
525
|
-
res.setHeader("Content-Length", Buffer.byteLength(html));
|
|
526
|
-
originalEnd(html);
|
|
527
|
-
};
|
|
528
|
-
res.on("error", handleError);
|
|
529
|
-
try {
|
|
530
|
-
const result = next();
|
|
531
|
-
if (result && typeof result.catch === "function") result.catch(handleError);
|
|
532
|
-
} catch (err) {
|
|
533
|
-
handleError(err);
|
|
534
|
-
}
|
|
535
|
-
});
|
|
536
|
-
server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`);
|
|
537
|
-
server.watcher.on("all", (event, path) => {
|
|
538
|
-
if (path.startsWith(routesDir) && (event === "add" || event === "unlink")) {
|
|
539
|
-
for (const resolvedId of [
|
|
540
|
-
RESOLVED_VIRTUAL_ROUTES_ID,
|
|
541
|
-
RESOLVED_VIRTUAL_MIDDLEWARE_ID,
|
|
542
|
-
RESOLVED_VIRTUAL_API_ROUTES_ID
|
|
543
|
-
]) {
|
|
544
|
-
const mod = server.moduleGraph.getModuleById(resolvedId);
|
|
545
|
-
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
546
|
-
}
|
|
547
|
-
server.ws.send({ type: "full-reload" });
|
|
548
|
-
}
|
|
549
|
-
});
|
|
550
|
-
},
|
|
551
|
-
config(userConfig) {
|
|
552
|
-
return {
|
|
553
|
-
resolve: { conditions: ["bun"] },
|
|
554
|
-
optimizeDeps: { exclude: scanPyreonPackages(userConfig.root ?? process.cwd()) },
|
|
555
|
-
server: { port: config.port },
|
|
556
|
-
define: {
|
|
557
|
-
__ZERO_MODE__: JSON.stringify(config.mode),
|
|
558
|
-
__ZERO_BASE__: JSON.stringify(config.base)
|
|
559
|
-
}
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* Check if the requested path matches any route. If not, render a 404 page.
|
|
566
|
-
* Returns true if the 404 was handled (response sent), false otherwise.
|
|
567
|
-
*
|
|
568
|
-
* In dev mode, the _404.tsx component cannot be SSR-rendered because
|
|
569
|
-
* the compiler emits _tpl() calls that require `document`. Instead,
|
|
570
|
-
* we return a static 404 page. The actual component rendering happens
|
|
571
|
-
* on the client side when the SPA loads.
|
|
572
|
-
*/
|
|
573
|
-
async function handle404(server, _routesDir, pathname, res) {
|
|
574
|
-
const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
|
|
575
|
-
if (flattenRoutePatterns(routes).some((pattern) => matchPattern(pattern, pathname))) return false;
|
|
576
|
-
const html = await render404Page(void 0);
|
|
577
|
-
res.statusCode = 404;
|
|
578
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
579
|
-
res.setHeader("Content-Length", Buffer.byteLength(html));
|
|
580
|
-
res.end(html);
|
|
581
|
-
return true;
|
|
582
|
-
}
|
|
583
|
-
/** Extract all URL patterns from a nested route tree. */
|
|
584
|
-
function flattenRoutePatterns(routes, prefix = "") {
|
|
585
|
-
const patterns = [];
|
|
586
|
-
for (const route of routes) {
|
|
587
|
-
if (!route.path) continue;
|
|
588
|
-
const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
|
|
589
|
-
patterns.push(fullPath);
|
|
590
|
-
if (route.children) patterns.push(...flattenRoutePatterns(route.children, fullPath));
|
|
591
|
-
}
|
|
592
|
-
return patterns;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
//#endregion
|
|
596
|
-
//#region src/isr.ts
|
|
597
|
-
/**
|
|
598
|
-
* In-memory ISR cache with stale-while-revalidate semantics.
|
|
599
|
-
*
|
|
600
|
-
* Wraps an SSR handler and caches responses per URL path.
|
|
601
|
-
* Serves stale content immediately while revalidating in the background.
|
|
602
|
-
*/
|
|
603
|
-
function createISRHandler(handler, config) {
|
|
604
|
-
const cache = /* @__PURE__ */ new Map();
|
|
605
|
-
const revalidating = /* @__PURE__ */ new Set();
|
|
606
|
-
const revalidateMs = config.revalidate * 1e3;
|
|
607
|
-
async function revalidate(url) {
|
|
608
|
-
const key = url.pathname;
|
|
609
|
-
if (revalidating.has(key)) return;
|
|
610
|
-
revalidating.add(key);
|
|
611
|
-
try {
|
|
612
|
-
const res = await handler(new Request(url.href, { method: "GET" }));
|
|
613
|
-
const html = await res.text();
|
|
614
|
-
const headers = {};
|
|
615
|
-
res.headers.forEach((v, k) => {
|
|
616
|
-
headers[k] = v;
|
|
617
|
-
});
|
|
618
|
-
cache.set(key, {
|
|
619
|
-
html,
|
|
620
|
-
headers,
|
|
621
|
-
timestamp: Date.now()
|
|
622
|
-
});
|
|
623
|
-
} catch {} finally {
|
|
624
|
-
revalidating.delete(key);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
return async (req) => {
|
|
628
|
-
if (req.method !== "GET") return handler(req);
|
|
629
|
-
const url = new URL(req.url);
|
|
630
|
-
const key = url.pathname;
|
|
631
|
-
const entry = cache.get(key);
|
|
632
|
-
if (entry) {
|
|
633
|
-
const age = Date.now() - entry.timestamp;
|
|
634
|
-
if (age > revalidateMs) revalidate(url);
|
|
635
|
-
return new Response(entry.html, {
|
|
636
|
-
status: 200,
|
|
637
|
-
headers: {
|
|
638
|
-
...entry.headers,
|
|
639
|
-
"content-type": "text/html; charset=utf-8",
|
|
640
|
-
"x-isr-cache": age > revalidateMs ? "STALE" : "HIT",
|
|
641
|
-
"x-isr-age": String(Math.round(age / 1e3))
|
|
642
|
-
}
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
const res = await handler(req);
|
|
646
|
-
const html = await res.text();
|
|
647
|
-
const headers = {};
|
|
648
|
-
res.headers.forEach((v, k) => {
|
|
649
|
-
headers[k] = v;
|
|
650
|
-
});
|
|
651
|
-
cache.set(key, {
|
|
652
|
-
html,
|
|
653
|
-
headers,
|
|
654
|
-
timestamp: Date.now()
|
|
655
|
-
});
|
|
656
|
-
return new Response(html, {
|
|
657
|
-
status: 200,
|
|
658
|
-
headers: {
|
|
659
|
-
...headers,
|
|
660
|
-
"content-type": "text/html; charset=utf-8",
|
|
661
|
-
"x-isr-cache": "MISS"
|
|
662
|
-
}
|
|
663
|
-
});
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
//#endregion
|
|
668
|
-
//#region src/adapters/validate.ts
|
|
669
|
-
/**
|
|
670
|
-
* Validate that adapter build inputs exist before copying.
|
|
671
|
-
* Throws with a clear error message if directories are missing.
|
|
672
|
-
* @internal
|
|
673
|
-
*/
|
|
674
|
-
async function validateBuildInputs(options) {
|
|
675
|
-
const { existsSync } = await import("node:fs");
|
|
676
|
-
if (!existsSync(options.clientOutDir)) throw new Error(`[zero:adapter] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`);
|
|
677
|
-
if (!existsSync(options.serverEntry)) throw new Error(`[zero:adapter] Server entry not found: ${options.serverEntry}. Run "vite build --ssr" first.`);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
//#endregion
|
|
681
|
-
//#region src/adapters/bun.ts
|
|
682
|
-
/**
|
|
683
|
-
* Bun adapter — generates a standalone Bun.serve() entry.
|
|
684
|
-
*/
|
|
685
|
-
function bunAdapter() {
|
|
686
|
-
return {
|
|
687
|
-
name: "bun",
|
|
688
|
-
async build(options) {
|
|
689
|
-
await validateBuildInputs(options);
|
|
690
|
-
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
691
|
-
const { join } = await import("node:path");
|
|
692
|
-
const outDir = options.outDir;
|
|
693
|
-
await mkdir(outDir, { recursive: true });
|
|
694
|
-
await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
|
|
695
|
-
await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
|
|
696
|
-
const port = options.config.port ?? 3e3;
|
|
697
|
-
const serverEntry = `
|
|
698
|
-
const handler = (await import("./server/entry-server.js")).default
|
|
699
|
-
const clientDir = new URL("./client/", import.meta.url).pathname
|
|
700
|
-
|
|
701
|
-
Bun.serve({
|
|
702
|
-
port: ${port},
|
|
703
|
-
async fetch(req) {
|
|
704
|
-
const url = new URL(req.url)
|
|
705
|
-
|
|
706
|
-
// Try static files first
|
|
707
|
-
if (req.method === "GET") {
|
|
708
|
-
const filePath = clientDir + (url.pathname === "/" ? "index.html" : url.pathname)
|
|
709
|
-
// Prevent path traversal — ensure resolved path stays within clientDir
|
|
710
|
-
const resolved = Bun.resolveSync(filePath, ".")
|
|
711
|
-
if (!resolved.startsWith(Bun.resolveSync(clientDir, "."))) {
|
|
712
|
-
return new Response("Forbidden", { status: 403 })
|
|
713
|
-
}
|
|
714
|
-
const file = Bun.file(filePath)
|
|
715
|
-
if (await file.exists()) {
|
|
716
|
-
return new Response(file, {
|
|
717
|
-
headers: {
|
|
718
|
-
"cache-control": filePath.endsWith(".js") || filePath.endsWith(".css")
|
|
719
|
-
? "public, max-age=31536000, immutable"
|
|
720
|
-
: "public, max-age=3600",
|
|
721
|
-
},
|
|
722
|
-
})
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// Fall through to SSR handler
|
|
727
|
-
return handler(req)
|
|
728
|
-
},
|
|
729
|
-
})
|
|
730
|
-
|
|
731
|
-
console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
|
|
732
|
-
`.trimStart();
|
|
733
|
-
await writeFile(join(outDir, "index.ts"), serverEntry);
|
|
734
|
-
}
|
|
735
|
-
};
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
//#endregion
|
|
739
|
-
//#region src/adapters/cloudflare.ts
|
|
740
|
-
/**
|
|
741
|
-
* Cloudflare Pages adapter — generates output for Cloudflare Pages with Functions.
|
|
742
|
-
*
|
|
743
|
-
* Produces:
|
|
744
|
-
* - Client assets in the output directory root (served as static)
|
|
745
|
-
* - `_worker.js` — Cloudflare Pages Function for SSR
|
|
746
|
-
*
|
|
747
|
-
* Note: Cloudflare Pages Functions have a ~1MB module size limit.
|
|
748
|
-
* For large apps, configure Vite's SSR build to bundle server code:
|
|
749
|
-
* `ssr: { noExternal: true }` in vite.config.ts.
|
|
750
|
-
*
|
|
751
|
-
* Deploy with: `npx wrangler pages deploy ./dist`
|
|
752
|
-
*
|
|
753
|
-
* @example
|
|
754
|
-
* ```ts
|
|
755
|
-
* // zero.config.ts
|
|
756
|
-
* import { defineConfig } from "@pyreon/zero"
|
|
757
|
-
*
|
|
758
|
-
* export default defineConfig({
|
|
759
|
-
* adapter: "cloudflare",
|
|
760
|
-
* })
|
|
761
|
-
* ```
|
|
762
|
-
*/
|
|
763
|
-
function cloudflareAdapter() {
|
|
764
|
-
return {
|
|
765
|
-
name: "cloudflare",
|
|
766
|
-
async build(options) {
|
|
767
|
-
await validateBuildInputs(options);
|
|
768
|
-
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
769
|
-
const { join } = await import("node:path");
|
|
770
|
-
const outDir = options.outDir;
|
|
771
|
-
await mkdir(outDir, { recursive: true });
|
|
772
|
-
await cp(options.clientOutDir, outDir, { recursive: true });
|
|
773
|
-
await cp(join(options.serverEntry, ".."), join(outDir, "_server"), { recursive: true });
|
|
774
|
-
const workerEntry = `
|
|
775
|
-
import handler from "./_server/entry-server.js"
|
|
776
|
-
|
|
777
|
-
export default {
|
|
778
|
-
async fetch(request, env, ctx) {
|
|
779
|
-
const url = new URL(request.url)
|
|
780
|
-
|
|
781
|
-
// Let Cloudflare serve static assets (files with extensions)
|
|
782
|
-
// This check is a fallback — Pages routes static files automatically
|
|
783
|
-
const ext = url.pathname.split(".").pop()
|
|
784
|
-
if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
|
|
785
|
-
// Cloudflare Pages handles static assets automatically via its asset binding
|
|
786
|
-
// Only reach here if the file doesn't exist — fall through to SSR
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// SSR handler
|
|
790
|
-
try {
|
|
791
|
-
return await handler(request)
|
|
792
|
-
} catch (err) {
|
|
793
|
-
return new Response("Internal Server Error", { status: 500 })
|
|
794
|
-
}
|
|
795
|
-
},
|
|
796
|
-
}
|
|
797
|
-
`.trimStart();
|
|
798
|
-
await writeFile(join(outDir, "_worker.js"), workerEntry);
|
|
799
|
-
await writeFile(join(outDir, "_routes.json"), JSON.stringify({
|
|
800
|
-
version: 1,
|
|
801
|
-
include: ["/*"],
|
|
802
|
-
exclude: [
|
|
803
|
-
"/assets/*",
|
|
804
|
-
"/favicon.*",
|
|
805
|
-
"/site.webmanifest",
|
|
806
|
-
"/robots.txt",
|
|
807
|
-
"/sitemap.xml"
|
|
808
|
-
]
|
|
809
|
-
}, null, 2));
|
|
810
|
-
}
|
|
811
|
-
};
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
//#endregion
|
|
815
|
-
//#region src/adapters/netlify.ts
|
|
816
|
-
/**
|
|
817
|
-
* Netlify adapter — generates output for Netlify Functions (v2).
|
|
818
|
-
*
|
|
819
|
-
* Produces:
|
|
820
|
-
* - Client assets in `publish/` directory
|
|
821
|
-
* - `netlify/functions/ssr.mjs` — Netlify Function for SSR
|
|
822
|
-
* - `netlify.toml` — routing configuration
|
|
823
|
-
*
|
|
824
|
-
* @example
|
|
825
|
-
* ```ts
|
|
826
|
-
* // zero.config.ts
|
|
827
|
-
* import { defineConfig } from "@pyreon/zero"
|
|
828
|
-
*
|
|
829
|
-
* export default defineConfig({
|
|
830
|
-
* adapter: "netlify",
|
|
831
|
-
* })
|
|
832
|
-
* ```
|
|
833
|
-
*/
|
|
834
|
-
function netlifyAdapter() {
|
|
835
|
-
return {
|
|
836
|
-
name: "netlify",
|
|
837
|
-
async build(options) {
|
|
838
|
-
await validateBuildInputs(options);
|
|
839
|
-
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
840
|
-
const { join } = await import("node:path");
|
|
841
|
-
const outDir = options.outDir;
|
|
842
|
-
const publishDir = join(outDir, "publish");
|
|
843
|
-
const functionsDir = join(outDir, "netlify", "functions");
|
|
844
|
-
await mkdir(publishDir, { recursive: true });
|
|
845
|
-
await mkdir(functionsDir, { recursive: true });
|
|
846
|
-
await cp(options.clientOutDir, publishDir, { recursive: true });
|
|
847
|
-
await cp(join(options.serverEntry, ".."), join(functionsDir, "_server"), { recursive: true });
|
|
848
|
-
const funcEntry = `
|
|
849
|
-
import handler from "./_server/entry-server.js"
|
|
850
|
-
|
|
851
|
-
export default async function(req, context) {
|
|
852
|
-
try {
|
|
853
|
-
return await handler(req)
|
|
854
|
-
} catch (err) {
|
|
855
|
-
return new Response("Internal Server Error", { status: 500 })
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
export const config = {
|
|
860
|
-
path: "/*",
|
|
861
|
-
preferStatic: true,
|
|
862
|
-
}
|
|
863
|
-
`.trimStart();
|
|
864
|
-
await writeFile(join(functionsDir, "ssr.mjs"), funcEntry);
|
|
865
|
-
const toml = `
|
|
866
|
-
[build]
|
|
867
|
-
publish = "publish"
|
|
868
|
-
functions = "netlify/functions"
|
|
869
|
-
|
|
870
|
-
[[headers]]
|
|
871
|
-
for = "/assets/*"
|
|
872
|
-
[headers.values]
|
|
873
|
-
Cache-Control = "public, max-age=31536000, immutable"
|
|
874
|
-
|
|
875
|
-
[[redirects]]
|
|
876
|
-
from = "/*"
|
|
877
|
-
to = "/.netlify/functions/ssr"
|
|
878
|
-
status = 200
|
|
879
|
-
conditions = {Role = ["admin", "user", ""]}
|
|
880
|
-
`.trimStart();
|
|
881
|
-
await writeFile(join(outDir, "netlify.toml"), toml);
|
|
882
|
-
}
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
//#endregion
|
|
887
|
-
//#region src/adapters/node.ts
|
|
888
|
-
/**
|
|
889
|
-
* Node.js adapter — generates a standalone server entry using node:http.
|
|
890
|
-
*/
|
|
891
|
-
function nodeAdapter() {
|
|
892
|
-
return {
|
|
893
|
-
name: "node",
|
|
894
|
-
async build(options) {
|
|
895
|
-
await validateBuildInputs(options);
|
|
896
|
-
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
897
|
-
const { join } = await import("node:path");
|
|
898
|
-
const outDir = options.outDir;
|
|
899
|
-
await mkdir(outDir, { recursive: true });
|
|
900
|
-
await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
|
|
901
|
-
await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
|
|
902
|
-
const port = options.config.port ?? 3e3;
|
|
903
|
-
const serverEntry = `
|
|
904
|
-
import { createServer } from "node:http"
|
|
905
|
-
import { readFile } from "node:fs/promises"
|
|
906
|
-
import { join, extname } from "node:path"
|
|
907
|
-
import { fileURLToPath } from "node:url"
|
|
908
|
-
|
|
909
|
-
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
|
910
|
-
const handler = (await import("./server/entry-server.js")).default
|
|
911
|
-
const clientDir = join(__dirname, "client")
|
|
912
|
-
|
|
913
|
-
const MIME_TYPES = {
|
|
914
|
-
".html": "text/html",
|
|
915
|
-
".js": "application/javascript",
|
|
916
|
-
".css": "text/css",
|
|
917
|
-
".json": "application/json",
|
|
918
|
-
".png": "image/png",
|
|
919
|
-
".jpg": "image/jpeg",
|
|
920
|
-
".svg": "image/svg+xml",
|
|
921
|
-
".woff2": "font/woff2",
|
|
922
|
-
".woff": "font/woff",
|
|
923
|
-
".ico": "image/x-icon",
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
const server = createServer(async (req, res) => {
|
|
927
|
-
const url = new URL(req.url ?? "/", "http://localhost")
|
|
928
|
-
|
|
929
|
-
// Try to serve static files first
|
|
930
|
-
if (req.method === "GET") {
|
|
931
|
-
try {
|
|
932
|
-
const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
|
|
933
|
-
// Prevent path traversal — ensure resolved path stays within clientDir
|
|
934
|
-
const { resolve } = await import("node:path")
|
|
935
|
-
const resolved = resolve(filePath)
|
|
936
|
-
if (!resolved.startsWith(resolve(clientDir))) {
|
|
937
|
-
res.writeHead(403)
|
|
938
|
-
res.end("Forbidden")
|
|
939
|
-
return
|
|
940
|
-
}
|
|
941
|
-
const ext = extname(filePath)
|
|
942
|
-
if (ext && ext !== ".html") {
|
|
943
|
-
const data = await readFile(filePath)
|
|
944
|
-
const mime = MIME_TYPES[ext] || "application/octet-stream"
|
|
945
|
-
res.writeHead(200, {
|
|
946
|
-
"content-type": mime,
|
|
947
|
-
"cache-control": ext === ".js" || ext === ".css"
|
|
948
|
-
? "public, max-age=31536000, immutable"
|
|
949
|
-
: "public, max-age=3600",
|
|
950
|
-
})
|
|
951
|
-
res.end(data)
|
|
952
|
-
return
|
|
953
|
-
}
|
|
954
|
-
} catch {}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// Fall through to SSR handler
|
|
958
|
-
const headers = {}
|
|
959
|
-
for (const [key, value] of Object.entries(req.headers)) {
|
|
960
|
-
if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
const request = new Request(url.href, {
|
|
964
|
-
method: req.method,
|
|
965
|
-
headers,
|
|
966
|
-
})
|
|
967
|
-
|
|
968
|
-
const response = await handler(request)
|
|
969
|
-
const body = await response.text()
|
|
970
|
-
|
|
971
|
-
const responseHeaders = {}
|
|
972
|
-
response.headers.forEach((v, k) => { responseHeaders[k] = v })
|
|
973
|
-
|
|
974
|
-
res.writeHead(response.status, responseHeaders)
|
|
975
|
-
res.end(body)
|
|
976
|
-
})
|
|
977
|
-
|
|
978
|
-
server.listen(${port}, () => {
|
|
979
|
-
console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
|
|
980
|
-
})
|
|
981
|
-
`.trimStart();
|
|
982
|
-
await writeFile(join(outDir, "index.js"), serverEntry);
|
|
983
|
-
await writeFile(join(outDir, "package.json"), JSON.stringify({ type: "module" }, null, 2));
|
|
984
|
-
}
|
|
985
|
-
};
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
//#endregion
|
|
989
|
-
//#region src/adapters/static.ts
|
|
990
|
-
/**
|
|
991
|
-
* Static adapter — just copies the client build output.
|
|
992
|
-
* Used with SSG mode where all pages are pre-rendered at build time.
|
|
993
|
-
*/
|
|
994
|
-
function staticAdapter() {
|
|
995
|
-
return {
|
|
996
|
-
name: "static",
|
|
997
|
-
async build(options) {
|
|
998
|
-
const { cp, mkdir } = await import("node:fs/promises");
|
|
999
|
-
await mkdir(options.outDir, { recursive: true });
|
|
1000
|
-
await cp(options.clientOutDir, options.outDir, { recursive: true });
|
|
1001
|
-
}
|
|
1002
|
-
};
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
//#endregion
|
|
1006
|
-
//#region src/adapters/vercel.ts
|
|
1007
|
-
/**
|
|
1008
|
-
* Vercel adapter — generates output for Vercel's Build Output API v3.
|
|
1009
|
-
*
|
|
1010
|
-
* Produces a `.vercel/output` directory with:
|
|
1011
|
-
* - `static/` — client-side assets (JS, CSS, images)
|
|
1012
|
-
* - `functions/ssr.func/` — serverless function for SSR
|
|
1013
|
-
* - `config.json` — routing configuration
|
|
1014
|
-
*
|
|
1015
|
-
* @example
|
|
1016
|
-
* ```ts
|
|
1017
|
-
* // zero.config.ts
|
|
1018
|
-
* import { defineConfig } from "@pyreon/zero"
|
|
1019
|
-
*
|
|
1020
|
-
* export default defineConfig({
|
|
1021
|
-
* adapter: "vercel",
|
|
1022
|
-
* })
|
|
1023
|
-
* ```
|
|
1024
|
-
*/
|
|
1025
|
-
function vercelAdapter() {
|
|
1026
|
-
return {
|
|
1027
|
-
name: "vercel",
|
|
1028
|
-
async build(options) {
|
|
1029
|
-
await validateBuildInputs(options);
|
|
1030
|
-
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
1031
|
-
const { join } = await import("node:path");
|
|
1032
|
-
const vercelDir = join(options.outDir, ".vercel", "output");
|
|
1033
|
-
const staticDir = join(vercelDir, "static");
|
|
1034
|
-
const funcDir = join(vercelDir, "functions", "ssr.func");
|
|
1035
|
-
await mkdir(staticDir, { recursive: true });
|
|
1036
|
-
await mkdir(funcDir, { recursive: true });
|
|
1037
|
-
await cp(options.clientOutDir, staticDir, { recursive: true });
|
|
1038
|
-
await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
|
|
1039
|
-
const funcEntry = `
|
|
1040
|
-
export default async function handler(req) {
|
|
1041
|
-
const handler = (await import("./entry-server.js")).default
|
|
1042
|
-
return handler(req)
|
|
1043
|
-
}
|
|
1044
|
-
`.trimStart();
|
|
1045
|
-
await writeFile(join(funcDir, "index.js"), funcEntry);
|
|
1046
|
-
await writeFile(join(funcDir, ".vc-config.json"), JSON.stringify({
|
|
1047
|
-
runtime: "nodejs20.x",
|
|
1048
|
-
handler: "index.js",
|
|
1049
|
-
launcherType: "Nodejs"
|
|
1050
|
-
}, null, 2));
|
|
1051
|
-
await writeFile(join(vercelDir, "config.json"), JSON.stringify({
|
|
1052
|
-
version: 3,
|
|
1053
|
-
routes: [
|
|
1054
|
-
{
|
|
1055
|
-
src: "/assets/(.*)",
|
|
1056
|
-
headers: { "Cache-Control": "public, max-age=31536000, immutable" }
|
|
1057
|
-
},
|
|
1058
|
-
{
|
|
1059
|
-
src: "/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)",
|
|
1060
|
-
dest: "/$1"
|
|
1061
|
-
},
|
|
1062
|
-
{
|
|
1063
|
-
src: "/(.*)",
|
|
1064
|
-
dest: "/ssr"
|
|
1065
|
-
}
|
|
1066
|
-
]
|
|
1067
|
-
}, null, 2));
|
|
1068
|
-
}
|
|
1069
|
-
};
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
//#endregion
|
|
1073
|
-
//#region src/adapters/index.ts
|
|
1074
|
-
/**
|
|
1075
|
-
* Resolve the adapter from config.
|
|
1076
|
-
* Returns a built-in adapter or throws if unknown.
|
|
1077
|
-
*/
|
|
1078
|
-
function resolveAdapter(config) {
|
|
1079
|
-
const name = config.adapter ?? "node";
|
|
1080
|
-
switch (name) {
|
|
1081
|
-
case "node": return nodeAdapter();
|
|
1082
|
-
case "bun": return bunAdapter();
|
|
1083
|
-
case "static": return staticAdapter();
|
|
1084
|
-
case "vercel": return vercelAdapter();
|
|
1085
|
-
case "cloudflare": return cloudflareAdapter();
|
|
1086
|
-
case "netlify": return netlifyAdapter();
|
|
1087
|
-
default: throw new Error(`[zero] Unknown adapter: "${name}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`);
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
//#endregion
|
|
1092
6
|
//#region src/utils/use-intersection-observer.ts
|
|
1093
7
|
/**
|
|
1094
8
|
* Observes an element and calls `onIntersect` once it enters the viewport.
|
|
@@ -1124,7 +38,7 @@ function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px")
|
|
|
1124
38
|
*/
|
|
1125
39
|
/** Shared empty props sentinel — identity-checked in mountElement to skip applyProps. */
|
|
1126
40
|
const EMPTY_PROPS = {};
|
|
1127
|
-
function h
|
|
41
|
+
function h(type, props, ...children) {
|
|
1128
42
|
return {
|
|
1129
43
|
type,
|
|
1130
44
|
props: props ?? EMPTY_PROPS,
|
|
@@ -1155,11 +69,11 @@ function jsx(type, props, key) {
|
|
|
1155
69
|
...rest,
|
|
1156
70
|
key
|
|
1157
71
|
} : rest;
|
|
1158
|
-
if (typeof type === "function") return h
|
|
72
|
+
if (typeof type === "function") return h(type, children !== void 0 ? {
|
|
1159
73
|
...propsWithKey,
|
|
1160
74
|
children
|
|
1161
75
|
} : propsWithKey);
|
|
1162
|
-
return h
|
|
76
|
+
return h(type, propsWithKey, ...children === void 0 ? [] : Array.isArray(children) ? children : [children]);
|
|
1163
77
|
}
|
|
1164
78
|
const jsxs = jsx;
|
|
1165
79
|
|
|
@@ -1492,702 +406,349 @@ function Script(props) {
|
|
|
1492
406
|
}
|
|
1493
407
|
|
|
1494
408
|
//#endregion
|
|
1495
|
-
//#region src/
|
|
1496
|
-
const HASHED_ASSET = /\.[a-f0-9]{8,}\.\w+$/;
|
|
1497
|
-
const STATIC_EXT = /\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm|ogg|mp3|wav)$/i;
|
|
1498
|
-
const SCRIPT_EXT = /\.(js|css|mjs)$/i;
|
|
1499
|
-
/** @internal Exported for testing */
|
|
1500
|
-
function matchGlob(pattern, path) {
|
|
1501
|
-
const regex = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
1502
|
-
return new RegExp(`^${regex}$`).test(path);
|
|
1503
|
-
}
|
|
1504
|
-
function resolveControl(path, immutableDuration, staticDuration, pageDuration, swr) {
|
|
1505
|
-
if (HASHED_ASSET.test(path)) return `public, max-age=${immutableDuration}, immutable`;
|
|
1506
|
-
if (SCRIPT_EXT.test(path)) return `public, max-age=3600, stale-while-revalidate=${swr}`;
|
|
1507
|
-
if (STATIC_EXT.test(path)) return `public, max-age=${staticDuration}, stale-while-revalidate=${swr}`;
|
|
1508
|
-
if (pageDuration > 0) return `public, max-age=${pageDuration}, stale-while-revalidate=${swr}`;
|
|
1509
|
-
return "no-cache";
|
|
1510
|
-
}
|
|
409
|
+
//#region src/i18n-routing.ts
|
|
1511
410
|
/**
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1515
|
-
* @example
|
|
1516
|
-
* import { cacheMiddleware } from "@pyreon/zero/cache"
|
|
1517
|
-
*
|
|
1518
|
-
* export default createHandler({
|
|
1519
|
-
* routes,
|
|
1520
|
-
* middleware: [
|
|
1521
|
-
* cacheMiddleware({
|
|
1522
|
-
* pages: 60,
|
|
1523
|
-
* staleWhileRevalidate: 300,
|
|
1524
|
-
* rules: [
|
|
1525
|
-
* { match: "/api/*", control: "no-store" },
|
|
1526
|
-
* ],
|
|
1527
|
-
* }),
|
|
1528
|
-
* ],
|
|
1529
|
-
* })
|
|
411
|
+
* Extract locale from a URL path.
|
|
412
|
+
* Returns { locale, pathWithoutLocale }.
|
|
1530
413
|
*/
|
|
1531
|
-
function
|
|
1532
|
-
const
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
return (ctx) => {
|
|
1538
|
-
const path = ctx.url.pathname;
|
|
1539
|
-
for (const rule of rules) if (matchGlob(rule.match, path)) {
|
|
1540
|
-
ctx.headers.set("Cache-Control", rule.control);
|
|
1541
|
-
return;
|
|
1542
|
-
}
|
|
1543
|
-
const control = resolveControl(path, immutableDuration, staticDuration, pageDuration, swr);
|
|
1544
|
-
ctx.headers.set("Cache-Control", control);
|
|
414
|
+
function extractLocaleFromPath(path, locales, defaultLocale) {
|
|
415
|
+
const segments = path.split("/").filter(Boolean);
|
|
416
|
+
const firstSegment = segments[0]?.toLowerCase();
|
|
417
|
+
if (firstSegment && locales.includes(firstSegment)) return {
|
|
418
|
+
locale: firstSegment,
|
|
419
|
+
pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
|
|
1545
420
|
};
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
* Adds common security headers to all responses.
|
|
1550
|
-
*/
|
|
1551
|
-
function securityHeaders() {
|
|
1552
|
-
return (ctx) => {
|
|
1553
|
-
ctx.headers.set("X-Content-Type-Options", "nosniff");
|
|
1554
|
-
ctx.headers.set("X-Frame-Options", "DENY");
|
|
1555
|
-
ctx.headers.set("X-XSS-Protection", "1; mode=block");
|
|
1556
|
-
ctx.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
1557
|
-
ctx.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
421
|
+
return {
|
|
422
|
+
locale: defaultLocale,
|
|
423
|
+
pathWithoutLocale: path
|
|
1558
424
|
};
|
|
1559
425
|
}
|
|
1560
426
|
/**
|
|
1561
|
-
*
|
|
1562
|
-
* Sets Vary: Accept-Encoding header so caches can serve compressed variants.
|
|
1563
|
-
* Actual compression is handled by the runtime (Bun/Node) or reverse proxy.
|
|
427
|
+
* Build a localized path.
|
|
1564
428
|
*/
|
|
1565
|
-
function
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
};
|
|
429
|
+
function buildLocalePath(path, locale, defaultLocale, strategy) {
|
|
430
|
+
const clean = path === "/" ? "" : path;
|
|
431
|
+
if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
|
|
432
|
+
return `/${locale}${clean}`;
|
|
1570
433
|
}
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
434
|
+
/** @internal Context for the current locale. */
|
|
435
|
+
const LocaleCtx = createContext("en");
|
|
436
|
+
/** Current locale signal — set by the server middleware or client-side detection. */
|
|
437
|
+
const localeSignal = signal("en");
|
|
1574
438
|
/**
|
|
1575
|
-
*
|
|
1576
|
-
* Middleware runs sequentially — if any returns a Response, the chain stops.
|
|
1577
|
-
*
|
|
1578
|
-
* @example
|
|
1579
|
-
* import { compose } from "@pyreon/zero/middleware"
|
|
1580
|
-
* import { corsMiddleware } from "@pyreon/zero/cors"
|
|
1581
|
-
* import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
|
|
439
|
+
* Read the current locale reactively.
|
|
1582
440
|
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
* cacheMiddleware(),
|
|
1587
|
-
* )
|
|
1588
|
-
*/
|
|
1589
|
-
function compose(...middlewares) {
|
|
1590
|
-
return async (ctx) => {
|
|
1591
|
-
for (const mw of middlewares) {
|
|
1592
|
-
const result = await mw(ctx);
|
|
1593
|
-
if (result instanceof Response) return result;
|
|
1594
|
-
}
|
|
1595
|
-
};
|
|
1596
|
-
}
|
|
1597
|
-
const ZERO_CTX_KEY = "__zeroCtx";
|
|
1598
|
-
/**
|
|
1599
|
-
* Get the shared Zero context from a middleware context.
|
|
1600
|
-
* Creates one if it doesn't exist. Middleware can use this to
|
|
1601
|
-
* pass data to downstream middleware without polluting `ctx.locals`.
|
|
441
|
+
* Returns the locale signal value directly — reactive in both SSR and CSR.
|
|
442
|
+
* The server middleware sets `localeSignal` per-request, and client-side
|
|
443
|
+
* `setLocale()` updates it as well.
|
|
1602
444
|
*
|
|
1603
445
|
* @example
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
1607
|
-
* }
|
|
1608
|
-
*
|
|
1609
|
-
* const loggingMiddleware: Middleware = (ctx) => {
|
|
1610
|
-
* const zctx = getContext(ctx)
|
|
1611
|
-
* console.log("User:", zctx.userId)
|
|
1612
|
-
* }
|
|
1613
|
-
*/
|
|
1614
|
-
function getContext(ctx) {
|
|
1615
|
-
let zctx = ctx.locals[ZERO_CTX_KEY];
|
|
1616
|
-
if (!zctx) {
|
|
1617
|
-
zctx = {};
|
|
1618
|
-
ctx.locals[ZERO_CTX_KEY] = zctx;
|
|
1619
|
-
}
|
|
1620
|
-
return zctx;
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
//#endregion
|
|
1624
|
-
//#region src/font.ts
|
|
1625
|
-
/**
|
|
1626
|
-
* Normalize a GoogleFontInput (string or object) into a ResolvedFont.
|
|
1627
|
-
*/
|
|
1628
|
-
function resolveGoogleFont(input) {
|
|
1629
|
-
if (typeof input === "string") return parseGoogleFamily(input);
|
|
1630
|
-
if (input.variable) return {
|
|
1631
|
-
family: input.family,
|
|
1632
|
-
italic: input.italic ?? false,
|
|
1633
|
-
variable: true,
|
|
1634
|
-
weightRange: input.weightRange
|
|
1635
|
-
};
|
|
1636
|
-
return {
|
|
1637
|
-
family: input.family,
|
|
1638
|
-
italic: input.italic ?? false,
|
|
1639
|
-
variable: false,
|
|
1640
|
-
weights: input.weights
|
|
1641
|
-
};
|
|
1642
|
-
}
|
|
1643
|
-
/**
|
|
1644
|
-
* Parse Google Fonts family string shorthand.
|
|
1645
|
-
*
|
|
1646
|
-
* Static weights: "Inter:wght@400;500;700"
|
|
1647
|
-
* Variable range: "Inter:wght@100..900"
|
|
1648
|
-
* Variable with italic: "Inter:ital,wght@100..900"
|
|
1649
|
-
*/
|
|
1650
|
-
function parseGoogleFamily(input) {
|
|
1651
|
-
const parts = input.split(":");
|
|
1652
|
-
const family = (parts[0] ?? "").trim();
|
|
1653
|
-
const spec = parts[1];
|
|
1654
|
-
let italic = false;
|
|
1655
|
-
if (spec) {
|
|
1656
|
-
italic = spec.includes("ital");
|
|
1657
|
-
const rangeMatch = spec.match(/wght@(\d+)\.\.(\d+)/);
|
|
1658
|
-
if (rangeMatch && rangeMatch[1] && rangeMatch[2]) return {
|
|
1659
|
-
family,
|
|
1660
|
-
italic,
|
|
1661
|
-
variable: true,
|
|
1662
|
-
weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])]
|
|
1663
|
-
};
|
|
1664
|
-
const afterAt = spec.split("@")[1];
|
|
1665
|
-
if (afterAt) {
|
|
1666
|
-
const entries = afterAt.split(";").filter(Boolean);
|
|
1667
|
-
const weights = /* @__PURE__ */ new Set();
|
|
1668
|
-
for (const entry of entries) if (entry.includes(",")) {
|
|
1669
|
-
const parts = entry.split(",");
|
|
1670
|
-
const weight = Number(parts[parts.length - 1]);
|
|
1671
|
-
if (weight > 0) weights.add(weight);
|
|
1672
|
-
if (parts[0] === "1") italic = true;
|
|
1673
|
-
} else if (entry.includes("..")) {} else {
|
|
1674
|
-
const weight = Number(entry);
|
|
1675
|
-
if (weight > 0) weights.add(weight);
|
|
1676
|
-
}
|
|
1677
|
-
if (weights.size > 0) return {
|
|
1678
|
-
family,
|
|
1679
|
-
italic,
|
|
1680
|
-
variable: false,
|
|
1681
|
-
weights: [...weights].sort((a, b) => a - b)
|
|
1682
|
-
};
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
return {
|
|
1686
|
-
family,
|
|
1687
|
-
italic,
|
|
1688
|
-
variable: false,
|
|
1689
|
-
weights: [400]
|
|
1690
|
-
};
|
|
1691
|
-
}
|
|
1692
|
-
/**
|
|
1693
|
-
* Generate a Google Fonts CSS URL.
|
|
1694
|
-
*/
|
|
1695
|
-
function googleFontsUrl(families, display = "swap") {
|
|
1696
|
-
return `https://fonts.googleapis.com/css2?${families.map((f) => {
|
|
1697
|
-
const axes = f.italic ? "ital,wght" : "wght";
|
|
1698
|
-
const name = f.family.replace(/ /g, "+");
|
|
1699
|
-
if (f.variable) {
|
|
1700
|
-
const range = `${f.weightRange[0]}..${f.weightRange[1]}`;
|
|
1701
|
-
return `family=${name}:${axes}@${f.italic ? `0,${range};1,${range}` : range}`;
|
|
1702
|
-
}
|
|
1703
|
-
return `family=${name}:${axes}@${f.weights.map((w) => f.italic ? `0,${w};1,${w}` : String(w)).join(";")}`;
|
|
1704
|
-
}).join("&")}&display=${display}`;
|
|
1705
|
-
}
|
|
1706
|
-
/**
|
|
1707
|
-
* Generate @font-face CSS for local fonts.
|
|
1708
|
-
*/
|
|
1709
|
-
function localFontFaces(fonts, display) {
|
|
1710
|
-
return fonts.map((f) => `@font-face {
|
|
1711
|
-
font-family: "${f.family}";
|
|
1712
|
-
src: url("${f.src}");
|
|
1713
|
-
font-weight: ${f.weight ?? "400"};
|
|
1714
|
-
font-style: ${f.style ?? "normal"};
|
|
1715
|
-
font-display: ${f.display ?? display};
|
|
1716
|
-
}`).join("\n\n");
|
|
1717
|
-
}
|
|
1718
|
-
/**
|
|
1719
|
-
* Generate size-adjusted fallback @font-face declarations to reduce CLS.
|
|
1720
|
-
*/
|
|
1721
|
-
function fallbackFontFaces(fallbacks) {
|
|
1722
|
-
return Object.entries(fallbacks).map(([family, metrics]) => {
|
|
1723
|
-
const overrides = [];
|
|
1724
|
-
if (metrics.sizeAdjust != null) overrides.push(` size-adjust: ${metrics.sizeAdjust * 100}%;`);
|
|
1725
|
-
if (metrics.ascentOverride != null) overrides.push(` ascent-override: ${metrics.ascentOverride}%;`);
|
|
1726
|
-
if (metrics.descentOverride != null) overrides.push(` descent-override: ${metrics.descentOverride}%;`);
|
|
1727
|
-
if (metrics.lineGapOverride != null) overrides.push(` line-gap-override: ${metrics.lineGapOverride}%;`);
|
|
1728
|
-
return `@font-face {
|
|
1729
|
-
font-family: "${family} Fallback";
|
|
1730
|
-
src: local("${metrics.fallback}");
|
|
1731
|
-
${overrides.join("\n")}
|
|
1732
|
-
}`;
|
|
1733
|
-
}).join("\n\n");
|
|
1734
|
-
}
|
|
1735
|
-
/**
|
|
1736
|
-
* Generate preload link tags for critical font files.
|
|
1737
|
-
*/
|
|
1738
|
-
function preloadTags(fonts) {
|
|
1739
|
-
return fonts.map((f) => {
|
|
1740
|
-
const ext = f.src.split(".").pop();
|
|
1741
|
-
const type = ext === "woff2" ? "font/woff2" : ext === "woff" ? "font/woff" : ext === "ttf" ? "font/ttf" : "font/otf";
|
|
1742
|
-
return `<link rel="preload" href="${f.src}" as="font" type="${type}" crossorigin>`;
|
|
1743
|
-
}).join("\n");
|
|
1744
|
-
}
|
|
1745
|
-
/**
|
|
1746
|
-
* Download Google Fonts CSS with woff2 user agent.
|
|
1747
|
-
*/
|
|
1748
|
-
async function downloadGoogleFontsCSS(url) {
|
|
1749
|
-
const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } });
|
|
1750
|
-
if (!response.ok) throw new Error(`Failed to fetch Google Fonts CSS: ${response.status}`);
|
|
1751
|
-
return response.text();
|
|
1752
|
-
}
|
|
1753
|
-
/**
|
|
1754
|
-
* Download a font file.
|
|
1755
|
-
*/
|
|
1756
|
-
async function downloadFontFile(url) {
|
|
1757
|
-
const response = await fetch(url);
|
|
1758
|
-
if (!response.ok) throw new Error(`Failed to download font: ${url}`);
|
|
1759
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
1760
|
-
return Buffer.from(arrayBuffer);
|
|
1761
|
-
}
|
|
1762
|
-
/**
|
|
1763
|
-
* Extract font file URLs from Google Fonts CSS.
|
|
1764
|
-
*/
|
|
1765
|
-
function extractFontUrls(css) {
|
|
1766
|
-
const urls = [];
|
|
1767
|
-
for (const match of css.matchAll(/url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)/g)) if (match[1]) urls.push(match[1]);
|
|
1768
|
-
return urls;
|
|
1769
|
-
}
|
|
1770
|
-
/**
|
|
1771
|
-
* Self-host Google Fonts: download CSS + font files, rewrite URLs to local paths.
|
|
446
|
+
* ```tsx
|
|
447
|
+
* const locale = useLocale() // "en", "de", etc.
|
|
448
|
+
* ```
|
|
1772
449
|
*/
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
const cachePath = join(cacheDir, `${Buffer.from(cssUrl).toString("base64url")}.json`);
|
|
1776
|
-
try {
|
|
1777
|
-
const cached = JSON.parse(await readFile(cachePath, "utf-8"));
|
|
1778
|
-
if (cached.css && cached.fontFiles) return {
|
|
1779
|
-
css: cached.css,
|
|
1780
|
-
fontFiles: cached.fontFiles.map((f) => ({
|
|
1781
|
-
name: f.name,
|
|
1782
|
-
content: Buffer.from(f.content, "base64")
|
|
1783
|
-
}))
|
|
1784
|
-
};
|
|
1785
|
-
} catch {}
|
|
1786
|
-
const css = await downloadGoogleFontsCSS(cssUrl);
|
|
1787
|
-
const fontUrls = extractFontUrls(css);
|
|
1788
|
-
const fontFiles = [];
|
|
1789
|
-
let rewrittenCss = css;
|
|
1790
|
-
for (const url of fontUrls) {
|
|
1791
|
-
const fileName = url.split("/").at(-1)?.split("?")[0] ?? "font";
|
|
1792
|
-
const content = await downloadFontFile(url);
|
|
1793
|
-
fontFiles.push({
|
|
1794
|
-
name: fileName,
|
|
1795
|
-
content
|
|
1796
|
-
});
|
|
1797
|
-
rewrittenCss = rewrittenCss.replace(url, `/${fontsSubDir}/${fileName}`);
|
|
1798
|
-
}
|
|
1799
|
-
try {
|
|
1800
|
-
await mkdir(cacheDir, { recursive: true });
|
|
1801
|
-
await writeFile(cachePath, JSON.stringify({
|
|
1802
|
-
css: rewrittenCss,
|
|
1803
|
-
fontFiles: fontFiles.map((f) => ({
|
|
1804
|
-
name: f.name,
|
|
1805
|
-
content: f.content.toString("base64")
|
|
1806
|
-
}))
|
|
1807
|
-
}));
|
|
1808
|
-
} catch {}
|
|
1809
|
-
return {
|
|
1810
|
-
css: rewrittenCss,
|
|
1811
|
-
fontFiles
|
|
1812
|
-
};
|
|
450
|
+
function useLocale() {
|
|
451
|
+
return localeSignal();
|
|
1813
452
|
}
|
|
1814
453
|
/**
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
1817
|
-
* Dev mode: injects Google Fonts CDN link for fast startup.
|
|
1818
|
-
* Build mode: downloads and self-hosts fonts for maximum performance + privacy.
|
|
454
|
+
* Set the locale client-side and update the URL.
|
|
1819
455
|
*
|
|
1820
456
|
* @example
|
|
1821
|
-
*
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
1824
|
-
* plugins: [
|
|
1825
|
-
* pyreon(),
|
|
1826
|
-
* zero(),
|
|
1827
|
-
* fontPlugin({
|
|
1828
|
-
* google: ["Inter:wght@400;500;600;700", "JetBrains Mono:wght@400"],
|
|
1829
|
-
* fallbacks: {
|
|
1830
|
-
* "Inter": { fallback: "Arial", sizeAdjust: 1.07, ascentOverride: 90 },
|
|
1831
|
-
* },
|
|
1832
|
-
* }),
|
|
1833
|
-
* ],
|
|
1834
|
-
* }
|
|
457
|
+
* ```tsx
|
|
458
|
+
* <button onClick={() => setLocale('de')}>Deutsch</button>
|
|
459
|
+
* ```
|
|
1835
460
|
*/
|
|
1836
|
-
function
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
return {
|
|
1846
|
-
name: "pyreon-zero-fonts",
|
|
1847
|
-
configResolved(resolvedConfig) {
|
|
1848
|
-
isBuild = resolvedConfig.command === "build";
|
|
1849
|
-
root = resolvedConfig.root;
|
|
1850
|
-
},
|
|
1851
|
-
async buildStart() {
|
|
1852
|
-
if (isBuild && shouldSelfHost && googleFamilies.length > 0) {
|
|
1853
|
-
const cssUrl = googleFontsUrl(googleFamilies, display);
|
|
1854
|
-
try {
|
|
1855
|
-
const result = await selfHostFonts(cssUrl, "assets/fonts", root);
|
|
1856
|
-
selfHostedCSS = result.css;
|
|
1857
|
-
selfHostedFontFiles = result.fontFiles;
|
|
1858
|
-
} catch {}
|
|
1859
|
-
}
|
|
1860
|
-
},
|
|
1861
|
-
generateBundle() {
|
|
1862
|
-
for (const file of selfHostedFontFiles) this.emitFile({
|
|
1863
|
-
type: "asset",
|
|
1864
|
-
fileName: `assets/fonts/${file.name}`,
|
|
1865
|
-
source: file.content
|
|
1866
|
-
});
|
|
1867
|
-
},
|
|
1868
|
-
transformIndexHtml(html) {
|
|
1869
|
-
const tags = [];
|
|
1870
|
-
collectGoogleFontTags(tags, {
|
|
1871
|
-
isBuild,
|
|
1872
|
-
selfHostedCSS,
|
|
1873
|
-
selfHostedFontFiles,
|
|
1874
|
-
shouldPreload,
|
|
1875
|
-
googleFamilies,
|
|
1876
|
-
display
|
|
1877
|
-
});
|
|
1878
|
-
collectLocalFontTags(tags, config, shouldPreload, display);
|
|
1879
|
-
if (tags.length === 0) return html;
|
|
1880
|
-
return html.replace("</head>", `${tags.join("\n")}\n</head>`);
|
|
1881
|
-
}
|
|
1882
|
-
};
|
|
1883
|
-
}
|
|
1884
|
-
function collectGoogleFontTags(tags, opts) {
|
|
1885
|
-
if (opts.isBuild && opts.selfHostedCSS) {
|
|
1886
|
-
tags.push(`<style>${opts.selfHostedCSS}</style>`);
|
|
1887
|
-
if (opts.shouldPreload) for (const file of opts.selfHostedFontFiles.slice(0, opts.googleFamilies.length)) {
|
|
1888
|
-
const type = file.name.split(".").pop() === "woff2" ? "font/woff2" : "font/woff";
|
|
1889
|
-
tags.push(`<link rel="preload" href="/assets/fonts/${file.name}" as="font" type="${type}" crossorigin>`);
|
|
1890
|
-
}
|
|
1891
|
-
} else if (opts.googleFamilies.length > 0) {
|
|
1892
|
-
const cssUrl = googleFontsUrl(opts.googleFamilies, opts.display);
|
|
1893
|
-
tags.push(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
|
1894
|
-
tags.push(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`);
|
|
1895
|
-
tags.push(`<link rel="stylesheet" href="${cssUrl}">`);
|
|
461
|
+
function setLocale(locale, config) {
|
|
462
|
+
localeSignal.set(locale);
|
|
463
|
+
if (typeof document !== "undefined") document.cookie = `${config.cookieName ?? "locale"}=${locale}; path=/; max-age=31536000`;
|
|
464
|
+
if (typeof window !== "undefined") {
|
|
465
|
+
const strategy = config.strategy ?? "prefix-except-default";
|
|
466
|
+
const { pathWithoutLocale } = extractLocaleFromPath(window.location.pathname, config.locales, config.defaultLocale);
|
|
467
|
+
const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy);
|
|
468
|
+
window.history.pushState(null, "", newPath);
|
|
469
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
1896
470
|
}
|
|
1897
471
|
}
|
|
1898
|
-
function collectLocalFontTags(tags, config, shouldPreload, display) {
|
|
1899
|
-
if (shouldPreload && config.local?.length) tags.push(preloadTags(config.local));
|
|
1900
|
-
if (config.local?.length) tags.push(`<style>${localFontFaces(config.local, display)}</style>`);
|
|
1901
|
-
if (config.fallbacks && Object.keys(config.fallbacks).length > 0) tags.push(`<style>${fallbackFontFaces(config.fallbacks)}</style>`);
|
|
1902
|
-
}
|
|
1903
|
-
/**
|
|
1904
|
-
* Generate CSS variables for font families.
|
|
1905
|
-
*/
|
|
1906
|
-
function fontVariables(families) {
|
|
1907
|
-
return `:root {\n${Object.entries(families).map(([key, value]) => ` --font-${key}: ${value};`).join("\n")}\n}`;
|
|
1908
|
-
}
|
|
1909
472
|
|
|
1910
473
|
//#endregion
|
|
1911
|
-
//#region src/
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
474
|
+
//#region src/meta.tsx
|
|
475
|
+
function faviconLinks(locale, config) {
|
|
476
|
+
const hasLocaleOverride = locale && config.locales?.[locale];
|
|
477
|
+
const prefix = hasLocaleOverride ? `/${locale}` : "";
|
|
478
|
+
const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
|
|
479
|
+
const links = [];
|
|
480
|
+
if (isSvg) links.push({
|
|
481
|
+
rel: "icon",
|
|
482
|
+
type: "image/svg+xml",
|
|
483
|
+
href: `${prefix}/favicon.svg`
|
|
484
|
+
});
|
|
485
|
+
links.push({
|
|
486
|
+
rel: "icon",
|
|
487
|
+
type: "image/png",
|
|
488
|
+
sizes: "32x32",
|
|
489
|
+
href: `${prefix}/favicon-32x32.png`
|
|
490
|
+
}, {
|
|
491
|
+
rel: "icon",
|
|
492
|
+
type: "image/png",
|
|
493
|
+
sizes: "16x16",
|
|
494
|
+
href: `${prefix}/favicon-16x16.png`
|
|
495
|
+
}, {
|
|
496
|
+
rel: "apple-touch-icon",
|
|
497
|
+
sizes: "180x180",
|
|
498
|
+
href: `${prefix}/apple-touch-icon.png`
|
|
499
|
+
});
|
|
500
|
+
if (config.manifest !== false) links.push({
|
|
501
|
+
rel: "manifest",
|
|
502
|
+
href: `${prefix}/site.webmanifest`
|
|
503
|
+
});
|
|
504
|
+
return links;
|
|
505
|
+
}
|
|
506
|
+
function ogImagePath(templateName, locale, outDir = "og", format = "png") {
|
|
507
|
+
const ext = format === "jpeg" ? "jpg" : "png";
|
|
508
|
+
return `/${outDir}/${templateName}${locale ? `-${locale}` : ""}.${ext}`;
|
|
1917
509
|
}
|
|
1918
|
-
const
|
|
510
|
+
const resolveStr = (v) => typeof v === "function" ? v() : v;
|
|
1919
511
|
/**
|
|
1920
|
-
*
|
|
1921
|
-
*
|
|
1922
|
-
* Transforms image imports with query params into optimized responsive images:
|
|
1923
|
-
*
|
|
1924
|
-
* @example
|
|
1925
|
-
* // vite.config.ts
|
|
1926
|
-
* import { imagePlugin } from "@pyreon/zero/image-plugin"
|
|
512
|
+
* Declarative meta component for SSR-compatible page metadata.
|
|
1927
513
|
*
|
|
1928
|
-
*
|
|
1929
|
-
*
|
|
1930
|
-
*
|
|
1931
|
-
* zero(),
|
|
1932
|
-
* imagePlugin({ widths: [480, 960, 1440], quality: 85 }),
|
|
1933
|
-
* ],
|
|
1934
|
-
* }
|
|
514
|
+
* Supports reactive title/description — when passed as `() => string` accessors,
|
|
515
|
+
* they are forwarded to `useHead()` as a reactive getter so updates propagate
|
|
516
|
+
* automatically via signal tracking.
|
|
1935
517
|
*
|
|
1936
518
|
* @example
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
1939
|
-
*
|
|
519
|
+
* ```tsx
|
|
520
|
+
* <Meta title="My Page" description="..." image="/og.jpg" canonical="https://..." />
|
|
521
|
+
* ```
|
|
1940
522
|
*
|
|
1941
|
-
*
|
|
523
|
+
* @example Reactive title
|
|
524
|
+
* ```tsx
|
|
525
|
+
* const count = signal(0)
|
|
526
|
+
* <Meta title={() => `${count()} items`} />
|
|
527
|
+
* ```
|
|
1942
528
|
*/
|
|
1943
|
-
function
|
|
1944
|
-
const
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
const include = config.include ?? IMAGE_EXT_RE;
|
|
1954
|
-
let root = "";
|
|
1955
|
-
let outDir = "";
|
|
1956
|
-
let isBuild = false;
|
|
1957
|
-
return {
|
|
1958
|
-
name: "pyreon-zero-images",
|
|
1959
|
-
enforce: "pre",
|
|
1960
|
-
configResolved(resolvedConfig) {
|
|
1961
|
-
root = resolvedConfig.root;
|
|
1962
|
-
outDir = resolvedConfig.build.outDir;
|
|
1963
|
-
isBuild = resolvedConfig.command === "build";
|
|
1964
|
-
},
|
|
1965
|
-
async resolveId(id) {
|
|
1966
|
-
if (id.includes("?optimize") && include.test(id.split("?")[0])) return `\0virtual:zero-image:${id}`;
|
|
1967
|
-
return null;
|
|
1968
|
-
},
|
|
1969
|
-
async load(id) {
|
|
1970
|
-
if (!id.startsWith("\0virtual:zero-image:")) return null;
|
|
1971
|
-
const rawPath = id.replace("\0virtual:zero-image:", "").split("?")[0] ?? id;
|
|
1972
|
-
const absPath = rawPath.startsWith("/") ? join(root, "public", rawPath) : rawPath;
|
|
1973
|
-
if (!isBuild) {
|
|
1974
|
-
const result = await loadDevImage(absPath, rawPath, placeholderSize);
|
|
1975
|
-
return `export default ${JSON.stringify(result)}`;
|
|
1976
|
-
}
|
|
1977
|
-
const processed = await processImage(absPath, {
|
|
1978
|
-
widths: defaultWidths,
|
|
1979
|
-
formats: defaultFormats,
|
|
1980
|
-
quality,
|
|
1981
|
-
placeholderSize,
|
|
1982
|
-
outSubDir,
|
|
1983
|
-
outDir: join(root, outDir)
|
|
1984
|
-
});
|
|
1985
|
-
await emitProcessedSources(processed, outSubDir, this);
|
|
1986
|
-
rebuildFormatSrcsets(processed, absPath);
|
|
1987
|
-
return `export default ${JSON.stringify(processed)}`;
|
|
1988
|
-
}
|
|
1989
|
-
};
|
|
1990
|
-
}
|
|
1991
|
-
async function loadDevImage(absPath, rawPath, placeholderSize) {
|
|
1992
|
-
const metadata = await getImageMetadata(absPath);
|
|
1993
|
-
const publicPath = rawPath.startsWith("/") ? rawPath : `/@fs/${absPath}`;
|
|
1994
|
-
return {
|
|
1995
|
-
src: publicPath,
|
|
1996
|
-
srcset: "",
|
|
1997
|
-
width: metadata.width,
|
|
1998
|
-
height: metadata.height,
|
|
1999
|
-
placeholder: await generateBlurPlaceholder(absPath, placeholderSize),
|
|
2000
|
-
formats: [],
|
|
2001
|
-
sources: [{
|
|
2002
|
-
src: publicPath,
|
|
2003
|
-
width: metadata.width,
|
|
2004
|
-
format: "original"
|
|
2005
|
-
}]
|
|
2006
|
-
};
|
|
2007
|
-
}
|
|
2008
|
-
async function emitProcessedSources(processed, outSubDir, ctx) {
|
|
2009
|
-
for (const source of processed.sources) {
|
|
2010
|
-
const fileName = join(outSubDir, basename(source.src));
|
|
2011
|
-
const content = await readFile(source.src);
|
|
2012
|
-
ctx.emitFile({
|
|
2013
|
-
type: "asset",
|
|
2014
|
-
fileName,
|
|
2015
|
-
source: content
|
|
2016
|
-
});
|
|
2017
|
-
source.src = `/${fileName}`;
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
function rebuildFormatSrcsets(processed, fallbackPath) {
|
|
2021
|
-
const formatGroups = /* @__PURE__ */ new Map();
|
|
2022
|
-
for (const s of processed.sources) {
|
|
2023
|
-
let group = formatGroups.get(s.format);
|
|
2024
|
-
if (!group) {
|
|
2025
|
-
group = [];
|
|
2026
|
-
formatGroups.set(s.format, group);
|
|
2027
|
-
}
|
|
2028
|
-
group.push(`${s.src} ${s.width}w`);
|
|
2029
|
-
}
|
|
2030
|
-
processed.formats = [...formatGroups.entries()].map(([fmt, entries]) => ({
|
|
2031
|
-
type: `image/${fmt}`,
|
|
2032
|
-
srcset: entries.join(", ")
|
|
2033
|
-
}));
|
|
2034
|
-
processed.srcset = processed.formats.at(-1)?.srcset ?? "";
|
|
2035
|
-
processed.src = processed.sources.at(-1)?.src ?? fallbackPath;
|
|
2036
|
-
}
|
|
2037
|
-
async function processImage(absPath, opts) {
|
|
2038
|
-
const metadata = await getImageMetadata(absPath);
|
|
2039
|
-
const name = basename(absPath, extname(absPath));
|
|
2040
|
-
const sources = [];
|
|
2041
|
-
const processedDir = join(opts.outDir, opts.outSubDir);
|
|
2042
|
-
if (!existsSync(processedDir)) await mkdir(processedDir, { recursive: true });
|
|
2043
|
-
for (const format of opts.formats) for (const targetWidth of opts.widths) {
|
|
2044
|
-
const width = Math.min(targetWidth, metadata.width);
|
|
2045
|
-
const outPath = join(processedDir, `${name}-${width}.${format}`);
|
|
2046
|
-
await resizeImage(absPath, outPath, width, format, opts.quality);
|
|
2047
|
-
sources.push({
|
|
2048
|
-
src: outPath,
|
|
2049
|
-
width,
|
|
2050
|
-
format
|
|
529
|
+
function Meta(props) {
|
|
530
|
+
const hasReactiveTitle = typeof props.title === "function";
|
|
531
|
+
const hasReactiveDescription = typeof props.description === "function";
|
|
532
|
+
if (hasReactiveTitle || hasReactiveDescription) useHead(() => {
|
|
533
|
+
const title = resolveStr(props.title);
|
|
534
|
+
const description = resolveStr(props.description);
|
|
535
|
+
const tags = buildMetaTags({
|
|
536
|
+
...props,
|
|
537
|
+
title,
|
|
538
|
+
description
|
|
2051
539
|
});
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
540
|
+
const input = {
|
|
541
|
+
meta: tags.meta,
|
|
542
|
+
link: tags.link,
|
|
543
|
+
script: tags.script
|
|
544
|
+
};
|
|
545
|
+
if (title) input.title = title;
|
|
546
|
+
return input;
|
|
547
|
+
});
|
|
548
|
+
else {
|
|
549
|
+
const title = resolveStr(props.title);
|
|
550
|
+
const description = resolveStr(props.description);
|
|
551
|
+
const tags = buildMetaTags({
|
|
552
|
+
...props,
|
|
553
|
+
title,
|
|
554
|
+
description
|
|
2063
555
|
});
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
}));
|
|
2069
|
-
const fallbackFormat = formats[formats.length - 1];
|
|
2070
|
-
const fallbackSources = formatGroups.get([...formatGroups.keys()].pop());
|
|
2071
|
-
const placeholder = await generateBlurPlaceholder(absPath, opts.placeholderSize);
|
|
2072
|
-
return {
|
|
2073
|
-
src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,
|
|
2074
|
-
srcset: fallbackFormat?.srcset ?? "",
|
|
2075
|
-
width: metadata.width,
|
|
2076
|
-
height: metadata.height,
|
|
2077
|
-
placeholder,
|
|
2078
|
-
formats,
|
|
2079
|
-
sources
|
|
2080
|
-
};
|
|
2081
|
-
}
|
|
2082
|
-
/**
|
|
2083
|
-
* Read basic image metadata.
|
|
2084
|
-
* Uses minimal binary header parsing — no external dependencies.
|
|
2085
|
-
*/
|
|
2086
|
-
async function getImageMetadata(absPath) {
|
|
2087
|
-
const buffer = await readFile(absPath);
|
|
2088
|
-
const ext = extname(absPath).toLowerCase();
|
|
2089
|
-
if (ext === ".png") return {
|
|
2090
|
-
width: buffer.readUInt32BE(16),
|
|
2091
|
-
height: buffer.readUInt32BE(20),
|
|
2092
|
-
format: "png"
|
|
2093
|
-
};
|
|
2094
|
-
if (ext === ".jpg" || ext === ".jpeg") return {
|
|
2095
|
-
...parseJpegDimensions(buffer),
|
|
2096
|
-
format: "jpeg"
|
|
2097
|
-
};
|
|
2098
|
-
if (ext === ".webp") return {
|
|
2099
|
-
...parseWebPDimensions(buffer),
|
|
2100
|
-
format: "webp"
|
|
2101
|
-
};
|
|
2102
|
-
return {
|
|
2103
|
-
width: 0,
|
|
2104
|
-
height: 0,
|
|
2105
|
-
format: ext.slice(1)
|
|
2106
|
-
};
|
|
2107
|
-
}
|
|
2108
|
-
/** @internal Exported for testing */
|
|
2109
|
-
function parseJpegDimensions(buffer) {
|
|
2110
|
-
let offset = 2;
|
|
2111
|
-
while (offset < buffer.length) {
|
|
2112
|
-
if (buffer[offset] !== 255) break;
|
|
2113
|
-
const marker = buffer[offset + 1];
|
|
2114
|
-
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
|
|
2115
|
-
const height = buffer.readUInt16BE(offset + 5);
|
|
2116
|
-
return {
|
|
2117
|
-
width: buffer.readUInt16BE(offset + 7),
|
|
2118
|
-
height
|
|
2119
|
-
};
|
|
2120
|
-
}
|
|
2121
|
-
const length = buffer.readUInt16BE(offset + 2);
|
|
2122
|
-
offset += 2 + length;
|
|
2123
|
-
}
|
|
2124
|
-
return {
|
|
2125
|
-
width: 0,
|
|
2126
|
-
height: 0
|
|
2127
|
-
};
|
|
2128
|
-
}
|
|
2129
|
-
/** @internal Exported for testing */
|
|
2130
|
-
function parseWebPDimensions(buffer) {
|
|
2131
|
-
const chunk = buffer.toString("ascii", 12, 16);
|
|
2132
|
-
if (chunk === "VP8 ") return {
|
|
2133
|
-
width: buffer.readUInt16LE(26) & 16383,
|
|
2134
|
-
height: buffer.readUInt16LE(28) & 16383
|
|
2135
|
-
};
|
|
2136
|
-
if (chunk === "VP8L") {
|
|
2137
|
-
const bits = buffer.readUInt32LE(21);
|
|
2138
|
-
return {
|
|
2139
|
-
width: (bits & 16383) + 1,
|
|
2140
|
-
height: (bits >> 14 & 16383) + 1
|
|
556
|
+
const input = {
|
|
557
|
+
meta: tags.meta,
|
|
558
|
+
link: tags.link,
|
|
559
|
+
script: tags.script
|
|
2141
560
|
};
|
|
561
|
+
if (title) input.title = title;
|
|
562
|
+
useHead(input);
|
|
2142
563
|
}
|
|
2143
|
-
|
|
2144
|
-
width: 1 + ((buffer[24] | buffer[25] << 8 | buffer[26] << 16) & 16777215),
|
|
2145
|
-
height: 1 + ((buffer[27] | buffer[28] << 8 | buffer[29] << 16) & 16777215)
|
|
2146
|
-
};
|
|
2147
|
-
return {
|
|
2148
|
-
width: 0,
|
|
2149
|
-
height: 0
|
|
2150
|
-
};
|
|
564
|
+
return props.children ?? null;
|
|
2151
565
|
}
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
566
|
+
function buildMetaTags(props) {
|
|
567
|
+
const meta = [];
|
|
568
|
+
const link = [];
|
|
569
|
+
const script = [];
|
|
570
|
+
const { title, description, canonical, imageAlt, imageWidth, imageHeight, type = "website", siteName, twitterCard = "summary_large_image", twitterSite, twitterCreator, locale = "en_US", alternateLocales, publishedTime, modifiedTime, author, tags, jsonLd, extra, video, videoWidth, videoHeight, audio, favicon, ogTemplate, ogImageDir, ogImageFormat } = props;
|
|
571
|
+
const robots = props.noIndex ? "noindex, nofollow" : props.robots ?? "index, follow";
|
|
572
|
+
const image = props.image ?? (ogTemplate ? ogImagePath(ogTemplate, locale !== "en_US" ? locale : void 0, ogImageDir, ogImageFormat) : void 0);
|
|
573
|
+
const resolvedImageWidth = imageWidth ?? (ogTemplate && !props.image ? 1200 : void 0);
|
|
574
|
+
const resolvedImageHeight = imageHeight ?? (ogTemplate && !props.image ? 630 : void 0);
|
|
575
|
+
if (description) meta.push({
|
|
576
|
+
name: "description",
|
|
577
|
+
content: description
|
|
578
|
+
});
|
|
579
|
+
if (robots) meta.push({
|
|
580
|
+
name: "robots",
|
|
581
|
+
content: robots
|
|
582
|
+
});
|
|
583
|
+
if (author) meta.push({
|
|
584
|
+
name: "author",
|
|
585
|
+
content: author
|
|
586
|
+
});
|
|
587
|
+
if (title) meta.push({
|
|
588
|
+
property: "og:title",
|
|
589
|
+
content: title
|
|
590
|
+
});
|
|
591
|
+
if (description) meta.push({
|
|
592
|
+
property: "og:description",
|
|
593
|
+
content: description
|
|
594
|
+
});
|
|
595
|
+
if (canonical) meta.push({
|
|
596
|
+
property: "og:url",
|
|
597
|
+
content: canonical
|
|
598
|
+
});
|
|
599
|
+
if (image) meta.push({
|
|
600
|
+
property: "og:image",
|
|
601
|
+
content: image
|
|
602
|
+
});
|
|
603
|
+
if (imageAlt) meta.push({
|
|
604
|
+
property: "og:image:alt",
|
|
605
|
+
content: imageAlt
|
|
606
|
+
});
|
|
607
|
+
if (resolvedImageWidth) meta.push({
|
|
608
|
+
property: "og:image:width",
|
|
609
|
+
content: String(resolvedImageWidth)
|
|
610
|
+
});
|
|
611
|
+
if (resolvedImageHeight) meta.push({
|
|
612
|
+
property: "og:image:height",
|
|
613
|
+
content: String(resolvedImageHeight)
|
|
614
|
+
});
|
|
615
|
+
meta.push({
|
|
616
|
+
property: "og:type",
|
|
617
|
+
content: type
|
|
618
|
+
});
|
|
619
|
+
if (siteName) meta.push({
|
|
620
|
+
property: "og:site_name",
|
|
621
|
+
content: siteName
|
|
622
|
+
});
|
|
623
|
+
meta.push({
|
|
624
|
+
property: "og:locale",
|
|
625
|
+
content: locale
|
|
626
|
+
});
|
|
627
|
+
if (video) {
|
|
628
|
+
meta.push({
|
|
629
|
+
property: "og:video",
|
|
630
|
+
content: video
|
|
631
|
+
});
|
|
632
|
+
if (videoWidth) meta.push({
|
|
633
|
+
property: "og:video:width",
|
|
634
|
+
content: String(videoWidth)
|
|
635
|
+
});
|
|
636
|
+
if (videoHeight) meta.push({
|
|
637
|
+
property: "og:video:height",
|
|
638
|
+
content: String(videoHeight)
|
|
639
|
+
});
|
|
640
|
+
if (video.endsWith(".mp4")) meta.push({
|
|
641
|
+
property: "og:video:type",
|
|
642
|
+
content: "video/mp4"
|
|
643
|
+
});
|
|
644
|
+
else if (video.endsWith(".webm")) meta.push({
|
|
645
|
+
property: "og:video:type",
|
|
646
|
+
content: "video/webm"
|
|
647
|
+
});
|
|
2180
648
|
}
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
649
|
+
if (audio) meta.push({
|
|
650
|
+
property: "og:audio",
|
|
651
|
+
content: audio
|
|
652
|
+
});
|
|
653
|
+
if (type === "article") {
|
|
654
|
+
if (publishedTime) meta.push({
|
|
655
|
+
property: "article:published_time",
|
|
656
|
+
content: publishedTime
|
|
657
|
+
});
|
|
658
|
+
if (modifiedTime) meta.push({
|
|
659
|
+
property: "article:modified_time",
|
|
660
|
+
content: modifiedTime
|
|
661
|
+
});
|
|
662
|
+
if (author) meta.push({
|
|
663
|
+
property: "article:author",
|
|
664
|
+
content: author
|
|
665
|
+
});
|
|
666
|
+
if (tags) for (const tag of tags) meta.push({
|
|
667
|
+
property: "article:tag",
|
|
668
|
+
content: tag
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
meta.push({
|
|
672
|
+
name: "twitter:card",
|
|
673
|
+
content: twitterCard
|
|
674
|
+
});
|
|
675
|
+
if (title) meta.push({
|
|
676
|
+
name: "twitter:title",
|
|
677
|
+
content: title
|
|
678
|
+
});
|
|
679
|
+
if (description) meta.push({
|
|
680
|
+
name: "twitter:description",
|
|
681
|
+
content: description
|
|
682
|
+
});
|
|
683
|
+
if (image) meta.push({
|
|
684
|
+
name: "twitter:image",
|
|
685
|
+
content: image
|
|
686
|
+
});
|
|
687
|
+
if (imageAlt) meta.push({
|
|
688
|
+
name: "twitter:image:alt",
|
|
689
|
+
content: imageAlt
|
|
690
|
+
});
|
|
691
|
+
if (twitterSite) meta.push({
|
|
692
|
+
name: "twitter:site",
|
|
693
|
+
content: twitterSite
|
|
694
|
+
});
|
|
695
|
+
if (twitterCreator) meta.push({
|
|
696
|
+
name: "twitter:creator",
|
|
697
|
+
content: twitterCreator
|
|
698
|
+
});
|
|
699
|
+
if (canonical) link.push({
|
|
700
|
+
rel: "canonical",
|
|
701
|
+
href: canonical
|
|
702
|
+
});
|
|
703
|
+
if (alternateLocales) for (const alt of alternateLocales) link.push({
|
|
704
|
+
rel: "alternate",
|
|
705
|
+
hreflang: alt.locale,
|
|
706
|
+
href: alt.url
|
|
707
|
+
});
|
|
708
|
+
if (jsonLd) script.push({
|
|
709
|
+
type: "application/ld+json",
|
|
710
|
+
children: JSON.stringify({
|
|
711
|
+
"@context": "https://schema.org",
|
|
712
|
+
...jsonLd
|
|
713
|
+
})
|
|
714
|
+
});
|
|
715
|
+
if (extra) for (const tag of extra) meta.push(tag);
|
|
716
|
+
if (props.i18n) {
|
|
717
|
+
const i18nConfig = props.i18n;
|
|
718
|
+
const origin = props.origin ?? "";
|
|
719
|
+
const { pathWithoutLocale } = extractLocaleFromPath(canonical?.replace(origin, "") ?? "/", i18nConfig.locales, i18nConfig.defaultLocale);
|
|
720
|
+
const strategy = i18nConfig.strategy ?? "prefix-except-default";
|
|
721
|
+
for (const loc of i18nConfig.locales) {
|
|
722
|
+
const localizedPath = strategy === "prefix-except-default" && loc === i18nConfig.defaultLocale ? pathWithoutLocale : `/${loc}${pathWithoutLocale === "/" ? "" : pathWithoutLocale}`;
|
|
723
|
+
link.push({
|
|
724
|
+
rel: "alternate",
|
|
725
|
+
hreflang: loc,
|
|
726
|
+
href: `${origin}${localizedPath}`
|
|
727
|
+
});
|
|
728
|
+
if (loc !== locale) meta.push({
|
|
729
|
+
property: "og:locale:alternate",
|
|
730
|
+
content: loc
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
link.push({
|
|
734
|
+
rel: "alternate",
|
|
735
|
+
hreflang: "x-default",
|
|
736
|
+
href: `${origin}${pathWithoutLocale}`
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
if (favicon) {
|
|
740
|
+
const faviconLocale = locale !== "en_US" ? locale : void 0;
|
|
741
|
+
for (const fl of faviconLinks(faviconLocale, favicon)) link.push(fl);
|
|
742
|
+
if (favicon.themeColor) meta.push({
|
|
743
|
+
name: "theme-color",
|
|
744
|
+
content: favicon.themeColor
|
|
745
|
+
});
|
|
2190
746
|
}
|
|
747
|
+
return {
|
|
748
|
+
meta,
|
|
749
|
+
link,
|
|
750
|
+
script
|
|
751
|
+
};
|
|
2191
752
|
}
|
|
2192
753
|
|
|
2193
754
|
//#endregion
|
|
@@ -2359,2299 +920,5 @@ function ThemeToggle(props) {
|
|
|
2359
920
|
const themeScript = `(function(){try{var t=localStorage.getItem("${STORAGE_KEY}");var r=t==="light"?"light":t==="dark"?"dark":window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.dataset.theme=r}catch(e){}})()`;
|
|
2360
921
|
|
|
2361
922
|
//#endregion
|
|
2362
|
-
|
|
2363
|
-
/**
|
|
2364
|
-
* Generate a sitemap.xml string from route file paths.
|
|
2365
|
-
*/
|
|
2366
|
-
function generateSitemap(routeFiles, config) {
|
|
2367
|
-
const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
|
|
2368
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2369
|
-
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
2370
|
-
${[...routeFiles.filter((f) => {
|
|
2371
|
-
const name = f.split("/").pop()?.replace(/\.\w+$/, "");
|
|
2372
|
-
return name !== "_layout" && name !== "_error" && name !== "_loading";
|
|
2373
|
-
}).map((f) => {
|
|
2374
|
-
let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "/").replace(/^index$/, "/");
|
|
2375
|
-
if (path.includes("[")) return null;
|
|
2376
|
-
path = path.replace(/\([\w-]+\)\//g, "");
|
|
2377
|
-
if (!path.startsWith("/")) path = `/${path}`;
|
|
2378
|
-
return path;
|
|
2379
|
-
}).filter((p) => p !== null).filter((p) => !exclude.some((e) => p.startsWith(e))).map((p) => ({
|
|
2380
|
-
path: p,
|
|
2381
|
-
changefreq,
|
|
2382
|
-
priority
|
|
2383
|
-
})), ...config.additionalPaths ?? []].map((entry) => {
|
|
2384
|
-
return ` <url>
|
|
2385
|
-
<loc>${escapeXml$1(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
|
|
2386
|
-
<changefreq>${entry.changefreq ?? changefreq}</changefreq>
|
|
2387
|
-
<priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
|
|
2388
|
-
</url>`;
|
|
2389
|
-
}).join("\n")}
|
|
2390
|
-
</urlset>`;
|
|
2391
|
-
}
|
|
2392
|
-
function escapeXml$1(str) {
|
|
2393
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2394
|
-
}
|
|
2395
|
-
/**
|
|
2396
|
-
* Generate a robots.txt string.
|
|
2397
|
-
*/
|
|
2398
|
-
function generateRobots(config = {}) {
|
|
2399
|
-
const { rules = [{
|
|
2400
|
-
userAgent: "*",
|
|
2401
|
-
allow: ["/"]
|
|
2402
|
-
}], sitemap, host } = config;
|
|
2403
|
-
const lines = [];
|
|
2404
|
-
for (const rule of rules) {
|
|
2405
|
-
lines.push(`User-agent: ${rule.userAgent}`);
|
|
2406
|
-
if (rule.allow) for (const path of rule.allow) lines.push(`Allow: ${path}`);
|
|
2407
|
-
if (rule.disallow) for (const path of rule.disallow) lines.push(`Disallow: ${path}`);
|
|
2408
|
-
if (rule.crawlDelay) lines.push(`Crawl-delay: ${rule.crawlDelay}`);
|
|
2409
|
-
lines.push("");
|
|
2410
|
-
}
|
|
2411
|
-
if (sitemap) lines.push(`Sitemap: ${sitemap}`);
|
|
2412
|
-
if (host) lines.push(`Host: ${host}`);
|
|
2413
|
-
return lines.join("\n");
|
|
2414
|
-
}
|
|
2415
|
-
/**
|
|
2416
|
-
* Generate a JSON-LD script tag string for structured data.
|
|
2417
|
-
*
|
|
2418
|
-
* @example
|
|
2419
|
-
* useHead({
|
|
2420
|
-
* script: [jsonLd({
|
|
2421
|
-
* "@type": "WebSite",
|
|
2422
|
-
* name: "My Site",
|
|
2423
|
-
* url: "https://example.com",
|
|
2424
|
-
* })],
|
|
2425
|
-
* })
|
|
2426
|
-
*/
|
|
2427
|
-
function jsonLd(data) {
|
|
2428
|
-
const ld = {
|
|
2429
|
-
"@context": "https://schema.org",
|
|
2430
|
-
...data
|
|
2431
|
-
};
|
|
2432
|
-
return `<script type="application/ld+json">${JSON.stringify(ld)}<\/script>`;
|
|
2433
|
-
}
|
|
2434
|
-
/**
|
|
2435
|
-
* Zero SEO Vite plugin.
|
|
2436
|
-
* Generates sitemap.xml and robots.txt at build time.
|
|
2437
|
-
*
|
|
2438
|
-
* @example
|
|
2439
|
-
* import { seoPlugin } from "@pyreon/zero/seo"
|
|
2440
|
-
*
|
|
2441
|
-
* export default {
|
|
2442
|
-
* plugins: [
|
|
2443
|
-
* pyreon(),
|
|
2444
|
-
* zero(),
|
|
2445
|
-
* seoPlugin({
|
|
2446
|
-
* sitemap: { origin: "https://example.com" },
|
|
2447
|
-
* robots: { sitemap: "https://example.com/sitemap.xml" },
|
|
2448
|
-
* }),
|
|
2449
|
-
* ],
|
|
2450
|
-
* }
|
|
2451
|
-
*/
|
|
2452
|
-
function seoPlugin(config = {}) {
|
|
2453
|
-
return {
|
|
2454
|
-
name: "pyreon-zero-seo",
|
|
2455
|
-
apply: "build",
|
|
2456
|
-
async generateBundle(_, _bundle) {
|
|
2457
|
-
if (config.sitemap) {
|
|
2458
|
-
const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
|
|
2459
|
-
const routesDir = `${process.cwd()}/src/routes`;
|
|
2460
|
-
try {
|
|
2461
|
-
const sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
|
|
2462
|
-
this.emitFile({
|
|
2463
|
-
type: "asset",
|
|
2464
|
-
fileName: "sitemap.xml",
|
|
2465
|
-
source: sitemap
|
|
2466
|
-
});
|
|
2467
|
-
} catch {}
|
|
2468
|
-
}
|
|
2469
|
-
if (config.robots) {
|
|
2470
|
-
const robots = generateRobots(config.robots);
|
|
2471
|
-
this.emitFile({
|
|
2472
|
-
type: "asset",
|
|
2473
|
-
fileName: "robots.txt",
|
|
2474
|
-
source: robots
|
|
2475
|
-
});
|
|
2476
|
-
}
|
|
2477
|
-
}
|
|
2478
|
-
};
|
|
2479
|
-
}
|
|
2480
|
-
/**
|
|
2481
|
-
* SEO middleware for dev server.
|
|
2482
|
-
* Serves sitemap.xml and robots.txt dynamically during development.
|
|
2483
|
-
*/
|
|
2484
|
-
function seoMiddleware(config = {}) {
|
|
2485
|
-
return async (ctx) => {
|
|
2486
|
-
if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
|
|
2487
|
-
if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
|
|
2488
|
-
const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
|
|
2489
|
-
const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
|
|
2490
|
-
return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
|
|
2491
|
-
} catch {}
|
|
2492
|
-
};
|
|
2493
|
-
}
|
|
2494
|
-
|
|
2495
|
-
//#endregion
|
|
2496
|
-
//#region src/cors.ts
|
|
2497
|
-
const DEFAULT_METHODS = [
|
|
2498
|
-
"GET",
|
|
2499
|
-
"POST",
|
|
2500
|
-
"PUT",
|
|
2501
|
-
"PATCH",
|
|
2502
|
-
"DELETE",
|
|
2503
|
-
"OPTIONS"
|
|
2504
|
-
];
|
|
2505
|
-
const DEFAULT_HEADERS = ["Content-Type", "Authorization"];
|
|
2506
|
-
/**
|
|
2507
|
-
* CORS middleware — handles preflight requests and sets appropriate
|
|
2508
|
-
* Access-Control headers on all responses.
|
|
2509
|
-
*
|
|
2510
|
-
* @example
|
|
2511
|
-
* import { corsMiddleware } from "@pyreon/zero/cors"
|
|
2512
|
-
*
|
|
2513
|
-
* corsMiddleware({ origin: "https://example.com", credentials: true })
|
|
2514
|
-
*
|
|
2515
|
-
* // Allow any origin
|
|
2516
|
-
* corsMiddleware({ origin: "*" })
|
|
2517
|
-
*
|
|
2518
|
-
* // Multiple origins
|
|
2519
|
-
* corsMiddleware({ origin: ["https://app.com", "https://admin.com"] })
|
|
2520
|
-
*/
|
|
2521
|
-
function corsMiddleware(config = {}) {
|
|
2522
|
-
const { origin = "*", methods = DEFAULT_METHODS, allowedHeaders = DEFAULT_HEADERS, exposedHeaders = [], credentials = false, maxAge = 86400 } = config;
|
|
2523
|
-
return (ctx) => {
|
|
2524
|
-
const resolvedOrigin = resolveOrigin(origin, ctx.req.headers.get("origin") ?? "");
|
|
2525
|
-
if (!resolvedOrigin) return;
|
|
2526
|
-
ctx.headers.set("Access-Control-Allow-Origin", resolvedOrigin);
|
|
2527
|
-
if (credentials) ctx.headers.set("Access-Control-Allow-Credentials", "true");
|
|
2528
|
-
if (exposedHeaders.length > 0) ctx.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
|
|
2529
|
-
if (resolvedOrigin !== "*") ctx.headers.append("Vary", "Origin");
|
|
2530
|
-
if (ctx.req.method === "OPTIONS") return new Response(null, {
|
|
2531
|
-
status: 204,
|
|
2532
|
-
headers: {
|
|
2533
|
-
"Access-Control-Allow-Origin": resolvedOrigin,
|
|
2534
|
-
"Access-Control-Allow-Methods": methods.join(", "),
|
|
2535
|
-
"Access-Control-Allow-Headers": allowedHeaders.join(", "),
|
|
2536
|
-
"Access-Control-Max-Age": String(maxAge),
|
|
2537
|
-
...credentials ? { "Access-Control-Allow-Credentials": "true" } : {}
|
|
2538
|
-
}
|
|
2539
|
-
});
|
|
2540
|
-
};
|
|
2541
|
-
}
|
|
2542
|
-
function resolveOrigin(config, requestOrigin) {
|
|
2543
|
-
if (config === "*") return "*";
|
|
2544
|
-
if (typeof config === "string") return config === requestOrigin ? config : null;
|
|
2545
|
-
if (typeof config === "function") return config(requestOrigin) ? requestOrigin : null;
|
|
2546
|
-
if (Array.isArray(config)) return config.includes(requestOrigin) ? requestOrigin : null;
|
|
2547
|
-
return null;
|
|
2548
|
-
}
|
|
2549
|
-
|
|
2550
|
-
//#endregion
|
|
2551
|
-
//#region src/rate-limit.ts
|
|
2552
|
-
/**
|
|
2553
|
-
* Rate limiting middleware — limits requests per client within a time window.
|
|
2554
|
-
* Uses an in-memory store (suitable for single-instance deployments).
|
|
2555
|
-
*
|
|
2556
|
-
* @example
|
|
2557
|
-
* import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
|
|
2558
|
-
*
|
|
2559
|
-
* // 100 requests per minute (default)
|
|
2560
|
-
* rateLimitMiddleware()
|
|
2561
|
-
*
|
|
2562
|
-
* // Strict API rate limiting
|
|
2563
|
-
* rateLimitMiddleware({
|
|
2564
|
-
* max: 20,
|
|
2565
|
-
* window: 60,
|
|
2566
|
-
* include: ["/api/*"],
|
|
2567
|
-
* })
|
|
2568
|
-
*/
|
|
2569
|
-
function rateLimitMiddleware(config = {}) {
|
|
2570
|
-
const { max = 100, window: windowSec = 60, keyFn = defaultKeyFn, onLimit, include, exclude } = config;
|
|
2571
|
-
const windowMs = windowSec * 1e3;
|
|
2572
|
-
const store = /* @__PURE__ */ new Map();
|
|
2573
|
-
const MAX_STORE_SIZE = 1e4;
|
|
2574
|
-
let lastCleanup = Date.now();
|
|
2575
|
-
function cleanupIfNeeded(now) {
|
|
2576
|
-
if (store.size < MAX_STORE_SIZE / 2 && now - lastCleanup < windowMs) return;
|
|
2577
|
-
lastCleanup = now;
|
|
2578
|
-
for (const [key, entry] of store) if (entry.resetAt <= now) store.delete(key);
|
|
2579
|
-
}
|
|
2580
|
-
return (ctx) => {
|
|
2581
|
-
if (include && !include.some((p) => matchSimpleGlob(p, ctx.path))) return;
|
|
2582
|
-
if (exclude?.some((p) => matchSimpleGlob(p, ctx.path))) return;
|
|
2583
|
-
const key = keyFn(ctx);
|
|
2584
|
-
const now = Date.now();
|
|
2585
|
-
cleanupIfNeeded(now);
|
|
2586
|
-
let entry = store.get(key);
|
|
2587
|
-
if (!entry || entry.resetAt <= now) {
|
|
2588
|
-
entry = {
|
|
2589
|
-
count: 0,
|
|
2590
|
-
resetAt: now + windowMs
|
|
2591
|
-
};
|
|
2592
|
-
store.set(key, entry);
|
|
2593
|
-
}
|
|
2594
|
-
entry.count++;
|
|
2595
|
-
const remaining = Math.max(0, max - entry.count);
|
|
2596
|
-
const resetSeconds = Math.ceil((entry.resetAt - now) / 1e3);
|
|
2597
|
-
ctx.headers.set("X-RateLimit-Limit", String(max));
|
|
2598
|
-
ctx.headers.set("X-RateLimit-Remaining", String(remaining));
|
|
2599
|
-
ctx.headers.set("X-RateLimit-Reset", String(resetSeconds));
|
|
2600
|
-
if (entry.count > max) {
|
|
2601
|
-
if (onLimit) return onLimit(ctx);
|
|
2602
|
-
return new Response(JSON.stringify({ error: "Too many requests" }), {
|
|
2603
|
-
status: 429,
|
|
2604
|
-
headers: {
|
|
2605
|
-
"Content-Type": "application/json",
|
|
2606
|
-
"Retry-After": String(resetSeconds),
|
|
2607
|
-
"X-RateLimit-Limit": String(max),
|
|
2608
|
-
"X-RateLimit-Remaining": "0",
|
|
2609
|
-
"X-RateLimit-Reset": String(resetSeconds)
|
|
2610
|
-
}
|
|
2611
|
-
});
|
|
2612
|
-
}
|
|
2613
|
-
};
|
|
2614
|
-
}
|
|
2615
|
-
function defaultKeyFn(ctx) {
|
|
2616
|
-
return ctx.req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? ctx.req.headers.get("x-real-ip") ?? "unknown";
|
|
2617
|
-
}
|
|
2618
|
-
/** Simple glob matching for path patterns. Supports trailing `*`. */
|
|
2619
|
-
function matchSimpleGlob(pattern, path) {
|
|
2620
|
-
if (pattern.endsWith("/*")) return path.startsWith(pattern.slice(0, -1));
|
|
2621
|
-
return pattern === path;
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2624
|
-
//#endregion
|
|
2625
|
-
//#region src/compression.ts
|
|
2626
|
-
/**
|
|
2627
|
-
* Compression middleware — compresses responses using gzip or deflate
|
|
2628
|
-
* based on the client's Accept-Encoding header.
|
|
2629
|
-
*
|
|
2630
|
-
* Only compresses text-based content types (HTML, JSON, JS, CSS, XML, SVG).
|
|
2631
|
-
* Skips responses below the size threshold and already-encoded responses.
|
|
2632
|
-
*
|
|
2633
|
-
* @example
|
|
2634
|
-
* import { compressionMiddleware } from "@pyreon/zero/compression"
|
|
2635
|
-
*
|
|
2636
|
-
* compressionMiddleware() // gzip with 1KB threshold
|
|
2637
|
-
* compressionMiddleware({ threshold: 512, encodings: ["gzip"] })
|
|
2638
|
-
*/
|
|
2639
|
-
function compressionMiddleware(config = {}) {
|
|
2640
|
-
const { threshold = 1024, encodings = ["gzip", "deflate"] } = config;
|
|
2641
|
-
return (ctx) => {
|
|
2642
|
-
const acceptEncoding = ctx.req.headers.get("accept-encoding") ?? "";
|
|
2643
|
-
const encoding = encodings.find((enc) => acceptEncoding.includes(enc));
|
|
2644
|
-
if (!encoding) return;
|
|
2645
|
-
ctx.locals.__compressionEncoding = encoding;
|
|
2646
|
-
ctx.locals.__compressionThreshold = threshold;
|
|
2647
|
-
ctx.headers.append("Vary", "Accept-Encoding");
|
|
2648
|
-
};
|
|
2649
|
-
}
|
|
2650
|
-
/**
|
|
2651
|
-
* Compress a Response body if it meets the criteria.
|
|
2652
|
-
* Use this to post-process responses after the handler runs.
|
|
2653
|
-
*
|
|
2654
|
-
* @example
|
|
2655
|
-
* const response = await handler(request)
|
|
2656
|
-
* const compressed = await compressResponse(response, 'gzip', 1024)
|
|
2657
|
-
*/
|
|
2658
|
-
async function compressResponse(response, encoding, threshold) {
|
|
2659
|
-
if (!isCompressible(response.headers.get("content-type") ?? "")) return response;
|
|
2660
|
-
if (response.headers.get("content-encoding")) return response;
|
|
2661
|
-
const body = await response.arrayBuffer();
|
|
2662
|
-
if (body.byteLength < threshold) return response;
|
|
2663
|
-
const compressed = await compress(body, encoding);
|
|
2664
|
-
const headers = new Headers(response.headers);
|
|
2665
|
-
headers.set("Content-Encoding", encoding);
|
|
2666
|
-
headers.delete("Content-Length");
|
|
2667
|
-
headers.append("Vary", "Accept-Encoding");
|
|
2668
|
-
return new Response(compressed, {
|
|
2669
|
-
status: response.status,
|
|
2670
|
-
statusText: response.statusText,
|
|
2671
|
-
headers
|
|
2672
|
-
});
|
|
2673
|
-
}
|
|
2674
|
-
const COMPRESSIBLE_TYPES = [
|
|
2675
|
-
"text/",
|
|
2676
|
-
"application/json",
|
|
2677
|
-
"application/javascript",
|
|
2678
|
-
"application/xml",
|
|
2679
|
-
"application/xhtml+xml",
|
|
2680
|
-
"image/svg+xml"
|
|
2681
|
-
];
|
|
2682
|
-
/** Check if a content type is compressible. Exported for testing. */
|
|
2683
|
-
function isCompressible(contentType) {
|
|
2684
|
-
return COMPRESSIBLE_TYPES.some((t) => contentType.includes(t));
|
|
2685
|
-
}
|
|
2686
|
-
async function compress(data, encoding) {
|
|
2687
|
-
if (typeof CompressionStream !== "undefined") {
|
|
2688
|
-
const format = encoding === "gzip" ? "gzip" : "deflate";
|
|
2689
|
-
const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(format));
|
|
2690
|
-
return new Response(stream).arrayBuffer();
|
|
2691
|
-
}
|
|
2692
|
-
try {
|
|
2693
|
-
const zlib = await import("node:zlib");
|
|
2694
|
-
const { promisify } = await import("node:util");
|
|
2695
|
-
const result = await (encoding === "gzip" ? promisify(zlib.gzip) : promisify(zlib.deflate))(Buffer.from(data));
|
|
2696
|
-
return result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength);
|
|
2697
|
-
} catch {
|
|
2698
|
-
return data;
|
|
2699
|
-
}
|
|
2700
|
-
}
|
|
2701
|
-
|
|
2702
|
-
//#endregion
|
|
2703
|
-
//#region src/actions.ts
|
|
2704
|
-
const actionRegistry = /* @__PURE__ */ new Map();
|
|
2705
|
-
/**
|
|
2706
|
-
* Define a server action. Returns a callable function that:
|
|
2707
|
-
* - On the **client**: sends a POST request to `/_zero/actions/<id>`
|
|
2708
|
-
* - On the **server** (SSR): executes the handler directly (no fetch)
|
|
2709
|
-
*
|
|
2710
|
-
* @example
|
|
2711
|
-
* // In a route file or module:
|
|
2712
|
-
* export const createPost = defineAction(async (ctx) => {
|
|
2713
|
-
* const data = ctx.json as { title: string; body: string }
|
|
2714
|
-
* // ... save to database
|
|
2715
|
-
* return { success: true, id: 123 }
|
|
2716
|
-
* })
|
|
2717
|
-
*
|
|
2718
|
-
* // In a component:
|
|
2719
|
-
* const result = await createPost({ title: 'Hello', body: '...' })
|
|
2720
|
-
*/
|
|
2721
|
-
function defineAction(handler) {
|
|
2722
|
-
const id = `action_${crypto.randomUUID().slice(0, 8)}`;
|
|
2723
|
-
actionRegistry.set(id, {
|
|
2724
|
-
id,
|
|
2725
|
-
handler
|
|
2726
|
-
});
|
|
2727
|
-
const callable = async (data) => {
|
|
2728
|
-
if (typeof globalThis.window === "undefined") return handler({
|
|
2729
|
-
request: new Request(`http://localhost/_zero/actions/${id}`, {
|
|
2730
|
-
method: "POST",
|
|
2731
|
-
headers: { "Content-Type": "application/json" },
|
|
2732
|
-
body: JSON.stringify(data ?? null)
|
|
2733
|
-
}),
|
|
2734
|
-
formData: null,
|
|
2735
|
-
json: data ?? null,
|
|
2736
|
-
headers: new Headers({ "Content-Type": "application/json" })
|
|
2737
|
-
});
|
|
2738
|
-
const response = await fetch(`/_zero/actions/${id}`, {
|
|
2739
|
-
method: "POST",
|
|
2740
|
-
headers: { "Content-Type": "application/json" },
|
|
2741
|
-
body: JSON.stringify(data ?? null)
|
|
2742
|
-
});
|
|
2743
|
-
if (!response.ok) {
|
|
2744
|
-
const body = await response.json().catch(() => ({}));
|
|
2745
|
-
throw new Error(body.error ?? `Action failed: ${response.statusText}`);
|
|
2746
|
-
}
|
|
2747
|
-
return response.json();
|
|
2748
|
-
};
|
|
2749
|
-
callable.actionId = id;
|
|
2750
|
-
return callable;
|
|
2751
|
-
}
|
|
2752
|
-
/**
|
|
2753
|
-
* Create a middleware that handles action requests at `/_zero/actions/*`.
|
|
2754
|
-
* Mount this before the SSR handler in the server entry.
|
|
2755
|
-
*/
|
|
2756
|
-
function createActionMiddleware() {
|
|
2757
|
-
return async (ctx) => {
|
|
2758
|
-
if (!ctx.path.startsWith("/_zero/actions/")) return;
|
|
2759
|
-
const actionId = ctx.path.slice(15);
|
|
2760
|
-
const action = actionRegistry.get(actionId);
|
|
2761
|
-
if (!action) return Response.json({ error: "Action not found" }, { status: 404 });
|
|
2762
|
-
if (ctx.req.method !== "POST") return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
2763
|
-
return executeAction(action, ctx.req);
|
|
2764
|
-
};
|
|
2765
|
-
}
|
|
2766
|
-
async function executeAction(action, req) {
|
|
2767
|
-
try {
|
|
2768
|
-
const contentType = req.headers.get("content-type") ?? "";
|
|
2769
|
-
let formData = null;
|
|
2770
|
-
let json = null;
|
|
2771
|
-
if (contentType.includes("application/json")) json = await req.json();
|
|
2772
|
-
else if (contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded")) formData = await req.formData();
|
|
2773
|
-
const result = await action.handler({
|
|
2774
|
-
request: req,
|
|
2775
|
-
formData,
|
|
2776
|
-
json,
|
|
2777
|
-
headers: req.headers
|
|
2778
|
-
});
|
|
2779
|
-
return Response.json(result ?? null);
|
|
2780
|
-
} catch (err) {
|
|
2781
|
-
const message = err instanceof Error ? err.message : "Internal server error";
|
|
2782
|
-
return Response.json({ error: message }, { status: 500 });
|
|
2783
|
-
}
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
//#endregion
|
|
2787
|
-
//#region src/favicon.ts
|
|
2788
|
-
let sharpWarned$1 = false;
|
|
2789
|
-
function warnSharpMissing$1() {
|
|
2790
|
-
if (sharpWarned$1) return;
|
|
2791
|
-
sharpWarned$1 = true;
|
|
2792
|
-
console.warn("\n[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
|
|
2793
|
-
}
|
|
2794
|
-
const SIZES = [
|
|
2795
|
-
{
|
|
2796
|
-
size: 16,
|
|
2797
|
-
name: "favicon-16x16.png"
|
|
2798
|
-
},
|
|
2799
|
-
{
|
|
2800
|
-
size: 32,
|
|
2801
|
-
name: "favicon-32x32.png"
|
|
2802
|
-
},
|
|
2803
|
-
{
|
|
2804
|
-
size: 180,
|
|
2805
|
-
name: "apple-touch-icon.png"
|
|
2806
|
-
},
|
|
2807
|
-
{
|
|
2808
|
-
size: 192,
|
|
2809
|
-
name: "icon-192.png"
|
|
2810
|
-
},
|
|
2811
|
-
{
|
|
2812
|
-
size: 512,
|
|
2813
|
-
name: "icon-512.png"
|
|
2814
|
-
}
|
|
2815
|
-
];
|
|
2816
|
-
/**
|
|
2817
|
-
* Favicon generation Vite plugin.
|
|
2818
|
-
*
|
|
2819
|
-
* Generates all required favicon formats at build time from a single source.
|
|
2820
|
-
* In dev mode, serves the source directly.
|
|
2821
|
-
*
|
|
2822
|
-
* @example
|
|
2823
|
-
* ```ts
|
|
2824
|
-
* // vite.config.ts
|
|
2825
|
-
* import { faviconPlugin } from "@pyreon/zero"
|
|
2826
|
-
*
|
|
2827
|
-
* export default {
|
|
2828
|
-
* plugins: [faviconPlugin({ source: "./src/assets/icon.svg" })],
|
|
2829
|
-
* }
|
|
2830
|
-
* ```
|
|
2831
|
-
*/
|
|
2832
|
-
function faviconPlugin(config) {
|
|
2833
|
-
const themeColor = config.themeColor ?? "#ffffff";
|
|
2834
|
-
const backgroundColor = config.backgroundColor ?? "#ffffff";
|
|
2835
|
-
const generateManifest = config.manifest !== false;
|
|
2836
|
-
let root = "";
|
|
2837
|
-
let isBuild = false;
|
|
2838
|
-
return {
|
|
2839
|
-
name: "pyreon-zero-favicon",
|
|
2840
|
-
enforce: "pre",
|
|
2841
|
-
configResolved(resolvedConfig) {
|
|
2842
|
-
root = resolvedConfig.root;
|
|
2843
|
-
isBuild = resolvedConfig.command === "build";
|
|
2844
|
-
},
|
|
2845
|
-
configureServer(server) {
|
|
2846
|
-
const sourcePath = join(root, config.source);
|
|
2847
|
-
const devCache = /* @__PURE__ */ new Map();
|
|
2848
|
-
server.middlewares.use(async (req, res, next) => {
|
|
2849
|
-
const url = req.url ?? "";
|
|
2850
|
-
const localeSource = resolveLocaleSource(url, config, root);
|
|
2851
|
-
const svgUrl = localeSource ? localeSource.url : url;
|
|
2852
|
-
const svgPath = localeSource ? localeSource.sourcePath : sourcePath;
|
|
2853
|
-
const isSvgSource = localeSource ? localeSource.source.endsWith(".svg") : config.source.endsWith(".svg");
|
|
2854
|
-
if (svgUrl.endsWith("/favicon.svg") && isSvgSource) try {
|
|
2855
|
-
const content = await readFile(svgPath, "utf-8");
|
|
2856
|
-
res.setHeader("Content-Type", "image/svg+xml");
|
|
2857
|
-
res.end(content);
|
|
2858
|
-
return;
|
|
2859
|
-
} catch {}
|
|
2860
|
-
const baseName = svgUrl.split("/").pop() ?? "";
|
|
2861
|
-
const sizeMatch = SIZES.find((s) => s.name === baseName);
|
|
2862
|
-
if (sizeMatch) {
|
|
2863
|
-
const cacheKey = `${svgPath}:${sizeMatch.size}`;
|
|
2864
|
-
let png = devCache.get(cacheKey);
|
|
2865
|
-
if (!png) {
|
|
2866
|
-
const result = await resizeToPng(svgPath, sizeMatch.size);
|
|
2867
|
-
if (result) {
|
|
2868
|
-
png = result;
|
|
2869
|
-
devCache.set(cacheKey, result);
|
|
2870
|
-
}
|
|
2871
|
-
}
|
|
2872
|
-
if (png) {
|
|
2873
|
-
res.setHeader("Content-Type", "image/png");
|
|
2874
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
2875
|
-
res.end(Buffer.from(png));
|
|
2876
|
-
return;
|
|
2877
|
-
}
|
|
2878
|
-
}
|
|
2879
|
-
if (baseName === "favicon.ico") {
|
|
2880
|
-
const cacheKey = `ico:${svgPath}`;
|
|
2881
|
-
let ico = devCache.get(cacheKey);
|
|
2882
|
-
if (!ico) {
|
|
2883
|
-
const result = await generateIco(svgPath);
|
|
2884
|
-
if (result) {
|
|
2885
|
-
ico = result;
|
|
2886
|
-
devCache.set(cacheKey, result);
|
|
2887
|
-
}
|
|
2888
|
-
}
|
|
2889
|
-
if (ico) {
|
|
2890
|
-
res.setHeader("Content-Type", "image/x-icon");
|
|
2891
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
2892
|
-
res.end(Buffer.from(ico));
|
|
2893
|
-
return;
|
|
2894
|
-
}
|
|
2895
|
-
}
|
|
2896
|
-
if (baseName === "site.webmanifest" && generateManifest) {
|
|
2897
|
-
const prefix = localeSource ? `/${localeSource.locale}` : "";
|
|
2898
|
-
const manifest = {
|
|
2899
|
-
name: config.name ?? "App",
|
|
2900
|
-
short_name: config.name ?? "App",
|
|
2901
|
-
icons: [{
|
|
2902
|
-
src: `${prefix}/icon-192.png`,
|
|
2903
|
-
sizes: "192x192",
|
|
2904
|
-
type: "image/png"
|
|
2905
|
-
}, {
|
|
2906
|
-
src: `${prefix}/icon-512.png`,
|
|
2907
|
-
sizes: "512x512",
|
|
2908
|
-
type: "image/png"
|
|
2909
|
-
}],
|
|
2910
|
-
theme_color: themeColor,
|
|
2911
|
-
background_color: backgroundColor,
|
|
2912
|
-
display: "standalone"
|
|
2913
|
-
};
|
|
2914
|
-
res.setHeader("Content-Type", "application/manifest+json");
|
|
2915
|
-
res.end(JSON.stringify(manifest, null, 2));
|
|
2916
|
-
return;
|
|
2917
|
-
}
|
|
2918
|
-
next();
|
|
2919
|
-
});
|
|
2920
|
-
},
|
|
2921
|
-
transformIndexHtml() {
|
|
2922
|
-
const isSvg = config.source.endsWith(".svg");
|
|
2923
|
-
const tags = [];
|
|
2924
|
-
if (isSvg) tags.push({
|
|
2925
|
-
tag: "link",
|
|
2926
|
-
attrs: {
|
|
2927
|
-
rel: "icon",
|
|
2928
|
-
type: "image/svg+xml",
|
|
2929
|
-
href: "/favicon.svg"
|
|
2930
|
-
},
|
|
2931
|
-
injectTo: "head"
|
|
2932
|
-
});
|
|
2933
|
-
tags.push({
|
|
2934
|
-
tag: "link",
|
|
2935
|
-
attrs: {
|
|
2936
|
-
rel: "icon",
|
|
2937
|
-
type: "image/png",
|
|
2938
|
-
sizes: "32x32",
|
|
2939
|
-
href: "/favicon-32x32.png"
|
|
2940
|
-
},
|
|
2941
|
-
injectTo: "head"
|
|
2942
|
-
}, {
|
|
2943
|
-
tag: "link",
|
|
2944
|
-
attrs: {
|
|
2945
|
-
rel: "icon",
|
|
2946
|
-
type: "image/png",
|
|
2947
|
-
sizes: "16x16",
|
|
2948
|
-
href: "/favicon-16x16.png"
|
|
2949
|
-
},
|
|
2950
|
-
injectTo: "head"
|
|
2951
|
-
}, {
|
|
2952
|
-
tag: "link",
|
|
2953
|
-
attrs: {
|
|
2954
|
-
rel: "apple-touch-icon",
|
|
2955
|
-
sizes: "180x180",
|
|
2956
|
-
href: "/apple-touch-icon.png"
|
|
2957
|
-
},
|
|
2958
|
-
injectTo: "head"
|
|
2959
|
-
});
|
|
2960
|
-
if (generateManifest) tags.push({
|
|
2961
|
-
tag: "link",
|
|
2962
|
-
attrs: {
|
|
2963
|
-
rel: "manifest",
|
|
2964
|
-
href: "/site.webmanifest"
|
|
2965
|
-
},
|
|
2966
|
-
injectTo: "head"
|
|
2967
|
-
});
|
|
2968
|
-
tags.push({
|
|
2969
|
-
tag: "meta",
|
|
2970
|
-
attrs: {
|
|
2971
|
-
name: "theme-color",
|
|
2972
|
-
content: themeColor
|
|
2973
|
-
},
|
|
2974
|
-
injectTo: "head"
|
|
2975
|
-
});
|
|
2976
|
-
return tags;
|
|
2977
|
-
},
|
|
2978
|
-
async generateBundle() {
|
|
2979
|
-
if (!isBuild) return;
|
|
2980
|
-
await generateFaviconSet.call(this, root, config.source, config.darkSource, "", config, themeColor, backgroundColor, generateManifest);
|
|
2981
|
-
if (config.locales) for (const [locale, localeConfig] of Object.entries(config.locales)) await generateFaviconSet.call(this, root, localeConfig.source, localeConfig.darkSource, `${locale}/`, config, themeColor, backgroundColor, generateManifest);
|
|
2982
|
-
}
|
|
2983
|
-
};
|
|
2984
|
-
}
|
|
2985
|
-
/**
|
|
2986
|
-
* Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.
|
|
2987
|
-
*/
|
|
2988
|
-
function wrapSvgWithDarkMode(lightSvg, darkSvg) {
|
|
2989
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${lightSvg.match(/viewBox="([^"]*)"/)?.[1] ?? "0 0 32 32"}">
|
|
2990
|
-
<style>
|
|
2991
|
-
:root { color-scheme: light dark; }
|
|
2992
|
-
@media (prefers-color-scheme: dark) { .light { display: none; } }
|
|
2993
|
-
@media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }
|
|
2994
|
-
</style>
|
|
2995
|
-
<g class="light">${stripSvgWrapper(lightSvg)}</g>
|
|
2996
|
-
<g class="dark">${stripSvgWrapper(darkSvg)}</g>
|
|
2997
|
-
</svg>`;
|
|
2998
|
-
}
|
|
2999
|
-
function stripSvgWrapper(svg) {
|
|
3000
|
-
return svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
|
|
3001
|
-
}
|
|
3002
|
-
/**
|
|
3003
|
-
* Resolve the source path for a locale-prefixed favicon URL.
|
|
3004
|
-
* Returns null if the URL is not locale-prefixed or locale has no override.
|
|
3005
|
-
*/
|
|
3006
|
-
function resolveLocaleSource(url, config, rootDir) {
|
|
3007
|
-
if (!config.locales) return null;
|
|
3008
|
-
for (const [locale, localeConfig] of Object.entries(config.locales)) {
|
|
3009
|
-
const prefix = `/${locale}/`;
|
|
3010
|
-
if (url.startsWith(prefix)) return {
|
|
3011
|
-
locale,
|
|
3012
|
-
url,
|
|
3013
|
-
source: localeConfig.source,
|
|
3014
|
-
sourcePath: join(rootDir, localeConfig.source)
|
|
3015
|
-
};
|
|
3016
|
-
}
|
|
3017
|
-
return null;
|
|
3018
|
-
}
|
|
3019
|
-
/**
|
|
3020
|
-
* Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.
|
|
3021
|
-
* Called once for base (prefix = '') and once per locale (prefix = '{locale}/').
|
|
3022
|
-
*/
|
|
3023
|
-
async function generateFaviconSet(rootDir, source, darkSource, prefix, config, themeColor, backgroundColor, generateManifest) {
|
|
3024
|
-
const sourcePath = join(rootDir, source);
|
|
3025
|
-
if (!existsSync(sourcePath)) {
|
|
3026
|
-
console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
|
|
3027
|
-
return;
|
|
3028
|
-
}
|
|
3029
|
-
if (source.endsWith(".svg")) {
|
|
3030
|
-
const svgContent = await readFile(sourcePath, "utf-8");
|
|
3031
|
-
let finalSvg = svgContent;
|
|
3032
|
-
if (darkSource) {
|
|
3033
|
-
const darkPath = join(rootDir, darkSource);
|
|
3034
|
-
if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
|
|
3035
|
-
}
|
|
3036
|
-
this.emitFile({
|
|
3037
|
-
type: "asset",
|
|
3038
|
-
fileName: `${prefix}favicon.svg`,
|
|
3039
|
-
source: finalSvg
|
|
3040
|
-
});
|
|
3041
|
-
}
|
|
3042
|
-
for (const { size, name } of SIZES) {
|
|
3043
|
-
const pngBuffer = await resizeToPng(sourcePath, size);
|
|
3044
|
-
if (pngBuffer) this.emitFile({
|
|
3045
|
-
type: "asset",
|
|
3046
|
-
fileName: `${prefix}${name}`,
|
|
3047
|
-
source: pngBuffer
|
|
3048
|
-
});
|
|
3049
|
-
}
|
|
3050
|
-
const ico = await generateIco(sourcePath);
|
|
3051
|
-
if (ico) this.emitFile({
|
|
3052
|
-
type: "asset",
|
|
3053
|
-
fileName: `${prefix}favicon.ico`,
|
|
3054
|
-
source: ico
|
|
3055
|
-
});
|
|
3056
|
-
if (generateManifest) {
|
|
3057
|
-
const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : "";
|
|
3058
|
-
const manifest = {
|
|
3059
|
-
name: config.name ?? "App",
|
|
3060
|
-
short_name: config.name ?? "App",
|
|
3061
|
-
icons: [{
|
|
3062
|
-
src: `${manifestPrefix}/icon-192.png`,
|
|
3063
|
-
sizes: "192x192",
|
|
3064
|
-
type: "image/png"
|
|
3065
|
-
}, {
|
|
3066
|
-
src: `${manifestPrefix}/icon-512.png`,
|
|
3067
|
-
sizes: "512x512",
|
|
3068
|
-
type: "image/png"
|
|
3069
|
-
}],
|
|
3070
|
-
theme_color: themeColor,
|
|
3071
|
-
background_color: backgroundColor,
|
|
3072
|
-
display: "standalone"
|
|
3073
|
-
};
|
|
3074
|
-
this.emitFile({
|
|
3075
|
-
type: "asset",
|
|
3076
|
-
fileName: `${prefix}site.webmanifest`,
|
|
3077
|
-
source: JSON.stringify(manifest, null, 2)
|
|
3078
|
-
});
|
|
3079
|
-
}
|
|
3080
|
-
}
|
|
3081
|
-
/**
|
|
3082
|
-
* Get favicon link tags for a specific locale.
|
|
3083
|
-
* Returns link objects suitable for `useHead()` or direct HTML injection.
|
|
3084
|
-
*
|
|
3085
|
-
* @example
|
|
3086
|
-
* ```ts
|
|
3087
|
-
* const links = faviconLinks("de", { source: "./icon.svg", locales: { de: { source: "./icon-de.svg" } } })
|
|
3088
|
-
* // → [{ rel: "icon", type: "image/svg+xml", href: "/de/favicon.svg" }, ...]
|
|
3089
|
-
* ```
|
|
3090
|
-
*/
|
|
3091
|
-
function faviconLinks(locale, config) {
|
|
3092
|
-
const hasLocaleOverride = locale && config.locales?.[locale];
|
|
3093
|
-
const prefix = hasLocaleOverride ? `/${locale}` : "";
|
|
3094
|
-
const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
|
|
3095
|
-
const links = [];
|
|
3096
|
-
if (isSvg) links.push({
|
|
3097
|
-
rel: "icon",
|
|
3098
|
-
type: "image/svg+xml",
|
|
3099
|
-
href: `${prefix}/favicon.svg`
|
|
3100
|
-
});
|
|
3101
|
-
links.push({
|
|
3102
|
-
rel: "icon",
|
|
3103
|
-
type: "image/png",
|
|
3104
|
-
sizes: "32x32",
|
|
3105
|
-
href: `${prefix}/favicon-32x32.png`
|
|
3106
|
-
}, {
|
|
3107
|
-
rel: "icon",
|
|
3108
|
-
type: "image/png",
|
|
3109
|
-
sizes: "16x16",
|
|
3110
|
-
href: `${prefix}/favicon-16x16.png`
|
|
3111
|
-
}, {
|
|
3112
|
-
rel: "apple-touch-icon",
|
|
3113
|
-
sizes: "180x180",
|
|
3114
|
-
href: `${prefix}/apple-touch-icon.png`
|
|
3115
|
-
});
|
|
3116
|
-
if (config.manifest !== false) links.push({
|
|
3117
|
-
rel: "manifest",
|
|
3118
|
-
href: `${prefix}/site.webmanifest`
|
|
3119
|
-
});
|
|
3120
|
-
return links;
|
|
3121
|
-
}
|
|
3122
|
-
async function resizeToPng(input, size) {
|
|
3123
|
-
try {
|
|
3124
|
-
return await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, {
|
|
3125
|
-
fit: "contain",
|
|
3126
|
-
background: {
|
|
3127
|
-
r: 0,
|
|
3128
|
-
g: 0,
|
|
3129
|
-
b: 0,
|
|
3130
|
-
alpha: 0
|
|
3131
|
-
}
|
|
3132
|
-
}).png().toBuffer();
|
|
3133
|
-
} catch {
|
|
3134
|
-
warnSharpMissing$1();
|
|
3135
|
-
return null;
|
|
3136
|
-
}
|
|
3137
|
-
}
|
|
3138
|
-
async function generateIco(input) {
|
|
3139
|
-
try {
|
|
3140
|
-
const sharp = await import("sharp").then((m) => m.default ?? m);
|
|
3141
|
-
const png16 = await sharp(input).resize(16, 16, {
|
|
3142
|
-
fit: "contain",
|
|
3143
|
-
background: {
|
|
3144
|
-
r: 0,
|
|
3145
|
-
g: 0,
|
|
3146
|
-
b: 0,
|
|
3147
|
-
alpha: 0
|
|
3148
|
-
}
|
|
3149
|
-
}).png().toBuffer();
|
|
3150
|
-
const png32 = await sharp(input).resize(32, 32, {
|
|
3151
|
-
fit: "contain",
|
|
3152
|
-
background: {
|
|
3153
|
-
r: 0,
|
|
3154
|
-
g: 0,
|
|
3155
|
-
b: 0,
|
|
3156
|
-
alpha: 0
|
|
3157
|
-
}
|
|
3158
|
-
}).png().toBuffer();
|
|
3159
|
-
return createIcoFromPngs([{
|
|
3160
|
-
buffer: png16,
|
|
3161
|
-
size: 16
|
|
3162
|
-
}, {
|
|
3163
|
-
buffer: png32,
|
|
3164
|
-
size: 32
|
|
3165
|
-
}]);
|
|
3166
|
-
} catch {
|
|
3167
|
-
warnSharpMissing$1();
|
|
3168
|
-
return null;
|
|
3169
|
-
}
|
|
3170
|
-
}
|
|
3171
|
-
/** @internal Exported for testing */
|
|
3172
|
-
function createIcoFromPngs(entries) {
|
|
3173
|
-
const headerSize = 6;
|
|
3174
|
-
const dirEntrySize = 16;
|
|
3175
|
-
const dirSize = dirEntrySize * entries.length;
|
|
3176
|
-
let dataOffset = headerSize + dirSize;
|
|
3177
|
-
const header = Buffer.alloc(headerSize);
|
|
3178
|
-
header.writeUInt16LE(0, 0);
|
|
3179
|
-
header.writeUInt16LE(1, 2);
|
|
3180
|
-
header.writeUInt16LE(entries.length, 4);
|
|
3181
|
-
const dirEntries = Buffer.alloc(dirSize);
|
|
3182
|
-
const dataBuffers = [];
|
|
3183
|
-
for (let i = 0; i < entries.length; i++) {
|
|
3184
|
-
const entry = entries[i];
|
|
3185
|
-
const offset = i * dirEntrySize;
|
|
3186
|
-
dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset);
|
|
3187
|
-
dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1);
|
|
3188
|
-
dirEntries.writeUInt8(0, offset + 2);
|
|
3189
|
-
dirEntries.writeUInt8(0, offset + 3);
|
|
3190
|
-
dirEntries.writeUInt16LE(1, offset + 4);
|
|
3191
|
-
dirEntries.writeUInt16LE(32, offset + 6);
|
|
3192
|
-
dirEntries.writeUInt32LE(entry.buffer.length, offset + 8);
|
|
3193
|
-
dirEntries.writeUInt32LE(dataOffset, offset + 12);
|
|
3194
|
-
dataOffset += entry.buffer.length;
|
|
3195
|
-
dataBuffers.push(entry.buffer);
|
|
3196
|
-
}
|
|
3197
|
-
return Buffer.concat([
|
|
3198
|
-
header,
|
|
3199
|
-
dirEntries,
|
|
3200
|
-
...dataBuffers
|
|
3201
|
-
]);
|
|
3202
|
-
}
|
|
3203
|
-
|
|
3204
|
-
//#endregion
|
|
3205
|
-
//#region src/og-image.ts
|
|
3206
|
-
/**
|
|
3207
|
-
* OG Image generation plugin.
|
|
3208
|
-
*
|
|
3209
|
-
* Generates Open Graph images at build time from templates with
|
|
3210
|
-
* text overlays. Supports locale-specific text for i18n apps.
|
|
3211
|
-
* Uses sharp for image processing (same optional dep as favicon/image plugins).
|
|
3212
|
-
*
|
|
3213
|
-
* @example
|
|
3214
|
-
* ```ts
|
|
3215
|
-
* // vite.config.ts
|
|
3216
|
-
* import { ogImagePlugin } from "@pyreon/zero/og-image"
|
|
3217
|
-
*
|
|
3218
|
-
* export default {
|
|
3219
|
-
* plugins: [
|
|
3220
|
-
* ogImagePlugin({
|
|
3221
|
-
* locales: ["en", "de", "cs"],
|
|
3222
|
-
* templates: [{
|
|
3223
|
-
* name: "default",
|
|
3224
|
-
* background: "./src/assets/og-bg.jpg",
|
|
3225
|
-
* layers: [{
|
|
3226
|
-
* text: { en: "Build faster", de: "Schneller bauen", cs: "Stavte rychleji" },
|
|
3227
|
-
* y: "40%",
|
|
3228
|
-
* fontSize: 72,
|
|
3229
|
-
* }],
|
|
3230
|
-
* }],
|
|
3231
|
-
* }),
|
|
3232
|
-
* ],
|
|
3233
|
-
* }
|
|
3234
|
-
* ```
|
|
3235
|
-
*/
|
|
3236
|
-
let sharpWarned = false;
|
|
3237
|
-
function warnSharpMissing() {
|
|
3238
|
-
if (sharpWarned) return;
|
|
3239
|
-
sharpWarned = true;
|
|
3240
|
-
console.warn("\n[zero:og-image] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n");
|
|
3241
|
-
}
|
|
3242
|
-
function resolvePosition(value, dimension, fallback = "50%") {
|
|
3243
|
-
if (value === void 0) value = fallback;
|
|
3244
|
-
if (typeof value === "number") return value;
|
|
3245
|
-
if (value.endsWith("%")) return Math.round(Number.parseFloat(value) / 100 * dimension);
|
|
3246
|
-
return Number.parseInt(value, 10) || 0;
|
|
3247
|
-
}
|
|
3248
|
-
function resolveLayerText(layer, locale) {
|
|
3249
|
-
if (typeof layer.text === "string") return layer.text;
|
|
3250
|
-
if (typeof layer.text === "function") return layer.text(locale);
|
|
3251
|
-
return layer.text[locale] ?? layer.text[Object.keys(layer.text)[0] ?? ""] ?? "";
|
|
3252
|
-
}
|
|
3253
|
-
function escapeXml(str) {
|
|
3254
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3255
|
-
}
|
|
3256
|
-
/**
|
|
3257
|
-
* Build an SVG overlay with text layers.
|
|
3258
|
-
* @internal Exported for testing.
|
|
3259
|
-
*/
|
|
3260
|
-
function buildTextOverlaySvg(layers, width, height, locale) {
|
|
3261
|
-
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${layers.map((layer) => {
|
|
3262
|
-
const text = resolveLayerText(layer, locale);
|
|
3263
|
-
const x = resolvePosition(layer.x, width, "50%");
|
|
3264
|
-
const y = resolvePosition(layer.y, height, "50%");
|
|
3265
|
-
const fontSize = layer.fontSize ?? 64;
|
|
3266
|
-
const fontFamily = layer.fontFamily ?? "sans-serif";
|
|
3267
|
-
const fontWeight = layer.fontWeight ?? "bold";
|
|
3268
|
-
const color = layer.color ?? "#ffffff";
|
|
3269
|
-
const anchor = layer.textAnchor ?? "middle";
|
|
3270
|
-
const maxWidth = layer.maxWidth ?? Math.round(width * .8);
|
|
3271
|
-
const words = text.split(" ");
|
|
3272
|
-
const lines = [];
|
|
3273
|
-
let currentLine = "";
|
|
3274
|
-
const estimateWidth = (s) => {
|
|
3275
|
-
let width = 0;
|
|
3276
|
-
for (let i = 0; i < s.length; i++) {
|
|
3277
|
-
const code = s.charCodeAt(i);
|
|
3278
|
-
if (code >= 12288 && code <= 40959) width += fontSize * 1;
|
|
3279
|
-
else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) width += fontSize * .35;
|
|
3280
|
-
else width += fontSize * .55;
|
|
3281
|
-
}
|
|
3282
|
-
return width;
|
|
3283
|
-
};
|
|
3284
|
-
for (const word of words) {
|
|
3285
|
-
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
3286
|
-
if (estimateWidth(testLine) > maxWidth && currentLine) {
|
|
3287
|
-
lines.push(currentLine);
|
|
3288
|
-
currentLine = word;
|
|
3289
|
-
} else currentLine = testLine;
|
|
3290
|
-
}
|
|
3291
|
-
if (currentLine) lines.push(currentLine);
|
|
3292
|
-
const tspans = lines.map((line, i) => {
|
|
3293
|
-
return `<tspan x="${x}" dy="${i === 0 ? "0" : `${fontSize * 1.2}`}">${escapeXml(line)}</tspan>`;
|
|
3294
|
-
}).join("");
|
|
3295
|
-
return `<text x="${x}" y="${y}" font-size="${fontSize}" font-family="${escapeXml(fontFamily)}" font-weight="${fontWeight}" fill="${color}" text-anchor="${anchor}" dominant-baseline="middle">${tspans}</text>`;
|
|
3296
|
-
}).join("")}</svg>`;
|
|
3297
|
-
}
|
|
3298
|
-
/**
|
|
3299
|
-
* Render an OG image from a template for a specific locale.
|
|
3300
|
-
* @internal Exported for testing.
|
|
3301
|
-
*/
|
|
3302
|
-
async function renderOgImage(template, locale, rootDir) {
|
|
3303
|
-
try {
|
|
3304
|
-
const sharp = await import("sharp").then((m) => m.default ?? m);
|
|
3305
|
-
const width = template.width ?? 1200;
|
|
3306
|
-
const height = template.height ?? 630;
|
|
3307
|
-
let pipeline;
|
|
3308
|
-
if (typeof template.background === "string") pipeline = sharp(join(rootDir, template.background)).resize(width, height, { fit: "cover" });
|
|
3309
|
-
else pipeline = sharp({ create: {
|
|
3310
|
-
width,
|
|
3311
|
-
height,
|
|
3312
|
-
channels: 4,
|
|
3313
|
-
background: template.background.color
|
|
3314
|
-
} });
|
|
3315
|
-
if (template.layers && template.layers.length > 0) {
|
|
3316
|
-
const svgOverlay = buildTextOverlaySvg(template.layers, width, height, locale);
|
|
3317
|
-
pipeline = pipeline.composite([{
|
|
3318
|
-
input: Buffer.from(svgOverlay),
|
|
3319
|
-
top: 0,
|
|
3320
|
-
left: 0
|
|
3321
|
-
}]);
|
|
3322
|
-
}
|
|
3323
|
-
if (template.format === "jpeg") return await pipeline.jpeg({ quality: template.quality ?? 90 }).toBuffer();
|
|
3324
|
-
return await pipeline.png().toBuffer();
|
|
3325
|
-
} catch {
|
|
3326
|
-
warnSharpMissing();
|
|
3327
|
-
return null;
|
|
3328
|
-
}
|
|
3329
|
-
}
|
|
3330
|
-
/**
|
|
3331
|
-
* Compute the OG image path for a template and locale.
|
|
3332
|
-
*
|
|
3333
|
-
* @example
|
|
3334
|
-
* ```ts
|
|
3335
|
-
* ogImagePath("default", "de") // → "/og/default-de.png"
|
|
3336
|
-
* ogImagePath("default") // → "/og/default.png"
|
|
3337
|
-
* ogImagePath("hero", "en", "images") // → "/images/hero-en.png"
|
|
3338
|
-
* ```
|
|
3339
|
-
*/
|
|
3340
|
-
function ogImagePath(templateName, locale, outDir = "og", format = "png") {
|
|
3341
|
-
const ext = format === "jpeg" ? "jpg" : "png";
|
|
3342
|
-
return `/${outDir}/${templateName}${locale ? `-${locale}` : ""}.${ext}`;
|
|
3343
|
-
}
|
|
3344
|
-
/**
|
|
3345
|
-
* OG image generation Vite plugin.
|
|
3346
|
-
*
|
|
3347
|
-
* Generates Open Graph images at build time. In dev, generates on-demand.
|
|
3348
|
-
* Requires `sharp` as an optional dependency.
|
|
3349
|
-
*
|
|
3350
|
-
* @example
|
|
3351
|
-
* ```ts
|
|
3352
|
-
* // vite.config.ts
|
|
3353
|
-
* import { ogImagePlugin } from "@pyreon/zero/og-image"
|
|
3354
|
-
*
|
|
3355
|
-
* export default {
|
|
3356
|
-
* plugins: [
|
|
3357
|
-
* ogImagePlugin({
|
|
3358
|
-
* locales: ["en", "de"],
|
|
3359
|
-
* templates: [{
|
|
3360
|
-
* name: "default",
|
|
3361
|
-
* background: { color: "#0066ff" },
|
|
3362
|
-
* layers: [{ text: { en: "Hello", de: "Hallo" }, fontSize: 72 }],
|
|
3363
|
-
* }],
|
|
3364
|
-
* }),
|
|
3365
|
-
* ],
|
|
3366
|
-
* }
|
|
3367
|
-
* ```
|
|
3368
|
-
*/
|
|
3369
|
-
function ogImagePlugin(config) {
|
|
3370
|
-
const outDir = config.outDir ?? "og";
|
|
3371
|
-
let root = "";
|
|
3372
|
-
let isBuild = false;
|
|
3373
|
-
return {
|
|
3374
|
-
name: "pyreon-zero-og-image",
|
|
3375
|
-
enforce: "pre",
|
|
3376
|
-
configResolved(resolvedConfig) {
|
|
3377
|
-
root = resolvedConfig.root;
|
|
3378
|
-
isBuild = resolvedConfig.command === "build";
|
|
3379
|
-
},
|
|
3380
|
-
configureServer(server) {
|
|
3381
|
-
const devCache = /* @__PURE__ */ new Map();
|
|
3382
|
-
server.middlewares.use(async (req, res, next) => {
|
|
3383
|
-
const url = req.url ?? "";
|
|
3384
|
-
if (!url.startsWith(`/${outDir}/`)) return next();
|
|
3385
|
-
const match = url.slice(outDir.length + 2).match(/^(.+?)(?:-([a-z]{2,5}))?\.(png|jpe?g)$/);
|
|
3386
|
-
if (!match) return next();
|
|
3387
|
-
const [, templateName, locale, ext] = match;
|
|
3388
|
-
const template = config.templates.find((t) => t.name === templateName);
|
|
3389
|
-
if (!template) return next();
|
|
3390
|
-
const resolvedLocale = locale ?? config.locales?.[0] ?? "en";
|
|
3391
|
-
const cacheKey = `${templateName}:${resolvedLocale}`;
|
|
3392
|
-
let buffer = devCache.get(cacheKey);
|
|
3393
|
-
if (!buffer) {
|
|
3394
|
-
const result = await renderOgImage(template, resolvedLocale, root);
|
|
3395
|
-
if (!result) return next();
|
|
3396
|
-
buffer = result;
|
|
3397
|
-
devCache.set(cacheKey, result);
|
|
3398
|
-
}
|
|
3399
|
-
const contentType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
|
|
3400
|
-
res.setHeader("Content-Type", contentType);
|
|
3401
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
3402
|
-
res.end(Buffer.from(buffer));
|
|
3403
|
-
});
|
|
3404
|
-
},
|
|
3405
|
-
async generateBundle() {
|
|
3406
|
-
if (!isBuild) return;
|
|
3407
|
-
for (const template of config.templates) {
|
|
3408
|
-
const locales = config.locales ?? [void 0];
|
|
3409
|
-
const ext = (template.format ?? "png") === "jpeg" ? "jpg" : "png";
|
|
3410
|
-
for (const locale of locales) {
|
|
3411
|
-
if (typeof template.background === "string") {
|
|
3412
|
-
const bgPath = join(root, template.background);
|
|
3413
|
-
if (!existsSync(bgPath)) {
|
|
3414
|
-
console.warn(`[zero:og-image] Background not found: ${bgPath}`);
|
|
3415
|
-
continue;
|
|
3416
|
-
}
|
|
3417
|
-
}
|
|
3418
|
-
const buffer = await renderOgImage(template, locale ?? "en", root);
|
|
3419
|
-
if (!buffer) continue;
|
|
3420
|
-
const suffix = locale ? `-${locale}` : "";
|
|
3421
|
-
this.emitFile({
|
|
3422
|
-
type: "asset",
|
|
3423
|
-
fileName: `${outDir}/${template.name}${suffix}.${ext}`,
|
|
3424
|
-
source: buffer
|
|
3425
|
-
});
|
|
3426
|
-
}
|
|
3427
|
-
}
|
|
3428
|
-
}
|
|
3429
|
-
};
|
|
3430
|
-
}
|
|
3431
|
-
|
|
3432
|
-
//#endregion
|
|
3433
|
-
//#region src/i18n-routing.ts
|
|
3434
|
-
/**
|
|
3435
|
-
* Detect preferred locale from Accept-Language header.
|
|
3436
|
-
*/
|
|
3437
|
-
function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
|
|
3438
|
-
if (!acceptLanguage) return defaultLocale;
|
|
3439
|
-
const preferred = acceptLanguage.split(",").map((part) => {
|
|
3440
|
-
const [lang, q] = part.trim().split(";q=");
|
|
3441
|
-
return {
|
|
3442
|
-
lang: lang?.split("-")[0]?.toLowerCase() ?? "",
|
|
3443
|
-
quality: q ? Number.parseFloat(q) : 1
|
|
3444
|
-
};
|
|
3445
|
-
}).sort((a, b) => b.quality - a.quality);
|
|
3446
|
-
for (const { lang } of preferred) if (locales.includes(lang)) return lang;
|
|
3447
|
-
return defaultLocale;
|
|
3448
|
-
}
|
|
3449
|
-
/**
|
|
3450
|
-
* Extract locale from a URL path.
|
|
3451
|
-
* Returns { locale, pathWithoutLocale }.
|
|
3452
|
-
*/
|
|
3453
|
-
function extractLocaleFromPath(path, locales, defaultLocale) {
|
|
3454
|
-
const segments = path.split("/").filter(Boolean);
|
|
3455
|
-
const firstSegment = segments[0]?.toLowerCase();
|
|
3456
|
-
if (firstSegment && locales.includes(firstSegment)) return {
|
|
3457
|
-
locale: firstSegment,
|
|
3458
|
-
pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
|
|
3459
|
-
};
|
|
3460
|
-
return {
|
|
3461
|
-
locale: defaultLocale,
|
|
3462
|
-
pathWithoutLocale: path
|
|
3463
|
-
};
|
|
3464
|
-
}
|
|
3465
|
-
/**
|
|
3466
|
-
* Build a localized path.
|
|
3467
|
-
*/
|
|
3468
|
-
function buildLocalePath(path, locale, defaultLocale, strategy) {
|
|
3469
|
-
const clean = path === "/" ? "" : path;
|
|
3470
|
-
if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
|
|
3471
|
-
return `/${locale}${clean}`;
|
|
3472
|
-
}
|
|
3473
|
-
/**
|
|
3474
|
-
* Create a LocaleContext for use in components and loaders.
|
|
3475
|
-
*/
|
|
3476
|
-
function createLocaleContext(locale, path, config) {
|
|
3477
|
-
const strategy = config.strategy ?? "prefix-except-default";
|
|
3478
|
-
return {
|
|
3479
|
-
locale,
|
|
3480
|
-
locales: config.locales,
|
|
3481
|
-
defaultLocale: config.defaultLocale,
|
|
3482
|
-
localePath(targetPath, targetLocale) {
|
|
3483
|
-
return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
|
|
3484
|
-
},
|
|
3485
|
-
alternates() {
|
|
3486
|
-
const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
|
|
3487
|
-
return config.locales.map((loc) => ({
|
|
3488
|
-
locale: loc,
|
|
3489
|
-
url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
|
|
3490
|
-
}));
|
|
3491
|
-
}
|
|
3492
|
-
};
|
|
3493
|
-
}
|
|
3494
|
-
/**
|
|
3495
|
-
* I18n routing middleware for Zero's server.
|
|
3496
|
-
*
|
|
3497
|
-
* - Detects locale from URL prefix or Accept-Language header
|
|
3498
|
-
* - Redirects root to preferred locale (when detectLocale is true)
|
|
3499
|
-
* - Sets locale context for loaders and components
|
|
3500
|
-
*
|
|
3501
|
-
* @example
|
|
3502
|
-
* ```ts
|
|
3503
|
-
* // zero.config.ts
|
|
3504
|
-
* import { i18nRouting } from "@pyreon/zero"
|
|
3505
|
-
*
|
|
3506
|
-
* export default defineConfig({
|
|
3507
|
-
* plugins: [
|
|
3508
|
-
* i18nRouting({
|
|
3509
|
-
* locales: ["en", "de", "cs"],
|
|
3510
|
-
* defaultLocale: "en",
|
|
3511
|
-
* }),
|
|
3512
|
-
* ],
|
|
3513
|
-
* })
|
|
3514
|
-
* ```
|
|
3515
|
-
*/
|
|
3516
|
-
function i18nRouting(config) {
|
|
3517
|
-
const strategy = config.strategy ?? "prefix-except-default";
|
|
3518
|
-
const detectEnabled = config.detectLocale !== false;
|
|
3519
|
-
const cookieName = config.cookieName ?? "locale";
|
|
3520
|
-
return {
|
|
3521
|
-
name: "pyreon-zero-i18n-routing",
|
|
3522
|
-
configResolved() {},
|
|
3523
|
-
configureServer(server) {
|
|
3524
|
-
server.middlewares.use((req, res, next) => {
|
|
3525
|
-
const url = req.url ?? "/";
|
|
3526
|
-
if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
|
|
3527
|
-
const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
|
|
3528
|
-
if (detectEnabled && url === "/") {
|
|
3529
|
-
const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
|
|
3530
|
-
const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
|
|
3531
|
-
const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
|
|
3532
|
-
if (strategy === "prefix" || preferred !== config.defaultLocale) {
|
|
3533
|
-
res.writeHead(302, { Location: `/${preferred}/` });
|
|
3534
|
-
res.end();
|
|
3535
|
-
return;
|
|
3536
|
-
}
|
|
3537
|
-
}
|
|
3538
|
-
req.__locale = locale;
|
|
3539
|
-
req.__localeContext = createLocaleContext(locale, url, config);
|
|
3540
|
-
localeSignal.set(locale);
|
|
3541
|
-
next();
|
|
3542
|
-
});
|
|
3543
|
-
}
|
|
3544
|
-
};
|
|
3545
|
-
}
|
|
3546
|
-
function parseCookies(header) {
|
|
3547
|
-
if (!header) return {};
|
|
3548
|
-
const result = {};
|
|
3549
|
-
for (const pair of header.split(";")) {
|
|
3550
|
-
const [key, value] = pair.trim().split("=");
|
|
3551
|
-
if (key && value) result[key] = decodeURIComponent(value);
|
|
3552
|
-
}
|
|
3553
|
-
return result;
|
|
3554
|
-
}
|
|
3555
|
-
/** @internal Context for the current locale. */
|
|
3556
|
-
const LocaleCtx = createContext("en");
|
|
3557
|
-
/** Current locale signal — set by the server middleware or client-side detection. */
|
|
3558
|
-
const localeSignal = signal("en");
|
|
3559
|
-
/**
|
|
3560
|
-
* Read the current locale reactively.
|
|
3561
|
-
*
|
|
3562
|
-
* Returns the locale signal value directly — reactive in both SSR and CSR.
|
|
3563
|
-
* The server middleware sets `localeSignal` per-request, and client-side
|
|
3564
|
-
* `setLocale()` updates it as well.
|
|
3565
|
-
*
|
|
3566
|
-
* @example
|
|
3567
|
-
* ```tsx
|
|
3568
|
-
* const locale = useLocale() // "en", "de", etc.
|
|
3569
|
-
* ```
|
|
3570
|
-
*/
|
|
3571
|
-
function useLocale() {
|
|
3572
|
-
return localeSignal();
|
|
3573
|
-
}
|
|
3574
|
-
/**
|
|
3575
|
-
* Set the locale client-side and update the URL.
|
|
3576
|
-
*
|
|
3577
|
-
* @example
|
|
3578
|
-
* ```tsx
|
|
3579
|
-
* <button onClick={() => setLocale('de')}>Deutsch</button>
|
|
3580
|
-
* ```
|
|
3581
|
-
*/
|
|
3582
|
-
function setLocale(locale, config) {
|
|
3583
|
-
localeSignal.set(locale);
|
|
3584
|
-
if (typeof document !== "undefined") document.cookie = `${config.cookieName ?? "locale"}=${locale}; path=/; max-age=31536000`;
|
|
3585
|
-
if (typeof window !== "undefined") {
|
|
3586
|
-
const strategy = config.strategy ?? "prefix-except-default";
|
|
3587
|
-
const { pathWithoutLocale } = extractLocaleFromPath(window.location.pathname, config.locales, config.defaultLocale);
|
|
3588
|
-
const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy);
|
|
3589
|
-
window.history.pushState(null, "", newPath);
|
|
3590
|
-
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
3591
|
-
}
|
|
3592
|
-
}
|
|
3593
|
-
|
|
3594
|
-
//#endregion
|
|
3595
|
-
//#region src/meta.tsx
|
|
3596
|
-
const resolveStr = (v) => typeof v === "function" ? v() : v;
|
|
3597
|
-
/**
|
|
3598
|
-
* Declarative meta component for SSR-compatible page metadata.
|
|
3599
|
-
*
|
|
3600
|
-
* Supports reactive title/description — when passed as `() => string` accessors,
|
|
3601
|
-
* they are forwarded to `useHead()` as a reactive getter so updates propagate
|
|
3602
|
-
* automatically via signal tracking.
|
|
3603
|
-
*
|
|
3604
|
-
* @example
|
|
3605
|
-
* ```tsx
|
|
3606
|
-
* <Meta title="My Page" description="..." image="/og.jpg" canonical="https://..." />
|
|
3607
|
-
* ```
|
|
3608
|
-
*
|
|
3609
|
-
* @example Reactive title
|
|
3610
|
-
* ```tsx
|
|
3611
|
-
* const count = signal(0)
|
|
3612
|
-
* <Meta title={() => `${count()} items`} />
|
|
3613
|
-
* ```
|
|
3614
|
-
*/
|
|
3615
|
-
function Meta(props) {
|
|
3616
|
-
const hasReactiveTitle = typeof props.title === "function";
|
|
3617
|
-
const hasReactiveDescription = typeof props.description === "function";
|
|
3618
|
-
if (hasReactiveTitle || hasReactiveDescription) useHead(() => {
|
|
3619
|
-
const title = resolveStr(props.title);
|
|
3620
|
-
const description = resolveStr(props.description);
|
|
3621
|
-
const tags = buildMetaTags({
|
|
3622
|
-
...props,
|
|
3623
|
-
title,
|
|
3624
|
-
description
|
|
3625
|
-
});
|
|
3626
|
-
const input = {
|
|
3627
|
-
meta: tags.meta,
|
|
3628
|
-
link: tags.link,
|
|
3629
|
-
script: tags.script
|
|
3630
|
-
};
|
|
3631
|
-
if (title) input.title = title;
|
|
3632
|
-
return input;
|
|
3633
|
-
});
|
|
3634
|
-
else {
|
|
3635
|
-
const title = resolveStr(props.title);
|
|
3636
|
-
const description = resolveStr(props.description);
|
|
3637
|
-
const tags = buildMetaTags({
|
|
3638
|
-
...props,
|
|
3639
|
-
title,
|
|
3640
|
-
description
|
|
3641
|
-
});
|
|
3642
|
-
const input = {
|
|
3643
|
-
meta: tags.meta,
|
|
3644
|
-
link: tags.link,
|
|
3645
|
-
script: tags.script
|
|
3646
|
-
};
|
|
3647
|
-
if (title) input.title = title;
|
|
3648
|
-
useHead(input);
|
|
3649
|
-
}
|
|
3650
|
-
return props.children ?? null;
|
|
3651
|
-
}
|
|
3652
|
-
function buildMetaTags(props) {
|
|
3653
|
-
const meta = [];
|
|
3654
|
-
const link = [];
|
|
3655
|
-
const script = [];
|
|
3656
|
-
const { title, description, canonical, imageAlt, imageWidth, imageHeight, type = "website", siteName, twitterCard = "summary_large_image", twitterSite, twitterCreator, locale = "en_US", alternateLocales, publishedTime, modifiedTime, author, tags, jsonLd, extra, video, videoWidth, videoHeight, audio, favicon, ogTemplate, ogImageDir, ogImageFormat } = props;
|
|
3657
|
-
const robots = props.noIndex ? "noindex, nofollow" : props.robots ?? "index, follow";
|
|
3658
|
-
const image = props.image ?? (ogTemplate ? ogImagePath(ogTemplate, locale !== "en_US" ? locale : void 0, ogImageDir, ogImageFormat) : void 0);
|
|
3659
|
-
const resolvedImageWidth = imageWidth ?? (ogTemplate && !props.image ? 1200 : void 0);
|
|
3660
|
-
const resolvedImageHeight = imageHeight ?? (ogTemplate && !props.image ? 630 : void 0);
|
|
3661
|
-
if (description) meta.push({
|
|
3662
|
-
name: "description",
|
|
3663
|
-
content: description
|
|
3664
|
-
});
|
|
3665
|
-
if (robots) meta.push({
|
|
3666
|
-
name: "robots",
|
|
3667
|
-
content: robots
|
|
3668
|
-
});
|
|
3669
|
-
if (author) meta.push({
|
|
3670
|
-
name: "author",
|
|
3671
|
-
content: author
|
|
3672
|
-
});
|
|
3673
|
-
if (title) meta.push({
|
|
3674
|
-
property: "og:title",
|
|
3675
|
-
content: title
|
|
3676
|
-
});
|
|
3677
|
-
if (description) meta.push({
|
|
3678
|
-
property: "og:description",
|
|
3679
|
-
content: description
|
|
3680
|
-
});
|
|
3681
|
-
if (canonical) meta.push({
|
|
3682
|
-
property: "og:url",
|
|
3683
|
-
content: canonical
|
|
3684
|
-
});
|
|
3685
|
-
if (image) meta.push({
|
|
3686
|
-
property: "og:image",
|
|
3687
|
-
content: image
|
|
3688
|
-
});
|
|
3689
|
-
if (imageAlt) meta.push({
|
|
3690
|
-
property: "og:image:alt",
|
|
3691
|
-
content: imageAlt
|
|
3692
|
-
});
|
|
3693
|
-
if (resolvedImageWidth) meta.push({
|
|
3694
|
-
property: "og:image:width",
|
|
3695
|
-
content: String(resolvedImageWidth)
|
|
3696
|
-
});
|
|
3697
|
-
if (resolvedImageHeight) meta.push({
|
|
3698
|
-
property: "og:image:height",
|
|
3699
|
-
content: String(resolvedImageHeight)
|
|
3700
|
-
});
|
|
3701
|
-
meta.push({
|
|
3702
|
-
property: "og:type",
|
|
3703
|
-
content: type
|
|
3704
|
-
});
|
|
3705
|
-
if (siteName) meta.push({
|
|
3706
|
-
property: "og:site_name",
|
|
3707
|
-
content: siteName
|
|
3708
|
-
});
|
|
3709
|
-
meta.push({
|
|
3710
|
-
property: "og:locale",
|
|
3711
|
-
content: locale
|
|
3712
|
-
});
|
|
3713
|
-
if (video) {
|
|
3714
|
-
meta.push({
|
|
3715
|
-
property: "og:video",
|
|
3716
|
-
content: video
|
|
3717
|
-
});
|
|
3718
|
-
if (videoWidth) meta.push({
|
|
3719
|
-
property: "og:video:width",
|
|
3720
|
-
content: String(videoWidth)
|
|
3721
|
-
});
|
|
3722
|
-
if (videoHeight) meta.push({
|
|
3723
|
-
property: "og:video:height",
|
|
3724
|
-
content: String(videoHeight)
|
|
3725
|
-
});
|
|
3726
|
-
if (video.endsWith(".mp4")) meta.push({
|
|
3727
|
-
property: "og:video:type",
|
|
3728
|
-
content: "video/mp4"
|
|
3729
|
-
});
|
|
3730
|
-
else if (video.endsWith(".webm")) meta.push({
|
|
3731
|
-
property: "og:video:type",
|
|
3732
|
-
content: "video/webm"
|
|
3733
|
-
});
|
|
3734
|
-
}
|
|
3735
|
-
if (audio) meta.push({
|
|
3736
|
-
property: "og:audio",
|
|
3737
|
-
content: audio
|
|
3738
|
-
});
|
|
3739
|
-
if (type === "article") {
|
|
3740
|
-
if (publishedTime) meta.push({
|
|
3741
|
-
property: "article:published_time",
|
|
3742
|
-
content: publishedTime
|
|
3743
|
-
});
|
|
3744
|
-
if (modifiedTime) meta.push({
|
|
3745
|
-
property: "article:modified_time",
|
|
3746
|
-
content: modifiedTime
|
|
3747
|
-
});
|
|
3748
|
-
if (author) meta.push({
|
|
3749
|
-
property: "article:author",
|
|
3750
|
-
content: author
|
|
3751
|
-
});
|
|
3752
|
-
if (tags) for (const tag of tags) meta.push({
|
|
3753
|
-
property: "article:tag",
|
|
3754
|
-
content: tag
|
|
3755
|
-
});
|
|
3756
|
-
}
|
|
3757
|
-
meta.push({
|
|
3758
|
-
name: "twitter:card",
|
|
3759
|
-
content: twitterCard
|
|
3760
|
-
});
|
|
3761
|
-
if (title) meta.push({
|
|
3762
|
-
name: "twitter:title",
|
|
3763
|
-
content: title
|
|
3764
|
-
});
|
|
3765
|
-
if (description) meta.push({
|
|
3766
|
-
name: "twitter:description",
|
|
3767
|
-
content: description
|
|
3768
|
-
});
|
|
3769
|
-
if (image) meta.push({
|
|
3770
|
-
name: "twitter:image",
|
|
3771
|
-
content: image
|
|
3772
|
-
});
|
|
3773
|
-
if (imageAlt) meta.push({
|
|
3774
|
-
name: "twitter:image:alt",
|
|
3775
|
-
content: imageAlt
|
|
3776
|
-
});
|
|
3777
|
-
if (twitterSite) meta.push({
|
|
3778
|
-
name: "twitter:site",
|
|
3779
|
-
content: twitterSite
|
|
3780
|
-
});
|
|
3781
|
-
if (twitterCreator) meta.push({
|
|
3782
|
-
name: "twitter:creator",
|
|
3783
|
-
content: twitterCreator
|
|
3784
|
-
});
|
|
3785
|
-
if (canonical) link.push({
|
|
3786
|
-
rel: "canonical",
|
|
3787
|
-
href: canonical
|
|
3788
|
-
});
|
|
3789
|
-
if (alternateLocales) for (const alt of alternateLocales) link.push({
|
|
3790
|
-
rel: "alternate",
|
|
3791
|
-
hreflang: alt.locale,
|
|
3792
|
-
href: alt.url
|
|
3793
|
-
});
|
|
3794
|
-
if (jsonLd) script.push({
|
|
3795
|
-
type: "application/ld+json",
|
|
3796
|
-
children: JSON.stringify({
|
|
3797
|
-
"@context": "https://schema.org",
|
|
3798
|
-
...jsonLd
|
|
3799
|
-
})
|
|
3800
|
-
});
|
|
3801
|
-
if (extra) for (const tag of extra) meta.push(tag);
|
|
3802
|
-
if (props.i18n) {
|
|
3803
|
-
const i18nConfig = props.i18n;
|
|
3804
|
-
const origin = props.origin ?? "";
|
|
3805
|
-
const { pathWithoutLocale } = extractLocaleFromPath(canonical?.replace(origin, "") ?? "/", i18nConfig.locales, i18nConfig.defaultLocale);
|
|
3806
|
-
const strategy = i18nConfig.strategy ?? "prefix-except-default";
|
|
3807
|
-
for (const loc of i18nConfig.locales) {
|
|
3808
|
-
const localizedPath = strategy === "prefix-except-default" && loc === i18nConfig.defaultLocale ? pathWithoutLocale : `/${loc}${pathWithoutLocale === "/" ? "" : pathWithoutLocale}`;
|
|
3809
|
-
link.push({
|
|
3810
|
-
rel: "alternate",
|
|
3811
|
-
hreflang: loc,
|
|
3812
|
-
href: `${origin}${localizedPath}`
|
|
3813
|
-
});
|
|
3814
|
-
if (loc !== locale) meta.push({
|
|
3815
|
-
property: "og:locale:alternate",
|
|
3816
|
-
content: loc
|
|
3817
|
-
});
|
|
3818
|
-
}
|
|
3819
|
-
link.push({
|
|
3820
|
-
rel: "alternate",
|
|
3821
|
-
hreflang: "x-default",
|
|
3822
|
-
href: `${origin}${pathWithoutLocale}`
|
|
3823
|
-
});
|
|
3824
|
-
}
|
|
3825
|
-
if (favicon) {
|
|
3826
|
-
const faviconLocale = locale !== "en_US" ? locale : void 0;
|
|
3827
|
-
for (const fl of faviconLinks(faviconLocale, favicon)) link.push(fl);
|
|
3828
|
-
if (favicon.themeColor) meta.push({
|
|
3829
|
-
name: "theme-color",
|
|
3830
|
-
content: favicon.themeColor
|
|
3831
|
-
});
|
|
3832
|
-
}
|
|
3833
|
-
return {
|
|
3834
|
-
meta,
|
|
3835
|
-
link,
|
|
3836
|
-
script
|
|
3837
|
-
};
|
|
3838
|
-
}
|
|
3839
|
-
|
|
3840
|
-
//#endregion
|
|
3841
|
-
//#region src/csp.ts
|
|
3842
|
-
/** Client-side fallback nonce (dev server, SPA). */
|
|
3843
|
-
let _clientNonce = "";
|
|
3844
|
-
/**
|
|
3845
|
-
* Read the current CSP nonce in a component.
|
|
3846
|
-
*
|
|
3847
|
-
* SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
|
|
3848
|
-
* system — fully isolated between concurrent requests via AsyncLocalStorage.
|
|
3849
|
-
* Client/dev: falls back to module-level variable set by middleware.
|
|
3850
|
-
*
|
|
3851
|
-
* @example
|
|
3852
|
-
* ```tsx
|
|
3853
|
-
* import { useNonce } from "@pyreon/zero/csp"
|
|
3854
|
-
*
|
|
3855
|
-
* function InlineScript() {
|
|
3856
|
-
* const nonce = useNonce()
|
|
3857
|
-
* return <script nonce={nonce}>console.log("safe")<\/script>
|
|
3858
|
-
* }
|
|
3859
|
-
* ```
|
|
3860
|
-
*/
|
|
3861
|
-
function useNonce() {
|
|
3862
|
-
const locals = useRequestLocals();
|
|
3863
|
-
if (locals.cspNonce) return locals.cspNonce;
|
|
3864
|
-
return _clientNonce;
|
|
3865
|
-
}
|
|
3866
|
-
const DIRECTIVE_MAP = {
|
|
3867
|
-
defaultSrc: "default-src",
|
|
3868
|
-
scriptSrc: "script-src",
|
|
3869
|
-
styleSrc: "style-src",
|
|
3870
|
-
imgSrc: "img-src",
|
|
3871
|
-
fontSrc: "font-src",
|
|
3872
|
-
connectSrc: "connect-src",
|
|
3873
|
-
mediaSrc: "media-src",
|
|
3874
|
-
objectSrc: "object-src",
|
|
3875
|
-
frameSrc: "frame-src",
|
|
3876
|
-
childSrc: "child-src",
|
|
3877
|
-
workerSrc: "worker-src",
|
|
3878
|
-
frameAncestors: "frame-ancestors",
|
|
3879
|
-
formAction: "form-action",
|
|
3880
|
-
baseUri: "base-uri",
|
|
3881
|
-
manifestSrc: "manifest-src",
|
|
3882
|
-
reportUri: "report-uri",
|
|
3883
|
-
reportTo: "report-to"
|
|
3884
|
-
};
|
|
3885
|
-
/**
|
|
3886
|
-
* Build a CSP header string from directives.
|
|
3887
|
-
* Exported for testing.
|
|
3888
|
-
*/
|
|
3889
|
-
function buildCspHeader(directives, nonce) {
|
|
3890
|
-
const parts = [];
|
|
3891
|
-
for (const [key, cssProp] of Object.entries(DIRECTIVE_MAP)) {
|
|
3892
|
-
const value = directives[key];
|
|
3893
|
-
if (!value) continue;
|
|
3894
|
-
if (Array.isArray(value)) {
|
|
3895
|
-
const resolved = nonce ? value.map((v) => v === "'nonce'" ? `'nonce-${nonce}'` : v) : value.filter((v) => v !== "'nonce'");
|
|
3896
|
-
parts.push(`${cssProp} ${resolved.join(" ")}`);
|
|
3897
|
-
} else if (typeof value === "string") parts.push(`${cssProp} ${value}`);
|
|
3898
|
-
}
|
|
3899
|
-
if (directives.upgradeInsecureRequests) parts.push("upgrade-insecure-requests");
|
|
3900
|
-
if (directives.blockAllMixedContent) parts.push("block-all-mixed-content");
|
|
3901
|
-
return parts.join("; ");
|
|
3902
|
-
}
|
|
3903
|
-
/**
|
|
3904
|
-
* Generate a random nonce string (base64, 16 bytes).
|
|
3905
|
-
*/
|
|
3906
|
-
function generateNonce() {
|
|
3907
|
-
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
3908
|
-
const bytes = new Uint8Array(16);
|
|
3909
|
-
crypto.getRandomValues(bytes);
|
|
3910
|
-
let binary = "";
|
|
3911
|
-
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
3912
|
-
return typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64");
|
|
3913
|
-
}
|
|
3914
|
-
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
|
3915
|
-
}
|
|
3916
|
-
/**
|
|
3917
|
-
* CSP middleware — sets Content-Security-Policy header.
|
|
3918
|
-
*
|
|
3919
|
-
* When directives contain `"'nonce'"`, a fresh nonce is generated per-request
|
|
3920
|
-
* and attached to `ctx.locals.cspNonce` for use in inline script tags.
|
|
3921
|
-
*
|
|
3922
|
-
* @example
|
|
3923
|
-
* ```ts
|
|
3924
|
-
* // Apply to all routes
|
|
3925
|
-
* export default defineConfig({
|
|
3926
|
-
* middleware: [
|
|
3927
|
-
* cspMiddleware({
|
|
3928
|
-
* directives: {
|
|
3929
|
-
* defaultSrc: ["'self'"],
|
|
3930
|
-
* scriptSrc: ["'self'", "'nonce'"],
|
|
3931
|
-
* styleSrc: ["'self'", "'unsafe-inline'"],
|
|
3932
|
-
* imgSrc: ["'self'", "data:", "https:"],
|
|
3933
|
-
* },
|
|
3934
|
-
* }),
|
|
3935
|
-
* ],
|
|
3936
|
-
* })
|
|
3937
|
-
* ```
|
|
3938
|
-
*/
|
|
3939
|
-
function cspMiddleware(config) {
|
|
3940
|
-
const headerName = config.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";
|
|
3941
|
-
const staticHeader = Object.values(config.directives).some((v) => Array.isArray(v) && v.includes("'nonce'")) ? null : buildCspHeader(config.directives);
|
|
3942
|
-
return (ctx) => {
|
|
3943
|
-
if (staticHeader) {
|
|
3944
|
-
_clientNonce = "";
|
|
3945
|
-
ctx.headers.set(headerName, staticHeader);
|
|
3946
|
-
} else {
|
|
3947
|
-
const nonce = generateNonce();
|
|
3948
|
-
_clientNonce = nonce;
|
|
3949
|
-
ctx.locals.cspNonce = nonce;
|
|
3950
|
-
ctx.headers.set(headerName, buildCspHeader(config.directives, nonce));
|
|
3951
|
-
}
|
|
3952
|
-
};
|
|
3953
|
-
}
|
|
3954
|
-
|
|
3955
|
-
//#endregion
|
|
3956
|
-
//#region src/logger.ts
|
|
3957
|
-
const COLORS = {
|
|
3958
|
-
reset: "\x1B[0m",
|
|
3959
|
-
dim: "\x1B[2m",
|
|
3960
|
-
green: "\x1B[32m",
|
|
3961
|
-
yellow: "\x1B[33m",
|
|
3962
|
-
red: "\x1B[31m",
|
|
3963
|
-
cyan: "\x1B[36m",
|
|
3964
|
-
magenta: "\x1B[35m"
|
|
3965
|
-
};
|
|
3966
|
-
function methodColor(method, colors) {
|
|
3967
|
-
if (!colors) return method.padEnd(7);
|
|
3968
|
-
const padded = method.padEnd(7);
|
|
3969
|
-
switch (method) {
|
|
3970
|
-
case "GET": return `${COLORS.green}${padded}${COLORS.reset}`;
|
|
3971
|
-
case "POST": return `${COLORS.cyan}${padded}${COLORS.reset}`;
|
|
3972
|
-
case "PUT": return `${COLORS.yellow}${padded}${COLORS.reset}`;
|
|
3973
|
-
case "PATCH": return `${COLORS.yellow}${padded}${COLORS.reset}`;
|
|
3974
|
-
case "DELETE": return `${COLORS.red}${padded}${COLORS.reset}`;
|
|
3975
|
-
default: return `${COLORS.magenta}${padded}${COLORS.reset}`;
|
|
3976
|
-
}
|
|
3977
|
-
}
|
|
3978
|
-
function defaultFormat(entry, colors) {
|
|
3979
|
-
const dur = entry.duration < 1 ? "<1ms" : entry.duration < 1e3 ? `${Math.round(entry.duration)}ms` : `${(entry.duration / 1e3).toFixed(2)}s`;
|
|
3980
|
-
const dim = colors ? COLORS.dim : "";
|
|
3981
|
-
const reset = colors ? COLORS.reset : "";
|
|
3982
|
-
return ` ${methodColor(entry.method, colors)} ${entry.path} ${dim}${dur}${reset}`;
|
|
3983
|
-
}
|
|
3984
|
-
/**
|
|
3985
|
-
* Request logging middleware.
|
|
3986
|
-
*
|
|
3987
|
-
* Logs incoming requests with method, path, and duration.
|
|
3988
|
-
* Runs in middleware phase — logs timing from middleware start to
|
|
3989
|
-
* microtask completion (approximate request duration).
|
|
3990
|
-
*
|
|
3991
|
-
* @example
|
|
3992
|
-
* ```ts
|
|
3993
|
-
* // Basic usage
|
|
3994
|
-
* loggerMiddleware()
|
|
3995
|
-
*
|
|
3996
|
-
* // Custom format
|
|
3997
|
-
* loggerMiddleware({
|
|
3998
|
-
* format: (e) => `${e.method} ${e.path} (${e.duration}ms)`,
|
|
3999
|
-
* })
|
|
4000
|
-
* ```
|
|
4001
|
-
*/
|
|
4002
|
-
function loggerMiddleware(config) {
|
|
4003
|
-
if ((config?.level ?? "all") === "none") return () => {};
|
|
4004
|
-
const skip = config?.skip ?? [
|
|
4005
|
-
"/__",
|
|
4006
|
-
"/@",
|
|
4007
|
-
"/node_modules"
|
|
4008
|
-
];
|
|
4009
|
-
const isDev = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
|
|
4010
|
-
const colors = config?.colors ?? isDev;
|
|
4011
|
-
return (ctx) => {
|
|
4012
|
-
if (skip.some((p) => ctx.path.startsWith(p))) return;
|
|
4013
|
-
const start = performance.now();
|
|
4014
|
-
const entry = {
|
|
4015
|
-
method: ctx.req.method ?? "GET",
|
|
4016
|
-
path: ctx.path,
|
|
4017
|
-
duration: 0,
|
|
4018
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
4019
|
-
userAgent: ctx.req.headers.get("user-agent") ?? void 0
|
|
4020
|
-
};
|
|
4021
|
-
queueMicrotask(() => {
|
|
4022
|
-
entry.duration = performance.now() - start;
|
|
4023
|
-
if (config?.format) {
|
|
4024
|
-
const line = config.format(entry);
|
|
4025
|
-
if (line) console.log(line);
|
|
4026
|
-
} else console.log(defaultFormat(entry, colors));
|
|
4027
|
-
});
|
|
4028
|
-
};
|
|
4029
|
-
}
|
|
4030
|
-
|
|
4031
|
-
//#endregion
|
|
4032
|
-
//#region src/env.ts
|
|
4033
|
-
/**
|
|
4034
|
-
* String validator — accepts any non-empty string.
|
|
4035
|
-
*/
|
|
4036
|
-
function str(options) {
|
|
4037
|
-
return {
|
|
4038
|
-
__type: "env-validator",
|
|
4039
|
-
required: options?.default === void 0 && options?.required !== false,
|
|
4040
|
-
defaultValue: options?.default,
|
|
4041
|
-
parse(raw, key) {
|
|
4042
|
-
if (raw === void 0 || raw === "") {
|
|
4043
|
-
if (options?.default !== void 0) return options.default;
|
|
4044
|
-
throw new EnvError(key, "is required but not set", options?.description);
|
|
4045
|
-
}
|
|
4046
|
-
return raw;
|
|
4047
|
-
}
|
|
4048
|
-
};
|
|
4049
|
-
}
|
|
4050
|
-
/**
|
|
4051
|
-
* Number validator — parses to a number, rejects NaN.
|
|
4052
|
-
*/
|
|
4053
|
-
function num(options) {
|
|
4054
|
-
return {
|
|
4055
|
-
__type: "env-validator",
|
|
4056
|
-
required: options?.default === void 0 && options?.required !== false,
|
|
4057
|
-
defaultValue: options?.default,
|
|
4058
|
-
parse(raw, key) {
|
|
4059
|
-
if (raw === void 0 || raw === "") {
|
|
4060
|
-
if (options?.default !== void 0) return options.default;
|
|
4061
|
-
throw new EnvError(key, "is required but not set", options?.description);
|
|
4062
|
-
}
|
|
4063
|
-
const n = Number(raw);
|
|
4064
|
-
if (Number.isNaN(n)) throw new EnvError(key, `must be a number, got "${raw}"`, options?.description);
|
|
4065
|
-
return n;
|
|
4066
|
-
}
|
|
4067
|
-
};
|
|
4068
|
-
}
|
|
4069
|
-
/**
|
|
4070
|
-
* Boolean validator — accepts "true"/"1" as true, "false"/"0" as false.
|
|
4071
|
-
*/
|
|
4072
|
-
function bool(options) {
|
|
4073
|
-
return {
|
|
4074
|
-
__type: "env-validator",
|
|
4075
|
-
required: options?.default === void 0 && options?.required !== false,
|
|
4076
|
-
defaultValue: options?.default,
|
|
4077
|
-
parse(raw, key) {
|
|
4078
|
-
if (raw === void 0 || raw === "") {
|
|
4079
|
-
if (options?.default !== void 0) return options.default;
|
|
4080
|
-
throw new EnvError(key, "is required but not set", options?.description);
|
|
4081
|
-
}
|
|
4082
|
-
const lower = raw.toLowerCase();
|
|
4083
|
-
if (lower === "true" || lower === "1") return true;
|
|
4084
|
-
if (lower === "false" || lower === "0") return false;
|
|
4085
|
-
throw new EnvError(key, `must be "true" or "false", got "${raw}"`, options?.description);
|
|
4086
|
-
}
|
|
4087
|
-
};
|
|
4088
|
-
}
|
|
4089
|
-
/**
|
|
4090
|
-
* URL validator — validates that the value is a valid URL.
|
|
4091
|
-
*/
|
|
4092
|
-
function url(options) {
|
|
4093
|
-
return {
|
|
4094
|
-
__type: "env-validator",
|
|
4095
|
-
required: options?.default === void 0 && options?.required !== false,
|
|
4096
|
-
defaultValue: options?.default,
|
|
4097
|
-
parse(raw, key) {
|
|
4098
|
-
if (raw === void 0 || raw === "") {
|
|
4099
|
-
if (options?.default !== void 0) return options.default;
|
|
4100
|
-
throw new EnvError(key, "is required but not set", options?.description);
|
|
4101
|
-
}
|
|
4102
|
-
try {
|
|
4103
|
-
new URL(raw);
|
|
4104
|
-
return raw;
|
|
4105
|
-
} catch {
|
|
4106
|
-
throw new EnvError(key, `must be a valid URL, got "${raw}"`, options?.description);
|
|
4107
|
-
}
|
|
4108
|
-
}
|
|
4109
|
-
};
|
|
4110
|
-
}
|
|
4111
|
-
/**
|
|
4112
|
-
* Enum validator — value must be one of the allowed values.
|
|
4113
|
-
*/
|
|
4114
|
-
function oneOf(values, options) {
|
|
4115
|
-
return {
|
|
4116
|
-
__type: "env-validator",
|
|
4117
|
-
required: options?.default === void 0 && options?.required !== false,
|
|
4118
|
-
defaultValue: options?.default,
|
|
4119
|
-
parse(raw, key) {
|
|
4120
|
-
if (raw === void 0 || raw === "") {
|
|
4121
|
-
if (options?.default !== void 0) return options.default;
|
|
4122
|
-
throw new EnvError(key, "is required but not set", options?.description);
|
|
4123
|
-
}
|
|
4124
|
-
if (!values.includes(raw)) throw new EnvError(key, `must be one of [${values.join(", ")}], got "${raw}"`, options?.description);
|
|
4125
|
-
return raw;
|
|
4126
|
-
}
|
|
4127
|
-
};
|
|
4128
|
-
}
|
|
4129
|
-
var EnvError = class extends Error {
|
|
4130
|
-
constructor(key, message, description) {
|
|
4131
|
-
const desc = description ? ` (${description})` : "";
|
|
4132
|
-
super(`[zero:env] ${key}${desc}: ${message}`);
|
|
4133
|
-
this.name = "EnvError";
|
|
4134
|
-
}
|
|
4135
|
-
};
|
|
4136
|
-
function isEnvValidator(v) {
|
|
4137
|
-
return typeof v === "object" && v !== null && v.__type === "env-validator";
|
|
4138
|
-
}
|
|
4139
|
-
/**
|
|
4140
|
-
* Convert a plain schema value to an EnvValidator.
|
|
4141
|
-
*
|
|
4142
|
-
* - `3000` → num({ default: 3000 })
|
|
4143
|
-
* - `false` → bool({ default: false })
|
|
4144
|
-
* - `"localhost"` → str({ default: "localhost" })
|
|
4145
|
-
* - `String` → str() (required)
|
|
4146
|
-
* - `Number` → num() (required)
|
|
4147
|
-
* - `Boolean` → bool() (required)
|
|
4148
|
-
* - EnvValidator → pass through
|
|
4149
|
-
*/
|
|
4150
|
-
function toValidator(value) {
|
|
4151
|
-
if (isEnvValidator(value)) return value;
|
|
4152
|
-
if (value === String) return str();
|
|
4153
|
-
if (value === Number) return num();
|
|
4154
|
-
if (value === Boolean) return bool();
|
|
4155
|
-
if (typeof value === "number") return num({ default: value });
|
|
4156
|
-
if (typeof value === "boolean") return bool({ default: value });
|
|
4157
|
-
if (typeof value === "string") return str({ default: value });
|
|
4158
|
-
throw new Error(`[zero:env] Invalid schema value: ${String(value)}. Use a default value, String/Number/Boolean, or a validator like url().`);
|
|
4159
|
-
}
|
|
4160
|
-
/**
|
|
4161
|
-
* Validate environment variables.
|
|
4162
|
-
*
|
|
4163
|
-
* Schema values can be:
|
|
4164
|
-
* - **Default values**: `3000`, `false`, `"localhost"` → type inferred, used as default
|
|
4165
|
-
* - **Constructors**: `String`, `Number`, `Boolean` → required, no default
|
|
4166
|
-
* - **Validators**: `url()`, `oneOf([...])`, `str()`, `num()`, `bool()` → explicit validation
|
|
4167
|
-
* - **Custom**: `schema(raw => z.coerce.number().parse(raw))` — bridge to any schema library
|
|
4168
|
-
*
|
|
4169
|
-
* @example
|
|
4170
|
-
* ```ts
|
|
4171
|
-
* import { validateEnv, url, oneOf } from "@pyreon/zero/env"
|
|
4172
|
-
*
|
|
4173
|
-
* const env = validateEnv({
|
|
4174
|
-
* PORT: 3000, // optional, default 3000
|
|
4175
|
-
* DATABASE_URL: url(), // required, validated URL
|
|
4176
|
-
* NODE_ENV: oneOf(["dev", "prod", "test"]), // required, must be one of
|
|
4177
|
-
* API_KEY: String, // required string
|
|
4178
|
-
* DEBUG: false, // optional, default false
|
|
4179
|
-
* })
|
|
4180
|
-
* ```
|
|
4181
|
-
*/
|
|
4182
|
-
function validateEnv(schema, source) {
|
|
4183
|
-
const env = source ?? (typeof process !== "undefined" ? process.env : {});
|
|
4184
|
-
const result = {};
|
|
4185
|
-
const errors = [];
|
|
4186
|
-
for (const [key, entry] of Object.entries(schema)) {
|
|
4187
|
-
const validator = toValidator(entry);
|
|
4188
|
-
try {
|
|
4189
|
-
result[key] = validator.parse(env[key], key);
|
|
4190
|
-
} catch (e) {
|
|
4191
|
-
errors.push(e.message);
|
|
4192
|
-
}
|
|
4193
|
-
}
|
|
4194
|
-
if (errors.length > 0) {
|
|
4195
|
-
const header = `\n[zero:env] Environment validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n`;
|
|
4196
|
-
const body = errors.map((e) => ` ✗ ${e.replace("[zero:env] ", "")}`).join("\n");
|
|
4197
|
-
throw new Error(header + body + "\n");
|
|
4198
|
-
}
|
|
4199
|
-
return result;
|
|
4200
|
-
}
|
|
4201
|
-
function publicEnv(schema) {
|
|
4202
|
-
const prefix = "ZERO_PUBLIC_";
|
|
4203
|
-
const env = typeof process !== "undefined" ? process.env : {};
|
|
4204
|
-
if (!schema) {
|
|
4205
|
-
const result = {};
|
|
4206
|
-
for (const [key, value] of Object.entries(env)) if (key.startsWith(prefix) && value !== void 0) result[key.slice(12)] = value;
|
|
4207
|
-
return result;
|
|
4208
|
-
}
|
|
4209
|
-
const prefixedSource = {};
|
|
4210
|
-
for (const key of Object.keys(schema)) prefixedSource[key] = env[`${prefix}${key}`];
|
|
4211
|
-
return validateEnv(schema, prefixedSource);
|
|
4212
|
-
}
|
|
4213
|
-
/**
|
|
4214
|
-
* Create an env validator from a custom parse function.
|
|
4215
|
-
* Use this to integrate any schema library (Zod, Valibot, ArkType, etc.).
|
|
4216
|
-
*
|
|
4217
|
-
* @example
|
|
4218
|
-
* ```ts
|
|
4219
|
-
* import { z } from "zod"
|
|
4220
|
-
* import { validateEnv, schema } from "@pyreon/zero/env"
|
|
4221
|
-
*
|
|
4222
|
-
* const env = validateEnv({
|
|
4223
|
-
* PORT: schema(raw => z.coerce.number().parse(raw)),
|
|
4224
|
-
* DATABASE_URL: schema(raw => z.string().url().parse(raw)),
|
|
4225
|
-
* HOST: "localhost", // plain defaults still work alongside
|
|
4226
|
-
* })
|
|
4227
|
-
* ```
|
|
4228
|
-
*/
|
|
4229
|
-
function schema(parse) {
|
|
4230
|
-
return {
|
|
4231
|
-
__type: "env-validator",
|
|
4232
|
-
required: true,
|
|
4233
|
-
defaultValue: void 0,
|
|
4234
|
-
parse(raw, key) {
|
|
4235
|
-
if (raw === void 0 || raw === "") throw new Error(`[zero:env] ${key}: is required but not set`);
|
|
4236
|
-
try {
|
|
4237
|
-
return parse(raw);
|
|
4238
|
-
} catch (e) {
|
|
4239
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
4240
|
-
throw new Error(`[zero:env] ${key}: ${msg}`);
|
|
4241
|
-
}
|
|
4242
|
-
}
|
|
4243
|
-
};
|
|
4244
|
-
}
|
|
4245
|
-
|
|
4246
|
-
//#endregion
|
|
4247
|
-
//#region src/ai.ts
|
|
4248
|
-
/**
|
|
4249
|
-
* Generate llms.txt content from route files and config.
|
|
4250
|
-
*
|
|
4251
|
-
* Format follows the llms.txt proposal:
|
|
4252
|
-
* ```
|
|
4253
|
-
* # {name}
|
|
4254
|
-
* > {description}
|
|
4255
|
-
*
|
|
4256
|
-
* ## Pages
|
|
4257
|
-
* - [/about](/about): About page
|
|
4258
|
-
*
|
|
4259
|
-
* ## API
|
|
4260
|
-
* - GET /api/posts: List posts
|
|
4261
|
-
* ```
|
|
4262
|
-
*
|
|
4263
|
-
* @internal Exported for testing.
|
|
4264
|
-
*/
|
|
4265
|
-
function generateLlmsTxt(routeFiles, apiFiles, config) {
|
|
4266
|
-
const lines = [];
|
|
4267
|
-
lines.push(`# ${config.name}`);
|
|
4268
|
-
lines.push(`> ${config.description}`);
|
|
4269
|
-
lines.push("");
|
|
4270
|
-
const routes = parseFileRoutes(routeFiles);
|
|
4271
|
-
const pages = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && !r.isCatchAll && !r.urlPath.includes(":"));
|
|
4272
|
-
if (pages.length > 0) {
|
|
4273
|
-
lines.push("## Pages");
|
|
4274
|
-
lines.push("");
|
|
4275
|
-
for (const page of pages) {
|
|
4276
|
-
const desc = config.pageDescriptions?.[page.urlPath];
|
|
4277
|
-
const url = `${config.origin}${page.urlPath === "/" ? "" : page.urlPath}`;
|
|
4278
|
-
if (desc) lines.push(`- [${page.urlPath}](${url}): ${desc}`);
|
|
4279
|
-
else lines.push(`- [${page.urlPath}](${url})`);
|
|
4280
|
-
}
|
|
4281
|
-
lines.push("");
|
|
4282
|
-
}
|
|
4283
|
-
const dynamicRoutes = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && (r.urlPath.includes(":") || r.isCatchAll));
|
|
4284
|
-
if (dynamicRoutes.length > 0) {
|
|
4285
|
-
lines.push("## Dynamic Pages");
|
|
4286
|
-
lines.push("");
|
|
4287
|
-
for (const route of dynamicRoutes) {
|
|
4288
|
-
const desc = config.pageDescriptions?.[route.urlPath];
|
|
4289
|
-
if (desc) lines.push(`- ${route.urlPath}: ${desc}`);
|
|
4290
|
-
else lines.push(`- ${route.urlPath}`);
|
|
4291
|
-
}
|
|
4292
|
-
lines.push("");
|
|
4293
|
-
}
|
|
4294
|
-
const apiPatterns = parseApiFiles(apiFiles);
|
|
4295
|
-
if (apiPatterns.length > 0 || config.apiDescriptions) {
|
|
4296
|
-
lines.push("## API Endpoints");
|
|
4297
|
-
lines.push("");
|
|
4298
|
-
if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) lines.push(`- ${endpoint}: ${desc}`);
|
|
4299
|
-
const describedPatterns = new Set(Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/, "")));
|
|
4300
|
-
for (const pattern of apiPatterns) if (!describedPatterns.has(pattern)) lines.push(`- ${pattern}`);
|
|
4301
|
-
lines.push("");
|
|
4302
|
-
}
|
|
4303
|
-
if (config.llmsExtra) {
|
|
4304
|
-
lines.push(config.llmsExtra);
|
|
4305
|
-
lines.push("");
|
|
4306
|
-
}
|
|
4307
|
-
return lines.join("\n");
|
|
4308
|
-
}
|
|
4309
|
-
/**
|
|
4310
|
-
* Generate llms-full.txt — expanded version with more detail.
|
|
4311
|
-
* Includes all route metadata and API descriptions.
|
|
4312
|
-
*
|
|
4313
|
-
* @internal Exported for testing.
|
|
4314
|
-
*/
|
|
4315
|
-
function generateLlmsFullTxt(routeFiles, apiFiles, config) {
|
|
4316
|
-
const lines = [];
|
|
4317
|
-
lines.push(`# ${config.name} — Full Reference`);
|
|
4318
|
-
lines.push(`> ${config.description}`);
|
|
4319
|
-
lines.push("");
|
|
4320
|
-
lines.push(`Base URL: ${config.origin}`);
|
|
4321
|
-
lines.push("");
|
|
4322
|
-
const pages = parseFileRoutes(routeFiles).filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound);
|
|
4323
|
-
if (pages.length > 0) {
|
|
4324
|
-
lines.push("## All Routes");
|
|
4325
|
-
lines.push("");
|
|
4326
|
-
for (const page of pages) {
|
|
4327
|
-
const desc = config.pageDescriptions?.[page.urlPath] ?? "";
|
|
4328
|
-
const dynamic = page.urlPath.includes(":") ? " (dynamic)" : "";
|
|
4329
|
-
const catchAll = page.isCatchAll ? " (catch-all)" : "";
|
|
4330
|
-
lines.push(`### ${page.urlPath}${dynamic}${catchAll}`);
|
|
4331
|
-
if (desc) lines.push(desc);
|
|
4332
|
-
lines.push(`- File: ${page.filePath}`);
|
|
4333
|
-
lines.push(`- Render mode: ${page.renderMode}`);
|
|
4334
|
-
lines.push("");
|
|
4335
|
-
}
|
|
4336
|
-
}
|
|
4337
|
-
if (config.apiDescriptions) {
|
|
4338
|
-
lines.push("## API Reference");
|
|
4339
|
-
lines.push("");
|
|
4340
|
-
for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
4341
|
-
lines.push(`### ${endpoint}`);
|
|
4342
|
-
lines.push(desc);
|
|
4343
|
-
lines.push("");
|
|
4344
|
-
}
|
|
4345
|
-
}
|
|
4346
|
-
if (config.llmsExtra) {
|
|
4347
|
-
lines.push("## Additional Information");
|
|
4348
|
-
lines.push("");
|
|
4349
|
-
lines.push(config.llmsExtra);
|
|
4350
|
-
lines.push("");
|
|
4351
|
-
}
|
|
4352
|
-
return lines.join("\n");
|
|
4353
|
-
}
|
|
4354
|
-
/**
|
|
4355
|
-
* Auto-infer JSON-LD structured data from page metadata.
|
|
4356
|
-
*
|
|
4357
|
-
* Returns an array of JSON-LD objects (multiple schemas can apply to one page).
|
|
4358
|
-
* For example, an article page gets both `Article` and `BreadcrumbList`.
|
|
4359
|
-
*
|
|
4360
|
-
* @example
|
|
4361
|
-
* ```tsx
|
|
4362
|
-
* const schemas = inferJsonLd({
|
|
4363
|
-
* url: "https://example.com/blog/my-post",
|
|
4364
|
-
* title: "My Post",
|
|
4365
|
-
* description: "A great article",
|
|
4366
|
-
* type: "article",
|
|
4367
|
-
* author: "Vit Bokisch",
|
|
4368
|
-
* publishedTime: "2026-03-31",
|
|
4369
|
-
* })
|
|
4370
|
-
* // → [Article schema, BreadcrumbList schema]
|
|
4371
|
-
* ```
|
|
4372
|
-
*/
|
|
4373
|
-
function inferJsonLd(options) {
|
|
4374
|
-
const schemas = [];
|
|
4375
|
-
if (options.type === "article") {
|
|
4376
|
-
const article = {
|
|
4377
|
-
"@context": "https://schema.org",
|
|
4378
|
-
"@type": "Article",
|
|
4379
|
-
headline: options.title,
|
|
4380
|
-
url: options.url
|
|
4381
|
-
};
|
|
4382
|
-
if (options.description) article.description = options.description;
|
|
4383
|
-
if (options.image) article.image = options.image;
|
|
4384
|
-
if (options.publishedTime) article.datePublished = options.publishedTime;
|
|
4385
|
-
if (options.author) article.author = {
|
|
4386
|
-
"@type": "Person",
|
|
4387
|
-
name: options.author
|
|
4388
|
-
};
|
|
4389
|
-
if (options.tags && options.tags.length > 0) article.keywords = options.tags.join(", ");
|
|
4390
|
-
if (options.siteName) article.publisher = {
|
|
4391
|
-
"@type": "Organization",
|
|
4392
|
-
name: options.siteName
|
|
4393
|
-
};
|
|
4394
|
-
schemas.push(article);
|
|
4395
|
-
} else if (options.type === "product") {
|
|
4396
|
-
const product = {
|
|
4397
|
-
"@context": "https://schema.org",
|
|
4398
|
-
"@type": "Product",
|
|
4399
|
-
name: options.title,
|
|
4400
|
-
url: options.url
|
|
4401
|
-
};
|
|
4402
|
-
if (options.description) product.description = options.description;
|
|
4403
|
-
if (options.image) product.image = options.image;
|
|
4404
|
-
schemas.push(product);
|
|
4405
|
-
} else {
|
|
4406
|
-
const webpage = {
|
|
4407
|
-
"@context": "https://schema.org",
|
|
4408
|
-
"@type": "WebPage",
|
|
4409
|
-
name: options.title,
|
|
4410
|
-
url: options.url
|
|
4411
|
-
};
|
|
4412
|
-
if (options.description) webpage.description = options.description;
|
|
4413
|
-
if (options.image) webpage.thumbnailUrl = options.image;
|
|
4414
|
-
schemas.push(webpage);
|
|
4415
|
-
}
|
|
4416
|
-
if (options.breadcrumbs && options.breadcrumbs.length > 0) schemas.push({
|
|
4417
|
-
"@context": "https://schema.org",
|
|
4418
|
-
"@type": "BreadcrumbList",
|
|
4419
|
-
itemListElement: options.breadcrumbs.map((bc, i) => ({
|
|
4420
|
-
"@type": "ListItem",
|
|
4421
|
-
position: i + 1,
|
|
4422
|
-
name: bc.name,
|
|
4423
|
-
item: bc.url
|
|
4424
|
-
}))
|
|
4425
|
-
});
|
|
4426
|
-
else {
|
|
4427
|
-
const urlObj = safeParseUrl(options.url);
|
|
4428
|
-
if (urlObj) {
|
|
4429
|
-
const segments = urlObj.pathname.split("/").filter(Boolean);
|
|
4430
|
-
if (segments.length > 0) {
|
|
4431
|
-
const items = [{
|
|
4432
|
-
"@type": "ListItem",
|
|
4433
|
-
position: 1,
|
|
4434
|
-
name: "Home",
|
|
4435
|
-
item: urlObj.origin
|
|
4436
|
-
}];
|
|
4437
|
-
let path = "";
|
|
4438
|
-
for (let i = 0; i < segments.length; i++) {
|
|
4439
|
-
path += `/${segments[i]}`;
|
|
4440
|
-
items.push({
|
|
4441
|
-
"@type": "ListItem",
|
|
4442
|
-
position: i + 2,
|
|
4443
|
-
name: capitalize(segments[i].replace(/-/g, " ")),
|
|
4444
|
-
item: `${urlObj.origin}${path}`
|
|
4445
|
-
});
|
|
4446
|
-
}
|
|
4447
|
-
schemas.push({
|
|
4448
|
-
"@context": "https://schema.org",
|
|
4449
|
-
"@type": "BreadcrumbList",
|
|
4450
|
-
itemListElement: items
|
|
4451
|
-
});
|
|
4452
|
-
}
|
|
4453
|
-
}
|
|
4454
|
-
}
|
|
4455
|
-
return schemas;
|
|
4456
|
-
}
|
|
4457
|
-
/**
|
|
4458
|
-
* Generate an OpenAI-compatible AI plugin manifest.
|
|
4459
|
-
*
|
|
4460
|
-
* Follows the /.well-known/ai-plugin.json spec.
|
|
4461
|
-
*
|
|
4462
|
-
* @internal Exported for testing.
|
|
4463
|
-
*/
|
|
4464
|
-
function generateAiPluginManifest(config) {
|
|
4465
|
-
return {
|
|
4466
|
-
schema_version: "v1",
|
|
4467
|
-
name_for_human: config.name,
|
|
4468
|
-
name_for_model: config.name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, ""),
|
|
4469
|
-
description_for_human: config.description,
|
|
4470
|
-
description_for_model: config.description,
|
|
4471
|
-
auth: { type: "none" },
|
|
4472
|
-
api: {
|
|
4473
|
-
type: "openapi",
|
|
4474
|
-
url: `${config.origin}/.well-known/openapi.yaml`
|
|
4475
|
-
},
|
|
4476
|
-
logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,
|
|
4477
|
-
contact_email: config.contactEmail ?? "",
|
|
4478
|
-
legal_info_url: config.legalUrl ?? `${config.origin}/legal`
|
|
4479
|
-
};
|
|
4480
|
-
}
|
|
4481
|
-
/**
|
|
4482
|
-
* Generate a minimal OpenAPI 3.0 spec from API route descriptions.
|
|
4483
|
-
*
|
|
4484
|
-
* @internal Exported for testing.
|
|
4485
|
-
*/
|
|
4486
|
-
function generateOpenApiSpec(apiFiles, config) {
|
|
4487
|
-
const paths = {};
|
|
4488
|
-
if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
4489
|
-
const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/);
|
|
4490
|
-
if (match) {
|
|
4491
|
-
const method = match[1].toLowerCase();
|
|
4492
|
-
const openApiPath = match[2].replace(/:(\w+)/g, "{$1}");
|
|
4493
|
-
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
4494
|
-
paths[openApiPath][method] = {
|
|
4495
|
-
summary: desc,
|
|
4496
|
-
responses: { "200": { description: "Success" } }
|
|
4497
|
-
};
|
|
4498
|
-
}
|
|
4499
|
-
}
|
|
4500
|
-
for (const pattern of parseApiFiles(apiFiles)) {
|
|
4501
|
-
const openApiPath = pattern.replace(/:(\w+)/g, "{$1}");
|
|
4502
|
-
if (!paths[openApiPath]) paths[openApiPath] = { get: {
|
|
4503
|
-
summary: `${openApiPath} endpoint`,
|
|
4504
|
-
responses: { "200": { description: "Success" } }
|
|
4505
|
-
} };
|
|
4506
|
-
}
|
|
4507
|
-
return {
|
|
4508
|
-
openapi: "3.0.0",
|
|
4509
|
-
info: {
|
|
4510
|
-
title: config.name,
|
|
4511
|
-
description: config.description,
|
|
4512
|
-
version: "1.0.0"
|
|
4513
|
-
},
|
|
4514
|
-
servers: [{ url: config.origin }],
|
|
4515
|
-
paths
|
|
4516
|
-
};
|
|
4517
|
-
}
|
|
4518
|
-
/**
|
|
4519
|
-
* AI integration Vite plugin.
|
|
4520
|
-
*
|
|
4521
|
-
* Generates at build time:
|
|
4522
|
-
* - `/llms.txt` — concise site summary for AI agents
|
|
4523
|
-
* - `/llms-full.txt` — detailed reference for AI agents
|
|
4524
|
-
* - `/.well-known/ai-plugin.json` — OpenAI plugin manifest
|
|
4525
|
-
* - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes
|
|
4526
|
-
*
|
|
4527
|
-
* In dev, serves these files via middleware.
|
|
4528
|
-
*
|
|
4529
|
-
* @example
|
|
4530
|
-
* ```ts
|
|
4531
|
-
* import { aiPlugin } from "@pyreon/zero/ai"
|
|
4532
|
-
*
|
|
4533
|
-
* export default {
|
|
4534
|
-
* plugins: [
|
|
4535
|
-
* aiPlugin({
|
|
4536
|
-
* name: "My App",
|
|
4537
|
-
* origin: "https://example.com",
|
|
4538
|
-
* description: "A modern web application",
|
|
4539
|
-
* apiDescriptions: {
|
|
4540
|
-
* "GET /api/posts": "List blog posts",
|
|
4541
|
-
* "GET /api/posts/:id": "Get post by ID",
|
|
4542
|
-
* },
|
|
4543
|
-
* }),
|
|
4544
|
-
* ],
|
|
4545
|
-
* }
|
|
4546
|
-
* ```
|
|
4547
|
-
*/
|
|
4548
|
-
function aiPlugin(config) {
|
|
4549
|
-
let root = "";
|
|
4550
|
-
let isBuild = false;
|
|
4551
|
-
let routeFiles = [];
|
|
4552
|
-
let apiFiles = [];
|
|
4553
|
-
return {
|
|
4554
|
-
name: "pyreon-zero-ai",
|
|
4555
|
-
enforce: "post",
|
|
4556
|
-
configResolved(resolvedConfig) {
|
|
4557
|
-
root = resolvedConfig.root;
|
|
4558
|
-
isBuild = resolvedConfig.command === "build";
|
|
4559
|
-
},
|
|
4560
|
-
async buildStart() {
|
|
4561
|
-
try {
|
|
4562
|
-
const { join } = await import("node:path");
|
|
4563
|
-
const routesDir = join(root, config.routesDir ?? "src/routes");
|
|
4564
|
-
const apiDir = join(root, config.apiDir ?? "src/api");
|
|
4565
|
-
routeFiles = await scanDir(routesDir, routesDir);
|
|
4566
|
-
apiFiles = await scanDir(apiDir, apiDir);
|
|
4567
|
-
} catch {}
|
|
4568
|
-
},
|
|
4569
|
-
configureServer(server) {
|
|
4570
|
-
server.middlewares.use(async (req, res, next) => {
|
|
4571
|
-
const url = req.url ?? "";
|
|
4572
|
-
if (url === "/llms.txt") {
|
|
4573
|
-
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
4574
|
-
res.end(generateLlmsTxt(routeFiles, apiFiles, config));
|
|
4575
|
-
return;
|
|
4576
|
-
}
|
|
4577
|
-
if (url === "/llms-full.txt") {
|
|
4578
|
-
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
4579
|
-
res.end(generateLlmsFullTxt(routeFiles, apiFiles, config));
|
|
4580
|
-
return;
|
|
4581
|
-
}
|
|
4582
|
-
if (url === "/.well-known/ai-plugin.json") {
|
|
4583
|
-
res.setHeader("Content-Type", "application/json");
|
|
4584
|
-
res.end(JSON.stringify(generateAiPluginManifest(config), null, 2));
|
|
4585
|
-
return;
|
|
4586
|
-
}
|
|
4587
|
-
if (url === "/.well-known/openapi.yaml" || url === "/.well-known/openapi.json") {
|
|
4588
|
-
res.setHeader("Content-Type", "application/json");
|
|
4589
|
-
res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2));
|
|
4590
|
-
return;
|
|
4591
|
-
}
|
|
4592
|
-
next();
|
|
4593
|
-
});
|
|
4594
|
-
},
|
|
4595
|
-
async generateBundle() {
|
|
4596
|
-
if (!isBuild) return;
|
|
4597
|
-
this.emitFile({
|
|
4598
|
-
type: "asset",
|
|
4599
|
-
fileName: "llms.txt",
|
|
4600
|
-
source: generateLlmsTxt(routeFiles, apiFiles, config)
|
|
4601
|
-
});
|
|
4602
|
-
this.emitFile({
|
|
4603
|
-
type: "asset",
|
|
4604
|
-
fileName: "llms-full.txt",
|
|
4605
|
-
source: generateLlmsFullTxt(routeFiles, apiFiles, config)
|
|
4606
|
-
});
|
|
4607
|
-
this.emitFile({
|
|
4608
|
-
type: "asset",
|
|
4609
|
-
fileName: ".well-known/ai-plugin.json",
|
|
4610
|
-
source: JSON.stringify(generateAiPluginManifest(config), null, 2)
|
|
4611
|
-
});
|
|
4612
|
-
this.emitFile({
|
|
4613
|
-
type: "asset",
|
|
4614
|
-
fileName: ".well-known/openapi.json",
|
|
4615
|
-
source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2)
|
|
4616
|
-
});
|
|
4617
|
-
}
|
|
4618
|
-
};
|
|
4619
|
-
}
|
|
4620
|
-
function parseApiFiles(files) {
|
|
4621
|
-
return files.filter((f) => f.endsWith(".ts") || f.endsWith(".js")).map((f) => {
|
|
4622
|
-
let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "");
|
|
4623
|
-
if (!path.startsWith("/")) path = `/${path}`;
|
|
4624
|
-
path = path.replace(/\[\.\.\.(\w+)\]/g, ":$1*").replace(/\[(\w+)\]/g, ":$1");
|
|
4625
|
-
return `/api${path === "/" ? "" : path}`;
|
|
4626
|
-
});
|
|
4627
|
-
}
|
|
4628
|
-
async function scanDir(dir, base) {
|
|
4629
|
-
const { readdir, stat } = await import("node:fs/promises");
|
|
4630
|
-
const { join, relative } = await import("node:path");
|
|
4631
|
-
try {
|
|
4632
|
-
const entries = await readdir(dir);
|
|
4633
|
-
const files = [];
|
|
4634
|
-
for (const entry of entries) {
|
|
4635
|
-
const full = join(dir, entry);
|
|
4636
|
-
if ((await stat(full)).isDirectory()) files.push(...await scanDir(full, base));
|
|
4637
|
-
else files.push(relative(base, full));
|
|
4638
|
-
}
|
|
4639
|
-
return files;
|
|
4640
|
-
} catch {
|
|
4641
|
-
return [];
|
|
4642
|
-
}
|
|
4643
|
-
}
|
|
4644
|
-
function safeParseUrl(url) {
|
|
4645
|
-
try {
|
|
4646
|
-
return new URL(url);
|
|
4647
|
-
} catch {
|
|
4648
|
-
return null;
|
|
4649
|
-
}
|
|
4650
|
-
}
|
|
4651
|
-
function capitalize(s) {
|
|
4652
|
-
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
4653
|
-
}
|
|
4654
|
-
|
|
4655
|
-
//#endregion
|
|
4656
|
-
export { Image, Link, Meta, Script, ThemeToggle, aiPlugin, bool, buildCspHeader, buildLocalePath, buildMetaTags, bunAdapter, cacheMiddleware, cloudflareAdapter, compose, compressResponse, compressionMiddleware, corsMiddleware, createActionMiddleware, createApiMiddleware, createApp, createISRHandler, createLink, createLocaleContext, createServer, cspMiddleware, zeroPlugin as default, defineAction, defineConfig, detectLocaleFromHeader, extractLocaleFromPath, faviconLinks, faviconPlugin, filePathToUrlPath, fontPlugin, fontVariables, generateAiPluginManifest, generateApiRouteModule, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateOpenApiSpec, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, imagePlugin, inferJsonLd, initTheme, isCompressible, jsonLd, loggerMiddleware, netlifyAdapter, nodeAdapter, num, ogImagePath, ogImagePlugin, oneOf, parseFileRoutes, prefetchRoute, publicEnv, rateLimitMiddleware, render404Page, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, schema, securityHeaders, seoMiddleware, seoPlugin, setLocale, setSSRThemeDefault, setTheme, staticAdapter, str, theme, themeScript, toggleTheme, url, useLink, useLocale, useNonce, validateEnv, varyEncoding, vercelAdapter };
|
|
923
|
+
export { Image, Link, Meta, Script, ThemeToggle, buildLocalePath, buildMetaTags, createLink, extractLocaleFromPath, initTheme, prefetchRoute, resolvedTheme, setLocale, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme, useLink, useLocale };
|
|
4657
924
|
//# sourceMappingURL=index.js.map
|