@pramen/cms-editor 0.0.63 → 0.0.65
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/README.md +52 -1
- package/dist/editor.js +117 -117
- package/dist/panel-jsx-dev-runtime.js +8 -0
- package/dist/panel-jsx-runtime.js +8 -0
- package/dist/panel-react-dom.js +8 -0
- package/dist/panel-react.js +8 -0
- package/package.json +6 -2
- package/src/app-context.tsx +9 -3
- package/src/blockkit.tsx +109 -32
- package/src/components.tsx +21 -4
- package/src/main.tsx +18 -0
- package/src/panel-boundary.tsx +67 -0
- package/src/panel-globals.ts +103 -0
- package/src/panel-runtime.ts +105 -0
- package/src/panels.ts +420 -0
- package/src/routes/_layout.tsx +5 -7
- package/src/routes/admin-page.tsx +77 -3
- package/src/theme.ts +80 -0
- package/src/types.ts +41 -7
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// What the editor bundle publishes on `globalThis` for panel bundles to build against.
|
|
2
|
+
//
|
|
3
|
+
// A panel is a separate bundle that renders into THIS bundle's React tree. Two copies of
|
|
4
|
+
// React in one page share no hook dispatcher, so a second copy does not degrade — the first
|
|
5
|
+
// `useState` in a panel throws "invalid hook call" and the screen is a blank error. The
|
|
6
|
+
// panel bundle therefore cannot contain React; it must import the one already here.
|
|
7
|
+
//
|
|
8
|
+
// So the editor publishes its React (plus react-dom and both JSX runtimes) on a global, and
|
|
9
|
+
// the shell's import map points those bare specifiers at four tiny shim modules that read it
|
|
10
|
+
// back out — see `panel-globals.ts`, which GENERATES the shims from the very namespaces
|
|
11
|
+
// published here, so the names they re-export cannot drift from the React actually loaded.
|
|
12
|
+
//
|
|
13
|
+
// The consequence, and the whole point: a panel is written as ordinary React, with ordinary
|
|
14
|
+
// `import { useState } from "react"`, built with react/react-dom marked external. Nothing
|
|
15
|
+
// about the source says it is a panel except the one `registerPanel` call. That is what
|
|
16
|
+
// makes an existing standalone screen portable rather than rewritable.
|
|
17
|
+
//
|
|
18
|
+
// THE SURFACE IS FOUR NAMESPACES AND ONE FUNCTION, and it is meant to stay that size —
|
|
19
|
+
// every name here is a thing this package can never move again:
|
|
20
|
+
//
|
|
21
|
+
// - `react` — the shared copy. Non-negotiable; it is the reason this exists.
|
|
22
|
+
// - `reactDom` — shared for the same reason, one level down. A panel that bundled its own
|
|
23
|
+
// react-dom would be a SECOND RECONCILER driving one React, which is worse than a second
|
|
24
|
+
// React because it can appear to work. It is also where `createPortal` lives, and a
|
|
25
|
+
// dialog is the first thing a panel needs that Block Kit could not express.
|
|
26
|
+
// - `jsxRuntime` / `jsxDevRuntime` — the automatic JSX transform emits imports from
|
|
27
|
+
// `react/jsx-runtime`, or from `react/jsx-dev-runtime` when the panel is built
|
|
28
|
+
// unminified. A panel bundle does not choose this, its compiler does — and the dev half
|
|
29
|
+
// is the one that MUST be here: left out, a development build resolves that specifier
|
|
30
|
+
// from the consumer's own node_modules and quietly bundles a second React, which is the
|
|
31
|
+
// exact failure this whole mechanism exists to prevent, arriving on the one build where
|
|
32
|
+
// nobody is looking for it.
|
|
33
|
+
// - `registerPanel` — the registration itself, and the gate: it refuses a bundle whose
|
|
34
|
+
// stated contract is not the one this editor implements, naming the slug and the fix.
|
|
35
|
+
// See `PANEL_RUNTIME_CONTRACT` in `panels.ts` for what bumps that number, and the note
|
|
36
|
+
// below for why the number itself is not published here.
|
|
37
|
+
//
|
|
38
|
+
// Not published, and each for a reason: the `Api` class (a panel gets the narrow `PanelApi`
|
|
39
|
+
// through props — see `panels.ts`), the router (a panel owns its own screen, not the
|
|
40
|
+
// editor's routing table), the podoba component library (it is a dependency a panel can
|
|
41
|
+
// install itself, and freezing OUR version of it as a global API is a promise this package
|
|
42
|
+
// should not make), and the app context (it carries the CMS's own state, none of which is a
|
|
43
|
+
// project screen's business).
|
|
44
|
+
|
|
45
|
+
import * as react from "react";
|
|
46
|
+
import * as reactDom from "react-dom";
|
|
47
|
+
import * as jsxRuntime from "react/jsx-runtime";
|
|
48
|
+
import * as jsxDevRuntime from "react/jsx-dev-runtime";
|
|
49
|
+
import { registerPanel, type PanelRegistration } from "./panels";
|
|
50
|
+
|
|
51
|
+
/** Where the runtime is published. Namespaced away from `PRAMEN_CMS_EDITOR`, which is the
|
|
52
|
+
* SHELL's config: that object is written by the server and read by the editor, this one is
|
|
53
|
+
* written by the editor and read by panel bundles, and putting them together would invite a
|
|
54
|
+
* shell to think it may set part of this. */
|
|
55
|
+
export const PANEL_RUNTIME_GLOBAL = "PRAMEN_CMS_EDITOR_RUNTIME";
|
|
56
|
+
|
|
57
|
+
// THE CONTRACT NUMBER IS NOT ON THIS OBJECT, and that omission is the point of the check
|
|
58
|
+
// rather than a gap in it. A panel must state the contract it was BUILT against; publishing
|
|
59
|
+
// ours would put the answer key beside the question, since
|
|
60
|
+
// `PRAMEN_CMS_EDITOR_RUNTIME.contract` is shorter to write than the literal and would satisfy
|
|
61
|
+
// every editor forever — the check would then be this editor comparing its number to its
|
|
62
|
+
// number. The number a panel states is `PANEL_RUNTIME_CONTRACT` in `panels.ts`, taken from
|
|
63
|
+
// the docs or from a refusal message, which prints both sides. It was published here once,
|
|
64
|
+
// read by nothing, and that is exactly what a version guarantee looks like when it is only a
|
|
65
|
+
// field.
|
|
66
|
+
|
|
67
|
+
export interface PanelRuntime {
|
|
68
|
+
readonly react: typeof react;
|
|
69
|
+
readonly reactDom: typeof reactDom;
|
|
70
|
+
readonly jsxRuntime: typeof jsxRuntime;
|
|
71
|
+
readonly jsxDevRuntime: typeof jsxDevRuntime;
|
|
72
|
+
registerPanel(def: PanelRegistration): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The host object a panel runtime is published on. */
|
|
76
|
+
export interface PanelRuntimeHost {
|
|
77
|
+
[PANEL_RUNTIME_GLOBAL]?: PanelRuntime;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function panelRuntime(): PanelRuntime {
|
|
81
|
+
return {
|
|
82
|
+
react,
|
|
83
|
+
reactDom,
|
|
84
|
+
jsxRuntime,
|
|
85
|
+
jsxDevRuntime,
|
|
86
|
+
// Wrapped rather than passed by reference so the published function is this module's,
|
|
87
|
+
// not the registry's — the registry keeps a second parameter (its warning sink) that is
|
|
88
|
+
// a test seam and must not become part of the surface a panel bundle can reach.
|
|
89
|
+
registerPanel: (def) => registerPanel(def),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Publish the runtime. Called at the very top of `main.tsx`, before anything is imported
|
|
95
|
+
* dynamically and before the router mounts, so that by the time a panel bundle's first
|
|
96
|
+
* `import "react"` is evaluated the global is already there.
|
|
97
|
+
*
|
|
98
|
+
* Publishing is unconditional — it does not wait to see whether any panel is configured.
|
|
99
|
+
* The object is four references; making it conditional would mean a deployment that adds a
|
|
100
|
+
* panel later has a second thing to switch on, and a debugging session that starts with
|
|
101
|
+
* "is the runtime there?" would have two answers.
|
|
102
|
+
*/
|
|
103
|
+
export function publishPanelRuntime(host: PanelRuntimeHost): void {
|
|
104
|
+
host[PANEL_RUNTIME_GLOBAL] = panelRuntime();
|
|
105
|
+
}
|
package/src/panels.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// The panel registry — where a deployment's own React screens are registered, and what
|
|
2
|
+
// they are handed.
|
|
3
|
+
//
|
|
4
|
+
// A panel is a component in a SEPARATE bundle, built by the consuming project, that renders
|
|
5
|
+
// inside this editor's chrome at `/apps/:slug`. The entry itself is a server fact
|
|
6
|
+
// (`adminPanel()` in @pramen/cms): the server owns the slug, label, icon, position and the
|
|
7
|
+
// role filter, and the bundle owns only the component. So this module never decides whether
|
|
8
|
+
// a panel exists — it answers "is there a component for this slug yet, and if not, was one
|
|
9
|
+
// turned away", and nothing more.
|
|
10
|
+
//
|
|
11
|
+
// HOW A BUNDLE GETS LOADED, AND WHY THE EDITOR DOES THE LOADING
|
|
12
|
+
//
|
|
13
|
+
// The obvious shape is for the shell to emit a second `<script type="module">` beside the
|
|
14
|
+
// editor's. It does not work in either order. Placed FIRST, the panel bundle evaluates
|
|
15
|
+
// before the editor has published the React it must share, so its very first import fails.
|
|
16
|
+
// Placed SECOND, it races the editor's own first render, and a deep link to `/apps/:slug`
|
|
17
|
+
// would resolve before the component it needs exists.
|
|
18
|
+
//
|
|
19
|
+
// So the shell declares panel bundle URLs as configuration and the EDITOR imports them,
|
|
20
|
+
// after publishing the runtime and before mounting the router. Ordering stops being a
|
|
21
|
+
// property of script tags and becomes a line of code. It also makes failure containable:
|
|
22
|
+
// a bundle that 404s or throws costs its own panel, and the admin still boots.
|
|
23
|
+
//
|
|
24
|
+
// The loads are NOT awaited before the first paint. Everything else in the chrome already
|
|
25
|
+
// arrives a round trip late (`listAdminPages` is what puts the entry in the nav at all), and
|
|
26
|
+
// blocking `createRoot` on a third-party fetch means one hanging request is a blank admin.
|
|
27
|
+
// Registration therefore notifies subscribers and the panel route re-reads — which is also
|
|
28
|
+
// what makes "still loading" tellable from "the bundle never registered this slug".
|
|
29
|
+
//
|
|
30
|
+
// Deliberately free of React at RUNTIME (the component type below is erased), so every rule
|
|
31
|
+
// here can be exercised without a DOM — the same discipline `mount.ts` keeps.
|
|
32
|
+
|
|
33
|
+
import type { ComponentType } from "react";
|
|
34
|
+
import type { RpcInput } from "./types";
|
|
35
|
+
|
|
36
|
+
/** The authenticated transport a panel is handed.
|
|
37
|
+
*
|
|
38
|
+
* A NARROW view of the editor's `Api`, not the class itself. `Api` carries ~40 typed
|
|
39
|
+
* wrappers for CMS handlers — pages, media, menus, taxonomies — and none of that is a
|
|
40
|
+
* panel's business; handing the object over would make every one of those methods an API to
|
|
41
|
+
* keep. What a panel needs is the ability to call ITS OWN project handlers with the
|
|
42
|
+
* session's credentials attached, plus a way to turn a relative media path into a URL.
|
|
43
|
+
*
|
|
44
|
+
* The session is not exposed and cannot be: no token, no base URL, no tenant. A panel calls
|
|
45
|
+
* through this or it does not call at all, which keeps one answer to "what does an
|
|
46
|
+
* authenticated request from the admin look like".
|
|
47
|
+
*/
|
|
48
|
+
export interface PanelApi {
|
|
49
|
+
/** Call a CMS/project RPC handler as the signed-in user. Rejects with the server's error
|
|
50
|
+
* message on a non-`ok` envelope. */
|
|
51
|
+
call<T = unknown>(name: string, input?: RpcInput): Promise<T>;
|
|
52
|
+
/** Absolute URL for a relative media/serving path the backend returned. */
|
|
53
|
+
resolve(path: string): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a panel component is handed. Four things, and the case for each:
|
|
57
|
+
*
|
|
58
|
+
* - `api` — a panel with no authenticated transport is a static page.
|
|
59
|
+
* - `basePath` — the editor may be mounted under a prefix (`/__admin`), and a panel that
|
|
60
|
+
* builds its own hrefs has no other way to stay inside it. Without this the one thing a
|
|
61
|
+
* panel is FOR — being part of the admin — is the thing it gets wrong.
|
|
62
|
+
* - `theme` — the chrome's light/dark choice. Styling follows automatically (podoba tokens
|
|
63
|
+
* flip on `[data-theme]` at the document root), so this is for the decisions CSS cannot
|
|
64
|
+
* make: a chart's palette, a canvas, an embedded third-party widget.
|
|
65
|
+
* - `setError` — the editor has ONE error surface, the banner in the root layout. A panel
|
|
66
|
+
* that invented a second would put failures in a place the reader has not learned to
|
|
67
|
+
* look, and the two would style differently.
|
|
68
|
+
*
|
|
69
|
+
* What is deliberately NOT here is the identity (`me`). It is one `api.call("me")` away, and
|
|
70
|
+
* a panel that branches on the caller's roles to decide what to show is doing client-side
|
|
71
|
+
* authorization — the gate that counts is `roles` on `adminPanel()`, enforced server-side
|
|
72
|
+
* before the entry is even listed. Making the roles inconvenient to reach is the point.
|
|
73
|
+
*/
|
|
74
|
+
export interface PanelProps {
|
|
75
|
+
api: PanelApi;
|
|
76
|
+
basePath: string;
|
|
77
|
+
theme: "light" | "dark";
|
|
78
|
+
setError: (message: string) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- the runtime contract ----------------------------------------------------------------
|
|
82
|
+
//
|
|
83
|
+
// A panel bundle is COMPILED there and LINKED here. Its JSX calls and its hook usage are
|
|
84
|
+
// fixed at the consumer's build against whichever React they had installed; the React it
|
|
85
|
+
// actually runs on is the one this editor loaded, on a deployment they do not control, at
|
|
86
|
+
// whatever version that deployment is pinned to. Nothing in the loading path notices the
|
|
87
|
+
// difference — the import map resolves, the shims hand over a perfectly good React — so a
|
|
88
|
+
// mismatch surfaces as a missing export or a hook that behaves differently, somewhere inside
|
|
89
|
+
// a stranger's minified bundle, with no message anyone can act on.
|
|
90
|
+
//
|
|
91
|
+
// So a bundle has to SAY which contract it was built against, and be turned away if it is not
|
|
92
|
+
// this one.
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The panel runtime contract this editor implements.
|
|
96
|
+
*
|
|
97
|
+
* WHAT BUMPS IT. Three things, and this is the whole list:
|
|
98
|
+
*
|
|
99
|
+
* 1. **A React major in this package.** A panel's hooks and JSX are compiled against one
|
|
100
|
+
* React's rules and executed against the editor's; going 19 -> 20 here silently moves
|
|
101
|
+
* every panel bundle ever built onto a React it was not written for. This is the rule
|
|
102
|
+
* that gets missed, because a React bump is a line in `package.json` nowhere near this
|
|
103
|
+
* file — hence `PANEL_RUNTIME_REACT_MAJOR` below, which a test pins to the manifest.
|
|
104
|
+
* 2. **A change to what a panel is handed.** A key leaving `PanelProps`, or one changing
|
|
105
|
+
* meaning; the same for `PanelApi`. ADDING a key is not a bump — a panel that does not
|
|
106
|
+
* read it cannot notice.
|
|
107
|
+
* 3. **A change to the published runtime.** A name leaving `panel-runtime.ts`, or the
|
|
108
|
+
* global it is published on being renamed.
|
|
109
|
+
*
|
|
110
|
+
* What does NOT bump it is everything a panel cannot reach: the chrome, the routes, the CMS
|
|
111
|
+
* handlers, podoba's version, this package's own release number. A contract that moved for
|
|
112
|
+
* those would be a release version wearing a check's clothes — every panel refused on an
|
|
113
|
+
* unrelated upgrade, and the only available response would be to edit the literal without
|
|
114
|
+
* rebuilding anything. A check people learn to satisfy blindly is worse than no check.
|
|
115
|
+
*/
|
|
116
|
+
export const PANEL_RUNTIME_CONTRACT = 1;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The React major `PANEL_RUNTIME_CONTRACT` stands for.
|
|
120
|
+
*
|
|
121
|
+
* Written down so rule 1 above has somewhere to be enforced rather than only stated:
|
|
122
|
+
* `test/cms-editor-panels.test.ts` reads the `react` range out of this package's manifest and
|
|
123
|
+
* fails if it has moved past this number. Upgrading React therefore cannot silently leave the
|
|
124
|
+
* contract behind — the bump gets decided, in a red test, by whoever did the upgrade.
|
|
125
|
+
*/
|
|
126
|
+
export const PANEL_RUNTIME_REACT_MAJOR = 19;
|
|
127
|
+
|
|
128
|
+
/** What a bundle passes to `registerPanel`: a slug, the contract it was built against, and
|
|
129
|
+
* the component to draw for it.
|
|
130
|
+
*
|
|
131
|
+
* No label, no icon, no position — those travel on the server's `adminPanel()` entry. If the
|
|
132
|
+
* bundle declared them, a bundle that failed to load would take the nav entry with it (so
|
|
133
|
+
* the section would silently cease to exist rather than say what went wrong), and a bundle
|
|
134
|
+
* that loaded would be asserting its own placement with nothing to check it against.
|
|
135
|
+
*/
|
|
136
|
+
export interface PanelRegistration {
|
|
137
|
+
/** Must match the `adminPanel()` slug on the server. A slug the server did not register
|
|
138
|
+
* (or did not list for this caller) is simply never routed to. */
|
|
139
|
+
slug: string;
|
|
140
|
+
/**
|
|
141
|
+
* The {@link PANEL_RUNTIME_CONTRACT} this bundle was BUILT against — a literal in the
|
|
142
|
+
* bundle's own source, `contract: 1`.
|
|
143
|
+
*
|
|
144
|
+
* A LITERAL, and it can be nothing else. None of this editor is in a panel bundle at build
|
|
145
|
+
* time: react, react-dom and both JSX runtimes are external and resolve, at runtime, to the
|
|
146
|
+
* copies this editor published. So every value the bundle could look up is this editor's,
|
|
147
|
+
* and a check fed from `globalThis` would be comparing our number to our number and passing
|
|
148
|
+
* for every bundle ever built. The literal is the only fact about the BUILD that survives
|
|
149
|
+
* into the bundle, which is why the published runtime does not carry the number at all —
|
|
150
|
+
* see `panel-runtime.ts`.
|
|
151
|
+
*
|
|
152
|
+
* That makes it the same kind of claim as a `peerDependencies` range: the author asserts it
|
|
153
|
+
* and the host verifies the shape. It can be edited without rebuilding and no check can
|
|
154
|
+
* catch that — but it cannot be edited by ACCIDENT, and it is the one line the refusal
|
|
155
|
+
* message names, right after telling you to rebuild.
|
|
156
|
+
*
|
|
157
|
+
* Required, never defaulted. A default is a value for the bundles that say nothing, and
|
|
158
|
+
* those are exactly the set this exists for: everything built before the field existed
|
|
159
|
+
* would be waved through as current.
|
|
160
|
+
*/
|
|
161
|
+
contract: number;
|
|
162
|
+
render: ComponentType<PanelProps>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What the registry KEEPS: the slug and the component.
|
|
166
|
+
*
|
|
167
|
+
* A separate type from `PanelRegistration` because the contract is a fact about the CALL, not
|
|
168
|
+
* about the panel — once it has been checked there is nothing left to store, and storing it
|
|
169
|
+
* would leave a field a later reader could imagine still differs from ours. */
|
|
170
|
+
export interface RegisteredPanel {
|
|
171
|
+
slug: string;
|
|
172
|
+
render: ComponentType<PanelProps>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const panels = new Map<string, RegisteredPanel>();
|
|
176
|
+
const listeners = new Set<() => void>();
|
|
177
|
+
/** Bundle imports still in flight. What tells "loading" from "loaded and never registered
|
|
178
|
+
* this slug" — the two look identical from the registry alone, and the second is a
|
|
179
|
+
* diagnostic worth printing rather than a spinner to leave up forever. */
|
|
180
|
+
let inFlight = 0;
|
|
181
|
+
/** Bumped on every registration, every refusal and every change to `inFlight`, and read as
|
|
182
|
+
* the `useSyncExternalStore` snapshot. A number rather than the Map, because a snapshot has to
|
|
183
|
+
* be referentially stable between notifications and a Map that is mutated in place is not. */
|
|
184
|
+
let version = 0;
|
|
185
|
+
/** Why a slug's registration was REFUSED, keyed by slug, so the panel's own route can say it
|
|
186
|
+
* instead of the generic "nothing registered this slug". The bundle IS there and it DID run;
|
|
187
|
+
* a refusal that reached only the console leaves the screen telling the reader something that
|
|
188
|
+
* is true and useless, and points them at the one thing that is not the problem. */
|
|
189
|
+
const refusals = new Map<string, string>();
|
|
190
|
+
|
|
191
|
+
function notify(): void {
|
|
192
|
+
version += 1;
|
|
193
|
+
for (const listener of [...listeners]) listener();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Turn a registration away.
|
|
197
|
+
*
|
|
198
|
+
* One sentence, two readers: it is rendered by the panel's route for whoever is looking at
|
|
199
|
+
* the admin, and warned for whoever is looking at the build — so it is written to read as
|
|
200
|
+
* prose in both places, with the package prefix added only on the console side. `notify`,
|
|
201
|
+
* because a route already mounted on that slug is showing "Loading…" and has to be told the
|
|
202
|
+
* loading ended in a refusal. */
|
|
203
|
+
function refuse(slug: string, reason: string, warn: (msg: string) => void): void {
|
|
204
|
+
refusals.set(slug, reason);
|
|
205
|
+
warn(`pramen/cms-editor: ${reason}`);
|
|
206
|
+
notify();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Why a bundle's stated contract is unacceptable, or `undefined` if it is fine.
|
|
211
|
+
*
|
|
212
|
+
* BOTH directions are refusals, because they are different mistakes with different fixes. A
|
|
213
|
+
* bundle BEHIND this editor is stale — built against a React, or a `PanelProps`, that is not
|
|
214
|
+
* what it will be handed. A bundle AHEAD of it is not itself wrong; the deployment is, with a
|
|
215
|
+
* panel and an admin shipped out of step, and rendering it would link it against a runtime
|
|
216
|
+
* missing whatever the newer contract added. Accepting either would trade a message naming
|
|
217
|
+
* the slug for a crash inside someone else's bundle.
|
|
218
|
+
*
|
|
219
|
+
* A separate exported function, and not folded into `registerPanel`, so the sentence a
|
|
220
|
+
* consumer will actually be sent can be asserted on its own — the wording IS the feature
|
|
221
|
+
* here, and a check whose only test is "it refused" pins none of it.
|
|
222
|
+
*
|
|
223
|
+
* `implemented` is a parameter rather than a read of the constant for one reason, and it is
|
|
224
|
+
* not tidiness: at contract 1 there is no legal number BELOW ours, so the stale branch has
|
|
225
|
+
* nothing to be exercised with and would ship as a branch whose only evidence is that it
|
|
226
|
+
* typechecks. It is the same kind of seam as `warn`, and the registry never passes it.
|
|
227
|
+
*/
|
|
228
|
+
export function contractRefusal(slug: string, stated: unknown, implemented: number = PANEL_RUNTIME_CONTRACT): string | undefined {
|
|
229
|
+
// `stated` came out of a foreign bundle's object literal, so it is genuinely unknown: `"1"`
|
|
230
|
+
// is what a hand-edited build config produces, `NaN` is what `Number(undefined)` produces,
|
|
231
|
+
// and neither is a version. `Number.isInteger` alone would reject every one of them at
|
|
232
|
+
// RUNTIME — the `typeof` is what narrows the value for the comparisons below, and dropping
|
|
233
|
+
// it as redundant is a type error, not a passing simplification.
|
|
234
|
+
if (typeof stated !== "number" || !Number.isInteger(stated) || stated < 1) {
|
|
235
|
+
return `The '${slug}' panel did not state which panel runtime contract it was built against. Add \`contract: ${implemented}\` to its registerPanel() call and rebuild it against the @pramen/cms-editor this admin serves (React ${PANEL_RUNTIME_REACT_MAJOR}).`;
|
|
236
|
+
}
|
|
237
|
+
if (stated < implemented) {
|
|
238
|
+
return `The '${slug}' panel was built against panel runtime contract ${stated}, and this editor implements ${implemented}. Rebuild the bundle against the @pramen/cms-editor this admin serves (React ${PANEL_RUNTIME_REACT_MAJOR}) and set \`contract: ${implemented}\` in its registerPanel() call.`;
|
|
239
|
+
}
|
|
240
|
+
if (stated > implemented) {
|
|
241
|
+
return `The '${slug}' panel was built against panel runtime contract ${stated}, and this editor implements ${implemented}. Upgrade @pramen/cms-editor — and the shell that serves it — to the release implementing contract ${stated}, or rebuild the panel against this one.`;
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Register a panel component. Called by a panel bundle, through the runtime published on
|
|
248
|
+
* `globalThis` — see `panel-runtime.ts`.
|
|
249
|
+
*
|
|
250
|
+
* THE CONTRACT IS CHECKED HERE, not in the runtime wrapper that publishes this function, and
|
|
251
|
+
* that is a decision rather than a convenience. This is the only chokepoint: the wrapper is
|
|
252
|
+
* one caller of it, and a check living there would leave the registry itself accepting a
|
|
253
|
+
* bundle built for another React — so the invariant would hold on one path and be a comment
|
|
254
|
+
* on every other. It is also the same KIND of judgement as the two refusals beside it (a
|
|
255
|
+
* missing slug, a `render` that is not a component): whether this registration may enter the
|
|
256
|
+
* registry at all. And it is here rather than in the generated shims because a shim resolves
|
|
257
|
+
* one bare specifier and has no registration to inspect — by the time anything knows a
|
|
258
|
+
* contract was stated, it is in this function's argument.
|
|
259
|
+
*
|
|
260
|
+
* A duplicate slug REPLACES rather than throwing. Registration happens inside a dynamic
|
|
261
|
+
* import of code this editor does not own, where a throw is swallowed into the loader's
|
|
262
|
+
* per-bundle catch and shows up as "the panel never registered" — the least informative
|
|
263
|
+
* possible report of "you registered it twice". A warning names both the slug and the fact,
|
|
264
|
+
* and the last registration wins, which is the only rule that makes a dev-time hot reload
|
|
265
|
+
* (re-evaluating the same bundle) behave. A refusal is not a throw for the same reason.
|
|
266
|
+
*/
|
|
267
|
+
export function registerPanel(def: PanelRegistration, warn: (msg: string) => void = console.warn): void {
|
|
268
|
+
const slug = typeof def?.slug === "string" ? def.slug.trim() : "";
|
|
269
|
+
if (!slug) {
|
|
270
|
+
// The only refusal with nothing to record it under, and nowhere to render it: without a
|
|
271
|
+
// slug there is no route to put it on. The console is the whole report.
|
|
272
|
+
warn("pramen/cms-editor: ignoring a panel registered with no slug — the slug is what matches it to its adminPanel() entry.");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Before `render`, because the contract is what decides whether the rest of this call means
|
|
276
|
+
// what it appears to: a bundle built for another React may well hand over a function that
|
|
277
|
+
// is not a component this editor can drive.
|
|
278
|
+
const mismatch = contractRefusal(slug, def.contract);
|
|
279
|
+
if (mismatch !== undefined) return refuse(slug, mismatch, warn);
|
|
280
|
+
const rendered = typeof def.render;
|
|
281
|
+
if (rendered !== "function") {
|
|
282
|
+
return refuse(slug, `The '${slug}' panel was ignored: 'render' must be a React component, and this one is of type ${rendered}.`, warn);
|
|
283
|
+
}
|
|
284
|
+
if (panels.has(slug)) warn(`pramen/cms-editor: panel '${slug}' was registered twice — the last registration wins.`);
|
|
285
|
+
// A registration that lands CLEARS the slug's refusal: a dev loop that fixes the contract
|
|
286
|
+
// and re-evaluates the bundle must not leave the old message on the screen.
|
|
287
|
+
refusals.delete(slug);
|
|
288
|
+
panels.set(slug, { slug, render: def.render });
|
|
289
|
+
notify();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The component registered for a slug, or `undefined`. */
|
|
293
|
+
export function getPanel(slug: string): RegisteredPanel | undefined {
|
|
294
|
+
return panels.get(slug);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Why this slug's registration was turned away, if it was — read by the panel route, so a
|
|
298
|
+
* refused panel says what happened on the screen it was supposed to be. */
|
|
299
|
+
export function panelRefusal(slug: string): string | undefined {
|
|
300
|
+
return refusals.get(slug);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Have all declared bundles finished loading (however they finished)? */
|
|
304
|
+
export function panelsSettled(): boolean {
|
|
305
|
+
return inFlight === 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The registry's version — the `useSyncExternalStore` snapshot. */
|
|
309
|
+
export function panelsVersion(): number {
|
|
310
|
+
return version;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function subscribePanels(listener: () => void): () => void {
|
|
314
|
+
listeners.add(listener);
|
|
315
|
+
return () => listeners.delete(listener);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Drop every registration and every listener. Tests only — the registry is module state,
|
|
319
|
+
* and a test that registered a panel must not leak it into the next one. */
|
|
320
|
+
export function resetPanels(): void {
|
|
321
|
+
panels.clear();
|
|
322
|
+
listeners.clear();
|
|
323
|
+
refusals.clear();
|
|
324
|
+
inFlight = 0;
|
|
325
|
+
version = 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// --- bundle loading ---------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
/** What the shell may set under `window.PRAMEN_CMS_EDITOR.panels`. */
|
|
331
|
+
export interface PanelHost {
|
|
332
|
+
PRAMEN_CMS_EDITOR?: { panels?: unknown };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Resolve one declared bundle URL, or reject it.
|
|
337
|
+
*
|
|
338
|
+
* These strings are `import()`ed, which is to say EXECUTED, so the check is about what a
|
|
339
|
+
* scheme can do and not about tidiness. `javascript:`, `data:` and `blob:` all parse
|
|
340
|
+
* happily as URLs and all name code with no origin to attribute it to; an http(s) URL is
|
|
341
|
+
* fetched under the page's own CSP and shows up in the network log like every other asset.
|
|
342
|
+
* So the allow-list is the two hierarchical web schemes and nothing else.
|
|
343
|
+
*
|
|
344
|
+
* Resolved against the DOCUMENT rather than the origin, for the reason `opensInSameTab`
|
|
345
|
+
* spells out: a relative specifier is what the browser would itself resolve that way, and
|
|
346
|
+
* resolving against `location.origin` instead would silently load a different file. It also
|
|
347
|
+
* means a fingerprinted asset path emitted by the host's bundler (`/_astro/panel.a1b2.js`)
|
|
348
|
+
* needs no special handling — it is just an absolute path.
|
|
349
|
+
*/
|
|
350
|
+
export function panelBundleUrl(raw: unknown, documentUrl: string): string | undefined {
|
|
351
|
+
if (typeof raw !== "string" || raw.trim() === "") return undefined;
|
|
352
|
+
let url: URL;
|
|
353
|
+
try {
|
|
354
|
+
url = new URL(raw.trim(), documentUrl);
|
|
355
|
+
} catch {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
|
359
|
+
return url.href;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The bundle URLs the shell declared, resolved and filtered.
|
|
364
|
+
*
|
|
365
|
+
* A rejected entry is WARNED about and dropped rather than throwing: the value is
|
|
366
|
+
* server-generated (see `AdminRuntimeConfig` in @pramen/cms-astro), so one that fails this
|
|
367
|
+
* check means something upstream is wrong — and taking the whole admin down over a bad panel
|
|
368
|
+
* URL would be the worst possible place to discover it. `brand.ts` and `mount.ts` fail the
|
|
369
|
+
* same way for the same reason.
|
|
370
|
+
*/
|
|
371
|
+
export function readPanelUrls(host: PanelHost | undefined, documentUrl: string, warn: (msg: string) => void = console.warn): string[] {
|
|
372
|
+
const declared = host?.PRAMEN_CMS_EDITOR?.panels;
|
|
373
|
+
if (declared === undefined) return [];
|
|
374
|
+
if (!Array.isArray(declared)) {
|
|
375
|
+
warn("pramen/cms-editor: ignoring `panels` — it must be an array of module URLs.");
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
const out: string[] = [];
|
|
379
|
+
for (const entry of declared) {
|
|
380
|
+
const url = panelBundleUrl(entry, documentUrl);
|
|
381
|
+
if (url === undefined) warn(`pramen/cms-editor: ignoring unusable panel bundle URL ${JSON.stringify(entry)} — it must be an http(s) URL or a path.`);
|
|
382
|
+
else out.push(url);
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** How a bundle is fetched. A parameter so the loader can be exercised without a network. */
|
|
388
|
+
export type PanelImporter = (url: string) => Promise<unknown>;
|
|
389
|
+
|
|
390
|
+
const dynamicImport: PanelImporter = (url) => import(/* @vite-ignore */ url);
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Import every declared panel bundle, in parallel.
|
|
394
|
+
*
|
|
395
|
+
* Per-bundle `catch`, not one `try` around the lot: bundles are independent deployments of
|
|
396
|
+
* independent project code, and one that 404s must not take the others' registrations with
|
|
397
|
+
* it. The returned promise settles when all of them have, which is what `panelsSettled`
|
|
398
|
+
* reports — nothing awaits it on the boot path.
|
|
399
|
+
*/
|
|
400
|
+
export async function loadPanelBundles(
|
|
401
|
+
urls: readonly string[],
|
|
402
|
+
importer: PanelImporter = dynamicImport,
|
|
403
|
+
warn: (msg: string) => void = console.warn,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
if (urls.length === 0) return;
|
|
406
|
+
inFlight += urls.length;
|
|
407
|
+
notify();
|
|
408
|
+
await Promise.all(
|
|
409
|
+
urls.map((url) =>
|
|
410
|
+
importer(url)
|
|
411
|
+
.catch((e: unknown) => {
|
|
412
|
+
warn(`pramen/cms-editor: panel bundle ${url} failed to load — ${String((e as Error)?.message ?? e)}`);
|
|
413
|
+
})
|
|
414
|
+
.finally(() => {
|
|
415
|
+
inFlight -= 1;
|
|
416
|
+
notify();
|
|
417
|
+
}),
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
}
|
package/src/routes/_layout.tsx
CHANGED
|
@@ -39,9 +39,9 @@ import { pagesHidden, splitsByType } from "../components";
|
|
|
39
39
|
import { APP_BAR_H } from "../chrome";
|
|
40
40
|
import { DarkThemeIcon, GroupFoldedIcon, GroupOpenIcon, LightThemeIcon, MenuToggleIcon, NAV_GLYPHS, RailToggleIcon, SettingsIcon, SignOutIcon } from "../icons";
|
|
41
41
|
import { opensInSameTab } from "../mount";
|
|
42
|
+
import { setTheme, useTheme } from "../theme";
|
|
42
43
|
import { buildNav, NAV_SECTION_IDS, navSections, navSectionsAreLabelled, railIsNarrow, type ExtraNavLink, type NavIcon, type NavSectionId } from "../nav";
|
|
43
44
|
|
|
44
|
-
const THEME_KEY = "pramen.cms.theme";
|
|
45
45
|
/** Which nav groups this browser has folded away. Per-browser, like the theme — it is a
|
|
46
46
|
* reading preference, not deployment configuration, and nothing server-side should carry it. */
|
|
47
47
|
const COLLAPSED_KEY = "pramen.cms.nav.collapsed";
|
|
@@ -148,12 +148,10 @@ export default function RootLayout() {
|
|
|
148
148
|
// otherwise be discarded with no prompt of any kind.
|
|
149
149
|
const guarded = (go: () => void) => () => { if (confirmNavigation()) go(); };
|
|
150
150
|
|
|
151
|
-
// Dark mode
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
localStorage.setItem(THEME_KEY, theme);
|
|
156
|
-
}, [theme]);
|
|
151
|
+
// Dark mode. The choice lives in `theme.ts` rather than here: podoba's tokens flip under
|
|
152
|
+
// `[data-theme="dark"]` on the document root, `main.tsx` applies the stored one before the
|
|
153
|
+
// first paint, and a PANEL is handed the same value — three readers, so one store.
|
|
154
|
+
const theme = useTheme();
|
|
157
155
|
|
|
158
156
|
// Below `md` the rail collapses to a disclosure under the brand row. A DISCLOSURE, not an
|
|
159
157
|
// overlay drawer: an overlay owes the reader a focus trap, a restore and an Esc handler,
|
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
// A
|
|
1
|
+
// A project's own screen (`/apps/:slug`), rendered inside the editor's own chrome.
|
|
2
2
|
//
|
|
3
3
|
// Routed BY THE EDITOR, which is the point: an `extraNav` link has to open a new tab,
|
|
4
4
|
// because `_404.tsx` registers the catch-all `/:__notFound+` and a same-tab click on an
|
|
5
|
-
// unmounted editor lands on the in-app 404. A registered
|
|
5
|
+
// unmounted editor lands on the in-app 404. A registered screen has a real route, so it is
|
|
6
6
|
// part of the admin rather than a link out of it.
|
|
7
|
+
//
|
|
8
|
+
// ONE route for two kinds. A Block Kit page (`adminPage()`) is described as JSON by the
|
|
9
|
+
// server and rendered here; a panel (`adminPanel()`) is a React component the deployment's
|
|
10
|
+
// own bundle registered. They share this route because they share everything a URL and a nav
|
|
11
|
+
// entry are made of — the slug space, the role filter, the "Apps" band, the breadcrumb — and
|
|
12
|
+
// differ only in where the rendering happens. Splitting them would have meant a second
|
|
13
|
+
// route, a second nav band and a slug that could mean two things.
|
|
7
14
|
|
|
8
|
-
import { createPage, useNavigate } from "@buzola/router";
|
|
15
|
+
import { createPage, useNavigate, useRouter } from "@buzola/router";
|
|
16
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
9
17
|
import { useApp } from "../app-context";
|
|
10
18
|
import { AdminPageView } from "../blockkit";
|
|
11
19
|
import { Notice } from "../components";
|
|
20
|
+
import { PanelBoundary } from "../panel-boundary";
|
|
21
|
+
import { getPanel, panelRefusal, panelsSettled, panelsVersion, subscribePanels, type PanelProps } from "../panels";
|
|
22
|
+
import { useTheme } from "../theme";
|
|
23
|
+
import { adminPageKind } from "../types";
|
|
12
24
|
import { Button } from "@podoba/react";
|
|
13
25
|
|
|
14
26
|
export default createPage()
|
|
@@ -29,8 +41,70 @@ export default createPage()
|
|
|
29
41
|
</Notice>
|
|
30
42
|
);
|
|
31
43
|
}
|
|
44
|
+
if (adminPageKind(def) === "panel") return <PanelRoute slug={def.slug} />;
|
|
32
45
|
// Keyed on the slug so switching between two pages REMOUNTS the view: buzola renders the
|
|
33
46
|
// same component instance across a params-only change, and the blocks, the form values
|
|
34
47
|
// and any toast all belong to one page.
|
|
35
48
|
return <AdminPageView api={api} key={def.slug} slug={def.slug} label={def.label} onError={setError} />;
|
|
36
49
|
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A panel: the component the deployment's own bundle registered for this slug.
|
|
53
|
+
*
|
|
54
|
+
* The registry is external state that changes without React knowing — bundles are imported
|
|
55
|
+
* from `main.tsx` and register whenever they land — so it is read through
|
|
56
|
+
* `useSyncExternalStore` rather than an effect. That is what makes a deep link to a panel
|
|
57
|
+
* work: the route can mount before the bundle has finished loading, and re-renders when it
|
|
58
|
+
* has, instead of deciding once and being wrong forever.
|
|
59
|
+
*/
|
|
60
|
+
function PanelRoute({ slug }: { slug: string }) {
|
|
61
|
+
const { api, setError } = useApp();
|
|
62
|
+
const theme = useTheme();
|
|
63
|
+
const basePath = useRouter().basePath;
|
|
64
|
+
const version = useSyncExternalStore(subscribePanels, panelsVersion, panelsVersion);
|
|
65
|
+
// `version` is the snapshot, not the value — a stable number is what a store hook needs,
|
|
66
|
+
// and the lookup is what the render actually wants. Depending on it is the point.
|
|
67
|
+
const panel = useMemo(() => getPanel(slug), [slug, version]);
|
|
68
|
+
|
|
69
|
+
if (!panel) {
|
|
70
|
+
// Three different situations, and only the first is a spinner.
|
|
71
|
+
//
|
|
72
|
+
// A REFUSAL comes first because it is the one the generic message would actively mislead
|
|
73
|
+
// about: the bundle is listed, it loaded, it ran, and it called `registerPanel` — telling
|
|
74
|
+
// the reader to go and check those four things sends them past the actual answer. The
|
|
75
|
+
// registry already holds a sentence naming the slug and the fix (a contract built against
|
|
76
|
+
// a different editor, a `render` that is not a component), so it is shown verbatim.
|
|
77
|
+
//
|
|
78
|
+
// Otherwise: the server listed this panel (it is in `adminPages`, so the caller may open
|
|
79
|
+
// it), which means the bundle either has not landed yet or landed and never registered
|
|
80
|
+
// this slug. The last is a deployment mistake — a missing `panels` entry, a bundle built
|
|
81
|
+
// without the `registerPanel` call, a slug typo between `app.ts` and the bundle — and it
|
|
82
|
+
// is one nobody can diagnose from a spinner.
|
|
83
|
+
//
|
|
84
|
+
// Read during render, not through a second store: a refusal calls the same `notify` a
|
|
85
|
+
// registration does, so `version` above is already the subscription that brings this
|
|
86
|
+
// component back when one is recorded.
|
|
87
|
+
const refused = panelRefusal(slug);
|
|
88
|
+
return (
|
|
89
|
+
<Notice>
|
|
90
|
+
{refused ??
|
|
91
|
+
(panelsSettled()
|
|
92
|
+
? `No panel is registered for '${slug}'. Check that this deployment's panel bundle is listed in the admin's \`panels\` config and calls registerPanel({ slug: "${slug}", … }).`
|
|
93
|
+
: "Loading…")}
|
|
94
|
+
</Notice>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const props: PanelProps = { api, basePath, theme, setError };
|
|
99
|
+
// Keyed on the slug for the same reason a Block Kit page is: buzola keeps one component
|
|
100
|
+
// instance across a params-only change, and a panel's state belongs to its own screen.
|
|
101
|
+
//
|
|
102
|
+
// Wrapped, because this is someone else's component in our tree: React unmounts the whole
|
|
103
|
+
// root on an uncaught render error, so without the boundary one bad panel does not break a
|
|
104
|
+
// screen, it blanks the admin — no sidebar, and no way off the route that is failing.
|
|
105
|
+
return (
|
|
106
|
+
<PanelBoundary key={slug} slug={slug}>
|
|
107
|
+
<panel.render {...props} />
|
|
108
|
+
</PanelBoundary>
|
|
109
|
+
);
|
|
110
|
+
}
|