@pyreon/zero 0.11.8 → 0.11.10
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/font.js +20 -7
- package/lib/font.js.map +1 -1
- package/lib/fs-router-BkbIWqek.js.map +1 -1
- package/lib/{fs-router-n4VA4lxu.js → fs-router-Dil4IKZR.js} +23 -19
- package/lib/fs-router-Dil4IKZR.js.map +1 -0
- package/lib/image-plugin.js.map +1 -1
- package/lib/index.js +893 -24
- package/lib/index.js.map +1 -1
- package/lib/link.js +13 -1
- package/lib/link.js.map +1 -1
- package/lib/types/actions.d.ts +57 -0
- package/lib/types/actions.d.ts.map +1 -0
- package/lib/types/adapters/bun.d.ts +6 -0
- package/lib/types/adapters/bun.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +10 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/node.d.ts +6 -0
- package/lib/types/adapters/node.d.ts.map +1 -0
- package/lib/types/adapters/static.d.ts +7 -0
- package/lib/types/adapters/static.d.ts.map +1 -0
- package/lib/types/api-routes.d.ts +66 -0
- package/lib/types/api-routes.d.ts.map +1 -0
- package/lib/types/app.d.ts +24 -0
- package/lib/types/app.d.ts.map +1 -0
- package/lib/types/cache.d.ts +54 -0
- package/lib/types/cache.d.ts.map +1 -0
- package/lib/types/client.d.ts +19 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/compression.d.ts +33 -0
- package/lib/types/compression.d.ts.map +1 -0
- package/lib/types/config.d.ts +18 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/cors.d.ts +32 -0
- package/lib/types/cors.d.ts.map +1 -0
- package/lib/types/entry-server.d.ts +37 -0
- package/lib/types/entry-server.d.ts.map +1 -0
- package/lib/types/error-overlay.d.ts +6 -0
- package/lib/types/error-overlay.d.ts.map +1 -0
- package/lib/types/favicon.d.ts +43 -0
- package/lib/types/favicon.d.ts.map +1 -0
- package/lib/types/font.d.ts +119 -0
- package/lib/types/font.d.ts.map +1 -0
- package/lib/types/fs-router.d.ts +47 -0
- package/lib/types/fs-router.d.ts.map +1 -0
- package/lib/types/i18n-routing.d.ts +98 -0
- package/lib/types/i18n-routing.d.ts.map +1 -0
- package/lib/types/image-plugin.d.ts +79 -0
- package/lib/types/image-plugin.d.ts.map +1 -0
- package/lib/types/image.d.ts +51 -0
- package/lib/types/image.d.ts.map +1 -0
- package/lib/types/index.d.ts +46 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/isr.d.ts +9 -0
- package/lib/types/isr.d.ts.map +1 -0
- package/lib/types/link.d.ts +127 -0
- package/lib/types/link.d.ts.map +1 -0
- package/lib/types/meta.d.ts +91 -0
- package/lib/types/meta.d.ts.map +1 -0
- package/lib/types/middleware.d.ts +35 -0
- package/lib/types/middleware.d.ts.map +1 -0
- package/lib/types/not-found.d.ts +7 -0
- package/lib/types/not-found.d.ts.map +1 -0
- package/lib/types/rate-limit.d.ts +34 -0
- package/lib/types/rate-limit.d.ts.map +1 -0
- package/lib/types/script.d.ts +35 -0
- package/lib/types/script.d.ts.map +1 -0
- package/lib/types/seo.d.ts +88 -0
- package/lib/types/seo.d.ts.map +1 -0
- package/lib/types/testing.d.ts +85 -0
- package/lib/types/testing.d.ts.map +1 -0
- package/lib/types/theme.d.ts +39 -0
- package/lib/types/theme.d.ts.map +1 -0
- package/lib/types/types.d.ts +111 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/utils/use-intersection-observer.d.ts +10 -0
- package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
- package/lib/types/utils/with-headers.d.ts +6 -0
- package/lib/types/utils/with-headers.d.ts.map +1 -0
- package/lib/types/vite-plugin.d.ts +17 -0
- package/lib/types/vite-plugin.d.ts.map +1 -0
- package/package.json +10 -10
- package/src/entry-server.ts +124 -76
- package/src/favicon.ts +380 -0
- package/src/font.ts +32 -8
- package/src/fs-router.ts +54 -13
- package/src/i18n-routing.ts +299 -0
- package/src/image-plugin.ts +1 -1
- package/src/index.ts +125 -76
- package/src/link.tsx +19 -0
- package/src/meta.tsx +210 -0
- package/src/middleware.ts +65 -0
- package/src/not-found.ts +44 -0
- package/src/types.ts +2 -0
- package/src/vite-plugin.ts +258 -127
- package/lib/fs-router-n4VA4lxu.js.map +0 -1
package/lib/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { a as parseFileRoutes, i as generateRouteModule, o as scanRouteFiles, r as generateMiddlewareModule, t as filePathToUrlPath } from "./fs-router-
|
|
2
|
-
import { Fragment, createRef, h, onMount, onUnmount } from "@pyreon/core";
|
|
3
|
-
import { HeadProvider } from "@pyreon/head";
|
|
1
|
+
import { a as parseFileRoutes, i as generateRouteModule, o as scanRouteFiles, r as generateMiddlewareModule, t as filePathToUrlPath } from "./fs-router-Dil4IKZR.js";
|
|
2
|
+
import { Fragment, createContext, createRef, h, onMount, onUnmount } from "@pyreon/core";
|
|
3
|
+
import { HeadProvider, useHead } from "@pyreon/head";
|
|
4
4
|
import { RouterProvider, RouterView, createRouter, useRouter } from "@pyreon/router";
|
|
5
5
|
import { createHandler } 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";
|
|
6
9
|
import { effect, signal } from "@pyreon/reactivity";
|
|
7
|
-
import { existsSync } from "node:fs";
|
|
8
10
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
9
|
-
import { basename, extname, join } from "node:path";
|
|
10
11
|
|
|
11
12
|
//#region src/app.ts
|
|
12
13
|
/**
|
|
@@ -169,6 +170,31 @@ function generateApiRouteModule(files, routesDir) {
|
|
|
169
170
|
].join("\n");
|
|
170
171
|
}
|
|
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
|
+
|
|
172
198
|
//#endregion
|
|
173
199
|
//#region src/entry-server.ts
|
|
174
200
|
/**
|
|
@@ -219,7 +245,7 @@ function createServer(options) {
|
|
|
219
245
|
routes: options.routes,
|
|
220
246
|
routerMode: "history"
|
|
221
247
|
});
|
|
222
|
-
|
|
248
|
+
const handler = createHandler({
|
|
223
249
|
App,
|
|
224
250
|
routes: options.routes,
|
|
225
251
|
middleware: allMiddleware,
|
|
@@ -227,6 +253,30 @@ function createServer(options) {
|
|
|
227
253
|
...options.template ? { template: options.template } : {},
|
|
228
254
|
...options.clientEntry ? { clientEntry: options.clientEntry } : {}
|
|
229
255
|
});
|
|
256
|
+
if (!options.notFoundComponent) return handler;
|
|
257
|
+
const NotFound = options.notFoundComponent;
|
|
258
|
+
const routePatterns = flattenRoutePatterns$1(options.routes);
|
|
259
|
+
return async (req) => {
|
|
260
|
+
const pathname = new URL(req.url).pathname;
|
|
261
|
+
if (!routePatterns.some((pattern) => matchPattern(pattern, pathname))) {
|
|
262
|
+
const fullHtml = await render404Page(NotFound, options.template);
|
|
263
|
+
return new Response(fullHtml, {
|
|
264
|
+
status: 404,
|
|
265
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return handler(req);
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/** Extract all URL patterns from a nested route tree. */
|
|
272
|
+
function flattenRoutePatterns$1(routes, prefix = "") {
|
|
273
|
+
const patterns = [];
|
|
274
|
+
for (const route of routes) {
|
|
275
|
+
const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
|
|
276
|
+
patterns.push(fullPath);
|
|
277
|
+
if (route.children) patterns.push(...flattenRoutePatterns$1(route.children, fullPath));
|
|
278
|
+
}
|
|
279
|
+
return patterns;
|
|
230
280
|
}
|
|
231
281
|
|
|
232
282
|
//#endregion
|
|
@@ -371,6 +421,19 @@ function formatStack(stack) {
|
|
|
371
421
|
|
|
372
422
|
//#endregion
|
|
373
423
|
//#region src/vite-plugin.ts
|
|
424
|
+
/**
|
|
425
|
+
* Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
|
|
426
|
+
* Returns package names to exclude from Vite's dep optimizer.
|
|
427
|
+
*/
|
|
428
|
+
function scanPyreonPackages(root) {
|
|
429
|
+
const pyreonDir = join(root, "node_modules", "@pyreon");
|
|
430
|
+
if (!existsSync(pyreonDir)) return [];
|
|
431
|
+
try {
|
|
432
|
+
return readdirSync(pyreonDir).filter((name) => !name.startsWith(".")).map((name) => `@pyreon/${name}`);
|
|
433
|
+
} catch {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
}
|
|
374
437
|
const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
|
|
375
438
|
const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
|
|
376
439
|
const VIRTUAL_MIDDLEWARE_ID = "virtual:zero/route-middleware";
|
|
@@ -409,7 +472,7 @@ function zeroPlugin(userConfig = {}) {
|
|
|
409
472
|
},
|
|
410
473
|
async load(id) {
|
|
411
474
|
if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
|
|
412
|
-
return generateRouteModule(await scanRouteFiles(routesDir), routesDir);
|
|
475
|
+
return generateRouteModule(await scanRouteFiles(routesDir), routesDir, { staticImports: config.mode === "ssg" });
|
|
413
476
|
} catch (_err) {
|
|
414
477
|
return `export const routes = []`;
|
|
415
478
|
}
|
|
@@ -425,6 +488,16 @@ function zeroPlugin(userConfig = {}) {
|
|
|
425
488
|
}
|
|
426
489
|
},
|
|
427
490
|
configureServer(server) {
|
|
491
|
+
server.middlewares.use((req, res, next) => {
|
|
492
|
+
const accept = req.headers.accept ?? "";
|
|
493
|
+
if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
|
|
494
|
+
const pathname = req.url?.split("?")[0] ?? "/";
|
|
495
|
+
if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
|
|
496
|
+
if (/\.\w+$/.test(pathname)) return next();
|
|
497
|
+
handle404(server, routesDir, pathname, res).then((handled) => {
|
|
498
|
+
if (!handled) next();
|
|
499
|
+
}, () => next());
|
|
500
|
+
});
|
|
428
501
|
server.middlewares.use((req, res, next) => {
|
|
429
502
|
if (!(req.headers.accept ?? "").includes("text/html")) return next();
|
|
430
503
|
const originalEnd = res.end.bind(res);
|
|
@@ -442,7 +515,8 @@ function zeroPlugin(userConfig = {}) {
|
|
|
442
515
|
};
|
|
443
516
|
res.on("error", handleError);
|
|
444
517
|
try {
|
|
445
|
-
next();
|
|
518
|
+
const result = next();
|
|
519
|
+
if (result && typeof result.catch === "function") result.catch(handleError);
|
|
446
520
|
} catch (err) {
|
|
447
521
|
handleError(err);
|
|
448
522
|
}
|
|
@@ -462,9 +536,10 @@ function zeroPlugin(userConfig = {}) {
|
|
|
462
536
|
}
|
|
463
537
|
});
|
|
464
538
|
},
|
|
465
|
-
config() {
|
|
539
|
+
config(userConfig) {
|
|
466
540
|
return {
|
|
467
541
|
resolve: { conditions: ["bun"] },
|
|
542
|
+
optimizeDeps: { exclude: scanPyreonPackages(userConfig.root ?? process.cwd()) },
|
|
468
543
|
server: { port: config.port },
|
|
469
544
|
define: {
|
|
470
545
|
__ZERO_MODE__: JSON.stringify(config.mode),
|
|
@@ -474,6 +549,36 @@ function zeroPlugin(userConfig = {}) {
|
|
|
474
549
|
}
|
|
475
550
|
};
|
|
476
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Check if the requested path matches any route. If not, render a 404 page.
|
|
554
|
+
* Returns true if the 404 was handled (response sent), false otherwise.
|
|
555
|
+
*
|
|
556
|
+
* In dev mode, the _404.tsx component cannot be SSR-rendered because
|
|
557
|
+
* the compiler emits _tpl() calls that require `document`. Instead,
|
|
558
|
+
* we return a static 404 page. The actual component rendering happens
|
|
559
|
+
* on the client side when the SPA loads.
|
|
560
|
+
*/
|
|
561
|
+
async function handle404(server, _routesDir, pathname, res) {
|
|
562
|
+
const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
|
|
563
|
+
if (flattenRoutePatterns(routes).some((pattern) => matchPattern(pattern, pathname))) return false;
|
|
564
|
+
const html = await render404Page(void 0);
|
|
565
|
+
res.statusCode = 404;
|
|
566
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
567
|
+
res.setHeader("Content-Length", Buffer.byteLength(html));
|
|
568
|
+
res.end(html);
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
/** Extract all URL patterns from a nested route tree. */
|
|
572
|
+
function flattenRoutePatterns(routes, prefix = "") {
|
|
573
|
+
const patterns = [];
|
|
574
|
+
for (const route of routes) {
|
|
575
|
+
if (!route.path) continue;
|
|
576
|
+
const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
|
|
577
|
+
patterns.push(fullPath);
|
|
578
|
+
if (route.children) patterns.push(...flattenRoutePatterns(route.children, fullPath));
|
|
579
|
+
}
|
|
580
|
+
return patterns;
|
|
581
|
+
}
|
|
477
582
|
|
|
478
583
|
//#endregion
|
|
479
584
|
//#region src/isr.ts
|
|
@@ -914,6 +1019,17 @@ function doPrefetch(href) {
|
|
|
914
1019
|
} catch {}
|
|
915
1020
|
}
|
|
916
1021
|
/**
|
|
1022
|
+
* Prefetch a route's JS chunk by injecting `<link rel="prefetch">` into the
|
|
1023
|
+
* document head. Deduplicates — calling with the same href twice is a no-op.
|
|
1024
|
+
*
|
|
1025
|
+
* @example
|
|
1026
|
+
* prefetchRoute('/about')
|
|
1027
|
+
* prefetchRoute('/dashboard')
|
|
1028
|
+
*/
|
|
1029
|
+
function prefetchRoute(href) {
|
|
1030
|
+
doPrefetch(href);
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
917
1033
|
* Composable that provides all link behavior — navigation, prefetching,
|
|
918
1034
|
* active state, and viewport observation.
|
|
919
1035
|
*
|
|
@@ -934,6 +1050,7 @@ function useLink(props) {
|
|
|
934
1050
|
const elementRef = createRef();
|
|
935
1051
|
const strategy = props.prefetch ?? "hover";
|
|
936
1052
|
function handleClick(e) {
|
|
1053
|
+
if (props.onClick) props.onClick(e);
|
|
937
1054
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || props.external) return;
|
|
938
1055
|
e.preventDefault();
|
|
939
1056
|
router.push(props.href);
|
|
@@ -1202,6 +1319,57 @@ function varyEncoding() {
|
|
|
1202
1319
|
};
|
|
1203
1320
|
}
|
|
1204
1321
|
|
|
1322
|
+
//#endregion
|
|
1323
|
+
//#region src/middleware.ts
|
|
1324
|
+
/**
|
|
1325
|
+
* Compose multiple middleware into a single middleware function.
|
|
1326
|
+
* Middleware runs sequentially — if any returns a Response, the chain stops.
|
|
1327
|
+
*
|
|
1328
|
+
* @example
|
|
1329
|
+
* import { compose } from "@pyreon/zero/middleware"
|
|
1330
|
+
* import { corsMiddleware } from "@pyreon/zero/cors"
|
|
1331
|
+
* import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
|
|
1332
|
+
*
|
|
1333
|
+
* const combined = compose(
|
|
1334
|
+
* corsMiddleware({ origin: "*" }),
|
|
1335
|
+
* rateLimitMiddleware({ max: 100 }),
|
|
1336
|
+
* cacheMiddleware(),
|
|
1337
|
+
* )
|
|
1338
|
+
*/
|
|
1339
|
+
function compose(...middlewares) {
|
|
1340
|
+
return async (ctx) => {
|
|
1341
|
+
for (const mw of middlewares) {
|
|
1342
|
+
const result = await mw(ctx);
|
|
1343
|
+
if (result instanceof Response) return result;
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
const ZERO_CTX_KEY = "__zeroCtx";
|
|
1348
|
+
/**
|
|
1349
|
+
* Get the shared Zero context from a middleware context.
|
|
1350
|
+
* Creates one if it doesn't exist. Middleware can use this to
|
|
1351
|
+
* pass data to downstream middleware without polluting `ctx.locals`.
|
|
1352
|
+
*
|
|
1353
|
+
* @example
|
|
1354
|
+
* const authMiddleware: Middleware = (ctx) => {
|
|
1355
|
+
* const zctx = getContext(ctx)
|
|
1356
|
+
* zctx.userId = "user_123"
|
|
1357
|
+
* }
|
|
1358
|
+
*
|
|
1359
|
+
* const loggingMiddleware: Middleware = (ctx) => {
|
|
1360
|
+
* const zctx = getContext(ctx)
|
|
1361
|
+
* console.log("User:", zctx.userId)
|
|
1362
|
+
* }
|
|
1363
|
+
*/
|
|
1364
|
+
function getContext(ctx) {
|
|
1365
|
+
let zctx = ctx.locals[ZERO_CTX_KEY];
|
|
1366
|
+
if (!zctx) {
|
|
1367
|
+
zctx = {};
|
|
1368
|
+
ctx.locals[ZERO_CTX_KEY] = zctx;
|
|
1369
|
+
}
|
|
1370
|
+
return zctx;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1205
1373
|
//#endregion
|
|
1206
1374
|
//#region src/font.ts
|
|
1207
1375
|
/**
|
|
@@ -1243,13 +1411,26 @@ function parseGoogleFamily(input) {
|
|
|
1243
1411
|
variable: true,
|
|
1244
1412
|
weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])]
|
|
1245
1413
|
};
|
|
1246
|
-
const
|
|
1247
|
-
if (
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1414
|
+
const afterAt = spec.split("@")[1];
|
|
1415
|
+
if (afterAt) {
|
|
1416
|
+
const entries = afterAt.split(";").filter(Boolean);
|
|
1417
|
+
const weights = /* @__PURE__ */ new Set();
|
|
1418
|
+
for (const entry of entries) if (entry.includes(",")) {
|
|
1419
|
+
const parts = entry.split(",");
|
|
1420
|
+
const weight = Number(parts[parts.length - 1]);
|
|
1421
|
+
if (weight > 0) weights.add(weight);
|
|
1422
|
+
if (parts[0] === "1") italic = true;
|
|
1423
|
+
} else if (entry.includes("..")) {} else {
|
|
1424
|
+
const weight = Number(entry);
|
|
1425
|
+
if (weight > 0) weights.add(weight);
|
|
1426
|
+
}
|
|
1427
|
+
if (weights.size > 0) return {
|
|
1428
|
+
family,
|
|
1429
|
+
italic,
|
|
1430
|
+
variable: false,
|
|
1431
|
+
weights: [...weights].sort((a, b) => a - b)
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1253
1434
|
}
|
|
1254
1435
|
return {
|
|
1255
1436
|
family,
|
|
@@ -1454,10 +1635,10 @@ function fontVariables(families) {
|
|
|
1454
1635
|
|
|
1455
1636
|
//#endregion
|
|
1456
1637
|
//#region src/image-plugin.ts
|
|
1457
|
-
let sharpWarned = false;
|
|
1458
|
-
function warnSharpMissing() {
|
|
1459
|
-
if (sharpWarned) return;
|
|
1460
|
-
sharpWarned = true;
|
|
1638
|
+
let sharpWarned$1 = false;
|
|
1639
|
+
function warnSharpMissing$1() {
|
|
1640
|
+
if (sharpWarned$1) return;
|
|
1641
|
+
sharpWarned$1 = true;
|
|
1461
1642
|
console.warn("\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n");
|
|
1462
1643
|
}
|
|
1463
1644
|
const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i;
|
|
@@ -1720,7 +1901,7 @@ async function resizeImage(input, output, width, format, quality) {
|
|
|
1720
1901
|
}
|
|
1721
1902
|
await pipeline.toFile(output);
|
|
1722
1903
|
} catch {
|
|
1723
|
-
warnSharpMissing();
|
|
1904
|
+
warnSharpMissing$1();
|
|
1724
1905
|
await writeFile(output, await readFile(input));
|
|
1725
1906
|
}
|
|
1726
1907
|
}
|
|
@@ -1991,7 +2172,7 @@ function seoPlugin(config = {}) {
|
|
|
1991
2172
|
apply: "build",
|
|
1992
2173
|
async generateBundle(_, _bundle) {
|
|
1993
2174
|
if (config.sitemap) {
|
|
1994
|
-
const { scanRouteFiles } = await import("./fs-router-
|
|
2175
|
+
const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
|
|
1995
2176
|
const routesDir = `${process.cwd()}/src/routes`;
|
|
1996
2177
|
try {
|
|
1997
2178
|
const sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
|
|
@@ -2021,7 +2202,7 @@ function seoMiddleware(config = {}) {
|
|
|
2021
2202
|
return async (ctx) => {
|
|
2022
2203
|
if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
|
|
2023
2204
|
if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
|
|
2024
|
-
const { scanRouteFiles } = await import("./fs-router-
|
|
2205
|
+
const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
|
|
2025
2206
|
const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
|
|
2026
2207
|
return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
|
|
2027
2208
|
} catch {}
|
|
@@ -2308,5 +2489,693 @@ async function executeAction(action, req) {
|
|
|
2308
2489
|
}
|
|
2309
2490
|
|
|
2310
2491
|
//#endregion
|
|
2311
|
-
|
|
2492
|
+
//#region src/favicon.ts
|
|
2493
|
+
let sharpWarned = false;
|
|
2494
|
+
function warnSharpMissing() {
|
|
2495
|
+
if (sharpWarned) return;
|
|
2496
|
+
sharpWarned = true;
|
|
2497
|
+
console.warn("\n[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
|
|
2498
|
+
}
|
|
2499
|
+
const SIZES = [
|
|
2500
|
+
{
|
|
2501
|
+
size: 16,
|
|
2502
|
+
name: "favicon-16x16.png"
|
|
2503
|
+
},
|
|
2504
|
+
{
|
|
2505
|
+
size: 32,
|
|
2506
|
+
name: "favicon-32x32.png"
|
|
2507
|
+
},
|
|
2508
|
+
{
|
|
2509
|
+
size: 180,
|
|
2510
|
+
name: "apple-touch-icon.png"
|
|
2511
|
+
},
|
|
2512
|
+
{
|
|
2513
|
+
size: 192,
|
|
2514
|
+
name: "icon-192.png"
|
|
2515
|
+
},
|
|
2516
|
+
{
|
|
2517
|
+
size: 512,
|
|
2518
|
+
name: "icon-512.png"
|
|
2519
|
+
}
|
|
2520
|
+
];
|
|
2521
|
+
/**
|
|
2522
|
+
* Favicon generation Vite plugin.
|
|
2523
|
+
*
|
|
2524
|
+
* Generates all required favicon formats at build time from a single source.
|
|
2525
|
+
* In dev mode, serves the source directly.
|
|
2526
|
+
*
|
|
2527
|
+
* @example
|
|
2528
|
+
* ```ts
|
|
2529
|
+
* // vite.config.ts
|
|
2530
|
+
* import { faviconPlugin } from "@pyreon/zero"
|
|
2531
|
+
*
|
|
2532
|
+
* export default {
|
|
2533
|
+
* plugins: [faviconPlugin({ source: "./src/assets/icon.svg" })],
|
|
2534
|
+
* }
|
|
2535
|
+
* ```
|
|
2536
|
+
*/
|
|
2537
|
+
function faviconPlugin(config) {
|
|
2538
|
+
const themeColor = config.themeColor ?? "#ffffff";
|
|
2539
|
+
const backgroundColor = config.backgroundColor ?? "#ffffff";
|
|
2540
|
+
const generateManifest = config.manifest !== false;
|
|
2541
|
+
let root = "";
|
|
2542
|
+
let isBuild = false;
|
|
2543
|
+
return {
|
|
2544
|
+
name: "pyreon-zero-favicon",
|
|
2545
|
+
enforce: "pre",
|
|
2546
|
+
configResolved(resolvedConfig) {
|
|
2547
|
+
root = resolvedConfig.root;
|
|
2548
|
+
isBuild = resolvedConfig.command === "build";
|
|
2549
|
+
},
|
|
2550
|
+
configureServer(server) {
|
|
2551
|
+
const sourcePath = join(root, config.source);
|
|
2552
|
+
server.middlewares.use(async (req, res, next) => {
|
|
2553
|
+
const url = req.url ?? "";
|
|
2554
|
+
if (url === "/favicon.svg" && config.source.endsWith(".svg")) try {
|
|
2555
|
+
const content = await readFile(sourcePath, "utf-8");
|
|
2556
|
+
res.setHeader("Content-Type", "image/svg+xml");
|
|
2557
|
+
res.end(content);
|
|
2558
|
+
return;
|
|
2559
|
+
} catch {}
|
|
2560
|
+
const sizeMatch = SIZES.find((s) => url === `/${s.name}`);
|
|
2561
|
+
if (sizeMatch) {
|
|
2562
|
+
const png = await resizeToPng(sourcePath, sizeMatch.size);
|
|
2563
|
+
if (png) {
|
|
2564
|
+
res.setHeader("Content-Type", "image/png");
|
|
2565
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
2566
|
+
res.end(Buffer.from(png));
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
if (url === "/favicon.ico") {
|
|
2571
|
+
const ico = await generateIco(sourcePath);
|
|
2572
|
+
if (ico) {
|
|
2573
|
+
res.setHeader("Content-Type", "image/x-icon");
|
|
2574
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
2575
|
+
res.end(Buffer.from(ico));
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
if (url === "/site.webmanifest" && generateManifest) {
|
|
2580
|
+
const manifest = {
|
|
2581
|
+
name: config.name ?? "App",
|
|
2582
|
+
short_name: config.name ?? "App",
|
|
2583
|
+
icons: [{
|
|
2584
|
+
src: "/icon-192.png",
|
|
2585
|
+
sizes: "192x192",
|
|
2586
|
+
type: "image/png"
|
|
2587
|
+
}, {
|
|
2588
|
+
src: "/icon-512.png",
|
|
2589
|
+
sizes: "512x512",
|
|
2590
|
+
type: "image/png"
|
|
2591
|
+
}],
|
|
2592
|
+
theme_color: themeColor,
|
|
2593
|
+
background_color: backgroundColor,
|
|
2594
|
+
display: "standalone"
|
|
2595
|
+
};
|
|
2596
|
+
res.setHeader("Content-Type", "application/manifest+json");
|
|
2597
|
+
res.end(JSON.stringify(manifest, null, 2));
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
next();
|
|
2601
|
+
});
|
|
2602
|
+
},
|
|
2603
|
+
transformIndexHtml() {
|
|
2604
|
+
const isSvg = config.source.endsWith(".svg");
|
|
2605
|
+
const tags = [];
|
|
2606
|
+
if (isSvg) tags.push({
|
|
2607
|
+
tag: "link",
|
|
2608
|
+
attrs: {
|
|
2609
|
+
rel: "icon",
|
|
2610
|
+
type: "image/svg+xml",
|
|
2611
|
+
href: "/favicon.svg"
|
|
2612
|
+
},
|
|
2613
|
+
injectTo: "head"
|
|
2614
|
+
});
|
|
2615
|
+
tags.push({
|
|
2616
|
+
tag: "link",
|
|
2617
|
+
attrs: {
|
|
2618
|
+
rel: "icon",
|
|
2619
|
+
type: "image/png",
|
|
2620
|
+
sizes: "32x32",
|
|
2621
|
+
href: "/favicon-32x32.png"
|
|
2622
|
+
},
|
|
2623
|
+
injectTo: "head"
|
|
2624
|
+
}, {
|
|
2625
|
+
tag: "link",
|
|
2626
|
+
attrs: {
|
|
2627
|
+
rel: "icon",
|
|
2628
|
+
type: "image/png",
|
|
2629
|
+
sizes: "16x16",
|
|
2630
|
+
href: "/favicon-16x16.png"
|
|
2631
|
+
},
|
|
2632
|
+
injectTo: "head"
|
|
2633
|
+
}, {
|
|
2634
|
+
tag: "link",
|
|
2635
|
+
attrs: {
|
|
2636
|
+
rel: "apple-touch-icon",
|
|
2637
|
+
sizes: "180x180",
|
|
2638
|
+
href: "/apple-touch-icon.png"
|
|
2639
|
+
},
|
|
2640
|
+
injectTo: "head"
|
|
2641
|
+
});
|
|
2642
|
+
if (generateManifest) tags.push({
|
|
2643
|
+
tag: "link",
|
|
2644
|
+
attrs: {
|
|
2645
|
+
rel: "manifest",
|
|
2646
|
+
href: "/site.webmanifest"
|
|
2647
|
+
},
|
|
2648
|
+
injectTo: "head"
|
|
2649
|
+
});
|
|
2650
|
+
tags.push({
|
|
2651
|
+
tag: "meta",
|
|
2652
|
+
attrs: {
|
|
2653
|
+
name: "theme-color",
|
|
2654
|
+
content: themeColor
|
|
2655
|
+
},
|
|
2656
|
+
injectTo: "head"
|
|
2657
|
+
});
|
|
2658
|
+
return tags;
|
|
2659
|
+
},
|
|
2660
|
+
async generateBundle() {
|
|
2661
|
+
if (!isBuild) return;
|
|
2662
|
+
const sourcePath = join(root, config.source);
|
|
2663
|
+
if (!existsSync(sourcePath)) {
|
|
2664
|
+
console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
if (config.source.endsWith(".svg")) {
|
|
2668
|
+
const svgContent = await readFile(sourcePath, "utf-8");
|
|
2669
|
+
let finalSvg = svgContent;
|
|
2670
|
+
if (config.darkSource) {
|
|
2671
|
+
const darkPath = join(root, config.darkSource);
|
|
2672
|
+
if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
|
|
2673
|
+
}
|
|
2674
|
+
this.emitFile({
|
|
2675
|
+
type: "asset",
|
|
2676
|
+
fileName: "favicon.svg",
|
|
2677
|
+
source: finalSvg
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
for (const { size, name } of SIZES) {
|
|
2681
|
+
const pngBuffer = await resizeToPng(sourcePath, size);
|
|
2682
|
+
if (pngBuffer) this.emitFile({
|
|
2683
|
+
type: "asset",
|
|
2684
|
+
fileName: name,
|
|
2685
|
+
source: pngBuffer
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
const ico = await generateIco(sourcePath);
|
|
2689
|
+
if (ico) this.emitFile({
|
|
2690
|
+
type: "asset",
|
|
2691
|
+
fileName: "favicon.ico",
|
|
2692
|
+
source: ico
|
|
2693
|
+
});
|
|
2694
|
+
if (generateManifest) {
|
|
2695
|
+
const manifest = {
|
|
2696
|
+
name: config.name ?? "App",
|
|
2697
|
+
short_name: config.name ?? "App",
|
|
2698
|
+
icons: [{
|
|
2699
|
+
src: "/icon-192.png",
|
|
2700
|
+
sizes: "192x192",
|
|
2701
|
+
type: "image/png"
|
|
2702
|
+
}, {
|
|
2703
|
+
src: "/icon-512.png",
|
|
2704
|
+
sizes: "512x512",
|
|
2705
|
+
type: "image/png"
|
|
2706
|
+
}],
|
|
2707
|
+
theme_color: themeColor,
|
|
2708
|
+
background_color: backgroundColor,
|
|
2709
|
+
display: "standalone"
|
|
2710
|
+
};
|
|
2711
|
+
this.emitFile({
|
|
2712
|
+
type: "asset",
|
|
2713
|
+
fileName: "site.webmanifest",
|
|
2714
|
+
source: JSON.stringify(manifest, null, 2)
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
/**
|
|
2721
|
+
* Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.
|
|
2722
|
+
*/
|
|
2723
|
+
function wrapSvgWithDarkMode(lightSvg, darkSvg) {
|
|
2724
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${lightSvg.match(/viewBox="([^"]*)"/)?.[1] ?? "0 0 32 32"}">
|
|
2725
|
+
<style>
|
|
2726
|
+
:root { color-scheme: light dark; }
|
|
2727
|
+
@media (prefers-color-scheme: dark) { .light { display: none; } }
|
|
2728
|
+
@media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }
|
|
2729
|
+
</style>
|
|
2730
|
+
<g class="light">${stripSvgWrapper(lightSvg)}</g>
|
|
2731
|
+
<g class="dark">${stripSvgWrapper(darkSvg)}</g>
|
|
2732
|
+
</svg>`;
|
|
2733
|
+
}
|
|
2734
|
+
function stripSvgWrapper(svg) {
|
|
2735
|
+
return svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
|
|
2736
|
+
}
|
|
2737
|
+
async function resizeToPng(input, size) {
|
|
2738
|
+
try {
|
|
2739
|
+
return await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, {
|
|
2740
|
+
fit: "contain",
|
|
2741
|
+
background: {
|
|
2742
|
+
r: 0,
|
|
2743
|
+
g: 0,
|
|
2744
|
+
b: 0,
|
|
2745
|
+
alpha: 0
|
|
2746
|
+
}
|
|
2747
|
+
}).png().toBuffer();
|
|
2748
|
+
} catch {
|
|
2749
|
+
warnSharpMissing();
|
|
2750
|
+
return null;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
async function generateIco(input) {
|
|
2754
|
+
try {
|
|
2755
|
+
const sharp = await import("sharp").then((m) => m.default ?? m);
|
|
2756
|
+
const png16 = await sharp(input).resize(16, 16, {
|
|
2757
|
+
fit: "contain",
|
|
2758
|
+
background: {
|
|
2759
|
+
r: 0,
|
|
2760
|
+
g: 0,
|
|
2761
|
+
b: 0,
|
|
2762
|
+
alpha: 0
|
|
2763
|
+
}
|
|
2764
|
+
}).png().toBuffer();
|
|
2765
|
+
const png32 = await sharp(input).resize(32, 32, {
|
|
2766
|
+
fit: "contain",
|
|
2767
|
+
background: {
|
|
2768
|
+
r: 0,
|
|
2769
|
+
g: 0,
|
|
2770
|
+
b: 0,
|
|
2771
|
+
alpha: 0
|
|
2772
|
+
}
|
|
2773
|
+
}).png().toBuffer();
|
|
2774
|
+
return createIcoFromPngs([{
|
|
2775
|
+
buffer: png16,
|
|
2776
|
+
size: 16
|
|
2777
|
+
}, {
|
|
2778
|
+
buffer: png32,
|
|
2779
|
+
size: 32
|
|
2780
|
+
}]);
|
|
2781
|
+
} catch {
|
|
2782
|
+
warnSharpMissing();
|
|
2783
|
+
return null;
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
/** @internal Exported for testing */
|
|
2787
|
+
function createIcoFromPngs(entries) {
|
|
2788
|
+
const headerSize = 6;
|
|
2789
|
+
const dirEntrySize = 16;
|
|
2790
|
+
const dirSize = dirEntrySize * entries.length;
|
|
2791
|
+
let dataOffset = headerSize + dirSize;
|
|
2792
|
+
const header = Buffer.alloc(headerSize);
|
|
2793
|
+
header.writeUInt16LE(0, 0);
|
|
2794
|
+
header.writeUInt16LE(1, 2);
|
|
2795
|
+
header.writeUInt16LE(entries.length, 4);
|
|
2796
|
+
const dirEntries = Buffer.alloc(dirSize);
|
|
2797
|
+
const dataBuffers = [];
|
|
2798
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2799
|
+
const entry = entries[i];
|
|
2800
|
+
const offset = i * dirEntrySize;
|
|
2801
|
+
dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset);
|
|
2802
|
+
dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1);
|
|
2803
|
+
dirEntries.writeUInt8(0, offset + 2);
|
|
2804
|
+
dirEntries.writeUInt8(0, offset + 3);
|
|
2805
|
+
dirEntries.writeUInt16LE(1, offset + 4);
|
|
2806
|
+
dirEntries.writeUInt16LE(32, offset + 6);
|
|
2807
|
+
dirEntries.writeUInt32LE(entry.buffer.length, offset + 8);
|
|
2808
|
+
dirEntries.writeUInt32LE(dataOffset, offset + 12);
|
|
2809
|
+
dataOffset += entry.buffer.length;
|
|
2810
|
+
dataBuffers.push(entry.buffer);
|
|
2811
|
+
}
|
|
2812
|
+
return Buffer.concat([
|
|
2813
|
+
header,
|
|
2814
|
+
dirEntries,
|
|
2815
|
+
...dataBuffers
|
|
2816
|
+
]);
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
//#endregion
|
|
2820
|
+
//#region src/i18n-routing.ts
|
|
2821
|
+
/**
|
|
2822
|
+
* Detect preferred locale from Accept-Language header.
|
|
2823
|
+
*/
|
|
2824
|
+
function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
|
|
2825
|
+
if (!acceptLanguage) return defaultLocale;
|
|
2826
|
+
const preferred = acceptLanguage.split(",").map((part) => {
|
|
2827
|
+
const [lang, q] = part.trim().split(";q=");
|
|
2828
|
+
return {
|
|
2829
|
+
lang: lang?.split("-")[0]?.toLowerCase() ?? "",
|
|
2830
|
+
quality: q ? Number.parseFloat(q) : 1
|
|
2831
|
+
};
|
|
2832
|
+
}).sort((a, b) => b.quality - a.quality);
|
|
2833
|
+
for (const { lang } of preferred) if (locales.includes(lang)) return lang;
|
|
2834
|
+
return defaultLocale;
|
|
2835
|
+
}
|
|
2836
|
+
/**
|
|
2837
|
+
* Extract locale from a URL path.
|
|
2838
|
+
* Returns { locale, pathWithoutLocale }.
|
|
2839
|
+
*/
|
|
2840
|
+
function extractLocaleFromPath(path, locales, defaultLocale) {
|
|
2841
|
+
const segments = path.split("/").filter(Boolean);
|
|
2842
|
+
const firstSegment = segments[0]?.toLowerCase();
|
|
2843
|
+
if (firstSegment && locales.includes(firstSegment)) return {
|
|
2844
|
+
locale: firstSegment,
|
|
2845
|
+
pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
|
|
2846
|
+
};
|
|
2847
|
+
return {
|
|
2848
|
+
locale: defaultLocale,
|
|
2849
|
+
pathWithoutLocale: path
|
|
2850
|
+
};
|
|
2851
|
+
}
|
|
2852
|
+
/**
|
|
2853
|
+
* Build a localized path.
|
|
2854
|
+
*/
|
|
2855
|
+
function buildLocalePath(path, locale, defaultLocale, strategy) {
|
|
2856
|
+
const clean = path === "/" ? "" : path;
|
|
2857
|
+
if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
|
|
2858
|
+
return `/${locale}${clean}`;
|
|
2859
|
+
}
|
|
2860
|
+
/**
|
|
2861
|
+
* Create a LocaleContext for use in components and loaders.
|
|
2862
|
+
*/
|
|
2863
|
+
function createLocaleContext(locale, path, config) {
|
|
2864
|
+
const strategy = config.strategy ?? "prefix-except-default";
|
|
2865
|
+
return {
|
|
2866
|
+
locale,
|
|
2867
|
+
locales: config.locales,
|
|
2868
|
+
defaultLocale: config.defaultLocale,
|
|
2869
|
+
localePath(targetPath, targetLocale) {
|
|
2870
|
+
return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
|
|
2871
|
+
},
|
|
2872
|
+
alternates() {
|
|
2873
|
+
const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
|
|
2874
|
+
return config.locales.map((loc) => ({
|
|
2875
|
+
locale: loc,
|
|
2876
|
+
url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
|
|
2877
|
+
}));
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
2881
|
+
/**
|
|
2882
|
+
* I18n routing middleware for Zero's server.
|
|
2883
|
+
*
|
|
2884
|
+
* - Detects locale from URL prefix or Accept-Language header
|
|
2885
|
+
* - Redirects root to preferred locale (when detectLocale is true)
|
|
2886
|
+
* - Sets locale context for loaders and components
|
|
2887
|
+
*
|
|
2888
|
+
* @example
|
|
2889
|
+
* ```ts
|
|
2890
|
+
* // zero.config.ts
|
|
2891
|
+
* import { i18nRouting } from "@pyreon/zero"
|
|
2892
|
+
*
|
|
2893
|
+
* export default defineConfig({
|
|
2894
|
+
* plugins: [
|
|
2895
|
+
* i18nRouting({
|
|
2896
|
+
* locales: ["en", "de", "cs"],
|
|
2897
|
+
* defaultLocale: "en",
|
|
2898
|
+
* }),
|
|
2899
|
+
* ],
|
|
2900
|
+
* })
|
|
2901
|
+
* ```
|
|
2902
|
+
*/
|
|
2903
|
+
function i18nRouting(config) {
|
|
2904
|
+
const strategy = config.strategy ?? "prefix-except-default";
|
|
2905
|
+
const detectEnabled = config.detectLocale !== false;
|
|
2906
|
+
const cookieName = config.cookieName ?? "locale";
|
|
2907
|
+
return {
|
|
2908
|
+
name: "pyreon-zero-i18n-routing",
|
|
2909
|
+
configResolved() {},
|
|
2910
|
+
configureServer(server) {
|
|
2911
|
+
server.middlewares.use((req, res, next) => {
|
|
2912
|
+
const url = req.url ?? "/";
|
|
2913
|
+
if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
|
|
2914
|
+
const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
|
|
2915
|
+
if (detectEnabled && url === "/") {
|
|
2916
|
+
const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
|
|
2917
|
+
const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
|
|
2918
|
+
const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
|
|
2919
|
+
if (strategy === "prefix" || preferred !== config.defaultLocale) {
|
|
2920
|
+
res.writeHead(302, { Location: `/${preferred}/` });
|
|
2921
|
+
res.end();
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
req.__locale = locale;
|
|
2926
|
+
req.__localeContext = createLocaleContext(locale, url, config);
|
|
2927
|
+
localeSignal.set(locale);
|
|
2928
|
+
next();
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2933
|
+
function parseCookies(header) {
|
|
2934
|
+
if (!header) return {};
|
|
2935
|
+
const result = {};
|
|
2936
|
+
for (const pair of header.split(";")) {
|
|
2937
|
+
const [key, value] = pair.trim().split("=");
|
|
2938
|
+
if (key && value) result[key] = decodeURIComponent(value);
|
|
2939
|
+
}
|
|
2940
|
+
return result;
|
|
2941
|
+
}
|
|
2942
|
+
/** @internal Context for the current locale. */
|
|
2943
|
+
const LocaleCtx = createContext("en");
|
|
2944
|
+
/** Current locale signal — set by the server middleware or client-side detection. */
|
|
2945
|
+
const localeSignal = signal("en");
|
|
2946
|
+
/**
|
|
2947
|
+
* Read the current locale reactively.
|
|
2948
|
+
*
|
|
2949
|
+
* Returns the locale signal value directly — reactive in both SSR and CSR.
|
|
2950
|
+
* The server middleware sets `localeSignal` per-request, and client-side
|
|
2951
|
+
* `setLocale()` updates it as well.
|
|
2952
|
+
*
|
|
2953
|
+
* @example
|
|
2954
|
+
* ```tsx
|
|
2955
|
+
* const locale = useLocale() // "en", "de", etc.
|
|
2956
|
+
* ```
|
|
2957
|
+
*/
|
|
2958
|
+
function useLocale() {
|
|
2959
|
+
return localeSignal();
|
|
2960
|
+
}
|
|
2961
|
+
/**
|
|
2962
|
+
* Set the locale client-side and update the URL.
|
|
2963
|
+
*
|
|
2964
|
+
* @example
|
|
2965
|
+
* ```tsx
|
|
2966
|
+
* <button onClick={() => setLocale('de')}>Deutsch</button>
|
|
2967
|
+
* ```
|
|
2968
|
+
*/
|
|
2969
|
+
function setLocale(locale, config) {
|
|
2970
|
+
localeSignal.set(locale);
|
|
2971
|
+
if (typeof document !== "undefined") document.cookie = `${config.cookieName ?? "locale"}=${locale}; path=/; max-age=31536000`;
|
|
2972
|
+
if (typeof window !== "undefined") {
|
|
2973
|
+
const strategy = config.strategy ?? "prefix-except-default";
|
|
2974
|
+
const { pathWithoutLocale } = extractLocaleFromPath(window.location.pathname, config.locales, config.defaultLocale);
|
|
2975
|
+
const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy);
|
|
2976
|
+
window.history.pushState(null, "", newPath);
|
|
2977
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
//#endregion
|
|
2982
|
+
//#region src/meta.tsx
|
|
2983
|
+
const resolveStr = (v) => typeof v === "function" ? v() : v;
|
|
2984
|
+
/**
|
|
2985
|
+
* Declarative meta component for SSR-compatible page metadata.
|
|
2986
|
+
*
|
|
2987
|
+
* Supports reactive title/description — when passed as `() => string` accessors,
|
|
2988
|
+
* they are forwarded to `useHead()` as a reactive getter so updates propagate
|
|
2989
|
+
* automatically via signal tracking.
|
|
2990
|
+
*
|
|
2991
|
+
* @example
|
|
2992
|
+
* ```tsx
|
|
2993
|
+
* <Meta title="My Page" description="..." image="/og.jpg" canonical="https://..." />
|
|
2994
|
+
* ```
|
|
2995
|
+
*
|
|
2996
|
+
* @example Reactive title
|
|
2997
|
+
* ```tsx
|
|
2998
|
+
* const count = signal(0)
|
|
2999
|
+
* <Meta title={() => `${count()} items`} />
|
|
3000
|
+
* ```
|
|
3001
|
+
*/
|
|
3002
|
+
function Meta(props) {
|
|
3003
|
+
const hasReactiveTitle = typeof props.title === "function";
|
|
3004
|
+
const hasReactiveDescription = typeof props.description === "function";
|
|
3005
|
+
if (hasReactiveTitle || hasReactiveDescription) useHead((() => {
|
|
3006
|
+
const title = resolveStr(props.title);
|
|
3007
|
+
const description = resolveStr(props.description);
|
|
3008
|
+
const tags = buildMetaTags({
|
|
3009
|
+
...props,
|
|
3010
|
+
title,
|
|
3011
|
+
description
|
|
3012
|
+
});
|
|
3013
|
+
return {
|
|
3014
|
+
title,
|
|
3015
|
+
meta: tags.meta,
|
|
3016
|
+
link: tags.link,
|
|
3017
|
+
script: tags.script
|
|
3018
|
+
};
|
|
3019
|
+
}));
|
|
3020
|
+
else {
|
|
3021
|
+
const title = resolveStr(props.title);
|
|
3022
|
+
const description = resolveStr(props.description);
|
|
3023
|
+
const tags = buildMetaTags({
|
|
3024
|
+
...props,
|
|
3025
|
+
title,
|
|
3026
|
+
description
|
|
3027
|
+
});
|
|
3028
|
+
useHead({
|
|
3029
|
+
title,
|
|
3030
|
+
meta: tags.meta,
|
|
3031
|
+
link: tags.link,
|
|
3032
|
+
script: tags.script
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
return props.children ?? null;
|
|
3036
|
+
}
|
|
3037
|
+
function buildMetaTags(props) {
|
|
3038
|
+
const meta = [];
|
|
3039
|
+
const link = [];
|
|
3040
|
+
const script = [];
|
|
3041
|
+
const { title, description, canonical, image, imageAlt, type = "website", siteName, twitterCard = "summary_large_image", twitterSite, twitterCreator, locale = "en_US", alternateLocales, robots = "index, follow", publishedTime, modifiedTime, author, tags, jsonLd, extra } = props;
|
|
3042
|
+
if (description) meta.push({
|
|
3043
|
+
name: "description",
|
|
3044
|
+
content: description
|
|
3045
|
+
});
|
|
3046
|
+
if (robots) meta.push({
|
|
3047
|
+
name: "robots",
|
|
3048
|
+
content: robots
|
|
3049
|
+
});
|
|
3050
|
+
if (author) meta.push({
|
|
3051
|
+
name: "author",
|
|
3052
|
+
content: author
|
|
3053
|
+
});
|
|
3054
|
+
if (title) meta.push({
|
|
3055
|
+
property: "og:title",
|
|
3056
|
+
content: title
|
|
3057
|
+
});
|
|
3058
|
+
if (description) meta.push({
|
|
3059
|
+
property: "og:description",
|
|
3060
|
+
content: description
|
|
3061
|
+
});
|
|
3062
|
+
if (canonical) meta.push({
|
|
3063
|
+
property: "og:url",
|
|
3064
|
+
content: canonical
|
|
3065
|
+
});
|
|
3066
|
+
if (image) meta.push({
|
|
3067
|
+
property: "og:image",
|
|
3068
|
+
content: image
|
|
3069
|
+
});
|
|
3070
|
+
if (imageAlt) meta.push({
|
|
3071
|
+
property: "og:image:alt",
|
|
3072
|
+
content: imageAlt
|
|
3073
|
+
});
|
|
3074
|
+
meta.push({
|
|
3075
|
+
property: "og:type",
|
|
3076
|
+
content: type
|
|
3077
|
+
});
|
|
3078
|
+
if (siteName) meta.push({
|
|
3079
|
+
property: "og:site_name",
|
|
3080
|
+
content: siteName
|
|
3081
|
+
});
|
|
3082
|
+
meta.push({
|
|
3083
|
+
property: "og:locale",
|
|
3084
|
+
content: locale
|
|
3085
|
+
});
|
|
3086
|
+
if (type === "article") {
|
|
3087
|
+
if (publishedTime) meta.push({
|
|
3088
|
+
property: "article:published_time",
|
|
3089
|
+
content: publishedTime
|
|
3090
|
+
});
|
|
3091
|
+
if (modifiedTime) meta.push({
|
|
3092
|
+
property: "article:modified_time",
|
|
3093
|
+
content: modifiedTime
|
|
3094
|
+
});
|
|
3095
|
+
if (author) meta.push({
|
|
3096
|
+
property: "article:author",
|
|
3097
|
+
content: author
|
|
3098
|
+
});
|
|
3099
|
+
if (tags) for (const tag of tags) meta.push({
|
|
3100
|
+
property: "article:tag",
|
|
3101
|
+
content: tag
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
meta.push({
|
|
3105
|
+
name: "twitter:card",
|
|
3106
|
+
content: twitterCard
|
|
3107
|
+
});
|
|
3108
|
+
if (title) meta.push({
|
|
3109
|
+
name: "twitter:title",
|
|
3110
|
+
content: title
|
|
3111
|
+
});
|
|
3112
|
+
if (description) meta.push({
|
|
3113
|
+
name: "twitter:description",
|
|
3114
|
+
content: description
|
|
3115
|
+
});
|
|
3116
|
+
if (image) meta.push({
|
|
3117
|
+
name: "twitter:image",
|
|
3118
|
+
content: image
|
|
3119
|
+
});
|
|
3120
|
+
if (imageAlt) meta.push({
|
|
3121
|
+
name: "twitter:image:alt",
|
|
3122
|
+
content: imageAlt
|
|
3123
|
+
});
|
|
3124
|
+
if (twitterSite) meta.push({
|
|
3125
|
+
name: "twitter:site",
|
|
3126
|
+
content: twitterSite
|
|
3127
|
+
});
|
|
3128
|
+
if (twitterCreator) meta.push({
|
|
3129
|
+
name: "twitter:creator",
|
|
3130
|
+
content: twitterCreator
|
|
3131
|
+
});
|
|
3132
|
+
if (canonical) link.push({
|
|
3133
|
+
rel: "canonical",
|
|
3134
|
+
href: canonical
|
|
3135
|
+
});
|
|
3136
|
+
if (alternateLocales) for (const alt of alternateLocales) link.push({
|
|
3137
|
+
rel: "alternate",
|
|
3138
|
+
hreflang: alt.locale,
|
|
3139
|
+
href: alt.url
|
|
3140
|
+
});
|
|
3141
|
+
if (jsonLd) script.push({
|
|
3142
|
+
type: "application/ld+json",
|
|
3143
|
+
children: JSON.stringify({
|
|
3144
|
+
"@context": "https://schema.org",
|
|
3145
|
+
...jsonLd
|
|
3146
|
+
})
|
|
3147
|
+
});
|
|
3148
|
+
if (extra) for (const tag of extra) meta.push(tag);
|
|
3149
|
+
if (props.i18n) {
|
|
3150
|
+
const i18nConfig = props.i18n;
|
|
3151
|
+
const origin = props.origin ?? "";
|
|
3152
|
+
const { pathWithoutLocale } = extractLocaleFromPath(canonical?.replace(origin, "") ?? "/", i18nConfig.locales, i18nConfig.defaultLocale);
|
|
3153
|
+
const strategy = i18nConfig.strategy ?? "prefix-except-default";
|
|
3154
|
+
for (const loc of i18nConfig.locales) {
|
|
3155
|
+
const localizedPath = strategy === "prefix-except-default" && loc === i18nConfig.defaultLocale ? pathWithoutLocale : `/${loc}${pathWithoutLocale === "/" ? "" : pathWithoutLocale}`;
|
|
3156
|
+
link.push({
|
|
3157
|
+
rel: "alternate",
|
|
3158
|
+
hreflang: loc,
|
|
3159
|
+
href: `${origin}${localizedPath}`
|
|
3160
|
+
});
|
|
3161
|
+
if (loc !== locale) meta.push({
|
|
3162
|
+
property: "og:locale:alternate",
|
|
3163
|
+
content: loc
|
|
3164
|
+
});
|
|
3165
|
+
}
|
|
3166
|
+
link.push({
|
|
3167
|
+
rel: "alternate",
|
|
3168
|
+
hreflang: "x-default",
|
|
3169
|
+
href: `${origin}${pathWithoutLocale}`
|
|
3170
|
+
});
|
|
3171
|
+
}
|
|
3172
|
+
return {
|
|
3173
|
+
meta,
|
|
3174
|
+
link,
|
|
3175
|
+
script
|
|
3176
|
+
};
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
//#endregion
|
|
3180
|
+
export { Image, Link, Meta, Script, ThemeToggle, buildLocalePath, buildMetaTags, bunAdapter, cacheMiddleware, compose, compressResponse, compressionMiddleware, corsMiddleware, createActionMiddleware, createApiMiddleware, createApp, createISRHandler, createLink, createLocaleContext, createServer, zeroPlugin as default, defineAction, defineConfig, detectLocaleFromHeader, extractLocaleFromPath, faviconPlugin, filePathToUrlPath, fontPlugin, fontVariables, generateApiRouteModule, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, imagePlugin, initTheme, isCompressible, jsonLd, nodeAdapter, parseFileRoutes, prefetchRoute, rateLimitMiddleware, render404Page, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, securityHeaders, seoMiddleware, seoPlugin, setLocale, setTheme, staticAdapter, theme, themeScript, toggleTheme, useLink, useLocale, varyEncoding };
|
|
2312
3181
|
//# sourceMappingURL=index.js.map
|