@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.
- 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 +14 -6
- package/dist/runtime/components/editor/EponymeFolderNav.vue +1 -8
- package/dist/runtime/components/editor/EponymeFormPage.vue +2 -2
- 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/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/server/api/eponyme-forms/[path].post.js +0 -15
- 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/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/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/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,2 +1,2 @@
|
|
|
1
|
-
declare const _default: import("
|
|
1
|
+
declare const _default: import("#app").RouteMiddleware;
|
|
2
2
|
export default _default;
|
|
@@ -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,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;
|
|
@@ -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 ? {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function middleEllipsis(value: string, maxLength?: number): string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function middleEllipsis(value, maxLength = 28) {
|
|
2
|
+
const characters = [...value];
|
|
3
|
+
if (characters.length <= maxLength) return value;
|
|
4
|
+
const available = maxLength - 1;
|
|
5
|
+
const startLength = Math.ceil(available / 2);
|
|
6
|
+
const endLength = Math.floor(available / 2);
|
|
7
|
+
return `${characters.slice(0, startLength).join("")}\u2026${characters.slice(-endLength).join("")}`;
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EponymeSchema } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Rewrites every phone value in a payload to E.164, wherever it sits in the schema.
|
|
4
|
+
*
|
|
5
|
+
* Applied on the server before a write, which is what makes the stored format a guarantee
|
|
6
|
+
* rather than a convention: the API accepts any JSON, so a client normalising on its own could
|
|
7
|
+
* always be bypassed. Values that cannot be parsed are left as typed for validation to reject.
|
|
8
|
+
*/
|
|
9
|
+
export declare function normalizeEponymePhones(schema: EponymeSchema, data: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isArrayItemFieldDefinition } from "./get-field-default-value.js";
|
|
2
|
+
import { toEponymePhoneValue } from "./normalize-phone.js";
|
|
3
|
+
export function normalizeEponymePhones(schema, data) {
|
|
4
|
+
const result = { ...data };
|
|
5
|
+
for (const [name, definition] of Object.entries(schema)) {
|
|
6
|
+
if (!(name in result)) continue;
|
|
7
|
+
result[name] = normalizeField(definition, result[name]);
|
|
8
|
+
}
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
function normalizeField(definition, value) {
|
|
12
|
+
if (definition.type === "phone") return toEponymePhoneValue(value, definition.options);
|
|
13
|
+
if (definition.type === "section")
|
|
14
|
+
return normalizeNested(definition.options.fields, value);
|
|
15
|
+
if (definition.type === "tabs") {
|
|
16
|
+
if (!isRecord(value)) return value;
|
|
17
|
+
const tabs = { ...value };
|
|
18
|
+
for (const [tabName, tab] of Object.entries(definition.options.tabs))
|
|
19
|
+
if (tabName in tabs) tabs[tabName] = normalizeNested(tab.fields, tabs[tabName]);
|
|
20
|
+
return tabs;
|
|
21
|
+
}
|
|
22
|
+
if (definition.type === "array") {
|
|
23
|
+
if (!Array.isArray(value)) return value;
|
|
24
|
+
return value.map((item) => normalizeArrayItem(definition.options.of, item));
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function normalizeArrayItem(of, item) {
|
|
29
|
+
if (isArrayItemFieldDefinition(of)) return normalizeField(of, item);
|
|
30
|
+
return normalizeNested(of, item);
|
|
31
|
+
}
|
|
32
|
+
function normalizeNested(schema, value) {
|
|
33
|
+
if (!isRecord(value)) return value;
|
|
34
|
+
return normalizeEponymePhones(schema, value);
|
|
35
|
+
}
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
38
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PhoneFieldOptions } from '../types/field.js';
|
|
2
|
+
export interface NormalizedPhone {
|
|
3
|
+
/** The number in E.164, present only when it parsed and validated. */
|
|
4
|
+
e164?: string;
|
|
5
|
+
/** Country the number resolved to, present as soon as it parsed. */
|
|
6
|
+
country?: string;
|
|
7
|
+
valid: boolean;
|
|
8
|
+
/** Set when the number is valid but its country is not in `countries`. */
|
|
9
|
+
countryNotAllowed?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Single source of truth for what a phone field accepts, shared by the validation the server
|
|
13
|
+
* enforces, the editor field, and the write path that stores the value.
|
|
14
|
+
*
|
|
15
|
+
* The `min` metadata bundle is deliberate: it is a fraction of the size of the full one and
|
|
16
|
+
* parses and formats every country. The trade is precision — it accepts a few numbers the full
|
|
17
|
+
* metadata would reject as impossible for their region.
|
|
18
|
+
*
|
|
19
|
+
* @see https://gitlab.com/catamphetamine/libphonenumber-js#min-vs-max-vs-mobile-vs-core
|
|
20
|
+
*/
|
|
21
|
+
export declare function normalizeEponymePhone(value: unknown, options?: PhoneFieldOptions): NormalizedPhone;
|
|
22
|
+
/**
|
|
23
|
+
* The value to store: E.164 whenever the number is understood, and the input untouched when it
|
|
24
|
+
* is not, so validation can report it and the editor can still see what was typed.
|
|
25
|
+
*/
|
|
26
|
+
export declare function toEponymePhoneValue(value: unknown, options?: PhoneFieldOptions): unknown;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { parsePhoneNumberFromString } from "libphonenumber-js/min";
|
|
2
|
+
export function normalizeEponymePhone(value, options = {}) {
|
|
3
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
4
|
+
if (!raw) return { valid: true };
|
|
5
|
+
const international = raw.startsWith("+");
|
|
6
|
+
if (options.detectCountry === false && !international) return { valid: false };
|
|
7
|
+
const parsed = parsePhoneNumberFromString(raw, international ? void 0 : options.defaultCountry);
|
|
8
|
+
if (!parsed || !parsed.isValid()) return { valid: false, country: parsed?.country };
|
|
9
|
+
const allowed = options.countries?.length ? options.countries : void 0;
|
|
10
|
+
if (allowed && (!parsed.country || !allowed.includes(parsed.country)))
|
|
11
|
+
return { valid: false, country: parsed.country, countryNotAllowed: true };
|
|
12
|
+
return { valid: true, e164: parsed.number, country: parsed.country };
|
|
13
|
+
}
|
|
14
|
+
export function toEponymePhoneValue(value, options = {}) {
|
|
15
|
+
if (typeof value !== "string") return value;
|
|
16
|
+
return normalizeEponymePhone(value, options).e164 ?? value;
|
|
17
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isArrayItemFieldDefinition } from "./get-field-default-value.js";
|
|
2
2
|
import { isFieldVisible } from "./is-field-visible.js";
|
|
3
|
+
import { normalizeEponymePhone } from "./normalize-phone.js";
|
|
3
4
|
const urlPattern = /^https?:\/\//i;
|
|
4
5
|
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
5
6
|
const colorPattern = /^#(?:[\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/i;
|
|
@@ -134,7 +135,10 @@ function validateFieldRules(name, definition, value, errors, mode, _context) {
|
|
|
134
135
|
const href = link.href.trim();
|
|
135
136
|
if (mode === "publish" && options.required && !href) addError(errors, name, "This field is required.");
|
|
136
137
|
if (!href) return;
|
|
137
|
-
if (link.type === "external" && !
|
|
138
|
+
if (link.type === "external" && !isAllowedProtocol(href, definition.options.protocols)) {
|
|
139
|
+
const allowed = normalizeProtocols(definition.options.protocols);
|
|
140
|
+
addError(errors, name, isDefaultProtocols(definition.options.protocols) ? "External links must be HTTP(S) URLs." : `External links must start with ${allowed.map((protocol) => `${protocol}:`).join(" or ")}.`);
|
|
141
|
+
}
|
|
138
142
|
if (link.type === "internal" && !href.startsWith("/") && !href.startsWith("#")) addError(errors, name, "Internal links must start with / or #.");
|
|
139
143
|
return;
|
|
140
144
|
}
|
|
@@ -160,6 +164,16 @@ function validateFieldRules(name, definition, value, errors, mode, _context) {
|
|
|
160
164
|
if (definition.options.max && normalized > definition.options.max) addError(errors, name, `Must be on or before ${definition.options.max}.`);
|
|
161
165
|
return;
|
|
162
166
|
}
|
|
167
|
+
if (definition.type === "phone") {
|
|
168
|
+
const phone = normalizeEponymePhone(normalized, definition.options);
|
|
169
|
+
if (normalized && phone.countryNotAllowed) {
|
|
170
|
+
const accepted = definition.options.countries?.join(", ");
|
|
171
|
+
addError(errors, name, phone.country ? `Numbers from ${phone.country} are not accepted. Use a number from: ${accepted}.` : `Must be a number from: ${accepted}.`);
|
|
172
|
+
} else if (normalized && !phone.valid) {
|
|
173
|
+
addError(errors, name, definition.options.detectCountry === false ? "Must be a valid phone number in international format, starting with the country code." : "Must be a valid phone number.");
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
163
177
|
if (definition.type === "color") {
|
|
164
178
|
if (normalized && !colorPattern.test(normalized)) addError(errors, name, "Must be a valid hex color.");
|
|
165
179
|
return;
|
|
@@ -179,8 +193,20 @@ function validateFieldRules(name, definition, value, errors, mode, _context) {
|
|
|
179
193
|
if (definition.options.maxLength !== void 0 && value.length > definition.options.maxLength) addError(errors, name, `Must contain at most ${definition.options.maxLength} characters.`);
|
|
180
194
|
if (definition.options.regex && !definition.options.regex.test(value)) addError(errors, name, "Has an invalid format.");
|
|
181
195
|
const [localPart, domain, ...extraParts] = normalized.split("@");
|
|
182
|
-
if (
|
|
183
|
-
|
|
196
|
+
if (definition.type === "email" && normalized && (!localPart || !domain?.includes(".") || extraParts.length)) addError(errors, name, "Must be a valid email address.");
|
|
197
|
+
}
|
|
198
|
+
const DEFAULT_URL_PROTOCOLS = ["http", "https"];
|
|
199
|
+
const schemePattern = /^([a-z][a-z0-9+.-]*):/i;
|
|
200
|
+
function normalizeProtocols(protocols) {
|
|
201
|
+
const declared = protocols?.length ? protocols : DEFAULT_URL_PROTOCOLS;
|
|
202
|
+
return declared.map((protocol) => protocol.trim().toLowerCase().replace(/:.*$/, ""));
|
|
203
|
+
}
|
|
204
|
+
function isDefaultProtocols(protocols) {
|
|
205
|
+
return !protocols?.length;
|
|
206
|
+
}
|
|
207
|
+
function isAllowedProtocol(href, protocols) {
|
|
208
|
+
const scheme = schemePattern.exec(href)?.[1]?.toLowerCase();
|
|
209
|
+
return Boolean(scheme) && normalizeProtocols(protocols).includes(scheme);
|
|
184
210
|
}
|
|
185
211
|
function getRichTextContent(value) {
|
|
186
212
|
return value.replace(/<br\s*\/?>/gi, " ").replace(/<\/p>|<\/h[1-6]>|<\/li>|<\/blockquote>/gi, " ").replace(/<[^>]+>/g, "").replace(/ | /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/\s+/g, " ").trim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@karibsen/eponyme",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A lightweight, type-safe content manager for Nuxt.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@iconify-json/mingcute": "^1.2.7",
|
|
63
|
+
"@karibsen/ui": "latest",
|
|
63
64
|
"@nuxt/icon": "^2.3.1",
|
|
64
65
|
"@nuxt/kit": "^4.4.8",
|
|
65
|
-
"@karibsen/ui": "latest",
|
|
66
66
|
"@tailwindcss/vite": "^4.1.12",
|
|
67
67
|
"@tanstack/vue-table": "^8.21.3",
|
|
68
68
|
"@tiptap/extension-image": "^3.29.2",
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"@vueuse/core": "^14.3.0",
|
|
73
73
|
"@vueuse/integrations": "^14.3.0",
|
|
74
74
|
"fuse.js": "^7.5.0",
|
|
75
|
+
"libphonenumber-js": "^1.13.9",
|
|
75
76
|
"picocolors": "^1.1.1",
|
|
76
77
|
"reka-ui": "^2.10.1",
|
|
77
78
|
"slugify": "^1.6.9",
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import type { H3Event } from 'h3';
|
|
2
|
-
import type { EponymeCaptchaResult, EponymeCaptchaVerifier } from '../../types/captcha.js';
|
|
3
|
-
export declare const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
|
|
4
|
-
export declare function eponymeCaptchaVerifier(): EponymeCaptchaVerifier;
|
|
5
|
-
export declare function isEponymeCaptchaConfigured(): boolean;
|
|
6
|
-
export declare function verifyEponymeCaptcha(event: H3Event, token: unknown): Promise<EponymeCaptchaResult>;
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { getRequestIP } from "h3";
|
|
2
|
-
import captchaVerifier from "#eponyme/captcha";
|
|
3
|
-
export const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
|
|
4
|
-
export function eponymeCaptchaVerifier() {
|
|
5
|
-
return captchaVerifier;
|
|
6
|
-
}
|
|
7
|
-
export function isEponymeCaptchaConfigured() {
|
|
8
|
-
return eponymeCaptchaVerifier().name !== "none";
|
|
9
|
-
}
|
|
10
|
-
export async function verifyEponymeCaptcha(event, token) {
|
|
11
|
-
if (typeof token !== "string" || !token.trim())
|
|
12
|
-
return { success: false, reason: "Missing captcha token." };
|
|
13
|
-
return eponymeCaptchaVerifier().verify(token, { ip: getRequestIP(event, { xForwardedFor: true }) });
|
|
14
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
export interface EponymeCaptchaResult {
|
|
2
|
-
success: boolean;
|
|
3
|
-
/** Provider-specific reason, surfaced in server logs rather than to the visitor. */
|
|
4
|
-
reason?: string;
|
|
5
|
-
}
|
|
6
|
-
/**
|
|
7
|
-
* Verifies the token a visitor's captcha widget produced. Implemented by an adapter
|
|
8
|
-
* package such as `@karibsen/eponyme-captcha`; the core ships none, so a project
|
|
9
|
-
* without an adapter simply has no captcha.
|
|
10
|
-
*/
|
|
11
|
-
export interface EponymeCaptchaVerifier {
|
|
12
|
-
name: string;
|
|
13
|
-
verify: (token: string, context: {
|
|
14
|
-
ip?: string;
|
|
15
|
-
}) => Promise<EponymeCaptchaResult>;
|
|
16
|
-
}
|
|
File without changes
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { EponymeCaptchaVerifier } from '../types/captcha.js';
|
|
2
|
-
/**
|
|
3
|
-
* Stands in when no adapter is installed. It never claims success, so a form that
|
|
4
|
-
* requires a captcha refuses submissions rather than silently accepting them all.
|
|
5
|
-
*/
|
|
6
|
-
declare const verifier: EponymeCaptchaVerifier;
|
|
7
|
-
export default verifier;
|