@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/README.md +211 -57
- package/lib/_chunks/app-BbPT0Y5M.js +36 -0
- package/lib/{fs-router-Bacdhsq-.js → _chunks/fs-router-DvBlRzmP.js} +21 -5
- package/lib/_chunks/use-intersection-observer-C6opeplh.js +29 -0
- package/lib/actions.js +24 -3
- package/lib/ai.js +1 -102
- package/lib/client.js +3 -33
- package/lib/csp.js +12 -9
- package/lib/favicon.js +1 -1
- package/lib/font.js +1 -1
- package/lib/image-plugin.js +1 -1
- package/lib/image.js +3 -27
- package/lib/index.js +8 -1085
- package/lib/link.js +3 -27
- package/lib/meta.js +1 -25
- package/lib/script.js +2 -26
- package/lib/seo.js +4 -4
- package/lib/server.js +275 -2129
- package/lib/testing.js +1 -69
- package/lib/theme.js +52 -22
- package/lib/types/config.d.ts +115 -0
- package/lib/types/csp.d.ts +9 -1
- package/lib/types/index.d.ts +120 -1
- package/lib/types/server.d.ts +192 -17
- package/lib/types/theme.d.ts +11 -2
- package/package.json +10 -10
- package/src/actions.ts +43 -5
- package/src/adapters/bun.ts +35 -7
- package/src/adapters/cloudflare.ts +17 -12
- package/src/adapters/netlify.ts +7 -1
- package/src/adapters/node.ts +33 -6
- package/src/adapters/vercel.ts +25 -4
- package/src/csp.ts +10 -7
- package/src/fs-router.ts +2 -1
- package/src/isr.ts +256 -51
- package/src/manifest.ts +23 -10
- package/src/server.ts +2 -1
- package/src/ssg-plugin.ts +27 -7
- package/src/theme.tsx +94 -38
- package/src/types.ts +76 -0
- package/lib/api-routes-CMsLztoj.js +0 -148
- package/lib/fs-router-3xzp-4Wj.js +0 -32
- package/lib/rolldown-runtime-CjeV3_4I.js +0 -18
package/lib/server.js
CHANGED
|
@@ -1,48 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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,
|
|
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/
|
|
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
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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(
|
|
498
|
+
const file = Bun.file(candidate)
|
|
485
499
|
if (await file.exists()) {
|
|
486
500
|
return new Response(file, {
|
|
487
501
|
headers: {
|
|
488
|
-
"cache-control":
|
|
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
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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) =>
|
|
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)
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
const
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
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/
|
|
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
|
-
*
|
|
2753
|
-
*
|
|
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
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
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
|
-
|
|
2783
|
-
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
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
|
-
*
|
|
2815
|
-
*
|
|
2816
|
-
*
|
|
2817
|
-
*
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|