@pyreon/zero 0.1.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/LICENSE +21 -0
- package/README.md +53 -0
- package/lib/cache.js +80 -0
- package/lib/cache.js.map +1 -0
- package/lib/client.js +58 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +35 -0
- package/lib/config.js.map +1 -0
- package/lib/font.js +251 -0
- package/lib/font.js.map +1 -0
- package/lib/fs-router-BkbIWqek.js +30 -0
- package/lib/fs-router-BkbIWqek.js.map +1 -0
- package/lib/fs-router-jfd1QGLB.js +261 -0
- package/lib/fs-router-jfd1QGLB.js.map +1 -0
- package/lib/image-plugin.js +289 -0
- package/lib/image-plugin.js.map +1 -0
- package/lib/image.js +113 -0
- package/lib/image.js.map +1 -0
- package/lib/index.js +1665 -0
- package/lib/index.js.map +1 -0
- package/lib/link.js +186 -0
- package/lib/link.js.map +1 -0
- package/lib/script.js +102 -0
- package/lib/script.js.map +1 -0
- package/lib/seo.js +136 -0
- package/lib/seo.js.map +1 -0
- package/lib/theme.js +165 -0
- package/lib/theme.js.map +1 -0
- package/lib/types/adapters/bun.d.ts +6 -0
- package/lib/types/adapters/bun.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +10 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/node.d.ts +6 -0
- package/lib/types/adapters/node.d.ts.map +1 -0
- package/lib/types/adapters/static.d.ts +7 -0
- package/lib/types/adapters/static.d.ts.map +1 -0
- package/lib/types/app.d.ts +24 -0
- package/lib/types/app.d.ts.map +1 -0
- package/lib/types/cache.d.ts +54 -0
- package/lib/types/cache.d.ts.map +1 -0
- package/lib/types/client.d.ts +19 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/config.d.ts +18 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/entry-server.d.ts +26 -0
- package/lib/types/entry-server.d.ts.map +1 -0
- package/lib/types/font.d.ts +119 -0
- package/lib/types/font.d.ts.map +1 -0
- package/lib/types/fs-router.d.ts +33 -0
- package/lib/types/fs-router.d.ts.map +1 -0
- package/lib/types/image-plugin.d.ts +79 -0
- package/lib/types/image-plugin.d.ts.map +1 -0
- package/lib/types/image.d.ts +50 -0
- package/lib/types/image.d.ts.map +1 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/isr.d.ts +9 -0
- package/lib/types/isr.d.ts.map +1 -0
- package/lib/types/link.d.ts +116 -0
- package/lib/types/link.d.ts.map +1 -0
- package/lib/types/script.d.ts +34 -0
- package/lib/types/script.d.ts.map +1 -0
- package/lib/types/seo.d.ts +88 -0
- package/lib/types/seo.d.ts.map +1 -0
- package/lib/types/theme.d.ts +38 -0
- package/lib/types/theme.d.ts.map +1 -0
- package/lib/types/types.d.ts +104 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/utils/use-intersection-observer.d.ts +10 -0
- package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
- package/lib/types/utils/with-headers.d.ts +6 -0
- package/lib/types/utils/with-headers.d.ts.map +1 -0
- package/lib/types/vite-plugin.d.ts +17 -0
- package/lib/types/vite-plugin.d.ts.map +1 -0
- package/package.json +100 -0
- package/src/adapters/bun.ts +65 -0
- package/src/adapters/index.ts +29 -0
- package/src/adapters/node.ts +113 -0
- package/src/adapters/static.ts +17 -0
- package/src/app.ts +62 -0
- package/src/cache.ts +149 -0
- package/src/client.ts +43 -0
- package/src/config.ts +36 -0
- package/src/entry-server.ts +51 -0
- package/src/font.ts +461 -0
- package/src/fs-router.ts +380 -0
- package/src/image-plugin.ts +452 -0
- package/src/image.tsx +167 -0
- package/src/index.ts +119 -0
- package/src/isr.ts +95 -0
- package/src/link.tsx +266 -0
- package/src/script.tsx +133 -0
- package/src/seo.ts +281 -0
- package/src/sharp.d.ts +20 -0
- package/src/theme.tsx +162 -0
- package/src/types.ts +130 -0
- package/src/utils/use-intersection-observer.ts +36 -0
- package/src/utils/with-headers.ts +16 -0
- package/src/vite-plugin.ts +92 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1665 @@
|
|
|
1
|
+
import { a as scanRouteFiles, i as parseFileRoutes, r as generateRouteModule, t as filePathToUrlPath } from "./fs-router-jfd1QGLB.js";
|
|
2
|
+
import { Fragment, createRef, h, onMount, onUnmount } from "@pyreon/core";
|
|
3
|
+
import { HeadProvider } from "@pyreon/head";
|
|
4
|
+
import { RouterProvider, RouterView, createRouter, useRouter } from "@pyreon/router";
|
|
5
|
+
import { createHandler } from "@pyreon/server";
|
|
6
|
+
import { effect, signal } from "@pyreon/reactivity";
|
|
7
|
+
import { jsx, jsxs } from "@pyreon/core/jsx-runtime";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { basename, extname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
//#region src/app.ts
|
|
13
|
+
/**
|
|
14
|
+
* Create a full Zero app — assembles router, head provider, and root layout.
|
|
15
|
+
*
|
|
16
|
+
* Used internally by entry-server and entry-client.
|
|
17
|
+
*/
|
|
18
|
+
function createApp(options) {
|
|
19
|
+
const router = createRouter({
|
|
20
|
+
routes: options.routes,
|
|
21
|
+
mode: options.routerMode ?? "history",
|
|
22
|
+
url: options.url,
|
|
23
|
+
scrollBehavior: "top"
|
|
24
|
+
});
|
|
25
|
+
const Layout = options.layout ?? DefaultLayout;
|
|
26
|
+
function App() {
|
|
27
|
+
return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
App,
|
|
31
|
+
router
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function DefaultLayout(props) {
|
|
35
|
+
return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/entry-server.ts
|
|
40
|
+
/**
|
|
41
|
+
* Create the SSR request handler for production.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* import { routes } from "virtual:zero/routes"
|
|
45
|
+
* import { createServer } from "@pyreon/zero"
|
|
46
|
+
*
|
|
47
|
+
* export default createServer({ routes })
|
|
48
|
+
*/
|
|
49
|
+
function createServer(options) {
|
|
50
|
+
const config = options.config ?? {};
|
|
51
|
+
const allMiddleware = [...config.middleware ?? [], ...options.middleware ?? []];
|
|
52
|
+
const { App } = createApp({
|
|
53
|
+
routes: options.routes,
|
|
54
|
+
routerMode: "history"
|
|
55
|
+
});
|
|
56
|
+
return createHandler({
|
|
57
|
+
App,
|
|
58
|
+
routes: options.routes,
|
|
59
|
+
middleware: allMiddleware,
|
|
60
|
+
mode: config.ssr?.mode ?? "string",
|
|
61
|
+
template: options.template,
|
|
62
|
+
clientEntry: options.clientEntry
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/config.ts
|
|
68
|
+
/**
|
|
69
|
+
* Define a Zero configuration.
|
|
70
|
+
* Used in `zero.config.ts` at the project root.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* import { defineConfig } from "@pyreon/zero/config"
|
|
74
|
+
*
|
|
75
|
+
* export default defineConfig({
|
|
76
|
+
* mode: "ssr",
|
|
77
|
+
* ssr: { mode: "stream" },
|
|
78
|
+
* port: 3000,
|
|
79
|
+
* })
|
|
80
|
+
*/
|
|
81
|
+
function defineConfig(config) {
|
|
82
|
+
return config;
|
|
83
|
+
}
|
|
84
|
+
/** Merge user config with defaults. */
|
|
85
|
+
function resolveConfig(userConfig = {}) {
|
|
86
|
+
return {
|
|
87
|
+
mode: "ssr",
|
|
88
|
+
base: "/",
|
|
89
|
+
port: 3e3,
|
|
90
|
+
adapter: "node",
|
|
91
|
+
...userConfig,
|
|
92
|
+
ssr: {
|
|
93
|
+
mode: "string",
|
|
94
|
+
...userConfig.ssr
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/vite-plugin.ts
|
|
101
|
+
const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
|
|
102
|
+
const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
|
|
103
|
+
/**
|
|
104
|
+
* Zero Vite plugin — adds file-based routing and zero-config conventions
|
|
105
|
+
* on top of @pyreon/vite-plugin.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* // vite.config.ts
|
|
109
|
+
* import pyreon from "@pyreon/vite-plugin"
|
|
110
|
+
* import zero from "@pyreon/zero"
|
|
111
|
+
*
|
|
112
|
+
* export default {
|
|
113
|
+
* plugins: [pyreon(), zero()],
|
|
114
|
+
* }
|
|
115
|
+
*/
|
|
116
|
+
function zeroPlugin(userConfig = {}) {
|
|
117
|
+
const config = resolveConfig(userConfig);
|
|
118
|
+
let routesDir;
|
|
119
|
+
let root;
|
|
120
|
+
return {
|
|
121
|
+
name: "pyreon-zero",
|
|
122
|
+
enforce: "pre",
|
|
123
|
+
_zeroConfig: userConfig,
|
|
124
|
+
configResolved(resolvedConfig) {
|
|
125
|
+
root = resolvedConfig.root;
|
|
126
|
+
routesDir = `${root}/src/routes`;
|
|
127
|
+
},
|
|
128
|
+
resolveId(id) {
|
|
129
|
+
if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID;
|
|
130
|
+
},
|
|
131
|
+
async load(id) {
|
|
132
|
+
if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
|
|
133
|
+
return generateRouteModule(await scanRouteFiles(routesDir), routesDir);
|
|
134
|
+
} catch (_err) {
|
|
135
|
+
return `export const routes = []`;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
configureServer(server) {
|
|
139
|
+
server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`);
|
|
140
|
+
server.watcher.on("all", (event, path) => {
|
|
141
|
+
if (path.startsWith(routesDir) && (event === "add" || event === "unlink")) {
|
|
142
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ROUTES_ID);
|
|
143
|
+
if (mod) {
|
|
144
|
+
server.moduleGraph.invalidateModule(mod);
|
|
145
|
+
server.ws.send({ type: "full-reload" });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
config() {
|
|
151
|
+
return {
|
|
152
|
+
resolve: { conditions: ["bun"] },
|
|
153
|
+
server: { port: config.port },
|
|
154
|
+
define: {
|
|
155
|
+
__ZERO_MODE__: JSON.stringify(config.mode),
|
|
156
|
+
__ZERO_BASE__: JSON.stringify(config.base)
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/isr.ts
|
|
165
|
+
/**
|
|
166
|
+
* In-memory ISR cache with stale-while-revalidate semantics.
|
|
167
|
+
*
|
|
168
|
+
* Wraps an SSR handler and caches responses per URL path.
|
|
169
|
+
* Serves stale content immediately while revalidating in the background.
|
|
170
|
+
*/
|
|
171
|
+
function createISRHandler(handler, config) {
|
|
172
|
+
const cache = /* @__PURE__ */ new Map();
|
|
173
|
+
const revalidating = /* @__PURE__ */ new Set();
|
|
174
|
+
const revalidateMs = config.revalidate * 1e3;
|
|
175
|
+
async function revalidate(url) {
|
|
176
|
+
const key = url.pathname;
|
|
177
|
+
if (revalidating.has(key)) return;
|
|
178
|
+
revalidating.add(key);
|
|
179
|
+
try {
|
|
180
|
+
const res = await handler(new Request(url.href, { method: "GET" }));
|
|
181
|
+
const html = await res.text();
|
|
182
|
+
const headers = {};
|
|
183
|
+
res.headers.forEach((v, k) => {
|
|
184
|
+
headers[k] = v;
|
|
185
|
+
});
|
|
186
|
+
cache.set(key, {
|
|
187
|
+
html,
|
|
188
|
+
headers,
|
|
189
|
+
timestamp: Date.now()
|
|
190
|
+
});
|
|
191
|
+
} catch {} finally {
|
|
192
|
+
revalidating.delete(key);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return async (req) => {
|
|
196
|
+
if (req.method !== "GET") return handler(req);
|
|
197
|
+
const url = new URL(req.url);
|
|
198
|
+
const key = url.pathname;
|
|
199
|
+
const entry = cache.get(key);
|
|
200
|
+
if (entry) {
|
|
201
|
+
const age = Date.now() - entry.timestamp;
|
|
202
|
+
if (age > revalidateMs) revalidate(url);
|
|
203
|
+
return new Response(entry.html, {
|
|
204
|
+
status: 200,
|
|
205
|
+
headers: {
|
|
206
|
+
...entry.headers,
|
|
207
|
+
"content-type": "text/html; charset=utf-8",
|
|
208
|
+
"x-isr-cache": age > revalidateMs ? "STALE" : "HIT",
|
|
209
|
+
"x-isr-age": String(Math.round(age / 1e3))
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const res = await handler(req);
|
|
214
|
+
const html = await res.text();
|
|
215
|
+
const headers = {};
|
|
216
|
+
res.headers.forEach((v, k) => {
|
|
217
|
+
headers[k] = v;
|
|
218
|
+
});
|
|
219
|
+
cache.set(key, {
|
|
220
|
+
html,
|
|
221
|
+
headers,
|
|
222
|
+
timestamp: Date.now()
|
|
223
|
+
});
|
|
224
|
+
return new Response(html, {
|
|
225
|
+
status: 200,
|
|
226
|
+
headers: {
|
|
227
|
+
...headers,
|
|
228
|
+
"content-type": "text/html; charset=utf-8",
|
|
229
|
+
"x-isr-cache": "MISS"
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/adapters/bun.ts
|
|
237
|
+
/**
|
|
238
|
+
* Bun adapter — generates a standalone Bun.serve() entry.
|
|
239
|
+
*/
|
|
240
|
+
function bunAdapter() {
|
|
241
|
+
return {
|
|
242
|
+
name: "bun",
|
|
243
|
+
async build(options) {
|
|
244
|
+
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
245
|
+
const { join } = await import("node:path");
|
|
246
|
+
const outDir = options.outDir;
|
|
247
|
+
await mkdir(outDir, { recursive: true });
|
|
248
|
+
await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
|
|
249
|
+
await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
|
|
250
|
+
const port = options.config.port ?? 3e3;
|
|
251
|
+
const serverEntry = `
|
|
252
|
+
const handler = (await import("./server/entry-server.js")).default
|
|
253
|
+
const clientDir = new URL("./client/", import.meta.url).pathname
|
|
254
|
+
|
|
255
|
+
Bun.serve({
|
|
256
|
+
port: ${port},
|
|
257
|
+
async fetch(req) {
|
|
258
|
+
const url = new URL(req.url)
|
|
259
|
+
|
|
260
|
+
// Try static files first
|
|
261
|
+
if (req.method === "GET") {
|
|
262
|
+
const filePath = clientDir + (url.pathname === "/" ? "index.html" : url.pathname)
|
|
263
|
+
// Prevent path traversal — ensure resolved path stays within clientDir
|
|
264
|
+
const resolved = Bun.resolveSync(filePath, ".")
|
|
265
|
+
if (!resolved.startsWith(Bun.resolveSync(clientDir, "."))) {
|
|
266
|
+
return new Response("Forbidden", { status: 403 })
|
|
267
|
+
}
|
|
268
|
+
const file = Bun.file(filePath)
|
|
269
|
+
if (await file.exists()) {
|
|
270
|
+
return new Response(file, {
|
|
271
|
+
headers: {
|
|
272
|
+
"cache-control": filePath.endsWith(".js") || filePath.endsWith(".css")
|
|
273
|
+
? "public, max-age=31536000, immutable"
|
|
274
|
+
: "public, max-age=3600",
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Fall through to SSR handler
|
|
281
|
+
return handler(req)
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
|
|
286
|
+
`.trimStart();
|
|
287
|
+
await writeFile(join(outDir, "index.ts"), serverEntry);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/adapters/node.ts
|
|
294
|
+
/**
|
|
295
|
+
* Node.js adapter — generates a standalone server entry using node:http.
|
|
296
|
+
*/
|
|
297
|
+
function nodeAdapter() {
|
|
298
|
+
return {
|
|
299
|
+
name: "node",
|
|
300
|
+
async build(options) {
|
|
301
|
+
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
302
|
+
const { join } = await import("node:path");
|
|
303
|
+
const outDir = options.outDir;
|
|
304
|
+
await mkdir(outDir, { recursive: true });
|
|
305
|
+
await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
|
|
306
|
+
await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
|
|
307
|
+
const port = options.config.port ?? 3e3;
|
|
308
|
+
const serverEntry = `
|
|
309
|
+
import { createServer } from "node:http"
|
|
310
|
+
import { readFile } from "node:fs/promises"
|
|
311
|
+
import { join, extname } from "node:path"
|
|
312
|
+
import { fileURLToPath } from "node:url"
|
|
313
|
+
|
|
314
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
|
315
|
+
const handler = (await import("./server/entry-server.js")).default
|
|
316
|
+
const clientDir = join(__dirname, "client")
|
|
317
|
+
|
|
318
|
+
const MIME_TYPES = {
|
|
319
|
+
".html": "text/html",
|
|
320
|
+
".js": "application/javascript",
|
|
321
|
+
".css": "text/css",
|
|
322
|
+
".json": "application/json",
|
|
323
|
+
".png": "image/png",
|
|
324
|
+
".jpg": "image/jpeg",
|
|
325
|
+
".svg": "image/svg+xml",
|
|
326
|
+
".woff2": "font/woff2",
|
|
327
|
+
".woff": "font/woff",
|
|
328
|
+
".ico": "image/x-icon",
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const server = createServer(async (req, res) => {
|
|
332
|
+
const url = new URL(req.url ?? "/", "http://localhost")
|
|
333
|
+
|
|
334
|
+
// Try to serve static files first
|
|
335
|
+
if (req.method === "GET") {
|
|
336
|
+
try {
|
|
337
|
+
const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
|
|
338
|
+
// Prevent path traversal — ensure resolved path stays within clientDir
|
|
339
|
+
const { resolve } = await import("node:path")
|
|
340
|
+
const resolved = resolve(filePath)
|
|
341
|
+
if (!resolved.startsWith(resolve(clientDir))) {
|
|
342
|
+
res.writeHead(403)
|
|
343
|
+
res.end("Forbidden")
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
const ext = extname(filePath)
|
|
347
|
+
if (ext && ext !== ".html") {
|
|
348
|
+
const data = await readFile(filePath)
|
|
349
|
+
const mime = MIME_TYPES[ext] || "application/octet-stream"
|
|
350
|
+
res.writeHead(200, {
|
|
351
|
+
"content-type": mime,
|
|
352
|
+
"cache-control": ext === ".js" || ext === ".css"
|
|
353
|
+
? "public, max-age=31536000, immutable"
|
|
354
|
+
: "public, max-age=3600",
|
|
355
|
+
})
|
|
356
|
+
res.end(data)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
} catch {}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Fall through to SSR handler
|
|
363
|
+
const headers = {}
|
|
364
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
365
|
+
if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const request = new Request(url.href, {
|
|
369
|
+
method: req.method,
|
|
370
|
+
headers,
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
const response = await handler(request)
|
|
374
|
+
const body = await response.text()
|
|
375
|
+
|
|
376
|
+
const responseHeaders = {}
|
|
377
|
+
response.headers.forEach((v, k) => { responseHeaders[k] = v })
|
|
378
|
+
|
|
379
|
+
res.writeHead(response.status, responseHeaders)
|
|
380
|
+
res.end(body)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
server.listen(${port}, () => {
|
|
384
|
+
console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
|
|
385
|
+
})
|
|
386
|
+
`.trimStart();
|
|
387
|
+
await writeFile(join(outDir, "index.js"), serverEntry);
|
|
388
|
+
await writeFile(join(outDir, "package.json"), JSON.stringify({ type: "module" }, null, 2));
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/adapters/static.ts
|
|
395
|
+
/**
|
|
396
|
+
* Static adapter — just copies the client build output.
|
|
397
|
+
* Used with SSG mode where all pages are pre-rendered at build time.
|
|
398
|
+
*/
|
|
399
|
+
function staticAdapter() {
|
|
400
|
+
return {
|
|
401
|
+
name: "static",
|
|
402
|
+
async build(options) {
|
|
403
|
+
const { cp, mkdir } = await import("node:fs/promises");
|
|
404
|
+
await mkdir(options.outDir, { recursive: true });
|
|
405
|
+
await cp(options.clientOutDir, options.outDir, { recursive: true });
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/adapters/index.ts
|
|
412
|
+
/**
|
|
413
|
+
* Resolve the adapter from config.
|
|
414
|
+
* Returns a built-in adapter or throws if unknown.
|
|
415
|
+
*/
|
|
416
|
+
function resolveAdapter(config) {
|
|
417
|
+
const name = config.adapter ?? "node";
|
|
418
|
+
switch (name) {
|
|
419
|
+
case "node": return nodeAdapter();
|
|
420
|
+
case "bun": return bunAdapter();
|
|
421
|
+
case "static": return staticAdapter();
|
|
422
|
+
default: throw new Error(`[zero] Unknown adapter: "${name}". Use "node", "bun", or "static".`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/utils/use-intersection-observer.ts
|
|
428
|
+
/**
|
|
429
|
+
* Observes an element and calls `onIntersect` once it enters the viewport.
|
|
430
|
+
* Automatically disconnects after the first intersection.
|
|
431
|
+
*
|
|
432
|
+
* @param getElement - Getter for the target element (may be undefined before mount).
|
|
433
|
+
* @param onIntersect - Callback fired when the element becomes visible.
|
|
434
|
+
* @param rootMargin - IntersectionObserver rootMargin. Default: "200px".
|
|
435
|
+
*/
|
|
436
|
+
function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px") {
|
|
437
|
+
onMount(() => {
|
|
438
|
+
const el = getElement();
|
|
439
|
+
if (!el) return void 0;
|
|
440
|
+
const observer = new IntersectionObserver((entries) => {
|
|
441
|
+
for (const entry of entries) if (entry.isIntersecting) {
|
|
442
|
+
onIntersect();
|
|
443
|
+
observer.disconnect();
|
|
444
|
+
}
|
|
445
|
+
}, { rootMargin });
|
|
446
|
+
observer.observe(el);
|
|
447
|
+
onUnmount(() => observer.disconnect());
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region src/image.tsx
|
|
453
|
+
/**
|
|
454
|
+
* Optimized image component with lazy loading, responsive images,
|
|
455
|
+
* multi-format <picture> support, and blur-up placeholders.
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* // With imagePlugin — spread the import directly
|
|
459
|
+
* import hero from "./hero.jpg?optimize"
|
|
460
|
+
* <Image {...hero} alt="Hero" priority />
|
|
461
|
+
*
|
|
462
|
+
* @example
|
|
463
|
+
* // Manual usage
|
|
464
|
+
* <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
|
|
465
|
+
*/
|
|
466
|
+
function Image(props) {
|
|
467
|
+
const isEager = props.priority || props.loading === "eager";
|
|
468
|
+
const loaded = signal(isEager);
|
|
469
|
+
const inView = signal(isEager);
|
|
470
|
+
const containerRef = createRef();
|
|
471
|
+
const resolvedSrcset = typeof props.srcset === "string" ? props.srcset : props.srcset?.map((s) => `${s.src} ${s.width}w`).join(", ");
|
|
472
|
+
const sizes = props.sizes ?? "100vw";
|
|
473
|
+
const fit = props.fit ?? "cover";
|
|
474
|
+
const hasFormats = props.formats && props.formats.length > 0;
|
|
475
|
+
const aspectRatio = `${props.width} / ${props.height}`;
|
|
476
|
+
if (!isEager) useIntersectionObserver(() => containerRef.current ?? void 0, () => inView.set(true));
|
|
477
|
+
const containerStyle = [
|
|
478
|
+
"position: relative",
|
|
479
|
+
"overflow: hidden",
|
|
480
|
+
`aspect-ratio: ${aspectRatio}`,
|
|
481
|
+
`max-width: ${props.width}px`,
|
|
482
|
+
"width: 100%",
|
|
483
|
+
props.style
|
|
484
|
+
].filter(Boolean).join("; ");
|
|
485
|
+
const imgEl = /* @__PURE__ */ jsx("img", {
|
|
486
|
+
src: () => inView() ? props.src : "",
|
|
487
|
+
srcset: () => !hasFormats && inView() && resolvedSrcset ? resolvedSrcset : "",
|
|
488
|
+
sizes: resolvedSrcset ? sizes : void 0,
|
|
489
|
+
alt: props.alt,
|
|
490
|
+
width: props.width,
|
|
491
|
+
height: props.height,
|
|
492
|
+
loading: isEager ? "eager" : "lazy",
|
|
493
|
+
decoding: props.decoding ?? "async",
|
|
494
|
+
fetchpriority: props.priority ? "high" : void 0,
|
|
495
|
+
onload: () => loaded.set(true),
|
|
496
|
+
style: () => [
|
|
497
|
+
"display: block",
|
|
498
|
+
"width: 100%",
|
|
499
|
+
"height: 100%",
|
|
500
|
+
`object-fit: ${fit}`,
|
|
501
|
+
"transition: opacity 0.3s ease",
|
|
502
|
+
props.placeholder && !loaded() ? "opacity: 0" : "opacity: 1"
|
|
503
|
+
].join("; ")
|
|
504
|
+
});
|
|
505
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
506
|
+
ref: containerRef,
|
|
507
|
+
class: props.class,
|
|
508
|
+
style: containerStyle,
|
|
509
|
+
children: [props.placeholder && /* @__PURE__ */ jsx("img", {
|
|
510
|
+
src: props.placeholder,
|
|
511
|
+
alt: "",
|
|
512
|
+
"aria-hidden": "true",
|
|
513
|
+
loading: "eager",
|
|
514
|
+
style: () => [
|
|
515
|
+
"position: absolute",
|
|
516
|
+
"inset: 0",
|
|
517
|
+
"width: 100%",
|
|
518
|
+
"height: 100%",
|
|
519
|
+
"object-fit: cover",
|
|
520
|
+
"filter: blur(20px)",
|
|
521
|
+
"transform: scale(1.1)",
|
|
522
|
+
"transition: opacity 0.4s ease",
|
|
523
|
+
loaded() ? "opacity: 0; pointer-events: none" : "opacity: 1"
|
|
524
|
+
].join("; ")
|
|
525
|
+
}), hasFormats ? /* @__PURE__ */ jsxs("picture", { children: [props.formats?.map((fmt) => /* @__PURE__ */ jsx("source", {
|
|
526
|
+
type: fmt.type,
|
|
527
|
+
srcset: () => inView() ? fmt.srcset : void 0,
|
|
528
|
+
sizes
|
|
529
|
+
})), imgEl] }) : imgEl]
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/link.tsx
|
|
535
|
+
const prefetched = /* @__PURE__ */ new Set();
|
|
536
|
+
function doPrefetch(href) {
|
|
537
|
+
if (prefetched.has(href)) return;
|
|
538
|
+
prefetched.add(href);
|
|
539
|
+
const docLink = document.createElement("link");
|
|
540
|
+
docLink.rel = "prefetch";
|
|
541
|
+
docLink.href = href;
|
|
542
|
+
docLink.as = "document";
|
|
543
|
+
document.head.appendChild(docLink);
|
|
544
|
+
try {
|
|
545
|
+
const chunkHint = document.createElement("link");
|
|
546
|
+
chunkHint.rel = "modulepreload";
|
|
547
|
+
chunkHint.href = href;
|
|
548
|
+
document.head.appendChild(chunkHint);
|
|
549
|
+
} catch {}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Composable that provides all link behavior — navigation, prefetching,
|
|
553
|
+
* active state, and viewport observation.
|
|
554
|
+
*
|
|
555
|
+
* Use this for full control when `createLink` is too opinionated.
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* function MyLink(props: LinkProps) {
|
|
559
|
+
* const link = useLink(props)
|
|
560
|
+
* return (
|
|
561
|
+
* <button ref={link.ref} class={link.classes()} onclick={link.handleClick}>
|
|
562
|
+
* {props.children}
|
|
563
|
+
* </button>
|
|
564
|
+
* )
|
|
565
|
+
* }
|
|
566
|
+
*/
|
|
567
|
+
function useLink(props) {
|
|
568
|
+
const router = useRouter();
|
|
569
|
+
const elementRef = createRef();
|
|
570
|
+
const strategy = props.prefetch ?? "hover";
|
|
571
|
+
function handleClick(e) {
|
|
572
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || props.external) return;
|
|
573
|
+
e.preventDefault();
|
|
574
|
+
router.push(props.href);
|
|
575
|
+
}
|
|
576
|
+
function handleMouseEnter() {
|
|
577
|
+
if (strategy === "hover") doPrefetch(props.href);
|
|
578
|
+
}
|
|
579
|
+
function handleTouchStart() {
|
|
580
|
+
if (strategy === "hover" || strategy === "viewport") doPrefetch(props.href);
|
|
581
|
+
}
|
|
582
|
+
if (strategy === "viewport") useIntersectionObserver(() => elementRef.current ?? void 0, () => doPrefetch(props.href));
|
|
583
|
+
const isActive = () => {
|
|
584
|
+
const currentPath = router.currentRoute()?.path;
|
|
585
|
+
if (!currentPath || !props.href) return false;
|
|
586
|
+
if (props.href === "/") return currentPath === "/";
|
|
587
|
+
return currentPath.startsWith(props.href);
|
|
588
|
+
};
|
|
589
|
+
const isExactActive = () => {
|
|
590
|
+
const currentPath = router.currentRoute()?.path;
|
|
591
|
+
if (!currentPath) return false;
|
|
592
|
+
return currentPath === props.href;
|
|
593
|
+
};
|
|
594
|
+
const classes = () => {
|
|
595
|
+
const cls = [];
|
|
596
|
+
if (props.class) cls.push(props.class);
|
|
597
|
+
if (props.activeClass && isActive()) cls.push(props.activeClass);
|
|
598
|
+
if (props.exactActiveClass && isExactActive()) cls.push(props.exactActiveClass);
|
|
599
|
+
return cls.join(" ");
|
|
600
|
+
};
|
|
601
|
+
return {
|
|
602
|
+
ref: elementRef,
|
|
603
|
+
handleClick,
|
|
604
|
+
handleMouseEnter,
|
|
605
|
+
handleTouchStart,
|
|
606
|
+
isActive,
|
|
607
|
+
isExactActive,
|
|
608
|
+
classes
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Higher-order component that wraps any component with link behavior.
|
|
613
|
+
*
|
|
614
|
+
* The wrapped component receives {@link LinkRenderProps} with all handlers,
|
|
615
|
+
* active state, and accessibility attributes pre-wired.
|
|
616
|
+
*
|
|
617
|
+
* @example
|
|
618
|
+
* // Custom button link
|
|
619
|
+
* const ButtonLink = createLink((props) => (
|
|
620
|
+
* <button
|
|
621
|
+
* ref={props.ref}
|
|
622
|
+
* class={props.class}
|
|
623
|
+
* onclick={props.onClick}
|
|
624
|
+
* onmouseenter={props.onMouseEnter}
|
|
625
|
+
* >
|
|
626
|
+
* {props.children}
|
|
627
|
+
* </button>
|
|
628
|
+
* ))
|
|
629
|
+
*
|
|
630
|
+
* // Custom styled component
|
|
631
|
+
* const CardLink = createLink((props) => (
|
|
632
|
+
* <div
|
|
633
|
+
* ref={props.ref}
|
|
634
|
+
* class={`card ${props.isActive() ? "card--active" : ""}`}
|
|
635
|
+
* onclick={props.onClick}
|
|
636
|
+
* onmouseenter={props.onMouseEnter}
|
|
637
|
+
* >
|
|
638
|
+
* {props.children}
|
|
639
|
+
* </div>
|
|
640
|
+
* ))
|
|
641
|
+
*
|
|
642
|
+
* // Usage
|
|
643
|
+
* <ButtonLink href="/about">About</ButtonLink>
|
|
644
|
+
* <CardLink href="/posts" prefetch="viewport">Posts</CardLink>
|
|
645
|
+
*/
|
|
646
|
+
function createLink(Component) {
|
|
647
|
+
return function WrappedLink(props) {
|
|
648
|
+
const link = useLink(props);
|
|
649
|
+
return /* @__PURE__ */ jsx(Component, {
|
|
650
|
+
href: props.href,
|
|
651
|
+
ref: link.ref,
|
|
652
|
+
onClick: link.handleClick,
|
|
653
|
+
onMouseEnter: link.handleMouseEnter,
|
|
654
|
+
onTouchStart: link.handleTouchStart,
|
|
655
|
+
isActive: link.isActive,
|
|
656
|
+
isExactActive: link.isExactActive,
|
|
657
|
+
class: link.classes,
|
|
658
|
+
style: props.style,
|
|
659
|
+
target: props.external ? "_blank" : void 0,
|
|
660
|
+
rel: props.external ? "noopener noreferrer" : void 0,
|
|
661
|
+
"aria-label": props["aria-label"],
|
|
662
|
+
children: props.children
|
|
663
|
+
});
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Default navigation link built on an `<a>` tag.
|
|
668
|
+
*
|
|
669
|
+
* @example
|
|
670
|
+
* <Link href="/about" prefetch="viewport">About</Link>
|
|
671
|
+
* <Link href="/posts" activeClass="nav-active">Posts</Link>
|
|
672
|
+
*/
|
|
673
|
+
const Link = createLink((props) => /* @__PURE__ */ jsx("a", {
|
|
674
|
+
ref: props.ref,
|
|
675
|
+
href: props.href,
|
|
676
|
+
class: props.class,
|
|
677
|
+
style: props.style,
|
|
678
|
+
target: props.target,
|
|
679
|
+
rel: props.rel,
|
|
680
|
+
"aria-label": props["aria-label"],
|
|
681
|
+
"aria-current": props.isExactActive() ? "page" : void 0,
|
|
682
|
+
onclick: props.onClick,
|
|
683
|
+
onmouseenter: props.onMouseEnter,
|
|
684
|
+
ontouchstart: props.onTouchStart,
|
|
685
|
+
children: props.children
|
|
686
|
+
}));
|
|
687
|
+
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/script.tsx
|
|
690
|
+
/**
|
|
691
|
+
* Optimized script loading component.
|
|
692
|
+
*
|
|
693
|
+
* @example
|
|
694
|
+
* // Load analytics after page is interactive
|
|
695
|
+
* <Script src="https://analytics.example.com/script.js" strategy="onIdle" />
|
|
696
|
+
*
|
|
697
|
+
* // Load chat widget when user scrolls
|
|
698
|
+
* <Script src="/chat-widget.js" strategy="onViewport" />
|
|
699
|
+
*
|
|
700
|
+
* // Inline script with deferred execution
|
|
701
|
+
* <Script strategy="afterHydration">
|
|
702
|
+
* {`console.log("App hydrated!")`}
|
|
703
|
+
* <\/Script>
|
|
704
|
+
*/
|
|
705
|
+
function Script(props) {
|
|
706
|
+
function loadScript() {
|
|
707
|
+
if (props.id && document.getElementById(props.id)) return;
|
|
708
|
+
const script = document.createElement("script");
|
|
709
|
+
if (props.src) script.src = props.src;
|
|
710
|
+
if (props.id) script.id = props.id;
|
|
711
|
+
script.async = props.async !== false;
|
|
712
|
+
if (props.onLoad) script.onload = props.onLoad;
|
|
713
|
+
if (props.onError) script.onerror = () => props.onError?.(/* @__PURE__ */ new Error(`Failed to load: ${props.src}`));
|
|
714
|
+
if (props.children && !props.src) script.textContent = props.children;
|
|
715
|
+
document.head.appendChild(script);
|
|
716
|
+
}
|
|
717
|
+
onMount(() => {
|
|
718
|
+
switch (props.strategy ?? "afterHydration") {
|
|
719
|
+
case "beforeHydration": break;
|
|
720
|
+
case "afterHydration":
|
|
721
|
+
loadScript();
|
|
722
|
+
break;
|
|
723
|
+
case "onIdle":
|
|
724
|
+
if ("requestIdleCallback" in window) requestIdleCallback(() => loadScript(), { timeout: 5e3 });
|
|
725
|
+
else setTimeout(loadScript, 200);
|
|
726
|
+
break;
|
|
727
|
+
case "onInteraction": {
|
|
728
|
+
const events = [
|
|
729
|
+
"click",
|
|
730
|
+
"scroll",
|
|
731
|
+
"keydown",
|
|
732
|
+
"touchstart"
|
|
733
|
+
];
|
|
734
|
+
function handler() {
|
|
735
|
+
for (const e of events) document.removeEventListener(e, handler);
|
|
736
|
+
loadScript();
|
|
737
|
+
}
|
|
738
|
+
for (const e of events) document.addEventListener(e, handler, {
|
|
739
|
+
once: true,
|
|
740
|
+
passive: true
|
|
741
|
+
});
|
|
742
|
+
onUnmount(() => {
|
|
743
|
+
for (const e of events) document.removeEventListener(e, handler);
|
|
744
|
+
});
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
case "onViewport": break;
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
const sentinelRef = createRef();
|
|
751
|
+
const strategy = props.strategy ?? "afterHydration";
|
|
752
|
+
if (strategy === "onViewport") useIntersectionObserver(() => sentinelRef.current ?? void 0, () => loadScript());
|
|
753
|
+
if (strategy === "onViewport") return /* @__PURE__ */ jsx("div", {
|
|
754
|
+
ref: sentinelRef,
|
|
755
|
+
style: "width:0;height:0;overflow:hidden"
|
|
756
|
+
});
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
//#endregion
|
|
761
|
+
//#region src/cache.ts
|
|
762
|
+
const HASHED_ASSET = /\.[a-f0-9]{8,}\.\w+$/;
|
|
763
|
+
const STATIC_EXT = /\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm|ogg|mp3|wav)$/i;
|
|
764
|
+
const SCRIPT_EXT = /\.(js|css|mjs)$/i;
|
|
765
|
+
/** @internal Exported for testing */
|
|
766
|
+
function matchGlob(pattern, path) {
|
|
767
|
+
const regex = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
768
|
+
return new RegExp(`^${regex}$`).test(path);
|
|
769
|
+
}
|
|
770
|
+
function resolveControl(path, immutableDuration, staticDuration, pageDuration, swr) {
|
|
771
|
+
if (HASHED_ASSET.test(path)) return `public, max-age=${immutableDuration}, immutable`;
|
|
772
|
+
if (SCRIPT_EXT.test(path)) return `public, max-age=3600, stale-while-revalidate=${swr}`;
|
|
773
|
+
if (STATIC_EXT.test(path)) return `public, max-age=${staticDuration}, stale-while-revalidate=${swr}`;
|
|
774
|
+
if (pageDuration > 0) return `public, max-age=${pageDuration}, stale-while-revalidate=${swr}`;
|
|
775
|
+
return "no-cache";
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Cache control middleware for Zero.
|
|
779
|
+
* Sets Cache-Control headers on the response based on asset type.
|
|
780
|
+
*
|
|
781
|
+
* @example
|
|
782
|
+
* import { cacheMiddleware } from "@pyreon/zero/cache"
|
|
783
|
+
*
|
|
784
|
+
* export default createHandler({
|
|
785
|
+
* routes,
|
|
786
|
+
* middleware: [
|
|
787
|
+
* cacheMiddleware({
|
|
788
|
+
* pages: 60,
|
|
789
|
+
* staleWhileRevalidate: 300,
|
|
790
|
+
* rules: [
|
|
791
|
+
* { match: "/api/*", control: "no-store" },
|
|
792
|
+
* ],
|
|
793
|
+
* }),
|
|
794
|
+
* ],
|
|
795
|
+
* })
|
|
796
|
+
*/
|
|
797
|
+
function cacheMiddleware(config = {}) {
|
|
798
|
+
const immutableDuration = config.immutable ?? 31536e3;
|
|
799
|
+
const staticDuration = config.static ?? 86400;
|
|
800
|
+
const pageDuration = config.pages ?? 0;
|
|
801
|
+
const swr = config.staleWhileRevalidate ?? 60;
|
|
802
|
+
const rules = config.rules ?? [];
|
|
803
|
+
return (ctx) => {
|
|
804
|
+
const path = ctx.url.pathname;
|
|
805
|
+
for (const rule of rules) if (matchGlob(rule.match, path)) {
|
|
806
|
+
ctx.headers.set("Cache-Control", rule.control);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const control = resolveControl(path, immutableDuration, staticDuration, pageDuration, swr);
|
|
810
|
+
ctx.headers.set("Cache-Control", control);
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Security headers middleware.
|
|
815
|
+
* Adds common security headers to all responses.
|
|
816
|
+
*/
|
|
817
|
+
function securityHeaders() {
|
|
818
|
+
return (ctx) => {
|
|
819
|
+
ctx.headers.set("X-Content-Type-Options", "nosniff");
|
|
820
|
+
ctx.headers.set("X-Frame-Options", "DENY");
|
|
821
|
+
ctx.headers.set("X-XSS-Protection", "1; mode=block");
|
|
822
|
+
ctx.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
823
|
+
ctx.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Compression detection middleware.
|
|
828
|
+
* Sets Vary: Accept-Encoding header so caches can serve compressed variants.
|
|
829
|
+
* Actual compression is handled by the runtime (Bun/Node) or reverse proxy.
|
|
830
|
+
*/
|
|
831
|
+
function varyEncoding() {
|
|
832
|
+
return (ctx) => {
|
|
833
|
+
const existing = ctx.headers.get("Vary");
|
|
834
|
+
if (!existing?.includes("Accept-Encoding")) ctx.headers.set("Vary", existing ? `${existing}, Accept-Encoding` : "Accept-Encoding");
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
//#endregion
|
|
839
|
+
//#region src/font.ts
|
|
840
|
+
/**
|
|
841
|
+
* Normalize a GoogleFontInput (string or object) into a ResolvedFont.
|
|
842
|
+
*/
|
|
843
|
+
function resolveGoogleFont(input) {
|
|
844
|
+
if (typeof input === "string") return parseGoogleFamily(input);
|
|
845
|
+
if (input.variable) return {
|
|
846
|
+
family: input.family,
|
|
847
|
+
italic: input.italic ?? false,
|
|
848
|
+
variable: true,
|
|
849
|
+
weightRange: input.weightRange
|
|
850
|
+
};
|
|
851
|
+
return {
|
|
852
|
+
family: input.family,
|
|
853
|
+
italic: input.italic ?? false,
|
|
854
|
+
variable: false,
|
|
855
|
+
weights: input.weights
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Parse Google Fonts family string shorthand.
|
|
860
|
+
*
|
|
861
|
+
* Static weights: "Inter:wght@400;500;700"
|
|
862
|
+
* Variable range: "Inter:wght@100..900"
|
|
863
|
+
* Variable with italic: "Inter:ital,wght@100..900"
|
|
864
|
+
*/
|
|
865
|
+
function parseGoogleFamily(input) {
|
|
866
|
+
const [familyPart, spec] = input.split(":");
|
|
867
|
+
const family = familyPart?.trim();
|
|
868
|
+
let italic = false;
|
|
869
|
+
if (spec) {
|
|
870
|
+
italic = spec.includes("ital");
|
|
871
|
+
const rangeMatch = spec.match(/wght@(\d+)\.\.(\d+)/);
|
|
872
|
+
if (rangeMatch) return {
|
|
873
|
+
family,
|
|
874
|
+
italic,
|
|
875
|
+
variable: true,
|
|
876
|
+
weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])]
|
|
877
|
+
};
|
|
878
|
+
const weightMatch = spec.match(/wght@([\d;]+)/);
|
|
879
|
+
if (weightMatch) return {
|
|
880
|
+
family,
|
|
881
|
+
italic,
|
|
882
|
+
variable: false,
|
|
883
|
+
weights: weightMatch[1]?.split(";").map(Number)
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
return {
|
|
887
|
+
family,
|
|
888
|
+
italic,
|
|
889
|
+
variable: false,
|
|
890
|
+
weights: [400]
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Generate a Google Fonts CSS URL.
|
|
895
|
+
*/
|
|
896
|
+
function googleFontsUrl(families, display = "swap") {
|
|
897
|
+
return `https://fonts.googleapis.com/css2?${families.map((f) => {
|
|
898
|
+
const axes = f.italic ? "ital,wght" : "wght";
|
|
899
|
+
const name = f.family.replace(/ /g, "+");
|
|
900
|
+
if (f.variable) {
|
|
901
|
+
const range = `${f.weightRange[0]}..${f.weightRange[1]}`;
|
|
902
|
+
return `family=${name}:${axes}@${f.italic ? `0,${range};1,${range}` : range}`;
|
|
903
|
+
}
|
|
904
|
+
return `family=${name}:${axes}@${f.weights.map((w) => f.italic ? `0,${w};1,${w}` : String(w)).join(";")}`;
|
|
905
|
+
}).join("&")}&display=${display}`;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Generate @font-face CSS for local fonts.
|
|
909
|
+
*/
|
|
910
|
+
function localFontFaces(fonts, display) {
|
|
911
|
+
return fonts.map((f) => `@font-face {
|
|
912
|
+
font-family: "${f.family}";
|
|
913
|
+
src: url("${f.src}");
|
|
914
|
+
font-weight: ${f.weight ?? "400"};
|
|
915
|
+
font-style: ${f.style ?? "normal"};
|
|
916
|
+
font-display: ${f.display ?? display};
|
|
917
|
+
}`).join("\n\n");
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Generate size-adjusted fallback @font-face declarations to reduce CLS.
|
|
921
|
+
*/
|
|
922
|
+
function fallbackFontFaces(fallbacks) {
|
|
923
|
+
return Object.entries(fallbacks).map(([family, metrics]) => {
|
|
924
|
+
const overrides = [];
|
|
925
|
+
if (metrics.sizeAdjust != null) overrides.push(` size-adjust: ${metrics.sizeAdjust * 100}%;`);
|
|
926
|
+
if (metrics.ascentOverride != null) overrides.push(` ascent-override: ${metrics.ascentOverride}%;`);
|
|
927
|
+
if (metrics.descentOverride != null) overrides.push(` descent-override: ${metrics.descentOverride}%;`);
|
|
928
|
+
if (metrics.lineGapOverride != null) overrides.push(` line-gap-override: ${metrics.lineGapOverride}%;`);
|
|
929
|
+
return `@font-face {
|
|
930
|
+
font-family: "${family} Fallback";
|
|
931
|
+
src: local("${metrics.fallback}");
|
|
932
|
+
${overrides.join("\n")}
|
|
933
|
+
}`;
|
|
934
|
+
}).join("\n\n");
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Generate preload link tags for critical font files.
|
|
938
|
+
*/
|
|
939
|
+
function preloadTags(fonts) {
|
|
940
|
+
return fonts.map((f) => {
|
|
941
|
+
const ext = f.src.split(".").pop();
|
|
942
|
+
const type = ext === "woff2" ? "font/woff2" : ext === "woff" ? "font/woff" : ext === "ttf" ? "font/ttf" : "font/otf";
|
|
943
|
+
return `<link rel="preload" href="${f.src}" as="font" type="${type}" crossorigin>`;
|
|
944
|
+
}).join("\n");
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Download Google Fonts CSS with woff2 user agent.
|
|
948
|
+
*/
|
|
949
|
+
async function downloadGoogleFontsCSS(url) {
|
|
950
|
+
const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } });
|
|
951
|
+
if (!response.ok) throw new Error(`Failed to fetch Google Fonts CSS: ${response.status}`);
|
|
952
|
+
return response.text();
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Download a font file.
|
|
956
|
+
*/
|
|
957
|
+
async function downloadFontFile(url) {
|
|
958
|
+
const response = await fetch(url);
|
|
959
|
+
if (!response.ok) throw new Error(`Failed to download font: ${url}`);
|
|
960
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
961
|
+
return Buffer.from(arrayBuffer);
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Extract font file URLs from Google Fonts CSS.
|
|
965
|
+
*/
|
|
966
|
+
function extractFontUrls(css) {
|
|
967
|
+
const urls = [];
|
|
968
|
+
for (const match of css.matchAll(/url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)/g)) if (match[1]) urls.push(match[1]);
|
|
969
|
+
return urls;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Self-host Google Fonts: download CSS + font files, rewrite URLs to local paths.
|
|
973
|
+
*/
|
|
974
|
+
async function selfHostFonts(cssUrl, fontsSubDir) {
|
|
975
|
+
const css = await downloadGoogleFontsCSS(cssUrl);
|
|
976
|
+
const fontUrls = extractFontUrls(css);
|
|
977
|
+
const fontFiles = [];
|
|
978
|
+
let rewrittenCss = css;
|
|
979
|
+
for (const url of fontUrls) {
|
|
980
|
+
const fileName = url.split("/").at(-1)?.split("?")[0] ?? "font";
|
|
981
|
+
const content = await downloadFontFile(url);
|
|
982
|
+
fontFiles.push({
|
|
983
|
+
name: fileName,
|
|
984
|
+
content
|
|
985
|
+
});
|
|
986
|
+
rewrittenCss = rewrittenCss.replace(url, `/${fontsSubDir}/${fileName}`);
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
css: rewrittenCss,
|
|
990
|
+
fontFiles
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Zero font optimization Vite plugin.
|
|
995
|
+
*
|
|
996
|
+
* Dev mode: injects Google Fonts CDN link for fast startup.
|
|
997
|
+
* Build mode: downloads and self-hosts fonts for maximum performance + privacy.
|
|
998
|
+
*
|
|
999
|
+
* @example
|
|
1000
|
+
* import { fontPlugin } from "@pyreon/zero/font"
|
|
1001
|
+
*
|
|
1002
|
+
* export default {
|
|
1003
|
+
* plugins: [
|
|
1004
|
+
* pyreon(),
|
|
1005
|
+
* zero(),
|
|
1006
|
+
* fontPlugin({
|
|
1007
|
+
* google: ["Inter:wght@400;500;600;700", "JetBrains Mono:wght@400"],
|
|
1008
|
+
* fallbacks: {
|
|
1009
|
+
* "Inter": { fallback: "Arial", sizeAdjust: 1.07, ascentOverride: 90 },
|
|
1010
|
+
* },
|
|
1011
|
+
* }),
|
|
1012
|
+
* ],
|
|
1013
|
+
* }
|
|
1014
|
+
*/
|
|
1015
|
+
function fontPlugin(config = {}) {
|
|
1016
|
+
const display = config.display ?? "swap";
|
|
1017
|
+
const shouldPreload = config.preload !== false;
|
|
1018
|
+
const shouldSelfHost = config.selfHost !== false;
|
|
1019
|
+
const googleFamilies = (config.google ?? []).map(resolveGoogleFont);
|
|
1020
|
+
let isBuild = false;
|
|
1021
|
+
let selfHostedCSS = "";
|
|
1022
|
+
let selfHostedFontFiles = [];
|
|
1023
|
+
return {
|
|
1024
|
+
name: "pyreon-zero-fonts",
|
|
1025
|
+
configResolved(resolvedConfig) {
|
|
1026
|
+
isBuild = resolvedConfig.command === "build";
|
|
1027
|
+
},
|
|
1028
|
+
async buildStart() {
|
|
1029
|
+
if (isBuild && shouldSelfHost && googleFamilies.length > 0) {
|
|
1030
|
+
const cssUrl = googleFontsUrl(googleFamilies, display);
|
|
1031
|
+
try {
|
|
1032
|
+
const result = await selfHostFonts(cssUrl, "assets/fonts");
|
|
1033
|
+
selfHostedCSS = result.css;
|
|
1034
|
+
selfHostedFontFiles = result.fontFiles;
|
|
1035
|
+
} catch {}
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
generateBundle() {
|
|
1039
|
+
for (const file of selfHostedFontFiles) this.emitFile({
|
|
1040
|
+
type: "asset",
|
|
1041
|
+
fileName: `assets/fonts/${file.name}`,
|
|
1042
|
+
source: file.content
|
|
1043
|
+
});
|
|
1044
|
+
},
|
|
1045
|
+
transformIndexHtml(html) {
|
|
1046
|
+
const tags = [];
|
|
1047
|
+
collectGoogleFontTags(tags, {
|
|
1048
|
+
isBuild,
|
|
1049
|
+
selfHostedCSS,
|
|
1050
|
+
selfHostedFontFiles,
|
|
1051
|
+
shouldPreload,
|
|
1052
|
+
googleFamilies,
|
|
1053
|
+
display
|
|
1054
|
+
});
|
|
1055
|
+
collectLocalFontTags(tags, config, shouldPreload, display);
|
|
1056
|
+
if (tags.length === 0) return html;
|
|
1057
|
+
return html.replace("</head>", `${tags.join("\n")}\n</head>`);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
function collectGoogleFontTags(tags, opts) {
|
|
1062
|
+
if (opts.isBuild && opts.selfHostedCSS) {
|
|
1063
|
+
tags.push(`<style>${opts.selfHostedCSS}</style>`);
|
|
1064
|
+
if (opts.shouldPreload) for (const file of opts.selfHostedFontFiles.slice(0, opts.googleFamilies.length)) {
|
|
1065
|
+
const type = file.name.split(".").pop() === "woff2" ? "font/woff2" : "font/woff";
|
|
1066
|
+
tags.push(`<link rel="preload" href="/assets/fonts/${file.name}" as="font" type="${type}" crossorigin>`);
|
|
1067
|
+
}
|
|
1068
|
+
} else if (opts.googleFamilies.length > 0) {
|
|
1069
|
+
const cssUrl = googleFontsUrl(opts.googleFamilies, opts.display);
|
|
1070
|
+
tags.push(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
|
1071
|
+
tags.push(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`);
|
|
1072
|
+
tags.push(`<link rel="stylesheet" href="${cssUrl}">`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
function collectLocalFontTags(tags, config, shouldPreload, display) {
|
|
1076
|
+
if (shouldPreload && config.local?.length) tags.push(preloadTags(config.local));
|
|
1077
|
+
if (config.local?.length) tags.push(`<style>${localFontFaces(config.local, display)}</style>`);
|
|
1078
|
+
if (config.fallbacks && Object.keys(config.fallbacks).length > 0) tags.push(`<style>${fallbackFontFaces(config.fallbacks)}</style>`);
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Generate CSS variables for font families.
|
|
1082
|
+
*/
|
|
1083
|
+
function fontVariables(families) {
|
|
1084
|
+
return `:root {\n${Object.entries(families).map(([key, value]) => ` --font-${key}: ${value};`).join("\n")}\n}`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
//#endregion
|
|
1088
|
+
//#region src/image-plugin.ts
|
|
1089
|
+
let sharpWarned = false;
|
|
1090
|
+
function warnSharpMissing() {
|
|
1091
|
+
if (sharpWarned) return;
|
|
1092
|
+
sharpWarned = true;
|
|
1093
|
+
console.warn("\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\n");
|
|
1094
|
+
}
|
|
1095
|
+
const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i;
|
|
1096
|
+
/**
|
|
1097
|
+
* Zero image processing Vite plugin.
|
|
1098
|
+
*
|
|
1099
|
+
* Transforms image imports with query params into optimized responsive images:
|
|
1100
|
+
*
|
|
1101
|
+
* @example
|
|
1102
|
+
* // vite.config.ts
|
|
1103
|
+
* import { imagePlugin } from "@pyreon/zero/image-plugin"
|
|
1104
|
+
*
|
|
1105
|
+
* export default {
|
|
1106
|
+
* plugins: [
|
|
1107
|
+
* pyreon(),
|
|
1108
|
+
* zero(),
|
|
1109
|
+
* imagePlugin({ widths: [480, 960, 1440], quality: 85 }),
|
|
1110
|
+
* ],
|
|
1111
|
+
* }
|
|
1112
|
+
*
|
|
1113
|
+
* @example
|
|
1114
|
+
* // In a component — import with ?optimize
|
|
1115
|
+
* import hero from "./images/hero.jpg?optimize"
|
|
1116
|
+
* // hero = { src, srcset, width, height, placeholder }
|
|
1117
|
+
*
|
|
1118
|
+
* <Image {...hero} alt="Hero" priority />
|
|
1119
|
+
*/
|
|
1120
|
+
function imagePlugin(config = {}) {
|
|
1121
|
+
const defaultWidths = config.widths ?? [
|
|
1122
|
+
640,
|
|
1123
|
+
1024,
|
|
1124
|
+
1920
|
|
1125
|
+
];
|
|
1126
|
+
const defaultFormats = config.formats ?? ["webp"];
|
|
1127
|
+
const quality = config.quality ?? 80;
|
|
1128
|
+
const placeholderSize = config.placeholderSize ?? 16;
|
|
1129
|
+
const outSubDir = config.outDir ?? "assets/img";
|
|
1130
|
+
const include = config.include ?? IMAGE_EXT_RE;
|
|
1131
|
+
let root = "";
|
|
1132
|
+
let outDir = "";
|
|
1133
|
+
let isBuild = false;
|
|
1134
|
+
return {
|
|
1135
|
+
name: "pyreon-zero-images",
|
|
1136
|
+
enforce: "pre",
|
|
1137
|
+
configResolved(resolvedConfig) {
|
|
1138
|
+
root = resolvedConfig.root;
|
|
1139
|
+
outDir = resolvedConfig.build.outDir;
|
|
1140
|
+
isBuild = resolvedConfig.command === "build";
|
|
1141
|
+
},
|
|
1142
|
+
async resolveId(id) {
|
|
1143
|
+
if (id.includes("?optimize") && include.test(id.split("?")[0])) return `\0virtual:zero-image:${id}`;
|
|
1144
|
+
return null;
|
|
1145
|
+
},
|
|
1146
|
+
async load(id) {
|
|
1147
|
+
if (!id.startsWith("\0virtual:zero-image:")) return null;
|
|
1148
|
+
const rawPath = id.replace("\0virtual:zero-image:", "").split("?")[0] ?? id;
|
|
1149
|
+
const absPath = rawPath.startsWith("/") ? join(root, "public", rawPath) : rawPath;
|
|
1150
|
+
if (!isBuild) {
|
|
1151
|
+
const result = await loadDevImage(absPath, rawPath, placeholderSize);
|
|
1152
|
+
return `export default ${JSON.stringify(result)}`;
|
|
1153
|
+
}
|
|
1154
|
+
const processed = await processImage(absPath, {
|
|
1155
|
+
widths: defaultWidths,
|
|
1156
|
+
formats: defaultFormats,
|
|
1157
|
+
quality,
|
|
1158
|
+
placeholderSize,
|
|
1159
|
+
outSubDir,
|
|
1160
|
+
outDir: join(root, outDir)
|
|
1161
|
+
});
|
|
1162
|
+
await emitProcessedSources(processed, outSubDir, this);
|
|
1163
|
+
rebuildFormatSrcsets(processed, absPath);
|
|
1164
|
+
return `export default ${JSON.stringify(processed)}`;
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
async function loadDevImage(absPath, rawPath, placeholderSize) {
|
|
1169
|
+
const metadata = await getImageMetadata(absPath);
|
|
1170
|
+
const publicPath = rawPath.startsWith("/") ? rawPath : `/@fs/${absPath}`;
|
|
1171
|
+
return {
|
|
1172
|
+
src: publicPath,
|
|
1173
|
+
srcset: "",
|
|
1174
|
+
width: metadata.width,
|
|
1175
|
+
height: metadata.height,
|
|
1176
|
+
placeholder: await generateBlurPlaceholder(absPath, placeholderSize),
|
|
1177
|
+
formats: [],
|
|
1178
|
+
sources: [{
|
|
1179
|
+
src: publicPath,
|
|
1180
|
+
width: metadata.width,
|
|
1181
|
+
format: "original"
|
|
1182
|
+
}]
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
async function emitProcessedSources(processed, outSubDir, ctx) {
|
|
1186
|
+
for (const source of processed.sources) {
|
|
1187
|
+
const fileName = join(outSubDir, basename(source.src));
|
|
1188
|
+
const content = await readFile(source.src);
|
|
1189
|
+
ctx.emitFile({
|
|
1190
|
+
type: "asset",
|
|
1191
|
+
fileName,
|
|
1192
|
+
source: content
|
|
1193
|
+
});
|
|
1194
|
+
source.src = `/${fileName}`;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
function rebuildFormatSrcsets(processed, fallbackPath) {
|
|
1198
|
+
const formatGroups = /* @__PURE__ */ new Map();
|
|
1199
|
+
for (const s of processed.sources) {
|
|
1200
|
+
let group = formatGroups.get(s.format);
|
|
1201
|
+
if (!group) {
|
|
1202
|
+
group = [];
|
|
1203
|
+
formatGroups.set(s.format, group);
|
|
1204
|
+
}
|
|
1205
|
+
group.push(`${s.src} ${s.width}w`);
|
|
1206
|
+
}
|
|
1207
|
+
processed.formats = [...formatGroups.entries()].map(([fmt, entries]) => ({
|
|
1208
|
+
type: `image/${fmt}`,
|
|
1209
|
+
srcset: entries.join(", ")
|
|
1210
|
+
}));
|
|
1211
|
+
processed.srcset = processed.formats.at(-1)?.srcset ?? "";
|
|
1212
|
+
processed.src = processed.sources.at(-1)?.src ?? fallbackPath;
|
|
1213
|
+
}
|
|
1214
|
+
async function processImage(absPath, opts) {
|
|
1215
|
+
const metadata = await getImageMetadata(absPath);
|
|
1216
|
+
const name = basename(absPath, extname(absPath));
|
|
1217
|
+
const sources = [];
|
|
1218
|
+
const processedDir = join(opts.outDir, opts.outSubDir);
|
|
1219
|
+
if (!existsSync(processedDir)) await mkdir(processedDir, { recursive: true });
|
|
1220
|
+
for (const format of opts.formats) for (const targetWidth of opts.widths) {
|
|
1221
|
+
const width = Math.min(targetWidth, metadata.width);
|
|
1222
|
+
const outPath = join(processedDir, `${name}-${width}.${format}`);
|
|
1223
|
+
await resizeImage(absPath, outPath, width, format, opts.quality);
|
|
1224
|
+
sources.push({
|
|
1225
|
+
src: outPath,
|
|
1226
|
+
width,
|
|
1227
|
+
format
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
const formatGroups = /* @__PURE__ */ new Map();
|
|
1231
|
+
for (const s of sources) {
|
|
1232
|
+
let group = formatGroups.get(s.format);
|
|
1233
|
+
if (!group) {
|
|
1234
|
+
group = [];
|
|
1235
|
+
formatGroups.set(s.format, group);
|
|
1236
|
+
}
|
|
1237
|
+
group.push({
|
|
1238
|
+
src: s.src,
|
|
1239
|
+
width: s.width
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
const formats = [...formatGroups.entries()].map(([fmt, group]) => ({
|
|
1243
|
+
type: `image/${fmt === "jpeg" ? "jpeg" : fmt}`,
|
|
1244
|
+
srcset: group.map((s) => `${s.src} ${s.width}w`).join(", ")
|
|
1245
|
+
}));
|
|
1246
|
+
const fallbackFormat = formats[formats.length - 1];
|
|
1247
|
+
const fallbackSources = formatGroups.get([...formatGroups.keys()].pop());
|
|
1248
|
+
const placeholder = await generateBlurPlaceholder(absPath, opts.placeholderSize);
|
|
1249
|
+
return {
|
|
1250
|
+
src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,
|
|
1251
|
+
srcset: fallbackFormat?.srcset ?? "",
|
|
1252
|
+
width: metadata.width,
|
|
1253
|
+
height: metadata.height,
|
|
1254
|
+
placeholder,
|
|
1255
|
+
formats,
|
|
1256
|
+
sources
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Read basic image metadata.
|
|
1261
|
+
* Uses minimal binary header parsing — no external dependencies.
|
|
1262
|
+
*/
|
|
1263
|
+
async function getImageMetadata(absPath) {
|
|
1264
|
+
const buffer = await readFile(absPath);
|
|
1265
|
+
const ext = extname(absPath).toLowerCase();
|
|
1266
|
+
if (ext === ".png") return {
|
|
1267
|
+
width: buffer.readUInt32BE(16),
|
|
1268
|
+
height: buffer.readUInt32BE(20),
|
|
1269
|
+
format: "png"
|
|
1270
|
+
};
|
|
1271
|
+
if (ext === ".jpg" || ext === ".jpeg") return {
|
|
1272
|
+
...parseJpegDimensions(buffer),
|
|
1273
|
+
format: "jpeg"
|
|
1274
|
+
};
|
|
1275
|
+
if (ext === ".webp") return {
|
|
1276
|
+
...parseWebPDimensions(buffer),
|
|
1277
|
+
format: "webp"
|
|
1278
|
+
};
|
|
1279
|
+
return {
|
|
1280
|
+
width: 0,
|
|
1281
|
+
height: 0,
|
|
1282
|
+
format: ext.slice(1)
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
/** @internal Exported for testing */
|
|
1286
|
+
function parseJpegDimensions(buffer) {
|
|
1287
|
+
let offset = 2;
|
|
1288
|
+
while (offset < buffer.length) {
|
|
1289
|
+
if (buffer[offset] !== 255) break;
|
|
1290
|
+
const marker = buffer[offset + 1];
|
|
1291
|
+
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
|
|
1292
|
+
const height = buffer.readUInt16BE(offset + 5);
|
|
1293
|
+
return {
|
|
1294
|
+
width: buffer.readUInt16BE(offset + 7),
|
|
1295
|
+
height
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
const length = buffer.readUInt16BE(offset + 2);
|
|
1299
|
+
offset += 2 + length;
|
|
1300
|
+
}
|
|
1301
|
+
return {
|
|
1302
|
+
width: 0,
|
|
1303
|
+
height: 0
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
/** @internal Exported for testing */
|
|
1307
|
+
function parseWebPDimensions(buffer) {
|
|
1308
|
+
const chunk = buffer.toString("ascii", 12, 16);
|
|
1309
|
+
if (chunk === "VP8 ") return {
|
|
1310
|
+
width: buffer.readUInt16LE(26) & 16383,
|
|
1311
|
+
height: buffer.readUInt16LE(28) & 16383
|
|
1312
|
+
};
|
|
1313
|
+
if (chunk === "VP8L") {
|
|
1314
|
+
const bits = buffer.readUInt32LE(21);
|
|
1315
|
+
return {
|
|
1316
|
+
width: (bits & 16383) + 1,
|
|
1317
|
+
height: (bits >> 14 & 16383) + 1
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
if (chunk === "VP8X") return {
|
|
1321
|
+
width: 1 + ((buffer[24] | buffer[25] << 8 | buffer[26] << 16) & 16777215),
|
|
1322
|
+
height: 1 + ((buffer[27] | buffer[28] << 8 | buffer[29] << 16) & 16777215)
|
|
1323
|
+
};
|
|
1324
|
+
return {
|
|
1325
|
+
width: 0,
|
|
1326
|
+
height: 0
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Resize an image using native platform capabilities.
|
|
1331
|
+
* Uses sharp if available, falls back to canvas API.
|
|
1332
|
+
*/
|
|
1333
|
+
async function resizeImage(input, output, width, format, quality) {
|
|
1334
|
+
try {
|
|
1335
|
+
let pipeline = (await import("sharp").then((m) => m.default ?? m))(input).resize(width);
|
|
1336
|
+
switch (format) {
|
|
1337
|
+
case "webp":
|
|
1338
|
+
pipeline = pipeline.webp({ quality });
|
|
1339
|
+
break;
|
|
1340
|
+
case "avif":
|
|
1341
|
+
pipeline = pipeline.avif({ quality });
|
|
1342
|
+
break;
|
|
1343
|
+
case "jpeg":
|
|
1344
|
+
pipeline = pipeline.jpeg({
|
|
1345
|
+
quality,
|
|
1346
|
+
mozjpeg: true
|
|
1347
|
+
});
|
|
1348
|
+
break;
|
|
1349
|
+
case "png":
|
|
1350
|
+
pipeline = pipeline.png({ compressionLevel: 9 });
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
await pipeline.toFile(output);
|
|
1354
|
+
} catch {
|
|
1355
|
+
warnSharpMissing();
|
|
1356
|
+
await writeFile(output, await readFile(input));
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Generate a tiny blur placeholder as a base64 data URI.
|
|
1361
|
+
*/
|
|
1362
|
+
async function generateBlurPlaceholder(input, size) {
|
|
1363
|
+
try {
|
|
1364
|
+
return `data:image/webp;base64,${(await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, { fit: "inside" }).blur(2).webp({ quality: 20 }).toBuffer()).toString("base64")}`;
|
|
1365
|
+
} catch {
|
|
1366
|
+
return "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E";
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
//#endregion
|
|
1371
|
+
//#region src/theme.tsx
|
|
1372
|
+
const STORAGE_KEY = "zero-theme";
|
|
1373
|
+
/** Reactive theme signal. */
|
|
1374
|
+
const theme = signal("system");
|
|
1375
|
+
/** Computed resolved theme (what's actually applied). */
|
|
1376
|
+
function resolvedTheme() {
|
|
1377
|
+
const t = theme();
|
|
1378
|
+
if (t === "system") {
|
|
1379
|
+
if (typeof window === "undefined") return "dark";
|
|
1380
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
1381
|
+
}
|
|
1382
|
+
return t;
|
|
1383
|
+
}
|
|
1384
|
+
/** Toggle between light and dark. */
|
|
1385
|
+
function toggleTheme() {
|
|
1386
|
+
setTheme(resolvedTheme() === "dark" ? "light" : "dark");
|
|
1387
|
+
}
|
|
1388
|
+
/** Set theme explicitly. */
|
|
1389
|
+
function setTheme(t) {
|
|
1390
|
+
theme.set(t);
|
|
1391
|
+
if (typeof document !== "undefined") {
|
|
1392
|
+
document.documentElement.dataset.theme = resolvedTheme();
|
|
1393
|
+
try {
|
|
1394
|
+
localStorage.setItem(STORAGE_KEY, t);
|
|
1395
|
+
} catch {}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Initialize the theme system. Call once in your app entry or layout.
|
|
1400
|
+
* Reads from localStorage, listens for system preference changes.
|
|
1401
|
+
*/
|
|
1402
|
+
function initTheme() {
|
|
1403
|
+
onMount(() => {
|
|
1404
|
+
try {
|
|
1405
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
1406
|
+
if (stored === "light" || stored === "dark" || stored === "system") theme.set(stored);
|
|
1407
|
+
} catch {}
|
|
1408
|
+
document.documentElement.dataset.theme = resolvedTheme();
|
|
1409
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
1410
|
+
function onChange() {
|
|
1411
|
+
if (theme() === "system") document.documentElement.dataset.theme = resolvedTheme();
|
|
1412
|
+
}
|
|
1413
|
+
mq.addEventListener("change", onChange);
|
|
1414
|
+
onUnmount(() => mq.removeEventListener("change", onChange));
|
|
1415
|
+
const dispose = effect(() => {
|
|
1416
|
+
document.documentElement.dataset.theme = resolvedTheme();
|
|
1417
|
+
});
|
|
1418
|
+
if (dispose) onUnmount(() => dispose.dispose());
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Theme toggle button component.
|
|
1423
|
+
*
|
|
1424
|
+
* @example
|
|
1425
|
+
* import { ThemeToggle } from "@pyreon/zero/theme"
|
|
1426
|
+
* <ThemeToggle />
|
|
1427
|
+
*/
|
|
1428
|
+
function ThemeToggle(props) {
|
|
1429
|
+
initTheme();
|
|
1430
|
+
return /* @__PURE__ */ jsx("button", {
|
|
1431
|
+
class: props.class,
|
|
1432
|
+
style: props.style,
|
|
1433
|
+
onclick: toggleTheme,
|
|
1434
|
+
"aria-label": "Toggle theme",
|
|
1435
|
+
title: "Toggle theme",
|
|
1436
|
+
type: "button",
|
|
1437
|
+
children: () => resolvedTheme() === "dark" ? /* @__PURE__ */ jsxs("svg", {
|
|
1438
|
+
width: "18",
|
|
1439
|
+
height: "18",
|
|
1440
|
+
viewBox: "0 0 24 24",
|
|
1441
|
+
fill: "none",
|
|
1442
|
+
stroke: "currentColor",
|
|
1443
|
+
"stroke-width": "2",
|
|
1444
|
+
"stroke-linecap": "round",
|
|
1445
|
+
"stroke-linejoin": "round",
|
|
1446
|
+
"aria-hidden": "true",
|
|
1447
|
+
children: [
|
|
1448
|
+
/* @__PURE__ */ jsx("circle", {
|
|
1449
|
+
cx: "12",
|
|
1450
|
+
cy: "12",
|
|
1451
|
+
r: "5"
|
|
1452
|
+
}),
|
|
1453
|
+
/* @__PURE__ */ jsx("line", {
|
|
1454
|
+
x1: "12",
|
|
1455
|
+
y1: "1",
|
|
1456
|
+
x2: "12",
|
|
1457
|
+
y2: "3"
|
|
1458
|
+
}),
|
|
1459
|
+
/* @__PURE__ */ jsx("line", {
|
|
1460
|
+
x1: "12",
|
|
1461
|
+
y1: "21",
|
|
1462
|
+
x2: "12",
|
|
1463
|
+
y2: "23"
|
|
1464
|
+
}),
|
|
1465
|
+
/* @__PURE__ */ jsx("line", {
|
|
1466
|
+
x1: "4.22",
|
|
1467
|
+
y1: "4.22",
|
|
1468
|
+
x2: "5.64",
|
|
1469
|
+
y2: "5.64"
|
|
1470
|
+
}),
|
|
1471
|
+
/* @__PURE__ */ jsx("line", {
|
|
1472
|
+
x1: "18.36",
|
|
1473
|
+
y1: "18.36",
|
|
1474
|
+
x2: "19.78",
|
|
1475
|
+
y2: "19.78"
|
|
1476
|
+
}),
|
|
1477
|
+
/* @__PURE__ */ jsx("line", {
|
|
1478
|
+
x1: "1",
|
|
1479
|
+
y1: "12",
|
|
1480
|
+
x2: "3",
|
|
1481
|
+
y2: "12"
|
|
1482
|
+
}),
|
|
1483
|
+
/* @__PURE__ */ jsx("line", {
|
|
1484
|
+
x1: "21",
|
|
1485
|
+
y1: "12",
|
|
1486
|
+
x2: "23",
|
|
1487
|
+
y2: "12"
|
|
1488
|
+
}),
|
|
1489
|
+
/* @__PURE__ */ jsx("line", {
|
|
1490
|
+
x1: "4.22",
|
|
1491
|
+
y1: "19.78",
|
|
1492
|
+
x2: "5.64",
|
|
1493
|
+
y2: "18.36"
|
|
1494
|
+
}),
|
|
1495
|
+
/* @__PURE__ */ jsx("line", {
|
|
1496
|
+
x1: "18.36",
|
|
1497
|
+
y1: "5.64",
|
|
1498
|
+
x2: "19.78",
|
|
1499
|
+
y2: "4.22"
|
|
1500
|
+
})
|
|
1501
|
+
]
|
|
1502
|
+
}) : /* @__PURE__ */ jsx("svg", {
|
|
1503
|
+
width: "18",
|
|
1504
|
+
height: "18",
|
|
1505
|
+
viewBox: "0 0 24 24",
|
|
1506
|
+
fill: "none",
|
|
1507
|
+
stroke: "currentColor",
|
|
1508
|
+
"stroke-width": "2",
|
|
1509
|
+
"stroke-linecap": "round",
|
|
1510
|
+
"stroke-linejoin": "round",
|
|
1511
|
+
"aria-hidden": "true",
|
|
1512
|
+
children: /* @__PURE__ */ jsx("path", { d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" })
|
|
1513
|
+
})
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Inline script to prevent flash of wrong theme.
|
|
1518
|
+
* Include this in your index.html <head> BEFORE any stylesheets.
|
|
1519
|
+
*
|
|
1520
|
+
* @example
|
|
1521
|
+
* // index.html
|
|
1522
|
+
* <head>
|
|
1523
|
+
* <script>{themeScript}<\/script>
|
|
1524
|
+
* ...
|
|
1525
|
+
* </head>
|
|
1526
|
+
*/
|
|
1527
|
+
const themeScript = `(function(){try{var t=localStorage.getItem("${STORAGE_KEY}");var r=t==="light"?"light":t==="dark"?"dark":window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.dataset.theme=r}catch(e){}})()`;
|
|
1528
|
+
|
|
1529
|
+
//#endregion
|
|
1530
|
+
//#region src/seo.ts
|
|
1531
|
+
/**
|
|
1532
|
+
* Generate a sitemap.xml string from route file paths.
|
|
1533
|
+
*/
|
|
1534
|
+
function generateSitemap(routeFiles, config) {
|
|
1535
|
+
const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
|
|
1536
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1537
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
1538
|
+
${[...routeFiles.filter((f) => {
|
|
1539
|
+
const name = f.split("/").pop()?.replace(/\.\w+$/, "");
|
|
1540
|
+
return name !== "_layout" && name !== "_error" && name !== "_loading";
|
|
1541
|
+
}).map((f) => {
|
|
1542
|
+
let path = f.replace(/\.\w+$/, "").replace(/\/index$/, "/").replace(/^index$/, "/");
|
|
1543
|
+
if (path.includes("[")) return null;
|
|
1544
|
+
path = path.replace(/\([\w-]+\)\//g, "");
|
|
1545
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
1546
|
+
return path;
|
|
1547
|
+
}).filter((p) => p !== null).filter((p) => !exclude.some((e) => p.startsWith(e))).map((p) => ({
|
|
1548
|
+
path: p,
|
|
1549
|
+
changefreq,
|
|
1550
|
+
priority
|
|
1551
|
+
})), ...config.additionalPaths ?? []].map((entry) => {
|
|
1552
|
+
return ` <url>
|
|
1553
|
+
<loc>${escapeXml(`${origin}${entry.path === "/" ? "" : entry.path}`)}</loc>
|
|
1554
|
+
<changefreq>${entry.changefreq ?? changefreq}</changefreq>
|
|
1555
|
+
<priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
|
|
1556
|
+
</url>`;
|
|
1557
|
+
}).join("\n")}
|
|
1558
|
+
</urlset>`;
|
|
1559
|
+
}
|
|
1560
|
+
function escapeXml(str) {
|
|
1561
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Generate a robots.txt string.
|
|
1565
|
+
*/
|
|
1566
|
+
function generateRobots(config = {}) {
|
|
1567
|
+
const { rules = [{
|
|
1568
|
+
userAgent: "*",
|
|
1569
|
+
allow: ["/"]
|
|
1570
|
+
}], sitemap, host } = config;
|
|
1571
|
+
const lines = [];
|
|
1572
|
+
for (const rule of rules) {
|
|
1573
|
+
lines.push(`User-agent: ${rule.userAgent}`);
|
|
1574
|
+
if (rule.allow) for (const path of rule.allow) lines.push(`Allow: ${path}`);
|
|
1575
|
+
if (rule.disallow) for (const path of rule.disallow) lines.push(`Disallow: ${path}`);
|
|
1576
|
+
if (rule.crawlDelay) lines.push(`Crawl-delay: ${rule.crawlDelay}`);
|
|
1577
|
+
lines.push("");
|
|
1578
|
+
}
|
|
1579
|
+
if (sitemap) lines.push(`Sitemap: ${sitemap}`);
|
|
1580
|
+
if (host) lines.push(`Host: ${host}`);
|
|
1581
|
+
return lines.join("\n");
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Generate a JSON-LD script tag string for structured data.
|
|
1585
|
+
*
|
|
1586
|
+
* @example
|
|
1587
|
+
* useHead({
|
|
1588
|
+
* script: [jsonLd({
|
|
1589
|
+
* "@type": "WebSite",
|
|
1590
|
+
* name: "My Site",
|
|
1591
|
+
* url: "https://example.com",
|
|
1592
|
+
* })],
|
|
1593
|
+
* })
|
|
1594
|
+
*/
|
|
1595
|
+
function jsonLd(data) {
|
|
1596
|
+
const ld = {
|
|
1597
|
+
"@context": "https://schema.org",
|
|
1598
|
+
...data
|
|
1599
|
+
};
|
|
1600
|
+
return `<script type="application/ld+json">${JSON.stringify(ld)}<\/script>`;
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Zero SEO Vite plugin.
|
|
1604
|
+
* Generates sitemap.xml and robots.txt at build time.
|
|
1605
|
+
*
|
|
1606
|
+
* @example
|
|
1607
|
+
* import { seoPlugin } from "@pyreon/zero/seo"
|
|
1608
|
+
*
|
|
1609
|
+
* export default {
|
|
1610
|
+
* plugins: [
|
|
1611
|
+
* pyreon(),
|
|
1612
|
+
* zero(),
|
|
1613
|
+
* seoPlugin({
|
|
1614
|
+
* sitemap: { origin: "https://example.com" },
|
|
1615
|
+
* robots: { sitemap: "https://example.com/sitemap.xml" },
|
|
1616
|
+
* }),
|
|
1617
|
+
* ],
|
|
1618
|
+
* }
|
|
1619
|
+
*/
|
|
1620
|
+
function seoPlugin(config = {}) {
|
|
1621
|
+
return {
|
|
1622
|
+
name: "pyreon-zero-seo",
|
|
1623
|
+
apply: "build",
|
|
1624
|
+
async generateBundle(_, _bundle) {
|
|
1625
|
+
if (config.sitemap) {
|
|
1626
|
+
const { scanRouteFiles } = await import("./fs-router-jfd1QGLB.js").then((n) => n.n);
|
|
1627
|
+
const routesDir = `${process.cwd()}/src/routes`;
|
|
1628
|
+
try {
|
|
1629
|
+
const sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
|
|
1630
|
+
this.emitFile({
|
|
1631
|
+
type: "asset",
|
|
1632
|
+
fileName: "sitemap.xml",
|
|
1633
|
+
source: sitemap
|
|
1634
|
+
});
|
|
1635
|
+
} catch {}
|
|
1636
|
+
}
|
|
1637
|
+
if (config.robots) {
|
|
1638
|
+
const robots = generateRobots(config.robots);
|
|
1639
|
+
this.emitFile({
|
|
1640
|
+
type: "asset",
|
|
1641
|
+
fileName: "robots.txt",
|
|
1642
|
+
source: robots
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* SEO middleware for dev server.
|
|
1650
|
+
* Serves sitemap.xml and robots.txt dynamically during development.
|
|
1651
|
+
*/
|
|
1652
|
+
function seoMiddleware(config = {}) {
|
|
1653
|
+
return async (ctx) => {
|
|
1654
|
+
if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
|
|
1655
|
+
if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
|
|
1656
|
+
const { scanRouteFiles } = await import("./fs-router-jfd1QGLB.js").then((n) => n.n);
|
|
1657
|
+
const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
|
|
1658
|
+
return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
|
|
1659
|
+
} catch {}
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
//#endregion
|
|
1664
|
+
export { Image, Link, Script, ThemeToggle, bunAdapter, cacheMiddleware, createApp, createISRHandler, createLink, createServer, zeroPlugin as default, defineConfig, filePathToUrlPath, fontPlugin, fontVariables, generateRobots, generateRouteModule, generateSitemap, imagePlugin, initTheme, jsonLd, nodeAdapter, parseFileRoutes, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, securityHeaders, seoMiddleware, seoPlugin, setTheme, staticAdapter, theme, themeScript, toggleTheme, useLink, varyEncoding };
|
|
1665
|
+
//# sourceMappingURL=index.js.map
|