@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.
- package/README.md +109 -45
- package/dist/eponyme.mjs +2 -3
- package/dist/module.d.mts +109 -13
- package/dist/module.json +1 -1
- package/dist/module.mjs +19 -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 +22 -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/fields/RichTextField.vue +37 -1
- package/dist/runtime/components/fields/TagsField.d.vue.ts +21 -0
- package/dist/runtime/components/fields/TagsField.vue +55 -0
- package/dist/runtime/components/fields/TagsField.vue.d.ts +21 -0
- package/dist/runtime/components/ui/EPAlertDialog.d.vue.ts +37 -0
- package/dist/runtime/components/ui/EPAlertDialog.vue +66 -0
- package/dist/runtime/components/ui/EPAlertDialog.vue.d.ts +37 -0
- package/dist/runtime/components/ui/EPDialog.d.vue.ts +1 -0
- package/dist/runtime/components/ui/EPDialog.vue +6 -2
- package/dist/runtime/components/ui/EPDialog.vue.d.ts +1 -0
- package/dist/runtime/components/ui/EPLinkEditor.vue +2 -1
- package/dist/runtime/components/ui/EPTagsInput.d.vue.ts +27 -0
- package/dist/runtime/components/ui/EPTagsInput.vue +101 -0
- package/dist/runtime/components/ui/EPTagsInput.vue.d.ts +27 -0
- package/dist/runtime/composables/useEponymeCollection.d.ts +13 -1
- package/dist/runtime/composables/useEponymeCollection.js +29 -4
- package/dist/runtime/composables/useEponymeForm.d.ts +0 -4
- package/dist/runtime/composables/useEponymeForm.js +1 -8
- package/dist/runtime/fields/index.d.ts +4 -0
- package/dist/runtime/fields/index.js +3 -1
- package/dist/runtime/fields/phone.d.ts +2 -0
- package/dist/runtime/fields/phone.js +3 -0
- package/dist/runtime/fields/tags.d.ts +6 -0
- package/dist/runtime/fields/tags.js +3 -0
- package/dist/runtime/middleware/eponyme-auth.d.ts +1 -1
- package/dist/runtime/middleware/eponyme-owner.d.ts +1 -1
- package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
- package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
- package/dist/runtime/server/api/eponyme-forms/[path].post.js +0 -15
- package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
- package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
- package/dist/runtime/server/services/eponyme-form-store.js +4 -3
- package/dist/runtime/server/services/eponyme-store.d.ts +289 -30
- package/dist/runtime/server/services/eponyme-store.js +405 -73
- package/dist/runtime/server/utils/eponyme-reindex.d.ts +13 -0
- package/dist/runtime/server/utils/eponyme-reindex.js +4 -0
- package/dist/runtime/types/eponyme-config.d.ts +0 -7
- package/dist/runtime/types/eponyme.d.ts +54 -6
- package/dist/runtime/types/field.d.ts +82 -4
- package/dist/runtime/types/index.d.ts +0 -1
- package/dist/runtime/types/index.js +0 -1
- package/dist/runtime/utils/eponyme-entry-index.d.ts +39 -0
- package/dist/runtime/utils/eponyme-entry-index.js +51 -0
- package/dist/runtime/utils/get-field-default-value.js +2 -0
- package/dist/runtime/utils/middle-ellipsis.d.ts +1 -0
- package/dist/runtime/utils/middle-ellipsis.js +8 -0
- package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
- package/dist/runtime/utils/normalize-eponyme-values.js +40 -0
- package/dist/runtime/utils/normalize-phone.d.ts +26 -0
- package/dist/runtime/utils/normalize-phone.js +17 -0
- package/dist/runtime/utils/normalize-tags.d.ts +10 -0
- package/dist/runtime/utils/normalize-tags.js +17 -0
- package/dist/runtime/utils/validate-eponyme-data.js +45 -3
- package/dist/runtime/utils/variables.d.ts +4 -0
- package/dist/runtime/utils/variables.js +8 -1
- package/package.json +4 -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
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# Eponyme
|
|
2
4
|
|
|
3
5
|
[![npm version][npm-version-src]][npm-version-href]
|
|
@@ -11,7 +13,7 @@ Define your content in `eponyme.config.ts`. Eponyme provides defaults, validatio
|
|
|
11
13
|
|
|
12
14
|
## Features
|
|
13
15
|
|
|
14
|
-
- Type-safe fields for text, slugs, rich text, numbers, booleans, images, links, dates, colors, sections, tabs, and arrays
|
|
16
|
+
- Type-safe fields for text, slugs, rich text, numbers, booleans, images, links, phone numbers, tags, dates, colors, sections, tabs, and arrays
|
|
15
17
|
- Declarative defaults and validation
|
|
16
18
|
- Conditional fields, character counters, and sortable arrays
|
|
17
19
|
- Private drafts with explicit publishing
|
|
@@ -42,6 +44,13 @@ Install the module:
|
|
|
42
44
|
pnpm add @karibsen/eponyme
|
|
43
45
|
```
|
|
44
46
|
|
|
47
|
+
Eponyme brings its own components, so `@nuxt/ui` is not required. If your application does use
|
|
48
|
+
it, it has to be `^4.10.0` or newer: both ship Tailwind, and an older `@nuxt/ui` resolves a
|
|
49
|
+
version the dashboard was not built against. What that breaks is not predictable — broken
|
|
50
|
+
dashboard styles are the visible case, but it can also fail the build or misbehave elsewhere with
|
|
51
|
+
no obvious link back to the cause. The module checks the installed version and warns at build
|
|
52
|
+
time.
|
|
53
|
+
|
|
45
54
|
Add it to `nuxt.config.ts`:
|
|
46
55
|
|
|
47
56
|
```ts
|
|
@@ -85,8 +94,16 @@ export default defineEponymeConfig({
|
|
|
85
94
|
label: 'Contact email',
|
|
86
95
|
defaultValue: 'hello@example.com',
|
|
87
96
|
}),
|
|
97
|
+
contactPhone: field.phone({
|
|
98
|
+
label: 'Contact phone',
|
|
99
|
+
defaultCountry: 'FR',
|
|
100
|
+
countries: ['FR', 'BE'],
|
|
101
|
+
autocomplete: 'tel',
|
|
102
|
+
}),
|
|
88
103
|
callToAction: field.url({
|
|
89
104
|
label: 'Call to action',
|
|
105
|
+
// Schemes an external link may use; defaults to ['http', 'https']
|
|
106
|
+
protocols: ['https', 'mailto'],
|
|
90
107
|
defaultValue: {
|
|
91
108
|
href: '/contact',
|
|
92
109
|
type: 'internal',
|
|
@@ -152,6 +169,59 @@ field.string({
|
|
|
152
169
|
})
|
|
153
170
|
```
|
|
154
171
|
|
|
172
|
+
### Tags
|
|
173
|
+
|
|
174
|
+
`field.tags()` holds a short list of values, with suggestions and optional free entry:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
tags: field.tags({
|
|
178
|
+
suggestions: ['Nuxt', 'Vue', 'TypeScript'],
|
|
179
|
+
allowCustom: true,
|
|
180
|
+
maxItems: 5,
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The value type follows the options: closed, it is `('Nuxt' | 'Vue' | 'TypeScript')[]`; with
|
|
185
|
+
`allowCustom`, `string[]`. One array never mixes two types.
|
|
186
|
+
|
|
187
|
+
The list is normalised **on the server** before the write: trimmed, blanks dropped, duplicates
|
|
188
|
+
folded case-insensitively. A suggestion imposes its spelling, so typing `nuxt` beside a declared
|
|
189
|
+
`Nuxt` yields `Nuxt` and a listing never splits in two. `minItems` and `maxItems` count what is
|
|
190
|
+
left after that folding.
|
|
191
|
+
|
|
192
|
+
### Phone numbers
|
|
193
|
+
|
|
194
|
+
`field.phone()` stores its value in **E.164** — `+33611131143` — whatever format it was typed
|
|
195
|
+
in. Normalisation happens on the server before the write, so the stored format is a guarantee
|
|
196
|
+
rather than a convention a client could skip.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
phone: field.phone({
|
|
200
|
+
label: 'Phone',
|
|
201
|
+
// ISO 3166-1 alpha-2, autocompleted from libphonenumber-js
|
|
202
|
+
countries: ['FR', 'BE'],
|
|
203
|
+
// Country a number typed without `+` belongs to
|
|
204
|
+
defaultCountry: 'FR',
|
|
205
|
+
autocomplete: 'tel',
|
|
206
|
+
placeholder: '06 11 13 11 43',
|
|
207
|
+
required: true,
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
| Option | Effect |
|
|
212
|
+
|---|---|
|
|
213
|
+
| `countries` | Countries whose numbers are accepted. A number resolving to any other is refused, with an error naming the country it came from. Omitted, every country is accepted |
|
|
214
|
+
| `defaultCountry` | Country assumed for a number written without an international prefix. Without it, only `+…` numbers can be understood |
|
|
215
|
+
| `detectCountry` | `false` requires the international `+…` form, so the country is never guessed. Defaults to `true` |
|
|
216
|
+
| `autocomplete` | `tel`, `tel-national` or `tel-country-code`, set on the input |
|
|
217
|
+
| `defaultValue`, `required`, `placeholder`, `label`, `description` | As on every field |
|
|
218
|
+
|
|
219
|
+
Validation uses the `min` metadata bundle of `libphonenumber-js`, a fraction of the size of the
|
|
220
|
+
full one. It parses and formats every country, and is slightly more permissive: a few numbers
|
|
221
|
+
that are impossible for their region still pass.
|
|
222
|
+
|
|
223
|
+
A phone field is also allowed in a public `form()`.
|
|
224
|
+
|
|
155
225
|
## Prisma
|
|
156
226
|
|
|
157
227
|
Eponyme uses the Prisma client owned by your application. It does not create the connection or run migrations.
|
|
@@ -174,17 +244,49 @@ Add the required models to your Prisma schema:
|
|
|
174
244
|
|
|
175
245
|
```prisma
|
|
176
246
|
model Eponyme {
|
|
177
|
-
name
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
247
|
+
name String @id
|
|
248
|
+
draft Json @db.JsonB
|
|
249
|
+
published Json @db.JsonB
|
|
250
|
+
status String
|
|
251
|
+
publishedAt DateTime?
|
|
252
|
+
createdAt DateTime @default(now())
|
|
253
|
+
updatedAt DateTime @updatedAt
|
|
254
|
+
deletedAt DateTime?
|
|
255
|
+
versions EponymeVersion[]
|
|
256
|
+
index EponymeEntryIndex[]
|
|
183
257
|
|
|
184
258
|
@@index([deletedAt])
|
|
259
|
+
@@index([publishedAt])
|
|
185
260
|
@@map("eponyme_entries")
|
|
186
261
|
}
|
|
187
262
|
|
|
263
|
+
/// Filterable values pulled out of the content columns, so a listing can look them up
|
|
264
|
+
/// instead of loading and normalizing every entry of a collection to compare them.
|
|
265
|
+
/// Rewritten for an entry inside the same transaction as the write that changed it.
|
|
266
|
+
model EponymeEntryIndex {
|
|
267
|
+
entryName String
|
|
268
|
+
/// "draft" or "published": the two disagree, and a public listing must only see one.
|
|
269
|
+
version String
|
|
270
|
+
key String
|
|
271
|
+
/// Case-folded, so a lookup never has to guess which spelling was stored first.
|
|
272
|
+
value String
|
|
273
|
+
entry Eponyme @relation(fields: [entryName], references: [name], onDelete: Cascade)
|
|
274
|
+
|
|
275
|
+
@@id([entryName, version, key, value])
|
|
276
|
+
@@index([key, version, value])
|
|
277
|
+
@@map("eponyme_entry_index")
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// Remembers which filterable schema each collection was last indexed against, so the
|
|
281
|
+
/// module can rebuild only what a config change actually invalidated.
|
|
282
|
+
model EponymeIndexState {
|
|
283
|
+
name String @id
|
|
284
|
+
fingerprint String
|
|
285
|
+
updatedAt DateTime @updatedAt
|
|
286
|
+
|
|
287
|
+
@@map("eponyme_index_state")
|
|
288
|
+
}
|
|
289
|
+
|
|
188
290
|
model EponymeVersion {
|
|
189
291
|
id Int @id @default(autoincrement())
|
|
190
292
|
entryName String
|
|
@@ -740,44 +842,6 @@ Two protections apply to the public submission route:
|
|
|
740
842
|
- a **body size limit**, 64 KB by default, configurable with `maxBodyBytes`. An oversized
|
|
741
843
|
body is refused with `413` before it is parsed.
|
|
742
844
|
|
|
743
|
-
### Captcha
|
|
744
|
-
|
|
745
|
-
Set `captcha: true` on the form, then install an adapter — Eponyme defines the contract
|
|
746
|
-
but ships no provider:
|
|
747
|
-
|
|
748
|
-
```bash
|
|
749
|
-
pnpm add @karibsen/eponyme-captcha
|
|
750
|
-
```
|
|
751
|
-
|
|
752
|
-
```ts
|
|
753
|
-
export default defineNuxtConfig({
|
|
754
|
-
modules: ['@karibsen/eponyme', '@karibsen/eponyme-captcha'],
|
|
755
|
-
eponymeCaptcha: { siteKey: '<your-turnstile-site-key>' },
|
|
756
|
-
})
|
|
757
|
-
```
|
|
758
|
-
|
|
759
|
-
Render the widget and bind its token; `useEponymeForm` sends it alongside the fields and
|
|
760
|
-
the server verifies it before validating anything:
|
|
761
|
-
|
|
762
|
-
```vue
|
|
763
|
-
<script setup lang="ts">
|
|
764
|
-
const { captchaToken, requiresCaptcha, submit } = useEponymeForm('contact')
|
|
765
|
-
</script>
|
|
766
|
-
|
|
767
|
-
<template>
|
|
768
|
-
<NuxtTurnstile v-if="requiresCaptcha" v-model="captchaToken" />
|
|
769
|
-
</template>
|
|
770
|
-
```
|
|
771
|
-
|
|
772
|
-
**It fails closed.** Without an adapter, a form declaring `captcha: true` answers `500`
|
|
773
|
-
rather than accepting submissions unprotected. A rejected token answers `422` with a
|
|
774
|
-
`_form` error; the provider's reason stays in the server log, since telling a bot which
|
|
775
|
-
check it failed only helps it.
|
|
776
|
-
|
|
777
|
-
Writing an adapter for another provider means supplying a `name` and an
|
|
778
|
-
`async verify(token, { ip })` returning `{ success, reason? }`, then pointing the
|
|
779
|
-
`#eponyme/captcha` alias at it.
|
|
780
|
-
|
|
781
845
|
Rate limiting and file uploads are not implemented yet.
|
|
782
846
|
|
|
783
847
|
## Hooks
|
package/dist/eponyme.mjs
CHANGED
|
@@ -11,7 +11,7 @@ function collection(options) {
|
|
|
11
11
|
return { __eponymeCollection: true, ...options };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const PUBLIC_FIELD_TYPES = ["string", "textarea", "email", "url", "number", "boolean", "select", "radio", "checkboxGroup"];
|
|
14
|
+
const PUBLIC_FIELD_TYPES = ["string", "textarea", "email", "phone", "url", "number", "boolean", "select", "radio", "checkboxGroup"];
|
|
15
15
|
const DEFAULT_HONEYPOT = "_eponyme_hp";
|
|
16
16
|
const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
|
17
17
|
function form(options) {
|
|
@@ -31,8 +31,7 @@ function form(options) {
|
|
|
31
31
|
// Storing a submission has to be an explicit decision, so `custom` is the default.
|
|
32
32
|
submission: { mode: options.submission?.mode ?? "custom" },
|
|
33
33
|
honeypot,
|
|
34
|
-
maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
35
|
-
captcha: options.captcha ?? false
|
|
34
|
+
maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
36
35
|
};
|
|
37
36
|
}
|
|
38
37
|
|
package/dist/module.d.mts
CHANGED
|
@@ -5,37 +5,133 @@ export { EPONYME_ROLES, canEditEponyme, isEponymeRole } from '../dist/runtime/ty
|
|
|
5
5
|
export { ArrayFieldDefinition, ArrayFieldOptions, ArrayItemDefinition, ArrayItemFieldDefinition, ArrayItemSchema, ArrayItemValue, BooleanFieldDefinition, BooleanFieldOptions, CheckboxGroupFieldDefinition, CheckboxGroupFieldOptions, CollectionSlugField, ColorFieldDefinition, ColorFieldOptions, DateFieldDefinition, DateFieldOptions, EmailFieldDefinition, EmailFieldOptions, EponymeAuthSession, EponymeAuthUser, EponymeCollectionDataByName, EponymeCollectionDefinition, EponymeCollectionDefinitionBase, EponymeCollectionName, EponymeCollectionOptions, EponymeConfig, EponymeData, EponymeDataByName, EponymeFormDataByName, EponymeFormDefinition, EponymeFormDefinitionBase, EponymeFormFieldDefinition, EponymeFormMode, EponymeFormName, EponymeFormOptions, EponymeFormSchema, EponymeManagedUserResult, EponymeName, EponymeRole, EponymeSchema, EponymeVariableDefinition, EponymeVariableSummary, EponymeVariableValue, EponymeVariables, FieldDefinition, FieldValidationResult, FieldValidator, FieldVisibilityCondition, ImageFieldDefinition, ImageFieldOptions, NumberFieldDefinition, NumberFieldOptions, RadioFieldDefinition, RadioFieldOptions, RichTextFieldDefinition, RichTextFieldOptions, SectionFieldDefinition, SectionFieldOptions, SectionItemFieldDefinition, SectionItemValue, SectionSchema, SectionValue, SelectFieldDefinition, SelectFieldOptions, SelectOption, SlugFieldDefinition, SlugFieldOptions, StringFieldDefinition, StringFieldOptions, TabDefinition, TabFieldDefinition, TabFieldOptions, TabsSchema, TabsValue, TextareaFieldDefinition, TextareaFieldOptions, TodayDateDefault, UrlFieldDefinition, UrlFieldOptions, UrlType, UrlValue } from '../dist/runtime/types/index.js';
|
|
6
6
|
import '../dist/runtime/types/variables.js';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Options read from the `eponyme` key of `nuxt.config`.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* export default defineNuxtConfig({
|
|
14
|
+
* modules: ['@karibsen/eponyme'],
|
|
15
|
+
* eponyme: {
|
|
16
|
+
* prismaClient: '~~/server/utils/prisma',
|
|
17
|
+
* previewPaths: {
|
|
18
|
+
* homepage: '/',
|
|
19
|
+
* articles: '/articles/:slug',
|
|
20
|
+
* },
|
|
21
|
+
* },
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
8
25
|
interface ModuleOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Route the dashboard is served on. Surrounding slashes are normalised, so `__eponyme` and
|
|
28
|
+
* `/__eponyme/` are equivalent.
|
|
29
|
+
*
|
|
30
|
+
* @default "/__eponyme"
|
|
31
|
+
*/
|
|
9
32
|
dashboardPath?: string;
|
|
10
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Public route rendering each entry, keyed by singleton name or by collection name. A
|
|
35
|
+
* collection value must contain `:slug`, which is replaced by the entry slug.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* An entry left out of this map has no preview, no sitemap entry, and no cache tag on the page
|
|
39
|
+
* that renders it.
|
|
40
|
+
*
|
|
41
|
+
* @default {}
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* previewPaths: {
|
|
45
|
+
* homepage: '/',
|
|
46
|
+
* articles: '/articles/:slug',
|
|
47
|
+
* }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
11
50
|
previewPaths?: Record<string, string>;
|
|
12
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Module specifier of a server file whose default export is an initialised `PrismaClient`.
|
|
53
|
+
* Nuxt aliases apply, and keep their usual meaning: a server file lives under `~~/`, not `~/`.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* prismaClient: '~~/server/utils/prisma'
|
|
58
|
+
* ```
|
|
59
|
+
* @see https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/introduction
|
|
60
|
+
*/
|
|
13
61
|
prismaClient: string;
|
|
14
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Palette offered by every color field, which a field overrides with its own `presets`.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* A bare string is labelled from its value. An entry that is not a valid hex colour is dropped.
|
|
67
|
+
*
|
|
68
|
+
* @default []
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* colorPresets: [
|
|
72
|
+
* { label: 'Ink', value: '#171714' },
|
|
73
|
+
* '#ffffff',
|
|
74
|
+
* ]
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
15
77
|
colorPresets?: Array<string | {
|
|
16
78
|
label: string;
|
|
17
79
|
value: string;
|
|
18
80
|
}>;
|
|
19
81
|
auth?: {
|
|
20
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Lifetime of an authenticated session, in days, counted from sign-in and never extended by
|
|
84
|
+
* activity.
|
|
85
|
+
*
|
|
86
|
+
* @default 7
|
|
87
|
+
*/
|
|
21
88
|
sessionDurationDays?: number;
|
|
22
89
|
};
|
|
23
90
|
/**
|
|
24
|
-
* How long a server instance may reuse content it has already read, in seconds.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
91
|
+
* How long a server instance may reuse content it has already read, in seconds. `0` disables it.
|
|
92
|
+
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* A save only clears the instance that served it, so this bounds how long another instance can
|
|
95
|
+
* still answer with the previous content.
|
|
96
|
+
*
|
|
97
|
+
* @default 5
|
|
27
98
|
*/
|
|
28
99
|
cacheSeconds?: number;
|
|
29
100
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
101
|
+
* Whether the filterable index is rebuilt at startup when the configuration that produced
|
|
102
|
+
* it has changed.
|
|
103
|
+
*
|
|
104
|
+
* @remarks
|
|
105
|
+
* Leave it on. Ordinary writes keep the index current, but a field that becomes filterable
|
|
106
|
+
* leaves the entries already stored without a row for it, and a filter on them then answers
|
|
107
|
+
* "none" instead of failing — a wrong listing rather than an error.
|
|
108
|
+
*
|
|
109
|
+
* Turning it off is an escape hatch: if a rebuild ever fails on every boot, this gets the
|
|
110
|
+
* application up again so `reindexEponymeEntries()` can be run by hand.
|
|
111
|
+
*
|
|
112
|
+
* @default true
|
|
113
|
+
*/
|
|
114
|
+
autoReindex?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* How long a browser may reuse published content, in seconds. `0` disables it.
|
|
117
|
+
*
|
|
118
|
+
* @remarks
|
|
119
|
+
* A browser cache cannot be purged: a visitor holding a copy keeps it until it expires, whatever
|
|
120
|
+
* is published meanwhile.
|
|
121
|
+
*
|
|
122
|
+
* @default 30
|
|
123
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
|
|
33
124
|
*/
|
|
34
125
|
browserCacheSeconds?: number;
|
|
35
126
|
/**
|
|
36
|
-
* How long a CDN may reuse published content, in seconds.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
127
|
+
* How long a CDN may reuse published content, in seconds. `0` disables it.
|
|
128
|
+
*
|
|
129
|
+
* @remarks
|
|
130
|
+
* Sent as `s-maxage`, with `stale-while-revalidate` twelve times longer. Cached responses carry
|
|
131
|
+
* cache tags, so a publication can purge them from the `eponyme:entry:published` hook.
|
|
132
|
+
*
|
|
133
|
+
* @default 300
|
|
134
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
|
|
39
135
|
*/
|
|
40
136
|
cdnCacheSeconds?: number;
|
|
41
137
|
}
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import { defineNuxtModule, useLogger, createResolver, findPath, addImports, addImportsDir, addServerImports, addRouteMiddleware, addTypeTemplate, addServerHandler, addServerPlugin } from '@nuxt/kit';
|
|
1
|
+
import { defineNuxtModule, useLogger, createResolver, findPath, addImports, addImportsDir, addServerImports, addRouteMiddleware, addTypeTemplate, addServerHandler, addServerPlugin, hasNuxtModule, hasNuxtModuleCompatibility, getNuxtModuleVersion } from '@nuxt/kit';
|
|
2
2
|
import pc from 'picocolors';
|
|
3
3
|
import tailwindcss from '@tailwindcss/vite';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
5
|
import { tagPreviewPathRoutes } from '../dist/runtime/utils/cache-tags.js';
|
|
6
6
|
|
|
7
|
+
const MINIMUM_NUXT_UI = "4.10.0";
|
|
8
|
+
async function warnOnIncompatibleNuxtUi(nuxt, logger) {
|
|
9
|
+
if (!hasNuxtModule("@nuxt/ui", nuxt)) return;
|
|
10
|
+
if (await hasNuxtModuleCompatibility("@nuxt/ui", `>=${MINIMUM_NUXT_UI}`, nuxt)) return;
|
|
11
|
+
const found = await getNuxtModuleVersion("@nuxt/ui", nuxt);
|
|
12
|
+
logger.warn(
|
|
13
|
+
`@nuxt/ui ${found || "(unknown version)"} is older than ${MINIMUM_NUXT_UI}, which Eponyme needs in order to share Tailwind with it.
|
|
14
|
+
Upgrade it with \`npm i @nuxt/ui@^${MINIMUM_NUXT_UI}\`. Keeping an older one resolves a Tailwind this module was not built
|
|
15
|
+
against, and what that breaks is not predictable: broken dashboard styles are the visible case, but it can also fail the
|
|
16
|
+
build or misbehave somewhere with no obvious link back to this warning.`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
7
19
|
const module$1 = defineNuxtModule({
|
|
8
20
|
meta: {
|
|
9
21
|
name: "@karibsen/eponyme",
|
|
@@ -18,6 +30,7 @@ const module$1 = defineNuxtModule({
|
|
|
18
30
|
sessionDurationDays: 7
|
|
19
31
|
},
|
|
20
32
|
cacheSeconds: 5,
|
|
33
|
+
autoReindex: true,
|
|
21
34
|
browserCacheSeconds: 30,
|
|
22
35
|
cdnCacheSeconds: 300
|
|
23
36
|
},
|
|
@@ -64,6 +77,7 @@ const module$1 = defineNuxtModule({
|
|
|
64
77
|
async setup(options, nuxt) {
|
|
65
78
|
const logger = useLogger("eponyme");
|
|
66
79
|
const resolver = createResolver(import.meta.url);
|
|
80
|
+
await warnOnIncompatibleNuxtUi(nuxt, logger);
|
|
67
81
|
const dashboardPath = `/${(options.dashboardPath || "__eponyme").replace(/^\/+|\/+$/g, "")}`;
|
|
68
82
|
const publicRuntimeConfig = nuxt.options.runtimeConfig.public;
|
|
69
83
|
publicRuntimeConfig.eponyme = {
|
|
@@ -76,6 +90,7 @@ const module$1 = defineNuxtModule({
|
|
|
76
90
|
};
|
|
77
91
|
nuxt.options.runtimeConfig.eponymeContent = {
|
|
78
92
|
cacheSeconds: options.cacheSeconds ?? 5,
|
|
93
|
+
autoReindex: options.autoReindex ?? true,
|
|
79
94
|
browserCacheSeconds: options.browserCacheSeconds ?? 30,
|
|
80
95
|
cdnCacheSeconds: options.cdnCacheSeconds ?? 300
|
|
81
96
|
};
|
|
@@ -113,6 +128,9 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
|
|
|
113
128
|
addImportsDir(resolver.resolve("./runtime/composables"));
|
|
114
129
|
addServerImports([
|
|
115
130
|
{ name: "getEponymeSitemapEntries", from: resolver.resolve("./runtime/server/utils/eponyme-sitemap") },
|
|
131
|
+
// The backfill for the filterable index, which a migration cannot do on its own:
|
|
132
|
+
// reading a value out of the JSONB envelope needs the configured schema.
|
|
133
|
+
{ name: "reindexEponymeEntries", from: resolver.resolve("./runtime/server/utils/eponyme-reindex") },
|
|
116
134
|
// A `custom` form posts to the host application's own route, so this is what
|
|
117
135
|
// keeps server-side validation as the security boundary.
|
|
118
136
|
{ name: "validateEponymeForm", from: resolver.resolve("./runtime/server/utils/eponyme-form") },
|
|
@@ -150,7 +168,6 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
|
|
|
150
168
|
nuxt.options.alias["#eponyme/prisma"] = prismaPath;
|
|
151
169
|
const variablesPath = await findPath(resolve(nuxt.options.rootDir, "eponyme.variables.ts"));
|
|
152
170
|
nuxt.options.alias["#eponyme/variables"] = variablesPath ?? resolver.resolve("./runtime/utils/empty-variables");
|
|
153
|
-
nuxt.options.alias["#eponyme/captcha"] ??= resolver.resolve("./runtime/utils/no-captcha");
|
|
154
171
|
addTypeTemplate({
|
|
155
172
|
filename: "types/eponyme-config.d.ts",
|
|
156
173
|
getContents: () => `declare module '#eponyme/config' {
|
|
@@ -161,10 +178,6 @@ declare module '#eponyme/variables' {
|
|
|
161
178
|
const variables: typeof import(${JSON.stringify(nuxt.options.alias["#eponyme/variables"])})['default']
|
|
162
179
|
export default variables
|
|
163
180
|
}
|
|
164
|
-
declare module '#eponyme/captcha' {
|
|
165
|
-
const verifier: import('${resolver.resolve("./runtime/types/captcha")}').EponymeCaptchaVerifier
|
|
166
|
-
export default verifier
|
|
167
|
-
}
|
|
168
181
|
`
|
|
169
182
|
});
|
|
170
183
|
addServerHandler({ middleware: true, handler: resolver.resolve("./runtime/server/middleware/eponyme-no-store") });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, ref, watch } from "vue";
|
|
3
|
+
import { middleEllipsis } from "../../utils/middle-ellipsis";
|
|
3
4
|
const props = defineProps({
|
|
4
5
|
basePath: { type: String, required: true },
|
|
5
6
|
eponyme: { type: String, required: true }
|
|
@@ -24,14 +25,6 @@ const visibleBreadcrumbs = computed(() => {
|
|
|
24
25
|
function label(name) {
|
|
25
26
|
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
26
27
|
}
|
|
27
|
-
function middleEllipsis(value, maxLength = 28) {
|
|
28
|
-
const characters = [...value];
|
|
29
|
-
if (characters.length <= maxLength) return value;
|
|
30
|
-
const available = maxLength - 1;
|
|
31
|
-
const startLength = Math.ceil(available / 2);
|
|
32
|
-
const endLength = Math.floor(available / 2);
|
|
33
|
-
return `${characters.slice(0, startLength).join("")}\u2026${characters.slice(-endLength).join("")}`;
|
|
34
|
-
}
|
|
35
28
|
function updateBreadcrumbMenuScroll(event) {
|
|
36
29
|
const menu = event.currentTarget;
|
|
37
30
|
if (!menu) return;
|
|
@@ -171,11 +171,11 @@ async function deleteSubmission(submission) {
|
|
|
171
171
|
v-for="header in headerGroup.headers"
|
|
172
172
|
:key="header.id"
|
|
173
173
|
scope="col"
|
|
174
|
-
class="ep:border-b ep:border-border-ep ep:p-3 ep:text-[11px] ep:font-semibold ep:
|
|
174
|
+
class="ep:border-b ep:border-border-ep ep:p-3 ep:text-[11px] ep:font-semibold ep:text-muted-ep ep:uppercase"
|
|
175
175
|
>
|
|
176
176
|
<button
|
|
177
177
|
type="button"
|
|
178
|
-
class="ep:flex ep:cursor-pointer ep:items-center ep:gap-1 ep:border-0 ep:bg-transparent ep:p-0 ep:text-inherit
|
|
178
|
+
class="ep:flex ep:cursor-pointer ep:items-center ep:gap-1 ep:border-0 ep:bg-transparent ep:p-0 ep:text-inherit"
|
|
179
179
|
:aria-sort="header.column.getIsSorted() === 'asc' ? 'ascending' : header.column.getIsSorted() === 'desc' ? 'descending' : 'none'"
|
|
180
180
|
@click="header.column.toggleSorting()"
|
|
181
181
|
>
|
|
@@ -12,6 +12,7 @@ import { useEponymeAuth } from "../../composables/useEponymeAuth";
|
|
|
12
12
|
import EPAvatar from "../ui/EPAvatar.vue";
|
|
13
13
|
import EPButton from "../ui/EPButton.vue";
|
|
14
14
|
import EPInputText from "../ui/EPInputText.vue";
|
|
15
|
+
import logoUrl from "../../assets/logo.png?url";
|
|
15
16
|
const props = defineProps({
|
|
16
17
|
basePath: { type: String, required: true }
|
|
17
18
|
});
|
|
@@ -128,9 +129,13 @@ watch(() => route.path, () => {
|
|
|
128
129
|
<NuxtLink
|
|
129
130
|
v-if="!collapsed"
|
|
130
131
|
:to="normalizedBasePath"
|
|
131
|
-
class="ep:flex ep:min-w-0 ep:items-center ep:
|
|
132
|
+
class="ep:flex ep:min-w-0 ep:items-center ep:no-underline"
|
|
132
133
|
>
|
|
133
|
-
|
|
134
|
+
<img
|
|
135
|
+
:src="logoUrl"
|
|
136
|
+
alt="Eponyme"
|
|
137
|
+
class="ep:block ep:size-8"
|
|
138
|
+
>
|
|
134
139
|
</NuxtLink>
|
|
135
140
|
<button
|
|
136
141
|
type="button"
|
|
@@ -5,10 +5,12 @@ import CheckboxGroupField from "./CheckboxGroupField.vue";
|
|
|
5
5
|
import ColorField from "./ColorField.vue";
|
|
6
6
|
import DateField from "./DateField.vue";
|
|
7
7
|
import NumberField from "./NumberField.vue";
|
|
8
|
+
import PhoneField from "./PhoneField.vue";
|
|
8
9
|
import RadioField from "./RadioField.vue";
|
|
9
10
|
import RichTextField from "./RichTextField.vue";
|
|
10
11
|
import SelectField from "./SelectField.vue";
|
|
11
12
|
import SlugField from "./SlugField.vue";
|
|
13
|
+
import TagsField from "./TagsField.vue";
|
|
12
14
|
import TextareaField from "./TextareaField.vue";
|
|
13
15
|
import TextField from "./TextField.vue";
|
|
14
16
|
import UrlField from "./UrlField.vue";
|
|
@@ -59,6 +61,10 @@ const component = computed(() => {
|
|
|
59
61
|
return ColorField;
|
|
60
62
|
case "url":
|
|
61
63
|
return UrlField;
|
|
64
|
+
case "phone":
|
|
65
|
+
return PhoneField;
|
|
66
|
+
case "tags":
|
|
67
|
+
return TagsField;
|
|
62
68
|
default:
|
|
63
69
|
return TextField;
|
|
64
70
|
}
|
|
@@ -78,6 +84,14 @@ const specificProps = computed(() => {
|
|
|
78
84
|
case "radio":
|
|
79
85
|
case "checkboxGroup":
|
|
80
86
|
return { options: field.options.options };
|
|
87
|
+
case "tags":
|
|
88
|
+
return {
|
|
89
|
+
suggestions: field.options.suggestions,
|
|
90
|
+
allowCustom: field.options.allowCustom,
|
|
91
|
+
minItems: field.options.minItems,
|
|
92
|
+
maxItems: field.options.maxItems,
|
|
93
|
+
placeholder: field.options.placeholder
|
|
94
|
+
};
|
|
81
95
|
case "select":
|
|
82
96
|
return { options: field.options.options, placeholder: field.options.placeholder };
|
|
83
97
|
case "number":
|
|
@@ -95,6 +109,14 @@ const specificProps = computed(() => {
|
|
|
95
109
|
return { presets: field.options.presets };
|
|
96
110
|
case "url":
|
|
97
111
|
return { placeholder: field.options.placeholder };
|
|
112
|
+
case "phone":
|
|
113
|
+
return {
|
|
114
|
+
placeholder: field.options.placeholder,
|
|
115
|
+
countries: field.options.countries,
|
|
116
|
+
defaultCountry: field.options.defaultCountry,
|
|
117
|
+
detectCountry: field.options.detectCountry,
|
|
118
|
+
autocomplete: field.options.autocomplete
|
|
119
|
+
};
|
|
98
120
|
default: {
|
|
99
121
|
const options = field.options;
|
|
100
122
|
return {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PhoneFieldOptions } from '../../types/field.js';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
id: string;
|
|
4
|
+
modelValue?: unknown;
|
|
5
|
+
label: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
required?: boolean;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
countries?: PhoneFieldOptions['countries'];
|
|
10
|
+
defaultCountry?: PhoneFieldOptions['defaultCountry'];
|
|
11
|
+
detectCountry?: boolean;
|
|
12
|
+
autocomplete?: PhoneFieldOptions['autocomplete'];
|
|
13
|
+
errors?: string[];
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
};
|
|
16
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
17
|
+
"update:modelValue": (value: string) => any;
|
|
18
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
19
|
+
"onUpdate:modelValue"?: ((value: string) => any) | undefined;
|
|
20
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
21
|
+
declare const _default: typeof __VLS_export;
|
|
22
|
+
export default _default;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import EPFormField from "../ui/EPFormField.vue";
|
|
4
|
+
import EPInputText from "../ui/EPInputText.vue";
|
|
5
|
+
import { normalizeEponymePhone } from "../../utils/normalize-phone";
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
id: { type: String, required: true },
|
|
8
|
+
modelValue: { type: null, required: false },
|
|
9
|
+
label: { type: String, required: true },
|
|
10
|
+
description: { type: String, required: false },
|
|
11
|
+
required: { type: Boolean, required: false },
|
|
12
|
+
placeholder: { type: String, required: false },
|
|
13
|
+
countries: { type: Array, required: false },
|
|
14
|
+
defaultCountry: { type: String, required: false },
|
|
15
|
+
detectCountry: { type: Boolean, required: false },
|
|
16
|
+
autocomplete: { type: String, required: false },
|
|
17
|
+
errors: { type: Array, required: false, default: () => [] },
|
|
18
|
+
disabled: { type: Boolean, required: false }
|
|
19
|
+
});
|
|
20
|
+
const emit = defineEmits(["update:modelValue"]);
|
|
21
|
+
const value = computed(() => typeof props.modelValue === "string" ? props.modelValue : "");
|
|
22
|
+
const options = computed(() => ({
|
|
23
|
+
countries: props.countries,
|
|
24
|
+
defaultCountry: props.defaultCountry,
|
|
25
|
+
detectCountry: props.detectCountry
|
|
26
|
+
}));
|
|
27
|
+
const parsed = computed(() => normalizeEponymePhone(value.value, options.value));
|
|
28
|
+
function normalize() {
|
|
29
|
+
const next = parsed.value.e164;
|
|
30
|
+
if (next && next !== value.value) emit("update:modelValue", next);
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<EPFormField
|
|
36
|
+
:id="id"
|
|
37
|
+
:label="label"
|
|
38
|
+
:description="description"
|
|
39
|
+
:required="required"
|
|
40
|
+
:errors="errors"
|
|
41
|
+
>
|
|
42
|
+
<EPInputText
|
|
43
|
+
:id="id"
|
|
44
|
+
:model-value="value"
|
|
45
|
+
type="tel"
|
|
46
|
+
:autocomplete="autocomplete"
|
|
47
|
+
:placeholder="placeholder"
|
|
48
|
+
:required="required"
|
|
49
|
+
:invalid="Boolean(errors.length)"
|
|
50
|
+
:disabled="disabled"
|
|
51
|
+
@update:model-value="emit('update:modelValue', String($event))"
|
|
52
|
+
@blur="normalize"
|
|
53
|
+
/>
|
|
54
|
+
<p
|
|
55
|
+
v-if="parsed.e164 && parsed.country"
|
|
56
|
+
class="ep:mt-1.5 ep:mb-0 ep:text-[11px] ep:text-muted-ep"
|
|
57
|
+
>
|
|
58
|
+
{{ parsed.country }} · {{ parsed.e164 }}
|
|
59
|
+
</p>
|
|
60
|
+
</EPFormField>
|
|
61
|
+
</template>
|