@riducms/plugin 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,38 +1,352 @@
1
1
  # `@riducms/plugin`
2
2
 
3
- Svelte and TypeScript APIs for Ridu admin plugins. Import field props, extension types, and
4
- `defineFieldPlugin` from this package.
3
+ Build Svelte components that work inside Ridu's admin. Ridu provides the document form,
4
+ validation messages, access state and tools for related documents. Your component supplies the UI.
5
+ The admin is built into static files and served by the Go application; these components run in
6
+ the browser, without a JavaScript server in production.
7
+
8
+ ## Choose the API for your job
9
+
10
+ | Job | Import | Register/select it |
11
+ | --- | --- | --- |
12
+ | Add a field type with its own Go rules and structured value | `defineAdminPlugin`, `definePluginField` from `@riducms/plugin/authoring/v1` | Plugin `fields` map; matching field-type key declared in Go |
13
+ | Supply an alternative advanced field editor in a paired plugin | `defineFieldComponent` from `@riducms/plugin/authoring/v1` | Plugin `components` map; Go `.Admin(field.Admin{Editor: field.PluginComponent(owner, name, config)})` |
14
+ | Change how one application's text/number/checkbox-style field looks | `defineFieldEditor` from `@riducms/plugin/editor` | `defineAdmin({ fields })`; Go `.Admin(field.Admin{Editor: field.Component("app:name", config)})` |
15
+ | Add application routes, dashboard panels or other admin UI | `defineAdmin` from `@riducms/plugin/admin` | `admin/src/admin.config.ts` |
16
+ | Customize an application's array/block row headings | `defineRowLabel` from `@riducms/plugin/admin` | `defineAdmin({ rowLabels })`; Go `.Admin(field.Admin{RowLabel: field.Component("app:name", config)})` |
17
+ | Use the shared visual controls | `@riducms/ui` | Compose controls in your component |
18
+
19
+ Public component/host types are exported from `@riducms/plugin`. The versioned authoring import
20
+ also exports the plugin field types. Do not import private `admin/src` implementations.
21
+
22
+ ## A plugin has a server half and a browser half
23
+
24
+ The **Go half** declares the plugin's field types, settings, validation and any server endpoints.
25
+ The **Svelte/TypeScript half** displays those fields and implements browser interactions.
26
+ Ridu generates the glue that checks both halves agree and chooses the correct component.
27
+
28
+ There are three separate names:
29
+
30
+ - **Plugin key**, such as `editorial-tools`: identifies the installed Go/admin pair.
31
+ - **Field-type key**, such as `review-note`: identifies a kind of value supplied by that plugin.
32
+ One plugin can provide several types; field-type keys must be unique across installed plugins.
33
+ - **Field name**, such as `review`: identifies where a value lives in a document. Several fields
34
+ can use the same `review-note` type, including fields inside repeatable rows.
35
+
36
+ Use `ridu plugin new` to start a paired package and `ridu plugin add` to install it into an app.
37
+ The app retains `generatedAdminPlugins` in `defineAdmin({ plugins: generatedAdminPlugins })`.
38
+ This keeps the generated Go/admin checks. Authors using explicit imports still supply an ordinary
39
+ plugin array, such as `plugins: [outlineAdminPlugin, seoAdminPlugin]`, alongside the corresponding
40
+ Go registrations and generated checks.
41
+
42
+ `pairingVersion` is a positive integer you maintain in both halves. Increase it when the Go and
43
+ admin packages can no longer work together. It is separate from your package release version.
44
+ The `/authoring/v1` import supplies Ridu's `apiVersion`; do not stamp it onto a declaration yourself.
45
+ Changing that number does not make incompatible code compatible.
46
+
47
+ ## Example: a note with a “Use note as title” button
48
+
49
+ This editor stores `{ text: string }`. Its Go field settings contain `{ "copyTo": "title" }`,
50
+ meaning the button should copy into the containing document's Title field.
51
+
52
+ First define checked data/settings in `value.ts`. A decoder is simply a function that checks
53
+ unknown data, returns the shape your component expects, or throws a useful error.
54
+
55
+ ```ts
56
+ export interface NoteValue {
57
+ text: string;
58
+ }
59
+ export interface NoteConfig {
60
+ copyTo: string;
61
+ }
62
+
63
+ export function decodeNote(raw: unknown): NoteValue {
64
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw) ||
65
+ !("text" in raw) || typeof raw.text !== "string" || Object.keys(raw).length !== 1) {
66
+ throw new Error("A note must be an object containing only a text string.");
67
+ }
68
+ return { text: raw.text };
69
+ }
70
+
71
+ export function decodeNoteConfig(raw: unknown): NoteConfig {
72
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw) ||
73
+ !("copyTo" in raw) || typeof raw.copyTo !== "string" || !raw.copyTo.trim() ||
74
+ Object.keys(raw).length !== 1) {
75
+ throw new Error("Note settings must contain a nonempty copyTo field path.");
76
+ }
77
+ return { copyTo: raw.copyTo };
78
+ }
79
+ ```
80
+
81
+ Then write `note-field.svelte`:
82
+
83
+ ```svelte
84
+ <script lang="ts">
85
+ import type { PluginFieldProps } from "@riducms/plugin/authoring/v1";
86
+ import type { NoteValue, NoteConfig } from "./value";
87
+
88
+ let { field, config, form }: PluginFieldProps<NoteValue, NoteConfig> = $props();
89
+ // Remember this document's Title field while this note editor is open.
90
+ // svelte-ignore state_referenced_locally
91
+ const title = form.bind(config.copyTo);
92
+ </script>
93
+
94
+ <label for={field.schema.id}>{field.schema.admin.label}</label>
95
+ <textarea
96
+ id={field.schema.id}
97
+ name={field.schema.path}
98
+ required={field.schema.required}
99
+ readonly={field.readOnly}
100
+ aria-invalid={field.issues.length > 0}
101
+ aria-describedby={`${field.schema.id}-issues`}
102
+ value={field.value?.text ?? ""}
103
+ oninput={(event) => field.set({ text: event.currentTarget.value })}
104
+ ></textarea>
105
+ <div id={`${field.schema.id}-issues`} aria-live="polite">
106
+ {#each field.issues as issue}<p>{issue.message}</p>{/each}
107
+ </div>
108
+ <button
109
+ type="button"
110
+ disabled={field.readOnly || title.readOnly || !field.value?.text}
111
+ onclick={() => title.set(field.value?.text ?? null)}
112
+ >
113
+ Use note as title
114
+ </button>
115
+ ```
116
+
117
+ Register it in `index.ts`:
5
118
 
6
119
  ```ts
7
- import { defineFieldPlugin, type FieldComponentProps } from "@riducms/plugin";
120
+ import { defineAdminPlugin, definePluginField } from "@riducms/plugin/authoring/v1";
121
+ import NoteField from "./note-field.svelte";
122
+ import { decodeNote, decodeNoteConfig } from "./value";
123
+
124
+ export const editorialAdminPlugin = defineAdminPlugin({
125
+ key: "editorial-tools",
126
+ pairingVersion: 1,
127
+ fields: {
128
+ "review-note": definePluginField({
129
+ component: NoteField,
130
+ decodeValue: decodeNote,
131
+ decodeConfig: decodeNoteConfig,
132
+ }),
133
+ },
134
+ });
8
135
  ```
9
136
 
10
- `defineAdminPlugin` also exposes collision-checked static extension surfaces for login, profile,
11
- security, navigation, logout, dashboard, routes, list cells, document actions, and document views.
12
- Core-view replacements receive a `defaultView` snippet so a plugin can wrap the existing screen.
13
- Auth and account components receive hosts for login, logout, identity refresh, and notifications.
14
-
15
- Collection list/create/edit, global, and not-found replacements use the same model. They can be
16
- registered for one resource or as a surface-wide fallback; exact resource registrations win over
17
- fallbacks. Their host exposes manifest refresh, document-change invalidation, and notifications,
18
- and application data remains available through the generated SDK.
19
-
20
- Fine-grained shell registrations cover login/navigation graphics, the account avatar, global
21
- header and action slots, account settings items, and ordered provider wrappers. Replacement
22
- graphics allow one registration per surface; additive shell components and providers keep plugin
23
- order.
24
-
25
- A paired backend plugin may also assign an exact `componentKey` to a renderer for a built-in field
26
- type. These renderers keep the core field's storage semantics and receive the public form resource,
27
- content locale, a detached current-form `snapshot()`, and the narrow `requestPlugin` authoring host.
28
- That host calls only the paired backend plugin's namespaced endpoints.
29
-
30
- Arrays and blocks may also select an exact paired-plugin row-label component. Register it with
31
- `defineRowLabelPlugin({ key, componentKey, component })` and author the matching Go option with
32
- `field.RowLabelComponent`. The component receives the manifest field, a detached deeply frozen row
33
- snapshot, its 1-based visual `rowNumber`, current i18n, and deterministic JSON config. Registration
34
- identities are collision checked before mount, and a manifest that selects a missing
35
- plugin/component fails with a field-specific diagnostic. The legacy single-child
36
- `field.RowLabel(path)` behavior remains available for arrays.
37
-
38
- Use `@riducms/ui` for shared interaction components and the generated SDK for application data.
137
+ The Go descriptor must declare the same plugin/field-type keys, admin package/export and pairing
138
+ version, plus its value types and validation. The application must actually contain a `title`
139
+ field. The config decoder above checks the setting's shape; it does not prove that path exists.
140
+ `form.bind` checks the current form when the component starts. See the
141
+ [plugin guide](https://riducms.com/docs/plugins/) for the Go registration side.
142
+
143
+ The flow is:
144
+
145
+ 1. Ridu opens the document and supplies its form to your component.
146
+ 2. Typing calls `field.set({ text: ... })`, changing the note in the **unsaved form**.
147
+ 3. Clicking the button copies the note's current text into Title in that same form.
148
+ 4. This is a one-time copy. Further typing does not update Title until another click.
149
+ 5. Saving the document sends the form through Ridu's normal server permissions and validation.
150
+ If validation fails, Ridu supplies the returned issues to the field.
151
+
152
+ Nothing in this button calls AI or saves to the database. A note inside `reviews.0.note` still
153
+ copies to the document-root `title`, because that is what its config says.
154
+
155
+ ## What `form.bind` means
156
+
157
+ A **binding** is a connection to one particular field in the open form. For example:
158
+
159
+ ```ts
160
+ const note = form.bind("reviews.0.note");
161
+ ```
162
+
163
+ This selects the first review **at the time of the call**. If that review later moves to the
164
+ third position, `note.set(...)` still edits the same review. Keeping the text `"reviews.0.note"`
165
+ and looking it up again later would select whoever occupies the first position then.
166
+
167
+ Create bindings during component setup or before starting delayed work, and keep the returned
168
+ object in your callback. Ridu stops that connection from being used after its target is removed,
169
+ the source editor unmounts, or the document, content locale, schema or saved/reset form values
170
+ are replaced. Reading/writing then throws. `binding.stale` remains safe to inspect, and
171
+ `binding.readOnly` becomes true. Neither a timeout nor a late response may reuse that old connection
172
+ to edit a new row/document. A new mounted editor gets new bindings.
173
+
174
+ A title binding also respects the note editor's editability: the button cannot update Title while
175
+ the source note is read-only, even if Title itself is editable. The server independently checks
176
+ permissions on save.
177
+
178
+ The operation engine assigns missing `_key` IDs to every declared array and block row, including
179
+ rows created through the API. Keys are nonempty and unique within each list. Keep returned keys
180
+ when editing or reordering existing rows; document duplication assigns fresh identities.
181
+
182
+ `form.bind(path)` returns `PluginFieldBinding<unknown>`, because TypeScript cannot infer a value
183
+ shape from an arbitrary string path. Check values you read and write the shape the target field
184
+ expects. This is different from your own `field`, which has your registered decoder types.
185
+
186
+ ## Values, config and validation
187
+
188
+ | API | Meaning |
189
+ | --- | --- |
190
+ | `field.value` | Latest checked form value, including unsaved edits. Objects/arrays are copies. |
191
+ | `field.rawValue` | Copied data before decoding, useful for recovery UI when old data is unsupported. |
192
+ | `field.set(next)` | Replace this value in the normal unsaved form. Use `null` to clear; undefined is rejected. |
193
+ | `field.issues` | Current form/server validation issues for the field and its children. |
194
+ | `form.get(path)` | Copy a value at its current document-root path; does not remember a row's identity. |
195
+ | `form.snapshot()` | Copy current form values once. It is neither live state nor a server fetch. |
196
+
197
+ Changing an object returned by a read does not edit the form. Call `set` with a replacement value.
198
+ Ridu copies values supplied to `set` too, so mutating your original object afterward does not
199
+ silently change saved or unsaved form data.
200
+
201
+ `PluginFieldProps<Value, Config, Type, Input>` uses `Input = Value` by default. When the server
202
+ accepts a different write shape from its returned data, supply `decodeInput` too. Until a save
203
+ returns normalized data, `field.value` can contain either checked `Value` or checked `Input`.
204
+ The host tries the output decoder first, then the supplied input decoder. Do not assume it has
205
+ invented server-generated properties for an unsaved edit. Null/undefined reads are empty states
206
+ handled separately from the decoders.
207
+
208
+ Decoders must be synchronous and safe to run repeatedly. Value data must be plain JSON-shaped
209
+ data: no class instances, functions, cycles or non-finite numbers. Config arrives as serialized
210
+ Go settings and must be checked before it becomes typed component data. For a selected component,
211
+ omit the Go configuration argument when there are no settings; no decoder or `config` prop is needed.
212
+ Every supplied object, including `{}`, requires `decodeConfig`, and a declared decoder requires an
213
+ object. Plugin-owned value configuration keeps its descriptor defaults and may use an empty
214
+ object without a decoder.
215
+
216
+ Let registration helpers infer the result. Their types check component props against decoder
217
+ results; generated TypeScript checks the declared saved/write types against the Go descriptor.
218
+ Type annotations do not execute validation, and `any`/assertions can bypass static checks. A
219
+ poorly written decoder can still accept invalid data. Go validation and authorization remain the
220
+ final checks on saving; UI customization does not change storage, filtering or operation rules.
221
+
222
+ ## Related documents and server requests
223
+
224
+ `authoring` supplies Ridu's tools to a field component:
225
+
226
+ - `collections`: available collection definitions, not the documents themselves.
227
+ - `locale`: content locale, separate from the admin interface language.
228
+ - `documentRevision`: an admin change counter for refreshing UI, not a saved `_revision` number.
229
+ - `findDocument(collection, id, signal?)`: fetch a saved document in the current locale. It does
230
+ not read the current form's unsaved changes.
231
+ - `referenceBrowser`: the related-document picker/editor. Render it and update your field in
232
+ `onCommit(ids)`. Return false to keep it open; otherwise it closes after acceptance. Saving a
233
+ related document inside the picker is a separate server operation.
234
+ - `requestPlugin<Result>(path, body, signal?)`: POST to this renderer's paired Go plugin endpoint,
235
+ using a relative path such as `generate-title`. The response generic does not validate data;
236
+ request `unknown` and decode it when needed. This does not update your form automatically.
237
+
238
+ Async authoring calls check that the field is still usable when they complete. An expired field
239
+ rejects the result. This cannot undo server work already dispatched, and it does not choose
240
+ between two requests made while the same field stays open. Your component must handle errors,
241
+ cancel unnecessary requests and prevent an older response overwriting a newer result.
242
+
243
+ ## Ordinary fields inside a structured plugin value
244
+
245
+ A plugin such as rich text can contain cards with ordinary Ridu fields inside them. The Go field
246
+ must declare those embedded trees, cases and variants. Here, **payload** means the ordinary field
247
+ data inside one such item, not the whole plugin value or the Payload CMS product.
248
+
249
+ To edit an existing item's fields directly in the parent form, render:
250
+
251
+ ```svelte
252
+ {#if selectedIdentity !== undefined && authoring.schemaForm !== undefined}
253
+ {@render authoring.schemaForm({ treeKey: "widgets", identity: selectedIdentity })}
254
+ {/if}
255
+ ```
256
+
257
+ The plugin provides `selectedIdentity` from its own selection UI. `treeKey` matches the Go-declared
258
+ tree, and `identity` identifies the existing item. Ridu finds its current position and renders its
259
+ ordinary fields. Edits immediately enter the parent form; this does not save the document.
260
+ Removed/malformed items show recovery UI rather than another item's fields.
261
+
262
+ For **Apply/Cancel**, use a temporary embedded draft:
263
+
264
+ 1. Call `beginSchemaDraft({ treeKey, identity })` for an existing item, or
265
+ `beginSchemaDraft({ treeKey, caseTag, variantSlug })` to prepare a new item with field defaults.
266
+ 2. Render `schemaDraftEditor({ draft, title, onApply, onCancel })` as a Svelte snippet.
267
+ 3. `onApply(payload)` receives checked field data. Update your plugin/editor state and serialize
268
+ it through `field.set`; Ridu does not insert the item into your value for you. Close the drawer
269
+ in your Apply/Cancel callbacks. Apply/Cancel releases the draft.
270
+ 4. Call `discard()` if you abandon a draft outside the drawer. Closing the field also cleans it up.
271
+
272
+ These drafts are temporary forms, not saved document versions. They reuse Ridu's form controller
273
+ and cannot save independently. Pending insertions or dirty drafts block outer Save until the user
274
+ applies/cancels them. Apply checks declared field rules; executable Go validators run on parent save.
275
+
276
+ Drafts follow the same item through reorder. Removal, changed source data, document/locale/schema
277
+ or relevant access changes expire them; reopen instead of applying old data.
278
+ `schemaIssues({ treeKey, identity })` supplies current issues for an item's badge.
279
+ `copySchemaPayload({ treeKey, caseTag, variantSlug }, payload)` copies ordinary field data with
280
+ fresh IDs for schema-declared nested rows/items. Your plugin still manages the copied outer item.
281
+ The host interprets only declared schema structure, not arbitrary JSON with similar property names.
282
+
283
+ See the [Outline fixture](../../tests/contracts/admin_app/outline-field.svelte) for a compact
284
+ embedded-form example and the [rich-text package](../plugin-richtext/) for an editor integration.
285
+
286
+ ## Other admin UI
287
+
288
+ Both paired plugins and `defineAdmin` can register `routes`, `dashboard`, `login`, `account`,
289
+ `navigation`, `logoutButton`, `views`, `branding`, `shell`, `providers`, `listCells`,
290
+ `documentActions` and `documentViews`.
291
+
292
+ Replacement login/account/navigation/core-view components receive a `defaultView` snippet.
293
+ Render `{@render defaultView()}` to keep the normal screen inside your wrapper. Providers must
294
+ render their `defaultView` to include the nested admin. Host methods perform login/logout,
295
+ refreshes and notifications; application-specific operations use the generated SDK.
296
+
297
+ Collection/global view replacements may target one resource or act as a fallback. An exact
298
+ resource match wins over its fallback; duplicate targets and exclusive replacements fail.
299
+ Table cells and document action/view props contain document data, not a field-form binding.
300
+ Use their hosts to refresh after your operation; `refresh()` fetches data, it does not save edits.
301
+
302
+ Paired plugins register row headings with `defineRowLabelPlugin({ key, componentKey, component })`
303
+ and Go `.Admin(field.Admin{RowLabel: field.PluginComponent(key, componentKey, config)})`. The heading
304
+ receives a copied, deeply frozen `row` and a 1-based visual `rowNumber`. Its config is unknown: validate it inside the component. Application-local
305
+ headings use `defineRowLabel`, adding a config decoder only when settings are supplied, and select
306
+ `.Admin(field.Admin{RowLabel: field.Component("app:name", config)})` in Go. Use
307
+ `field.Admin{RowLabelPath: path}` for array headings based on one child field.
308
+
309
+ Use `defineAdminMessages` for interface messages. Catalog keys have no prefix; refer to them as
310
+ `plugin.editorial-tools:messageName` for that plugin or `app:messageName` for the application.
311
+
312
+ ## Local field editors and visual wrappers
313
+
314
+ Local editors support string, number, boolean, text-list, and number-list value shapes.
315
+ `FieldEditorProps<"text-list">` uses `string[]`; `FieldEditorProps<"number-list">` uses `number[]`.
316
+ Lists remain one field occurrence and arrays are copied on reads and writes. The host rejects
317
+ mixed types, non-finite numbers, and null elements at runtime. A built-in numeric control can retain
318
+ unfinished input in its form draft; a custom binding rejects such values instead of claiming they
319
+ are `number[]`.
320
+
321
+ ```ts
322
+ const editor = defineFieldEditor({ type: "text-list", component: SellingPoints });
323
+ // SellingPoints.svelte receives FieldEditorProps<"text-list"> and can call:
324
+ // field.set([...(field.value ?? []), "Solid oak"]);
325
+ ```
326
+
327
+ Application-local editors support text, textarea, email, date, code, number and checkbox fields.
328
+ They receive `FieldEditorProps<Type, Config>` and their own `field.set`, plus document reads and
329
+ related-document browsing/lookup. They cannot bind another field for writing or call plugin
330
+ endpoints, and do not expose embedded draft forms. Advanced `AdminComponent` renderers stay on
331
+ the paired-plugin API. Packaged distribution of application-local components is still deferred.
332
+
333
+ `Field` from `@riducms/plugin/editor/field` is an optional wrapper for a local field editor's label,
334
+ description and issues. `FieldFrame` from `@riducms/ui` is the underlying visual component, also
335
+ usable by plugin editors. Neither owns form values or registration. Your input still supplies its
336
+ value, read-only state and change handler. Importing only editor types does not import this UI.
337
+
338
+ ## Checks and further reading
339
+
340
+ Run your plugin's TypeScript/Svelte checks, then the application's `ridu check` and production
341
+ `ridu build`. Ridu checks actual registrations against the resolved Go schema, including field
342
+ selection, decoder settings, duplicate registrations and Go/admin compatibility. These checks
343
+ compile with the application's production Vite configuration without starting a browser or dev
344
+ server. They do not prove every future field value or arbitrary callback is correct: test those
345
+ interactions as well.
346
+
347
+ For custom UI, use the documented editor contracts and run the checks above in the consuming
348
+ application so its Svelte and TypeScript configuration checks the complete integration.
349
+
350
+ - [Plugin fields and editors](https://riducms.com/docs/fields/plugin/)
351
+ - [Building plugins](https://riducms.com/docs/plugins/)
352
+ - [Application-local field components](https://riducms.com/docs/custom-components/field-components/)
package/package.json CHANGED
@@ -3,12 +3,17 @@
3
3
  "url": "https://github.com/riducms/ridu/issues"
4
4
  },
5
5
  "dependencies": {
6
- "@riducms/protocol": "0.1.4",
7
- "@riducms/translations": "0.1.4"
6
+ "@riducms/protocol": "0.2.0",
7
+ "@riducms/translations": "0.2.0",
8
+ "@riducms/ui": "0.2.0"
8
9
  },
9
10
  "description": "Public TypeScript contracts for statically registered Ridu admin plugins.",
10
11
  "exports": {
11
- ".": "./src/index.ts"
12
+ ".": "./src/index.ts",
13
+ "./admin": "./src/admin.ts",
14
+ "./authoring/v1": "./src/authoring/v1.ts",
15
+ "./editor": "./src/editor/index.ts",
16
+ "./editor/field": "./src/editor/field.ts"
12
17
  },
13
18
  "files": [
14
19
  "src",
@@ -34,5 +39,5 @@
34
39
  "test": "bun test"
35
40
  },
36
41
  "type": "module",
37
- "version": "0.1.4"
42
+ "version": "0.2.0"
38
43
  }
package/src/admin.ts ADDED
@@ -0,0 +1,171 @@
1
+ import { bindSchemaManifest } from "@riducms/protocol";
2
+ import { resolveBlockTypes } from "@riducms/protocol";
3
+ import { validatePluginManifest, validatePluginRegistrations } from "./plugin-registry";
4
+ import { isRegisteredRowLabel, type RegisteredRowLabel } from "./local-row-label";
5
+ import { localEditorReference } from "./editor/registry";
6
+ import type { SchemaField } from "@riducms/protocol";
7
+ import type { SchemaManifest } from "@riducms/protocol";
8
+ import type { TranslationLanguage } from "@riducms/translations";
9
+ import type { PluginMessageCatalog } from "./i18n";
10
+ import { resolveAdminExtensions, type AdminContributions, type AdminPlugin } from "./plugin";
11
+ import {
12
+ validateAdminEditors,
13
+ validateFieldEditorRegistrations,
14
+ type FieldEditorConfig,
15
+ } from "./editor/registry";
16
+
17
+ /**
18
+ * Settings for your application's admin, exported from `admin/src/admin.config.ts`.
19
+ * Register your custom components here. Keep `generatedAdminPlugins` in `plugins`
20
+ * to load installed plugins such as rich text. Use `fields` to replace inputs for
21
+ * text, textarea, email, date, code, number and checkbox fields.
22
+ */
23
+ export interface AdminConfig extends AdminContributions, FieldEditorConfig {
24
+ /** Custom array or block row headings selected by Go `field.Admin{RowLabel: field.Component("app:name")}`. */
25
+ rowLabels?: Readonly<Record<`app:${string}`, RegisteredRowLabel>>;
26
+ /** Installed admin plugins, normally `generatedAdminPlugins` from Ridu's generated file. */
27
+ plugins?: readonly AdminPlugin[];
28
+ /** Interface messages made with `defineAdminMessages`; refer to them as `app:messageName`. */
29
+ messages?: PluginMessageCatalog;
30
+ /** Bundled admin UI language catalogs. These are separate from document content locales. */
31
+ languages?: readonly TranslationLanguage[];
32
+ }
33
+
34
+ /**
35
+ * Configure your application's admin components and installed plugins.
36
+ *
37
+ * Export the result as the default export of `admin/src/admin.config.ts`. Keep
38
+ * `plugins: generatedAdminPlugins` to load installed packages such as rich text.
39
+ * Add `fields` for inputs made with `defineFieldEditor`, `rowLabels` for headings
40
+ * made with `defineRowLabel`, or options such as `dashboard`, `routes`, and
41
+ * `documentActions` for other parts of the admin. All options are optional.
42
+ *
43
+ * Application field and row-label keys use `app:name`; select the same key in Go
44
+ * with `field.Component`. A plugin's new field types instead belong inside its
45
+ * `defineAdminPlugin({ fields: ... })` registration.
46
+ *
47
+ * Installed plugins load first, then your application components. Entries need
48
+ * unique keys, and only one component may replace a given screen or location.
49
+ * Run `ridu check` to also check selections, field types, and settings from Go.
50
+ * These checks also run when building and starting the admin.
51
+ *
52
+ * @param config The installed plugins, custom components, and interface translations.
53
+ * @returns The same configuration object after registration checks. The admin
54
+ * reads it when starting; calling this function does not mount any components.
55
+ * @throws If registrations, keys, or replacement locations conflict.
56
+ * @example
57
+ * ```ts
58
+ * import { defineAdmin } from '@riducms/plugin/admin';
59
+ * import { generatedAdminPlugins } from './ridu.plugins.generated';
60
+ * import WelcomePanel from './components/welcome-panel.svelte';
61
+ *
62
+ * export default defineAdmin({
63
+ * plugins: generatedAdminPlugins,
64
+ * dashboard: [{ key: 'welcome', component: WelcomePanel, position: 'before' }]
65
+ * });
66
+ * ```
67
+ */
68
+ export function defineAdmin(config: AdminConfig): AdminConfig {
69
+ if ("fieldPlugins" in config)
70
+ throw new Error(
71
+ "Application fieldPlugins are not supported; use a field-owned Editor component or a paired advanced field plugin."
72
+ );
73
+ validatePluginRegistrations(config.plugins ?? []);
74
+ validateFieldEditorRegistrations(config);
75
+ for (const [reference, label] of Object.entries(config.rowLabels ?? {})) {
76
+ if (!localEditorReference.test(reference) || !isRegisteredRowLabel(label))
77
+ throw new Error(`Row label ${reference} must use an app:name reference and defineRowLabel.`);
78
+ }
79
+ resolveAdminExtensions(config.plugins ?? [], config);
80
+ return config;
81
+ }
82
+
83
+ /**
84
+ * Check admin registrations against Ridu's resolved Go schema. Used by Ridu's build
85
+ * tooling and admin startup; application authors normally run `ridu check` instead.
86
+ * Set `completeManifest` only with the full schema from Go: runtime schemas can omit
87
+ * resources the current user cannot access, so absence there does not prove a typo.
88
+ * Throws with the affected registration or field when a selection/config is invalid.
89
+ */
90
+ export function validateAdminConfig(
91
+ config: AdminConfig,
92
+ manifest: Pick<SchemaManifest, "collections" | "globals" | "blocks"> &
93
+ Partial<Pick<SchemaManifest, "plugins">>,
94
+ options: { completeManifest?: boolean } = {}
95
+ ): void {
96
+ defineAdmin(config);
97
+ validateAdminEditors(config, manifest);
98
+ validatePluginManifest(config.plugins ?? [], manifest, options.completeManifest === true);
99
+ bindSchemaManifest(manifest);
100
+ const inspect = (fields: readonly SchemaField[], owner: string) => {
101
+ for (const field of fields) {
102
+ const selection = field.nested?.rowLabelComponent;
103
+ if (selection?.reference !== undefined) {
104
+ const reference = selection.reference;
105
+ if (
106
+ !localEditorReference.test(reference) ||
107
+ selection.plugin !== undefined ||
108
+ selection.component !== undefined ||
109
+ (field.type !== "array" && field.type !== "blocks")
110
+ )
111
+ throw new Error(`${owner}.${field.path}: invalid local row label selection.`);
112
+ const label = config.rowLabels?.[reference as `app:${string}`];
113
+ if (label === undefined)
114
+ throw new Error(
115
+ `${owner}.${field.path}: row label ${reference} is not registered in admin/src/admin.config.ts.`
116
+ );
117
+ try {
118
+ label.decode(field);
119
+ } catch (error) {
120
+ throw new Error(
121
+ `${owner}.${field.path}: ${error instanceof Error ? error.message : String(error)}`
122
+ );
123
+ }
124
+ }
125
+ for (const tree of field.plugin?.embeddedTrees ?? [])
126
+ for (const item of tree.cases)
127
+ for (const variant of resolveBlockTypes(item)) inspect(variant.fields, owner);
128
+ if (field.nested !== undefined) inspect(field.nested.fields, owner);
129
+ for (const block of resolveBlockTypes(field.blocks) ?? []) inspect(block.fields, owner);
130
+ }
131
+ };
132
+ for (const collection of manifest.collections) inspect(collection.fields, collection.slug);
133
+ for (const global of manifest.globals ?? []) inspect(global.fields, global.slug);
134
+ // Runtime manifests are permission-filtered; only build checks can prove target absence.
135
+ if (!options.completeManifest) return;
136
+ const collections = new Map(manifest.collections.map((item) => [item.slug, item]));
137
+ const globals = new Set((manifest.globals ?? []).map((item) => item.slug));
138
+ const extensions = resolveAdminExtensions(config.plugins ?? [], config);
139
+ for (const cell of extensions.listCells) {
140
+ const collection = collections.get(cell.collection);
141
+ if (!collection?.fields.some((field) => field.name === cell.field || field.path === cell.field))
142
+ throw new Error(
143
+ `Admin list cell ${cell.key} selects unknown field ${cell.collection}.${cell.field}.`
144
+ );
145
+ }
146
+ for (const view of extensions.views) {
147
+ if ("collection" in view && view.collection !== undefined && !collections.has(view.collection))
148
+ throw new Error(`Admin core view ${view.key} selects unknown collection ${view.collection}.`);
149
+ if ("global" in view && view.global !== undefined && !globals.has(view.global))
150
+ throw new Error(`Admin core view ${view.key} selects unknown global ${view.global}.`);
151
+ }
152
+ for (const action of extensions.documentActions) {
153
+ if (action.collection !== undefined && !collections.has(action.collection))
154
+ throw new Error(
155
+ `Admin document action ${action.key} selects unknown collection ${action.collection}.`
156
+ );
157
+ }
158
+ for (const view of extensions.documentViews) {
159
+ if (
160
+ view.collection !== undefined &&
161
+ !collections.has(view.collection) &&
162
+ !globals.has(view.collection)
163
+ )
164
+ throw new Error(
165
+ `Admin document view ${view.key} selects unknown resource ${view.collection}.`
166
+ );
167
+ }
168
+ }
169
+
170
+ export { defineRowLabel } from "./local-row-label";
171
+ export type { RowLabelProps, RowLabelDefinition, RegisteredRowLabel } from "./local-row-label";
@@ -0,0 +1,71 @@
1
+ import type { AdminPlugin } from "../plugin";
2
+ import { validatePluginRegistrations } from "../plugin-registry";
3
+
4
+ export { definePluginField, defineFieldComponent } from "../field";
5
+ export type {
6
+ PluginFieldBinding,
7
+ PluginFieldProps,
8
+ PluginForm,
9
+ PluginFieldRegistration,
10
+ } from "../field";
11
+
12
+ /** This literal belongs to this versioned entry point, never to the consuming runtime. */
13
+ const authoringAPIVersion = 1;
14
+
15
+ /**
16
+ * Define the fields, pages, and other admin components supplied by a Go plugin.
17
+ *
18
+ * Export the result from the plugin's JavaScript package. The Go descriptor's
19
+ * `Admin.Package` and `Admin.Export` identify that package and export. Applications
20
+ * install both packages and load the generated plugins with `defineAdmin`.
21
+ *
22
+ * Required options are `key`, matching the Go plugin's key, and `pairingVersion`,
23
+ * a positive integer matching its `Admin.PairingVersion`. Increase the latter when
24
+ * an older Go or admin package would no longer work with the other. This import
25
+ * supplies `apiVersion: 1`; do not pass or overwrite that property.
26
+ *
27
+ * Use `fields` for the default editors of new Go field types. Each map key matches
28
+ * a Go `PluginFieldType.Key`, and each value is returned by `definePluginField`.
29
+ * Use `components` for alternative editors made with `defineFieldComponent`,
30
+ * selected explicitly in Go with `field.PluginComponent`. The other options add
31
+ * pages, dashboard panels, navigation, and the UI described by `AdminContributions`.
32
+ *
33
+ * @param plugin The plugin key, compatibility version, and components to register.
34
+ * @returns A frozen copy with `apiVersion: 1`, preserving inferred field value and
35
+ * input types. Export it; do not call its components yourself or edit its maps.
36
+ * @throws If required metadata, field registrations, or component keys are invalid.
37
+ * Run `ridu check` in an application to also check the Go descriptor and selections.
38
+ * @example
39
+ * ```ts
40
+ * import { defineAdminPlugin, definePluginField } from '@riducms/plugin/authoring/v1';
41
+ * import ColorField from './color-field.svelte';
42
+ * import { decodeColor } from './value';
43
+ *
44
+ * export const colorAdminPlugin = defineAdminPlugin({
45
+ * key: 'color',
46
+ * pairingVersion: 1,
47
+ * fields: {
48
+ * color: definePluginField({ component: ColorField, decodeValue: decodeColor })
49
+ * }
50
+ * });
51
+ * ```
52
+ */
53
+ export function defineAdminPlugin<const Plugin extends Omit<AdminPlugin, "apiVersion">>(
54
+ plugin: Plugin & { apiVersion?: never }
55
+ ): Plugin & { readonly apiVersion: 1 } {
56
+ if ("apiVersion" in plugin)
57
+ throw new Error(
58
+ "The authoring import owns apiVersion; do not restamp another plugin definition."
59
+ );
60
+ const result = Object.freeze({
61
+ ...plugin,
62
+ ...(plugin.fields === undefined ? {} : { fields: Object.freeze({ ...plugin.fields }) }),
63
+ ...(plugin.components === undefined
64
+ ? {}
65
+ : { components: Object.freeze({ ...plugin.components }) }),
66
+ apiVersion: authoringAPIVersion,
67
+ });
68
+ const definition: Omit<AdminPlugin, "apiVersion"> = plugin;
69
+ validatePluginRegistrations([{ ...definition, apiVersion: authoringAPIVersion }]);
70
+ return result;
71
+ }