@karibsen/eponyme 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +51 -39
  2. package/dist/eponyme.mjs +2 -3
  3. package/dist/module.d.mts +94 -13
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +14 -6
  6. package/dist/runtime/components/editor/EponymeFolderNav.vue +1 -8
  7. package/dist/runtime/components/editor/EponymeFormPage.vue +2 -2
  8. package/dist/runtime/components/editor/EponymeSidebar.vue +7 -2
  9. package/dist/runtime/components/fields/FieldRenderer.vue +11 -0
  10. package/dist/runtime/components/fields/PhoneField.d.vue.ts +22 -0
  11. package/dist/runtime/components/fields/PhoneField.vue +61 -0
  12. package/dist/runtime/components/fields/PhoneField.vue.d.ts +22 -0
  13. package/dist/runtime/components/ui/EPLinkEditor.vue +2 -1
  14. package/dist/runtime/composables/useEponymeForm.d.ts +0 -4
  15. package/dist/runtime/composables/useEponymeForm.js +1 -8
  16. package/dist/runtime/fields/index.d.ts +2 -0
  17. package/dist/runtime/fields/index.js +2 -1
  18. package/dist/runtime/fields/phone.d.ts +2 -0
  19. package/dist/runtime/fields/phone.js +3 -0
  20. package/dist/runtime/middleware/eponyme-auth.d.ts +1 -1
  21. package/dist/runtime/middleware/eponyme-owner.d.ts +1 -1
  22. package/dist/runtime/server/api/eponyme-forms/[path].post.js +0 -15
  23. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  24. package/dist/runtime/server/services/eponyme-form-store.js +4 -3
  25. package/dist/runtime/server/services/eponyme-store.d.ts +11 -0
  26. package/dist/runtime/server/services/eponyme-store.js +36 -6
  27. package/dist/runtime/types/eponyme-config.d.ts +0 -7
  28. package/dist/runtime/types/eponyme.d.ts +2 -5
  29. package/dist/runtime/types/field.d.ts +49 -4
  30. package/dist/runtime/types/index.d.ts +0 -1
  31. package/dist/runtime/types/index.js +0 -1
  32. package/dist/runtime/utils/middle-ellipsis.d.ts +1 -0
  33. package/dist/runtime/utils/middle-ellipsis.js +8 -0
  34. package/dist/runtime/utils/normalize-eponyme-phones.d.ts +9 -0
  35. package/dist/runtime/utils/normalize-eponyme-phones.js +38 -0
  36. package/dist/runtime/utils/normalize-phone.d.ts +26 -0
  37. package/dist/runtime/utils/normalize-phone.js +17 -0
  38. package/dist/runtime/utils/validate-eponyme-data.js +29 -3
  39. package/package.json +3 -2
  40. package/dist/runtime/server/utils/eponyme-captcha.d.ts +0 -6
  41. package/dist/runtime/server/utils/eponyme-captcha.js +0 -14
  42. package/dist/runtime/types/captcha.d.ts +0 -16
  43. package/dist/runtime/types/captcha.js +0 -0
  44. package/dist/runtime/utils/no-captcha.d.ts +0 -7
  45. package/dist/runtime/utils/no-captcha.js +0 -5
@@ -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,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, [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;
@@ -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';
@@ -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";
@@ -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" && !urlPattern.test(href)) addError(errors, name, "External links must be HTTP(S) URLs.");
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 ((definition.type === "email" || definition.options.email) && normalized && (!localPart || !domain?.includes(".") || extraParts.length)) addError(errors, name, "Must be a valid email address.");
183
- if (definition.options.url && normalized && !urlPattern.test(normalized)) addError(errors, name, "Must be an HTTP(S) URL.");
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(/&nbsp;|&#160;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/\s+/g, " ").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
- "version": "0.6.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;
@@ -1,5 +0,0 @@
1
- const verifier = {
2
- name: "none",
3
- verify: async () => ({ success: false, reason: "No captcha adapter is installed." })
4
- };
5
- export default verifier;