@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/src/plugin.ts CHANGED
@@ -1,4 +1,6 @@
1
- import type { FieldPlugin } from "./field";
1
+ import type { FieldType } from "@riducms/protocol";
2
+ import type { RegisteredPluginField } from "./field";
3
+ import { resolvePluginFields, type ResolvedPluginField } from "./plugin-registry";
2
4
  import { ADMIN_PLUGIN_API_VERSION } from "@riducms/protocol";
3
5
  import type {
4
6
  OperationCapabilities,
@@ -10,32 +12,64 @@ import type {
10
12
  import type { Component, Snippet } from "svelte";
11
13
 
12
14
  import type { FieldDocument } from "./authoring";
13
- import type { AdminI18n, PluginMessageCatalog, PluginTranslationKey } from "./i18n";
15
+ import type { AdminI18n, PluginMessageCatalog, ExtensionTranslationKey } from "./i18n";
14
16
  import { freezeAdminMessages, validateAdminMessages } from "./i18n";
15
17
  import type { RowLabelPlugin } from "./row-label";
16
18
 
17
19
  export { ADMIN_PLUGIN_API_VERSION };
18
20
 
19
21
  /**
20
- * Describes the admin half exported by a statically installed plugin package.
21
- * Pairing versions are plugin-owned and must change when its Go and admin
22
- * halves are no longer mutually compatible.
22
+ * The browser UI supplied by an installed Go/admin plugin pair.
23
+ * Create this with `defineAdminPlugin` from `@riducms/plugin/authoring/v1`,
24
+ * which supplies the framework API version. Go declares the plugin's server
25
+ * behaviour; this object connects its fields and other admin UI components.
23
26
  */
24
- export interface AdminPlugin {
25
- /** Framework admin-plugin contract version compiled by this package. */
27
+ export interface AdminPlugin extends AdminContributions {
28
+ /** Supplied by the versioned authoring import. Authors must not set this themselves. */
26
29
  apiVersion: typeof ADMIN_PLUGIN_API_VERSION;
27
- /** Stable key matching the compiled Go plugin. */
30
+ /** Owning Go plugin's key, for example `"editorial-tools"`; not necessarily a field-type key. */
28
31
  key: string;
29
- /** Plugin-owned backend/admin compatibility version. */
32
+ /** Positive integer matching the Go descriptor; bump it when the two halves become incompatible. */
30
33
  pairingVersion: number;
31
- /** Field renderers contributed by this package. */
32
- fields: readonly FieldPlugin[];
34
+ /**
35
+ * Default editors for field types declared by this Go plugin. Each map key is a
36
+ * globally unique field-type name; each value comes from `definePluginField`.
37
+ * For example, one plugin may provide both `"review-note"` and `"color-swatch"`.
38
+ */
39
+ fields?: Readonly<
40
+ Record<string, RegisteredPluginField & { readonly type: "plugin"; readonly fieldType?: never }>
41
+ >;
42
+ /**
43
+ * Alternative editors made with `defineFieldComponent`, keyed by component name.
44
+ * Go chooses one with `.Admin(field.Admin{Editor: field.PluginComponent(pluginKey, componentName, config)})`.
45
+ * These may edit built-in fields or an explicitly named plugin field type.
46
+ */
47
+ components?: Readonly<
48
+ Record<
49
+ string,
50
+ RegisteredPluginField &
51
+ (
52
+ | { readonly type: Exclude<FieldType, "plugin"> }
53
+ | { readonly type: "plugin"; readonly fieldType: string }
54
+ )
55
+ >
56
+ >;
33
57
  /** Array and blocks row-label components contributed by this package. */
34
58
  rowLabels?: readonly RowLabelPlugin[];
35
59
  /** Statically bundled, plugin-owned interface messages. */
36
60
  messages?: PluginMessageCatalog;
61
+ /** Package-relative modules/styles to import at build time; must match the Go descriptor's list. */
62
+ assets?: readonly string[];
63
+ }
64
+
65
+ /**
66
+ * Places where a plugin or application can add admin UI. Arrays keep registration
67
+ * order; application entries follow plugin entries. Replacement slots allow one
68
+ * matching replacement. Conflicts are reported instead of silently overriding UI.
69
+ */
70
+ export interface AdminContributions {
37
71
  /** Authenticated admin routes contributed by this package. */
38
- routes?: readonly AdminPluginRoute[];
72
+ routes?: readonly AdminRoute[];
39
73
  /** Panels composed into or replacing the authenticated dashboard. */
40
74
  dashboard?: readonly AdminDashboardPanel[];
41
75
  /** Components composed around or replacing the sign-in screen. */
@@ -60,65 +94,77 @@ export interface AdminPlugin {
60
94
  documentActions?: readonly AdminDocumentAction[];
61
95
  /** Read-only or operational views rendered beside Edit and API. */
62
96
  documentViews?: readonly AdminDocumentView[];
63
- /** Package-relative static modules imported by the generated admin registry. */
64
- assets?: readonly string[];
65
97
  }
66
98
 
99
+ /** Common data supplied to admin extension components. Use the generated SDK for requests. */
67
100
  export interface AdminExtensionProps {
101
+ /** Resolved Go schema available to this admin session; it may omit inaccessible resources. */
68
102
  manifest: SchemaManifest;
103
+ /** Signed-in user document, absent on screens where no user is signed in yet. */
69
104
  user?: FieldDocument;
105
+ /** Current admin interface translations and formatting preferences. */
70
106
  i18n: AdminI18n;
71
107
  }
72
108
 
109
+ /** Sign-in operations provided to a custom login screen. */
73
110
  export interface AdminLoginExtensionHost {
74
- /** Authenticates, evaluates admin access, and enters the authenticated shell. */
111
+ /** Sign in, check admin access and enter the admin. Rejects if authentication/access fails. */
75
112
  login: (credentials: { email: string; password: string }) => Promise<void>;
113
+ /** Show a temporary success or error message. Does not perform an operation itself. */
76
114
  notify: (tone: AdminExtensionNotificationTone, title: string, message?: string) => void;
77
115
  }
78
116
 
79
117
  export interface AdminLoginComponentProps extends AdminExtensionProps {
80
- /** The complete framework sign-in screen, available to replacement wrappers. */
118
+ /** Render with `{@render defaultView()}` to keep Ridu's sign-in screen inside your wrapper. */
81
119
  defaultView: Snippet;
82
120
  host: AdminLoginExtensionHost;
83
121
  }
84
122
 
123
+ /** Add content before/after sign-in, or replace it with a custom screen. */
85
124
  export interface AdminLoginComponent {
86
125
  key: string;
87
126
  component: Component<AdminLoginComponentProps>;
127
+ /** Defaults to after. Only one replace entry is allowed across plugins and the application. */
88
128
  position?: "before" | "after" | "replace";
89
129
  }
90
130
 
91
131
  export type AdminAccountSurface = "profile" | "security";
92
132
 
133
+ /** Operations for keeping a custom account screen in sync with the signed-in session. */
93
134
  export interface AdminAccountExtensionHost {
94
135
  /** Reloads the signed-in document and updates the shell identity. */
95
136
  refreshUser: () => Promise<FieldDocument | undefined>;
96
137
  /** Ends the current session and returns to sign-in. */
97
138
  logout: () => Promise<void>;
139
+ /** Show a temporary success or error message. */
98
140
  notify: (tone: AdminExtensionNotificationTone, title: string, message?: string) => void;
99
141
  }
100
142
 
101
143
  export interface AdminAccountComponentProps extends AdminExtensionProps {
102
144
  surface: AdminAccountSurface;
103
- /** The complete framework account screen, available to replacement wrappers. */
145
+ /** Render with `{@render defaultView()}` to keep Ridu's account screen inside your wrapper. */
104
146
  defaultView: Snippet;
105
147
  host: AdminAccountExtensionHost;
106
148
  }
107
149
 
150
+ /** Add to or replace the profile/security screen identified by `surface`. */
108
151
  export interface AdminAccountComponent {
109
152
  key: string;
110
153
  surface: AdminAccountSurface;
111
154
  component: Component<AdminAccountComponentProps>;
155
+ /** Defaults to after. Each account surface permits at most one replacement. */
112
156
  position?: "before" | "after" | "replace";
113
157
  }
114
158
 
159
+ /** Choose the whole navigation's boundary, the resource-link list's boundary, or replace navigation. */
115
160
  export type AdminNavigationPosition = "before" | "beforeLinks" | "afterLinks" | "after" | "replace";
116
161
 
117
162
  export interface AdminNavigationComponentProps extends AdminExtensionProps {
118
- /** The complete framework navigation, available to replacement wrappers. */
163
+ /** Render with `{@render defaultView()}` to include Ridu's navigation in a replacement. */
119
164
  defaultView: Snippet;
120
165
  }
121
166
 
167
+ /** Insert a component at a navigation position. At most one entry can replace navigation. */
122
168
  export interface AdminNavigationComponent {
123
169
  key: string;
124
170
  component: Component<AdminNavigationComponentProps>;
@@ -126,6 +172,7 @@ export interface AdminNavigationComponent {
126
172
  }
127
173
 
128
174
  export interface AdminLogoutExtensionHost {
175
+ /** End the current session and return to sign-in. Await this instead of only changing local UI. */
129
176
  logout: () => Promise<void>;
130
177
  }
131
178
 
@@ -133,6 +180,7 @@ export interface AdminLogoutButtonProps extends AdminExtensionProps {
133
180
  host: AdminLogoutExtensionHost;
134
181
  }
135
182
 
183
+ /** Replace the account menu's sign-out button; call the supplied `host.logout()` on activation. */
136
184
  export interface AdminLogoutButton {
137
185
  key: string;
138
186
  component: Component<AdminLogoutButtonProps>;
@@ -141,9 +189,13 @@ export interface AdminLogoutButton {
141
189
  export type AdminCoreViewSurface =
142
190
  "collectionList" | "collectionCreate" | "collectionEdit" | "global" | "notFound";
143
191
 
192
+ /** Refresh tools for a custom collection/global screen. They do not save documents. */
144
193
  export interface AdminCoreViewHost {
194
+ /** Fetch the current Go schema again and update the admin's schema-dependent UI. */
145
195
  refreshManifest: () => Promise<void>;
196
+ /** Notify Ridu that your code changed documents so dependent lists/lookups can refresh. */
146
197
  documentsChanged: () => void;
198
+ /** Show a temporary success or error message. */
147
199
  notify: (tone: AdminExtensionNotificationTone, title: string, message?: string) => void;
148
200
  }
149
201
 
@@ -152,7 +204,7 @@ export interface AdminCoreViewProps extends AdminExtensionProps {
152
204
  collection?: SchemaCollection;
153
205
  global?: SchemaGlobal;
154
206
  documentID?: string;
155
- /** The complete framework route, available to replacement wrappers. */
207
+ /** Render with `{@render defaultView()}` to wrap the normal screen rather than rebuilding it. */
156
208
  defaultView: Snippet;
157
209
  host: AdminCoreViewHost;
158
210
  }
@@ -179,6 +231,11 @@ interface AdminNotFoundCoreView {
179
231
  component: Component<AdminCoreViewProps>;
180
232
  }
181
233
 
234
+ /**
235
+ * Replace a collection/global/not-found screen. A resource-specific registration
236
+ * wins over an all-resources fallback for that surface; duplicate targets fail.
237
+ * The supplied `defaultView` lets your component wrap the existing screen.
238
+ */
182
239
  export type AdminCoreView = AdminCollectionCoreView | AdminGlobalCoreView | AdminNotFoundCoreView;
183
240
 
184
241
  export type AdminBrandSurface = "loginLogo" | "navigationLogo" | "accountAvatar";
@@ -187,6 +244,7 @@ export interface AdminBrandComponentProps extends AdminExtensionProps {
187
244
  surface: AdminBrandSurface;
188
245
  }
189
246
 
247
+ /** Replace one logo/avatar location. Only one component may claim each surface. */
190
248
  export interface AdminBrandComponent {
191
249
  key: string;
192
250
  surface: AdminBrandSurface;
@@ -199,6 +257,7 @@ export interface AdminShellComponentProps extends AdminExtensionProps {
199
257
  position: AdminShellPosition;
200
258
  }
201
259
 
260
+ /** Add UI to the global header, actions area or account settings menu in registration order. */
202
261
  export interface AdminShellComponent {
203
262
  key: string;
204
263
  position: AdminShellPosition;
@@ -206,11 +265,12 @@ export interface AdminShellComponent {
206
265
  }
207
266
 
208
267
  export interface AdminProviderProps {
209
- /** The remaining provider stack and framework admin application. */
268
+ /** Render `{@render defaultView()}` after setting context so the nested providers/admin appear. */
210
269
  defaultView: Snippet;
211
270
  i18n: AdminI18n;
212
271
  }
213
272
 
273
+ /** Wrap the admin in a Svelte context provider. Render the supplied `defaultView` to continue the UI. */
214
274
  export interface AdminProvider {
215
275
  key: string;
216
276
  component: Component<AdminProviderProps>;
@@ -222,38 +282,50 @@ export interface AdminDashboardPanelProps {
222
282
  i18n: AdminI18n;
223
283
  }
224
284
 
285
+ /** Add dashboard content before/after Ridu's overview, or replace that overview. */
225
286
  export interface AdminDashboardPanel {
226
- /** Plugin-owned identity used for deterministic ordering and collision checks. */
287
+ /** Unique name among dashboard entries; duplicates throw. Array order controls display order. */
227
288
  key: string;
228
289
  component: Component<AdminDashboardPanelProps>;
229
- /** Replace suppresses the framework overview and may only be registered once. */
290
+ /** Defaults to after. Replace hides Ridu's overview and may only be registered once. */
230
291
  position?: "before" | "after" | "replace";
231
292
  }
232
293
 
294
+ /** Data for displaying a collection table cell. This is not an editable document-form binding. */
233
295
  export interface AdminListCellProps {
234
296
  collection: SchemaCollection;
235
297
  field: SchemaField;
236
298
  document: FieldDocument;
299
+ /** This field's saved value. Check its type before rendering; changing it does not save a document. */
237
300
  value: unknown;
238
301
  i18n: AdminI18n;
239
302
  }
240
303
 
304
+ /** Replace the cell display for one collection/field pair; duplicate targets are rejected. */
241
305
  export interface AdminListCell {
242
306
  key: string;
307
+ /** Collection slug, for example `"posts"`. */
243
308
  collection: string;
309
+ /** Top-level field name/path in that collection, for example `"title"`. */
244
310
  field: string;
311
+ /** Fallback column heading. */
245
312
  label: string;
246
- labelKey?: PluginTranslationKey;
313
+ /** Optional translation key from this plugin's messages, or app:... for application entries. */
314
+ labelKey?: ExtensionTranslationKey;
247
315
  component: Component<AdminListCellProps>;
248
316
  }
249
317
 
250
318
  export type AdminExtensionNotificationTone = "success" | "error";
251
319
 
320
+ /** Tools for a document action or extra document tab after it completes its own operation. */
252
321
  export interface AdminDocumentExtensionHost {
322
+ /** Reload the current document from the server. This is not a save of unsaved form edits. */
253
323
  refresh: () => Promise<void>;
324
+ /** Show a temporary success or error message. */
254
325
  notify: (tone: AdminExtensionNotificationTone, title: string, message?: string) => void;
255
326
  }
256
327
 
328
+ /** Saved document data for actions/tabs; use the generated SDK for application-specific operations. */
257
329
  export interface AdminDocumentExtensionProps {
258
330
  collection: SchemaCollection;
259
331
  document: FieldDocument;
@@ -261,43 +333,49 @@ export interface AdminDocumentExtensionProps {
261
333
  i18n: AdminI18n;
262
334
  }
263
335
 
336
+ /** Add a component alongside the document's standard actions. Implement the operation in that component. */
264
337
  export interface AdminDocumentAction {
265
338
  key: string;
266
339
  /** Omit to show the action for every non-global collection. */
267
340
  collection?: string;
268
- /** Hide the action unless the evaluated document operation is allowed. */
341
+ /** Hide unless this document operation is allowed. Visibility does not replace server authorization. */
269
342
  requires?: keyof OperationCapabilities;
270
343
  component: Component<AdminDocumentExtensionProps>;
271
344
  }
272
345
 
346
+ /** Add a tab beside Edit and API. Keys `edit` and `api` are reserved for Ridu. */
273
347
  export interface AdminDocumentView {
274
348
  key: string;
275
349
  label: string;
276
- labelKey?: PluginTranslationKey;
277
- /** Omit to show the view for every collection and global. */
350
+ labelKey?: ExtensionTranslationKey;
351
+ /** Collection or global slug; omit to show for every collection and global. */
278
352
  collection?: string;
279
353
  component: Component<AdminDocumentExtensionProps>;
280
354
  }
281
355
 
282
- export interface AdminPluginNavigation {
356
+ export interface AdminRouteNavigation {
283
357
  /** Human-readable navigation label inside the authenticated admin shell. */
284
358
  label: string;
285
- labelKey?: PluginTranslationKey;
359
+ labelKey?: ExtensionTranslationKey;
286
360
  /** Optional grouping hint reserved for shells that render grouped plugin navigation. */
287
361
  group?: string;
288
362
  }
289
363
 
290
364
  /** One statically bundled route mounted beneath the authenticated admin layout. */
291
- export interface AdminPluginRoute {
292
- /** Relative route path; it must exactly match the compiled backend descriptor. */
365
+ export interface AdminRoute {
366
+ /** Relative admin path, without a leading slash. Paired plugins must match their Go descriptor. */
293
367
  path: string;
294
368
  /** Svelte route component rendered by the framework router. */
295
369
  component: Component;
296
370
  /** Optional navigation entry; omit it for a route reachable only by links or redirects. */
297
- navigation?: AdminPluginNavigation;
371
+ navigation?: AdminRouteNavigation;
298
372
  }
299
373
 
300
- /** Metadata emitted from the compiled Go plugin into the generated registry. */
374
+ /**
375
+ * Go plugin metadata written into Ridu's generated admin registration file.
376
+ * Do not hand-maintain a second copy: `ridu generate` resolves the executable Go
377
+ * configuration and emits the matching imports and compatibility checks.
378
+ */
301
379
  export interface BackendAdminPlugin {
302
380
  /** Framework admin-plugin API required by the compiled backend. */
303
381
  apiVersion: number;
@@ -313,18 +391,22 @@ export interface BackendAdminPlugin {
313
391
  routes?: readonly string[];
314
392
  /** Exact ordered package-relative static assets declared by the backend. */
315
393
  assets?: readonly string[];
394
+ /** Exact field-type keys declared by this backend plugin. */
395
+ fieldTypes?: readonly string[];
316
396
  }
317
397
 
398
+ /** One imported admin plugin and its generated Go metadata, compared before use. */
318
399
  export interface AdminPluginPair {
319
400
  admin: AdminPlugin;
320
401
  backend: BackendAdminPlugin;
321
402
  }
322
403
 
404
+ /** Checked plugin registrations collected for Ridu's admin runtime. Normally consumed by generated code. */
323
405
  export interface ResolvedAdminPluginPairs {
324
406
  plugins: readonly AdminPlugin[];
325
- fields: readonly FieldPlugin[];
407
+ fields: readonly ResolvedPluginField[];
326
408
  rowLabels: readonly RowLabelPlugin[];
327
- routes: readonly AdminPluginRoute[];
409
+ routes: readonly AdminRoute[];
328
410
  dashboard: readonly AdminDashboardPanel[];
329
411
  login: readonly AdminLoginComponent[];
330
412
  account: readonly AdminAccountComponent[];
@@ -338,27 +420,22 @@ export interface ResolvedAdminPluginPairs {
338
420
  documentActions: readonly AdminDocumentAction[];
339
421
  documentViews: readonly AdminDocumentView[];
340
422
  messages: Readonly<Record<string, PluginMessageCatalog>>;
423
+ applicationMessages: PluginMessageCatalog | undefined;
341
424
  }
342
425
 
343
- export type ResolvedAdminPluginExtensions = Omit<ResolvedAdminPluginPairs, "plugins" | "fields">;
344
-
345
- /** Defines one package export consumed by Ridu's generated static registry. */
346
- export function defineAdminPlugin<const Plugin extends AdminPlugin>(plugin: Plugin): Plugin {
347
- return plugin;
348
- }
426
+ export type ResolvedAdminExtensions = Omit<ResolvedAdminPluginPairs, "plugins" | "fields">;
349
427
 
350
428
  /**
351
- * Validates every compiled-backend/admin-package pair before exposing its
352
- * field registrations. Missing packages still fail at the static import, while
353
- * stale or mismatched packages fail here with a plugin-specific diagnostic.
429
+ * Check that installed Go/admin plugin packages agree on identity, API version,
430
+ * pairing version, routes, assets and field types. Ridu's generated file calls this;
431
+ * authors should fix mismatched packages rather than editing generated metadata.
432
+ * Missing imports fail during compilation; incompatible pairs throw here.
354
433
  */
355
434
  export function resolveAdminPluginPairs(
356
435
  pairs: readonly AdminPluginPair[]
357
436
  ): ResolvedAdminPluginPairs {
358
437
  const keys = new Set<string>();
359
- const fieldIdentities = new Set<string>();
360
438
  const plugins: AdminPlugin[] = [];
361
- const fields: FieldPlugin[] = [];
362
439
 
363
440
  for (const { admin, backend } of pairs) {
364
441
  const identity = `${backend.package}#${backend.export}`;
@@ -390,39 +467,33 @@ export function resolveAdminPluginPairs(
390
467
  (admin.routes ?? []).map((route) => route.path)
391
468
  );
392
469
  assertSameValues(`${backend.key} assets`, backend.assets ?? [], admin.assets ?? []);
393
- for (const field of admin.fields) {
394
- if (
395
- (field.type === "plugin" || field.componentKey !== undefined) &&
396
- field.key !== backend.key
397
- ) {
398
- throw new Error(
399
- `Admin plugin ${backend.key} registered plugin field ${field.key ?? "<missing>"}`
400
- );
401
- }
402
- const fieldIdentity = `${field.type}:${field.key ?? ""}:${field.componentKey ?? ""}`;
403
- if (fieldIdentities.has(fieldIdentity)) {
404
- throw new Error(`Admin field renderer ${fieldIdentity} is registered more than once`);
405
- }
406
- fieldIdentities.add(fieldIdentity);
407
- fields.push(field);
408
- }
470
+ assertSameValues(
471
+ `${backend.key} field types`,
472
+ [...(backend.fieldTypes ?? [])].sort(),
473
+ Object.keys(admin.fields ?? {}).sort()
474
+ );
409
475
  keys.add(backend.key);
410
476
  plugins.push(admin);
411
477
  }
412
- const extensions = resolveAdminPluginExtensions(plugins);
478
+ const extensions = resolveAdminExtensions(plugins);
413
479
 
414
480
  return {
415
481
  plugins: Object.freeze(plugins),
416
- fields: Object.freeze(fields),
482
+ fields: resolvePluginFields(plugins),
417
483
  ...extensions,
418
484
  };
419
485
  }
420
486
 
421
- /** Finalizes frontend-only extension registrations before the admin mounts. */
422
- export function resolveAdminPluginExtensions(
423
- plugins: readonly AdminPlugin[]
424
- ): ResolvedAdminPluginExtensions {
425
- const routes: AdminPluginRoute[] = [];
487
+ /**
488
+ * Collect plugin UI first, then application UI, preserving order and rejecting
489
+ * duplicate identities or competing replacements. Used by Ridu's admin runtime;
490
+ * application authors normally provide `defineAdmin` configuration instead.
491
+ */
492
+ export function resolveAdminExtensions(
493
+ plugins: readonly AdminPlugin[],
494
+ application?: AdminContributions & { messages?: PluginMessageCatalog }
495
+ ): ResolvedAdminExtensions {
496
+ const routes: AdminRoute[] = [];
426
497
  const dashboard: AdminDashboardPanel[] = [];
427
498
  const login: AdminLoginComponent[] = [];
428
499
  const account: AdminAccountComponent[] = [];
@@ -446,7 +517,14 @@ export function resolveAdminPluginExtensions(
446
517
  let replacementNavigation: string | undefined;
447
518
  let logoutButton: AdminLogoutButton | undefined;
448
519
 
449
- for (const plugin of plugins) {
520
+ const sources = [
521
+ ...plugins.map((plugin) => ({ ...plugin, namespace: `plugin.${plugin.key}:` })),
522
+ ...(application === undefined
523
+ ? []
524
+ : [{ ...application, key: "application", namespace: "app:", rowLabels: [] }]),
525
+ ];
526
+ for (const plugin of sources) {
527
+ validateContributions(plugin, plugin.namespace === "app:");
450
528
  for (const rowLabel of plugin.rowLabels ?? []) {
451
529
  if (rowLabel.key !== plugin.key) {
452
530
  throw new Error(
@@ -465,7 +543,7 @@ export function resolveAdminPluginExtensions(
465
543
  );
466
544
  rowLabels.push(rowLabel);
467
545
  }
468
- if (plugin.messages !== undefined) {
546
+ if (plugin.messages !== undefined && plugin.namespace !== "app:") {
469
547
  if (messages[plugin.key] !== undefined) {
470
548
  throw new Error(`Admin plugin messages for ${plugin.key} are registered more than once`);
471
549
  }
@@ -474,7 +552,7 @@ export function resolveAdminPluginExtensions(
474
552
  }
475
553
  for (const route of plugin.routes ?? []) {
476
554
  validatePluginLabelKey(plugin, route.navigation?.labelKey, `route ${route.path}`);
477
- claim(identities, `route:${route.path}`, `Admin plugin route ${route.path}`);
555
+ claim(identities, `route:${route.path.toLowerCase()}`, `Admin plugin route ${route.path}`);
478
556
  routes.push(route);
479
557
  }
480
558
  for (const panel of plugin.dashboard ?? []) {
@@ -613,6 +691,8 @@ export function resolveAdminPluginExtensions(
613
691
  }
614
692
 
615
693
  return {
694
+ applicationMessages:
695
+ application?.messages === undefined ? undefined : freezeAdminMessages(application.messages),
616
696
  rowLabels: Object.freeze(rowLabels),
617
697
  routes: Object.freeze(routes),
618
698
  dashboard: Object.freeze(dashboard),
@@ -634,12 +714,12 @@ export function resolveAdminPluginExtensions(
634
714
  const adminComponentKeyPattern = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
635
715
 
636
716
  function validatePluginLabelKey(
637
- plugin: AdminPlugin,
638
- labelKey: PluginTranslationKey | undefined,
717
+ plugin: { key: string; namespace: string; messages?: PluginMessageCatalog },
718
+ labelKey: ExtensionTranslationKey | undefined,
639
719
  surface: string
640
720
  ) {
641
721
  if (labelKey === undefined) return;
642
- const prefix = `plugin.${plugin.key}:`;
722
+ const prefix = plugin.namespace;
643
723
  if (!labelKey.startsWith(prefix)) {
644
724
  throw new Error(
645
725
  `Admin plugin ${plugin.key} ${surface} label key must use its own ${prefix} namespace`
@@ -663,3 +743,114 @@ function assertSameValues(label: string, backend: readonly string[], admin: read
663
743
  throw new Error(`Admin plugin ${label} do not match the compiled backend declaration`);
664
744
  }
665
745
  }
746
+
747
+ // Application routes are literal paths: no router
748
+ // patterns, query strings, normalization aliases or framework namespace shadowing.
749
+ const reservedRouteRoots = new Set([
750
+ "account",
751
+ "collections",
752
+ "globals",
753
+ "login",
754
+ "create-first-user",
755
+ "forgot-password",
756
+ "reset-password",
757
+ "request-verification",
758
+ "verify-email",
759
+ ]);
760
+ function validateContributions(
761
+ source: AdminContributions & { messages?: PluginMessageCatalog },
762
+ local: boolean
763
+ ) {
764
+ if (source.messages !== undefined) validateAdminMessages("application", source.messages);
765
+ const groups = [
766
+ "routes",
767
+ "dashboard",
768
+ "login",
769
+ "account",
770
+ "navigation",
771
+ "views",
772
+ "branding",
773
+ "shell",
774
+ "providers",
775
+ "listCells",
776
+ "documentActions",
777
+ "documentViews",
778
+ ] as const;
779
+ const positions = {
780
+ dashboard: ["before", "after", "replace"],
781
+ login: ["before", "after", "replace"],
782
+ account: ["before", "after", "replace"],
783
+ navigation: ["before", "beforeLinks", "afterLinks", "after", "replace"],
784
+ shell: ["header", "actions", "settingsMenu"],
785
+ } as const;
786
+ for (const group of groups) {
787
+ const entries = source[group];
788
+ if (entries === undefined) continue;
789
+ if (!Array.isArray(entries)) throw new Error(`Admin ${group} registrations must be an array.`);
790
+ for (const entry of entries) {
791
+ if (entry === null || typeof entry !== "object" || typeof entry.component !== "function")
792
+ throw new Error(`Admin ${group} registration requires a Svelte component.`);
793
+ if (
794
+ "key" in entry &&
795
+ (typeof entry.key !== "string" || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(entry.key))
796
+ )
797
+ throw new Error(`Admin ${group} registration has an invalid key.`);
798
+ if (group !== "routes" && !("key" in entry))
799
+ throw new Error(`Admin ${group} registration requires a key.`);
800
+ if (group in positions) {
801
+ const allowed: readonly string[] = positions[group as keyof typeof positions];
802
+ const position = "position" in entry ? entry.position : undefined;
803
+ if (
804
+ (position === undefined && (group === "navigation" || group === "shell")) ||
805
+ (position !== undefined && !allowed.includes(position))
806
+ )
807
+ throw new Error(`Admin ${group} registration has an invalid position.`);
808
+ }
809
+ if (group === "account" || group === "branding" || group === "views") {
810
+ const allowed =
811
+ group === "account"
812
+ ? ["profile", "security"]
813
+ : group === "branding"
814
+ ? ["loginLogo", "navigationLogo", "accountAvatar"]
815
+ : ["collectionList", "collectionCreate", "collectionEdit", "global", "notFound"];
816
+ if (!("surface" in entry) || !allowed.includes(entry.surface))
817
+ throw new Error(`Admin ${group} registration has an invalid surface.`);
818
+ }
819
+ }
820
+ }
821
+ for (const route of local ? (source.routes ?? []) : []) {
822
+ if (
823
+ typeof route.path !== "string" ||
824
+ !/^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(route.path) ||
825
+ reservedRouteRoots.has(route.path.split("/")[0]!.toLowerCase())
826
+ )
827
+ throw new Error(
828
+ `Admin route ${route.path} must be a literal relative path outside framework namespaces.`
829
+ );
830
+ }
831
+ if (
832
+ source.logoutButton !== undefined &&
833
+ (typeof source.logoutButton.component !== "function" || !source.logoutButton.key)
834
+ )
835
+ throw new Error("Admin logout button requires a key and Svelte component.");
836
+ for (const action of source.documentActions ?? []) {
837
+ if (
838
+ action.requires !== undefined &&
839
+ ![
840
+ "admin",
841
+ "create",
842
+ "read",
843
+ "readVersions",
844
+ "update",
845
+ "delete",
846
+ "duplicate",
847
+ "publish",
848
+ "unpublish",
849
+ "restoreDeleted",
850
+ "deletePermanent",
851
+ "selectAll",
852
+ ].includes(action.requires)
853
+ )
854
+ throw new Error(`Admin document action ${action.key} has an invalid required operation.`);
855
+ }
856
+ }