@pyreon/zero 0.12.2 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/actions.js +97 -0
- package/lib/actions.js.map +1 -0
- package/lib/ai.js +503 -0
- package/lib/ai.js.map +1 -0
- package/lib/api-routes.js +137 -0
- package/lib/api-routes.js.map +1 -0
- package/lib/compression.js +80 -0
- package/lib/compression.js.map +1 -0
- package/lib/cors.js +57 -0
- package/lib/cors.js.map +1 -0
- package/lib/csp.js +119 -0
- package/lib/csp.js.map +1 -0
- package/lib/env.js +217 -0
- package/lib/env.js.map +1 -0
- package/lib/favicon.js +424 -0
- package/lib/favicon.js.map +1 -0
- package/lib/i18n-routing.js +167 -0
- package/lib/i18n-routing.js.map +1 -0
- package/lib/index.js +80 -22
- package/lib/index.js.map +1 -1
- package/lib/link.js +5 -0
- package/lib/link.js.map +1 -1
- package/lib/logger.js +78 -0
- package/lib/logger.js.map +1 -0
- package/lib/meta.js +336 -0
- package/lib/meta.js.map +1 -0
- package/lib/middleware.js +53 -0
- package/lib/middleware.js.map +1 -0
- package/lib/og-image.js +233 -0
- package/lib/og-image.js.map +1 -0
- package/lib/rate-limit.js +76 -0
- package/lib/rate-limit.js.map +1 -0
- package/lib/testing.js +179 -0
- package/lib/testing.js.map +1 -0
- package/lib/theme.js +11 -2
- package/lib/theme.js.map +1 -1
- package/lib/types/actions.d.ts +27 -24
- package/lib/types/actions.d.ts.map +1 -1
- package/lib/types/ai.d.ts +76 -95
- package/lib/types/ai.d.ts.map +1 -1
- package/lib/types/api-routes.d.ts +37 -33
- package/lib/types/api-routes.d.ts.map +1 -1
- package/lib/types/cache.d.ts +26 -22
- package/lib/types/cache.d.ts.map +1 -1
- package/lib/types/client.d.ts +13 -9
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/compression.d.ts +14 -10
- package/lib/types/compression.d.ts.map +1 -1
- package/lib/types/config.d.ts +39 -4
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/cors.d.ts +20 -16
- package/lib/types/cors.d.ts.map +1 -1
- package/lib/types/csp.d.ts +42 -61
- package/lib/types/csp.d.ts.map +1 -1
- package/lib/types/env.d.ts +26 -26
- package/lib/types/env.d.ts.map +1 -1
- package/lib/types/favicon.d.ts +58 -54
- package/lib/types/favicon.d.ts.map +1 -1
- package/lib/types/font.d.ts +68 -65
- package/lib/types/font.d.ts.map +1 -1
- package/lib/types/i18n-routing.d.ts +43 -37
- package/lib/types/i18n-routing.d.ts.map +1 -1
- package/lib/types/image-plugin.d.ts +49 -45
- package/lib/types/image-plugin.d.ts.map +1 -1
- package/lib/types/image.d.ts +47 -36
- package/lib/types/image.d.ts.map +1 -1
- package/lib/types/index.d.ts +1961 -56
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/link.d.ts +61 -56
- package/lib/types/link.d.ts.map +1 -1
- package/lib/types/logger.d.ts +37 -48
- package/lib/types/logger.d.ts.map +1 -1
- package/lib/types/meta.d.ts +180 -105
- package/lib/types/meta.d.ts.map +1 -1
- package/lib/types/middleware.d.ts +8 -4
- package/lib/types/middleware.d.ts.map +1 -1
- package/lib/types/og-image.d.ts +63 -59
- package/lib/types/og-image.d.ts.map +1 -1
- package/lib/types/rate-limit.d.ts +20 -16
- package/lib/types/rate-limit.d.ts.map +1 -1
- package/lib/types/script.d.ts +23 -19
- package/lib/types/script.d.ts.map +1 -1
- package/lib/types/seo.d.ts +47 -43
- package/lib/types/seo.d.ts.map +1 -1
- package/lib/types/testing.d.ts +64 -27
- package/lib/types/testing.d.ts.map +1 -1
- package/lib/types/theme.d.ts +22 -12
- package/lib/types/theme.d.ts.map +1 -1
- package/package.json +12 -12
- package/src/actions.ts +1 -3
- package/src/adapters/bun.ts +2 -0
- package/src/adapters/cloudflare.ts +2 -0
- package/src/adapters/netlify.ts +2 -0
- package/src/adapters/node.ts +2 -0
- package/src/adapters/validate.ts +16 -0
- package/src/adapters/vercel.ts +2 -0
- package/src/compression.ts +19 -3
- package/src/entry-server.ts +28 -5
- package/src/index.ts +1 -0
- package/src/link.tsx +6 -0
- package/src/meta.tsx +41 -13
- package/src/rate-limit.ts +11 -9
- package/src/theme.tsx +12 -1
- package/src/vite-plugin.ts +5 -1
- package/lib/types/adapters/bun.d.ts +0 -6
- package/lib/types/adapters/bun.d.ts.map +0 -1
- package/lib/types/adapters/cloudflare.d.ts +0 -26
- package/lib/types/adapters/cloudflare.d.ts.map +0 -1
- package/lib/types/adapters/index.d.ts +0 -13
- package/lib/types/adapters/index.d.ts.map +0 -1
- package/lib/types/adapters/netlify.d.ts +0 -21
- package/lib/types/adapters/netlify.d.ts.map +0 -1
- package/lib/types/adapters/node.d.ts +0 -6
- package/lib/types/adapters/node.d.ts.map +0 -1
- package/lib/types/adapters/static.d.ts +0 -7
- package/lib/types/adapters/static.d.ts.map +0 -1
- package/lib/types/adapters/vercel.d.ts +0 -21
- package/lib/types/adapters/vercel.d.ts.map +0 -1
- package/lib/types/app.d.ts +0 -24
- package/lib/types/app.d.ts.map +0 -1
- package/lib/types/entry-server.d.ts +0 -37
- package/lib/types/entry-server.d.ts.map +0 -1
- package/lib/types/error-overlay.d.ts +0 -6
- package/lib/types/error-overlay.d.ts.map +0 -1
- package/lib/types/fs-router.d.ts +0 -47
- package/lib/types/fs-router.d.ts.map +0 -1
- package/lib/types/isr.d.ts +0 -9
- package/lib/types/isr.d.ts.map +0 -1
- package/lib/types/not-found.d.ts +0 -7
- package/lib/types/not-found.d.ts.map +0 -1
- package/lib/types/types.d.ts +0 -111
- package/lib/types/types.d.ts.map +0 -1
- package/lib/types/utils/use-intersection-observer.d.ts +0 -10
- package/lib/types/utils/use-intersection-observer.d.ts.map +0 -1
- package/lib/types/utils/with-headers.d.ts +0 -6
- package/lib/types/utils/with-headers.d.ts.map +0 -1
- package/lib/types/vite-plugin.d.ts +0 -17
- package/lib/types/vite-plugin.d.ts.map +0 -1
package/lib/actions.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//#region src/actions.ts
|
|
2
|
+
const actionRegistry = /* @__PURE__ */ new Map();
|
|
3
|
+
/**
|
|
4
|
+
* Define a server action. Returns a callable function that:
|
|
5
|
+
* - On the **client**: sends a POST request to `/_zero/actions/<id>`
|
|
6
|
+
* - On the **server** (SSR): executes the handler directly (no fetch)
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* // In a route file or module:
|
|
10
|
+
* export const createPost = defineAction(async (ctx) => {
|
|
11
|
+
* const data = ctx.json as { title: string; body: string }
|
|
12
|
+
* // ... save to database
|
|
13
|
+
* return { success: true, id: 123 }
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* // In a component:
|
|
17
|
+
* const result = await createPost({ title: 'Hello', body: '...' })
|
|
18
|
+
*/
|
|
19
|
+
function defineAction(handler) {
|
|
20
|
+
const id = `action_${crypto.randomUUID().slice(0, 8)}`;
|
|
21
|
+
actionRegistry.set(id, {
|
|
22
|
+
id,
|
|
23
|
+
handler
|
|
24
|
+
});
|
|
25
|
+
const callable = async (data) => {
|
|
26
|
+
if (typeof globalThis.window === "undefined") return handler({
|
|
27
|
+
request: new Request(`http://localhost/_zero/actions/${id}`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify(data ?? null)
|
|
31
|
+
}),
|
|
32
|
+
formData: null,
|
|
33
|
+
json: data ?? null,
|
|
34
|
+
headers: new Headers({ "Content-Type": "application/json" })
|
|
35
|
+
});
|
|
36
|
+
const response = await fetch(`/_zero/actions/${id}`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify(data ?? null)
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const body = await response.json().catch(() => ({}));
|
|
43
|
+
throw new Error(body.error ?? `Action failed: ${response.statusText}`);
|
|
44
|
+
}
|
|
45
|
+
return response.json();
|
|
46
|
+
};
|
|
47
|
+
callable.actionId = id;
|
|
48
|
+
return callable;
|
|
49
|
+
}
|
|
50
|
+
/** Get all registered actions. Useful for testing. */
|
|
51
|
+
function getRegisteredActions() {
|
|
52
|
+
return actionRegistry;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reset the action registry. Useful for testing.
|
|
56
|
+
* @internal
|
|
57
|
+
*/
|
|
58
|
+
function _resetActions() {
|
|
59
|
+
actionRegistry.clear();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Create a middleware that handles action requests at `/_zero/actions/*`.
|
|
63
|
+
* Mount this before the SSR handler in the server entry.
|
|
64
|
+
*/
|
|
65
|
+
function createActionMiddleware() {
|
|
66
|
+
return async (ctx) => {
|
|
67
|
+
if (!ctx.path.startsWith("/_zero/actions/")) return;
|
|
68
|
+
const actionId = ctx.path.slice(15);
|
|
69
|
+
const action = actionRegistry.get(actionId);
|
|
70
|
+
if (!action) return Response.json({ error: "Action not found" }, { status: 404 });
|
|
71
|
+
if (ctx.req.method !== "POST") return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
72
|
+
return executeAction(action, ctx.req);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async function executeAction(action, req) {
|
|
76
|
+
try {
|
|
77
|
+
const contentType = req.headers.get("content-type") ?? "";
|
|
78
|
+
let formData = null;
|
|
79
|
+
let json = null;
|
|
80
|
+
if (contentType.includes("application/json")) json = await req.json();
|
|
81
|
+
else if (contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded")) formData = await req.formData();
|
|
82
|
+
const result = await action.handler({
|
|
83
|
+
request: req,
|
|
84
|
+
formData,
|
|
85
|
+
json,
|
|
86
|
+
headers: req.headers
|
|
87
|
+
});
|
|
88
|
+
return Response.json(result ?? null);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const message = err instanceof Error ? err.message : "Internal server error";
|
|
91
|
+
return Response.json({ error: message }, { status: 500 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
export { _resetActions, createActionMiddleware, defineAction, getRegisteredActions };
|
|
97
|
+
//# sourceMappingURL=actions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"actions.js","names":[],"sources":["../src/actions.ts"],"sourcesContent":["import type { MiddlewareContext } from '@pyreon/server'\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** Context passed to server action handlers. */\nexport interface ActionContext {\n /** The original request. */\n request: Request\n /** Parsed form data (for form submissions). */\n formData: FormData | null\n /** Parsed JSON body (for JSON submissions). */\n json: unknown\n /** Request headers. */\n headers: Headers\n}\n\n/** A server action handler function. */\nexport type ActionHandler<T = unknown> = (ctx: ActionContext) => T | Promise<T>\n\n/** A registered action with its ID and handler. */\ninterface RegisteredAction {\n id: string\n handler: ActionHandler\n}\n\n/** Client-side callable action returned by defineAction. */\nexport interface Action<T = unknown> {\n /** Call the action with JSON data. */\n (data?: unknown): Promise<T>\n /** The action's unique ID. */\n actionId: string\n}\n\n// ─── Registry ────────────────────────────────────────────────────────────────\n\nconst actionRegistry = new Map<string, RegisteredAction>()\n\n/**\n * Define a server action. Returns a callable function that:\n * - On the **client**: sends a POST request to `/_zero/actions/<id>`\n * - On the **server** (SSR): executes the handler directly (no fetch)\n *\n * @example\n * // In a route file or module:\n * export const createPost = defineAction(async (ctx) => {\n * const data = ctx.json as { title: string; body: string }\n * // ... save to database\n * return { success: true, id: 123 }\n * })\n *\n * // In a component:\n * const result = await createPost({ title: 'Hello', body: '...' })\n */\nexport function defineAction<T = unknown>(handler: ActionHandler<T>): Action<T> {\n const id = `action_${crypto.randomUUID().slice(0, 8)}`\n\n actionRegistry.set(id, { id, handler: handler as ActionHandler })\n\n const callable = async (data?: unknown): Promise<T> => {\n // Server-side: execute handler directly (no network round-trip)\n if (typeof globalThis.window === 'undefined') {\n return handler({\n request: new Request(`http://localhost/_zero/actions/${id}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(data ?? null),\n }),\n formData: null,\n json: data ?? null,\n headers: new Headers({ 'Content-Type': 'application/json' }),\n })\n }\n\n // Client-side: POST to the action endpoint\n const response = await fetch(`/_zero/actions/${id}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(data ?? null),\n })\n if (!response.ok) {\n const body = await response.json().catch(() => ({}))\n throw new Error((body as { error?: string }).error ?? `Action failed: ${response.statusText}`)\n }\n return response.json()\n }\n\n callable.actionId = id\n return callable as Action<T>\n}\n\n/** Get all registered actions. Useful for testing. */\nexport function getRegisteredActions(): Map<string, RegisteredAction> {\n return actionRegistry\n}\n\n/**\n * Reset the action registry. Useful for testing.\n * @internal\n */\nexport function _resetActions(): void {\n actionRegistry.clear()\n}\n\n// ─── Server handler ──────────────────────────────────────────────────────────\n\n/**\n * Create a middleware that handles action requests at `/_zero/actions/*`.\n * Mount this before the SSR handler in the server entry.\n */\nexport function createActionMiddleware(): (\n ctx: MiddlewareContext,\n) => Response | undefined | Promise<Response | undefined> {\n return async (ctx: MiddlewareContext) => {\n if (!ctx.path.startsWith('/_zero/actions/')) return\n\n const actionId = ctx.path.slice('/_zero/actions/'.length)\n const action = actionRegistry.get(actionId)\n\n if (!action) {\n return Response.json({ error: 'Action not found' }, { status: 404 })\n }\n\n if (ctx.req.method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n\n return executeAction(action, ctx.req)\n }\n}\n\nasync function executeAction(action: RegisteredAction, req: Request): Promise<Response> {\n try {\n const contentType = req.headers.get('content-type') ?? ''\n let formData: FormData | null = null\n let json: unknown = null\n\n if (contentType.includes('application/json')) {\n json = await req.json()\n } else if (\n contentType.includes('multipart/form-data') ||\n contentType.includes('application/x-www-form-urlencoded')\n ) {\n formData = await req.formData()\n }\n\n const result = await action.handler({\n request: req,\n formData,\n json,\n headers: req.headers,\n })\n\n return Response.json(result ?? null)\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Internal server error'\n return Response.json({ error: message }, { status: 500 })\n }\n}\n"],"mappings":";AAmCA,MAAM,iCAAiB,IAAI,KAA+B;;;;;;;;;;;;;;;;;AAkB1D,SAAgB,aAA0B,SAAsC;CAC9E,MAAM,KAAK,UAAU,OAAO,YAAY,CAAC,MAAM,GAAG,EAAE;AAEpD,gBAAe,IAAI,IAAI;EAAE;EAAa;EAA0B,CAAC;CAEjE,MAAM,WAAW,OAAO,SAA+B;AAErD,MAAI,OAAO,WAAW,WAAW,YAC/B,QAAO,QAAQ;GACb,SAAS,IAAI,QAAQ,kCAAkC,MAAM;IAC3D,QAAQ;IACR,SAAS,EAAE,gBAAgB,oBAAoB;IAC/C,MAAM,KAAK,UAAU,QAAQ,KAAK;IACnC,CAAC;GACF,UAAU;GACV,MAAM,QAAQ;GACd,SAAS,IAAI,QAAQ,EAAE,gBAAgB,oBAAoB,CAAC;GAC7D,CAAC;EAIJ,MAAM,WAAW,MAAM,MAAM,kBAAkB,MAAM;GACnD,QAAQ;GACR,SAAS,EAAE,gBAAgB,oBAAoB;GAC/C,MAAM,KAAK,UAAU,QAAQ,KAAK;GACnC,CAAC;AACF,MAAI,CAAC,SAAS,IAAI;GAChB,MAAM,OAAO,MAAM,SAAS,MAAM,CAAC,aAAa,EAAE,EAAE;AACpD,SAAM,IAAI,MAAO,KAA4B,SAAS,kBAAkB,SAAS,aAAa;;AAEhG,SAAO,SAAS,MAAM;;AAGxB,UAAS,WAAW;AACpB,QAAO;;;AAIT,SAAgB,uBAAsD;AACpE,QAAO;;;;;;AAOT,SAAgB,gBAAsB;AACpC,gBAAe,OAAO;;;;;;AASxB,SAAgB,yBAE0C;AACxD,QAAO,OAAO,QAA2B;AACvC,MAAI,CAAC,IAAI,KAAK,WAAW,kBAAkB,CAAE;EAE7C,MAAM,WAAW,IAAI,KAAK,MAAM,GAAyB;EACzD,MAAM,SAAS,eAAe,IAAI,SAAS;AAE3C,MAAI,CAAC,OACH,QAAO,SAAS,KAAK,EAAE,OAAO,oBAAoB,EAAE,EAAE,QAAQ,KAAK,CAAC;AAGtE,MAAI,IAAI,IAAI,WAAW,OACrB,QAAO,SAAS,KAAK,EAAE,OAAO,sBAAsB,EAAE,EAAE,QAAQ,KAAK,CAAC;AAGxE,SAAO,cAAc,QAAQ,IAAI,IAAI;;;AAIzC,eAAe,cAAc,QAA0B,KAAiC;AACtF,KAAI;EACF,MAAM,cAAc,IAAI,QAAQ,IAAI,eAAe,IAAI;EACvD,IAAI,WAA4B;EAChC,IAAI,OAAgB;AAEpB,MAAI,YAAY,SAAS,mBAAmB,CAC1C,QAAO,MAAM,IAAI,MAAM;WAEvB,YAAY,SAAS,sBAAsB,IAC3C,YAAY,SAAS,oCAAoC,CAEzD,YAAW,MAAM,IAAI,UAAU;EAGjC,MAAM,SAAS,MAAM,OAAO,QAAQ;GAClC,SAAS;GACT;GACA;GACA,SAAS,IAAI;GACd,CAAC;AAEF,SAAO,SAAS,KAAK,UAAU,KAAK;UAC7B,KAAK;EACZ,MAAM,UAAU,eAAe,QAAQ,IAAI,UAAU;AACrD,SAAO,SAAS,KAAK,EAAE,OAAO,SAAS,EAAE,EAAE,QAAQ,KAAK,CAAC"}
|
package/lib/ai.js
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
//#region src/fs-router.ts
|
|
2
|
+
const ROUTE_EXTENSIONS = [
|
|
3
|
+
".tsx",
|
|
4
|
+
".jsx",
|
|
5
|
+
".ts",
|
|
6
|
+
".js"
|
|
7
|
+
];
|
|
8
|
+
/**
|
|
9
|
+
* Parse a set of file paths (relative to routes dir) into FileRoute objects.
|
|
10
|
+
*
|
|
11
|
+
* @param files Array of file paths like ["index.tsx", "users/[id].tsx"]
|
|
12
|
+
* @param defaultMode Default rendering mode from config
|
|
13
|
+
*/
|
|
14
|
+
function parseFileRoutes(files, defaultMode = "ssr") {
|
|
15
|
+
return files.filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext))).map((filePath) => parseFilePath(filePath, defaultMode)).sort(sortRoutes);
|
|
16
|
+
}
|
|
17
|
+
function parseFilePath(filePath, defaultMode) {
|
|
18
|
+
let route = filePath;
|
|
19
|
+
for (const ext of ROUTE_EXTENSIONS) if (route.endsWith(ext)) {
|
|
20
|
+
route = route.slice(0, -ext.length);
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
const fileName = getFileName(route);
|
|
24
|
+
const isLayout = fileName === "_layout";
|
|
25
|
+
const isError = fileName === "_error";
|
|
26
|
+
const isLoading = fileName === "_loading";
|
|
27
|
+
const isNotFound = fileName === "_404" || fileName === "_not-found";
|
|
28
|
+
const isCatchAll = route.includes("[...");
|
|
29
|
+
const parts = route.split("/");
|
|
30
|
+
parts.pop();
|
|
31
|
+
const dirPath = parts.filter((s) => !(s.startsWith("(") && s.endsWith(")"))).join("/");
|
|
32
|
+
const urlPath = filePathToUrlPath(route);
|
|
33
|
+
return {
|
|
34
|
+
filePath,
|
|
35
|
+
urlPath,
|
|
36
|
+
dirPath,
|
|
37
|
+
depth: urlPath === "/" ? 0 : urlPath.split("/").filter(Boolean).length,
|
|
38
|
+
isLayout,
|
|
39
|
+
isError,
|
|
40
|
+
isLoading,
|
|
41
|
+
isNotFound,
|
|
42
|
+
isCatchAll,
|
|
43
|
+
renderMode: defaultMode
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Convert a file path (without extension) to a URL path pattern.
|
|
48
|
+
*
|
|
49
|
+
* Examples:
|
|
50
|
+
* "index" → "/"
|
|
51
|
+
* "about" → "/about"
|
|
52
|
+
* "users/index" → "/users"
|
|
53
|
+
* "users/[id]" → "/users/:id"
|
|
54
|
+
* "blog/[...slug]" → "/blog/:slug*"
|
|
55
|
+
* "(auth)/login" → "/login" (group stripped)
|
|
56
|
+
* "_layout" → "/" (layout marker)
|
|
57
|
+
*/
|
|
58
|
+
function filePathToUrlPath(filePath) {
|
|
59
|
+
const segments = filePath.split("/");
|
|
60
|
+
const urlSegments = [];
|
|
61
|
+
for (const seg of segments) {
|
|
62
|
+
if (seg.startsWith("(") && seg.endsWith(")")) continue;
|
|
63
|
+
if (seg === "_layout" || seg === "_error" || seg === "_loading" || seg === "_404" || seg === "_not-found") continue;
|
|
64
|
+
if (seg === "index") continue;
|
|
65
|
+
const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
|
|
66
|
+
if (catchAll) {
|
|
67
|
+
urlSegments.push(`:${catchAll[1]}*`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const dynamic = seg.match(/^\[(\w+)\]$/);
|
|
71
|
+
if (dynamic) {
|
|
72
|
+
urlSegments.push(`:${dynamic[1]}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
urlSegments.push(seg);
|
|
76
|
+
}
|
|
77
|
+
return `/${urlSegments.join("/")}` || "/";
|
|
78
|
+
}
|
|
79
|
+
/** Sort routes: static before dynamic, catch-all last. */
|
|
80
|
+
function sortRoutes(a, b) {
|
|
81
|
+
if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1;
|
|
82
|
+
if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1;
|
|
83
|
+
const aDynamic = a.urlPath.includes(":");
|
|
84
|
+
if (aDynamic !== b.urlPath.includes(":")) return aDynamic ? 1 : -1;
|
|
85
|
+
return a.urlPath.localeCompare(b.urlPath);
|
|
86
|
+
}
|
|
87
|
+
function getFileName(filePath) {
|
|
88
|
+
const parts = filePath.split("/");
|
|
89
|
+
return parts[parts.length - 1] ?? "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/ai.ts
|
|
94
|
+
/**
|
|
95
|
+
* Generate llms.txt content from route files and config.
|
|
96
|
+
*
|
|
97
|
+
* Format follows the llms.txt proposal:
|
|
98
|
+
* ```
|
|
99
|
+
* # {name}
|
|
100
|
+
* > {description}
|
|
101
|
+
*
|
|
102
|
+
* ## Pages
|
|
103
|
+
* - [/about](/about): About page
|
|
104
|
+
*
|
|
105
|
+
* ## API
|
|
106
|
+
* - GET /api/posts: List posts
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* @internal Exported for testing.
|
|
110
|
+
*/
|
|
111
|
+
function generateLlmsTxt(routeFiles, apiFiles, config) {
|
|
112
|
+
const lines = [];
|
|
113
|
+
lines.push(`# ${config.name}`);
|
|
114
|
+
lines.push(`> ${config.description}`);
|
|
115
|
+
lines.push("");
|
|
116
|
+
const routes = parseFileRoutes(routeFiles);
|
|
117
|
+
const pages = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && !r.isCatchAll && !r.urlPath.includes(":"));
|
|
118
|
+
if (pages.length > 0) {
|
|
119
|
+
lines.push("## Pages");
|
|
120
|
+
lines.push("");
|
|
121
|
+
for (const page of pages) {
|
|
122
|
+
const desc = config.pageDescriptions?.[page.urlPath];
|
|
123
|
+
const url = `${config.origin}${page.urlPath === "/" ? "" : page.urlPath}`;
|
|
124
|
+
if (desc) lines.push(`- [${page.urlPath}](${url}): ${desc}`);
|
|
125
|
+
else lines.push(`- [${page.urlPath}](${url})`);
|
|
126
|
+
}
|
|
127
|
+
lines.push("");
|
|
128
|
+
}
|
|
129
|
+
const dynamicRoutes = routes.filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound && (r.urlPath.includes(":") || r.isCatchAll));
|
|
130
|
+
if (dynamicRoutes.length > 0) {
|
|
131
|
+
lines.push("## Dynamic Pages");
|
|
132
|
+
lines.push("");
|
|
133
|
+
for (const route of dynamicRoutes) {
|
|
134
|
+
const desc = config.pageDescriptions?.[route.urlPath];
|
|
135
|
+
if (desc) lines.push(`- ${route.urlPath}: ${desc}`);
|
|
136
|
+
else lines.push(`- ${route.urlPath}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push("");
|
|
139
|
+
}
|
|
140
|
+
const apiPatterns = parseApiFiles(apiFiles);
|
|
141
|
+
if (apiPatterns.length > 0 || config.apiDescriptions) {
|
|
142
|
+
lines.push("## API Endpoints");
|
|
143
|
+
lines.push("");
|
|
144
|
+
if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) lines.push(`- ${endpoint}: ${desc}`);
|
|
145
|
+
const describedPatterns = new Set(Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/, "")));
|
|
146
|
+
for (const pattern of apiPatterns) if (!describedPatterns.has(pattern)) lines.push(`- ${pattern}`);
|
|
147
|
+
lines.push("");
|
|
148
|
+
}
|
|
149
|
+
if (config.llmsExtra) {
|
|
150
|
+
lines.push(config.llmsExtra);
|
|
151
|
+
lines.push("");
|
|
152
|
+
}
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Generate llms-full.txt — expanded version with more detail.
|
|
157
|
+
* Includes all route metadata and API descriptions.
|
|
158
|
+
*
|
|
159
|
+
* @internal Exported for testing.
|
|
160
|
+
*/
|
|
161
|
+
function generateLlmsFullTxt(routeFiles, apiFiles, config) {
|
|
162
|
+
const lines = [];
|
|
163
|
+
lines.push(`# ${config.name} — Full Reference`);
|
|
164
|
+
lines.push(`> ${config.description}`);
|
|
165
|
+
lines.push("");
|
|
166
|
+
lines.push(`Base URL: ${config.origin}`);
|
|
167
|
+
lines.push("");
|
|
168
|
+
const pages = parseFileRoutes(routeFiles).filter((r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound);
|
|
169
|
+
if (pages.length > 0) {
|
|
170
|
+
lines.push("## All Routes");
|
|
171
|
+
lines.push("");
|
|
172
|
+
for (const page of pages) {
|
|
173
|
+
const desc = config.pageDescriptions?.[page.urlPath] ?? "";
|
|
174
|
+
const dynamic = page.urlPath.includes(":") ? " (dynamic)" : "";
|
|
175
|
+
const catchAll = page.isCatchAll ? " (catch-all)" : "";
|
|
176
|
+
lines.push(`### ${page.urlPath}${dynamic}${catchAll}`);
|
|
177
|
+
if (desc) lines.push(desc);
|
|
178
|
+
lines.push(`- File: ${page.filePath}`);
|
|
179
|
+
lines.push(`- Render mode: ${page.renderMode}`);
|
|
180
|
+
lines.push("");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (config.apiDescriptions) {
|
|
184
|
+
lines.push("## API Reference");
|
|
185
|
+
lines.push("");
|
|
186
|
+
for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
187
|
+
lines.push(`### ${endpoint}`);
|
|
188
|
+
lines.push(desc);
|
|
189
|
+
lines.push("");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (config.llmsExtra) {
|
|
193
|
+
lines.push("## Additional Information");
|
|
194
|
+
lines.push("");
|
|
195
|
+
lines.push(config.llmsExtra);
|
|
196
|
+
lines.push("");
|
|
197
|
+
}
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Auto-infer JSON-LD structured data from page metadata.
|
|
202
|
+
*
|
|
203
|
+
* Returns an array of JSON-LD objects (multiple schemas can apply to one page).
|
|
204
|
+
* For example, an article page gets both `Article` and `BreadcrumbList`.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```tsx
|
|
208
|
+
* const schemas = inferJsonLd({
|
|
209
|
+
* url: "https://example.com/blog/my-post",
|
|
210
|
+
* title: "My Post",
|
|
211
|
+
* description: "A great article",
|
|
212
|
+
* type: "article",
|
|
213
|
+
* author: "Vit Bokisch",
|
|
214
|
+
* publishedTime: "2026-03-31",
|
|
215
|
+
* })
|
|
216
|
+
* // → [Article schema, BreadcrumbList schema]
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
function inferJsonLd(options) {
|
|
220
|
+
const schemas = [];
|
|
221
|
+
if (options.type === "article") {
|
|
222
|
+
const article = {
|
|
223
|
+
"@context": "https://schema.org",
|
|
224
|
+
"@type": "Article",
|
|
225
|
+
headline: options.title,
|
|
226
|
+
url: options.url
|
|
227
|
+
};
|
|
228
|
+
if (options.description) article.description = options.description;
|
|
229
|
+
if (options.image) article.image = options.image;
|
|
230
|
+
if (options.publishedTime) article.datePublished = options.publishedTime;
|
|
231
|
+
if (options.author) article.author = {
|
|
232
|
+
"@type": "Person",
|
|
233
|
+
name: options.author
|
|
234
|
+
};
|
|
235
|
+
if (options.tags && options.tags.length > 0) article.keywords = options.tags.join(", ");
|
|
236
|
+
if (options.siteName) article.publisher = {
|
|
237
|
+
"@type": "Organization",
|
|
238
|
+
name: options.siteName
|
|
239
|
+
};
|
|
240
|
+
schemas.push(article);
|
|
241
|
+
} else if (options.type === "product") {
|
|
242
|
+
const product = {
|
|
243
|
+
"@context": "https://schema.org",
|
|
244
|
+
"@type": "Product",
|
|
245
|
+
name: options.title,
|
|
246
|
+
url: options.url
|
|
247
|
+
};
|
|
248
|
+
if (options.description) product.description = options.description;
|
|
249
|
+
if (options.image) product.image = options.image;
|
|
250
|
+
schemas.push(product);
|
|
251
|
+
} else {
|
|
252
|
+
const webpage = {
|
|
253
|
+
"@context": "https://schema.org",
|
|
254
|
+
"@type": "WebPage",
|
|
255
|
+
name: options.title,
|
|
256
|
+
url: options.url
|
|
257
|
+
};
|
|
258
|
+
if (options.description) webpage.description = options.description;
|
|
259
|
+
if (options.image) webpage.thumbnailUrl = options.image;
|
|
260
|
+
schemas.push(webpage);
|
|
261
|
+
}
|
|
262
|
+
if (options.breadcrumbs && options.breadcrumbs.length > 0) schemas.push({
|
|
263
|
+
"@context": "https://schema.org",
|
|
264
|
+
"@type": "BreadcrumbList",
|
|
265
|
+
itemListElement: options.breadcrumbs.map((bc, i) => ({
|
|
266
|
+
"@type": "ListItem",
|
|
267
|
+
position: i + 1,
|
|
268
|
+
name: bc.name,
|
|
269
|
+
item: bc.url
|
|
270
|
+
}))
|
|
271
|
+
});
|
|
272
|
+
else {
|
|
273
|
+
const urlObj = safeParseUrl(options.url);
|
|
274
|
+
if (urlObj) {
|
|
275
|
+
const segments = urlObj.pathname.split("/").filter(Boolean);
|
|
276
|
+
if (segments.length > 0) {
|
|
277
|
+
const items = [{
|
|
278
|
+
"@type": "ListItem",
|
|
279
|
+
position: 1,
|
|
280
|
+
name: "Home",
|
|
281
|
+
item: urlObj.origin
|
|
282
|
+
}];
|
|
283
|
+
let path = "";
|
|
284
|
+
for (let i = 0; i < segments.length; i++) {
|
|
285
|
+
path += `/${segments[i]}`;
|
|
286
|
+
items.push({
|
|
287
|
+
"@type": "ListItem",
|
|
288
|
+
position: i + 2,
|
|
289
|
+
name: capitalize(segments[i].replace(/-/g, " ")),
|
|
290
|
+
item: `${urlObj.origin}${path}`
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
schemas.push({
|
|
294
|
+
"@context": "https://schema.org",
|
|
295
|
+
"@type": "BreadcrumbList",
|
|
296
|
+
itemListElement: items
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return schemas;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Generate an OpenAI-compatible AI plugin manifest.
|
|
305
|
+
*
|
|
306
|
+
* Follows the /.well-known/ai-plugin.json spec.
|
|
307
|
+
*
|
|
308
|
+
* @internal Exported for testing.
|
|
309
|
+
*/
|
|
310
|
+
function generateAiPluginManifest(config) {
|
|
311
|
+
return {
|
|
312
|
+
schema_version: "v1",
|
|
313
|
+
name_for_human: config.name,
|
|
314
|
+
name_for_model: config.name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, ""),
|
|
315
|
+
description_for_human: config.description,
|
|
316
|
+
description_for_model: config.description,
|
|
317
|
+
auth: { type: "none" },
|
|
318
|
+
api: {
|
|
319
|
+
type: "openapi",
|
|
320
|
+
url: `${config.origin}/.well-known/openapi.yaml`
|
|
321
|
+
},
|
|
322
|
+
logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,
|
|
323
|
+
contact_email: config.contactEmail ?? "",
|
|
324
|
+
legal_info_url: config.legalUrl ?? `${config.origin}/legal`
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Generate a minimal OpenAPI 3.0 spec from API route descriptions.
|
|
329
|
+
*
|
|
330
|
+
* @internal Exported for testing.
|
|
331
|
+
*/
|
|
332
|
+
function generateOpenApiSpec(apiFiles, config) {
|
|
333
|
+
const paths = {};
|
|
334
|
+
if (config.apiDescriptions) for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
335
|
+
const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/);
|
|
336
|
+
if (match) {
|
|
337
|
+
const method = match[1].toLowerCase();
|
|
338
|
+
const openApiPath = match[2].replace(/:(\w+)/g, "{$1}");
|
|
339
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
340
|
+
paths[openApiPath][method] = {
|
|
341
|
+
summary: desc,
|
|
342
|
+
responses: { "200": { description: "Success" } }
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (const pattern of parseApiFiles(apiFiles)) {
|
|
347
|
+
const openApiPath = pattern.replace(/:(\w+)/g, "{$1}");
|
|
348
|
+
if (!paths[openApiPath]) paths[openApiPath] = { get: {
|
|
349
|
+
summary: `${openApiPath} endpoint`,
|
|
350
|
+
responses: { "200": { description: "Success" } }
|
|
351
|
+
} };
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
openapi: "3.0.0",
|
|
355
|
+
info: {
|
|
356
|
+
title: config.name,
|
|
357
|
+
description: config.description,
|
|
358
|
+
version: "1.0.0"
|
|
359
|
+
},
|
|
360
|
+
servers: [{ url: config.origin }],
|
|
361
|
+
paths
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* AI integration Vite plugin.
|
|
366
|
+
*
|
|
367
|
+
* Generates at build time:
|
|
368
|
+
* - `/llms.txt` — concise site summary for AI agents
|
|
369
|
+
* - `/llms-full.txt` — detailed reference for AI agents
|
|
370
|
+
* - `/.well-known/ai-plugin.json` — OpenAI plugin manifest
|
|
371
|
+
* - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes
|
|
372
|
+
*
|
|
373
|
+
* In dev, serves these files via middleware.
|
|
374
|
+
*
|
|
375
|
+
* @example
|
|
376
|
+
* ```ts
|
|
377
|
+
* import { aiPlugin } from "@pyreon/zero/ai"
|
|
378
|
+
*
|
|
379
|
+
* export default {
|
|
380
|
+
* plugins: [
|
|
381
|
+
* aiPlugin({
|
|
382
|
+
* name: "My App",
|
|
383
|
+
* origin: "https://example.com",
|
|
384
|
+
* description: "A modern web application",
|
|
385
|
+
* apiDescriptions: {
|
|
386
|
+
* "GET /api/posts": "List blog posts",
|
|
387
|
+
* "GET /api/posts/:id": "Get post by ID",
|
|
388
|
+
* },
|
|
389
|
+
* }),
|
|
390
|
+
* ],
|
|
391
|
+
* }
|
|
392
|
+
* ```
|
|
393
|
+
*/
|
|
394
|
+
function aiPlugin(config) {
|
|
395
|
+
let root = "";
|
|
396
|
+
let isBuild = false;
|
|
397
|
+
let routeFiles = [];
|
|
398
|
+
let apiFiles = [];
|
|
399
|
+
return {
|
|
400
|
+
name: "pyreon-zero-ai",
|
|
401
|
+
enforce: "post",
|
|
402
|
+
configResolved(resolvedConfig) {
|
|
403
|
+
root = resolvedConfig.root;
|
|
404
|
+
isBuild = resolvedConfig.command === "build";
|
|
405
|
+
},
|
|
406
|
+
async buildStart() {
|
|
407
|
+
try {
|
|
408
|
+
const { join } = await import("node:path");
|
|
409
|
+
const routesDir = join(root, config.routesDir ?? "src/routes");
|
|
410
|
+
const apiDir = join(root, config.apiDir ?? "src/api");
|
|
411
|
+
routeFiles = await scanDir(routesDir, routesDir);
|
|
412
|
+
apiFiles = await scanDir(apiDir, apiDir);
|
|
413
|
+
} catch {}
|
|
414
|
+
},
|
|
415
|
+
configureServer(server) {
|
|
416
|
+
server.middlewares.use(async (req, res, next) => {
|
|
417
|
+
const url = req.url ?? "";
|
|
418
|
+
if (url === "/llms.txt") {
|
|
419
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
420
|
+
res.end(generateLlmsTxt(routeFiles, apiFiles, config));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (url === "/llms-full.txt") {
|
|
424
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
425
|
+
res.end(generateLlmsFullTxt(routeFiles, apiFiles, config));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (url === "/.well-known/ai-plugin.json") {
|
|
429
|
+
res.setHeader("Content-Type", "application/json");
|
|
430
|
+
res.end(JSON.stringify(generateAiPluginManifest(config), null, 2));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (url === "/.well-known/openapi.yaml" || url === "/.well-known/openapi.json") {
|
|
434
|
+
res.setHeader("Content-Type", "application/json");
|
|
435
|
+
res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
next();
|
|
439
|
+
});
|
|
440
|
+
},
|
|
441
|
+
async generateBundle() {
|
|
442
|
+
if (!isBuild) return;
|
|
443
|
+
this.emitFile({
|
|
444
|
+
type: "asset",
|
|
445
|
+
fileName: "llms.txt",
|
|
446
|
+
source: generateLlmsTxt(routeFiles, apiFiles, config)
|
|
447
|
+
});
|
|
448
|
+
this.emitFile({
|
|
449
|
+
type: "asset",
|
|
450
|
+
fileName: "llms-full.txt",
|
|
451
|
+
source: generateLlmsFullTxt(routeFiles, apiFiles, config)
|
|
452
|
+
});
|
|
453
|
+
this.emitFile({
|
|
454
|
+
type: "asset",
|
|
455
|
+
fileName: ".well-known/ai-plugin.json",
|
|
456
|
+
source: JSON.stringify(generateAiPluginManifest(config), null, 2)
|
|
457
|
+
});
|
|
458
|
+
this.emitFile({
|
|
459
|
+
type: "asset",
|
|
460
|
+
fileName: ".well-known/openapi.json",
|
|
461
|
+
source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2)
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function parseApiFiles(files) {
|
|
467
|
+
return files.filter((f) => f.endsWith(".ts") || f.endsWith(".js")).map((f) => {
|
|
468
|
+
let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "");
|
|
469
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
470
|
+
path = path.replace(/\[\.\.\.(\w+)\]/g, ":$1*").replace(/\[(\w+)\]/g, ":$1");
|
|
471
|
+
return `/api${path === "/" ? "" : path}`;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
async function scanDir(dir, base) {
|
|
475
|
+
const { readdir, stat } = await import("node:fs/promises");
|
|
476
|
+
const { join, relative } = await import("node:path");
|
|
477
|
+
try {
|
|
478
|
+
const entries = await readdir(dir);
|
|
479
|
+
const files = [];
|
|
480
|
+
for (const entry of entries) {
|
|
481
|
+
const full = join(dir, entry);
|
|
482
|
+
if ((await stat(full)).isDirectory()) files.push(...await scanDir(full, base));
|
|
483
|
+
else files.push(relative(base, full));
|
|
484
|
+
}
|
|
485
|
+
return files;
|
|
486
|
+
} catch {
|
|
487
|
+
return [];
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function safeParseUrl(url) {
|
|
491
|
+
try {
|
|
492
|
+
return new URL(url);
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function capitalize(s) {
|
|
498
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
//#endregion
|
|
502
|
+
export { aiPlugin, generateAiPluginManifest, generateLlmsFullTxt, generateLlmsTxt, generateOpenApiSpec, inferJsonLd };
|
|
503
|
+
//# sourceMappingURL=ai.js.map
|