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.
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,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 };
@@ -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 };