wuppycms 0.0.0-stage → 0.2.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/dist/admin-fo4l4lu2.mjs +59 -0
- package/dist/astro.d.mts +20 -0
- package/dist/astro.mjs +302 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +210 -0
- package/dist/components-BrGAEPly.mjs +39 -0
- package/dist/config-CM8_-x_W.mjs +1242 -0
- package/dist/dist-D7fysV_5.mjs +9048 -0
- package/dist/frontmatter-DGaz9--t.mjs +24 -0
- package/dist/generate-iX4B_2U2.mjs +473 -0
- package/dist/i18n-CgLAHd9Q.mjs +79 -0
- package/dist/index-B6AHmgP9.d.mts +34 -0
- package/dist/index.d.mts +8636 -0
- package/dist/index.mjs +83 -0
- package/dist/live-runtime.d.mts +120 -0
- package/dist/live-runtime.mjs +207 -0
- package/dist/markdown.mjs +4 -0
- package/dist/mdx.d.mts +68 -0
- package/dist/mdx.mjs +4 -0
- package/dist/media-DGfJXgBA.mjs +356 -0
- package/dist/page-cache-middleware.d.mts +8 -0
- package/dist/page-cache-middleware.mjs +101 -0
- package/dist/publishing.d.mts +17 -0
- package/dist/publishing.mjs +42 -0
- package/dist/read-Bh4-a3Ap.mjs +18 -0
- package/dist/redirect-middleware.d.mts +6 -0
- package/dist/redirect-middleware.mjs +37 -0
- package/dist/render-DzH7Wil3.mjs +116 -0
- package/dist/routes/admin.d.mts +7 -0
- package/dist/routes/admin.mjs +10 -0
- package/dist/routes/api.d.mts +7 -0
- package/dist/routes/api.mjs +97 -0
- package/dist/routes/icons.d.mts +12 -0
- package/dist/routes/icons.mjs +36 -0
- package/dist/routes/robots.d.mts +7 -0
- package/dist/routes/robots.mjs +15 -0
- package/dist/routes/rss.d.mts +7 -0
- package/dist/routes/rss.mjs +20 -0
- package/dist/routes/sitemap.d.mts +7 -0
- package/dist/routes/sitemap.mjs +23 -0
- package/dist/routes/uploads.d.mts +7 -0
- package/dist/routes/uploads.mjs +11 -0
- package/dist/schemas-dHRJ1HeG.mjs +275 -0
- package/dist/seo.d.mts +125 -0
- package/dist/seo.mjs +130 -0
- package/dist/server-D92ryW2y.mjs +5883 -0
- package/dist/service-CgcvCG7l.mjs +353 -0
- package/dist/site-dF-xVPqe.mjs +148 -0
- package/dist/site.d.mts +28 -0
- package/dist/site.mjs +44 -0
- package/dist/sitemap-Ba0vil6a.mjs +46 -0
- package/dist/theme-pages-CRsBm1EX.mjs +246 -0
- package/dist/theme-seo-middleware.d.mts +7 -0
- package/dist/theme-seo-middleware.mjs +62 -0
- package/dist/visual-middleware.d.mts +7 -0
- package/dist/visual-middleware.mjs +71 -0
- package/package.json +72 -3
- package/README.md +0 -3
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { S as stripComponentImports, b as findDisallowed, w as dataSchema, y as componentTags } from "./config-CM8_-x_W.mjs";
|
|
2
|
+
import { n as serializeDocument, t as parseDocument } from "./frontmatter-DGaz9--t.mjs";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
//#region src/content/validation/path.ts
|
|
8
|
+
var UnsafePathError = class extends Error {};
|
|
9
|
+
const SEGMENT = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
10
|
+
/** Throws unless `slug` is a safe relative slug path (e.g. "blog/hello"). Returns it normalized. */
|
|
11
|
+
function assertSafeSlug(slug) {
|
|
12
|
+
if (!slug || slug.includes("\0") || slug.includes("\\")) throw new UnsafePathError(`Unsafe slug: ${JSON.stringify(slug)}`);
|
|
13
|
+
const parts = slug.split("/");
|
|
14
|
+
if (!parts.every((p) => SEGMENT.test(p))) throw new UnsafePathError(`Unsafe slug: ${JSON.stringify(slug)}`);
|
|
15
|
+
return parts.join("/");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/content/adapters/filesystem.ts
|
|
20
|
+
var FilesystemContent = class {
|
|
21
|
+
constructor(root, collection) {
|
|
22
|
+
this.root = root;
|
|
23
|
+
this.collection = collection;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A collection holds `.md` and `.mdx` files side by side: an existing entry keeps its own extension,
|
|
27
|
+
* a new one gets the collection's format.
|
|
28
|
+
*/
|
|
29
|
+
formatOf(slug) {
|
|
30
|
+
const other = this.collection.format === "md" ? "mdx" : "md";
|
|
31
|
+
const at = (ext) => join(this.root, `${this.collection.dir}/${assertSafeSlug(slug)}.${ext}`);
|
|
32
|
+
return !existsSync(at(this.collection.format)) && existsSync(at(other)) ? other : this.collection.format;
|
|
33
|
+
}
|
|
34
|
+
/** Repo-relative path (forward slashes), for Git. */
|
|
35
|
+
relPath(slug, format = this.formatOf(slug)) {
|
|
36
|
+
return `${this.collection.dir}/${assertSafeSlug(slug)}.${format}`;
|
|
37
|
+
}
|
|
38
|
+
/** Absolute path, for metadata reads. */
|
|
39
|
+
absolutePath(slug) {
|
|
40
|
+
return join(this.root, this.relPath(slug));
|
|
41
|
+
}
|
|
42
|
+
file(slug, format = this.formatOf(slug)) {
|
|
43
|
+
return join(this.root, this.relPath(slug, format));
|
|
44
|
+
}
|
|
45
|
+
async get(slug) {
|
|
46
|
+
try {
|
|
47
|
+
return {
|
|
48
|
+
slug,
|
|
49
|
+
...parseDocument(await readFile(this.file(slug), "utf8"))
|
|
50
|
+
};
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (e.code === "ENOENT") return null;
|
|
53
|
+
throw e;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async put(slug, doc, format = this.formatOf(slug)) {
|
|
57
|
+
const path = this.file(slug, format);
|
|
58
|
+
await mkdir(dirname(path), { recursive: true });
|
|
59
|
+
await writeFile(path, serializeDocument(doc));
|
|
60
|
+
}
|
|
61
|
+
async delete(slug, format = this.formatOf(slug)) {
|
|
62
|
+
await rm(this.file(slug, format), { force: true });
|
|
63
|
+
}
|
|
64
|
+
async list() {
|
|
65
|
+
const base = join(this.root, this.collection.dir);
|
|
66
|
+
try {
|
|
67
|
+
const slugs = (await readdir(base, { recursive: true })).filter((n) => /\.mdx?$/.test(n)).map((n) => n.replace(/\.mdx?$/, "").split("\\").join("/"));
|
|
68
|
+
return [...new Set(slugs)].toSorted();
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (e.code === "ENOENT") return [];
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/content/service.ts
|
|
78
|
+
var UnknownCollectionError = class extends Error {};
|
|
79
|
+
var ContentExistsError = class extends Error {
|
|
80
|
+
constructor(collection, slug) {
|
|
81
|
+
super(`An entry already exists at ${collection}/${slug}`);
|
|
82
|
+
this.collection = collection;
|
|
83
|
+
this.slug = slug;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var DisallowedContentError = class extends Error {
|
|
87
|
+
constructor(found) {
|
|
88
|
+
super(`Disallowed content: ${found.join(", ")}`);
|
|
89
|
+
this.found = found;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
/** File-backed content where every mutation becomes a Git commit. */
|
|
93
|
+
var ContentService = class {
|
|
94
|
+
stores = /* @__PURE__ */ new Map();
|
|
95
|
+
schemas = /* @__PURE__ */ new Map();
|
|
96
|
+
tail = Promise.resolve();
|
|
97
|
+
/** Bumped on every successful mutation; indexes compare it to know when to rebuild. */
|
|
98
|
+
version = 0;
|
|
99
|
+
/** Parsed entries per collection, reused until a mutation (version) or the TTL; see `listEntries`. */
|
|
100
|
+
listed = /* @__PURE__ */ new Map();
|
|
101
|
+
/** Git allows one writer per repo (index.lock), so mutations run one at a time. */
|
|
102
|
+
serial(fn) {
|
|
103
|
+
const run = this.tail.then(fn, fn);
|
|
104
|
+
this.tail = run.catch(() => void 0);
|
|
105
|
+
return run;
|
|
106
|
+
}
|
|
107
|
+
constructor(root, collections, git, options = {}) {
|
|
108
|
+
this.root = root;
|
|
109
|
+
this.collections = collections;
|
|
110
|
+
this.git = git;
|
|
111
|
+
this.options = options;
|
|
112
|
+
for (const c of collections) {
|
|
113
|
+
this.stores.set(c.name, new FilesystemContent(root, c));
|
|
114
|
+
this.schemas.set(c.name, dataSchema(c));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** MDX components an entry body may use (empty means none). */
|
|
118
|
+
get allowedComponents() {
|
|
119
|
+
return this.options.components ?? [];
|
|
120
|
+
}
|
|
121
|
+
emit(type, extra) {
|
|
122
|
+
this.version++;
|
|
123
|
+
this.options.emit?.({
|
|
124
|
+
type,
|
|
125
|
+
...extra,
|
|
126
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
store(name) {
|
|
130
|
+
const s = this.stores.get(name);
|
|
131
|
+
if (!s) throw new UnknownCollectionError(`Unknown collection: ${name}`);
|
|
132
|
+
return s;
|
|
133
|
+
}
|
|
134
|
+
async list(collection) {
|
|
135
|
+
return this.store(collection).list();
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Metadata for dashboard/search/list views; reads each entry once. A big collection is expensive to read (10k files
|
|
139
|
+
* take about half a second), so the result is reused until Wuppy itself writes or `listCacheMs` passes.
|
|
140
|
+
*/
|
|
141
|
+
async listEntries(collection) {
|
|
142
|
+
const ttl = this.options.listCacheMs ?? 2e3;
|
|
143
|
+
const hit = this.listed.get(collection);
|
|
144
|
+
if (hit && hit.version === this.version && Date.now() - hit.at < ttl) return hit.rows.slice();
|
|
145
|
+
const version = this.version;
|
|
146
|
+
const rows = await this.readEntries(collection);
|
|
147
|
+
this.listed.set(collection, {
|
|
148
|
+
at: Date.now(),
|
|
149
|
+
version,
|
|
150
|
+
rows
|
|
151
|
+
});
|
|
152
|
+
return rows.slice();
|
|
153
|
+
}
|
|
154
|
+
async readEntries(collection) {
|
|
155
|
+
const store = this.store(collection);
|
|
156
|
+
const slugs = await store.list();
|
|
157
|
+
const { stat } = await import("node:fs/promises");
|
|
158
|
+
return (await Promise.all(slugs.map(async (slug) => {
|
|
159
|
+
const entry = await store.get(slug);
|
|
160
|
+
if (!entry) return null;
|
|
161
|
+
let modified = (/* @__PURE__ */ new Date(0)).toISOString();
|
|
162
|
+
try {
|
|
163
|
+
modified = (await stat(store.absolutePath(slug))).mtime.toISOString();
|
|
164
|
+
} catch {}
|
|
165
|
+
return {
|
|
166
|
+
...entry,
|
|
167
|
+
modified
|
|
168
|
+
};
|
|
169
|
+
}))).filter((e) => e !== null);
|
|
170
|
+
}
|
|
171
|
+
async get(collection, slug) {
|
|
172
|
+
return this.store(collection).get(slug);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Plugins' before-hooks first, then the MDX rules: whatever a plugin changes is checked like anything an editor
|
|
176
|
+
* writes. A plugin cannot publish or unpublish: `draft` stays what the editor (and their permission) said.
|
|
177
|
+
*/
|
|
178
|
+
async checked(collection, slug, doc, isNew) {
|
|
179
|
+
const s = this.store(collection);
|
|
180
|
+
if (this.options.beforeSave) {
|
|
181
|
+
const out = await this.options.beforeSave({
|
|
182
|
+
collection,
|
|
183
|
+
slug,
|
|
184
|
+
isNew,
|
|
185
|
+
data: doc.data,
|
|
186
|
+
body: doc.body
|
|
187
|
+
});
|
|
188
|
+
const data = { ...out.data };
|
|
189
|
+
if ("draft" in doc.data) data.draft = doc.data.draft;
|
|
190
|
+
else delete data.draft;
|
|
191
|
+
doc = {
|
|
192
|
+
data,
|
|
193
|
+
body: out.body
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const current = s.formatOf(slug);
|
|
197
|
+
const tags = await componentTags(doc.body);
|
|
198
|
+
const format = current === "mdx" || s.collection.format === "mdx" && tags.length > 0 ? "mdx" : "md";
|
|
199
|
+
if (format === "mdx") doc = {
|
|
200
|
+
...doc,
|
|
201
|
+
body: stripComponentImports(doc.body)
|
|
202
|
+
};
|
|
203
|
+
const found = await findDisallowed(doc.body, this.options.components ?? [], format);
|
|
204
|
+
if (found.length > 0) throw new DisallowedContentError(found);
|
|
205
|
+
return doc;
|
|
206
|
+
}
|
|
207
|
+
async create(collection, slug, doc, message) {
|
|
208
|
+
const s = this.store(collection);
|
|
209
|
+
doc = await this.checked(collection, slug, doc, true);
|
|
210
|
+
doc = {
|
|
211
|
+
...doc,
|
|
212
|
+
data: this.schemas.get(collection).parse(doc.data)
|
|
213
|
+
};
|
|
214
|
+
await this.serial(async () => {
|
|
215
|
+
if (await s.get(slug)) throw new ContentExistsError(collection, slug);
|
|
216
|
+
await s.put(slug, doc);
|
|
217
|
+
await this.git.commit([s.relPath(slug)], message ?? `content: create ${collection}/${slug}`).catch((e) => {
|
|
218
|
+
if (!/nothing to commit/i.test(e.message)) throw e;
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
this.emit("content.updated", {
|
|
222
|
+
collection,
|
|
223
|
+
slug
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
async put(collection, slug, doc, message) {
|
|
227
|
+
const s = this.store(collection);
|
|
228
|
+
const isNew = this.options.beforeSave ? !await s.get(slug) : false;
|
|
229
|
+
doc = await this.checked(collection, slug, doc, isNew);
|
|
230
|
+
doc = {
|
|
231
|
+
...doc,
|
|
232
|
+
data: this.schemas.get(collection).parse(doc.data)
|
|
233
|
+
};
|
|
234
|
+
const before = s.formatOf(slug);
|
|
235
|
+
const format = s.collection.format === "mdx" && (await componentTags(doc.body)).length ? "mdx" : before;
|
|
236
|
+
await this.serial(async () => {
|
|
237
|
+
await s.put(slug, doc, format);
|
|
238
|
+
if (format !== before) await s.delete(slug, before);
|
|
239
|
+
await this.git.commit([...new Set([s.relPath(slug, before), s.relPath(slug, format)])], message ?? `content: update ${collection}/${slug}`).catch((e) => {
|
|
240
|
+
if (!/nothing to commit/i.test(e.message)) throw e;
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
this.emit("content.updated", {
|
|
244
|
+
collection,
|
|
245
|
+
slug
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
async delete(collection, slug) {
|
|
249
|
+
await this.trash(collection, slug);
|
|
250
|
+
}
|
|
251
|
+
/** Move an entry to `.wuppy/trash`; Git keeps the old file too. */
|
|
252
|
+
async trash(collection, slug) {
|
|
253
|
+
const s = this.store(collection);
|
|
254
|
+
const entry = await s.get(slug);
|
|
255
|
+
if (!entry) return;
|
|
256
|
+
await this.options.beforeDelete?.({
|
|
257
|
+
collection,
|
|
258
|
+
slug
|
|
259
|
+
});
|
|
260
|
+
const rel = this.trashRel(collection, slug);
|
|
261
|
+
const path = join(this.root, rel);
|
|
262
|
+
await this.serial(async () => {
|
|
263
|
+
await mkdir(dirname(path), { recursive: true });
|
|
264
|
+
await writeFile(path, JSON.stringify({
|
|
265
|
+
...entry,
|
|
266
|
+
collection,
|
|
267
|
+
deletedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
268
|
+
}, null, 2));
|
|
269
|
+
const file = s.relPath(slug);
|
|
270
|
+
await s.delete(slug);
|
|
271
|
+
await this.git.commit([file, rel], `content: trash ${collection}/${slug}`);
|
|
272
|
+
});
|
|
273
|
+
this.emit("content.deleted", {
|
|
274
|
+
collection,
|
|
275
|
+
slug
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
async listTrash() {
|
|
279
|
+
const base = join(this.root, ".wuppy", "trash");
|
|
280
|
+
const names = await readdir(base, { recursive: true }).catch(() => []);
|
|
281
|
+
return (await Promise.all(names.filter((name) => name.endsWith(".json")).map(async (name) => {
|
|
282
|
+
try {
|
|
283
|
+
const parsed = JSON.parse(await readFile(join(base, name), "utf8"));
|
|
284
|
+
return {
|
|
285
|
+
collection: parsed.collection,
|
|
286
|
+
slug: parsed.slug,
|
|
287
|
+
title: typeof parsed.data?.title === "string" ? parsed.data.title : parsed.slug,
|
|
288
|
+
deletedAt: parsed.deletedAt
|
|
289
|
+
};
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}))).filter((row) => row !== null).toSorted((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
|
294
|
+
}
|
|
295
|
+
async restore(collection, slug) {
|
|
296
|
+
const s = this.store(collection);
|
|
297
|
+
const rel = this.trashRel(collection, slug);
|
|
298
|
+
const path = join(this.root, rel);
|
|
299
|
+
try {
|
|
300
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
301
|
+
const doc = await this.checked(collection, slug, {
|
|
302
|
+
data: parsed.data,
|
|
303
|
+
body: parsed.body
|
|
304
|
+
}, true);
|
|
305
|
+
await this.serial(async () => {
|
|
306
|
+
await s.put(slug, doc);
|
|
307
|
+
await rm(path, { force: true });
|
|
308
|
+
await this.git.commit([s.relPath(slug), rel], `content: restore ${collection}/${slug}`);
|
|
309
|
+
});
|
|
310
|
+
this.emit("content.updated", {
|
|
311
|
+
collection,
|
|
312
|
+
slug
|
|
313
|
+
});
|
|
314
|
+
return true;
|
|
315
|
+
} catch (e) {
|
|
316
|
+
if (e.code === "ENOENT") return false;
|
|
317
|
+
throw e;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async deleteTrash(collection, slug) {
|
|
321
|
+
this.store(collection);
|
|
322
|
+
const rel = this.trashRel(collection, slug);
|
|
323
|
+
try {
|
|
324
|
+
await rm(join(this.root, rel));
|
|
325
|
+
await this.git.commit([rel], `content: delete trash ${collection}/${slug}`).catch(() => void 0);
|
|
326
|
+
return true;
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
trashRel(collection, slug) {
|
|
332
|
+
return `.wuppy/trash/${collection}/${encodeURIComponent(slug)}.json`;
|
|
333
|
+
}
|
|
334
|
+
async history(collection, slug) {
|
|
335
|
+
return this.git.history(this.store(collection).relPath(slug));
|
|
336
|
+
}
|
|
337
|
+
/** The entry as it was in commit `sha`, or null if it did not exist then. */
|
|
338
|
+
async revision(collection, slug, sha) {
|
|
339
|
+
const text = await this.git.show(sha, this.store(collection).relPath(slug));
|
|
340
|
+
return text === null ? null : parseDocument(text);
|
|
341
|
+
}
|
|
342
|
+
diff(sha) {
|
|
343
|
+
return this.git.diff(sha);
|
|
344
|
+
}
|
|
345
|
+
async revert(sha) {
|
|
346
|
+
const created = await this.serial(() => this.git.revert(sha));
|
|
347
|
+
this.emit("content.reverted", { sha: created });
|
|
348
|
+
return created;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
//#endregion
|
|
353
|
+
export { FilesystemContent as a, UnknownCollectionError as i, ContentService as n, UnsafePathError as o, DisallowedContentError as r, assertSafeSlug as s, ContentExistsError as t };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { S as stripComponentImports, n as loadConfig } from "./config-CM8_-x_W.mjs";
|
|
2
|
+
import { n as serializeDocument, t as parseDocument } from "./frontmatter-DGaz9--t.mjs";
|
|
3
|
+
import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
//#region src/themes/site.ts
|
|
7
|
+
/**
|
|
8
|
+
* A GitHub theme is design only; content belongs to the site. A theme's collections are stored in the site:
|
|
9
|
+
* one with `kind: "posts"`/`"pages"` (its `blog`, `about`) in the site's Posts/Pages collection, any other one in
|
|
10
|
+
* `content/<name>`. `map` renames the theme's fields to the site's (`pubDate` -> `date`).
|
|
11
|
+
*/
|
|
12
|
+
/** The site's folder (content, media, data, accounts); a theme runs from its own folder with this set. */
|
|
13
|
+
const siteRoot = () => process.env.WUPPY_BASE_ROOT || process.cwd();
|
|
14
|
+
/** The running theme's folder, or undefined when the site runs itself. */
|
|
15
|
+
const themeRoot = () => resolve(process.cwd()) === resolve(siteRoot()) ? void 0 : process.cwd();
|
|
16
|
+
const kindOf = (c) => c.kind ?? (c.name === "posts" || c.name === "pages" ? c.name : void 0);
|
|
17
|
+
/** The site's collections with the theme's routes and extra fields added, and where each theme collection reads. */
|
|
18
|
+
function linkTheme(site, theme) {
|
|
19
|
+
const collections = site.map((c) => ({
|
|
20
|
+
...c,
|
|
21
|
+
fields: { ...c.fields }
|
|
22
|
+
}));
|
|
23
|
+
const links = [];
|
|
24
|
+
for (const t of theme) {
|
|
25
|
+
const kind = kindOf(t);
|
|
26
|
+
const found = collections.find((c) => kind ? kindOf(c) === kind : c.name === t.name);
|
|
27
|
+
const name = kind ?? t.name;
|
|
28
|
+
const target = found ?? {
|
|
29
|
+
name,
|
|
30
|
+
dir: `content/${name}`,
|
|
31
|
+
format: t.format,
|
|
32
|
+
fields: {}
|
|
33
|
+
};
|
|
34
|
+
if (!found) collections.push(target);
|
|
35
|
+
const map = t.map ?? {};
|
|
36
|
+
for (const [field, type] of Object.entries(t.fields)) target.fields[map[field] ?? field] ??= type;
|
|
37
|
+
if (t.route) target.route = t.route;
|
|
38
|
+
links.push({
|
|
39
|
+
name: t.name,
|
|
40
|
+
dir: target.dir,
|
|
41
|
+
rename: Object.fromEntries(Object.entries(map).map(([from, to]) => [to, from]))
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
collections,
|
|
46
|
+
links
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** The site's config; with a theme, its collections linked in (`links` says where the theme reads). */
|
|
50
|
+
async function loadSiteConfig(site, theme) {
|
|
51
|
+
const config = await loadConfig(site);
|
|
52
|
+
if (!theme) return {
|
|
53
|
+
...config,
|
|
54
|
+
links: []
|
|
55
|
+
};
|
|
56
|
+
const linked = linkTheme(config.collections, (await loadConfig(theme)).collections);
|
|
57
|
+
return {
|
|
58
|
+
...config,
|
|
59
|
+
collections: linked.collections,
|
|
60
|
+
links: linked.links
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Theme frontmatter as the site stores it, or the reverse with an inverted map. */
|
|
64
|
+
function renameFields(data, rename) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [key, value] of Object.entries(data)) out[rename[key] ?? key] = value;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Copies a theme's sample entries into the site once, when it is installed: like a WordPress theme's demo import.
|
|
71
|
+
* Existing site entries are never overwritten; `.md` and `.mdx` files keep their extension.
|
|
72
|
+
*/
|
|
73
|
+
async function importDemo(site, theme) {
|
|
74
|
+
const siteConfig = await loadConfig(site);
|
|
75
|
+
const themeConfig = await loadConfig(theme);
|
|
76
|
+
const { links } = linkTheme(siteConfig.collections, themeConfig.collections);
|
|
77
|
+
const copied = [];
|
|
78
|
+
for (const [i, t] of themeConfig.collections.entries()) {
|
|
79
|
+
const link = links[i];
|
|
80
|
+
const from = join(theme, t.dir);
|
|
81
|
+
const names = await readdir(from, { recursive: true }).catch(() => []);
|
|
82
|
+
for (const name of names.filter((n) => /\.mdx?$/.test(n)).toSorted()) {
|
|
83
|
+
const target = join(site, link.dir, name);
|
|
84
|
+
if (await access(target).then(() => true, () => false)) continue;
|
|
85
|
+
const doc = parseDocument(await readFile(join(from, name), "utf8"));
|
|
86
|
+
await mkdir(dirname(target), { recursive: true });
|
|
87
|
+
await writeFile(target, serializeDocument({
|
|
88
|
+
data: renameFields(doc.data, t.map ?? {}),
|
|
89
|
+
body: name.endsWith(".mdx") ? stripComponentImports(doc.body) : doc.body
|
|
90
|
+
}));
|
|
91
|
+
copied.push(join(link.dir, name));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return copied;
|
|
95
|
+
}
|
|
96
|
+
/** The site's Pages collection folder (`kind: "pages"`, or the collection named `pages`), if it has one. */
|
|
97
|
+
const pagesDir = (collections) => collections.find((c) => kindOf(c) === "pages")?.dir;
|
|
98
|
+
/** A theme's static pages (`src/pages/about.md`, `index.astro`): no `[param]` routes, `_partials` or endpoints. */
|
|
99
|
+
async function themePageFiles(theme) {
|
|
100
|
+
return (await readdir(join(theme, "src/pages"), { recursive: true }).catch(() => [])).map((n) => n.split("\\").join("/")).filter((n) => /\.(mdx?|astro)$/.test(n) && !n.includes("[") && !n.split("/").some((part) => part.startsWith("_"))).toSorted();
|
|
101
|
+
}
|
|
102
|
+
/** A page's title from its path: `index` is Home, `tags/index` is Tags, `about-me` is About me. */
|
|
103
|
+
const pageTitle = (rel) => {
|
|
104
|
+
const name = rel.replace(/(^|\/)index$/, "").split("/").at(-1) ?? "";
|
|
105
|
+
return name ? name.charAt(0).toUpperCase() + name.slice(1).replaceAll("-", " ") : "Home";
|
|
106
|
+
};
|
|
107
|
+
/** The URL a theme page file serves: `src/pages/tags/index.astro` → `/tags`. */
|
|
108
|
+
const templateUrl = (template) => "/" + template.replace(/^src\/pages\//, "").replace(/\.astro$/, "").replace(/(^|\/)index$/, "");
|
|
109
|
+
/**
|
|
110
|
+
* A theme's pages become entries of the site's Pages collection, so they are listed and edited in the admin like any
|
|
111
|
+
* page. Markdown pages bring their text; the theme keeps serving them at their URL with its own layout (see
|
|
112
|
+
* theme-pages.ts), and the theme-specific `layout` stays in the theme file. An `.astro` page keeps its text in code: its
|
|
113
|
+
* entry only names it (`template`), so the admin opens that file and edits the page's SEO fields. Existing site pages
|
|
114
|
+
* are never overwritten.
|
|
115
|
+
*/
|
|
116
|
+
async function importPages(site, theme) {
|
|
117
|
+
const pages = (await loadSiteConfig(site, theme)).collections.find((c) => kindOf(c) === "pages");
|
|
118
|
+
if (!pages) return [];
|
|
119
|
+
const { dir } = pages;
|
|
120
|
+
const required = Object.entries(pages.fields).flatMap(([k, f]) => typeof f === "object" && f.required && (f.type === "string" || f.type === "text") ? [k] : []);
|
|
121
|
+
const copied = [];
|
|
122
|
+
for (const name of await themePageFiles(theme)) {
|
|
123
|
+
const rel = name.replace(/\.(mdx?|astro)$/, "");
|
|
124
|
+
const base = join(site, dir, rel);
|
|
125
|
+
if (await Promise.any([".md", ".mdx"].map((ext) => access(base + ext))).then(() => true, () => false)) continue;
|
|
126
|
+
const astro = name.endsWith(".astro");
|
|
127
|
+
const file = astro ? `${rel}.md` : name;
|
|
128
|
+
const doc = astro ? {
|
|
129
|
+
data: {
|
|
130
|
+
title: pageTitle(rel),
|
|
131
|
+
template: `src/pages/${name}`
|
|
132
|
+
},
|
|
133
|
+
body: ""
|
|
134
|
+
} : parseDocument(await readFile(join(theme, "src/pages", name), "utf8"));
|
|
135
|
+
const { layout: _layout, ...rest } = doc.data;
|
|
136
|
+
for (const k of required) rest[k] ??= "";
|
|
137
|
+
await mkdir(dirname(base), { recursive: true });
|
|
138
|
+
await writeFile(join(site, dir, file), serializeDocument({
|
|
139
|
+
data: rest,
|
|
140
|
+
body: doc.body
|
|
141
|
+
}));
|
|
142
|
+
copied.push(join(dir, file));
|
|
143
|
+
}
|
|
144
|
+
return copied;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
//#endregion
|
|
148
|
+
export { pagesDir as a, templateUrl as c, loadSiteConfig as i, themeRoot as l, importPages as n, renameFields as o, linkTheme as r, siteRoot as s, importDemo as t };
|
package/dist/site.d.mts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { a as resolveLocale, i as localeSwitcher, o as splitLocale, r as hreflangAlternates, t as LocaleLink } from "./index-B6AHmgP9.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/site/index.d.ts
|
|
4
|
+
/** Small helpers for public templates. Pure functions: no I/O, safe to import in any Astro page. */
|
|
5
|
+
/** Whole minutes to read `text` at `wpm` words per minute, at least 1. */
|
|
6
|
+
declare function readingTime(text: string, wpm?: number): number;
|
|
7
|
+
interface Page<T> {
|
|
8
|
+
items: T[];
|
|
9
|
+
page: number;
|
|
10
|
+
pages: number;
|
|
11
|
+
prev: number | null;
|
|
12
|
+
next: number | null;
|
|
13
|
+
}
|
|
14
|
+
/** 1-based pagination; out-of-range or non-numeric pages clamp to the nearest valid one. */
|
|
15
|
+
declare function paginate<T>(all: readonly T[], page: number | string | null | undefined, size?: number): Page<T>;
|
|
16
|
+
/** Entries sharing the most values of `key` (tags, categories) with `current`, best first; `current` itself is excluded. */
|
|
17
|
+
declare function related<T extends {
|
|
18
|
+
id: string;
|
|
19
|
+
data: Record<string, unknown>;
|
|
20
|
+
}>(current: T, all: readonly T[], key: string, limit?: number): T[];
|
|
21
|
+
/** `srcset` for an uploaded image: one allowlisted width per entry, in `format` (default WebP). */
|
|
22
|
+
declare function imageSrcset(url: string, widths?: readonly number[], format?: "webp" | "avif" | "jpeg" | "png", crop?: {
|
|
23
|
+
ratio: string;
|
|
24
|
+
focalX?: number;
|
|
25
|
+
focalY?: number;
|
|
26
|
+
}): string;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { type LocaleLink, Page, hreflangAlternates, imageSrcset, localeSwitcher, paginate, readingTime, related, resolveLocale, splitLocale };
|
package/dist/site.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { i as splitLocale, n as localeSwitcher, r as resolveLocale, t as hreflangAlternates } from "./i18n-CgLAHd9Q.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/site/index.ts
|
|
4
|
+
/** Small helpers for public templates. Pure functions: no I/O, safe to import in any Astro page. */
|
|
5
|
+
/** Whole minutes to read `text` at `wpm` words per minute, at least 1. */
|
|
6
|
+
function readingTime(text, wpm = 200) {
|
|
7
|
+
const words = text.match(/[\p{L}\p{N}']+/gu)?.length ?? 0;
|
|
8
|
+
return Math.max(1, Math.ceil(words / wpm));
|
|
9
|
+
}
|
|
10
|
+
/** 1-based pagination; out-of-range or non-numeric pages clamp to the nearest valid one. */
|
|
11
|
+
function paginate(all, page, size = 10) {
|
|
12
|
+
const pages = Math.max(1, Math.ceil(all.length / size));
|
|
13
|
+
const requested = Math.floor(Number(page));
|
|
14
|
+
const current = Number.isFinite(requested) ? Math.min(Math.max(requested, 1), pages) : 1;
|
|
15
|
+
return {
|
|
16
|
+
items: all.slice((current - 1) * size, current * size),
|
|
17
|
+
page: current,
|
|
18
|
+
pages,
|
|
19
|
+
prev: current > 1 ? current - 1 : null,
|
|
20
|
+
next: current < pages ? current + 1 : null
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Entries sharing the most values of `key` (tags, categories) with `current`, best first; `current` itself is excluded. */
|
|
24
|
+
function related(current, all, key, limit = 3) {
|
|
25
|
+
const values = (e) => Array.isArray(e.data[key]) ? e.data[key].map(String) : [];
|
|
26
|
+
const mine = new Set(values(current));
|
|
27
|
+
return all.filter((e) => e.id !== current.id).map((e) => ({
|
|
28
|
+
e,
|
|
29
|
+
score: values(e).filter((v) => mine.has(v)).length
|
|
30
|
+
})).filter((x) => x.score > 0).toSorted((a, b) => b.score - a.score || a.e.id.localeCompare(b.e.id)).slice(0, limit).map((x) => x.e);
|
|
31
|
+
}
|
|
32
|
+
/** `srcset` for an uploaded image: one allowlisted width per entry, in `format` (default WebP). */
|
|
33
|
+
function imageSrcset(url, widths = [
|
|
34
|
+
480,
|
|
35
|
+
768,
|
|
36
|
+
1024,
|
|
37
|
+
1600
|
|
38
|
+
], format = "webp", crop) {
|
|
39
|
+
const extra = crop ? `&ar=${crop.ratio}${crop.focalX === void 0 ? "" : `&fx=${crop.focalX}`}${crop.focalY === void 0 ? "" : `&fy=${crop.focalY}`}` : "";
|
|
40
|
+
return widths.map((w) => `${url}?w=${w}&format=${format}${extra} ${w}w`).join(", ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
export { hreflangAlternates, imageSrcset, localeSwitcher, paginate, readingTime, related, resolveLocale, splitLocale };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isPublished } from "./publishing.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/seo/sitemap.ts
|
|
4
|
+
async function publishedEntries(service, siteUrl, exclude = []) {
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const c of service.collections) {
|
|
7
|
+
if (exclude.includes(c.name)) continue;
|
|
8
|
+
const route = c.route ?? `/${c.name}/{slug}`;
|
|
9
|
+
for (const slug of await service.list(c.name)) {
|
|
10
|
+
const entry = await service.get(c.name, slug);
|
|
11
|
+
if (!entry || !isPublished(entry.data)) continue;
|
|
12
|
+
out.push({
|
|
13
|
+
url: new URL(route.replace("{slug}", slug), siteUrl).href,
|
|
14
|
+
collection: c.name,
|
|
15
|
+
data: entry.data
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/** Published, indexable entries (`draft` and `noindex` are skipped). `date` becomes lastmod when it is an ISO date. */
|
|
22
|
+
async function contentUrls(service, siteUrl, { exclude, priority = {} } = {}) {
|
|
23
|
+
return (await publishedEntries(service, siteUrl, exclude)).filter(({ data }) => data.noindex !== true).map(({ url, collection, data }) => ({
|
|
24
|
+
loc: url,
|
|
25
|
+
...typeof data.date === "string" ? { lastmod: data.date } : {},
|
|
26
|
+
...priority[collection] === void 0 ? {} : { priority: priority[collection] }
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
/** Dated, published entries, newest first. Undated entries are left out: a feed needs a pubDate. */
|
|
30
|
+
async function contentFeed(service, siteUrl, limit = 50) {
|
|
31
|
+
const items = [];
|
|
32
|
+
for (const { url, data } of await publishedEntries(service, siteUrl)) {
|
|
33
|
+
const date = typeof data.date === "string" || data.date instanceof Date ? new Date(data.date) : null;
|
|
34
|
+
if (!date || Number.isNaN(date.getTime())) continue;
|
|
35
|
+
items.push({
|
|
36
|
+
link: url,
|
|
37
|
+
title: typeof data.title === "string" ? data.title : url,
|
|
38
|
+
date,
|
|
39
|
+
...typeof data.description === "string" ? { description: data.description } : {}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return items.toSorted((a, b) => b.date.getTime() - a.date.getTime()).slice(0, limit);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
export { contentUrls as n, contentFeed as t };
|