@riducms/plugin 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/i18n.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  AdminI18n,
3
3
  PluginMessageCatalog,
4
4
  PluginTranslationKey,
5
+ ExtensionTranslationKey,
5
6
  TranslationMessage,
6
7
  } from "@riducms/translations";
7
8
  import { validatePluginMessageCatalog } from "@riducms/translations";
@@ -67,11 +68,21 @@ type CheckedAdminMessages<Input extends AdminMessagesShape> =
67
68
  export interface DefineAdminMessagesInput<
68
69
  Fallback extends Readonly<Record<string, TranslationMessage>>,
69
70
  > {
71
+ /** Default text for every message key. Keys here do not include the plugin/app namespace. */
70
72
  fallback: Fallback;
73
+ /** Catalogs by language code; each supplied language must translate every fallback key. */
71
74
  translations?: Readonly<Record<string, ExactCatalog<Fallback>>>;
72
75
  }
73
76
 
74
- /** Defines one plugin-owned message catalog with exact translated key coverage. */
77
+ /**
78
+ * Define interface text for a plugin or application. Every supplied translation
79
+ * must have the fallback catalog's keys, message kinds and `{placeholder}` names.
80
+ * The helper checks and freezes the catalogs; literals also receive TypeScript checks.
81
+ *
82
+ * For plugin key `"notes"`, fallback key `"copy"` is read as
83
+ * `i18n.t("plugin.notes:copy")`. In `defineAdmin({ messages })`, it is `"app:copy"`.
84
+ * These are admin interface translations, not translations of saved content.
85
+ */
75
86
  export function defineAdminMessages<const Input extends AdminMessagesShape>(
76
87
  input: Input & CheckedAdminMessages<Input>
77
88
  ): DefineAdminMessagesInput<Input["fallback"]> {
@@ -79,6 +90,7 @@ export function defineAdminMessages<const Input extends AdminMessagesShape>(
79
90
  return freezeAdminMessages(input) as DefineAdminMessagesInput<Input["fallback"]>;
80
91
  }
81
92
 
93
+ /** Check a catalog at runtime, throwing with its owner key on invalid messages/translations. */
82
94
  export function validateAdminMessages(key: string, messages: PluginMessageCatalog) {
83
95
  validatePluginMessageCatalog(key, messages);
84
96
  }
@@ -91,7 +103,20 @@ export function freezeAdminMessages(messages: PluginMessageCatalog): PluginMessa
91
103
  return Object.freeze(messages);
92
104
  }
93
105
 
94
- const [getAdminI18n, setAdminI18n] = createContext<AdminI18n>();
106
+ // Separate named exports let TypeScript show their docs at call sites; these remain the same functions.
107
+ const [readAdminI18n, provideAdminI18n] = createContext<AdminI18n>();
95
108
 
96
- export { getAdminI18n, setAdminI18n };
97
- export type { AdminI18n, PluginMessageCatalog, PluginTranslationKey };
109
+ /**
110
+ * Read Ridu's translation context during Svelte component setup. Field/extension
111
+ * components already receive `i18n` in props; nested components can use this getter.
112
+ * Throws outside a provider, such as a standalone component test without context.
113
+ */
114
+ export const getAdminI18n = readAdminI18n;
115
+
116
+ /**
117
+ * Provide translations to child components during Svelte component setup.
118
+ * Ridu normally does this. Use it when hosting components independently, for
119
+ * example in a test that needs to supply its own AdminI18n instance.
120
+ */
121
+ export const setAdminI18n = provideAdminI18n;
122
+ export type { AdminI18n, PluginMessageCatalog, PluginTranslationKey, ExtensionTranslationKey };
package/src/index.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  export type {
2
+ EmbeddedSchemaFormScope,
3
+ EmbeddedSchemaVariantScope,
4
+ EmbeddedSchemaDraft,
5
+ EmbeddedSchemaDraftEditorProps,
2
6
  FieldAuthoringHost,
3
7
  FieldDocument,
4
8
  FieldReferenceBrowserProps,
5
9
  FieldReferenceFilter,
6
10
  } from "./authoring";
7
- export { defineFieldPlugin, type FieldComponentProps, type FieldPlugin } from "./field";
11
+ export type {
12
+ PluginFieldBinding,
13
+ PluginFieldProps,
14
+ PluginForm,
15
+ RegisteredPluginField,
16
+ PluginFieldRegistration,
17
+ } from "./field";
8
18
  export {
9
19
  defineRowLabelPlugin,
10
20
  type RowLabelComponentProps,
@@ -12,7 +22,7 @@ export {
12
22
  type RowLabelSnapshot,
13
23
  type RowLabelValue,
14
24
  } from "./row-label";
15
- export type { FieldForm, FieldFormResource } from "./form";
25
+ export type { FieldDocumentForm, FieldFormResource, FieldLiveValidation } from "./form";
16
26
  export {
17
27
  defineAdminMessages,
18
28
  getAdminI18n,
@@ -25,10 +35,10 @@ export {
25
35
  } from "./i18n";
26
36
  export {
27
37
  ADMIN_PLUGIN_API_VERSION,
28
- defineAdminPlugin,
29
- resolveAdminPluginExtensions,
38
+ resolveAdminExtensions,
30
39
  resolveAdminPluginPairs,
31
40
  type AdminPlugin,
41
+ type AdminContributions,
32
42
  type AdminDashboardPanel,
33
43
  type AdminDashboardPanelProps,
34
44
  type AdminExtensionProps,
@@ -64,10 +74,12 @@ export {
64
74
  type AdminExtensionNotificationTone,
65
75
  type AdminListCell,
66
76
  type AdminListCellProps,
67
- type AdminPluginNavigation,
77
+ type AdminRouteNavigation,
68
78
  type AdminPluginPair,
69
- type AdminPluginRoute,
79
+ type AdminRoute,
70
80
  type BackendAdminPlugin,
71
- type ResolvedAdminPluginExtensions,
81
+ type ResolvedAdminExtensions,
72
82
  type ResolvedAdminPluginPairs,
73
83
  } from "./plugin";
84
+
85
+ export { resolvePluginFields, type ResolvedPluginField } from "./plugin-registry";
@@ -0,0 +1,87 @@
1
+ import type { Component } from "svelte";
2
+ import type { SchemaField } from "@riducms/protocol";
3
+ import type { RowLabelComponentProps } from "./row-label";
4
+ import { decodeComponentConfig } from "./component-config";
5
+
6
+ /** Props for an application-local array/block heading; row data is read-only and config is decoded. */
7
+ export type RowLabelProps<Config = undefined> = Omit<RowLabelComponentProps, "config"> &
8
+ ([Config] extends [undefined] ? { config?: never } : { config: Config });
9
+ /** Heading component plus a synchronous settings decoder, required when Config is not undefined. */
10
+ export type RowLabelDefinition<Config = undefined> = {
11
+ component: Component<RowLabelProps<NoInfer<Config>>>;
12
+ } & (0 extends 1 & Config
13
+ ? { decodeConfig: (value: unknown) => Config }
14
+ : [Config] extends [never]
15
+ ? { decodeConfig: (value: unknown) => Config }
16
+ : [Config] extends [undefined]
17
+ ? { decodeConfig?: (value: unknown) => Config }
18
+ : { decodeConfig: (value: unknown) => Config });
19
+ const registration = Symbol("ridu-row-label");
20
+ const registered = new WeakSet<RegisteredRowLabel>();
21
+ /** Helper result stored in `defineAdmin({ rowLabels: ... })`; do not construct it manually. */
22
+ export interface RegisteredRowLabel {
23
+ readonly [registration]: true;
24
+ readonly component: Component<never>;
25
+ decode(field: SchemaField): unknown;
26
+ }
27
+
28
+ /**
29
+ * Register a custom heading for an array or block row in your application.
30
+ *
31
+ * Put the result under an `app:name` key in `defineAdmin`'s `rowLabels`. Select
32
+ * that key in Go with `field.Admin{RowLabel: field.Component("app:name")}`.
33
+ * Keep `RowLabelPath` for the plain-text name used by row actions and screen readers.
34
+ *
35
+ * `component` is required and receives `RowLabelProps`: `row` contains current,
36
+ * unsaved values, and `rowNumber` starts at one. Display a summary here; use a
37
+ * field component for controls that edit values.
38
+ *
39
+ * Add `decodeConfig` when the Go `field.Component` call supplies settings. Return
40
+ * checked settings synchronously, or throw a useful error. A decoder requires a
41
+ * Go configuration object, even for empty settings; without one, omit Go settings.
42
+ *
43
+ * @param definition The heading component and optional settings decoder.
44
+ * @returns A frozen registration for `defineAdmin`'s `rowLabels` map. It does not
45
+ * choose a Go field or mount the component until the admin renders that field.
46
+ * @throws If the component is invalid. `ridu check` also checks Go selections and settings.
47
+ * @example
48
+ * ```ts
49
+ * import { defineAdmin, defineRowLabel } from '@riducms/plugin/admin';
50
+ * import { generatedAdminPlugins } from './ridu.plugins.generated';
51
+ * import LinkRowLabel from './components/link-row-label.svelte';
52
+ *
53
+ * export default defineAdmin({
54
+ * plugins: generatedAdminPlugins,
55
+ * rowLabels: {
56
+ * 'app:linkSummary': defineRowLabel({ component: LinkRowLabel })
57
+ * }
58
+ * });
59
+ * ```
60
+ */
61
+ export function defineRowLabel<Config = undefined>(
62
+ definition: RowLabelDefinition<Config>
63
+ ): RegisteredRowLabel {
64
+ if (typeof definition.component !== "function")
65
+ throw new Error("A row label requires a Svelte component.");
66
+ const { component, decodeConfig } = definition;
67
+ const label = Object.freeze({
68
+ [registration]: true as const,
69
+ component,
70
+ decode(field: SchemaField): unknown {
71
+ const selection = field.nested?.rowLabelComponent;
72
+ try {
73
+ return decodeComponentConfig(selection, decodeConfig);
74
+ } catch (error) {
75
+ throw new Error(
76
+ `Row label ${selection?.reference} config for ${field.path} is invalid: ${error instanceof Error ? error.message : String(error)}`
77
+ );
78
+ }
79
+ },
80
+ });
81
+ registered.add(label);
82
+ return label;
83
+ }
84
+
85
+ export function isRegisteredRowLabel(value: RegisteredRowLabel): boolean {
86
+ return registered.has(value);
87
+ }
@@ -0,0 +1,188 @@
1
+ import { bindSchemaManifest } from "@riducms/protocol";
2
+ import { resolveBlockTypes } from "@riducms/protocol";
3
+ import { ADMIN_PLUGIN_API_VERSION, type SchemaField, type SchemaManifest } from "@riducms/protocol";
4
+ import { assertPluginFieldRegistration, type RegisteredPluginField } from "./field";
5
+ import type { AdminPlugin } from "./plugin";
6
+
7
+ /** A registered field editor with the names Ridu uses to select it. Returned by registry resolution. */
8
+ export interface ResolvedPluginField {
9
+ /** Owning Go/admin plugin key. */
10
+ readonly owner: string;
11
+ /** Field-type key for a default editor, or component name for an explicitly selected editor. */
12
+ readonly key: string;
13
+ /** Present only for editors registered in `components` and selected by Go `AdminComponent`. */
14
+ readonly componentKey?: string;
15
+ readonly registration: RegisteredPluginField;
16
+ }
17
+
18
+ export function validatePluginRegistrations(plugins: readonly AdminPlugin[]): void {
19
+ resolvePluginFields(plugins);
20
+ }
21
+
22
+ /**
23
+ * Collect field registrations, checking plugin IDs, field-type ownership, named
24
+ * components and API versions. Throws on malformed/duplicate registrations.
25
+ * Used by Ridu's runtime/check tooling; authors normally call the registration
26
+ * helpers and retain their generated plugin list instead of invoking this directly.
27
+ */
28
+ export function resolvePluginFields(
29
+ plugins: readonly AdminPlugin[]
30
+ ): readonly ResolvedPluginField[] {
31
+ const owners = new Set<string>();
32
+ const identities = new Set<string>();
33
+ const resolved: ResolvedPluginField[] = [];
34
+ for (const plugin of plugins) {
35
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(plugin.key) || owners.has(plugin.key))
36
+ throw new Error(`Invalid or duplicate admin plugin key ${plugin.key}.`);
37
+ if (plugin.apiVersion !== ADMIN_PLUGIN_API_VERSION)
38
+ throw new Error(
39
+ `Admin plugin ${plugin.key} uses API ${plugin.apiVersion}; this admin supports ${ADMIN_PLUGIN_API_VERSION}.`
40
+ );
41
+ if (!Number.isSafeInteger(plugin.pairingVersion) || plugin.pairingVersion < 1)
42
+ throw new Error(`Admin plugin ${plugin.key} requires a positive pairingVersion.`);
43
+ owners.add(plugin.key);
44
+ for (const [kind, entries] of [
45
+ ["fields", plugin.fields],
46
+ ["components", plugin.components],
47
+ ] as const) {
48
+ if (entries === undefined) continue;
49
+ if (
50
+ typeof entries !== "object" ||
51
+ entries === null ||
52
+ Array.isArray(entries) ||
53
+ (Object.getPrototypeOf(entries) !== Object.prototype &&
54
+ Object.getPrototypeOf(entries) !== null)
55
+ )
56
+ throw new Error(`Plugin ${plugin.key} ${kind} must be a keyed registration object.`);
57
+ for (const [key, registration] of Object.entries(entries)) {
58
+ if (
59
+ !(
60
+ kind === "fields" ? /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ : /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
61
+ ).test(key)
62
+ )
63
+ throw new Error(`Invalid plugin ${kind} key ${key}.`);
64
+ assertPluginFieldRegistration(registration);
65
+ if (
66
+ kind === "fields"
67
+ ? registration.type !== "plugin" || registration.fieldType !== undefined
68
+ : registration.type === "plugin" && registration.fieldType === undefined
69
+ )
70
+ throw new Error(`Plugin ${plugin.key} ${kind}.${key} uses the wrong registration kind.`);
71
+ const identity = kind === "fields" ? `field:${key}` : `component:${plugin.key}:${key}`;
72
+ if (identities.has(identity))
73
+ throw new Error(`Duplicate admin field renderer ${identity}.`);
74
+ identities.add(identity);
75
+ resolved.push(
76
+ Object.freeze({
77
+ owner: plugin.key,
78
+ key,
79
+ registration,
80
+ ...(kind === "components" ? { componentKey: key } : {}),
81
+ })
82
+ );
83
+ }
84
+ }
85
+ }
86
+ return Object.freeze(resolved);
87
+ }
88
+
89
+ /** Canonical manifest, actual executable registrations, and the same decoders as the host. */
90
+ export function validatePluginManifest(
91
+ plugins: readonly AdminPlugin[],
92
+ manifest: Pick<SchemaManifest, "collections" | "globals" | "blocks"> &
93
+ Partial<Pick<SchemaManifest, "plugins">>,
94
+ complete: boolean
95
+ ): void {
96
+ const registrations = resolvePluginFields(plugins);
97
+ // Startup may receive only public or access-filtered schemas. Build/check own
98
+ // completeness; selected fields below still validate their exact renderer.
99
+ if (complete && manifest.plugins !== undefined)
100
+ for (const item of registrations) {
101
+ if (
102
+ item.registration.fieldType !== undefined &&
103
+ !manifest.plugins.some((plugin) =>
104
+ plugin.fieldTypes?.some((field) => field.key === item.registration.fieldType)
105
+ )
106
+ )
107
+ throw new Error(
108
+ `Named renderer ${item.owner}:${item.key} selects undeclared field type ${item.registration.fieldType}.`
109
+ );
110
+ }
111
+ const fields = new Map(
112
+ registrations.filter((item) => item.componentKey === undefined).map((item) => [item.key, item])
113
+ );
114
+ const components = new Map(
115
+ registrations
116
+ .filter((item) => item.componentKey !== undefined)
117
+ .map((item) => [`${item.owner}:${item.key}`, item])
118
+ );
119
+ if (manifest.plugins !== undefined) {
120
+ for (const backend of manifest.plugins) {
121
+ if (backend.admin === undefined) continue;
122
+ const plugin = plugins.find((candidate) => candidate.key === backend.key);
123
+ if (plugin === undefined)
124
+ throw new Error(`Missing admin registration for backend plugin ${backend.key}.`);
125
+ if (
126
+ plugin.apiVersion !== backend.admin.apiVersion ||
127
+ plugin.pairingVersion !== backend.admin.pairingVersion
128
+ )
129
+ throw new Error(`Backend/admin compatibility mismatch for plugin ${backend.key}.`);
130
+ if (
131
+ JSON.stringify(backend.admin.routes ?? []) !==
132
+ JSON.stringify((plugin.routes ?? []).map((route) => route.path)) ||
133
+ JSON.stringify(backend.admin.assets ?? []) !== JSON.stringify(plugin.assets ?? [])
134
+ )
135
+ throw new Error(
136
+ `Plugin ${backend.key} routes/assets do not match the canonical Go descriptor.`
137
+ );
138
+ const expected = (backend.fieldTypes ?? []).map((field) => field.key).sort();
139
+ if (JSON.stringify(expected) !== JSON.stringify(Object.keys(plugin.fields ?? {}).sort()))
140
+ throw new Error(
141
+ `Plugin ${backend.key} field types do not match the canonical Go descriptor (${expected.join(", ")}).`
142
+ );
143
+ }
144
+ if (complete)
145
+ for (const plugin of plugins) {
146
+ if (
147
+ !manifest.plugins.some(
148
+ (backend) => backend.key === plugin.key && backend.admin !== undefined
149
+ )
150
+ )
151
+ throw new Error(`Admin plugin ${plugin.key} has no paired Go admin descriptor.`);
152
+ }
153
+ }
154
+ const failures: string[] = [];
155
+ bindSchemaManifest(manifest);
156
+ const inspect = (items: readonly SchemaField[], owner: string) => {
157
+ for (const field of items) {
158
+ try {
159
+ const selection = field.admin.component;
160
+ const selected =
161
+ selection !== undefined
162
+ ? components.get(`${selection.plugin}:${selection.component}`)
163
+ : field.type === "plugin"
164
+ ? fields.get(field.plugin?.key ?? "")
165
+ : undefined;
166
+ if (selection !== undefined || field.type === "plugin") {
167
+ if (selected === undefined)
168
+ throw new Error(
169
+ `Missing renderer for ${selection !== undefined ? `${selection.plugin}:${selection.component}` : field.plugin?.key}.`
170
+ );
171
+ selected.registration.decodeConfig(field);
172
+ }
173
+ } catch (error) {
174
+ failures.push(
175
+ `${owner}.${field.path}: ${error instanceof Error ? error.message : String(error)}`
176
+ );
177
+ }
178
+ if (field.nested !== undefined) inspect(field.nested.fields, owner);
179
+ for (const block of resolveBlockTypes(field.blocks) ?? []) inspect(block.fields, owner);
180
+ for (const tree of field.plugin?.embeddedTrees ?? [])
181
+ for (const branch of tree.cases)
182
+ for (const block of resolveBlockTypes(branch)) inspect(block.fields, owner);
183
+ }
184
+ };
185
+ for (const collection of manifest.collections) inspect(collection.fields, collection.slug);
186
+ for (const global of manifest.globals ?? []) inspect(global.fields, global.slug);
187
+ if (failures.length) throw new Error(failures.join("\n"));
188
+ }