@karibsen/eponyme 0.1.1 → 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 +126 -12
- package/dist/module.json +1 -1
- package/dist/module.mjs +46 -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.js +4 -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 +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 +75 -2
- package/dist/runtime/server/services/eponyme-store.js +232 -7
- 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/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/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/validate-eponyme-data.js +1 -1
- 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";
|
|
@@ -71,7 +72,7 @@ export class EponymeService {
|
|
|
71
72
|
if (!schema) return void 0;
|
|
72
73
|
const defaults = this.createState(schema);
|
|
73
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: {} });
|
|
74
|
-
if (!row) return void 0;
|
|
75
|
+
if (!row || row.deletedAt) return void 0;
|
|
75
76
|
const state = this.normalizeState(schema, row.data);
|
|
76
77
|
if (sameData(state, row.data)) return { state, updatedAt: row.updatedAt };
|
|
77
78
|
const healed = await this.client.eponyme.update({ where: { name }, data: { data: state } });
|
|
@@ -138,7 +139,7 @@ export class EponymeService {
|
|
|
138
139
|
const definition = this.collections[name];
|
|
139
140
|
if (!definition) return void 0;
|
|
140
141
|
const rows = await this.client.eponyme.findMany({
|
|
141
|
-
where: { name: { startsWith: `${name}/` } },
|
|
142
|
+
where: { name: { startsWith: `${name}/` }, deletedAt: null },
|
|
142
143
|
orderBy: [
|
|
143
144
|
{ updatedAt: "desc" },
|
|
144
145
|
{ name: "asc" }
|
|
@@ -181,7 +182,7 @@ export class EponymeService {
|
|
|
181
182
|
if (!path.includes(":slug"))
|
|
182
183
|
throw new Error(`[Eponyme] previewPaths.${name} must include ":slug" to generate collection sitemap URLs.`);
|
|
183
184
|
const rows = await this.client.eponyme.findMany({
|
|
184
|
-
where: { name: { startsWith: `${name}/` } },
|
|
185
|
+
where: { name: { startsWith: `${name}/` }, deletedAt: null },
|
|
185
186
|
orderBy: [
|
|
186
187
|
{ updatedAt: "desc" },
|
|
187
188
|
{ name: "asc" }
|
|
@@ -200,6 +201,108 @@ export class EponymeService {
|
|
|
200
201
|
entries.set(entry.loc, entry);
|
|
201
202
|
return [...entries.values()];
|
|
202
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
|
+
}
|
|
203
306
|
async createCollectionEntry(name, payload, actorId) {
|
|
204
307
|
const definition = this.collections[name];
|
|
205
308
|
if (!definition) return void 0;
|
|
@@ -210,8 +313,8 @@ export class EponymeService {
|
|
|
210
313
|
const slug = normalizeSlug(String(input[definition.slugField] || input[definition.titleField] || ""));
|
|
211
314
|
if (!slug) return { errors: { [definition.slugField]: ["A slug or title is required."] } };
|
|
212
315
|
const entryName = `${name}/${slug}`;
|
|
213
|
-
|
|
214
|
-
|
|
316
|
+
const taken = await this.slugTakenError(definition, entryName);
|
|
317
|
+
if (taken) return taken;
|
|
215
318
|
const defaults = createDefaultEponymeData(definition.fields);
|
|
216
319
|
const data = { ...defaults, ...input, [definition.slugField]: slug };
|
|
217
320
|
const errors = validateEponymePatch(definition.fields, data, data, "draft");
|
|
@@ -228,14 +331,47 @@ export class EponymeService {
|
|
|
228
331
|
try {
|
|
229
332
|
await this.client.eponyme.create({ data: { name: entryName, data: state } });
|
|
230
333
|
} catch (error) {
|
|
231
|
-
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."] } };
|
|
232
335
|
throw error;
|
|
233
336
|
}
|
|
234
337
|
await this.client.eponymeVersion.create({ data: { entryName, data: state, action: "draft", status: "draft", userId: actorId ?? null } });
|
|
235
338
|
return { slug, data, status: "draft", publishedAt: null };
|
|
236
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. */
|
|
237
354
|
async deleteCollectionEntry(name) {
|
|
238
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;
|
|
239
375
|
try {
|
|
240
376
|
await this.client.eponyme.delete({ where: { name } });
|
|
241
377
|
} catch (error) {
|
|
@@ -244,6 +380,33 @@ export class EponymeService {
|
|
|
244
380
|
}
|
|
245
381
|
return true;
|
|
246
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
|
+
}
|
|
247
410
|
async patch(name, payload, action = "publish", actorId) {
|
|
248
411
|
const schema = this.getSchema(name);
|
|
249
412
|
if (!schema) return void 0;
|
|
@@ -293,7 +456,7 @@ export class EponymeService {
|
|
|
293
456
|
});
|
|
294
457
|
return versions.map((version) => ({
|
|
295
458
|
id: version.id,
|
|
296
|
-
action: version.action
|
|
459
|
+
action: isHistoryAction(version.action) ? version.action : "restore",
|
|
297
460
|
status: version.status === "draft" ? "draft" : "published",
|
|
298
461
|
createdAt: new Date(version.createdAt).toISOString(),
|
|
299
462
|
user: version.user ? { id: version.user.id, username: version.user.username } : null
|
|
@@ -333,6 +496,68 @@ function getStoredState(value) {
|
|
|
333
496
|
);
|
|
334
497
|
return valid ? state : void 0;
|
|
335
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
|
+
}
|
|
336
561
|
function mergeErrors(...groups) {
|
|
337
562
|
const errors = {};
|
|
338
563
|
for (const group of groups) {
|
|
@@ -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,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,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";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const DOWNLOADABLE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2
|
+
"7z",
|
|
3
|
+
"apk",
|
|
4
|
+
"bz2",
|
|
5
|
+
"csv",
|
|
6
|
+
"dmg",
|
|
7
|
+
"doc",
|
|
8
|
+
"docx",
|
|
9
|
+
"epub",
|
|
10
|
+
"gz",
|
|
11
|
+
"json",
|
|
12
|
+
"odg",
|
|
13
|
+
"odp",
|
|
14
|
+
"ods",
|
|
15
|
+
"odt",
|
|
16
|
+
"pdf",
|
|
17
|
+
"ppt",
|
|
18
|
+
"pptx",
|
|
19
|
+
"rar",
|
|
20
|
+
"rtf",
|
|
21
|
+
"tar",
|
|
22
|
+
"txt",
|
|
23
|
+
"xls",
|
|
24
|
+
"xlsx",
|
|
25
|
+
"xml",
|
|
26
|
+
"zip"
|
|
27
|
+
]);
|
|
28
|
+
export function getDownloadableExtension(href) {
|
|
29
|
+
const path = href.trim().split(/[?#]/, 1)[0] ?? "";
|
|
30
|
+
const extension = path.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase();
|
|
31
|
+
return extension && DOWNLOADABLE_EXTENSIONS.has(extension) ? extension : void 0;
|
|
32
|
+
}
|
|
33
|
+
export function isDownloadableLink(href) {
|
|
34
|
+
return getDownloadableExtension(href) !== void 0;
|
|
35
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { EponymeNavigationNode } from '../types/eponyme-navigation.js';
|
|
2
|
+
/**
|
|
3
|
+
* Keeps the nodes matching `query`, along with every ancestor needed to reach them.
|
|
4
|
+
* A node matched on its own label keeps all of its children, so selecting a folder
|
|
5
|
+
* shows what it holds. An empty query returns the tree untouched.
|
|
6
|
+
*/
|
|
7
|
+
export declare function filterEponymeNavigationTree(nodes: EponymeNavigationNode[], query: string): EponymeNavigationNode[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Fuse from "fuse.js";
|
|
2
|
+
function normalize(value) {
|
|
3
|
+
return value.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
|
4
|
+
}
|
|
5
|
+
function collect(nodes, items) {
|
|
6
|
+
for (const node of nodes) {
|
|
7
|
+
items.push({ path: node.path, label: normalize(node.label), searchPath: normalize(node.path) });
|
|
8
|
+
collect(node.children, items);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const indexes = /* @__PURE__ */ new WeakMap();
|
|
12
|
+
function indexOf(nodes) {
|
|
13
|
+
const cached = indexes.get(nodes);
|
|
14
|
+
if (cached) return cached;
|
|
15
|
+
const items = [];
|
|
16
|
+
collect(nodes, items);
|
|
17
|
+
const fuse = new Fuse(items, {
|
|
18
|
+
keys: ["label", "searchPath"],
|
|
19
|
+
// Tolerant enough for a typo, strict enough that a short query does not match everything.
|
|
20
|
+
threshold: 0.35,
|
|
21
|
+
// Without this a match near the end of a long label is penalised into irrelevance.
|
|
22
|
+
ignoreLocation: true
|
|
23
|
+
});
|
|
24
|
+
indexes.set(nodes, fuse);
|
|
25
|
+
return fuse;
|
|
26
|
+
}
|
|
27
|
+
export function filterEponymeNavigationTree(nodes, query) {
|
|
28
|
+
const term = normalize(query.trim());
|
|
29
|
+
if (!term) return nodes;
|
|
30
|
+
const matched = new Set(indexOf(nodes).search(term).map((result) => result.item.path));
|
|
31
|
+
function keep(list) {
|
|
32
|
+
return list.flatMap((node) => {
|
|
33
|
+
if (matched.has(node.path)) return [node];
|
|
34
|
+
const children = keep(node.children);
|
|
35
|
+
return children.length ? [{ ...node, children }] : [];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return keep(nodes);
|
|
39
|
+
}
|
|
@@ -31,7 +31,7 @@ export function getFieldDefaultValue(definition) {
|
|
|
31
31
|
case "number":
|
|
32
32
|
return 0;
|
|
33
33
|
case "url":
|
|
34
|
-
return { href: "", type: "external", openInNewTab: false };
|
|
34
|
+
return { href: "", type: "external", openInNewTab: false, download: false };
|
|
35
35
|
case "boolean":
|
|
36
36
|
return false;
|
|
37
37
|
default:
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NuxtApp } from '#app';
|
|
2
|
+
/**
|
|
3
|
+
* `getCachedData` for reads that must never serve a stale value.
|
|
4
|
+
*
|
|
5
|
+
* Returning `undefined` unconditionally looks like the obvious way to always refetch,
|
|
6
|
+
* but it also discards the payload the server rendered from: the first client render
|
|
7
|
+
* then has no data and disagrees with the HTML, which Vue reports as a hydration
|
|
8
|
+
* mismatch. Reusing the payload while hydrating, and nothing afterwards, keeps the
|
|
9
|
+
* first paint faithful and still refetches on every later navigation.
|
|
10
|
+
*/
|
|
11
|
+
export declare function cacheDuringHydrationOnly<T>(key: string, nuxtApp: NuxtApp): T | undefined;
|
|
@@ -127,7 +127,7 @@ function validateFieldRules(name, definition, value, errors, mode, _context) {
|
|
|
127
127
|
return;
|
|
128
128
|
}
|
|
129
129
|
const link = value;
|
|
130
|
-
if (typeof link.href !== "string" || !["internal", "external"].includes(String(link.type)) || typeof link.openInNewTab !== "boolean") {
|
|
130
|
+
if (typeof link.href !== "string" || !["internal", "external"].includes(String(link.type)) || typeof link.openInNewTab !== "boolean" || link.download !== void 0 && typeof link.download !== "boolean") {
|
|
131
131
|
addError(errors, name, "Must be a valid link.");
|
|
132
132
|
return;
|
|
133
133
|
}
|