@riducms/plugin 0.1.5 → 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 +346 -32
- package/package.json +9 -4
- package/src/admin.ts +171 -0
- package/src/authoring/v1.ts +71 -0
- package/src/authoring.ts +160 -2
- package/src/component-config.ts +51 -0
- package/src/editor/field.svelte +21 -0
- package/src/editor/field.ts +9 -0
- package/src/editor/index.ts +3 -0
- package/src/editor/registry.ts +174 -0
- package/src/editor/types.ts +104 -0
- package/src/field.ts +596 -16
- package/src/form.ts +33 -4
- package/src/i18n.ts +29 -4
- package/src/index.ts +19 -7
- package/src/local-row-label.ts +87 -0
- package/src/plugin-registry.ts +188 -0
- package/src/plugin.ts +265 -74
- package/src/row-label.ts +15 -4
package/README.md
CHANGED
|
@@ -1,38 +1,352 @@
|
|
|
1
1
|
# `@riducms/plugin`
|
|
2
2
|
|
|
3
|
-
Svelte
|
|
4
|
-
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
7
|
-
"@riducms/translations": "0.
|
|
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.
|
|
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
|
+
}
|