@pyreon/zero 0.12.6 → 0.12.8

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/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { a as parseFileRoutes, i as generateRouteModule, o as scanRouteFiles, r as generateMiddlewareModule, t as filePathToUrlPath } from "./fs-router-Dil4IKZR.js";
1
2
  import { Fragment, createContext, h } from "@pyreon/core";
2
3
  import { HeadProvider } from "@pyreon/head";
3
4
  import { RouterProvider, RouterView, createRouter } from "@pyreon/router";
@@ -5,6 +6,7 @@ import { createHandler } from "@pyreon/server";
5
6
  import { renderToString } from "@pyreon/runtime-server";
6
7
  import { existsSync, readdirSync } from "node:fs";
7
8
  import { join } from "node:path";
9
+ import { readFile } from "node:fs/promises";
8
10
  import { signal } from "@pyreon/reactivity";
9
11
 
10
12
  //#region src/app.ts
@@ -319,270 +321,6 @@ function resolveConfig(userConfig = {}) {
319
321
  };
320
322
  }
321
323
 
322
- //#endregion
323
- //#region src/fs-router.ts
324
- const ROUTE_EXTENSIONS = [
325
- ".tsx",
326
- ".jsx",
327
- ".ts",
328
- ".js"
329
- ];
330
- /**
331
- * Parse a set of file paths (relative to routes dir) into FileRoute objects.
332
- *
333
- * @param files Array of file paths like ["index.tsx", "users/[id].tsx"]
334
- * @param defaultMode Default rendering mode from config
335
- */
336
- function parseFileRoutes(files, defaultMode = "ssr") {
337
- return files.filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext))).map((filePath) => parseFilePath(filePath, defaultMode)).sort(sortRoutes);
338
- }
339
- function parseFilePath(filePath, defaultMode) {
340
- let route = filePath;
341
- for (const ext of ROUTE_EXTENSIONS) if (route.endsWith(ext)) {
342
- route = route.slice(0, -ext.length);
343
- break;
344
- }
345
- const fileName = getFileName(route);
346
- const isLayout = fileName === "_layout";
347
- const isError = fileName === "_error";
348
- const isLoading = fileName === "_loading";
349
- const isNotFound = fileName === "_404" || fileName === "_not-found";
350
- const isCatchAll = route.includes("[...");
351
- const parts = route.split("/");
352
- parts.pop();
353
- const dirPath = parts.filter((s) => !(s.startsWith("(") && s.endsWith(")"))).join("/");
354
- const urlPath = filePathToUrlPath(route);
355
- return {
356
- filePath,
357
- urlPath,
358
- dirPath,
359
- depth: urlPath === "/" ? 0 : urlPath.split("/").filter(Boolean).length,
360
- isLayout,
361
- isError,
362
- isLoading,
363
- isNotFound,
364
- isCatchAll,
365
- renderMode: defaultMode
366
- };
367
- }
368
- /**
369
- * Convert a file path (without extension) to a URL path pattern.
370
- *
371
- * Examples:
372
- * "index" → "/"
373
- * "about" → "/about"
374
- * "users/index" → "/users"
375
- * "users/[id]" → "/users/:id"
376
- * "blog/[...slug]" → "/blog/:slug*"
377
- * "(auth)/login" → "/login" (group stripped)
378
- * "_layout" → "/" (layout marker)
379
- */
380
- function filePathToUrlPath(filePath) {
381
- const segments = filePath.split("/");
382
- const urlSegments = [];
383
- for (const seg of segments) {
384
- if (seg.startsWith("(") && seg.endsWith(")")) continue;
385
- if (seg === "_layout" || seg === "_error" || seg === "_loading" || seg === "_404" || seg === "_not-found") continue;
386
- if (seg === "index") continue;
387
- const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
388
- if (catchAll) {
389
- urlSegments.push(`:${catchAll[1]}*`);
390
- continue;
391
- }
392
- const dynamic = seg.match(/^\[(\w+)\]$/);
393
- if (dynamic) {
394
- urlSegments.push(`:${dynamic[1]}`);
395
- continue;
396
- }
397
- urlSegments.push(seg);
398
- }
399
- return `/${urlSegments.join("/")}` || "/";
400
- }
401
- /** Sort routes: static before dynamic, catch-all last. */
402
- function sortRoutes(a, b) {
403
- if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1;
404
- if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1;
405
- const aDynamic = a.urlPath.includes(":");
406
- if (aDynamic !== b.urlPath.includes(":")) return aDynamic ? 1 : -1;
407
- return a.urlPath.localeCompare(b.urlPath);
408
- }
409
- function getFileName(filePath) {
410
- const parts = filePath.split("/");
411
- return parts[parts.length - 1] ?? "";
412
- }
413
- /**
414
- * Group flat file routes into a directory tree.
415
- */
416
- function getOrCreateChild(node, segment) {
417
- let child = node.children.get(segment);
418
- if (!child) {
419
- child = {
420
- pages: [],
421
- children: /* @__PURE__ */ new Map()
422
- };
423
- node.children.set(segment, child);
424
- }
425
- return child;
426
- }
427
- function resolveNode(root, dirPath) {
428
- let node = root;
429
- if (dirPath) for (const segment of dirPath.split("/")) node = getOrCreateChild(node, segment);
430
- return node;
431
- }
432
- function placeRoute(node, route) {
433
- if (route.isLayout) node.layout = route;
434
- else if (route.isError) node.error = route;
435
- else if (route.isLoading) node.loading = route;
436
- else if (route.isNotFound) node.notFound = route;
437
- else node.pages.push(route);
438
- }
439
- function buildRouteTree(routes) {
440
- const root = {
441
- pages: [],
442
- children: /* @__PURE__ */ new Map()
443
- };
444
- for (const route of routes) placeRoute(resolveNode(root, route.dirPath), route);
445
- return root;
446
- }
447
- function generateRouteModule(files, routesDir, options) {
448
- const tree = buildRouteTree(parseFileRoutes(files));
449
- const imports = [];
450
- let importCounter = 0;
451
- const useStaticImports = options?.staticImports ?? false;
452
- function nextImport(filePath, exportName = "default") {
453
- const name = `_${importCounter++}`;
454
- const fullPath = `${routesDir}/${filePath}`;
455
- if (exportName === "default") imports.push(`import ${name} from "${fullPath}"`);
456
- else imports.push(`import { ${exportName} as ${name} } from "${fullPath}"`);
457
- return name;
458
- }
459
- function nextLazy(filePath, loadingName, errorName) {
460
- const name = `_${importCounter++}`;
461
- const fullPath = `${routesDir}/${filePath}`;
462
- if (useStaticImports) imports.push(`import ${name} from "${fullPath}"`);
463
- else {
464
- const opts = [];
465
- if (loadingName) opts.push(`loading: ${loadingName}`);
466
- if (errorName) opts.push(`error: ${errorName}`);
467
- const optsStr = opts.length > 0 ? `, { ${opts.join(", ")} }` : "";
468
- imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`);
469
- }
470
- return name;
471
- }
472
- function nextModuleImport(filePath) {
473
- const name = `_m${importCounter++}`;
474
- const fullPath = `${routesDir}/${filePath}`;
475
- imports.push(`import * as ${name} from "${fullPath}"`);
476
- return name;
477
- }
478
- function generatePageRoute(page, indent, loadingName, errorName, notFoundName) {
479
- const mod = nextModuleImport(page.filePath);
480
- const comp = nextLazy(page.filePath, loadingName, errorName);
481
- const props = [
482
- `${indent} path: ${JSON.stringify(page.urlPath)}`,
483
- `${indent} component: ${comp}`,
484
- `${indent} loader: ${mod}.loader`,
485
- `${indent} beforeEnter: ${mod}.guard`,
486
- `${indent} meta: { ...${mod}.meta, renderMode: ${mod}.renderMode }`
487
- ];
488
- if (errorName) props.push(`${indent} errorComponent: ${mod}.error || ${errorName}`);
489
- if (notFoundName) props.push(`${indent} notFoundComponent: ${notFoundName}`);
490
- return `${indent}{\n${props.join(",\n")}\n${indent}}`;
491
- }
492
- function wrapWithLayout(node, children, indent, errorName, notFoundName) {
493
- const layout = node.layout;
494
- const layoutMod = nextModuleImport(layout.filePath);
495
- const layoutComp = nextImport(layout.filePath, "layout");
496
- const props = [
497
- `${indent}path: ${JSON.stringify(layout.urlPath)}`,
498
- `${indent}component: ${layoutComp}`,
499
- `${indent}loader: ${layoutMod}.loader`,
500
- `${indent}beforeEnter: ${layoutMod}.guard`,
501
- `${indent}meta: { ...${layoutMod}.meta, renderMode: ${layoutMod}.renderMode }`
502
- ];
503
- if (errorName) props.push(`${indent}errorComponent: ${errorName}`);
504
- if (notFoundName) props.push(`${indent}notFoundComponent: ${notFoundName}`);
505
- if (children.length > 0) props.push(`${indent}children: [\n${children.join(",\n")}\n${indent}]`);
506
- return `${indent}{\n${props.map((p) => ` ${p}`).join(",\n")}\n${indent}}`;
507
- }
508
- /**
509
- * Generate route definitions for a tree node.
510
- */
511
- function generateNode(node, depth) {
512
- const indent = " ".repeat(depth + 1);
513
- const errorName = node.error ? nextImport(node.error.filePath) : void 0;
514
- const loadingName = node.loading ? nextImport(node.loading.filePath) : void 0;
515
- const notFoundName = node.notFound ? nextImport(node.notFound.filePath) : void 0;
516
- const childRouteDefs = [];
517
- for (const [, childNode] of node.children) childRouteDefs.push(...generateNode(childNode, depth + 1));
518
- const allChildren = [...node.pages.map((page) => generatePageRoute(page, indent, loadingName, errorName, notFoundName)), ...childRouteDefs];
519
- if (node.layout) return [wrapWithLayout(node, allChildren, indent, errorName, notFoundName)];
520
- return allChildren;
521
- }
522
- const routeDefs = generateNode(tree, 0);
523
- return [
524
- `import { lazy } from "@pyreon/router"`,
525
- "",
526
- ...imports,
527
- "",
528
- `function clean(routes) {`,
529
- ` return routes.map(r => {`,
530
- ` const c = {}`,
531
- ` for (const k in r) if (r[k] !== undefined) c[k] = r[k]`,
532
- ` if (c.children) c.children = clean(c.children)`,
533
- ` return c`,
534
- ` })`,
535
- `}`,
536
- "",
537
- `export const routes = clean([`,
538
- routeDefs.join(",\n"),
539
- `])`
540
- ].join("\n");
541
- }
542
- /**
543
- * Generate a virtual module that maps URL patterns to their middleware exports.
544
- * Used by the server entry to dispatch per-route middleware.
545
- */
546
- function generateMiddlewareModule(files, routesDir) {
547
- const routes = parseFileRoutes(files);
548
- const imports = [];
549
- const entries = [];
550
- let counter = 0;
551
- for (const route of routes) {
552
- if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue;
553
- const name = `_mw${counter++}`;
554
- const fullPath = `${routesDir}/${route.filePath}`;
555
- imports.push(`import { middleware as ${name} } from "${fullPath}"`);
556
- entries.push(` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`);
557
- }
558
- return [
559
- ...imports,
560
- "",
561
- `export const routeMiddleware = [`,
562
- entries.join(",\n"),
563
- `].filter(e => e.middleware)`
564
- ].join("\n");
565
- }
566
- /**
567
- * Scan a directory for route files.
568
- * Returns paths relative to the routes directory.
569
- */
570
- async function scanRouteFiles(routesDir) {
571
- const { readdir } = await import("node:fs/promises");
572
- const { join, relative } = await import("node:path");
573
- const files = [];
574
- async function walk(dir) {
575
- const entries = await readdir(dir, { withFileTypes: true });
576
- for (const entry of entries) {
577
- const fullPath = join(dir, entry.name);
578
- if (entry.isDirectory()) await walk(fullPath);
579
- else if (ROUTE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) files.push(relative(routesDir, fullPath));
580
- }
581
- }
582
- await walk(routesDir);
583
- return files;
584
- }
585
-
586
324
  //#endregion
587
325
  //#region src/isr.ts
588
326
  /**
@@ -1401,6 +1139,1340 @@ function flattenRoutePatterns(routes, prefix = "") {
1401
1139
  return patterns;
1402
1140
  }
1403
1141
 
1142
+ //#endregion
1143
+ //#region src/favicon.ts
1144
+ let sharpWarned$1 = false;
1145
+ function warnSharpMissing$1() {
1146
+ if (sharpWarned$1) return;
1147
+ sharpWarned$1 = true;
1148
+ console.warn("\n[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
1149
+ }
1150
+ const SIZES = [
1151
+ {
1152
+ size: 16,
1153
+ name: "favicon-16x16.png"
1154
+ },
1155
+ {
1156
+ size: 32,
1157
+ name: "favicon-32x32.png"
1158
+ },
1159
+ {
1160
+ size: 180,
1161
+ name: "apple-touch-icon.png"
1162
+ },
1163
+ {
1164
+ size: 192,
1165
+ name: "icon-192.png"
1166
+ },
1167
+ {
1168
+ size: 512,
1169
+ name: "icon-512.png"
1170
+ }
1171
+ ];
1172
+ /**
1173
+ * Favicon generation Vite plugin.
1174
+ *
1175
+ * Generates all required favicon formats at build time from a single source.
1176
+ * In dev mode, serves the source directly.
1177
+ *
1178
+ * @example
1179
+ * ```ts
1180
+ * // vite.config.ts
1181
+ * import { faviconPlugin } from "@pyreon/zero"
1182
+ *
1183
+ * export default {
1184
+ * plugins: [faviconPlugin({ source: "./src/assets/icon.svg" })],
1185
+ * }
1186
+ * ```
1187
+ */
1188
+ function faviconPlugin(config) {
1189
+ const themeColor = config.themeColor ?? "#ffffff";
1190
+ const backgroundColor = config.backgroundColor ?? "#ffffff";
1191
+ const generateManifest = config.manifest !== false;
1192
+ let root = "";
1193
+ let isBuild = false;
1194
+ return {
1195
+ name: "pyreon-zero-favicon",
1196
+ enforce: "pre",
1197
+ configResolved(resolvedConfig) {
1198
+ root = resolvedConfig.root;
1199
+ isBuild = resolvedConfig.command === "build";
1200
+ },
1201
+ configureServer(server) {
1202
+ const sourcePath = join(root, config.source);
1203
+ const darkPath = config.darkSource ? join(root, config.darkSource) : null;
1204
+ const devSourcePath = typeof config.devSource === "string" ? join(root, config.devSource) : null;
1205
+ const autoDevBadge = config.devSource === true;
1206
+ const devCache = /* @__PURE__ */ new Map();
1207
+ /** Resolve source path for a request — handles dark variants and dev badge. */
1208
+ function resolveSourceForDev(baseName, defaultSource) {
1209
+ if (darkPath && baseName.includes("-dark-")) return darkPath;
1210
+ if (baseName.includes("-light-")) return defaultSource;
1211
+ return defaultSource;
1212
+ }
1213
+ server.middlewares.use(async (req, res, next) => {
1214
+ const url = req.url ?? "";
1215
+ const localeSource = resolveLocaleSource(url, config, root);
1216
+ const svgUrl = localeSource ? localeSource.url : url;
1217
+ const svgPath = localeSource ? localeSource.sourcePath : sourcePath;
1218
+ const isSvgSource = localeSource ? localeSource.source.endsWith(".svg") : config.source.endsWith(".svg");
1219
+ if (svgUrl.endsWith("/favicon.svg") && isSvgSource) try {
1220
+ let content = await readFile(svgPath, "utf-8");
1221
+ if (autoDevBadge) content = addDevBadgeToSvg(content);
1222
+ else if (devSourcePath && existsSync(devSourcePath)) content = await readFile(devSourcePath, "utf-8");
1223
+ res.setHeader("Content-Type", "image/svg+xml");
1224
+ res.end(content);
1225
+ return;
1226
+ } catch {}
1227
+ const baseName = svgUrl.split("/").pop() ?? "";
1228
+ const cleanName = baseName.replace(/-?(light|dark)-/, "-");
1229
+ const sizeMatch = SIZES.find((s) => s.name === cleanName || baseName === s.name);
1230
+ if (sizeMatch) {
1231
+ const resolvedSource = resolveSourceForDev(baseName, svgPath);
1232
+ const cacheKey = `${resolvedSource}:${sizeMatch.size}:${autoDevBadge}`;
1233
+ let png = devCache.get(cacheKey);
1234
+ if (!png) {
1235
+ let result = await resizeToPng(resolvedSource, sizeMatch.size);
1236
+ if (result && autoDevBadge) result = await addDevBadgeToPng(result, sizeMatch.size);
1237
+ if (result) {
1238
+ png = result;
1239
+ devCache.set(cacheKey, result);
1240
+ }
1241
+ }
1242
+ if (png) {
1243
+ res.setHeader("Content-Type", "image/png");
1244
+ res.setHeader("Cache-Control", "no-cache");
1245
+ res.end(Buffer.from(png));
1246
+ return;
1247
+ }
1248
+ }
1249
+ if (baseName === "favicon.ico") {
1250
+ const cacheKey = `ico:${svgPath}`;
1251
+ let ico = devCache.get(cacheKey);
1252
+ if (!ico) {
1253
+ const result = await generateIco(svgPath);
1254
+ if (result) {
1255
+ ico = result;
1256
+ devCache.set(cacheKey, result);
1257
+ }
1258
+ }
1259
+ if (ico) {
1260
+ res.setHeader("Content-Type", "image/x-icon");
1261
+ res.setHeader("Cache-Control", "no-cache");
1262
+ res.end(Buffer.from(ico));
1263
+ return;
1264
+ }
1265
+ }
1266
+ if (baseName === "site.webmanifest" && generateManifest) {
1267
+ const prefix = localeSource ? `/${localeSource.locale}` : "";
1268
+ const manifest = {
1269
+ name: config.name ?? "App",
1270
+ short_name: config.name ?? "App",
1271
+ icons: [{
1272
+ src: `${prefix}/icon-192.png`,
1273
+ sizes: "192x192",
1274
+ type: "image/png"
1275
+ }, {
1276
+ src: `${prefix}/icon-512.png`,
1277
+ sizes: "512x512",
1278
+ type: "image/png"
1279
+ }],
1280
+ theme_color: themeColor,
1281
+ background_color: backgroundColor,
1282
+ display: "standalone"
1283
+ };
1284
+ res.setHeader("Content-Type", "application/manifest+json");
1285
+ res.end(JSON.stringify(manifest, null, 2));
1286
+ return;
1287
+ }
1288
+ next();
1289
+ });
1290
+ },
1291
+ transformIndexHtml() {
1292
+ const isSvg = config.source.endsWith(".svg");
1293
+ const hasDark = !!config.darkSource;
1294
+ const tags = [];
1295
+ if (isSvg) tags.push({
1296
+ tag: "link",
1297
+ attrs: {
1298
+ rel: "icon",
1299
+ type: "image/svg+xml",
1300
+ href: "/favicon.svg"
1301
+ },
1302
+ injectTo: "head"
1303
+ });
1304
+ if (hasDark) {
1305
+ const lightAttrs = { "data-favicon-theme": "light" };
1306
+ const darkAttrs = {
1307
+ "data-favicon-theme": "dark",
1308
+ media: "not all"
1309
+ };
1310
+ tags.push({
1311
+ tag: "link",
1312
+ attrs: {
1313
+ rel: "icon",
1314
+ type: "image/png",
1315
+ sizes: "32x32",
1316
+ href: "/favicon-light-32x32.png",
1317
+ ...lightAttrs
1318
+ },
1319
+ injectTo: "head"
1320
+ }, {
1321
+ tag: "link",
1322
+ attrs: {
1323
+ rel: "icon",
1324
+ type: "image/png",
1325
+ sizes: "32x32",
1326
+ href: "/favicon-dark-32x32.png",
1327
+ ...darkAttrs
1328
+ },
1329
+ injectTo: "head"
1330
+ }, {
1331
+ tag: "link",
1332
+ attrs: {
1333
+ rel: "icon",
1334
+ type: "image/png",
1335
+ sizes: "16x16",
1336
+ href: "/favicon-light-16x16.png",
1337
+ ...lightAttrs
1338
+ },
1339
+ injectTo: "head"
1340
+ }, {
1341
+ tag: "link",
1342
+ attrs: {
1343
+ rel: "icon",
1344
+ type: "image/png",
1345
+ sizes: "16x16",
1346
+ href: "/favicon-dark-16x16.png",
1347
+ ...darkAttrs
1348
+ },
1349
+ injectTo: "head"
1350
+ }, {
1351
+ tag: "link",
1352
+ attrs: {
1353
+ rel: "apple-touch-icon",
1354
+ sizes: "180x180",
1355
+ href: "/apple-touch-icon-light.png",
1356
+ ...lightAttrs
1357
+ },
1358
+ injectTo: "head"
1359
+ }, {
1360
+ tag: "link",
1361
+ attrs: {
1362
+ rel: "apple-touch-icon",
1363
+ sizes: "180x180",
1364
+ href: "/apple-touch-icon-dark.png",
1365
+ ...darkAttrs
1366
+ },
1367
+ injectTo: "head"
1368
+ });
1369
+ } else tags.push({
1370
+ tag: "link",
1371
+ attrs: {
1372
+ rel: "icon",
1373
+ type: "image/png",
1374
+ sizes: "32x32",
1375
+ href: "/favicon-32x32.png"
1376
+ },
1377
+ injectTo: "head"
1378
+ }, {
1379
+ tag: "link",
1380
+ attrs: {
1381
+ rel: "icon",
1382
+ type: "image/png",
1383
+ sizes: "16x16",
1384
+ href: "/favicon-16x16.png"
1385
+ },
1386
+ injectTo: "head"
1387
+ }, {
1388
+ tag: "link",
1389
+ attrs: {
1390
+ rel: "apple-touch-icon",
1391
+ sizes: "180x180",
1392
+ href: "/apple-touch-icon.png"
1393
+ },
1394
+ injectTo: "head"
1395
+ });
1396
+ if (generateManifest) tags.push({
1397
+ tag: "link",
1398
+ attrs: {
1399
+ rel: "manifest",
1400
+ href: "/site.webmanifest"
1401
+ },
1402
+ injectTo: "head"
1403
+ });
1404
+ tags.push({
1405
+ tag: "meta",
1406
+ attrs: {
1407
+ name: "theme-color",
1408
+ content: themeColor
1409
+ },
1410
+ injectTo: "head"
1411
+ });
1412
+ return tags;
1413
+ },
1414
+ async generateBundle() {
1415
+ if (!isBuild) return;
1416
+ await generateFaviconSet.call(this, root, config.source, config.darkSource, "", config, themeColor, backgroundColor, generateManifest);
1417
+ 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);
1418
+ }
1419
+ };
1420
+ }
1421
+ /**
1422
+ * Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.
1423
+ */
1424
+ function wrapSvgWithDarkMode(lightSvg, darkSvg) {
1425
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${lightSvg.match(/viewBox="([^"]*)"/)?.[1] ?? "0 0 32 32"}">
1426
+ <style>
1427
+ :root { color-scheme: light dark; }
1428
+ @media (prefers-color-scheme: dark) { .light { display: none; } }
1429
+ @media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }
1430
+ </style>
1431
+ <g class="light">${stripSvgWrapper(lightSvg)}</g>
1432
+ <g class="dark">${stripSvgWrapper(darkSvg)}</g>
1433
+ </svg>`;
1434
+ }
1435
+ function stripSvgWrapper(svg) {
1436
+ return svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
1437
+ }
1438
+ /**
1439
+ * Resolve the source path for a locale-prefixed favicon URL.
1440
+ * Returns null if the URL is not locale-prefixed or locale has no override.
1441
+ */
1442
+ function resolveLocaleSource(url, config, rootDir) {
1443
+ if (!config.locales) return null;
1444
+ for (const [locale, localeConfig] of Object.entries(config.locales)) {
1445
+ const prefix = `/${locale}/`;
1446
+ if (url.startsWith(prefix)) return {
1447
+ locale,
1448
+ url,
1449
+ source: localeConfig.source,
1450
+ sourcePath: join(rootDir, localeConfig.source)
1451
+ };
1452
+ }
1453
+ return null;
1454
+ }
1455
+ /**
1456
+ * Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.
1457
+ * Called once for base (prefix = '') and once per locale (prefix = '{locale}/').
1458
+ */
1459
+ async function generateFaviconSet(rootDir, source, darkSource, prefix, config, themeColor, backgroundColor, generateManifest) {
1460
+ const sourcePath = join(rootDir, source);
1461
+ if (!existsSync(sourcePath)) {
1462
+ console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
1463
+ return;
1464
+ }
1465
+ if (source.endsWith(".svg")) {
1466
+ const svgContent = await readFile(sourcePath, "utf-8");
1467
+ let finalSvg = svgContent;
1468
+ if (darkSource) {
1469
+ const darkPath = join(rootDir, darkSource);
1470
+ if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
1471
+ }
1472
+ this.emitFile({
1473
+ type: "asset",
1474
+ fileName: `${prefix}favicon.svg`,
1475
+ source: finalSvg
1476
+ });
1477
+ }
1478
+ if (darkSource) {
1479
+ const darkPath = join(rootDir, darkSource);
1480
+ const darkExists = existsSync(darkPath);
1481
+ for (const { size, name } of SIZES) {
1482
+ const lightName = name.replace(/^(favicon-)/, "$1light-").replace(/^(apple-touch-icon)/, "$1-light").replace(/^(icon-)/, "$1light-");
1483
+ const lightPng = await resizeToPng(sourcePath, size);
1484
+ if (lightPng) this.emitFile({
1485
+ type: "asset",
1486
+ fileName: `${prefix}${lightName}`,
1487
+ source: lightPng
1488
+ });
1489
+ if (darkExists) {
1490
+ const darkName = name.replace(/^(favicon-)/, "$1dark-").replace(/^(apple-touch-icon)/, "$1-dark").replace(/^(icon-)/, "$1dark-");
1491
+ const darkPng = await resizeToPng(darkPath, size);
1492
+ if (darkPng) this.emitFile({
1493
+ type: "asset",
1494
+ fileName: `${prefix}${darkName}`,
1495
+ source: darkPng
1496
+ });
1497
+ }
1498
+ }
1499
+ for (const { size, name } of SIZES) {
1500
+ const pngBuffer = await resizeToPng(sourcePath, size);
1501
+ if (pngBuffer) this.emitFile({
1502
+ type: "asset",
1503
+ fileName: `${prefix}${name}`,
1504
+ source: pngBuffer
1505
+ });
1506
+ }
1507
+ } else for (const { size, name } of SIZES) {
1508
+ const pngBuffer = await resizeToPng(sourcePath, size);
1509
+ if (pngBuffer) this.emitFile({
1510
+ type: "asset",
1511
+ fileName: `${prefix}${name}`,
1512
+ source: pngBuffer
1513
+ });
1514
+ }
1515
+ const ico = await generateIco(sourcePath);
1516
+ if (ico) this.emitFile({
1517
+ type: "asset",
1518
+ fileName: `${prefix}favicon.ico`,
1519
+ source: ico
1520
+ });
1521
+ if (generateManifest) {
1522
+ const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : "";
1523
+ const manifest = {
1524
+ name: config.name ?? "App",
1525
+ short_name: config.name ?? "App",
1526
+ icons: [{
1527
+ src: `${manifestPrefix}/icon-192.png`,
1528
+ sizes: "192x192",
1529
+ type: "image/png"
1530
+ }, {
1531
+ src: `${manifestPrefix}/icon-512.png`,
1532
+ sizes: "512x512",
1533
+ type: "image/png"
1534
+ }],
1535
+ theme_color: themeColor,
1536
+ background_color: backgroundColor,
1537
+ display: "standalone"
1538
+ };
1539
+ this.emitFile({
1540
+ type: "asset",
1541
+ fileName: `${prefix}site.webmanifest`,
1542
+ source: JSON.stringify(manifest, null, 2)
1543
+ });
1544
+ }
1545
+ }
1546
+ /**
1547
+ * Get favicon link tags for a specific locale.
1548
+ * Returns link objects suitable for `useHead()` or direct HTML injection.
1549
+ *
1550
+ * @example
1551
+ * ```ts
1552
+ * const links = faviconLinks("de", { source: "./icon.svg", locales: { de: { source: "./icon-de.svg" } } })
1553
+ * // → [{ rel: "icon", type: "image/svg+xml", href: "/de/favicon.svg" }, ...]
1554
+ * ```
1555
+ */
1556
+ function faviconLinks(locale, config) {
1557
+ const hasLocaleOverride = locale && config.locales?.[locale];
1558
+ const prefix = hasLocaleOverride ? `/${locale}` : "";
1559
+ const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
1560
+ const links = [];
1561
+ if (isSvg) links.push({
1562
+ rel: "icon",
1563
+ type: "image/svg+xml",
1564
+ href: `${prefix}/favicon.svg`
1565
+ });
1566
+ links.push({
1567
+ rel: "icon",
1568
+ type: "image/png",
1569
+ sizes: "32x32",
1570
+ href: `${prefix}/favicon-32x32.png`
1571
+ }, {
1572
+ rel: "icon",
1573
+ type: "image/png",
1574
+ sizes: "16x16",
1575
+ href: `${prefix}/favicon-16x16.png`
1576
+ }, {
1577
+ rel: "apple-touch-icon",
1578
+ sizes: "180x180",
1579
+ href: `${prefix}/apple-touch-icon.png`
1580
+ });
1581
+ if (config.manifest !== false) links.push({
1582
+ rel: "manifest",
1583
+ href: `${prefix}/site.webmanifest`
1584
+ });
1585
+ return links;
1586
+ }
1587
+ async function resizeToPng(input, size) {
1588
+ try {
1589
+ return await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, {
1590
+ fit: "contain",
1591
+ background: {
1592
+ r: 0,
1593
+ g: 0,
1594
+ b: 0,
1595
+ alpha: 0
1596
+ }
1597
+ }).png().toBuffer();
1598
+ } catch {
1599
+ warnSharpMissing$1();
1600
+ return null;
1601
+ }
1602
+ }
1603
+ async function generateIco(input) {
1604
+ try {
1605
+ const sharp = await import("sharp").then((m) => m.default ?? m);
1606
+ const png16 = await sharp(input).resize(16, 16, {
1607
+ fit: "contain",
1608
+ background: {
1609
+ r: 0,
1610
+ g: 0,
1611
+ b: 0,
1612
+ alpha: 0
1613
+ }
1614
+ }).png().toBuffer();
1615
+ const png32 = await sharp(input).resize(32, 32, {
1616
+ fit: "contain",
1617
+ background: {
1618
+ r: 0,
1619
+ g: 0,
1620
+ b: 0,
1621
+ alpha: 0
1622
+ }
1623
+ }).png().toBuffer();
1624
+ return createIcoFromPngs([{
1625
+ buffer: png16,
1626
+ size: 16
1627
+ }, {
1628
+ buffer: png32,
1629
+ size: 32
1630
+ }]);
1631
+ } catch {
1632
+ warnSharpMissing$1();
1633
+ return null;
1634
+ }
1635
+ }
1636
+ /** @internal Exported for testing */
1637
+ function createIcoFromPngs(entries) {
1638
+ const headerSize = 6;
1639
+ const dirEntrySize = 16;
1640
+ const dirSize = dirEntrySize * entries.length;
1641
+ let dataOffset = headerSize + dirSize;
1642
+ const header = Buffer.alloc(headerSize);
1643
+ header.writeUInt16LE(0, 0);
1644
+ header.writeUInt16LE(1, 2);
1645
+ header.writeUInt16LE(entries.length, 4);
1646
+ const dirEntries = Buffer.alloc(dirSize);
1647
+ const dataBuffers = [];
1648
+ for (let i = 0; i < entries.length; i++) {
1649
+ const entry = entries[i];
1650
+ const offset = i * dirEntrySize;
1651
+ dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset);
1652
+ dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1);
1653
+ dirEntries.writeUInt8(0, offset + 2);
1654
+ dirEntries.writeUInt8(0, offset + 3);
1655
+ dirEntries.writeUInt16LE(1, offset + 4);
1656
+ dirEntries.writeUInt16LE(32, offset + 6);
1657
+ dirEntries.writeUInt32LE(entry.buffer.length, offset + 8);
1658
+ dirEntries.writeUInt32LE(dataOffset, offset + 12);
1659
+ dataOffset += entry.buffer.length;
1660
+ dataBuffers.push(entry.buffer);
1661
+ }
1662
+ return Buffer.concat([
1663
+ header,
1664
+ dirEntries,
1665
+ ...dataBuffers
1666
+ ]);
1667
+ }
1668
+ /**
1669
+ * Add a "DEV" badge overlay to an SVG string.
1670
+ * Adds a small colored circle with "DEV" text in the bottom-right corner.
1671
+ */
1672
+ function addDevBadgeToSvg(svg) {
1673
+ const [, , w, h] = (svg.match(/viewBox="([^"]*)"/)?.[1] ?? "0 0 32 32").split(" ").map(Number);
1674
+ const size = Math.min(w ?? 32, h ?? 32);
1675
+ const r = size * .28;
1676
+ const cx = (w ?? 32) - r;
1677
+ const cy = (h ?? 32) - r;
1678
+ const fontSize = r * .85;
1679
+ const badge = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#ef4444" stroke="white" stroke-width="${size * .03}"/><text x="${cx}" y="${cy}" font-size="${fontSize}" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="central" font-family="sans-serif">D</text>`;
1680
+ return svg.replace(/<\/svg>\s*$/, `${badge}</svg>`);
1681
+ }
1682
+ /**
1683
+ * Add a "DEV" badge to a PNG buffer via sharp composite.
1684
+ * Composites a red circle with "D" in the bottom-right corner.
1685
+ */
1686
+ async function addDevBadgeToPng(pngBuffer, size) {
1687
+ try {
1688
+ const sharp = await import("sharp").then((m) => m.default ?? m);
1689
+ const r = Math.round(size * .28);
1690
+ const d = r * 2;
1691
+ const badgeSvg = `<svg width="${d}" height="${d}" xmlns="http://www.w3.org/2000/svg">
1692
+ <circle cx="${r}" cy="${r}" r="${r}" fill="#ef4444"/>
1693
+ <text x="${r}" y="${r}" font-size="${Math.round(r * .85)}" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="central" font-family="sans-serif">D</text>
1694
+ </svg>`;
1695
+ const badgePng = await sharp(Buffer.from(badgeSvg)).png().toBuffer();
1696
+ return await sharp(Buffer.from(pngBuffer)).composite([{
1697
+ input: badgePng,
1698
+ gravity: "southeast"
1699
+ }]).png().toBuffer();
1700
+ } catch {
1701
+ return pngBuffer;
1702
+ }
1703
+ }
1704
+
1705
+ //#endregion
1706
+ //#region src/seo.ts
1707
+ /**
1708
+ * Generate a sitemap.xml string from route file paths.
1709
+ */
1710
+ function generateSitemap(routeFiles, config) {
1711
+ const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
1712
+ return `<?xml version="1.0" encoding="UTF-8"?>
1713
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
1714
+ ${[...routeFiles.filter((f) => {
1715
+ const name = f.split("/").pop()?.replace(/\.\w+$/, "");
1716
+ return name !== "_layout" && name !== "_error" && name !== "_loading";
1717
+ }).map((f) => {
1718
+ let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "/").replace(/^index$/, "/");
1719
+ if (path.includes("[")) return null;
1720
+ path = path.replace(/\([\w-]+\)\//g, "");
1721
+ if (!path.startsWith("/")) path = `/${path}`;
1722
+ return path;
1723
+ }).filter((p) => p !== null).filter((p) => !exclude.some((e) => p.startsWith(e))).map((p) => ({
1724
+ path: p,
1725
+ changefreq,
1726
+ priority
1727
+ })), ...config.additionalPaths ?? []].map((entry) => {
1728
+ return ` <url>
1729
+ <loc>${escapeXml$1(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
1730
+ <changefreq>${entry.changefreq ?? changefreq}</changefreq>
1731
+ <priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
1732
+ </url>`;
1733
+ }).join("\n")}
1734
+ </urlset>`;
1735
+ }
1736
+ function escapeXml$1(str) {
1737
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1738
+ }
1739
+ /**
1740
+ * Generate a robots.txt string.
1741
+ */
1742
+ function generateRobots(config = {}) {
1743
+ const { rules = [{
1744
+ userAgent: "*",
1745
+ allow: ["/"]
1746
+ }], sitemap, host } = config;
1747
+ const lines = [];
1748
+ for (const rule of rules) {
1749
+ lines.push(`User-agent: ${rule.userAgent}`);
1750
+ if (rule.allow) for (const path of rule.allow) lines.push(`Allow: ${path}`);
1751
+ if (rule.disallow) for (const path of rule.disallow) lines.push(`Disallow: ${path}`);
1752
+ if (rule.crawlDelay) lines.push(`Crawl-delay: ${rule.crawlDelay}`);
1753
+ lines.push("");
1754
+ }
1755
+ if (sitemap) lines.push(`Sitemap: ${sitemap}`);
1756
+ if (host) lines.push(`Host: ${host}`);
1757
+ return lines.join("\n");
1758
+ }
1759
+ /**
1760
+ * Generate a JSON-LD script tag string for structured data.
1761
+ *
1762
+ * @example
1763
+ * useHead({
1764
+ * script: [jsonLd({
1765
+ * "@type": "WebSite",
1766
+ * name: "My Site",
1767
+ * url: "https://example.com",
1768
+ * })],
1769
+ * })
1770
+ */
1771
+ function jsonLd(data) {
1772
+ const ld = {
1773
+ "@context": "https://schema.org",
1774
+ ...data
1775
+ };
1776
+ return `<script type="application/ld+json">${JSON.stringify(ld)}<\/script>`;
1777
+ }
1778
+ /**
1779
+ * Zero SEO Vite plugin.
1780
+ * Generates sitemap.xml and robots.txt at build time.
1781
+ *
1782
+ * @example
1783
+ * import { seoPlugin } from "@pyreon/zero/seo"
1784
+ *
1785
+ * export default {
1786
+ * plugins: [
1787
+ * pyreon(),
1788
+ * zero(),
1789
+ * seoPlugin({
1790
+ * sitemap: { origin: "https://example.com" },
1791
+ * robots: { sitemap: "https://example.com/sitemap.xml" },
1792
+ * }),
1793
+ * ],
1794
+ * }
1795
+ */
1796
+ function seoPlugin(config = {}) {
1797
+ return {
1798
+ name: "pyreon-zero-seo",
1799
+ apply: "build",
1800
+ async generateBundle(_, _bundle) {
1801
+ if (config.sitemap) {
1802
+ const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
1803
+ const routesDir = `${process.cwd()}/src/routes`;
1804
+ try {
1805
+ const sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
1806
+ this.emitFile({
1807
+ type: "asset",
1808
+ fileName: "sitemap.xml",
1809
+ source: sitemap
1810
+ });
1811
+ } catch {}
1812
+ }
1813
+ if (config.robots) {
1814
+ const robots = generateRobots(config.robots);
1815
+ this.emitFile({
1816
+ type: "asset",
1817
+ fileName: "robots.txt",
1818
+ source: robots
1819
+ });
1820
+ }
1821
+ }
1822
+ };
1823
+ }
1824
+ /**
1825
+ * SEO middleware for dev server.
1826
+ * Serves sitemap.xml and robots.txt dynamically during development.
1827
+ */
1828
+ function seoMiddleware(config = {}) {
1829
+ return async (ctx) => {
1830
+ if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
1831
+ if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
1832
+ const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
1833
+ const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
1834
+ return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
1835
+ } catch {}
1836
+ };
1837
+ }
1838
+
1839
+ //#endregion
1840
+ //#region src/og-image.ts
1841
+ /**
1842
+ * OG Image generation plugin.
1843
+ *
1844
+ * Generates Open Graph images at build time from templates with
1845
+ * text overlays. Supports locale-specific text for i18n apps.
1846
+ * Uses sharp for image processing (same optional dep as favicon/image plugins).
1847
+ *
1848
+ * @example
1849
+ * ```ts
1850
+ * // vite.config.ts
1851
+ * import { ogImagePlugin } from "@pyreon/zero/og-image"
1852
+ *
1853
+ * export default {
1854
+ * plugins: [
1855
+ * ogImagePlugin({
1856
+ * locales: ["en", "de", "cs"],
1857
+ * templates: [{
1858
+ * name: "default",
1859
+ * background: "./src/assets/og-bg.jpg",
1860
+ * layers: [{
1861
+ * text: { en: "Build faster", de: "Schneller bauen", cs: "Stavte rychleji" },
1862
+ * y: "40%",
1863
+ * fontSize: 72,
1864
+ * }],
1865
+ * }],
1866
+ * }),
1867
+ * ],
1868
+ * }
1869
+ * ```
1870
+ */
1871
+ let sharpWarned = false;
1872
+ function warnSharpMissing() {
1873
+ if (sharpWarned) return;
1874
+ sharpWarned = true;
1875
+ console.warn("\n[zero:og-image] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n");
1876
+ }
1877
+ function resolvePosition(value, dimension, fallback = "50%") {
1878
+ if (value === void 0) value = fallback;
1879
+ if (typeof value === "number") return value;
1880
+ if (value.endsWith("%")) return Math.round(Number.parseFloat(value) / 100 * dimension);
1881
+ return Number.parseInt(value, 10) || 0;
1882
+ }
1883
+ function resolveLayerText(layer, locale) {
1884
+ if (typeof layer.text === "string") return layer.text;
1885
+ if (typeof layer.text === "function") return layer.text(locale);
1886
+ return layer.text[locale] ?? layer.text[Object.keys(layer.text)[0] ?? ""] ?? "";
1887
+ }
1888
+ function escapeXml(str) {
1889
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1890
+ }
1891
+ /**
1892
+ * Build an SVG overlay with text layers.
1893
+ * @internal Exported for testing.
1894
+ */
1895
+ function buildTextOverlaySvg(layers, width, height, locale) {
1896
+ return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${layers.map((layer) => {
1897
+ const text = resolveLayerText(layer, locale);
1898
+ const x = resolvePosition(layer.x, width, "50%");
1899
+ const y = resolvePosition(layer.y, height, "50%");
1900
+ const fontSize = layer.fontSize ?? 64;
1901
+ const fontFamily = layer.fontFamily ?? "sans-serif";
1902
+ const fontWeight = layer.fontWeight ?? "bold";
1903
+ const color = layer.color ?? "#ffffff";
1904
+ const anchor = layer.textAnchor ?? "middle";
1905
+ const maxWidth = layer.maxWidth ?? Math.round(width * .8);
1906
+ const words = text.split(" ");
1907
+ const lines = [];
1908
+ let currentLine = "";
1909
+ const estimateWidth = (s) => {
1910
+ let width = 0;
1911
+ for (let i = 0; i < s.length; i++) {
1912
+ const code = s.charCodeAt(i);
1913
+ if (code >= 12288 && code <= 40959) width += fontSize * 1;
1914
+ else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) width += fontSize * .35;
1915
+ else width += fontSize * .55;
1916
+ }
1917
+ return width;
1918
+ };
1919
+ for (const word of words) {
1920
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
1921
+ if (estimateWidth(testLine) > maxWidth && currentLine) {
1922
+ lines.push(currentLine);
1923
+ currentLine = word;
1924
+ } else currentLine = testLine;
1925
+ }
1926
+ if (currentLine) lines.push(currentLine);
1927
+ const tspans = lines.map((line, i) => {
1928
+ return `<tspan x="${x}" dy="${i === 0 ? "0" : `${fontSize * 1.2}`}">${escapeXml(line)}</tspan>`;
1929
+ }).join("");
1930
+ 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>`;
1931
+ }).join("")}</svg>`;
1932
+ }
1933
+ /**
1934
+ * Render an OG image from a template for a specific locale.
1935
+ * @internal Exported for testing.
1936
+ */
1937
+ async function renderOgImage(template, locale, rootDir) {
1938
+ try {
1939
+ const sharp = await import("sharp").then((m) => m.default ?? m);
1940
+ const width = template.width ?? 1200;
1941
+ const height = template.height ?? 630;
1942
+ let pipeline;
1943
+ if (typeof template.background === "string") pipeline = sharp(join(rootDir, template.background)).resize(width, height, { fit: "cover" });
1944
+ else pipeline = sharp({ create: {
1945
+ width,
1946
+ height,
1947
+ channels: 4,
1948
+ background: template.background.color
1949
+ } });
1950
+ if (template.layers && template.layers.length > 0) {
1951
+ const svgOverlay = buildTextOverlaySvg(template.layers, width, height, locale);
1952
+ pipeline = pipeline.composite([{
1953
+ input: Buffer.from(svgOverlay),
1954
+ top: 0,
1955
+ left: 0
1956
+ }]);
1957
+ }
1958
+ if (template.format === "jpeg") return await pipeline.jpeg({ quality: template.quality ?? 90 }).toBuffer();
1959
+ return await pipeline.png().toBuffer();
1960
+ } catch {
1961
+ warnSharpMissing();
1962
+ return null;
1963
+ }
1964
+ }
1965
+ /**
1966
+ * Compute the OG image path for a template and locale.
1967
+ *
1968
+ * @example
1969
+ * ```ts
1970
+ * ogImagePath("default", "de") // → "/og/default-de.png"
1971
+ * ogImagePath("default") // → "/og/default.png"
1972
+ * ogImagePath("hero", "en", "images") // → "/images/hero-en.png"
1973
+ * ```
1974
+ */
1975
+ function ogImagePath(templateName, locale, outDir = "og", format = "png") {
1976
+ const ext = format === "jpeg" ? "jpg" : "png";
1977
+ return `/${outDir}/${templateName}${locale ? `-${locale}` : ""}.${ext}`;
1978
+ }
1979
+ /**
1980
+ * OG image generation Vite plugin.
1981
+ *
1982
+ * Generates Open Graph images at build time. In dev, generates on-demand.
1983
+ * Requires `sharp` as an optional dependency.
1984
+ *
1985
+ * @example
1986
+ * ```ts
1987
+ * // vite.config.ts
1988
+ * import { ogImagePlugin } from "@pyreon/zero/og-image"
1989
+ *
1990
+ * export default {
1991
+ * plugins: [
1992
+ * ogImagePlugin({
1993
+ * locales: ["en", "de"],
1994
+ * templates: [{
1995
+ * name: "default",
1996
+ * background: { color: "#0066ff" },
1997
+ * layers: [{ text: { en: "Hello", de: "Hallo" }, fontSize: 72 }],
1998
+ * }],
1999
+ * }),
2000
+ * ],
2001
+ * }
2002
+ * ```
2003
+ */
2004
+ function ogImagePlugin(config) {
2005
+ const outDir = config.outDir ?? "og";
2006
+ let root = "";
2007
+ let isBuild = false;
2008
+ return {
2009
+ name: "pyreon-zero-og-image",
2010
+ enforce: "pre",
2011
+ configResolved(resolvedConfig) {
2012
+ root = resolvedConfig.root;
2013
+ isBuild = resolvedConfig.command === "build";
2014
+ },
2015
+ configureServer(server) {
2016
+ const devCache = /* @__PURE__ */ new Map();
2017
+ server.middlewares.use(async (req, res, next) => {
2018
+ const url = req.url ?? "";
2019
+ if (!url.startsWith(`/${outDir}/`)) return next();
2020
+ const match = url.slice(outDir.length + 2).match(/^(.+?)(?:-([a-z]{2,5}))?\.(png|jpe?g)$/);
2021
+ if (!match) return next();
2022
+ const [, templateName, locale, ext] = match;
2023
+ const template = config.templates.find((t) => t.name === templateName);
2024
+ if (!template) return next();
2025
+ const resolvedLocale = locale ?? config.locales?.[0] ?? "en";
2026
+ const cacheKey = `${templateName}:${resolvedLocale}`;
2027
+ let buffer = devCache.get(cacheKey);
2028
+ if (!buffer) {
2029
+ const result = await renderOgImage(template, resolvedLocale, root);
2030
+ if (!result) return next();
2031
+ buffer = result;
2032
+ devCache.set(cacheKey, result);
2033
+ }
2034
+ const contentType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
2035
+ res.setHeader("Content-Type", contentType);
2036
+ res.setHeader("Cache-Control", "no-cache");
2037
+ res.end(Buffer.from(buffer));
2038
+ });
2039
+ },
2040
+ async generateBundle() {
2041
+ if (!isBuild) return;
2042
+ for (const template of config.templates) {
2043
+ const locales = config.locales ?? [void 0];
2044
+ const ext = (template.format ?? "png") === "jpeg" ? "jpg" : "png";
2045
+ for (const locale of locales) {
2046
+ if (typeof template.background === "string") {
2047
+ const bgPath = join(root, template.background);
2048
+ if (!existsSync(bgPath)) {
2049
+ console.warn(`[zero:og-image] Background not found: ${bgPath}`);
2050
+ continue;
2051
+ }
2052
+ }
2053
+ const buffer = await renderOgImage(template, locale ?? "en", root);
2054
+ if (!buffer) continue;
2055
+ const suffix = locale ? `-${locale}` : "";
2056
+ this.emitFile({
2057
+ type: "asset",
2058
+ fileName: `${outDir}/${template.name}${suffix}.${ext}`,
2059
+ source: buffer
2060
+ });
2061
+ }
2062
+ }
2063
+ }
2064
+ };
2065
+ }
2066
+
2067
+ //#endregion
2068
+ //#region src/ai.ts
2069
+ /**
2070
+ * Generate llms.txt content from route files and config.
2071
+ *
2072
+ * Format follows the llms.txt proposal:
2073
+ * ```
2074
+ * # {name}
2075
+ * > {description}
2076
+ *
2077
+ * ## Pages
2078
+ * - [/about](/about): About page
2079
+ *
2080
+ * ## API
2081
+ * - GET /api/posts: List posts
2082
+ * ```
2083
+ *
2084
+ * @internal Exported for testing.
2085
+ */
2086
+ function generateLlmsTxt(routeFiles, apiFiles, config) {
2087
+ const lines = [];
2088
+ lines.push(`# ${config.name}`);
2089
+ lines.push(`> ${config.description}`);
2090
+ lines.push("");
2091
+ const routes = parseFileRoutes(routeFiles);
2092
+ const pages = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && !r.isCatchAll && !r.urlPath.includes(":"));
2093
+ if (pages.length > 0) {
2094
+ lines.push("## Pages");
2095
+ lines.push("");
2096
+ for (const page of pages) {
2097
+ const desc = config.pageDescriptions?.[page.urlPath];
2098
+ const url = `${config.origin}${page.urlPath === "/" ? "" : page.urlPath}`;
2099
+ if (desc) lines.push(`- [${page.urlPath}](${url}): ${desc}`);
2100
+ else lines.push(`- [${page.urlPath}](${url})`);
2101
+ }
2102
+ lines.push("");
2103
+ }
2104
+ const dynamicRoutes = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && (r.urlPath.includes(":") || r.isCatchAll));
2105
+ if (dynamicRoutes.length > 0) {
2106
+ lines.push("## Dynamic Pages");
2107
+ lines.push("");
2108
+ for (const route of dynamicRoutes) {
2109
+ const desc = config.pageDescriptions?.[route.urlPath];
2110
+ if (desc) lines.push(`- ${route.urlPath}: ${desc}`);
2111
+ else lines.push(`- ${route.urlPath}`);
2112
+ }
2113
+ lines.push("");
2114
+ }
2115
+ const apiPatterns = parseApiFiles(apiFiles);
2116
+ if (apiPatterns.length > 0 || config.apiDescriptions) {
2117
+ lines.push("## API Endpoints");
2118
+ lines.push("");
2119
+ if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) lines.push(`- ${endpoint}: ${desc}`);
2120
+ const describedPatterns = new Set(Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/, "")));
2121
+ for (const pattern of apiPatterns) if (!describedPatterns.has(pattern)) lines.push(`- ${pattern}`);
2122
+ lines.push("");
2123
+ }
2124
+ if (config.llmsExtra) {
2125
+ lines.push(config.llmsExtra);
2126
+ lines.push("");
2127
+ }
2128
+ return lines.join("\n");
2129
+ }
2130
+ /**
2131
+ * Generate llms-full.txt — expanded version with more detail.
2132
+ * Includes all route metadata and API descriptions.
2133
+ *
2134
+ * @internal Exported for testing.
2135
+ */
2136
+ function generateLlmsFullTxt(routeFiles, apiFiles, config) {
2137
+ const lines = [];
2138
+ lines.push(`# ${config.name} — Full Reference`);
2139
+ lines.push(`> ${config.description}`);
2140
+ lines.push("");
2141
+ lines.push(`Base URL: ${config.origin}`);
2142
+ lines.push("");
2143
+ const pages = parseFileRoutes(routeFiles).filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound);
2144
+ if (pages.length > 0) {
2145
+ lines.push("## All Routes");
2146
+ lines.push("");
2147
+ for (const page of pages) {
2148
+ const desc = config.pageDescriptions?.[page.urlPath] ?? "";
2149
+ const dynamic = page.urlPath.includes(":") ? " (dynamic)" : "";
2150
+ const catchAll = page.isCatchAll ? " (catch-all)" : "";
2151
+ lines.push(`### ${page.urlPath}${dynamic}${catchAll}`);
2152
+ if (desc) lines.push(desc);
2153
+ lines.push(`- File: ${page.filePath}`);
2154
+ lines.push(`- Render mode: ${page.renderMode}`);
2155
+ lines.push("");
2156
+ }
2157
+ }
2158
+ if (config.apiDescriptions) {
2159
+ lines.push("## API Reference");
2160
+ lines.push("");
2161
+ for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
2162
+ lines.push(`### ${endpoint}`);
2163
+ lines.push(desc);
2164
+ lines.push("");
2165
+ }
2166
+ }
2167
+ if (config.llmsExtra) {
2168
+ lines.push("## Additional Information");
2169
+ lines.push("");
2170
+ lines.push(config.llmsExtra);
2171
+ lines.push("");
2172
+ }
2173
+ return lines.join("\n");
2174
+ }
2175
+ /**
2176
+ * Auto-infer JSON-LD structured data from page metadata.
2177
+ *
2178
+ * Returns an array of JSON-LD objects (multiple schemas can apply to one page).
2179
+ * For example, an article page gets both `Article` and `BreadcrumbList`.
2180
+ *
2181
+ * @example
2182
+ * ```tsx
2183
+ * const schemas = inferJsonLd({
2184
+ * url: "https://example.com/blog/my-post",
2185
+ * title: "My Post",
2186
+ * description: "A great article",
2187
+ * type: "article",
2188
+ * author: "Vit Bokisch",
2189
+ * publishedTime: "2026-03-31",
2190
+ * })
2191
+ * // → [Article schema, BreadcrumbList schema]
2192
+ * ```
2193
+ */
2194
+ function inferJsonLd(options) {
2195
+ const schemas = [];
2196
+ if (options.type === "article") {
2197
+ const article = {
2198
+ "@context": "https://schema.org",
2199
+ "@type": "Article",
2200
+ headline: options.title,
2201
+ url: options.url
2202
+ };
2203
+ if (options.description) article.description = options.description;
2204
+ if (options.image) article.image = options.image;
2205
+ if (options.publishedTime) article.datePublished = options.publishedTime;
2206
+ if (options.author) article.author = {
2207
+ "@type": "Person",
2208
+ name: options.author
2209
+ };
2210
+ if (options.tags && options.tags.length > 0) article.keywords = options.tags.join(", ");
2211
+ if (options.siteName) article.publisher = {
2212
+ "@type": "Organization",
2213
+ name: options.siteName
2214
+ };
2215
+ schemas.push(article);
2216
+ } else if (options.type === "product") {
2217
+ const product = {
2218
+ "@context": "https://schema.org",
2219
+ "@type": "Product",
2220
+ name: options.title,
2221
+ url: options.url
2222
+ };
2223
+ if (options.description) product.description = options.description;
2224
+ if (options.image) product.image = options.image;
2225
+ schemas.push(product);
2226
+ } else {
2227
+ const webpage = {
2228
+ "@context": "https://schema.org",
2229
+ "@type": "WebPage",
2230
+ name: options.title,
2231
+ url: options.url
2232
+ };
2233
+ if (options.description) webpage.description = options.description;
2234
+ if (options.image) webpage.thumbnailUrl = options.image;
2235
+ schemas.push(webpage);
2236
+ }
2237
+ if (options.breadcrumbs && options.breadcrumbs.length > 0) schemas.push({
2238
+ "@context": "https://schema.org",
2239
+ "@type": "BreadcrumbList",
2240
+ itemListElement: options.breadcrumbs.map((bc, i) => ({
2241
+ "@type": "ListItem",
2242
+ position: i + 1,
2243
+ name: bc.name,
2244
+ item: bc.url
2245
+ }))
2246
+ });
2247
+ else {
2248
+ const urlObj = safeParseUrl(options.url);
2249
+ if (urlObj) {
2250
+ const segments = urlObj.pathname.split("/").filter(Boolean);
2251
+ if (segments.length > 0) {
2252
+ const items = [{
2253
+ "@type": "ListItem",
2254
+ position: 1,
2255
+ name: "Home",
2256
+ item: urlObj.origin
2257
+ }];
2258
+ let path = "";
2259
+ for (let i = 0; i < segments.length; i++) {
2260
+ path += `/${segments[i]}`;
2261
+ items.push({
2262
+ "@type": "ListItem",
2263
+ position: i + 2,
2264
+ name: capitalize(segments[i].replace(/-/g, " ")),
2265
+ item: `${urlObj.origin}${path}`
2266
+ });
2267
+ }
2268
+ schemas.push({
2269
+ "@context": "https://schema.org",
2270
+ "@type": "BreadcrumbList",
2271
+ itemListElement: items
2272
+ });
2273
+ }
2274
+ }
2275
+ }
2276
+ return schemas;
2277
+ }
2278
+ /**
2279
+ * Generate an OpenAI-compatible AI plugin manifest.
2280
+ *
2281
+ * Follows the /.well-known/ai-plugin.json spec.
2282
+ *
2283
+ * @internal Exported for testing.
2284
+ */
2285
+ function generateAiPluginManifest(config) {
2286
+ return {
2287
+ schema_version: "v1",
2288
+ name_for_human: config.name,
2289
+ name_for_model: config.name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, ""),
2290
+ description_for_human: config.description,
2291
+ description_for_model: config.description,
2292
+ auth: { type: "none" },
2293
+ api: {
2294
+ type: "openapi",
2295
+ url: `${config.origin}/.well-known/openapi.yaml`
2296
+ },
2297
+ logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,
2298
+ contact_email: config.contactEmail ?? "",
2299
+ legal_info_url: config.legalUrl ?? `${config.origin}/legal`
2300
+ };
2301
+ }
2302
+ /**
2303
+ * Generate a minimal OpenAPI 3.0 spec from API route descriptions.
2304
+ *
2305
+ * @internal Exported for testing.
2306
+ */
2307
+ function generateOpenApiSpec(apiFiles, config) {
2308
+ const paths = {};
2309
+ if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
2310
+ const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/);
2311
+ if (match) {
2312
+ const method = match[1].toLowerCase();
2313
+ const openApiPath = match[2].replace(/:(\w+)/g, "{$1}");
2314
+ if (!paths[openApiPath]) paths[openApiPath] = {};
2315
+ paths[openApiPath][method] = {
2316
+ summary: desc,
2317
+ responses: { "200": { description: "Success" } }
2318
+ };
2319
+ }
2320
+ }
2321
+ for (const pattern of parseApiFiles(apiFiles)) {
2322
+ const openApiPath = pattern.replace(/:(\w+)/g, "{$1}");
2323
+ if (!paths[openApiPath]) paths[openApiPath] = { get: {
2324
+ summary: `${openApiPath} endpoint`,
2325
+ responses: { "200": { description: "Success" } }
2326
+ } };
2327
+ }
2328
+ return {
2329
+ openapi: "3.0.0",
2330
+ info: {
2331
+ title: config.name,
2332
+ description: config.description,
2333
+ version: "1.0.0"
2334
+ },
2335
+ servers: [{ url: config.origin }],
2336
+ paths
2337
+ };
2338
+ }
2339
+ /**
2340
+ * AI integration Vite plugin.
2341
+ *
2342
+ * Generates at build time:
2343
+ * - `/llms.txt` — concise site summary for AI agents
2344
+ * - `/llms-full.txt` — detailed reference for AI agents
2345
+ * - `/.well-known/ai-plugin.json` — OpenAI plugin manifest
2346
+ * - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes
2347
+ *
2348
+ * In dev, serves these files via middleware.
2349
+ *
2350
+ * @example
2351
+ * ```ts
2352
+ * import { aiPlugin } from "@pyreon/zero/ai"
2353
+ *
2354
+ * export default {
2355
+ * plugins: [
2356
+ * aiPlugin({
2357
+ * name: "My App",
2358
+ * origin: "https://example.com",
2359
+ * description: "A modern web application",
2360
+ * apiDescriptions: {
2361
+ * "GET /api/posts": "List blog posts",
2362
+ * "GET /api/posts/:id": "Get post by ID",
2363
+ * },
2364
+ * }),
2365
+ * ],
2366
+ * }
2367
+ * ```
2368
+ */
2369
+ function aiPlugin(config) {
2370
+ let root = "";
2371
+ let isBuild = false;
2372
+ let routeFiles = [];
2373
+ let apiFiles = [];
2374
+ return {
2375
+ name: "pyreon-zero-ai",
2376
+ enforce: "post",
2377
+ configResolved(resolvedConfig) {
2378
+ root = resolvedConfig.root;
2379
+ isBuild = resolvedConfig.command === "build";
2380
+ },
2381
+ async buildStart() {
2382
+ try {
2383
+ const { join } = await import("node:path");
2384
+ const routesDir = join(root, config.routesDir ?? "src/routes");
2385
+ const apiDir = join(root, config.apiDir ?? "src/api");
2386
+ routeFiles = await scanDir(routesDir, routesDir);
2387
+ apiFiles = await scanDir(apiDir, apiDir);
2388
+ } catch {}
2389
+ },
2390
+ configureServer(server) {
2391
+ server.middlewares.use(async (req, res, next) => {
2392
+ const url = req.url ?? "";
2393
+ if (url === "/llms.txt") {
2394
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
2395
+ res.end(generateLlmsTxt(routeFiles, apiFiles, config));
2396
+ return;
2397
+ }
2398
+ if (url === "/llms-full.txt") {
2399
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
2400
+ res.end(generateLlmsFullTxt(routeFiles, apiFiles, config));
2401
+ return;
2402
+ }
2403
+ if (url === "/.well-known/ai-plugin.json") {
2404
+ res.setHeader("Content-Type", "application/json");
2405
+ res.end(JSON.stringify(generateAiPluginManifest(config), null, 2));
2406
+ return;
2407
+ }
2408
+ if (url === "/.well-known/openapi.yaml" || url === "/.well-known/openapi.json") {
2409
+ res.setHeader("Content-Type", "application/json");
2410
+ res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2));
2411
+ return;
2412
+ }
2413
+ next();
2414
+ });
2415
+ },
2416
+ async generateBundle() {
2417
+ if (!isBuild) return;
2418
+ this.emitFile({
2419
+ type: "asset",
2420
+ fileName: "llms.txt",
2421
+ source: generateLlmsTxt(routeFiles, apiFiles, config)
2422
+ });
2423
+ this.emitFile({
2424
+ type: "asset",
2425
+ fileName: "llms-full.txt",
2426
+ source: generateLlmsFullTxt(routeFiles, apiFiles, config)
2427
+ });
2428
+ this.emitFile({
2429
+ type: "asset",
2430
+ fileName: ".well-known/ai-plugin.json",
2431
+ source: JSON.stringify(generateAiPluginManifest(config), null, 2)
2432
+ });
2433
+ this.emitFile({
2434
+ type: "asset",
2435
+ fileName: ".well-known/openapi.json",
2436
+ source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2)
2437
+ });
2438
+ }
2439
+ };
2440
+ }
2441
+ function parseApiFiles(files) {
2442
+ return files.filter((f) => f.endsWith(".ts") || f.endsWith(".js")).map((f) => {
2443
+ let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "");
2444
+ if (!path.startsWith("/")) path = `/${path}`;
2445
+ path = path.replace(/\[\.\.\.(\w+)\]/g, ":$1*").replace(/\[(\w+)\]/g, ":$1");
2446
+ return `/api${path === "/" ? "" : path}`;
2447
+ });
2448
+ }
2449
+ async function scanDir(dir, base) {
2450
+ const { readdir, stat } = await import("node:fs/promises");
2451
+ const { join, relative } = await import("node:path");
2452
+ try {
2453
+ const entries = await readdir(dir);
2454
+ const files = [];
2455
+ for (const entry of entries) {
2456
+ const full = join(dir, entry);
2457
+ if ((await stat(full)).isDirectory()) files.push(...await scanDir(full, base));
2458
+ else files.push(relative(base, full));
2459
+ }
2460
+ return files;
2461
+ } catch {
2462
+ return [];
2463
+ }
2464
+ }
2465
+ function safeParseUrl(url) {
2466
+ try {
2467
+ return new URL(url);
2468
+ } catch {
2469
+ return null;
2470
+ }
2471
+ }
2472
+ function capitalize(s) {
2473
+ return s.charAt(0).toUpperCase() + s.slice(1);
2474
+ }
2475
+
1404
2476
  //#endregion
1405
2477
  //#region src/i18n-routing.ts
1406
2478
  /**
@@ -1530,5 +2602,5 @@ const LocaleCtx = createContext("en");
1530
2602
  const localeSignal = signal("en");
1531
2603
 
1532
2604
  //#endregion
1533
- export { bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, filePathToUrlPath, generateMiddlewareModule, generateRouteModule, getContext, i18nRouting, netlifyAdapter, nodeAdapter, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, staticAdapter, vercelAdapter };
2605
+ export { aiPlugin, bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter };
1534
2606
  //# sourceMappingURL=server.js.map