@pyreon/zero 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/font.js +29 -2
- package/lib/font.js.map +1 -1
- package/lib/index.js +1502 -84
- package/lib/index.js.map +1 -1
- package/lib/types/adapters/cloudflare.d.ts +26 -0
- package/lib/types/adapters/cloudflare.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +3 -0
- package/lib/types/adapters/index.d.ts.map +1 -1
- package/lib/types/adapters/netlify.d.ts +21 -0
- package/lib/types/adapters/netlify.d.ts.map +1 -0
- package/lib/types/adapters/vercel.d.ts +21 -0
- package/lib/types/adapters/vercel.d.ts.map +1 -0
- package/lib/types/ai.d.ts +182 -0
- package/lib/types/ai.d.ts.map +1 -0
- package/lib/types/csp.d.ts +107 -0
- package/lib/types/csp.d.ts.map +1 -0
- package/lib/types/env.d.ts +118 -0
- package/lib/types/env.d.ts.map +1 -0
- package/lib/types/favicon.d.ts +42 -0
- package/lib/types/favicon.d.ts.map +1 -1
- package/lib/types/font.d.ts.map +1 -1
- package/lib/types/index.d.ts +13 -3
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/logger.d.ts +68 -0
- package/lib/types/logger.d.ts.map +1 -0
- package/lib/types/meta.d.ts +36 -0
- package/lib/types/meta.d.ts.map +1 -1
- package/lib/types/og-image.d.ts +107 -0
- package/lib/types/og-image.d.ts.map +1 -0
- package/lib/types/types.d.ts +1 -1
- package/lib/types/types.d.ts.map +1 -1
- package/package.json +55 -10
- package/src/adapters/cloudflare.ts +82 -0
- package/src/adapters/index.ts +13 -1
- package/src/adapters/netlify.ts +84 -0
- package/src/adapters/vercel.ts +84 -0
- package/src/ai.ts +623 -0
- package/src/csp.ts +207 -0
- package/src/env.ts +344 -0
- package/src/favicon.ts +221 -80
- package/src/font.ts +37 -1
- package/src/index.ts +41 -2
- package/src/logger.ts +144 -0
- package/src/meta.tsx +84 -2
- package/src/og-image.ts +378 -0
- package/src/types.ts +1 -1
package/lib/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { a as parseFileRoutes, i as generateRouteModule, o as scanRouteFiles, r
|
|
|
2
2
|
import { Fragment, createContext, createRef, h, onMount, onUnmount } from "@pyreon/core";
|
|
3
3
|
import { HeadProvider, useHead } from "@pyreon/head";
|
|
4
4
|
import { RouterProvider, RouterView, createRouter, useRouter } from "@pyreon/router";
|
|
5
|
-
import { createHandler } from "@pyreon/server";
|
|
5
|
+
import { createHandler, useRequestLocals } from "@pyreon/server";
|
|
6
6
|
import { renderToString } from "@pyreon/runtime-server";
|
|
7
7
|
import { existsSync, readdirSync } from "node:fs";
|
|
8
8
|
import { basename, extname, join } from "node:path";
|
|
@@ -709,6 +709,152 @@ console.log("\\n ⚡ Zero production server running on http://localhost:${port}
|
|
|
709
709
|
};
|
|
710
710
|
}
|
|
711
711
|
|
|
712
|
+
//#endregion
|
|
713
|
+
//#region src/adapters/cloudflare.ts
|
|
714
|
+
/**
|
|
715
|
+
* Cloudflare Pages adapter — generates output for Cloudflare Pages with Functions.
|
|
716
|
+
*
|
|
717
|
+
* Produces:
|
|
718
|
+
* - Client assets in the output directory root (served as static)
|
|
719
|
+
* - `_worker.js` — Cloudflare Pages Function for SSR
|
|
720
|
+
*
|
|
721
|
+
* Note: Cloudflare Pages Functions have a ~1MB module size limit.
|
|
722
|
+
* For large apps, configure Vite's SSR build to bundle server code:
|
|
723
|
+
* `ssr: { noExternal: true }` in vite.config.ts.
|
|
724
|
+
*
|
|
725
|
+
* Deploy with: `npx wrangler pages deploy ./dist`
|
|
726
|
+
*
|
|
727
|
+
* @example
|
|
728
|
+
* ```ts
|
|
729
|
+
* // zero.config.ts
|
|
730
|
+
* import { defineConfig } from "@pyreon/zero"
|
|
731
|
+
*
|
|
732
|
+
* export default defineConfig({
|
|
733
|
+
* adapter: "cloudflare",
|
|
734
|
+
* })
|
|
735
|
+
* ```
|
|
736
|
+
*/
|
|
737
|
+
function cloudflareAdapter() {
|
|
738
|
+
return {
|
|
739
|
+
name: "cloudflare",
|
|
740
|
+
async build(options) {
|
|
741
|
+
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
742
|
+
const { join } = await import("node:path");
|
|
743
|
+
const outDir = options.outDir;
|
|
744
|
+
await mkdir(outDir, { recursive: true });
|
|
745
|
+
await cp(options.clientOutDir, outDir, { recursive: true });
|
|
746
|
+
await cp(join(options.serverEntry, ".."), join(outDir, "_server"), { recursive: true });
|
|
747
|
+
const workerEntry = `
|
|
748
|
+
import handler from "./_server/entry-server.js"
|
|
749
|
+
|
|
750
|
+
export default {
|
|
751
|
+
async fetch(request, env, ctx) {
|
|
752
|
+
const url = new URL(request.url)
|
|
753
|
+
|
|
754
|
+
// Let Cloudflare serve static assets (files with extensions)
|
|
755
|
+
// This check is a fallback — Pages routes static files automatically
|
|
756
|
+
const ext = url.pathname.split(".").pop()
|
|
757
|
+
if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
|
|
758
|
+
// Cloudflare Pages handles static assets automatically via its asset binding
|
|
759
|
+
// Only reach here if the file doesn't exist — fall through to SSR
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// SSR handler
|
|
763
|
+
try {
|
|
764
|
+
return await handler(request)
|
|
765
|
+
} catch (err) {
|
|
766
|
+
return new Response("Internal Server Error", { status: 500 })
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
}
|
|
770
|
+
`.trimStart();
|
|
771
|
+
await writeFile(join(outDir, "_worker.js"), workerEntry);
|
|
772
|
+
await writeFile(join(outDir, "_routes.json"), JSON.stringify({
|
|
773
|
+
version: 1,
|
|
774
|
+
include: ["/*"],
|
|
775
|
+
exclude: [
|
|
776
|
+
"/assets/*",
|
|
777
|
+
"/favicon.*",
|
|
778
|
+
"/site.webmanifest",
|
|
779
|
+
"/robots.txt",
|
|
780
|
+
"/sitemap.xml"
|
|
781
|
+
]
|
|
782
|
+
}, null, 2));
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
//#endregion
|
|
788
|
+
//#region src/adapters/netlify.ts
|
|
789
|
+
/**
|
|
790
|
+
* Netlify adapter — generates output for Netlify Functions (v2).
|
|
791
|
+
*
|
|
792
|
+
* Produces:
|
|
793
|
+
* - Client assets in `publish/` directory
|
|
794
|
+
* - `netlify/functions/ssr.mjs` — Netlify Function for SSR
|
|
795
|
+
* - `netlify.toml` — routing configuration
|
|
796
|
+
*
|
|
797
|
+
* @example
|
|
798
|
+
* ```ts
|
|
799
|
+
* // zero.config.ts
|
|
800
|
+
* import { defineConfig } from "@pyreon/zero"
|
|
801
|
+
*
|
|
802
|
+
* export default defineConfig({
|
|
803
|
+
* adapter: "netlify",
|
|
804
|
+
* })
|
|
805
|
+
* ```
|
|
806
|
+
*/
|
|
807
|
+
function netlifyAdapter() {
|
|
808
|
+
return {
|
|
809
|
+
name: "netlify",
|
|
810
|
+
async build(options) {
|
|
811
|
+
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
812
|
+
const { join } = await import("node:path");
|
|
813
|
+
const outDir = options.outDir;
|
|
814
|
+
const publishDir = join(outDir, "publish");
|
|
815
|
+
const functionsDir = join(outDir, "netlify", "functions");
|
|
816
|
+
await mkdir(publishDir, { recursive: true });
|
|
817
|
+
await mkdir(functionsDir, { recursive: true });
|
|
818
|
+
await cp(options.clientOutDir, publishDir, { recursive: true });
|
|
819
|
+
await cp(join(options.serverEntry, ".."), join(functionsDir, "_server"), { recursive: true });
|
|
820
|
+
const funcEntry = `
|
|
821
|
+
import handler from "./_server/entry-server.js"
|
|
822
|
+
|
|
823
|
+
export default async function(req, context) {
|
|
824
|
+
try {
|
|
825
|
+
return await handler(req)
|
|
826
|
+
} catch (err) {
|
|
827
|
+
return new Response("Internal Server Error", { status: 500 })
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export const config = {
|
|
832
|
+
path: "/*",
|
|
833
|
+
preferStatic: true,
|
|
834
|
+
}
|
|
835
|
+
`.trimStart();
|
|
836
|
+
await writeFile(join(functionsDir, "ssr.mjs"), funcEntry);
|
|
837
|
+
const toml = `
|
|
838
|
+
[build]
|
|
839
|
+
publish = "publish"
|
|
840
|
+
functions = "netlify/functions"
|
|
841
|
+
|
|
842
|
+
[[headers]]
|
|
843
|
+
for = "/assets/*"
|
|
844
|
+
[headers.values]
|
|
845
|
+
Cache-Control = "public, max-age=31536000, immutable"
|
|
846
|
+
|
|
847
|
+
[[redirects]]
|
|
848
|
+
from = "/*"
|
|
849
|
+
to = "/.netlify/functions/ssr"
|
|
850
|
+
status = 200
|
|
851
|
+
conditions = {Role = ["admin", "user", ""]}
|
|
852
|
+
`.trimStart();
|
|
853
|
+
await writeFile(join(outDir, "netlify.toml"), toml);
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
712
858
|
//#endregion
|
|
713
859
|
//#region src/adapters/node.ts
|
|
714
860
|
/**
|
|
@@ -827,6 +973,72 @@ function staticAdapter() {
|
|
|
827
973
|
};
|
|
828
974
|
}
|
|
829
975
|
|
|
976
|
+
//#endregion
|
|
977
|
+
//#region src/adapters/vercel.ts
|
|
978
|
+
/**
|
|
979
|
+
* Vercel adapter — generates output for Vercel's Build Output API v3.
|
|
980
|
+
*
|
|
981
|
+
* Produces a `.vercel/output` directory with:
|
|
982
|
+
* - `static/` — client-side assets (JS, CSS, images)
|
|
983
|
+
* - `functions/ssr.func/` — serverless function for SSR
|
|
984
|
+
* - `config.json` — routing configuration
|
|
985
|
+
*
|
|
986
|
+
* @example
|
|
987
|
+
* ```ts
|
|
988
|
+
* // zero.config.ts
|
|
989
|
+
* import { defineConfig } from "@pyreon/zero"
|
|
990
|
+
*
|
|
991
|
+
* export default defineConfig({
|
|
992
|
+
* adapter: "vercel",
|
|
993
|
+
* })
|
|
994
|
+
* ```
|
|
995
|
+
*/
|
|
996
|
+
function vercelAdapter() {
|
|
997
|
+
return {
|
|
998
|
+
name: "vercel",
|
|
999
|
+
async build(options) {
|
|
1000
|
+
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
1001
|
+
const { join } = await import("node:path");
|
|
1002
|
+
const vercelDir = join(options.outDir, ".vercel", "output");
|
|
1003
|
+
const staticDir = join(vercelDir, "static");
|
|
1004
|
+
const funcDir = join(vercelDir, "functions", "ssr.func");
|
|
1005
|
+
await mkdir(staticDir, { recursive: true });
|
|
1006
|
+
await mkdir(funcDir, { recursive: true });
|
|
1007
|
+
await cp(options.clientOutDir, staticDir, { recursive: true });
|
|
1008
|
+
await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
|
|
1009
|
+
const funcEntry = `
|
|
1010
|
+
export default async function handler(req) {
|
|
1011
|
+
const handler = (await import("./entry-server.js")).default
|
|
1012
|
+
return handler(req)
|
|
1013
|
+
}
|
|
1014
|
+
`.trimStart();
|
|
1015
|
+
await writeFile(join(funcDir, "index.js"), funcEntry);
|
|
1016
|
+
await writeFile(join(funcDir, ".vc-config.json"), JSON.stringify({
|
|
1017
|
+
runtime: "nodejs20.x",
|
|
1018
|
+
handler: "index.js",
|
|
1019
|
+
launcherType: "Nodejs"
|
|
1020
|
+
}, null, 2));
|
|
1021
|
+
await writeFile(join(vercelDir, "config.json"), JSON.stringify({
|
|
1022
|
+
version: 3,
|
|
1023
|
+
routes: [
|
|
1024
|
+
{
|
|
1025
|
+
src: "/assets/(.*)",
|
|
1026
|
+
headers: { "Cache-Control": "public, max-age=31536000, immutable" }
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
src: "/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)",
|
|
1030
|
+
dest: "/$1"
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
src: "/(.*)",
|
|
1034
|
+
dest: "/ssr"
|
|
1035
|
+
}
|
|
1036
|
+
]
|
|
1037
|
+
}, null, 2));
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
830
1042
|
//#endregion
|
|
831
1043
|
//#region src/adapters/index.ts
|
|
832
1044
|
/**
|
|
@@ -839,7 +1051,10 @@ function resolveAdapter(config) {
|
|
|
839
1051
|
case "node": return nodeAdapter();
|
|
840
1052
|
case "bun": return bunAdapter();
|
|
841
1053
|
case "static": return staticAdapter();
|
|
842
|
-
|
|
1054
|
+
case "vercel": return vercelAdapter();
|
|
1055
|
+
case "cloudflare": return cloudflareAdapter();
|
|
1056
|
+
case "netlify": return netlifyAdapter();
|
|
1057
|
+
default: throw new Error(`[zero] Unknown adapter: "${name}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`);
|
|
843
1058
|
}
|
|
844
1059
|
}
|
|
845
1060
|
|
|
@@ -1520,7 +1735,19 @@ function extractFontUrls(css) {
|
|
|
1520
1735
|
/**
|
|
1521
1736
|
* Self-host Google Fonts: download CSS + font files, rewrite URLs to local paths.
|
|
1522
1737
|
*/
|
|
1523
|
-
async function selfHostFonts(cssUrl, fontsSubDir) {
|
|
1738
|
+
async function selfHostFonts(cssUrl, fontsSubDir, root) {
|
|
1739
|
+
const cacheDir = join(root, "node_modules", ".cache", "zero-fonts");
|
|
1740
|
+
const cachePath = join(cacheDir, `${Buffer.from(cssUrl).toString("base64url")}.json`);
|
|
1741
|
+
try {
|
|
1742
|
+
const cached = JSON.parse(await readFile(cachePath, "utf-8"));
|
|
1743
|
+
if (cached.css && cached.fontFiles) return {
|
|
1744
|
+
css: cached.css,
|
|
1745
|
+
fontFiles: cached.fontFiles.map((f) => ({
|
|
1746
|
+
name: f.name,
|
|
1747
|
+
content: Buffer.from(f.content, "base64")
|
|
1748
|
+
}))
|
|
1749
|
+
};
|
|
1750
|
+
} catch {}
|
|
1524
1751
|
const css = await downloadGoogleFontsCSS(cssUrl);
|
|
1525
1752
|
const fontUrls = extractFontUrls(css);
|
|
1526
1753
|
const fontFiles = [];
|
|
@@ -1534,6 +1761,16 @@ async function selfHostFonts(cssUrl, fontsSubDir) {
|
|
|
1534
1761
|
});
|
|
1535
1762
|
rewrittenCss = rewrittenCss.replace(url, `/${fontsSubDir}/${fileName}`);
|
|
1536
1763
|
}
|
|
1764
|
+
try {
|
|
1765
|
+
await mkdir(cacheDir, { recursive: true });
|
|
1766
|
+
await writeFile(cachePath, JSON.stringify({
|
|
1767
|
+
css: rewrittenCss,
|
|
1768
|
+
fontFiles: fontFiles.map((f) => ({
|
|
1769
|
+
name: f.name,
|
|
1770
|
+
content: f.content.toString("base64")
|
|
1771
|
+
}))
|
|
1772
|
+
}));
|
|
1773
|
+
} catch {}
|
|
1537
1774
|
return {
|
|
1538
1775
|
css: rewrittenCss,
|
|
1539
1776
|
fontFiles
|
|
@@ -1567,18 +1804,20 @@ function fontPlugin(config = {}) {
|
|
|
1567
1804
|
const shouldSelfHost = config.selfHost !== false;
|
|
1568
1805
|
const googleFamilies = (config.google ?? []).map(resolveGoogleFont);
|
|
1569
1806
|
let isBuild = false;
|
|
1807
|
+
let root = "";
|
|
1570
1808
|
let selfHostedCSS = "";
|
|
1571
1809
|
let selfHostedFontFiles = [];
|
|
1572
1810
|
return {
|
|
1573
1811
|
name: "pyreon-zero-fonts",
|
|
1574
1812
|
configResolved(resolvedConfig) {
|
|
1575
1813
|
isBuild = resolvedConfig.command === "build";
|
|
1814
|
+
root = resolvedConfig.root;
|
|
1576
1815
|
},
|
|
1577
1816
|
async buildStart() {
|
|
1578
1817
|
if (isBuild && shouldSelfHost && googleFamilies.length > 0) {
|
|
1579
1818
|
const cssUrl = googleFontsUrl(googleFamilies, display);
|
|
1580
1819
|
try {
|
|
1581
|
-
const result = await selfHostFonts(cssUrl, "assets/fonts");
|
|
1820
|
+
const result = await selfHostFonts(cssUrl, "assets/fonts", root);
|
|
1582
1821
|
selfHostedCSS = result.css;
|
|
1583
1822
|
selfHostedFontFiles = result.fontFiles;
|
|
1584
1823
|
} catch {}
|
|
@@ -1635,10 +1874,10 @@ function fontVariables(families) {
|
|
|
1635
1874
|
|
|
1636
1875
|
//#endregion
|
|
1637
1876
|
//#region src/image-plugin.ts
|
|
1638
|
-
let sharpWarned$
|
|
1639
|
-
function warnSharpMissing$
|
|
1640
|
-
if (sharpWarned$
|
|
1641
|
-
sharpWarned$
|
|
1877
|
+
let sharpWarned$2 = false;
|
|
1878
|
+
function warnSharpMissing$2() {
|
|
1879
|
+
if (sharpWarned$2) return;
|
|
1880
|
+
sharpWarned$2 = true;
|
|
1642
1881
|
console.warn("\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n");
|
|
1643
1882
|
}
|
|
1644
1883
|
const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i;
|
|
@@ -1901,7 +2140,7 @@ async function resizeImage(input, output, width, format, quality) {
|
|
|
1901
2140
|
}
|
|
1902
2141
|
await pipeline.toFile(output);
|
|
1903
2142
|
} catch {
|
|
1904
|
-
warnSharpMissing$
|
|
2143
|
+
warnSharpMissing$2();
|
|
1905
2144
|
await writeFile(output, await readFile(input));
|
|
1906
2145
|
}
|
|
1907
2146
|
}
|
|
@@ -2099,14 +2338,14 @@ ${[...routeFiles.filter((f) => {
|
|
|
2099
2338
|
priority
|
|
2100
2339
|
})), ...config.additionalPaths ?? []].map((entry) => {
|
|
2101
2340
|
return ` <url>
|
|
2102
|
-
<loc>${escapeXml(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
|
|
2341
|
+
<loc>${escapeXml$1(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
|
|
2103
2342
|
<changefreq>${entry.changefreq ?? changefreq}</changefreq>
|
|
2104
2343
|
<priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
|
|
2105
2344
|
</url>`;
|
|
2106
2345
|
}).join("\n")}
|
|
2107
2346
|
</urlset>`;
|
|
2108
2347
|
}
|
|
2109
|
-
function escapeXml(str) {
|
|
2348
|
+
function escapeXml$1(str) {
|
|
2110
2349
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2111
2350
|
}
|
|
2112
2351
|
/**
|
|
@@ -2490,10 +2729,10 @@ async function executeAction(action, req) {
|
|
|
2490
2729
|
|
|
2491
2730
|
//#endregion
|
|
2492
2731
|
//#region src/favicon.ts
|
|
2493
|
-
let sharpWarned = false;
|
|
2494
|
-
function warnSharpMissing() {
|
|
2495
|
-
if (sharpWarned) return;
|
|
2496
|
-
sharpWarned = true;
|
|
2732
|
+
let sharpWarned$1 = false;
|
|
2733
|
+
function warnSharpMissing$1() {
|
|
2734
|
+
if (sharpWarned$1) return;
|
|
2735
|
+
sharpWarned$1 = true;
|
|
2497
2736
|
console.warn("\n[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
|
|
2498
2737
|
}
|
|
2499
2738
|
const SIZES = [
|
|
@@ -2549,17 +2788,31 @@ function faviconPlugin(config) {
|
|
|
2549
2788
|
},
|
|
2550
2789
|
configureServer(server) {
|
|
2551
2790
|
const sourcePath = join(root, config.source);
|
|
2791
|
+
const devCache = /* @__PURE__ */ new Map();
|
|
2552
2792
|
server.middlewares.use(async (req, res, next) => {
|
|
2553
2793
|
const url = req.url ?? "";
|
|
2554
|
-
|
|
2555
|
-
|
|
2794
|
+
const localeSource = resolveLocaleSource(url, config, root);
|
|
2795
|
+
const svgUrl = localeSource ? localeSource.url : url;
|
|
2796
|
+
const svgPath = localeSource ? localeSource.sourcePath : sourcePath;
|
|
2797
|
+
const isSvgSource = localeSource ? localeSource.source.endsWith(".svg") : config.source.endsWith(".svg");
|
|
2798
|
+
if (svgUrl.endsWith("/favicon.svg") && isSvgSource) try {
|
|
2799
|
+
const content = await readFile(svgPath, "utf-8");
|
|
2556
2800
|
res.setHeader("Content-Type", "image/svg+xml");
|
|
2557
2801
|
res.end(content);
|
|
2558
2802
|
return;
|
|
2559
2803
|
} catch {}
|
|
2560
|
-
const
|
|
2804
|
+
const baseName = svgUrl.split("/").pop() ?? "";
|
|
2805
|
+
const sizeMatch = SIZES.find((s) => s.name === baseName);
|
|
2561
2806
|
if (sizeMatch) {
|
|
2562
|
-
const
|
|
2807
|
+
const cacheKey = `${svgPath}:${sizeMatch.size}`;
|
|
2808
|
+
let png = devCache.get(cacheKey);
|
|
2809
|
+
if (!png) {
|
|
2810
|
+
const result = await resizeToPng(svgPath, sizeMatch.size);
|
|
2811
|
+
if (result) {
|
|
2812
|
+
png = result;
|
|
2813
|
+
devCache.set(cacheKey, result);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2563
2816
|
if (png) {
|
|
2564
2817
|
res.setHeader("Content-Type", "image/png");
|
|
2565
2818
|
res.setHeader("Cache-Control", "no-cache");
|
|
@@ -2567,8 +2820,16 @@ function faviconPlugin(config) {
|
|
|
2567
2820
|
return;
|
|
2568
2821
|
}
|
|
2569
2822
|
}
|
|
2570
|
-
if (
|
|
2571
|
-
const
|
|
2823
|
+
if (baseName === "favicon.ico") {
|
|
2824
|
+
const cacheKey = `ico:${svgPath}`;
|
|
2825
|
+
let ico = devCache.get(cacheKey);
|
|
2826
|
+
if (!ico) {
|
|
2827
|
+
const result = await generateIco(svgPath);
|
|
2828
|
+
if (result) {
|
|
2829
|
+
ico = result;
|
|
2830
|
+
devCache.set(cacheKey, result);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2572
2833
|
if (ico) {
|
|
2573
2834
|
res.setHeader("Content-Type", "image/x-icon");
|
|
2574
2835
|
res.setHeader("Cache-Control", "no-cache");
|
|
@@ -2576,16 +2837,17 @@ function faviconPlugin(config) {
|
|
|
2576
2837
|
return;
|
|
2577
2838
|
}
|
|
2578
2839
|
}
|
|
2579
|
-
if (
|
|
2840
|
+
if (baseName === "site.webmanifest" && generateManifest) {
|
|
2841
|
+
const prefix = localeSource ? `/${localeSource.locale}` : "";
|
|
2580
2842
|
const manifest = {
|
|
2581
2843
|
name: config.name ?? "App",
|
|
2582
2844
|
short_name: config.name ?? "App",
|
|
2583
2845
|
icons: [{
|
|
2584
|
-
src:
|
|
2846
|
+
src: `${prefix}/icon-192.png`,
|
|
2585
2847
|
sizes: "192x192",
|
|
2586
2848
|
type: "image/png"
|
|
2587
2849
|
}, {
|
|
2588
|
-
src:
|
|
2850
|
+
src: `${prefix}/icon-512.png`,
|
|
2589
2851
|
sizes: "512x512",
|
|
2590
2852
|
type: "image/png"
|
|
2591
2853
|
}],
|
|
@@ -2659,61 +2921,8 @@ function faviconPlugin(config) {
|
|
|
2659
2921
|
},
|
|
2660
2922
|
async generateBundle() {
|
|
2661
2923
|
if (!isBuild) return;
|
|
2662
|
-
|
|
2663
|
-
if (
|
|
2664
|
-
console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
|
|
2665
|
-
return;
|
|
2666
|
-
}
|
|
2667
|
-
if (config.source.endsWith(".svg")) {
|
|
2668
|
-
const svgContent = await readFile(sourcePath, "utf-8");
|
|
2669
|
-
let finalSvg = svgContent;
|
|
2670
|
-
if (config.darkSource) {
|
|
2671
|
-
const darkPath = join(root, config.darkSource);
|
|
2672
|
-
if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
|
|
2673
|
-
}
|
|
2674
|
-
this.emitFile({
|
|
2675
|
-
type: "asset",
|
|
2676
|
-
fileName: "favicon.svg",
|
|
2677
|
-
source: finalSvg
|
|
2678
|
-
});
|
|
2679
|
-
}
|
|
2680
|
-
for (const { size, name } of SIZES) {
|
|
2681
|
-
const pngBuffer = await resizeToPng(sourcePath, size);
|
|
2682
|
-
if (pngBuffer) this.emitFile({
|
|
2683
|
-
type: "asset",
|
|
2684
|
-
fileName: name,
|
|
2685
|
-
source: pngBuffer
|
|
2686
|
-
});
|
|
2687
|
-
}
|
|
2688
|
-
const ico = await generateIco(sourcePath);
|
|
2689
|
-
if (ico) this.emitFile({
|
|
2690
|
-
type: "asset",
|
|
2691
|
-
fileName: "favicon.ico",
|
|
2692
|
-
source: ico
|
|
2693
|
-
});
|
|
2694
|
-
if (generateManifest) {
|
|
2695
|
-
const manifest = {
|
|
2696
|
-
name: config.name ?? "App",
|
|
2697
|
-
short_name: config.name ?? "App",
|
|
2698
|
-
icons: [{
|
|
2699
|
-
src: "/icon-192.png",
|
|
2700
|
-
sizes: "192x192",
|
|
2701
|
-
type: "image/png"
|
|
2702
|
-
}, {
|
|
2703
|
-
src: "/icon-512.png",
|
|
2704
|
-
sizes: "512x512",
|
|
2705
|
-
type: "image/png"
|
|
2706
|
-
}],
|
|
2707
|
-
theme_color: themeColor,
|
|
2708
|
-
background_color: backgroundColor,
|
|
2709
|
-
display: "standalone"
|
|
2710
|
-
};
|
|
2711
|
-
this.emitFile({
|
|
2712
|
-
type: "asset",
|
|
2713
|
-
fileName: "site.webmanifest",
|
|
2714
|
-
source: JSON.stringify(manifest, null, 2)
|
|
2715
|
-
});
|
|
2716
|
-
}
|
|
2924
|
+
await generateFaviconSet.call(this, root, config.source, config.darkSource, "", config, themeColor, backgroundColor, generateManifest);
|
|
2925
|
+
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);
|
|
2717
2926
|
}
|
|
2718
2927
|
};
|
|
2719
2928
|
}
|
|
@@ -2734,6 +2943,126 @@ function wrapSvgWithDarkMode(lightSvg, darkSvg) {
|
|
|
2734
2943
|
function stripSvgWrapper(svg) {
|
|
2735
2944
|
return svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
|
|
2736
2945
|
}
|
|
2946
|
+
/**
|
|
2947
|
+
* Resolve the source path for a locale-prefixed favicon URL.
|
|
2948
|
+
* Returns null if the URL is not locale-prefixed or locale has no override.
|
|
2949
|
+
*/
|
|
2950
|
+
function resolveLocaleSource(url, config, rootDir) {
|
|
2951
|
+
if (!config.locales) return null;
|
|
2952
|
+
for (const [locale, localeConfig] of Object.entries(config.locales)) {
|
|
2953
|
+
const prefix = `/${locale}/`;
|
|
2954
|
+
if (url.startsWith(prefix)) return {
|
|
2955
|
+
locale,
|
|
2956
|
+
url,
|
|
2957
|
+
source: localeConfig.source,
|
|
2958
|
+
sourcePath: join(rootDir, localeConfig.source)
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
return null;
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.
|
|
2965
|
+
* Called once for base (prefix = '') and once per locale (prefix = '{locale}/').
|
|
2966
|
+
*/
|
|
2967
|
+
async function generateFaviconSet(rootDir, source, darkSource, prefix, config, themeColor, backgroundColor, generateManifest) {
|
|
2968
|
+
const sourcePath = join(rootDir, source);
|
|
2969
|
+
if (!existsSync(sourcePath)) {
|
|
2970
|
+
console.warn(`[zero:favicon] Source not found: ${sourcePath}`);
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
if (source.endsWith(".svg")) {
|
|
2974
|
+
const svgContent = await readFile(sourcePath, "utf-8");
|
|
2975
|
+
let finalSvg = svgContent;
|
|
2976
|
+
if (darkSource) {
|
|
2977
|
+
const darkPath = join(rootDir, darkSource);
|
|
2978
|
+
if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
|
|
2979
|
+
}
|
|
2980
|
+
this.emitFile({
|
|
2981
|
+
type: "asset",
|
|
2982
|
+
fileName: `${prefix}favicon.svg`,
|
|
2983
|
+
source: finalSvg
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
for (const { size, name } of SIZES) {
|
|
2987
|
+
const pngBuffer = await resizeToPng(sourcePath, size);
|
|
2988
|
+
if (pngBuffer) this.emitFile({
|
|
2989
|
+
type: "asset",
|
|
2990
|
+
fileName: `${prefix}${name}`,
|
|
2991
|
+
source: pngBuffer
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
const ico = await generateIco(sourcePath);
|
|
2995
|
+
if (ico) this.emitFile({
|
|
2996
|
+
type: "asset",
|
|
2997
|
+
fileName: `${prefix}favicon.ico`,
|
|
2998
|
+
source: ico
|
|
2999
|
+
});
|
|
3000
|
+
if (generateManifest) {
|
|
3001
|
+
const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : "";
|
|
3002
|
+
const manifest = {
|
|
3003
|
+
name: config.name ?? "App",
|
|
3004
|
+
short_name: config.name ?? "App",
|
|
3005
|
+
icons: [{
|
|
3006
|
+
src: `${manifestPrefix}/icon-192.png`,
|
|
3007
|
+
sizes: "192x192",
|
|
3008
|
+
type: "image/png"
|
|
3009
|
+
}, {
|
|
3010
|
+
src: `${manifestPrefix}/icon-512.png`,
|
|
3011
|
+
sizes: "512x512",
|
|
3012
|
+
type: "image/png"
|
|
3013
|
+
}],
|
|
3014
|
+
theme_color: themeColor,
|
|
3015
|
+
background_color: backgroundColor,
|
|
3016
|
+
display: "standalone"
|
|
3017
|
+
};
|
|
3018
|
+
this.emitFile({
|
|
3019
|
+
type: "asset",
|
|
3020
|
+
fileName: `${prefix}site.webmanifest`,
|
|
3021
|
+
source: JSON.stringify(manifest, null, 2)
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* Get favicon link tags for a specific locale.
|
|
3027
|
+
* Returns link objects suitable for `useHead()` or direct HTML injection.
|
|
3028
|
+
*
|
|
3029
|
+
* @example
|
|
3030
|
+
* ```ts
|
|
3031
|
+
* const links = faviconLinks("de", { source: "./icon.svg", locales: { de: { source: "./icon-de.svg" } } })
|
|
3032
|
+
* // → [{ rel: "icon", type: "image/svg+xml", href: "/de/favicon.svg" }, ...]
|
|
3033
|
+
* ```
|
|
3034
|
+
*/
|
|
3035
|
+
function faviconLinks(locale, config) {
|
|
3036
|
+
const hasLocaleOverride = locale && config.locales?.[locale];
|
|
3037
|
+
const prefix = hasLocaleOverride ? `/${locale}` : "";
|
|
3038
|
+
const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
|
|
3039
|
+
const links = [];
|
|
3040
|
+
if (isSvg) links.push({
|
|
3041
|
+
rel: "icon",
|
|
3042
|
+
type: "image/svg+xml",
|
|
3043
|
+
href: `${prefix}/favicon.svg`
|
|
3044
|
+
});
|
|
3045
|
+
links.push({
|
|
3046
|
+
rel: "icon",
|
|
3047
|
+
type: "image/png",
|
|
3048
|
+
sizes: "32x32",
|
|
3049
|
+
href: `${prefix}/favicon-32x32.png`
|
|
3050
|
+
}, {
|
|
3051
|
+
rel: "icon",
|
|
3052
|
+
type: "image/png",
|
|
3053
|
+
sizes: "16x16",
|
|
3054
|
+
href: `${prefix}/favicon-16x16.png`
|
|
3055
|
+
}, {
|
|
3056
|
+
rel: "apple-touch-icon",
|
|
3057
|
+
sizes: "180x180",
|
|
3058
|
+
href: `${prefix}/apple-touch-icon.png`
|
|
3059
|
+
});
|
|
3060
|
+
if (config.manifest !== false) links.push({
|
|
3061
|
+
rel: "manifest",
|
|
3062
|
+
href: `${prefix}/site.webmanifest`
|
|
3063
|
+
});
|
|
3064
|
+
return links;
|
|
3065
|
+
}
|
|
2737
3066
|
async function resizeToPng(input, size) {
|
|
2738
3067
|
try {
|
|
2739
3068
|
return await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, {
|
|
@@ -2746,7 +3075,7 @@ async function resizeToPng(input, size) {
|
|
|
2746
3075
|
}
|
|
2747
3076
|
}).png().toBuffer();
|
|
2748
3077
|
} catch {
|
|
2749
|
-
warnSharpMissing();
|
|
3078
|
+
warnSharpMissing$1();
|
|
2750
3079
|
return null;
|
|
2751
3080
|
}
|
|
2752
3081
|
}
|
|
@@ -2779,7 +3108,7 @@ async function generateIco(input) {
|
|
|
2779
3108
|
size: 32
|
|
2780
3109
|
}]);
|
|
2781
3110
|
} catch {
|
|
2782
|
-
warnSharpMissing();
|
|
3111
|
+
warnSharpMissing$1();
|
|
2783
3112
|
return null;
|
|
2784
3113
|
}
|
|
2785
3114
|
}
|
|
@@ -2816,6 +3145,234 @@ function createIcoFromPngs(entries) {
|
|
|
2816
3145
|
]);
|
|
2817
3146
|
}
|
|
2818
3147
|
|
|
3148
|
+
//#endregion
|
|
3149
|
+
//#region src/og-image.ts
|
|
3150
|
+
/**
|
|
3151
|
+
* OG Image generation plugin.
|
|
3152
|
+
*
|
|
3153
|
+
* Generates Open Graph images at build time from templates with
|
|
3154
|
+
* text overlays. Supports locale-specific text for i18n apps.
|
|
3155
|
+
* Uses sharp for image processing (same optional dep as favicon/image plugins).
|
|
3156
|
+
*
|
|
3157
|
+
* @example
|
|
3158
|
+
* ```ts
|
|
3159
|
+
* // vite.config.ts
|
|
3160
|
+
* import { ogImagePlugin } from "@pyreon/zero/og-image"
|
|
3161
|
+
*
|
|
3162
|
+
* export default {
|
|
3163
|
+
* plugins: [
|
|
3164
|
+
* ogImagePlugin({
|
|
3165
|
+
* locales: ["en", "de", "cs"],
|
|
3166
|
+
* templates: [{
|
|
3167
|
+
* name: "default",
|
|
3168
|
+
* background: "./src/assets/og-bg.jpg",
|
|
3169
|
+
* layers: [{
|
|
3170
|
+
* text: { en: "Build faster", de: "Schneller bauen", cs: "Stavte rychleji" },
|
|
3171
|
+
* y: "40%",
|
|
3172
|
+
* fontSize: 72,
|
|
3173
|
+
* }],
|
|
3174
|
+
* }],
|
|
3175
|
+
* }),
|
|
3176
|
+
* ],
|
|
3177
|
+
* }
|
|
3178
|
+
* ```
|
|
3179
|
+
*/
|
|
3180
|
+
let sharpWarned = false;
|
|
3181
|
+
function warnSharpMissing() {
|
|
3182
|
+
if (sharpWarned) return;
|
|
3183
|
+
sharpWarned = true;
|
|
3184
|
+
console.warn("\n[zero:og-image] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n");
|
|
3185
|
+
}
|
|
3186
|
+
function resolvePosition(value, dimension, fallback = "50%") {
|
|
3187
|
+
if (value === void 0) value = fallback;
|
|
3188
|
+
if (typeof value === "number") return value;
|
|
3189
|
+
if (value.endsWith("%")) return Math.round(Number.parseFloat(value) / 100 * dimension);
|
|
3190
|
+
return Number.parseInt(value, 10) || 0;
|
|
3191
|
+
}
|
|
3192
|
+
function resolveLayerText(layer, locale) {
|
|
3193
|
+
if (typeof layer.text === "string") return layer.text;
|
|
3194
|
+
if (typeof layer.text === "function") return layer.text(locale);
|
|
3195
|
+
return layer.text[locale] ?? layer.text[Object.keys(layer.text)[0] ?? ""] ?? "";
|
|
3196
|
+
}
|
|
3197
|
+
function escapeXml(str) {
|
|
3198
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3199
|
+
}
|
|
3200
|
+
/**
|
|
3201
|
+
* Build an SVG overlay with text layers.
|
|
3202
|
+
* @internal Exported for testing.
|
|
3203
|
+
*/
|
|
3204
|
+
function buildTextOverlaySvg(layers, width, height, locale) {
|
|
3205
|
+
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${layers.map((layer) => {
|
|
3206
|
+
const text = resolveLayerText(layer, locale);
|
|
3207
|
+
const x = resolvePosition(layer.x, width, "50%");
|
|
3208
|
+
const y = resolvePosition(layer.y, height, "50%");
|
|
3209
|
+
const fontSize = layer.fontSize ?? 64;
|
|
3210
|
+
const fontFamily = layer.fontFamily ?? "sans-serif";
|
|
3211
|
+
const fontWeight = layer.fontWeight ?? "bold";
|
|
3212
|
+
const color = layer.color ?? "#ffffff";
|
|
3213
|
+
const anchor = layer.textAnchor ?? "middle";
|
|
3214
|
+
const maxWidth = layer.maxWidth ?? Math.round(width * .8);
|
|
3215
|
+
const words = text.split(" ");
|
|
3216
|
+
const lines = [];
|
|
3217
|
+
let currentLine = "";
|
|
3218
|
+
const estimateWidth = (s) => {
|
|
3219
|
+
let width = 0;
|
|
3220
|
+
for (let i = 0; i < s.length; i++) {
|
|
3221
|
+
const code = s.charCodeAt(i);
|
|
3222
|
+
if (code >= 12288 && code <= 40959) width += fontSize * 1;
|
|
3223
|
+
else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) width += fontSize * .35;
|
|
3224
|
+
else width += fontSize * .55;
|
|
3225
|
+
}
|
|
3226
|
+
return width;
|
|
3227
|
+
};
|
|
3228
|
+
for (const word of words) {
|
|
3229
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
3230
|
+
if (estimateWidth(testLine) > maxWidth && currentLine) {
|
|
3231
|
+
lines.push(currentLine);
|
|
3232
|
+
currentLine = word;
|
|
3233
|
+
} else currentLine = testLine;
|
|
3234
|
+
}
|
|
3235
|
+
if (currentLine) lines.push(currentLine);
|
|
3236
|
+
const tspans = lines.map((line, i) => {
|
|
3237
|
+
return `<tspan x="${x}" dy="${i === 0 ? "0" : `${fontSize * 1.2}`}">${escapeXml(line)}</tspan>`;
|
|
3238
|
+
}).join("");
|
|
3239
|
+
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>`;
|
|
3240
|
+
}).join("")}</svg>`;
|
|
3241
|
+
}
|
|
3242
|
+
/**
|
|
3243
|
+
* Render an OG image from a template for a specific locale.
|
|
3244
|
+
* @internal Exported for testing.
|
|
3245
|
+
*/
|
|
3246
|
+
async function renderOgImage(template, locale, rootDir) {
|
|
3247
|
+
try {
|
|
3248
|
+
const sharp = await import("sharp").then((m) => m.default ?? m);
|
|
3249
|
+
const width = template.width ?? 1200;
|
|
3250
|
+
const height = template.height ?? 630;
|
|
3251
|
+
let pipeline;
|
|
3252
|
+
if (typeof template.background === "string") pipeline = sharp(join(rootDir, template.background)).resize(width, height, { fit: "cover" });
|
|
3253
|
+
else pipeline = sharp({ create: {
|
|
3254
|
+
width,
|
|
3255
|
+
height,
|
|
3256
|
+
channels: 4,
|
|
3257
|
+
background: template.background.color
|
|
3258
|
+
} });
|
|
3259
|
+
if (template.layers && template.layers.length > 0) {
|
|
3260
|
+
const svgOverlay = buildTextOverlaySvg(template.layers, width, height, locale);
|
|
3261
|
+
pipeline = pipeline.composite([{
|
|
3262
|
+
input: Buffer.from(svgOverlay),
|
|
3263
|
+
top: 0,
|
|
3264
|
+
left: 0
|
|
3265
|
+
}]);
|
|
3266
|
+
}
|
|
3267
|
+
if (template.format === "jpeg") return await pipeline.jpeg({ quality: template.quality ?? 90 }).toBuffer();
|
|
3268
|
+
return await pipeline.png().toBuffer();
|
|
3269
|
+
} catch {
|
|
3270
|
+
warnSharpMissing();
|
|
3271
|
+
return null;
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
/**
|
|
3275
|
+
* Compute the OG image path for a template and locale.
|
|
3276
|
+
*
|
|
3277
|
+
* @example
|
|
3278
|
+
* ```ts
|
|
3279
|
+
* ogImagePath("default", "de") // → "/og/default-de.png"
|
|
3280
|
+
* ogImagePath("default") // → "/og/default.png"
|
|
3281
|
+
* ogImagePath("hero", "en", "images") // → "/images/hero-en.png"
|
|
3282
|
+
* ```
|
|
3283
|
+
*/
|
|
3284
|
+
function ogImagePath(templateName, locale, outDir = "og", format = "png") {
|
|
3285
|
+
const ext = format === "jpeg" ? "jpg" : "png";
|
|
3286
|
+
return `/${outDir}/${templateName}${locale ? `-${locale}` : ""}.${ext}`;
|
|
3287
|
+
}
|
|
3288
|
+
/**
|
|
3289
|
+
* OG image generation Vite plugin.
|
|
3290
|
+
*
|
|
3291
|
+
* Generates Open Graph images at build time. In dev, generates on-demand.
|
|
3292
|
+
* Requires `sharp` as an optional dependency.
|
|
3293
|
+
*
|
|
3294
|
+
* @example
|
|
3295
|
+
* ```ts
|
|
3296
|
+
* // vite.config.ts
|
|
3297
|
+
* import { ogImagePlugin } from "@pyreon/zero/og-image"
|
|
3298
|
+
*
|
|
3299
|
+
* export default {
|
|
3300
|
+
* plugins: [
|
|
3301
|
+
* ogImagePlugin({
|
|
3302
|
+
* locales: ["en", "de"],
|
|
3303
|
+
* templates: [{
|
|
3304
|
+
* name: "default",
|
|
3305
|
+
* background: { color: "#0066ff" },
|
|
3306
|
+
* layers: [{ text: { en: "Hello", de: "Hallo" }, fontSize: 72 }],
|
|
3307
|
+
* }],
|
|
3308
|
+
* }),
|
|
3309
|
+
* ],
|
|
3310
|
+
* }
|
|
3311
|
+
* ```
|
|
3312
|
+
*/
|
|
3313
|
+
function ogImagePlugin(config) {
|
|
3314
|
+
const outDir = config.outDir ?? "og";
|
|
3315
|
+
let root = "";
|
|
3316
|
+
let isBuild = false;
|
|
3317
|
+
return {
|
|
3318
|
+
name: "pyreon-zero-og-image",
|
|
3319
|
+
enforce: "pre",
|
|
3320
|
+
configResolved(resolvedConfig) {
|
|
3321
|
+
root = resolvedConfig.root;
|
|
3322
|
+
isBuild = resolvedConfig.command === "build";
|
|
3323
|
+
},
|
|
3324
|
+
configureServer(server) {
|
|
3325
|
+
const devCache = /* @__PURE__ */ new Map();
|
|
3326
|
+
server.middlewares.use(async (req, res, next) => {
|
|
3327
|
+
const url = req.url ?? "";
|
|
3328
|
+
if (!url.startsWith(`/${outDir}/`)) return next();
|
|
3329
|
+
const match = url.slice(outDir.length + 2).match(/^(.+?)(?:-([a-z]{2,5}))?\.(png|jpe?g)$/);
|
|
3330
|
+
if (!match) return next();
|
|
3331
|
+
const [, templateName, locale, ext] = match;
|
|
3332
|
+
const template = config.templates.find((t) => t.name === templateName);
|
|
3333
|
+
if (!template) return next();
|
|
3334
|
+
const resolvedLocale = locale ?? config.locales?.[0] ?? "en";
|
|
3335
|
+
const cacheKey = `${templateName}:${resolvedLocale}`;
|
|
3336
|
+
let buffer = devCache.get(cacheKey);
|
|
3337
|
+
if (!buffer) {
|
|
3338
|
+
const result = await renderOgImage(template, resolvedLocale, root);
|
|
3339
|
+
if (!result) return next();
|
|
3340
|
+
buffer = result;
|
|
3341
|
+
devCache.set(cacheKey, result);
|
|
3342
|
+
}
|
|
3343
|
+
const contentType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
|
|
3344
|
+
res.setHeader("Content-Type", contentType);
|
|
3345
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
3346
|
+
res.end(Buffer.from(buffer));
|
|
3347
|
+
});
|
|
3348
|
+
},
|
|
3349
|
+
async generateBundle() {
|
|
3350
|
+
if (!isBuild) return;
|
|
3351
|
+
for (const template of config.templates) {
|
|
3352
|
+
const locales = config.locales ?? [void 0];
|
|
3353
|
+
const ext = (template.format ?? "png") === "jpeg" ? "jpg" : "png";
|
|
3354
|
+
for (const locale of locales) {
|
|
3355
|
+
if (typeof template.background === "string") {
|
|
3356
|
+
const bgPath = join(root, template.background);
|
|
3357
|
+
if (!existsSync(bgPath)) {
|
|
3358
|
+
console.warn(`[zero:og-image] Background not found: ${bgPath}`);
|
|
3359
|
+
continue;
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
const buffer = await renderOgImage(template, locale ?? "en", root);
|
|
3363
|
+
if (!buffer) continue;
|
|
3364
|
+
const suffix = locale ? `-${locale}` : "";
|
|
3365
|
+
this.emitFile({
|
|
3366
|
+
type: "asset",
|
|
3367
|
+
fileName: `${outDir}/${template.name}${suffix}.${ext}`,
|
|
3368
|
+
source: buffer
|
|
3369
|
+
});
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
};
|
|
3374
|
+
}
|
|
3375
|
+
|
|
2819
3376
|
//#endregion
|
|
2820
3377
|
//#region src/i18n-routing.ts
|
|
2821
3378
|
/**
|
|
@@ -3038,7 +3595,11 @@ function buildMetaTags(props) {
|
|
|
3038
3595
|
const meta = [];
|
|
3039
3596
|
const link = [];
|
|
3040
3597
|
const script = [];
|
|
3041
|
-
const { title, description, canonical,
|
|
3598
|
+
const { title, description, canonical, imageAlt, imageWidth, imageHeight, type = "website", siteName, twitterCard = "summary_large_image", twitterSite, twitterCreator, locale = "en_US", alternateLocales, publishedTime, modifiedTime, author, tags, jsonLd, extra, video, videoWidth, videoHeight, audio, favicon, ogTemplate, ogImageDir, ogImageFormat } = props;
|
|
3599
|
+
const robots = props.noIndex ? "noindex, nofollow" : props.robots ?? "index, follow";
|
|
3600
|
+
const image = props.image ?? (ogTemplate ? ogImagePath(ogTemplate, locale !== "en_US" ? locale : void 0, ogImageDir, ogImageFormat) : void 0);
|
|
3601
|
+
const resolvedImageWidth = imageWidth ?? (ogTemplate && !props.image ? 1200 : void 0);
|
|
3602
|
+
const resolvedImageHeight = imageHeight ?? (ogTemplate && !props.image ? 630 : void 0);
|
|
3042
3603
|
if (description) meta.push({
|
|
3043
3604
|
name: "description",
|
|
3044
3605
|
content: description
|
|
@@ -3071,6 +3632,14 @@ function buildMetaTags(props) {
|
|
|
3071
3632
|
property: "og:image:alt",
|
|
3072
3633
|
content: imageAlt
|
|
3073
3634
|
});
|
|
3635
|
+
if (resolvedImageWidth) meta.push({
|
|
3636
|
+
property: "og:image:width",
|
|
3637
|
+
content: String(resolvedImageWidth)
|
|
3638
|
+
});
|
|
3639
|
+
if (resolvedImageHeight) meta.push({
|
|
3640
|
+
property: "og:image:height",
|
|
3641
|
+
content: String(resolvedImageHeight)
|
|
3642
|
+
});
|
|
3074
3643
|
meta.push({
|
|
3075
3644
|
property: "og:type",
|
|
3076
3645
|
content: type
|
|
@@ -3083,7 +3652,33 @@ function buildMetaTags(props) {
|
|
|
3083
3652
|
property: "og:locale",
|
|
3084
3653
|
content: locale
|
|
3085
3654
|
});
|
|
3086
|
-
if (
|
|
3655
|
+
if (video) {
|
|
3656
|
+
meta.push({
|
|
3657
|
+
property: "og:video",
|
|
3658
|
+
content: video
|
|
3659
|
+
});
|
|
3660
|
+
if (videoWidth) meta.push({
|
|
3661
|
+
property: "og:video:width",
|
|
3662
|
+
content: String(videoWidth)
|
|
3663
|
+
});
|
|
3664
|
+
if (videoHeight) meta.push({
|
|
3665
|
+
property: "og:video:height",
|
|
3666
|
+
content: String(videoHeight)
|
|
3667
|
+
});
|
|
3668
|
+
if (video.endsWith(".mp4")) meta.push({
|
|
3669
|
+
property: "og:video:type",
|
|
3670
|
+
content: "video/mp4"
|
|
3671
|
+
});
|
|
3672
|
+
else if (video.endsWith(".webm")) meta.push({
|
|
3673
|
+
property: "og:video:type",
|
|
3674
|
+
content: "video/webm"
|
|
3675
|
+
});
|
|
3676
|
+
}
|
|
3677
|
+
if (audio) meta.push({
|
|
3678
|
+
property: "og:audio",
|
|
3679
|
+
content: audio
|
|
3680
|
+
});
|
|
3681
|
+
if (type === "article") {
|
|
3087
3682
|
if (publishedTime) meta.push({
|
|
3088
3683
|
property: "article:published_time",
|
|
3089
3684
|
content: publishedTime
|
|
@@ -3169,6 +3764,14 @@ function buildMetaTags(props) {
|
|
|
3169
3764
|
href: `${origin}${pathWithoutLocale}`
|
|
3170
3765
|
});
|
|
3171
3766
|
}
|
|
3767
|
+
if (favicon) {
|
|
3768
|
+
const faviconLocale = locale !== "en_US" ? locale : void 0;
|
|
3769
|
+
for (const fl of faviconLinks(faviconLocale, favicon)) link.push(fl);
|
|
3770
|
+
if (favicon.themeColor) meta.push({
|
|
3771
|
+
name: "theme-color",
|
|
3772
|
+
content: favicon.themeColor
|
|
3773
|
+
});
|
|
3774
|
+
}
|
|
3172
3775
|
return {
|
|
3173
3776
|
meta,
|
|
3174
3777
|
link,
|
|
@@ -3177,5 +3780,820 @@ function buildMetaTags(props) {
|
|
|
3177
3780
|
}
|
|
3178
3781
|
|
|
3179
3782
|
//#endregion
|
|
3180
|
-
|
|
3783
|
+
//#region src/csp.ts
|
|
3784
|
+
/** Client-side fallback nonce (dev server, SPA). */
|
|
3785
|
+
let _clientNonce = "";
|
|
3786
|
+
/**
|
|
3787
|
+
* Read the current CSP nonce in a component.
|
|
3788
|
+
*
|
|
3789
|
+
* SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
|
|
3790
|
+
* system — fully isolated between concurrent requests via AsyncLocalStorage.
|
|
3791
|
+
* Client/dev: falls back to module-level variable set by middleware.
|
|
3792
|
+
*
|
|
3793
|
+
* @example
|
|
3794
|
+
* ```tsx
|
|
3795
|
+
* import { useNonce } from "@pyreon/zero/csp"
|
|
3796
|
+
*
|
|
3797
|
+
* function InlineScript() {
|
|
3798
|
+
* const nonce = useNonce()
|
|
3799
|
+
* return <script nonce={nonce}>console.log("safe")<\/script>
|
|
3800
|
+
* }
|
|
3801
|
+
* ```
|
|
3802
|
+
*/
|
|
3803
|
+
function useNonce() {
|
|
3804
|
+
const locals = useRequestLocals();
|
|
3805
|
+
if (locals.cspNonce) return locals.cspNonce;
|
|
3806
|
+
return _clientNonce;
|
|
3807
|
+
}
|
|
3808
|
+
const DIRECTIVE_MAP = {
|
|
3809
|
+
defaultSrc: "default-src",
|
|
3810
|
+
scriptSrc: "script-src",
|
|
3811
|
+
styleSrc: "style-src",
|
|
3812
|
+
imgSrc: "img-src",
|
|
3813
|
+
fontSrc: "font-src",
|
|
3814
|
+
connectSrc: "connect-src",
|
|
3815
|
+
mediaSrc: "media-src",
|
|
3816
|
+
objectSrc: "object-src",
|
|
3817
|
+
frameSrc: "frame-src",
|
|
3818
|
+
childSrc: "child-src",
|
|
3819
|
+
workerSrc: "worker-src",
|
|
3820
|
+
frameAncestors: "frame-ancestors",
|
|
3821
|
+
formAction: "form-action",
|
|
3822
|
+
baseUri: "base-uri",
|
|
3823
|
+
manifestSrc: "manifest-src",
|
|
3824
|
+
reportUri: "report-uri",
|
|
3825
|
+
reportTo: "report-to"
|
|
3826
|
+
};
|
|
3827
|
+
/**
|
|
3828
|
+
* Build a CSP header string from directives.
|
|
3829
|
+
* Exported for testing.
|
|
3830
|
+
*/
|
|
3831
|
+
function buildCspHeader(directives, nonce) {
|
|
3832
|
+
const parts = [];
|
|
3833
|
+
for (const [key, cssProp] of Object.entries(DIRECTIVE_MAP)) {
|
|
3834
|
+
const value = directives[key];
|
|
3835
|
+
if (!value) continue;
|
|
3836
|
+
if (Array.isArray(value)) {
|
|
3837
|
+
const resolved = nonce ? value.map((v) => v === "'nonce'" ? `'nonce-${nonce}'` : v) : value.filter((v) => v !== "'nonce'");
|
|
3838
|
+
parts.push(`${cssProp} ${resolved.join(" ")}`);
|
|
3839
|
+
} else if (typeof value === "string") parts.push(`${cssProp} ${value}`);
|
|
3840
|
+
}
|
|
3841
|
+
if (directives.upgradeInsecureRequests) parts.push("upgrade-insecure-requests");
|
|
3842
|
+
if (directives.blockAllMixedContent) parts.push("block-all-mixed-content");
|
|
3843
|
+
return parts.join("; ");
|
|
3844
|
+
}
|
|
3845
|
+
/**
|
|
3846
|
+
* Generate a random nonce string (base64, 16 bytes).
|
|
3847
|
+
*/
|
|
3848
|
+
function generateNonce() {
|
|
3849
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
3850
|
+
const bytes = new Uint8Array(16);
|
|
3851
|
+
crypto.getRandomValues(bytes);
|
|
3852
|
+
let binary = "";
|
|
3853
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
3854
|
+
return typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64");
|
|
3855
|
+
}
|
|
3856
|
+
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
|
3857
|
+
}
|
|
3858
|
+
/**
|
|
3859
|
+
* CSP middleware — sets Content-Security-Policy header.
|
|
3860
|
+
*
|
|
3861
|
+
* When directives contain `"'nonce'"`, a fresh nonce is generated per-request
|
|
3862
|
+
* and attached to `ctx.locals.cspNonce` for use in inline script tags.
|
|
3863
|
+
*
|
|
3864
|
+
* @example
|
|
3865
|
+
* ```ts
|
|
3866
|
+
* // Apply to all routes
|
|
3867
|
+
* export default defineConfig({
|
|
3868
|
+
* middleware: [
|
|
3869
|
+
* cspMiddleware({
|
|
3870
|
+
* directives: {
|
|
3871
|
+
* defaultSrc: ["'self'"],
|
|
3872
|
+
* scriptSrc: ["'self'", "'nonce'"],
|
|
3873
|
+
* styleSrc: ["'self'", "'unsafe-inline'"],
|
|
3874
|
+
* imgSrc: ["'self'", "data:", "https:"],
|
|
3875
|
+
* },
|
|
3876
|
+
* }),
|
|
3877
|
+
* ],
|
|
3878
|
+
* })
|
|
3879
|
+
* ```
|
|
3880
|
+
*/
|
|
3881
|
+
function cspMiddleware(config) {
|
|
3882
|
+
const headerName = config.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";
|
|
3883
|
+
const staticHeader = Object.values(config.directives).some((v) => Array.isArray(v) && v.includes("'nonce'")) ? null : buildCspHeader(config.directives);
|
|
3884
|
+
return (ctx) => {
|
|
3885
|
+
if (staticHeader) {
|
|
3886
|
+
_clientNonce = "";
|
|
3887
|
+
ctx.headers.set(headerName, staticHeader);
|
|
3888
|
+
} else {
|
|
3889
|
+
const nonce = generateNonce();
|
|
3890
|
+
_clientNonce = nonce;
|
|
3891
|
+
ctx.locals.cspNonce = nonce;
|
|
3892
|
+
ctx.headers.set(headerName, buildCspHeader(config.directives, nonce));
|
|
3893
|
+
}
|
|
3894
|
+
};
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
//#endregion
|
|
3898
|
+
//#region src/logger.ts
|
|
3899
|
+
const COLORS = {
|
|
3900
|
+
reset: "\x1B[0m",
|
|
3901
|
+
dim: "\x1B[2m",
|
|
3902
|
+
green: "\x1B[32m",
|
|
3903
|
+
yellow: "\x1B[33m",
|
|
3904
|
+
red: "\x1B[31m",
|
|
3905
|
+
cyan: "\x1B[36m",
|
|
3906
|
+
magenta: "\x1B[35m"
|
|
3907
|
+
};
|
|
3908
|
+
function methodColor(method, colors) {
|
|
3909
|
+
if (!colors) return method.padEnd(7);
|
|
3910
|
+
const padded = method.padEnd(7);
|
|
3911
|
+
switch (method) {
|
|
3912
|
+
case "GET": return `${COLORS.green}${padded}${COLORS.reset}`;
|
|
3913
|
+
case "POST": return `${COLORS.cyan}${padded}${COLORS.reset}`;
|
|
3914
|
+
case "PUT": return `${COLORS.yellow}${padded}${COLORS.reset}`;
|
|
3915
|
+
case "PATCH": return `${COLORS.yellow}${padded}${COLORS.reset}`;
|
|
3916
|
+
case "DELETE": return `${COLORS.red}${padded}${COLORS.reset}`;
|
|
3917
|
+
default: return `${COLORS.magenta}${padded}${COLORS.reset}`;
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
function defaultFormat(entry, colors) {
|
|
3921
|
+
const dur = entry.duration < 1 ? "<1ms" : entry.duration < 1e3 ? `${Math.round(entry.duration)}ms` : `${(entry.duration / 1e3).toFixed(2)}s`;
|
|
3922
|
+
const dim = colors ? COLORS.dim : "";
|
|
3923
|
+
const reset = colors ? COLORS.reset : "";
|
|
3924
|
+
return ` ${methodColor(entry.method, colors)} ${entry.path} ${dim}${dur}${reset}`;
|
|
3925
|
+
}
|
|
3926
|
+
/**
|
|
3927
|
+
* Request logging middleware.
|
|
3928
|
+
*
|
|
3929
|
+
* Logs incoming requests with method, path, and duration.
|
|
3930
|
+
* Runs in middleware phase — logs timing from middleware start to
|
|
3931
|
+
* microtask completion (approximate request duration).
|
|
3932
|
+
*
|
|
3933
|
+
* @example
|
|
3934
|
+
* ```ts
|
|
3935
|
+
* // Basic usage
|
|
3936
|
+
* loggerMiddleware()
|
|
3937
|
+
*
|
|
3938
|
+
* // Custom format
|
|
3939
|
+
* loggerMiddleware({
|
|
3940
|
+
* format: (e) => `${e.method} ${e.path} (${e.duration}ms)`,
|
|
3941
|
+
* })
|
|
3942
|
+
* ```
|
|
3943
|
+
*/
|
|
3944
|
+
function loggerMiddleware(config) {
|
|
3945
|
+
if ((config?.level ?? "all") === "none") return () => {};
|
|
3946
|
+
const skip = config?.skip ?? [
|
|
3947
|
+
"/__",
|
|
3948
|
+
"/@",
|
|
3949
|
+
"/node_modules"
|
|
3950
|
+
];
|
|
3951
|
+
const isDev = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
|
|
3952
|
+
const colors = config?.colors ?? isDev;
|
|
3953
|
+
return (ctx) => {
|
|
3954
|
+
if (skip.some((p) => ctx.path.startsWith(p))) return;
|
|
3955
|
+
const start = performance.now();
|
|
3956
|
+
const entry = {
|
|
3957
|
+
method: ctx.req.method ?? "GET",
|
|
3958
|
+
path: ctx.path,
|
|
3959
|
+
duration: 0,
|
|
3960
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
3961
|
+
userAgent: ctx.req.headers.get("user-agent") ?? void 0
|
|
3962
|
+
};
|
|
3963
|
+
queueMicrotask(() => {
|
|
3964
|
+
entry.duration = performance.now() - start;
|
|
3965
|
+
if (config?.format) {
|
|
3966
|
+
const line = config.format(entry);
|
|
3967
|
+
if (line) console.log(line);
|
|
3968
|
+
} else console.log(defaultFormat(entry, colors));
|
|
3969
|
+
});
|
|
3970
|
+
};
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
//#endregion
|
|
3974
|
+
//#region src/env.ts
|
|
3975
|
+
/**
|
|
3976
|
+
* String validator — accepts any non-empty string.
|
|
3977
|
+
*/
|
|
3978
|
+
function str(options) {
|
|
3979
|
+
return {
|
|
3980
|
+
__type: "env-validator",
|
|
3981
|
+
required: options?.default === void 0 && options?.required !== false,
|
|
3982
|
+
defaultValue: options?.default,
|
|
3983
|
+
parse(raw, key) {
|
|
3984
|
+
if (raw === void 0 || raw === "") {
|
|
3985
|
+
if (options?.default !== void 0) return options.default;
|
|
3986
|
+
throw new EnvError(key, "is required but not set", options?.description);
|
|
3987
|
+
}
|
|
3988
|
+
return raw;
|
|
3989
|
+
}
|
|
3990
|
+
};
|
|
3991
|
+
}
|
|
3992
|
+
/**
|
|
3993
|
+
* Number validator — parses to a number, rejects NaN.
|
|
3994
|
+
*/
|
|
3995
|
+
function num(options) {
|
|
3996
|
+
return {
|
|
3997
|
+
__type: "env-validator",
|
|
3998
|
+
required: options?.default === void 0 && options?.required !== false,
|
|
3999
|
+
defaultValue: options?.default,
|
|
4000
|
+
parse(raw, key) {
|
|
4001
|
+
if (raw === void 0 || raw === "") {
|
|
4002
|
+
if (options?.default !== void 0) return options.default;
|
|
4003
|
+
throw new EnvError(key, "is required but not set", options?.description);
|
|
4004
|
+
}
|
|
4005
|
+
const n = Number(raw);
|
|
4006
|
+
if (Number.isNaN(n)) throw new EnvError(key, `must be a number, got "${raw}"`, options?.description);
|
|
4007
|
+
return n;
|
|
4008
|
+
}
|
|
4009
|
+
};
|
|
4010
|
+
}
|
|
4011
|
+
/**
|
|
4012
|
+
* Boolean validator — accepts "true"/"1" as true, "false"/"0" as false.
|
|
4013
|
+
*/
|
|
4014
|
+
function bool(options) {
|
|
4015
|
+
return {
|
|
4016
|
+
__type: "env-validator",
|
|
4017
|
+
required: options?.default === void 0 && options?.required !== false,
|
|
4018
|
+
defaultValue: options?.default,
|
|
4019
|
+
parse(raw, key) {
|
|
4020
|
+
if (raw === void 0 || raw === "") {
|
|
4021
|
+
if (options?.default !== void 0) return options.default;
|
|
4022
|
+
throw new EnvError(key, "is required but not set", options?.description);
|
|
4023
|
+
}
|
|
4024
|
+
const lower = raw.toLowerCase();
|
|
4025
|
+
if (lower === "true" || lower === "1") return true;
|
|
4026
|
+
if (lower === "false" || lower === "0") return false;
|
|
4027
|
+
throw new EnvError(key, `must be "true" or "false", got "${raw}"`, options?.description);
|
|
4028
|
+
}
|
|
4029
|
+
};
|
|
4030
|
+
}
|
|
4031
|
+
/**
|
|
4032
|
+
* URL validator — validates that the value is a valid URL.
|
|
4033
|
+
*/
|
|
4034
|
+
function url(options) {
|
|
4035
|
+
return {
|
|
4036
|
+
__type: "env-validator",
|
|
4037
|
+
required: options?.default === void 0 && options?.required !== false,
|
|
4038
|
+
defaultValue: options?.default,
|
|
4039
|
+
parse(raw, key) {
|
|
4040
|
+
if (raw === void 0 || raw === "") {
|
|
4041
|
+
if (options?.default !== void 0) return options.default;
|
|
4042
|
+
throw new EnvError(key, "is required but not set", options?.description);
|
|
4043
|
+
}
|
|
4044
|
+
try {
|
|
4045
|
+
new URL(raw);
|
|
4046
|
+
return raw;
|
|
4047
|
+
} catch {
|
|
4048
|
+
throw new EnvError(key, `must be a valid URL, got "${raw}"`, options?.description);
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
};
|
|
4052
|
+
}
|
|
4053
|
+
/**
|
|
4054
|
+
* Enum validator — value must be one of the allowed values.
|
|
4055
|
+
*/
|
|
4056
|
+
function oneOf(values, options) {
|
|
4057
|
+
return {
|
|
4058
|
+
__type: "env-validator",
|
|
4059
|
+
required: options?.default === void 0 && options?.required !== false,
|
|
4060
|
+
defaultValue: options?.default,
|
|
4061
|
+
parse(raw, key) {
|
|
4062
|
+
if (raw === void 0 || raw === "") {
|
|
4063
|
+
if (options?.default !== void 0) return options.default;
|
|
4064
|
+
throw new EnvError(key, "is required but not set", options?.description);
|
|
4065
|
+
}
|
|
4066
|
+
if (!values.includes(raw)) throw new EnvError(key, `must be one of [${values.join(", ")}], got "${raw}"`, options?.description);
|
|
4067
|
+
return raw;
|
|
4068
|
+
}
|
|
4069
|
+
};
|
|
4070
|
+
}
|
|
4071
|
+
var EnvError = class extends Error {
|
|
4072
|
+
constructor(key, message, description) {
|
|
4073
|
+
const desc = description ? ` (${description})` : "";
|
|
4074
|
+
super(`[zero:env] ${key}${desc}: ${message}`);
|
|
4075
|
+
this.name = "EnvError";
|
|
4076
|
+
}
|
|
4077
|
+
};
|
|
4078
|
+
function isEnvValidator(v) {
|
|
4079
|
+
return typeof v === "object" && v !== null && v.__type === "env-validator";
|
|
4080
|
+
}
|
|
4081
|
+
/**
|
|
4082
|
+
* Convert a plain schema value to an EnvValidator.
|
|
4083
|
+
*
|
|
4084
|
+
* - `3000` → num({ default: 3000 })
|
|
4085
|
+
* - `false` → bool({ default: false })
|
|
4086
|
+
* - `"localhost"` → str({ default: "localhost" })
|
|
4087
|
+
* - `String` → str() (required)
|
|
4088
|
+
* - `Number` → num() (required)
|
|
4089
|
+
* - `Boolean` → bool() (required)
|
|
4090
|
+
* - EnvValidator → pass through
|
|
4091
|
+
*/
|
|
4092
|
+
function toValidator(value) {
|
|
4093
|
+
if (isEnvValidator(value)) return value;
|
|
4094
|
+
if (value === String) return str();
|
|
4095
|
+
if (value === Number) return num();
|
|
4096
|
+
if (value === Boolean) return bool();
|
|
4097
|
+
if (typeof value === "number") return num({ default: value });
|
|
4098
|
+
if (typeof value === "boolean") return bool({ default: value });
|
|
4099
|
+
if (typeof value === "string") return str({ default: value });
|
|
4100
|
+
throw new Error(`[zero:env] Invalid schema value: ${String(value)}. Use a default value, String/Number/Boolean, or a validator like url().`);
|
|
4101
|
+
}
|
|
4102
|
+
/**
|
|
4103
|
+
* Validate environment variables.
|
|
4104
|
+
*
|
|
4105
|
+
* Schema values can be:
|
|
4106
|
+
* - **Default values**: `3000`, `false`, `"localhost"` → type inferred, used as default
|
|
4107
|
+
* - **Constructors**: `String`, `Number`, `Boolean` → required, no default
|
|
4108
|
+
* - **Validators**: `url()`, `oneOf([...])`, `str()`, `num()`, `bool()` → explicit validation
|
|
4109
|
+
* - **Custom**: `schema(raw => z.coerce.number().parse(raw))` — bridge to any schema library
|
|
4110
|
+
*
|
|
4111
|
+
* @example
|
|
4112
|
+
* ```ts
|
|
4113
|
+
* import { validateEnv, url, oneOf } from "@pyreon/zero/env"
|
|
4114
|
+
*
|
|
4115
|
+
* const env = validateEnv({
|
|
4116
|
+
* PORT: 3000, // optional, default 3000
|
|
4117
|
+
* DATABASE_URL: url(), // required, validated URL
|
|
4118
|
+
* NODE_ENV: oneOf(["dev", "prod", "test"]), // required, must be one of
|
|
4119
|
+
* API_KEY: String, // required string
|
|
4120
|
+
* DEBUG: false, // optional, default false
|
|
4121
|
+
* })
|
|
4122
|
+
* ```
|
|
4123
|
+
*/
|
|
4124
|
+
function validateEnv(schema, source) {
|
|
4125
|
+
const env = source ?? (typeof process !== "undefined" ? process.env : {});
|
|
4126
|
+
const result = {};
|
|
4127
|
+
const errors = [];
|
|
4128
|
+
for (const [key, entry] of Object.entries(schema)) {
|
|
4129
|
+
const validator = toValidator(entry);
|
|
4130
|
+
try {
|
|
4131
|
+
result[key] = validator.parse(env[key], key);
|
|
4132
|
+
} catch (e) {
|
|
4133
|
+
errors.push(e.message);
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
4136
|
+
if (errors.length > 0) {
|
|
4137
|
+
const header = `\n[zero:env] Environment validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n`;
|
|
4138
|
+
const body = errors.map((e) => ` ✗ ${e.replace("[zero:env] ", "")}`).join("\n");
|
|
4139
|
+
throw new Error(header + body + "\n");
|
|
4140
|
+
}
|
|
4141
|
+
return result;
|
|
4142
|
+
}
|
|
4143
|
+
function publicEnv(schema) {
|
|
4144
|
+
const prefix = "ZERO_PUBLIC_";
|
|
4145
|
+
const env = typeof process !== "undefined" ? process.env : {};
|
|
4146
|
+
if (!schema) {
|
|
4147
|
+
const result = {};
|
|
4148
|
+
for (const [key, value] of Object.entries(env)) if (key.startsWith(prefix) && value !== void 0) result[key.slice(12)] = value;
|
|
4149
|
+
return result;
|
|
4150
|
+
}
|
|
4151
|
+
const prefixedSource = {};
|
|
4152
|
+
for (const key of Object.keys(schema)) prefixedSource[key] = env[`${prefix}${key}`];
|
|
4153
|
+
return validateEnv(schema, prefixedSource);
|
|
4154
|
+
}
|
|
4155
|
+
/**
|
|
4156
|
+
* Create an env validator from a custom parse function.
|
|
4157
|
+
* Use this to integrate any schema library (Zod, Valibot, ArkType, etc.).
|
|
4158
|
+
*
|
|
4159
|
+
* @example
|
|
4160
|
+
* ```ts
|
|
4161
|
+
* import { z } from "zod"
|
|
4162
|
+
* import { validateEnv, schema } from "@pyreon/zero/env"
|
|
4163
|
+
*
|
|
4164
|
+
* const env = validateEnv({
|
|
4165
|
+
* PORT: schema(raw => z.coerce.number().parse(raw)),
|
|
4166
|
+
* DATABASE_URL: schema(raw => z.string().url().parse(raw)),
|
|
4167
|
+
* HOST: "localhost", // plain defaults still work alongside
|
|
4168
|
+
* })
|
|
4169
|
+
* ```
|
|
4170
|
+
*/
|
|
4171
|
+
function schema(parse) {
|
|
4172
|
+
return {
|
|
4173
|
+
__type: "env-validator",
|
|
4174
|
+
required: true,
|
|
4175
|
+
defaultValue: void 0,
|
|
4176
|
+
parse(raw, key) {
|
|
4177
|
+
if (raw === void 0 || raw === "") throw new Error(`[zero:env] ${key}: is required but not set`);
|
|
4178
|
+
try {
|
|
4179
|
+
return parse(raw);
|
|
4180
|
+
} catch (e) {
|
|
4181
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
4182
|
+
throw new Error(`[zero:env] ${key}: ${msg}`);
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
};
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
//#endregion
|
|
4189
|
+
//#region src/ai.ts
|
|
4190
|
+
/**
|
|
4191
|
+
* Generate llms.txt content from route files and config.
|
|
4192
|
+
*
|
|
4193
|
+
* Format follows the llms.txt proposal:
|
|
4194
|
+
* ```
|
|
4195
|
+
* # {name}
|
|
4196
|
+
* > {description}
|
|
4197
|
+
*
|
|
4198
|
+
* ## Pages
|
|
4199
|
+
* - [/about](/about): About page
|
|
4200
|
+
*
|
|
4201
|
+
* ## API
|
|
4202
|
+
* - GET /api/posts: List posts
|
|
4203
|
+
* ```
|
|
4204
|
+
*
|
|
4205
|
+
* @internal Exported for testing.
|
|
4206
|
+
*/
|
|
4207
|
+
function generateLlmsTxt(routeFiles, apiFiles, config) {
|
|
4208
|
+
const lines = [];
|
|
4209
|
+
lines.push(`# ${config.name}`);
|
|
4210
|
+
lines.push(`> ${config.description}`);
|
|
4211
|
+
lines.push("");
|
|
4212
|
+
const routes = parseFileRoutes(routeFiles);
|
|
4213
|
+
const pages = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && !r.isCatchAll && !r.urlPath.includes(":"));
|
|
4214
|
+
if (pages.length > 0) {
|
|
4215
|
+
lines.push("## Pages");
|
|
4216
|
+
lines.push("");
|
|
4217
|
+
for (const page of pages) {
|
|
4218
|
+
const desc = config.pageDescriptions?.[page.urlPath];
|
|
4219
|
+
const url = `${config.origin}${page.urlPath === "/" ? "" : page.urlPath}`;
|
|
4220
|
+
if (desc) lines.push(`- [${page.urlPath}](${url}): ${desc}`);
|
|
4221
|
+
else lines.push(`- [${page.urlPath}](${url})`);
|
|
4222
|
+
}
|
|
4223
|
+
lines.push("");
|
|
4224
|
+
}
|
|
4225
|
+
const dynamicRoutes = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && (r.urlPath.includes(":") || r.isCatchAll));
|
|
4226
|
+
if (dynamicRoutes.length > 0) {
|
|
4227
|
+
lines.push("## Dynamic Pages");
|
|
4228
|
+
lines.push("");
|
|
4229
|
+
for (const route of dynamicRoutes) {
|
|
4230
|
+
const desc = config.pageDescriptions?.[route.urlPath];
|
|
4231
|
+
if (desc) lines.push(`- ${route.urlPath}: ${desc}`);
|
|
4232
|
+
else lines.push(`- ${route.urlPath}`);
|
|
4233
|
+
}
|
|
4234
|
+
lines.push("");
|
|
4235
|
+
}
|
|
4236
|
+
const apiPatterns = parseApiFiles(apiFiles);
|
|
4237
|
+
if (apiPatterns.length > 0 || config.apiDescriptions) {
|
|
4238
|
+
lines.push("## API Endpoints");
|
|
4239
|
+
lines.push("");
|
|
4240
|
+
if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) lines.push(`- ${endpoint}: ${desc}`);
|
|
4241
|
+
const describedPatterns = new Set(Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/, "")));
|
|
4242
|
+
for (const pattern of apiPatterns) if (!describedPatterns.has(pattern)) lines.push(`- ${pattern}`);
|
|
4243
|
+
lines.push("");
|
|
4244
|
+
}
|
|
4245
|
+
if (config.llmsExtra) {
|
|
4246
|
+
lines.push(config.llmsExtra);
|
|
4247
|
+
lines.push("");
|
|
4248
|
+
}
|
|
4249
|
+
return lines.join("\n");
|
|
4250
|
+
}
|
|
4251
|
+
/**
|
|
4252
|
+
* Generate llms-full.txt — expanded version with more detail.
|
|
4253
|
+
* Includes all route metadata and API descriptions.
|
|
4254
|
+
*
|
|
4255
|
+
* @internal Exported for testing.
|
|
4256
|
+
*/
|
|
4257
|
+
function generateLlmsFullTxt(routeFiles, apiFiles, config) {
|
|
4258
|
+
const lines = [];
|
|
4259
|
+
lines.push(`# ${config.name} — Full Reference`);
|
|
4260
|
+
lines.push(`> ${config.description}`);
|
|
4261
|
+
lines.push("");
|
|
4262
|
+
lines.push(`Base URL: ${config.origin}`);
|
|
4263
|
+
lines.push("");
|
|
4264
|
+
const pages = parseFileRoutes(routeFiles).filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound);
|
|
4265
|
+
if (pages.length > 0) {
|
|
4266
|
+
lines.push("## All Routes");
|
|
4267
|
+
lines.push("");
|
|
4268
|
+
for (const page of pages) {
|
|
4269
|
+
const desc = config.pageDescriptions?.[page.urlPath] ?? "";
|
|
4270
|
+
const dynamic = page.urlPath.includes(":") ? " (dynamic)" : "";
|
|
4271
|
+
const catchAll = page.isCatchAll ? " (catch-all)" : "";
|
|
4272
|
+
lines.push(`### ${page.urlPath}${dynamic}${catchAll}`);
|
|
4273
|
+
if (desc) lines.push(desc);
|
|
4274
|
+
lines.push(`- File: ${page.filePath}`);
|
|
4275
|
+
lines.push(`- Render mode: ${page.renderMode}`);
|
|
4276
|
+
lines.push("");
|
|
4277
|
+
}
|
|
4278
|
+
}
|
|
4279
|
+
if (config.apiDescriptions) {
|
|
4280
|
+
lines.push("## API Reference");
|
|
4281
|
+
lines.push("");
|
|
4282
|
+
for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
4283
|
+
lines.push(`### ${endpoint}`);
|
|
4284
|
+
lines.push(desc);
|
|
4285
|
+
lines.push("");
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
if (config.llmsExtra) {
|
|
4289
|
+
lines.push("## Additional Information");
|
|
4290
|
+
lines.push("");
|
|
4291
|
+
lines.push(config.llmsExtra);
|
|
4292
|
+
lines.push("");
|
|
4293
|
+
}
|
|
4294
|
+
return lines.join("\n");
|
|
4295
|
+
}
|
|
4296
|
+
/**
|
|
4297
|
+
* Auto-infer JSON-LD structured data from page metadata.
|
|
4298
|
+
*
|
|
4299
|
+
* Returns an array of JSON-LD objects (multiple schemas can apply to one page).
|
|
4300
|
+
* For example, an article page gets both `Article` and `BreadcrumbList`.
|
|
4301
|
+
*
|
|
4302
|
+
* @example
|
|
4303
|
+
* ```tsx
|
|
4304
|
+
* const schemas = inferJsonLd({
|
|
4305
|
+
* url: "https://example.com/blog/my-post",
|
|
4306
|
+
* title: "My Post",
|
|
4307
|
+
* description: "A great article",
|
|
4308
|
+
* type: "article",
|
|
4309
|
+
* author: "Vit Bokisch",
|
|
4310
|
+
* publishedTime: "2026-03-31",
|
|
4311
|
+
* })
|
|
4312
|
+
* // → [Article schema, BreadcrumbList schema]
|
|
4313
|
+
* ```
|
|
4314
|
+
*/
|
|
4315
|
+
function inferJsonLd(options) {
|
|
4316
|
+
const schemas = [];
|
|
4317
|
+
if (options.type === "article") {
|
|
4318
|
+
const article = {
|
|
4319
|
+
"@context": "https://schema.org",
|
|
4320
|
+
"@type": "Article",
|
|
4321
|
+
headline: options.title,
|
|
4322
|
+
url: options.url
|
|
4323
|
+
};
|
|
4324
|
+
if (options.description) article.description = options.description;
|
|
4325
|
+
if (options.image) article.image = options.image;
|
|
4326
|
+
if (options.publishedTime) article.datePublished = options.publishedTime;
|
|
4327
|
+
if (options.author) article.author = {
|
|
4328
|
+
"@type": "Person",
|
|
4329
|
+
name: options.author
|
|
4330
|
+
};
|
|
4331
|
+
if (options.tags && options.tags.length > 0) article.keywords = options.tags.join(", ");
|
|
4332
|
+
if (options.siteName) article.publisher = {
|
|
4333
|
+
"@type": "Organization",
|
|
4334
|
+
name: options.siteName
|
|
4335
|
+
};
|
|
4336
|
+
schemas.push(article);
|
|
4337
|
+
} else if (options.type === "product") {
|
|
4338
|
+
const product = {
|
|
4339
|
+
"@context": "https://schema.org",
|
|
4340
|
+
"@type": "Product",
|
|
4341
|
+
name: options.title,
|
|
4342
|
+
url: options.url
|
|
4343
|
+
};
|
|
4344
|
+
if (options.description) product.description = options.description;
|
|
4345
|
+
if (options.image) product.image = options.image;
|
|
4346
|
+
schemas.push(product);
|
|
4347
|
+
} else {
|
|
4348
|
+
const webpage = {
|
|
4349
|
+
"@context": "https://schema.org",
|
|
4350
|
+
"@type": "WebPage",
|
|
4351
|
+
name: options.title,
|
|
4352
|
+
url: options.url
|
|
4353
|
+
};
|
|
4354
|
+
if (options.description) webpage.description = options.description;
|
|
4355
|
+
if (options.image) webpage.thumbnailUrl = options.image;
|
|
4356
|
+
schemas.push(webpage);
|
|
4357
|
+
}
|
|
4358
|
+
if (options.breadcrumbs && options.breadcrumbs.length > 0) schemas.push({
|
|
4359
|
+
"@context": "https://schema.org",
|
|
4360
|
+
"@type": "BreadcrumbList",
|
|
4361
|
+
itemListElement: options.breadcrumbs.map((bc, i) => ({
|
|
4362
|
+
"@type": "ListItem",
|
|
4363
|
+
position: i + 1,
|
|
4364
|
+
name: bc.name,
|
|
4365
|
+
item: bc.url
|
|
4366
|
+
}))
|
|
4367
|
+
});
|
|
4368
|
+
else {
|
|
4369
|
+
const urlObj = safeParseUrl(options.url);
|
|
4370
|
+
if (urlObj) {
|
|
4371
|
+
const segments = urlObj.pathname.split("/").filter(Boolean);
|
|
4372
|
+
if (segments.length > 0) {
|
|
4373
|
+
const items = [{
|
|
4374
|
+
"@type": "ListItem",
|
|
4375
|
+
position: 1,
|
|
4376
|
+
name: "Home",
|
|
4377
|
+
item: urlObj.origin
|
|
4378
|
+
}];
|
|
4379
|
+
let path = "";
|
|
4380
|
+
for (let i = 0; i < segments.length; i++) {
|
|
4381
|
+
path += `/${segments[i]}`;
|
|
4382
|
+
items.push({
|
|
4383
|
+
"@type": "ListItem",
|
|
4384
|
+
position: i + 2,
|
|
4385
|
+
name: capitalize(segments[i].replace(/-/g, " ")),
|
|
4386
|
+
item: `${urlObj.origin}${path}`
|
|
4387
|
+
});
|
|
4388
|
+
}
|
|
4389
|
+
schemas.push({
|
|
4390
|
+
"@context": "https://schema.org",
|
|
4391
|
+
"@type": "BreadcrumbList",
|
|
4392
|
+
itemListElement: items
|
|
4393
|
+
});
|
|
4394
|
+
}
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
return schemas;
|
|
4398
|
+
}
|
|
4399
|
+
/**
|
|
4400
|
+
* Generate an OpenAI-compatible AI plugin manifest.
|
|
4401
|
+
*
|
|
4402
|
+
* Follows the /.well-known/ai-plugin.json spec.
|
|
4403
|
+
*
|
|
4404
|
+
* @internal Exported for testing.
|
|
4405
|
+
*/
|
|
4406
|
+
function generateAiPluginManifest(config) {
|
|
4407
|
+
return {
|
|
4408
|
+
schema_version: "v1",
|
|
4409
|
+
name_for_human: config.name,
|
|
4410
|
+
name_for_model: config.name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, ""),
|
|
4411
|
+
description_for_human: config.description,
|
|
4412
|
+
description_for_model: config.description,
|
|
4413
|
+
auth: { type: "none" },
|
|
4414
|
+
api: {
|
|
4415
|
+
type: "openapi",
|
|
4416
|
+
url: `${config.origin}/.well-known/openapi.yaml`
|
|
4417
|
+
},
|
|
4418
|
+
logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,
|
|
4419
|
+
contact_email: config.contactEmail ?? "",
|
|
4420
|
+
legal_info_url: config.legalUrl ?? `${config.origin}/legal`
|
|
4421
|
+
};
|
|
4422
|
+
}
|
|
4423
|
+
/**
|
|
4424
|
+
* Generate a minimal OpenAPI 3.0 spec from API route descriptions.
|
|
4425
|
+
*
|
|
4426
|
+
* @internal Exported for testing.
|
|
4427
|
+
*/
|
|
4428
|
+
function generateOpenApiSpec(apiFiles, config) {
|
|
4429
|
+
const paths = {};
|
|
4430
|
+
if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
4431
|
+
const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/);
|
|
4432
|
+
if (match) {
|
|
4433
|
+
const method = match[1].toLowerCase();
|
|
4434
|
+
const openApiPath = match[2].replace(/:(\w+)/g, "{$1}");
|
|
4435
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
4436
|
+
paths[openApiPath][method] = {
|
|
4437
|
+
summary: desc,
|
|
4438
|
+
responses: { "200": { description: "Success" } }
|
|
4439
|
+
};
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
for (const pattern of parseApiFiles(apiFiles)) {
|
|
4443
|
+
const openApiPath = pattern.replace(/:(\w+)/g, "{$1}");
|
|
4444
|
+
if (!paths[openApiPath]) paths[openApiPath] = { get: {
|
|
4445
|
+
summary: `${openApiPath} endpoint`,
|
|
4446
|
+
responses: { "200": { description: "Success" } }
|
|
4447
|
+
} };
|
|
4448
|
+
}
|
|
4449
|
+
return {
|
|
4450
|
+
openapi: "3.0.0",
|
|
4451
|
+
info: {
|
|
4452
|
+
title: config.name,
|
|
4453
|
+
description: config.description,
|
|
4454
|
+
version: "1.0.0"
|
|
4455
|
+
},
|
|
4456
|
+
servers: [{ url: config.origin }],
|
|
4457
|
+
paths
|
|
4458
|
+
};
|
|
4459
|
+
}
|
|
4460
|
+
/**
|
|
4461
|
+
* AI integration Vite plugin.
|
|
4462
|
+
*
|
|
4463
|
+
* Generates at build time:
|
|
4464
|
+
* - `/llms.txt` — concise site summary for AI agents
|
|
4465
|
+
* - `/llms-full.txt` — detailed reference for AI agents
|
|
4466
|
+
* - `/.well-known/ai-plugin.json` — OpenAI plugin manifest
|
|
4467
|
+
* - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes
|
|
4468
|
+
*
|
|
4469
|
+
* In dev, serves these files via middleware.
|
|
4470
|
+
*
|
|
4471
|
+
* @example
|
|
4472
|
+
* ```ts
|
|
4473
|
+
* import { aiPlugin } from "@pyreon/zero/ai"
|
|
4474
|
+
*
|
|
4475
|
+
* export default {
|
|
4476
|
+
* plugins: [
|
|
4477
|
+
* aiPlugin({
|
|
4478
|
+
* name: "My App",
|
|
4479
|
+
* origin: "https://example.com",
|
|
4480
|
+
* description: "A modern web application",
|
|
4481
|
+
* apiDescriptions: {
|
|
4482
|
+
* "GET /api/posts": "List blog posts",
|
|
4483
|
+
* "GET /api/posts/:id": "Get post by ID",
|
|
4484
|
+
* },
|
|
4485
|
+
* }),
|
|
4486
|
+
* ],
|
|
4487
|
+
* }
|
|
4488
|
+
* ```
|
|
4489
|
+
*/
|
|
4490
|
+
function aiPlugin(config) {
|
|
4491
|
+
let root = "";
|
|
4492
|
+
let isBuild = false;
|
|
4493
|
+
let routeFiles = [];
|
|
4494
|
+
let apiFiles = [];
|
|
4495
|
+
return {
|
|
4496
|
+
name: "pyreon-zero-ai",
|
|
4497
|
+
enforce: "post",
|
|
4498
|
+
configResolved(resolvedConfig) {
|
|
4499
|
+
root = resolvedConfig.root;
|
|
4500
|
+
isBuild = resolvedConfig.command === "build";
|
|
4501
|
+
},
|
|
4502
|
+
async buildStart() {
|
|
4503
|
+
try {
|
|
4504
|
+
const { join } = await import("node:path");
|
|
4505
|
+
const routesDir = join(root, config.routesDir ?? "src/routes");
|
|
4506
|
+
const apiDir = join(root, config.apiDir ?? "src/api");
|
|
4507
|
+
routeFiles = await scanDir(routesDir, routesDir);
|
|
4508
|
+
apiFiles = await scanDir(apiDir, apiDir);
|
|
4509
|
+
} catch {}
|
|
4510
|
+
},
|
|
4511
|
+
configureServer(server) {
|
|
4512
|
+
server.middlewares.use(async (req, res, next) => {
|
|
4513
|
+
const url = req.url ?? "";
|
|
4514
|
+
if (url === "/llms.txt") {
|
|
4515
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
4516
|
+
res.end(generateLlmsTxt(routeFiles, apiFiles, config));
|
|
4517
|
+
return;
|
|
4518
|
+
}
|
|
4519
|
+
if (url === "/llms-full.txt") {
|
|
4520
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
4521
|
+
res.end(generateLlmsFullTxt(routeFiles, apiFiles, config));
|
|
4522
|
+
return;
|
|
4523
|
+
}
|
|
4524
|
+
if (url === "/.well-known/ai-plugin.json") {
|
|
4525
|
+
res.setHeader("Content-Type", "application/json");
|
|
4526
|
+
res.end(JSON.stringify(generateAiPluginManifest(config), null, 2));
|
|
4527
|
+
return;
|
|
4528
|
+
}
|
|
4529
|
+
if (url === "/.well-known/openapi.yaml" || url === "/.well-known/openapi.json") {
|
|
4530
|
+
res.setHeader("Content-Type", "application/json");
|
|
4531
|
+
res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2));
|
|
4532
|
+
return;
|
|
4533
|
+
}
|
|
4534
|
+
next();
|
|
4535
|
+
});
|
|
4536
|
+
},
|
|
4537
|
+
async generateBundle() {
|
|
4538
|
+
if (!isBuild) return;
|
|
4539
|
+
this.emitFile({
|
|
4540
|
+
type: "asset",
|
|
4541
|
+
fileName: "llms.txt",
|
|
4542
|
+
source: generateLlmsTxt(routeFiles, apiFiles, config)
|
|
4543
|
+
});
|
|
4544
|
+
this.emitFile({
|
|
4545
|
+
type: "asset",
|
|
4546
|
+
fileName: "llms-full.txt",
|
|
4547
|
+
source: generateLlmsFullTxt(routeFiles, apiFiles, config)
|
|
4548
|
+
});
|
|
4549
|
+
this.emitFile({
|
|
4550
|
+
type: "asset",
|
|
4551
|
+
fileName: ".well-known/ai-plugin.json",
|
|
4552
|
+
source: JSON.stringify(generateAiPluginManifest(config), null, 2)
|
|
4553
|
+
});
|
|
4554
|
+
this.emitFile({
|
|
4555
|
+
type: "asset",
|
|
4556
|
+
fileName: ".well-known/openapi.json",
|
|
4557
|
+
source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2)
|
|
4558
|
+
});
|
|
4559
|
+
}
|
|
4560
|
+
};
|
|
4561
|
+
}
|
|
4562
|
+
function parseApiFiles(files) {
|
|
4563
|
+
return files.filter((f) => f.endsWith(".ts") || f.endsWith(".js")).map((f) => {
|
|
4564
|
+
let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "");
|
|
4565
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
4566
|
+
path = path.replace(/\[\.\.\.(\w+)\]/g, ":$1*").replace(/\[(\w+)\]/g, ":$1");
|
|
4567
|
+
return `/api${path === "/" ? "" : path}`;
|
|
4568
|
+
});
|
|
4569
|
+
}
|
|
4570
|
+
async function scanDir(dir, base) {
|
|
4571
|
+
const { readdir, stat } = await import("node:fs/promises");
|
|
4572
|
+
const { join, relative } = await import("node:path");
|
|
4573
|
+
try {
|
|
4574
|
+
const entries = await readdir(dir);
|
|
4575
|
+
const files = [];
|
|
4576
|
+
for (const entry of entries) {
|
|
4577
|
+
const full = join(dir, entry);
|
|
4578
|
+
if ((await stat(full)).isDirectory()) files.push(...await scanDir(full, base));
|
|
4579
|
+
else files.push(relative(base, full));
|
|
4580
|
+
}
|
|
4581
|
+
return files;
|
|
4582
|
+
} catch {
|
|
4583
|
+
return [];
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
function safeParseUrl(url) {
|
|
4587
|
+
try {
|
|
4588
|
+
return new URL(url);
|
|
4589
|
+
} catch {
|
|
4590
|
+
return null;
|
|
4591
|
+
}
|
|
4592
|
+
}
|
|
4593
|
+
function capitalize(s) {
|
|
4594
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
4595
|
+
}
|
|
4596
|
+
|
|
4597
|
+
//#endregion
|
|
4598
|
+
export { Image, Link, Meta, Script, ThemeToggle, aiPlugin, bool, buildCspHeader, buildLocalePath, buildMetaTags, bunAdapter, cacheMiddleware, cloudflareAdapter, compose, compressResponse, compressionMiddleware, corsMiddleware, createActionMiddleware, createApiMiddleware, createApp, createISRHandler, createLink, createLocaleContext, createServer, cspMiddleware, zeroPlugin as default, defineAction, defineConfig, detectLocaleFromHeader, extractLocaleFromPath, faviconLinks, faviconPlugin, filePathToUrlPath, fontPlugin, fontVariables, generateAiPluginManifest, generateApiRouteModule, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateOpenApiSpec, generateRobots, generateRouteModule, generateSitemap, getContext, i18nRouting, imagePlugin, inferJsonLd, initTheme, isCompressible, jsonLd, loggerMiddleware, netlifyAdapter, nodeAdapter, num, ogImagePath, ogImagePlugin, oneOf, parseFileRoutes, prefetchRoute, publicEnv, rateLimitMiddleware, render404Page, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, schema, securityHeaders, seoMiddleware, seoPlugin, setLocale, setTheme, staticAdapter, str, theme, themeScript, toggleTheme, url, useLink, useLocale, useNonce, validateEnv, varyEncoding, vercelAdapter };
|
|
3181
4599
|
//# sourceMappingURL=index.js.map
|