@karibsen/eponyme 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +198 -16
- package/dist/eponyme.d.mts +22 -2
- package/dist/eponyme.mjs +5 -1
- package/dist/module.d.mts +3 -2
- package/dist/module.json +1 -1
- package/dist/module.mjs +73 -43
- package/dist/runtime/components/editor/EponymeCollectionPage.vue +81 -19
- package/dist/runtime/components/editor/EponymeForm.vue +3 -3
- package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
- package/dist/runtime/components/editor/EponymeSidebar.vue +25 -33
- 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 +80 -8
- 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.d.ts +2 -0
- package/dist/runtime/composables/useEponyme.js +7 -5
- package/dist/runtime/composables/useEponymeCollection.d.ts +12 -2
- package/dist/runtime/composables/useEponymeCollection.js +15 -5
- package/dist/runtime/composables/useEponymeForm.js +4 -1
- package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
- package/dist/runtime/composables/useEponymeHistory.js +2 -1
- package/dist/runtime/composables/useEponymeVariables.d.ts +3 -0
- package/dist/runtime/composables/useEponymeVariables.js +5 -0
- package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
- package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
- package/dist/runtime/server/api/eponyme/[name].get.d.ts +5 -1
- package/dist/runtime/server/api/eponyme/[name].get.js +3 -1
- 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-collections/[path].get.d.ts +1 -3
- package/dist/runtime/server/api/eponyme-collections/[path].get.js +31 -4
- 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 +14 -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.d.ts +1 -0
- package/dist/runtime/server/services/eponyme-store.d.ts +91 -3
- package/dist/runtime/server/services/eponyme-store.js +272 -9
- 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/server/utils/eponyme-variables.d.ts +5 -0
- package/dist/runtime/server/utils/eponyme-variables.js +5 -0
- package/dist/runtime/types/eponyme-config.d.ts +7 -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/types/variables.d.ts +17 -0
- package/dist/runtime/types/variables.js +0 -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/empty-variables.d.ts +2 -0
- package/dist/runtime/utils/empty-variables.js +1 -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/validate-eponyme-data.js +1 -1
- package/dist/runtime/utils/variables.d.ts +18 -0
- package/dist/runtime/utils/variables.js +52 -0
- package/dist/types.d.mts +2 -2
- package/package.json +3 -1
|
@@ -1,9 +1,11 @@
|
|
|
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";
|
|
4
5
|
import { normalizeSlug } from "../../utils/normalize-slug.js";
|
|
5
6
|
import { applyPreviewSlug } from "../../utils/preview.js";
|
|
6
7
|
import { validateEponymeData, validateEponymePatch } from "../../utils/validate-eponyme-data.js";
|
|
8
|
+
export const COLLECTION_METADATA_KEYS = ["updatedAt", "publishedAt", "title", "slug"];
|
|
7
9
|
export class EponymeService {
|
|
8
10
|
constructor(config, client) {
|
|
9
11
|
this.client = client;
|
|
@@ -70,7 +72,7 @@ export class EponymeService {
|
|
|
70
72
|
if (!schema) return void 0;
|
|
71
73
|
const defaults = this.createState(schema);
|
|
72
74
|
const row = this.getCollectionEntry(name) ? await this.client.eponyme.findUnique({ where: { name } }) : await this.client.eponyme.upsert({ where: { name }, create: { name, data: defaults }, update: {} });
|
|
73
|
-
if (!row) return void 0;
|
|
75
|
+
if (!row || row.deletedAt) return void 0;
|
|
74
76
|
const state = this.normalizeState(schema, row.data);
|
|
75
77
|
if (sameData(state, row.data)) return { state, updatedAt: row.updatedAt };
|
|
76
78
|
const healed = await this.client.eponyme.update({ where: { name }, data: { data: state } });
|
|
@@ -127,11 +129,17 @@ export class EponymeService {
|
|
|
127
129
|
}));
|
|
128
130
|
return Object.fromEntries(entries);
|
|
129
131
|
}
|
|
130
|
-
|
|
132
|
+
/** Metadata keys that can be sorted on, alongside every field of the collection. */
|
|
133
|
+
collectionSortKeys(name) {
|
|
134
|
+
const definition = this.collections[name];
|
|
135
|
+
if (!definition) return void 0;
|
|
136
|
+
return [.../* @__PURE__ */ new Set([...COLLECTION_METADATA_KEYS, ...Object.keys(definition.fields)])];
|
|
137
|
+
}
|
|
138
|
+
async listCollection(name, version = "published", query = {}) {
|
|
131
139
|
const definition = this.collections[name];
|
|
132
140
|
if (!definition) return void 0;
|
|
133
141
|
const rows = await this.client.eponyme.findMany({
|
|
134
|
-
where: { name: { startsWith: `${name}/` } },
|
|
142
|
+
where: { name: { startsWith: `${name}/` }, deletedAt: null },
|
|
135
143
|
orderBy: [
|
|
136
144
|
{ updatedAt: "desc" },
|
|
137
145
|
{ name: "asc" }
|
|
@@ -149,7 +157,14 @@ export class EponymeService {
|
|
|
149
157
|
updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
|
|
150
158
|
};
|
|
151
159
|
}));
|
|
152
|
-
|
|
160
|
+
const visible = version === "draft" ? entries : entries.filter((entry) => entry.publishedAt);
|
|
161
|
+
const sorted = query.orderBy ? sortCollectionEntries(visible, query.orderBy, query.order ?? "desc") : visible;
|
|
162
|
+
const skip = Math.max(0, Math.trunc(query.skip ?? 0) || 0);
|
|
163
|
+
const take = query.take === void 0 ? void 0 : Math.max(0, Math.trunc(query.take) || 0);
|
|
164
|
+
return {
|
|
165
|
+
entries: take === void 0 ? sorted.slice(skip) : sorted.slice(skip, skip + take),
|
|
166
|
+
total: sorted.length
|
|
167
|
+
};
|
|
153
168
|
}
|
|
154
169
|
/**
|
|
155
170
|
* Builds the public URL list configured by `previewPaths`.
|
|
@@ -167,7 +182,7 @@ export class EponymeService {
|
|
|
167
182
|
if (!path.includes(":slug"))
|
|
168
183
|
throw new Error(`[Eponyme] previewPaths.${name} must include ":slug" to generate collection sitemap URLs.`);
|
|
169
184
|
const rows = await this.client.eponyme.findMany({
|
|
170
|
-
where: { name: { startsWith: `${name}/` } },
|
|
185
|
+
where: { name: { startsWith: `${name}/` }, deletedAt: null },
|
|
171
186
|
orderBy: [
|
|
172
187
|
{ updatedAt: "desc" },
|
|
173
188
|
{ name: "asc" }
|
|
@@ -186,6 +201,108 @@ export class EponymeService {
|
|
|
186
201
|
entries.set(entry.loc, entry);
|
|
187
202
|
return [...entries.values()];
|
|
188
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Every singleton and every live collection entry, as a portable file.
|
|
206
|
+
* Collections are read with one query each rather than one per entry.
|
|
207
|
+
*/
|
|
208
|
+
async exportContent() {
|
|
209
|
+
const schemas = {};
|
|
210
|
+
const entries = [];
|
|
211
|
+
for (const [name, schema] of Object.entries(this.schemas)) {
|
|
212
|
+
schemas[name] = schemaFingerprint(schema);
|
|
213
|
+
const row = await this.loadRow(name);
|
|
214
|
+
if (row) entries.push({ name, ...toExportEntry(row.state) });
|
|
215
|
+
}
|
|
216
|
+
for (const [name, definition] of Object.entries(this.collections)) {
|
|
217
|
+
schemas[name] = schemaFingerprint(definition.fields);
|
|
218
|
+
const rows = await this.client.eponyme.findMany({
|
|
219
|
+
where: { name: { startsWith: `${name}/` }, deletedAt: null },
|
|
220
|
+
orderBy: [
|
|
221
|
+
{ updatedAt: "desc" },
|
|
222
|
+
{ name: "asc" }
|
|
223
|
+
]
|
|
224
|
+
});
|
|
225
|
+
for (const row of rows) {
|
|
226
|
+
const slug = row.name.slice(name.length + 1);
|
|
227
|
+
if (!slug || slug.includes("/")) continue;
|
|
228
|
+
entries.push({ name: row.name, collection: name, ...toExportEntry(this.normalizeState(definition.fields, row.data)) });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
eponyme: { format: 1, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), schemas },
|
|
233
|
+
entries
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Applies an export file on top of the current content: every entry it carries is
|
|
238
|
+
* upserted, everything else is left alone. Nothing is ever deleted.
|
|
239
|
+
*
|
|
240
|
+
* The schema check runs first and refuses the whole file, so an import can never
|
|
241
|
+
* land half of its entries into a configuration that no longer matches.
|
|
242
|
+
*/
|
|
243
|
+
async importContent(file, options = {}) {
|
|
244
|
+
const parsed = parseExportFile(file);
|
|
245
|
+
if (!parsed) return { errors: ["This file is not an Eponyme export."] };
|
|
246
|
+
const declared = parsed.eponyme.schemas;
|
|
247
|
+
const mismatch = Object.keys(declared).filter((name) => {
|
|
248
|
+
const local = this.schemas[name] ?? this.collections[name]?.fields;
|
|
249
|
+
return !local || schemaFingerprint(local) !== declared[name];
|
|
250
|
+
});
|
|
251
|
+
for (const entry of parsed.entries) {
|
|
252
|
+
const owner = entry.collection ?? this.getCollectionEntry(entry.name)?.name ?? entry.name;
|
|
253
|
+
if (!(owner in declared) && !mismatch.includes(owner)) mismatch.push(owner);
|
|
254
|
+
}
|
|
255
|
+
if (mismatch.length) {
|
|
256
|
+
return {
|
|
257
|
+
errors: ["The exported schema does not match this application's configuration."],
|
|
258
|
+
schemaMismatch: mismatch
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const result = { dryRun: Boolean(options.dryRun), created: 0, updated: 0, skipped: [] };
|
|
262
|
+
for (const entry of parsed.entries) {
|
|
263
|
+
const collectionEntry = this.getCollectionEntry(entry.name);
|
|
264
|
+
const schema = this.schemas[entry.name] ?? collectionEntry?.definition.fields;
|
|
265
|
+
if (!schema) {
|
|
266
|
+
result.skipped.push({ name: entry.name, reason: "No schema is configured for this entry." });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const row = await this.client.eponyme.findUnique({ where: { name: entry.name } });
|
|
270
|
+
if (row?.deletedAt) {
|
|
271
|
+
result.skipped.push({ name: entry.name, reason: "An entry with this name is in the trash. Restore it or delete it permanently first." });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const state = this.normalizeState(schema, {
|
|
275
|
+
__eponyme: {
|
|
276
|
+
version: 1,
|
|
277
|
+
draft: entry.draft,
|
|
278
|
+
published: entry.published,
|
|
279
|
+
status: entry.status,
|
|
280
|
+
publishedAt: entry.publishedAt
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
if (collectionEntry) {
|
|
284
|
+
const { slugField } = collectionEntry.definition;
|
|
285
|
+
if (slugField in state.__eponyme.draft) state.__eponyme.draft[slugField] = collectionEntry.slug;
|
|
286
|
+
if (slugField in state.__eponyme.published) state.__eponyme.published[slugField] = collectionEntry.slug;
|
|
287
|
+
}
|
|
288
|
+
if (!options.dryRun) {
|
|
289
|
+
const data = state;
|
|
290
|
+
await this.client.eponyme.upsert({ where: { name: entry.name }, create: { name: entry.name, data }, update: { data } });
|
|
291
|
+
await this.client.eponymeVersion.create({
|
|
292
|
+
data: {
|
|
293
|
+
entryName: entry.name,
|
|
294
|
+
data,
|
|
295
|
+
action: "import",
|
|
296
|
+
status: state.__eponyme.status,
|
|
297
|
+
userId: options.actorId ?? null
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (row) result.updated++;
|
|
302
|
+
else result.created++;
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
189
306
|
async createCollectionEntry(name, payload, actorId) {
|
|
190
307
|
const definition = this.collections[name];
|
|
191
308
|
if (!definition) return void 0;
|
|
@@ -196,8 +313,8 @@ export class EponymeService {
|
|
|
196
313
|
const slug = normalizeSlug(String(input[definition.slugField] || input[definition.titleField] || ""));
|
|
197
314
|
if (!slug) return { errors: { [definition.slugField]: ["A slug or title is required."] } };
|
|
198
315
|
const entryName = `${name}/${slug}`;
|
|
199
|
-
|
|
200
|
-
|
|
316
|
+
const taken = await this.slugTakenError(definition, entryName);
|
|
317
|
+
if (taken) return taken;
|
|
201
318
|
const defaults = createDefaultEponymeData(definition.fields);
|
|
202
319
|
const data = { ...defaults, ...input, [definition.slugField]: slug };
|
|
203
320
|
const errors = validateEponymePatch(definition.fields, data, data, "draft");
|
|
@@ -214,14 +331,47 @@ export class EponymeService {
|
|
|
214
331
|
try {
|
|
215
332
|
await this.client.eponyme.create({ data: { name: entryName, data: state } });
|
|
216
333
|
} catch (error) {
|
|
217
|
-
if (isPrismaError(error, "P2002")) return { errors: { [definition.slugField]: ["This slug is already in use."] } };
|
|
334
|
+
if (isPrismaError(error, "P2002")) return await this.slugTakenError(definition, entryName) ?? { errors: { [definition.slugField]: ["This slug is already in use."] } };
|
|
218
335
|
throw error;
|
|
219
336
|
}
|
|
220
337
|
await this.client.eponymeVersion.create({ data: { entryName, data: state, action: "draft", status: "draft", userId: actorId ?? null } });
|
|
221
338
|
return { slug, data, status: "draft", publishedAt: null };
|
|
222
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* A trashed entry keeps its row, so it keeps its slug: reusing one has to be
|
|
342
|
+
* refused with an error the editor can act on rather than the generic message.
|
|
343
|
+
*/
|
|
344
|
+
async slugTakenError(definition, entryName) {
|
|
345
|
+
const row = await this.client.eponyme.findUnique({ where: { name: entryName } });
|
|
346
|
+
if (!row) return void 0;
|
|
347
|
+
return {
|
|
348
|
+
errors: {
|
|
349
|
+
[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."]
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/** Moves an entry to the trash. Its history is kept, so it stays restorable. */
|
|
223
354
|
async deleteCollectionEntry(name) {
|
|
224
355
|
if (!this.getCollectionEntry(name)) return false;
|
|
356
|
+
const { count } = await this.client.eponyme.updateMany({
|
|
357
|
+
where: { name, deletedAt: null },
|
|
358
|
+
data: { deletedAt: /* @__PURE__ */ new Date() }
|
|
359
|
+
});
|
|
360
|
+
return count > 0;
|
|
361
|
+
}
|
|
362
|
+
async restoreCollectionEntry(name) {
|
|
363
|
+
if (!this.getCollectionEntry(name)) return false;
|
|
364
|
+
const { count } = await this.client.eponyme.updateMany({
|
|
365
|
+
where: { name, deletedAt: { not: null } },
|
|
366
|
+
data: { deletedAt: null }
|
|
367
|
+
});
|
|
368
|
+
return count > 0;
|
|
369
|
+
}
|
|
370
|
+
/** Definitive: `onDelete: Cascade` takes the entry's whole history with it. */
|
|
371
|
+
async purgeCollectionEntry(name) {
|
|
372
|
+
if (!this.getCollectionEntry(name)) return false;
|
|
373
|
+
const row = await this.client.eponyme.findUnique({ where: { name } });
|
|
374
|
+
if (!row?.deletedAt) return false;
|
|
225
375
|
try {
|
|
226
376
|
await this.client.eponyme.delete({ where: { name } });
|
|
227
377
|
} catch (error) {
|
|
@@ -230,6 +380,33 @@ export class EponymeService {
|
|
|
230
380
|
}
|
|
231
381
|
return true;
|
|
232
382
|
}
|
|
383
|
+
/** Trashed entries of a collection, most recently updated first. */
|
|
384
|
+
async listCollectionTrash(name) {
|
|
385
|
+
const definition = this.collections[name];
|
|
386
|
+
if (!definition) return void 0;
|
|
387
|
+
const rows = await this.client.eponyme.findMany({
|
|
388
|
+
where: { name: { startsWith: `${name}/` }, deletedAt: { not: null } },
|
|
389
|
+
orderBy: [
|
|
390
|
+
{ updatedAt: "desc" },
|
|
391
|
+
{ name: "asc" }
|
|
392
|
+
]
|
|
393
|
+
});
|
|
394
|
+
const entries = rows.flatMap((row) => {
|
|
395
|
+
const slug = row.name.slice(name.length + 1);
|
|
396
|
+
if (!slug || slug.includes("/")) return [];
|
|
397
|
+
const state = this.normalizeState(definition.fields, row.data);
|
|
398
|
+
return [{
|
|
399
|
+
slug,
|
|
400
|
+
title: String(state.__eponyme.draft[definition.titleField] || slug),
|
|
401
|
+
data: state.__eponyme.draft,
|
|
402
|
+
status: state.__eponyme.status,
|
|
403
|
+
publishedAt: state.__eponyme.publishedAt,
|
|
404
|
+
updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null,
|
|
405
|
+
deletedAt: row.deletedAt ? new Date(row.deletedAt).toISOString() : null
|
|
406
|
+
}];
|
|
407
|
+
});
|
|
408
|
+
return { entries, total: entries.length };
|
|
409
|
+
}
|
|
233
410
|
async patch(name, payload, action = "publish", actorId) {
|
|
234
411
|
const schema = this.getSchema(name);
|
|
235
412
|
if (!schema) return void 0;
|
|
@@ -279,7 +456,7 @@ export class EponymeService {
|
|
|
279
456
|
});
|
|
280
457
|
return versions.map((version) => ({
|
|
281
458
|
id: version.id,
|
|
282
|
-
action: version.action
|
|
459
|
+
action: isHistoryAction(version.action) ? version.action : "restore",
|
|
283
460
|
status: version.status === "draft" ? "draft" : "published",
|
|
284
461
|
createdAt: new Date(version.createdAt).toISOString(),
|
|
285
462
|
user: version.user ? { id: version.user.id, username: version.user.username } : null
|
|
@@ -319,6 +496,68 @@ function getStoredState(value) {
|
|
|
319
496
|
);
|
|
320
497
|
return valid ? state : void 0;
|
|
321
498
|
}
|
|
499
|
+
function isHistoryAction(action) {
|
|
500
|
+
return action === "draft" || action === "publish" || action === "import";
|
|
501
|
+
}
|
|
502
|
+
function toExportEntry(state) {
|
|
503
|
+
return {
|
|
504
|
+
draft: state.__eponyme.draft,
|
|
505
|
+
published: state.__eponyme.published,
|
|
506
|
+
status: state.__eponyme.status,
|
|
507
|
+
publishedAt: state.__eponyme.publishedAt
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function isPlainObject(value) {
|
|
511
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
512
|
+
}
|
|
513
|
+
function describeSchema(schema) {
|
|
514
|
+
const fields = schema;
|
|
515
|
+
return Object.keys(fields).sort().map((key) => `${key}:${describeField(fields[key])}`).join(",");
|
|
516
|
+
}
|
|
517
|
+
function describeField(field) {
|
|
518
|
+
if (!isPlainObject(field) || typeof field.type !== "string") return "unknown";
|
|
519
|
+
const options = isPlainObject(field.options) ? field.options : {};
|
|
520
|
+
if (field.type === "array") {
|
|
521
|
+
const item = isPlainObject(options.of) ? options.of : {};
|
|
522
|
+
return `array(${typeof item.type === "string" ? describeField(item) : describeSchema(item)})`;
|
|
523
|
+
}
|
|
524
|
+
if (field.type === "section")
|
|
525
|
+
return `section(${describeSchema(isPlainObject(options.fields) ? options.fields : {})})`;
|
|
526
|
+
if (field.type === "tabs") {
|
|
527
|
+
const tabs = isPlainObject(options.tabs) ? options.tabs : {};
|
|
528
|
+
return `tabs(${Object.keys(tabs).sort().map((key) => {
|
|
529
|
+
const tab = tabs[key];
|
|
530
|
+
return `${key}:${describeSchema(isPlainObject(tab) && isPlainObject(tab.fields) ? tab.fields : {})}`;
|
|
531
|
+
}).join(",")})`;
|
|
532
|
+
}
|
|
533
|
+
return field.type;
|
|
534
|
+
}
|
|
535
|
+
export function schemaFingerprint(schema) {
|
|
536
|
+
return createHash("sha256").update(describeSchema(schema)).digest("hex").slice(0, 32);
|
|
537
|
+
}
|
|
538
|
+
function parseExportFile(value) {
|
|
539
|
+
if (!isPlainObject(value)) return void 0;
|
|
540
|
+
const envelope = value.eponyme;
|
|
541
|
+
if (!isPlainObject(envelope) || envelope.format !== 1 || !isPlainObject(envelope.schemas)) return void 0;
|
|
542
|
+
if (!Object.values(envelope.schemas).every((fingerprint) => typeof fingerprint === "string")) return void 0;
|
|
543
|
+
if (!Array.isArray(value.entries)) return void 0;
|
|
544
|
+
const entries = [];
|
|
545
|
+
for (const entry of value.entries) {
|
|
546
|
+
if (!isPlainObject(entry)) return void 0;
|
|
547
|
+
const { name, collection, draft, published, status, publishedAt } = entry;
|
|
548
|
+
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");
|
|
549
|
+
if (!valid) return void 0;
|
|
550
|
+
entries.push({ name, collection, draft, published, status, publishedAt });
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
eponyme: {
|
|
554
|
+
format: 1,
|
|
555
|
+
exportedAt: typeof envelope.exportedAt === "string" ? envelope.exportedAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
556
|
+
schemas: envelope.schemas
|
|
557
|
+
},
|
|
558
|
+
entries
|
|
559
|
+
};
|
|
560
|
+
}
|
|
322
561
|
function mergeErrors(...groups) {
|
|
323
562
|
const errors = {};
|
|
324
563
|
for (const group of groups) {
|
|
@@ -333,6 +572,30 @@ export function isPrismaError(error, code) {
|
|
|
333
572
|
function sameData(left, right) {
|
|
334
573
|
return isDeepStrictEqual(left, right);
|
|
335
574
|
}
|
|
575
|
+
function sortValue(entry, key) {
|
|
576
|
+
if (COLLECTION_METADATA_KEYS.includes(key))
|
|
577
|
+
return entry[key];
|
|
578
|
+
return entry.data[key];
|
|
579
|
+
}
|
|
580
|
+
function isEmptyValue(value) {
|
|
581
|
+
return value === void 0 || value === null || value === "";
|
|
582
|
+
}
|
|
583
|
+
function sortCollectionEntries(entries, key, direction) {
|
|
584
|
+
const factor = direction === "asc" ? 1 : -1;
|
|
585
|
+
return [...entries].sort((left, right) => {
|
|
586
|
+
const a = sortValue(left, key);
|
|
587
|
+
const b = sortValue(right, key);
|
|
588
|
+
if (isEmptyValue(a) || isEmptyValue(b)) {
|
|
589
|
+
if (isEmptyValue(a) && isEmptyValue(b)) return left.slug.localeCompare(right.slug);
|
|
590
|
+
return isEmptyValue(a) ? 1 : -1;
|
|
591
|
+
}
|
|
592
|
+
if (typeof a === "number" && typeof b === "number")
|
|
593
|
+
return (a - b) * factor || left.slug.localeCompare(right.slug);
|
|
594
|
+
if (typeof a === "boolean" && typeof b === "boolean")
|
|
595
|
+
return (Number(a) - Number(b)) * factor || left.slug.localeCompare(right.slug);
|
|
596
|
+
return String(a).localeCompare(String(b), void 0, { numeric: true }) * factor || left.slug.localeCompare(right.slug);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
336
599
|
function withLastmod(loc, publishedAt) {
|
|
337
600
|
return publishedAt ? { loc, lastmod: publishedAt } : { loc };
|
|
338
601
|
}
|
|
@@ -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,5 @@
|
|
|
1
|
+
import eponymeVariables from "#eponyme/variables";
|
|
2
|
+
import { interpolateEponymeValue, resolveEponymeVariables } from "../../utils/variables.js";
|
|
3
|
+
export function interpolateEponymeContent(value) {
|
|
4
|
+
return interpolateEponymeValue(value, resolveEponymeVariables(eponymeVariables));
|
|
5
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { EponymeAction, EponymeStatus } from '../server/services/eponyme-store.js';
|
|
2
|
+
export interface EponymeEntryContext {
|
|
3
|
+
/** Full entry name, `homepage` or `articles/my-article`. */
|
|
4
|
+
name: string;
|
|
5
|
+
/** Collection name and slug when the entry belongs to one. */
|
|
6
|
+
collection?: {
|
|
7
|
+
name: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
};
|
|
10
|
+
action: EponymeAction | 'restore';
|
|
11
|
+
status: EponymeStatus;
|
|
12
|
+
publishedAt: string | null;
|
|
13
|
+
data: Record<string, unknown>;
|
|
14
|
+
/** Author of the write, absent for an unattended one. */
|
|
15
|
+
userId?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Mutating `data` here changes what gets written. */
|
|
18
|
+
export interface EponymeEntryBeforeSaveContext {
|
|
19
|
+
name: string;
|
|
20
|
+
collection?: {
|
|
21
|
+
name: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
};
|
|
24
|
+
action: EponymeAction;
|
|
25
|
+
data: Record<string, unknown>;
|
|
26
|
+
userId?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Trash moves carry no content: the entry is untouched, only its availability changed. */
|
|
29
|
+
export interface EponymeEntryTrashContext {
|
|
30
|
+
/** Full entry name, `articles/my-article`. Only collection entries can be trashed. */
|
|
31
|
+
name: string;
|
|
32
|
+
collection: {
|
|
33
|
+
name: string;
|
|
34
|
+
slug: string;
|
|
35
|
+
};
|
|
36
|
+
/** Author of the move, absent for an unattended one. */
|
|
37
|
+
userId?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface EponymeFormSubmissionContext {
|
|
40
|
+
form: string;
|
|
41
|
+
data: Record<string, unknown>;
|
|
42
|
+
/** Absent until the submission is stored, so `undefined` in `before`. */
|
|
43
|
+
id?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface EponymeHooks {
|
|
46
|
+
/**
|
|
47
|
+
* Runs before the write. Throwing rejects it with a 422, so an application can
|
|
48
|
+
* enforce a rule the schema cannot express. Mutating `data` amends the payload.
|
|
49
|
+
*/
|
|
50
|
+
'eponyme:entry:beforeSave': (context: EponymeEntryBeforeSaveContext) => void | Promise<void>;
|
|
51
|
+
/** After a draft save. The entry is already stored. */
|
|
52
|
+
'eponyme:entry:saved': (context: EponymeEntryContext) => void | Promise<void>;
|
|
53
|
+
/** After a publication: the usual place to purge a cache or ping a webhook. */
|
|
54
|
+
'eponyme:entry:published': (context: EponymeEntryContext) => void | Promise<void>;
|
|
55
|
+
/** After a history version was restored as the current draft. */
|
|
56
|
+
'eponyme:entry:restored': (context: EponymeEntryContext) => void | Promise<void>;
|
|
57
|
+
/** After a collection entry was moved to the trash. It is recoverable, nothing was destroyed. */
|
|
58
|
+
'eponyme:entry:trashed': (context: EponymeEntryTrashContext) => void | Promise<void>;
|
|
59
|
+
/** After a collection entry was taken back out of the trash. */
|
|
60
|
+
'eponyme:entry:untrashed': (context: EponymeEntryTrashContext) => void | Promise<void>;
|
|
61
|
+
/** After a collection entry and its whole history were deleted for good. */
|
|
62
|
+
'eponyme:entry:purged': (context: EponymeEntryTrashContext) => void | Promise<void>;
|
|
63
|
+
/** Runs before a managed submission is stored. Throwing rejects it with a 422. */
|
|
64
|
+
'eponyme:form:beforeSubmit': (context: EponymeFormSubmissionContext) => void | Promise<void>;
|
|
65
|
+
/** After a managed submission was stored, with its id. */
|
|
66
|
+
'eponyme:form:submitted': (context: Required<EponymeFormSubmissionContext>) => void | Promise<void>;
|
|
67
|
+
}
|
|
File without changes
|
|
@@ -9,3 +9,13 @@ declare module '@nuxt/schema' {
|
|
|
9
9
|
eponyme: ModuleOptions
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
declare module 'nitropack/types' {
|
|
14
|
+
import type { EponymeHooks } from './hooks'
|
|
15
|
+
|
|
16
|
+
// Interface augmentation: the body is empty by design, the point is the supertype.
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
18
|
+
interface NitroRuntimeHooks extends EponymeHooks {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export {}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A variable resolves to a fixed value, or is computed on every read. */
|
|
2
|
+
export type EponymeVariableValue = string | number | (() => string | number);
|
|
3
|
+
export interface EponymeVariableDefinition {
|
|
4
|
+
/** Shown in the editor's variable menu. Defaults to the variable name. */
|
|
5
|
+
label?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
value: EponymeVariableValue;
|
|
8
|
+
}
|
|
9
|
+
export type EponymeVariables = Record<string, EponymeVariableValue | EponymeVariableDefinition>;
|
|
10
|
+
/** What the editor needs to list a variable without being able to resolve it. */
|
|
11
|
+
export interface EponymeVariableSummary {
|
|
12
|
+
name: string;
|
|
13
|
+
label: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
/** Value at build time, shown as an example in the menu. */
|
|
16
|
+
preview: string;
|
|
17
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { EponymeNavigationNode } from '../types/eponyme-navigation.js';
|
|
2
|
+
export interface EponymeNavigationTreeInput {
|
|
3
|
+
/** Singleton entry names, `pages/homepage`. Only the keys are read. */
|
|
4
|
+
schemas: Record<string, unknown>;
|
|
5
|
+
collections: Record<string, {
|
|
6
|
+
label?: string;
|
|
7
|
+
}>;
|
|
8
|
+
forms: Record<string, {
|
|
9
|
+
label?: string;
|
|
10
|
+
}>;
|
|
11
|
+
/** Entries already loaded per collection, added as leaves under their collection. */
|
|
12
|
+
collectionEntries?: Record<string, Array<{
|
|
13
|
+
slug: string;
|
|
14
|
+
title: string;
|
|
15
|
+
}>>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Turns the flat, slash-separated names of the configuration into the nested tree the
|
|
19
|
+
* sidebar renders. Intermediate segments become folders, so `pages/homepage` yields a
|
|
20
|
+
* `pages` folder even though nothing declares one.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildEponymeNavigationTree(input: EponymeNavigationTreeInput): EponymeNavigationNode[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { humanizeLabel } from "./humanize-label.js";
|
|
2
|
+
export function buildEponymeNavigationTree(input) {
|
|
3
|
+
const root = [];
|
|
4
|
+
function addPath(pathValue, finalKind, configuredLabel) {
|
|
5
|
+
const parts = pathValue.split("/");
|
|
6
|
+
let children = root;
|
|
7
|
+
for (let index = 0; index < parts.length; index++) {
|
|
8
|
+
const part = parts[index];
|
|
9
|
+
const path = parts.slice(0, index + 1).join("/");
|
|
10
|
+
const last = index === parts.length - 1;
|
|
11
|
+
const kind = last ? finalKind : "folder";
|
|
12
|
+
let node = children.find((item) => item.path === path);
|
|
13
|
+
if (!node) {
|
|
14
|
+
node = { kind, path, label: humanizeLabel(part, last ? configuredLabel : void 0), children: [] };
|
|
15
|
+
children.push(node);
|
|
16
|
+
} else if (last) {
|
|
17
|
+
node.kind = finalKind;
|
|
18
|
+
if (configuredLabel) node.label = configuredLabel;
|
|
19
|
+
}
|
|
20
|
+
children = node.children;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for (const entry of Object.keys(input.schemas)) addPath(entry, "entry");
|
|
24
|
+
for (const [name, definition] of Object.entries(input.collections)) {
|
|
25
|
+
addPath(name, "collection", definition.label);
|
|
26
|
+
for (const entry of input.collectionEntries?.[name] ?? []) addPath(`${name}/${entry.slug}`, "entry", entry.title);
|
|
27
|
+
}
|
|
28
|
+
for (const [name, definition] of Object.entries(input.forms)) addPath(name, "form", definition.label);
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates in the dashboard are formatted with a fixed locale rather than the ambient one.
|
|
3
|
+
*
|
|
4
|
+
* `Intl.DateTimeFormat(undefined, …)` resolves to the server's locale during SSR and to
|
|
5
|
+
* the visitor's in the browser, so the two renders disagree and Vue reports a hydration
|
|
6
|
+
* mismatch on every timestamp. The dashboard interface is English throughout, so pinning
|
|
7
|
+
* the locale keeps the formatting consistent with the rest of it. `en-GB` is day-first
|
|
8
|
+
* and 24-hour, which reads closer to what most of the world expects than `en-US`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const EPONYME_DATE_LOCALE = "en-GB";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EPONYME_DATE_LOCALE = "en-GB";
|