@pyreon/zero 0.11.8 → 0.11.9
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/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/index.js +872 -17
- package/lib/index.js.map +1 -1
- package/lib/link.js +12 -1
- package/lib/link.js.map +1 -1
- package/package.json +10 -10
- package/src/entry-server.ts +124 -76
- package/src/favicon.ts +380 -0
- package/src/fs-router.ts +54 -13
- package/src/i18n-routing.ts +299 -0
- package/src/index.ts +125 -76
- package/src/link.tsx +12 -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
|
*
|
|
@@ -1202,6 +1318,57 @@ function varyEncoding() {
|
|
|
1202
1318
|
};
|
|
1203
1319
|
}
|
|
1204
1320
|
|
|
1321
|
+
//#endregion
|
|
1322
|
+
//#region src/middleware.ts
|
|
1323
|
+
/**
|
|
1324
|
+
* Compose multiple middleware into a single middleware function.
|
|
1325
|
+
* Middleware runs sequentially — if any returns a Response, the chain stops.
|
|
1326
|
+
*
|
|
1327
|
+
* @example
|
|
1328
|
+
* import { compose } from "@pyreon/zero/middleware"
|
|
1329
|
+
* import { corsMiddleware } from "@pyreon/zero/cors"
|
|
1330
|
+
* import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
|
|
1331
|
+
*
|
|
1332
|
+
* const combined = compose(
|
|
1333
|
+
* corsMiddleware({ origin: "*" }),
|
|
1334
|
+
* rateLimitMiddleware({ max: 100 }),
|
|
1335
|
+
* cacheMiddleware(),
|
|
1336
|
+
* )
|
|
1337
|
+
*/
|
|
1338
|
+
function compose(...middlewares) {
|
|
1339
|
+
return async (ctx) => {
|
|
1340
|
+
for (const mw of middlewares) {
|
|
1341
|
+
const result = await mw(ctx);
|
|
1342
|
+
if (result instanceof Response) return result;
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
const ZERO_CTX_KEY = "__zeroCtx";
|
|
1347
|
+
/**
|
|
1348
|
+
* Get the shared Zero context from a middleware context.
|
|
1349
|
+
* Creates one if it doesn't exist. Middleware can use this to
|
|
1350
|
+
* pass data to downstream middleware without polluting `ctx.locals`.
|
|
1351
|
+
*
|
|
1352
|
+
* @example
|
|
1353
|
+
* const authMiddleware: Middleware = (ctx) => {
|
|
1354
|
+
* const zctx = getContext(ctx)
|
|
1355
|
+
* zctx.userId = "user_123"
|
|
1356
|
+
* }
|
|
1357
|
+
*
|
|
1358
|
+
* const loggingMiddleware: Middleware = (ctx) => {
|
|
1359
|
+
* const zctx = getContext(ctx)
|
|
1360
|
+
* console.log("User:", zctx.userId)
|
|
1361
|
+
* }
|
|
1362
|
+
*/
|
|
1363
|
+
function getContext(ctx) {
|
|
1364
|
+
let zctx = ctx.locals[ZERO_CTX_KEY];
|
|
1365
|
+
if (!zctx) {
|
|
1366
|
+
zctx = {};
|
|
1367
|
+
ctx.locals[ZERO_CTX_KEY] = zctx;
|
|
1368
|
+
}
|
|
1369
|
+
return zctx;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1205
1372
|
//#endregion
|
|
1206
1373
|
//#region src/font.ts
|
|
1207
1374
|
/**
|
|
@@ -1454,10 +1621,10 @@ function fontVariables(families) {
|
|
|
1454
1621
|
|
|
1455
1622
|
//#endregion
|
|
1456
1623
|
//#region src/image-plugin.ts
|
|
1457
|
-
let sharpWarned = false;
|
|
1458
|
-
function warnSharpMissing() {
|
|
1459
|
-
if (sharpWarned) return;
|
|
1460
|
-
sharpWarned = true;
|
|
1624
|
+
let sharpWarned$1 = false;
|
|
1625
|
+
function warnSharpMissing$1() {
|
|
1626
|
+
if (sharpWarned$1) return;
|
|
1627
|
+
sharpWarned$1 = true;
|
|
1461
1628
|
console.warn("\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n");
|
|
1462
1629
|
}
|
|
1463
1630
|
const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i;
|
|
@@ -1720,7 +1887,7 @@ async function resizeImage(input, output, width, format, quality) {
|
|
|
1720
1887
|
}
|
|
1721
1888
|
await pipeline.toFile(output);
|
|
1722
1889
|
} catch {
|
|
1723
|
-
warnSharpMissing();
|
|
1890
|
+
warnSharpMissing$1();
|
|
1724
1891
|
await writeFile(output, await readFile(input));
|
|
1725
1892
|
}
|
|
1726
1893
|
}
|
|
@@ -1991,7 +2158,7 @@ function seoPlugin(config = {}) {
|
|
|
1991
2158
|
apply: "build",
|
|
1992
2159
|
async generateBundle(_, _bundle) {
|
|
1993
2160
|
if (config.sitemap) {
|
|
1994
|
-
const { scanRouteFiles } = await import("./fs-router-
|
|
2161
|
+
const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
|
|
1995
2162
|
const routesDir = `${process.cwd()}/src/routes`;
|
|
1996
2163
|
try {
|
|
1997
2164
|
const sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
|
|
@@ -2021,7 +2188,7 @@ function seoMiddleware(config = {}) {
|
|
|
2021
2188
|
return async (ctx) => {
|
|
2022
2189
|
if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
|
|
2023
2190
|
if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
|
|
2024
|
-
const { scanRouteFiles } = await import("./fs-router-
|
|
2191
|
+
const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
|
|
2025
2192
|
const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
|
|
2026
2193
|
return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
|
|
2027
2194
|
} catch {}
|
|
@@ -2308,5 +2475,693 @@ async function executeAction(action, req) {
|
|
|
2308
2475
|
}
|
|
2309
2476
|
|
|
2310
2477
|
//#endregion
|
|
2311
|
-
|
|
2478
|
+
//#region src/favicon.ts
|
|
2479
|
+
let sharpWarned = false;
|
|
2480
|
+
function warnSharpMissing() {
|
|
2481
|
+
if (sharpWarned) return;
|
|
2482
|
+
sharpWarned = true;
|
|
2483
|
+
console.warn("\n[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
|
|
2484
|
+
}
|
|
2485
|
+
const SIZES = [
|
|
2486
|
+
{
|
|
2487
|
+
size: 16,
|
|
2488
|
+
name: "favicon-16x16.png"
|
|
2489
|
+
},
|
|
2490
|
+
{
|
|
2491
|
+
size: 32,
|
|
2492
|
+
name: "favicon-32x32.png"
|
|
2493
|
+
},
|
|
2494
|
+
{
|
|
2495
|
+
size: 180,
|
|
2496
|
+
name: "apple-touch-icon.png"
|
|
2497
|
+
},
|
|
2498
|
+
{
|
|
2499
|
+
size: 192,
|
|
2500
|
+
name: "icon-192.png"
|
|
2501
|
+
},
|
|
2502
|
+
{
|
|
2503
|
+
size: 512,
|
|
2504
|
+
name: "icon-512.png"
|
|
2505
|
+
}
|
|
2506
|
+
];
|
|
2507
|
+
/**
|
|
2508
|
+
* Favicon generation Vite plugin.
|
|
2509
|
+
*
|
|
2510
|
+
* Generates all required favicon formats at build time from a single source.
|
|
2511
|
+
* In dev mode, serves the source directly.
|
|
2512
|
+
*
|
|
2513
|
+
* @example
|
|
2514
|
+
* ```ts
|
|
2515
|
+
* // vite.config.ts
|
|
2516
|
+
* import { faviconPlugin } from "@pyreon/zero"
|
|
2517
|
+
*
|
|
2518
|
+
* export default {
|
|
2519
|
+
* plugins: [faviconPlugin({ source: "./src/assets/icon.svg" })],
|
|
2520
|
+
* }
|
|
2521
|
+
* ```
|
|
2522
|
+
*/
|
|
2523
|
+
function faviconPlugin(config) {
|
|
2524
|
+
const themeColor = config.themeColor ?? "#ffffff";
|
|
2525
|
+
const backgroundColor = config.backgroundColor ?? "#ffffff";
|
|
2526
|
+
const generateManifest = config.manifest !== false;
|
|
2527
|
+
let root = "";
|
|
2528
|
+
let isBuild = false;
|
|
2529
|
+
return {
|
|
2530
|
+
name: "pyreon-zero-favicon",
|
|
2531
|
+
enforce: "pre",
|
|
2532
|
+
configResolved(resolvedConfig) {
|
|
2533
|
+
root = resolvedConfig.root;
|
|
2534
|
+
isBuild = resolvedConfig.command === "build";
|
|
2535
|
+
},
|
|
2536
|
+
configureServer(server) {
|
|
2537
|
+
const sourcePath = join(root, config.source);
|
|
2538
|
+
server.middlewares.use(async (req, res, next) => {
|
|
2539
|
+
const url = req.url ?? "";
|
|
2540
|
+
if (url === "/favicon.svg" && config.source.endsWith(".svg")) try {
|
|
2541
|
+
const content = await readFile(sourcePath, "utf-8");
|
|
2542
|
+
res.setHeader("Content-Type", "image/svg+xml");
|
|
2543
|
+
res.end(content);
|
|
2544
|
+
return;
|
|
2545
|
+
} catch {}
|
|
2546
|
+
const sizeMatch = SIZES.find((s) => url === `/${s.name}`);
|
|
2547
|
+
if (sizeMatch) {
|
|
2548
|
+
const png = await resizeToPng(sourcePath, sizeMatch.size);
|
|
2549
|
+
if (png) {
|
|
2550
|
+
res.setHeader("Content-Type", "image/png");
|
|
2551
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
2552
|
+
res.end(Buffer.from(png));
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
if (url === "/favicon.ico") {
|
|
2557
|
+
const ico = await generateIco(sourcePath);
|
|
2558
|
+
if (ico) {
|
|
2559
|
+
res.setHeader("Content-Type", "image/x-icon");
|
|
2560
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
2561
|
+
res.end(Buffer.from(ico));
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
if (url === "/site.webmanifest" && generateManifest) {
|
|
2566
|
+
const manifest = {
|
|
2567
|
+
name: config.name ?? "App",
|
|
2568
|
+
short_name: config.name ?? "App",
|
|
2569
|
+
icons: [{
|
|
2570
|
+
src: "/icon-192.png",
|
|
2571
|
+
sizes: "192x192",
|
|
2572
|
+
type: "image/png"
|
|
2573
|
+
}, {
|
|
2574
|
+
src: "/icon-512.png",
|
|
2575
|
+
sizes: "512x512",
|
|
2576
|
+
type: "image/png"
|
|
2577
|
+
}],
|
|
2578
|
+
theme_color: themeColor,
|
|
2579
|
+
background_color: backgroundColor,
|
|
2580
|
+
display: "standalone"
|
|
2581
|
+
};
|
|
2582
|
+
res.setHeader("Content-Type", "application/manifest+json");
|
|
2583
|
+
res.end(JSON.stringify(manifest, null, 2));
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
next();
|
|
2587
|
+
});
|
|
2588
|
+
},
|
|
2589
|
+
transformIndexHtml() {
|
|
2590
|
+
const isSvg = config.source.endsWith(".svg");
|
|
2591
|
+
const tags = [];
|
|
2592
|
+
if (isSvg) tags.push({
|
|
2593
|
+
tag: "link",
|
|
2594
|
+
attrs: {
|
|
2595
|
+
rel: "icon",
|
|
2596
|
+
type: "image/svg+xml",
|
|
2597
|
+
href: "/favicon.svg"
|
|
2598
|
+
},
|
|
2599
|
+
injectTo: "head"
|
|
2600
|
+
});
|
|
2601
|
+
tags.push({
|
|
2602
|
+
tag: "link",
|
|
2603
|
+
attrs: {
|
|
2604
|
+
rel: "icon",
|
|
2605
|
+
type: "image/png",
|
|
2606
|
+
sizes: "32x32",
|
|
2607
|
+
href: "/favicon-32x32.png"
|
|
2608
|
+
},
|
|
2609
|
+
injectTo: "head"
|
|
2610
|
+
}, {
|
|
2611
|
+
tag: "link",
|
|
2612
|
+
attrs: {
|
|
2613
|
+
rel: "icon",
|
|
2614
|
+
type: "image/png",
|
|
2615
|
+
sizes: "16x16",
|
|
2616
|
+
href: "/favicon-16x16.png"
|
|
2617
|
+
},
|
|
2618
|
+
injectTo: "head"
|
|
2619
|
+
}, {
|
|
2620
|
+
tag: "link",
|
|
2621
|
+
attrs: {
|
|
2622
|
+
rel: "apple-touch-icon",
|
|
2623
|
+
sizes: "180x180",
|
|
2624
|
+
href: "/apple-touch-icon.png"
|
|
2625
|
+
},
|
|
2626
|
+
injectTo: "head"
|
|
2627
|
+
});
|
|
2628
|
+
if (generateManifest) tags.push({
|
|
2629
|
+
tag: "link",
|
|
2630
|
+
attrs: {
|
|
2631
|
+
rel: "manifest",
|
|
2632
|
+
href: "/site.webmanifest"
|
|
2633
|
+
},
|
|
2634
|
+
injectTo: "head"
|
|
2635
|
+
});
|
|
2636
|
+
tags.push({
|
|
2637
|
+
tag: "meta",
|
|
2638
|
+
attrs: {
|
|
2639
|
+
name: "theme-color",
|
|
2640
|
+
content: themeColor
|
|
2641
|
+
},
|
|
2642
|
+
injectTo: "head"
|
|
2643
|
+
});
|
|
2644
|
+
return tags;
|
|
2645
|
+
},
|
|
2646
|
+
async generateBundle() {
|
|
2647
|
+
if (!isBuild) return;
|
|
2648
|
+
const sourcePath = join(root, config.source);
|
|
2649
|
+
if (!existsSync(sourcePath)) {
|
|
2650
|
+
console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
if (config.source.endsWith(".svg")) {
|
|
2654
|
+
const svgContent = await readFile(sourcePath, "utf-8");
|
|
2655
|
+
let finalSvg = svgContent;
|
|
2656
|
+
if (config.darkSource) {
|
|
2657
|
+
const darkPath = join(root, config.darkSource);
|
|
2658
|
+
if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
|
|
2659
|
+
}
|
|
2660
|
+
this.emitFile({
|
|
2661
|
+
type: "asset",
|
|
2662
|
+
fileName: "favicon.svg",
|
|
2663
|
+
source: finalSvg
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
for (const { size, name } of SIZES) {
|
|
2667
|
+
const pngBuffer = await resizeToPng(sourcePath, size);
|
|
2668
|
+
if (pngBuffer) this.emitFile({
|
|
2669
|
+
type: "asset",
|
|
2670
|
+
fileName: name,
|
|
2671
|
+
source: pngBuffer
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
const ico = await generateIco(sourcePath);
|
|
2675
|
+
if (ico) this.emitFile({
|
|
2676
|
+
type: "asset",
|
|
2677
|
+
fileName: "favicon.ico",
|
|
2678
|
+
source: ico
|
|
2679
|
+
});
|
|
2680
|
+
if (generateManifest) {
|
|
2681
|
+
const manifest = {
|
|
2682
|
+
name: config.name ?? "App",
|
|
2683
|
+
short_name: config.name ?? "App",
|
|
2684
|
+
icons: [{
|
|
2685
|
+
src: "/icon-192.png",
|
|
2686
|
+
sizes: "192x192",
|
|
2687
|
+
type: "image/png"
|
|
2688
|
+
}, {
|
|
2689
|
+
src: "/icon-512.png",
|
|
2690
|
+
sizes: "512x512",
|
|
2691
|
+
type: "image/png"
|
|
2692
|
+
}],
|
|
2693
|
+
theme_color: themeColor,
|
|
2694
|
+
background_color: backgroundColor,
|
|
2695
|
+
display: "standalone"
|
|
2696
|
+
};
|
|
2697
|
+
this.emitFile({
|
|
2698
|
+
type: "asset",
|
|
2699
|
+
fileName: "site.webmanifest",
|
|
2700
|
+
source: JSON.stringify(manifest, null, 2)
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.
|
|
2708
|
+
*/
|
|
2709
|
+
function wrapSvgWithDarkMode(lightSvg, darkSvg) {
|
|
2710
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${lightSvg.match(/viewBox="([^"]*)"/)?.[1] ?? "0 0 32 32"}">
|
|
2711
|
+
<style>
|
|
2712
|
+
:root { color-scheme: light dark; }
|
|
2713
|
+
@media (prefers-color-scheme: dark) { .light { display: none; } }
|
|
2714
|
+
@media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }
|
|
2715
|
+
</style>
|
|
2716
|
+
<g class="light">${stripSvgWrapper(lightSvg)}</g>
|
|
2717
|
+
<g class="dark">${stripSvgWrapper(darkSvg)}</g>
|
|
2718
|
+
</svg>`;
|
|
2719
|
+
}
|
|
2720
|
+
function stripSvgWrapper(svg) {
|
|
2721
|
+
return svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
|
|
2722
|
+
}
|
|
2723
|
+
async function resizeToPng(input, size) {
|
|
2724
|
+
try {
|
|
2725
|
+
return await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, {
|
|
2726
|
+
fit: "contain",
|
|
2727
|
+
background: {
|
|
2728
|
+
r: 0,
|
|
2729
|
+
g: 0,
|
|
2730
|
+
b: 0,
|
|
2731
|
+
alpha: 0
|
|
2732
|
+
}
|
|
2733
|
+
}).png().toBuffer();
|
|
2734
|
+
} catch {
|
|
2735
|
+
warnSharpMissing();
|
|
2736
|
+
return null;
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
async function generateIco(input) {
|
|
2740
|
+
try {
|
|
2741
|
+
const sharp = await import("sharp").then((m) => m.default ?? m);
|
|
2742
|
+
const png16 = await sharp(input).resize(16, 16, {
|
|
2743
|
+
fit: "contain",
|
|
2744
|
+
background: {
|
|
2745
|
+
r: 0,
|
|
2746
|
+
g: 0,
|
|
2747
|
+
b: 0,
|
|
2748
|
+
alpha: 0
|
|
2749
|
+
}
|
|
2750
|
+
}).png().toBuffer();
|
|
2751
|
+
const png32 = await sharp(input).resize(32, 32, {
|
|
2752
|
+
fit: "contain",
|
|
2753
|
+
background: {
|
|
2754
|
+
r: 0,
|
|
2755
|
+
g: 0,
|
|
2756
|
+
b: 0,
|
|
2757
|
+
alpha: 0
|
|
2758
|
+
}
|
|
2759
|
+
}).png().toBuffer();
|
|
2760
|
+
return createIcoFromPngs([{
|
|
2761
|
+
buffer: png16,
|
|
2762
|
+
size: 16
|
|
2763
|
+
}, {
|
|
2764
|
+
buffer: png32,
|
|
2765
|
+
size: 32
|
|
2766
|
+
}]);
|
|
2767
|
+
} catch {
|
|
2768
|
+
warnSharpMissing();
|
|
2769
|
+
return null;
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
/** @internal Exported for testing */
|
|
2773
|
+
function createIcoFromPngs(entries) {
|
|
2774
|
+
const headerSize = 6;
|
|
2775
|
+
const dirEntrySize = 16;
|
|
2776
|
+
const dirSize = dirEntrySize * entries.length;
|
|
2777
|
+
let dataOffset = headerSize + dirSize;
|
|
2778
|
+
const header = Buffer.alloc(headerSize);
|
|
2779
|
+
header.writeUInt16LE(0, 0);
|
|
2780
|
+
header.writeUInt16LE(1, 2);
|
|
2781
|
+
header.writeUInt16LE(entries.length, 4);
|
|
2782
|
+
const dirEntries = Buffer.alloc(dirSize);
|
|
2783
|
+
const dataBuffers = [];
|
|
2784
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2785
|
+
const entry = entries[i];
|
|
2786
|
+
const offset = i * dirEntrySize;
|
|
2787
|
+
dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset);
|
|
2788
|
+
dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1);
|
|
2789
|
+
dirEntries.writeUInt8(0, offset + 2);
|
|
2790
|
+
dirEntries.writeUInt8(0, offset + 3);
|
|
2791
|
+
dirEntries.writeUInt16LE(1, offset + 4);
|
|
2792
|
+
dirEntries.writeUInt16LE(32, offset + 6);
|
|
2793
|
+
dirEntries.writeUInt32LE(entry.buffer.length, offset + 8);
|
|
2794
|
+
dirEntries.writeUInt32LE(dataOffset, offset + 12);
|
|
2795
|
+
dataOffset += entry.buffer.length;
|
|
2796
|
+
dataBuffers.push(entry.buffer);
|
|
2797
|
+
}
|
|
2798
|
+
return Buffer.concat([
|
|
2799
|
+
header,
|
|
2800
|
+
dirEntries,
|
|
2801
|
+
...dataBuffers
|
|
2802
|
+
]);
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
//#endregion
|
|
2806
|
+
//#region src/i18n-routing.ts
|
|
2807
|
+
/**
|
|
2808
|
+
* Detect preferred locale from Accept-Language header.
|
|
2809
|
+
*/
|
|
2810
|
+
function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
|
|
2811
|
+
if (!acceptLanguage) return defaultLocale;
|
|
2812
|
+
const preferred = acceptLanguage.split(",").map((part) => {
|
|
2813
|
+
const [lang, q] = part.trim().split(";q=");
|
|
2814
|
+
return {
|
|
2815
|
+
lang: lang?.split("-")[0]?.toLowerCase() ?? "",
|
|
2816
|
+
quality: q ? Number.parseFloat(q) : 1
|
|
2817
|
+
};
|
|
2818
|
+
}).sort((a, b) => b.quality - a.quality);
|
|
2819
|
+
for (const { lang } of preferred) if (locales.includes(lang)) return lang;
|
|
2820
|
+
return defaultLocale;
|
|
2821
|
+
}
|
|
2822
|
+
/**
|
|
2823
|
+
* Extract locale from a URL path.
|
|
2824
|
+
* Returns { locale, pathWithoutLocale }.
|
|
2825
|
+
*/
|
|
2826
|
+
function extractLocaleFromPath(path, locales, defaultLocale) {
|
|
2827
|
+
const segments = path.split("/").filter(Boolean);
|
|
2828
|
+
const firstSegment = segments[0]?.toLowerCase();
|
|
2829
|
+
if (firstSegment && locales.includes(firstSegment)) return {
|
|
2830
|
+
locale: firstSegment,
|
|
2831
|
+
pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
|
|
2832
|
+
};
|
|
2833
|
+
return {
|
|
2834
|
+
locale: defaultLocale,
|
|
2835
|
+
pathWithoutLocale: path
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2838
|
+
/**
|
|
2839
|
+
* Build a localized path.
|
|
2840
|
+
*/
|
|
2841
|
+
function buildLocalePath(path, locale, defaultLocale, strategy) {
|
|
2842
|
+
const clean = path === "/" ? "" : path;
|
|
2843
|
+
if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
|
|
2844
|
+
return `/${locale}${clean}`;
|
|
2845
|
+
}
|
|
2846
|
+
/**
|
|
2847
|
+
* Create a LocaleContext for use in components and loaders.
|
|
2848
|
+
*/
|
|
2849
|
+
function createLocaleContext(locale, path, config) {
|
|
2850
|
+
const strategy = config.strategy ?? "prefix-except-default";
|
|
2851
|
+
return {
|
|
2852
|
+
locale,
|
|
2853
|
+
locales: config.locales,
|
|
2854
|
+
defaultLocale: config.defaultLocale,
|
|
2855
|
+
localePath(targetPath, targetLocale) {
|
|
2856
|
+
return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
|
|
2857
|
+
},
|
|
2858
|
+
alternates() {
|
|
2859
|
+
const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
|
|
2860
|
+
return config.locales.map((loc) => ({
|
|
2861
|
+
locale: loc,
|
|
2862
|
+
url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
|
|
2863
|
+
}));
|
|
2864
|
+
}
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
2867
|
+
/**
|
|
2868
|
+
* I18n routing middleware for Zero's server.
|
|
2869
|
+
*
|
|
2870
|
+
* - Detects locale from URL prefix or Accept-Language header
|
|
2871
|
+
* - Redirects root to preferred locale (when detectLocale is true)
|
|
2872
|
+
* - Sets locale context for loaders and components
|
|
2873
|
+
*
|
|
2874
|
+
* @example
|
|
2875
|
+
* ```ts
|
|
2876
|
+
* // zero.config.ts
|
|
2877
|
+
* import { i18nRouting } from "@pyreon/zero"
|
|
2878
|
+
*
|
|
2879
|
+
* export default defineConfig({
|
|
2880
|
+
* plugins: [
|
|
2881
|
+
* i18nRouting({
|
|
2882
|
+
* locales: ["en", "de", "cs"],
|
|
2883
|
+
* defaultLocale: "en",
|
|
2884
|
+
* }),
|
|
2885
|
+
* ],
|
|
2886
|
+
* })
|
|
2887
|
+
* ```
|
|
2888
|
+
*/
|
|
2889
|
+
function i18nRouting(config) {
|
|
2890
|
+
const strategy = config.strategy ?? "prefix-except-default";
|
|
2891
|
+
const detectEnabled = config.detectLocale !== false;
|
|
2892
|
+
const cookieName = config.cookieName ?? "locale";
|
|
2893
|
+
return {
|
|
2894
|
+
name: "pyreon-zero-i18n-routing",
|
|
2895
|
+
configResolved() {},
|
|
2896
|
+
configureServer(server) {
|
|
2897
|
+
server.middlewares.use((req, res, next) => {
|
|
2898
|
+
const url = req.url ?? "/";
|
|
2899
|
+
if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
|
|
2900
|
+
const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
|
|
2901
|
+
if (detectEnabled && url === "/") {
|
|
2902
|
+
const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
|
|
2903
|
+
const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
|
|
2904
|
+
const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
|
|
2905
|
+
if (strategy === "prefix" || preferred !== config.defaultLocale) {
|
|
2906
|
+
res.writeHead(302, { Location: `/${preferred}/` });
|
|
2907
|
+
res.end();
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
req.__locale = locale;
|
|
2912
|
+
req.__localeContext = createLocaleContext(locale, url, config);
|
|
2913
|
+
localeSignal.set(locale);
|
|
2914
|
+
next();
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
};
|
|
2918
|
+
}
|
|
2919
|
+
function parseCookies(header) {
|
|
2920
|
+
if (!header) return {};
|
|
2921
|
+
const result = {};
|
|
2922
|
+
for (const pair of header.split(";")) {
|
|
2923
|
+
const [key, value] = pair.trim().split("=");
|
|
2924
|
+
if (key && value) result[key] = decodeURIComponent(value);
|
|
2925
|
+
}
|
|
2926
|
+
return result;
|
|
2927
|
+
}
|
|
2928
|
+
/** @internal Context for the current locale. */
|
|
2929
|
+
const LocaleCtx = createContext("en");
|
|
2930
|
+
/** Current locale signal — set by the server middleware or client-side detection. */
|
|
2931
|
+
const localeSignal = signal("en");
|
|
2932
|
+
/**
|
|
2933
|
+
* Read the current locale reactively.
|
|
2934
|
+
*
|
|
2935
|
+
* Returns the locale signal value directly — reactive in both SSR and CSR.
|
|
2936
|
+
* The server middleware sets `localeSignal` per-request, and client-side
|
|
2937
|
+
* `setLocale()` updates it as well.
|
|
2938
|
+
*
|
|
2939
|
+
* @example
|
|
2940
|
+
* ```tsx
|
|
2941
|
+
* const locale = useLocale() // "en", "de", etc.
|
|
2942
|
+
* ```
|
|
2943
|
+
*/
|
|
2944
|
+
function useLocale() {
|
|
2945
|
+
return localeSignal();
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* Set the locale client-side and update the URL.
|
|
2949
|
+
*
|
|
2950
|
+
* @example
|
|
2951
|
+
* ```tsx
|
|
2952
|
+
* <button onClick={() => setLocale('de')}>Deutsch</button>
|
|
2953
|
+
* ```
|
|
2954
|
+
*/
|
|
2955
|
+
function setLocale(locale, config) {
|
|
2956
|
+
localeSignal.set(locale);
|
|
2957
|
+
if (typeof document !== "undefined") document.cookie = `${config.cookieName ?? "locale"}=${locale}; path=/; max-age=31536000`;
|
|
2958
|
+
if (typeof window !== "undefined") {
|
|
2959
|
+
const strategy = config.strategy ?? "prefix-except-default";
|
|
2960
|
+
const { pathWithoutLocale } = extractLocaleFromPath(window.location.pathname, config.locales, config.defaultLocale);
|
|
2961
|
+
const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy);
|
|
2962
|
+
window.history.pushState(null, "", newPath);
|
|
2963
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
//#endregion
|
|
2968
|
+
//#region src/meta.tsx
|
|
2969
|
+
const resolveStr = (v) => typeof v === "function" ? v() : v;
|
|
2970
|
+
/**
|
|
2971
|
+
* Declarative meta component for SSR-compatible page metadata.
|
|
2972
|
+
*
|
|
2973
|
+
* Supports reactive title/description — when passed as `() => string` accessors,
|
|
2974
|
+
* they are forwarded to `useHead()` as a reactive getter so updates propagate
|
|
2975
|
+
* automatically via signal tracking.
|
|
2976
|
+
*
|
|
2977
|
+
* @example
|
|
2978
|
+
* ```tsx
|
|
2979
|
+
* <Meta title="My Page" description="..." image="/og.jpg" canonical="https://..." />
|
|
2980
|
+
* ```
|
|
2981
|
+
*
|
|
2982
|
+
* @example Reactive title
|
|
2983
|
+
* ```tsx
|
|
2984
|
+
* const count = signal(0)
|
|
2985
|
+
* <Meta title={() => `${count()} items`} />
|
|
2986
|
+
* ```
|
|
2987
|
+
*/
|
|
2988
|
+
function Meta(props) {
|
|
2989
|
+
const hasReactiveTitle = typeof props.title === "function";
|
|
2990
|
+
const hasReactiveDescription = typeof props.description === "function";
|
|
2991
|
+
if (hasReactiveTitle || hasReactiveDescription) useHead((() => {
|
|
2992
|
+
const title = resolveStr(props.title);
|
|
2993
|
+
const description = resolveStr(props.description);
|
|
2994
|
+
const tags = buildMetaTags({
|
|
2995
|
+
...props,
|
|
2996
|
+
title,
|
|
2997
|
+
description
|
|
2998
|
+
});
|
|
2999
|
+
return {
|
|
3000
|
+
title,
|
|
3001
|
+
meta: tags.meta,
|
|
3002
|
+
link: tags.link,
|
|
3003
|
+
script: tags.script
|
|
3004
|
+
};
|
|
3005
|
+
}));
|
|
3006
|
+
else {
|
|
3007
|
+
const title = resolveStr(props.title);
|
|
3008
|
+
const description = resolveStr(props.description);
|
|
3009
|
+
const tags = buildMetaTags({
|
|
3010
|
+
...props,
|
|
3011
|
+
title,
|
|
3012
|
+
description
|
|
3013
|
+
});
|
|
3014
|
+
useHead({
|
|
3015
|
+
title,
|
|
3016
|
+
meta: tags.meta,
|
|
3017
|
+
link: tags.link,
|
|
3018
|
+
script: tags.script
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
return props.children ?? null;
|
|
3022
|
+
}
|
|
3023
|
+
function buildMetaTags(props) {
|
|
3024
|
+
const meta = [];
|
|
3025
|
+
const link = [];
|
|
3026
|
+
const script = [];
|
|
3027
|
+
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;
|
|
3028
|
+
if (description) meta.push({
|
|
3029
|
+
name: "description",
|
|
3030
|
+
content: description
|
|
3031
|
+
});
|
|
3032
|
+
if (robots) meta.push({
|
|
3033
|
+
name: "robots",
|
|
3034
|
+
content: robots
|
|
3035
|
+
});
|
|
3036
|
+
if (author) meta.push({
|
|
3037
|
+
name: "author",
|
|
3038
|
+
content: author
|
|
3039
|
+
});
|
|
3040
|
+
if (title) meta.push({
|
|
3041
|
+
property: "og:title",
|
|
3042
|
+
content: title
|
|
3043
|
+
});
|
|
3044
|
+
if (description) meta.push({
|
|
3045
|
+
property: "og:description",
|
|
3046
|
+
content: description
|
|
3047
|
+
});
|
|
3048
|
+
if (canonical) meta.push({
|
|
3049
|
+
property: "og:url",
|
|
3050
|
+
content: canonical
|
|
3051
|
+
});
|
|
3052
|
+
if (image) meta.push({
|
|
3053
|
+
property: "og:image",
|
|
3054
|
+
content: image
|
|
3055
|
+
});
|
|
3056
|
+
if (imageAlt) meta.push({
|
|
3057
|
+
property: "og:image:alt",
|
|
3058
|
+
content: imageAlt
|
|
3059
|
+
});
|
|
3060
|
+
meta.push({
|
|
3061
|
+
property: "og:type",
|
|
3062
|
+
content: type
|
|
3063
|
+
});
|
|
3064
|
+
if (siteName) meta.push({
|
|
3065
|
+
property: "og:site_name",
|
|
3066
|
+
content: siteName
|
|
3067
|
+
});
|
|
3068
|
+
meta.push({
|
|
3069
|
+
property: "og:locale",
|
|
3070
|
+
content: locale
|
|
3071
|
+
});
|
|
3072
|
+
if (type === "article") {
|
|
3073
|
+
if (publishedTime) meta.push({
|
|
3074
|
+
property: "article:published_time",
|
|
3075
|
+
content: publishedTime
|
|
3076
|
+
});
|
|
3077
|
+
if (modifiedTime) meta.push({
|
|
3078
|
+
property: "article:modified_time",
|
|
3079
|
+
content: modifiedTime
|
|
3080
|
+
});
|
|
3081
|
+
if (author) meta.push({
|
|
3082
|
+
property: "article:author",
|
|
3083
|
+
content: author
|
|
3084
|
+
});
|
|
3085
|
+
if (tags) for (const tag of tags) meta.push({
|
|
3086
|
+
property: "article:tag",
|
|
3087
|
+
content: tag
|
|
3088
|
+
});
|
|
3089
|
+
}
|
|
3090
|
+
meta.push({
|
|
3091
|
+
name: "twitter:card",
|
|
3092
|
+
content: twitterCard
|
|
3093
|
+
});
|
|
3094
|
+
if (title) meta.push({
|
|
3095
|
+
name: "twitter:title",
|
|
3096
|
+
content: title
|
|
3097
|
+
});
|
|
3098
|
+
if (description) meta.push({
|
|
3099
|
+
name: "twitter:description",
|
|
3100
|
+
content: description
|
|
3101
|
+
});
|
|
3102
|
+
if (image) meta.push({
|
|
3103
|
+
name: "twitter:image",
|
|
3104
|
+
content: image
|
|
3105
|
+
});
|
|
3106
|
+
if (imageAlt) meta.push({
|
|
3107
|
+
name: "twitter:image:alt",
|
|
3108
|
+
content: imageAlt
|
|
3109
|
+
});
|
|
3110
|
+
if (twitterSite) meta.push({
|
|
3111
|
+
name: "twitter:site",
|
|
3112
|
+
content: twitterSite
|
|
3113
|
+
});
|
|
3114
|
+
if (twitterCreator) meta.push({
|
|
3115
|
+
name: "twitter:creator",
|
|
3116
|
+
content: twitterCreator
|
|
3117
|
+
});
|
|
3118
|
+
if (canonical) link.push({
|
|
3119
|
+
rel: "canonical",
|
|
3120
|
+
href: canonical
|
|
3121
|
+
});
|
|
3122
|
+
if (alternateLocales) for (const alt of alternateLocales) link.push({
|
|
3123
|
+
rel: "alternate",
|
|
3124
|
+
hreflang: alt.locale,
|
|
3125
|
+
href: alt.url
|
|
3126
|
+
});
|
|
3127
|
+
if (jsonLd) script.push({
|
|
3128
|
+
type: "application/ld+json",
|
|
3129
|
+
children: JSON.stringify({
|
|
3130
|
+
"@context": "https://schema.org",
|
|
3131
|
+
...jsonLd
|
|
3132
|
+
})
|
|
3133
|
+
});
|
|
3134
|
+
if (extra) for (const tag of extra) meta.push(tag);
|
|
3135
|
+
if (props.i18n) {
|
|
3136
|
+
const i18nConfig = props.i18n;
|
|
3137
|
+
const origin = props.origin ?? "";
|
|
3138
|
+
const { pathWithoutLocale } = extractLocaleFromPath(canonical?.replace(origin, "") ?? "/", i18nConfig.locales, i18nConfig.defaultLocale);
|
|
3139
|
+
const strategy = i18nConfig.strategy ?? "prefix-except-default";
|
|
3140
|
+
for (const loc of i18nConfig.locales) {
|
|
3141
|
+
const localizedPath = strategy === "prefix-except-default" && loc === i18nConfig.defaultLocale ? pathWithoutLocale : `/${loc}${pathWithoutLocale === "/" ? "" : pathWithoutLocale}`;
|
|
3142
|
+
link.push({
|
|
3143
|
+
rel: "alternate",
|
|
3144
|
+
hreflang: loc,
|
|
3145
|
+
href: `${origin}${localizedPath}`
|
|
3146
|
+
});
|
|
3147
|
+
if (loc !== locale) meta.push({
|
|
3148
|
+
property: "og:locale:alternate",
|
|
3149
|
+
content: loc
|
|
3150
|
+
});
|
|
3151
|
+
}
|
|
3152
|
+
link.push({
|
|
3153
|
+
rel: "alternate",
|
|
3154
|
+
hreflang: "x-default",
|
|
3155
|
+
href: `${origin}${pathWithoutLocale}`
|
|
3156
|
+
});
|
|
3157
|
+
}
|
|
3158
|
+
return {
|
|
3159
|
+
meta,
|
|
3160
|
+
link,
|
|
3161
|
+
script
|
|
3162
|
+
};
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
//#endregion
|
|
3166
|
+
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
3167
|
//# sourceMappingURL=index.js.map
|