@karibsen/eponyme 0.5.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 (68) 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 +23 -6
  6. package/dist/runtime/assets/icon.ico +0 -0
  7. package/dist/runtime/assets/logo.png +0 -0
  8. package/dist/runtime/assets.d.ts +9 -0
  9. package/dist/runtime/components/editor/EponymeFolderNav.vue +1 -8
  10. package/dist/runtime/components/editor/EponymeFormPage.vue +2 -2
  11. package/dist/runtime/components/editor/EponymePreviewPanel.vue +4 -3
  12. package/dist/runtime/components/editor/EponymeSidebar.vue +7 -2
  13. package/dist/runtime/components/fields/FieldRenderer.vue +11 -0
  14. package/dist/runtime/components/fields/PhoneField.d.vue.ts +22 -0
  15. package/dist/runtime/components/fields/PhoneField.vue +61 -0
  16. package/dist/runtime/components/fields/PhoneField.vue.d.ts +22 -0
  17. package/dist/runtime/components/ui/EPLinkEditor.vue +2 -1
  18. package/dist/runtime/composables/useEponyme.d.ts +22 -14
  19. package/dist/runtime/composables/useEponyme.js +5 -3
  20. package/dist/runtime/composables/useEponymeCollection.js +6 -4
  21. package/dist/runtime/composables/useEponymeFavicon.d.ts +1 -0
  22. package/dist/runtime/composables/useEponymeFavicon.js +14 -0
  23. package/dist/runtime/composables/useEponymeForm.d.ts +0 -4
  24. package/dist/runtime/composables/useEponymeForm.js +1 -8
  25. package/dist/runtime/fields/index.d.ts +2 -0
  26. package/dist/runtime/fields/index.js +2 -1
  27. package/dist/runtime/fields/phone.d.ts +2 -0
  28. package/dist/runtime/fields/phone.js +3 -0
  29. package/dist/runtime/middleware/eponyme-auth.d.ts +1 -1
  30. package/dist/runtime/middleware/eponyme-owner.d.ts +1 -1
  31. package/dist/runtime/pages/EponymeChangePasswordPage.vue +2 -0
  32. package/dist/runtime/pages/EponymeDetailPage.vue +2 -0
  33. package/dist/runtime/pages/EponymeIndexPage.vue +2 -0
  34. package/dist/runtime/pages/EponymeLoginPage.vue +9 -4
  35. package/dist/runtime/pages/EponymeUsersPage.vue +2 -0
  36. package/dist/runtime/server/api/eponyme-forms/[path].post.js +0 -15
  37. package/dist/runtime/server/middleware/eponyme-no-store.js +4 -1
  38. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  39. package/dist/runtime/server/services/eponyme-form-store.js +4 -3
  40. package/dist/runtime/server/services/eponyme-store.d.ts +11 -0
  41. package/dist/runtime/server/services/eponyme-store.js +36 -6
  42. package/dist/runtime/server/utils/eponyme-cache.d.ts +2 -14
  43. package/dist/runtime/server/utils/eponyme-cache.js +1 -6
  44. package/dist/runtime/types/eponyme-config.d.ts +0 -7
  45. package/dist/runtime/types/eponyme.d.ts +2 -5
  46. package/dist/runtime/types/field.d.ts +49 -4
  47. package/dist/runtime/types/index.d.ts +0 -1
  48. package/dist/runtime/types/index.js +0 -1
  49. package/dist/runtime/utils/cache-tags.d.ts +46 -0
  50. package/dist/runtime/utils/cache-tags.js +28 -0
  51. package/dist/runtime/utils/hydration-cache.d.ts +10 -0
  52. package/dist/runtime/utils/hydration-cache.js +3 -0
  53. package/dist/runtime/utils/middle-ellipsis.d.ts +1 -0
  54. package/dist/runtime/utils/middle-ellipsis.js +8 -0
  55. package/dist/runtime/utils/normalize-eponyme-phones.d.ts +9 -0
  56. package/dist/runtime/utils/normalize-eponyme-phones.js +38 -0
  57. package/dist/runtime/utils/normalize-phone.d.ts +26 -0
  58. package/dist/runtime/utils/normalize-phone.js +17 -0
  59. package/dist/runtime/utils/preview.d.ts +10 -0
  60. package/dist/runtime/utils/preview.js +7 -2
  61. package/dist/runtime/utils/validate-eponyme-data.js +29 -3
  62. package/package.json +3 -2
  63. package/dist/runtime/server/utils/eponyme-captcha.d.ts +0 -6
  64. package/dist/runtime/server/utils/eponyme-captcha.js +0 -14
  65. package/dist/runtime/types/captcha.d.ts +0 -16
  66. package/dist/runtime/types/captcha.js +0 -0
  67. package/dist/runtime/utils/no-captcha.d.ts +0 -7
  68. package/dist/runtime/utils/no-captcha.js +0 -5
@@ -1,7 +1,7 @@
1
1
  import { clearNuxtData, useAsyncData, useRequestFetch, useRoute } from "#app";
2
2
  import { computed, ref } from "vue";
3
3
  import { readPreviewQuery, readPreviewVersion } from "../utils/preview.js";
4
- import { cacheDuringHydrationOnly } from "../utils/hydration-cache.js";
4
+ import { cacheForPublicRead } from "../utils/hydration-cache.js";
5
5
  export function useEponyme(name, options = {}) {
6
6
  const route = useRoute();
7
7
  const preview = readPreviewQuery(route.query);
@@ -10,11 +10,13 @@ export function useEponyme(name, options = {}) {
10
10
  const saving = ref(false);
11
11
  const requestFetch = useRequestFetch();
12
12
  const cacheKey = `eponyme:${name}:${version}${options.raw ? ":raw" : ""}`;
13
- const fetchCache = version === "published" && !options.raw ? void 0 : "no-store";
13
+ const isPublicContent = version === "published" && !options.raw;
14
+ const fetchCache = isPublicContent ? void 0 : "no-store";
15
+ const isPreviewRead = options.version === void 0 && !isPublicContent;
14
16
  const { data: response, pending: loading, error, refresh: load } = useAsyncData(
15
17
  cacheKey,
16
18
  () => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: fetchCache }),
17
- { getCachedData: cacheDuringHydrationOnly }
19
+ { server: !isPreviewRead, getCachedData: cacheForPublicRead(isPublicContent) }
18
20
  );
19
21
  const data = computed(() => response.value?.data);
20
22
  const status = computed(() => response.value?.status ?? "published");
@@ -2,7 +2,7 @@ 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
- import { cacheDuringHydrationOnly } from "../utils/hydration-cache.js";
5
+ import { cacheForPublicRead } from "../utils/hydration-cache.js";
6
6
  export function useEponymeCollection(name, options = {}) {
7
7
  const requestFetch = useRequestFetch();
8
8
  const query = {
@@ -17,7 +17,7 @@ export function useEponymeCollection(name, options = {}) {
17
17
  `eponyme:collection:public:${name}:${options.take ?? ""}:${options.skip ?? ""}:${options.orderBy ?? ""}:${options.order ?? ""}`,
18
18
  // Always the published listing, which is publicly cacheable: the browser cache may answer it.
19
19
  () => requestFetch(`/api/eponyme-collections/${name}`, { query }),
20
- { getCachedData: cacheDuringHydrationOnly }
20
+ { getCachedData: cacheForPublicRead(true) }
21
21
  );
22
22
  const entries = computed(() => result.data.value?.entries ?? []);
23
23
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
@@ -36,14 +36,16 @@ export function useEponymeCollectionEntry(name, slug, options = {}) {
36
36
  const preview = readPreviewQuery(route.query);
37
37
  const version = options.version ?? (preview.entry === `${name}/${slug}` ? readPreviewVersion(preview.version) : "published");
38
38
  const requestFetch = useRequestFetch();
39
+ const isPublicContent = version === "published";
40
+ const isPreviewRead = options.version === void 0 && !isPublicContent;
39
41
  const result = useAsyncData(
40
42
  `eponyme:collection-entry:${name}:${slug}:${version}`,
41
43
  () => requestFetch(`/api/eponyme/${name}/${encodeURIComponent(slug)}`, {
42
44
  query: { version },
43
45
  // A draft or a historical version must never be stored, whatever the server says.
44
- cache: version === "published" ? void 0 : "no-store"
46
+ cache: isPublicContent ? void 0 : "no-store"
45
47
  }),
46
- { getCachedData: cacheDuringHydrationOnly }
48
+ { server: !isPreviewRead, getCachedData: cacheForPublicRead(isPublicContent) }
47
49
  );
48
50
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
49
51
  return {
@@ -0,0 +1 @@
1
+ export declare function useEponymeFavicon(): void;
@@ -0,0 +1,14 @@
1
+ import { useHead } from "#app";
2
+ import iconUrl from "../assets/icon.ico?url";
3
+ export function useEponymeFavicon() {
4
+ useHead({
5
+ link: [
6
+ {
7
+ key: "eponyme-favicon",
8
+ rel: "icon",
9
+ type: "image/x-icon",
10
+ href: iconUrl
11
+ }
12
+ ]
13
+ });
14
+ }
@@ -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;
@@ -1,2 +1,2 @@
1
- declare const _default: import("nuxt/app").RouteMiddleware;
1
+ declare const _default: import("#app").RouteMiddleware;
2
2
  export default _default;
@@ -2,9 +2,11 @@
2
2
  import { navigateTo, useRuntimeConfig, useSeoMeta } from "#app";
3
3
  import { computed, ref } from "vue";
4
4
  import { useEponymeAuth } from "../composables/useEponymeAuth";
5
+ import { useEponymeFavicon } from "../composables/useEponymeFavicon";
5
6
  import EPButton from "../components/ui/EPButton.vue";
6
7
  import EPFormField from "../components/ui/EPFormField.vue";
7
8
  import EPInputText from "../components/ui/EPInputText.vue";
9
+ useEponymeFavicon();
8
10
  useSeoMeta({ title: "Change password \xB7 Eponyme" });
9
11
  const auth = useEponymeAuth();
10
12
  const currentPassword = ref("");
@@ -9,6 +9,7 @@ import EponymeFolderPage from "../components/editor/EponymeFolderPage.vue";
9
9
  import EponymePageNavigation from "../components/editor/EponymePageNavigation.vue";
10
10
  import EponymeSidebar from "../components/editor/EponymeSidebar.vue";
11
11
  import { useEponymeConfig } from "../composables/useEponymeConfig";
12
+ import { useEponymeFavicon } from "../composables/useEponymeFavicon";
12
13
  import { getEponymeCollections, getEponymeForms, getEponymeSchemas } from "../utils/get-eponyme-schemas";
13
14
  const route = useRoute();
14
15
  const config = useEponymeConfig();
@@ -29,6 +30,7 @@ const isFolder = computed(() => !schemas[eponymeName.value] && !collection.value
29
30
  function label(name) {
30
31
  return name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
31
32
  }
33
+ useEponymeFavicon();
32
34
  useSeoMeta({
33
35
  title: () => `${label(eponymeName.value.split("/").at(-1) ?? eponymeName.value)} \xB7 Eponyme`
34
36
  });
@@ -8,6 +8,7 @@ import EPDialog from "../components/ui/EPDialog.vue";
8
8
  import EPToast from "../components/ui/EPToast.vue";
9
9
  import { useEponymeAuth } from "../composables/useEponymeAuth";
10
10
  import { useEponymeConfig } from "../composables/useEponymeConfig";
11
+ import { useEponymeFavicon } from "../composables/useEponymeFavicon";
11
12
  import { getEponymeCollections, getEponymeForms, getEponymeSchemas } from "../utils/get-eponyme-schemas";
12
13
  const config = useEponymeConfig();
13
14
  const route = useRoute();
@@ -17,6 +18,7 @@ const eponymes = getEponymeSchemas(config);
17
18
  const collections = getEponymeCollections(config);
18
19
  const forms = getEponymeForms(config);
19
20
  const entries = { ...eponymes, ...collections, ...forms };
21
+ useEponymeFavicon();
20
22
  useSeoMeta({ title: "Eponyme" });
21
23
  const fileInput = ref();
22
24
  const exporting = ref(false);
@@ -2,15 +2,18 @@
2
2
  import { navigateTo, useRoute, useRuntimeConfig, useSeoMeta } from "#app";
3
3
  import { computed, ref } from "vue";
4
4
  import { useEponymeAuth } from "../composables/useEponymeAuth";
5
+ import { useEponymeFavicon } from "../composables/useEponymeFavicon";
5
6
  import EPButton from "../components/ui/EPButton.vue";
6
7
  import EPFormField from "../components/ui/EPFormField.vue";
7
8
  import EPInputText from "../components/ui/EPInputText.vue";
9
+ import logoUrl from "../assets/logo.png?url";
8
10
  const route = useRoute();
9
11
  const auth = useEponymeAuth();
10
12
  const username = ref("");
11
13
  const password = ref("");
12
14
  const error = ref("");
13
15
  const dashboardPath = computed(() => useRuntimeConfig().public.eponyme?.dashboardPath ?? "/__eponyme");
16
+ useEponymeFavicon();
14
17
  useSeoMeta({ title: "Sign in \xB7 Eponyme" });
15
18
  const existingUser = await auth.refresh();
16
19
  if (existingUser)
@@ -37,10 +40,12 @@ async function navigateAfterLogin(mustChangePassword) {
37
40
  <template>
38
41
  <main class="eponyme-root ep:flex ep:min-h-screen ep:items-center ep:justify-center ep:bg-theme-ep ep:px-6 ep:py-12 ep:font-sans ep:text-text-ep">
39
42
  <section class="ep:w-full ep:max-w-sm">
40
- <p class="ep:m-0 ep:text-[11px] ep:font-semibold ep:tracking-widest ep:text-muted-ep ep:uppercase">
41
- Eponyme
42
- </p>
43
- <h1 class="ep:mt-2 ep:mb-2 ep:text-3xl ep:font-semibold ep:tracking-tight ep:text-white">
43
+ <img
44
+ :src="logoUrl"
45
+ alt="Eponyme"
46
+ class="ep:block ep:size-14"
47
+ >
48
+ <h1 class="ep:mt-4 ep:mb-2 ep:text-3xl ep:font-semibold ep:tracking-tight ep:text-white">
44
49
  Sign in
45
50
  </h1>
46
51
  <p class="ep:mt-0 ep:mb-8 ep:text-sm ep:leading-relaxed ep:text-muted-ep">
@@ -3,6 +3,7 @@ import { useRequestFetch, useRoute, useSeoMeta } from "#app";
3
3
  import { computed, ref } from "vue";
4
4
  import EponymeCopy from "../components/editor/EponymeCopy.vue";
5
5
  import EponymeSidebar from "../components/editor/EponymeSidebar.vue";
6
+ import { useEponymeFavicon } from "../composables/useEponymeFavicon";
6
7
  import EPButton from "../components/ui/EPButton.vue";
7
8
  import EPDialog from "../components/ui/EPDialog.vue";
8
9
  import EPFormField from "../components/ui/EPFormField.vue";
@@ -11,6 +12,7 @@ import EPSelect from "../components/ui/EPSelect.vue";
11
12
  import EPSwitch from "../components/ui/EPSwitch.vue";
12
13
  import EPTooltip from "../components/ui/EPTooltip.vue";
13
14
  import EPAvatar from "../components/ui/EPAvatar.vue";
15
+ useEponymeFavicon();
14
16
  useSeoMeta({ title: "Users \xB7 Eponyme" });
15
17
  const route = useRoute();
16
18
  const requestFetch = useRequestFetch();
@@ -1,7 +1,6 @@
1
1
  import { createError, defineEventHandler, getRequestHeader, readRawBody, setResponseHeader, setResponseStatus } from "h3";
2
2
  import { useEponymeFormService } from "../../services/eponyme-form-service.js";
3
3
  import { readEponymeFormRoute } from "../../utils/form-route.js";
4
- import { CAPTCHA_TOKEN_KEY, isEponymeCaptchaConfigured, verifyEponymeCaptcha } from "../../utils/eponyme-captcha.js";
5
4
  import { callEponymeBlockingHook, callEponymeHook } from "../../utils/eponyme-hooks.js";
6
5
  export default defineEventHandler(async (event) => {
7
6
  const route = readEponymeFormRoute(event);
@@ -27,20 +26,6 @@ export default defineEventHandler(async (event) => {
27
26
  setResponseStatus(event, 201);
28
27
  return { submitted: true };
29
28
  }
30
- if (definition.captcha) {
31
- if (!isEponymeCaptchaConfigured()) {
32
- throw createError({
33
- statusCode: 500,
34
- statusMessage: `Form "${route.name}" requires a captcha but no adapter is installed. Add one, such as @karibsen/eponyme-captcha.`
35
- });
36
- }
37
- const token = payload && typeof payload === "object" ? payload[CAPTCHA_TOKEN_KEY] : void 0;
38
- const result2 = await verifyEponymeCaptcha(event, token);
39
- if (!result2.success) {
40
- setResponseStatus(event, 422);
41
- return { errors: { _form: ["Captcha verification failed. Please try again."] } };
42
- }
43
- }
44
29
  const validated = service.validate(route.name, payload);
45
30
  if (validated && "errors" in validated) {
46
31
  setResponseStatus(event, 422);
@@ -1,6 +1,9 @@
1
1
  import { defineEventHandler, getRequestURL } from "h3";
2
2
  import { setEponymePrivateCache } from "../utils/eponyme-cache.js";
3
+ import { EPONYME_PREVIEW_QUERY } from "../../utils/preview.js";
3
4
  export default defineEventHandler((event) => {
4
- if (!getRequestURL(event).pathname.startsWith("/api/eponyme")) return;
5
+ const url = getRequestURL(event);
6
+ if (url.searchParams.has(EPONYME_PREVIEW_QUERY)) return setEponymePrivateCache(event);
7
+ if (!url.pathname.startsWith("/api/eponyme")) return;
5
8
  setEponymePrivateCache(event);
6
9
  });
@@ -1,5 +1,6 @@
1
1
  import type { EponymeConfig, EponymeFormDefinitionBase } from '../../types/index.js';
2
2
  import { type ValidationErrors } from '../../utils/validate-eponyme-data.js';
3
+ /** Kept here rather than imported, so the store stays free of H3 imports. */
3
4
  type DateValue = Date | string;
4
5
  export interface EponymeFormSubmission {
5
6
  id: string;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { normalizeEponymePhones } from "../../utils/normalize-eponyme-phones.js";
2
3
  import { getEponymeForms } from "../../utils/get-eponyme-schemas.js";
3
4
  import { validateEponymeData } from "../../utils/validate-eponyme-data.js";
4
- const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
5
5
  const DEFAULT_PER_PAGE = 25;
6
6
  const MAX_PER_PAGE = 100;
7
7
  export class EponymeFormService {
@@ -37,10 +37,11 @@ export class EponymeFormService {
37
37
  if (!payload || typeof payload !== "object" || Array.isArray(payload))
38
38
  return { errors: { _form: ["Body must be an object."] } };
39
39
  const input = payload;
40
- const { [definition.honeypot || ""]: _honeypot, [CAPTCHA_TOKEN_KEY]: _captcha, ...rest } = input;
41
- const unknownKeys = Object.keys(rest).filter((key) => !(key in definition.fields));
40
+ const { [definition.honeypot || ""]: _honeypot, ...submitted } = input;
41
+ const unknownKeys = Object.keys(submitted).filter((key) => !(key in definition.fields));
42
42
  if (unknownKeys.length)
43
43
  return { errors: Object.fromEntries(unknownKeys.map((key) => [key, ["Unknown field."]])) };
44
+ const rest = normalizeEponymePhones(definition.fields, submitted);
44
45
  const errors = validateEponymeData(definition.fields, rest, "publish");
45
46
  if (Object.keys(errors).length) return { errors };
46
47
  return { data: rest };
@@ -233,7 +233,18 @@ export declare class EponymeService {
233
233
  private getCollectionEntry;
234
234
  /** Reconcile every configured eponyme at application startup. This is the one place that heals. */
235
235
  syncAll(): Promise<void>;
236
+ /**
237
+ * Brings a stored document back in line with the schema: keys the schema no longer declares
238
+ * are dropped, and a key it declares but the document lacks takes its default.
239
+ *
240
+ * Recursive, because a field added inside a section, a tab or an array item is otherwise
241
+ * never filled in: the container it lives in already exists and is still valid, so a
242
+ * top-level pass keeps it untouched and the new field reads as missing forever. That was
243
+ * silent — `defaultValue` simply did nothing for anything but a root field.
244
+ */
236
245
  private reconcile;
246
+ /** Descends into the containers; a leaf is returned untouched for `reconcile` to judge. */
247
+ private reconcileField;
237
248
  private createState;
238
249
  private normalizeState;
239
250
  /**
@@ -1,10 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { createDefaultEponymeData } from "../../utils/create-default-eponyme-data.js";
4
+ import { isArrayItemFieldDefinition } from "../../utils/get-field-default-value.js";
4
5
  import { getEponymeCollections, getEponymeSchemas } from "../../utils/get-eponyme-schemas.js";
5
6
  import { normalizeSlug } from "../../utils/normalize-slug.js";
6
7
  import { applyPreviewSlug } from "../../utils/preview.js";
7
8
  import { validateEponymeData, validateEponymePatch } from "../../utils/validate-eponyme-data.js";
9
+ import { normalizeEponymePhones } from "../../utils/normalize-eponyme-phones.js";
8
10
  export const COLLECTION_METADATA_KEYS = ["updatedAt", "publishedAt", "title", "slug"];
9
11
  export class EponymeService {
10
12
  constructor(config, client, options = {}) {
@@ -63,15 +65,43 @@ export class EponymeService {
63
65
  async syncAll() {
64
66
  await Promise.all(Object.keys(this.schemas).map((name) => this.loadState(name, { heal: true })));
65
67
  }
68
+ /**
69
+ * Brings a stored document back in line with the schema: keys the schema no longer declares
70
+ * are dropped, and a key it declares but the document lacks takes its default.
71
+ *
72
+ * Recursive, because a field added inside a section, a tab or an array item is otherwise
73
+ * never filled in: the container it lives in already exists and is still valid, so a
74
+ * top-level pass keeps it untouched and the new field reads as missing forever. That was
75
+ * silent — `defaultValue` simply did nothing for anything but a root field.
76
+ */
66
77
  reconcile(schema, value, mode) {
67
- const persisted = value && typeof value === "object" && !Array.isArray(value) ? value : {};
78
+ const persisted = isPlainObject(value) ? value : {};
68
79
  const defaults = createDefaultEponymeData(schema);
69
80
  return Object.fromEntries(Object.keys(schema).map((key) => {
70
- const candidate = key in persisted ? persisted[key] : defaults[key];
71
- const errors = validateEponymeData({ [key]: schema[key] }, { [key]: candidate }, mode);
81
+ const definition = schema[key];
82
+ const candidate = key in persisted ? this.reconcileField(definition, persisted[key], mode) : defaults[key];
83
+ const errors = validateEponymeData({ [key]: definition }, { [key]: candidate }, mode);
72
84
  return [key, Object.keys(errors).length ? defaults[key] : candidate];
73
85
  }));
74
86
  }
87
+ /** Descends into the containers; a leaf is returned untouched for `reconcile` to judge. */
88
+ reconcileField(definition, value, mode) {
89
+ if (definition.type === "section")
90
+ return isPlainObject(value) ? this.reconcile(definition.options.fields, value, mode) : value;
91
+ if (definition.type === "tabs") {
92
+ if (!isPlainObject(value)) return value;
93
+ return Object.fromEntries(Object.entries(definition.options.tabs).map(([tabName, tab]) => [
94
+ tabName,
95
+ this.reconcile(tab.fields, value[tabName], mode)
96
+ ]));
97
+ }
98
+ if (definition.type === "array") {
99
+ const of = definition.options.of;
100
+ if (!Array.isArray(value) || isArrayItemFieldDefinition(of)) return value;
101
+ return value.map((item) => isPlainObject(item) ? this.reconcile(of, item, mode) : item);
102
+ }
103
+ return value;
104
+ }
75
105
  createState(schema, value) {
76
106
  const data = this.reconcile(schema, value ?? createDefaultEponymeData(schema), "publish");
77
107
  return {
@@ -386,7 +416,7 @@ export class EponymeService {
386
416
  const taken = await this.slugTakenError(definition, entryName);
387
417
  if (taken) return taken;
388
418
  const defaults = createDefaultEponymeData(definition.fields);
389
- const data = { ...defaults, ...input, [definition.slugField]: slug };
419
+ const data = normalizeEponymePhones(definition.fields, { ...defaults, ...input, [definition.slugField]: slug });
390
420
  const errors = validateEponymePatch(definition.fields, data, data, "draft");
391
421
  if (Object.keys(errors).length) return { errors };
392
422
  const state = {
@@ -494,8 +524,8 @@ export class EponymeService {
494
524
  if (submittedSlug !== collectionEntry.slug)
495
525
  return { errors: { [collectionEntry.definition.slugField]: ["The slug cannot be changed after creation."] } };
496
526
  }
497
- const data = { ...state.__eponyme.draft, ...payload };
498
- const patchErrors = validateEponymePatch(schema, payload, data, action);
527
+ const data = normalizeEponymePhones(schema, { ...state.__eponyme.draft, ...payload });
528
+ const patchErrors = validateEponymePatch(schema, normalizeEponymePhones(schema, payload), data, action);
499
529
  const errors = action === "publish" ? mergeErrors(patchErrors, validateEponymeData(schema, data, "publish")) : patchErrors;
500
530
  if (Object.keys(errors).length) return { errors };
501
531
  const publishedAt = action === "publish" ? (/* @__PURE__ */ new Date()).toISOString() : state.__eponyme.publishedAt;
@@ -1,18 +1,6 @@
1
1
  import type { H3Event } from 'h3';
2
- /**
3
- * Cache tags for one entry, most specific first.
4
- *
5
- * A CDN cannot usually be purged by URL — Vercel and Cloudflare both purge by tag — and one
6
- * entry appears in more than one cached response: its own, and its collection's listing.
7
- * Tagging both is what lets publishing an article drop the article *and* the index that
8
- * lists it.
9
- *
10
- * A comma is the delimiter every CDN uses for this header, so it is stripped rather than
11
- * escaped: a tag containing one would silently become two.
12
- */
13
- export declare function getEponymeCacheTags(name: string, collection?: {
14
- name: string;
15
- } | string): string[];
2
+ /** Re-exported so server code keeps one import for everything cache-related. */
3
+ export { getEponymeCacheTags } from '../../utils/cache-tags.js';
16
4
  /**
17
5
  * Published content is identical for every visitor, so both the browser and the CDN may
18
6
  * hold on to it. The two windows are deliberately different:
@@ -1,12 +1,7 @@
1
1
  import { setResponseHeader } from "h3";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
3
  const STALE_WHILE_REVALIDATE_FACTOR = 12;
4
- export function getEponymeCacheTags(name, collection) {
5
- const collectionName = typeof collection === "string" ? collection : collection?.name;
6
- const tags = ["eponyme", `eponyme:${name}`];
7
- if (collectionName) tags.push(`eponyme:${collectionName}`);
8
- return [...new Set(tags)].map((tag) => tag.replace(/,/g, "").slice(0, 256));
9
- }
4
+ export { getEponymeCacheTags } from "../../utils/cache-tags.js";
10
5
  function setCacheTags(event, tags) {
11
6
  if (!tags.length) return;
12
7
  const value = tags.join(",");
@@ -16,10 +16,3 @@ declare module '#eponyme/variables' {
16
16
  const variables: EponymeVariables
17
17
  export default variables
18
18
  }
19
-
20
- declare module '#eponyme/captcha' {
21
- import type { EponymeCaptchaVerifier } from './captcha'
22
-
23
- const verifier: EponymeCaptchaVerifier
24
- export default verifier
25
- }
@@ -1,4 +1,4 @@
1
- import type { ArrayFieldDefinition, ArrayItemValue, BooleanFieldDefinition, CheckboxGroupFieldDefinition, EmailFieldDefinition, FieldDefinition, NumberFieldDefinition, RadioFieldDefinition, SectionFieldDefinition, SectionValue, SelectFieldDefinition, SlugFieldDefinition, StringFieldDefinition, TabFieldDefinition, TabsValue, TextareaFieldDefinition, UrlFieldDefinition, UrlValue } from './field.js';
1
+ import type { ArrayFieldDefinition, ArrayItemValue, BooleanFieldDefinition, CheckboxGroupFieldDefinition, EmailFieldDefinition, FieldDefinition, NumberFieldDefinition, PhoneFieldDefinition, RadioFieldDefinition, SectionFieldDefinition, SectionValue, SelectFieldDefinition, SlugFieldDefinition, StringFieldDefinition, TabFieldDefinition, TabsValue, TextareaFieldDefinition, UrlFieldDefinition, UrlValue } from './field.js';
2
2
  export type EponymeSchema = Record<string, FieldDefinition>;
3
3
  export interface EponymeCollectionDefinitionBase {
4
4
  __eponymeCollection: true;
@@ -26,7 +26,7 @@ export interface EponymeCollectionDefinition<T extends EponymeSchema = EponymeSc
26
26
  * `slug`, `richText`, `image`, `date`, `color`, `array`, `section` and `tabs` are
27
27
  * authoring concerns and are rejected by `form()`.
28
28
  */
29
- export type EponymeFormFieldDefinition = StringFieldDefinition | TextareaFieldDefinition | EmailFieldDefinition | UrlFieldDefinition | NumberFieldDefinition | BooleanFieldDefinition | SelectFieldDefinition | RadioFieldDefinition | CheckboxGroupFieldDefinition;
29
+ export type EponymeFormFieldDefinition = StringFieldDefinition | TextareaFieldDefinition | EmailFieldDefinition | PhoneFieldDefinition | UrlFieldDefinition | NumberFieldDefinition | BooleanFieldDefinition | SelectFieldDefinition | RadioFieldDefinition | CheckboxGroupFieldDefinition;
30
30
  export type EponymeFormSchema = Record<string, EponymeFormFieldDefinition>;
31
31
  /** `custom` leaves delivery to the host application; `managed` stores the submission. */
32
32
  export type EponymeFormMode = 'custom' | 'managed';
@@ -42,8 +42,6 @@ export interface EponymeFormOptions<T extends EponymeFormSchema = EponymeFormSch
42
42
  honeypot?: string | false;
43
43
  /** In bytes. */
44
44
  maxBodyBytes?: number;
45
- /** Requires a valid captcha token. Needs an adapter such as `@karibsen/eponyme-captcha`. */
46
- captcha?: boolean;
47
45
  }
48
46
  export interface EponymeFormDefinitionBase {
49
47
  __eponymeForm: true;
@@ -53,7 +51,6 @@ export interface EponymeFormDefinitionBase {
53
51
  submission: Required<EponymeFormSubmissionOptions>;
54
52
  honeypot: string | false;
55
53
  maxBodyBytes: number;
56
- captcha: boolean;
57
54
  }
58
55
  export interface EponymeFormDefinition<T extends EponymeFormSchema = EponymeFormSchema> extends EponymeFormDefinitionBase {
59
56
  fields: T;
@@ -1,4 +1,5 @@
1
- export type EponymeFieldType = 'string' | 'slug' | 'email' | 'url' | 'textarea' | 'richText' | 'number' | 'boolean' | 'image' | 'select' | 'radio' | 'checkboxGroup' | 'date' | 'color' | 'array' | 'section' | 'tabs';
1
+ import type { CountryCode } from 'libphonenumber-js/min';
2
+ export type EponymeFieldType = 'string' | 'slug' | 'email' | 'phone' | 'url' | 'textarea' | 'richText' | 'number' | 'boolean' | 'image' | 'select' | 'radio' | 'checkboxGroup' | 'date' | 'color' | 'array' | 'section' | 'tabs';
2
3
  export type FieldVisibilityCondition = {
3
4
  field: string;
4
5
  equals: unknown;
@@ -27,8 +28,6 @@ export interface TextFieldOptions extends DefaultFieldOptions<string> {
27
28
  placeholder?: string;
28
29
  trim?: boolean;
29
30
  regex?: RegExp;
30
- email?: boolean;
31
- url?: boolean;
32
31
  }
33
32
  export type StringFieldOptions = TextFieldOptions;
34
33
  export type TextareaFieldOptions = TextFieldOptions;
@@ -65,6 +64,37 @@ export interface EmailFieldDefinition {
65
64
  type: 'email';
66
65
  options: EmailFieldOptions;
67
66
  }
67
+ /**
68
+ * Countries a phone field accepts, as ISO 3166-1 alpha-2 codes. Re-exported so declaring a
69
+ * field autocompletes them without importing from `libphonenumber-js` in application code.
70
+ */
71
+ export type PhoneCountry = CountryCode;
72
+ export interface PhoneFieldOptions extends DefaultFieldOptions<string> {
73
+ placeholder?: string;
74
+ /**
75
+ * Countries whose numbers are accepted. A number resolving to any other country is refused.
76
+ * Omitted, every country is accepted.
77
+ */
78
+ countries?: PhoneCountry[];
79
+ /**
80
+ * Country a number typed without an international prefix belongs to. Without it, only
81
+ * `+…` numbers can be understood.
82
+ */
83
+ defaultCountry?: PhoneCountry;
84
+ /**
85
+ * Whether a number may be written in national form and resolved against `defaultCountry`.
86
+ * `false` requires the international `+…` form, so the country is never guessed.
87
+ *
88
+ * @default true
89
+ */
90
+ detectCountry?: boolean;
91
+ /** Value of the input's `autocomplete` attribute. */
92
+ autocomplete?: 'tel' | 'tel-national' | 'tel-country-code';
93
+ }
94
+ export interface PhoneFieldDefinition {
95
+ type: 'phone';
96
+ options: PhoneFieldOptions;
97
+ }
68
98
  export type UrlType = 'internal' | 'external';
69
99
  export interface UrlValue {
70
100
  href: string;
@@ -72,8 +102,23 @@ export interface UrlValue {
72
102
  openInNewTab: boolean;
73
103
  download?: boolean;
74
104
  }
105
+ /**
106
+ * Scheme of an external link, without the trailing colon. The common ones are suggested, and
107
+ * any other scheme is still accepted — `bitcoin`, `matrix` or an application scheme are valid
108
+ * choices this list has no business refusing.
109
+ */
110
+ export type UrlProtocol = 'http' | 'https' | 'mailto' | 'tel' | 'sms' | 'ftp' | (string & {});
75
111
  export interface UrlFieldOptions extends DefaultFieldOptions<UrlValue> {
76
112
  placeholder?: string;
113
+ /**
114
+ * Schemes an external link may use. `['https']` refuses plain `http`, and adding `mailto`
115
+ * or `tel` is what makes a contact link possible.
116
+ *
117
+ * Internal links are unaffected: they still have to start with `/` or `#`.
118
+ *
119
+ * @default ['http', 'https']
120
+ */
121
+ protocols?: UrlProtocol[];
77
122
  }
78
123
  export interface UrlFieldDefinition {
79
124
  type: 'url';
@@ -155,7 +200,7 @@ export interface ColorFieldDefinition {
155
200
  type: 'color';
156
201
  options: ColorFieldOptions;
157
202
  }
158
- export type ArrayItemFieldDefinition = StringFieldDefinition | SlugFieldDefinition | EmailFieldDefinition | UrlFieldDefinition | TextareaFieldDefinition | RichTextFieldDefinition | NumberFieldDefinition | BooleanFieldDefinition | ImageFieldDefinition | SelectFieldDefinition | RadioFieldDefinition | CheckboxGroupFieldDefinition | DateFieldDefinition | ColorFieldDefinition;
203
+ export type ArrayItemFieldDefinition = StringFieldDefinition | SlugFieldDefinition | EmailFieldDefinition | PhoneFieldDefinition | UrlFieldDefinition | TextareaFieldDefinition | RichTextFieldDefinition | NumberFieldDefinition | BooleanFieldDefinition | ImageFieldDefinition | SelectFieldDefinition | RadioFieldDefinition | CheckboxGroupFieldDefinition | DateFieldDefinition | ColorFieldDefinition;
159
204
  export type ArrayItemSchema = Record<string, ArrayItemFieldDefinition>;
160
205
  export type ArrayItemDefinition = ArrayItemFieldDefinition | ArrayItemSchema;
161
206
  export type ArrayItemValue<T extends ArrayItemDefinition> = T extends NumberFieldDefinition ? number : T extends BooleanFieldDefinition ? boolean : T extends UrlFieldDefinition ? UrlValue : T extends CheckboxGroupFieldDefinition<infer Value> ? Value[] : T extends ArrayItemFieldDefinition ? string : T extends ArrayItemSchema ? {
@@ -1,5 +1,4 @@
1
1
  export * from './auth.js';
2
2
  export * from './eponyme.js';
3
3
  export * from './field.js';
4
- export * from './captcha.js';
5
4
  export * from './variables.js';