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.
Files changed (179) hide show
  1. package/README.md +186 -0
  2. package/drizzle.kilo.config.ts +17 -0
  3. package/package.json +97 -0
  4. package/src/admin/AiSettingsEditor.tsx +48 -0
  5. package/src/admin/AiWritingAssistant.tsx +159 -0
  6. package/src/admin/ApiTokensEditor.tsx +69 -0
  7. package/src/admin/ArrayField.tsx +118 -0
  8. package/src/admin/BlocksField.tsx +131 -0
  9. package/src/admin/Button.tsx +31 -0
  10. package/src/admin/CloseButton.tsx +18 -0
  11. package/src/admin/CmsLocalesProvider.tsx +19 -0
  12. package/src/admin/CmsSidebar.tsx +147 -0
  13. package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
  14. package/src/admin/CmsThemeProvider.tsx +100 -0
  15. package/src/admin/CollectionListEditor.tsx +278 -0
  16. package/src/admin/CollectionRecordEditor.tsx +420 -0
  17. package/src/admin/ContentEditorForm.tsx +21 -0
  18. package/src/admin/ContentViewSettings.tsx +55 -0
  19. package/src/admin/DashboardBuilder.tsx +284 -0
  20. package/src/admin/DragHandle.tsx +3 -0
  21. package/src/admin/DynamicField.tsx +517 -0
  22. package/src/admin/EditorHeader.tsx +13 -0
  23. package/src/admin/EditorSkeleton.tsx +27 -0
  24. package/src/admin/EmailSettingsEditor.tsx +80 -0
  25. package/src/admin/Field.tsx +215 -0
  26. package/src/admin/FilterBuilder.tsx +62 -0
  27. package/src/admin/GroupField.tsx +61 -0
  28. package/src/admin/ImageUploader.tsx +198 -0
  29. package/src/admin/ListingSettingsModal.tsx +68 -0
  30. package/src/admin/ListingView.tsx +123 -0
  31. package/src/admin/LocalesEditor.tsx +86 -0
  32. package/src/admin/LocalizedField.tsx +46 -0
  33. package/src/admin/MapPicker.tsx +53 -0
  34. package/src/admin/MediaLibrary.tsx +483 -0
  35. package/src/admin/MediaUploader.tsx +55 -0
  36. package/src/admin/Portal.tsx +10 -0
  37. package/src/admin/PublishingPanel.tsx +102 -0
  38. package/src/admin/RecordMeta.tsx +24 -0
  39. package/src/admin/ReviewQueueEditor.tsx +103 -0
  40. package/src/admin/RichTextEditor.tsx +49 -0
  41. package/src/admin/RoleEditor.tsx +121 -0
  42. package/src/admin/RolesEditor.tsx +42 -0
  43. package/src/admin/RowActionsMenu.tsx +64 -0
  44. package/src/admin/ScheduledEditor.tsx +155 -0
  45. package/src/admin/SeoSettingsEditor.tsx +53 -0
  46. package/src/admin/SingleRecordEditor.tsx +215 -0
  47. package/src/admin/SiteSettingsEditor.tsx +96 -0
  48. package/src/admin/Toast.tsx +45 -0
  49. package/src/admin/UserEditor.tsx +64 -0
  50. package/src/admin/UsersEditor.tsx +37 -0
  51. package/src/admin/WebhooksEditor.tsx +115 -0
  52. package/src/admin/admin.css +6637 -0
  53. package/src/admin/globals.css +14 -0
  54. package/src/admin/pages/admin-index.tsx +5 -0
  55. package/src/admin/pages/ai-settings.tsx +9 -0
  56. package/src/admin/pages/api-tokens.tsx +10 -0
  57. package/src/admin/pages/collection-list.tsx +22 -0
  58. package/src/admin/pages/collection-record.tsx +31 -0
  59. package/src/admin/pages/dashboard.tsx +10 -0
  60. package/src/admin/pages/email-settings.tsx +10 -0
  61. package/src/admin/pages/layout.tsx +24 -0
  62. package/src/admin/pages/locales-settings.tsx +10 -0
  63. package/src/admin/pages/login.tsx +25 -0
  64. package/src/admin/pages/media.tsx +9 -0
  65. package/src/admin/pages/review-queue.tsx +20 -0
  66. package/src/admin/pages/role-edit.tsx +11 -0
  67. package/src/admin/pages/roles.tsx +10 -0
  68. package/src/admin/pages/scheduled.tsx +17 -0
  69. package/src/admin/pages/seo-settings.tsx +9 -0
  70. package/src/admin/pages/setup-admin.tsx +76 -0
  71. package/src/admin/pages/single-record.tsx +24 -0
  72. package/src/admin/pages/site-settings.tsx +9 -0
  73. package/src/admin/pages/user-edit.tsx +11 -0
  74. package/src/admin/pages/user-new.tsx +10 -0
  75. package/src/admin/pages/users.tsx +10 -0
  76. package/src/admin/pages/webhooks-settings.tsx +10 -0
  77. package/src/admin/useKeyedRows.ts +35 -0
  78. package/src/ai-config.ts +35 -0
  79. package/src/ai-secrets.ts +17 -0
  80. package/src/ai-types.ts +10 -0
  81. package/src/ai-writer.ts +84 -0
  82. package/src/api-tokens.ts +25 -0
  83. package/src/auth-client.ts +5 -0
  84. package/src/auth.ts +33 -0
  85. package/src/calendar-grid.ts +12 -0
  86. package/src/cli/index.mjs +149 -0
  87. package/src/cms-appearance.ts +55 -0
  88. package/src/collections/content-view.ts +69 -0
  89. package/src/collections/define.ts +17 -0
  90. package/src/collections/filters.ts +92 -0
  91. package/src/collections/index.ts +11 -0
  92. package/src/collections/join.ts +64 -0
  93. package/src/collections/locale.ts +22 -0
  94. package/src/collections/query.ts +160 -0
  95. package/src/collections/registry.ts +88 -0
  96. package/src/collections/schedule.ts +12 -0
  97. package/src/collections/server.ts +12 -0
  98. package/src/collections/single.ts +26 -0
  99. package/src/collections/table.ts +14 -0
  100. package/src/collections/types.ts +336 -0
  101. package/src/collections/validation.ts +217 -0
  102. package/src/collections/values.ts +181 -0
  103. package/src/collections/workflow.ts +90 -0
  104. package/src/config.ts +34 -0
  105. package/src/content-types.ts +218 -0
  106. package/src/dashboard.ts +39 -0
  107. package/src/db.ts +50 -0
  108. package/src/email.ts +39 -0
  109. package/src/format.ts +4 -0
  110. package/src/listQuery.ts +23 -0
  111. package/src/locale-settings.ts +23 -0
  112. package/src/media-storage.ts +19 -0
  113. package/src/permissions.ts +37 -0
  114. package/src/publishing-workflow-db.ts +11 -0
  115. package/src/publishing-workflow.ts +20 -0
  116. package/src/richtext.ts +5 -0
  117. package/src/routes/admin/ai/settings/route.ts +20 -0
  118. package/src/routes/admin/ai/write/route.ts +21 -0
  119. package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
  120. package/src/routes/admin/api-tokens/route.ts +26 -0
  121. package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
  122. package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
  123. package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
  124. package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
  125. package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
  126. package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
  127. package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
  128. package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
  129. package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
  130. package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
  131. package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
  132. package/src/routes/admin/collections/[collection]/route.ts +88 -0
  133. package/src/routes/admin/collections/route.ts +13 -0
  134. package/src/routes/admin/content-views/[type]/route.ts +25 -0
  135. package/src/routes/admin/dashboard/data/route.ts +39 -0
  136. package/src/routes/admin/dashboard/route.ts +30 -0
  137. package/src/routes/admin/email-settings/route.ts +49 -0
  138. package/src/routes/admin/email-settings/test/route.ts +18 -0
  139. package/src/routes/admin/locales/[code]/route.ts +38 -0
  140. package/src/routes/admin/locales/reorder/route.ts +18 -0
  141. package/src/routes/admin/locales/route.ts +46 -0
  142. package/src/routes/admin/media/[id]/restore/route.ts +13 -0
  143. package/src/routes/admin/media/[id]/route.ts +56 -0
  144. package/src/routes/admin/media/bulk/route.ts +46 -0
  145. package/src/routes/admin/media/folders/[id]/route.ts +34 -0
  146. package/src/routes/admin/media/route.ts +41 -0
  147. package/src/routes/admin/media/stats/route.ts +14 -0
  148. package/src/routes/admin/roles/[id]/route.ts +43 -0
  149. package/src/routes/admin/roles/route.ts +34 -0
  150. package/src/routes/admin/settings/route.ts +45 -0
  151. package/src/routes/admin/single/[type]/route.ts +54 -0
  152. package/src/routes/admin/singles/route.ts +15 -0
  153. package/src/routes/admin/upload/route.ts +55 -0
  154. package/src/routes/admin/users/[id]/route.ts +24 -0
  155. package/src/routes/admin/users/route.ts +24 -0
  156. package/src/routes/admin/webhooks/[id]/route.ts +38 -0
  157. package/src/routes/admin/webhooks/route.ts +26 -0
  158. package/src/routes/cron/publish-scheduled.ts +37 -0
  159. package/src/routes/setup/admin.ts +19 -0
  160. package/src/schema/admin-ui.ts +18 -0
  161. package/src/schema/auth.ts +49 -0
  162. package/src/schema/developer-tools.ts +44 -0
  163. package/src/schema/helpers.ts +35 -0
  164. package/src/schema/index.ts +33 -0
  165. package/src/schema/locales.ts +13 -0
  166. package/src/schema/media.ts +22 -0
  167. package/src/schema/rbac.ts +11 -0
  168. package/src/schema/settings.ts +28 -0
  169. package/src/secrets.ts +26 -0
  170. package/src/seo.ts +69 -0
  171. package/src/storage/index.ts +20 -0
  172. package/src/storage/local.ts +27 -0
  173. package/src/storage/s3-compatible.ts +31 -0
  174. package/src/storage/types.ts +8 -0
  175. package/src/storage/vercel-blob.ts +17 -0
  176. package/src/useBodyScrollLock.ts +12 -0
  177. package/src/useLocalStorageState.ts +26 -0
  178. package/src/webhook-events.ts +12 -0
  179. 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
+ }