@riducms/plugin 0.1.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/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +38 -0
- package/src/authoring.ts +54 -0
- package/src/field.ts +26 -0
- package/src/form.ts +17 -0
- package/src/i18n.ts +97 -0
- package/src/index.ts +73 -0
- package/src/plugin.ts +665 -0
- package/src/row-label.ts +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Haniel Ubogu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# `@riducms/plugin`
|
|
2
|
+
|
|
3
|
+
Svelte and TypeScript APIs for Ridu admin plugins. Import field props, extension types, and
|
|
4
|
+
`defineFieldPlugin` from this package.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { defineFieldPlugin, type FieldComponentProps } from "@riducms/plugin";
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`defineAdminPlugin` also exposes collision-checked static extension surfaces for login, profile,
|
|
11
|
+
security, navigation, logout, dashboard, routes, list cells, document actions, and document views.
|
|
12
|
+
Core-view replacements receive a `defaultView` snippet so a plugin can wrap the existing screen.
|
|
13
|
+
Auth and account components receive hosts for login, logout, identity refresh, and notifications.
|
|
14
|
+
|
|
15
|
+
Collection list/create/edit, global, and not-found replacements use the same model. They can be
|
|
16
|
+
registered for one resource or as a surface-wide fallback; exact resource registrations win over
|
|
17
|
+
fallbacks. Their host exposes manifest refresh, document-change invalidation, and notifications,
|
|
18
|
+
and application data remains available through the generated SDK.
|
|
19
|
+
|
|
20
|
+
Fine-grained shell registrations cover login/navigation graphics, the account avatar, global
|
|
21
|
+
header and action slots, account settings items, and ordered provider wrappers. Replacement
|
|
22
|
+
graphics allow one registration per surface; additive shell components and providers keep plugin
|
|
23
|
+
order.
|
|
24
|
+
|
|
25
|
+
A paired backend plugin may also assign an exact `componentKey` to a renderer for a built-in field
|
|
26
|
+
type. These renderers keep the core field's storage semantics and receive the public form resource,
|
|
27
|
+
content locale, a detached current-form `snapshot()`, and the narrow `requestPlugin` authoring host.
|
|
28
|
+
That host calls only the paired backend plugin's namespaced endpoints.
|
|
29
|
+
|
|
30
|
+
Arrays and blocks may also select an exact paired-plugin row-label component. Register it with
|
|
31
|
+
`defineRowLabelPlugin({ key, componentKey, component })` and author the matching Go option with
|
|
32
|
+
`field.RowLabelComponent`. The component receives the manifest field, a detached deeply frozen row
|
|
33
|
+
snapshot, its 1-based visual `rowNumber`, current i18n, and deterministic JSON config. Registration
|
|
34
|
+
identities are collision checked before mount, and a manifest that selects a missing
|
|
35
|
+
plugin/component fails with a field-specific diagnostic. The legacy single-child
|
|
36
|
+
`field.RowLabel(path)` behavior remains available for arrays.
|
|
37
|
+
|
|
38
|
+
Use `@riducms/ui` for shared interaction components and the generated SDK for application data.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bugs": {
|
|
3
|
+
"url": "https://github.com/riducms/ridu/issues"
|
|
4
|
+
},
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"@riducms/protocol": "0.1.0",
|
|
7
|
+
"@riducms/translations": "0.1.0"
|
|
8
|
+
},
|
|
9
|
+
"description": "Public TypeScript contracts for statically registered Ridu admin plugins.",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://riducms.com",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"name": "@riducms/plugin",
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"svelte": "^5.56.8"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"directory": "packages/plugin",
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/riducms/ridu.git"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"check": "tsc -p tsconfig.json",
|
|
34
|
+
"test": "bun test"
|
|
35
|
+
},
|
|
36
|
+
"type": "module",
|
|
37
|
+
"version": "0.1.0"
|
|
38
|
+
}
|
package/src/authoring.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { SchemaCollection, SchemaField } from "@riducms/protocol";
|
|
2
|
+
import type { Component } from "svelte";
|
|
3
|
+
|
|
4
|
+
export type FieldDocument = Record<string, unknown> & {
|
|
5
|
+
id: string;
|
|
6
|
+
createdAt?: string;
|
|
7
|
+
updatedAt?: string;
|
|
8
|
+
deletedAt?: string;
|
|
9
|
+
_status?: "draft" | "published";
|
|
10
|
+
_revision?: number;
|
|
11
|
+
_localization?: {
|
|
12
|
+
sources: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface FieldReferenceFilter {
|
|
17
|
+
field: string;
|
|
18
|
+
operator:
|
|
19
|
+
| "equals"
|
|
20
|
+
| "notEquals"
|
|
21
|
+
| "like"
|
|
22
|
+
| "contains"
|
|
23
|
+
| "greaterThan"
|
|
24
|
+
| "greaterThanEqual"
|
|
25
|
+
| "lessThan"
|
|
26
|
+
| "lessThanEqual";
|
|
27
|
+
value: string | number | boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FieldReferenceBrowserProps {
|
|
31
|
+
open?: boolean;
|
|
32
|
+
field: SchemaField;
|
|
33
|
+
collection: SchemaCollection;
|
|
34
|
+
hasMany: boolean;
|
|
35
|
+
selectedIDs: readonly string[];
|
|
36
|
+
readOnly?: boolean;
|
|
37
|
+
initialDocument?: FieldDocument;
|
|
38
|
+
initialDocumentID?: string;
|
|
39
|
+
optionFilter?: FieldReferenceFilter | readonly FieldReferenceFilter[];
|
|
40
|
+
defaultValues?: Readonly<Record<string, unknown>>;
|
|
41
|
+
allowCreate?: boolean;
|
|
42
|
+
locale?: string;
|
|
43
|
+
onCommit: (ids: string[]) => void | boolean | Promise<void | boolean>;
|
|
44
|
+
onClose: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FieldAuthoringHost {
|
|
48
|
+
readonly collections: readonly SchemaCollection[];
|
|
49
|
+
readonly documentRevision: number;
|
|
50
|
+
readonly locale?: string;
|
|
51
|
+
referenceBrowser: Component<FieldReferenceBrowserProps>;
|
|
52
|
+
findDocument(collection: string, id: string, signal?: AbortSignal): Promise<FieldDocument>;
|
|
53
|
+
requestPlugin?<Result>(path: string, body: unknown, signal?: AbortSignal): Promise<Result>;
|
|
54
|
+
}
|
package/src/field.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FieldType, SchemaField } from "@riducms/protocol";
|
|
2
|
+
import type { Component } from "svelte";
|
|
3
|
+
|
|
4
|
+
import type { FieldAuthoringHost } from "./authoring";
|
|
5
|
+
import type { FieldForm } from "./form";
|
|
6
|
+
import type { AdminI18n } from "./i18n";
|
|
7
|
+
|
|
8
|
+
export interface FieldComponentProps {
|
|
9
|
+
field: SchemaField;
|
|
10
|
+
form: FieldForm;
|
|
11
|
+
i18n: AdminI18n;
|
|
12
|
+
authoring?: FieldAuthoringHost;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FieldPlugin {
|
|
16
|
+
type: FieldType;
|
|
17
|
+
key?: string;
|
|
18
|
+
/** Named renderer selected by field.admin.component for built-in field semantics. */
|
|
19
|
+
componentKey?: string;
|
|
20
|
+
component?: Component<FieldComponentProps>;
|
|
21
|
+
canRender(field: SchemaField): boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function defineFieldPlugin<const Plugin extends FieldPlugin>(plugin: Plugin): Plugin {
|
|
25
|
+
return plugin;
|
|
26
|
+
}
|
package/src/form.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ValidationIssue } from "@riducms/protocol";
|
|
2
|
+
|
|
3
|
+
export interface FieldFormResource {
|
|
4
|
+
collection: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
global?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FieldForm {
|
|
10
|
+
readonly contentLocale?: string;
|
|
11
|
+
readonly resource?: FieldFormResource;
|
|
12
|
+
get(path: string): unknown;
|
|
13
|
+
issuesFor(path: string): readonly ValidationIssue[];
|
|
14
|
+
register(path: string): () => void;
|
|
15
|
+
set(path: string, value: unknown): void;
|
|
16
|
+
snapshot?(): Record<string, unknown>;
|
|
17
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdminI18n,
|
|
3
|
+
PluginMessageCatalog,
|
|
4
|
+
PluginTranslationKey,
|
|
5
|
+
TranslationMessage,
|
|
6
|
+
} from "@riducms/translations";
|
|
7
|
+
import { validatePluginMessageCatalog } from "@riducms/translations";
|
|
8
|
+
import { createContext } from "svelte";
|
|
9
|
+
|
|
10
|
+
type ExactCatalog<Source extends Readonly<Record<string, TranslationMessage>>> = Readonly<{
|
|
11
|
+
[Key in keyof Source]: TranslationMessage;
|
|
12
|
+
}>;
|
|
13
|
+
|
|
14
|
+
type MessageText<Message> = Message extends string
|
|
15
|
+
? Message
|
|
16
|
+
: Message extends Readonly<Record<string, unknown>>
|
|
17
|
+
? Extract<Message[keyof Message], string>
|
|
18
|
+
: never;
|
|
19
|
+
type PlaceholderNames<Text extends string> = Text extends `${string}{${infer Name}}${infer Rest}`
|
|
20
|
+
? Name | PlaceholderNames<Rest>
|
|
21
|
+
: never;
|
|
22
|
+
type SamePlaceholders<Source, Candidate> = [
|
|
23
|
+
Exclude<PlaceholderNames<MessageText<Source>>, PlaceholderNames<MessageText<Candidate>>>,
|
|
24
|
+
] extends [never]
|
|
25
|
+
? [
|
|
26
|
+
Exclude<PlaceholderNames<MessageText<Candidate>>, PlaceholderNames<MessageText<Source>>>,
|
|
27
|
+
] extends [never]
|
|
28
|
+
? true
|
|
29
|
+
: false
|
|
30
|
+
: false;
|
|
31
|
+
type CompatibleMessage<Source, Candidate> =
|
|
32
|
+
SamePlaceholders<Source, Candidate> extends true
|
|
33
|
+
? Source extends string
|
|
34
|
+
? Candidate extends string
|
|
35
|
+
? Candidate
|
|
36
|
+
: never
|
|
37
|
+
: Candidate extends string
|
|
38
|
+
? never
|
|
39
|
+
: Candidate
|
|
40
|
+
: never;
|
|
41
|
+
type CheckedCatalog<
|
|
42
|
+
Source extends Readonly<Record<string, TranslationMessage>>,
|
|
43
|
+
Candidate extends ExactCatalog<Source>,
|
|
44
|
+
> = Readonly<{
|
|
45
|
+
[Key in keyof Source]: CompatibleMessage<Source[Key], Candidate[Key]>;
|
|
46
|
+
}> &
|
|
47
|
+
Readonly<Record<Exclude<keyof Candidate, keyof Source>, never>>;
|
|
48
|
+
type AdminMessagesShape = Readonly<{
|
|
49
|
+
fallback: Readonly<Record<string, TranslationMessage>>;
|
|
50
|
+
translations?: Readonly<Record<string, Readonly<Record<string, TranslationMessage>>>>;
|
|
51
|
+
}>;
|
|
52
|
+
type CheckedAdminMessages<Input extends AdminMessagesShape> =
|
|
53
|
+
Input["translations"] extends Readonly<
|
|
54
|
+
Record<string, Readonly<Record<string, TranslationMessage>>>
|
|
55
|
+
>
|
|
56
|
+
? {
|
|
57
|
+
translations: {
|
|
58
|
+
[
|
|
59
|
+
Language in keyof Input["translations"]
|
|
60
|
+
]: Input["translations"][Language] extends ExactCatalog<Input["fallback"]>
|
|
61
|
+
? CheckedCatalog<Input["fallback"], Input["translations"][Language]>
|
|
62
|
+
: never;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
: unknown;
|
|
66
|
+
|
|
67
|
+
export interface DefineAdminMessagesInput<
|
|
68
|
+
Fallback extends Readonly<Record<string, TranslationMessage>>,
|
|
69
|
+
> {
|
|
70
|
+
fallback: Fallback;
|
|
71
|
+
translations?: Readonly<Record<string, ExactCatalog<Fallback>>>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Defines one plugin-owned message catalog with exact translated key coverage. */
|
|
75
|
+
export function defineAdminMessages<const Input extends AdminMessagesShape>(
|
|
76
|
+
input: Input & CheckedAdminMessages<Input>
|
|
77
|
+
): DefineAdminMessagesInput<Input["fallback"]> {
|
|
78
|
+
validatePluginMessageCatalog("definition", input);
|
|
79
|
+
return freezeAdminMessages(input) as DefineAdminMessagesInput<Input["fallback"]>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function validateAdminMessages(key: string, messages: PluginMessageCatalog) {
|
|
83
|
+
validatePluginMessageCatalog(key, messages);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function freezeAdminMessages(messages: PluginMessageCatalog): PluginMessageCatalog {
|
|
87
|
+
if (Object.isFrozen(messages)) return messages;
|
|
88
|
+
Object.freeze(messages.fallback);
|
|
89
|
+
for (const catalog of Object.values(messages.translations ?? {})) Object.freeze(catalog);
|
|
90
|
+
if (messages.translations !== undefined) Object.freeze(messages.translations);
|
|
91
|
+
return Object.freeze(messages);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const [getAdminI18n, setAdminI18n] = createContext<AdminI18n>();
|
|
95
|
+
|
|
96
|
+
export { getAdminI18n, setAdminI18n };
|
|
97
|
+
export type { AdminI18n, PluginMessageCatalog, PluginTranslationKey };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
FieldAuthoringHost,
|
|
3
|
+
FieldDocument,
|
|
4
|
+
FieldReferenceBrowserProps,
|
|
5
|
+
FieldReferenceFilter,
|
|
6
|
+
} from "./authoring";
|
|
7
|
+
export { defineFieldPlugin, type FieldComponentProps, type FieldPlugin } from "./field";
|
|
8
|
+
export {
|
|
9
|
+
defineRowLabelPlugin,
|
|
10
|
+
type RowLabelComponentProps,
|
|
11
|
+
type RowLabelPlugin,
|
|
12
|
+
type RowLabelSnapshot,
|
|
13
|
+
type RowLabelValue,
|
|
14
|
+
} from "./row-label";
|
|
15
|
+
export type { FieldForm, FieldFormResource } from "./form";
|
|
16
|
+
export {
|
|
17
|
+
defineAdminMessages,
|
|
18
|
+
getAdminI18n,
|
|
19
|
+
setAdminI18n,
|
|
20
|
+
validateAdminMessages,
|
|
21
|
+
type AdminI18n,
|
|
22
|
+
type DefineAdminMessagesInput,
|
|
23
|
+
type PluginMessageCatalog,
|
|
24
|
+
type PluginTranslationKey,
|
|
25
|
+
} from "./i18n";
|
|
26
|
+
export {
|
|
27
|
+
ADMIN_PLUGIN_API_VERSION,
|
|
28
|
+
defineAdminPlugin,
|
|
29
|
+
resolveAdminPluginExtensions,
|
|
30
|
+
resolveAdminPluginPairs,
|
|
31
|
+
type AdminPlugin,
|
|
32
|
+
type AdminDashboardPanel,
|
|
33
|
+
type AdminDashboardPanelProps,
|
|
34
|
+
type AdminExtensionProps,
|
|
35
|
+
type AdminLoginComponent,
|
|
36
|
+
type AdminLoginComponentProps,
|
|
37
|
+
type AdminLoginExtensionHost,
|
|
38
|
+
type AdminAccountComponent,
|
|
39
|
+
type AdminAccountComponentProps,
|
|
40
|
+
type AdminAccountExtensionHost,
|
|
41
|
+
type AdminAccountSurface,
|
|
42
|
+
type AdminNavigationComponent,
|
|
43
|
+
type AdminNavigationComponentProps,
|
|
44
|
+
type AdminNavigationPosition,
|
|
45
|
+
type AdminLogoutButton,
|
|
46
|
+
type AdminLogoutButtonProps,
|
|
47
|
+
type AdminLogoutExtensionHost,
|
|
48
|
+
type AdminCoreView,
|
|
49
|
+
type AdminCoreViewHost,
|
|
50
|
+
type AdminCoreViewProps,
|
|
51
|
+
type AdminCoreViewSurface,
|
|
52
|
+
type AdminBrandComponent,
|
|
53
|
+
type AdminBrandComponentProps,
|
|
54
|
+
type AdminBrandSurface,
|
|
55
|
+
type AdminShellComponent,
|
|
56
|
+
type AdminShellComponentProps,
|
|
57
|
+
type AdminShellPosition,
|
|
58
|
+
type AdminProvider,
|
|
59
|
+
type AdminProviderProps,
|
|
60
|
+
type AdminDocumentAction,
|
|
61
|
+
type AdminDocumentExtensionHost,
|
|
62
|
+
type AdminDocumentExtensionProps,
|
|
63
|
+
type AdminDocumentView,
|
|
64
|
+
type AdminExtensionNotificationTone,
|
|
65
|
+
type AdminListCell,
|
|
66
|
+
type AdminListCellProps,
|
|
67
|
+
type AdminPluginNavigation,
|
|
68
|
+
type AdminPluginPair,
|
|
69
|
+
type AdminPluginRoute,
|
|
70
|
+
type BackendAdminPlugin,
|
|
71
|
+
type ResolvedAdminPluginExtensions,
|
|
72
|
+
type ResolvedAdminPluginPairs,
|
|
73
|
+
} from "./plugin";
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import type { FieldPlugin } from "./field";
|
|
2
|
+
import { ADMIN_PLUGIN_API_VERSION } from "@riducms/protocol";
|
|
3
|
+
import type {
|
|
4
|
+
OperationCapabilities,
|
|
5
|
+
SchemaCollection,
|
|
6
|
+
SchemaField,
|
|
7
|
+
SchemaGlobal,
|
|
8
|
+
SchemaManifest,
|
|
9
|
+
} from "@riducms/protocol";
|
|
10
|
+
import type { Component, Snippet } from "svelte";
|
|
11
|
+
|
|
12
|
+
import type { FieldDocument } from "./authoring";
|
|
13
|
+
import type { AdminI18n, PluginMessageCatalog, PluginTranslationKey } from "./i18n";
|
|
14
|
+
import { freezeAdminMessages, validateAdminMessages } from "./i18n";
|
|
15
|
+
import type { RowLabelPlugin } from "./row-label";
|
|
16
|
+
|
|
17
|
+
export { ADMIN_PLUGIN_API_VERSION };
|
|
18
|
+
|
|
19
|
+
/**
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
export interface AdminPlugin {
|
|
25
|
+
/** Framework admin-plugin contract version compiled by this package. */
|
|
26
|
+
apiVersion: typeof ADMIN_PLUGIN_API_VERSION;
|
|
27
|
+
/** Stable key matching the compiled Go plugin. */
|
|
28
|
+
key: string;
|
|
29
|
+
/** Plugin-owned backend/admin compatibility version. */
|
|
30
|
+
pairingVersion: number;
|
|
31
|
+
/** Field renderers contributed by this package. */
|
|
32
|
+
fields: readonly FieldPlugin[];
|
|
33
|
+
/** Array and blocks row-label components contributed by this package. */
|
|
34
|
+
rowLabels?: readonly RowLabelPlugin[];
|
|
35
|
+
/** Statically bundled, plugin-owned interface messages. */
|
|
36
|
+
messages?: PluginMessageCatalog;
|
|
37
|
+
/** Authenticated admin routes contributed by this package. */
|
|
38
|
+
routes?: readonly AdminPluginRoute[];
|
|
39
|
+
/** Panels composed into or replacing the authenticated dashboard. */
|
|
40
|
+
dashboard?: readonly AdminDashboardPanel[];
|
|
41
|
+
/** Components composed around or replacing the sign-in screen. */
|
|
42
|
+
login?: readonly AdminLoginComponent[];
|
|
43
|
+
/** Components composed around or replacing the framework account screens. */
|
|
44
|
+
account?: readonly AdminAccountComponent[];
|
|
45
|
+
/** Components composed around or replacing the framework navigation. */
|
|
46
|
+
navigation?: readonly AdminNavigationComponent[];
|
|
47
|
+
/** Replaces the default sign-out control in the account menu. */
|
|
48
|
+
logoutButton?: AdminLogoutButton;
|
|
49
|
+
/** Wraps or replaces framework collection, global, and not-found views. */
|
|
50
|
+
views?: readonly AdminCoreView[];
|
|
51
|
+
/** Replaces framework-owned brand graphics at exact shell surfaces. */
|
|
52
|
+
branding?: readonly AdminBrandComponent[];
|
|
53
|
+
/** Adds global header, action, or account-settings menu components. */
|
|
54
|
+
shell?: readonly AdminShellComponent[];
|
|
55
|
+
/** Wraps the admin in statically ordered Svelte context providers. */
|
|
56
|
+
providers?: readonly AdminProvider[];
|
|
57
|
+
/** Collection list cells rendered for exact collection/field pairs. */
|
|
58
|
+
listCells?: readonly AdminListCell[];
|
|
59
|
+
/** Actions rendered alongside framework-owned document actions. */
|
|
60
|
+
documentActions?: readonly AdminDocumentAction[];
|
|
61
|
+
/** Read-only or operational views rendered beside Edit and API. */
|
|
62
|
+
documentViews?: readonly AdminDocumentView[];
|
|
63
|
+
/** Package-relative static modules imported by the generated admin registry. */
|
|
64
|
+
assets?: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AdminExtensionProps {
|
|
68
|
+
manifest: SchemaManifest;
|
|
69
|
+
user?: FieldDocument;
|
|
70
|
+
i18n: AdminI18n;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AdminLoginExtensionHost {
|
|
74
|
+
/** Authenticates, evaluates admin access, and enters the authenticated shell. */
|
|
75
|
+
login: (credentials: { email: string; password: string }) => Promise<void>;
|
|
76
|
+
notify: (tone: AdminExtensionNotificationTone, title: string, message?: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AdminLoginComponentProps extends AdminExtensionProps {
|
|
80
|
+
/** The complete framework sign-in screen, available to replacement wrappers. */
|
|
81
|
+
defaultView: Snippet;
|
|
82
|
+
host: AdminLoginExtensionHost;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AdminLoginComponent {
|
|
86
|
+
key: string;
|
|
87
|
+
component: Component<AdminLoginComponentProps>;
|
|
88
|
+
position?: "before" | "after" | "replace";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type AdminAccountSurface = "profile" | "security";
|
|
92
|
+
|
|
93
|
+
export interface AdminAccountExtensionHost {
|
|
94
|
+
/** Reloads the signed-in document and updates the shell identity. */
|
|
95
|
+
refreshUser: () => Promise<FieldDocument | undefined>;
|
|
96
|
+
/** Ends the current session and returns to sign-in. */
|
|
97
|
+
logout: () => Promise<void>;
|
|
98
|
+
notify: (tone: AdminExtensionNotificationTone, title: string, message?: string) => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface AdminAccountComponentProps extends AdminExtensionProps {
|
|
102
|
+
surface: AdminAccountSurface;
|
|
103
|
+
/** The complete framework account screen, available to replacement wrappers. */
|
|
104
|
+
defaultView: Snippet;
|
|
105
|
+
host: AdminAccountExtensionHost;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AdminAccountComponent {
|
|
109
|
+
key: string;
|
|
110
|
+
surface: AdminAccountSurface;
|
|
111
|
+
component: Component<AdminAccountComponentProps>;
|
|
112
|
+
position?: "before" | "after" | "replace";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type AdminNavigationPosition = "before" | "beforeLinks" | "afterLinks" | "after" | "replace";
|
|
116
|
+
|
|
117
|
+
export interface AdminNavigationComponentProps extends AdminExtensionProps {
|
|
118
|
+
/** The complete framework navigation, available to replacement wrappers. */
|
|
119
|
+
defaultView: Snippet;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface AdminNavigationComponent {
|
|
123
|
+
key: string;
|
|
124
|
+
component: Component<AdminNavigationComponentProps>;
|
|
125
|
+
position: AdminNavigationPosition;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface AdminLogoutExtensionHost {
|
|
129
|
+
logout: () => Promise<void>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface AdminLogoutButtonProps extends AdminExtensionProps {
|
|
133
|
+
host: AdminLogoutExtensionHost;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface AdminLogoutButton {
|
|
137
|
+
key: string;
|
|
138
|
+
component: Component<AdminLogoutButtonProps>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type AdminCoreViewSurface =
|
|
142
|
+
"collectionList" | "collectionCreate" | "collectionEdit" | "global" | "notFound";
|
|
143
|
+
|
|
144
|
+
export interface AdminCoreViewHost {
|
|
145
|
+
refreshManifest: () => Promise<void>;
|
|
146
|
+
documentsChanged: () => void;
|
|
147
|
+
notify: (tone: AdminExtensionNotificationTone, title: string, message?: string) => void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface AdminCoreViewProps extends AdminExtensionProps {
|
|
151
|
+
surface: AdminCoreViewSurface;
|
|
152
|
+
collection?: SchemaCollection;
|
|
153
|
+
global?: SchemaGlobal;
|
|
154
|
+
documentID?: string;
|
|
155
|
+
/** The complete framework route, available to replacement wrappers. */
|
|
156
|
+
defaultView: Snippet;
|
|
157
|
+
host: AdminCoreViewHost;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface AdminCollectionCoreView {
|
|
161
|
+
key: string;
|
|
162
|
+
surface: "collectionList" | "collectionCreate" | "collectionEdit";
|
|
163
|
+
/** Omit to replace this surface for every collection. */
|
|
164
|
+
collection?: string;
|
|
165
|
+
component: Component<AdminCoreViewProps>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface AdminGlobalCoreView {
|
|
169
|
+
key: string;
|
|
170
|
+
surface: "global";
|
|
171
|
+
/** Omit to replace this surface for every global. */
|
|
172
|
+
global?: string;
|
|
173
|
+
component: Component<AdminCoreViewProps>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface AdminNotFoundCoreView {
|
|
177
|
+
key: string;
|
|
178
|
+
surface: "notFound";
|
|
179
|
+
component: Component<AdminCoreViewProps>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type AdminCoreView = AdminCollectionCoreView | AdminGlobalCoreView | AdminNotFoundCoreView;
|
|
183
|
+
|
|
184
|
+
export type AdminBrandSurface = "loginLogo" | "navigationLogo" | "accountAvatar";
|
|
185
|
+
|
|
186
|
+
export interface AdminBrandComponentProps extends AdminExtensionProps {
|
|
187
|
+
surface: AdminBrandSurface;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface AdminBrandComponent {
|
|
191
|
+
key: string;
|
|
192
|
+
surface: AdminBrandSurface;
|
|
193
|
+
component: Component<AdminBrandComponentProps>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type AdminShellPosition = "header" | "actions" | "settingsMenu";
|
|
197
|
+
|
|
198
|
+
export interface AdminShellComponentProps extends AdminExtensionProps {
|
|
199
|
+
position: AdminShellPosition;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface AdminShellComponent {
|
|
203
|
+
key: string;
|
|
204
|
+
position: AdminShellPosition;
|
|
205
|
+
component: Component<AdminShellComponentProps>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface AdminProviderProps {
|
|
209
|
+
/** The remaining provider stack and framework admin application. */
|
|
210
|
+
defaultView: Snippet;
|
|
211
|
+
i18n: AdminI18n;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface AdminProvider {
|
|
215
|
+
key: string;
|
|
216
|
+
component: Component<AdminProviderProps>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface AdminDashboardPanelProps {
|
|
220
|
+
manifest: SchemaManifest;
|
|
221
|
+
user?: FieldDocument;
|
|
222
|
+
i18n: AdminI18n;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface AdminDashboardPanel {
|
|
226
|
+
/** Plugin-owned identity used for deterministic ordering and collision checks. */
|
|
227
|
+
key: string;
|
|
228
|
+
component: Component<AdminDashboardPanelProps>;
|
|
229
|
+
/** Replace suppresses the framework overview and may only be registered once. */
|
|
230
|
+
position?: "before" | "after" | "replace";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface AdminListCellProps {
|
|
234
|
+
collection: SchemaCollection;
|
|
235
|
+
field: SchemaField;
|
|
236
|
+
document: FieldDocument;
|
|
237
|
+
value: unknown;
|
|
238
|
+
i18n: AdminI18n;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface AdminListCell {
|
|
242
|
+
key: string;
|
|
243
|
+
collection: string;
|
|
244
|
+
field: string;
|
|
245
|
+
label: string;
|
|
246
|
+
labelKey?: PluginTranslationKey;
|
|
247
|
+
component: Component<AdminListCellProps>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export type AdminExtensionNotificationTone = "success" | "error";
|
|
251
|
+
|
|
252
|
+
export interface AdminDocumentExtensionHost {
|
|
253
|
+
refresh: () => Promise<void>;
|
|
254
|
+
notify: (tone: AdminExtensionNotificationTone, title: string, message?: string) => void;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface AdminDocumentExtensionProps {
|
|
258
|
+
collection: SchemaCollection;
|
|
259
|
+
document: FieldDocument;
|
|
260
|
+
host: AdminDocumentExtensionHost;
|
|
261
|
+
i18n: AdminI18n;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface AdminDocumentAction {
|
|
265
|
+
key: string;
|
|
266
|
+
/** Omit to show the action for every non-global collection. */
|
|
267
|
+
collection?: string;
|
|
268
|
+
/** Hide the action unless the evaluated document operation is allowed. */
|
|
269
|
+
requires?: keyof OperationCapabilities;
|
|
270
|
+
component: Component<AdminDocumentExtensionProps>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface AdminDocumentView {
|
|
274
|
+
key: string;
|
|
275
|
+
label: string;
|
|
276
|
+
labelKey?: PluginTranslationKey;
|
|
277
|
+
/** Omit to show the view for every collection and global. */
|
|
278
|
+
collection?: string;
|
|
279
|
+
component: Component<AdminDocumentExtensionProps>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface AdminPluginNavigation {
|
|
283
|
+
/** Human-readable navigation label inside the authenticated admin shell. */
|
|
284
|
+
label: string;
|
|
285
|
+
labelKey?: PluginTranslationKey;
|
|
286
|
+
/** Optional grouping hint reserved for shells that render grouped plugin navigation. */
|
|
287
|
+
group?: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 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. */
|
|
293
|
+
path: string;
|
|
294
|
+
/** Svelte route component rendered by the framework router. */
|
|
295
|
+
component: Component;
|
|
296
|
+
/** Optional navigation entry; omit it for a route reachable only by links or redirects. */
|
|
297
|
+
navigation?: AdminPluginNavigation;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Metadata emitted from the compiled Go plugin into the generated registry. */
|
|
301
|
+
export interface BackendAdminPlugin {
|
|
302
|
+
/** Framework admin-plugin API required by the compiled backend. */
|
|
303
|
+
apiVersion: number;
|
|
304
|
+
/** Named AdminPlugin export in package. */
|
|
305
|
+
export: string;
|
|
306
|
+
/** Stable backend plugin key. */
|
|
307
|
+
key: string;
|
|
308
|
+
/** Installed bare JavaScript package specifier. */
|
|
309
|
+
package: string;
|
|
310
|
+
/** Plugin-owned backend/admin compatibility version. */
|
|
311
|
+
pairingVersion: number;
|
|
312
|
+
/** Exact ordered route paths declared by the compiled backend. */
|
|
313
|
+
routes?: readonly string[];
|
|
314
|
+
/** Exact ordered package-relative static assets declared by the backend. */
|
|
315
|
+
assets?: readonly string[];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface AdminPluginPair {
|
|
319
|
+
admin: AdminPlugin;
|
|
320
|
+
backend: BackendAdminPlugin;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface ResolvedAdminPluginPairs {
|
|
324
|
+
plugins: readonly AdminPlugin[];
|
|
325
|
+
fields: readonly FieldPlugin[];
|
|
326
|
+
rowLabels: readonly RowLabelPlugin[];
|
|
327
|
+
routes: readonly AdminPluginRoute[];
|
|
328
|
+
dashboard: readonly AdminDashboardPanel[];
|
|
329
|
+
login: readonly AdminLoginComponent[];
|
|
330
|
+
account: readonly AdminAccountComponent[];
|
|
331
|
+
navigation: readonly AdminNavigationComponent[];
|
|
332
|
+
logoutButton: AdminLogoutButton | undefined;
|
|
333
|
+
views: readonly AdminCoreView[];
|
|
334
|
+
branding: readonly AdminBrandComponent[];
|
|
335
|
+
shell: readonly AdminShellComponent[];
|
|
336
|
+
providers: readonly AdminProvider[];
|
|
337
|
+
listCells: readonly AdminListCell[];
|
|
338
|
+
documentActions: readonly AdminDocumentAction[];
|
|
339
|
+
documentViews: readonly AdminDocumentView[];
|
|
340
|
+
messages: Readonly<Record<string, PluginMessageCatalog>>;
|
|
341
|
+
}
|
|
342
|
+
|
|
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
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
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.
|
|
354
|
+
*/
|
|
355
|
+
export function resolveAdminPluginPairs(
|
|
356
|
+
pairs: readonly AdminPluginPair[]
|
|
357
|
+
): ResolvedAdminPluginPairs {
|
|
358
|
+
const keys = new Set<string>();
|
|
359
|
+
const fieldIdentities = new Set<string>();
|
|
360
|
+
const plugins: AdminPlugin[] = [];
|
|
361
|
+
const fields: FieldPlugin[] = [];
|
|
362
|
+
|
|
363
|
+
for (const { admin, backend } of pairs) {
|
|
364
|
+
const identity = `${backend.package}#${backend.export}`;
|
|
365
|
+
if (keys.has(backend.key))
|
|
366
|
+
throw new Error(`Admin plugin ${backend.key} is paired more than once`);
|
|
367
|
+
if (backend.apiVersion !== ADMIN_PLUGIN_API_VERSION) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`Backend plugin ${backend.key} requires admin plugin API ${backend.apiVersion}, but this admin supports ${ADMIN_PLUGIN_API_VERSION}`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (admin.apiVersion !== ADMIN_PLUGIN_API_VERSION) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Admin plugin ${identity} uses API ${admin.apiVersion}, but this admin supports ${ADMIN_PLUGIN_API_VERSION}`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (admin.key !== backend.key) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`Admin plugin ${identity} declares key ${admin.key}, but the compiled backend expects ${backend.key}`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (admin.pairingVersion !== backend.pairingVersion) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`Admin plugin ${backend.key} pairing version ${admin.pairingVersion} does not match backend version ${backend.pairingVersion}; install matching plugin packages`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
assertSameValues(
|
|
388
|
+
`${backend.key} routes`,
|
|
389
|
+
backend.routes ?? [],
|
|
390
|
+
(admin.routes ?? []).map((route) => route.path)
|
|
391
|
+
);
|
|
392
|
+
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
|
+
}
|
|
409
|
+
keys.add(backend.key);
|
|
410
|
+
plugins.push(admin);
|
|
411
|
+
}
|
|
412
|
+
const extensions = resolveAdminPluginExtensions(plugins);
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
plugins: Object.freeze(plugins),
|
|
416
|
+
fields: Object.freeze(fields),
|
|
417
|
+
...extensions,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Finalizes frontend-only extension registrations before the admin mounts. */
|
|
422
|
+
export function resolveAdminPluginExtensions(
|
|
423
|
+
plugins: readonly AdminPlugin[]
|
|
424
|
+
): ResolvedAdminPluginExtensions {
|
|
425
|
+
const routes: AdminPluginRoute[] = [];
|
|
426
|
+
const dashboard: AdminDashboardPanel[] = [];
|
|
427
|
+
const login: AdminLoginComponent[] = [];
|
|
428
|
+
const account: AdminAccountComponent[] = [];
|
|
429
|
+
const navigation: AdminNavigationComponent[] = [];
|
|
430
|
+
const views: AdminCoreView[] = [];
|
|
431
|
+
const branding: AdminBrandComponent[] = [];
|
|
432
|
+
const shell: AdminShellComponent[] = [];
|
|
433
|
+
const providers: AdminProvider[] = [];
|
|
434
|
+
const listCells: AdminListCell[] = [];
|
|
435
|
+
const documentActions: AdminDocumentAction[] = [];
|
|
436
|
+
const documentViews: AdminDocumentView[] = [];
|
|
437
|
+
const rowLabels: RowLabelPlugin[] = [];
|
|
438
|
+
const messages: Record<string, PluginMessageCatalog> = Object.create(null) as Record<
|
|
439
|
+
string,
|
|
440
|
+
PluginMessageCatalog
|
|
441
|
+
>;
|
|
442
|
+
const identities = new Set<string>();
|
|
443
|
+
let replacementDashboard: string | undefined;
|
|
444
|
+
let replacementLogin: string | undefined;
|
|
445
|
+
const replacementAccounts = new Map<AdminAccountSurface, string>();
|
|
446
|
+
let replacementNavigation: string | undefined;
|
|
447
|
+
let logoutButton: AdminLogoutButton | undefined;
|
|
448
|
+
|
|
449
|
+
for (const plugin of plugins) {
|
|
450
|
+
for (const rowLabel of plugin.rowLabels ?? []) {
|
|
451
|
+
if (rowLabel.key !== plugin.key) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
`Admin plugin ${plugin.key} registered row label component for plugin ${rowLabel.key}`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
if (!adminComponentKeyPattern.test(rowLabel.componentKey)) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Admin row label component ${rowLabel.key}:${rowLabel.componentKey} has an invalid component key`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
claim(
|
|
462
|
+
identities,
|
|
463
|
+
`row-label:${rowLabel.key}:${rowLabel.componentKey}`,
|
|
464
|
+
`Admin row label component ${rowLabel.key}:${rowLabel.componentKey}`
|
|
465
|
+
);
|
|
466
|
+
rowLabels.push(rowLabel);
|
|
467
|
+
}
|
|
468
|
+
if (plugin.messages !== undefined) {
|
|
469
|
+
if (messages[plugin.key] !== undefined) {
|
|
470
|
+
throw new Error(`Admin plugin messages for ${plugin.key} are registered more than once`);
|
|
471
|
+
}
|
|
472
|
+
validateAdminMessages(plugin.key, plugin.messages);
|
|
473
|
+
messages[plugin.key] = freezeAdminMessages(plugin.messages);
|
|
474
|
+
}
|
|
475
|
+
for (const route of plugin.routes ?? []) {
|
|
476
|
+
validatePluginLabelKey(plugin, route.navigation?.labelKey, `route ${route.path}`);
|
|
477
|
+
claim(identities, `route:${route.path}`, `Admin plugin route ${route.path}`);
|
|
478
|
+
routes.push(route);
|
|
479
|
+
}
|
|
480
|
+
for (const panel of plugin.dashboard ?? []) {
|
|
481
|
+
claim(identities, `dashboard:${panel.key}`, `Admin dashboard panel ${panel.key}`);
|
|
482
|
+
if (panel.position === "replace") {
|
|
483
|
+
if (replacementDashboard !== undefined) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
`Admin dashboard replacements ${replacementDashboard} and ${panel.key} are both registered`
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
replacementDashboard = panel.key;
|
|
489
|
+
}
|
|
490
|
+
dashboard.push(panel);
|
|
491
|
+
}
|
|
492
|
+
for (const component of plugin.login ?? []) {
|
|
493
|
+
claim(identities, `login:${component.key}`, `Admin login component ${component.key}`);
|
|
494
|
+
if (component.position === "replace") {
|
|
495
|
+
if (replacementLogin !== undefined) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`Admin login replacements ${replacementLogin} and ${component.key} are both registered`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
replacementLogin = component.key;
|
|
501
|
+
}
|
|
502
|
+
login.push(component);
|
|
503
|
+
}
|
|
504
|
+
for (const component of plugin.account ?? []) {
|
|
505
|
+
claim(
|
|
506
|
+
identities,
|
|
507
|
+
`account:${component.surface}:${component.key}`,
|
|
508
|
+
`Admin account component ${component.key}`
|
|
509
|
+
);
|
|
510
|
+
if (component.position === "replace") {
|
|
511
|
+
const replacement = replacementAccounts.get(component.surface);
|
|
512
|
+
if (replacement !== undefined) {
|
|
513
|
+
throw new Error(
|
|
514
|
+
`Admin ${component.surface} account replacements ${replacement} and ${component.key} are both registered`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
replacementAccounts.set(component.surface, component.key);
|
|
518
|
+
}
|
|
519
|
+
account.push(component);
|
|
520
|
+
}
|
|
521
|
+
for (const component of plugin.navigation ?? []) {
|
|
522
|
+
claim(
|
|
523
|
+
identities,
|
|
524
|
+
`navigation:${component.key}`,
|
|
525
|
+
`Admin navigation component ${component.key}`
|
|
526
|
+
);
|
|
527
|
+
if (component.position === "replace") {
|
|
528
|
+
if (replacementNavigation !== undefined) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`Admin navigation replacements ${replacementNavigation} and ${component.key} are both registered`
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
replacementNavigation = component.key;
|
|
534
|
+
}
|
|
535
|
+
navigation.push(component);
|
|
536
|
+
}
|
|
537
|
+
if (plugin.logoutButton !== undefined) {
|
|
538
|
+
if (logoutButton !== undefined) {
|
|
539
|
+
throw new Error(
|
|
540
|
+
`Admin logout buttons ${logoutButton.key} and ${plugin.logoutButton.key} are both registered`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
claim(
|
|
544
|
+
identities,
|
|
545
|
+
`logout-button:${plugin.logoutButton.key}`,
|
|
546
|
+
`Admin logout button ${plugin.logoutButton.key}`
|
|
547
|
+
);
|
|
548
|
+
logoutButton = plugin.logoutButton;
|
|
549
|
+
}
|
|
550
|
+
for (const view of plugin.views ?? []) {
|
|
551
|
+
const resource =
|
|
552
|
+
"collection" in view
|
|
553
|
+
? (view.collection ?? "*")
|
|
554
|
+
: "global" in view
|
|
555
|
+
? (view.global ?? "*")
|
|
556
|
+
: "*";
|
|
557
|
+
claim(
|
|
558
|
+
identities,
|
|
559
|
+
`core-view:${view.surface}:${resource}`,
|
|
560
|
+
`Admin core view ${view.surface}.${resource}`
|
|
561
|
+
);
|
|
562
|
+
views.push(view);
|
|
563
|
+
}
|
|
564
|
+
for (const component of plugin.branding ?? []) {
|
|
565
|
+
claim(
|
|
566
|
+
identities,
|
|
567
|
+
`branding:${component.surface}`,
|
|
568
|
+
`Admin branding surface ${component.surface}`
|
|
569
|
+
);
|
|
570
|
+
branding.push(component);
|
|
571
|
+
}
|
|
572
|
+
for (const component of plugin.shell ?? []) {
|
|
573
|
+
claim(
|
|
574
|
+
identities,
|
|
575
|
+
`shell:${component.position}:${component.key}`,
|
|
576
|
+
`Admin shell component ${component.key}`
|
|
577
|
+
);
|
|
578
|
+
shell.push(component);
|
|
579
|
+
}
|
|
580
|
+
for (const provider of plugin.providers ?? []) {
|
|
581
|
+
claim(identities, `provider:${provider.key}`, `Admin provider ${provider.key}`);
|
|
582
|
+
providers.push(provider);
|
|
583
|
+
}
|
|
584
|
+
for (const cell of plugin.listCells ?? []) {
|
|
585
|
+
validatePluginLabelKey(plugin, cell.labelKey, `list cell ${cell.collection}.${cell.field}`);
|
|
586
|
+
claim(
|
|
587
|
+
identities,
|
|
588
|
+
`list-cell:${cell.collection}:${cell.field}`,
|
|
589
|
+
`Admin list cell ${cell.collection}.${cell.field}`
|
|
590
|
+
);
|
|
591
|
+
listCells.push(cell);
|
|
592
|
+
}
|
|
593
|
+
for (const action of plugin.documentActions ?? []) {
|
|
594
|
+
claim(
|
|
595
|
+
identities,
|
|
596
|
+
`document-action:${action.collection ?? "*"}:${action.key}`,
|
|
597
|
+
`Admin document action ${action.key}`
|
|
598
|
+
);
|
|
599
|
+
documentActions.push(action);
|
|
600
|
+
}
|
|
601
|
+
for (const view of plugin.documentViews ?? []) {
|
|
602
|
+
validatePluginLabelKey(plugin, view.labelKey, `document view ${view.key}`);
|
|
603
|
+
if (view.key === "edit" || view.key === "api") {
|
|
604
|
+
throw new Error(`Admin document view ${view.key} collides with a framework view`);
|
|
605
|
+
}
|
|
606
|
+
claim(
|
|
607
|
+
identities,
|
|
608
|
+
`document-view:${view.collection ?? "*"}:${view.key}`,
|
|
609
|
+
`Admin document view ${view.key}`
|
|
610
|
+
);
|
|
611
|
+
documentViews.push(view);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
rowLabels: Object.freeze(rowLabels),
|
|
617
|
+
routes: Object.freeze(routes),
|
|
618
|
+
dashboard: Object.freeze(dashboard),
|
|
619
|
+
login: Object.freeze(login),
|
|
620
|
+
account: Object.freeze(account),
|
|
621
|
+
navigation: Object.freeze(navigation),
|
|
622
|
+
logoutButton,
|
|
623
|
+
views: Object.freeze(views),
|
|
624
|
+
branding: Object.freeze(branding),
|
|
625
|
+
shell: Object.freeze(shell),
|
|
626
|
+
providers: Object.freeze(providers),
|
|
627
|
+
listCells: Object.freeze(listCells),
|
|
628
|
+
documentActions: Object.freeze(documentActions),
|
|
629
|
+
documentViews: Object.freeze(documentViews),
|
|
630
|
+
messages: Object.freeze(messages),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const adminComponentKeyPattern = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
635
|
+
|
|
636
|
+
function validatePluginLabelKey(
|
|
637
|
+
plugin: AdminPlugin,
|
|
638
|
+
labelKey: PluginTranslationKey | undefined,
|
|
639
|
+
surface: string
|
|
640
|
+
) {
|
|
641
|
+
if (labelKey === undefined) return;
|
|
642
|
+
const prefix = `plugin.${plugin.key}:`;
|
|
643
|
+
if (!labelKey.startsWith(prefix)) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`Admin plugin ${plugin.key} ${surface} label key must use its own ${prefix} namespace`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const messageKey = labelKey.slice(prefix.length);
|
|
649
|
+
if (plugin.messages?.fallback[messageKey] === undefined) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
`Admin plugin ${plugin.key} ${surface} label key ${labelKey} is not defined in its messages`
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function claim(identities: Set<string>, identity: string, label: string) {
|
|
657
|
+
if (identities.has(identity)) throw new Error(`${label} is already registered`);
|
|
658
|
+
identities.add(identity);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function assertSameValues(label: string, backend: readonly string[], admin: readonly string[]) {
|
|
662
|
+
if (backend.length !== admin.length || backend.some((value, index) => value !== admin[index])) {
|
|
663
|
+
throw new Error(`Admin plugin ${label} do not match the compiled backend declaration`);
|
|
664
|
+
}
|
|
665
|
+
}
|
package/src/row-label.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { SchemaField } from "@riducms/protocol";
|
|
2
|
+
import type { Component } from "svelte";
|
|
3
|
+
|
|
4
|
+
import type { AdminI18n } from "./i18n";
|
|
5
|
+
|
|
6
|
+
export type RowLabelValue =
|
|
7
|
+
| string
|
|
8
|
+
| number
|
|
9
|
+
| boolean
|
|
10
|
+
| null
|
|
11
|
+
| undefined
|
|
12
|
+
| readonly RowLabelValue[]
|
|
13
|
+
| { readonly [key: string]: RowLabelValue };
|
|
14
|
+
|
|
15
|
+
/** A detached, deeply frozen view of the current array or blocks row. */
|
|
16
|
+
export type RowLabelSnapshot = Readonly<Record<string, RowLabelValue>>;
|
|
17
|
+
|
|
18
|
+
export interface RowLabelComponentProps {
|
|
19
|
+
field: SchemaField;
|
|
20
|
+
row: RowLabelSnapshot;
|
|
21
|
+
/** The current visual position, starting at one. */
|
|
22
|
+
rowNumber: number;
|
|
23
|
+
i18n: AdminI18n;
|
|
24
|
+
/** Deterministic JSON from field.nested.rowLabelComponent.config. */
|
|
25
|
+
config: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One exact plugin-owned component available to array and blocks headings. */
|
|
29
|
+
export interface RowLabelPlugin {
|
|
30
|
+
key: string;
|
|
31
|
+
componentKey: string;
|
|
32
|
+
component: Component<RowLabelComponentProps>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function defineRowLabelPlugin<const Plugin extends RowLabelPlugin>(plugin: Plugin): Plugin {
|
|
36
|
+
return plugin;
|
|
37
|
+
}
|