@karibsen/eponyme 0.1.0 → 0.1.1

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 (35) hide show
  1. package/README.md +72 -4
  2. package/dist/eponyme.d.mts +22 -2
  3. package/dist/eponyme.mjs +5 -1
  4. package/dist/module.d.mts +3 -2
  5. package/dist/module.json +1 -1
  6. package/dist/module.mjs +28 -8
  7. package/dist/runtime/components/editor/EponymeCollectionPage.vue +1 -1
  8. package/dist/runtime/components/editor/EponymeForm.vue +1 -1
  9. package/dist/runtime/components/editor/EponymeSidebar.vue +1 -1
  10. package/dist/runtime/components/fields/RichTextField.vue +31 -0
  11. package/dist/runtime/composables/useEponyme.d.ts +2 -0
  12. package/dist/runtime/composables/useEponyme.js +3 -3
  13. package/dist/runtime/composables/useEponymeCollection.d.ts +12 -2
  14. package/dist/runtime/composables/useEponymeCollection.js +12 -3
  15. package/dist/runtime/composables/useEponymeVariables.d.ts +3 -0
  16. package/dist/runtime/composables/useEponymeVariables.js +5 -0
  17. package/dist/runtime/server/api/eponyme/[name].get.d.ts +5 -1
  18. package/dist/runtime/server/api/eponyme/[name].get.js +3 -1
  19. package/dist/runtime/server/api/eponyme-collections/[path].get.d.ts +1 -3
  20. package/dist/runtime/server/api/eponyme-collections/[path].get.js +31 -4
  21. package/dist/runtime/server/services/eponyme-store.d.ts +16 -1
  22. package/dist/runtime/server/services/eponyme-store.js +40 -2
  23. package/dist/runtime/server/utils/eponyme-variables.d.ts +5 -0
  24. package/dist/runtime/server/utils/eponyme-variables.js +5 -0
  25. package/dist/runtime/types/eponyme-config.d.ts +7 -0
  26. package/dist/runtime/types/index.d.ts +1 -0
  27. package/dist/runtime/types/index.js +1 -0
  28. package/dist/runtime/types/variables.d.ts +17 -0
  29. package/dist/runtime/types/variables.js +0 -0
  30. package/dist/runtime/utils/empty-variables.d.ts +2 -0
  31. package/dist/runtime/utils/empty-variables.js +1 -0
  32. package/dist/runtime/utils/variables.d.ts +18 -0
  33. package/dist/runtime/utils/variables.js +52 -0
  34. package/dist/types.d.mts +2 -2
  35. package/package.json +1 -1
package/README.md CHANGED
@@ -46,7 +46,7 @@ Add it to `nuxt.config.ts`:
46
46
  export default defineNuxtConfig({
47
47
  modules: ['@karibsen/eponyme'],
48
48
  eponyme: {
49
- prismaClient: '~/server/utils/prisma',
49
+ prismaClient: '~~/server/utils/prisma',
50
50
  dashboardPath: '/__eponyme',
51
51
  previewPaths: {
52
52
  homepage: '/',
@@ -153,7 +153,10 @@ field.string({
153
153
 
154
154
  Eponyme uses the Prisma client owned by your application. It does not create the connection or run migrations.
155
155
 
156
- Export an initialized client from the path configured in `nuxt.config.ts`:
156
+ Export an initialized client from the path configured in `nuxt.config.ts`. Note the
157
+ double tilde: `~~/` points at the project root, where `server/` lives, while `~/` points
158
+ at the source directory (`app/` in Nuxt 4). A relative path such as
159
+ `./server/utils/prisma` works too.
157
160
 
158
161
  ```ts
159
162
  // server/utils/prisma.ts
@@ -312,6 +315,37 @@ GET /api/eponyme-collections/articles
312
315
 
313
316
  It returns published entries by default. Pass `version=draft` from an authenticated request to read dashboard content.
314
317
 
318
+ ### Sorting and limiting
319
+
320
+ Collections accept `take`, `skip`, `orderBy` and `order`:
321
+
322
+ ```ts
323
+ const { entries, total } = useEponymeCollection('articles', {
324
+ orderBy: 'date',
325
+ order: 'desc',
326
+ take: 4,
327
+ })
328
+ ```
329
+
330
+ `orderBy` accepts the `updatedAt`, `publishedAt`, `title` and `slug` metadata as well
331
+ as **any field of the collection**, which is what lets articles be ordered by their own
332
+ date rather than by when they were last edited. The key is typed from the schema, so a
333
+ misspelled field fails to compile.
334
+
335
+ `total` counts every matching entry *before* `take` and `skip`, so it can drive a pager.
336
+ Sorting runs after unpublished entries are filtered out, meaning a limit never spends a
337
+ slot on an entry that would have been dropped. Entries with a missing or empty value sort
338
+ last in both directions. The default order remains `updatedAt` descending.
339
+
340
+ The same options work over HTTP, where the response is `{ entries, total }`:
341
+
342
+ ```http
343
+ GET /api/eponyme-collections/articles?orderBy=date&order=desc&take=4
344
+ ```
345
+
346
+ `take` is capped at 200, and an unknown `orderBy` answers `400` with the accepted keys
347
+ rather than returning an arbitrary order.
348
+
315
349
  ## Entries
316
350
 
317
351
  `useEponyme()` is auto-imported and infers its data from `eponyme.config.ts`:
@@ -345,6 +379,40 @@ The dashboard uses explicit saves. `Ctrl+S` or `Cmd+S` stores a draft. Publish r
345
379
 
346
380
  The `errors` ref contains field errors returned with HTTP `422`. Eponyme creates missing singleton rows from configured defaults. It also reconciles stored JSONB data when fields are added or removed from the schema.
347
381
 
382
+ ## Content variables
383
+
384
+ Editors can drop `{{ currentYear }}` into any text or rich-text field. Variables are
385
+ resolved when the page is served, so a year stays current without anyone re-saving the
386
+ content.
387
+
388
+ The built-ins cover dates: `currentYear`, `nextYear`, `previousYear`, `currentMonth`,
389
+ `currentDay`, `today` and `currentDate`.
390
+
391
+ Add your own in `eponyme.variables.ts` at the project root:
392
+
393
+ ```ts
394
+ export default defineEponymeVariables({
395
+ clubName: 'AS Chelles Athlétisme',
396
+ season: {
397
+ label: 'Season',
398
+ description: 'The season spanning two calendar years.',
399
+ value: () => `${new Date().getFullYear()}-${new Date().getFullYear() + 1}`,
400
+ },
401
+ })
402
+ ```
403
+
404
+ A plain value is fixed; a function is called on every read. Declaring a name that
405
+ matches a built-in overrides it. The rich text editor lists every variable behind a
406
+ toolbar button, with a preview of what it currently resolves to.
407
+
408
+ **Names only, never expressions.** `{{ currentYear }}` works, `{{ new Date().getFullYear() }}`
409
+ is left as-is. Content comes from the database and is editable from the dashboard, so
410
+ evaluating it would grant arbitrary code execution on the server. An unknown name is also
411
+ left untouched, so a typo is visible on the page instead of silently deleting text.
412
+
413
+ The dashboard reads content with `?raw=1` and therefore shows the source form, keeping
414
+ variables editable. Public reads receive the resolved values.
415
+
348
416
  ## Preview
349
417
 
350
418
  Map a configured entry to its public route:
@@ -352,7 +420,7 @@ Map a configured entry to its public route:
352
420
  ```ts
353
421
  export default defineNuxtConfig({
354
422
  eponyme: {
355
- prismaClient: '~/server/utils/prisma',
423
+ prismaClient: '~~/server/utils/prisma',
356
424
  previewPaths: {
357
425
  'homepage': '/',
358
426
  'pages/contact': '/contact',
@@ -391,7 +459,7 @@ Singleton entries use their exact `previewPaths` value. Collections use a dynami
391
459
  ```ts
392
460
  export default defineNuxtConfig({
393
461
  eponyme: {
394
- prismaClient: '~/server/utils/prisma',
462
+ prismaClient: '~~/server/utils/prisma',
395
463
  previewPaths: {
396
464
  homepage: '/',
397
465
  articles: '/articles/:slug',
@@ -1,5 +1,6 @@
1
1
  import { EponymeConfig, EponymeSchema, EponymeCollectionOptions, CollectionSlugField, EponymeCollectionDefinition, EponymeFormSchema, EponymeFormOptions, EponymeFormDefinition } from '../dist/runtime/types/index.js';
2
- export { ArrayFieldDefinition, ArrayFieldOptions, ArrayItemDefinition, ArrayItemFieldDefinition, ArrayItemSchema, ArrayItemValue, BooleanFieldDefinition, BooleanFieldOptions, CheckboxGroupFieldDefinition, CheckboxGroupFieldOptions, CollectionSlugField, ColorFieldDefinition, ColorFieldOptions, DateFieldDefinition, DateFieldOptions, EmailFieldDefinition, EmailFieldOptions, EponymeAuthSession, EponymeAuthUser, EponymeCollectionDataByName, EponymeCollectionDefinition, EponymeCollectionDefinitionBase, EponymeCollectionName, EponymeCollectionOptions, EponymeConfig, EponymeData, EponymeDataByName, EponymeFormDataByName, EponymeFormDefinition, EponymeFormDefinitionBase, EponymeFormFieldDefinition, EponymeFormMode, EponymeFormName, EponymeFormOptions, EponymeFormSchema, EponymeManagedUserResult, EponymeName, EponymeRole, EponymeSchema, FieldDefinition, FieldValidationResult, FieldValidator, FieldVisibilityCondition, ImageFieldDefinition, ImageFieldOptions, NumberFieldDefinition, NumberFieldOptions, RadioFieldDefinition, RadioFieldOptions, RichTextFieldDefinition, RichTextFieldOptions, SectionFieldDefinition, SectionFieldOptions, SectionItemFieldDefinition, SectionItemValue, SectionSchema, SectionValue, SelectFieldDefinition, SelectFieldOptions, SelectOption, SlugFieldDefinition, SlugFieldOptions, StringFieldDefinition, StringFieldOptions, TabDefinition, TabFieldDefinition, TabFieldOptions, TabsSchema, TabsValue, TextareaFieldDefinition, TextareaFieldOptions, TodayDateDefault, UrlFieldDefinition, UrlFieldOptions, UrlType, UrlValue } from '../dist/runtime/types/index.js';
2
+ export { ArrayFieldDefinition, ArrayFieldOptions, ArrayItemDefinition, ArrayItemFieldDefinition, ArrayItemSchema, ArrayItemValue, BooleanFieldDefinition, BooleanFieldOptions, CheckboxGroupFieldDefinition, CheckboxGroupFieldOptions, CollectionSlugField, ColorFieldDefinition, ColorFieldOptions, DateFieldDefinition, DateFieldOptions, EmailFieldDefinition, EmailFieldOptions, EponymeAuthSession, EponymeAuthUser, EponymeCollectionDataByName, EponymeCollectionDefinition, EponymeCollectionDefinitionBase, EponymeCollectionName, EponymeCollectionOptions, EponymeConfig, EponymeData, EponymeDataByName, EponymeFormDataByName, EponymeFormDefinition, EponymeFormDefinitionBase, EponymeFormFieldDefinition, EponymeFormMode, EponymeFormName, EponymeFormOptions, EponymeFormSchema, EponymeManagedUserResult, EponymeName, EponymeRole, EponymeSchema, EponymeVariableDefinition, EponymeVariableSummary, EponymeVariableValue, EponymeVariables, FieldDefinition, FieldValidationResult, FieldValidator, FieldVisibilityCondition, ImageFieldDefinition, ImageFieldOptions, NumberFieldDefinition, NumberFieldOptions, RadioFieldDefinition, RadioFieldOptions, RichTextFieldDefinition, RichTextFieldOptions, SectionFieldDefinition, SectionFieldOptions, SectionItemFieldDefinition, SectionItemValue, SectionSchema, SectionValue, SelectFieldDefinition, SelectFieldOptions, SelectOption, SlugFieldDefinition, SlugFieldOptions, StringFieldDefinition, StringFieldOptions, TabDefinition, TabFieldDefinition, TabFieldOptions, TabsSchema, TabsValue, TextareaFieldDefinition, TextareaFieldOptions, TodayDateDefault, UrlFieldDefinition, UrlFieldOptions, UrlType, UrlValue } from '../dist/runtime/types/index.js';
3
+ import { EponymeVariables } from '../dist/runtime/types/variables.js';
3
4
  export { field, today } from '../dist/runtime/fields/index.js';
4
5
  export { EPONYME_ROLES, canEditEponyme, isEponymeRole } from '../dist/runtime/types/auth.js';
5
6
 
@@ -12,4 +13,23 @@ declare function collection<const T extends EponymeSchema>(options: CollectionOp
12
13
 
13
14
  declare function form<const T extends EponymeFormSchema>(options: EponymeFormOptions<T>): EponymeFormDefinition<T>;
14
15
 
15
- export { collection, defineEponymeConfig, form };
16
+ /**
17
+ * Declares the variables editors can drop into rich text with `{{ name }}`.
18
+ * Place them in `eponyme.variables.ts` at the project root:
19
+ *
20
+ * ```ts
21
+ * export default defineEponymeVariables({
22
+ * clubName: 'AS Chelles Athlétisme',
23
+ * season: {
24
+ * label: 'Season',
25
+ * value: () => `${new Date().getFullYear()}-${new Date().getFullYear() + 1}`,
26
+ * },
27
+ * })
28
+ * ```
29
+ *
30
+ * A function is called on every read, so the value stays current without anyone
31
+ * re-saving the content.
32
+ */
33
+ declare function defineEponymeVariables<const T extends EponymeVariables>(variables: T): T;
34
+
35
+ export { collection, defineEponymeConfig, defineEponymeVariables, form };
package/dist/eponyme.mjs CHANGED
@@ -35,4 +35,8 @@ function form(options) {
35
35
  };
36
36
  }
37
37
 
38
- export { collection, defineEponymeConfig, form };
38
+ function defineEponymeVariables(variables) {
39
+ return variables;
40
+ }
41
+
42
+ export { collection, defineEponymeConfig, defineEponymeVariables, form };
package/dist/module.d.mts CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
- export { collection, defineEponymeConfig, form } from './eponyme.mjs';
2
+ export { collection, defineEponymeConfig, defineEponymeVariables, form } from './eponyme.mjs';
3
3
  export { field, today } from '../dist/runtime/fields/index.js';
4
4
  export { EPONYME_ROLES, canEditEponyme, isEponymeRole } from '../dist/runtime/types/auth.js';
5
- export { ArrayFieldDefinition, ArrayFieldOptions, ArrayItemDefinition, ArrayItemFieldDefinition, ArrayItemSchema, ArrayItemValue, BooleanFieldDefinition, BooleanFieldOptions, CheckboxGroupFieldDefinition, CheckboxGroupFieldOptions, CollectionSlugField, ColorFieldDefinition, ColorFieldOptions, DateFieldDefinition, DateFieldOptions, EmailFieldDefinition, EmailFieldOptions, EponymeAuthSession, EponymeAuthUser, EponymeCollectionDataByName, EponymeCollectionDefinition, EponymeCollectionDefinitionBase, EponymeCollectionName, EponymeCollectionOptions, EponymeConfig, EponymeData, EponymeDataByName, EponymeFormDataByName, EponymeFormDefinition, EponymeFormDefinitionBase, EponymeFormFieldDefinition, EponymeFormMode, EponymeFormName, EponymeFormOptions, EponymeFormSchema, EponymeManagedUserResult, EponymeName, EponymeRole, EponymeSchema, FieldDefinition, FieldValidationResult, FieldValidator, FieldVisibilityCondition, ImageFieldDefinition, ImageFieldOptions, NumberFieldDefinition, NumberFieldOptions, RadioFieldDefinition, RadioFieldOptions, RichTextFieldDefinition, RichTextFieldOptions, SectionFieldDefinition, SectionFieldOptions, SectionItemFieldDefinition, SectionItemValue, SectionSchema, SectionValue, SelectFieldDefinition, SelectFieldOptions, SelectOption, SlugFieldDefinition, SlugFieldOptions, StringFieldDefinition, StringFieldOptions, TabDefinition, TabFieldDefinition, TabFieldOptions, TabsSchema, TabsValue, TextareaFieldDefinition, TextareaFieldOptions, TodayDateDefault, UrlFieldDefinition, UrlFieldOptions, UrlType, UrlValue } from '../dist/runtime/types/index.js';
5
+ export { ArrayFieldDefinition, ArrayFieldOptions, ArrayItemDefinition, ArrayItemFieldDefinition, ArrayItemSchema, ArrayItemValue, BooleanFieldDefinition, BooleanFieldOptions, CheckboxGroupFieldDefinition, CheckboxGroupFieldOptions, CollectionSlugField, ColorFieldDefinition, ColorFieldOptions, DateFieldDefinition, DateFieldOptions, EmailFieldDefinition, EmailFieldOptions, EponymeAuthSession, EponymeAuthUser, EponymeCollectionDataByName, EponymeCollectionDefinition, EponymeCollectionDefinitionBase, EponymeCollectionName, EponymeCollectionOptions, EponymeConfig, EponymeData, EponymeDataByName, EponymeFormDataByName, EponymeFormDefinition, EponymeFormDefinitionBase, EponymeFormFieldDefinition, EponymeFormMode, EponymeFormName, EponymeFormOptions, EponymeFormSchema, EponymeManagedUserResult, EponymeName, EponymeRole, EponymeSchema, EponymeVariableDefinition, EponymeVariableSummary, EponymeVariableValue, EponymeVariables, FieldDefinition, FieldValidationResult, FieldValidator, FieldVisibilityCondition, ImageFieldDefinition, ImageFieldOptions, NumberFieldDefinition, NumberFieldOptions, RadioFieldDefinition, RadioFieldOptions, RichTextFieldDefinition, RichTextFieldOptions, SectionFieldDefinition, SectionFieldOptions, SectionItemFieldDefinition, SectionItemValue, SectionSchema, SectionValue, SelectFieldDefinition, SelectFieldOptions, SelectOption, SlugFieldDefinition, SlugFieldOptions, StringFieldDefinition, StringFieldOptions, TabDefinition, TabFieldDefinition, TabFieldOptions, TabsSchema, TabsValue, TextareaFieldDefinition, TextareaFieldOptions, TodayDateDefault, UrlFieldDefinition, UrlFieldOptions, UrlType, UrlValue } from '../dist/runtime/types/index.js';
6
+ import '../dist/runtime/types/variables.js';
6
7
 
7
8
  interface ModuleOptions {
8
9
  dashboardPath?: string;
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
3
  "configKey": "eponyme",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -46,6 +46,7 @@ const module$1 = defineNuxtModule({
46
46
  "mingcute:align-arrow-right-line",
47
47
  "mingcute:link-2-line",
48
48
  "mingcute:settings-3-line",
49
+ "mingcute:code-line",
49
50
  "mingcute:refresh-2-line",
50
51
  "mingcute:external-link-line",
51
52
  "mingcute:paragraph-line",
@@ -70,13 +71,20 @@ const module$1 = defineNuxtModule({
70
71
  logger.info("No prismaClient configured, skipping wiring (prepare).");
71
72
  return;
72
73
  }
73
- const prismaPath = await findPath(resolve(nuxt.options.rootDir, options.prismaClient));
74
- if (!prismaPath)
75
- throw new Error(`${pc.red("[Eponyme]")} Prisma client module not found: ${options.prismaClient}`);
74
+ const prismaSpecifier = options.prismaClient.replace(/^[~@]{2}\//, `${nuxt.options.rootDir}/`).replace(/^[~@]\//, `${nuxt.options.srcDir}/`);
75
+ const prismaPath = await findPath(resolve(nuxt.options.rootDir, prismaSpecifier));
76
+ if (!prismaPath) {
77
+ throw new Error(
78
+ `${pc.red("[Eponyme]")} Prisma client module not found: ${options.prismaClient}
79
+ Expected a module exporting a PrismaClient as its default export, for example '~~/server/utils/prisma'.
80
+ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.`
81
+ );
82
+ }
76
83
  addImports([
77
84
  { name: "defineEponymeConfig", from: resolver.resolve("./config/config") },
78
85
  { name: "collection", from: resolver.resolve("./config/collection") },
79
86
  { name: "form", from: resolver.resolve("./config/form") },
87
+ { name: "defineEponymeVariables", from: resolver.resolve("./config/variables") },
80
88
  { name: "field", from: resolver.resolve("./runtime/fields") },
81
89
  { name: "today", from: resolver.resolve("./runtime/fields") }
82
90
  ]);
@@ -96,6 +104,12 @@ const module$1 = defineNuxtModule({
96
104
  path: resolver.resolve("./runtime/middleware/eponyme-owner")
97
105
  });
98
106
  nuxt.options.build.transpile.push("reka-ui");
107
+ nuxt.options.vite.optimizeDeps ??= {};
108
+ nuxt.options.vite.optimizeDeps.include = [
109
+ ...nuxt.options.vite.optimizeDeps.include ?? [],
110
+ "slugify",
111
+ "sortablejs"
112
+ ];
99
113
  nuxt.options.css.push(resolver.resolve("./runtime/assets/dashboard.css"));
100
114
  nuxt.hook("vite:extendConfig", (viteConfig) => {
101
115
  const plugins = viteConfig.plugins;
@@ -109,12 +123,18 @@ const module$1 = defineNuxtModule({
109
123
  }
110
124
  nuxt.options.alias["#eponyme/config"] = configPath;
111
125
  nuxt.options.alias["#eponyme/prisma"] = prismaPath;
126
+ const variablesPath = await findPath(resolve(nuxt.options.rootDir, "eponyme.variables.ts"));
127
+ nuxt.options.alias["#eponyme/variables"] = variablesPath ?? resolver.resolve("./runtime/utils/empty-variables");
112
128
  addTypeTemplate({
113
129
  filename: "types/eponyme-config.d.ts",
114
130
  getContents: () => `declare module '#eponyme/config' {
115
131
  const config: typeof import(${JSON.stringify(configPath)})['default']
116
132
  export default config
117
133
  }
134
+ declare module '#eponyme/variables' {
135
+ const variables: typeof import(${JSON.stringify(nuxt.options.alias["#eponyme/variables"])})['default']
136
+ export default variables
137
+ }
118
138
  `
119
139
  });
120
140
  addServerHandler({ route: "/api/eponyme/**", handler: resolver.resolve("./runtime/server/api/eponyme/[name].get") });
@@ -140,11 +160,11 @@ const module$1 = defineNuxtModule({
140
160
  addServerPlugin(resolver.resolve("./runtime/server/plugins/eponyme-sync"));
141
161
  nuxt.hook("pages:extend", (pages) => {
142
162
  pages.push(
143
- { name: "eponyme-login", path: `${dashboardPath}/login`, file: resolver.resolve("./runtime/pages/EponymeLoginPage.vue") },
144
- { name: "eponyme-change-password", path: `${dashboardPath}/change-password`, file: resolver.resolve("./runtime/pages/EponymeChangePasswordPage.vue"), meta: { middleware: ["eponyme-auth"] } },
145
- { name: "eponyme-users", path: `${dashboardPath}/users`, file: resolver.resolve("./runtime/pages/EponymeUsersPage.vue"), meta: { middleware: ["eponyme-auth", "eponyme-owner"] } },
146
- { name: "eponyme-index", path: dashboardPath, file: resolver.resolve("./runtime/pages/EponymeIndexPage.vue"), meta: { middleware: ["eponyme-auth"] } },
147
- { name: "eponyme-detail", path: `${dashboardPath}/:eponyme(.*)*`, file: resolver.resolve("./runtime/pages/EponymeDetailPage.vue"), meta: { middleware: ["eponyme-auth"] } }
163
+ { name: "eponyme-login", path: `${dashboardPath}/login`, file: resolver.resolve("./runtime/pages/EponymeLoginPage.vue"), meta: { layout: false } },
164
+ { name: "eponyme-change-password", path: `${dashboardPath}/change-password`, file: resolver.resolve("./runtime/pages/EponymeChangePasswordPage.vue"), meta: { layout: false, middleware: ["eponyme-auth"] } },
165
+ { name: "eponyme-users", path: `${dashboardPath}/users`, file: resolver.resolve("./runtime/pages/EponymeUsersPage.vue"), meta: { layout: false, middleware: ["eponyme-auth", "eponyme-owner"] } },
166
+ { name: "eponyme-index", path: dashboardPath, file: resolver.resolve("./runtime/pages/EponymeIndexPage.vue"), meta: { layout: false, middleware: ["eponyme-auth"] } },
167
+ { name: "eponyme-detail", path: `${dashboardPath}/:eponyme(.*)*`, file: resolver.resolve("./runtime/pages/EponymeDetailPage.vue"), meta: { layout: false, middleware: ["eponyme-auth"] } }
148
168
  );
149
169
  });
150
170
  logger.debug(`config loaded: ${configPath}`);
@@ -27,7 +27,7 @@ const endpoint = computed(() => `/api/eponyme-collections/${props.name}`);
27
27
  const auth = useEponymeAuth();
28
28
  const { data: response, pending, refresh } = useAsyncData(
29
29
  `eponyme:collection:${props.name}`,
30
- () => requestFetch(endpoint.value, { query: { version: "draft" } })
30
+ () => requestFetch(endpoint.value, { query: { version: "draft", raw: 1 } })
31
31
  );
32
32
  const entries = computed(() => response.value?.entries ?? []);
33
33
  const label = computed(() => props.definition.label || humanize(props.name.split("/").at(-1) || props.name));
@@ -30,7 +30,7 @@ const previewPaths = runtimeConfig.public.eponyme?.previewPaths ?? {};
30
30
  const collectionNames = Object.keys(getEponymeCollections(useEponymeConfig()));
31
31
  const previewPath = computed(() => resolvePreviewPath(previewPaths, collectionNames, props.name));
32
32
  const auth = useEponymeAuth();
33
- const { data: eponymeData, errors: serverErrors, pending, status, publishedAt, refresh, save: persist } = useEponyme(props.name, { version: "draft" });
33
+ const { data: eponymeData, errors: serverErrors, pending, status, publishedAt, refresh, save: persist } = useEponyme(props.name, { version: "draft", raw: true });
34
34
  const data = ref({});
35
35
  const savedData = ref({});
36
36
  const validation = useEponymeValidation(props.schema, data, savedData, { serverErrors });
@@ -39,7 +39,7 @@ onMounted(async () => {
39
39
  await restoreNavigationScroll();
40
40
  const [statusResponse, ...collectionResponses] = await Promise.all([
41
41
  requestFetch("/api/eponyme-statuses"),
42
- ...Object.keys(collections).map((name) => requestFetch(`/api/eponyme-collections/${name}`, { query: { version: "draft" } }))
42
+ ...Object.keys(collections).map((name) => requestFetch(`/api/eponyme-collections/${name}`, { query: { version: "draft", raw: 1 } }))
43
43
  ]);
44
44
  statuses.value = { ...statusResponse.statuses };
45
45
  collectionEntries.value = Object.fromEntries(Object.keys(collections).map((name, index) => [name, collectionResponses[index]?.entries ?? []]));
@@ -5,6 +5,8 @@ import StarterKit from "@tiptap/starter-kit";
5
5
  import { EditorContent, useEditor } from "@tiptap/vue-3";
6
6
  import { computed, ref, watch } from "vue";
7
7
  import EPFormField from "../ui/EPFormField.vue";
8
+ import EPDropdownMenu from "../ui/EPDropdownMenu.vue";
9
+ import { useEponymeVariables } from "../../composables/useEponymeVariables";
8
10
  const props = defineProps({
9
11
  id: { type: String, required: true },
10
12
  modelValue: { type: null, required: false },
@@ -83,6 +85,14 @@ function setImage() {
83
85
  const alt = window.prompt("Alt text (describe the image)", "") ?? "";
84
86
  instance.chain().focus().setImage({ src: src.trim(), alt: alt.trim() }).run();
85
87
  }
88
+ const variables = useEponymeVariables();
89
+ const variableItems = computed(() => variables.map((variable) => ({
90
+ label: variable.preview ? `${variable.label} \u2014 ${variable.preview}` : variable.label,
91
+ value: variable.name
92
+ })));
93
+ function insertVariable(name) {
94
+ editor.value?.chain().focus().insertContent(`{{ ${name} }}`).run();
95
+ }
86
96
  const tools = computed(() => {
87
97
  const instance = editor.value;
88
98
  if (!instance) return [];
@@ -148,6 +158,27 @@ const tools = computed(() => {
148
158
  />
149
159
  </button>
150
160
  </template>
161
+ <EPDropdownMenu
162
+ v-if="variables.length"
163
+ :items="variableItems"
164
+ @select="insertVariable"
165
+ >
166
+ <template #trigger>
167
+ <button
168
+ type="button"
169
+ class="rich-text-tool"
170
+ :disabled="disabled"
171
+ title="Insert a variable"
172
+ aria-label="Insert a variable"
173
+ >
174
+ <Icon
175
+ name="mingcute:code-line"
176
+ size="18"
177
+ aria-hidden="true"
178
+ />
179
+ </button>
180
+ </template>
181
+ </EPDropdownMenu>
151
182
  </div>
152
183
  <EditorContent
153
184
  v-if="editor"
@@ -6,6 +6,8 @@ import type { ValidationErrors } from '../utils/validate-eponyme-data.js';
6
6
  type ConfigEponymeName = EponymeName<typeof eponymeConfig>;
7
7
  export interface UseEponymeOptions {
8
8
  version?: EponymeVersionSelector;
9
+ /** Keep `{{ variables }}` unresolved. The dashboard editor needs the source text. */
10
+ raw?: boolean;
9
11
  }
10
12
  /** Read and save a configured eponyme through the public or draft API. */
11
13
  export declare function useEponyme<const Name extends ConfigEponymeName>(name: Name, options?: UseEponymeOptions): {
@@ -9,8 +9,8 @@ export function useEponyme(name, options = {}) {
9
9
  const saving = ref(false);
10
10
  const requestFetch = useRequestFetch();
11
11
  const { data: response, pending: loading, error, refresh: load } = useAsyncData(
12
- `eponyme:${name}:${version}`,
13
- () => requestFetch(`/api/eponyme/${name}`, { query: { version }, cache: "no-store" }),
12
+ `eponyme:${name}:${version}${options.raw ? ":raw" : ""}`,
13
+ () => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: "no-store" }),
14
14
  { getCachedData: () => void 0 }
15
15
  );
16
16
  const data = computed(() => response.value?.data);
@@ -36,7 +36,7 @@ export function useEponyme(name, options = {}) {
36
36
  const slug = parts.pop();
37
37
  const collectionName = parts.join("/");
38
38
  if (collectionName && slug) {
39
- clearNuxtData(`eponyme:collection:public:${collectionName}`);
39
+ clearNuxtData((key) => key.startsWith(`eponyme:collection:public:${collectionName}`));
40
40
  clearNuxtData((key) => key.startsWith(`eponyme:collection-entry:${collectionName}:${slug}:`));
41
41
  }
42
42
  }
@@ -1,10 +1,20 @@
1
1
  import type { ComputedRef, Ref } from 'vue';
2
2
  import type eponymeConfig from '#eponyme/config';
3
3
  import type { EponymeCollectionDataByName, EponymeCollectionName } from '../types/index.js';
4
- import type { EponymeCollectionEntry, EponymeStatus, EponymeVersionSelector } from '../server/services/eponyme-store.js';
4
+ import type { EponymeCollectionEntry, EponymeSortDirection, EponymeStatus, EponymeVersionSelector } from '../server/services/eponyme-store.js';
5
5
  type ConfigCollectionName = EponymeCollectionName<typeof eponymeConfig>;
6
+ /** Sortable keys: the entry metadata plus every field of the collection. */
7
+ export type EponymeCollectionSortKey<Name extends ConfigCollectionName> = 'updatedAt' | 'publishedAt' | 'title' | 'slug' | (keyof EponymeCollectionDataByName<typeof eponymeConfig, Name> & string);
8
+ export interface UseEponymeCollectionOptions<Name extends ConfigCollectionName> {
9
+ take?: number;
10
+ skip?: number;
11
+ orderBy?: EponymeCollectionSortKey<Name>;
12
+ order?: EponymeSortDirection;
13
+ }
6
14
  export interface UseEponymeCollectionResult<Data extends Record<string, unknown>> {
7
15
  entries: ComputedRef<EponymeCollectionEntry<Data>[]>;
16
+ /** Matching entries before `take` and `skip`, for building a pager. */
17
+ total: ComputedRef<number>;
8
18
  pending: Ref<boolean>;
9
19
  error: Ref<Error | null | undefined>;
10
20
  refresh: () => Promise<void>;
@@ -22,6 +32,6 @@ export interface UseEponymeCollectionEntryResult<Data extends Record<string, unk
22
32
  error: Ref<Error | null | undefined>;
23
33
  refresh: () => Promise<void>;
24
34
  }
25
- export declare function useEponymeCollection<const Name extends ConfigCollectionName>(name: Name): UseEponymeCollectionResult<EponymeCollectionDataByName<typeof eponymeConfig, Name>>;
35
+ export declare function useEponymeCollection<const Name extends ConfigCollectionName>(name: Name, options?: UseEponymeCollectionOptions<Name>): UseEponymeCollectionResult<EponymeCollectionDataByName<typeof eponymeConfig, Name>>;
26
36
  export declare function useEponymeCollectionEntry<const Name extends ConfigCollectionName>(name: Name, slug: string, options?: UseEponymeCollectionEntryOptions): UseEponymeCollectionEntryResult<EponymeCollectionDataByName<typeof eponymeConfig, Name>>;
27
37
  export {};
@@ -2,17 +2,26 @@ import { useAsyncData, useRequestFetch, useRoute } from "#app";
2
2
  import { useEventListener } from "@vueuse/core";
3
3
  import { computed } from "vue";
4
4
  import { readPreviewQuery, readPreviewVersion } from "../utils/preview.js";
5
- export function useEponymeCollection(name) {
5
+ export function useEponymeCollection(name, options = {}) {
6
6
  const requestFetch = useRequestFetch();
7
+ const query = {
8
+ take: options.take,
9
+ skip: options.skip,
10
+ orderBy: options.orderBy,
11
+ order: options.order
12
+ };
7
13
  const result = useAsyncData(
8
- `eponyme:collection:public:${name}`,
9
- () => requestFetch(`/api/eponyme-collections/${name}`, { cache: "no-store" }),
14
+ // The options belong in the key, otherwise two differently sorted calls to the
15
+ // same collection would share one cache entry.
16
+ `eponyme:collection:public:${name}:${options.take ?? ""}:${options.skip ?? ""}:${options.orderBy ?? ""}:${options.order ?? ""}`,
17
+ () => requestFetch(`/api/eponyme-collections/${name}`, { query, cache: "no-store" }),
10
18
  { getCachedData: () => void 0 }
11
19
  );
12
20
  const entries = computed(() => result.data.value?.entries ?? []);
13
21
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
14
22
  return {
15
23
  entries,
24
+ total: computed(() => result.data.value?.total ?? 0),
16
25
  pending: result.pending,
17
26
  error: result.error,
18
27
  refresh: async () => {
@@ -0,0 +1,3 @@
1
+ import type { EponymeVariableSummary } from '../types/variables.js';
2
+ /** Variables an editor can insert with `{{ name }}`, for listing them in the editor. */
3
+ export declare function useEponymeVariables(): EponymeVariableSummary[];
@@ -0,0 +1,5 @@
1
+ import eponymeVariables from "#eponyme/variables";
2
+ import { summariseEponymeVariables } from "../utils/variables.js";
3
+ export function useEponymeVariables() {
4
+ return summariseEponymeVariables(eponymeVariables);
5
+ }
@@ -1,4 +1,8 @@
1
- declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../services/eponyme-store.js").EponymeResult | {
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
+ data: Record<string, unknown>;
3
+ status: import("../../services/eponyme-store.js").EponymeStatus;
4
+ publishedAt: string | null;
5
+ } | {
2
6
  data: Record<string, unknown>;
3
7
  }>>;
4
8
  export default _default;
@@ -1,6 +1,7 @@
1
1
  import { createError, defineEventHandler, getQuery, getRequestURL, setResponseHeader } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { requireEponymeUser } from "../../utils/auth.js";
4
+ import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
4
5
  function readVersion(raw) {
5
6
  if (raw === "draft") return "draft";
6
7
  const id = Number(raw);
@@ -16,5 +17,6 @@ export default defineEventHandler(async (event) => {
16
17
  }
17
18
  const result = name ? await useEponymeService().getResult(name, version) : void 0;
18
19
  if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
19
- return requestedVersion ? result : { data: result.data };
20
+ const data = getQuery(event).raw ? result.data : interpolateEponymeContent(result.data);
21
+ return requestedVersion ? { ...result, data } : { data };
20
22
  });
@@ -1,4 +1,2 @@
1
- declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
- entries: import("../../services/eponyme-store.js").EponymeCollectionEntry<Record<string, unknown>>[];
3
- }>>;
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../services/eponyme-store.js").EponymeCollectionPage<Record<string, unknown>>>>;
4
2
  export default _default;
@@ -1,11 +1,38 @@
1
1
  import { createError, defineEventHandler, getQuery, getRequestURL } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { requireEponymeUser } from "../../utils/auth.js";
4
+ import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
5
+ const MAX_TAKE = 200;
6
+ function readCount(raw, min, max) {
7
+ if (raw === void 0 || raw === "") return void 0;
8
+ const value = Number(raw);
9
+ if (!Number.isFinite(value)) return void 0;
10
+ return Math.min(max, Math.max(min, Math.trunc(value)));
11
+ }
4
12
  export default defineEventHandler(async (event) => {
5
13
  const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-collections\//, ""));
6
- const version = getQuery(event).version === "draft" ? "draft" : "published";
14
+ const query = getQuery(event);
15
+ const version = query.version === "draft" ? "draft" : "published";
7
16
  if (version === "draft") await requireEponymeUser(event);
8
- const entries = name ? await useEponymeService().listCollection(name, version) : void 0;
9
- if (!entries) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
10
- return { entries };
17
+ if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
18
+ const service = useEponymeService();
19
+ const orderBy = query.orderBy === void 0 || query.orderBy === "" ? void 0 : String(query.orderBy);
20
+ if (orderBy) {
21
+ const allowed = service.collectionSortKeys(name);
22
+ if (!allowed) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
23
+ if (!allowed.includes(orderBy)) {
24
+ throw createError({
25
+ statusCode: 400,
26
+ statusMessage: `Unknown sort key "${orderBy}". Available keys: ${allowed.join(", ")}.`
27
+ });
28
+ }
29
+ }
30
+ const page = await service.listCollection(name, version, {
31
+ take: readCount(query.take, 1, MAX_TAKE),
32
+ skip: readCount(query.skip, 0, Number.MAX_SAFE_INTEGER),
33
+ orderBy,
34
+ order: query.order === "asc" ? "asc" : query.order === "desc" ? "desc" : void 0
35
+ });
36
+ if (!page) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
37
+ return query.raw ? page : interpolateEponymeContent(page);
11
38
  });
@@ -38,6 +38,19 @@ export interface EponymeSitemapEntry {
38
38
  loc: string;
39
39
  lastmod?: string;
40
40
  }
41
+ export type EponymeSortDirection = 'asc' | 'desc';
42
+ export interface EponymeCollectionQuery {
43
+ take?: number;
44
+ skip?: number;
45
+ orderBy?: string;
46
+ order?: EponymeSortDirection;
47
+ }
48
+ export interface EponymeCollectionPage<Data extends Record<string, unknown> = Record<string, unknown>> {
49
+ entries: EponymeCollectionEntry<Data>[];
50
+ /** Matching entries before `take` and `skip`, so it can drive a pager. */
51
+ total: number;
52
+ }
53
+ export declare const COLLECTION_METADATA_KEYS: readonly ["updatedAt", "publishedAt", "title", "slug"];
41
54
  export type PrismaEponymeRow = {
42
55
  name: string;
43
56
  data: unknown;
@@ -175,7 +188,9 @@ export declare class EponymeService {
175
188
  /** Content as it was in a given version, for previewing without restoring it. */
176
189
  private getVersionResult;
177
190
  statuses(): Promise<Record<string, EponymeStatus>>;
178
- listCollection(name: string, version?: EponymeVersion): Promise<EponymeCollectionEntry[] | undefined>;
191
+ /** Metadata keys that can be sorted on, alongside every field of the collection. */
192
+ collectionSortKeys(name: string): string[] | undefined;
193
+ listCollection(name: string, version?: EponymeVersion, query?: EponymeCollectionQuery): Promise<EponymeCollectionPage | undefined>;
179
194
  /**
180
195
  * Builds the public URL list configured by `previewPaths`.
181
196
  * Collections are resolved directly from their stored rows so this performs
@@ -4,6 +4,7 @@ import { getEponymeCollections, getEponymeSchemas } from "../../utils/get-eponym
4
4
  import { normalizeSlug } from "../../utils/normalize-slug.js";
5
5
  import { applyPreviewSlug } from "../../utils/preview.js";
6
6
  import { validateEponymeData, validateEponymePatch } from "../../utils/validate-eponyme-data.js";
7
+ export const COLLECTION_METADATA_KEYS = ["updatedAt", "publishedAt", "title", "slug"];
7
8
  export class EponymeService {
8
9
  constructor(config, client) {
9
10
  this.client = client;
@@ -127,7 +128,13 @@ export class EponymeService {
127
128
  }));
128
129
  return Object.fromEntries(entries);
129
130
  }
130
- async listCollection(name, version = "published") {
131
+ /** Metadata keys that can be sorted on, alongside every field of the collection. */
132
+ collectionSortKeys(name) {
133
+ const definition = this.collections[name];
134
+ if (!definition) return void 0;
135
+ return [.../* @__PURE__ */ new Set([...COLLECTION_METADATA_KEYS, ...Object.keys(definition.fields)])];
136
+ }
137
+ async listCollection(name, version = "published", query = {}) {
131
138
  const definition = this.collections[name];
132
139
  if (!definition) return void 0;
133
140
  const rows = await this.client.eponyme.findMany({
@@ -149,7 +156,14 @@ export class EponymeService {
149
156
  updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
150
157
  };
151
158
  }));
152
- return version === "draft" ? entries : entries.filter((entry) => entry.publishedAt);
159
+ const visible = version === "draft" ? entries : entries.filter((entry) => entry.publishedAt);
160
+ const sorted = query.orderBy ? sortCollectionEntries(visible, query.orderBy, query.order ?? "desc") : visible;
161
+ const skip = Math.max(0, Math.trunc(query.skip ?? 0) || 0);
162
+ const take = query.take === void 0 ? void 0 : Math.max(0, Math.trunc(query.take) || 0);
163
+ return {
164
+ entries: take === void 0 ? sorted.slice(skip) : sorted.slice(skip, skip + take),
165
+ total: sorted.length
166
+ };
153
167
  }
154
168
  /**
155
169
  * Builds the public URL list configured by `previewPaths`.
@@ -333,6 +347,30 @@ export function isPrismaError(error, code) {
333
347
  function sameData(left, right) {
334
348
  return isDeepStrictEqual(left, right);
335
349
  }
350
+ function sortValue(entry, key) {
351
+ if (COLLECTION_METADATA_KEYS.includes(key))
352
+ return entry[key];
353
+ return entry.data[key];
354
+ }
355
+ function isEmptyValue(value) {
356
+ return value === void 0 || value === null || value === "";
357
+ }
358
+ function sortCollectionEntries(entries, key, direction) {
359
+ const factor = direction === "asc" ? 1 : -1;
360
+ return [...entries].sort((left, right) => {
361
+ const a = sortValue(left, key);
362
+ const b = sortValue(right, key);
363
+ if (isEmptyValue(a) || isEmptyValue(b)) {
364
+ if (isEmptyValue(a) && isEmptyValue(b)) return left.slug.localeCompare(right.slug);
365
+ return isEmptyValue(a) ? 1 : -1;
366
+ }
367
+ if (typeof a === "number" && typeof b === "number")
368
+ return (a - b) * factor || left.slug.localeCompare(right.slug);
369
+ if (typeof a === "boolean" && typeof b === "boolean")
370
+ return (Number(a) - Number(b)) * factor || left.slug.localeCompare(right.slug);
371
+ return String(a).localeCompare(String(b), void 0, { numeric: true }) * factor || left.slug.localeCompare(right.slug);
372
+ });
373
+ }
336
374
  function withLastmod(loc, publishedAt) {
337
375
  return publishedAt ? { loc, lastmod: publishedAt } : { loc };
338
376
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Resolved per call rather than cached: a variable such as `currentYear` has to
3
+ * reflect the moment the page is served, not the moment the server booted.
4
+ */
5
+ export declare function interpolateEponymeContent<T>(value: T): T;
@@ -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
+ }
@@ -9,3 +9,10 @@ declare module '#eponyme/prisma' {
9
9
  const prisma: unknown
10
10
  export default prisma
11
11
  }
12
+
13
+ declare module '#eponyme/variables' {
14
+ import type { EponymeVariables } from './variables'
15
+
16
+ const variables: EponymeVariables
17
+ export default variables
18
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './auth.js';
2
2
  export * from './eponyme.js';
3
3
  export * from './field.js';
4
+ export * from './variables.js';
@@ -1,3 +1,4 @@
1
1
  export * from "./auth.js";
2
2
  export * from "./eponyme.js";
3
3
  export * from "./field.js";
4
+ export * from "./variables.js";
@@ -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,2 @@
1
+ declare const _default: {};
2
+ export default _default;
@@ -0,0 +1 @@
1
+ export default {};
@@ -0,0 +1,18 @@
1
+ import type { EponymeVariableDefinition, EponymeVariables } from '../types/variables.js';
2
+ /** Always available, whatever the host application configures. */
3
+ export declare function builtinEponymeVariables(now?: Date): Record<string, EponymeVariableDefinition>;
4
+ /** Host variables win, so an application can override a built-in for its own wording. */
5
+ export declare function resolveEponymeVariables(custom?: EponymeVariables, now?: Date): Record<string, string>;
6
+ export declare function summariseEponymeVariables(custom?: EponymeVariables): Array<{
7
+ name: string;
8
+ label: string;
9
+ description?: string;
10
+ preview: string;
11
+ }>;
12
+ /**
13
+ * Replaces every `{{ name }}` occurrence. An unknown name is left untouched rather
14
+ * than blanked, so a typo shows up on the page instead of silently deleting text.
15
+ */
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. */
18
+ export declare function interpolateEponymeValue<T>(value: T, variables: Record<string, string>): T;
@@ -0,0 +1,52 @@
1
+ const VARIABLE_PATTERN = /\{\{\s*([A-Z_]\w*)\s*\}\}/gi;
2
+ export function builtinEponymeVariables(now = /* @__PURE__ */ new Date()) {
3
+ return {
4
+ currentYear: { label: "Current year", description: "The year at the time the page is served.", value: () => now.getFullYear() },
5
+ nextYear: { label: "Next year", description: "Useful for a season spanning two years.", value: () => now.getFullYear() + 1 },
6
+ previousYear: { label: "Previous year", value: () => now.getFullYear() - 1 },
7
+ currentMonth: { label: "Current month", value: () => now.toLocaleDateString(void 0, { month: "long" }) },
8
+ currentDay: { label: "Current day", value: () => String(now.getDate()) },
9
+ today: { label: "Today", description: "Localised date, for example 29 July 2026.", value: () => now.toLocaleDateString(void 0, { dateStyle: "long" }) },
10
+ currentDate: { label: "Today (ISO)", description: "Machine-readable date, for example 2026-07-29.", value: () => now.toISOString().slice(0, 10) }
11
+ };
12
+ }
13
+ function toDefinition(entry) {
14
+ return typeof entry === "object" && entry !== null && "value" in entry ? entry : { value: entry };
15
+ }
16
+ export function resolveEponymeVariables(custom = {}, now) {
17
+ const merged = { ...builtinEponymeVariables(now) };
18
+ for (const [name, entry] of Object.entries(custom)) merged[name] = toDefinition(entry);
19
+ return Object.fromEntries(Object.entries(merged).map(([name, definition]) => {
20
+ try {
21
+ const value = typeof definition.value === "function" ? definition.value() : definition.value;
22
+ return [name, String(value)];
23
+ } catch {
24
+ return [name, ""];
25
+ }
26
+ }));
27
+ }
28
+ export function summariseEponymeVariables(custom = {}) {
29
+ const merged = { ...builtinEponymeVariables() };
30
+ for (const [name, entry] of Object.entries(custom)) merged[name] = toDefinition(entry);
31
+ const resolved = resolveEponymeVariables(custom);
32
+ return Object.entries(merged).map(([name, definition]) => ({
33
+ name,
34
+ label: definition.label ?? name,
35
+ description: definition.description,
36
+ preview: resolved[name] ?? ""
37
+ }));
38
+ }
39
+ export function interpolateEponymeText(text, variables) {
40
+ if (!text.includes("{{")) return text;
41
+ return text.replace(VARIABLE_PATTERN, (match, name) => variables[name] ?? match);
42
+ }
43
+ export function interpolateEponymeValue(value, variables) {
44
+ if (typeof value === "string") return interpolateEponymeText(value, variables);
45
+ if (Array.isArray(value)) return value.map((item) => interpolateEponymeValue(item, variables));
46
+ if (value && typeof value === "object") {
47
+ return Object.fromEntries(
48
+ Object.entries(value).map(([key, nested]) => [key, interpolateEponymeValue(nested, variables)])
49
+ );
50
+ }
51
+ return value;
52
+ }
package/dist/types.d.mts CHANGED
@@ -1,10 +1,10 @@
1
- export { type collection, type defineEponymeConfig, type form } from './eponyme.mjs'
1
+ export { type collection, type defineEponymeConfig, type defineEponymeVariables, type form } from './eponyme.mjs'
2
2
 
3
3
  export { type field, type today } from '../dist/runtime/fields/index.js'
4
4
 
5
5
  export { type EPONYME_ROLES, type canEditEponyme, type isEponymeRole } from '../dist/runtime/types/auth.js'
6
6
 
7
- export { type ArrayFieldDefinition, type ArrayFieldOptions, type ArrayItemDefinition, type ArrayItemFieldDefinition, type ArrayItemSchema, type ArrayItemValue, type BooleanFieldDefinition, type BooleanFieldOptions, type CheckboxGroupFieldDefinition, type CheckboxGroupFieldOptions, type CollectionSlugField, type ColorFieldDefinition, type ColorFieldOptions, type DateFieldDefinition, type DateFieldOptions, type EmailFieldDefinition, type EmailFieldOptions, type EponymeAuthSession, type EponymeAuthUser, type EponymeCollectionDataByName, type EponymeCollectionDefinition, type EponymeCollectionDefinitionBase, type EponymeCollectionName, type EponymeCollectionOptions, type EponymeConfig, type EponymeData, type EponymeDataByName, type EponymeFormDataByName, type EponymeFormDefinition, type EponymeFormDefinitionBase, type EponymeFormFieldDefinition, type EponymeFormMode, type EponymeFormName, type EponymeFormOptions, type EponymeFormSchema, type EponymeManagedUserResult, type EponymeName, type EponymeRole, type EponymeSchema, type FieldDefinition, type FieldValidationResult, type FieldValidator, type FieldVisibilityCondition, type ImageFieldDefinition, type ImageFieldOptions, type NumberFieldDefinition, type NumberFieldOptions, type RadioFieldDefinition, type RadioFieldOptions, type RichTextFieldDefinition, type RichTextFieldOptions, type SectionFieldDefinition, type SectionFieldOptions, type SectionItemFieldDefinition, type SectionItemValue, type SectionSchema, type SectionValue, type SelectFieldDefinition, type SelectFieldOptions, type SelectOption, type SlugFieldDefinition, type SlugFieldOptions, type StringFieldDefinition, type StringFieldOptions, type TabDefinition, type TabFieldDefinition, type TabFieldOptions, type TabsSchema, type TabsValue, type TextareaFieldDefinition, type TextareaFieldOptions, type TodayDateDefault, type UrlFieldDefinition, type UrlFieldOptions, type UrlType, type UrlValue } from '../dist/runtime/types/index.js'
7
+ export { type ArrayFieldDefinition, type ArrayFieldOptions, type ArrayItemDefinition, type ArrayItemFieldDefinition, type ArrayItemSchema, type ArrayItemValue, type BooleanFieldDefinition, type BooleanFieldOptions, type CheckboxGroupFieldDefinition, type CheckboxGroupFieldOptions, type CollectionSlugField, type ColorFieldDefinition, type ColorFieldOptions, type DateFieldDefinition, type DateFieldOptions, type EmailFieldDefinition, type EmailFieldOptions, type EponymeAuthSession, type EponymeAuthUser, type EponymeCollectionDataByName, type EponymeCollectionDefinition, type EponymeCollectionDefinitionBase, type EponymeCollectionName, type EponymeCollectionOptions, type EponymeConfig, type EponymeData, type EponymeDataByName, type EponymeFormDataByName, type EponymeFormDefinition, type EponymeFormDefinitionBase, type EponymeFormFieldDefinition, type EponymeFormMode, type EponymeFormName, type EponymeFormOptions, type EponymeFormSchema, type EponymeManagedUserResult, type EponymeName, type EponymeRole, type EponymeSchema, type EponymeVariableDefinition, type EponymeVariableSummary, type EponymeVariableValue, type EponymeVariables, type FieldDefinition, type FieldValidationResult, type FieldValidator, type FieldVisibilityCondition, type ImageFieldDefinition, type ImageFieldOptions, type NumberFieldDefinition, type NumberFieldOptions, type RadioFieldDefinition, type RadioFieldOptions, type RichTextFieldDefinition, type RichTextFieldOptions, type SectionFieldDefinition, type SectionFieldOptions, type SectionItemFieldDefinition, type SectionItemValue, type SectionSchema, type SectionValue, type SelectFieldDefinition, type SelectFieldOptions, type SelectOption, type SlugFieldDefinition, type SlugFieldOptions, type StringFieldDefinition, type StringFieldOptions, type TabDefinition, type TabFieldDefinition, type TabFieldOptions, type TabsSchema, type TabsValue, type TextareaFieldDefinition, type TextareaFieldOptions, type TodayDateDefault, type UrlFieldDefinition, type UrlFieldOptions, type UrlType, type UrlValue } from '../dist/runtime/types/index.js'
8
8
 
9
9
  export { default } from './module.mjs'
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A lightweight, type-safe content manager for Nuxt.",
5
5
  "license": "MIT",
6
6
  "type": "module",