@riducms/admin 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 (226) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +217 -0
  4. package/package.json +62 -0
  5. package/src/app/admin-core-view-route.svelte +89 -0
  6. package/src/app/admin-layout.svelte +457 -0
  7. package/src/app/admin-navigation-progress.svelte +204 -0
  8. package/src/app/admin-page-title.svelte +94 -0
  9. package/src/app/admin-provider-layer.svelte +26 -0
  10. package/src/app/admin-router-root.svelte +9 -0
  11. package/src/app/admin-routes.svelte +116 -0
  12. package/src/app/deferred-redirect.svelte +23 -0
  13. package/src/app/loading-boundary.svelte +70 -0
  14. package/src/app/not-found-route.svelte +29 -0
  15. package/src/app-meta.ts +2 -0
  16. package/src/app.css +467 -0
  17. package/src/app.svelte +88 -0
  18. package/src/assets/ridu-logo-arch.svg +3 -0
  19. package/src/assets/ridu-word-logo.svg +7 -0
  20. package/src/components/brand/ridu-logo.svelte +24 -0
  21. package/src/components/code-block/code-block.svelte +107 -0
  22. package/src/components/json-tree/json-tree-node.svelte +77 -0
  23. package/src/components/json-tree/json-viewer.svelte +113 -0
  24. package/src/components/ui/banner/banner.svelte +41 -0
  25. package/src/components/ui/banner/index.ts +5 -0
  26. package/src/components/ui/bulk-action-bar/bulk-action-bar.svelte +50 -0
  27. package/src/components/ui/bulk-action-bar/index.ts +1 -0
  28. package/src/components/ui/button/index.ts +8 -0
  29. package/src/components/ui/calendar/calendar-caption.svelte +72 -0
  30. package/src/components/ui/calendar/calendar-cell.svelte +20 -0
  31. package/src/components/ui/calendar/calendar-day.svelte +27 -0
  32. package/src/components/ui/calendar/calendar-grid-row.svelte +13 -0
  33. package/src/components/ui/calendar/calendar-grid.svelte +17 -0
  34. package/src/components/ui/calendar/calendar-head-cell.svelte +20 -0
  35. package/src/components/ui/calendar/calendar-header.svelte +20 -0
  36. package/src/components/ui/calendar/calendar-heading.svelte +17 -0
  37. package/src/components/ui/calendar/calendar-month-select.svelte +45 -0
  38. package/src/components/ui/calendar/calendar-month.svelte +16 -0
  39. package/src/components/ui/calendar/calendar-months.svelte +20 -0
  40. package/src/components/ui/calendar/calendar-nav.svelte +20 -0
  41. package/src/components/ui/calendar/calendar-next-button.svelte +30 -0
  42. package/src/components/ui/calendar/calendar-prev-button.svelte +30 -0
  43. package/src/components/ui/calendar/calendar-year-select.svelte +44 -0
  44. package/src/components/ui/calendar/calendar.svelte +118 -0
  45. package/src/components/ui/calendar/index.ts +1 -0
  46. package/src/components/ui/checkbox/checkbox.svelte +35 -0
  47. package/src/components/ui/checkbox/index.ts +1 -0
  48. package/src/components/ui/confirmation-dialog/confirmation-dialog.svelte +37 -0
  49. package/src/components/ui/confirmation-dialog/index.ts +1 -0
  50. package/src/components/ui/date-value-control/date-value-calendar.svelte +37 -0
  51. package/src/components/ui/date-value-control/date-value-control-loader.svelte +36 -0
  52. package/src/components/ui/date-value-control/date-value-control.svelte +177 -0
  53. package/src/components/ui/date-value-control/index.ts +3 -0
  54. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  55. package/src/components/ui/dialog/dialog-content.svelte +52 -0
  56. package/src/components/ui/dialog/dialog-description.svelte +20 -0
  57. package/src/components/ui/dialog/dialog-footer.svelte +20 -0
  58. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  59. package/src/components/ui/dialog/dialog-overlay.svelte +20 -0
  60. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  61. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  62. package/src/components/ui/dialog/dialog.svelte +7 -0
  63. package/src/components/ui/dialog/index.ts +8 -0
  64. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +27 -0
  65. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +20 -0
  66. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  67. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +16 -0
  68. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  69. package/src/components/ui/dropdown-menu/index.ts +5 -0
  70. package/src/components/ui/empty-state/empty-state.svelte +34 -0
  71. package/src/components/ui/empty-state/index.ts +1 -0
  72. package/src/components/ui/input/index.ts +1 -0
  73. package/src/components/ui/kbd/index.ts +1 -0
  74. package/src/components/ui/kbd/kbd.svelte +24 -0
  75. package/src/components/ui/pagination/index.ts +1 -0
  76. package/src/components/ui/pagination/pagination.svelte +88 -0
  77. package/src/components/ui/popover/index.ts +3 -0
  78. package/src/components/ui/popover/popover-content.svelte +29 -0
  79. package/src/components/ui/popover/popover-trigger.svelte +11 -0
  80. package/src/components/ui/popover/popover.svelte +7 -0
  81. package/src/components/ui/radio-group/index.ts +3 -0
  82. package/src/components/ui/radio-group/radio-card-item.svelte +38 -0
  83. package/src/components/ui/radio-group/radio-group-item.svelte +17 -0
  84. package/src/components/ui/radio-group/radio-group.svelte +19 -0
  85. package/src/components/ui/select/index.ts +4 -0
  86. package/src/components/ui/select/select-content.svelte +38 -0
  87. package/src/components/ui/select/select-item.svelte +40 -0
  88. package/src/components/ui/select/select-scroll-down-button.svelte +23 -0
  89. package/src/components/ui/select/select-scroll-up-button.svelte +23 -0
  90. package/src/components/ui/select/select-trigger.svelte +29 -0
  91. package/src/components/ui/select/select.svelte +11 -0
  92. package/src/components/ui/sheet/index.ts +6 -0
  93. package/src/components/ui/sheet/sheet-close.svelte +7 -0
  94. package/src/components/ui/sheet/sheet-content.svelte +77 -0
  95. package/src/components/ui/sheet/sheet-description.svelte +17 -0
  96. package/src/components/ui/sheet/sheet-overlay.svelte +36 -0
  97. package/src/components/ui/sheet/sheet-title.svelte +17 -0
  98. package/src/components/ui/sheet/sheet-trigger.svelte +7 -0
  99. package/src/components/ui/sheet/sheet.svelte +7 -0
  100. package/src/components/ui/skeleton/index.ts +1 -0
  101. package/src/components/ui/skeleton/skeleton.svelte +31 -0
  102. package/src/components/ui/slider/index.ts +3 -0
  103. package/src/components/ui/slider/slider-content.svelte +59 -0
  104. package/src/components/ui/slider/slider.svelte +35 -0
  105. package/src/components/ui/sonner/index.ts +1 -0
  106. package/src/components/ui/sonner/sonner.svelte +55 -0
  107. package/src/components/ui/status-indicator/index.ts +5 -0
  108. package/src/components/ui/status-indicator/status-indicator.svelte +54 -0
  109. package/src/components/ui/table/index.ts +6 -0
  110. package/src/components/ui/table/table-body.svelte +20 -0
  111. package/src/components/ui/table/table-cell.svelte +23 -0
  112. package/src/components/ui/table/table-head.svelte +23 -0
  113. package/src/components/ui/table/table-header.svelte +20 -0
  114. package/src/components/ui/table/table-row.svelte +23 -0
  115. package/src/components/ui/table/table.svelte +22 -0
  116. package/src/components/ui/tabs/index.ts +8 -0
  117. package/src/components/ui/tabs/tabs-content.svelte +17 -0
  118. package/src/components/ui/tabs/tabs-list.svelte +40 -0
  119. package/src/components/ui/tabs/tabs-trigger.svelte +23 -0
  120. package/src/components/ui/tabs/tabs.svelte +19 -0
  121. package/src/components/ui/textarea/index.ts +1 -0
  122. package/src/components/ui/tooltip/index.ts +4 -0
  123. package/src/components/ui/tooltip/tooltip-content.svelte +50 -0
  124. package/src/components/ui/tooltip/tooltip-provider.svelte +7 -0
  125. package/src/components/ui/tooltip/tooltip-trigger.svelte +7 -0
  126. package/src/components/ui/tooltip/tooltip.svelte +7 -0
  127. package/src/core/api/admin-client.ts +45 -0
  128. package/src/core/forms/field-condition.ts +63 -0
  129. package/src/core/forms/form-controller.svelte.ts +369 -0
  130. package/src/core/forms/form-draft-recovery.ts +80 -0
  131. package/src/core/forms/form-schema.ts +609 -0
  132. package/src/core/forms/form-validation.ts +750 -0
  133. package/src/core/i18n/admin-i18n.svelte.ts +364 -0
  134. package/src/core/i18n/localized-schema.ts +111 -0
  135. package/src/core/notifications/notification-center.svelte.ts +69 -0
  136. package/src/core/notifications/validation-toast.svelte +18 -0
  137. package/src/core/plugins/field-registry.ts +60 -0
  138. package/src/core/plugins/row-label-registry.ts +62 -0
  139. package/src/core/preferences/preference-write-queue.ts +107 -0
  140. package/src/core/routing/admin-paths.ts +137 -0
  141. package/src/core/runtime/admin-runtime.svelte.ts +625 -0
  142. package/src/features/auth/account-menu.svelte +159 -0
  143. package/src/features/auth/account-navigation.svelte +44 -0
  144. package/src/features/auth/account-route.svelte +395 -0
  145. package/src/features/auth/account-security-controller.svelte.ts +119 -0
  146. package/src/features/auth/account-security-route.svelte +460 -0
  147. package/src/features/auth/auth-brand.svelte +17 -0
  148. package/src/features/auth/auth-frame.svelte +18 -0
  149. package/src/features/auth/create-first-user-controller.svelte.ts +220 -0
  150. package/src/features/auth/create-first-user-route.svelte +174 -0
  151. package/src/features/auth/forgot-password-route.svelte +78 -0
  152. package/src/features/auth/login-route.svelte +221 -0
  153. package/src/features/auth/request-verification-route.svelte +73 -0
  154. package/src/features/auth/reset-password-route.svelte +107 -0
  155. package/src/features/auth/verify-email-route.svelte +60 -0
  156. package/src/features/collections/collection-list-bulk-actions.svelte +143 -0
  157. package/src/features/collections/collection-list-bulk-editor.svelte +157 -0
  158. package/src/features/collections/collection-list-controller.svelte.ts +717 -0
  159. package/src/features/collections/collection-list-filter-popover.svelte +238 -0
  160. package/src/features/collections/collection-list-preferences-controller.svelte.ts +393 -0
  161. package/src/features/collections/collection-list-results.svelte +410 -0
  162. package/src/features/collections/collection-list-route.svelte +951 -0
  163. package/src/features/collections/collection-list-workspace-toolbar.svelte +409 -0
  164. package/src/features/collections/collection-table-skeleton.svelte +44 -0
  165. package/src/features/collections/list-workspace.ts +265 -0
  166. package/src/features/dashboard/dashboard-route.svelte +141 -0
  167. package/src/features/development/development-tools.svelte +217 -0
  168. package/src/features/documents/document-api-view-loader.svelte +31 -0
  169. package/src/features/documents/document-api-view.svelte +547 -0
  170. package/src/features/documents/document-controller.svelte.ts +1252 -0
  171. package/src/features/documents/document-field-partition.ts +18 -0
  172. package/src/features/documents/document-field-sections.svelte +46 -0
  173. package/src/features/documents/document-heading.ts +20 -0
  174. package/src/features/documents/document-route-dirty.ts +43 -0
  175. package/src/features/documents/document-route-loader.svelte +22 -0
  176. package/src/features/documents/document-route.svelte +1124 -0
  177. package/src/features/documents/live-preview-panel.svelte +390 -0
  178. package/src/features/documents/live-preview.ts +61 -0
  179. package/src/features/documents/upload-document-preview-loader.svelte +40 -0
  180. package/src/features/documents/upload-document-preview.svelte +525 -0
  181. package/src/features/localization/content-locale-switcher.svelte +93 -0
  182. package/src/features/navigation/admin-breadcrumbs.svelte +124 -0
  183. package/src/features/navigation/admin-command-menu.svelte +245 -0
  184. package/src/features/navigation/admin-resource-groups.ts +72 -0
  185. package/src/features/reference-browser/reference-browser-loader.svelte +15 -0
  186. package/src/features/reference-browser/reference-browser-workflow.svelte.ts +691 -0
  187. package/src/features/reference-browser/reference-browser.svelte +844 -0
  188. package/src/features/uploads/bulk-upload-route.svelte +624 -0
  189. package/src/features/uploads/bulk-upload.ts +39 -0
  190. package/src/features/uploads/upload-metadata-fields.svelte +112 -0
  191. package/src/features/versions/version-diff.ts +72 -0
  192. package/src/features/versions/version-history-route.svelte +581 -0
  193. package/src/fields/field-clipboard.ts +132 -0
  194. package/src/fields/field-layout.svelte +60 -0
  195. package/src/fields/field-layout.ts +57 -0
  196. package/src/fields/field-messages.svelte +29 -0
  197. package/src/fields/field-renderer.svelte +152 -0
  198. package/src/fields/field-shell.svelte +30 -0
  199. package/src/fields/field-tab-layout.svelte +99 -0
  200. package/src/fields/join/join-field.svelte +358 -0
  201. package/src/fields/localized-field-presentation.ts +17 -0
  202. package/src/fields/nested/nested-field.svelte +602 -0
  203. package/src/fields/nested/scoped-field.ts +78 -0
  204. package/src/fields/nested/sortable-row.svelte +32 -0
  205. package/src/fields/point/point-field.svelte +52 -0
  206. package/src/fields/radio/radio-field.svelte +34 -0
  207. package/src/fields/relationship/relationship-field-controller.svelte.ts +245 -0
  208. package/src/fields/relationship/relationship-field.svelte +258 -0
  209. package/src/fields/relationship/relationship-lookup-controller.svelte.ts +129 -0
  210. package/src/fields/relationship/relationship-query.ts +88 -0
  211. package/src/fields/relationship/relationship-quick-picker-controller.svelte.ts +198 -0
  212. package/src/fields/relationship/relationship-quick-picker.svelte +327 -0
  213. package/src/fields/relationship/relationship-value.ts +47 -0
  214. package/src/fields/scalar/date-control-value.ts +60 -0
  215. package/src/fields/scalar/date-value.ts +26 -0
  216. package/src/fields/scalar/scalar-field.svelte +137 -0
  217. package/src/fields/select/select-field.svelte +132 -0
  218. package/src/fields/select/select-value.ts +14 -0
  219. package/src/fields/text/slug-field.svelte +87 -0
  220. package/src/fields/text/slug.ts +38 -0
  221. package/src/fields/text/text-field.svelte +41 -0
  222. package/src/fields/ui/ui-field.svelte +12 -0
  223. package/src/fields/virtual/virtual-field.svelte +28 -0
  224. package/src/index.ts +49 -0
  225. package/src/main.ts +12 -0
  226. package/src/vite-env.d.ts +9 -0
@@ -0,0 +1,750 @@
1
+ import type { AdminI18n } from "@riducms/plugin";
2
+ import type { SchemaField, ValidationIssue } from "@riducms/protocol";
3
+
4
+ import { initialFormValues, type FormValues } from "@admin/core/forms/form-schema";
5
+
6
+ export interface FormValidationOptions {
7
+ requireMissing: boolean;
8
+ include?: (path: string, canonicalPath: string) => boolean;
9
+ i18n?: AdminI18n;
10
+ }
11
+
12
+ export function validateFormValues(
13
+ fields: readonly SchemaField[],
14
+ values: FormValues,
15
+ options: FormValidationOptions
16
+ ): ValidationIssue[] {
17
+ const issues: ValidationIssue[] = [];
18
+ validateFields(
19
+ fields,
20
+ values,
21
+ "",
22
+ options.requireMissing,
23
+ issues,
24
+ options.include ?? (() => true),
25
+ options.i18n
26
+ );
27
+ return issues;
28
+ }
29
+
30
+ export function invalidFieldLabels(
31
+ fields: readonly SchemaField[],
32
+ issues: readonly ValidationIssue[],
33
+ i18n?: AdminI18n
34
+ ) {
35
+ const labels = new Set<string>();
36
+ for (const issue of issues) {
37
+ labels.add(fieldLabelForPath(fields, issue.path, i18n) ?? issue.path.replaceAll(".", " → "));
38
+ }
39
+ return [...labels];
40
+ }
41
+
42
+ function fieldLabelForPath(fields: readonly SchemaField[], path: string, i18n?: AdminI18n) {
43
+ return resolveFieldLabel(fields, path.split("."), [], i18n);
44
+ }
45
+
46
+ function resolveFieldLabel(
47
+ fields: readonly SchemaField[],
48
+ segments: readonly string[],
49
+ parents: readonly string[],
50
+ i18n?: AdminI18n
51
+ ): string | undefined {
52
+ const [name, ...remaining] = segments;
53
+ if (name === undefined) return parents.length > 0 ? parents.join(" → ") : undefined;
54
+ const field = fields.find((candidate) => candidate.name === name);
55
+ if (field === undefined) return parents.length > 0 ? parents.join(" → ") : undefined;
56
+
57
+ const labels = [...parents, localizedFieldLabel(field, i18n)];
58
+ if (remaining.length === 0) return labels.join(" → ");
59
+ if (field.type === "group" && field.nested !== undefined) {
60
+ return resolveFieldLabel(field.nested.fields, remaining, labels, i18n);
61
+ }
62
+ if (field.type === "array" && field.nested !== undefined) {
63
+ const [index, ...childSegments] = remaining;
64
+ const rowLabels = isIndex(index)
65
+ ? [
66
+ ...labels,
67
+ translate(
68
+ i18n,
69
+ "fields:rowNumber",
70
+ { number: Number(index) + 1 },
71
+ `Row ${Number(index) + 1}`
72
+ ),
73
+ ]
74
+ : labels;
75
+ return resolveFieldLabel(
76
+ field.nested.fields,
77
+ isIndex(index) ? childSegments : remaining,
78
+ rowLabels,
79
+ i18n
80
+ );
81
+ }
82
+ if (field.type === "blocks" && field.blocks !== undefined) {
83
+ const [index, ...childSegments] = remaining;
84
+ const blockLabels = isIndex(index)
85
+ ? [
86
+ ...labels,
87
+ translate(
88
+ i18n,
89
+ "fields:blockNumber",
90
+ { number: Number(index) + 1 },
91
+ `Block ${Number(index) + 1}`
92
+ ),
93
+ ]
94
+ : labels;
95
+ const blockFields = field.blocks.types.flatMap((block) => block.fields);
96
+ return resolveFieldLabel(
97
+ blockFields,
98
+ isIndex(index) ? childSegments : remaining,
99
+ blockLabels,
100
+ i18n
101
+ );
102
+ }
103
+ return labels.join(" → ");
104
+ }
105
+
106
+ function isIndex(value: string | undefined) {
107
+ return value !== undefined && /^\d+$/.test(value);
108
+ }
109
+
110
+ function validateFields(
111
+ fields: readonly SchemaField[],
112
+ values: FormValues,
113
+ prefix: string,
114
+ requireMissing: boolean,
115
+ issues: ValidationIssue[],
116
+ include: (path: string, canonicalPath: string) => boolean,
117
+ i18n?: AdminI18n
118
+ ) {
119
+ for (const field of fields) {
120
+ if (field.category === "presentation") continue;
121
+ const path = joinPath(prefix, field.name);
122
+ if (!include(path, field.path)) continue;
123
+ const exists = Object.hasOwn(values, field.name);
124
+ const value = values[field.name];
125
+ if (!exists) {
126
+ const defaults = requireMissing ? initialFormValues([field]) : {};
127
+ if (Object.hasOwn(defaults, field.name)) {
128
+ validateFields([field], defaults, prefix, true, issues, include, i18n);
129
+ } else if (requireMissing && field.required) {
130
+ issues.push(requiredIssue(field, path, i18n));
131
+ } else if (requireMissing && field.type === "array" && (field.nested?.minRows ?? 0) > 0) {
132
+ issues.push(
133
+ issue(
134
+ "min_rows",
135
+ path,
136
+ translate(
137
+ i18n,
138
+ "errors:minRows",
139
+ { label: localizedFieldLabel(field, i18n), count: field.nested?.minRows ?? 0 },
140
+ `${localizedFieldLabel(field, i18n)} must contain at least ${field.nested?.minRows} rows`
141
+ )
142
+ )
143
+ );
144
+ }
145
+ continue;
146
+ }
147
+ if (value === null || value === undefined) {
148
+ if (field.required) issues.push(requiredIssue(field, path, i18n));
149
+ else if (field.type === "array" && (field.nested?.minRows ?? 0) > 0) {
150
+ issues.push(
151
+ issue(
152
+ "min_rows",
153
+ path,
154
+ translate(
155
+ i18n,
156
+ "errors:minRows",
157
+ { label: localizedFieldLabel(field, i18n), count: field.nested?.minRows ?? 0 },
158
+ `${localizedFieldLabel(field, i18n)} must contain at least ${field.nested?.minRows} rows`
159
+ )
160
+ )
161
+ );
162
+ }
163
+ continue;
164
+ }
165
+
166
+ switch (field.type) {
167
+ case "text":
168
+ case "code":
169
+ case "textarea":
170
+ validateString(field, value, path, issues, i18n);
171
+ break;
172
+ case "email":
173
+ case "date":
174
+ validateOptionalString(field, value, path, issues, i18n);
175
+ break;
176
+ case "number":
177
+ if (typeof value !== "number" || !Number.isFinite(value)) {
178
+ issues.push(
179
+ issue(
180
+ "invalid_number",
181
+ path,
182
+ translate(
183
+ i18n,
184
+ "errors:finiteNumber",
185
+ { label: localizedFieldLabel(field, i18n) },
186
+ `${localizedFieldLabel(field, i18n)} must be a finite number`
187
+ )
188
+ )
189
+ );
190
+ }
191
+ break;
192
+ case "checkbox":
193
+ if (typeof value !== "boolean") {
194
+ issues.push(
195
+ issue(
196
+ "invalid_type",
197
+ path,
198
+ translate(
199
+ i18n,
200
+ "errors:boolean",
201
+ { label: localizedFieldLabel(field, i18n) },
202
+ `${localizedFieldLabel(field, i18n)} must be a boolean`
203
+ )
204
+ )
205
+ );
206
+ }
207
+ break;
208
+ case "select":
209
+ case "radio":
210
+ validateSelect(field, value, path, issues, i18n);
211
+ break;
212
+ case "point":
213
+ validatePoint(field, value, path, issues, i18n);
214
+ break;
215
+ case "relationship":
216
+ validateReference(field, value, path, false, issues, i18n);
217
+ break;
218
+ case "upload":
219
+ validateReference(field, value, path, true, issues, i18n);
220
+ break;
221
+ case "group":
222
+ if (!isRecord(value) || field.nested === undefined) {
223
+ issues.push(
224
+ issue(
225
+ "invalid_type",
226
+ path,
227
+ translate(
228
+ i18n,
229
+ "errors:object",
230
+ { label: localizedFieldLabel(field, i18n) },
231
+ `${localizedFieldLabel(field, i18n)} must be an object`
232
+ )
233
+ )
234
+ );
235
+ } else {
236
+ validateFields(field.nested.fields, value, path, true, issues, include, i18n);
237
+ }
238
+ break;
239
+ case "array":
240
+ validateRows(field, value, path, issues, include, i18n);
241
+ break;
242
+ case "blocks":
243
+ validateBlocks(field, value, path, issues, include, i18n);
244
+ break;
245
+ case "json":
246
+ case "plugin":
247
+ // Plugin validators and internal populated-document checks remain server-authoritative.
248
+ break;
249
+ }
250
+ }
251
+ }
252
+
253
+ function validateString(
254
+ field: SchemaField,
255
+ value: unknown,
256
+ path: string,
257
+ issues: ValidationIssue[],
258
+ i18n?: AdminI18n
259
+ ) {
260
+ if (typeof value !== "string") {
261
+ issues.push(
262
+ issue(
263
+ "invalid_type",
264
+ path,
265
+ translate(
266
+ i18n,
267
+ "errors:string",
268
+ { label: localizedFieldLabel(field, i18n) },
269
+ `${localizedFieldLabel(field, i18n)} must be a string`
270
+ )
271
+ )
272
+ );
273
+ } else if (field.required && value.length === 0) {
274
+ issues.push(requiredIssue(field, path, i18n));
275
+ }
276
+ }
277
+
278
+ function validateOptionalString(
279
+ field: SchemaField,
280
+ value: unknown,
281
+ path: string,
282
+ issues: ValidationIssue[],
283
+ i18n?: AdminI18n
284
+ ) {
285
+ if (typeof value !== "string") {
286
+ issues.push(
287
+ issue(
288
+ "invalid_type",
289
+ path,
290
+ translate(
291
+ i18n,
292
+ "errors:string",
293
+ { label: localizedFieldLabel(field, i18n) },
294
+ `${localizedFieldLabel(field, i18n)} must be a string`
295
+ )
296
+ )
297
+ );
298
+ } else if (field.required && value.length === 0) {
299
+ issues.push(requiredIssue(field, path, i18n));
300
+ }
301
+ }
302
+
303
+ function validateSelect(
304
+ field: SchemaField,
305
+ value: unknown,
306
+ path: string,
307
+ issues: ValidationIssue[],
308
+ i18n?: AdminI18n
309
+ ) {
310
+ if (field.type === "select" && field.select?.hasMany === true) {
311
+ validateSelectMany(field, value, path, issues, i18n);
312
+ return;
313
+ }
314
+ if (typeof value !== "string") {
315
+ issues.push(
316
+ issue(
317
+ "invalid_type",
318
+ path,
319
+ translate(
320
+ i18n,
321
+ "errors:string",
322
+ { label: localizedFieldLabel(field, i18n) },
323
+ `${localizedFieldLabel(field, i18n)} must be a string`
324
+ )
325
+ )
326
+ );
327
+ return;
328
+ }
329
+ if (value === "") {
330
+ if (field.required) issues.push(requiredIssue(field, path, i18n));
331
+ return;
332
+ }
333
+ if (!field.select?.choices.some((choice) => choice.value === value)) {
334
+ issues.push(
335
+ issue(
336
+ "invalid_choice",
337
+ path,
338
+ translate(
339
+ i18n,
340
+ "errors:allowedChoice",
341
+ { label: localizedFieldLabel(field, i18n) },
342
+ `${localizedFieldLabel(field, i18n)} is not an allowed choice`
343
+ )
344
+ )
345
+ );
346
+ }
347
+ }
348
+
349
+ function validateSelectMany(
350
+ field: SchemaField,
351
+ value: unknown,
352
+ path: string,
353
+ issues: ValidationIssue[],
354
+ i18n?: AdminI18n
355
+ ) {
356
+ if (!Array.isArray(value)) {
357
+ issues.push(
358
+ issue(
359
+ "invalid_type",
360
+ path,
361
+ translate(
362
+ i18n,
363
+ "errors:array",
364
+ { label: localizedFieldLabel(field, i18n) },
365
+ `${localizedFieldLabel(field, i18n)} must be an array`
366
+ )
367
+ )
368
+ );
369
+ return;
370
+ }
371
+ if (value.length === 0) {
372
+ if (field.required) issues.push(requiredIssue(field, path, i18n));
373
+ return;
374
+ }
375
+
376
+ const choices = new Set((field.select?.choices ?? []).map((choice) => choice.value));
377
+ const seen = new Set<string>();
378
+ for (const [index, candidate] of value.entries()) {
379
+ const candidatePath = `${path}.${index}`;
380
+ if (typeof candidate !== "string") {
381
+ issues.push(
382
+ issue(
383
+ "invalid_type",
384
+ candidatePath,
385
+ translate(
386
+ i18n,
387
+ "errors:string",
388
+ { label: localizedFieldLabel(field, i18n) },
389
+ `${localizedFieldLabel(field, i18n)} must be a string`
390
+ )
391
+ )
392
+ );
393
+ continue;
394
+ }
395
+ if (!choices.has(candidate)) {
396
+ issues.push(
397
+ issue(
398
+ "invalid_choice",
399
+ candidatePath,
400
+ translate(
401
+ i18n,
402
+ "errors:allowedChoice",
403
+ { label: localizedFieldLabel(field, i18n) },
404
+ `${localizedFieldLabel(field, i18n)} is not an allowed choice`
405
+ )
406
+ )
407
+ );
408
+ }
409
+ if (seen.has(candidate)) {
410
+ issues.push(
411
+ issue(
412
+ "duplicate_choice",
413
+ candidatePath,
414
+ translate(
415
+ i18n,
416
+ "errors:duplicateChoice",
417
+ { label: localizedFieldLabel(field, i18n) },
418
+ `${localizedFieldLabel(field, i18n)} must not contain duplicate choices`
419
+ )
420
+ )
421
+ );
422
+ } else {
423
+ seen.add(candidate);
424
+ }
425
+ }
426
+ }
427
+
428
+ function validatePoint(
429
+ field: SchemaField,
430
+ value: unknown,
431
+ path: string,
432
+ issues: ValidationIssue[],
433
+ i18n?: AdminI18n
434
+ ) {
435
+ if (
436
+ !Array.isArray(value) ||
437
+ value.length !== 2 ||
438
+ !value.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate)) ||
439
+ value[0] < -180 ||
440
+ value[0] > 180 ||
441
+ value[1] < -90 ||
442
+ value[1] > 90
443
+ ) {
444
+ issues.push(
445
+ issue(
446
+ "invalid_point",
447
+ path,
448
+ translate(
449
+ i18n,
450
+ "errors:validPoint",
451
+ { label: localizedFieldLabel(field, i18n) },
452
+ `${localizedFieldLabel(field, i18n)} must be a valid [longitude, latitude] pair`
453
+ )
454
+ )
455
+ );
456
+ }
457
+ }
458
+
459
+ function validateReference(
460
+ field: SchemaField,
461
+ value: unknown,
462
+ path: string,
463
+ upload: boolean,
464
+ issues: ValidationIssue[],
465
+ i18n?: AdminI18n
466
+ ) {
467
+ const contract = upload ? field.upload : field.relationship;
468
+ const hasMany = contract?.hasMany === true;
469
+ const kind = upload ? "upload" : "relationship";
470
+ if (hasMany) {
471
+ if (!Array.isArray(value)) {
472
+ issues.push(
473
+ issue(
474
+ "invalid_type",
475
+ path,
476
+ translate(
477
+ i18n,
478
+ "errors:array",
479
+ { label: localizedFieldLabel(field, i18n) },
480
+ `${localizedFieldLabel(field, i18n)} must be an array`
481
+ )
482
+ )
483
+ );
484
+ return;
485
+ }
486
+ if (field.required && value.length === 0) {
487
+ issues.push(
488
+ issue(
489
+ "required",
490
+ path,
491
+ translate(
492
+ i18n,
493
+ "errors:oneReference",
494
+ {
495
+ label: localizedFieldLabel(field, i18n),
496
+ kind: translate(i18n, upload ? "fields:upload" : "fields:relationship", {}, kind),
497
+ },
498
+ `${localizedFieldLabel(field, i18n)} must contain at least one ${kind}`
499
+ )
500
+ )
501
+ );
502
+ }
503
+ for (const [index, item] of value.entries()) {
504
+ validateReferenceValue(field, item, `${path}.${index}`, upload, issues, i18n);
505
+ }
506
+ return;
507
+ }
508
+ if (value === "") {
509
+ if (field.required) issues.push(requiredIssue(field, path, i18n));
510
+ return;
511
+ }
512
+ validateReferenceValue(field, value, path, upload, issues, i18n);
513
+ }
514
+
515
+ function validateReferenceValue(
516
+ field: SchemaField,
517
+ value: unknown,
518
+ path: string,
519
+ upload: boolean,
520
+ issues: ValidationIssue[],
521
+ i18n?: AdminI18n
522
+ ) {
523
+ if (upload || field.relationship?.polymorphic !== true) {
524
+ if (typeof value !== "string" || value === "") {
525
+ issues.push(
526
+ issue(
527
+ upload ? "invalid_upload" : "invalid_relationship",
528
+ path,
529
+ translate(
530
+ i18n,
531
+ "errors:referenceID",
532
+ {
533
+ kind: translate(
534
+ i18n,
535
+ upload ? "fields:upload" : "fields:relationship",
536
+ {},
537
+ upload ? "upload" : "relationship"
538
+ ),
539
+ },
540
+ `${upload ? "upload" : "relationship"} reference must be a non-empty ID string`
541
+ )
542
+ )
543
+ );
544
+ }
545
+ return;
546
+ }
547
+ if (
548
+ !isRecord(value) ||
549
+ typeof value.relationTo !== "string" ||
550
+ typeof value.id !== "string" ||
551
+ value.id === "" ||
552
+ !field.relationship.targets?.some((target) => target.collectionSlug === value.relationTo)
553
+ ) {
554
+ issues.push(
555
+ issue(
556
+ "invalid_relationship",
557
+ path,
558
+ translate(
559
+ i18n,
560
+ "errors:polymorphicReference",
561
+ {},
562
+ "polymorphic reference requires an allowed relationTo and non-empty ID"
563
+ )
564
+ )
565
+ );
566
+ }
567
+ }
568
+
569
+ function validateRows(
570
+ field: SchemaField,
571
+ value: unknown,
572
+ path: string,
573
+ issues: ValidationIssue[],
574
+ include: (path: string, canonicalPath: string) => boolean,
575
+ i18n?: AdminI18n
576
+ ) {
577
+ if (!Array.isArray(value) || field.nested === undefined) {
578
+ issues.push(
579
+ issue(
580
+ "invalid_type",
581
+ path,
582
+ translate(
583
+ i18n,
584
+ "errors:array",
585
+ { label: localizedFieldLabel(field, i18n) },
586
+ `${localizedFieldLabel(field, i18n)} must be an array`
587
+ )
588
+ )
589
+ );
590
+ return;
591
+ }
592
+ if (field.required && value.length === 0) {
593
+ issues.push(
594
+ issue(
595
+ "required",
596
+ path,
597
+ translate(
598
+ i18n,
599
+ "errors:oneRow",
600
+ { label: localizedFieldLabel(field, i18n) },
601
+ `${localizedFieldLabel(field, i18n)} must contain at least one row`
602
+ )
603
+ )
604
+ );
605
+ }
606
+ if (value.length < (field.nested.minRows ?? 0) && !(field.required && value.length === 0)) {
607
+ issues.push(
608
+ issue(
609
+ "min_rows",
610
+ path,
611
+ translate(
612
+ i18n,
613
+ "errors:minRows",
614
+ { label: localizedFieldLabel(field, i18n), count: field.nested.minRows ?? 0 },
615
+ `${localizedFieldLabel(field, i18n)} must contain at least ${field.nested.minRows} rows`
616
+ )
617
+ )
618
+ );
619
+ }
620
+ if ((field.nested.maxRows ?? 0) > 0 && value.length > (field.nested.maxRows ?? 0)) {
621
+ issues.push(
622
+ issue(
623
+ "max_rows",
624
+ path,
625
+ translate(
626
+ i18n,
627
+ "errors:maxRows",
628
+ { label: localizedFieldLabel(field, i18n), count: field.nested.maxRows ?? 0 },
629
+ `${localizedFieldLabel(field, i18n)} must contain no more than ${field.nested.maxRows} rows`
630
+ )
631
+ )
632
+ );
633
+ }
634
+ for (const [index, row] of value.entries()) {
635
+ const rowPath = `${path}.${index}`;
636
+ if (!isRecord(row)) {
637
+ issues.push(
638
+ issue(
639
+ "invalid_type",
640
+ rowPath,
641
+ translate(i18n, "errors:rowObject", {}, "array row must be an object")
642
+ )
643
+ );
644
+ continue;
645
+ }
646
+ validateFields(field.nested.fields, row, rowPath, true, issues, include, i18n);
647
+ }
648
+ }
649
+
650
+ function validateBlocks(
651
+ field: SchemaField,
652
+ value: unknown,
653
+ path: string,
654
+ issues: ValidationIssue[],
655
+ include: (path: string, canonicalPath: string) => boolean,
656
+ i18n?: AdminI18n
657
+ ) {
658
+ if (!Array.isArray(value) || field.blocks === undefined) {
659
+ issues.push(
660
+ issue(
661
+ "invalid_type",
662
+ path,
663
+ translate(
664
+ i18n,
665
+ "errors:array",
666
+ { label: localizedFieldLabel(field, i18n) },
667
+ `${localizedFieldLabel(field, i18n)} must be an array`
668
+ )
669
+ )
670
+ );
671
+ return;
672
+ }
673
+ if (field.required && value.length === 0) {
674
+ issues.push(
675
+ issue(
676
+ "required",
677
+ path,
678
+ translate(
679
+ i18n,
680
+ "errors:oneBlock",
681
+ { label: localizedFieldLabel(field, i18n) },
682
+ `${localizedFieldLabel(field, i18n)} must contain at least one block`
683
+ )
684
+ )
685
+ );
686
+ }
687
+ for (const [index, row] of value.entries()) {
688
+ const rowPath = `${path}.${index}`;
689
+ if (!isRecord(row)) {
690
+ issues.push(
691
+ issue(
692
+ "invalid_type",
693
+ rowPath,
694
+ translate(i18n, "errors:blockObject", {}, "block must be an object")
695
+ )
696
+ );
697
+ continue;
698
+ }
699
+ const block = field.blocks.types.find((candidate) => candidate.key === row.blockType);
700
+ if (block === undefined) {
701
+ issues.push(
702
+ issue(
703
+ "invalid_block",
704
+ `${rowPath}.blockType`,
705
+ translate(i18n, "errors:blockType", {}, "block type is not allowed")
706
+ )
707
+ );
708
+ continue;
709
+ }
710
+ validateFields(block.fields, row, rowPath, true, issues, include, i18n);
711
+ }
712
+ }
713
+
714
+ function requiredIssue(field: SchemaField, path: string, i18n?: AdminI18n) {
715
+ return issue(
716
+ "required",
717
+ path,
718
+ translate(
719
+ i18n,
720
+ "errors:fieldRequired",
721
+ { label: localizedFieldLabel(field, i18n) },
722
+ `${localizedFieldLabel(field, i18n)} is required`
723
+ )
724
+ );
725
+ }
726
+
727
+ function issue(code: string, path: string, message: string): ValidationIssue {
728
+ return { code, path, message };
729
+ }
730
+
731
+ function joinPath(prefix: string, name: string) {
732
+ return prefix === "" ? name : `${prefix}.${name}`;
733
+ }
734
+
735
+ function isRecord(value: unknown): value is Record<string, unknown> {
736
+ return typeof value === "object" && value !== null && !Array.isArray(value);
737
+ }
738
+
739
+ function translate(
740
+ i18n: AdminI18n | undefined,
741
+ key: Parameters<AdminI18n["t"]>[0],
742
+ variables: Readonly<Record<string, string | number>>,
743
+ fallback: string
744
+ ) {
745
+ return i18n?.t(key, variables) ?? fallback;
746
+ }
747
+
748
+ function localizedFieldLabel(field: SchemaField, i18n?: AdminI18n) {
749
+ return i18n?.text(field.admin.label, field.admin.labelTranslations) ?? field.admin.label;
750
+ }