@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.
@@ -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
+ }
@@ -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: podoba tokens flip under `[data-theme="dark"]` no `dark:` prefixes.
152
- const [theme, setTheme] = useState(() => (typeof localStorage !== "undefined" ? localStorage.getItem(THEME_KEY) ?? "light" : "light"));
153
- useEffect(() => {
154
- document.documentElement.dataset.theme = theme === "dark" ? "dark" : "light";
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 custom admin page (`/apps/:slug`) — Block Kit, rendered inside the editor's own chrome.
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 page has a real route, so it is
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
+ }