@karibsen/eponyme 0.6.0 → 0.8.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 (74) hide show
  1. package/README.md +109 -45
  2. package/dist/eponyme.mjs +2 -3
  3. package/dist/module.d.mts +109 -13
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +19 -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 +22 -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/fields/RichTextField.vue +37 -1
  14. package/dist/runtime/components/fields/TagsField.d.vue.ts +21 -0
  15. package/dist/runtime/components/fields/TagsField.vue +55 -0
  16. package/dist/runtime/components/fields/TagsField.vue.d.ts +21 -0
  17. package/dist/runtime/components/ui/EPAlertDialog.d.vue.ts +37 -0
  18. package/dist/runtime/components/ui/EPAlertDialog.vue +66 -0
  19. package/dist/runtime/components/ui/EPAlertDialog.vue.d.ts +37 -0
  20. package/dist/runtime/components/ui/EPDialog.d.vue.ts +1 -0
  21. package/dist/runtime/components/ui/EPDialog.vue +6 -2
  22. package/dist/runtime/components/ui/EPDialog.vue.d.ts +1 -0
  23. package/dist/runtime/components/ui/EPLinkEditor.vue +2 -1
  24. package/dist/runtime/components/ui/EPTagsInput.d.vue.ts +27 -0
  25. package/dist/runtime/components/ui/EPTagsInput.vue +101 -0
  26. package/dist/runtime/components/ui/EPTagsInput.vue.d.ts +27 -0
  27. package/dist/runtime/composables/useEponymeCollection.d.ts +13 -1
  28. package/dist/runtime/composables/useEponymeCollection.js +29 -4
  29. package/dist/runtime/composables/useEponymeForm.d.ts +0 -4
  30. package/dist/runtime/composables/useEponymeForm.js +1 -8
  31. package/dist/runtime/fields/index.d.ts +4 -0
  32. package/dist/runtime/fields/index.js +3 -1
  33. package/dist/runtime/fields/phone.d.ts +2 -0
  34. package/dist/runtime/fields/phone.js +3 -0
  35. package/dist/runtime/fields/tags.d.ts +6 -0
  36. package/dist/runtime/fields/tags.js +3 -0
  37. package/dist/runtime/middleware/eponyme-auth.d.ts +1 -1
  38. package/dist/runtime/middleware/eponyme-owner.d.ts +1 -1
  39. package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
  40. package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
  41. package/dist/runtime/server/api/eponyme-forms/[path].post.js +0 -15
  42. package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
  43. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  44. package/dist/runtime/server/services/eponyme-form-store.js +4 -3
  45. package/dist/runtime/server/services/eponyme-store.d.ts +289 -30
  46. package/dist/runtime/server/services/eponyme-store.js +405 -73
  47. package/dist/runtime/server/utils/eponyme-reindex.d.ts +13 -0
  48. package/dist/runtime/server/utils/eponyme-reindex.js +4 -0
  49. package/dist/runtime/types/eponyme-config.d.ts +0 -7
  50. package/dist/runtime/types/eponyme.d.ts +54 -6
  51. package/dist/runtime/types/field.d.ts +82 -4
  52. package/dist/runtime/types/index.d.ts +0 -1
  53. package/dist/runtime/types/index.js +0 -1
  54. package/dist/runtime/utils/eponyme-entry-index.d.ts +39 -0
  55. package/dist/runtime/utils/eponyme-entry-index.js +51 -0
  56. package/dist/runtime/utils/get-field-default-value.js +2 -0
  57. package/dist/runtime/utils/middle-ellipsis.d.ts +1 -0
  58. package/dist/runtime/utils/middle-ellipsis.js +8 -0
  59. package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
  60. package/dist/runtime/utils/normalize-eponyme-values.js +40 -0
  61. package/dist/runtime/utils/normalize-phone.d.ts +26 -0
  62. package/dist/runtime/utils/normalize-phone.js +17 -0
  63. package/dist/runtime/utils/normalize-tags.d.ts +10 -0
  64. package/dist/runtime/utils/normalize-tags.js +17 -0
  65. package/dist/runtime/utils/validate-eponyme-data.js +45 -3
  66. package/dist/runtime/utils/variables.d.ts +4 -0
  67. package/dist/runtime/utils/variables.js +8 -1
  68. package/package.json +4 -2
  69. package/dist/runtime/server/utils/eponyme-captcha.d.ts +0 -6
  70. package/dist/runtime/server/utils/eponyme-captcha.js +0 -14
  71. package/dist/runtime/types/captcha.d.ts +0 -16
  72. package/dist/runtime/types/captcha.js +0 -0
  73. package/dist/runtime/utils/no-captcha.d.ts +0 -7
  74. package/dist/runtime/utils/no-captcha.js +0 -5
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Rebuilds the filterable index of every entry, unconditionally.
3
+ *
4
+ * Rarely needed: startup already rebuilds whatever a configuration change invalidated, so
5
+ * adding a `field.tags()` to a collection that already holds entries is handled on its own.
6
+ * This is the repair for what a fingerprint cannot see — editing a custom `validate` so that
7
+ * it now rejects values already stored, or an index suspected of having drifted.
8
+ *
9
+ * Auto-imported, so a host can expose it on a route or call it from a script.
10
+ */
11
+ export declare function reindexEponymeEntries(): Promise<{
12
+ entries: number;
13
+ }>;
@@ -0,0 +1,4 @@
1
+ import { useEponymeService } from "../services/eponyme-service.js";
2
+ export async function reindexEponymeEntries() {
3
+ return useEponymeService().reindexAll();
4
+ }
@@ -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, TagsFieldDefinition, TagsValueOf, 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;
@@ -68,7 +65,7 @@ export type FieldValue<T extends FieldDefinition> = T extends {
68
65
  type: 'number';
69
66
  } ? number : T extends {
70
67
  type: 'boolean';
71
- } ? boolean : T extends UrlFieldDefinition ? UrlValue : T extends ArrayFieldDefinition<infer Item> ? Array<ArrayItemValue<Item>> : T extends CheckboxGroupFieldDefinition<infer Value> ? Value[] : T extends SectionFieldDefinition<infer Section> ? SectionValue<Section> : T extends TabFieldDefinition<infer Tabs> ? TabsValue<Tabs> : string;
68
+ } ? boolean : T extends UrlFieldDefinition ? UrlValue : T extends ArrayFieldDefinition<infer Item> ? Array<ArrayItemValue<Item>> : T extends CheckboxGroupFieldDefinition<infer Value> ? Value[] : T extends TagsFieldDefinition<infer Options> ? Array<TagsValueOf<Options>> : T extends SectionFieldDefinition<infer Section> ? SectionValue<Section> : T extends TabFieldDefinition<infer Tabs> ? TabsValue<Tabs> : string;
72
69
  export type EponymeData<T extends EponymeSchema> = {
73
70
  [K in keyof T]: T[K] extends FieldDefinition ? FieldValue<T[K]> : never;
74
71
  };
@@ -77,6 +74,57 @@ export type EponymeCollectionName<T extends EponymeConfig> = {
77
74
  [K in keyof T & string]: T[K] extends EponymeCollectionDefinitionBase ? K : T[K] extends EponymeFormDefinitionBase ? never : T[K] extends EponymeSchema ? never : T[K] extends EponymeConfig ? `${K}/${EponymeCollectionName<T[K]>}` : never;
78
75
  }[keyof T & string];
79
76
  export type EponymeCollectionDataByName<T extends EponymeConfig, Name extends EponymeCollectionName<T>> = EponymeCollectionAtPath<T, Name> extends EponymeCollectionDefinition<infer Schema> ? EponymeData<Schema> : never;
77
+ /**
78
+ * Field types a `where` can filter on, mirroring the write path's `INDEXABLE_TYPES`.
79
+ *
80
+ * The two lists are separate because one runs on values and the other on types, but they
81
+ * describe the same rule: a filterable field is one whose stored form compares correctly as
82
+ * text. That is why `date` is here — validation pins it to `YYYY-MM-DD` — and `number` is
83
+ * not, since `'10'` sorts before `'9'`.
84
+ */
85
+ type FilterableFieldType = 'tags' | 'checkboxGroup' | 'select' | 'radio' | 'boolean' | 'date';
86
+ /** A multi-valued field is filtered by one of its items, not by the whole list. */
87
+ type FilterOperand<T extends FieldDefinition> = FieldValue<T> extends readonly (infer Item)[] ? Item : FieldValue<T>;
88
+ /**
89
+ * Bounds, offered only where alphabetical order is the value's natural order — which is
90
+ * `field.date()`, pinned by validation to `YYYY-MM-DD`. On a tag or a select, comparing
91
+ * with `gte` would sort labels, which answers no question anyone asks.
92
+ */
93
+ export interface EponymeFilterBounds {
94
+ gte?: string;
95
+ lte?: string;
96
+ gt?: string;
97
+ lt?: string;
98
+ }
99
+ type FilterOperators<T extends FieldDefinition> = {
100
+ /** Any of these, the same as passing the list directly. */
101
+ in?: readonly FilterOperand<T>[];
102
+ /** None of these. Combines with the positive operators to narrow then subtract. */
103
+ not?: FilterOperand<T> | readonly FilterOperand<T>[];
104
+ /**
105
+ * Substring of the stored value, case-insensitive.
106
+ *
107
+ * The only operator that cannot use the index's ordering — a leading wildcard scans it.
108
+ * That still reads a narrow table instead of every entry's content, but it does not
109
+ * scale the way the others do.
110
+ */
111
+ contains?: string;
112
+ } & (T extends {
113
+ type: 'date';
114
+ } ? EponymeFilterBounds : unknown);
115
+ /** The values of one key are ORed; the keys of a `where` are ANDed. */
116
+ export type EponymeFieldFilter<T extends FieldDefinition> = FilterOperand<T> | readonly FilterOperand<T>[] | FilterOperators<T>;
117
+ /**
118
+ * The filter a schema accepts, keyed by field name. Nested fields are left out: a field
119
+ * inside a section, a tab or an array has no single key to name it by, which is the same
120
+ * reason `orderBy` cannot reach one.
121
+ */
122
+ export type EponymeFilterInput<T extends EponymeSchema> = {
123
+ [K in keyof T as T[K] extends {
124
+ type: FilterableFieldType;
125
+ } ? K : never]?: T[K] extends FieldDefinition ? EponymeFieldFilter<T[K]> : never;
126
+ };
127
+ export type EponymeCollectionFilter<T extends EponymeConfig, Name extends EponymeCollectionName<T>> = EponymeCollectionAtPath<T, Name> extends EponymeCollectionDefinition<infer Schema> ? EponymeFilterInput<Schema> : never;
80
128
  export type EponymeName<T extends EponymeConfig> = {
81
129
  [K in keyof T & string]: T[K] extends EponymeCollectionDefinitionBase ? never : T[K] extends EponymeFormDefinitionBase ? never : T[K] extends EponymeSchema ? K : T[K] extends EponymeConfig ? `${K}/${EponymeName<T[K]>}` : never;
82
130
  }[keyof T & string];
@@ -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' | 'tags' | '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';
@@ -131,6 +176,39 @@ export interface CheckboxGroupFieldDefinition<T extends string = string> {
131
176
  type: 'checkboxGroup';
132
177
  options: CheckboxGroupFieldOptions<T>;
133
178
  }
179
+ export interface TagsFieldOptions<T extends string = string> extends DefaultFieldOptions<T[]> {
180
+ /** Offered while typing. With `allowCustom` off, they are also the only accepted values. */
181
+ suggestions?: readonly T[];
182
+ /**
183
+ * Whether a tag outside `suggestions` may be entered.
184
+ *
185
+ * @default false
186
+ */
187
+ allowCustom?: boolean;
188
+ minItems?: number;
189
+ maxItems?: number;
190
+ placeholder?: string;
191
+ }
192
+ /**
193
+ * Values a tags field may hold, read from the options as they were written: the suggestions
194
+ * themselves while the list is closed, any string once `allowCustom` opens it. A tag list is
195
+ * therefore never a mix of types.
196
+ *
197
+ * Read from the object rather than from a type parameter on purpose. A parameter carrying
198
+ * `allowCustom` is re-inferred as `boolean` when the config is checked against `EponymeConfig`,
199
+ * and a conditional on `boolean` distributes over `true | false`, collapsing the union of
200
+ * suggestions to plain `string`. The shape of the object survives that check — which is also
201
+ * how `field.select()` and `field.checkboxGroup()` keep their literals.
202
+ */
203
+ export type TagsValueOf<O> = O extends {
204
+ allowCustom: true;
205
+ } ? string : O extends {
206
+ suggestions: readonly (infer S extends string)[];
207
+ } ? S : string;
208
+ export interface TagsFieldDefinition<O extends TagsFieldOptions = TagsFieldOptions> {
209
+ type: 'tags';
210
+ options: O;
211
+ }
134
212
  export interface TodayDateDefault {
135
213
  readonly __eponymeDefault: 'today';
136
214
  }
@@ -155,7 +233,7 @@ export interface ColorFieldDefinition {
155
233
  type: 'color';
156
234
  options: ColorFieldOptions;
157
235
  }
158
- export type ArrayItemFieldDefinition = StringFieldDefinition | SlugFieldDefinition | EmailFieldDefinition | UrlFieldDefinition | TextareaFieldDefinition | RichTextFieldDefinition | NumberFieldDefinition | BooleanFieldDefinition | ImageFieldDefinition | SelectFieldDefinition | RadioFieldDefinition | CheckboxGroupFieldDefinition | DateFieldDefinition | ColorFieldDefinition;
236
+ export type ArrayItemFieldDefinition = StringFieldDefinition | SlugFieldDefinition | EmailFieldDefinition | PhoneFieldDefinition | UrlFieldDefinition | TextareaFieldDefinition | RichTextFieldDefinition | NumberFieldDefinition | BooleanFieldDefinition | ImageFieldDefinition | SelectFieldDefinition | RadioFieldDefinition | CheckboxGroupFieldDefinition | TagsFieldDefinition | DateFieldDefinition | ColorFieldDefinition;
159
237
  export type ArrayItemSchema = Record<string, ArrayItemFieldDefinition>;
160
238
  export type ArrayItemDefinition = ArrayItemFieldDefinition | ArrayItemSchema;
161
239
  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,39 @@
1
+ import type { EponymeSchema } from '../types/eponyme.js';
2
+ export interface EponymeIndexRow {
3
+ entryName: string;
4
+ version: 'draft' | 'published';
5
+ key: string;
6
+ value: string;
7
+ }
8
+ /** Rejecting a query on an unknown key beats silently returning the whole collection. */
9
+ export declare function eponymeIndexKeys(schema: EponymeSchema): string[];
10
+ export declare function isEponymeIndexKey(schema: EponymeSchema, key: string): boolean;
11
+ /**
12
+ * Deliberately not `schemaFingerprint`, which covers names and types only. That one is the
13
+ * import compatibility contract — widening it would start refusing imports over harmless
14
+ * edits. Here the opposite bias applies: `field.select()` losing an option, or
15
+ * `field.tags({ allowCustom })` being turned off, makes `reconcile` drop stored values, and
16
+ * `defaultValue` decides what an entry missing the field gets indexed with. All of that has
17
+ * to invalidate the index, and none of it changes a name or a type.
18
+ *
19
+ * `validate` and `visibleWhen` are functions, kept as their mere presence: a body cannot be
20
+ * hashed honestly, since a minifier would change it without changing behaviour. Editing a
21
+ * custom validator so that it rejects previously stored values therefore needs a manual
22
+ * `reindexEponymeEntries()`.
23
+ */
24
+ export declare function describeEponymeIndexSchema(schema: EponymeSchema): string;
25
+ /**
26
+ * Only top-level fields are covered, which is the same reach as `orderBy`: a field nested
27
+ * in a section, a tab or an array is not addressable by a single key, so neither sorting
28
+ * nor filtering can name one.
29
+ *
30
+ * Draft and published are indexed separately. The dashboard lists drafts and a public page
31
+ * lists published entries, and the two disagree on purpose — one index for both would make
32
+ * an unpublished tag leak into a public listing.
33
+ */
34
+ export declare function buildEponymeIndexRows(entryName: string, schema: EponymeSchema, versions: {
35
+ draft: Record<string, unknown>;
36
+ published: Record<string, unknown>;
37
+ }): EponymeIndexRow[];
38
+ /** The form both the write path and a query reduce a value to, so they always agree. */
39
+ export declare function foldEponymeIndexValue(value: string): string;
@@ -0,0 +1,51 @@
1
+ const INDEXABLE_TYPES = /* @__PURE__ */ new Set(["tags", "checkboxGroup", "select", "radio", "boolean", "date"]);
2
+ const INDEX_ALGORITHM = 1;
3
+ const COSMETIC_OPTION_KEYS = /* @__PURE__ */ new Set(["label", "description", "placeholder"]);
4
+ export function eponymeIndexKeys(schema) {
5
+ return Object.keys(schema).filter((name) => isIndexable(schema[name]));
6
+ }
7
+ export function isEponymeIndexKey(schema, key) {
8
+ return isIndexable(schema[key]);
9
+ }
10
+ function isIndexable(definition) {
11
+ return Boolean(definition && INDEXABLE_TYPES.has(definition.type));
12
+ }
13
+ export function describeEponymeIndexSchema(schema) {
14
+ const fields = Object.keys(schema).filter((name) => isIndexable(schema[name])).sort().map((name) => `${name}:${schema[name].type}:${stableDescribe(schema[name].options)}`);
15
+ return `v${INDEX_ALGORITHM}|${fields.join(",")}`;
16
+ }
17
+ function stableDescribe(value) {
18
+ if (typeof value === "function") return "fn";
19
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
20
+ if (Array.isArray(value)) return `[${value.map(stableDescribe).join(",")}]`;
21
+ const entries = Object.keys(value).filter((key) => !COSMETIC_OPTION_KEYS.has(key)).sort().map((key) => `${key}:${stableDescribe(value[key])}`);
22
+ return `{${entries.join(",")}}`;
23
+ }
24
+ export function buildEponymeIndexRows(entryName, schema, versions) {
25
+ const rows = /* @__PURE__ */ new Map();
26
+ for (const version of ["draft", "published"]) {
27
+ const data = versions[version];
28
+ for (const [key, definition] of Object.entries(schema)) {
29
+ if (!isIndexable(definition)) continue;
30
+ for (const value of indexValues(data[key])) {
31
+ rows.set(`${version}\0${key}\0${value}`, { entryName, version, key, value });
32
+ }
33
+ }
34
+ }
35
+ return [...rows.values()];
36
+ }
37
+ function indexValues(value) {
38
+ if (value === void 0 || value === null || value === "") return [];
39
+ if (typeof value === "boolean") return [String(value)];
40
+ if (typeof value === "string") return [fold(value)];
41
+ if (Array.isArray(value)) {
42
+ return value.flatMap((item) => typeof item === "string" && item.trim() ? [fold(item)] : []);
43
+ }
44
+ return [];
45
+ }
46
+ export function foldEponymeIndexValue(value) {
47
+ return fold(value);
48
+ }
49
+ function fold(value) {
50
+ return value.trim().toLocaleLowerCase();
51
+ }
@@ -17,6 +17,8 @@ export function getFieldDefaultValue(definition) {
17
17
  return [];
18
18
  case "checkboxGroup":
19
19
  return [];
20
+ case "tags":
21
+ return [];
20
22
  case "section":
21
23
  return Object.fromEntries(
22
24
  Object.entries(definition.options.fields).map(([name, field]) => [name, getFieldDefaultValue(field)])
@@ -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,13 @@
1
+ import type { EponymeSchema } from '../types/index.js';
2
+ /**
3
+ * Rewrites every value that has a canonical form — a phone in E.164, a tag list deduplicated —
4
+ * wherever it sits in the schema.
5
+ *
6
+ * Applied on the server before a write, which is what makes a stored format a guarantee rather
7
+ * than a convention: the API accepts any JSON, so a client normalising on its own could always
8
+ * be bypassed. A value that cannot be understood is left as sent, for validation to reject.
9
+ *
10
+ * One walk for every field type that needs one. A second walker would mean a second place to
11
+ * forget a container when a field type is added.
12
+ */
13
+ export declare function normalizeEponymeValues(schema: EponymeSchema, data: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,40 @@
1
+ import { isArrayItemFieldDefinition } from "./get-field-default-value.js";
2
+ import { toEponymePhoneValue } from "./normalize-phone.js";
3
+ import { normalizeEponymeTags } from "./normalize-tags.js";
4
+ export function normalizeEponymeValues(schema, data) {
5
+ const result = { ...data };
6
+ for (const [name, definition] of Object.entries(schema)) {
7
+ if (!(name in result)) continue;
8
+ result[name] = normalizeField(definition, result[name]);
9
+ }
10
+ return result;
11
+ }
12
+ function normalizeField(definition, value) {
13
+ if (definition.type === "phone") return toEponymePhoneValue(value, definition.options);
14
+ if (definition.type === "tags") return Array.isArray(value) ? normalizeEponymeTags(value, definition.options) : value;
15
+ if (definition.type === "section")
16
+ return normalizeNested(definition.options.fields, value);
17
+ if (definition.type === "tabs") {
18
+ if (!isRecord(value)) return value;
19
+ const tabs = { ...value };
20
+ for (const [tabName, tab] of Object.entries(definition.options.tabs))
21
+ if (tabName in tabs) tabs[tabName] = normalizeNested(tab.fields, tabs[tabName]);
22
+ return tabs;
23
+ }
24
+ if (definition.type === "array") {
25
+ if (!Array.isArray(value)) return value;
26
+ return value.map((item) => normalizeArrayItem(definition.options.of, item));
27
+ }
28
+ return value;
29
+ }
30
+ function normalizeArrayItem(of, item) {
31
+ if (isArrayItemFieldDefinition(of)) return normalizeField(of, item);
32
+ return normalizeNested(of, item);
33
+ }
34
+ function normalizeNested(schema, value) {
35
+ if (!isRecord(value)) return value;
36
+ return normalizeEponymeValues(schema, value);
37
+ }
38
+ function isRecord(value) {
39
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
40
+ }
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ import type { TagsFieldOptions } from '../types/field.js';
2
+ /**
3
+ * The canonical form of a tag list, shared by the validation the server enforces, the editor
4
+ * field, and the write path that stores the value.
5
+ *
6
+ * A tag list is a taxonomy, so `Nuxt` and ` nuxt ` are the same tag and must not both exist.
7
+ * The first spelling wins, and a suggestion counts as a first spelling: typing `nuxt` next to a
8
+ * declared `Nuxt` yields `Nuxt`, which keeps a listing from splitting in two.
9
+ */
10
+ export declare function normalizeEponymeTags(value: unknown, options?: TagsFieldOptions): string[];
@@ -0,0 +1,17 @@
1
+ export function normalizeEponymeTags(value, options = {}) {
2
+ if (!Array.isArray(value)) return [];
3
+ const suggestions = new Map((options.suggestions ?? []).map((tag) => [fold(tag), tag]));
4
+ const seen = /* @__PURE__ */ new Map();
5
+ for (const item of value) {
6
+ if (typeof item !== "string") continue;
7
+ const tag = item.trim();
8
+ if (!tag) continue;
9
+ const key = fold(tag);
10
+ if (seen.has(key)) continue;
11
+ seen.set(key, suggestions.get(key) ?? tag);
12
+ }
13
+ return [...seen.values()];
14
+ }
15
+ function fold(tag) {
16
+ return tag.trim().toLocaleLowerCase();
17
+ }
@@ -1,5 +1,7 @@
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";
4
+ import { normalizeEponymeTags } from "./normalize-tags.js";
3
5
  const urlPattern = /^https?:\/\//i;
4
6
  const datePattern = /^\d{4}-\d{2}-\d{2}$/;
5
7
  const colorPattern = /^#(?:[\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/i;
@@ -70,6 +72,21 @@ function validateFieldRules(name, definition, value, errors, mode, _context) {
70
72
  }
71
73
  return;
72
74
  }
75
+ if (definition.type === "tags") {
76
+ if (!Array.isArray(value)) {
77
+ addError(errors, name, "Must be an array.");
78
+ return;
79
+ }
80
+ const tags = normalizeEponymeTags(value, definition.options);
81
+ if (mode === "publish" && options.required && tags.length === 0) addError(errors, name, "This field is required.");
82
+ if (mode === "publish" && definition.options.minItems !== void 0 && tags.length < definition.options.minItems) addError(errors, name, `Must contain at least ${definition.options.minItems} items.`);
83
+ if (definition.options.maxItems !== void 0 && tags.length > definition.options.maxItems) addError(errors, name, `Must contain at most ${definition.options.maxItems} items.`);
84
+ if (value.some((item) => typeof item !== "string" || !item.trim())) addError(errors, name, "Must contain only non-empty tags.");
85
+ const suggestions = definition.options.suggestions;
86
+ if (!definition.options.allowCustom && suggestions?.length && tags.some((tag) => !suggestions.includes(tag)))
87
+ addError(errors, name, "Must contain only available options.");
88
+ return;
89
+ }
73
90
  if (definition.type === "checkboxGroup") {
74
91
  if (!Array.isArray(value)) {
75
92
  addError(errors, name, "Must be an array.");
@@ -134,7 +151,10 @@ function validateFieldRules(name, definition, value, errors, mode, _context) {
134
151
  const href = link.href.trim();
135
152
  if (mode === "publish" && options.required && !href) addError(errors, name, "This field is required.");
136
153
  if (!href) return;
137
- if (link.type === "external" && !urlPattern.test(href)) addError(errors, name, "External links must be HTTP(S) URLs.");
154
+ if (link.type === "external" && !isAllowedProtocol(href, definition.options.protocols)) {
155
+ const allowed = normalizeProtocols(definition.options.protocols);
156
+ 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 ")}.`);
157
+ }
138
158
  if (link.type === "internal" && !href.startsWith("/") && !href.startsWith("#")) addError(errors, name, "Internal links must start with / or #.");
139
159
  return;
140
160
  }
@@ -160,6 +180,16 @@ function validateFieldRules(name, definition, value, errors, mode, _context) {
160
180
  if (definition.options.max && normalized > definition.options.max) addError(errors, name, `Must be on or before ${definition.options.max}.`);
161
181
  return;
162
182
  }
183
+ if (definition.type === "phone") {
184
+ const phone = normalizeEponymePhone(normalized, definition.options);
185
+ if (normalized && phone.countryNotAllowed) {
186
+ const accepted = definition.options.countries?.join(", ");
187
+ addError(errors, name, phone.country ? `Numbers from ${phone.country} are not accepted. Use a number from: ${accepted}.` : `Must be a number from: ${accepted}.`);
188
+ } else if (normalized && !phone.valid) {
189
+ 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.");
190
+ }
191
+ return;
192
+ }
163
193
  if (definition.type === "color") {
164
194
  if (normalized && !colorPattern.test(normalized)) addError(errors, name, "Must be a valid hex color.");
165
195
  return;
@@ -179,8 +209,20 @@ function validateFieldRules(name, definition, value, errors, mode, _context) {
179
209
  if (definition.options.maxLength !== void 0 && value.length > definition.options.maxLength) addError(errors, name, `Must contain at most ${definition.options.maxLength} characters.`);
180
210
  if (definition.options.regex && !definition.options.regex.test(value)) addError(errors, name, "Has an invalid format.");
181
211
  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.");
212
+ if (definition.type === "email" && normalized && (!localPart || !domain?.includes(".") || extraParts.length)) addError(errors, name, "Must be a valid email address.");
213
+ }
214
+ const DEFAULT_URL_PROTOCOLS = ["http", "https"];
215
+ const schemePattern = /^([a-z][a-z0-9+.-]*):/i;
216
+ function normalizeProtocols(protocols) {
217
+ const declared = protocols?.length ? protocols : DEFAULT_URL_PROTOCOLS;
218
+ return declared.map((protocol) => protocol.trim().toLowerCase().replace(/:.*$/, ""));
219
+ }
220
+ function isDefaultProtocols(protocols) {
221
+ return !protocols?.length;
222
+ }
223
+ function isAllowedProtocol(href, protocols) {
224
+ const scheme = schemePattern.exec(href)?.[1]?.toLowerCase();
225
+ return Boolean(scheme) && normalizeProtocols(protocols).includes(scheme);
184
226
  }
185
227
  function getRichTextContent(value) {
186
228
  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();
@@ -1,4 +1,8 @@
1
1
  import type { EponymeVariableDefinition, EponymeVariables } from '../types/variables.js';
2
+ export declare function findEponymeVariableRanges(text: string): Array<{
3
+ from: number;
4
+ to: number;
5
+ }>;
2
6
  /** Always available, whatever the host application configures. */
3
7
  export declare function builtinEponymeVariables(now?: Date): Record<string, EponymeVariableDefinition>;
4
8
  /** Host variables win, so an application can override a built-in for its own wording. */
@@ -1,4 +1,11 @@
1
- const VARIABLE_PATTERN = /\{\{\s*([A-Z_]\w*)\s*\}\}/gi;
1
+ const VARIABLE_PATTERN_SOURCE = String.raw`\{\{\s*([A-Z_]\w*)\s*\}\}`;
2
+ const VARIABLE_PATTERN = new RegExp(VARIABLE_PATTERN_SOURCE, "gi");
3
+ export function findEponymeVariableRanges(text) {
4
+ return [...text.matchAll(new RegExp(VARIABLE_PATTERN_SOURCE, "gi"))].map((match) => ({
5
+ from: match.index,
6
+ to: match.index + match[0].length
7
+ }));
8
+ }
2
9
  export function builtinEponymeVariables(now = /* @__PURE__ */ new Date()) {
3
10
  return {
4
11
  currentYear: { label: "Current year", description: "The year at the time the page is served.", value: () => now.getFullYear() },