@karibsen/eponyme 0.6.0 → 0.8.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 (74) hide show
  1. package/README.md +109 -45
  2. package/dist/eponyme.mjs +2 -3
  3. package/dist/module.d.mts +109 -13
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +19 -6
  6. package/dist/runtime/components/editor/EponymeFolderNav.vue +1 -8
  7. package/dist/runtime/components/editor/EponymeFormPage.vue +2 -2
  8. package/dist/runtime/components/editor/EponymeSidebar.vue +7 -2
  9. package/dist/runtime/components/fields/FieldRenderer.vue +22 -0
  10. package/dist/runtime/components/fields/PhoneField.d.vue.ts +22 -0
  11. package/dist/runtime/components/fields/PhoneField.vue +61 -0
  12. package/dist/runtime/components/fields/PhoneField.vue.d.ts +22 -0
  13. package/dist/runtime/components/fields/RichTextField.vue +37 -1
  14. package/dist/runtime/components/fields/TagsField.d.vue.ts +21 -0
  15. package/dist/runtime/components/fields/TagsField.vue +55 -0
  16. package/dist/runtime/components/fields/TagsField.vue.d.ts +21 -0
  17. package/dist/runtime/components/ui/EPAlertDialog.d.vue.ts +37 -0
  18. package/dist/runtime/components/ui/EPAlertDialog.vue +66 -0
  19. package/dist/runtime/components/ui/EPAlertDialog.vue.d.ts +37 -0
  20. package/dist/runtime/components/ui/EPDialog.d.vue.ts +1 -0
  21. package/dist/runtime/components/ui/EPDialog.vue +6 -2
  22. package/dist/runtime/components/ui/EPDialog.vue.d.ts +1 -0
  23. package/dist/runtime/components/ui/EPLinkEditor.vue +2 -1
  24. package/dist/runtime/components/ui/EPTagsInput.d.vue.ts +27 -0
  25. package/dist/runtime/components/ui/EPTagsInput.vue +101 -0
  26. package/dist/runtime/components/ui/EPTagsInput.vue.d.ts +27 -0
  27. package/dist/runtime/composables/useEponymeCollection.d.ts +13 -1
  28. package/dist/runtime/composables/useEponymeCollection.js +29 -4
  29. package/dist/runtime/composables/useEponymeForm.d.ts +0 -4
  30. package/dist/runtime/composables/useEponymeForm.js +1 -8
  31. package/dist/runtime/fields/index.d.ts +4 -0
  32. package/dist/runtime/fields/index.js +3 -1
  33. package/dist/runtime/fields/phone.d.ts +2 -0
  34. package/dist/runtime/fields/phone.js +3 -0
  35. package/dist/runtime/fields/tags.d.ts +6 -0
  36. package/dist/runtime/fields/tags.js +3 -0
  37. package/dist/runtime/middleware/eponyme-auth.d.ts +1 -1
  38. package/dist/runtime/middleware/eponyme-owner.d.ts +1 -1
  39. package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
  40. package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
  41. package/dist/runtime/server/api/eponyme-forms/[path].post.js +0 -15
  42. package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
  43. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  44. package/dist/runtime/server/services/eponyme-form-store.js +4 -3
  45. package/dist/runtime/server/services/eponyme-store.d.ts +289 -30
  46. package/dist/runtime/server/services/eponyme-store.js +405 -73
  47. package/dist/runtime/server/utils/eponyme-reindex.d.ts +13 -0
  48. package/dist/runtime/server/utils/eponyme-reindex.js +4 -0
  49. package/dist/runtime/types/eponyme-config.d.ts +0 -7
  50. package/dist/runtime/types/eponyme.d.ts +54 -6
  51. package/dist/runtime/types/field.d.ts +82 -4
  52. package/dist/runtime/types/index.d.ts +0 -1
  53. package/dist/runtime/types/index.js +0 -1
  54. package/dist/runtime/utils/eponyme-entry-index.d.ts +39 -0
  55. package/dist/runtime/utils/eponyme-entry-index.js +51 -0
  56. package/dist/runtime/utils/get-field-default-value.js +2 -0
  57. package/dist/runtime/utils/middle-ellipsis.d.ts +1 -0
  58. package/dist/runtime/utils/middle-ellipsis.js +8 -0
  59. package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
  60. package/dist/runtime/utils/normalize-eponyme-values.js +40 -0
  61. package/dist/runtime/utils/normalize-phone.d.ts +26 -0
  62. package/dist/runtime/utils/normalize-phone.js +17 -0
  63. package/dist/runtime/utils/normalize-tags.d.ts +10 -0
  64. package/dist/runtime/utils/normalize-tags.js +17 -0
  65. package/dist/runtime/utils/validate-eponyme-data.js +45 -3
  66. package/dist/runtime/utils/variables.d.ts +4 -0
  67. package/dist/runtime/utils/variables.js +8 -1
  68. package/package.json +4 -2
  69. package/dist/runtime/server/utils/eponyme-captcha.d.ts +0 -6
  70. package/dist/runtime/server/utils/eponyme-captcha.js +0 -14
  71. package/dist/runtime/types/captcha.d.ts +0 -16
  72. package/dist/runtime/types/captcha.js +0 -0
  73. package/dist/runtime/utils/no-captcha.d.ts +0 -7
  74. package/dist/runtime/utils/no-captcha.js +0 -5
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![Eponyme cover](https://raw.githubusercontent.com/karibsen-studio/eponyme/main/.github/assets/cover.png)
2
+
1
3
  # Eponyme
2
4
 
3
5
  [![npm version][npm-version-src]][npm-version-href]
@@ -11,7 +13,7 @@ Define your content in `eponyme.config.ts`. Eponyme provides defaults, validatio
11
13
 
12
14
  ## Features
13
15
 
14
- - Type-safe fields for text, slugs, rich text, numbers, booleans, images, links, dates, colors, sections, tabs, and arrays
16
+ - Type-safe fields for text, slugs, rich text, numbers, booleans, images, links, phone numbers, tags, dates, colors, sections, tabs, and arrays
15
17
  - Declarative defaults and validation
16
18
  - Conditional fields, character counters, and sortable arrays
17
19
  - Private drafts with explicit publishing
@@ -42,6 +44,13 @@ Install the module:
42
44
  pnpm add @karibsen/eponyme
43
45
  ```
44
46
 
47
+ Eponyme brings its own components, so `@nuxt/ui` is not required. If your application does use
48
+ it, it has to be `^4.10.0` or newer: both ship Tailwind, and an older `@nuxt/ui` resolves a
49
+ version the dashboard was not built against. What that breaks is not predictable — broken
50
+ dashboard styles are the visible case, but it can also fail the build or misbehave elsewhere with
51
+ no obvious link back to the cause. The module checks the installed version and warns at build
52
+ time.
53
+
45
54
  Add it to `nuxt.config.ts`:
46
55
 
47
56
  ```ts
@@ -85,8 +94,16 @@ export default defineEponymeConfig({
85
94
  label: 'Contact email',
86
95
  defaultValue: 'hello@example.com',
87
96
  }),
97
+ contactPhone: field.phone({
98
+ label: 'Contact phone',
99
+ defaultCountry: 'FR',
100
+ countries: ['FR', 'BE'],
101
+ autocomplete: 'tel',
102
+ }),
88
103
  callToAction: field.url({
89
104
  label: 'Call to action',
105
+ // Schemes an external link may use; defaults to ['http', 'https']
106
+ protocols: ['https', 'mailto'],
90
107
  defaultValue: {
91
108
  href: '/contact',
92
109
  type: 'internal',
@@ -152,6 +169,59 @@ field.string({
152
169
  })
153
170
  ```
154
171
 
172
+ ### Tags
173
+
174
+ `field.tags()` holds a short list of values, with suggestions and optional free entry:
175
+
176
+ ```ts
177
+ tags: field.tags({
178
+ suggestions: ['Nuxt', 'Vue', 'TypeScript'],
179
+ allowCustom: true,
180
+ maxItems: 5,
181
+ })
182
+ ```
183
+
184
+ The value type follows the options: closed, it is `('Nuxt' | 'Vue' | 'TypeScript')[]`; with
185
+ `allowCustom`, `string[]`. One array never mixes two types.
186
+
187
+ The list is normalised **on the server** before the write: trimmed, blanks dropped, duplicates
188
+ folded case-insensitively. A suggestion imposes its spelling, so typing `nuxt` beside a declared
189
+ `Nuxt` yields `Nuxt` and a listing never splits in two. `minItems` and `maxItems` count what is
190
+ left after that folding.
191
+
192
+ ### Phone numbers
193
+
194
+ `field.phone()` stores its value in **E.164** — `+33611131143` — whatever format it was typed
195
+ in. Normalisation happens on the server before the write, so the stored format is a guarantee
196
+ rather than a convention a client could skip.
197
+
198
+ ```ts
199
+ phone: field.phone({
200
+ label: 'Phone',
201
+ // ISO 3166-1 alpha-2, autocompleted from libphonenumber-js
202
+ countries: ['FR', 'BE'],
203
+ // Country a number typed without `+` belongs to
204
+ defaultCountry: 'FR',
205
+ autocomplete: 'tel',
206
+ placeholder: '06 11 13 11 43',
207
+ required: true,
208
+ })
209
+ ```
210
+
211
+ | Option | Effect |
212
+ |---|---|
213
+ | `countries` | Countries whose numbers are accepted. A number resolving to any other is refused, with an error naming the country it came from. Omitted, every country is accepted |
214
+ | `defaultCountry` | Country assumed for a number written without an international prefix. Without it, only `+…` numbers can be understood |
215
+ | `detectCountry` | `false` requires the international `+…` form, so the country is never guessed. Defaults to `true` |
216
+ | `autocomplete` | `tel`, `tel-national` or `tel-country-code`, set on the input |
217
+ | `defaultValue`, `required`, `placeholder`, `label`, `description` | As on every field |
218
+
219
+ Validation uses the `min` metadata bundle of `libphonenumber-js`, a fraction of the size of the
220
+ full one. It parses and formats every country, and is slightly more permissive: a few numbers
221
+ that are impossible for their region still pass.
222
+
223
+ A phone field is also allowed in a public `form()`.
224
+
155
225
  ## Prisma
156
226
 
157
227
  Eponyme uses the Prisma client owned by your application. It does not create the connection or run migrations.
@@ -174,17 +244,49 @@ Add the required models to your Prisma schema:
174
244
 
175
245
  ```prisma
176
246
  model Eponyme {
177
- name String @id
178
- data Json @db.JsonB
179
- createdAt DateTime @default(now())
180
- updatedAt DateTime @updatedAt
181
- deletedAt DateTime?
182
- versions EponymeVersion[]
247
+ name String @id
248
+ draft Json @db.JsonB
249
+ published Json @db.JsonB
250
+ status String
251
+ publishedAt DateTime?
252
+ createdAt DateTime @default(now())
253
+ updatedAt DateTime @updatedAt
254
+ deletedAt DateTime?
255
+ versions EponymeVersion[]
256
+ index EponymeEntryIndex[]
183
257
 
184
258
  @@index([deletedAt])
259
+ @@index([publishedAt])
185
260
  @@map("eponyme_entries")
186
261
  }
187
262
 
263
+ /// Filterable values pulled out of the content columns, so a listing can look them up
264
+ /// instead of loading and normalizing every entry of a collection to compare them.
265
+ /// Rewritten for an entry inside the same transaction as the write that changed it.
266
+ model EponymeEntryIndex {
267
+ entryName String
268
+ /// "draft" or "published": the two disagree, and a public listing must only see one.
269
+ version String
270
+ key String
271
+ /// Case-folded, so a lookup never has to guess which spelling was stored first.
272
+ value String
273
+ entry Eponyme @relation(fields: [entryName], references: [name], onDelete: Cascade)
274
+
275
+ @@id([entryName, version, key, value])
276
+ @@index([key, version, value])
277
+ @@map("eponyme_entry_index")
278
+ }
279
+
280
+ /// Remembers which filterable schema each collection was last indexed against, so the
281
+ /// module can rebuild only what a config change actually invalidated.
282
+ model EponymeIndexState {
283
+ name String @id
284
+ fingerprint String
285
+ updatedAt DateTime @updatedAt
286
+
287
+ @@map("eponyme_index_state")
288
+ }
289
+
188
290
  model EponymeVersion {
189
291
  id Int @id @default(autoincrement())
190
292
  entryName String
@@ -740,44 +842,6 @@ Two protections apply to the public submission route:
740
842
  - a **body size limit**, 64 KB by default, configurable with `maxBodyBytes`. An oversized
741
843
  body is refused with `413` before it is parsed.
742
844
 
743
- ### Captcha
744
-
745
- Set `captcha: true` on the form, then install an adapter — Eponyme defines the contract
746
- but ships no provider:
747
-
748
- ```bash
749
- pnpm add @karibsen/eponyme-captcha
750
- ```
751
-
752
- ```ts
753
- export default defineNuxtConfig({
754
- modules: ['@karibsen/eponyme', '@karibsen/eponyme-captcha'],
755
- eponymeCaptcha: { siteKey: '<your-turnstile-site-key>' },
756
- })
757
- ```
758
-
759
- Render the widget and bind its token; `useEponymeForm` sends it alongside the fields and
760
- the server verifies it before validating anything:
761
-
762
- ```vue
763
- <script setup lang="ts">
764
- const { captchaToken, requiresCaptcha, submit } = useEponymeForm('contact')
765
- </script>
766
-
767
- <template>
768
- <NuxtTurnstile v-if="requiresCaptcha" v-model="captchaToken" />
769
- </template>
770
- ```
771
-
772
- **It fails closed.** Without an adapter, a form declaring `captcha: true` answers `500`
773
- rather than accepting submissions unprotected. A rejected token answers `422` with a
774
- `_form` error; the provider's reason stays in the server log, since telling a bot which
775
- check it failed only helps it.
776
-
777
- Writing an adapter for another provider means supplying a `name` and an
778
- `async verify(token, { ip })` returning `{ success, reason? }`, then pointing the
779
- `#eponyme/captcha` alias at it.
780
-
781
845
  Rate limiting and file uploads are not implemented yet.
782
846
 
783
847
  ## Hooks
package/dist/eponyme.mjs CHANGED
@@ -11,7 +11,7 @@ function collection(options) {
11
11
  return { __eponymeCollection: true, ...options };
12
12
  }
13
13
 
14
- const PUBLIC_FIELD_TYPES = ["string", "textarea", "email", "url", "number", "boolean", "select", "radio", "checkboxGroup"];
14
+ const PUBLIC_FIELD_TYPES = ["string", "textarea", "email", "phone", "url", "number", "boolean", "select", "radio", "checkboxGroup"];
15
15
  const DEFAULT_HONEYPOT = "_eponyme_hp";
16
16
  const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
17
17
  function form(options) {
@@ -31,8 +31,7 @@ function form(options) {
31
31
  // Storing a submission has to be an explicit decision, so `custom` is the default.
32
32
  submission: { mode: options.submission?.mode ?? "custom" },
33
33
  honeypot,
34
- maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
35
- captcha: options.captcha ?? false
34
+ maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
36
35
  };
37
36
  }
38
37
 
package/dist/module.d.mts CHANGED
@@ -5,37 +5,133 @@ export { EPONYME_ROLES, canEditEponyme, isEponymeRole } from '../dist/runtime/ty
5
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
6
  import '../dist/runtime/types/variables.js';
7
7
 
8
+ /**
9
+ * Options read from the `eponyme` key of `nuxt.config`.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * export default defineNuxtConfig({
14
+ * modules: ['@karibsen/eponyme'],
15
+ * eponyme: {
16
+ * prismaClient: '~~/server/utils/prisma',
17
+ * previewPaths: {
18
+ * homepage: '/',
19
+ * articles: '/articles/:slug',
20
+ * },
21
+ * },
22
+ * })
23
+ * ```
24
+ */
8
25
  interface ModuleOptions {
26
+ /**
27
+ * Route the dashboard is served on. Surrounding slashes are normalised, so `__eponyme` and
28
+ * `/__eponyme/` are equivalent.
29
+ *
30
+ * @default "/__eponyme"
31
+ */
9
32
  dashboardPath?: string;
10
- /** Public routes used for previews and sitemap entries. Collections use a `:slug` placeholder. */
33
+ /**
34
+ * Public route rendering each entry, keyed by singleton name or by collection name. A
35
+ * collection value must contain `:slug`, which is replaced by the entry slug.
36
+ *
37
+ * @remarks
38
+ * An entry left out of this map has no preview, no sitemap entry, and no cache tag on the page
39
+ * that renders it.
40
+ *
41
+ * @default {}
42
+ * @example
43
+ * ```ts
44
+ * previewPaths: {
45
+ * homepage: '/',
46
+ * articles: '/articles/:slug',
47
+ * }
48
+ * ```
49
+ */
11
50
  previewPaths?: Record<string, string>;
12
- /** Path to a server module exporting an already initialized PrismaClient. */
51
+ /**
52
+ * Module specifier of a server file whose default export is an initialised `PrismaClient`.
53
+ * Nuxt aliases apply, and keep their usual meaning: a server file lives under `~~/`, not `~/`.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * prismaClient: '~~/server/utils/prisma'
58
+ * ```
59
+ * @see https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/introduction
60
+ */
13
61
  prismaClient: string;
14
- /** Default palette offered by every color field; a field can override it with its own `presets`. */
62
+ /**
63
+ * Palette offered by every color field, which a field overrides with its own `presets`.
64
+ *
65
+ * @remarks
66
+ * A bare string is labelled from its value. An entry that is not a valid hex colour is dropped.
67
+ *
68
+ * @default []
69
+ * @example
70
+ * ```ts
71
+ * colorPresets: [
72
+ * { label: 'Ink', value: '#171714' },
73
+ * '#ffffff',
74
+ * ]
75
+ * ```
76
+ */
15
77
  colorPresets?: Array<string | {
16
78
  label: string;
17
79
  value: string;
18
80
  }>;
19
81
  auth?: {
20
- /** Fixed lifetime of an authenticated session. */
82
+ /**
83
+ * Lifetime of an authenticated session, in days, counted from sign-in and never extended by
84
+ * activity.
85
+ *
86
+ * @default 7
87
+ */
21
88
  sessionDurationDays?: number;
22
89
  };
23
90
  /**
24
- * How long a server instance may reuse content it has already read, in seconds.
25
- * A save clears what it changed on its own instance, so this only bounds how long
26
- * another instance can still serve the previous content. `0` disables the cache.
91
+ * How long a server instance may reuse content it has already read, in seconds. `0` disables it.
92
+ *
93
+ * @remarks
94
+ * A save only clears the instance that served it, so this bounds how long another instance can
95
+ * still answer with the previous content.
96
+ *
97
+ * @default 5
27
98
  */
28
99
  cacheSeconds?: number;
29
100
  /**
30
- * How long a browser may reuse published content, in seconds. This is what makes a
31
- * client-side navigation instant, and it is the one window nobody can purge: a visitor
32
- * who already holds a copy keeps it until it expires. Keep it short. `0` disables it.
101
+ * Whether the filterable index is rebuilt at startup when the configuration that produced
102
+ * it has changed.
103
+ *
104
+ * @remarks
105
+ * Leave it on. Ordinary writes keep the index current, but a field that becomes filterable
106
+ * leaves the entries already stored without a row for it, and a filter on them then answers
107
+ * "none" instead of failing — a wrong listing rather than an error.
108
+ *
109
+ * Turning it off is an escape hatch: if a rebuild ever fails on every boot, this gets the
110
+ * application up again so `reindexEponymeEntries()` can be run by hand.
111
+ *
112
+ * @default true
113
+ */
114
+ autoReindex?: boolean;
115
+ /**
116
+ * How long a browser may reuse published content, in seconds. `0` disables it.
117
+ *
118
+ * @remarks
119
+ * A browser cache cannot be purged: a visitor holding a copy keeps it until it expires, whatever
120
+ * is published meanwhile.
121
+ *
122
+ * @default 30
123
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
33
124
  */
34
125
  browserCacheSeconds?: number;
35
126
  /**
36
- * How long a CDN may reuse published content, in seconds. A CDN can be purged — the
37
- * `eponyme:entry:published` hook is the place to do it — so this can be generous.
38
- * `0` disables it.
127
+ * How long a CDN may reuse published content, in seconds. `0` disables it.
128
+ *
129
+ * @remarks
130
+ * Sent as `s-maxage`, with `stale-while-revalidate` twelve times longer. Cached responses carry
131
+ * cache tags, so a publication can purge them from the `eponyme:entry:published` hook.
132
+ *
133
+ * @default 300
134
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
39
135
  */
40
136
  cdnCacheSeconds?: number;
41
137
  }
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
3
  "configKey": "eponyme",
4
- "version": "0.6.0",
4
+ "version": "0.8.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,9 +1,21 @@
1
- import { defineNuxtModule, useLogger, createResolver, findPath, addImports, addImportsDir, addServerImports, addRouteMiddleware, addTypeTemplate, addServerHandler, addServerPlugin } from '@nuxt/kit';
1
+ import { defineNuxtModule, useLogger, createResolver, findPath, addImports, addImportsDir, addServerImports, addRouteMiddleware, addTypeTemplate, addServerHandler, addServerPlugin, hasNuxtModule, hasNuxtModuleCompatibility, getNuxtModuleVersion } from '@nuxt/kit';
2
2
  import pc from 'picocolors';
3
3
  import tailwindcss from '@tailwindcss/vite';
4
4
  import { resolve } from 'node:path';
5
5
  import { tagPreviewPathRoutes } from '../dist/runtime/utils/cache-tags.js';
6
6
 
7
+ const MINIMUM_NUXT_UI = "4.10.0";
8
+ async function warnOnIncompatibleNuxtUi(nuxt, logger) {
9
+ if (!hasNuxtModule("@nuxt/ui", nuxt)) return;
10
+ if (await hasNuxtModuleCompatibility("@nuxt/ui", `>=${MINIMUM_NUXT_UI}`, nuxt)) return;
11
+ const found = await getNuxtModuleVersion("@nuxt/ui", nuxt);
12
+ logger.warn(
13
+ `@nuxt/ui ${found || "(unknown version)"} is older than ${MINIMUM_NUXT_UI}, which Eponyme needs in order to share Tailwind with it.
14
+ Upgrade it with \`npm i @nuxt/ui@^${MINIMUM_NUXT_UI}\`. Keeping an older one resolves a Tailwind this module was not built
15
+ against, and what that breaks is not predictable: broken dashboard styles are the visible case, but it can also fail the
16
+ build or misbehave somewhere with no obvious link back to this warning.`
17
+ );
18
+ }
7
19
  const module$1 = defineNuxtModule({
8
20
  meta: {
9
21
  name: "@karibsen/eponyme",
@@ -18,6 +30,7 @@ const module$1 = defineNuxtModule({
18
30
  sessionDurationDays: 7
19
31
  },
20
32
  cacheSeconds: 5,
33
+ autoReindex: true,
21
34
  browserCacheSeconds: 30,
22
35
  cdnCacheSeconds: 300
23
36
  },
@@ -64,6 +77,7 @@ const module$1 = defineNuxtModule({
64
77
  async setup(options, nuxt) {
65
78
  const logger = useLogger("eponyme");
66
79
  const resolver = createResolver(import.meta.url);
80
+ await warnOnIncompatibleNuxtUi(nuxt, logger);
67
81
  const dashboardPath = `/${(options.dashboardPath || "__eponyme").replace(/^\/+|\/+$/g, "")}`;
68
82
  const publicRuntimeConfig = nuxt.options.runtimeConfig.public;
69
83
  publicRuntimeConfig.eponyme = {
@@ -76,6 +90,7 @@ const module$1 = defineNuxtModule({
76
90
  };
77
91
  nuxt.options.runtimeConfig.eponymeContent = {
78
92
  cacheSeconds: options.cacheSeconds ?? 5,
93
+ autoReindex: options.autoReindex ?? true,
79
94
  browserCacheSeconds: options.browserCacheSeconds ?? 30,
80
95
  cdnCacheSeconds: options.cdnCacheSeconds ?? 300
81
96
  };
@@ -113,6 +128,9 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
113
128
  addImportsDir(resolver.resolve("./runtime/composables"));
114
129
  addServerImports([
115
130
  { name: "getEponymeSitemapEntries", from: resolver.resolve("./runtime/server/utils/eponyme-sitemap") },
131
+ // The backfill for the filterable index, which a migration cannot do on its own:
132
+ // reading a value out of the JSONB envelope needs the configured schema.
133
+ { name: "reindexEponymeEntries", from: resolver.resolve("./runtime/server/utils/eponyme-reindex") },
116
134
  // A `custom` form posts to the host application's own route, so this is what
117
135
  // keeps server-side validation as the security boundary.
118
136
  { name: "validateEponymeForm", from: resolver.resolve("./runtime/server/utils/eponyme-form") },
@@ -150,7 +168,6 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
150
168
  nuxt.options.alias["#eponyme/prisma"] = prismaPath;
151
169
  const variablesPath = await findPath(resolve(nuxt.options.rootDir, "eponyme.variables.ts"));
152
170
  nuxt.options.alias["#eponyme/variables"] = variablesPath ?? resolver.resolve("./runtime/utils/empty-variables");
153
- nuxt.options.alias["#eponyme/captcha"] ??= resolver.resolve("./runtime/utils/no-captcha");
154
171
  addTypeTemplate({
155
172
  filename: "types/eponyme-config.d.ts",
156
173
  getContents: () => `declare module '#eponyme/config' {
@@ -161,10 +178,6 @@ declare module '#eponyme/variables' {
161
178
  const variables: typeof import(${JSON.stringify(nuxt.options.alias["#eponyme/variables"])})['default']
162
179
  export default variables
163
180
  }
164
- declare module '#eponyme/captcha' {
165
- const verifier: import('${resolver.resolve("./runtime/types/captcha")}').EponymeCaptchaVerifier
166
- export default verifier
167
- }
168
181
  `
169
182
  });
170
183
  addServerHandler({ middleware: true, handler: resolver.resolve("./runtime/server/middleware/eponyme-no-store") });
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  import { computed, ref, watch } from "vue";
3
+ import { middleEllipsis } from "../../utils/middle-ellipsis";
3
4
  const props = defineProps({
4
5
  basePath: { type: String, required: true },
5
6
  eponyme: { type: String, required: true }
@@ -24,14 +25,6 @@ const visibleBreadcrumbs = computed(() => {
24
25
  function label(name) {
25
26
  return name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
26
27
  }
27
- function middleEllipsis(value, maxLength = 28) {
28
- const characters = [...value];
29
- if (characters.length <= maxLength) return value;
30
- const available = maxLength - 1;
31
- const startLength = Math.ceil(available / 2);
32
- const endLength = Math.floor(available / 2);
33
- return `${characters.slice(0, startLength).join("")}\u2026${characters.slice(-endLength).join("")}`;
34
- }
35
28
  function updateBreadcrumbMenuScroll(event) {
36
29
  const menu = event.currentTarget;
37
30
  if (!menu) return;
@@ -171,11 +171,11 @@ async function deleteSubmission(submission) {
171
171
  v-for="header in headerGroup.headers"
172
172
  :key="header.id"
173
173
  scope="col"
174
- class="ep:border-b ep:border-border-ep ep:p-3 ep:text-[11px] ep:font-semibold ep:tracking-widest ep:text-muted-ep ep:uppercase"
174
+ class="ep:border-b ep:border-border-ep ep:p-3 ep:text-[11px] ep:font-semibold ep:text-muted-ep ep:uppercase"
175
175
  >
176
176
  <button
177
177
  type="button"
178
- class="ep:flex ep:cursor-pointer ep:items-center ep:gap-1 ep:border-0 ep:bg-transparent ep:p-0 ep:text-inherit ep:uppercase"
178
+ class="ep:flex ep:cursor-pointer ep:items-center ep:gap-1 ep:border-0 ep:bg-transparent ep:p-0 ep:text-inherit"
179
179
  :aria-sort="header.column.getIsSorted() === 'asc' ? 'ascending' : header.column.getIsSorted() === 'desc' ? 'descending' : 'none'"
180
180
  @click="header.column.toggleSorting()"
181
181
  >
@@ -12,6 +12,7 @@ import { useEponymeAuth } from "../../composables/useEponymeAuth";
12
12
  import EPAvatar from "../ui/EPAvatar.vue";
13
13
  import EPButton from "../ui/EPButton.vue";
14
14
  import EPInputText from "../ui/EPInputText.vue";
15
+ import logoUrl from "../../assets/logo.png?url";
15
16
  const props = defineProps({
16
17
  basePath: { type: String, required: true }
17
18
  });
@@ -128,9 +129,13 @@ watch(() => route.path, () => {
128
129
  <NuxtLink
129
130
  v-if="!collapsed"
130
131
  :to="normalizedBasePath"
131
- class="ep:flex ep:min-w-0 ep:items-center ep:gap-2 ep:px-3 ep:text-sm ep:font-bold ep:tracking-tight ep:text-white ep:no-underline"
132
+ class="ep:flex ep:min-w-0 ep:items-center ep:no-underline"
132
133
  >
133
- Eponyme
134
+ <img
135
+ :src="logoUrl"
136
+ alt="Eponyme"
137
+ class="ep:block ep:size-8"
138
+ >
134
139
  </NuxtLink>
135
140
  <button
136
141
  type="button"
@@ -5,10 +5,12 @@ import CheckboxGroupField from "./CheckboxGroupField.vue";
5
5
  import ColorField from "./ColorField.vue";
6
6
  import DateField from "./DateField.vue";
7
7
  import NumberField from "./NumberField.vue";
8
+ import PhoneField from "./PhoneField.vue";
8
9
  import RadioField from "./RadioField.vue";
9
10
  import RichTextField from "./RichTextField.vue";
10
11
  import SelectField from "./SelectField.vue";
11
12
  import SlugField from "./SlugField.vue";
13
+ import TagsField from "./TagsField.vue";
12
14
  import TextareaField from "./TextareaField.vue";
13
15
  import TextField from "./TextField.vue";
14
16
  import UrlField from "./UrlField.vue";
@@ -59,6 +61,10 @@ const component = computed(() => {
59
61
  return ColorField;
60
62
  case "url":
61
63
  return UrlField;
64
+ case "phone":
65
+ return PhoneField;
66
+ case "tags":
67
+ return TagsField;
62
68
  default:
63
69
  return TextField;
64
70
  }
@@ -78,6 +84,14 @@ const specificProps = computed(() => {
78
84
  case "radio":
79
85
  case "checkboxGroup":
80
86
  return { options: field.options.options };
87
+ case "tags":
88
+ return {
89
+ suggestions: field.options.suggestions,
90
+ allowCustom: field.options.allowCustom,
91
+ minItems: field.options.minItems,
92
+ maxItems: field.options.maxItems,
93
+ placeholder: field.options.placeholder
94
+ };
81
95
  case "select":
82
96
  return { options: field.options.options, placeholder: field.options.placeholder };
83
97
  case "number":
@@ -95,6 +109,14 @@ const specificProps = computed(() => {
95
109
  return { presets: field.options.presets };
96
110
  case "url":
97
111
  return { placeholder: field.options.placeholder };
112
+ case "phone":
113
+ return {
114
+ placeholder: field.options.placeholder,
115
+ countries: field.options.countries,
116
+ defaultCountry: field.options.defaultCountry,
117
+ detectCountry: field.options.detectCountry,
118
+ autocomplete: field.options.autocomplete
119
+ };
98
120
  default: {
99
121
  const options = field.options;
100
122
  return {
@@ -0,0 +1,22 @@
1
+ import type { PhoneFieldOptions } from '../../types/field.js';
2
+ type __VLS_Props = {
3
+ id: string;
4
+ modelValue?: unknown;
5
+ label: string;
6
+ description?: string;
7
+ required?: boolean;
8
+ placeholder?: string;
9
+ countries?: PhoneFieldOptions['countries'];
10
+ defaultCountry?: PhoneFieldOptions['defaultCountry'];
11
+ detectCountry?: boolean;
12
+ autocomplete?: PhoneFieldOptions['autocomplete'];
13
+ errors?: string[];
14
+ disabled?: boolean;
15
+ };
16
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
17
+ "update:modelValue": (value: string) => any;
18
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
19
+ "onUpdate:modelValue"?: ((value: string) => any) | undefined;
20
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
21
+ declare const _default: typeof __VLS_export;
22
+ export default _default;
@@ -0,0 +1,61 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import EPFormField from "../ui/EPFormField.vue";
4
+ import EPInputText from "../ui/EPInputText.vue";
5
+ import { normalizeEponymePhone } from "../../utils/normalize-phone";
6
+ const props = defineProps({
7
+ id: { type: String, required: true },
8
+ modelValue: { type: null, required: false },
9
+ label: { type: String, required: true },
10
+ description: { type: String, required: false },
11
+ required: { type: Boolean, required: false },
12
+ placeholder: { type: String, required: false },
13
+ countries: { type: Array, required: false },
14
+ defaultCountry: { type: String, required: false },
15
+ detectCountry: { type: Boolean, required: false },
16
+ autocomplete: { type: String, required: false },
17
+ errors: { type: Array, required: false, default: () => [] },
18
+ disabled: { type: Boolean, required: false }
19
+ });
20
+ const emit = defineEmits(["update:modelValue"]);
21
+ const value = computed(() => typeof props.modelValue === "string" ? props.modelValue : "");
22
+ const options = computed(() => ({
23
+ countries: props.countries,
24
+ defaultCountry: props.defaultCountry,
25
+ detectCountry: props.detectCountry
26
+ }));
27
+ const parsed = computed(() => normalizeEponymePhone(value.value, options.value));
28
+ function normalize() {
29
+ const next = parsed.value.e164;
30
+ if (next && next !== value.value) emit("update:modelValue", next);
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <EPFormField
36
+ :id="id"
37
+ :label="label"
38
+ :description="description"
39
+ :required="required"
40
+ :errors="errors"
41
+ >
42
+ <EPInputText
43
+ :id="id"
44
+ :model-value="value"
45
+ type="tel"
46
+ :autocomplete="autocomplete"
47
+ :placeholder="placeholder"
48
+ :required="required"
49
+ :invalid="Boolean(errors.length)"
50
+ :disabled="disabled"
51
+ @update:model-value="emit('update:modelValue', String($event))"
52
+ @blur="normalize"
53
+ />
54
+ <p
55
+ v-if="parsed.e164 && parsed.country"
56
+ class="ep:mt-1.5 ep:mb-0 ep:text-[11px] ep:text-muted-ep"
57
+ >
58
+ {{ parsed.country }} · {{ parsed.e164 }}
59
+ </p>
60
+ </EPFormField>
61
+ </template>