@riducms/plugin 0.1.5 → 0.2.1

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/src/field.ts CHANGED
@@ -1,26 +1,606 @@
1
- import type { FieldType, SchemaField } from "@riducms/protocol";
1
+ import type { FieldType, SchemaField, ValidationIssue } from "@riducms/protocol";
2
2
  import type { Component } from "svelte";
3
-
4
3
  import type { FieldAuthoringHost } from "./authoring";
5
- import type { FieldForm } from "./form";
4
+ import type { FieldDocumentForm, FieldLiveValidation } from "./form";
6
5
  import type { AdminI18n } from "./i18n";
6
+ import { decodeComponentConfig } from "./component-config";
7
+
8
+ /**
9
+ * Read or update one field in the current, unsaved document form.
10
+ *
11
+ * A binding is a connection to a particular field. For a field inside an array or
12
+ * block, it follows that row's stable `_key` when the row moves. It does not switch
13
+ * to whichever row later occupies the same numbered position.
14
+ *
15
+ * Ridu creates and cleans up bindings. Removing the field, unmounting its editor,
16
+ * or replacing the document, locale, schema, or saved/reset form values makes a
17
+ * binding stale. Reads and writes then throw; `stale` and `readOnly` remain safe
18
+ * to check. Obtain a new binding from the newly mounted editor.
19
+ */
20
+ export interface PluginFieldBinding<Value, Type extends FieldType = FieldType, Input = Value> {
21
+ /**
22
+ * A copy of the field's resolved settings, including its current path, label and rules.
23
+ * `schema.admin.readOnly` reflects field configuration and access, not a pending document save.
24
+ */
25
+ readonly schema: SchemaField & { type: Type };
26
+ /**
27
+ * The latest value in the form, including unsaved edits. Objects and arrays are
28
+ * copied: modifying this result does not update the form. Call `set` to change it.
29
+ *
30
+ * `null` and `undefined` mean the field is empty or absent. Other values pass
31
+ * through `decodeValue`, or through `decodeInput` if the output decoder rejects
32
+ * a pending edit. Reading throws if neither decoder accepts the value.
33
+ */
34
+ readonly value: Value | Input | null | undefined;
35
+ /**
36
+ * A copy of the current value before decoding. Use this to display or recover
37
+ * older data your decoder rejects; validate it before treating it as `Value`.
38
+ */
39
+ readonly rawValue: unknown;
40
+ /** Current form and server validation issues for this field and its children. */
41
+ readonly issues: readonly ValidationIssue[];
42
+ /** Opted-in server feedback, managed by the host; save validation stays independent. */
43
+ readonly liveValidation: FieldLiveValidation;
44
+ /**
45
+ * Whether changes are currently blocked, for example by access rules, a save in
46
+ * progress, or a stale binding. Bindings from `form.bind` also respect the source
47
+ * editor's read-only state. `set` checks this again when called.
48
+ */
49
+ readonly readOnly: boolean;
50
+ /** Whether this connection has expired. Once true, it cannot become usable again. */
51
+ readonly stale: boolean;
52
+ /**
53
+ * Replace the value in the unsaved form. Use `null` to clear it; `undefined` is
54
+ * rejected. Ridu copies and decodes the supplied value, checks editability, and
55
+ * updates its normal form state. This does not send a save request to the server.
56
+ *
57
+ * Throws if the binding is stale, the field is read-only, the decoder rejects the
58
+ * value, or a structured edit changes a protected child field. Server validation
59
+ * still runs when the document is saved.
60
+ */
61
+ set: (value: Input | null) => void;
62
+ }
7
63
 
8
- export interface FieldComponentProps {
9
- field: SchemaField;
10
- form: FieldForm;
64
+ /** Read the containing document form, or connect to another field to update it. */
65
+ export interface PluginForm extends FieldDocumentForm {
66
+ /**
67
+ * Connect to another field in this document's unsaved form.
68
+ *
69
+ * Paths start at the document root: `"title"`, `"seo.description"`, or
70
+ * `"reviews.0.note"`. The last example selects the first review **now**. Keep
71
+ * the returned binding: it follows that same row if the user reorders reviews.
72
+ * Calling `bind("reviews.0.note")` later would select the row at index 0 then.
73
+ *
74
+ * Create the binding before starting a request, timer or other delayed work.
75
+ * Ridu disables it if the target disappears or the source editor closes, and
76
+ * also when the document, locale, schema or saved/reset form values change.
77
+ * Later reads and writes throw instead of editing a different field or document.
78
+ * The target must be editable, and the source editor must be editable too.
79
+ *
80
+ * The returned value type is `unknown`: a string path does not prove a field's
81
+ * TypeScript type. Check read values and supply the target field's expected shape.
82
+ * `bind` itself throws if the path has no identifiable field. Repeated rows
83
+ * currently require nonempty, unique `_key` values.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * // In component setup: remember the Title field for this editor.
88
+ * const title = form.bind("title");
89
+ * // In a click handler: change Title in the form. The user still needs to save.
90
+ * function useNoteAsTitle() {
91
+ * title.set(field.value?.text ?? null);
92
+ * }
93
+ * ```
94
+ */
95
+ bind(path: string): PluginFieldBinding<unknown>;
96
+ }
97
+
98
+ /**
99
+ * Props Ridu supplies to a plugin's Svelte field editor.
100
+ *
101
+ * `Value` describes saved data, `Config` describes decoded editor settings, and
102
+ * `Type` is the schema field type (normally `"plugin"`). Set `Input` only when
103
+ * edits sent to the server have a different shape from saved data; it defaults
104
+ * to `Value`. Register the component with `definePluginField` or `defineFieldComponent`.
105
+ *
106
+ * `field` reads and changes this input's unsaved value. `form` reads the document
107
+ * and can bind another editable field. `i18n` translates messages, and `authoring`
108
+ * supplies document pickers, plugin requests, and forms for structured values.
109
+ * `config` is present only when the registration supplies a settings decoder.
110
+ * These props are created by Ridu; the application does not assemble them itself.
111
+ */
112
+ export type PluginFieldProps<
113
+ Value,
114
+ Config = undefined,
115
+ Type extends FieldType = "plugin",
116
+ Input = Value,
117
+ > = {
118
+ /** This editor's field. Read `field.value` and change it with `field.set(...)`. */
119
+ field: PluginFieldBinding<Value, Type, Input>;
120
+ /** The same document form used by the surrounding fields, including unsaved edits. */
121
+ form: PluginForm;
122
+ /** Translate interface text and format dates/numbers using the admin's preferences. */
11
123
  i18n: AdminI18n;
12
- authoring?: FieldAuthoringHost;
124
+ /** Ridu tools for related documents, plugin requests and forms inside structured values. */
125
+ authoring: FieldAuthoringHost;
126
+ } & ([Config] extends [undefined] ? { config?: never } : { config: Config });
127
+
128
+ type FieldDefinition<Value, Config, Type extends FieldType, Input = Value> = {
129
+ /** A Svelte component accepting `PluginFieldProps` for the decoder's value/config types. */
130
+ component: Component<
131
+ PluginFieldProps<NoInfer<Value>, NoInfer<Config>, NoInfer<Type>, NoInfer<Input>>
132
+ >;
133
+ /**
134
+ * Check saved data and return the shape your component can read; throw a useful
135
+ * error for unsupported data. Runs synchronously and can run on every value read.
136
+ * Accept JSON data, not class instances, functions or cyclic objects. Ridu handles
137
+ * empty null/undefined values separately. Go validation still decides what saves.
138
+ */
139
+ decodeValue: (value: unknown) => Value;
140
+ };
141
+
142
+ const registration = Symbol("ridu-plugin-field");
143
+ const valueContract = Symbol("ridu-plugin-field-value");
144
+ const registrations = new WeakSet<RegisteredPluginField>();
145
+
146
+ /**
147
+ * Registration stored by Ridu after a helper has checked the component and decoders.
148
+ * Plugin authors should use `definePluginField` or `defineFieldComponent` and let
149
+ * TypeScript infer the result. Do not construct this object or call its component
150
+ * directly: Ridu supplies the field's form connection when rendering it.
151
+ */
152
+ export interface RegisteredPluginField {
153
+ readonly [registration]: true;
154
+ readonly type: FieldType;
155
+ readonly fieldType?: string;
156
+ readonly component: Component<never>;
157
+ decodeConfig(field: SchemaField): unknown;
158
+ decodeValue(value: unknown): unknown;
159
+ decodeInput(value: unknown): unknown;
160
+ decodeFormValue(value: unknown): unknown;
13
161
  }
14
162
 
15
- export interface FieldPlugin {
16
- type: FieldType;
17
- key?: string;
18
- /** Named renderer selected by field.admin.component for built-in field semantics. */
19
- componentKey?: string;
20
- component?: Component<FieldComponentProps>;
21
- canRender(field: SchemaField): boolean;
163
+ /**
164
+ * Helper return type that preserves saved-value and write-value types for generated
165
+ * Go/admin compatibility checks. Let TypeScript infer it from your registration;
166
+ * annotating everything as `RegisteredPluginField` would lose this information.
167
+ */
168
+ export interface PluginFieldRegistration<
169
+ Value,
170
+ Input = Value,
171
+ Type extends FieldType = FieldType,
172
+ > extends RegisteredPluginField {
173
+ readonly type: Type;
174
+ readonly [valueContract]: { value: (value: Value) => Value; input: (value: Input) => Input };
175
+ }
176
+
177
+ /**
178
+ * Register the default Svelte editor for a new field type provided by your Go plugin.
179
+ *
180
+ * Put the result in `defineAdminPlugin({ fields: { color: ... } })`. The map key
181
+ * matches the Go descriptor's `PluginFieldType.Key`. Applications then use the Go
182
+ * field helper, such as `color.Field("accent")`, without selecting an editor.
183
+ * For a different input on an existing application field, use `defineFieldEditor`.
184
+ *
185
+ * Required options are `component` and `decodeValue`. The component receives
186
+ * `PluginFieldProps`. `decodeValue` checks unknown saved data and returns the
187
+ * component's value type. TypeScript infers that type from the function's return.
188
+ *
189
+ * Add `decodeInput` only when edits sent to the server differ from saved values.
190
+ * Without it, `decodeValue` checks reads and writes. Add `decodeConfig` only when
191
+ * the component needs Go field settings as its `config` prop. Without a settings
192
+ * decoder, the plugin's field config must be empty.
193
+ *
194
+ * Decoders must return synchronously and throw useful errors for invalid data.
195
+ * Keep them free of network requests or other side effects: they may run on every
196
+ * value read. Ridu handles empty `null`/`undefined` values separately. These checks
197
+ * help the browser interpret values; Go validation still decides what can be saved.
198
+ *
199
+ * @param definition The component, required saved-value decoder, and optional settings/input decoders.
200
+ * @returns A frozen registration retaining inferred saved and input types. Put
201
+ * it directly in the plugin's `fields` map; it does not add the Go field type.
202
+ * @throws If a component or decoder is missing or invalid. Data/config decoding
203
+ * can throw later when the admin reads a value or renders the selected field.
204
+ * @example
205
+ * ```ts
206
+ * import { defineAdminPlugin, definePluginField } from '@riducms/plugin/authoring/v1';
207
+ * import ColorField from './color-field.svelte';
208
+ * import { decodeColor } from './value';
209
+ *
210
+ * export const colorAdminPlugin = defineAdminPlugin({
211
+ * key: 'color',
212
+ * pairingVersion: 1,
213
+ * fields: {
214
+ * color: definePluginField({ component: ColorField, decodeValue: decodeColor })
215
+ * }
216
+ * });
217
+ * ```
218
+ */
219
+ export function definePluginField<Value, Config, Input>(
220
+ definition: FieldDefinition<Value, Config, "plugin", Input> & {
221
+ /** Check serialized Go field settings and return the component's config; throw if invalid. */
222
+ decodeConfig: (value: unknown) => Config;
223
+ /** Check values passed to `field.set`; required when writes differ from saved values. */
224
+ decodeInput: (value: unknown) => Input;
225
+ }
226
+ ): PluginFieldRegistration<Value, Input, "plugin"> & { readonly fieldType?: never };
227
+ /**
228
+ * Register a plugin field editor with checked settings. Put the result in your
229
+ * `defineAdminPlugin` fields map; the map key must match the Go field-type key.
230
+ * `decodeValue` checks reads and writes of the same shape. `decodeConfig` checks
231
+ * the Go settings before rendering. Both must be synchronous and throw on invalid
232
+ * data. Ridu owns the unsaved form; Go still validates the document on save.
233
+ */
234
+ export function definePluginField<Value, Config>(
235
+ definition: FieldDefinition<Value, Config, "plugin"> & {
236
+ /** Check serialized Go field settings and return the component's config; throw if invalid. */
237
+ decodeConfig: (value: unknown) => Config;
238
+ decodeInput?: never;
239
+ }
240
+ ): PluginFieldRegistration<Value, Value, "plugin"> & { readonly fieldType?: never };
241
+ /**
242
+ * Register a plugin field editor without settings. Put the result in your plugin's
243
+ * `fields` map under the Go field-type key. `decodeValue` checks saved data;
244
+ * `decodeInput` checks the different shape your component writes. Both must run
245
+ * synchronously and throw on invalid data. The component receives no config prop;
246
+ * the descriptor-owned field configuration must be empty.
247
+ * Go still validates the document on save.
248
+ */
249
+ export function definePluginField<Value, Input>(
250
+ definition: FieldDefinition<Value, undefined, "plugin", Input> & {
251
+ /** Check values passed to `field.set`; required when writes differ from saved values. */
252
+ decodeInput: (value: unknown) => Input;
253
+ decodeConfig?: never;
254
+ }
255
+ ): PluginFieldRegistration<Value, Input, "plugin"> & { readonly fieldType?: never };
256
+ /**
257
+ * Register a plugin field editor without settings. Put the result in your plugin's
258
+ * `fields` map under the Go field-type key. `decodeValue` checks reads and writes;
259
+ * it must run synchronously and throw on invalid data. The component receives no
260
+ * config prop; the descriptor-owned field configuration must be empty.
261
+ * Ridu owns the unsaved form,
262
+ * and Go still validates the document on save.
263
+ */
264
+ export function definePluginField<Value>(
265
+ definition: FieldDefinition<Value, undefined, "plugin"> & {
266
+ decodeConfig?: never;
267
+ decodeInput?: never;
268
+ }
269
+ ): PluginFieldRegistration<Value, Value, "plugin"> & { readonly fieldType?: never };
270
+ export function definePluginField(definition: RuntimeDefinition): RegisteredPluginField {
271
+ return createRegistration("plugin", definition);
272
+ }
273
+
274
+ type SingleType<Type, Whole = Type> = 0 extends 1 & Type
275
+ ? never
276
+ : Type extends unknown
277
+ ? [Whole] extends [Type]
278
+ ? Type
279
+ : never
280
+ : never;
281
+
282
+ type BuiltinValue<Type extends FieldType> = Type extends "text-list"
283
+ ? string[]
284
+ : Type extends "number-list"
285
+ ? number[]
286
+ : Type extends "number"
287
+ ? number
288
+ : Type extends "checkbox"
289
+ ? boolean
290
+ : Type extends "text" | "textarea" | "email" | "date" | "code"
291
+ ? string
292
+ : Type extends "ui"
293
+ ? undefined
294
+ : unknown;
295
+
296
+ type NamedFieldTarget<Type extends FieldType, Key extends string> = Type extends "plugin"
297
+ ? { readonly fieldType: Key }
298
+ : { readonly fieldType?: never };
299
+ /**
300
+ * Register a plugin component that can replace an existing field's editor.
301
+ *
302
+ * Put the result in `defineAdminPlugin({ components: { colorSwatch: ... } })`.
303
+ * Go selects that name through `field.PluginComponent(pluginKey, "colorSwatch")`
304
+ * in the field's `Admin.Editor` setting. This changes the input while keeping
305
+ * the original Go field's storage, validation, permissions, and generated types.
306
+ * For an input used only in your application, use `defineFieldEditor` instead.
307
+ *
308
+ * Required options are `type`, `component`, and `decodeValue`. Use the exact Go
309
+ * field type for `type`, such as `"text"`. When it is `"plugin"`, also supply the
310
+ * exact `fieldType` key, such as `"richtext"`. Omit `fieldType` for built-in fields.
311
+ *
312
+ * `decodeValue` checks saved data. Add `decodeInput` if write values have another
313
+ * shape; otherwise `decodeValue` checks both. The Svelte component receives the
314
+ * matching `PluginFieldProps<Value, Config, Type, Input>`.
315
+ *
316
+ * Add `decodeConfig` only when `field.PluginComponent` supplies settings. That
317
+ * decoder requires a Go settings object, even when empty. Without one, omit the
318
+ * settings argument. All decoders must return synchronously and throw useful
319
+ * errors for invalid input. Go still validates the document when it is saved.
320
+ *
321
+ * @param definition The existing field type, component, and value/settings decoders.
322
+ * @returns A frozen registration for the plugin's `components` map. It preserves
323
+ * the selected field type and inferred value/input types; it does not create a new field type.
324
+ * @throws If the field type, component, decoders, or plugin field-type selection are invalid.
325
+ * @example
326
+ * ```ts
327
+ * import { defineAdminPlugin, defineFieldComponent } from '@riducms/plugin/authoring/v1';
328
+ * import ColorSwatch from './color-swatch.svelte';
329
+ *
330
+ * export const editorialAdminPlugin = defineAdminPlugin({
331
+ * key: 'editorial-tools',
332
+ * pairingVersion: 1,
333
+ * components: {
334
+ * colorSwatch: defineFieldComponent({
335
+ * type: 'text',
336
+ * component: ColorSwatch,
337
+ * decodeValue(value): string {
338
+ * if (typeof value !== 'string') throw new Error('Expected text.');
339
+ * return value;
340
+ * }
341
+ * })
342
+ * }
343
+ * });
344
+ * ```
345
+ */
346
+ export function defineFieldComponent<
347
+ const Type extends FieldType,
348
+ Value extends BuiltinValue<Type>,
349
+ Config,
350
+ Input extends BuiltinValue<Type>,
351
+ const Key extends string = string,
352
+ >(
353
+ definition: { type: Type & SingleType<NoInfer<Type>> } & NamedFieldTarget<Type, Key> &
354
+ FieldDefinition<Value, Config, Type, Input> & {
355
+ /** Check the settings supplied by Go `field.Admin.Editor`; throw if invalid. */
356
+ decodeConfig: (value: unknown) => Config;
357
+ /** Check values passed to `field.set` when writes differ from saved values. */
358
+ decodeInput: (value: unknown) => Input;
359
+ }
360
+ ): PluginFieldRegistration<Value, Input, Type> & NamedFieldTarget<Type, Key>;
361
+ /**
362
+ * Name an alternative editor in your paired plugin's `components` map. Go selects
363
+ * it with `.Admin(field.Admin{Editor: field.PluginComponent(pluginKey, componentName, config)})`. Set `type` to
364
+ * the actual field type; also supply `fieldType` for plugin values. `decodeValue`
365
+ * checks reads/writes and `decodeConfig` checks settings, synchronously. Changing
366
+ * the editor does not change the field's storage or server validation.
367
+ */
368
+ export function defineFieldComponent<
369
+ const Type extends FieldType,
370
+ Value extends BuiltinValue<Type>,
371
+ Config,
372
+ const Key extends string = string,
373
+ >(
374
+ definition: { type: Type & SingleType<NoInfer<Type>> } & NamedFieldTarget<Type, Key> &
375
+ FieldDefinition<Value, Config, Type> & {
376
+ /** Check the settings supplied by Go `field.Admin.Editor`; throw if invalid. */
377
+ decodeConfig: (value: unknown) => Config;
378
+ decodeInput?: never;
379
+ }
380
+ ): PluginFieldRegistration<Value, Value, Type> & NamedFieldTarget<Type, Key>;
381
+ /**
382
+ * Name an alternative editor without settings in your plugin's `components` map.
383
+ * Go selects it with `field.Admin.Editor`. Set the actual schema `type`, plus
384
+ * `fieldType` for plugin values. Supply synchronous `decodeValue` and `decodeInput`
385
+ * for different saved/write shapes. Omit the Go settings argument; no config prop is
386
+ * passed. Any supplied component settings require a decoder.
387
+ * The field's storage and server validation stay the same.
388
+ */
389
+ export function defineFieldComponent<
390
+ const Type extends FieldType,
391
+ Value extends BuiltinValue<Type>,
392
+ Input extends BuiltinValue<Type>,
393
+ const Key extends string = string,
394
+ >(
395
+ definition: { type: Type & SingleType<NoInfer<Type>> } & NamedFieldTarget<Type, Key> &
396
+ FieldDefinition<Value, undefined, Type, Input> & {
397
+ /** Check values passed to `field.set` when writes differ from saved values. */
398
+ decodeInput: (value: unknown) => Input;
399
+ decodeConfig?: never;
400
+ }
401
+ ): PluginFieldRegistration<Value, Input, Type> & NamedFieldTarget<Type, Key>;
402
+ /**
403
+ * Name an alternative editor without settings in your plugin's `components` map.
404
+ * Go selects it with `field.Admin.Editor`. Set the actual schema `type`, plus
405
+ * `fieldType` for plugin values. `decodeValue` synchronously checks reads/writes.
406
+ * Omit the Go settings argument; no config prop is passed. Any supplied component
407
+ * settings require a decoder. The field's storage and server
408
+ * validation stay the same. For local field editors, use `defineFieldEditor`.
409
+ */
410
+ export function defineFieldComponent<
411
+ const Type extends FieldType,
412
+ Value extends BuiltinValue<Type>,
413
+ const Key extends string = string,
414
+ >(
415
+ definition: { type: Type & SingleType<NoInfer<Type>> } & NamedFieldTarget<Type, Key> &
416
+ FieldDefinition<Value, undefined, Type> & { decodeConfig?: never; decodeInput?: never }
417
+ ): PluginFieldRegistration<Value, Value, Type> & NamedFieldTarget<Type, Key>;
418
+ export function defineFieldComponent(
419
+ definition: RuntimeDefinition & { type: FieldType; fieldType?: string }
420
+ ): RegisteredPluginField {
421
+ if (
422
+ definition.type === "plugin" &&
423
+ !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(definition.fieldType ?? "")
424
+ )
425
+ throw new Error("A named plugin renderer requires an exact fieldType.");
426
+ if (definition.type !== "plugin" && definition.fieldType !== undefined)
427
+ throw new Error("Only plugin renderers select fieldType.");
428
+ return createRegistration(definition.type, definition, definition.fieldType);
429
+ }
430
+
431
+ interface RuntimeDefinition {
432
+ component: Component<never>;
433
+ decodeValue: (value: unknown) => unknown;
434
+ decodeConfig?: (value: unknown) => unknown;
435
+ decodeInput?: (value: unknown) => unknown;
436
+ }
437
+ function createRegistration(
438
+ type: FieldType,
439
+ definition: RuntimeDefinition,
440
+ fieldType?: string
441
+ ): RegisteredPluginField {
442
+ const supported: Record<FieldType, true> = {
443
+ "text-list": true,
444
+ "number-list": true,
445
+ text: true,
446
+ textarea: true,
447
+ email: true,
448
+ date: true,
449
+ code: true,
450
+ number: true,
451
+ checkbox: true,
452
+ json: true,
453
+ select: true,
454
+ radio: true,
455
+ point: true,
456
+ ui: true,
457
+ join: true,
458
+ virtual: true,
459
+ relationship: true,
460
+ upload: true,
461
+ group: true,
462
+ array: true,
463
+ blocks: true,
464
+ plugin: true,
465
+ };
466
+ if (!Object.hasOwn(supported, type)) throw new Error(`Unsupported field renderer type ${type}.`);
467
+ if ("canRender" in definition || "key" in definition || "componentKey" in definition)
468
+ throw new Error(
469
+ "Renderer matching comes from its keyed registration; key/canRender/componentKey are not supported."
470
+ );
471
+ if (
472
+ typeof definition.component !== "function" ||
473
+ typeof definition.decodeValue !== "function" ||
474
+ (definition.decodeConfig !== undefined && typeof definition.decodeConfig !== "function") ||
475
+ (definition.decodeInput !== undefined && typeof definition.decodeInput !== "function")
476
+ )
477
+ throw new Error(
478
+ "A plugin field requires a component, decodeValue and a synchronous config decoder when configured."
479
+ );
480
+ const { component, decodeValue, decodeConfig, decodeInput = decodeValue } = definition;
481
+ const hasInputDecoder = definition.decodeInput !== undefined;
482
+ const result = Object.freeze({
483
+ [registration]: true as const,
484
+ [valueContract]: { value: (value: unknown) => value, input: (value: unknown) => value },
485
+ type,
486
+ ...(fieldType === undefined ? {} : { fieldType }),
487
+ component,
488
+ decodeValue(value: unknown) {
489
+ return checkedValue(type, synchronous(decodeValue(detach(value))));
490
+ },
491
+ decodeInput(value: unknown) {
492
+ return checkedValue(type, synchronous(decodeInput(detach(value))));
493
+ },
494
+ decodeFormValue(value: unknown) {
495
+ try {
496
+ return checkedValue(type, synchronous(decodeValue(detach(value))));
497
+ } catch (error) {
498
+ if (!hasInputDecoder) throw error;
499
+ return checkedValue(type, synchronous(decodeInput(detach(value))));
500
+ }
501
+ },
502
+ decodeConfig(field: SchemaField) {
503
+ if (field.type !== type)
504
+ throw new Error(`Renderer expects ${type}, but ${field.path} is ${field.type}.`);
505
+ if (fieldType !== undefined && field.plugin?.key !== fieldType)
506
+ throw new Error(
507
+ `Renderer expects plugin field type ${fieldType}, but ${field.path} uses ${field.plugin?.key}.`
508
+ );
509
+ if (field.admin.component !== undefined) {
510
+ try {
511
+ return decodeComponentConfig(field.admin.component, decodeConfig);
512
+ } catch (error) {
513
+ throw new Error(
514
+ `Component ${field.admin.component.plugin}:${field.admin.component.component} config for ${field.path} is invalid: ${error instanceof Error ? error.message : String(error)}`
515
+ );
516
+ }
517
+ }
518
+ const raw = field.plugin?.config ?? {};
519
+ if (decodeConfig === undefined) {
520
+ if (
521
+ typeof raw !== "object" ||
522
+ raw === null ||
523
+ Array.isArray(raw) ||
524
+ Object.keys(raw).length !== 0
525
+ )
526
+ throw new Error(
527
+ `Field ${field.path} has configuration but its renderer has no decodeConfig.`
528
+ );
529
+ return undefined;
530
+ }
531
+ try {
532
+ return synchronous(decodeConfig(detach(raw)));
533
+ } catch (error) {
534
+ throw new Error(
535
+ `Invalid renderer config for ${field.path}: ${error instanceof Error ? error.message : String(error)}`
536
+ );
537
+ }
538
+ },
539
+ });
540
+ registrations.add(result);
541
+ return result;
542
+ }
543
+
544
+ export function assertPluginFieldRegistration(value: RegisteredPluginField): void {
545
+ if (!registrations.has(value))
546
+ throw new Error(
547
+ "Plugin field registrations must use definePluginField or defineFieldComponent."
548
+ );
549
+ }
550
+ function synchronous<Value>(value: Value): Value {
551
+ if (
552
+ typeof value === "object" &&
553
+ value !== null &&
554
+ "then" in value &&
555
+ typeof value.then === "function"
556
+ )
557
+ throw new Error("Field decoders must be synchronous.");
558
+ return value;
559
+ }
560
+ function detach(value: unknown): unknown {
561
+ const seen = new Set<object>();
562
+ const copy = (item: unknown, depth: number): unknown => {
563
+ if (depth > 100) throw new Error("Field value exceeds the supported JSON depth.");
564
+ if (
565
+ item === null ||
566
+ item === undefined ||
567
+ typeof item === "string" ||
568
+ typeof item === "boolean"
569
+ )
570
+ return item;
571
+ if (typeof item === "number" && Number.isFinite(item)) return item;
572
+ if (
573
+ typeof item !== "object" ||
574
+ (!Array.isArray(item) &&
575
+ Object.getPrototypeOf(item) !== Object.prototype &&
576
+ Object.getPrototypeOf(item) !== null) ||
577
+ seen.has(item)
578
+ )
579
+ throw new Error("Field values and config must contain finite, acyclic JSON data.");
580
+ seen.add(item);
581
+ const result = Array.isArray(item)
582
+ ? item.map((child) => copy(child, depth + 1))
583
+ : Object.fromEntries(
584
+ Object.entries(item).map(([key, child]) => [key, copy(child, depth + 1)])
585
+ );
586
+ seen.delete(item);
587
+ return result;
588
+ };
589
+ return copy(value, 0);
22
590
  }
23
591
 
24
- export function defineFieldPlugin<const Plugin extends FieldPlugin>(plugin: Plugin): Plugin {
25
- return plugin;
592
+ function checkedValue(type: FieldType, result: unknown): unknown {
593
+ if (
594
+ (["text", "textarea", "email", "date", "code"].includes(type) && typeof result !== "string") ||
595
+ (type === "number" && typeof result !== "number") ||
596
+ (type === "text-list" &&
597
+ (!Array.isArray(result) || !Array.from(result).every((item) => typeof item === "string"))) ||
598
+ (type === "number-list" &&
599
+ (!Array.isArray(result) ||
600
+ !Array.from(result).every((item) => typeof item === "number" && Number.isFinite(item)))) ||
601
+ (type === "checkbox" && typeof result !== "boolean") ||
602
+ (type === "ui" && result !== undefined)
603
+ )
604
+ throw new Error(`Invalid decoded ${type} value.`);
605
+ return detach(result);
26
606
  }
package/src/form.ts CHANGED
@@ -1,17 +1,46 @@
1
1
  import type { ValidationIssue } from "@riducms/protocol";
2
2
 
3
+ /** Identifies the collection document or global currently open in the form. */
3
4
  export interface FieldFormResource {
5
+ /** The collection slug, or the global slug when `global` is true. */
4
6
  collection: string;
7
+ /** The saved document ID. A new document does not have one yet. */
5
8
  id?: string;
9
+ /** True when the form edits a global instead of a collection document. */
6
10
  global?: boolean;
7
11
  }
8
12
 
9
- export interface FieldForm {
13
+ /**
14
+ * Read the current document form, including edits that have not been saved.
15
+ * Returned objects are copies: mutating them does not change the form.
16
+ * In field editors these reads throw once the editor's connection to the form expires.
17
+ */
18
+ export interface FieldDocumentForm {
19
+ /** The content locale being edited, such as `"fr"`; separate from the admin UI language. */
10
20
  readonly contentLocale?: string;
21
+ /** Which collection document or global is being edited, when supplied by this form. */
11
22
  readonly resource?: FieldFormResource;
23
+ /**
24
+ * Read a copy of a value by its current document-root path, such as `"title"`
25
+ * or `"reviews.0.note"`. Returns undefined for a missing value. A numbered path
26
+ * reads whichever row is at that position now; it does not remember row identity.
27
+ * Check the returned value's type before using it.
28
+ */
12
29
  get(path: string): unknown;
30
+ /** Current validation issues at or below a document-root path, including server issues. */
13
31
  issuesFor(path: string): readonly ValidationIssue[];
14
- register(path: string): () => void;
15
- set(path: string, value: unknown): void;
16
- snapshot?(): Record<string, unknown>;
32
+ /**
33
+ * Copy all current form values, including unsaved edits. The result is a snapshot:
34
+ * it does not update when the user types, and changing it does not update the form.
35
+ * This is form data, not a fresh document fetched from the server.
36
+ */
37
+ snapshot(): Record<string, unknown>;
38
+ }
39
+
40
+ /** Host-owned advisory feedback. A checked result is not a promise that Save will succeed. */
41
+ export interface FieldLiveValidation {
42
+ /** Idle until edited; skipped means the current input could not be checked. */
43
+ readonly status: "idle" | "pending" | "checked" | "skipped" | "failed";
44
+ /** Retry a failed check using current form values. Ridu owns cancellation and requests. */
45
+ retry(): void;
17
46
  }