@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.
- package/README.md +51 -39
- package/dist/eponyme.mjs +2 -3
- package/dist/module.d.mts +94 -13
- package/dist/module.json +1 -1
- package/dist/module.mjs +23 -6
- package/dist/runtime/assets/icon.ico +0 -0
- package/dist/runtime/assets/logo.png +0 -0
- package/dist/runtime/assets.d.ts +9 -0
- package/dist/runtime/components/editor/EponymeFolderNav.vue +1 -8
- package/dist/runtime/components/editor/EponymeFormPage.vue +2 -2
- package/dist/runtime/components/editor/EponymePreviewPanel.vue +4 -3
- package/dist/runtime/components/editor/EponymeSidebar.vue +7 -2
- package/dist/runtime/components/fields/FieldRenderer.vue +11 -0
- package/dist/runtime/components/fields/PhoneField.d.vue.ts +22 -0
- package/dist/runtime/components/fields/PhoneField.vue +61 -0
- package/dist/runtime/components/fields/PhoneField.vue.d.ts +22 -0
- package/dist/runtime/components/ui/EPLinkEditor.vue +2 -1
- package/dist/runtime/composables/useEponyme.d.ts +22 -14
- package/dist/runtime/composables/useEponyme.js +5 -3
- package/dist/runtime/composables/useEponymeCollection.js +6 -4
- package/dist/runtime/composables/useEponymeFavicon.d.ts +1 -0
- package/dist/runtime/composables/useEponymeFavicon.js +14 -0
- package/dist/runtime/composables/useEponymeForm.d.ts +0 -4
- package/dist/runtime/composables/useEponymeForm.js +1 -8
- package/dist/runtime/fields/index.d.ts +2 -0
- package/dist/runtime/fields/index.js +2 -1
- package/dist/runtime/fields/phone.d.ts +2 -0
- package/dist/runtime/fields/phone.js +3 -0
- package/dist/runtime/middleware/eponyme-auth.d.ts +1 -1
- package/dist/runtime/middleware/eponyme-owner.d.ts +1 -1
- package/dist/runtime/pages/EponymeChangePasswordPage.vue +2 -0
- package/dist/runtime/pages/EponymeDetailPage.vue +2 -0
- package/dist/runtime/pages/EponymeIndexPage.vue +2 -0
- package/dist/runtime/pages/EponymeLoginPage.vue +9 -4
- package/dist/runtime/pages/EponymeUsersPage.vue +2 -0
- package/dist/runtime/server/api/eponyme-forms/[path].post.js +0 -15
- package/dist/runtime/server/middleware/eponyme-no-store.js +4 -1
- package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
- package/dist/runtime/server/services/eponyme-form-store.js +4 -3
- package/dist/runtime/server/services/eponyme-store.d.ts +11 -0
- package/dist/runtime/server/services/eponyme-store.js +36 -6
- package/dist/runtime/server/utils/eponyme-cache.d.ts +2 -14
- package/dist/runtime/server/utils/eponyme-cache.js +1 -6
- package/dist/runtime/types/eponyme-config.d.ts +0 -7
- package/dist/runtime/types/eponyme.d.ts +2 -5
- package/dist/runtime/types/field.d.ts +49 -4
- package/dist/runtime/types/index.d.ts +0 -1
- package/dist/runtime/types/index.js +0 -1
- package/dist/runtime/utils/cache-tags.d.ts +46 -0
- package/dist/runtime/utils/cache-tags.js +28 -0
- package/dist/runtime/utils/hydration-cache.d.ts +10 -0
- package/dist/runtime/utils/hydration-cache.js +3 -0
- package/dist/runtime/utils/middle-ellipsis.d.ts +1 -0
- package/dist/runtime/utils/middle-ellipsis.js +8 -0
- package/dist/runtime/utils/normalize-eponyme-phones.d.ts +9 -0
- package/dist/runtime/utils/normalize-eponyme-phones.js +38 -0
- package/dist/runtime/utils/normalize-phone.d.ts +26 -0
- package/dist/runtime/utils/normalize-phone.js +17 -0
- package/dist/runtime/utils/preview.d.ts +10 -0
- package/dist/runtime/utils/preview.js +7 -2
- package/dist/runtime/utils/validate-eponyme-data.js +29 -3
- package/package.json +3 -2
- package/dist/runtime/server/utils/eponyme-captcha.d.ts +0 -6
- package/dist/runtime/server/utils/eponyme-captcha.js +0 -14
- package/dist/runtime/types/captcha.d.ts +0 -16
- package/dist/runtime/types/captcha.js +0 -0
- package/dist/runtime/utils/no-captcha.d.ts +0 -7
- 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 {
|
|
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
|
|
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:
|
|
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 {
|
|
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:
|
|
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:
|
|
46
|
+
cache: isPublicContent ? void 0 : "no-store"
|
|
45
47
|
}),
|
|
46
|
-
{ getCachedData:
|
|
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;
|
|
@@ -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
|
-
|
|
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 };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: import("
|
|
1
|
+
declare const _default: import("#app").RouteMiddleware;
|
|
2
2
|
export default _default;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: import("
|
|
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
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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,
|
|
41
|
-
const unknownKeys = Object.keys(
|
|
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 =
|
|
78
|
+
const persisted = isPlainObject(value) ? value : {};
|
|
68
79
|
const defaults = createDefaultEponymeData(schema);
|
|
69
80
|
return Object.fromEntries(Object.keys(schema).map((key) => {
|
|
70
|
-
const
|
|
71
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ? {
|