@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.
- package/README.md +159 -12
- package/dist/eponyme.mjs +2 -1
- package/dist/module.d.mts +6 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +55 -36
- package/dist/runtime/components/editor/EponymeCollectionPage.vue +80 -18
- package/dist/runtime/components/editor/EponymeForm.vue +2 -2
- package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
- package/dist/runtime/components/editor/EponymeSidebar.vue +24 -32
- package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
- package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
- package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
- package/dist/runtime/components/fields/RichTextField.vue +51 -10
- package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
- package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
- package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
- package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
- package/dist/runtime/composables/useEponyme.js +5 -3
- package/dist/runtime/composables/useEponymeCollection.js +3 -2
- package/dist/runtime/composables/useEponymeForm.d.ts +4 -0
- package/dist/runtime/composables/useEponymeForm.js +11 -1
- package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
- package/dist/runtime/composables/useEponymeHistory.js +2 -1
- package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
- package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
- package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
- package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
- package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-export.get.js +11 -0
- package/dist/runtime/server/api/eponyme-forms/[path].post.js +29 -1
- package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
- package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
- package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-import.post.js +31 -0
- package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
- package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
- package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
- package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
- package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
- package/dist/runtime/server/services/eponyme-form-store.js +2 -1
- package/dist/runtime/server/services/eponyme-service.js +8 -1
- package/dist/runtime/server/services/eponyme-store.d.ts +118 -5
- package/dist/runtime/server/services/eponyme-store.js +344 -45
- package/dist/runtime/server/utils/eponyme-captcha.d.ts +6 -0
- package/dist/runtime/server/utils/eponyme-captcha.js +14 -0
- package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
- package/dist/runtime/server/utils/eponyme-entry.js +7 -0
- package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
- package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
- package/dist/runtime/types/captcha.d.ts +16 -0
- package/dist/runtime/types/captcha.js +0 -0
- package/dist/runtime/types/eponyme-config.d.ts +7 -0
- package/dist/runtime/types/eponyme.d.ts +3 -0
- package/dist/runtime/types/field.d.ts +1 -0
- package/dist/runtime/types/hooks.d.ts +67 -0
- package/dist/runtime/types/hooks.js +0 -0
- package/dist/runtime/types/index.d.ts +1 -0
- package/dist/runtime/types/index.js +1 -0
- package/dist/runtime/types/nuxt.d.ts +10 -0
- package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
- package/dist/runtime/utils/build-navigation-tree.js +30 -0
- package/dist/runtime/utils/date-locale.d.ts +10 -0
- package/dist/runtime/utils/date-locale.js +1 -0
- package/dist/runtime/utils/downloadable-link.d.ts +2 -0
- package/dist/runtime/utils/downloadable-link.js +35 -0
- package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
- package/dist/runtime/utils/filter-navigation-tree.js +39 -0
- package/dist/runtime/utils/get-field-default-value.js +1 -1
- package/dist/runtime/utils/hydration-cache.d.ts +11 -0
- package/dist/runtime/utils/hydration-cache.js +3 -0
- package/dist/runtime/utils/no-captcha.d.ts +7 -0
- package/dist/runtime/utils/no-captcha.js +5 -0
- package/dist/runtime/utils/validate-eponyme-data.js +1 -1
- package/dist/runtime/utils/variables.d.ts +8 -1
- package/dist/runtime/utils/variables.js +17 -4
- 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
|
-
/**
|
|
69
|
-
|
|
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
|
|
73
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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.
|
|
141
|
-
|
|
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
|
-
|
|
150
|
-
|
|
224
|
+
if (!slug || slug.includes("/")) return [];
|
|
225
|
+
const state = this.normalizeState(definition.fields, row.data);
|
|
226
|
+
return [{
|
|
151
227
|
slug,
|
|
152
|
-
title: String(
|
|
153
|
-
data:
|
|
154
|
-
status: version === "published" ? "published" :
|
|
155
|
-
publishedAt:
|
|
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.
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
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;
|