kilo-cms 0.1.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 +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- package/src/webhooks.ts +30 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Pure types + slug types. MUST NOT import 'drizzle-orm', './db', 'server-only', or any
|
|
2
|
+
// specific host's schema — this module is reachable from client components, AND it's the
|
|
3
|
+
// package's own generic engine, not one host app's fixed list of content types.
|
|
4
|
+
//
|
|
5
|
+
// Slugs are runtime-registered (via defineKiloConfig({ collections: [...], singles: [...] }))
|
|
6
|
+
// rather than a compile-time union — a host app has no fixed set of collections the package
|
|
7
|
+
// could know about ahead of time. This trades away automatic exhaustiveness-checking inside
|
|
8
|
+
// the engine itself (accepted: the engine already re-validates registrations at runtime, see
|
|
9
|
+
// registry.ts's dev-mode assertions) for the host still getting real completion/exhaustiveness
|
|
10
|
+
// in ITS OWN code: `npx kilo-cms sync` generates `.kilo/types.gen.ts` with a real
|
|
11
|
+
// `CollectionSlug` union derived from the host's live config, the same way Prisma/Payload
|
|
12
|
+
// generate types from config instead of hand-authoring a union.
|
|
13
|
+
export type CollectionSlug = string
|
|
14
|
+
export type SingleSlug = string
|
|
15
|
+
export type EntrySlug = string
|
|
16
|
+
|
|
17
|
+
export type FieldWidth = 'full' | 'half' | 'third'
|
|
18
|
+
export type SelectOption = { value: string; label: string }
|
|
19
|
+
|
|
20
|
+
// Split so `ArrayItemFieldConfig` below can share the per-field props that make sense
|
|
21
|
+
// nested inside an array row, without the top-level-only ones (group/readOnly/listColumn/…).
|
|
22
|
+
type FieldCore = {
|
|
23
|
+
key: string // MUST equal the drizzle column property name (or, for an array item, the object key)
|
|
24
|
+
label: string
|
|
25
|
+
width?: FieldWidth // default 'full'
|
|
26
|
+
hint?: string
|
|
27
|
+
/** Only honored on text/textarea/number/password inputs — an example value shown in the empty
|
|
28
|
+
* input, not a default. Use `hint` instead for instructions that should stay visible once the
|
|
29
|
+
* field has a value. */
|
|
30
|
+
placeholder?: string
|
|
31
|
+
required?: boolean // validation: value must be present/non-empty
|
|
32
|
+
nullable?: boolean // DB truth: set iff the drizzle column is NOT .notNull() (array items: always false)
|
|
33
|
+
/** Client-side hides the field unless the condition holds against the current record (or, for
|
|
34
|
+
* an array/block item field, the current row). Server-side, `required` is only enforced when
|
|
35
|
+
* the condition holds (evaluated against the submitted body/row) — a conditionally-hidden
|
|
36
|
+
* required field never blocks save. `field` names a SIBLING field in the same record/row —
|
|
37
|
+
* cross-row or cross-record conditions aren't supported. Exactly one comparator per condition
|
|
38
|
+
* (no AND/OR combination in v1 — compose by gating a `group` field instead, if you need more
|
|
39
|
+
* than one condition to control several fields at once). `gt`/`gte`/`lt`/`lte` coerce both
|
|
40
|
+
* sides with `Number(...)` — only meaningful against a `number`/`rating` sibling field. */
|
|
41
|
+
visibleIf?:
|
|
42
|
+
| { field: string; equals: unknown }
|
|
43
|
+
| { field: string; notEquals: unknown }
|
|
44
|
+
| { field: string; in: unknown[] }
|
|
45
|
+
| { field: string; gt: number }
|
|
46
|
+
| { field: string; gte: number }
|
|
47
|
+
| { field: string; lt: number }
|
|
48
|
+
| { field: string; lte: number }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type FieldBase = FieldCore & {
|
|
52
|
+
group: string // MUST match an EntryConfig['groups'][n].id
|
|
53
|
+
readOnly?: boolean // rendered disabled; never accepted from the client
|
|
54
|
+
// Never rendered as a form control at all (not even disabled) — but still gets a blank value,
|
|
55
|
+
// still round-trips through save/read, and IS accepted from the client (unlike readOnly, which
|
|
56
|
+
// renders disabled and is stripped from the submitted body). For a value set by something other
|
|
57
|
+
// than the admin form (seed data, automation) that shouldn't be exposed for manual editing.
|
|
58
|
+
hidden?: boolean
|
|
59
|
+
listColumn?: boolean // include as a ListingView column
|
|
60
|
+
listPrimary?: boolean // exactly one per collection: the linked title cell
|
|
61
|
+
searchable?: boolean // joined into the ?q= ILIKE OR-clause (text/textarea only)
|
|
62
|
+
sortable?: boolean // allowed value for ?sortBy=
|
|
63
|
+
filterable?: boolean // exposed in FilterBuilder + server filter whitelist
|
|
64
|
+
/** Only honored on text/textarea/richtext. Storage becomes a per-locale map (see
|
|
65
|
+
* `src/lib/collections/locale.ts`) instead of the field's normal scalar shape — declare the
|
|
66
|
+
* drizzle column as `jsonb('…').$type<Record<Locale,string>>()`, never the type's normal
|
|
67
|
+
* column type. `required` is enforced only for the default locale; other locales may stay
|
|
68
|
+
* blank (the public/consuming side is expected to fall back to the default locale). */
|
|
69
|
+
localized?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
|
|
73
|
+
|
|
74
|
+
export type FieldConfig =
|
|
75
|
+
| (FieldBase & {
|
|
76
|
+
type: 'text'
|
|
77
|
+
maxLength?: number
|
|
78
|
+
// 'password' renders masked with a show/hide toggle and formats as •••••••• in read-only
|
|
79
|
+
// views and list columns. It does NOT encrypt at rest or redact the value from API reads —
|
|
80
|
+
// for a real secret needing that, use a hand-written encrypted column (see
|
|
81
|
+
// emailSettings.encryptedPassword / webhooks.encryptedSecret) outside the generic engine.
|
|
82
|
+
// 'tel' is a loose shape check (digits/spaces/+-()), not real phone-number validation.
|
|
83
|
+
format?: 'plain' | 'slug' | 'email' | 'url' | 'password' | 'tel'
|
|
84
|
+
/** Only honored with format: 'slug'. When the submitted value is blank the server
|
|
85
|
+
* derives it by slugifying this sibling field. Blank source ⇒ `draft-<id>`. */
|
|
86
|
+
slugFrom?: string
|
|
87
|
+
/** Renders an autocomplete + inline "+ Create new …" sourced from another collection's
|
|
88
|
+
* titleField. The STORED VALUE STAYS A PLAIN STRING (the label), not a foreign key —
|
|
89
|
+
* this is deliberately NOT a `relation`. */
|
|
90
|
+
suggestionsFrom?: { collection: CollectionSlug; createLabel?: string }
|
|
91
|
+
})
|
|
92
|
+
| (FieldBase & { type: 'textarea'; maxLength?: number; rows?: number })
|
|
93
|
+
// display: 'range' renders <input type=range> (min/max required to be meaningful) instead of a
|
|
94
|
+
// number box; 'percentage' keeps the number box but formats with a trailing % on read/list.
|
|
95
|
+
| (FieldBase & { type: 'number'; min?: number; max?: number; integer?: boolean; display?: 'plain' | 'currency' | 'percentage' | 'range' })
|
|
96
|
+
| (FieldBase & { type: 'boolean'; display?: 'toggle' | 'checkbox'; checkboxLabel?: string })
|
|
97
|
+
// display: 'radio' renders native radio inputs instead of a <select> dropdown — same
|
|
98
|
+
// storage/validation/options either way, purely a widget choice (mirrors boolean.display).
|
|
99
|
+
| (FieldBase & { type: 'select'; options: SelectOption[]; display?: 'dropdown' | 'radio' })
|
|
100
|
+
// drizzle: text('…') holding a 6-digit hex string like '#3366ff' (what <input type=color> produces)
|
|
101
|
+
| (FieldBase & { type: 'color' })
|
|
102
|
+
// drizzle: integer('…'), 0 = unrated. `max` is the star count (default 5).
|
|
103
|
+
| (FieldBase & { type: 'rating'; max?: number })
|
|
104
|
+
| (FieldBase & { type: 'date' }) // drizzle date() -> 'YYYY-MM-DD' string
|
|
105
|
+
| (FieldBase & { type: 'time' }) // drizzle time() -> 'HH:MM:SS' string
|
|
106
|
+
| (FieldBase & { type: 'datetime' }) // drizzle timestamp() -> Date, JSON'd as ISO
|
|
107
|
+
// hasMany: true stores an array of ids (jsonb, NEVER nullable, blank is []) instead of a single
|
|
108
|
+
// id (text, single FK). Declare the drizzle column accordingly — the two are different column
|
|
109
|
+
// TYPES, not a runtime toggle on the same column. Never filterable/searchable/sortable when
|
|
110
|
+
// hasMany (jsonb array); listColumn is fine (renders joined labels).
|
|
111
|
+
| (FieldBase & { type: 'relation'; relationTo: CollectionSlug; hasMany?: boolean })
|
|
112
|
+
// Read-only, no backing column — resolved at GET time by querying `collection` for rows whose
|
|
113
|
+
// `on` field points back at this record's id (the reverse of a `relation`). Payload's `join`.
|
|
114
|
+
// Never accepted from the client, never searchable/sortable/filterable. `listColumn: true` IS
|
|
115
|
+
// supported — the list route resolves every listColumn join with ONE batched query per field
|
|
116
|
+
// (grouped by `on`'s value across the current page's rows), the same way relation labels are
|
|
117
|
+
// batched — not one query per row.
|
|
118
|
+
| (FieldBase & { type: 'join'; collection: CollectionSlug; on: string })
|
|
119
|
+
// drizzle: jsonb('…').$type<RichTextDocument>().notNull().default({ type: 'doc', content: [{ type: 'paragraph' }] })
|
|
120
|
+
| (FieldBase & { type: 'richtext' })
|
|
121
|
+
// drizzle: text('…') nullable, or text('…').notNull() for required — a required image renders no Remove button
|
|
122
|
+
| (FieldBase & { type: 'image'; aspect?: number; allowUrlInput?: boolean })
|
|
123
|
+
// drizzle: jsonb('…').$type<string[]>().notNull().default([]) — ALWAYS .notNull(), never nullable
|
|
124
|
+
| (FieldBase & { type: 'tags'; maxTags?: number; maxTagLength?: number })
|
|
125
|
+
// drizzle: jsonb('…').$type<string[]>().notNull().default([]) — ALWAYS .notNull(), never nullable.
|
|
126
|
+
// A CLOSED option set (validated server-side); `tags` is the freeform counterpart.
|
|
127
|
+
| (FieldBase & { type: 'multiselect'; options: SelectOption[] })
|
|
128
|
+
// drizzle: jsonb('…').$type<ItemShape[]>().notNull().default([]) — ALWAYS .notNull(), never nullable.
|
|
129
|
+
// A repeatable list of structured rows (Payload's `array` / Filament's `Repeater`). Item
|
|
130
|
+
// sub-fields are intentionally restricted to `ArrayItemFieldConfig` below — no relation
|
|
131
|
+
// (would need relationOptions threaded per row), richtext/tags/multiselect, or nested array.
|
|
132
|
+
| (FieldBase & { type: 'array'; fields: ArrayItemFieldConfig[]; minItems?: number; maxItems?: number; itemLabel?: string })
|
|
133
|
+
// drizzle: jsonb('…').$type<BlockRow[]>().notNull().default([]) — ALWAYS .notNull(), never nullable.
|
|
134
|
+
// A repeatable list where each row can be one of several registered shapes (Payload's
|
|
135
|
+
// `blocks` / Sanity's portable blocks) — an `array` where the row shape itself is a
|
|
136
|
+
// discriminated union. Each stored row is `{ blockType, ...that type's own field values }`.
|
|
137
|
+
| (FieldBase & { type: 'blocks'; blockTypes: BlockTypeDef[]; minItems?: number; maxItems?: number })
|
|
138
|
+
// drizzle: jsonb('…').$type<Record<string,unknown>>().notNull().default({}) — ALWAYS .notNull().
|
|
139
|
+
// A nested object WITHOUT repetition (unlike array/blocks, always exactly one occurrence) —
|
|
140
|
+
// Payload's `group`. Own fields reuse `BlockFieldConfig` (scalar + one level of array), same
|
|
141
|
+
// nesting cap as a block. Not itself nestable inside array/block items in v1.
|
|
142
|
+
| (FieldBase & { type: 'group'; fields: BlockFieldConfig[] })
|
|
143
|
+
// drizzle: jsonb('…'), any JSON-serializable value. No schema — a raw JSON/code editor
|
|
144
|
+
// (textarea of JSON text, parsed client-side). For structured, validated data prefer a proper
|
|
145
|
+
// field type; this is the escape hatch when nothing else fits.
|
|
146
|
+
| (FieldBase & { type: 'json' })
|
|
147
|
+
// drizzle: jsonb('…').$type<{lat:number;lng:number}>() — plain lat/lng number inputs, no map
|
|
148
|
+
// picker (no map library dependency in this project; wire one later if actually needed).
|
|
149
|
+
| (FieldBase & { type: 'point' })
|
|
150
|
+
// drizzle: text('…'), same storage shape as `image` (a '/uploads/…' path or absolute URL) but
|
|
151
|
+
// for any file type the upload endpoint already allowlists (images/video/audio/pdf — see
|
|
152
|
+
// `/api/admin/upload`). `accept` is passed straight to the browser's file picker; the server
|
|
153
|
+
// allowlist is the real gate, `accept` is just a UX hint.
|
|
154
|
+
| (FieldBase & { type: 'file'; accept?: string })
|
|
155
|
+
|
|
156
|
+
export type FieldType = FieldConfig['type']
|
|
157
|
+
|
|
158
|
+
// Only the type-specific extras from FieldConfig's corresponding variant, plus FieldCore
|
|
159
|
+
// (no group/readOnly/hidden/listColumn/listPrimary/searchable/sortable/filterable — none of
|
|
160
|
+
// those mean anything for a field nested inside an array row). Derived via Omit, not
|
|
161
|
+
// hand-duplicated, so a new prop added to one of these variants is automatically available on
|
|
162
|
+
// array items too. Deliberately excludes: relation/join (external data/queries), richtext/tags/
|
|
163
|
+
// multiselect (editor complexity), array/blocks/group (nesting depth), and `localized` (a
|
|
164
|
+
// per-locale map inside a repeater row is more complexity than it's worth for v1).
|
|
165
|
+
type ArrayItemFieldType = 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'date' | 'image' | 'color' | 'rating' | 'json' | 'point' | 'file'
|
|
166
|
+
export type ArrayItemFieldConfig = DistributiveOmit<
|
|
167
|
+
Extract<FieldConfig, { type: ArrayItemFieldType }>,
|
|
168
|
+
'group' | 'readOnly' | 'hidden' | 'listColumn' | 'listPrimary' | 'searchable' | 'sortable' | 'filterable' | 'localized'
|
|
169
|
+
>
|
|
170
|
+
|
|
171
|
+
// A block's own fields: any scalar item type, OR one level of `array`-of-scalars (so e.g. a
|
|
172
|
+
// "Stats" block can have a repeatable list of {label, value} rows). Deliberately NOT recursive —
|
|
173
|
+
// the nested array's `fields` is the plain, non-recursive `ArrayItemFieldConfig`, so nesting is
|
|
174
|
+
// capped at exactly block -> array -> scalar. No array-of-arrays, no block-in-block.
|
|
175
|
+
export type BlockFieldConfig = ArrayItemFieldConfig | (FieldCore & { type: 'array'; fields: ArrayItemFieldConfig[]; minItems?: number; maxItems?: number; itemLabel?: string })
|
|
176
|
+
export type BlockTypeDef = { type: string; label: string; fields: BlockFieldConfig[] }
|
|
177
|
+
|
|
178
|
+
/** Shared parameter type for the engine functions (`blankValueForField`, `fieldSchema`,
|
|
179
|
+
* `DynamicField`) that must handle a top-level field, an array item, or a block field —
|
|
180
|
+
* `BlockFieldConfig` already includes `ArrayItemFieldConfig`, so this is the full set. */
|
|
181
|
+
export type AnyFieldConfig = FieldConfig | ArrayItemFieldConfig | BlockFieldConfig
|
|
182
|
+
|
|
183
|
+
export type CollectionGroup = { id: string; label: string; description?: string }
|
|
184
|
+
|
|
185
|
+
export type WorkflowConfig = {
|
|
186
|
+
publish: true
|
|
187
|
+
audit: boolean
|
|
188
|
+
review: boolean
|
|
189
|
+
trash: boolean
|
|
190
|
+
approval?: 'setting' | 'always' | 'never'
|
|
191
|
+
sortOrder?: boolean
|
|
192
|
+
duplicate?: boolean
|
|
193
|
+
webhooks?: boolean
|
|
194
|
+
/** Requires `...schedulingColumns` on the table. `dateField`/`timeField` name DECLARED
|
|
195
|
+
* fields (a `date` and an optional `time`) that the editor combines, in the browser's
|
|
196
|
+
* local timezone, into the engine-owned `scheduledAt`. */
|
|
197
|
+
scheduling?: { dateField: string; timeField?: string }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const workflowColumnKeys = [
|
|
201
|
+
'createdBy',
|
|
202
|
+
'ownerId',
|
|
203
|
+
'updatedBy',
|
|
204
|
+
'reviewerId',
|
|
205
|
+
'approverId',
|
|
206
|
+
'reviewStatus',
|
|
207
|
+
'reviewedAt',
|
|
208
|
+
'approvedAt',
|
|
209
|
+
'published',
|
|
210
|
+
'publishedAt',
|
|
211
|
+
'deletedAt',
|
|
212
|
+
'sortOrder',
|
|
213
|
+
'scheduledAt',
|
|
214
|
+
] as const
|
|
215
|
+
export type WorkflowColumnKey = typeof workflowColumnKeys[number]
|
|
216
|
+
|
|
217
|
+
type EntryBase = {
|
|
218
|
+
label: string
|
|
219
|
+
description?: string
|
|
220
|
+
touchUpdatedAt: boolean
|
|
221
|
+
groups: CollectionGroup[]
|
|
222
|
+
fields: FieldConfig[]
|
|
223
|
+
workflow?: WorkflowConfig
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type CollectionConfig = EntryBase & {
|
|
227
|
+
kind: 'collection'
|
|
228
|
+
slug: CollectionSlug
|
|
229
|
+
labelSingular: string // 'Add product', 'New product'
|
|
230
|
+
titleField: string // field key used as the row title AND as the relation label
|
|
231
|
+
defaultSort: { field: string; dir: 'asc' | 'desc' }
|
|
232
|
+
/** Public URL template for the list's "Preview" row action, e.g. '/services/:slug'.
|
|
233
|
+
* ':<fieldKey>' is substituted from the row. Omitted ⇒ no Preview action. */
|
|
234
|
+
previewPath?: string
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type SingleConfig = EntryBase & {
|
|
238
|
+
kind: 'single'
|
|
239
|
+
slug: SingleSlug
|
|
240
|
+
fixedKey: { column: string; value: string }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export type EntryConfig = CollectionConfig | SingleConfig
|
|
244
|
+
|
|
245
|
+
export const workflowListFilters = [
|
|
246
|
+
{ key: 'published', label: 'Status', type: 'boolean' as const, requires: 'publish' as const },
|
|
247
|
+
{
|
|
248
|
+
key: 'reviewStatus',
|
|
249
|
+
label: 'Approval status',
|
|
250
|
+
type: 'select' as const,
|
|
251
|
+
requires: 'review' as const,
|
|
252
|
+
options: [
|
|
253
|
+
{ value: 'draft', label: 'Draft' },
|
|
254
|
+
{ value: 'in_review', label: 'In review' },
|
|
255
|
+
{ value: 'reviewed', label: 'Reviewed' },
|
|
256
|
+
{ value: 'approved', label: 'Approved' },
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
] as const
|
|
260
|
+
export const workflowSortKeys = ['published', 'publishedAt', 'sortOrder', 'reviewStatus', 'scheduledAt'] as const
|
|
261
|
+
|
|
262
|
+
/** Engine-owned, default-hidden list columns. `requires` is a WorkflowConfig flag. */
|
|
263
|
+
export const workflowListColumns = [
|
|
264
|
+
{ key: 'reviewStatus', label: 'Approval status', requires: 'review' as const, kind: 'reviewStatus' as const },
|
|
265
|
+
{ key: 'ownerId', label: 'Owner', requires: 'audit' as const, kind: 'user' as const },
|
|
266
|
+
{ key: 'reviewerId', label: 'Reviewer', requires: 'review' as const, kind: 'user' as const },
|
|
267
|
+
{ key: 'createdBy', label: 'Created by', requires: 'audit' as const, kind: 'user' as const },
|
|
268
|
+
{ key: 'updatedBy', label: 'Last updated by', requires: 'audit' as const, kind: 'user' as const },
|
|
269
|
+
{ key: 'approverId', label: 'Approved by', requires: 'review' as const, kind: 'user' as const },
|
|
270
|
+
{ key: 'publishedAt', label: 'Published', requires: 'publish' as const, kind: 'datetime' as const },
|
|
271
|
+
{ key: 'scheduledAt', label: 'Scheduled', requires: 'scheduling' as const, kind: 'datetime' as const },
|
|
272
|
+
] as const
|
|
273
|
+
|
|
274
|
+
// Authoring rules for defs/*.ts:
|
|
275
|
+
// - nullable: true iff the drizzle column is NOT .notNull(). Drives whether "empty" becomes
|
|
276
|
+
// null or the type's zero value.
|
|
277
|
+
// - required: true for any .notNull() column with no meaningful zero value.
|
|
278
|
+
// - readOnly: true for createdAt / updatedAt / any DB-managed column.
|
|
279
|
+
// - Exactly one field per collection has listPrimary: true, and it should also be listColumn: true.
|
|
280
|
+
// - searchable is only honored on text / textarea.
|
|
281
|
+
// - filterable is only honored on boolean / select / relation / text.
|
|
282
|
+
// - richtext columns must never be nullable — the editor can't represent null, blank is
|
|
283
|
+
// the empty tiptap doc.
|
|
284
|
+
// - image is a plain text() column holding a '/uploads/…' path or an absolute URL.
|
|
285
|
+
// - tags columns must never be nullable — blank is [].
|
|
286
|
+
// - searchable/filterable are NOT honored on richtext / image / tags (jsonb columns would
|
|
287
|
+
// need a cast + GIN index for ILIKE/containment that the engine doesn't build).
|
|
288
|
+
// - time is a drizzle time() column holding 'HH:MM:SS'; it is nullable in practice — blank is null.
|
|
289
|
+
// - multiselect columns must never be nullable — blank is []. searchable/filterable are NOT
|
|
290
|
+
// honored (jsonb containment would need a GIN index the engine doesn't build).
|
|
291
|
+
// - text.suggestionsFrom does NOT change storage: the column stays plain text holding the label.
|
|
292
|
+
// Use `relation` when you actually want a foreign key.
|
|
293
|
+
// - text.slugFrom is only honored when format === 'slug'.
|
|
294
|
+
// - A slug is the URL segment, the RBAC key, the content-view key, and the webhook prefix.
|
|
295
|
+
// It does NOT have to equal the drizzle export name — `query.ts`'s `tables` map is the
|
|
296
|
+
// single place the two are bound (e.g. 'blog' -> blogPosts, 'homepage' -> pages).
|
|
297
|
+
// - array columns must never be nullable — blank is []. searchable/filterable/sortable are
|
|
298
|
+
// NOT honored (jsonb, and item shape is arbitrary). listColumn renders an item-count summary,
|
|
299
|
+
// not the row contents. Item sub-fields (`field.fields`) never declare group/readOnly/
|
|
300
|
+
// listColumn/listPrimary/searchable/sortable/filterable — those are meaningless nested.
|
|
301
|
+
// - text.format: 'password' does not change storage or validation (still plain text, still
|
|
302
|
+
// maxLength/required) — only the widget (masked + reveal toggle) and read-side masking
|
|
303
|
+
// (list columns, read-only views) change. Never mark a password field searchable/sortable.
|
|
304
|
+
// - select.display: 'radio' does not change storage/validation either — still the same
|
|
305
|
+
// options/value shape as a dropdown select, only the widget changes.
|
|
306
|
+
// - color is a plain text() column holding a 6-digit hex string ('#rrggbb'); blank defaults to
|
|
307
|
+
// '#000000' (not '""') so the swatch always has a valid value to show.
|
|
308
|
+
// - blocks columns must never be nullable — blank is []. searchable/filterable/sortable are NOT
|
|
309
|
+
// honored, same as array. A block's own fields (`BlockTypeDef.fields`) never declare
|
|
310
|
+
// group/readOnly/listColumn/listPrimary/searchable/sortable/filterable, same as array items —
|
|
311
|
+
// the only nesting a block's fields may use is one `array` field of scalar items; no
|
|
312
|
+
// array-of-arrays and no block-in-block. `BlockTypeDef.type` values must be unique within one
|
|
313
|
+
// `blocks` field's `blockTypes` (it's the stored discriminant).
|
|
314
|
+
// - hidden: true is a top-level-only modifier (never on an array/block item field). The field
|
|
315
|
+
// still gets a blank value, still round-trips through save/read, and IS accepted from the
|
|
316
|
+
// client — it's just never rendered as a form control. Different from readOnly (renders
|
|
317
|
+
// disabled, stripped from the submitted body).
|
|
318
|
+
// - text.format: 'tel' is a loose shape check, not real phone-number validation — same
|
|
319
|
+
// storage/required rules as plain text.
|
|
320
|
+
// - number.display: 'percentage' only changes read-side formatting (adds a trailing %); the
|
|
321
|
+
// edit-time control is still a plain number input, same as 'currency' today.
|
|
322
|
+
// - number.display: 'range' DOES change the edit-time control (<input type=range>) — declare
|
|
323
|
+
// `min`/`max` or the slider has no meaningful bounds.
|
|
324
|
+
// - rating is an integer() column, 0 = unrated (not null unless explicitly nullable). required
|
|
325
|
+
// means "must pick at least 1 star", not "column is non-null".
|
|
326
|
+
// - relation.hasMany changes the drizzle column type entirely (jsonb string[] vs text) — never
|
|
327
|
+
// flip it on an existing field without a migration. hasMany relations are never
|
|
328
|
+
// filterable/searchable/sortable.
|
|
329
|
+
// - join has NO backing column at all — never declare `readOnly: false` on it (it's implicitly
|
|
330
|
+
// read-only), never make it searchable/sortable/filterable/listColumn. `on` must name a
|
|
331
|
+
// declared `relation` field (not hasMany) on the target collection.
|
|
332
|
+
// - group/json/point/file are plain additions — group is jsonb object (NOT NULL, blank {}), json
|
|
333
|
+
// is jsonb any-value, point is jsonb {lat,lng}, file is text() exactly like image but for any
|
|
334
|
+
// allowlisted upload mime type.
|
|
335
|
+
// - visibleIf/localized are FieldCore/FieldBase modifiers, not new types — see their own doc
|
|
336
|
+
// comments above. Neither is available on array/block item fields.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { blankValueForField, isFieldVisible, richTextToText } from './values'
|
|
4
|
+
import { fallbackLocaleConfig, type LocaleConfig } from './locale'
|
|
5
|
+
import type { RichTextDocument } from '../richtext'
|
|
6
|
+
import type { AnyFieldConfig, EntryConfig } from './types'
|
|
7
|
+
|
|
8
|
+
export type ParseResult =
|
|
9
|
+
| { ok: true; data: Record<string, unknown> }
|
|
10
|
+
| { ok: false; error: string }
|
|
11
|
+
|
|
12
|
+
/** 'live' validates required fields at full strength (the default). 'draft' relaxes required-ness —
|
|
13
|
+
* used only by workflow-enabled collections saving an unpublished draft. */
|
|
14
|
+
export type ParseMode = 'draft' | 'live'
|
|
15
|
+
|
|
16
|
+
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
17
|
+
const uploadPathPattern = /^\/uploads\/(?:[a-z0-9-]+\/)?[a-z0-9-]+\.(jpg|png|webp)$/
|
|
18
|
+
// A generic upload path — same shape as an image's, but any extension the upload endpoint
|
|
19
|
+
// allowlists (mp4/webm/mov/mp3/wav/ogg/pdf/etc.), not just image formats.
|
|
20
|
+
const uploadFilePattern = /^\/uploads\/(?:[a-z0-9-]+\/)?[a-z0-9-]+\.[a-z0-9]+$/
|
|
21
|
+
// Loose shape check, not real phone-number validation — see FieldConfig text.format: 'tel'.
|
|
22
|
+
const telPattern = /^[+\d][\d\s\-().]{5,}$/
|
|
23
|
+
|
|
24
|
+
function relaxIfHidden<T extends AnyFieldConfig>(field: T, row: Record<string, unknown>): T {
|
|
25
|
+
return field.visibleIf && !isFieldVisible(field, row) ? { ...field, required: false } : field
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Reparents a nested `z.object(...).safeParse` failure's issues onto the outer `ctx`, prefixed
|
|
29
|
+
* by the row `index` (array/blocks) or left as-is (group, which has no index of its own). */
|
|
30
|
+
function addIssuesAt(ctx: z.RefinementCtx, index: number | undefined, error: z.ZodError) {
|
|
31
|
+
for (const issue of error.issues) ctx.addIssue({ ...issue, path: index === undefined ? issue.path : [index, ...issue.path] })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function baseSchema(field: AnyFieldConfig, required: boolean, mode: ParseMode): z.ZodTypeAny {
|
|
35
|
+
switch (field.type) {
|
|
36
|
+
case 'text': {
|
|
37
|
+
let schema = z.string().max(field.maxLength ?? 255)
|
|
38
|
+
// Enforce the slug shape whenever the value is being validated at full strength, and
|
|
39
|
+
// unconditionally for slug fields that were never declared `required` (e.g. shop_*
|
|
40
|
+
// productCategories.slug, which is always parsed in 'live' mode anyway).
|
|
41
|
+
if (field.format === 'slug' && (required || !field.required)) schema = schema.regex(slugPattern, 'Use lowercase letters, numbers, and hyphens.')
|
|
42
|
+
if (field.format === 'email') schema = schema.email('Enter a valid email address.')
|
|
43
|
+
if (field.format === 'url') schema = schema.url('Enter a valid URL.')
|
|
44
|
+
if (field.format === 'tel') schema = schema.regex(telPattern, 'Enter a valid phone number.')
|
|
45
|
+
return required ? schema.trim().min(1, 'This field is required.') : schema
|
|
46
|
+
}
|
|
47
|
+
case 'textarea': {
|
|
48
|
+
const schema = z.string().max(field.maxLength ?? 5000)
|
|
49
|
+
return required ? schema.trim().min(1, 'This field is required.') : schema
|
|
50
|
+
}
|
|
51
|
+
case 'number': {
|
|
52
|
+
let schema = z.number()
|
|
53
|
+
if (field.integer) schema = schema.int('Must be a whole number.')
|
|
54
|
+
if (typeof field.min === 'number') schema = schema.min(field.min)
|
|
55
|
+
if (typeof field.max === 'number') schema = schema.max(field.max)
|
|
56
|
+
return schema
|
|
57
|
+
}
|
|
58
|
+
case 'boolean':
|
|
59
|
+
return z.boolean()
|
|
60
|
+
case 'color':
|
|
61
|
+
return z.string().regex(/^#[0-9a-f]{6}$/i, 'Use a hex color like #3366ff.')
|
|
62
|
+
case 'rating': {
|
|
63
|
+
const schema = z.number().int().min(0).max(field.max ?? 5)
|
|
64
|
+
return required ? schema.refine((value) => value > 0, 'Pick a rating.') : schema
|
|
65
|
+
}
|
|
66
|
+
case 'select': {
|
|
67
|
+
const values = field.options.map((option) => option.value)
|
|
68
|
+
// Deliberately a refine rather than z.enum: z.enum's tuple requirement is awkward
|
|
69
|
+
// with a runtime array and its typing varies across zod majors.
|
|
70
|
+
return z.string().refine((value) => values.includes(value), 'Not a valid option.')
|
|
71
|
+
}
|
|
72
|
+
case 'date':
|
|
73
|
+
return z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use the YYYY-MM-DD format.')
|
|
74
|
+
case 'datetime':
|
|
75
|
+
return z.coerce.date()
|
|
76
|
+
case 'relation': {
|
|
77
|
+
if (field.hasMany) {
|
|
78
|
+
const list = z.array(z.string().trim().min(1).max(200)).max(200)
|
|
79
|
+
return required ? list.min(1, 'Pick at least one related record.') : list
|
|
80
|
+
}
|
|
81
|
+
return required ? z.string().trim().min(1, 'Pick a related record.').max(200) : z.string().max(200)
|
|
82
|
+
}
|
|
83
|
+
case 'join':
|
|
84
|
+
return z.unknown() // no backing column, never accepted from the client — see collectionBodySchema
|
|
85
|
+
case 'richtext': {
|
|
86
|
+
const doc = z
|
|
87
|
+
.object({ type: z.literal('doc'), content: z.array(z.unknown()).optional() })
|
|
88
|
+
.passthrough()
|
|
89
|
+
.transform((value) => value as unknown as RichTextDocument)
|
|
90
|
+
return required
|
|
91
|
+
? doc.refine((value) => richTextToText(value, 400).trim().length > 0, 'This field is required.')
|
|
92
|
+
: doc
|
|
93
|
+
}
|
|
94
|
+
case 'image':
|
|
95
|
+
// '' never reaches here for a non-required field — fieldSchema's preprocess collapses it
|
|
96
|
+
// to the blank value first. A required image therefore rejects '' via this union.
|
|
97
|
+
return z.union([
|
|
98
|
+
z.string().url('Enter a valid image URL.'),
|
|
99
|
+
z.string().regex(uploadPathPattern, 'Upload a file, or paste a full https:// URL.'),
|
|
100
|
+
])
|
|
101
|
+
case 'tags': {
|
|
102
|
+
const list = z.array(z.string().trim().min(1).max(field.maxTagLength ?? 40)).max(field.maxTags ?? 20)
|
|
103
|
+
return required ? list.min(1, 'Add at least one tag.') : list
|
|
104
|
+
}
|
|
105
|
+
case 'time':
|
|
106
|
+
return z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Use the HH:MM format.')
|
|
107
|
+
case 'multiselect': {
|
|
108
|
+
const values = field.options.map((option) => option.value)
|
|
109
|
+
const list = z.array(z.string().refine((value) => values.includes(value), 'Not a valid option.')).max(field.options.length)
|
|
110
|
+
return required ? list.min(1, 'Choose at least one option.') : list
|
|
111
|
+
}
|
|
112
|
+
// array/blocks/group all validate each row/object against a schema built FROM THAT ROW,
|
|
113
|
+
// not a single static schema shared by every row — that's what lets an item sub-field's
|
|
114
|
+
// own `visibleIf` (gated on a SIBLING field in the same row) relax `required` server-side
|
|
115
|
+
// too, the same way top-level `visibleIf` already does via `collectionBodySchema`. Plain
|
|
116
|
+
// `z.array(z.object(staticShape))` can't do this: the shape has to be recomputed per row.
|
|
117
|
+
case 'array': {
|
|
118
|
+
let base = z.array(z.unknown())
|
|
119
|
+
if (typeof field.maxItems === 'number') base = base.max(field.maxItems)
|
|
120
|
+
const min = required ? Math.max(field.minItems ?? 1, 1) : field.minItems
|
|
121
|
+
if (typeof min === 'number') base = base.min(min, required ? 'Add at least one item.' : `Add at least ${min}.`)
|
|
122
|
+
return base.transform((rows, ctx) => rows.map((row, index) => {
|
|
123
|
+
const plainRow = (row && typeof row === 'object' ? row : {}) as Record<string, unknown>
|
|
124
|
+
const itemShape = Object.fromEntries(field.fields.map((item) => [item.key, fieldSchema(relaxIfHidden(item, plainRow), mode)]))
|
|
125
|
+
const result = z.object(itemShape).safeParse(row)
|
|
126
|
+
if (!result.success) { addIssuesAt(ctx, index, result.error); return plainRow }
|
|
127
|
+
return result.data
|
|
128
|
+
}))
|
|
129
|
+
}
|
|
130
|
+
case 'blocks': {
|
|
131
|
+
let base = z.array(z.unknown())
|
|
132
|
+
if (typeof field.maxItems === 'number') base = base.max(field.maxItems)
|
|
133
|
+
const min = required ? Math.max(field.minItems ?? 1, 1) : field.minItems
|
|
134
|
+
if (typeof min === 'number') base = base.min(min, required ? 'Add at least one block.' : `Add at least ${min}.`)
|
|
135
|
+
return base.transform((rows, ctx) => rows.map((row, index) => {
|
|
136
|
+
const plainRow = (row && typeof row === 'object' ? row : {}) as Record<string, unknown>
|
|
137
|
+
const blockDef = field.blockTypes.find((def) => def.type === plainRow.blockType)
|
|
138
|
+
if (!blockDef) { ctx.addIssue({ code: 'custom', path: [index, 'blockType'], message: 'Unknown block type.' }); return plainRow }
|
|
139
|
+
const itemShape: Record<string, z.ZodTypeAny> = { blockType: z.literal(blockDef.type) }
|
|
140
|
+
for (const item of blockDef.fields) itemShape[item.key] = fieldSchema(relaxIfHidden(item, plainRow), mode)
|
|
141
|
+
const result = z.object(itemShape).safeParse(row)
|
|
142
|
+
if (!result.success) { addIssuesAt(ctx, index, result.error); return plainRow }
|
|
143
|
+
return result.data
|
|
144
|
+
}))
|
|
145
|
+
}
|
|
146
|
+
case 'group':
|
|
147
|
+
return z.unknown().transform((raw, ctx) => {
|
|
148
|
+
const plainRow = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
|
149
|
+
const itemShape = Object.fromEntries(field.fields.map((subfield) => [subfield.key, fieldSchema(relaxIfHidden(subfield, plainRow), mode)]))
|
|
150
|
+
const result = z.object(itemShape).safeParse(raw)
|
|
151
|
+
if (!result.success) { addIssuesAt(ctx, undefined, result.error); return plainRow }
|
|
152
|
+
return result.data
|
|
153
|
+
})
|
|
154
|
+
case 'json':
|
|
155
|
+
return z.unknown()
|
|
156
|
+
case 'point':
|
|
157
|
+
return z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180) })
|
|
158
|
+
case 'file':
|
|
159
|
+
return z.union([
|
|
160
|
+
z.string().url('Enter a valid file URL.'),
|
|
161
|
+
z.string().regex(uploadFilePattern, 'Upload a file, or paste a full https:// URL.'),
|
|
162
|
+
])
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Wraps the base schema so "missing"/"empty" collapses to the field's blank value.
|
|
167
|
+
* `localeConfig` defaults to the static fallback — API routes pass the live admin-configured
|
|
168
|
+
* one (see `getLocaleConfig()`) so validation actually enforces whatever locales are live, not
|
|
169
|
+
* a stale baked-in pair. */
|
|
170
|
+
export function fieldSchema(field: AnyFieldConfig, mode: ParseMode = 'live', localeConfig: LocaleConfig = fallbackLocaleConfig): z.ZodTypeAny {
|
|
171
|
+
if ('localized' in field && field.localized) {
|
|
172
|
+
// required only enforced for the default locale — other locales may stay untranslated.
|
|
173
|
+
return z.object(Object.fromEntries(localeConfig.codes.map((locale) => [
|
|
174
|
+
locale,
|
|
175
|
+
fieldSchema({ ...field, localized: false, required: locale === localeConfig.default ? field.required : false } as AnyFieldConfig, mode, localeConfig),
|
|
176
|
+
])))
|
|
177
|
+
}
|
|
178
|
+
const blank = blankValueForField(field, localeConfig)
|
|
179
|
+
const required = mode === 'live' && Boolean(field.required)
|
|
180
|
+
const inner = baseSchema(field, required, mode)
|
|
181
|
+
if (required && !field.nullable) return inner
|
|
182
|
+
return z.preprocess(
|
|
183
|
+
(value) => (value === undefined || value === null || value === '' ? undefined : value),
|
|
184
|
+
inner.optional().transform((value) => (value === undefined ? blank : value)),
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `body` (when given) relaxes `required` for any field whose `visibleIf` doesn't hold against
|
|
189
|
+
* it — a conditionally-hidden required field never blocks save. Evaluated against the raw
|
|
190
|
+
* submitted body, not the stored row, since the gating field's new value is right there in it.
|
|
191
|
+
* `allowedFields` (when given) ALSO relaxes `required` for any field the caller has no write
|
|
192
|
+
* permission for — `applyFieldPermissions` silently overwrites those with the row's existing
|
|
193
|
+
* value after validation anyway, so requiring them here would let a role without access to one
|
|
194
|
+
* required field block every OTHER edit to the record too (it can never submit a real value for
|
|
195
|
+
* a field it can't even see, especially now that read-redaction omits it from the record
|
|
196
|
+
* entirely — see `redactFieldsForRead`). */
|
|
197
|
+
export function collectionBodySchema(config: EntryConfig, mode: ParseMode = 'live', body?: Record<string, unknown>, allowedFields?: string[], localeConfig: LocaleConfig = fallbackLocaleConfig) {
|
|
198
|
+
const shape: Record<string, z.ZodTypeAny> = {}
|
|
199
|
+
for (const field of config.fields) {
|
|
200
|
+
if (field.readOnly || field.type === 'join') continue // never accepted from the client
|
|
201
|
+
const hidden = field.visibleIf && body && !isFieldVisible(field, body)
|
|
202
|
+
const unwritable = allowedFields && !allowedFields.includes(field.key)
|
|
203
|
+
const effective = hidden || unwritable ? { ...field, required: false } : field
|
|
204
|
+
shape[field.key] = fieldSchema(effective, mode, localeConfig)
|
|
205
|
+
}
|
|
206
|
+
return z.object(shape).strip() // unknown keys (including id) are dropped
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function formatZodError(error: z.ZodError): string {
|
|
210
|
+
return error.issues.map((issue) => `${issue.path.join(' ')}: ${issue.message}`).join(' · ')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function parseCollectionBody(config: EntryConfig, body: unknown, mode: ParseMode = 'live', allowedFields?: string[], localeConfig: LocaleConfig = fallbackLocaleConfig): ParseResult {
|
|
214
|
+
const rawBody = body && typeof body === 'object' ? (body as Record<string, unknown>) : undefined
|
|
215
|
+
const parsed = collectionBodySchema(config, mode, rawBody, allowedFields, localeConfig).safeParse(body)
|
|
216
|
+
return parsed.success ? { ok: true, data: parsed.data } : { ok: false, error: formatZodError(parsed.error) }
|
|
217
|
+
}
|