@karibsen/eponyme 0.6.0 → 0.7.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 (45) hide show
  1. package/README.md +51 -39
  2. package/dist/eponyme.mjs +2 -3
  3. package/dist/module.d.mts +94 -13
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +14 -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 +11 -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/ui/EPLinkEditor.vue +2 -1
  14. package/dist/runtime/composables/useEponymeForm.d.ts +0 -4
  15. package/dist/runtime/composables/useEponymeForm.js +1 -8
  16. package/dist/runtime/fields/index.d.ts +2 -0
  17. package/dist/runtime/fields/index.js +2 -1
  18. package/dist/runtime/fields/phone.d.ts +2 -0
  19. package/dist/runtime/fields/phone.js +3 -0
  20. package/dist/runtime/middleware/eponyme-auth.d.ts +1 -1
  21. package/dist/runtime/middleware/eponyme-owner.d.ts +1 -1
  22. package/dist/runtime/server/api/eponyme-forms/[path].post.js +0 -15
  23. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  24. package/dist/runtime/server/services/eponyme-form-store.js +4 -3
  25. package/dist/runtime/server/services/eponyme-store.d.ts +11 -0
  26. package/dist/runtime/server/services/eponyme-store.js +36 -6
  27. package/dist/runtime/types/eponyme-config.d.ts +0 -7
  28. package/dist/runtime/types/eponyme.d.ts +2 -5
  29. package/dist/runtime/types/field.d.ts +49 -4
  30. package/dist/runtime/types/index.d.ts +0 -1
  31. package/dist/runtime/types/index.js +0 -1
  32. package/dist/runtime/utils/middle-ellipsis.d.ts +1 -0
  33. package/dist/runtime/utils/middle-ellipsis.js +8 -0
  34. package/dist/runtime/utils/normalize-eponyme-phones.d.ts +9 -0
  35. package/dist/runtime/utils/normalize-eponyme-phones.js +38 -0
  36. package/dist/runtime/utils/normalize-phone.d.ts +26 -0
  37. package/dist/runtime/utils/normalize-phone.js +17 -0
  38. package/dist/runtime/utils/validate-eponyme-data.js +29 -3
  39. package/package.json +3 -2
  40. package/dist/runtime/server/utils/eponyme-captcha.d.ts +0 -6
  41. package/dist/runtime/server/utils/eponyme-captcha.js +0 -14
  42. package/dist/runtime/types/captcha.d.ts +0 -16
  43. package/dist/runtime/types/captcha.js +0 -0
  44. package/dist/runtime/utils/no-captcha.d.ts +0 -7
  45. 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, 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,39 @@ field.string({
152
169
  })
153
170
  ```
154
171
 
172
+ ### Phone numbers
173
+
174
+ `field.phone()` stores its value in **E.164** — `+33611131143` — whatever format it was typed
175
+ in. Normalisation happens on the server before the write, so the stored format is a guarantee
176
+ rather than a convention a client could skip.
177
+
178
+ ```ts
179
+ phone: field.phone({
180
+ label: 'Phone',
181
+ // ISO 3166-1 alpha-2, autocompleted from libphonenumber-js
182
+ countries: ['FR', 'BE'],
183
+ // Country a number typed without `+` belongs to
184
+ defaultCountry: 'FR',
185
+ autocomplete: 'tel',
186
+ placeholder: '06 11 13 11 43',
187
+ required: true,
188
+ })
189
+ ```
190
+
191
+ | Option | Effect |
192
+ |---|---|
193
+ | `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 |
194
+ | `defaultCountry` | Country assumed for a number written without an international prefix. Without it, only `+…` numbers can be understood |
195
+ | `detectCountry` | `false` requires the international `+…` form, so the country is never guessed. Defaults to `true` |
196
+ | `autocomplete` | `tel`, `tel-national` or `tel-country-code`, set on the input |
197
+ | `defaultValue`, `required`, `placeholder`, `label`, `description` | As on every field |
198
+
199
+ Validation uses the `min` metadata bundle of `libphonenumber-js`, a fraction of the size of the
200
+ full one. It parses and formats every country, and is slightly more permissive: a few numbers
201
+ that are impossible for their region still pass.
202
+
203
+ A phone field is also allowed in a public `form()`.
204
+
155
205
  ## Prisma
156
206
 
157
207
  Eponyme uses the Prisma client owned by your application. It does not create the connection or run migrations.
@@ -740,44 +790,6 @@ Two protections apply to the public submission route:
740
790
  - a **body size limit**, 64 KB by default, configurable with `maxBodyBytes`. An oversized
741
791
  body is refused with `413` before it is parsed.
742
792
 
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
793
  Rate limiting and file uploads are not implemented yet.
782
794
 
783
795
  ## 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,118 @@ 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
+ * How long a browser may reuse published content, in seconds. `0` disables it.
102
+ *
103
+ * @remarks
104
+ * A browser cache cannot be purged: a visitor holding a copy keeps it until it expires, whatever
105
+ * is published meanwhile.
106
+ *
107
+ * @default 30
108
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
33
109
  */
34
110
  browserCacheSeconds?: number;
35
111
  /**
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.
112
+ * How long a CDN may reuse published content, in seconds. `0` disables it.
113
+ *
114
+ * @remarks
115
+ * Sent as `s-maxage`, with `stale-while-revalidate` twelve times longer. Cached responses carry
116
+ * cache tags, so a publication can purge them from the `eponyme:entry:published` hook.
117
+ *
118
+ * @default 300
119
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
39
120
  */
40
121
  cdnCacheSeconds?: number;
41
122
  }
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.7.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",
@@ -64,6 +76,7 @@ const module$1 = defineNuxtModule({
64
76
  async setup(options, nuxt) {
65
77
  const logger = useLogger("eponyme");
66
78
  const resolver = createResolver(import.meta.url);
79
+ await warnOnIncompatibleNuxtUi(nuxt, logger);
67
80
  const dashboardPath = `/${(options.dashboardPath || "__eponyme").replace(/^\/+|\/+$/g, "")}`;
68
81
  const publicRuntimeConfig = nuxt.options.runtimeConfig.public;
69
82
  publicRuntimeConfig.eponyme = {
@@ -150,7 +163,6 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
150
163
  nuxt.options.alias["#eponyme/prisma"] = prismaPath;
151
164
  const variablesPath = await findPath(resolve(nuxt.options.rootDir, "eponyme.variables.ts"));
152
165
  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
166
  addTypeTemplate({
155
167
  filename: "types/eponyme-config.d.ts",
156
168
  getContents: () => `declare module '#eponyme/config' {
@@ -161,10 +173,6 @@ declare module '#eponyme/variables' {
161
173
  const variables: typeof import(${JSON.stringify(nuxt.options.alias["#eponyme/variables"])})['default']
162
174
  export default variables
163
175
  }
164
- declare module '#eponyme/captcha' {
165
- const verifier: import('${resolver.resolve("./runtime/types/captcha")}').EponymeCaptchaVerifier
166
- export default verifier
167
- }
168
176
  `
169
177
  });
170
178
  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,6 +5,7 @@ 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";
@@ -59,6 +60,8 @@ const component = computed(() => {
59
60
  return ColorField;
60
61
  case "url":
61
62
  return UrlField;
63
+ case "phone":
64
+ return PhoneField;
62
65
  default:
63
66
  return TextField;
64
67
  }
@@ -95,6 +98,14 @@ const specificProps = computed(() => {
95
98
  return { presets: field.options.presets };
96
99
  case "url":
97
100
  return { placeholder: field.options.placeholder };
101
+ case "phone":
102
+ return {
103
+ placeholder: field.options.placeholder,
104
+ countries: field.options.countries,
105
+ defaultCountry: field.options.defaultCountry,
106
+ detectCountry: field.options.detectCountry,
107
+ autocomplete: field.options.autocomplete
108
+ };
98
109
  default: {
99
110
  const options = field.options;
100
111
  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>
@@ -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;
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  import { computed, ref } from "vue";
3
+ import { middleEllipsis } from "../../utils/middle-ellipsis";
3
4
  import EPButton from "./EPButton.vue";
4
5
  import EPLinkDialog from "./EPLinkDialog.vue";
5
6
  import EPTooltip from "./EPTooltip.vue";
@@ -51,7 +52,7 @@ function openLink() {
51
52
  />
52
53
  <div class="ep:min-w-0 ep:flex-1">
53
54
  <p class="ep:m-0 ep:truncate ep:text-sm ep:text-white">
54
- {{ value.href || "No link configured" }}
55
+ {{ value.href ? middleEllipsis(value.href, 40) : "No link configured" }}
55
56
  </p>
56
57
  <p class="ep:mt-0.5 ep:mb-0 ep:text-[11px] ep:text-muted-ep">
57
58
  {{ value.type === "internal" ? "Internal" : "External" }} · {{ value.download ? "Download" : value.openInNewTab ? "New tab" : "Same tab" }}
@@ -24,10 +24,6 @@ export interface UseEponymeFormResult<Data extends Record<string, unknown>> {
24
24
  submitted: Ref<boolean>;
25
25
  /** Name of the trap field to render hidden, or `false` when disabled. */
26
26
  honeypot: string | false;
27
- /** True when the form declares `captcha: true`; render a widget and fill `captchaToken`. */
28
- requiresCaptcha: boolean;
29
- /** Bind this to the captcha widget, for example `<NuxtTurnstile v-model="captchaToken" />`. */
30
- captchaToken: Ref<string>;
31
27
  submit: () => Promise<boolean>;
32
28
  reset: () => void;
33
29
  }
@@ -10,14 +10,12 @@ function resolveForm(name) {
10
10
  if (!definition) throw new Error(`[Eponyme] Unknown form "${name}". Declare it with form() in eponyme.config.ts.`);
11
11
  return definition;
12
12
  }
13
- const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
14
13
  export function useEponymeForm(name, options = {}) {
15
14
  const definition = resolveForm(name);
16
15
  const requestFetch = useRequestFetch();
17
16
  const serverErrors = ref({});
18
17
  const pending = ref(false);
19
18
  const submitted = ref(false);
20
- const captchaToken = ref("");
21
19
  const values = ref(createDefaultEponymeData(definition.fields));
22
20
  const validation = useEponymeValidation(definition.fields, values, ref({}), { serverErrors });
23
21
  const fields = computed(() => Object.entries(definition.fields).map(([fieldName, fieldDefinition]) => ({
@@ -35,7 +33,6 @@ export function useEponymeForm(name, options = {}) {
35
33
  values.value = createDefaultEponymeData(definition.fields);
36
34
  serverErrors.value = {};
37
35
  submitted.value = false;
38
- captchaToken.value = "";
39
36
  validation.reset();
40
37
  }
41
38
  async function submit() {
@@ -52,9 +49,7 @@ export function useEponymeForm(name, options = {}) {
52
49
  console.warn(`[Eponyme] Form "${name}" is managed, so its onSubmit handler is ignored.`);
53
50
  await requestFetch(`/api/eponyme-forms/${name}`, {
54
51
  method: "POST",
55
- // The token travels beside the fields, never as one of them: it is
56
- // transport, and the schema only knows about declared fields.
57
- body: definition.captcha ? { ...values.value, [CAPTCHA_TOKEN_KEY]: captchaToken.value } : values.value
52
+ body: values.value
58
53
  });
59
54
  }
60
55
  submitted.value = true;
@@ -77,8 +72,6 @@ export function useEponymeForm(name, options = {}) {
77
72
  pending,
78
73
  submitted,
79
74
  honeypot: definition.honeypot,
80
- requiresCaptcha: definition.captcha,
81
- captchaToken,
82
75
  submit,
83
76
  reset
84
77
  };
@@ -6,6 +6,7 @@ import { date, today } from './date.js';
6
6
  import { email } from './email.js';
7
7
  import { image } from './image.js';
8
8
  import { number } from './number.js';
9
+ import { phone } from './phone.js';
9
10
  import { radio } from './radio.js';
10
11
  import { richText } from './rich-text.js';
11
12
  import { section } from './section.js';
@@ -19,6 +20,7 @@ export declare const field: {
19
20
  string: typeof string;
20
21
  slug: typeof slug;
21
22
  email: typeof email;
23
+ phone: typeof phone;
22
24
  url: typeof url;
23
25
  textarea: typeof textarea;
24
26
  richText: typeof richText;
@@ -6,6 +6,7 @@ import { date, today } from "./date.js";
6
6
  import { email } from "./email.js";
7
7
  import { image } from "./image.js";
8
8
  import { number } from "./number.js";
9
+ import { phone } from "./phone.js";
9
10
  import { radio } from "./radio.js";
10
11
  import { richText } from "./rich-text.js";
11
12
  import { section } from "./section.js";
@@ -15,5 +16,5 @@ import { string } from "./string.js";
15
16
  import { tab } from "./tab.js";
16
17
  import { textarea } from "./textarea.js";
17
18
  import { url } from "./url.js";
18
- export const field = { string, slug, email, url, textarea, richText, number, boolean, image, select, radio, checkboxGroup, date, color, array, section, tab };
19
+ export const field = { string, slug, email, phone, url, textarea, richText, number, boolean, image, select, radio, checkboxGroup, date, color, array, section, tab };
19
20
  export { today };
@@ -0,0 +1,2 @@
1
+ import type { PhoneFieldDefinition, PhoneFieldOptions } from '../types/field.js';
2
+ export declare function phone(options?: PhoneFieldOptions): PhoneFieldDefinition;
@@ -0,0 +1,3 @@
1
+ export function phone(options = {}) {
2
+ return { type: "phone", options };
3
+ }
@@ -1,2 +1,2 @@
1
- declare const _default: import("nuxt/app").RouteMiddleware;
1
+ declare const _default: import("#app").RouteMiddleware;
2
2
  export default _default;