@pyreon/zero 0.12.7 → 0.12.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/favicon.js +6 -0
- package/lib/favicon.js.map +1 -1
- package/lib/fs-router-Dil4IKZR.js +290 -0
- package/lib/fs-router-Dil4IKZR.js.map +1 -0
- package/lib/image-plugin.js +68 -1
- package/lib/image-plugin.js.map +1 -1
- package/lib/server.js +1343 -265
- package/lib/server.js.map +1 -1
- package/lib/types/favicon.d.ts.map +1 -1
- package/lib/types/image-plugin.d.ts +49 -1
- package/lib/types/image-plugin.d.ts.map +1 -1
- package/lib/types/image.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/server.d.ts +425 -1
- package/lib/types/server.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/favicon.ts +13 -0
- package/src/image-plugin.ts +143 -0
- package/src/image-types.d.ts +51 -0
- package/src/server.ts +9 -1
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,1346 @@ 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
|
+
if (hasDark) tags.push({
|
|
1413
|
+
tag: "script",
|
|
1414
|
+
attrs: {},
|
|
1415
|
+
injectTo: "head",
|
|
1416
|
+
children: `(function(){try{var t=localStorage.getItem("zero-theme");var r=t==="light"?"light":t==="dark"?"dark":window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.querySelectorAll("[data-favicon-theme]").forEach(function(l){l.media=l.dataset.faviconTheme===r?"":"not all"})}catch(e){}})()`
|
|
1417
|
+
});
|
|
1418
|
+
return tags;
|
|
1419
|
+
},
|
|
1420
|
+
async generateBundle() {
|
|
1421
|
+
if (!isBuild) return;
|
|
1422
|
+
await generateFaviconSet.call(this, root, config.source, config.darkSource, "", config, themeColor, backgroundColor, generateManifest);
|
|
1423
|
+
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);
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.
|
|
1429
|
+
*/
|
|
1430
|
+
function wrapSvgWithDarkMode(lightSvg, darkSvg) {
|
|
1431
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${lightSvg.match(/viewBox="([^"]*)"/)?.[1] ?? "0 0 32 32"}">
|
|
1432
|
+
<style>
|
|
1433
|
+
:root { color-scheme: light dark; }
|
|
1434
|
+
@media (prefers-color-scheme: dark) { .light { display: none; } }
|
|
1435
|
+
@media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }
|
|
1436
|
+
</style>
|
|
1437
|
+
<g class="light">${stripSvgWrapper(lightSvg)}</g>
|
|
1438
|
+
<g class="dark">${stripSvgWrapper(darkSvg)}</g>
|
|
1439
|
+
</svg>`;
|
|
1440
|
+
}
|
|
1441
|
+
function stripSvgWrapper(svg) {
|
|
1442
|
+
return svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Resolve the source path for a locale-prefixed favicon URL.
|
|
1446
|
+
* Returns null if the URL is not locale-prefixed or locale has no override.
|
|
1447
|
+
*/
|
|
1448
|
+
function resolveLocaleSource(url, config, rootDir) {
|
|
1449
|
+
if (!config.locales) return null;
|
|
1450
|
+
for (const [locale, localeConfig] of Object.entries(config.locales)) {
|
|
1451
|
+
const prefix = `/${locale}/`;
|
|
1452
|
+
if (url.startsWith(prefix)) return {
|
|
1453
|
+
locale,
|
|
1454
|
+
url,
|
|
1455
|
+
source: localeConfig.source,
|
|
1456
|
+
sourcePath: join(rootDir, localeConfig.source)
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
return null;
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.
|
|
1463
|
+
* Called once for base (prefix = '') and once per locale (prefix = '{locale}/').
|
|
1464
|
+
*/
|
|
1465
|
+
async function generateFaviconSet(rootDir, source, darkSource, prefix, config, themeColor, backgroundColor, generateManifest) {
|
|
1466
|
+
const sourcePath = join(rootDir, source);
|
|
1467
|
+
if (!existsSync(sourcePath)) {
|
|
1468
|
+
console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
if (source.endsWith(".svg")) {
|
|
1472
|
+
const svgContent = await readFile(sourcePath, "utf-8");
|
|
1473
|
+
let finalSvg = svgContent;
|
|
1474
|
+
if (darkSource) {
|
|
1475
|
+
const darkPath = join(rootDir, darkSource);
|
|
1476
|
+
if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
|
|
1477
|
+
}
|
|
1478
|
+
this.emitFile({
|
|
1479
|
+
type: "asset",
|
|
1480
|
+
fileName: `${prefix}favicon.svg`,
|
|
1481
|
+
source: finalSvg
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
if (darkSource) {
|
|
1485
|
+
const darkPath = join(rootDir, darkSource);
|
|
1486
|
+
const darkExists = existsSync(darkPath);
|
|
1487
|
+
for (const { size, name } of SIZES) {
|
|
1488
|
+
const lightName = name.replace(/^(favicon-)/, "$1light-").replace(/^(apple-touch-icon)/, "$1-light").replace(/^(icon-)/, "$1light-");
|
|
1489
|
+
const lightPng = await resizeToPng(sourcePath, size);
|
|
1490
|
+
if (lightPng) this.emitFile({
|
|
1491
|
+
type: "asset",
|
|
1492
|
+
fileName: `${prefix}${lightName}`,
|
|
1493
|
+
source: lightPng
|
|
1494
|
+
});
|
|
1495
|
+
if (darkExists) {
|
|
1496
|
+
const darkName = name.replace(/^(favicon-)/, "$1dark-").replace(/^(apple-touch-icon)/, "$1-dark").replace(/^(icon-)/, "$1dark-");
|
|
1497
|
+
const darkPng = await resizeToPng(darkPath, size);
|
|
1498
|
+
if (darkPng) this.emitFile({
|
|
1499
|
+
type: "asset",
|
|
1500
|
+
fileName: `${prefix}${darkName}`,
|
|
1501
|
+
source: darkPng
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
for (const { size, name } of SIZES) {
|
|
1506
|
+
const pngBuffer = await resizeToPng(sourcePath, size);
|
|
1507
|
+
if (pngBuffer) this.emitFile({
|
|
1508
|
+
type: "asset",
|
|
1509
|
+
fileName: `${prefix}${name}`,
|
|
1510
|
+
source: pngBuffer
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
} else for (const { size, name } of SIZES) {
|
|
1514
|
+
const pngBuffer = await resizeToPng(sourcePath, size);
|
|
1515
|
+
if (pngBuffer) this.emitFile({
|
|
1516
|
+
type: "asset",
|
|
1517
|
+
fileName: `${prefix}${name}`,
|
|
1518
|
+
source: pngBuffer
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
const ico = await generateIco(sourcePath);
|
|
1522
|
+
if (ico) this.emitFile({
|
|
1523
|
+
type: "asset",
|
|
1524
|
+
fileName: `${prefix}favicon.ico`,
|
|
1525
|
+
source: ico
|
|
1526
|
+
});
|
|
1527
|
+
if (generateManifest) {
|
|
1528
|
+
const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : "";
|
|
1529
|
+
const manifest = {
|
|
1530
|
+
name: config.name ?? "App",
|
|
1531
|
+
short_name: config.name ?? "App",
|
|
1532
|
+
icons: [{
|
|
1533
|
+
src: `${manifestPrefix}/icon-192.png`,
|
|
1534
|
+
sizes: "192x192",
|
|
1535
|
+
type: "image/png"
|
|
1536
|
+
}, {
|
|
1537
|
+
src: `${manifestPrefix}/icon-512.png`,
|
|
1538
|
+
sizes: "512x512",
|
|
1539
|
+
type: "image/png"
|
|
1540
|
+
}],
|
|
1541
|
+
theme_color: themeColor,
|
|
1542
|
+
background_color: backgroundColor,
|
|
1543
|
+
display: "standalone"
|
|
1544
|
+
};
|
|
1545
|
+
this.emitFile({
|
|
1546
|
+
type: "asset",
|
|
1547
|
+
fileName: `${prefix}site.webmanifest`,
|
|
1548
|
+
source: JSON.stringify(manifest, null, 2)
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Get favicon link tags for a specific locale.
|
|
1554
|
+
* Returns link objects suitable for `useHead()` or direct HTML injection.
|
|
1555
|
+
*
|
|
1556
|
+
* @example
|
|
1557
|
+
* ```ts
|
|
1558
|
+
* const links = faviconLinks("de", { source: "./icon.svg", locales: { de: { source: "./icon-de.svg" } } })
|
|
1559
|
+
* // → [{ rel: "icon", type: "image/svg+xml", href: "/de/favicon.svg" }, ...]
|
|
1560
|
+
* ```
|
|
1561
|
+
*/
|
|
1562
|
+
function faviconLinks(locale, config) {
|
|
1563
|
+
const hasLocaleOverride = locale && config.locales?.[locale];
|
|
1564
|
+
const prefix = hasLocaleOverride ? `/${locale}` : "";
|
|
1565
|
+
const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
|
|
1566
|
+
const links = [];
|
|
1567
|
+
if (isSvg) links.push({
|
|
1568
|
+
rel: "icon",
|
|
1569
|
+
type: "image/svg+xml",
|
|
1570
|
+
href: `${prefix}/favicon.svg`
|
|
1571
|
+
});
|
|
1572
|
+
links.push({
|
|
1573
|
+
rel: "icon",
|
|
1574
|
+
type: "image/png",
|
|
1575
|
+
sizes: "32x32",
|
|
1576
|
+
href: `${prefix}/favicon-32x32.png`
|
|
1577
|
+
}, {
|
|
1578
|
+
rel: "icon",
|
|
1579
|
+
type: "image/png",
|
|
1580
|
+
sizes: "16x16",
|
|
1581
|
+
href: `${prefix}/favicon-16x16.png`
|
|
1582
|
+
}, {
|
|
1583
|
+
rel: "apple-touch-icon",
|
|
1584
|
+
sizes: "180x180",
|
|
1585
|
+
href: `${prefix}/apple-touch-icon.png`
|
|
1586
|
+
});
|
|
1587
|
+
if (config.manifest !== false) links.push({
|
|
1588
|
+
rel: "manifest",
|
|
1589
|
+
href: `${prefix}/site.webmanifest`
|
|
1590
|
+
});
|
|
1591
|
+
return links;
|
|
1592
|
+
}
|
|
1593
|
+
async function resizeToPng(input, size) {
|
|
1594
|
+
try {
|
|
1595
|
+
return await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, {
|
|
1596
|
+
fit: "contain",
|
|
1597
|
+
background: {
|
|
1598
|
+
r: 0,
|
|
1599
|
+
g: 0,
|
|
1600
|
+
b: 0,
|
|
1601
|
+
alpha: 0
|
|
1602
|
+
}
|
|
1603
|
+
}).png().toBuffer();
|
|
1604
|
+
} catch {
|
|
1605
|
+
warnSharpMissing$1();
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
async function generateIco(input) {
|
|
1610
|
+
try {
|
|
1611
|
+
const sharp = await import("sharp").then((m) => m.default ?? m);
|
|
1612
|
+
const png16 = await sharp(input).resize(16, 16, {
|
|
1613
|
+
fit: "contain",
|
|
1614
|
+
background: {
|
|
1615
|
+
r: 0,
|
|
1616
|
+
g: 0,
|
|
1617
|
+
b: 0,
|
|
1618
|
+
alpha: 0
|
|
1619
|
+
}
|
|
1620
|
+
}).png().toBuffer();
|
|
1621
|
+
const png32 = await sharp(input).resize(32, 32, {
|
|
1622
|
+
fit: "contain",
|
|
1623
|
+
background: {
|
|
1624
|
+
r: 0,
|
|
1625
|
+
g: 0,
|
|
1626
|
+
b: 0,
|
|
1627
|
+
alpha: 0
|
|
1628
|
+
}
|
|
1629
|
+
}).png().toBuffer();
|
|
1630
|
+
return createIcoFromPngs([{
|
|
1631
|
+
buffer: png16,
|
|
1632
|
+
size: 16
|
|
1633
|
+
}, {
|
|
1634
|
+
buffer: png32,
|
|
1635
|
+
size: 32
|
|
1636
|
+
}]);
|
|
1637
|
+
} catch {
|
|
1638
|
+
warnSharpMissing$1();
|
|
1639
|
+
return null;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
/** @internal Exported for testing */
|
|
1643
|
+
function createIcoFromPngs(entries) {
|
|
1644
|
+
const headerSize = 6;
|
|
1645
|
+
const dirEntrySize = 16;
|
|
1646
|
+
const dirSize = dirEntrySize * entries.length;
|
|
1647
|
+
let dataOffset = headerSize + dirSize;
|
|
1648
|
+
const header = Buffer.alloc(headerSize);
|
|
1649
|
+
header.writeUInt16LE(0, 0);
|
|
1650
|
+
header.writeUInt16LE(1, 2);
|
|
1651
|
+
header.writeUInt16LE(entries.length, 4);
|
|
1652
|
+
const dirEntries = Buffer.alloc(dirSize);
|
|
1653
|
+
const dataBuffers = [];
|
|
1654
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1655
|
+
const entry = entries[i];
|
|
1656
|
+
const offset = i * dirEntrySize;
|
|
1657
|
+
dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset);
|
|
1658
|
+
dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1);
|
|
1659
|
+
dirEntries.writeUInt8(0, offset + 2);
|
|
1660
|
+
dirEntries.writeUInt8(0, offset + 3);
|
|
1661
|
+
dirEntries.writeUInt16LE(1, offset + 4);
|
|
1662
|
+
dirEntries.writeUInt16LE(32, offset + 6);
|
|
1663
|
+
dirEntries.writeUInt32LE(entry.buffer.length, offset + 8);
|
|
1664
|
+
dirEntries.writeUInt32LE(dataOffset, offset + 12);
|
|
1665
|
+
dataOffset += entry.buffer.length;
|
|
1666
|
+
dataBuffers.push(entry.buffer);
|
|
1667
|
+
}
|
|
1668
|
+
return Buffer.concat([
|
|
1669
|
+
header,
|
|
1670
|
+
dirEntries,
|
|
1671
|
+
...dataBuffers
|
|
1672
|
+
]);
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Add a "DEV" badge overlay to an SVG string.
|
|
1676
|
+
* Adds a small colored circle with "DEV" text in the bottom-right corner.
|
|
1677
|
+
*/
|
|
1678
|
+
function addDevBadgeToSvg(svg) {
|
|
1679
|
+
const [, , w, h] = (svg.match(/viewBox="([^"]*)"/)?.[1] ?? "0 0 32 32").split(" ").map(Number);
|
|
1680
|
+
const size = Math.min(w ?? 32, h ?? 32);
|
|
1681
|
+
const r = size * .28;
|
|
1682
|
+
const cx = (w ?? 32) - r;
|
|
1683
|
+
const cy = (h ?? 32) - r;
|
|
1684
|
+
const fontSize = r * .85;
|
|
1685
|
+
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>`;
|
|
1686
|
+
return svg.replace(/<\/svg>\s*$/, `${badge}</svg>`);
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Add a "DEV" badge to a PNG buffer via sharp composite.
|
|
1690
|
+
* Composites a red circle with "D" in the bottom-right corner.
|
|
1691
|
+
*/
|
|
1692
|
+
async function addDevBadgeToPng(pngBuffer, size) {
|
|
1693
|
+
try {
|
|
1694
|
+
const sharp = await import("sharp").then((m) => m.default ?? m);
|
|
1695
|
+
const r = Math.round(size * .28);
|
|
1696
|
+
const d = r * 2;
|
|
1697
|
+
const badgeSvg = `<svg width="${d}" height="${d}" xmlns="http://www.w3.org/2000/svg">
|
|
1698
|
+
<circle cx="${r}" cy="${r}" r="${r}" fill="#ef4444"/>
|
|
1699
|
+
<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>
|
|
1700
|
+
</svg>`;
|
|
1701
|
+
const badgePng = await sharp(Buffer.from(badgeSvg)).png().toBuffer();
|
|
1702
|
+
return await sharp(Buffer.from(pngBuffer)).composite([{
|
|
1703
|
+
input: badgePng,
|
|
1704
|
+
gravity: "southeast"
|
|
1705
|
+
}]).png().toBuffer();
|
|
1706
|
+
} catch {
|
|
1707
|
+
return pngBuffer;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
//#endregion
|
|
1712
|
+
//#region src/seo.ts
|
|
1713
|
+
/**
|
|
1714
|
+
* Generate a sitemap.xml string from route file paths.
|
|
1715
|
+
*/
|
|
1716
|
+
function generateSitemap(routeFiles, config) {
|
|
1717
|
+
const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
|
|
1718
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1719
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
1720
|
+
${[...routeFiles.filter((f) => {
|
|
1721
|
+
const name = f.split("/").pop()?.replace(/\.\w+$/, "");
|
|
1722
|
+
return name !== "_layout" && name !== "_error" && name !== "_loading";
|
|
1723
|
+
}).map((f) => {
|
|
1724
|
+
let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "/").replace(/^index$/, "/");
|
|
1725
|
+
if (path.includes("[")) return null;
|
|
1726
|
+
path = path.replace(/\([\w-]+\)\//g, "");
|
|
1727
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
1728
|
+
return path;
|
|
1729
|
+
}).filter((p) => p !== null).filter((p) => !exclude.some((e) => p.startsWith(e))).map((p) => ({
|
|
1730
|
+
path: p,
|
|
1731
|
+
changefreq,
|
|
1732
|
+
priority
|
|
1733
|
+
})), ...config.additionalPaths ?? []].map((entry) => {
|
|
1734
|
+
return ` <url>
|
|
1735
|
+
<loc>${escapeXml$1(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
|
|
1736
|
+
<changefreq>${entry.changefreq ?? changefreq}</changefreq>
|
|
1737
|
+
<priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
|
|
1738
|
+
</url>`;
|
|
1739
|
+
}).join("\n")}
|
|
1740
|
+
</urlset>`;
|
|
1741
|
+
}
|
|
1742
|
+
function escapeXml$1(str) {
|
|
1743
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Generate a robots.txt string.
|
|
1747
|
+
*/
|
|
1748
|
+
function generateRobots(config = {}) {
|
|
1749
|
+
const { rules = [{
|
|
1750
|
+
userAgent: "*",
|
|
1751
|
+
allow: ["/"]
|
|
1752
|
+
}], sitemap, host } = config;
|
|
1753
|
+
const lines = [];
|
|
1754
|
+
for (const rule of rules) {
|
|
1755
|
+
lines.push(`User-agent: ${rule.userAgent}`);
|
|
1756
|
+
if (rule.allow) for (const path of rule.allow) lines.push(`Allow: ${path}`);
|
|
1757
|
+
if (rule.disallow) for (const path of rule.disallow) lines.push(`Disallow: ${path}`);
|
|
1758
|
+
if (rule.crawlDelay) lines.push(`Crawl-delay: ${rule.crawlDelay}`);
|
|
1759
|
+
lines.push("");
|
|
1760
|
+
}
|
|
1761
|
+
if (sitemap) lines.push(`Sitemap: ${sitemap}`);
|
|
1762
|
+
if (host) lines.push(`Host: ${host}`);
|
|
1763
|
+
return lines.join("\n");
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Generate a JSON-LD script tag string for structured data.
|
|
1767
|
+
*
|
|
1768
|
+
* @example
|
|
1769
|
+
* useHead({
|
|
1770
|
+
* script: [jsonLd({
|
|
1771
|
+
* "@type": "WebSite",
|
|
1772
|
+
* name: "My Site",
|
|
1773
|
+
* url: "https://example.com",
|
|
1774
|
+
* })],
|
|
1775
|
+
* })
|
|
1776
|
+
*/
|
|
1777
|
+
function jsonLd(data) {
|
|
1778
|
+
const ld = {
|
|
1779
|
+
"@context": "https://schema.org",
|
|
1780
|
+
...data
|
|
1781
|
+
};
|
|
1782
|
+
return `<script type="application/ld+json">${JSON.stringify(ld)}<\/script>`;
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* Zero SEO Vite plugin.
|
|
1786
|
+
* Generates sitemap.xml and robots.txt at build time.
|
|
1787
|
+
*
|
|
1788
|
+
* @example
|
|
1789
|
+
* import { seoPlugin } from "@pyreon/zero/seo"
|
|
1790
|
+
*
|
|
1791
|
+
* export default {
|
|
1792
|
+
* plugins: [
|
|
1793
|
+
* pyreon(),
|
|
1794
|
+
* zero(),
|
|
1795
|
+
* seoPlugin({
|
|
1796
|
+
* sitemap: { origin: "https://example.com" },
|
|
1797
|
+
* robots: { sitemap: "https://example.com/sitemap.xml" },
|
|
1798
|
+
* }),
|
|
1799
|
+
* ],
|
|
1800
|
+
* }
|
|
1801
|
+
*/
|
|
1802
|
+
function seoPlugin(config = {}) {
|
|
1803
|
+
return {
|
|
1804
|
+
name: "pyreon-zero-seo",
|
|
1805
|
+
apply: "build",
|
|
1806
|
+
async generateBundle(_, _bundle) {
|
|
1807
|
+
if (config.sitemap) {
|
|
1808
|
+
const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
|
|
1809
|
+
const routesDir = `${process.cwd()}/src/routes`;
|
|
1810
|
+
try {
|
|
1811
|
+
const sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
|
|
1812
|
+
this.emitFile({
|
|
1813
|
+
type: "asset",
|
|
1814
|
+
fileName: "sitemap.xml",
|
|
1815
|
+
source: sitemap
|
|
1816
|
+
});
|
|
1817
|
+
} catch {}
|
|
1818
|
+
}
|
|
1819
|
+
if (config.robots) {
|
|
1820
|
+
const robots = generateRobots(config.robots);
|
|
1821
|
+
this.emitFile({
|
|
1822
|
+
type: "asset",
|
|
1823
|
+
fileName: "robots.txt",
|
|
1824
|
+
source: robots
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* SEO middleware for dev server.
|
|
1832
|
+
* Serves sitemap.xml and robots.txt dynamically during development.
|
|
1833
|
+
*/
|
|
1834
|
+
function seoMiddleware(config = {}) {
|
|
1835
|
+
return async (ctx) => {
|
|
1836
|
+
if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
|
|
1837
|
+
if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
|
|
1838
|
+
const { scanRouteFiles } = await import("./fs-router-Dil4IKZR.js").then((n) => n.n);
|
|
1839
|
+
const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
|
|
1840
|
+
return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
|
|
1841
|
+
} catch {}
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
//#endregion
|
|
1846
|
+
//#region src/og-image.ts
|
|
1847
|
+
/**
|
|
1848
|
+
* OG Image generation plugin.
|
|
1849
|
+
*
|
|
1850
|
+
* Generates Open Graph images at build time from templates with
|
|
1851
|
+
* text overlays. Supports locale-specific text for i18n apps.
|
|
1852
|
+
* Uses sharp for image processing (same optional dep as favicon/image plugins).
|
|
1853
|
+
*
|
|
1854
|
+
* @example
|
|
1855
|
+
* ```ts
|
|
1856
|
+
* // vite.config.ts
|
|
1857
|
+
* import { ogImagePlugin } from "@pyreon/zero/og-image"
|
|
1858
|
+
*
|
|
1859
|
+
* export default {
|
|
1860
|
+
* plugins: [
|
|
1861
|
+
* ogImagePlugin({
|
|
1862
|
+
* locales: ["en", "de", "cs"],
|
|
1863
|
+
* templates: [{
|
|
1864
|
+
* name: "default",
|
|
1865
|
+
* background: "./src/assets/og-bg.jpg",
|
|
1866
|
+
* layers: [{
|
|
1867
|
+
* text: { en: "Build faster", de: "Schneller bauen", cs: "Stavte rychleji" },
|
|
1868
|
+
* y: "40%",
|
|
1869
|
+
* fontSize: 72,
|
|
1870
|
+
* }],
|
|
1871
|
+
* }],
|
|
1872
|
+
* }),
|
|
1873
|
+
* ],
|
|
1874
|
+
* }
|
|
1875
|
+
* ```
|
|
1876
|
+
*/
|
|
1877
|
+
let sharpWarned = false;
|
|
1878
|
+
function warnSharpMissing() {
|
|
1879
|
+
if (sharpWarned) return;
|
|
1880
|
+
sharpWarned = true;
|
|
1881
|
+
console.warn("\n[zero:og-image] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n");
|
|
1882
|
+
}
|
|
1883
|
+
function resolvePosition(value, dimension, fallback = "50%") {
|
|
1884
|
+
if (value === void 0) value = fallback;
|
|
1885
|
+
if (typeof value === "number") return value;
|
|
1886
|
+
if (value.endsWith("%")) return Math.round(Number.parseFloat(value) / 100 * dimension);
|
|
1887
|
+
return Number.parseInt(value, 10) || 0;
|
|
1888
|
+
}
|
|
1889
|
+
function resolveLayerText(layer, locale) {
|
|
1890
|
+
if (typeof layer.text === "string") return layer.text;
|
|
1891
|
+
if (typeof layer.text === "function") return layer.text(locale);
|
|
1892
|
+
return layer.text[locale] ?? layer.text[Object.keys(layer.text)[0] ?? ""] ?? "";
|
|
1893
|
+
}
|
|
1894
|
+
function escapeXml(str) {
|
|
1895
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Build an SVG overlay with text layers.
|
|
1899
|
+
* @internal Exported for testing.
|
|
1900
|
+
*/
|
|
1901
|
+
function buildTextOverlaySvg(layers, width, height, locale) {
|
|
1902
|
+
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${layers.map((layer) => {
|
|
1903
|
+
const text = resolveLayerText(layer, locale);
|
|
1904
|
+
const x = resolvePosition(layer.x, width, "50%");
|
|
1905
|
+
const y = resolvePosition(layer.y, height, "50%");
|
|
1906
|
+
const fontSize = layer.fontSize ?? 64;
|
|
1907
|
+
const fontFamily = layer.fontFamily ?? "sans-serif";
|
|
1908
|
+
const fontWeight = layer.fontWeight ?? "bold";
|
|
1909
|
+
const color = layer.color ?? "#ffffff";
|
|
1910
|
+
const anchor = layer.textAnchor ?? "middle";
|
|
1911
|
+
const maxWidth = layer.maxWidth ?? Math.round(width * .8);
|
|
1912
|
+
const words = text.split(" ");
|
|
1913
|
+
const lines = [];
|
|
1914
|
+
let currentLine = "";
|
|
1915
|
+
const estimateWidth = (s) => {
|
|
1916
|
+
let width = 0;
|
|
1917
|
+
for (let i = 0; i < s.length; i++) {
|
|
1918
|
+
const code = s.charCodeAt(i);
|
|
1919
|
+
if (code >= 12288 && code <= 40959) width += fontSize * 1;
|
|
1920
|
+
else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) width += fontSize * .35;
|
|
1921
|
+
else width += fontSize * .55;
|
|
1922
|
+
}
|
|
1923
|
+
return width;
|
|
1924
|
+
};
|
|
1925
|
+
for (const word of words) {
|
|
1926
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
1927
|
+
if (estimateWidth(testLine) > maxWidth && currentLine) {
|
|
1928
|
+
lines.push(currentLine);
|
|
1929
|
+
currentLine = word;
|
|
1930
|
+
} else currentLine = testLine;
|
|
1931
|
+
}
|
|
1932
|
+
if (currentLine) lines.push(currentLine);
|
|
1933
|
+
const tspans = lines.map((line, i) => {
|
|
1934
|
+
return `<tspan x="${x}" dy="${i === 0 ? "0" : `${fontSize * 1.2}`}">${escapeXml(line)}</tspan>`;
|
|
1935
|
+
}).join("");
|
|
1936
|
+
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>`;
|
|
1937
|
+
}).join("")}</svg>`;
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Render an OG image from a template for a specific locale.
|
|
1941
|
+
* @internal Exported for testing.
|
|
1942
|
+
*/
|
|
1943
|
+
async function renderOgImage(template, locale, rootDir) {
|
|
1944
|
+
try {
|
|
1945
|
+
const sharp = await import("sharp").then((m) => m.default ?? m);
|
|
1946
|
+
const width = template.width ?? 1200;
|
|
1947
|
+
const height = template.height ?? 630;
|
|
1948
|
+
let pipeline;
|
|
1949
|
+
if (typeof template.background === "string") pipeline = sharp(join(rootDir, template.background)).resize(width, height, { fit: "cover" });
|
|
1950
|
+
else pipeline = sharp({ create: {
|
|
1951
|
+
width,
|
|
1952
|
+
height,
|
|
1953
|
+
channels: 4,
|
|
1954
|
+
background: template.background.color
|
|
1955
|
+
} });
|
|
1956
|
+
if (template.layers && template.layers.length > 0) {
|
|
1957
|
+
const svgOverlay = buildTextOverlaySvg(template.layers, width, height, locale);
|
|
1958
|
+
pipeline = pipeline.composite([{
|
|
1959
|
+
input: Buffer.from(svgOverlay),
|
|
1960
|
+
top: 0,
|
|
1961
|
+
left: 0
|
|
1962
|
+
}]);
|
|
1963
|
+
}
|
|
1964
|
+
if (template.format === "jpeg") return await pipeline.jpeg({ quality: template.quality ?? 90 }).toBuffer();
|
|
1965
|
+
return await pipeline.png().toBuffer();
|
|
1966
|
+
} catch {
|
|
1967
|
+
warnSharpMissing();
|
|
1968
|
+
return null;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Compute the OG image path for a template and locale.
|
|
1973
|
+
*
|
|
1974
|
+
* @example
|
|
1975
|
+
* ```ts
|
|
1976
|
+
* ogImagePath("default", "de") // → "/og/default-de.png"
|
|
1977
|
+
* ogImagePath("default") // → "/og/default.png"
|
|
1978
|
+
* ogImagePath("hero", "en", "images") // → "/images/hero-en.png"
|
|
1979
|
+
* ```
|
|
1980
|
+
*/
|
|
1981
|
+
function ogImagePath(templateName, locale, outDir = "og", format = "png") {
|
|
1982
|
+
const ext = format === "jpeg" ? "jpg" : "png";
|
|
1983
|
+
return `/${outDir}/${templateName}${locale ? `-${locale}` : ""}.${ext}`;
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* OG image generation Vite plugin.
|
|
1987
|
+
*
|
|
1988
|
+
* Generates Open Graph images at build time. In dev, generates on-demand.
|
|
1989
|
+
* Requires `sharp` as an optional dependency.
|
|
1990
|
+
*
|
|
1991
|
+
* @example
|
|
1992
|
+
* ```ts
|
|
1993
|
+
* // vite.config.ts
|
|
1994
|
+
* import { ogImagePlugin } from "@pyreon/zero/og-image"
|
|
1995
|
+
*
|
|
1996
|
+
* export default {
|
|
1997
|
+
* plugins: [
|
|
1998
|
+
* ogImagePlugin({
|
|
1999
|
+
* locales: ["en", "de"],
|
|
2000
|
+
* templates: [{
|
|
2001
|
+
* name: "default",
|
|
2002
|
+
* background: { color: "#0066ff" },
|
|
2003
|
+
* layers: [{ text: { en: "Hello", de: "Hallo" }, fontSize: 72 }],
|
|
2004
|
+
* }],
|
|
2005
|
+
* }),
|
|
2006
|
+
* ],
|
|
2007
|
+
* }
|
|
2008
|
+
* ```
|
|
2009
|
+
*/
|
|
2010
|
+
function ogImagePlugin(config) {
|
|
2011
|
+
const outDir = config.outDir ?? "og";
|
|
2012
|
+
let root = "";
|
|
2013
|
+
let isBuild = false;
|
|
2014
|
+
return {
|
|
2015
|
+
name: "pyreon-zero-og-image",
|
|
2016
|
+
enforce: "pre",
|
|
2017
|
+
configResolved(resolvedConfig) {
|
|
2018
|
+
root = resolvedConfig.root;
|
|
2019
|
+
isBuild = resolvedConfig.command === "build";
|
|
2020
|
+
},
|
|
2021
|
+
configureServer(server) {
|
|
2022
|
+
const devCache = /* @__PURE__ */ new Map();
|
|
2023
|
+
server.middlewares.use(async (req, res, next) => {
|
|
2024
|
+
const url = req.url ?? "";
|
|
2025
|
+
if (!url.startsWith(`/${outDir}/`)) return next();
|
|
2026
|
+
const match = url.slice(outDir.length + 2).match(/^(.+?)(?:-([a-z]{2,5}))?\.(png|jpe?g)$/);
|
|
2027
|
+
if (!match) return next();
|
|
2028
|
+
const [, templateName, locale, ext] = match;
|
|
2029
|
+
const template = config.templates.find((t) => t.name === templateName);
|
|
2030
|
+
if (!template) return next();
|
|
2031
|
+
const resolvedLocale = locale ?? config.locales?.[0] ?? "en";
|
|
2032
|
+
const cacheKey = `${templateName}:${resolvedLocale}`;
|
|
2033
|
+
let buffer = devCache.get(cacheKey);
|
|
2034
|
+
if (!buffer) {
|
|
2035
|
+
const result = await renderOgImage(template, resolvedLocale, root);
|
|
2036
|
+
if (!result) return next();
|
|
2037
|
+
buffer = result;
|
|
2038
|
+
devCache.set(cacheKey, result);
|
|
2039
|
+
}
|
|
2040
|
+
const contentType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
|
|
2041
|
+
res.setHeader("Content-Type", contentType);
|
|
2042
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
2043
|
+
res.end(Buffer.from(buffer));
|
|
2044
|
+
});
|
|
2045
|
+
},
|
|
2046
|
+
async generateBundle() {
|
|
2047
|
+
if (!isBuild) return;
|
|
2048
|
+
for (const template of config.templates) {
|
|
2049
|
+
const locales = config.locales ?? [void 0];
|
|
2050
|
+
const ext = (template.format ?? "png") === "jpeg" ? "jpg" : "png";
|
|
2051
|
+
for (const locale of locales) {
|
|
2052
|
+
if (typeof template.background === "string") {
|
|
2053
|
+
const bgPath = join(root, template.background);
|
|
2054
|
+
if (!existsSync(bgPath)) {
|
|
2055
|
+
console.warn(`[zero:og-image] Background not found: ${bgPath}`);
|
|
2056
|
+
continue;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
const buffer = await renderOgImage(template, locale ?? "en", root);
|
|
2060
|
+
if (!buffer) continue;
|
|
2061
|
+
const suffix = locale ? `-${locale}` : "";
|
|
2062
|
+
this.emitFile({
|
|
2063
|
+
type: "asset",
|
|
2064
|
+
fileName: `${outDir}/${template.name}${suffix}.${ext}`,
|
|
2065
|
+
source: buffer
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
//#endregion
|
|
2074
|
+
//#region src/ai.ts
|
|
2075
|
+
/**
|
|
2076
|
+
* Generate llms.txt content from route files and config.
|
|
2077
|
+
*
|
|
2078
|
+
* Format follows the llms.txt proposal:
|
|
2079
|
+
* ```
|
|
2080
|
+
* # {name}
|
|
2081
|
+
* > {description}
|
|
2082
|
+
*
|
|
2083
|
+
* ## Pages
|
|
2084
|
+
* - [/about](/about): About page
|
|
2085
|
+
*
|
|
2086
|
+
* ## API
|
|
2087
|
+
* - GET /api/posts: List posts
|
|
2088
|
+
* ```
|
|
2089
|
+
*
|
|
2090
|
+
* @internal Exported for testing.
|
|
2091
|
+
*/
|
|
2092
|
+
function generateLlmsTxt(routeFiles, apiFiles, config) {
|
|
2093
|
+
const lines = [];
|
|
2094
|
+
lines.push(`# ${config.name}`);
|
|
2095
|
+
lines.push(`> ${config.description}`);
|
|
2096
|
+
lines.push("");
|
|
2097
|
+
const routes = parseFileRoutes(routeFiles);
|
|
2098
|
+
const pages = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && !r.isCatchAll && !r.urlPath.includes(":"));
|
|
2099
|
+
if (pages.length > 0) {
|
|
2100
|
+
lines.push("## Pages");
|
|
2101
|
+
lines.push("");
|
|
2102
|
+
for (const page of pages) {
|
|
2103
|
+
const desc = config.pageDescriptions?.[page.urlPath];
|
|
2104
|
+
const url = `${config.origin}${page.urlPath === "/" ? "" : page.urlPath}`;
|
|
2105
|
+
if (desc) lines.push(`- [${page.urlPath}](${url}): ${desc}`);
|
|
2106
|
+
else lines.push(`- [${page.urlPath}](${url})`);
|
|
2107
|
+
}
|
|
2108
|
+
lines.push("");
|
|
2109
|
+
}
|
|
2110
|
+
const dynamicRoutes = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && (r.urlPath.includes(":") || r.isCatchAll));
|
|
2111
|
+
if (dynamicRoutes.length > 0) {
|
|
2112
|
+
lines.push("## Dynamic Pages");
|
|
2113
|
+
lines.push("");
|
|
2114
|
+
for (const route of dynamicRoutes) {
|
|
2115
|
+
const desc = config.pageDescriptions?.[route.urlPath];
|
|
2116
|
+
if (desc) lines.push(`- ${route.urlPath}: ${desc}`);
|
|
2117
|
+
else lines.push(`- ${route.urlPath}`);
|
|
2118
|
+
}
|
|
2119
|
+
lines.push("");
|
|
2120
|
+
}
|
|
2121
|
+
const apiPatterns = parseApiFiles(apiFiles);
|
|
2122
|
+
if (apiPatterns.length > 0 || config.apiDescriptions) {
|
|
2123
|
+
lines.push("## API Endpoints");
|
|
2124
|
+
lines.push("");
|
|
2125
|
+
if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) lines.push(`- ${endpoint}: ${desc}`);
|
|
2126
|
+
const describedPatterns = new Set(Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/, "")));
|
|
2127
|
+
for (const pattern of apiPatterns) if (!describedPatterns.has(pattern)) lines.push(`- ${pattern}`);
|
|
2128
|
+
lines.push("");
|
|
2129
|
+
}
|
|
2130
|
+
if (config.llmsExtra) {
|
|
2131
|
+
lines.push(config.llmsExtra);
|
|
2132
|
+
lines.push("");
|
|
2133
|
+
}
|
|
2134
|
+
return lines.join("\n");
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* Generate llms-full.txt — expanded version with more detail.
|
|
2138
|
+
* Includes all route metadata and API descriptions.
|
|
2139
|
+
*
|
|
2140
|
+
* @internal Exported for testing.
|
|
2141
|
+
*/
|
|
2142
|
+
function generateLlmsFullTxt(routeFiles, apiFiles, config) {
|
|
2143
|
+
const lines = [];
|
|
2144
|
+
lines.push(`# ${config.name} — Full Reference`);
|
|
2145
|
+
lines.push(`> ${config.description}`);
|
|
2146
|
+
lines.push("");
|
|
2147
|
+
lines.push(`Base URL: ${config.origin}`);
|
|
2148
|
+
lines.push("");
|
|
2149
|
+
const pages = parseFileRoutes(routeFiles).filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound);
|
|
2150
|
+
if (pages.length > 0) {
|
|
2151
|
+
lines.push("## All Routes");
|
|
2152
|
+
lines.push("");
|
|
2153
|
+
for (const page of pages) {
|
|
2154
|
+
const desc = config.pageDescriptions?.[page.urlPath] ?? "";
|
|
2155
|
+
const dynamic = page.urlPath.includes(":") ? " (dynamic)" : "";
|
|
2156
|
+
const catchAll = page.isCatchAll ? " (catch-all)" : "";
|
|
2157
|
+
lines.push(`### ${page.urlPath}${dynamic}${catchAll}`);
|
|
2158
|
+
if (desc) lines.push(desc);
|
|
2159
|
+
lines.push(`- File: ${page.filePath}`);
|
|
2160
|
+
lines.push(`- Render mode: ${page.renderMode}`);
|
|
2161
|
+
lines.push("");
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
if (config.apiDescriptions) {
|
|
2165
|
+
lines.push("## API Reference");
|
|
2166
|
+
lines.push("");
|
|
2167
|
+
for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
2168
|
+
lines.push(`### ${endpoint}`);
|
|
2169
|
+
lines.push(desc);
|
|
2170
|
+
lines.push("");
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
if (config.llmsExtra) {
|
|
2174
|
+
lines.push("## Additional Information");
|
|
2175
|
+
lines.push("");
|
|
2176
|
+
lines.push(config.llmsExtra);
|
|
2177
|
+
lines.push("");
|
|
2178
|
+
}
|
|
2179
|
+
return lines.join("\n");
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* Auto-infer JSON-LD structured data from page metadata.
|
|
2183
|
+
*
|
|
2184
|
+
* Returns an array of JSON-LD objects (multiple schemas can apply to one page).
|
|
2185
|
+
* For example, an article page gets both `Article` and `BreadcrumbList`.
|
|
2186
|
+
*
|
|
2187
|
+
* @example
|
|
2188
|
+
* ```tsx
|
|
2189
|
+
* const schemas = inferJsonLd({
|
|
2190
|
+
* url: "https://example.com/blog/my-post",
|
|
2191
|
+
* title: "My Post",
|
|
2192
|
+
* description: "A great article",
|
|
2193
|
+
* type: "article",
|
|
2194
|
+
* author: "Vit Bokisch",
|
|
2195
|
+
* publishedTime: "2026-03-31",
|
|
2196
|
+
* })
|
|
2197
|
+
* // → [Article schema, BreadcrumbList schema]
|
|
2198
|
+
* ```
|
|
2199
|
+
*/
|
|
2200
|
+
function inferJsonLd(options) {
|
|
2201
|
+
const schemas = [];
|
|
2202
|
+
if (options.type === "article") {
|
|
2203
|
+
const article = {
|
|
2204
|
+
"@context": "https://schema.org",
|
|
2205
|
+
"@type": "Article",
|
|
2206
|
+
headline: options.title,
|
|
2207
|
+
url: options.url
|
|
2208
|
+
};
|
|
2209
|
+
if (options.description) article.description = options.description;
|
|
2210
|
+
if (options.image) article.image = options.image;
|
|
2211
|
+
if (options.publishedTime) article.datePublished = options.publishedTime;
|
|
2212
|
+
if (options.author) article.author = {
|
|
2213
|
+
"@type": "Person",
|
|
2214
|
+
name: options.author
|
|
2215
|
+
};
|
|
2216
|
+
if (options.tags && options.tags.length > 0) article.keywords = options.tags.join(", ");
|
|
2217
|
+
if (options.siteName) article.publisher = {
|
|
2218
|
+
"@type": "Organization",
|
|
2219
|
+
name: options.siteName
|
|
2220
|
+
};
|
|
2221
|
+
schemas.push(article);
|
|
2222
|
+
} else if (options.type === "product") {
|
|
2223
|
+
const product = {
|
|
2224
|
+
"@context": "https://schema.org",
|
|
2225
|
+
"@type": "Product",
|
|
2226
|
+
name: options.title,
|
|
2227
|
+
url: options.url
|
|
2228
|
+
};
|
|
2229
|
+
if (options.description) product.description = options.description;
|
|
2230
|
+
if (options.image) product.image = options.image;
|
|
2231
|
+
schemas.push(product);
|
|
2232
|
+
} else {
|
|
2233
|
+
const webpage = {
|
|
2234
|
+
"@context": "https://schema.org",
|
|
2235
|
+
"@type": "WebPage",
|
|
2236
|
+
name: options.title,
|
|
2237
|
+
url: options.url
|
|
2238
|
+
};
|
|
2239
|
+
if (options.description) webpage.description = options.description;
|
|
2240
|
+
if (options.image) webpage.thumbnailUrl = options.image;
|
|
2241
|
+
schemas.push(webpage);
|
|
2242
|
+
}
|
|
2243
|
+
if (options.breadcrumbs && options.breadcrumbs.length > 0) schemas.push({
|
|
2244
|
+
"@context": "https://schema.org",
|
|
2245
|
+
"@type": "BreadcrumbList",
|
|
2246
|
+
itemListElement: options.breadcrumbs.map((bc, i) => ({
|
|
2247
|
+
"@type": "ListItem",
|
|
2248
|
+
position: i + 1,
|
|
2249
|
+
name: bc.name,
|
|
2250
|
+
item: bc.url
|
|
2251
|
+
}))
|
|
2252
|
+
});
|
|
2253
|
+
else {
|
|
2254
|
+
const urlObj = safeParseUrl(options.url);
|
|
2255
|
+
if (urlObj) {
|
|
2256
|
+
const segments = urlObj.pathname.split("/").filter(Boolean);
|
|
2257
|
+
if (segments.length > 0) {
|
|
2258
|
+
const items = [{
|
|
2259
|
+
"@type": "ListItem",
|
|
2260
|
+
position: 1,
|
|
2261
|
+
name: "Home",
|
|
2262
|
+
item: urlObj.origin
|
|
2263
|
+
}];
|
|
2264
|
+
let path = "";
|
|
2265
|
+
for (let i = 0; i < segments.length; i++) {
|
|
2266
|
+
path += `/${segments[i]}`;
|
|
2267
|
+
items.push({
|
|
2268
|
+
"@type": "ListItem",
|
|
2269
|
+
position: i + 2,
|
|
2270
|
+
name: capitalize(segments[i].replace(/-/g, " ")),
|
|
2271
|
+
item: `${urlObj.origin}${path}`
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
schemas.push({
|
|
2275
|
+
"@context": "https://schema.org",
|
|
2276
|
+
"@type": "BreadcrumbList",
|
|
2277
|
+
itemListElement: items
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
return schemas;
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Generate an OpenAI-compatible AI plugin manifest.
|
|
2286
|
+
*
|
|
2287
|
+
* Follows the /.well-known/ai-plugin.json spec.
|
|
2288
|
+
*
|
|
2289
|
+
* @internal Exported for testing.
|
|
2290
|
+
*/
|
|
2291
|
+
function generateAiPluginManifest(config) {
|
|
2292
|
+
return {
|
|
2293
|
+
schema_version: "v1",
|
|
2294
|
+
name_for_human: config.name,
|
|
2295
|
+
name_for_model: config.name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, ""),
|
|
2296
|
+
description_for_human: config.description,
|
|
2297
|
+
description_for_model: config.description,
|
|
2298
|
+
auth: { type: "none" },
|
|
2299
|
+
api: {
|
|
2300
|
+
type: "openapi",
|
|
2301
|
+
url: `${config.origin}/.well-known/openapi.yaml`
|
|
2302
|
+
},
|
|
2303
|
+
logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,
|
|
2304
|
+
contact_email: config.contactEmail ?? "",
|
|
2305
|
+
legal_info_url: config.legalUrl ?? `${config.origin}/legal`
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* Generate a minimal OpenAPI 3.0 spec from API route descriptions.
|
|
2310
|
+
*
|
|
2311
|
+
* @internal Exported for testing.
|
|
2312
|
+
*/
|
|
2313
|
+
function generateOpenApiSpec(apiFiles, config) {
|
|
2314
|
+
const paths = {};
|
|
2315
|
+
if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
2316
|
+
const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/);
|
|
2317
|
+
if (match) {
|
|
2318
|
+
const method = match[1].toLowerCase();
|
|
2319
|
+
const openApiPath = match[2].replace(/:(\w+)/g, "{$1}");
|
|
2320
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
2321
|
+
paths[openApiPath][method] = {
|
|
2322
|
+
summary: desc,
|
|
2323
|
+
responses: { "200": { description: "Success" } }
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
for (const pattern of parseApiFiles(apiFiles)) {
|
|
2328
|
+
const openApiPath = pattern.replace(/:(\w+)/g, "{$1}");
|
|
2329
|
+
if (!paths[openApiPath]) paths[openApiPath] = { get: {
|
|
2330
|
+
summary: `${openApiPath} endpoint`,
|
|
2331
|
+
responses: { "200": { description: "Success" } }
|
|
2332
|
+
} };
|
|
2333
|
+
}
|
|
2334
|
+
return {
|
|
2335
|
+
openapi: "3.0.0",
|
|
2336
|
+
info: {
|
|
2337
|
+
title: config.name,
|
|
2338
|
+
description: config.description,
|
|
2339
|
+
version: "1.0.0"
|
|
2340
|
+
},
|
|
2341
|
+
servers: [{ url: config.origin }],
|
|
2342
|
+
paths
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
2345
|
+
/**
|
|
2346
|
+
* AI integration Vite plugin.
|
|
2347
|
+
*
|
|
2348
|
+
* Generates at build time:
|
|
2349
|
+
* - `/llms.txt` — concise site summary for AI agents
|
|
2350
|
+
* - `/llms-full.txt` — detailed reference for AI agents
|
|
2351
|
+
* - `/.well-known/ai-plugin.json` — OpenAI plugin manifest
|
|
2352
|
+
* - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes
|
|
2353
|
+
*
|
|
2354
|
+
* In dev, serves these files via middleware.
|
|
2355
|
+
*
|
|
2356
|
+
* @example
|
|
2357
|
+
* ```ts
|
|
2358
|
+
* import { aiPlugin } from "@pyreon/zero/ai"
|
|
2359
|
+
*
|
|
2360
|
+
* export default {
|
|
2361
|
+
* plugins: [
|
|
2362
|
+
* aiPlugin({
|
|
2363
|
+
* name: "My App",
|
|
2364
|
+
* origin: "https://example.com",
|
|
2365
|
+
* description: "A modern web application",
|
|
2366
|
+
* apiDescriptions: {
|
|
2367
|
+
* "GET /api/posts": "List blog posts",
|
|
2368
|
+
* "GET /api/posts/:id": "Get post by ID",
|
|
2369
|
+
* },
|
|
2370
|
+
* }),
|
|
2371
|
+
* ],
|
|
2372
|
+
* }
|
|
2373
|
+
* ```
|
|
2374
|
+
*/
|
|
2375
|
+
function aiPlugin(config) {
|
|
2376
|
+
let root = "";
|
|
2377
|
+
let isBuild = false;
|
|
2378
|
+
let routeFiles = [];
|
|
2379
|
+
let apiFiles = [];
|
|
2380
|
+
return {
|
|
2381
|
+
name: "pyreon-zero-ai",
|
|
2382
|
+
enforce: "post",
|
|
2383
|
+
configResolved(resolvedConfig) {
|
|
2384
|
+
root = resolvedConfig.root;
|
|
2385
|
+
isBuild = resolvedConfig.command === "build";
|
|
2386
|
+
},
|
|
2387
|
+
async buildStart() {
|
|
2388
|
+
try {
|
|
2389
|
+
const { join } = await import("node:path");
|
|
2390
|
+
const routesDir = join(root, config.routesDir ?? "src/routes");
|
|
2391
|
+
const apiDir = join(root, config.apiDir ?? "src/api");
|
|
2392
|
+
routeFiles = await scanDir(routesDir, routesDir);
|
|
2393
|
+
apiFiles = await scanDir(apiDir, apiDir);
|
|
2394
|
+
} catch {}
|
|
2395
|
+
},
|
|
2396
|
+
configureServer(server) {
|
|
2397
|
+
server.middlewares.use(async (req, res, next) => {
|
|
2398
|
+
const url = req.url ?? "";
|
|
2399
|
+
if (url === "/llms.txt") {
|
|
2400
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2401
|
+
res.end(generateLlmsTxt(routeFiles, apiFiles, config));
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
if (url === "/llms-full.txt") {
|
|
2405
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
2406
|
+
res.end(generateLlmsFullTxt(routeFiles, apiFiles, config));
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
if (url === "/.well-known/ai-plugin.json") {
|
|
2410
|
+
res.setHeader("Content-Type", "application/json");
|
|
2411
|
+
res.end(JSON.stringify(generateAiPluginManifest(config), null, 2));
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
if (url === "/.well-known/openapi.yaml" || url === "/.well-known/openapi.json") {
|
|
2415
|
+
res.setHeader("Content-Type", "application/json");
|
|
2416
|
+
res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2));
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
next();
|
|
2420
|
+
});
|
|
2421
|
+
},
|
|
2422
|
+
async generateBundle() {
|
|
2423
|
+
if (!isBuild) return;
|
|
2424
|
+
this.emitFile({
|
|
2425
|
+
type: "asset",
|
|
2426
|
+
fileName: "llms.txt",
|
|
2427
|
+
source: generateLlmsTxt(routeFiles, apiFiles, config)
|
|
2428
|
+
});
|
|
2429
|
+
this.emitFile({
|
|
2430
|
+
type: "asset",
|
|
2431
|
+
fileName: "llms-full.txt",
|
|
2432
|
+
source: generateLlmsFullTxt(routeFiles, apiFiles, config)
|
|
2433
|
+
});
|
|
2434
|
+
this.emitFile({
|
|
2435
|
+
type: "asset",
|
|
2436
|
+
fileName: ".well-known/ai-plugin.json",
|
|
2437
|
+
source: JSON.stringify(generateAiPluginManifest(config), null, 2)
|
|
2438
|
+
});
|
|
2439
|
+
this.emitFile({
|
|
2440
|
+
type: "asset",
|
|
2441
|
+
fileName: ".well-known/openapi.json",
|
|
2442
|
+
source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2)
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
function parseApiFiles(files) {
|
|
2448
|
+
return files.filter((f) => f.endsWith(".ts") || f.endsWith(".js")).map((f) => {
|
|
2449
|
+
let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "");
|
|
2450
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
2451
|
+
path = path.replace(/\[\.\.\.(\w+)\]/g, ":$1*").replace(/\[(\w+)\]/g, ":$1");
|
|
2452
|
+
return `/api${path === "/" ? "" : path}`;
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
async function scanDir(dir, base) {
|
|
2456
|
+
const { readdir, stat } = await import("node:fs/promises");
|
|
2457
|
+
const { join, relative } = await import("node:path");
|
|
2458
|
+
try {
|
|
2459
|
+
const entries = await readdir(dir);
|
|
2460
|
+
const files = [];
|
|
2461
|
+
for (const entry of entries) {
|
|
2462
|
+
const full = join(dir, entry);
|
|
2463
|
+
if ((await stat(full)).isDirectory()) files.push(...await scanDir(full, base));
|
|
2464
|
+
else files.push(relative(base, full));
|
|
2465
|
+
}
|
|
2466
|
+
return files;
|
|
2467
|
+
} catch {
|
|
2468
|
+
return [];
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
function safeParseUrl(url) {
|
|
2472
|
+
try {
|
|
2473
|
+
return new URL(url);
|
|
2474
|
+
} catch {
|
|
2475
|
+
return null;
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
function capitalize(s) {
|
|
2479
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
1404
2482
|
//#endregion
|
|
1405
2483
|
//#region src/i18n-routing.ts
|
|
1406
2484
|
/**
|
|
@@ -1530,5 +2608,5 @@ const LocaleCtx = createContext("en");
|
|
|
1530
2608
|
const localeSignal = signal("en");
|
|
1531
2609
|
|
|
1532
2610
|
//#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 };
|
|
2611
|
+
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
2612
|
//# sourceMappingURL=server.js.map
|