@karibsen/eponyme 0.1.1 → 0.3.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 (77) hide show
  1. package/README.md +159 -12
  2. package/dist/eponyme.mjs +2 -1
  3. package/dist/module.d.mts +6 -0
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +55 -36
  6. package/dist/runtime/components/editor/EponymeCollectionPage.vue +80 -18
  7. package/dist/runtime/components/editor/EponymeForm.vue +2 -2
  8. package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
  9. package/dist/runtime/components/editor/EponymeSidebar.vue +24 -32
  10. package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
  11. package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
  12. package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
  13. package/dist/runtime/components/fields/RichTextField.vue +51 -10
  14. package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
  15. package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
  16. package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
  17. package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
  18. package/dist/runtime/composables/useEponyme.js +5 -3
  19. package/dist/runtime/composables/useEponymeCollection.js +3 -2
  20. package/dist/runtime/composables/useEponymeForm.d.ts +4 -0
  21. package/dist/runtime/composables/useEponymeForm.js +11 -1
  22. package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
  23. package/dist/runtime/composables/useEponymeHistory.js +2 -1
  24. package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
  25. package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
  26. package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
  27. package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
  28. package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
  29. package/dist/runtime/server/api/eponyme-export.get.js +11 -0
  30. package/dist/runtime/server/api/eponyme-forms/[path].post.js +29 -1
  31. package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
  32. package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
  33. package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
  34. package/dist/runtime/server/api/eponyme-import.post.js +31 -0
  35. package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
  36. package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
  37. package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
  38. package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
  39. package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
  40. package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
  41. package/dist/runtime/server/services/eponyme-form-store.js +2 -1
  42. package/dist/runtime/server/services/eponyme-service.js +8 -1
  43. package/dist/runtime/server/services/eponyme-store.d.ts +118 -5
  44. package/dist/runtime/server/services/eponyme-store.js +344 -45
  45. package/dist/runtime/server/utils/eponyme-captcha.d.ts +6 -0
  46. package/dist/runtime/server/utils/eponyme-captcha.js +14 -0
  47. package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
  48. package/dist/runtime/server/utils/eponyme-entry.js +7 -0
  49. package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
  50. package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
  51. package/dist/runtime/types/captcha.d.ts +16 -0
  52. package/dist/runtime/types/captcha.js +0 -0
  53. package/dist/runtime/types/eponyme-config.d.ts +7 -0
  54. package/dist/runtime/types/eponyme.d.ts +3 -0
  55. package/dist/runtime/types/field.d.ts +1 -0
  56. package/dist/runtime/types/hooks.d.ts +67 -0
  57. package/dist/runtime/types/hooks.js +0 -0
  58. package/dist/runtime/types/index.d.ts +1 -0
  59. package/dist/runtime/types/index.js +1 -0
  60. package/dist/runtime/types/nuxt.d.ts +10 -0
  61. package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
  62. package/dist/runtime/utils/build-navigation-tree.js +30 -0
  63. package/dist/runtime/utils/date-locale.d.ts +10 -0
  64. package/dist/runtime/utils/date-locale.js +1 -0
  65. package/dist/runtime/utils/downloadable-link.d.ts +2 -0
  66. package/dist/runtime/utils/downloadable-link.js +35 -0
  67. package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
  68. package/dist/runtime/utils/filter-navigation-tree.js +39 -0
  69. package/dist/runtime/utils/get-field-default-value.js +1 -1
  70. package/dist/runtime/utils/hydration-cache.d.ts +11 -0
  71. package/dist/runtime/utils/hydration-cache.js +3 -0
  72. package/dist/runtime/utils/no-captcha.d.ts +7 -0
  73. package/dist/runtime/utils/no-captcha.js +5 -0
  74. package/dist/runtime/utils/validate-eponyme-data.js +1 -1
  75. package/dist/runtime/utils/variables.d.ts +8 -1
  76. package/dist/runtime/utils/variables.js +17 -4
  77. package/package.json +3 -1
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { isDeepStrictEqual } from "node:util";
2
3
  import { createDefaultEponymeData } from "../../utils/create-default-eponyme-data.js";
3
4
  import { getEponymeCollections, getEponymeSchemas } from "../../utils/get-eponyme-schemas.js";
@@ -6,13 +7,44 @@ import { applyPreviewSlug } from "../../utils/preview.js";
6
7
  import { validateEponymeData, validateEponymePatch } from "../../utils/validate-eponyme-data.js";
7
8
  export const COLLECTION_METADATA_KEYS = ["updatedAt", "publishedAt", "title", "slug"];
8
9
  export class EponymeService {
9
- constructor(config, client) {
10
+ constructor(config, client, options = {}) {
10
11
  this.client = client;
11
12
  this.schemas = getEponymeSchemas(config);
12
13
  this.collections = getEponymeCollections(config);
14
+ this.cacheMs = Math.max(0, options.cacheSeconds ?? 5) * 1e3;
13
15
  }
14
16
  schemas;
15
17
  collections;
18
+ /** Entries a read has already persisted the normalized shape for, so it only attempts it once. */
19
+ healedByRead = /* @__PURE__ */ new Set();
20
+ /**
21
+ * Read-path cache of normalized rows, so a page that reads the same entry from a
22
+ * singleton, a listing and a layout pays one round trip instead of one per read.
23
+ * Writers never read from it, so the `updatedAt` they lock on always comes from the
24
+ * database and a stale key can never turn into a spurious conflict.
25
+ */
26
+ cache = /* @__PURE__ */ new Map();
27
+ cacheMs;
28
+ cached(key, load) {
29
+ if (!this.cacheMs) return load();
30
+ const hit = this.cache.get(key);
31
+ if (hit && hit.expires > Date.now()) return hit.value;
32
+ const pending = load().catch((error) => {
33
+ this.cache.delete(key);
34
+ throw error;
35
+ });
36
+ this.cache.set(key, { value: pending, expires: Date.now() + this.cacheMs });
37
+ return pending;
38
+ }
39
+ /**
40
+ * Drops what a write to `name` invalidates: the entry itself, and the listing of the
41
+ * collection that contains it, whose row set just changed.
42
+ */
43
+ invalidate(name) {
44
+ this.cache.delete(`row:${name}`);
45
+ const collection = this.getCollectionEntry(name)?.name;
46
+ if (collection) this.cache.delete(`rows:${collection}`);
47
+ }
16
48
  getSchema(name) {
17
49
  return this.schemas[name] ?? this.getCollectionEntry(name)?.definition.fields;
18
50
  }
@@ -27,9 +59,9 @@ export class EponymeService {
27
59
  if (slug && !slug.includes("/")) return { name: collectionName, slug, definition };
28
60
  }
29
61
  }
30
- /** Reconcile every configured eponyme at application startup. */
62
+ /** Reconcile every configured eponyme at application startup. This is the one place that heals. */
31
63
  async syncAll() {
32
- await Promise.all(Object.keys(this.schemas).map((name) => this.loadState(name)));
64
+ await Promise.all(Object.keys(this.schemas).map((name) => this.loadState(name, { heal: true })));
33
65
  }
34
66
  reconcile(schema, value, mode) {
35
67
  const persisted = value && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -65,20 +97,50 @@ export class EponymeService {
65
97
  }
66
98
  };
67
99
  }
68
- /** Reads the stored state along with the `updatedAt` it was read at, used as the optimistic lock token. */
69
- async loadRow(name) {
100
+ /**
101
+ * A singleton row is created on first read, but only when it is genuinely missing.
102
+ * An `upsert` would send a write on every read instead, and each one costs a database
103
+ * round trip on the critical path of a public page.
104
+ */
105
+ async loadSingletonRow(name, schema) {
106
+ const row = await this.client.eponyme.findUnique({ where: { name } });
107
+ if (row) return row;
108
+ try {
109
+ return await this.client.eponyme.create({ data: { name, data: this.createState(schema) } });
110
+ } catch (error) {
111
+ if (isPrismaError(error, "P2002")) return await this.client.eponyme.findUnique({ where: { name } });
112
+ throw error;
113
+ }
114
+ }
115
+ /**
116
+ * Reads the stored state along with the `updatedAt` it was read at, used as the optimistic lock token.
117
+ *
118
+ * `heal` persists the normalized state when it differs from what is stored. It has to stay on for
119
+ * writers, because healing moves `updatedAt` and so decides the lock token they are about to compare.
120
+ *
121
+ * `cache` is only ever set for published content. Draft reads serve the preview panel, which has to
122
+ * show a save immediately, and they come from the dashboard rather than from a public page — so
123
+ * there is nothing to gain by caching them and a stale preview to lose.
124
+ */
125
+ async loadRow(name, { heal = false, cache = false } = {}) {
126
+ if (!cache || heal) return await this.readRow(name, heal);
127
+ return await this.cached(`row:${name}`, () => this.readRow(name, false));
128
+ }
129
+ async readRow(name, heal) {
70
130
  const schema = this.getSchema(name);
71
131
  if (!schema) return void 0;
72
- const defaults = this.createState(schema);
73
- const row = this.getCollectionEntry(name) ? await this.client.eponyme.findUnique({ where: { name } }) : await this.client.eponyme.upsert({ where: { name }, create: { name, data: defaults }, update: {} });
74
- if (!row) return void 0;
132
+ const row = this.getCollectionEntry(name) ? await this.client.eponyme.findUnique({ where: { name } }) : await this.loadSingletonRow(name, schema);
133
+ if (!row || row.deletedAt) return void 0;
75
134
  const state = this.normalizeState(schema, row.data);
76
135
  if (sameData(state, row.data)) return { state, updatedAt: row.updatedAt };
136
+ if (!heal && this.healedByRead.has(name)) return { state, updatedAt: row.updatedAt };
137
+ this.healedByRead.add(name);
77
138
  const healed = await this.client.eponyme.update({ where: { name }, data: { data: state } });
139
+ this.invalidate(name);
78
140
  return { state, updatedAt: healed.updatedAt ?? row.updatedAt };
79
141
  }
80
- async loadState(name) {
81
- return (await this.loadRow(name))?.state;
142
+ async loadState(name, options = {}) {
143
+ return (await this.loadRow(name, options))?.state;
82
144
  }
83
145
  /**
84
146
  * Writes a new state only if the row still carries the `updatedAt` we read.
@@ -87,19 +149,38 @@ export class EponymeService {
87
149
  */
88
150
  async writeState(name, next, expectedUpdatedAt) {
89
151
  const data = next;
90
- if (!expectedUpdatedAt) {
91
- await this.client.eponyme.update({ where: { name }, data: { data } });
92
- return true;
152
+ try {
153
+ if (!expectedUpdatedAt) {
154
+ await this.client.eponyme.update({ where: { name }, data: { data } });
155
+ return true;
156
+ }
157
+ const { count } = await this.client.eponyme.updateMany({ where: { name, updatedAt: expectedUpdatedAt }, data: { data } });
158
+ return count > 0;
159
+ } finally {
160
+ this.invalidate(name);
93
161
  }
94
- const { count } = await this.client.eponyme.updateMany({ where: { name, updatedAt: expectedUpdatedAt }, data: { data } });
95
- return count > 0;
162
+ }
163
+ /**
164
+ * The live rows of a collection, shared by the public listing and the sitemap so both
165
+ * read them once. The trash and the export deliberately query around it: one wants the
166
+ * rows this filter excludes, the other must not see a cached view of the content.
167
+ */
168
+ async liveCollectionRows(name, cache = false) {
169
+ const load = () => this.client.eponyme.findMany({
170
+ where: { name: { startsWith: `${name}/` }, deletedAt: null },
171
+ orderBy: [
172
+ { updatedAt: "desc" },
173
+ { name: "asc" }
174
+ ]
175
+ });
176
+ return cache ? await this.cached(`rows:${name}`, load) : await load();
96
177
  }
97
178
  async get(name, version = "published") {
98
179
  return (await this.getResult(name, version))?.data;
99
180
  }
100
181
  async getResult(name, version = "published") {
101
182
  if (typeof version === "number") return this.getVersionResult(name, version);
102
- const state = await this.loadState(name);
183
+ const state = await this.loadState(name, { cache: version === "published" });
103
184
  if (!state) return void 0;
104
185
  if (version === "published" && this.getCollectionEntry(name) && !state.__eponyme.publishedAt) return void 0;
105
186
  return {
@@ -137,25 +218,20 @@ export class EponymeService {
137
218
  async listCollection(name, version = "published", query = {}) {
138
219
  const definition = this.collections[name];
139
220
  if (!definition) return void 0;
140
- const rows = await this.client.eponyme.findMany({
141
- where: { name: { startsWith: `${name}/` } },
142
- orderBy: [
143
- { updatedAt: "desc" },
144
- { name: "asc" }
145
- ]
146
- });
147
- const entries = await Promise.all(rows.filter((row) => !row.name.slice(name.length + 1).includes("/")).map(async (row) => {
221
+ const rows = await this.liveCollectionRows(name, version === "published");
222
+ const entries = rows.flatMap((row) => {
148
223
  const slug = row.name.slice(name.length + 1);
149
- const result = await this.getResult(row.name, version);
150
- return {
224
+ if (!slug || slug.includes("/")) return [];
225
+ const state = this.normalizeState(definition.fields, row.data);
226
+ return [{
151
227
  slug,
152
- title: String(result?.data[definition.titleField] || slug),
153
- data: result?.data ?? {},
154
- status: version === "published" ? "published" : result?.status ?? "draft",
155
- publishedAt: result?.publishedAt ?? null,
228
+ title: String(state.__eponyme[version][definition.titleField] || slug),
229
+ data: state.__eponyme[version],
230
+ status: version === "published" ? "published" : state.__eponyme.status,
231
+ publishedAt: state.__eponyme.publishedAt,
156
232
  updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
157
- };
158
- }));
233
+ }];
234
+ });
159
235
  const visible = version === "draft" ? entries : entries.filter((entry) => entry.publishedAt);
160
236
  const sorted = query.orderBy ? sortCollectionEntries(visible, query.orderBy, query.order ?? "desc") : visible;
161
237
  const skip = Math.max(0, Math.trunc(query.skip ?? 0) || 0);
@@ -180,13 +256,7 @@ export class EponymeService {
180
256
  }
181
257
  if (!path.includes(":slug"))
182
258
  throw new Error(`[Eponyme] previewPaths.${name} must include ":slug" to generate collection sitemap URLs.`);
183
- const rows = await this.client.eponyme.findMany({
184
- where: { name: { startsWith: `${name}/` } },
185
- orderBy: [
186
- { updatedAt: "desc" },
187
- { name: "asc" }
188
- ]
189
- });
259
+ const rows = await this.liveCollectionRows(name, true);
190
260
  return rows.flatMap((row) => {
191
261
  const slug = row.name.slice(name.length + 1);
192
262
  if (!slug || slug.includes("/")) return [];
@@ -200,6 +270,109 @@ export class EponymeService {
200
270
  entries.set(entry.loc, entry);
201
271
  return [...entries.values()];
202
272
  }
273
+ /**
274
+ * Every singleton and every live collection entry, as a portable file.
275
+ * Collections are read with one query each rather than one per entry.
276
+ */
277
+ async exportContent() {
278
+ const schemas = {};
279
+ const entries = [];
280
+ for (const [name, schema] of Object.entries(this.schemas)) {
281
+ schemas[name] = schemaFingerprint(schema);
282
+ const row = await this.loadRow(name);
283
+ if (row) entries.push({ name, ...toExportEntry(row.state) });
284
+ }
285
+ for (const [name, definition] of Object.entries(this.collections)) {
286
+ schemas[name] = schemaFingerprint(definition.fields);
287
+ const rows = await this.client.eponyme.findMany({
288
+ where: { name: { startsWith: `${name}/` }, deletedAt: null },
289
+ orderBy: [
290
+ { updatedAt: "desc" },
291
+ { name: "asc" }
292
+ ]
293
+ });
294
+ for (const row of rows) {
295
+ const slug = row.name.slice(name.length + 1);
296
+ if (!slug || slug.includes("/")) continue;
297
+ entries.push({ name: row.name, collection: name, ...toExportEntry(this.normalizeState(definition.fields, row.data)) });
298
+ }
299
+ }
300
+ return {
301
+ eponyme: { format: 1, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), schemas },
302
+ entries
303
+ };
304
+ }
305
+ /**
306
+ * Applies an export file on top of the current content: every entry it carries is
307
+ * upserted, everything else is left alone. Nothing is ever deleted.
308
+ *
309
+ * The schema check runs first and refuses the whole file, so an import can never
310
+ * land half of its entries into a configuration that no longer matches.
311
+ */
312
+ async importContent(file, options = {}) {
313
+ const parsed = parseExportFile(file);
314
+ if (!parsed) return { errors: ["This file is not an Eponyme export."] };
315
+ const declared = parsed.eponyme.schemas;
316
+ const mismatch = Object.keys(declared).filter((name) => {
317
+ const local = this.schemas[name] ?? this.collections[name]?.fields;
318
+ return !local || schemaFingerprint(local) !== declared[name];
319
+ });
320
+ for (const entry of parsed.entries) {
321
+ const owner = entry.collection ?? this.getCollectionEntry(entry.name)?.name ?? entry.name;
322
+ if (!(owner in declared) && !mismatch.includes(owner)) mismatch.push(owner);
323
+ }
324
+ if (mismatch.length) {
325
+ return {
326
+ errors: ["The exported schema does not match this application's configuration."],
327
+ schemaMismatch: mismatch
328
+ };
329
+ }
330
+ const result = { dryRun: Boolean(options.dryRun), created: 0, updated: 0, skipped: [] };
331
+ for (const entry of parsed.entries) {
332
+ const collectionEntry = this.getCollectionEntry(entry.name);
333
+ const schema = this.schemas[entry.name] ?? collectionEntry?.definition.fields;
334
+ if (!schema) {
335
+ result.skipped.push({ name: entry.name, reason: "No schema is configured for this entry." });
336
+ continue;
337
+ }
338
+ const row = await this.client.eponyme.findUnique({ where: { name: entry.name } });
339
+ if (row?.deletedAt) {
340
+ result.skipped.push({ name: entry.name, reason: "An entry with this name is in the trash. Restore it or delete it permanently first." });
341
+ continue;
342
+ }
343
+ const state = this.normalizeState(schema, {
344
+ __eponyme: {
345
+ version: 1,
346
+ draft: entry.draft,
347
+ published: entry.published,
348
+ status: entry.status,
349
+ publishedAt: entry.publishedAt
350
+ }
351
+ });
352
+ if (collectionEntry) {
353
+ const { slugField } = collectionEntry.definition;
354
+ if (slugField in state.__eponyme.draft) state.__eponyme.draft[slugField] = collectionEntry.slug;
355
+ if (slugField in state.__eponyme.published) state.__eponyme.published[slugField] = collectionEntry.slug;
356
+ }
357
+ if (!options.dryRun) {
358
+ const data = state;
359
+ await this.client.eponyme.upsert({ where: { name: entry.name }, create: { name: entry.name, data }, update: { data } });
360
+ this.invalidate(entry.name);
361
+ await this.client.eponymeVersion.create({
362
+ data: {
363
+ entryName: entry.name,
364
+ data,
365
+ action: "import",
366
+ status: state.__eponyme.status,
367
+ userId: options.actorId ?? null
368
+ }
369
+ });
370
+ }
371
+ if (row) result.updated++;
372
+ else result.created++;
373
+ }
374
+ return result;
375
+ }
203
376
  async createCollectionEntry(name, payload, actorId) {
204
377
  const definition = this.collections[name];
205
378
  if (!definition) return void 0;
@@ -210,8 +383,8 @@ export class EponymeService {
210
383
  const slug = normalizeSlug(String(input[definition.slugField] || input[definition.titleField] || ""));
211
384
  if (!slug) return { errors: { [definition.slugField]: ["A slug or title is required."] } };
212
385
  const entryName = `${name}/${slug}`;
213
- if (await this.client.eponyme.findUnique({ where: { name: entryName } }))
214
- return { errors: { [definition.slugField]: ["This slug is already in use."] } };
386
+ const taken = await this.slugTakenError(definition, entryName);
387
+ if (taken) return taken;
215
388
  const defaults = createDefaultEponymeData(definition.fields);
216
389
  const data = { ...defaults, ...input, [definition.slugField]: slug };
217
390
  const errors = validateEponymePatch(definition.fields, data, data, "draft");
@@ -227,27 +400,91 @@ export class EponymeService {
227
400
  };
228
401
  try {
229
402
  await this.client.eponyme.create({ data: { name: entryName, data: state } });
403
+ this.invalidate(entryName);
230
404
  } catch (error) {
231
- if (isPrismaError(error, "P2002")) return { errors: { [definition.slugField]: ["This slug is already in use."] } };
405
+ if (isPrismaError(error, "P2002")) return await this.slugTakenError(definition, entryName) ?? { errors: { [definition.slugField]: ["This slug is already in use."] } };
232
406
  throw error;
233
407
  }
234
408
  await this.client.eponymeVersion.create({ data: { entryName, data: state, action: "draft", status: "draft", userId: actorId ?? null } });
235
409
  return { slug, data, status: "draft", publishedAt: null };
236
410
  }
411
+ /**
412
+ * A trashed entry keeps its row, so it keeps its slug: reusing one has to be
413
+ * refused with an error the editor can act on rather than the generic message.
414
+ */
415
+ async slugTakenError(definition, entryName) {
416
+ const row = await this.client.eponyme.findUnique({ where: { name: entryName } });
417
+ if (!row) return void 0;
418
+ return {
419
+ errors: {
420
+ [definition.slugField]: [row.deletedAt ? "An entry with this slug is in the trash. Restore it or delete it permanently first." : "This slug is already in use."]
421
+ }
422
+ };
423
+ }
424
+ /** Moves an entry to the trash. Its history is kept, so it stays restorable. */
237
425
  async deleteCollectionEntry(name) {
238
426
  if (!this.getCollectionEntry(name)) return false;
427
+ const { count } = await this.client.eponyme.updateMany({
428
+ where: { name, deletedAt: null },
429
+ data: { deletedAt: /* @__PURE__ */ new Date() }
430
+ });
431
+ this.invalidate(name);
432
+ return count > 0;
433
+ }
434
+ async restoreCollectionEntry(name) {
435
+ if (!this.getCollectionEntry(name)) return false;
436
+ const { count } = await this.client.eponyme.updateMany({
437
+ where: { name, deletedAt: { not: null } },
438
+ data: { deletedAt: null }
439
+ });
440
+ this.invalidate(name);
441
+ return count > 0;
442
+ }
443
+ /** Definitive: `onDelete: Cascade` takes the entry's whole history with it. */
444
+ async purgeCollectionEntry(name) {
445
+ if (!this.getCollectionEntry(name)) return false;
446
+ const row = await this.client.eponyme.findUnique({ where: { name } });
447
+ if (!row?.deletedAt) return false;
239
448
  try {
240
449
  await this.client.eponyme.delete({ where: { name } });
450
+ this.invalidate(name);
241
451
  } catch (error) {
242
452
  if (isPrismaError(error, "P2025")) return false;
243
453
  throw error;
244
454
  }
245
455
  return true;
246
456
  }
457
+ /** Trashed entries of a collection, most recently updated first. */
458
+ async listCollectionTrash(name) {
459
+ const definition = this.collections[name];
460
+ if (!definition) return void 0;
461
+ const rows = await this.client.eponyme.findMany({
462
+ where: { name: { startsWith: `${name}/` }, deletedAt: { not: null } },
463
+ orderBy: [
464
+ { updatedAt: "desc" },
465
+ { name: "asc" }
466
+ ]
467
+ });
468
+ const entries = rows.flatMap((row) => {
469
+ const slug = row.name.slice(name.length + 1);
470
+ if (!slug || slug.includes("/")) return [];
471
+ const state = this.normalizeState(definition.fields, row.data);
472
+ return [{
473
+ slug,
474
+ title: String(state.__eponyme.draft[definition.titleField] || slug),
475
+ data: state.__eponyme.draft,
476
+ status: state.__eponyme.status,
477
+ publishedAt: state.__eponyme.publishedAt,
478
+ updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null,
479
+ deletedAt: row.deletedAt ? new Date(row.deletedAt).toISOString() : null
480
+ }];
481
+ });
482
+ return { entries, total: entries.length };
483
+ }
247
484
  async patch(name, payload, action = "publish", actorId) {
248
485
  const schema = this.getSchema(name);
249
486
  if (!schema) return void 0;
250
- const row = await this.loadRow(name);
487
+ const row = await this.loadRow(name, { heal: true });
251
488
  if (!row) return void 0;
252
489
  const { state, updatedAt } = row;
253
490
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { errors: { _form: ["Body must be an object."] } };
@@ -293,7 +530,7 @@ export class EponymeService {
293
530
  });
294
531
  return versions.map((version) => ({
295
532
  id: version.id,
296
- action: version.action === "draft" || version.action === "publish" ? version.action : "restore",
533
+ action: isHistoryAction(version.action) ? version.action : "restore",
297
534
  status: version.status === "draft" ? "draft" : "published",
298
535
  createdAt: new Date(version.createdAt).toISOString(),
299
536
  user: version.user ? { id: version.user.id, username: version.user.username } : null
@@ -304,7 +541,7 @@ export class EponymeService {
304
541
  if (!schema) return void 0;
305
542
  const version = await this.client.eponymeVersion.findUnique({ where: { id: versionId } });
306
543
  if (!version || version.entryName !== name) return void 0;
307
- const current = await this.loadRow(name);
544
+ const current = await this.loadRow(name, { heal: true });
308
545
  if (!current) return void 0;
309
546
  const state = this.normalizeState(schema, version.data);
310
547
  if (!await this.writeState(name, state, current.updatedAt)) return { conflict: true };
@@ -333,6 +570,68 @@ function getStoredState(value) {
333
570
  );
334
571
  return valid ? state : void 0;
335
572
  }
573
+ function isHistoryAction(action) {
574
+ return action === "draft" || action === "publish" || action === "import";
575
+ }
576
+ function toExportEntry(state) {
577
+ return {
578
+ draft: state.__eponyme.draft,
579
+ published: state.__eponyme.published,
580
+ status: state.__eponyme.status,
581
+ publishedAt: state.__eponyme.publishedAt
582
+ };
583
+ }
584
+ function isPlainObject(value) {
585
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
586
+ }
587
+ function describeSchema(schema) {
588
+ const fields = schema;
589
+ return Object.keys(fields).sort().map((key) => `${key}:${describeField(fields[key])}`).join(",");
590
+ }
591
+ function describeField(field) {
592
+ if (!isPlainObject(field) || typeof field.type !== "string") return "unknown";
593
+ const options = isPlainObject(field.options) ? field.options : {};
594
+ if (field.type === "array") {
595
+ const item = isPlainObject(options.of) ? options.of : {};
596
+ return `array(${typeof item.type === "string" ? describeField(item) : describeSchema(item)})`;
597
+ }
598
+ if (field.type === "section")
599
+ return `section(${describeSchema(isPlainObject(options.fields) ? options.fields : {})})`;
600
+ if (field.type === "tabs") {
601
+ const tabs = isPlainObject(options.tabs) ? options.tabs : {};
602
+ return `tabs(${Object.keys(tabs).sort().map((key) => {
603
+ const tab = tabs[key];
604
+ return `${key}:${describeSchema(isPlainObject(tab) && isPlainObject(tab.fields) ? tab.fields : {})}`;
605
+ }).join(",")})`;
606
+ }
607
+ return field.type;
608
+ }
609
+ export function schemaFingerprint(schema) {
610
+ return createHash("sha256").update(describeSchema(schema)).digest("hex").slice(0, 32);
611
+ }
612
+ function parseExportFile(value) {
613
+ if (!isPlainObject(value)) return void 0;
614
+ const envelope = value.eponyme;
615
+ if (!isPlainObject(envelope) || envelope.format !== 1 || !isPlainObject(envelope.schemas)) return void 0;
616
+ if (!Object.values(envelope.schemas).every((fingerprint) => typeof fingerprint === "string")) return void 0;
617
+ if (!Array.isArray(value.entries)) return void 0;
618
+ const entries = [];
619
+ for (const entry of value.entries) {
620
+ if (!isPlainObject(entry)) return void 0;
621
+ const { name, collection, draft, published, status, publishedAt } = entry;
622
+ const valid = typeof name === "string" && name.length > 0 && isPlainObject(draft) && isPlainObject(published) && (status === "draft" || status === "published") && (publishedAt === null || typeof publishedAt === "string") && (collection === void 0 || typeof collection === "string");
623
+ if (!valid) return void 0;
624
+ entries.push({ name, collection, draft, published, status, publishedAt });
625
+ }
626
+ return {
627
+ eponyme: {
628
+ format: 1,
629
+ exportedAt: typeof envelope.exportedAt === "string" ? envelope.exportedAt : (/* @__PURE__ */ new Date()).toISOString(),
630
+ schemas: envelope.schemas
631
+ },
632
+ entries
633
+ };
634
+ }
336
635
  function mergeErrors(...groups) {
337
636
  const errors = {};
338
637
  for (const group of groups) {
@@ -0,0 +1,6 @@
1
+ import type { H3Event } from 'h3';
2
+ import type { EponymeCaptchaResult, EponymeCaptchaVerifier } from '../../types/captcha.js';
3
+ export declare const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
4
+ export declare function eponymeCaptchaVerifier(): EponymeCaptchaVerifier;
5
+ export declare function isEponymeCaptchaConfigured(): boolean;
6
+ export declare function verifyEponymeCaptcha(event: H3Event, token: unknown): Promise<EponymeCaptchaResult>;
@@ -0,0 +1,14 @@
1
+ import { getRequestIP } from "h3";
2
+ import captchaVerifier from "#eponyme/captcha";
3
+ export const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
4
+ export function eponymeCaptchaVerifier() {
5
+ return captchaVerifier;
6
+ }
7
+ export function isEponymeCaptchaConfigured() {
8
+ return eponymeCaptchaVerifier().name !== "none";
9
+ }
10
+ export async function verifyEponymeCaptcha(event, token) {
11
+ if (typeof token !== "string" || !token.trim())
12
+ return { success: false, reason: "Missing captcha token." };
13
+ return eponymeCaptchaVerifier().verify(token, { ip: getRequestIP(event, { xForwardedFor: true }) });
14
+ }
@@ -0,0 +1,9 @@
1
+ import type { EponymeService } from '../services/eponyme-store.js';
2
+ /**
3
+ * Splits `<collection>/<slug>` when the name belongs to a configured collection, so
4
+ * hook listeners can branch on the collection without re-parsing the name themselves.
5
+ */
6
+ export declare function splitEponymeCollectionEntry(service: EponymeService, name: string): {
7
+ name: string;
8
+ slug: string;
9
+ } | undefined;
@@ -0,0 +1,7 @@
1
+ export function splitEponymeCollectionEntry(service, name) {
2
+ const separator = name.lastIndexOf("/");
3
+ if (separator <= 0) return void 0;
4
+ const collection = name.slice(0, separator);
5
+ const slug = name.slice(separator + 1);
6
+ return slug && service.getCollection(collection) ? { name: collection, slug } : void 0;
7
+ }
@@ -0,0 +1,16 @@
1
+ import type { EponymeHooks } from '../../types/hooks.js';
2
+ type HookName = keyof EponymeHooks;
3
+ type HookContext<Name extends HookName> = Parameters<EponymeHooks[Name]>[0];
4
+ /**
5
+ * Notification hooks. The write already happened, so a listener that throws is
6
+ * logged and swallowed: a failing webhook must not report a successful save as an
7
+ * error the editor cannot act on.
8
+ */
9
+ export declare function callEponymeHook<Name extends Exclude<HookName, `${string}before${string}`>>(name: Name, context: HookContext<Name>): Promise<void>;
10
+ /**
11
+ * Blocking hooks, run before the write. A listener may mutate `context.data` to amend
12
+ * the payload, or throw to reject the operation — the error surfaces as a 422 so the
13
+ * message reaches the editor.
14
+ */
15
+ export declare function callEponymeBlockingHook<Name extends Extract<HookName, `${string}before${string}`>>(name: Name, context: HookContext<Name>): Promise<void>;
16
+ export {};
@@ -0,0 +1,24 @@
1
+ import { createError } from "h3";
2
+ import { useNitroApp } from "nitropack/runtime";
3
+ function hooks() {
4
+ return useNitroApp().hooks;
5
+ }
6
+ export async function callEponymeHook(name, context) {
7
+ try {
8
+ await hooks().callHook(name, context);
9
+ } catch (error) {
10
+ console.error(`[Eponyme] The "${name}" hook threw. The operation itself succeeded.`, error);
11
+ }
12
+ }
13
+ export async function callEponymeBlockingHook(name, context) {
14
+ try {
15
+ await hooks().callHook(name, context);
16
+ } catch (error) {
17
+ if (error && typeof error === "object" && "statusCode" in error) throw error;
18
+ throw createError({
19
+ statusCode: 422,
20
+ statusMessage: error instanceof Error ? error.message : `Rejected by the "${name}" hook.`,
21
+ data: { errors: { _form: [error instanceof Error ? error.message : "Rejected by a server hook."] } }
22
+ });
23
+ }
24
+ }
@@ -0,0 +1,16 @@
1
+ export interface EponymeCaptchaResult {
2
+ success: boolean;
3
+ /** Provider-specific reason, surfaced in server logs rather than to the visitor. */
4
+ reason?: string;
5
+ }
6
+ /**
7
+ * Verifies the token a visitor's captcha widget produced. Implemented by an adapter
8
+ * package such as `@karibsen/eponyme-captcha`; the core ships none, so a project
9
+ * without an adapter simply has no captcha.
10
+ */
11
+ export interface EponymeCaptchaVerifier {
12
+ name: string;
13
+ verify: (token: string, context: {
14
+ ip?: string;
15
+ }) => Promise<EponymeCaptchaResult>;
16
+ }
File without changes
@@ -16,3 +16,10 @@ declare module '#eponyme/variables' {
16
16
  const variables: EponymeVariables
17
17
  export default variables
18
18
  }
19
+
20
+ declare module '#eponyme/captcha' {
21
+ import type { EponymeCaptchaVerifier } from './captcha'
22
+
23
+ const verifier: EponymeCaptchaVerifier
24
+ export default verifier
25
+ }
@@ -42,6 +42,8 @@ export interface EponymeFormOptions<T extends EponymeFormSchema = EponymeFormSch
42
42
  honeypot?: string | false;
43
43
  /** In bytes. */
44
44
  maxBodyBytes?: number;
45
+ /** Requires a valid captcha token. Needs an adapter such as `@karibsen/eponyme-captcha`. */
46
+ captcha?: boolean;
45
47
  }
46
48
  export interface EponymeFormDefinitionBase {
47
49
  __eponymeForm: true;
@@ -51,6 +53,7 @@ export interface EponymeFormDefinitionBase {
51
53
  submission: Required<EponymeFormSubmissionOptions>;
52
54
  honeypot: string | false;
53
55
  maxBodyBytes: number;
56
+ captcha: boolean;
54
57
  }
55
58
  export interface EponymeFormDefinition<T extends EponymeFormSchema = EponymeFormSchema> extends EponymeFormDefinitionBase {
56
59
  fields: T;
@@ -70,6 +70,7 @@ export interface UrlValue {
70
70
  href: string;
71
71
  type: UrlType;
72
72
  openInNewTab: boolean;
73
+ download?: boolean;
73
74
  }
74
75
  export interface UrlFieldOptions extends DefaultFieldOptions<UrlValue> {
75
76
  placeholder?: string;