@pyreon/zero 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/server.js CHANGED
@@ -1,48 +1,23 @@
1
- import { t as __exportAll } from "./rolldown-runtime-CjeV3_4I.js";
2
- import { i as matchApiRoute, n as createApiMiddleware, r as generateApiRouteModule } from "./api-routes-CMsLztoj.js";
3
- import { a as generateRouteModuleFromRoutes, c as scanRouteFilesWithExports, i as generateRouteModule, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles, t as filePathToUrlPath } from "./fs-router-Bacdhsq-.js";
4
- import { Fragment, createContext, h } from "@pyreon/core";
5
- import { HeadProvider } from "@pyreon/head";
6
- import { RouterProvider, RouterView, createRouter, getRedirectInfo } from "@pyreon/router";
1
+ import { a as generateRouteModuleFromRoutes, c as scanRouteFilesWithExports, i as generateRouteModule, l as __exportAll, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles, t as filePathToUrlPath } from "./_chunks/fs-router-DvBlRzmP.js";
2
+ import { createLocaleContext, detectLocaleFromHeader, expandRoutesForLocales, i18nRouting } from "./i18n-routing.js";
3
+ import { t as createApp } from "./_chunks/app-BbPT0Y5M.js";
4
+ import { createApiMiddleware, generateApiRouteModule, matchApiRoute } from "./api-routes.js";
5
+ import { defineConfig, resolveConfig } from "./config.js";
6
+ import { compose, getContext } from "./middleware.js";
7
+ import { faviconLinks, faviconPlugin } from "./favicon.js";
8
+ import { generateRobots, generateSitemap, jsonLd, seoMiddleware, seoPlugin } from "./seo.js";
9
+ import { ogImagePath, ogImagePlugin } from "./og-image.js";
10
+ import { aiPlugin, generateLlmsFullTxt, generateLlmsTxt, inferJsonLd } from "./ai.js";
11
+ import { h } from "@pyreon/core";
12
+ import { getRedirectInfo } from "@pyreon/router";
7
13
  import { createHandler } from "@pyreon/server";
8
14
  import { renderToString } from "@pyreon/runtime-server";
9
- import { existsSync, readFileSync, readdirSync } from "node:fs";
15
+ import { existsSync, readdirSync } from "node:fs";
10
16
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
11
17
  import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
12
18
  import { Readable } from "node:stream";
13
- import { signal } from "@pyreon/reactivity";
14
19
  import { pathToFileURL } from "node:url";
15
20
 
16
- //#region src/app.ts
17
- /**
18
- * Create a full Zero app — assembles router, head provider, and root layout.
19
- *
20
- * Used internally by entry-server and entry-client.
21
- */
22
- function createApp(options) {
23
- const router = createRouter({
24
- routes: options.routes,
25
- mode: options.routerMode ?? "history",
26
- ...options.url ? { url: options.url } : {},
27
- ...options.base && options.base !== "/" ? { base: options.base } : {},
28
- scrollBehavior: "top"
29
- });
30
- const hasLayoutInRoutes = options.layout !== void 0 && options.routes.some((r) => r.component === options.layout);
31
- if (hasLayoutInRoutes && process.env.NODE_ENV !== "production") console.warn("[Pyreon] `createApp({ layout })` was passed a component that is ALSO a parent route in the matched chain (likely an fs-router `_layout.tsx`). The explicit `layout` option is being ignored to prevent double-mount. Remove the `layout` argument from `createApp`/`startClient` — the fs-router-emitted route handles it.");
32
- const Layout = hasLayoutInRoutes ? DefaultLayout : options.layout ?? DefaultLayout;
33
- function App() {
34
- return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
35
- }
36
- return {
37
- App,
38
- router
39
- };
40
- }
41
- function DefaultLayout(props) {
42
- return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
43
- }
44
-
45
- //#endregion
46
21
  //#region src/not-found.ts
47
22
  const DEFAULT_404_BODY = "<h1>404 — Not Found</h1><p>The page you requested does not exist.</p>";
48
23
  /**
@@ -180,111 +155,105 @@ function flattenRoutePatterns$1(routes, prefix = "") {
180
155
  }
181
156
 
182
157
  //#endregion
183
- //#region src/config.ts
158
+ //#region src/isr.ts
159
+ const __DEV__$1 = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
160
+ const _countSink$1 = globalThis;
184
161
  /**
185
- * Define a Zero configuration.
186
- * Used in `zero.config.ts` at the project root.
187
- *
188
- * @example
189
- * import { defineConfig } from "@pyreon/zero/config"
162
+ * The default in-memory ISR store: a `Map` with insertion-order LRU
163
+ * eviction, capped at `maxEntries` (default `1000`). Drop in as
164
+ * `config.store` if you want to tweak the cap or wrap the store with
165
+ * instrumentation; pass a different `ISRStore` impl for Redis / KV /
166
+ * etc. backings.
190
167
  *
191
- * export default defineConfig({
192
- * mode: "ssr",
193
- * ssr: { mode: "stream" },
194
- * port: 3000,
195
- * })
168
+ * `get` does the LRU bump (re-inserts the touched entry at the
169
+ * newest position) so hot paths survive eviction even when the cap is
170
+ * small. Without that, `Map.get(...)` wouldn't update ordering and
171
+ * frequently-read entries could be evicted by occasional writes.
196
172
  */
197
- function defineConfig(config) {
198
- return config;
199
- }
200
- /** Merge user config with defaults. */
201
- function resolveConfig(userConfig = {}) {
173
+ function createMemoryStore(opts = {}) {
174
+ const cache = /* @__PURE__ */ new Map();
175
+ const maxEntries = Math.max(1, opts.maxEntries ?? 1e3);
202
176
  return {
203
- mode: "ssr",
204
- base: "/",
205
- port: 3e3,
206
- adapter: "node",
207
- ...userConfig,
208
- ssr: {
209
- mode: "string",
210
- ...userConfig.ssr
177
+ get(key) {
178
+ const entry = cache.get(key);
179
+ if (entry !== void 0) {
180
+ cache.delete(key);
181
+ cache.set(key, entry);
182
+ }
183
+ return entry;
184
+ },
185
+ set(key, entry) {
186
+ if (cache.has(key)) cache.delete(key);
187
+ cache.set(key, entry);
188
+ while (cache.size > maxEntries) {
189
+ const oldest = cache.keys().next().value;
190
+ if (oldest === void 0) break;
191
+ cache.delete(oldest);
192
+ }
193
+ },
194
+ delete(key) {
195
+ cache.delete(key);
196
+ },
197
+ clear() {
198
+ cache.clear();
211
199
  }
212
200
  };
213
201
  }
214
-
215
- //#endregion
216
- //#region src/isr.ts
217
- /**
218
- * In-memory ISR cache with stale-while-revalidate semantics.
219
- *
220
- * Wraps an SSR handler and caches responses per URL path.
221
- * Serves stale content immediately while revalidating in the background.
222
- *
223
- * Bounded by `config.maxEntries` (default: 1000) with LRU eviction. The
224
- * `Map` preserves insertion order, so re-inserting an entry on every
225
- * serve (touching it) keeps the LRU order correct. Without the cap,
226
- * unbounded URL spaces like `/user/:id` would grow cache memory without
227
- * limit over the server's lifetime — a real leak in long-running
228
- * deployments.
229
- */
230
202
  function createISRHandler(handler, config) {
231
- const cache = /* @__PURE__ */ new Map();
203
+ const store = config.store ?? createMemoryStore(config.maxEntries !== void 0 ? { maxEntries: config.maxEntries } : {});
232
204
  const revalidating = /* @__PURE__ */ new Set();
233
205
  const revalidateMs = config.revalidate * 1e3;
234
206
  const REVALIDATE_TIMEOUT_MS = Math.max(1, config.revalidateTimeoutMs ?? 3e4);
235
207
  function isCacheable(res) {
236
208
  return res.status >= 200 && res.status < 300 && !res.headers.has("set-cookie");
237
209
  }
238
- const maxEntries = Math.max(1, config.maxEntries ?? 1e3);
239
210
  const deriveKey = typeof config.cacheKey === "function" ? (req, _url) => config.cacheKey(req) : (_req, url) => url.pathname;
240
- function set(key, entry) {
241
- if (cache.has(key)) cache.delete(key);
242
- cache.set(key, entry);
243
- while (cache.size > maxEntries) {
244
- const oldest = cache.keys().next().value;
245
- if (oldest === void 0) break;
246
- cache.delete(oldest);
247
- }
248
- }
249
- function touch(key) {
250
- const entry = cache.get(key);
251
- if (entry !== void 0) {
252
- cache.delete(key);
253
- cache.set(key, entry);
254
- }
255
- return entry;
256
- }
257
211
  async function revalidate(url, originalReq) {
258
212
  const key = deriveKey(originalReq, url);
259
213
  if (revalidating.has(key)) return;
260
214
  revalidating.add(key);
215
+ let timeoutId;
261
216
  try {
262
- const req = new Request(url.href, {
217
+ let req;
218
+ if (typeof config.revalidateRequest === "function") {
219
+ const custom = config.revalidateRequest(originalReq);
220
+ if (custom === null) {
221
+ revalidating.delete(key);
222
+ return;
223
+ }
224
+ req = custom;
225
+ } else req = new Request(url.href, {
263
226
  method: "GET",
264
227
  headers: originalReq.headers
265
228
  });
266
- const res = await Promise.race([handler(req), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("[Pyreon ISR] revalidation timeout")), REVALIDATE_TIMEOUT_MS))]);
229
+ const res = await Promise.race([handler(req), new Promise((_, reject) => {
230
+ timeoutId = setTimeout(() => reject(/* @__PURE__ */ new Error("[Pyreon ISR] revalidation timeout")), REVALIDATE_TIMEOUT_MS);
231
+ })]);
267
232
  if (isCacheable(res)) {
268
233
  const html = await res.text();
269
234
  const headers = {};
270
235
  res.headers.forEach((v, k) => {
271
236
  headers[k] = v;
272
237
  });
273
- set(key, {
238
+ await store.set(key, {
274
239
  html,
275
240
  headers,
276
241
  timestamp: Date.now()
277
242
  });
278
243
  }
279
244
  } catch {} finally {
245
+ if (timeoutId !== void 0) {
246
+ clearTimeout(timeoutId);
247
+ if (__DEV__$1) _countSink$1.__pyreon_count__?.("isr.revalidate.timerClear");
248
+ }
280
249
  revalidating.delete(key);
281
250
  }
282
251
  }
283
- return async (req) => {
252
+ const fetch = async (req) => {
284
253
  if (req.method !== "GET") return handler(req);
285
254
  const url = new URL(req.url);
286
255
  const key = deriveKey(req, url);
287
- const entry = touch(key);
256
+ const entry = await store.get(key);
288
257
  if (entry) {
289
258
  const age = Date.now() - entry.timestamp;
290
259
  if (age > revalidateMs) revalidate(url, req);
@@ -312,7 +281,7 @@ function createISRHandler(handler, config) {
312
281
  "x-isr-cache": "BYPASS"
313
282
  }
314
283
  });
315
- set(key, {
284
+ await store.set(key, {
316
285
  html,
317
286
  headers,
318
287
  timestamp: Date.now()
@@ -326,6 +295,23 @@ function createISRHandler(handler, config) {
326
295
  }
327
296
  });
328
297
  };
298
+ const isrHandler = fetch;
299
+ isrHandler.revalidateNow = async (key) => {
300
+ const existed = await store.get(key) !== void 0;
301
+ let dropped = false;
302
+ if (existed && store.delete) {
303
+ await store.delete(key);
304
+ dropped = true;
305
+ }
306
+ revalidating.delete(key);
307
+ return { dropped };
308
+ };
309
+ isrHandler.revalidateAll = async () => {
310
+ if (!store.clear) throw new Error("[Pyreon ISR] revalidateAll() called against a store that does not implement `clear()`. The default in-memory store supports this; external stores (Redis/KV/etc.) must opt in by implementing `ISRStore.clear()`.");
311
+ await store.clear();
312
+ revalidating.clear();
313
+ };
314
+ return isrHandler;
329
315
  }
330
316
 
331
317
  //#endregion
@@ -465,6 +451,8 @@ function bunAdapter() {
465
451
  await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
466
452
  const port = options.config.port ?? 3e3;
467
453
  const serverEntry = `
454
+ import { normalize } from "node:path"
455
+
468
456
  const handler = (await import("./server/entry-server.js")).default
469
457
  const clientDir = new URL("./client/", import.meta.url).pathname
470
458
 
@@ -473,19 +461,45 @@ Bun.serve({
473
461
  async fetch(req) {
474
462
  const url = new URL(req.url)
475
463
 
476
- // Try static files first
464
+ // Try static files first (GET only).
465
+ //
466
+ // Path safety: decode percent-encoding, normalize \`..\` segments,
467
+ // then assert the resulting path doesn't escape the clientDir
468
+ // prefix. The previous implementation used \`Bun.resolveSync\`,
469
+ // which is MODULE resolution — it throws on any non-existent
470
+ // path, so it crashed every SSR route (URLs without a matching
471
+ // static file) with a 500 before the SSR handler ran.
472
+ // \`node:path.normalize\` is pure-string path arithmetic and
473
+ // doesn't touch the filesystem — safe for arbitrary input.
477
474
  if (req.method === "GET") {
478
- const filePath = clientDir + (url.pathname === "/" ? "index.html" : url.pathname)
479
- // Prevent path traversal — ensure resolved path stays within clientDir
480
- const resolved = Bun.resolveSync(filePath, ".")
481
- if (!resolved.startsWith(Bun.resolveSync(clientDir, "."))) {
475
+ let decoded
476
+ try {
477
+ decoded = decodeURIComponent(url.pathname)
478
+ } catch {
479
+ // Malformed %-encoding → reject (don't fall through to SSR
480
+ // with a corrupt URL).
481
+ return new Response("Bad Request", { status: 400 })
482
+ }
483
+ // Reject null bytes outright — no legitimate use in a URL,
484
+ // and they can confuse downstream filesystem code.
485
+ if (decoded.includes("\\0")) {
486
+ return new Response("Forbidden", { status: 403 })
487
+ }
488
+ const reqPath = decoded === "/" ? "/index.html" : decoded
489
+ // Prepend clientDir then normalize. If the normalized result
490
+ // no longer starts with clientDir, a \`..\` segment escaped —
491
+ // reject. Using string-startsWith with clientDir (which ends
492
+ // in "/") prevents the "/clientdir-evil/" sibling-prefix
493
+ // bypass.
494
+ const candidate = normalize(clientDir + reqPath)
495
+ if (!candidate.startsWith(clientDir)) {
482
496
  return new Response("Forbidden", { status: 403 })
483
497
  }
484
- const file = Bun.file(filePath)
498
+ const file = Bun.file(candidate)
485
499
  if (await file.exists()) {
486
500
  return new Response(file, {
487
501
  headers: {
488
- "cache-control": filePath.endsWith(".js") || filePath.endsWith(".css")
502
+ "cache-control": candidate.endsWith(".js") || candidate.endsWith(".css")
489
503
  ? "public, max-age=31536000, immutable"
490
504
  : "public, max-age=3600",
491
505
  },
@@ -594,20 +608,17 @@ import handler from "./_server/entry-server.js"
594
608
 
595
609
  export default {
596
610
  async fetch(request, env, ctx) {
597
- const url = new URL(request.url)
598
-
599
- // Let Cloudflare serve static assets (files with extensions)
600
- // This check is a fallback — Pages routes static files automatically
601
- const ext = url.pathname.split(".").pop()
602
- if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
603
- // Cloudflare Pages handles static assets automatically via its asset binding
604
- // Only reach here if the file doesn't exist — fall through to SSR
605
- }
606
-
607
- // SSR handler
608
611
  try {
609
612
  return await handler(request)
610
613
  } catch (err) {
614
+ // Surface the error to Cloudflare Tail logs so production
615
+ // crashes give real diagnostic info — pre-fix the catch
616
+ // swallowed \`err\` entirely and the operator saw only a
617
+ // bare "Internal Server Error" with no stack, no message,
618
+ // no path. Logging via \`console.error\` is the standard
619
+ // Workers logging surface (lands in \`wrangler tail\` + the
620
+ // Cloudflare dashboard log stream).
621
+ console.error("[Pyreon SSR] handler failed:", err)
611
622
  return new Response("Internal Server Error", { status: 500 })
612
623
  }
613
624
  },
@@ -709,6 +720,12 @@ export default async function(req, context) {
709
720
  try {
710
721
  return await handler(req)
711
722
  } catch (err) {
723
+ // Surface the error to Netlify Function logs so production
724
+ // crashes give real diagnostic info — pre-fix the catch
725
+ // swallowed \`err\` entirely and the operator saw only a
726
+ // bare "Internal Server Error". \`console.error\` lands in
727
+ // Netlify's function runtime logs panel + \`netlify functions:log\`.
728
+ console.error("[Pyreon SSR] handler failed:", err)
712
729
  return new Response("Internal Server Error", { status: 500 })
713
730
  }
714
731
  }
@@ -800,11 +817,11 @@ const MIME_TYPES = {
800
817
  const server = createServer(async (req, res) => {
801
818
  const url = new URL(req.url ?? "/", "http://localhost")
802
819
 
803
- // Try to serve static files first
820
+ // Try to serve static files first (GET only).
804
821
  if (req.method === "GET") {
805
822
  try {
806
823
  const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
807
- // Prevent path traversal — ensure resolved path stays within clientDir
824
+ // Prevent path traversal — ensure resolved path stays within clientDir.
808
825
  const { resolve } = await import("node:path")
809
826
  const resolved = resolve(filePath)
810
827
  if (!resolved.startsWith(resolve(clientDir))) {
@@ -813,7 +830,14 @@ const server = createServer(async (req, res) => {
813
830
  return
814
831
  }
815
832
  const ext = extname(filePath)
816
- if (ext && ext !== ".html") {
833
+ // Pre-fix shape was \`if (ext && ext !== ".html")\` which made the
834
+ // static branch silently refuse to serve .html files — INCLUDING
835
+ // the root \`/\` → \`index.html\` mapping the line above explicitly
836
+ // sets up. Result: GET / always fell through to SSR, even when an
837
+ // \`index.html\` shell existed in clientDir. Matches the bun
838
+ // adapter's behavior (which serves index.html at /) and the
839
+ // standard static + dynamic deployment pattern.
840
+ if (ext) {
817
841
  const data = await readFile(filePath)
818
842
  const mime = MIME_TYPES[ext] || "application/octet-stream"
819
843
  res.writeHead(200, {
@@ -828,7 +852,7 @@ const server = createServer(async (req, res) => {
828
852
  } catch {}
829
853
  }
830
854
 
831
- // Fall through to SSR handler
855
+ // Fall through to SSR handler.
832
856
  const headers = {}
833
857
  for (const [key, value] of Object.entries(req.headers)) {
834
858
  if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
@@ -840,13 +864,33 @@ const server = createServer(async (req, res) => {
840
864
  })
841
865
 
842
866
  const response = await handler(request)
843
- const body = await response.text()
844
867
 
845
868
  const responseHeaders = {}
846
869
  response.headers.forEach((v, k) => { responseHeaders[k] = v })
847
870
 
848
871
  res.writeHead(response.status, responseHeaders)
849
- res.end(body)
872
+
873
+ // Pipe the Response body stream directly to res instead of buffering
874
+ // the whole body via response.text(). For mode: 'stream' SSR (Suspense
875
+ // out-of-order streaming) the pre-fix \`await response.text()\` drained
876
+ // every Suspense chunk server-side and arrived at the client all at
877
+ // once at the end — silently defeating streaming. For mode: 'string'
878
+ // the body is a single chunk and this loop runs once with identical
879
+ // observable behaviour.
880
+ if (response.body) {
881
+ const reader = response.body.getReader()
882
+ try {
883
+ while (true) {
884
+ const { value, done } = await reader.read()
885
+ if (done) break
886
+ res.write(value)
887
+ }
888
+ } finally {
889
+ res.end()
890
+ }
891
+ } else {
892
+ res.end()
893
+ }
850
894
  })
851
895
 
852
896
  server.listen(${port}, () => {
@@ -943,9 +987,15 @@ function vercelAdapter() {
943
987
  await cp(options.clientOutDir, staticDir, { recursive: true });
944
988
  await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
945
989
  const funcEntry = `
946
- export default async function handler(req) {
947
- const handler = (await import("./entry-server.js")).default
948
- return handler(req)
990
+ import handler from "./entry-server.js"
991
+
992
+ export default async function vercelHandler(req) {
993
+ try {
994
+ return await handler(req)
995
+ } catch (err) {
996
+ console.error("[Pyreon SSR] handler failed:", err)
997
+ return new Response("Internal Server Error", { status: 500 })
998
+ }
949
999
  }
950
1000
  `.trimStart();
951
1001
  await writeFile(join(funcDir, "index.js"), funcEntry);
@@ -1022,57 +1072,6 @@ function resolveAdapter(config) {
1022
1072
  }
1023
1073
  }
1024
1074
 
1025
- //#endregion
1026
- //#region src/middleware.ts
1027
- /**
1028
- * Compose multiple middleware into a single middleware function.
1029
- * Middleware runs sequentially — if any returns a Response, the chain stops.
1030
- *
1031
- * @example
1032
- * import { compose } from "@pyreon/zero/middleware"
1033
- * import { corsMiddleware } from "@pyreon/zero/cors"
1034
- * import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
1035
- *
1036
- * const combined = compose(
1037
- * corsMiddleware({ origin: "*" }),
1038
- * rateLimitMiddleware({ max: 100 }),
1039
- * cacheMiddleware(),
1040
- * )
1041
- */
1042
- function compose(...middlewares) {
1043
- return async (ctx) => {
1044
- for (const mw of middlewares) {
1045
- const result = await mw(ctx);
1046
- if (result instanceof Response) return result;
1047
- }
1048
- };
1049
- }
1050
- const ZERO_CTX_KEY = "__zeroCtx";
1051
- /**
1052
- * Get the shared Zero context from a middleware context.
1053
- * Creates one if it doesn't exist. Middleware can use this to
1054
- * pass data to downstream middleware without polluting `ctx.locals`.
1055
- *
1056
- * @example
1057
- * const authMiddleware: Middleware = (ctx) => {
1058
- * const zctx = getContext(ctx)
1059
- * zctx.userId = "user_123"
1060
- * }
1061
- *
1062
- * const loggingMiddleware: Middleware = (ctx) => {
1063
- * const zctx = getContext(ctx)
1064
- * console.log("User:", zctx.userId)
1065
- * }
1066
- */
1067
- function getContext(ctx) {
1068
- let zctx = ctx.locals[ZERO_CTX_KEY];
1069
- if (!zctx) {
1070
- zctx = {};
1071
- ctx.locals[ZERO_CTX_KEY] = zctx;
1072
- }
1073
- return zctx;
1074
- }
1075
-
1076
1075
  //#endregion
1077
1076
  //#region src/error-overlay.ts
1078
1077
  /**
@@ -1180,245 +1179,6 @@ function formatStack(stack) {
1180
1179
  }).join("\n");
1181
1180
  }
1182
1181
 
1183
- //#endregion
1184
- //#region src/i18n-routing.ts
1185
- /**
1186
- * Detect preferred locale from Accept-Language header.
1187
- */
1188
- function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
1189
- if (!acceptLanguage) return defaultLocale;
1190
- const preferred = acceptLanguage.split(",").map((part) => {
1191
- const [lang, q] = part.trim().split(";q=");
1192
- return {
1193
- lang: lang?.split("-")[0]?.toLowerCase() ?? "",
1194
- quality: q ? Number.parseFloat(q) : 1
1195
- };
1196
- }).sort((a, b) => b.quality - a.quality);
1197
- for (const { lang } of preferred) if (locales.includes(lang)) return lang;
1198
- return defaultLocale;
1199
- }
1200
- /**
1201
- * Extract locale from a URL path.
1202
- * Returns { locale, pathWithoutLocale }.
1203
- */
1204
- function extractLocaleFromPath(path, locales, defaultLocale) {
1205
- const segments = path.split("/").filter(Boolean);
1206
- const firstSegment = segments[0]?.toLowerCase();
1207
- if (firstSegment && locales.includes(firstSegment)) return {
1208
- locale: firstSegment,
1209
- pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
1210
- };
1211
- return {
1212
- locale: defaultLocale,
1213
- pathWithoutLocale: path
1214
- };
1215
- }
1216
- /**
1217
- * Build a localized path.
1218
- */
1219
- function buildLocalePath(path, locale, defaultLocale, strategy) {
1220
- const clean = path === "/" ? "" : path;
1221
- if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
1222
- return `/${locale}${clean}`;
1223
- }
1224
- /**
1225
- * Fan a `FileRoute[]` into per-locale duplicates so the file-system router
1226
- * knows about every localized URL pattern at build time. PR H — was the
1227
- * missing half of the i18n story before this PR (the `i18nRouting()` Vite
1228
- * plugin only handled request-time locale detection; routes themselves
1229
- * were never duplicated, so static-host SSG outputs and SSR matching had
1230
- * no `/de/about` / `/cs/about` records to render against).
1231
- *
1232
- * Strategy semantics:
1233
- *
1234
- * - **`prefix-except-default`** (default): the default locale's routes
1235
- * keep their original `urlPath` unchanged (`/about` stays `/about`); all
1236
- * non-default locales get a prefix (`/de/about`, `/cs/about`). Best for
1237
- * SEO-on-default-locale apps — search engines see canonical URLs at
1238
- * `/about` while non-default speakers get explicit prefixes.
1239
- *
1240
- * - **`prefix`**: every locale gets its own prefix, including the default
1241
- * (`/en/about`, `/de/about`, `/cs/about`). Root `/` becomes `/en` /
1242
- * `/de` / `/cs`. Better when no locale is "primary" — every URL
1243
- * self-identifies its locale.
1244
- *
1245
- * Layouts, error boundaries, loading components, and 404 pages duplicate
1246
- * along with their pages — same source file (same `filePath`), new
1247
- * locale-prefixed `urlPath` / `dirPath` / `depth`. The route tree built
1248
- * from the expanded array therefore has one fully-formed subtree per
1249
- * locale, so layout matching, dynamic params (`[id]` → `:id`), and
1250
- * catch-all routes (`[...slug]` → `:slug*`) all compose naturally with
1251
- * the locale prefix — no special cases.
1252
- *
1253
- * `getStaticPaths` composition (for SSG): each duplicate route inherits
1254
- * the same `exports.getStaticPaths`. The SSG plugin's `expandUrlPattern`
1255
- * step then expands `/blog/[slug]` × `[en, de]` × `getStaticPaths()
1256
- * → ['a', 'b']` into `/blog/a`, `/blog/b`, `/de/blog/a`, `/de/blog/b`
1257
- * (or all six prefixed forms under `'prefix'` strategy). Cardinality
1258
- * compounds, which is by design — `ssg.concurrency` (PR D) limits
1259
- * in-flight renders independent of route count.
1260
- *
1261
- * No-op when `config.locales` is empty or contains only the default
1262
- * locale (prefix-except-default strategy with no other locales) — returns
1263
- * the input array unchanged. Always return a fresh array on duplication
1264
- * so callers don't accidentally mutate cached input.
1265
- *
1266
- * Reference: the helper is called from `vite-plugin.ts`'s virtual route
1267
- * module load AND `ssg-plugin.ts`'s pre-render path expansion. Tested in
1268
- * isolation — duplication is a pure transform on FileRoute[] with no
1269
- * filesystem or network side effects.
1270
- */
1271
- function expandRoutesForLocales(routes, config) {
1272
- const strategy = config.strategy ?? "prefix-except-default";
1273
- const { locales, defaultLocale } = config;
1274
- if (locales.length === 0) return routes;
1275
- for (const locale of locales) validateLocale(locale);
1276
- validateLocale(defaultLocale);
1277
- if (strategy === "prefix-except-default" && locales.length === 1 && locales[0] === defaultLocale) return routes;
1278
- const expanded = [];
1279
- for (const route of routes) for (const locale of locales) {
1280
- if (strategy === "prefix-except-default" && locale === defaultLocale) {
1281
- expanded.push(route);
1282
- continue;
1283
- }
1284
- if (strategy === "prefix-except-default" && route.isLayout && route.urlPath === "/") continue;
1285
- const newUrlPath = prefixUrlPath(route.urlPath, locale);
1286
- const newDirPath = route.dirPath === "" ? locale : `${locale}/${route.dirPath}`;
1287
- const newDepth = newUrlPath === "/" ? 0 : newUrlPath.split("/").filter(Boolean).length;
1288
- expanded.push({
1289
- ...route,
1290
- urlPath: newUrlPath,
1291
- dirPath: newDirPath,
1292
- depth: newDepth
1293
- });
1294
- }
1295
- return expanded;
1296
- }
1297
- /**
1298
- * Prepend `/locale` to a URL pattern. Handles three shapes:
1299
- * `/` → `/de`
1300
- * `/about` → `/de/about`
1301
- * `/users/:id` / `/blog/:slug*` → `/de/users/:id` / `/de/blog/:slug*`
1302
- *
1303
- * Internal helper to `expandRoutesForLocales`; not exported because the
1304
- * public surface for path-building is `buildLocalePath` (which strips
1305
- * existing locale prefixes — different semantics).
1306
- */
1307
- function prefixUrlPath(urlPath, locale) {
1308
- if (urlPath === "/") return `/${locale}`;
1309
- return `/${locale}${urlPath}`;
1310
- }
1311
- /**
1312
- * Validate a locale string (PR L2).
1313
- *
1314
- * The locale drives both URL pattern emission AND filesystem writes
1315
- * (see `expandRoutesForLocales` for full rationale). Reject input that
1316
- * would either:
1317
- * - break path-traversal boundaries (`..`, `/`, `\`)
1318
- * - produce invalid URL segments (whitespace, NUL)
1319
- * - create hidden-file artifacts (`.` leading)
1320
- * - silently kill the app (empty string)
1321
- *
1322
- * Throws with an actionable `[Pyreon]` error message. Called per-locale
1323
- * by `expandRoutesForLocales` after the empty-locales no-op guard.
1324
- *
1325
- * @internal — exported for unit testing.
1326
- */
1327
- function validateLocale(locale) {
1328
- if (typeof locale !== "string" || locale === "") throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Locales must be non-empty strings (e.g. "en", "de", "en-US").`);
1329
- if (locale.trim() !== locale) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading or trailing whitespace not allowed.`);
1330
- if (locale.includes("/") || locale.includes("\\")) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path separators ("/", "\\\\") not allowed — they would break URL emission and could write outside the dist directory.`);
1331
- if (locale === ".." || locale === ".") throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path-traversal segments not allowed.`);
1332
- if (locale.startsWith(".")) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading dot not allowed — it would create a hidden-file directory (\`dist/.${locale.slice(1)}/\`) invisible to most file listings.`);
1333
- if (locale.includes("\0")) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. NUL characters not allowed.`);
1334
- }
1335
- /**
1336
- * Create a LocaleContext for use in components and loaders.
1337
- */
1338
- function createLocaleContext(locale, path, config) {
1339
- const strategy = config.strategy ?? "prefix-except-default";
1340
- return {
1341
- locale,
1342
- locales: config.locales,
1343
- defaultLocale: config.defaultLocale,
1344
- localePath(targetPath, targetLocale) {
1345
- return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
1346
- },
1347
- alternates() {
1348
- const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
1349
- return config.locales.map((loc) => ({
1350
- locale: loc,
1351
- url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
1352
- }));
1353
- }
1354
- };
1355
- }
1356
- /**
1357
- * I18n routing middleware for Zero's server.
1358
- *
1359
- * - Detects locale from URL prefix or Accept-Language header
1360
- * - Redirects root to preferred locale (when detectLocale is true)
1361
- * - Sets locale context for loaders and components
1362
- *
1363
- * @example
1364
- * ```ts
1365
- * // zero.config.ts
1366
- * import { i18nRouting } from "@pyreon/zero"
1367
- *
1368
- * export default defineConfig({
1369
- * plugins: [
1370
- * i18nRouting({
1371
- * locales: ["en", "de", "cs"],
1372
- * defaultLocale: "en",
1373
- * }),
1374
- * ],
1375
- * })
1376
- * ```
1377
- */
1378
- function i18nRouting(config) {
1379
- const strategy = config.strategy ?? "prefix-except-default";
1380
- const detectEnabled = config.detectLocale !== false;
1381
- const cookieName = config.cookieName ?? "locale";
1382
- return {
1383
- name: "pyreon-zero-i18n-routing",
1384
- configResolved() {},
1385
- configureServer(server) {
1386
- server.middlewares.use((req, res, next) => {
1387
- const url = req.url ?? "/";
1388
- if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
1389
- const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
1390
- if (detectEnabled && url === "/") {
1391
- const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
1392
- const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
1393
- const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
1394
- if (strategy === "prefix" || preferred !== config.defaultLocale) {
1395
- res.writeHead(302, { Location: `/${preferred}/` });
1396
- res.end();
1397
- return;
1398
- }
1399
- }
1400
- req.__locale = locale;
1401
- req.__localeContext = createLocaleContext(locale, url, config);
1402
- localeSignal.set(locale);
1403
- next();
1404
- });
1405
- }
1406
- };
1407
- }
1408
- function parseCookies(header) {
1409
- if (!header) return {};
1410
- const result = {};
1411
- for (const pair of header.split(";")) {
1412
- const [key, value] = pair.trim().split("=");
1413
- if (key && value) result[key] = decodeURIComponent(value);
1414
- }
1415
- return result;
1416
- }
1417
- /** @internal Context for the current locale. */
1418
- const LocaleCtx = createContext("en");
1419
- /** Current locale signal — set by the server middleware or client-side detection. */
1420
- const localeSignal = signal("en");
1421
-
1422
1182
  //#endregion
1423
1183
  //#region src/ssg-plugin.ts
1424
1184
  /**
@@ -2192,8 +1952,11 @@ function ssgPlugin(userConfig = {}) {
2192
1952
  const start = Date.now();
2193
1953
  const renderOne = async (p) => {
2194
1954
  if (__DEV__) _countSink.__pyreon_count__?.("ssg.pathRender");
1955
+ let timeoutId;
2195
1956
  try {
2196
- const result = await Promise.race([renderPath(p), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`Prerender timeout for "${p}" (30s)`)), 3e4))]);
1957
+ const result = await Promise.race([renderPath(p), new Promise((_, reject) => {
1958
+ timeoutId = setTimeout(() => reject(/* @__PURE__ */ new Error(`Prerender timeout for "${p}" (30s)`)), 3e4);
1959
+ })]);
2197
1960
  if (result.kind === "redirect") {
2198
1961
  if (__DEV__) _countSink.__pyreon_count__?.("ssg.pathRedirect");
2199
1962
  redirects.push({
@@ -2256,6 +2019,8 @@ function ssgPlugin(userConfig = {}) {
2256
2019
  error: callbackError
2257
2020
  });
2258
2021
  }
2022
+ } finally {
2023
+ if (timeoutId !== void 0) clearTimeout(timeoutId);
2259
2024
  }
2260
2025
  };
2261
2026
  const concurrency = Math.max(1, config.ssg?.concurrency ?? 4);
@@ -2294,21 +2059,28 @@ function ssgPlugin(userConfig = {}) {
2294
2059
  let emitted404Count = 0;
2295
2060
  if (config.ssg?.emit404 !== false && handlerMod.__renderNotFound) {
2296
2061
  const localeEntries = handlerMod.__notFoundComponentsByLocale instanceof Map ? [...handlerMod.__notFoundComponentsByLocale.keys()] : handlerMod.__notFoundComponent ? [null] : [];
2297
- for (const locale of localeEntries) try {
2298
- const result = await Promise.race([handlerMod.__renderNotFound(locale), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`Prerender timeout for ${locale == null ? "404" : `${locale}/404`} (30s)`)), 3e4))]);
2299
- if (result) {
2300
- const html = injectIntoTemplate(template, result);
2301
- const filePath = locale == null ? join(distDir, "404.html") : join(distDir, locale, "404.html");
2302
- if (locale != null) await mkdir(join(distDir, locale), { recursive: true });
2303
- await writeFile(filePath, html, "utf-8");
2304
- emitted404Count++;
2305
- if (__DEV__) _countSink.__pyreon_count__?.("ssg.404Emit");
2062
+ for (const locale of localeEntries) {
2063
+ let timeoutId;
2064
+ try {
2065
+ const result = await Promise.race([handlerMod.__renderNotFound(locale), new Promise((_, reject) => {
2066
+ timeoutId = setTimeout(() => reject(/* @__PURE__ */ new Error(`Prerender timeout for ${locale == null ? "404" : `${locale}/404`} (30s)`)), 3e4);
2067
+ })]);
2068
+ if (result) {
2069
+ const html = injectIntoTemplate(template, result);
2070
+ const filePath = locale == null ? join(distDir, "404.html") : join(distDir, locale, "404.html");
2071
+ if (locale != null) await mkdir(join(distDir, locale), { recursive: true });
2072
+ await writeFile(filePath, html, "utf-8");
2073
+ emitted404Count++;
2074
+ if (__DEV__) _countSink.__pyreon_count__?.("ssg.404Emit");
2075
+ }
2076
+ } catch (error) {
2077
+ errors.push({
2078
+ path: locale == null ? "404.html" : `${locale}/404.html`,
2079
+ error
2080
+ });
2081
+ } finally {
2082
+ if (timeoutId !== void 0) clearTimeout(timeoutId);
2306
2083
  }
2307
- } catch (error) {
2308
- errors.push({
2309
- path: locale == null ? "404.html" : `${locale}/404.html`,
2310
- error
2311
- });
2312
2084
  }
2313
2085
  }
2314
2086
  if (redirects.length > 0 && config.ssg?.emitRedirects !== false) {
@@ -2747,761 +2519,78 @@ function flattenRoutePatterns(routes) {
2747
2519
  }
2748
2520
 
2749
2521
  //#endregion
2750
- //#region src/favicon.ts
2522
+ //#region src/icons-plugin.ts
2523
+ /** Set key → exported component name. `ui` → `UiIcon`, `brand-marks` → `BrandMarksIcon`. */
2524
+ function componentNameFromSetKey(key) {
2525
+ const safe = key.split(/[-_/\s]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("").replace(/[^A-Za-z0-9_$]/g, "");
2526
+ return `${/^[A-Za-z_$]/.test(safe) ? safe : `Set${safe}`}Icon`;
2527
+ }
2528
+ /** Filename stem → registry key. `Check-Circle.svg` → `check-circle`. */
2529
+ function iconNameFromFile(file) {
2530
+ return basename(file, ".svg").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
2531
+ }
2532
+ /** Registry key → safe JS import binding. `check-circle` → `checkCircle`. */
2533
+ function bindingFromName(name) {
2534
+ const safe = name.replace(/[-/](.)/g, (_, c) => c.toUpperCase()).replace(/[^A-Za-z0-9_$]/g, "_");
2535
+ return /^[A-Za-z_$]/.test(safe) ? safe : `_${safe}`;
2536
+ }
2537
+ /** List the `*.svg` filenames in `dir` (sorted, stable). Empty if missing. */
2538
+ function scanIconDir(dir) {
2539
+ if (!existsSync(dir)) return [];
2540
+ return readdirSync(dir).filter((f) => f.toLowerCase().endsWith(".svg")).sort();
2541
+ }
2751
2542
  /**
2752
- * Stable content hash (FNV-1a, 32-bit) of the favicon source file(s),
2753
- * rendered as a `?v=<hex>` cache-bust query for the injected `<head>`
2754
- * links. Browsers cache favicons extremely aggressively (often per-
2755
- * session / effectively forever), so with a stable URL a changed icon
2756
- * is never re-fetched by returning visitors. Same source bytes →
2757
- * identical query (no needless cache churn); changed bytes → new query
2758
- * → browser re-downloads. Falls back to `''` (no query, prior
2759
- * behaviour) if a source can't be read — never break the build over a
2760
- * cache-bust nicety. NOTE: this versions everything referenced via
2761
- * `<link>` (svg/png/apple-touch/manifest). The bare `/favicon.ico`
2762
- * convention request (browsers fetch it with no link tag) and the
2763
- * `site.webmanifest`'s internal icon entries keep stable URLs — those
2764
- * rely on host cache headers / are re-resolved on PWA (re)install.
2543
+ * Render the generated `.tsx` source for a set of svg filenames. Pure
2544
+ * unit-tested directly; the plugin only adds fs + watch around it.
2765
2545
  */
2766
- function faviconVersionQuery(paths) {
2767
- let h = 2166136261;
2768
- let any = false;
2769
- for (const p of paths) {
2770
- let buf;
2771
- try {
2772
- buf = readFileSync(p);
2773
- } catch {
2774
- continue;
2775
- }
2776
- any = true;
2777
- for (let i = 0; i < buf.length; i++) {
2778
- h ^= buf[i];
2779
- h = Math.imul(h, 16777619);
2780
- }
2546
+ function generateIconSetSource(files, opts) {
2547
+ const query = opts.mode === "image" ? "" : "?raw";
2548
+ const seen = /* @__PURE__ */ new Map();
2549
+ const entries = [];
2550
+ for (const file of files) {
2551
+ const key = iconNameFromFile(file);
2552
+ let binding = bindingFromName(key);
2553
+ while (seen.has(binding)) binding = `${binding}_`;
2554
+ seen.set(binding, key);
2555
+ entries.push({
2556
+ key,
2557
+ binding,
2558
+ file
2559
+ });
2781
2560
  }
2782
- if (!any) return "";
2783
- return `?v=${(h >>> 0).toString(16).padStart(8, "0")}`;
2784
- }
2785
- let sharpWarned$1 = false;
2786
- function warnSharpMissing$1() {
2787
- if (sharpWarned$1) return;
2788
- sharpWarned$1 = true;
2789
- console.warn("\n[Pyreon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n");
2561
+ const header = [
2562
+ "// AUTO-GENERATED by @pyreon/zero iconsPlugin — do not edit.",
2563
+ `// Add / remove .svg files in ${opts.importDir} and this regenerates.`,
2564
+ "/// <reference types=\"vite/client\" />",
2565
+ "import { createNamedIcon } from '@pyreon/zero'"
2566
+ ];
2567
+ const imports = entries.map((e) => `import ${e.binding} from '${opts.importDir}/${e.file}${query}'`);
2568
+ const registry = [
2569
+ "const REGISTRY = {",
2570
+ ...entries.map((e) => ` ${JSON.stringify(e.key)}: ${e.binding},`),
2571
+ "} as const"
2572
+ ];
2573
+ const tail = [
2574
+ "export type IconName = keyof typeof REGISTRY",
2575
+ `export const Icon = createNamedIcon(REGISTRY${opts.mode === "image" ? ", { mode: 'image' }" : ""})`,
2576
+ ""
2577
+ ];
2578
+ return [
2579
+ ...header,
2580
+ "",
2581
+ ...imports,
2582
+ "",
2583
+ ...registry,
2584
+ "",
2585
+ ...tail
2586
+ ].join("\n");
2790
2587
  }
2791
- const SIZES = [
2792
- {
2793
- size: 16,
2794
- name: "favicon-16x16.png"
2795
- },
2796
- {
2797
- size: 32,
2798
- name: "favicon-32x32.png"
2799
- },
2800
- {
2801
- size: 180,
2802
- name: "apple-touch-icon.png"
2803
- },
2804
- {
2805
- size: 192,
2806
- name: "icon-192.png"
2807
- },
2808
- {
2809
- size: 512,
2810
- name: "icon-512.png"
2811
- }
2812
- ];
2813
2588
  /**
2814
- * Favicon generation Vite plugin.
2815
- *
2816
- * Generates all required favicon formats at build time from a single source.
2817
- * In dev mode, serves the source directly.
2818
- *
2819
- * @example
2820
- * ```ts
2821
- * // vite.config.ts
2822
- * import { faviconPlugin } from "@pyreon/zero"
2823
- *
2824
- * export default {
2825
- * plugins: [faviconPlugin({ source: "./src/assets/icon.svg" })],
2826
- * }
2827
- * ```
2828
- */
2829
- function faviconPlugin(config) {
2830
- const themeColor = config.themeColor ?? "#ffffff";
2831
- const backgroundColor = config.backgroundColor ?? "#ffffff";
2832
- const generateManifest = config.manifest !== false;
2833
- let root = "";
2834
- let isBuild = false;
2835
- let versionQuery = null;
2836
- function getVersionQuery() {
2837
- if (versionQuery === null) {
2838
- const paths = [join(root, config.source)];
2839
- if (config.darkSource) paths.push(join(root, config.darkSource));
2840
- versionQuery = faviconVersionQuery(paths);
2841
- }
2842
- return versionQuery;
2843
- }
2844
- return {
2845
- name: "pyreon-zero-favicon",
2846
- enforce: "pre",
2847
- configResolved(resolvedConfig) {
2848
- root = resolvedConfig.root;
2849
- isBuild = resolvedConfig.command === "build";
2850
- },
2851
- configureServer(server) {
2852
- const sourcePath = join(root, config.source);
2853
- const darkPath = config.darkSource ? join(root, config.darkSource) : null;
2854
- const devSourcePath = typeof config.devSource === "string" ? join(root, config.devSource) : null;
2855
- const autoDevBadge = config.devSource === true;
2856
- const devCache = /* @__PURE__ */ new Map();
2857
- /** Resolve source path for a request — handles dark variants and dev badge. */
2858
- function resolveSourceForDev(baseName, defaultSource) {
2859
- if (darkPath && baseName.includes("-dark-")) return darkPath;
2860
- if (baseName.includes("-light-")) return defaultSource;
2861
- return defaultSource;
2862
- }
2863
- server.middlewares.use(async (req, res, next) => {
2864
- const url = (req.url ?? "").split("?")[0];
2865
- const localeSource = resolveLocaleSource(url, config, root);
2866
- const svgUrl = localeSource ? localeSource.url : url;
2867
- const svgPath = localeSource ? localeSource.sourcePath : sourcePath;
2868
- const isSvgSource = localeSource ? localeSource.source.endsWith(".svg") : config.source.endsWith(".svg");
2869
- if (isSvgSource && (svgUrl.endsWith("/favicon-light.svg") || svgUrl.endsWith("/favicon-dark.svg"))) {
2870
- const isDarkVariant = svgUrl.endsWith("/favicon-dark.svg");
2871
- const variantPath = isDarkVariant ? darkPath ?? svgPath : svgPath;
2872
- try {
2873
- let content = await readFile(variantPath, "utf-8");
2874
- if (!isDarkVariant) {
2875
- if (autoDevBadge) content = addDevBadgeToSvg(content);
2876
- else if (devSourcePath && existsSync(devSourcePath)) content = await readFile(devSourcePath, "utf-8");
2877
- }
2878
- res.setHeader("Content-Type", "image/svg+xml");
2879
- res.end(content);
2880
- return;
2881
- } catch {}
2882
- }
2883
- if (svgUrl.endsWith("/favicon.svg") && isSvgSource) try {
2884
- let content = await readFile(svgPath, "utf-8");
2885
- if (autoDevBadge) content = addDevBadgeToSvg(content);
2886
- else if (devSourcePath && existsSync(devSourcePath)) content = await readFile(devSourcePath, "utf-8");
2887
- res.setHeader("Content-Type", "image/svg+xml");
2888
- res.end(content);
2889
- return;
2890
- } catch {}
2891
- const baseName = svgUrl.split("/").pop() ?? "";
2892
- const cleanName = baseName.replace(/-?(light|dark)-/, "-");
2893
- const sizeMatch = SIZES.find((s) => s.name === cleanName || baseName === s.name);
2894
- if (sizeMatch) {
2895
- const resolvedSource = resolveSourceForDev(baseName, svgPath);
2896
- const cacheKey = `${resolvedSource}:${sizeMatch.size}:${autoDevBadge}`;
2897
- let png = devCache.get(cacheKey);
2898
- if (!png) {
2899
- let result = await resizeToPng(resolvedSource, sizeMatch.size);
2900
- if (result && autoDevBadge) result = await addDevBadgeToPng(result, sizeMatch.size);
2901
- if (result) {
2902
- png = result;
2903
- devCache.set(cacheKey, result);
2904
- }
2905
- }
2906
- if (png) {
2907
- res.setHeader("Content-Type", "image/png");
2908
- res.setHeader("Cache-Control", "no-cache");
2909
- res.end(Buffer.from(png));
2910
- return;
2911
- }
2912
- }
2913
- if (baseName === "favicon.ico") {
2914
- const cacheKey = `ico:${svgPath}`;
2915
- let ico = devCache.get(cacheKey);
2916
- if (!ico) {
2917
- const result = await generateIco(svgPath);
2918
- if (result) {
2919
- ico = result;
2920
- devCache.set(cacheKey, result);
2921
- }
2922
- }
2923
- if (ico) {
2924
- res.setHeader("Content-Type", "image/x-icon");
2925
- res.setHeader("Cache-Control", "no-cache");
2926
- res.end(Buffer.from(ico));
2927
- return;
2928
- }
2929
- }
2930
- if (baseName === "site.webmanifest" && generateManifest) {
2931
- const prefix = localeSource ? `/${localeSource.locale}` : "";
2932
- const manifest = {
2933
- name: config.name ?? "App",
2934
- short_name: config.name ?? "App",
2935
- icons: [{
2936
- src: `${prefix}/icon-192.png`,
2937
- sizes: "192x192",
2938
- type: "image/png"
2939
- }, {
2940
- src: `${prefix}/icon-512.png`,
2941
- sizes: "512x512",
2942
- type: "image/png"
2943
- }],
2944
- theme_color: themeColor,
2945
- background_color: backgroundColor,
2946
- display: "standalone"
2947
- };
2948
- res.setHeader("Content-Type", "application/manifest+json");
2949
- res.end(JSON.stringify(manifest, null, 2));
2950
- return;
2951
- }
2952
- next();
2953
- });
2954
- },
2955
- transformIndexHtml() {
2956
- const isSvg = config.source.endsWith(".svg");
2957
- const hasDark = !!config.darkSource;
2958
- const tags = [];
2959
- if (isSvg && hasDark) tags.push({
2960
- tag: "link",
2961
- attrs: {
2962
- rel: "icon",
2963
- type: "image/svg+xml",
2964
- href: "/favicon-light.svg",
2965
- "data-favicon-theme": "light"
2966
- },
2967
- injectTo: "head"
2968
- }, {
2969
- tag: "link",
2970
- attrs: {
2971
- rel: "icon",
2972
- type: "image/svg+xml",
2973
- href: "/favicon-dark.svg",
2974
- "data-favicon-theme": "dark",
2975
- media: "not all"
2976
- },
2977
- injectTo: "head"
2978
- });
2979
- else if (isSvg) tags.push({
2980
- tag: "link",
2981
- attrs: {
2982
- rel: "icon",
2983
- type: "image/svg+xml",
2984
- href: "/favicon.svg"
2985
- },
2986
- injectTo: "head"
2987
- });
2988
- if (hasDark) {
2989
- const lightAttrs = { "data-favicon-theme": "light" };
2990
- const darkAttrs = {
2991
- "data-favicon-theme": "dark",
2992
- media: "not all"
2993
- };
2994
- tags.push({
2995
- tag: "link",
2996
- attrs: {
2997
- rel: "icon",
2998
- type: "image/png",
2999
- sizes: "32x32",
3000
- href: "/favicon-light-32x32.png",
3001
- ...lightAttrs
3002
- },
3003
- injectTo: "head"
3004
- }, {
3005
- tag: "link",
3006
- attrs: {
3007
- rel: "icon",
3008
- type: "image/png",
3009
- sizes: "32x32",
3010
- href: "/favicon-dark-32x32.png",
3011
- ...darkAttrs
3012
- },
3013
- injectTo: "head"
3014
- }, {
3015
- tag: "link",
3016
- attrs: {
3017
- rel: "icon",
3018
- type: "image/png",
3019
- sizes: "16x16",
3020
- href: "/favicon-light-16x16.png",
3021
- ...lightAttrs
3022
- },
3023
- injectTo: "head"
3024
- }, {
3025
- tag: "link",
3026
- attrs: {
3027
- rel: "icon",
3028
- type: "image/png",
3029
- sizes: "16x16",
3030
- href: "/favicon-dark-16x16.png",
3031
- ...darkAttrs
3032
- },
3033
- injectTo: "head"
3034
- }, {
3035
- tag: "link",
3036
- attrs: {
3037
- rel: "apple-touch-icon",
3038
- sizes: "180x180",
3039
- href: "/apple-touch-icon-light.png",
3040
- ...lightAttrs
3041
- },
3042
- injectTo: "head"
3043
- }, {
3044
- tag: "link",
3045
- attrs: {
3046
- rel: "apple-touch-icon",
3047
- sizes: "180x180",
3048
- href: "/apple-touch-icon-dark.png",
3049
- ...darkAttrs
3050
- },
3051
- injectTo: "head"
3052
- });
3053
- } else tags.push({
3054
- tag: "link",
3055
- attrs: {
3056
- rel: "icon",
3057
- type: "image/png",
3058
- sizes: "32x32",
3059
- href: "/favicon-32x32.png"
3060
- },
3061
- injectTo: "head"
3062
- }, {
3063
- tag: "link",
3064
- attrs: {
3065
- rel: "icon",
3066
- type: "image/png",
3067
- sizes: "16x16",
3068
- href: "/favicon-16x16.png"
3069
- },
3070
- injectTo: "head"
3071
- }, {
3072
- tag: "link",
3073
- attrs: {
3074
- rel: "apple-touch-icon",
3075
- sizes: "180x180",
3076
- href: "/apple-touch-icon.png"
3077
- },
3078
- injectTo: "head"
3079
- });
3080
- if (generateManifest) tags.push({
3081
- tag: "link",
3082
- attrs: {
3083
- rel: "manifest",
3084
- href: "/site.webmanifest"
3085
- },
3086
- injectTo: "head"
3087
- });
3088
- tags.push({
3089
- tag: "meta",
3090
- attrs: {
3091
- name: "theme-color",
3092
- content: themeColor
3093
- },
3094
- injectTo: "head"
3095
- });
3096
- if (hasDark) tags.push({
3097
- tag: "script",
3098
- attrs: {},
3099
- injectTo: "head",
3100
- 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){}})()`
3101
- });
3102
- const v = getVersionQuery();
3103
- if (v) {
3104
- for (const t of tags) if (t.tag === "link" && t.attrs.href) t.attrs.href += v;
3105
- }
3106
- return tags;
3107
- },
3108
- async generateBundle() {
3109
- if (!isBuild) return;
3110
- try {
3111
- await import("sharp");
3112
- } catch {
3113
- this.error(`[Pyreon] faviconPlugin: a favicon \`source\` is configured but \`sharp\` is not installed — NO favicons would be generated and the production build would silently ship none.
3114
- Fix: bun add -D sharp (or: npm i -D sharp)
3115
- Source: ${config.source}\nTo intentionally build without favicons, remove faviconPlugin() from your Vite plugins.`);
3116
- }
3117
- await generateFaviconSet.call(this, root, config.source, config.darkSource, "", config, themeColor, backgroundColor, generateManifest);
3118
- 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);
3119
- }
3120
- };
3121
- }
3122
- /**
3123
- * Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.
3124
- */
3125
- function wrapSvgWithDarkMode(lightSvg, darkSvg) {
3126
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${lightSvg.match(/viewBox="([^"]*)"/)?.[1] ?? "0 0 32 32"}">
3127
- <style>
3128
- :root { color-scheme: light dark; }
3129
- @media (prefers-color-scheme: dark) { .light { display: none; } }
3130
- @media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }
3131
- </style>
3132
- <g class="light">${stripSvgWrapper(lightSvg)}</g>
3133
- <g class="dark">${stripSvgWrapper(darkSvg)}</g>
3134
- </svg>`;
3135
- }
3136
- function stripSvgWrapper(svg) {
3137
- return svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "").trim();
3138
- }
3139
- /**
3140
- * Resolve the source path for a locale-prefixed favicon URL.
3141
- * Returns null if the URL is not locale-prefixed or locale has no override.
3142
- */
3143
- function resolveLocaleSource(url, config, rootDir) {
3144
- if (!config.locales) return null;
3145
- for (const [locale, localeConfig] of Object.entries(config.locales)) {
3146
- const prefix = `/${locale}/`;
3147
- if (url.startsWith(prefix)) return {
3148
- locale,
3149
- url,
3150
- source: localeConfig.source,
3151
- sourcePath: join(rootDir, localeConfig.source)
3152
- };
3153
- }
3154
- return null;
3155
- }
3156
- /**
3157
- * Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.
3158
- * Called once for base (prefix = '') and once per locale (prefix = '{locale}/').
3159
- */
3160
- async function generateFaviconSet(rootDir, source, darkSource, prefix, config, themeColor, backgroundColor, generateManifest) {
3161
- const sourcePath = join(rootDir, source);
3162
- if (!existsSync(sourcePath)) {
3163
- console.warn(`[Pyreon] Source not found: ${sourcePath}`);
3164
- return;
3165
- }
3166
- if (source.endsWith(".svg")) {
3167
- const svgContent = await readFile(sourcePath, "utf-8");
3168
- let finalSvg = svgContent;
3169
- if (darkSource) {
3170
- const darkPath = join(rootDir, darkSource);
3171
- if (existsSync(darkPath)) {
3172
- const darkSvg = await readFile(darkPath, "utf-8");
3173
- finalSvg = wrapSvgWithDarkMode(svgContent, darkSvg);
3174
- this.emitFile({
3175
- type: "asset",
3176
- fileName: `${prefix}favicon-light.svg`,
3177
- source: svgContent
3178
- });
3179
- this.emitFile({
3180
- type: "asset",
3181
- fileName: `${prefix}favicon-dark.svg`,
3182
- source: darkSvg
3183
- });
3184
- }
3185
- }
3186
- this.emitFile({
3187
- type: "asset",
3188
- fileName: `${prefix}favicon.svg`,
3189
- source: finalSvg
3190
- });
3191
- }
3192
- if (darkSource) {
3193
- const darkPath = join(rootDir, darkSource);
3194
- const darkExists = existsSync(darkPath);
3195
- for (const { size, name } of SIZES) {
3196
- const lightName = name.replace(/^(favicon-)/, "$1light-").replace(/^(apple-touch-icon)/, "$1-light").replace(/^(icon-)/, "$1light-");
3197
- const lightPng = await resizeToPng(sourcePath, size);
3198
- if (lightPng) this.emitFile({
3199
- type: "asset",
3200
- fileName: `${prefix}${lightName}`,
3201
- source: lightPng
3202
- });
3203
- if (darkExists) {
3204
- const darkName = name.replace(/^(favicon-)/, "$1dark-").replace(/^(apple-touch-icon)/, "$1-dark").replace(/^(icon-)/, "$1dark-");
3205
- const darkPng = await resizeToPng(darkPath, size);
3206
- if (darkPng) this.emitFile({
3207
- type: "asset",
3208
- fileName: `${prefix}${darkName}`,
3209
- source: darkPng
3210
- });
3211
- }
3212
- }
3213
- for (const { size, name } of SIZES) {
3214
- const pngBuffer = await resizeToPng(sourcePath, size);
3215
- if (pngBuffer) this.emitFile({
3216
- type: "asset",
3217
- fileName: `${prefix}${name}`,
3218
- source: pngBuffer
3219
- });
3220
- }
3221
- } else for (const { size, name } of SIZES) {
3222
- const pngBuffer = await resizeToPng(sourcePath, size);
3223
- if (pngBuffer) this.emitFile({
3224
- type: "asset",
3225
- fileName: `${prefix}${name}`,
3226
- source: pngBuffer
3227
- });
3228
- }
3229
- const ico = await generateIco(sourcePath);
3230
- if (ico) this.emitFile({
3231
- type: "asset",
3232
- fileName: `${prefix}favicon.ico`,
3233
- source: ico
3234
- });
3235
- if (generateManifest) {
3236
- const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : "";
3237
- const manifest = {
3238
- name: config.name ?? "App",
3239
- short_name: config.name ?? "App",
3240
- icons: [{
3241
- src: `${manifestPrefix}/icon-192.png`,
3242
- sizes: "192x192",
3243
- type: "image/png"
3244
- }, {
3245
- src: `${manifestPrefix}/icon-512.png`,
3246
- sizes: "512x512",
3247
- type: "image/png"
3248
- }],
3249
- theme_color: themeColor,
3250
- background_color: backgroundColor,
3251
- display: "standalone"
3252
- };
3253
- this.emitFile({
3254
- type: "asset",
3255
- fileName: `${prefix}site.webmanifest`,
3256
- source: JSON.stringify(manifest, null, 2)
3257
- });
3258
- }
3259
- }
3260
- /**
3261
- * Get favicon link tags for a specific locale.
3262
- * Returns link objects suitable for `useHead()` or direct HTML injection.
3263
- *
3264
- * @example
3265
- * ```ts
3266
- * const links = faviconLinks("de", { source: "./icon.svg", locales: { de: { source: "./icon-de.svg" } } })
3267
- * // → [{ rel: "icon", type: "image/svg+xml", href: "/de/favicon.svg" }, ...]
3268
- * ```
3269
- */
3270
- function faviconLinks(locale, config) {
3271
- const hasLocaleOverride = locale && config.locales?.[locale];
3272
- const prefix = hasLocaleOverride ? `/${locale}` : "";
3273
- const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
3274
- const hasDark = !!config.darkSource;
3275
- const links = [];
3276
- if (isSvg && hasDark) links.push({
3277
- rel: "icon",
3278
- type: "image/svg+xml",
3279
- href: `${prefix}/favicon-light.svg`,
3280
- "data-favicon-theme": "light"
3281
- }, {
3282
- rel: "icon",
3283
- type: "image/svg+xml",
3284
- href: `${prefix}/favicon-dark.svg`,
3285
- "data-favicon-theme": "dark",
3286
- media: "not all"
3287
- });
3288
- else if (isSvg) links.push({
3289
- rel: "icon",
3290
- type: "image/svg+xml",
3291
- href: `${prefix}/favicon.svg`
3292
- });
3293
- links.push({
3294
- rel: "icon",
3295
- type: "image/png",
3296
- sizes: "32x32",
3297
- href: `${prefix}/favicon-32x32.png`
3298
- }, {
3299
- rel: "icon",
3300
- type: "image/png",
3301
- sizes: "16x16",
3302
- href: `${prefix}/favicon-16x16.png`
3303
- }, {
3304
- rel: "apple-touch-icon",
3305
- sizes: "180x180",
3306
- href: `${prefix}/apple-touch-icon.png`
3307
- });
3308
- if (config.manifest !== false) links.push({
3309
- rel: "manifest",
3310
- href: `${prefix}/site.webmanifest`
3311
- });
3312
- return links;
3313
- }
3314
- async function resizeToPng(input, size) {
3315
- try {
3316
- return await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, {
3317
- fit: "contain",
3318
- background: {
3319
- r: 0,
3320
- g: 0,
3321
- b: 0,
3322
- alpha: 0
3323
- }
3324
- }).png().toBuffer();
3325
- } catch {
3326
- warnSharpMissing$1();
3327
- return null;
3328
- }
3329
- }
3330
- async function generateIco(input) {
3331
- try {
3332
- const sharp = await import("sharp").then((m) => m.default ?? m);
3333
- const png16 = await sharp(input).resize(16, 16, {
3334
- fit: "contain",
3335
- background: {
3336
- r: 0,
3337
- g: 0,
3338
- b: 0,
3339
- alpha: 0
3340
- }
3341
- }).png().toBuffer();
3342
- const png32 = await sharp(input).resize(32, 32, {
3343
- fit: "contain",
3344
- background: {
3345
- r: 0,
3346
- g: 0,
3347
- b: 0,
3348
- alpha: 0
3349
- }
3350
- }).png().toBuffer();
3351
- return createIcoFromPngs([{
3352
- buffer: png16,
3353
- size: 16
3354
- }, {
3355
- buffer: png32,
3356
- size: 32
3357
- }]);
3358
- } catch {
3359
- warnSharpMissing$1();
3360
- return null;
3361
- }
3362
- }
3363
- /** @internal Exported for testing */
3364
- function createIcoFromPngs(entries) {
3365
- const headerSize = 6;
3366
- const dirEntrySize = 16;
3367
- const dirSize = dirEntrySize * entries.length;
3368
- let dataOffset = headerSize + dirSize;
3369
- const header = Buffer.alloc(headerSize);
3370
- header.writeUInt16LE(0, 0);
3371
- header.writeUInt16LE(1, 2);
3372
- header.writeUInt16LE(entries.length, 4);
3373
- const dirEntries = Buffer.alloc(dirSize);
3374
- const dataBuffers = [];
3375
- for (let i = 0; i < entries.length; i++) {
3376
- const entry = entries[i];
3377
- const offset = i * dirEntrySize;
3378
- dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset);
3379
- dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1);
3380
- dirEntries.writeUInt8(0, offset + 2);
3381
- dirEntries.writeUInt8(0, offset + 3);
3382
- dirEntries.writeUInt16LE(1, offset + 4);
3383
- dirEntries.writeUInt16LE(32, offset + 6);
3384
- dirEntries.writeUInt32LE(entry.buffer.length, offset + 8);
3385
- dirEntries.writeUInt32LE(dataOffset, offset + 12);
3386
- dataOffset += entry.buffer.length;
3387
- dataBuffers.push(entry.buffer);
3388
- }
3389
- return Buffer.concat([
3390
- header,
3391
- dirEntries,
3392
- ...dataBuffers
3393
- ]);
3394
- }
3395
- /**
3396
- * Add a "DEV" badge overlay to an SVG string.
3397
- * Adds a small colored circle with "DEV" text in the bottom-right corner.
3398
- */
3399
- function addDevBadgeToSvg(svg) {
3400
- const [, , w, h] = (svg.match(/viewBox="([^"]*)"/)?.[1] ?? "0 0 32 32").split(" ").map(Number);
3401
- const size = Math.min(w ?? 32, h ?? 32);
3402
- const r = size * .28;
3403
- const cx = (w ?? 32) - r;
3404
- const cy = (h ?? 32) - r;
3405
- const fontSize = r * .85;
3406
- 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>`;
3407
- return svg.replace(/<\/svg>\s*$/, `${badge}</svg>`);
3408
- }
3409
- /**
3410
- * Add a "DEV" badge to a PNG buffer via sharp composite.
3411
- * Composites a red circle with "D" in the bottom-right corner.
3412
- */
3413
- async function addDevBadgeToPng(pngBuffer, size) {
3414
- try {
3415
- const sharp = await import("sharp").then((m) => m.default ?? m);
3416
- const r = Math.round(size * .28);
3417
- const d = r * 2;
3418
- const badgeSvg = `<svg width="${d}" height="${d}" xmlns="http://www.w3.org/2000/svg">
3419
- <circle cx="${r}" cy="${r}" r="${r}" fill="#ef4444"/>
3420
- <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>
3421
- </svg>`;
3422
- const badgePng = await sharp(Buffer.from(badgeSvg)).png().toBuffer();
3423
- return await sharp(Buffer.from(pngBuffer)).composite([{
3424
- input: badgePng,
3425
- gravity: "southeast"
3426
- }]).png().toBuffer();
3427
- } catch {
3428
- return pngBuffer;
3429
- }
3430
- }
3431
-
3432
- //#endregion
3433
- //#region src/icons-plugin.ts
3434
- /** Set key → exported component name. `ui` → `UiIcon`, `brand-marks` → `BrandMarksIcon`. */
3435
- function componentNameFromSetKey(key) {
3436
- const safe = key.split(/[-_/\s]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("").replace(/[^A-Za-z0-9_$]/g, "");
3437
- return `${/^[A-Za-z_$]/.test(safe) ? safe : `Set${safe}`}Icon`;
3438
- }
3439
- /** Filename stem → registry key. `Check-Circle.svg` → `check-circle`. */
3440
- function iconNameFromFile(file) {
3441
- return basename(file, ".svg").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
3442
- }
3443
- /** Registry key → safe JS import binding. `check-circle` → `checkCircle`. */
3444
- function bindingFromName(name) {
3445
- const safe = name.replace(/[-/](.)/g, (_, c) => c.toUpperCase()).replace(/[^A-Za-z0-9_$]/g, "_");
3446
- return /^[A-Za-z_$]/.test(safe) ? safe : `_${safe}`;
3447
- }
3448
- /** List the `*.svg` filenames in `dir` (sorted, stable). Empty if missing. */
3449
- function scanIconDir(dir) {
3450
- if (!existsSync(dir)) return [];
3451
- return readdirSync(dir).filter((f) => f.toLowerCase().endsWith(".svg")).sort();
3452
- }
3453
- /**
3454
- * Render the generated `.tsx` source for a set of svg filenames. Pure —
3455
- * unit-tested directly; the plugin only adds fs + watch around it.
3456
- */
3457
- function generateIconSetSource(files, opts) {
3458
- const query = opts.mode === "image" ? "" : "?raw";
3459
- const seen = /* @__PURE__ */ new Map();
3460
- const entries = [];
3461
- for (const file of files) {
3462
- const key = iconNameFromFile(file);
3463
- let binding = bindingFromName(key);
3464
- while (seen.has(binding)) binding = `${binding}_`;
3465
- seen.set(binding, key);
3466
- entries.push({
3467
- key,
3468
- binding,
3469
- file
3470
- });
3471
- }
3472
- const header = [
3473
- "// AUTO-GENERATED by @pyreon/zero iconsPlugin — do not edit.",
3474
- `// Add / remove .svg files in ${opts.importDir} and this regenerates.`,
3475
- "/// <reference types=\"vite/client\" />",
3476
- "import { createNamedIcon } from '@pyreon/zero'"
3477
- ];
3478
- const imports = entries.map((e) => `import ${e.binding} from '${opts.importDir}/${e.file}${query}'`);
3479
- const registry = [
3480
- "const REGISTRY = {",
3481
- ...entries.map((e) => ` ${JSON.stringify(e.key)}: ${e.binding},`),
3482
- "} as const"
3483
- ];
3484
- const tail = [
3485
- "export type IconName = keyof typeof REGISTRY",
3486
- `export const Icon = createNamedIcon(REGISTRY${opts.mode === "image" ? ", { mode: 'image' }" : ""})`,
3487
- ""
3488
- ];
3489
- return [
3490
- ...header,
3491
- "",
3492
- ...imports,
3493
- "",
3494
- ...registry,
3495
- "",
3496
- ...tail
3497
- ].join("\n");
3498
- }
3499
- /**
3500
- * Render the generated `.tsx` for the NAMED MULTI-SET form. One file, one
3501
- * `createNamedIcon` import, one strictly-typed component PER set with
3502
- * namespaced types (`UiIcon`/`UiIconName`, `BrandIcon`/`BrandIconName`) so
3503
- * sets never clash. Bindings are per-set-prefixed so two sets sharing a
3504
- * glyph filename don't collide.
2589
+ * Render the generated `.tsx` for the NAMED MULTI-SET form. One file, one
2590
+ * `createNamedIcon` import, one strictly-typed component PER set with
2591
+ * namespaced types (`UiIcon`/`UiIconName`, `BrandIcon`/`BrandIconName`) so
2592
+ * sets never clash. Bindings are per-set-prefixed so two sets sharing a
2593
+ * glyph filename don't collide.
3505
2594
  */
3506
2595
  function generateNamedIconSetsSource(sets) {
3507
2596
  const header = [
@@ -3614,948 +2703,5 @@ function iconsPlugin(cfg) {
3614
2703
  }
3615
2704
 
3616
2705
  //#endregion
3617
- //#region src/seo.ts
3618
- /**
3619
- * Generate a sitemap.xml string from route file paths.
3620
- *
3621
- * When `i18n` is set (PR K — passed by `seoPlugin` after reading the
3622
- * i18n config from `zero({ i18n: ... })`), URLs are clustered by their
3623
- * un-prefixed (default-locale) form and each `<url>` carries
3624
- * `<xhtml:link rel="alternate" hreflang="...">` siblings for every
3625
- * locale variant + an `x-default` entry pointing at the default locale.
3626
- */
3627
- function generateSitemap(routeFiles, config, i18n) {
3628
- const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
3629
- const paths = routeFiles.filter((f) => {
3630
- const name = f.split("/").pop()?.replace(/\.\w+$/, "");
3631
- return name !== "_layout" && name !== "_error" && name !== "_loading";
3632
- }).map((f) => {
3633
- let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "/").replace(/^index$/, "/");
3634
- if (path.includes("[")) return null;
3635
- path = path.replace(/\([\w-]+\)\//g, "");
3636
- if (!path.startsWith("/")) path = `/${path}`;
3637
- return path;
3638
- }).filter((p) => p !== null).filter((p) => !exclude.some((e) => p.startsWith(e)));
3639
- const clusters = clusterPathsByLocale((() => {
3640
- const byPath = /* @__PURE__ */ new Map();
3641
- for (const e of [...paths.map((p) => ({
3642
- path: p,
3643
- changefreq,
3644
- priority
3645
- })), ...config.additionalPaths ?? []]) if (!byPath.has(e.path)) byPath.set(e.path, e);
3646
- return [...byPath.values()];
3647
- })(), i18n);
3648
- return `<?xml version="1.0" encoding="UTF-8"?>
3649
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${i18n != null && i18n.locales.length > 0 ? " xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"" : ""}>
3650
- ${clusters.map((cluster) => renderClusterEntry(cluster, origin, changefreq, priority, i18n)).join("\n")}
3651
- </urlset>`;
3652
- }
3653
- /**
3654
- * Cluster URL entries by their un-prefixed (default-locale) form.
3655
- *
3656
- * Each output cluster has:
3657
- * - `canonical`: the SitemapEntry that should be used as the `<url>`
3658
- * payload (default-locale variant; falls back to the first variant
3659
- * if no default-locale entry exists in the cluster).
3660
- * - `variantsByLocale`: Map of locale → SitemapEntry for the cluster.
3661
- *
3662
- * Without i18n, every entry becomes its own single-variant cluster.
3663
- *
3664
- * @internal — exported for unit testing.
3665
- */
3666
- function clusterPathsByLocale(entries, i18n) {
3667
- if (i18n == null || i18n.locales.length === 0) return entries.map((entry) => ({
3668
- canonical: entry,
3669
- variantsByLocale: new Map([[null, entry]])
3670
- }));
3671
- const strategy = i18n.strategy ?? "prefix-except-default";
3672
- const { defaultLocale, locales } = i18n;
3673
- const byUnPrefixed = /* @__PURE__ */ new Map();
3674
- for (const entry of entries) {
3675
- const { unPrefixed, locale } = stripLocalePrefix(entry.path, locales, defaultLocale, strategy);
3676
- let cluster = byUnPrefixed.get(unPrefixed);
3677
- if (!cluster) {
3678
- cluster = /* @__PURE__ */ new Map();
3679
- byUnPrefixed.set(unPrefixed, cluster);
3680
- }
3681
- cluster.set(locale, entry);
3682
- }
3683
- const out = [];
3684
- for (const variantsByLocale of byUnPrefixed.values()) {
3685
- const canonical = variantsByLocale.get(defaultLocale) ?? variantsByLocale.get(null) ?? [...variantsByLocale.values()][0];
3686
- out.push({
3687
- canonical,
3688
- variantsByLocale
3689
- });
3690
- }
3691
- return out;
3692
- }
3693
- /**
3694
- * Strip the locale prefix from a path under the i18n strategy.
3695
- *
3696
- * Returns `{ unPrefixed, locale }`:
3697
- * - `/about` under `prefix-except-default` (default=en) → `{ unPrefixed: '/about', locale: 'en' }`
3698
- * - `/de/about` under either strategy → `{ unPrefixed: '/about', locale: 'de' }`
3699
- * - `/de` (locale root) → `{ unPrefixed: '/', locale: 'de' }`
3700
- * - `/about` under `prefix` → no locale match, returns `{ unPrefixed: '/about', locale: null }`
3701
- * (the URL doesn't fit any locale subtree — sitemap treats it as standalone).
3702
- *
3703
- * @internal — exported for unit testing.
3704
- */
3705
- function stripLocalePrefix(path, locales, defaultLocale, strategy) {
3706
- for (const locale of locales) {
3707
- if (path === `/${locale}`) return {
3708
- unPrefixed: "/",
3709
- locale
3710
- };
3711
- if (path.startsWith(`/${locale}/`)) return {
3712
- unPrefixed: path.slice(`/${locale}`.length),
3713
- locale
3714
- };
3715
- }
3716
- if (strategy === "prefix-except-default") return {
3717
- unPrefixed: path,
3718
- locale: defaultLocale
3719
- };
3720
- return {
3721
- unPrefixed: path,
3722
- locale: null
3723
- };
3724
- }
3725
- function renderClusterEntry(cluster, origin, changefreq, priority, i18n) {
3726
- const { canonical, variantsByLocale } = cluster;
3727
- const lines = [
3728
- " <url>",
3729
- ` <loc>${escapeXml$1(`${origin}${canonical.path === "/" ? "" : canonical.path}`)}</loc>`,
3730
- ` <changefreq>${canonical.changefreq ?? changefreq}</changefreq>`,
3731
- ` <priority>${canonical.priority ?? priority}</priority>`
3732
- ];
3733
- if (canonical.lastmod) lines.push(` <lastmod>${canonical.lastmod}</lastmod>`);
3734
- if (i18n != null && i18n.locales.length > 0 && variantsByLocale.size > 1) {
3735
- for (const locale of i18n.locales) {
3736
- const variant = variantsByLocale.get(locale);
3737
- if (!variant) continue;
3738
- const variantLoc = `${origin}${variant.path === "/" ? "" : variant.path}`;
3739
- lines.push(` <xhtml:link rel="alternate" hreflang="${escapeXml$1(locale)}" href="${escapeXml$1(variantLoc)}"/>`);
3740
- }
3741
- const defaultVariant = variantsByLocale.get(i18n.defaultLocale);
3742
- if (defaultVariant) {
3743
- const defaultLoc = `${origin}${defaultVariant.path === "/" ? "" : defaultVariant.path}`;
3744
- lines.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml$1(defaultLoc)}"/>`);
3745
- }
3746
- }
3747
- lines.push(" </url>");
3748
- return lines.join("\n");
3749
- }
3750
- function escapeXml$1(str) {
3751
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3752
- }
3753
- /**
3754
- * Resolve the i18n config to feed `generateSitemap` for hreflang
3755
- * emission. Priority order:
3756
- * 1. Explicit user config — `hreflang: I18nRoutingConfig` (object)
3757
- * 2. Auto-detect from SSG manifest — `hreflang: true` + `manifestI18n`
3758
- * present (only happens in SSG mode where the manifest exists)
3759
- * 3. Nothing — emit plain sitemap without xhtml:link siblings
3760
- *
3761
- * @internal — exported for unit testing.
3762
- */
3763
- function resolveHreflangI18n(hreflang, manifestI18n) {
3764
- if (hreflang == null || hreflang === false) return void 0;
3765
- if (hreflang === true) return manifestI18n;
3766
- return hreflang;
3767
- }
3768
- /**
3769
- * Duck-type guard for `I18nRoutingConfig`. The SSG manifest is JSON,
3770
- * so the embedded i18n field could in principle be malformed if a
3771
- * downstream user hand-edits the manifest (don't). Validate the shape
3772
- * before trusting it.
3773
- *
3774
- * @internal
3775
- */
3776
- function isI18nRoutingConfig(value) {
3777
- if (value == null || typeof value !== "object") return false;
3778
- const v = value;
3779
- return Array.isArray(v.locales) && v.locales.every((l) => typeof l === "string") && typeof v.defaultLocale === "string";
3780
- }
3781
- /**
3782
- * Generate a robots.txt string.
3783
- */
3784
- function generateRobots(config = {}) {
3785
- const { rules = [{
3786
- userAgent: "*",
3787
- allow: ["/"]
3788
- }], sitemap, host } = config;
3789
- const lines = [];
3790
- for (const rule of rules) {
3791
- lines.push(`User-agent: ${rule.userAgent}`);
3792
- if (rule.allow) for (const path of rule.allow) lines.push(`Allow: ${path}`);
3793
- if (rule.disallow) for (const path of rule.disallow) lines.push(`Disallow: ${path}`);
3794
- if (rule.crawlDelay) lines.push(`Crawl-delay: ${rule.crawlDelay}`);
3795
- lines.push("");
3796
- }
3797
- if (sitemap) lines.push(`Sitemap: ${sitemap}`);
3798
- if (host) lines.push(`Host: ${host}`);
3799
- return lines.join("\n");
3800
- }
3801
- /**
3802
- * Generate a JSON-LD script tag string for structured data.
3803
- *
3804
- * @example
3805
- * useHead({
3806
- * script: [jsonLd({
3807
- * "@type": "WebSite",
3808
- * name: "My Site",
3809
- * url: "https://example.com",
3810
- * })],
3811
- * })
3812
- */
3813
- function jsonLd(data) {
3814
- const ld = {
3815
- "@context": "https://schema.org",
3816
- ...data
3817
- };
3818
- return `<script type="application/ld+json">${JSON.stringify(ld)}<\/script>`;
3819
- }
3820
- /**
3821
- * Zero SEO Vite plugin.
3822
- * Generates sitemap.xml and robots.txt at build time.
3823
- *
3824
- * @example
3825
- * import { seoPlugin } from "@pyreon/zero/seo"
3826
- *
3827
- * export default {
3828
- * plugins: [
3829
- * pyreon(),
3830
- * zero(),
3831
- * seoPlugin({
3832
- * sitemap: {
3833
- * origin: "https://example.com",
3834
- * useSsgPaths: true, // include dynamic-route enumerations
3835
- * },
3836
- * robots: { sitemap: "https://example.com/sitemap.xml" },
3837
- * }),
3838
- * ],
3839
- * }
3840
- */
3841
- function seoPlugin(config = {}) {
3842
- const useSsgPaths = config.sitemap?.useSsgPaths === true;
3843
- let distDir = "";
3844
- return {
3845
- name: "pyreon-zero-seo",
3846
- apply: "build",
3847
- ...useSsgPaths ? { enforce: "post" } : {},
3848
- configResolved(resolved) {
3849
- distDir = resolve(resolved.root, resolved.build.outDir);
3850
- },
3851
- async generateBundle(_, _bundle) {
3852
- if (config.sitemap && !useSsgPaths) {
3853
- const { scanRouteFiles } = await import("./fs-router-Bacdhsq-.js").then((n) => n.n);
3854
- const routesDir = `${process.cwd()}/src/routes`;
3855
- try {
3856
- const files = await scanRouteFiles(routesDir);
3857
- const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, void 0);
3858
- const sitemap = generateSitemap(files, config.sitemap, hreflangI18n);
3859
- this.emitFile({
3860
- type: "asset",
3861
- fileName: "sitemap.xml",
3862
- source: sitemap
3863
- });
3864
- } catch {}
3865
- }
3866
- if (config.robots) {
3867
- const robots = generateRobots(config.robots);
3868
- this.emitFile({
3869
- type: "asset",
3870
- fileName: "robots.txt",
3871
- source: robots
3872
- });
3873
- }
3874
- },
3875
- async closeBundle() {
3876
- if (!config.sitemap || !useSsgPaths) return;
3877
- const { scanRouteFiles } = await import("./fs-router-Bacdhsq-.js").then((n) => n.n);
3878
- const routesDir = `${process.cwd()}/src/routes`;
3879
- const manifestPath = join(distDir, "_pyreon-ssg-paths.json");
3880
- try {
3881
- let ssgPaths = [];
3882
- let manifestI18n;
3883
- if (existsSync(manifestPath)) {
3884
- const raw = await readFile(manifestPath, "utf-8");
3885
- const parsed = JSON.parse(raw);
3886
- if (Array.isArray(parsed.paths)) ssgPaths = parsed.paths.filter((p) => typeof p === "string").map((path) => ({ path }));
3887
- if (isI18nRoutingConfig(parsed.i18n)) manifestI18n = parsed.i18n;
3888
- try {
3889
- await rm(manifestPath, { force: true });
3890
- } catch {}
3891
- }
3892
- let files = [];
3893
- try {
3894
- files = await scanRouteFiles(routesDir);
3895
- } catch {}
3896
- const merged = {
3897
- ...config.sitemap,
3898
- additionalPaths: [...ssgPaths, ...config.sitemap.additionalPaths ?? []]
3899
- };
3900
- const hreflangI18n = resolveHreflangI18n(config.sitemap.hreflang, manifestI18n);
3901
- const sitemap = generateSitemap(files, merged, hreflangI18n);
3902
- await writeFile(join(distDir, "sitemap.xml"), sitemap, "utf-8");
3903
- } catch {}
3904
- }
3905
- };
3906
- }
3907
- /**
3908
- * SEO middleware for dev server.
3909
- * Serves sitemap.xml and robots.txt dynamically during development.
3910
- */
3911
- function seoMiddleware(config = {}) {
3912
- return async (ctx) => {
3913
- if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
3914
- if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
3915
- const { scanRouteFiles } = await import("./fs-router-Bacdhsq-.js").then((n) => n.n);
3916
- const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
3917
- return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
3918
- } catch {}
3919
- };
3920
- }
3921
-
3922
- //#endregion
3923
- //#region src/og-image.ts
3924
- /**
3925
- * OG Image generation plugin.
3926
- *
3927
- * Generates Open Graph images at build time from templates with
3928
- * text overlays. Supports locale-specific text for i18n apps.
3929
- * Uses sharp for image processing (same optional dep as favicon/image plugins).
3930
- *
3931
- * @example
3932
- * ```ts
3933
- * // vite.config.ts
3934
- * import { ogImagePlugin } from "@pyreon/zero/og-image"
3935
- *
3936
- * export default {
3937
- * plugins: [
3938
- * ogImagePlugin({
3939
- * locales: ["en", "de", "cs"],
3940
- * templates: [{
3941
- * name: "default",
3942
- * background: "./src/assets/og-bg.jpg",
3943
- * layers: [{
3944
- * text: { en: "Build faster", de: "Schneller bauen", cs: "Stavte rychleji" },
3945
- * y: "40%",
3946
- * fontSize: 72,
3947
- * }],
3948
- * }],
3949
- * }),
3950
- * ],
3951
- * }
3952
- * ```
3953
- */
3954
- let sharpWarned = false;
3955
- function warnSharpMissing() {
3956
- if (sharpWarned) return;
3957
- sharpWarned = true;
3958
- console.warn("\n[Pyreon] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n");
3959
- }
3960
- function resolvePosition(value, dimension, fallback = "50%") {
3961
- if (value === void 0) value = fallback;
3962
- if (typeof value === "number") return value;
3963
- if (value.endsWith("%")) return Math.round(Number.parseFloat(value) / 100 * dimension);
3964
- return Number.parseInt(value, 10) || 0;
3965
- }
3966
- function resolveLayerText(layer, locale) {
3967
- if (typeof layer.text === "string") return layer.text;
3968
- if (typeof layer.text === "function") return layer.text(locale);
3969
- return layer.text[locale] ?? layer.text[Object.keys(layer.text)[0] ?? ""] ?? "";
3970
- }
3971
- function escapeXml(str) {
3972
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3973
- }
3974
- /**
3975
- * Build an SVG overlay with text layers.
3976
- * @internal Exported for testing.
3977
- */
3978
- function buildTextOverlaySvg(layers, width, height, locale) {
3979
- return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${layers.map((layer) => {
3980
- const text = resolveLayerText(layer, locale);
3981
- const x = resolvePosition(layer.x, width, "50%");
3982
- const y = resolvePosition(layer.y, height, "50%");
3983
- const fontSize = layer.fontSize ?? 64;
3984
- const fontFamily = layer.fontFamily ?? "sans-serif";
3985
- const fontWeight = layer.fontWeight ?? "bold";
3986
- const color = layer.color ?? "#ffffff";
3987
- const anchor = layer.textAnchor ?? "middle";
3988
- const maxWidth = layer.maxWidth ?? Math.round(width * .8);
3989
- const words = text.split(" ");
3990
- const lines = [];
3991
- let currentLine = "";
3992
- const estimateWidth = (s) => {
3993
- let w = 0;
3994
- for (let i = 0; i < s.length; i++) {
3995
- const code = s.charCodeAt(i);
3996
- if (code >= 12288 && code <= 40959) w += fontSize * 1;
3997
- else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) w += fontSize * .35;
3998
- else w += fontSize * .55;
3999
- }
4000
- return w;
4001
- };
4002
- for (const word of words) {
4003
- const testLine = currentLine ? `${currentLine} ${word}` : word;
4004
- if (estimateWidth(testLine) > maxWidth && currentLine) {
4005
- lines.push(currentLine);
4006
- currentLine = word;
4007
- } else currentLine = testLine;
4008
- }
4009
- if (currentLine) lines.push(currentLine);
4010
- const tspans = lines.map((line, i) => {
4011
- return `<tspan x="${x}" dy="${i === 0 ? "0" : `${fontSize * 1.2}`}">${escapeXml(line)}</tspan>`;
4012
- }).join("");
4013
- 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>`;
4014
- }).join("")}</svg>`;
4015
- }
4016
- /**
4017
- * Render an OG image from a template for a specific locale.
4018
- * @internal Exported for testing.
4019
- */
4020
- async function renderOgImage(template, locale, rootDir) {
4021
- try {
4022
- const sharp = await import("sharp").then((m) => m.default ?? m);
4023
- const width = template.width ?? 1200;
4024
- const height = template.height ?? 630;
4025
- let pipeline;
4026
- if (typeof template.background === "string") pipeline = sharp(join(rootDir, template.background)).resize(width, height, { fit: "cover" });
4027
- else pipeline = sharp({ create: {
4028
- width,
4029
- height,
4030
- channels: 4,
4031
- background: template.background.color
4032
- } });
4033
- if (template.layers && template.layers.length > 0) {
4034
- const svgOverlay = buildTextOverlaySvg(template.layers, width, height, locale);
4035
- pipeline = pipeline.composite([{
4036
- input: Buffer.from(svgOverlay),
4037
- top: 0,
4038
- left: 0
4039
- }]);
4040
- }
4041
- if (template.format === "jpeg") return await pipeline.jpeg({ quality: template.quality ?? 90 }).toBuffer();
4042
- return await pipeline.png().toBuffer();
4043
- } catch {
4044
- warnSharpMissing();
4045
- return null;
4046
- }
4047
- }
4048
- /**
4049
- * Compute the OG image path for a template and locale.
4050
- *
4051
- * @example
4052
- * ```ts
4053
- * ogImagePath("default", "de") // → "/og/default-de.png"
4054
- * ogImagePath("default") // → "/og/default.png"
4055
- * ogImagePath("hero", "en", "images") // → "/images/hero-en.png"
4056
- * ```
4057
- */
4058
- function ogImagePath(templateName, locale, outDir = "og", format = "png") {
4059
- const ext = format === "jpeg" ? "jpg" : "png";
4060
- return `/${outDir}/${templateName}${locale ? `-${locale}` : ""}.${ext}`;
4061
- }
4062
- /**
4063
- * OG image generation Vite plugin.
4064
- *
4065
- * Generates Open Graph images at build time. In dev, generates on-demand.
4066
- * Requires `sharp` as an optional dependency.
4067
- *
4068
- * @example
4069
- * ```ts
4070
- * // vite.config.ts
4071
- * import { ogImagePlugin } from "@pyreon/zero/og-image"
4072
- *
4073
- * export default {
4074
- * plugins: [
4075
- * ogImagePlugin({
4076
- * locales: ["en", "de"],
4077
- * templates: [{
4078
- * name: "default",
4079
- * background: { color: "#0066ff" },
4080
- * layers: [{ text: { en: "Hello", de: "Hallo" }, fontSize: 72 }],
4081
- * }],
4082
- * }),
4083
- * ],
4084
- * }
4085
- * ```
4086
- */
4087
- function ogImagePlugin(config) {
4088
- const outDir = config.outDir ?? "og";
4089
- let root = "";
4090
- let isBuild = false;
4091
- return {
4092
- name: "pyreon-zero-og-image",
4093
- enforce: "pre",
4094
- configResolved(resolvedConfig) {
4095
- root = resolvedConfig.root;
4096
- isBuild = resolvedConfig.command === "build";
4097
- },
4098
- configureServer(server) {
4099
- const devCache = /* @__PURE__ */ new Map();
4100
- server.middlewares.use(async (req, res, next) => {
4101
- const url = req.url ?? "";
4102
- if (!url.startsWith(`/${outDir}/`)) return next();
4103
- const match = url.slice(outDir.length + 2).match(/^(.+?)(?:-([a-z]{2,5}))?\.(png|jpe?g)$/);
4104
- if (!match) return next();
4105
- const [, templateName, locale, ext] = match;
4106
- const template = config.templates.find((t) => t.name === templateName);
4107
- if (!template) return next();
4108
- const resolvedLocale = locale ?? config.locales?.[0] ?? "en";
4109
- const cacheKey = `${templateName}:${resolvedLocale}`;
4110
- let buffer = devCache.get(cacheKey);
4111
- if (!buffer) {
4112
- const result = await renderOgImage(template, resolvedLocale, root);
4113
- if (!result) return next();
4114
- buffer = result;
4115
- devCache.set(cacheKey, result);
4116
- }
4117
- const contentType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
4118
- res.setHeader("Content-Type", contentType);
4119
- res.setHeader("Cache-Control", "no-cache");
4120
- res.end(Buffer.from(buffer));
4121
- });
4122
- },
4123
- async generateBundle() {
4124
- if (!isBuild) return;
4125
- for (const template of config.templates) {
4126
- const locales = config.locales ?? [void 0];
4127
- const ext = (template.format ?? "png") === "jpeg" ? "jpg" : "png";
4128
- for (const locale of locales) {
4129
- if (typeof template.background === "string") {
4130
- const bgPath = join(root, template.background);
4131
- if (!existsSync(bgPath)) {
4132
- console.warn(`[Pyreon] Background not found: ${bgPath}`);
4133
- continue;
4134
- }
4135
- }
4136
- const buffer = await renderOgImage(template, locale ?? "en", root);
4137
- if (!buffer) continue;
4138
- const suffix = locale ? `-${locale}` : "";
4139
- this.emitFile({
4140
- type: "asset",
4141
- fileName: `${outDir}/${template.name}${suffix}.${ext}`,
4142
- source: buffer
4143
- });
4144
- }
4145
- }
4146
- }
4147
- };
4148
- }
4149
-
4150
- //#endregion
4151
- //#region src/ai.ts
4152
- /**
4153
- * Generate llms.txt content from route files and config.
4154
- *
4155
- * Format follows the llms.txt proposal:
4156
- * ```
4157
- * # {name}
4158
- * > {description}
4159
- *
4160
- * ## Pages
4161
- * - [/about](/about): About page
4162
- *
4163
- * ## API
4164
- * - GET /api/posts: List posts
4165
- * ```
4166
- *
4167
- * @internal Exported for testing.
4168
- */
4169
- function generateLlmsTxt(routeFiles, apiFiles, config) {
4170
- const lines = [];
4171
- lines.push(`# ${config.name}`);
4172
- lines.push(`> ${config.description}`);
4173
- lines.push("");
4174
- const routes = parseFileRoutes(routeFiles);
4175
- const pages = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && !r.isCatchAll && !r.urlPath.includes(":"));
4176
- if (pages.length > 0) {
4177
- lines.push("## Pages");
4178
- lines.push("");
4179
- for (const page of pages) {
4180
- const desc = config.pageDescriptions?.[page.urlPath];
4181
- const url = `${config.origin}${page.urlPath === "/" ? "" : page.urlPath}`;
4182
- if (desc) lines.push(`- [${page.urlPath}](${url}): ${desc}`);
4183
- else lines.push(`- [${page.urlPath}](${url})`);
4184
- }
4185
- lines.push("");
4186
- }
4187
- const dynamicRoutes = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && (r.urlPath.includes(":") || r.isCatchAll));
4188
- if (dynamicRoutes.length > 0) {
4189
- lines.push("## Dynamic Pages");
4190
- lines.push("");
4191
- for (const route of dynamicRoutes) {
4192
- const desc = config.pageDescriptions?.[route.urlPath];
4193
- if (desc) lines.push(`- ${route.urlPath}: ${desc}`);
4194
- else lines.push(`- ${route.urlPath}`);
4195
- }
4196
- lines.push("");
4197
- }
4198
- const apiPatterns = parseApiFiles(apiFiles);
4199
- if (apiPatterns.length > 0 || config.apiDescriptions) {
4200
- lines.push("## API Endpoints");
4201
- lines.push("");
4202
- if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) lines.push(`- ${endpoint}: ${desc}`);
4203
- const describedPatterns = new Set(Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/, "")));
4204
- for (const pattern of apiPatterns) if (!describedPatterns.has(pattern)) lines.push(`- ${pattern}`);
4205
- lines.push("");
4206
- }
4207
- if (config.llmsExtra) {
4208
- lines.push(config.llmsExtra);
4209
- lines.push("");
4210
- }
4211
- return lines.join("\n");
4212
- }
4213
- /**
4214
- * Generate llms-full.txt — expanded version with more detail.
4215
- * Includes all route metadata and API descriptions.
4216
- *
4217
- * @internal Exported for testing.
4218
- */
4219
- function generateLlmsFullTxt(routeFiles, apiFiles, config) {
4220
- const lines = [];
4221
- lines.push(`# ${config.name} — Full Reference`);
4222
- lines.push(`> ${config.description}`);
4223
- lines.push("");
4224
- lines.push(`Base URL: ${config.origin}`);
4225
- lines.push("");
4226
- const pages = parseFileRoutes(routeFiles).filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound);
4227
- if (pages.length > 0) {
4228
- lines.push("## All Routes");
4229
- lines.push("");
4230
- for (const page of pages) {
4231
- const desc = config.pageDescriptions?.[page.urlPath] ?? "";
4232
- const dynamic = page.urlPath.includes(":") ? " (dynamic)" : "";
4233
- const catchAll = page.isCatchAll ? " (catch-all)" : "";
4234
- lines.push(`### ${page.urlPath}${dynamic}${catchAll}`);
4235
- if (desc) lines.push(desc);
4236
- lines.push(`- File: ${page.filePath}`);
4237
- lines.push(`- Render mode: ${page.renderMode}`);
4238
- lines.push("");
4239
- }
4240
- }
4241
- if (config.apiDescriptions) {
4242
- lines.push("## API Reference");
4243
- lines.push("");
4244
- for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
4245
- lines.push(`### ${endpoint}`);
4246
- lines.push(desc);
4247
- lines.push("");
4248
- }
4249
- }
4250
- if (config.llmsExtra) {
4251
- lines.push("## Additional Information");
4252
- lines.push("");
4253
- lines.push(config.llmsExtra);
4254
- lines.push("");
4255
- }
4256
- return lines.join("\n");
4257
- }
4258
- /**
4259
- * Auto-infer JSON-LD structured data from page metadata.
4260
- *
4261
- * Returns an array of JSON-LD objects (multiple schemas can apply to one page).
4262
- * For example, an article page gets both `Article` and `BreadcrumbList`.
4263
- *
4264
- * @example
4265
- * ```tsx
4266
- * const schemas = inferJsonLd({
4267
- * url: "https://example.com/blog/my-post",
4268
- * title: "My Post",
4269
- * description: "A great article",
4270
- * type: "article",
4271
- * author: "Vit Bokisch",
4272
- * publishedTime: "2026-03-31",
4273
- * })
4274
- * // → [Article schema, BreadcrumbList schema]
4275
- * ```
4276
- */
4277
- function inferJsonLd(options) {
4278
- const schemas = [];
4279
- if (options.type === "article") {
4280
- const article = {
4281
- "@context": "https://schema.org",
4282
- "@type": "Article",
4283
- headline: options.title,
4284
- url: options.url
4285
- };
4286
- if (options.description) article.description = options.description;
4287
- if (options.image) article.image = options.image;
4288
- if (options.publishedTime) article.datePublished = options.publishedTime;
4289
- if (options.author) article.author = {
4290
- "@type": "Person",
4291
- name: options.author
4292
- };
4293
- if (options.tags && options.tags.length > 0) article.keywords = options.tags.join(", ");
4294
- if (options.siteName) article.publisher = {
4295
- "@type": "Organization",
4296
- name: options.siteName
4297
- };
4298
- schemas.push(article);
4299
- } else if (options.type === "product") {
4300
- const product = {
4301
- "@context": "https://schema.org",
4302
- "@type": "Product",
4303
- name: options.title,
4304
- url: options.url
4305
- };
4306
- if (options.description) product.description = options.description;
4307
- if (options.image) product.image = options.image;
4308
- schemas.push(product);
4309
- } else {
4310
- const webpage = {
4311
- "@context": "https://schema.org",
4312
- "@type": "WebPage",
4313
- name: options.title,
4314
- url: options.url
4315
- };
4316
- if (options.description) webpage.description = options.description;
4317
- if (options.image) webpage.thumbnailUrl = options.image;
4318
- schemas.push(webpage);
4319
- }
4320
- if (options.breadcrumbs && options.breadcrumbs.length > 0) schemas.push({
4321
- "@context": "https://schema.org",
4322
- "@type": "BreadcrumbList",
4323
- itemListElement: options.breadcrumbs.map((bc, i) => ({
4324
- "@type": "ListItem",
4325
- position: i + 1,
4326
- name: bc.name,
4327
- item: bc.url
4328
- }))
4329
- });
4330
- else {
4331
- const urlObj = safeParseUrl(options.url);
4332
- if (urlObj) {
4333
- const segments = urlObj.pathname.split("/").filter(Boolean);
4334
- if (segments.length > 0) {
4335
- const items = [{
4336
- "@type": "ListItem",
4337
- position: 1,
4338
- name: "Home",
4339
- item: urlObj.origin
4340
- }];
4341
- let path = "";
4342
- for (let i = 0; i < segments.length; i++) {
4343
- path += `/${segments[i]}`;
4344
- items.push({
4345
- "@type": "ListItem",
4346
- position: i + 2,
4347
- name: capitalize(segments[i].replace(/-/g, " ")),
4348
- item: `${urlObj.origin}${path}`
4349
- });
4350
- }
4351
- schemas.push({
4352
- "@context": "https://schema.org",
4353
- "@type": "BreadcrumbList",
4354
- itemListElement: items
4355
- });
4356
- }
4357
- }
4358
- }
4359
- return schemas;
4360
- }
4361
- /**
4362
- * Generate an OpenAI-compatible AI plugin manifest.
4363
- *
4364
- * Follows the /.well-known/ai-plugin.json spec.
4365
- *
4366
- * @internal Exported for testing.
4367
- */
4368
- function generateAiPluginManifest(config) {
4369
- return {
4370
- schema_version: "v1",
4371
- name_for_human: config.name,
4372
- name_for_model: config.name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, ""),
4373
- description_for_human: config.description,
4374
- description_for_model: config.description,
4375
- auth: { type: "none" },
4376
- api: {
4377
- type: "openapi",
4378
- url: `${config.origin}/.well-known/openapi.yaml`
4379
- },
4380
- logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,
4381
- contact_email: config.contactEmail ?? "",
4382
- legal_info_url: config.legalUrl ?? `${config.origin}/legal`
4383
- };
4384
- }
4385
- /**
4386
- * Generate a minimal OpenAPI 3.0 spec from API route descriptions.
4387
- *
4388
- * @internal Exported for testing.
4389
- */
4390
- function generateOpenApiSpec(apiFiles, config) {
4391
- const paths = {};
4392
- if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
4393
- const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/);
4394
- if (match) {
4395
- const method = match[1].toLowerCase();
4396
- const openApiPath = match[2].replace(/:(\w+)/g, "{$1}");
4397
- if (!paths[openApiPath]) paths[openApiPath] = {};
4398
- paths[openApiPath][method] = {
4399
- summary: desc,
4400
- responses: { "200": { description: "Success" } }
4401
- };
4402
- }
4403
- }
4404
- for (const pattern of parseApiFiles(apiFiles)) {
4405
- const openApiPath = pattern.replace(/:(\w+)/g, "{$1}");
4406
- if (!paths[openApiPath]) paths[openApiPath] = { get: {
4407
- summary: `${openApiPath} endpoint`,
4408
- responses: { "200": { description: "Success" } }
4409
- } };
4410
- }
4411
- return {
4412
- openapi: "3.0.0",
4413
- info: {
4414
- title: config.name,
4415
- description: config.description,
4416
- version: "1.0.0"
4417
- },
4418
- servers: [{ url: config.origin }],
4419
- paths
4420
- };
4421
- }
4422
- /**
4423
- * AI integration Vite plugin.
4424
- *
4425
- * Generates at build time:
4426
- * - `/llms.txt` — concise site summary for AI agents
4427
- * - `/llms-full.txt` — detailed reference for AI agents
4428
- * - `/.well-known/ai-plugin.json` — OpenAI plugin manifest
4429
- * - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes
4430
- *
4431
- * In dev, serves these files via middleware.
4432
- *
4433
- * @example
4434
- * ```ts
4435
- * import { aiPlugin } from "@pyreon/zero/ai"
4436
- *
4437
- * export default {
4438
- * plugins: [
4439
- * aiPlugin({
4440
- * name: "My App",
4441
- * origin: "https://example.com",
4442
- * description: "A modern web application",
4443
- * apiDescriptions: {
4444
- * "GET /api/posts": "List blog posts",
4445
- * "GET /api/posts/:id": "Get post by ID",
4446
- * },
4447
- * }),
4448
- * ],
4449
- * }
4450
- * ```
4451
- */
4452
- function aiPlugin(config) {
4453
- let root = "";
4454
- let isBuild = false;
4455
- let routeFiles = [];
4456
- let apiFiles = [];
4457
- return {
4458
- name: "pyreon-zero-ai",
4459
- enforce: "post",
4460
- configResolved(resolvedConfig) {
4461
- root = resolvedConfig.root;
4462
- isBuild = resolvedConfig.command === "build";
4463
- },
4464
- async buildStart() {
4465
- try {
4466
- const { join } = await import("node:path");
4467
- const routesDir = join(root, config.routesDir ?? "src/routes");
4468
- const apiDir = join(root, config.apiDir ?? "src/api");
4469
- routeFiles = await scanDir(routesDir, routesDir);
4470
- apiFiles = await scanDir(apiDir, apiDir);
4471
- } catch {}
4472
- },
4473
- configureServer(server) {
4474
- server.middlewares.use(async (req, res, next) => {
4475
- const url = req.url ?? "";
4476
- if (url === "/llms.txt") {
4477
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
4478
- res.end(generateLlmsTxt(routeFiles, apiFiles, config));
4479
- return;
4480
- }
4481
- if (url === "/llms-full.txt") {
4482
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
4483
- res.end(generateLlmsFullTxt(routeFiles, apiFiles, config));
4484
- return;
4485
- }
4486
- if (url === "/.well-known/ai-plugin.json") {
4487
- res.setHeader("Content-Type", "application/json");
4488
- res.end(JSON.stringify(generateAiPluginManifest(config), null, 2));
4489
- return;
4490
- }
4491
- if (url === "/.well-known/openapi.yaml" || url === "/.well-known/openapi.json") {
4492
- res.setHeader("Content-Type", "application/json");
4493
- res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2));
4494
- return;
4495
- }
4496
- next();
4497
- });
4498
- },
4499
- async generateBundle() {
4500
- if (!isBuild) return;
4501
- this.emitFile({
4502
- type: "asset",
4503
- fileName: "llms.txt",
4504
- source: generateLlmsTxt(routeFiles, apiFiles, config)
4505
- });
4506
- this.emitFile({
4507
- type: "asset",
4508
- fileName: "llms-full.txt",
4509
- source: generateLlmsFullTxt(routeFiles, apiFiles, config)
4510
- });
4511
- this.emitFile({
4512
- type: "asset",
4513
- fileName: ".well-known/ai-plugin.json",
4514
- source: JSON.stringify(generateAiPluginManifest(config), null, 2)
4515
- });
4516
- this.emitFile({
4517
- type: "asset",
4518
- fileName: ".well-known/openapi.json",
4519
- source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2)
4520
- });
4521
- }
4522
- };
4523
- }
4524
- function parseApiFiles(files) {
4525
- return files.filter((f) => f.endsWith(".ts") || f.endsWith(".js")).map((f) => {
4526
- let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "");
4527
- if (!path.startsWith("/")) path = `/${path}`;
4528
- path = path.replace(/\[\.\.\.(\w+)\]/g, ":$1*").replace(/\[(\w+)\]/g, ":$1");
4529
- return `/api${path === "/" ? "" : path}`;
4530
- });
4531
- }
4532
- async function scanDir(dir, base) {
4533
- const { readdir, stat } = await import("node:fs/promises");
4534
- const { join, relative } = await import("node:path");
4535
- try {
4536
- const entries = await readdir(dir);
4537
- const files = [];
4538
- for (const entry of entries) {
4539
- const full = join(dir, entry);
4540
- if ((await stat(full)).isDirectory()) files.push(...await scanDir(full, base));
4541
- else files.push(relative(base, full));
4542
- }
4543
- return files;
4544
- } catch {
4545
- return [];
4546
- }
4547
- }
4548
- function safeParseUrl(url) {
4549
- try {
4550
- return new URL(url);
4551
- } catch {
4552
- return null;
4553
- }
4554
- }
4555
- function capitalize(s) {
4556
- return s.charAt(0).toUpperCase() + s.slice(1);
4557
- }
4558
-
4559
- //#endregion
4560
- export { _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, componentNameFromSetKey, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateIconSetSource, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateNamedIconSetsSource, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, iconNameFromFile, iconsPlugin, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanIconDir, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
2706
+ export { _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, componentNameFromSetKey, compose, createApp, createISRHandler, createLocaleContext, createMemoryStore, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateIconSetSource, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateNamedIconSetsSource, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, iconNameFromFile, iconsPlugin, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanIconDir, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
4561
2707
  //# sourceMappingURL=server.js.map