@karibsen/eponyme 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +159 -12
  2. package/dist/eponyme.mjs +2 -1
  3. package/dist/module.d.mts +6 -0
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +55 -36
  6. package/dist/runtime/components/editor/EponymeCollectionPage.vue +80 -18
  7. package/dist/runtime/components/editor/EponymeForm.vue +2 -2
  8. package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
  9. package/dist/runtime/components/editor/EponymeSidebar.vue +24 -32
  10. package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
  11. package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
  12. package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
  13. package/dist/runtime/components/fields/RichTextField.vue +51 -10
  14. package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
  15. package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
  16. package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
  17. package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
  18. package/dist/runtime/composables/useEponyme.js +5 -3
  19. package/dist/runtime/composables/useEponymeCollection.js +3 -2
  20. package/dist/runtime/composables/useEponymeForm.d.ts +4 -0
  21. package/dist/runtime/composables/useEponymeForm.js +11 -1
  22. package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
  23. package/dist/runtime/composables/useEponymeHistory.js +2 -1
  24. package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
  25. package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
  26. package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
  27. package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
  28. package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
  29. package/dist/runtime/server/api/eponyme-export.get.js +11 -0
  30. package/dist/runtime/server/api/eponyme-forms/[path].post.js +29 -1
  31. package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
  32. package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
  33. package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
  34. package/dist/runtime/server/api/eponyme-import.post.js +31 -0
  35. package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
  36. package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
  37. package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
  38. package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
  39. package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
  40. package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
  41. package/dist/runtime/server/services/eponyme-form-store.js +2 -1
  42. package/dist/runtime/server/services/eponyme-service.js +8 -1
  43. package/dist/runtime/server/services/eponyme-store.d.ts +118 -5
  44. package/dist/runtime/server/services/eponyme-store.js +344 -45
  45. package/dist/runtime/server/utils/eponyme-captcha.d.ts +6 -0
  46. package/dist/runtime/server/utils/eponyme-captcha.js +14 -0
  47. package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
  48. package/dist/runtime/server/utils/eponyme-entry.js +7 -0
  49. package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
  50. package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
  51. package/dist/runtime/types/captcha.d.ts +16 -0
  52. package/dist/runtime/types/captcha.js +0 -0
  53. package/dist/runtime/types/eponyme-config.d.ts +7 -0
  54. package/dist/runtime/types/eponyme.d.ts +3 -0
  55. package/dist/runtime/types/field.d.ts +1 -0
  56. package/dist/runtime/types/hooks.d.ts +67 -0
  57. package/dist/runtime/types/hooks.js +0 -0
  58. package/dist/runtime/types/index.d.ts +1 -0
  59. package/dist/runtime/types/index.js +1 -0
  60. package/dist/runtime/types/nuxt.d.ts +10 -0
  61. package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
  62. package/dist/runtime/utils/build-navigation-tree.js +30 -0
  63. package/dist/runtime/utils/date-locale.d.ts +10 -0
  64. package/dist/runtime/utils/date-locale.js +1 -0
  65. package/dist/runtime/utils/downloadable-link.d.ts +2 -0
  66. package/dist/runtime/utils/downloadable-link.js +35 -0
  67. package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
  68. package/dist/runtime/utils/filter-navigation-tree.js +39 -0
  69. package/dist/runtime/utils/get-field-default-value.js +1 -1
  70. package/dist/runtime/utils/hydration-cache.d.ts +11 -0
  71. package/dist/runtime/utils/hydration-cache.js +3 -0
  72. package/dist/runtime/utils/no-captcha.d.ts +7 -0
  73. package/dist/runtime/utils/no-captcha.js +5 -0
  74. package/dist/runtime/utils/validate-eponyme-data.js +1 -1
  75. package/dist/runtime/utils/variables.d.ts +8 -1
  76. package/dist/runtime/utils/variables.js +17 -4
  77. package/package.json +3 -1
@@ -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
@@ -1,4 +1,5 @@
1
1
  export * from './auth.js';
2
2
  export * from './eponyme.js';
3
3
  export * from './field.js';
4
+ export * from './captcha.js';
4
5
  export * from './variables.js';
@@ -1,4 +1,5 @@
1
1
  export * from "./auth.js";
2
2
  export * from "./eponyme.js";
3
3
  export * from "./field.js";
4
+ export * from "./captcha.js";
4
5
  export * from "./variables.js";
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ import type { EponymeCaptchaVerifier } from '../types/captcha.js';
2
+ /**
3
+ * Stands in when no adapter is installed. It never claims success, so a form that
4
+ * requires a captcha refuses submissions rather than silently accepting them all.
5
+ */
6
+ declare const verifier: EponymeCaptchaVerifier;
7
+ export default verifier;
@@ -0,0 +1,5 @@
1
+ const verifier = {
2
+ name: "none",
3
+ verify: async () => ({ success: false, reason: "No captcha adapter is installed." })
4
+ };
5
+ export default verifier;
@@ -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
  }
@@ -14,5 +14,12 @@ export declare function summariseEponymeVariables(custom?: EponymeVariables): Ar
14
14
  * than blanked, so a typo shows up on the page instead of silently deleting text.
15
15
  */
16
16
  export declare function interpolateEponymeText(text: string, variables: Record<string, string>): string;
17
- /** Walks strings nested in objects and arrays, leaving every other value alone. */
17
+ /**
18
+ * Walks strings nested in objects and arrays, leaving every other value alone.
19
+ *
20
+ * A branch containing no `{{ name }}` is returned as-is rather than rebuilt, so content
21
+ * without variables — which is most of it — costs no copy. The result therefore shares
22
+ * structure with its input and must be treated as read-only, which is already true of
23
+ * everything the read routes return.
24
+ */
18
25
  export declare function interpolateEponymeValue<T>(value: T, variables: Record<string, string>): T;
@@ -42,11 +42,24 @@ export function interpolateEponymeText(text, variables) {
42
42
  }
43
43
  export function interpolateEponymeValue(value, variables) {
44
44
  if (typeof value === "string") return interpolateEponymeText(value, variables);
45
- if (Array.isArray(value)) return value.map((item) => interpolateEponymeValue(item, variables));
45
+ if (Array.isArray(value)) {
46
+ let changed = false;
47
+ const next = value.map((item) => {
48
+ const interpolated = interpolateEponymeValue(item, variables);
49
+ if (interpolated !== item) changed = true;
50
+ return interpolated;
51
+ });
52
+ return changed ? next : value;
53
+ }
46
54
  if (value && typeof value === "object") {
47
- return Object.fromEntries(
48
- Object.entries(value).map(([key, nested]) => [key, interpolateEponymeValue(nested, variables)])
49
- );
55
+ let changed = false;
56
+ const next = {};
57
+ for (const [key, nested] of Object.entries(value)) {
58
+ const interpolated = interpolateEponymeValue(nested, variables);
59
+ if (interpolated !== nested) changed = true;
60
+ next[key] = interpolated;
61
+ }
62
+ return changed ? next : value;
50
63
  }
51
64
  return value;
52
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "A lightweight, type-safe content manager for Nuxt.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -62,6 +62,7 @@
62
62
  "@iconify-json/mingcute": "^1.2.7",
63
63
  "@nuxt/icon": "^2.3.1",
64
64
  "@nuxt/kit": "^4.4.8",
65
+ "@karibsen/ui": "latest",
65
66
  "@tailwindcss/vite": "^4.1.12",
66
67
  "@tanstack/vue-table": "^8.21.3",
67
68
  "@tiptap/extension-image": "^3.29.2",
@@ -70,6 +71,7 @@
70
71
  "@tiptap/vue-3": "^3.28.0",
71
72
  "@vueuse/core": "^14.3.0",
72
73
  "@vueuse/integrations": "^14.3.0",
74
+ "fuse.js": "^7.5.0",
73
75
  "picocolors": "^1.1.1",
74
76
  "reka-ui": "^2.10.1",
75
77
  "slugify": "^1.6.9",