wuppycms 0.0.0-stage → 0.2.1

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.
Files changed (58) hide show
  1. package/dist/admin-fo4l4lu2.mjs +59 -0
  2. package/dist/astro.d.mts +20 -0
  3. package/dist/astro.mjs +302 -0
  4. package/dist/cli.d.mts +1 -0
  5. package/dist/cli.mjs +210 -0
  6. package/dist/components-BrGAEPly.mjs +39 -0
  7. package/dist/config-CM8_-x_W.mjs +1242 -0
  8. package/dist/dist-D7fysV_5.mjs +9048 -0
  9. package/dist/frontmatter-DGaz9--t.mjs +24 -0
  10. package/dist/generate-iX4B_2U2.mjs +473 -0
  11. package/dist/i18n-CgLAHd9Q.mjs +79 -0
  12. package/dist/index-B6AHmgP9.d.mts +34 -0
  13. package/dist/index.d.mts +8636 -0
  14. package/dist/index.mjs +83 -0
  15. package/dist/live-runtime.d.mts +120 -0
  16. package/dist/live-runtime.mjs +207 -0
  17. package/dist/markdown.mjs +4 -0
  18. package/dist/mdx.d.mts +68 -0
  19. package/dist/mdx.mjs +4 -0
  20. package/dist/media-DGfJXgBA.mjs +356 -0
  21. package/dist/page-cache-middleware.d.mts +8 -0
  22. package/dist/page-cache-middleware.mjs +101 -0
  23. package/dist/publishing.d.mts +17 -0
  24. package/dist/publishing.mjs +42 -0
  25. package/dist/read-Bh4-a3Ap.mjs +18 -0
  26. package/dist/redirect-middleware.d.mts +6 -0
  27. package/dist/redirect-middleware.mjs +37 -0
  28. package/dist/render-DzH7Wil3.mjs +116 -0
  29. package/dist/routes/admin.d.mts +7 -0
  30. package/dist/routes/admin.mjs +10 -0
  31. package/dist/routes/api.d.mts +7 -0
  32. package/dist/routes/api.mjs +97 -0
  33. package/dist/routes/icons.d.mts +12 -0
  34. package/dist/routes/icons.mjs +36 -0
  35. package/dist/routes/robots.d.mts +7 -0
  36. package/dist/routes/robots.mjs +15 -0
  37. package/dist/routes/rss.d.mts +7 -0
  38. package/dist/routes/rss.mjs +20 -0
  39. package/dist/routes/sitemap.d.mts +7 -0
  40. package/dist/routes/sitemap.mjs +23 -0
  41. package/dist/routes/uploads.d.mts +7 -0
  42. package/dist/routes/uploads.mjs +11 -0
  43. package/dist/schemas-dHRJ1HeG.mjs +275 -0
  44. package/dist/seo.d.mts +125 -0
  45. package/dist/seo.mjs +130 -0
  46. package/dist/server-D92ryW2y.mjs +5883 -0
  47. package/dist/service-CgcvCG7l.mjs +353 -0
  48. package/dist/site-dF-xVPqe.mjs +148 -0
  49. package/dist/site.d.mts +28 -0
  50. package/dist/site.mjs +44 -0
  51. package/dist/sitemap-Ba0vil6a.mjs +46 -0
  52. package/dist/theme-pages-CRsBm1EX.mjs +246 -0
  53. package/dist/theme-seo-middleware.d.mts +7 -0
  54. package/dist/theme-seo-middleware.mjs +62 -0
  55. package/dist/visual-middleware.d.mts +7 -0
  56. package/dist/visual-middleware.mjs +71 -0
  57. package/package.json +72 -3
  58. package/README.md +0 -3
@@ -0,0 +1,356 @@
1
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, extname, join, resolve, sep } from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+
5
+ //#region src/media/optimize.ts
6
+ /** Widths a client may ask for; a closed list keeps anyone from filling the cache with arbitrary sizes. */
7
+ const WIDTHS = [
8
+ 320,
9
+ 480,
10
+ 640,
11
+ 768,
12
+ 1024,
13
+ 1280,
14
+ 1600,
15
+ 1920,
16
+ 2560
17
+ ];
18
+ const FORMATS = {
19
+ webp: "image/webp",
20
+ avif: "image/avif",
21
+ jpeg: "image/jpeg",
22
+ png: "image/png"
23
+ };
24
+ /** Crop shapes a client may ask for (`?ar=16:9`), again a closed list. */
25
+ const ASPECT_RATIOS = [
26
+ "1:1",
27
+ "4:3",
28
+ "3:2",
29
+ "16:9",
30
+ "3:4",
31
+ "2:3",
32
+ "9:16"
33
+ ];
34
+ let sharpPromise;
35
+ /** `sharp` is optional: without it uploads still work, they just are not resized or analysed. */
36
+ const loadSharp = () => sharpPromise ??= import("./dist-D7fysV_5.mjs").then((m) => m.default).catch(() => null);
37
+ async function imageInfo(bytes) {
38
+ const sharp = await loadSharp();
39
+ if (!sharp) return null;
40
+ try {
41
+ const image = sharp(bytes, { limitInputPixels: 1e8 });
42
+ const { width, height } = await image.metadata();
43
+ if (!width || !height) return null;
44
+ return {
45
+ width,
46
+ height,
47
+ lqip: `data:image/webp;base64,${(await image.resize({ width: 16 }).webp({ quality: 40 }).toBuffer()).toString("base64")}`
48
+ };
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ const clamp = (n, max) => Math.min(Math.max(Math.round(n), 0), max);
54
+ /**
55
+ * The largest box of shape `ratio` inside a `width` x `height` image, placed so the focal point
56
+ * (percent) sits at its centre where the image allows, otherwise pushed against the nearest edge.
57
+ */
58
+ function cropBox(width, height, ratio, focalX = 50, focalY = 50) {
59
+ const [rw, rh] = ratio.split(":").map(Number);
60
+ const boxW = Math.min(width, Math.round(height * rw / rh));
61
+ const boxH = Math.min(height, Math.round(width * rh / rw));
62
+ return {
63
+ width: boxW,
64
+ height: boxH,
65
+ left: clamp(width * focalX / 100 - boxW / 2, width - boxW),
66
+ top: clamp(height * focalY / 100 - boxH / 2, height - boxH)
67
+ };
68
+ }
69
+ /** Parses `?w=640&format=webp&ar=16:9&fx=30&fy=40`. Returns null when nothing is requested, `"invalid"` for values outside the allowlist. */
70
+ function parseVariant(params) {
71
+ const w = params.get("w");
72
+ const f = params.get("format");
73
+ const ar = params.get("ar");
74
+ if (w === null && f === null && ar === null) return null;
75
+ const width = w === null ? void 0 : Number(w);
76
+ if (width !== void 0 && !WIDTHS.includes(width)) return "invalid";
77
+ if (f !== null && !(f in FORMATS)) return "invalid";
78
+ if (ar !== null && !ASPECT_RATIOS.includes(ar)) return "invalid";
79
+ const focal = (name) => {
80
+ const v = params.get(name);
81
+ return v === null ? void 0 : /^\d{1,3}$/.test(v) && Number(v) <= 100 ? Number(v) : "invalid";
82
+ };
83
+ const fx = focal("fx");
84
+ const fy = focal("fy");
85
+ if (fx === "invalid" || fy === "invalid") return "invalid";
86
+ return {
87
+ ...width ? { width } : {},
88
+ ...f ? { format: f } : {},
89
+ ...ar ? {
90
+ ratio: ar,
91
+ ...fx === void 0 ? {} : { focalX: fx },
92
+ ...fy === void 0 ? {} : { focalY: fy }
93
+ } : {}
94
+ };
95
+ }
96
+ /** Resized/re-encoded copy, cached on disk under `.wuppy/cache/images` keyed by source path, mtime and variant. */
97
+ async function renderVariant(root, sourcePath, mtimeMs, variant) {
98
+ const sharp = await loadSharp();
99
+ if (!sharp) return null;
100
+ const format = variant.format ?? "webp";
101
+ const cached = join(root, ".wuppy", "cache", "images", `${createHash("sha256").update(`${sourcePath}\n${mtimeMs}\n${variant.width ?? ""}\n${format}\n${variant.ratio ?? ""}\n${variant.focalX ?? ""}\n${variant.focalY ?? ""}`).digest("hex")}.${format}`);
102
+ try {
103
+ return {
104
+ bytes: await readFile(cached),
105
+ mime: FORMATS[format]
106
+ };
107
+ } catch {}
108
+ try {
109
+ let image = sharp(await readFile(sourcePath), { limitInputPixels: 1e8 }).rotate();
110
+ if (variant.ratio) {
111
+ const { data, info } = await image.toBuffer({ resolveWithObject: true });
112
+ const box = cropBox(info.width, info.height, variant.ratio, variant.focalX, variant.focalY);
113
+ image = sharp(data, { limitInputPixels: 1e8 }).extract(box);
114
+ }
115
+ if (variant.width) image = image.resize({
116
+ width: variant.width,
117
+ withoutEnlargement: true
118
+ });
119
+ const bytes = await image.toFormat(format, { quality: 80 }).toBuffer();
120
+ await mkdir(join(root, ".wuppy", "cache", "images"), { recursive: true });
121
+ await writeFile(cached, bytes);
122
+ return {
123
+ bytes,
124
+ mime: FORMATS[format]
125
+ };
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ //#endregion
132
+ //#region src/media/index.ts
133
+ const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
134
+ var MediaTooLargeError = class extends Error {};
135
+ var UnsupportedMediaError = class extends Error {};
136
+ const ascii = (b, from, to) => String.fromCharCode(...b.subarray(from, to));
137
+ function detectImage(b) {
138
+ if (b.length >= 8 && b[0] === 137 && ascii(b, 1, 4) === "PNG") return {
139
+ mime: "image/png",
140
+ ext: "png"
141
+ };
142
+ if (b.length >= 3 && b[0] === 255 && b[1] === 216 && b[2] === 255) return {
143
+ mime: "image/jpeg",
144
+ ext: "jpg"
145
+ };
146
+ if (b.length >= 6 && /^GIF8[79]a$/.test(ascii(b, 0, 6))) return {
147
+ mime: "image/gif",
148
+ ext: "gif"
149
+ };
150
+ if (b.length >= 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP") return {
151
+ mime: "image/webp",
152
+ ext: "webp"
153
+ };
154
+ return null;
155
+ }
156
+ const MIME_BY_EXT = {
157
+ ".png": "image/png",
158
+ ".jpg": "image/jpeg",
159
+ ".gif": "image/gif",
160
+ ".webp": "image/webp"
161
+ };
162
+ const EMPTY = {
163
+ meta: {},
164
+ folders: []
165
+ };
166
+ var MediaService = class {
167
+ constructor(root, git, emit, store, beforeUpload) {
168
+ this.root = root;
169
+ this.git = git;
170
+ this.emit = emit;
171
+ this.store = store;
172
+ this.beforeUpload = beforeUpload;
173
+ }
174
+ async data() {
175
+ if (!this.store) return EMPTY;
176
+ return this.store.read("media", EMPTY);
177
+ }
178
+ async list() {
179
+ const base = join(this.root, "public", "uploads");
180
+ const names = await readdir(base, { recursive: true }).catch(() => []);
181
+ const meta = (await this.data()).meta;
182
+ return (await Promise.all(names.filter((n) => MIME_BY_EXT[extname(n).toLowerCase()]).map(async (n) => {
183
+ const s = await stat(join(base, n));
184
+ const url = `/uploads/${n.split("\\").join("/")}`;
185
+ return {
186
+ url,
187
+ name: n.split("\\").join("/"),
188
+ size: s.size,
189
+ modified: s.mtime.toISOString(),
190
+ ...meta[url]
191
+ };
192
+ }))).toSorted((a, b) => b.modified.localeCompare(a.modified) || b.url.localeCompare(a.url));
193
+ }
194
+ async folders() {
195
+ return (await this.data()).folders;
196
+ }
197
+ async saveFolders(folders) {
198
+ const data = await this.data();
199
+ await this.store?.write("media", {
200
+ ...data,
201
+ folders
202
+ }, "media: folders");
203
+ }
204
+ async update(url, patch) {
205
+ const data = await this.data();
206
+ const next = {
207
+ ...data.meta[url],
208
+ ...patch
209
+ };
210
+ if (this.store) await this.store.write("media", {
211
+ ...data,
212
+ meta: {
213
+ ...data.meta,
214
+ [url]: next
215
+ }
216
+ }, `media: update ${url}`);
217
+ return next;
218
+ }
219
+ /** Site settings may only tighten the built-in limits: the hard cap and the magic-byte check always apply. */
220
+ async limits() {
221
+ const media = (await this.store?.read("settings", {}) ?? {}).media;
222
+ return {
223
+ maxBytes: Math.min(media?.maxBytes ?? MAX_UPLOAD_BYTES, MAX_UPLOAD_BYTES),
224
+ allowed: media?.allowed ?? null
225
+ };
226
+ }
227
+ /** Ignored upload directories are valid; a missing Git stage must not fail the upload after the bytes are written. */
228
+ async commit(rel, message) {
229
+ await this.git.commit([`public/uploads/${rel}`], message).catch((e) => {
230
+ if (!/nothing to commit|ignored by one of your \.gitignore|paths are ignored/i.test(e.message)) throw e;
231
+ });
232
+ }
233
+ async upload(bytes, meta = {}) {
234
+ const limits = await this.limits();
235
+ if (bytes.byteLength > limits.maxBytes) throw new MediaTooLargeError("File too large");
236
+ const type = detectImage(bytes);
237
+ if (!type) throw new UnsupportedMediaError("Only PNG, JPEG, GIF and WebP images are allowed");
238
+ if (limits.allowed && !limits.allowed.includes(type.mime)) throw new UnsupportedMediaError(`${type.mime} uploads are turned off in Settings`);
239
+ await this.beforeUpload?.({
240
+ mime: type.mime,
241
+ size: bytes.byteLength
242
+ });
243
+ const now = /* @__PURE__ */ new Date();
244
+ const rel = `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}/${randomUUID()}.${type.ext}`;
245
+ const path = join(this.root, "public", "uploads", rel);
246
+ await mkdir(dirname(path), { recursive: true });
247
+ await writeFile(path, bytes);
248
+ const url = `/uploads/${rel}`;
249
+ const info = await imageInfo(bytes);
250
+ if (Object.keys(meta).length || info) await this.update(url, {
251
+ ...meta,
252
+ ...info
253
+ });
254
+ await this.commit(rel, `media: upload ${rel}`);
255
+ this.emit?.({
256
+ type: "media.uploaded",
257
+ url,
258
+ at: now.toISOString()
259
+ });
260
+ return {
261
+ url,
262
+ path: `public/uploads/${rel}`
263
+ };
264
+ }
265
+ relOf(url) {
266
+ const rel = url.replace(/^\/uploads\//, "");
267
+ if (!rel || rel.includes("..") || rel.startsWith("/")) throw new UnsupportedMediaError("Invalid media url");
268
+ return rel;
269
+ }
270
+ /** Overwrites a file with a new image of the same type. The old bytes stay reachable through Git history. */
271
+ async replace(url, bytes) {
272
+ const rel = this.relOf(url);
273
+ const limits = await this.limits();
274
+ if (bytes.byteLength > limits.maxBytes) throw new MediaTooLargeError("File too large");
275
+ const type = detectImage(bytes);
276
+ if (!type) throw new UnsupportedMediaError("Only PNG, JPEG, GIF and WebP images are allowed");
277
+ const current = MIME_BY_EXT[extname(rel).toLowerCase()];
278
+ if (!current) throw new UnsupportedMediaError("Invalid media url");
279
+ if (current !== type.mime) throw new UnsupportedMediaError(`Replacement must be ${current}`);
280
+ const path = join(this.root, "public", "uploads", rel);
281
+ await stat(path).catch(() => {
282
+ throw new UnsupportedMediaError("Unknown media file");
283
+ });
284
+ await this.beforeUpload?.({
285
+ mime: type.mime,
286
+ size: bytes.byteLength,
287
+ replaces: url
288
+ });
289
+ await writeFile(path, bytes);
290
+ const info = await imageInfo(bytes);
291
+ if (info) await this.update(url, info);
292
+ await this.commit(rel, `media: replace ${rel}`);
293
+ }
294
+ /** Renames a folder (and everything below it) in the folder list and on every item. */
295
+ async renameFolder(from, to) {
296
+ const data = await this.data();
297
+ const swap = (f) => f === from ? to : f.startsWith(`${from}/`) ? `${to}${f.slice(from.length)}` : f;
298
+ const folders = [...new Set(data.folders.map(swap))];
299
+ const meta = Object.fromEntries(Object.entries(data.meta).map(([url, m]) => [url, m.folder ? {
300
+ ...m,
301
+ folder: swap(m.folder)
302
+ } : m]));
303
+ await this.store?.write("media", {
304
+ folders,
305
+ meta
306
+ }, `media: rename folder ${from}`);
307
+ }
308
+ /** Removes a folder tree; its files stay in the library, unfiled. */
309
+ async deleteFolder(name) {
310
+ const data = await this.data();
311
+ const inside = (f) => f === name || f.startsWith(`${name}/`);
312
+ const meta = Object.fromEntries(Object.entries(data.meta).map(([url, m]) => {
313
+ const { folder, ...rest } = m;
314
+ return [url, folder && inside(folder) ? rest : m];
315
+ }));
316
+ await this.store?.write("media", {
317
+ folders: data.folders.filter((f) => !inside(f)),
318
+ meta
319
+ }, `media: delete folder ${name}`);
320
+ }
321
+ async delete(url) {
322
+ const rel = this.relOf(url);
323
+ await rm(join(this.root, "public", "uploads", rel), { force: true });
324
+ await this.commit(rel, `media: delete ${rel}`);
325
+ }
326
+ };
327
+ /** Serves files from `<root>/public/uploads`; only known image extensions, never outside that directory. */
328
+ async function serveUpload(root, rel, params = new URLSearchParams()) {
329
+ const base = resolve(root, "public", "uploads");
330
+ const target = resolve(base, rel);
331
+ const mime = MIME_BY_EXT[extname(target).toLowerCase()];
332
+ if (!mime || !target.startsWith(base + sep)) return new Response("Not found", { status: 404 });
333
+ const variant = parseVariant(params);
334
+ if (variant === "invalid") return new Response("Unsupported size or format", { status: 400 });
335
+ try {
336
+ if (variant && mime !== "image/gif") {
337
+ const rendered = await renderVariant(root, target, (await stat(target)).mtimeMs, variant);
338
+ if (rendered) return new Response(Buffer.from(rendered.bytes), { headers: {
339
+ "content-type": rendered.mime,
340
+ "x-content-type-options": "nosniff",
341
+ "cache-control": "public, max-age=31536000, immutable",
342
+ vary: "Accept"
343
+ } });
344
+ }
345
+ return new Response(await readFile(target), { headers: {
346
+ "content-type": mime,
347
+ "x-content-type-options": "nosniff",
348
+ "cache-control": "public, max-age=31536000, immutable"
349
+ } });
350
+ } catch {
351
+ return new Response("Not found", { status: 404 });
352
+ }
353
+ }
354
+
355
+ //#endregion
356
+ export { detectImage as a, UnsupportedMediaError as i, MediaService as n, serveUpload as o, MediaTooLargeError as r, MAX_UPLOAD_BYTES as t };
@@ -0,0 +1,8 @@
1
+ //#region src/astro/page-cache-middleware.d.ts
2
+ declare const onRequest: (ctx: {
3
+ request: Request;
4
+ url: URL;
5
+ isPrerendered?: boolean;
6
+ }, next: () => Promise<Response>) => Promise<Response>;
7
+ //#endregion
8
+ export { onRequest };
@@ -0,0 +1,101 @@
1
+ import "./config-CM8_-x_W.mjs";
2
+ import "./frontmatter-DGaz9--t.mjs";
3
+ import { s as siteRoot } from "./site-dF-xVPqe.mjs";
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { createHash } from "node:crypto";
7
+
8
+ //#region src/astro/page-cache.ts
9
+ /**
10
+ * Id of the current Git commit. Every content, media and settings write is a commit, so it changes exactly when pages can.
11
+ * Null without a repository: nothing signals a change then, so nothing is cached.
12
+ */
13
+ async function contentVersion(root) {
14
+ try {
15
+ const head = (await readFile(join(root, ".git", "HEAD"), "utf8")).trim();
16
+ if (!head.startsWith("ref: ")) return head;
17
+ const ref = head.slice(5);
18
+ const loose = await readFile(join(root, ".git", ref), "utf8").catch(() => "");
19
+ if (loose) return loose.trim();
20
+ return (await readFile(join(root, ".git", "packed-refs"), "utf8")).split("\n").find((line) => line.endsWith(` ${ref}`))?.split(" ")[0] ?? "unknown";
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ /**
26
+ * Rendered public pages, reused until the next commit. A page can also change without one (a scheduled post
27
+ * going live), so an entry is dropped after `maxAgeMs` too.
28
+ */
29
+ var PageCache = class {
30
+ entries = /* @__PURE__ */ new Map();
31
+ constructor(maxEntries = 500, maxBytes = 512 * 1024, maxAgeMs = 6e4) {
32
+ this.maxEntries = maxEntries;
33
+ this.maxBytes = maxBytes;
34
+ this.maxAgeMs = maxAgeMs;
35
+ }
36
+ get(key, version, now = Date.now()) {
37
+ const hit = this.entries.get(key);
38
+ if (!hit) return void 0;
39
+ if (hit.version !== version || now - hit.at > this.maxAgeMs) {
40
+ this.entries.delete(key);
41
+ return;
42
+ }
43
+ return hit;
44
+ }
45
+ set(key, version, body, headers, now = Date.now()) {
46
+ if (body.length > this.maxBytes) return null;
47
+ if (this.entries.size >= this.maxEntries) this.entries.delete(this.entries.keys().next().value);
48
+ const entry = {
49
+ version,
50
+ at: now,
51
+ body,
52
+ headers,
53
+ etag: `W/"${createHash("sha1").update(body).digest("hex").slice(0, 20)}"`
54
+ };
55
+ this.entries.set(key, entry);
56
+ return entry;
57
+ }
58
+ };
59
+ const SKIP_PATH = /^\/(admin|api|uploads|_astro)(\/|$)/;
60
+ /**
61
+ * Wraps page rendering: serves cached HTML, adds an ETag, and answers `If-None-Match` with 304.
62
+ * Previews, edit mode, non-GET requests and anything that sets cookies or forbids storing are passed through.
63
+ */
64
+ function createPageCacheMiddleware(root, cache = new PageCache(), enabled = true) {
65
+ return async (ctx, next) => {
66
+ const { request, url } = ctx;
67
+ const params = url.searchParams;
68
+ if (!enabled || ctx.isPrerendered || request.method !== "GET" || SKIP_PATH.test(url.pathname) || params.has("wuppy-preview") || params.has("wuppy-edit")) return next();
69
+ const key = url.pathname + url.search;
70
+ const version = await contentVersion(root);
71
+ if (!version) return next();
72
+ const reply = (e) => {
73
+ const headers = new Headers(e.headers);
74
+ headers.set("ETag", e.etag);
75
+ headers.set("Cache-Control", "public, max-age=0, must-revalidate");
76
+ if (request.headers.get("if-none-match") === e.etag) return new Response(null, {
77
+ status: 304,
78
+ headers
79
+ });
80
+ return new Response(e.body, {
81
+ status: 200,
82
+ headers
83
+ });
84
+ };
85
+ const hit = cache.get(key, version);
86
+ if (hit) return reply(hit);
87
+ const res = await next();
88
+ const type = res.headers.get("content-type") ?? "";
89
+ if (res.status !== 200 || !type.includes("text/html") || res.headers.has("set-cookie") || /no-store|private/.test(res.headers.get("cache-control") ?? "")) return res;
90
+ const body = await res.clone().text();
91
+ const stored = cache.set(key, version, body, [["content-type", type]]);
92
+ return stored ? reply(stored) : res;
93
+ };
94
+ }
95
+
96
+ //#endregion
97
+ //#region src/astro/page-cache-middleware.ts
98
+ const onRequest = createPageCacheMiddleware(siteRoot(), void 0, import.meta.env?.PROD ?? process.env.NODE_ENV === "production");
99
+
100
+ //#endregion
101
+ export { onRequest };
@@ -0,0 +1,17 @@
1
+ //#region src/publishing/index.d.ts
2
+ /** Draft/schedule rule shared by the sitemap, the starter site and anything else that lists public content. */
3
+ declare function isPublished(data: Record<string, unknown>, now?: Date): boolean;
4
+ type EntryStatus = "draft" | "scheduled" | "published";
5
+ /**
6
+ * What an editor sees for an entry: a future `publishAt` means scheduled (whether or not `draft` is set),
7
+ * otherwise `draft: true` is a draft, otherwise it is live.
8
+ */
9
+ declare function entryStatus(data: Record<string, unknown>, now?: Date): EntryStatus;
10
+ declare function signPreview(secret: string, collection: string, slug: string, ttlMs?: number, now?: number): {
11
+ token: string;
12
+ expires: Date;
13
+ };
14
+ /** True only for an unexpired token minted for exactly this collection and slug. */
15
+ declare function verifyPreview(secret: string, collection: string, slug: string, token: string | null | undefined, now?: number): boolean;
16
+ //#endregion
17
+ export { EntryStatus, entryStatus, isPublished, signPreview, verifyPreview };
@@ -0,0 +1,42 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ //#region src/publishing/index.ts
4
+ /** Draft/schedule rule shared by the sitemap, the starter site and anything else that lists public content. */
5
+ function isPublished(data, now = /* @__PURE__ */ new Date()) {
6
+ if (data.draft === true) return false;
7
+ const at = data.publishAt;
8
+ if (at === void 0 || at === null || at === "") return true;
9
+ const t = (at instanceof Date ? at : new Date(String(at))).getTime();
10
+ return Number.isFinite(t) && t <= now.getTime();
11
+ }
12
+ /**
13
+ * What an editor sees for an entry: a future `publishAt` means scheduled (whether or not `draft` is set),
14
+ * otherwise `draft: true` is a draft, otherwise it is live.
15
+ */
16
+ function entryStatus(data, now = /* @__PURE__ */ new Date()) {
17
+ const at = data.publishAt;
18
+ const t = at === void 0 || at === null || at === "" ? NaN : (at instanceof Date ? at : new Date(String(at))).getTime();
19
+ if (Number.isFinite(t) && t > now.getTime()) return "scheduled";
20
+ return data.draft === true ? "draft" : "published";
21
+ }
22
+ /** Preview links let someone without an account see one unpublished entry until `expires`. */
23
+ const previewMac = (secret, collection, slug, expires) => createHmac("sha256", secret).update(`preview\n${collection}\n${slug}\n${expires}`).digest("base64url");
24
+ function signPreview(secret, collection, slug, ttlMs = 24 * 36e5, now = Date.now()) {
25
+ const expires = now + ttlMs;
26
+ return {
27
+ token: `${expires}.${previewMac(secret, collection, slug, expires)}`,
28
+ expires: new Date(expires)
29
+ };
30
+ }
31
+ /** True only for an unexpired token minted for exactly this collection and slug. */
32
+ function verifyPreview(secret, collection, slug, token, now = Date.now()) {
33
+ const [exp = "", mac = ""] = (token ?? "").split(".");
34
+ const expires = Number(exp);
35
+ if (!secret || !Number.isFinite(expires) || expires < now) return false;
36
+ const want = Buffer.from(previewMac(secret, collection, slug, expires));
37
+ const got = Buffer.from(mac);
38
+ return want.length === got.length && timingSafeEqual(want, got);
39
+ }
40
+
41
+ //#endregion
42
+ export { entryStatus, isPublished, signPreview, verifyPreview };
@@ -0,0 +1,18 @@
1
+ import { s as siteRoot } from "./site-dF-xVPqe.mjs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ //#region src/data/read.ts
6
+ /** Read a site data file (the site's, also when a theme runs) without constructing the full server (templates, scripts). */
7
+ async function readData(name, fallback, root = siteRoot()) {
8
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error(`Invalid data name: ${name}`);
9
+ try {
10
+ return JSON.parse(await readFile(join(root, ".wuppy", "data", `${name}.json`), "utf8"));
11
+ } catch (e) {
12
+ if (e.code === "ENOENT") return fallback;
13
+ throw e;
14
+ }
15
+ }
16
+
17
+ //#endregion
18
+ export { readData as t };
@@ -0,0 +1,6 @@
1
+ //#region src/astro/redirect-middleware.d.ts
2
+ declare const onRequest: (ctx: {
3
+ url: URL;
4
+ }, next: () => Promise<Response>) => Promise<Response>;
5
+ //#endregion
6
+ export { onRequest };
@@ -0,0 +1,37 @@
1
+ import { d as resolveRedirect, n as loadConfig } from "./config-CM8_-x_W.mjs";
2
+ import "./frontmatter-DGaz9--t.mjs";
3
+ import { s as siteRoot } from "./site-dF-xVPqe.mjs";
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ //#region src/astro/redirect-middleware.ts
8
+ let config;
9
+ let stored;
10
+ async function storedRedirects(root) {
11
+ const path = join(root, ".wuppy", "data", "redirects.json");
12
+ try {
13
+ const info = await stat(path);
14
+ if (stored?.mtime === info.mtimeMs) return stored.rules;
15
+ const rules = JSON.parse(await readFile(path, "utf8"));
16
+ stored = {
17
+ mtime: info.mtimeMs,
18
+ rules
19
+ };
20
+ return rules;
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+ const onRequest = async (ctx, next) => {
26
+ const root = siteRoot();
27
+ config ??= loadConfig(root);
28
+ const [cfg, dynamic] = await Promise.all([config, storedRedirects(root)]);
29
+ const hit = resolveRedirect([...cfg.redirects, ...dynamic], ctx.url.pathname, ctx.url.search);
30
+ return hit ? new Response(null, {
31
+ status: hit.status,
32
+ headers: { location: hit.to }
33
+ }) : next();
34
+ };
35
+
36
+ //#endregion
37
+ export { onRequest };