Build a field plugin
Create a new field type with Go validation, generated types, and a Svelte editor.
Use a field plugin when you need a new field type with its own Go validation or data format. A field plugin has a Go package for the server and a Svelte/TypeScript package for its admin editor. Ridu loads both when an application installs the plugin.
This guide builds a color field that stores a #RRGGBB string, rejects invalid colors on the
server, and provides a Svelte editor. It also tells Ridu which Go and TypeScript types to generate
for applications that use the field.
Change an existing field’s editor
If you only want a different input, a preview, or a character counter, start with
Field components. That tutorial works directly in
your application’s admin.config.ts and does not require a plugin. Built-in
TextList and NumberList editors also use this path: register
type: "text-list" or type: "number-list" and use the matching typed array binding.
For dashboards, navigation, document screens, and other UI changes, use Custom components.
1. Create the plugin files
ridu plugin new ./ridu-color \
--key color \
--module example.com/acme/ridu-color \
--admin-package @acme/ridu-color-adminThe command creates the Go and Svelte packages, their configuration, and tests. Replace the starter field with the color implementation below. Keep the generated supported Ridu versions until you have tested the plugin with other versions.
2. Define the Go value and field helper
The helper lets an application write color.Field("accent") in its collection. The exported
Value type tells Go applications that this field contains a color string.
package color
import (
"encoding/json"
"github.com/riducms/ridu"
"github.com/riducms/ridu/field"
)
const Key = "color"
// Value is the color type used in generated Go documents.
type Value string
type plugin struct{}
func New() ridu.Plugin { return plugin{} }
func (plugin) Key() string { return Key }
func Field(name string) field.PluginField {
// This field does not need any per-field configuration.
return field.Plugin(name, Key, json.RawMessage(`{}`))
}Returning field.PluginField lets applications add options such as .Required(), .Localized(),
.Access(...), .Hooks(...), .Validate(...), or .Admin(...) after calling your helper. Those
settings apply wherever the field is used, including inside a group or block. Plugin fields do
not have the built-in fields’ .Unique() or .Default() methods.
The third argument to field.Plugin contains public settings for the field. This color field needs
none, so it passes {}. Settings are sent to the admin: never put credentials or secrets there.
If your field’s stored JSON contains collection names, declare the property names with
.CollectionReferenceKeys(...). For example, the rich-text plugin declares relationTo. This lets
Ridu update those names when a collection is renamed and detect unsafe changes to saved references.
3. Describe the field and its admin package
The descriptor tells Ridu which Go and TypeScript types represent the color value, and which admin package supplies its editor:
package color
import "github.com/riducms/ridu"
// Match pairingVersion in the TypeScript admin plugin.
const AdminPluginPairingVersion = 1
func (plugin) Descriptor() ridu.PluginDescriptor {
// Tell Ridu which admin package and export to import.
admin := ridu.AdminPluginMetadata{
Package: "@acme/ridu-color-admin",
Export: "colorAdminPlugin",
APIVersion: ridu.AdminPluginAPIVersion,
PairingVersion: AdminPluginPairingVersion,
}
return ridu.PluginDescriptor{
Version: "1.0.0",
GoPackage: "example.com/acme/ridu-color",
APIVersion: ridu.PluginAPIVersion,
Ridu: ridu.RiduCompatibility{
Minimum: ridu.FrameworkVersion,
},
Admin: &admin,
// Import these value types into generated application code.
FieldTypes: []ridu.PluginFieldType{{
Key: Key,
TypeScriptPackage: "@acme/ridu-color-admin",
TypeScriptOutput: "Color",
TypeScriptInput: "ColorInput",
GoPackage: "example.com/acme/ridu-color",
GoType: "Value",
JSONSchema: []byte(
`{"type":"string","pattern":"^#[0-9A-Fa-f]{6}$"}`,
),
}},
}
}TypeScriptOutput and TypeScriptInput name the types you will export in step 5. GoPackage and
GoType name the Go Value type above. JSONSchema describes valid colors in OpenAPI.
The Admin settings identify the package and exported plugin object from step 6.
TypeScriptWhere is optional. Without it, the generated client uses Ridu’s standard single-value
query filters (ScalarWhere<Color>). Specify it only if the field needs a different filter type.
If you omit both GoPackage and GoType, Go receives json.RawMessage instead of Value.
4. Validate colors on the server
The Go validator runs whenever a value is saved, whether it comes from the admin, an API request, or application code:
package color
import (
"regexp"
"github.com/riducms/ridu"
"github.com/riducms/ridu/schema"
)
var hexadecimal = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
func (plugin) FieldValidators() map[string]ridu.PluginFieldValidator {
return map[string]ridu.PluginFieldValidator{Key: validateColor}
}
func validateColor(
ctx ridu.PluginFieldValidationContext,
) []schema.Issue {
value, ok := ctx.Value.StringValue()
if ok && hexadecimal.MatchString(value) {
// No issues means the value passed validation.
return nil
}
// RuntimePath identifies the exact field, including array rows.
return []schema.Issue{{
Code: "invalid_color",
Path: ctx.RuntimePath,
Message: "color must use the #RRGGBB format",
}}
}Return ctx.RuntimePath with the error so the admin can show it beside the right input, even
inside an array such as items.2.accent. Keep the error code stable so API clients can recognize
it. Ridu checks .Required() separately; this function checks whether a supplied color is valid.
5. Export and check the TypeScript value
The names must exactly match TypeScriptOutput and TypeScriptInput in the Go descriptor:
export type Color = `#${string}`;
// Reads and writes use the same value shape for this field.
export type ColorInput = Color;
// TypeScript's # prefix type does not check the six hex digits.
export function decodeColor(value: unknown): Color {
if (typeof value !== 'string' || !/^#[0-9a-f]{6}$/i.test(value))
throw new Error('Expected a six-digit hexadecimal color.');
return value as Color;
}Color gives TypeScript callers a useful type. It cannot check how many hexadecimal digits a
string contains, so decodeColor checks the actual value in the browser. Go performs the same
check on the server.
6. Build the Svelte editor
<script lang="ts">
import type { PluginFieldProps } from '@riducms/plugin';
import { decodeColor, type Color } from './value';
let { field }: PluginFieldProps<Color> = $props();
</script>
<label for={field.schema.id}>
{field.schema.admin.label}
</label>
<!-- Black is an empty-state preview; oninput sets the form value. -->
<input
id={field.schema.id}
type="color"
value={field.value ?? '#000000'}
disabled={field.readOnly}
oninput={(event) =>
field.set(decodeColor(event.currentTarget.value))}
/>
{#each field.issues as issue (issue.code)}
<p>{issue.message}</p>
{/each}The component reads the current value from field.value and updates the unsaved form with
field.set(...). Saving the document sends it to Go for validation. field.readOnly disables the
control when the user cannot edit it, and field.issues supplies messages to display after a
validation failure.
Ridu keeps the editor attached to its row when rows move. If an API request started by your
component finishes after the user closes the editor or changes document, discard the result
when field.stale is true. Saving or resetting the form also makes the old field object stale.
The example uses a native color picker. Its black preview for an empty field is only a display fallback: it does not set a value until the user chooses a color. A required empty field still fails validation on save.
Export the editor from the admin package:
import {
defineAdminPlugin,
definePluginField
} from '@riducms/plugin/authoring/v1';
import ColorField from './color-field.svelte';
import { decodeColor } from './value';
export type { Color, ColorInput } from './value';
export const colorAdminPlugin = defineAdminPlugin({
// Match the Go plugin's Key and AdminPluginPairingVersion.
key: 'color',
pairingVersion: 1,
fields: {
color: definePluginField({
component: ColorField,
// Check incoming data before the editor receives a Color.
decodeValue: decodeColor
})
}
});The plugin’s key and pairingVersion must match the Go descriptor from step 3. The version number
lets Ridu check that the Go and admin packages work together. Under fields, color matches the Go
field type’s key. decodeColor checks a value before the editor receives it. The import from
authoring/v1 selects this version of Ridu’s plugin API.
The two helpers serve different purposes:
definePluginFieldconnectsColorFieldto its value decoder and returns a field registration. Its options also let you decode editor settings or accept a write value with a different shape from the saved value.defineAdminPlugincollects those field registrations and any other plugin UI into the exportedcolorAdminPluginobject. The Go descriptor names this export so Ridu can load it when the application installs the plugin.
See PluginFieldProps for everything passed to ColorField,
including field, form, and document-picking tools. If your plugin needs to offer a second editor
for an existing field, use defineFieldComponent in its
fieldEditors map; the application chooses it explicitly with
field.PluginComponent.
Run ridu check in an application using the plugin to catch a missing component, mismatched
field type, or invalid registration.
7. Test the field
Put this test in the separate color_test package. It checks that the plugin loads, generates the
declared types, and accepts or rejects data through the local API and REST:
package color_test
import (
"testing"
"github.com/riducms/ridu"
color "example.com/acme/ridu-color"
"github.com/riducms/ridu/field"
"github.com/riducms/ridu/plugintest"
"github.com/riducms/ridu/store"
)
func TestConformance(t *testing.T) {
plugintest.Run(t, plugintest.Fixture{
Plugin: color.New(),
Fields: field.Fields{
color.Field("accent").Required(),
},
ValidData: store.Values{"accent": store.String("#663399")},
InvalidData: store.Values{"accent": store.String("purple")},
Compatibility: []plugintest.CompatibilityCase{
{RiduVersion: ridu.FrameworkVersion, Compatible: true},
},
})
}Keep the generated admin test and run svelte-check. Also compile an application that uses Color,
ColorInput, and its generated client. If a plugin adds database tables, test applying and reversing
its migrations on each supported database. See Testing and Plugins.
8. Install and use it
After publishing the Go and admin packages, install them into an application:
ridu plugin add color \
--go-package example.com/acme/ridu-color \
--go-version v1.0.0 \
--admin-package @acme/ridu-color-admin \
--admin-version '^1.0.0'The CLI installs the Go and admin packages and updates the generated imports. Keep
Plugins: installedPlugins() in content.Config() and plugins: generatedAdminPlugins in
admin.config.ts so both packages load.
Use the field helper in a collection:
package content
import (
"github.com/riducms/ridu"
color "example.com/acme/ridu-color"
"github.com/riducms/ridu/field"
)
var Brands = ridu.Collection{
Slug: "brands",
Fields: field.Fields{
color.Field("accent").Required(),
},
}Add content.Brands to your Go config’s Collections list. With ridu dev running, save the
config and open Brands in the admin. Create a brand, choose an accent color, save, and reload it.
The selected color should remain. Sending "purple" through the API should return invalid_color.
Before deployment, review the generated types, create and check the database migration, and run
ridu check.
Hold input that cannot be saved yet
An editor may show input that is not a valid value yet. In a text-input version of ColorField,
#1a2 is not a Color until the user types the remaining digits. Report such input with
field.reportPendingEdit(issues) instead of calling field.set. While issues are reported, Save
fails with them, leaving the document asks for confirmation, and the issues move with the field
when its row is reordered. Report an empty list once the input is valid again, or when the editor
discards the input because another editor replaced the stored value. Compare the stored value
rather than reacting to every update: moving the row also re-reads field.value, and unfinished
input should survive that:
<script lang="ts">
import { untrack } from 'svelte';
import type { PluginFieldProps } from '@riducms/plugin';
import { decodeColor, type Color } from './value';
let { field }: PluginFieldProps<Color> = $props();
// Unfinished text and the stored value it was typed over.
let draft = $state<{ text: string; over: typeof field.value }>();
// Another editor replaced the stored value: drop the draft and its report.
$effect(() => {
const value = field.value;
untrack(() => {
if (draft === undefined || draft.over === value) return;
draft = undefined;
field.reportPendingEdit([]);
});
});
function edit(text: string) {
try {
const color = text === '' ? null : decodeColor(text);
draft = undefined;
field.reportPendingEdit([]);
field.set(color);
} catch {
draft = { text, over: field.value };
field.reportPendingEdit([
{
code: 'unfinished_color',
path: field.schema.path,
message: 'Enter six hexadecimal digits, such as #1a2b3c.'
}
]);
}
}
</script>
<input
aria-label={field.schema.admin.label}
value={draft?.text ?? field.value ?? ''}
disabled={field.readOnly}
oninput={(event) => edit(event.currentTarget.value)}
/>Only plugin field editors receive reportPendingEdit. Editors registered with
defineFieldEditor do not.
Fix common problems
| Symptom | What to check |
|---|---|
| Ridu reports an unknown plugin field | Check that color.New() is registered and the helper uses the same color key. |
| Generated TypeScript has the wrong type | Check FieldTypes in the descriptor and its named TypeScript exports, then run ridu check. |
Generated Go uses json.RawMessage |
Supply both GoPackage and the exported GoType. |
| The admin cannot find the color editor | Install the admin package and check its export name and field key. |
| Ridu reports an admin version mismatch | Check that the Go and admin packages use the same PairingVersion and supported API version. |
| Bun or Vite cannot find a generated import | Install the package named in the descriptor and check that it exports the expected value. |
| Validation appears on the wrong row | Set the error’s Path to ctx.RuntimePath, which includes the current row number. |
| The browser accepts a value the API rejects | Check that the input, TypeScript types, JSON Schema, and Go validator all accept the same values. |
A secret appears in /api/schema |
Remove it from the field settings; they are public and sent to the browser. |
| A renamed plugin appears as a removal and an addition | Restore its previous key or write a migration for the rename. |
Ridu stores custom field values as JSON through the application’s database adapter. A plugin that needs its own tables or indexes must provide and test the corresponding database migrations.