@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.
Files changed (61) hide show
  1. package/README.md +126 -12
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +46 -36
  4. package/dist/runtime/components/editor/EponymeCollectionPage.vue +80 -18
  5. package/dist/runtime/components/editor/EponymeForm.vue +2 -2
  6. package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
  7. package/dist/runtime/components/editor/EponymeSidebar.vue +24 -32
  8. package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
  9. package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
  10. package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
  11. package/dist/runtime/components/fields/RichTextField.vue +51 -10
  12. package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
  13. package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
  14. package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
  15. package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
  16. package/dist/runtime/composables/useEponyme.js +5 -3
  17. package/dist/runtime/composables/useEponymeCollection.js +3 -2
  18. package/dist/runtime/composables/useEponymeForm.js +4 -1
  19. package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
  20. package/dist/runtime/composables/useEponymeHistory.js +2 -1
  21. package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
  22. package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
  23. package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
  24. package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
  25. package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
  26. package/dist/runtime/server/api/eponyme-export.get.js +11 -0
  27. package/dist/runtime/server/api/eponyme-forms/[path].post.js +14 -1
  28. package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
  29. package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
  30. package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
  31. package/dist/runtime/server/api/eponyme-import.post.js +31 -0
  32. package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
  33. package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
  34. package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
  35. package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
  36. package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
  37. package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
  38. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  39. package/dist/runtime/server/services/eponyme-store.d.ts +75 -2
  40. package/dist/runtime/server/services/eponyme-store.js +232 -7
  41. package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
  42. package/dist/runtime/server/utils/eponyme-entry.js +7 -0
  43. package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
  44. package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
  45. package/dist/runtime/types/field.d.ts +1 -0
  46. package/dist/runtime/types/hooks.d.ts +67 -0
  47. package/dist/runtime/types/hooks.js +0 -0
  48. package/dist/runtime/types/nuxt.d.ts +10 -0
  49. package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
  50. package/dist/runtime/utils/build-navigation-tree.js +30 -0
  51. package/dist/runtime/utils/date-locale.d.ts +10 -0
  52. package/dist/runtime/utils/date-locale.js +1 -0
  53. package/dist/runtime/utils/downloadable-link.d.ts +2 -0
  54. package/dist/runtime/utils/downloadable-link.js +35 -0
  55. package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
  56. package/dist/runtime/utils/filter-navigation-tree.js +39 -0
  57. package/dist/runtime/utils/get-field-default-value.js +1 -1
  58. package/dist/runtime/utils/hydration-cache.d.ts +11 -0
  59. package/dist/runtime/utils/hydration-cache.js +3 -0
  60. package/dist/runtime/utils/validate-eponyme-data.js +1 -1
  61. 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
- if (await this.client.eponyme.findUnique({ where: { name: entryName } }))
214
- return { errors: { [definition.slugField]: ["This slug is already in use."] } };
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 === "draft" || version.action === "publish" ? version.action : "restore",
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
+ }
@@ -70,6 +70,7 @@ export interface UrlValue {
70
70
  href: string;
71
71
  type: UrlType;
72
72
  openInNewTab: boolean;
73
+ download?: boolean;
73
74
  }
74
75
  export interface UrlFieldOptions extends DefaultFieldOptions<UrlValue> {
75
76
  placeholder?: string;
@@ -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,2 @@
1
+ export declare function getDownloadableExtension(href: string): string | undefined;
2
+ export declare function isDownloadableLink(href: string): boolean;
@@ -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;
@@ -0,0 +1,3 @@
1
+ export function cacheDuringHydrationOnly(key, nuxtApp) {
2
+ return nuxtApp.isHydrating ? nuxtApp.payload.data[key] : void 0;
3
+ }
@@ -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
  }