@gogitcms/editor 0.23.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/app/index.html +38 -0
- package/app/src/App.tsx +2665 -0
- package/app/src/apollo.ts +119 -0
- package/app/src/auth.ts +248 -0
- package/app/src/config.ts +88 -0
- package/app/src/main.tsx +18 -0
- package/app/src/media.ts +151 -0
- package/app/src/navigation.tsx +321 -0
- package/app/src/plugins.ts +11 -0
- package/app/src/previewHost.tsx +263 -0
- package/app/src/previewToken.ts +68 -0
- package/app/src/queries.ts +591 -0
- package/app/src/virtual-cms-plugins.d.ts +6 -0
- package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
- package/app/vendor/analytics/src/config.ts +94 -0
- package/app/vendor/analytics/src/events.ts +56 -0
- package/app/vendor/analytics/src/index.ts +23 -0
- package/app/vendor/analytics/src/provider.tsx +196 -0
- package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
- package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
- package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
- package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
- package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
- package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
- package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
- package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
- package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
- package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
- package/app/vendor/design-system/src/components/Button.tsx +131 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
- package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
- package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
- package/app/vendor/design-system/src/components/Icon.tsx +28 -0
- package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
- package/app/vendor/design-system/src/components/Input.tsx +106 -0
- package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
- package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
- package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
- package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
- package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
- package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
- package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
- package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
- package/app/vendor/design-system/src/components/Segment.tsx +87 -0
- package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
- package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
- package/app/vendor/design-system/src/components/Text.tsx +85 -0
- package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
- package/app/vendor/design-system/src/components/layout.tsx +284 -0
- package/app/vendor/design-system/src/components/primitives.tsx +143 -0
- package/app/vendor/design-system/src/components/reorder.ts +40 -0
- package/app/vendor/design-system/src/fieldComponents.ts +63 -0
- package/app/vendor/design-system/src/icons.ts +102 -0
- package/app/vendor/design-system/src/index.ts +198 -0
- package/app/vendor/design-system/src/media.ts +229 -0
- package/app/vendor/design-system/src/theme.ts +116 -0
- package/app/vendor/design-system/src/web/Button.tsx +110 -0
- package/app/vendor/design-system/src/web/Icon.tsx +52 -0
- package/app/vendor/design-system/src/web/Input.tsx +39 -0
- package/app/vendor/design-system/src/web/index.ts +43 -0
- package/app/vendor/design-system/src/web/primitives.tsx +119 -0
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
- package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
- package/app/vendor/markdown-editor/src/field.tsx +67 -0
- package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
- package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
- package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
- package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
- package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
- package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
- package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
- package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
- package/app/vendor/markdown-editor/src/icons.tsx +120 -0
- package/app/vendor/markdown-editor/src/index.ts +23 -0
- package/app/vendor/markdown-editor/src/theme.ts +249 -0
- package/app/vendor/markdown-editor/src/types.ts +58 -0
- package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
- package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
- package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
- package/app/vendor/plugin-sdk/src/index.ts +33 -0
- package/app/vendor/plugin-sdk/src/loader.ts +45 -0
- package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
- package/app/vendor/plugin-sdk/src/react.tsx +155 -0
- package/app/vendor/plugin-sdk/src/registry.ts +337 -0
- package/app/vendor/plugin-sdk/src/types.ts +230 -0
- package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
- package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
- package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
- package/app/vendor/realtime/src/collab.ts +308 -0
- package/app/vendor/realtime/src/hooks.ts +57 -0
- package/app/vendor/realtime/src/index.ts +12 -0
- package/app/vendor/realtime/src/pure.ts +43 -0
- package/app/vite.config.mjs +59 -0
- package/bin/gogitcms-editor.mjs +168 -0
- package/npm-shrinkwrap.json +5696 -0
- package/package.json +74 -0
- package/src/commands/build.mjs +100 -0
- package/src/commands/dev.mjs +186 -0
- package/src/commands/init.mjs +144 -0
- package/src/commands/login.mjs +116 -0
- package/src/commands/status.mjs +68 -0
- package/src/lib/api.mjs +122 -0
- package/src/lib/appRoot.mjs +51 -0
- package/src/lib/baked.json +3 -0
- package/src/lib/config.mjs +108 -0
- package/src/lib/credentials.mjs +91 -0
- package/src/lib/defaults.mjs +26 -0
- package/src/lib/graphql.mjs +28 -0
- package/src/lib/localServer.mjs +206 -0
- package/src/lib/open.mjs +23 -0
- package/src/lib/plugins.mjs +164 -0
- package/src/lib/tokenBroker.mjs +85 -0
- package/src/tui/prompts.mjs +92 -0
- package/src/tui/screen.mjs +282 -0
- package/src/tui/theme.mjs +40 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// loadPlugins is the shared async loader both hosts use: the SPA feeds it the
|
|
2
|
+
// virtual:cms-plugins entries the build generated; the desktop feeds it
|
|
3
|
+
// entries whose load() imports from the plugin file server. Failures are
|
|
4
|
+
// per-plugin — one broken plugin never takes down the rest of the app.
|
|
5
|
+
|
|
6
|
+
import type { PluginLoadEntry, PluginSetup } from "./types";
|
|
7
|
+
import type { PluginRegistry } from "./registry";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load every entry in parallel and run each plugin's setup function against a
|
|
11
|
+
* registry-scoped API. Resolves once every plugin is ready or failed; hosts
|
|
12
|
+
* normally fire-and-forget and re-render from registry subscriptions instead
|
|
13
|
+
* of awaiting.
|
|
14
|
+
*/
|
|
15
|
+
export async function loadPlugins(entries: PluginLoadEntry[], registry: PluginRegistry): Promise<void> {
|
|
16
|
+
const seen = new Set<string>();
|
|
17
|
+
const jobs: Promise<void>[] = [];
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
if (!entry.id || seen.has(entry.id)) {
|
|
20
|
+
console.error(`[plugins] duplicate or empty plugin id ${JSON.stringify(entry.id)} skipped`);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
seen.add(entry.id);
|
|
24
|
+
registry.markPending(entry.id);
|
|
25
|
+
jobs.push(loadOne(entry, registry));
|
|
26
|
+
}
|
|
27
|
+
await Promise.all(jobs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadOne(entry: PluginLoadEntry, registry: PluginRegistry): Promise<void> {
|
|
31
|
+
try {
|
|
32
|
+
const mod = (await entry.load()) as { default?: unknown } | null;
|
|
33
|
+
const setup = mod?.default;
|
|
34
|
+
if (typeof setup !== "function") {
|
|
35
|
+
throw new Error(`plugin "${entry.id}" has no default-exported setup function`);
|
|
36
|
+
}
|
|
37
|
+
await (setup as PluginSetup)(registry.api(entry.id), entry.options);
|
|
38
|
+
registry.markReady(entry.id);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// The failure is surfaced in the registry (settings UI, fallback panels);
|
|
41
|
+
// log it too so a broken plugin is loud during development.
|
|
42
|
+
console.error(`[plugins] "${entry.id}" failed to load:`, err);
|
|
43
|
+
registry.markFailed(entry.id, err);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Sidebar nav keys for plugin links. A plugin id is a package name and may
|
|
2
|
+
// contain "/" (@acme/analytics), so the id is percent-encoded into the key and
|
|
3
|
+
// the first literal "/" separates it from the route path.
|
|
4
|
+
|
|
5
|
+
const PREFIX = "plugin:";
|
|
6
|
+
|
|
7
|
+
export function pluginNavKey(pluginId: string, path: string): string {
|
|
8
|
+
return PREFIX + encodeURIComponent(pluginId) + (path ? "/" + path : "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parsePluginNavKey(key: string): { id: string; path: string } | null {
|
|
12
|
+
if (!key.startsWith(PREFIX)) return null;
|
|
13
|
+
const rest = key.slice(PREFIX.length);
|
|
14
|
+
const slash = rest.indexOf("/");
|
|
15
|
+
if (slash === -1) return { id: decodeURIComponent(rest), path: "" };
|
|
16
|
+
return { id: decodeURIComponent(rest.slice(0, slash)), path: rest.slice(slash + 1) };
|
|
17
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// React-facing helpers for the hosts: registry subscription, memoized lazy
|
|
2
|
+
// resolution of plugin component loaders, and the renderField chain that puts
|
|
3
|
+
// plugin field components into the editor.
|
|
4
|
+
|
|
5
|
+
import React, { Suspense, useSyncExternalStore, type ComponentType, type LazyExoticComponent } from "react";
|
|
6
|
+
import { Text, type RenderField } from "@gogitcms/design-system";
|
|
7
|
+
import { PLUGIN_COMPONENT_PREFIX, PluginRegistry } from "./registry";
|
|
8
|
+
import type { PluginComponentLoader, PluginScreenProps, PluginTabContext } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Re-render the calling component whenever the registry changes (a plugin
|
|
12
|
+
* finished loading, a registration landed). Hosts call this once near the
|
|
13
|
+
* root, which is what re-evaluates every sync decision made from registry
|
|
14
|
+
* state — including createPluginRenderField's fallbacks below.
|
|
15
|
+
*/
|
|
16
|
+
export function usePluginRegistry(registry: PluginRegistry): number {
|
|
17
|
+
return useSyncExternalStore(registry.subscribe, registry.getVersion, registry.getVersion);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// One React.lazy per loader function, so repeated renders reuse the same
|
|
21
|
+
// component identity (and the same in-flight import) instead of remounting.
|
|
22
|
+
const lazyCache = new WeakMap<PluginComponentLoader<any>, LazyExoticComponent<ComponentType<any>>>();
|
|
23
|
+
|
|
24
|
+
export function getLazyComponent<P>(loader: PluginComponentLoader<P>): ComponentType<P> {
|
|
25
|
+
let lazy = lazyCache.get(loader);
|
|
26
|
+
if (!lazy) {
|
|
27
|
+
lazy = React.lazy(loader as PluginComponentLoader<any>);
|
|
28
|
+
lazyCache.set(loader, lazy);
|
|
29
|
+
}
|
|
30
|
+
return lazy as unknown as ComponentType<P>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A resolution problem is worth exactly one console line, not one per render.
|
|
34
|
+
const warned = new Set<string>();
|
|
35
|
+
function warnOnce(key: string, message: string) {
|
|
36
|
+
if (warned.has(key)) return;
|
|
37
|
+
warned.add(key);
|
|
38
|
+
console.warn(message);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Renders a plugin's screen for the current plugins/{id}/{path} route. Owns
|
|
43
|
+
* the degraded states so a deep link into a missing/broken plugin lands on a
|
|
44
|
+
* polite panel instead of a crash:
|
|
45
|
+
* - plugin still loading → nothing (the host shell is already on screen)
|
|
46
|
+
* - plugin failed / unknown / no matching route → explanatory text
|
|
47
|
+
* - matched → the route's lazy component with PluginScreenProps
|
|
48
|
+
*/
|
|
49
|
+
export function PluginScreenHost({
|
|
50
|
+
registry,
|
|
51
|
+
pluginId,
|
|
52
|
+
path,
|
|
53
|
+
workspaceId,
|
|
54
|
+
repositoryId,
|
|
55
|
+
branchId,
|
|
56
|
+
gitRef,
|
|
57
|
+
apiUrl,
|
|
58
|
+
navigate,
|
|
59
|
+
params,
|
|
60
|
+
tab,
|
|
61
|
+
}: {
|
|
62
|
+
registry: PluginRegistry;
|
|
63
|
+
pluginId: string;
|
|
64
|
+
path: string;
|
|
65
|
+
workspaceId: string;
|
|
66
|
+
repositoryId: string;
|
|
67
|
+
branchId?: string;
|
|
68
|
+
/** Git ref name of the branch. Named `gitRef` because `ref` is reserved on
|
|
69
|
+
* a React element's props. */
|
|
70
|
+
gitRef?: string;
|
|
71
|
+
apiUrl: string;
|
|
72
|
+
navigate: (to: string) => void;
|
|
73
|
+
/** Extra params merged over the route's matched `:segment` params. */
|
|
74
|
+
params?: Record<string, string>;
|
|
75
|
+
/** Present when this route is being rendered as a column tab, not a screen. */
|
|
76
|
+
tab?: PluginTabContext;
|
|
77
|
+
}) {
|
|
78
|
+
usePluginRegistry(registry);
|
|
79
|
+
const status = registry.status(pluginId);
|
|
80
|
+
if (status === "pending") return null;
|
|
81
|
+
const match = status === "ready" ? registry.matchRoute(pluginId, path) : null;
|
|
82
|
+
if (!match) {
|
|
83
|
+
const what = tab ? "tab" : "screen";
|
|
84
|
+
return (
|
|
85
|
+
<Text variant="body" color="tertiary" style={{ padding: 24 }} testID="plugin-screen-unavailable">
|
|
86
|
+
{status === "ready"
|
|
87
|
+
? `The plugin "${pluginId}" has no ${what} at "${path || "/"}".`
|
|
88
|
+
: `This ${what} comes from the plugin "${pluginId}", which isn't available.`}
|
|
89
|
+
</Text>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const Lazy = getLazyComponent(match.record.component);
|
|
93
|
+
const props: PluginScreenProps = {
|
|
94
|
+
workspaceId,
|
|
95
|
+
repositoryId,
|
|
96
|
+
branchId,
|
|
97
|
+
ref: gitRef,
|
|
98
|
+
params: { ...match.params, ...params },
|
|
99
|
+
navigate,
|
|
100
|
+
apiUrl,
|
|
101
|
+
tab,
|
|
102
|
+
};
|
|
103
|
+
return (
|
|
104
|
+
<Suspense fallback={<React.Fragment />}>
|
|
105
|
+
<Lazy {...props} />
|
|
106
|
+
</Suspense>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build the host's renderField: fields whose `display.component` is
|
|
112
|
+
* `plugin:<name>` render the registered plugin component; everything else is
|
|
113
|
+
* delegated to `next` (the markdown render field) and, beneath that, the
|
|
114
|
+
* design-system's built-in controls (by returning undefined).
|
|
115
|
+
*
|
|
116
|
+
* Fallbacks keep documents editable when a plugin is absent:
|
|
117
|
+
* - unknown component after all plugins settled → default control for the type
|
|
118
|
+
* - component registered but wrong field type → default control for the type
|
|
119
|
+
* - plugins still loading → empty placeholder (no
|
|
120
|
+
* default-control flash that would swap out from under the user's cursor)
|
|
121
|
+
*/
|
|
122
|
+
export function createPluginRenderField(registry: PluginRegistry, next?: RenderField): RenderField {
|
|
123
|
+
return (args) => {
|
|
124
|
+
const component = args.field.component;
|
|
125
|
+
if (!component || !component.startsWith(PLUGIN_COMPONENT_PREFIX)) {
|
|
126
|
+
return next ? next(args) : undefined;
|
|
127
|
+
}
|
|
128
|
+
const record = registry.fieldComponent(component);
|
|
129
|
+
if (!record) {
|
|
130
|
+
if (!registry.settled()) return <React.Fragment />;
|
|
131
|
+
warnOnce(component, `[plugins] no plugin provides field component "${component}"; rendering the default control`);
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
if (!record.types.includes(args.field.type as any)) {
|
|
135
|
+
warnOnce(
|
|
136
|
+
`${component}:${args.field.type}`,
|
|
137
|
+
`[plugins] field component "${component}" (plugin ${record.pluginId}) does not accept type "${args.field.type}"; rendering the default control`
|
|
138
|
+
);
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
const Lazy = getLazyComponent(record.component);
|
|
142
|
+
return (
|
|
143
|
+
<Suspense fallback={<React.Fragment />}>
|
|
144
|
+
<Lazy
|
|
145
|
+
field={args.field}
|
|
146
|
+
value={args.value}
|
|
147
|
+
onChange={args.onChange}
|
|
148
|
+
readOnly={args.readOnly}
|
|
149
|
+
collab={args.collab}
|
|
150
|
+
path={args.path}
|
|
151
|
+
/>
|
|
152
|
+
</Suspense>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// PluginRegistry collects every registration plugins make and is what the
|
|
2
|
+
// hosts read contributions from. Mutations happen through per-plugin scoped
|
|
3
|
+
// API instances (api()), so each record carries the plugin id that made it —
|
|
4
|
+
// contributions are attributable and a plugin can never overwrite another's.
|
|
5
|
+
//
|
|
6
|
+
// The registry is host-agnostic and React-free; subscribe()/getVersion() fit
|
|
7
|
+
// React's useSyncExternalStore so hosts re-render as plugins finish loading.
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
CmsPluginApi,
|
|
11
|
+
PluginDocument,
|
|
12
|
+
PluginDocumentAction,
|
|
13
|
+
PluginFieldComponent,
|
|
14
|
+
PluginRoute,
|
|
15
|
+
PluginSidebarLink,
|
|
16
|
+
PluginStatus,
|
|
17
|
+
PluginUserMenuLink,
|
|
18
|
+
} from "./types";
|
|
19
|
+
|
|
20
|
+
// The schema prefix under which plugin field components are addressed
|
|
21
|
+
// (display.component: "plugin:<name>"), whitelisted by the schema parser.
|
|
22
|
+
export const PLUGIN_COMPONENT_PREFIX = "plugin:";
|
|
23
|
+
|
|
24
|
+
// Bare field-component names: something a human can type after `plugin:` in a
|
|
25
|
+
// hand-written schema without quoting surprises.
|
|
26
|
+
const FIELD_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
27
|
+
|
|
28
|
+
export type PluginRouteRecord = PluginRoute & { pluginId: string };
|
|
29
|
+
export type PluginSidebarLinkRecord = Required<Pick<PluginSidebarLink, "section">> &
|
|
30
|
+
PluginSidebarLink & { pluginId: string };
|
|
31
|
+
export type PluginUserMenuLinkRecord = PluginUserMenuLink & { pluginId: string };
|
|
32
|
+
export type PluginFieldComponentRecord = PluginFieldComponent & {
|
|
33
|
+
pluginId: string;
|
|
34
|
+
/** The schema-facing key: `plugin:<name>`. */
|
|
35
|
+
key: string;
|
|
36
|
+
};
|
|
37
|
+
export type PluginDocumentActionRecord = PluginDocumentAction & {
|
|
38
|
+
pluginId: string;
|
|
39
|
+
/** Host-facing key, unique across plugins: `<pluginId>:<action id>`. */
|
|
40
|
+
key: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type PluginInfo = { id: string; status: PluginStatus; error?: string };
|
|
44
|
+
|
|
45
|
+
export type RouteMatch = { record: PluginRouteRecord; params: Record<string, string> };
|
|
46
|
+
|
|
47
|
+
// normalizePath trims slashes so "dashboard", "/dashboard" and "dashboard/"
|
|
48
|
+
// all name the same route.
|
|
49
|
+
const normalizePath = (p: string) => p.split("/").filter(Boolean).join("/");
|
|
50
|
+
|
|
51
|
+
// A misbehaving plugin is worth exactly one console line, not one per render.
|
|
52
|
+
// documentActionsFor runs on every toolbar render, so an `available` that
|
|
53
|
+
// throws would otherwise flood the console.
|
|
54
|
+
const warned = new Set<string>();
|
|
55
|
+
function warnOnce(key: string, message: string) {
|
|
56
|
+
if (warned.has(key)) return;
|
|
57
|
+
warned.add(key);
|
|
58
|
+
console.warn(message);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Capabilities only the host can provide, injected at construction.
|
|
63
|
+
*
|
|
64
|
+
* Everything else on CmsPluginApi is bookkeeping the registry does itself. These
|
|
65
|
+
* are the calls that need the host's own session — so they are supplied rather
|
|
66
|
+
* than reached for, and a host that does not implement one gets a clear error
|
|
67
|
+
* instead of a plugin quietly reading a global.
|
|
68
|
+
*/
|
|
69
|
+
export type PluginHost = {
|
|
70
|
+
/**
|
|
71
|
+
* Mint a short-lived, repository-scoped preview token (see the CMS's
|
|
72
|
+
* POST /git/repositories/{id}/preview-token).
|
|
73
|
+
*
|
|
74
|
+
* Deliberately not "give me the user's access token": the credential a plugin
|
|
75
|
+
* holds should be the narrowest one that does its job, and this one is refused
|
|
76
|
+
* by every other CMS endpoint.
|
|
77
|
+
*/
|
|
78
|
+
getPreviewToken?: (repositoryId: string) => Promise<string>;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export class PluginRegistry {
|
|
82
|
+
private host: PluginHost;
|
|
83
|
+
private routes: PluginRouteRecord[] = [];
|
|
84
|
+
private sidebarLinks: PluginSidebarLinkRecord[] = [];
|
|
85
|
+
private userMenuLinks: PluginUserMenuLinkRecord[] = [];
|
|
86
|
+
private fieldComponents = new Map<string, PluginFieldComponentRecord>();
|
|
87
|
+
private documentActions: PluginDocumentActionRecord[] = [];
|
|
88
|
+
private statuses = new Map<string, { status: PluginStatus; error?: string }>();
|
|
89
|
+
private listeners = new Set<() => void>();
|
|
90
|
+
private version = 0;
|
|
91
|
+
|
|
92
|
+
constructor(host: PluginHost = {}) {
|
|
93
|
+
this.host = host;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- change notification (useSyncExternalStore-shaped) --------------------
|
|
97
|
+
|
|
98
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
99
|
+
this.listeners.add(listener);
|
|
100
|
+
return () => this.listeners.delete(listener);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Monotonic version, bumped on every mutation — the store snapshot. */
|
|
104
|
+
getVersion = (): number => this.version;
|
|
105
|
+
|
|
106
|
+
private bump() {
|
|
107
|
+
this.version++;
|
|
108
|
+
for (const l of this.listeners) l();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- load state -----------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
markPending(pluginId: string) {
|
|
114
|
+
this.statuses.set(pluginId, { status: "pending" });
|
|
115
|
+
this.bump();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
markReady(pluginId: string) {
|
|
119
|
+
this.statuses.set(pluginId, { status: "ready" });
|
|
120
|
+
this.bump();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
markFailed(pluginId: string, error: unknown) {
|
|
124
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
125
|
+
this.statuses.set(pluginId, { status: "failed", error: message });
|
|
126
|
+
this.bump();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
status(pluginId: string): PluginStatus | undefined {
|
|
130
|
+
return this.statuses.get(pluginId)?.status;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
plugins(): PluginInfo[] {
|
|
134
|
+
return [...this.statuses.entries()].map(([id, s]) => ({ id, status: s.status, error: s.error }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** True once no plugin is still pending (vacuously true with none loaded). */
|
|
138
|
+
settled(): boolean {
|
|
139
|
+
return [...this.statuses.values()].every((s) => s.status !== "pending");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- scoped registration API ----------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** The per-plugin CmsPluginApi handed to a plugin's setup function. */
|
|
145
|
+
api(pluginId: string): CmsPluginApi {
|
|
146
|
+
// Registrations funnel through one guard so a bad call from a plugin
|
|
147
|
+
// reports the offender and skips the record instead of throwing into the
|
|
148
|
+
// loader (which would fail the whole plugin over one bad link).
|
|
149
|
+
const registry = this;
|
|
150
|
+
return {
|
|
151
|
+
id: pluginId,
|
|
152
|
+
async getPreviewToken(repositoryId: string): Promise<string> {
|
|
153
|
+
const mint = registry.host.getPreviewToken;
|
|
154
|
+
if (!mint) {
|
|
155
|
+
// A plugin asking for one in a host that cannot mint them is a
|
|
156
|
+
// configuration problem, and the plugin's own error message ("the
|
|
157
|
+
// preview service refused the session") would hide it.
|
|
158
|
+
throw new Error(
|
|
159
|
+
`[plugin ${pluginId}] getPreviewToken: this host does not provide preview tokens`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (!repositoryId) {
|
|
163
|
+
throw new Error(`[plugin ${pluginId}] getPreviewToken: repositoryId is required`);
|
|
164
|
+
}
|
|
165
|
+
return mint(repositoryId);
|
|
166
|
+
},
|
|
167
|
+
addRoute(route: PluginRoute) {
|
|
168
|
+
const path = normalizePath(route.path ?? "");
|
|
169
|
+
if (typeof route.component !== "function") {
|
|
170
|
+
console.error(`[plugin ${pluginId}] addRoute("${route.path}"): component must be a loader function`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (registry.routes.some((r) => r.pluginId === pluginId && r.path === path)) {
|
|
174
|
+
console.error(`[plugin ${pluginId}] addRoute: duplicate route path "${path}" ignored`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
registry.routes.push({ ...route, path, pluginId });
|
|
178
|
+
registry.bump();
|
|
179
|
+
},
|
|
180
|
+
addSidebarLink(link: PluginSidebarLink) {
|
|
181
|
+
if (!link.label || !link.to) {
|
|
182
|
+
console.error(`[plugin ${pluginId}] addSidebarLink: label and to are required`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
registry.sidebarLinks.push({
|
|
186
|
+
...link,
|
|
187
|
+
to: normalizePath(link.to),
|
|
188
|
+
section: link.section || "Plugins",
|
|
189
|
+
pluginId,
|
|
190
|
+
});
|
|
191
|
+
registry.bump();
|
|
192
|
+
},
|
|
193
|
+
addUserMenuLink(link: PluginUserMenuLink) {
|
|
194
|
+
const forms = [link.to, link.onSelect].filter((x) => x != null).length;
|
|
195
|
+
if (!link.label || forms !== 1) {
|
|
196
|
+
console.error(`[plugin ${pluginId}] addUserMenuLink: label plus exactly one of to/onSelect required`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
registry.userMenuLinks.push({
|
|
200
|
+
...link,
|
|
201
|
+
to: link.to != null ? normalizePath(link.to) : undefined,
|
|
202
|
+
pluginId,
|
|
203
|
+
});
|
|
204
|
+
registry.bump();
|
|
205
|
+
},
|
|
206
|
+
registerFieldComponent(reg: PluginFieldComponent) {
|
|
207
|
+
if (!FIELD_NAME_RE.test(reg.name ?? "")) {
|
|
208
|
+
console.error(
|
|
209
|
+
`[plugin ${pluginId}] registerFieldComponent: invalid name ${JSON.stringify(reg.name)} (want ${FIELD_NAME_RE})`
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (typeof reg.component !== "function") {
|
|
214
|
+
console.error(`[plugin ${pluginId}] registerFieldComponent("${reg.name}"): component must be a loader function`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const key = PLUGIN_COMPONENT_PREFIX + reg.name;
|
|
218
|
+
const existing = registry.fieldComponents.get(key);
|
|
219
|
+
if (existing) {
|
|
220
|
+
// One global namespace: first registration wins so an installed,
|
|
221
|
+
// working schema doesn't change meaning when a second plugin lands.
|
|
222
|
+
console.error(
|
|
223
|
+
`[plugin ${pluginId}] registerFieldComponent: "${key}" already registered by plugin ${existing.pluginId}; ignored`
|
|
224
|
+
);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
registry.fieldComponents.set(key, { ...reg, types: [...reg.types], pluginId, key });
|
|
228
|
+
registry.bump();
|
|
229
|
+
},
|
|
230
|
+
addDocumentAction(action: PluginDocumentAction) {
|
|
231
|
+
if (!FIELD_NAME_RE.test(action.id ?? "")) {
|
|
232
|
+
console.error(
|
|
233
|
+
`[plugin ${pluginId}] addDocumentAction: invalid id ${JSON.stringify(action.id)} (want ${FIELD_NAME_RE})`
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!action.label) {
|
|
238
|
+
console.error(`[plugin ${pluginId}] addDocumentAction("${action.id}"): label is required`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (typeof action.onSelect !== "function") {
|
|
242
|
+
console.error(`[plugin ${pluginId}] addDocumentAction("${action.id}"): onSelect must be a function`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const key = `${pluginId}:${action.id}`;
|
|
246
|
+
if (registry.documentActions.some((a) => a.key === key)) {
|
|
247
|
+
console.error(`[plugin ${pluginId}] addDocumentAction: duplicate id "${action.id}" ignored`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
registry.documentActions.push({
|
|
251
|
+
...action,
|
|
252
|
+
collections: action.collections ? [...action.collections] : undefined,
|
|
253
|
+
pluginId,
|
|
254
|
+
key,
|
|
255
|
+
});
|
|
256
|
+
registry.bump();
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- queries the hosts render from ----------------------------------------
|
|
262
|
+
|
|
263
|
+
/** Field component for a schema `display.component` key ("plugin:<name>"). */
|
|
264
|
+
fieldComponent(key: string): PluginFieldComponentRecord | undefined {
|
|
265
|
+
return this.fieldComponents.get(key);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
sidebar(): PluginSidebarLinkRecord[] {
|
|
269
|
+
return [...this.sidebarLinks];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Toolbar actions that apply to one document, in registration order.
|
|
274
|
+
*
|
|
275
|
+
* Both filters are applied here rather than in each host: `collections`
|
|
276
|
+
* (cheap, declarative) and then the action's own `available` predicate. An
|
|
277
|
+
* `available` that throws is treated as "not available" and reported once —
|
|
278
|
+
* a plugin bug should cost its own button, not the whole toolbar.
|
|
279
|
+
*/
|
|
280
|
+
documentActionsFor(doc: PluginDocument): PluginDocumentActionRecord[] {
|
|
281
|
+
return this.documentActions.filter((a) => {
|
|
282
|
+
if (a.collections && !a.collections.includes(doc.collection)) return false;
|
|
283
|
+
if (!a.available) return true;
|
|
284
|
+
try {
|
|
285
|
+
return a.available(doc);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
warnOnce(
|
|
288
|
+
`available:${a.key}`,
|
|
289
|
+
`[plugin ${a.pluginId}] document action "${a.id}" available() threw; hiding the button: ${String(err)}`
|
|
290
|
+
);
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
userMenu(): PluginUserMenuLinkRecord[] {
|
|
297
|
+
return [...this.userMenuLinks];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
routesFor(pluginId: string): PluginRouteRecord[] {
|
|
301
|
+
return this.routes.filter((r) => r.pluginId === pluginId);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Match a subpath against a plugin's registered routes. `:segment` patterns
|
|
306
|
+
* capture into params; among routes that match, the one with the most static
|
|
307
|
+
* segments wins (so "reports/summary" beats "reports/:kind").
|
|
308
|
+
*/
|
|
309
|
+
matchRoute(pluginId: string, subpath: string): RouteMatch | null {
|
|
310
|
+
const segs = normalizePath(subpath).split("/").filter(Boolean);
|
|
311
|
+
let best: RouteMatch | null = null;
|
|
312
|
+
let bestStatic = -1;
|
|
313
|
+
for (const record of this.routes) {
|
|
314
|
+
if (record.pluginId !== pluginId) continue;
|
|
315
|
+
const patSegs = record.path.split("/").filter(Boolean);
|
|
316
|
+
if (patSegs.length !== segs.length) continue;
|
|
317
|
+
const params: Record<string, string> = {};
|
|
318
|
+
let staticCount = 0;
|
|
319
|
+
let ok = true;
|
|
320
|
+
for (let i = 0; i < patSegs.length; i++) {
|
|
321
|
+
if (patSegs[i].startsWith(":")) {
|
|
322
|
+
params[patSegs[i].slice(1)] = segs[i];
|
|
323
|
+
} else if (patSegs[i] === segs[i]) {
|
|
324
|
+
staticCount++;
|
|
325
|
+
} else {
|
|
326
|
+
ok = false;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (ok && staticCount > bestStatic) {
|
|
331
|
+
best = { record, params };
|
|
332
|
+
bestStatic = staticCount;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return best;
|
|
336
|
+
}
|
|
337
|
+
}
|