@pramen/cms-editor 0.0.61 → 0.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/nav.ts CHANGED
@@ -31,6 +31,41 @@ export interface ExtraNavLink {
31
31
  order?: number;
32
32
  }
33
33
 
34
+ /** The glyphs the sidebar can draw, by NAME.
35
+ *
36
+ * A name, not a component: this module is a pure function of the session's facts and is
37
+ * tested without a DOM, so it must not import JSX. `NAV_GLYPHS` in `icons.tsx` maps each
38
+ * name to the drawing, and because this is a closed union a glyph added here without a
39
+ * drawing is a compile error rather than a blank square in the nav. */
40
+ export type NavGlyph =
41
+ | "pages"
42
+ | "collection"
43
+ | "media"
44
+ | "menus"
45
+ | "taxonomies"
46
+ | "widgets"
47
+ | "redirects"
48
+ | "app"
49
+ | "types"
50
+ | "users"
51
+ | "settings"
52
+ | "link";
53
+
54
+ /** What sits in an entry's icon slot.
55
+ *
56
+ * Two cases because a collection and a Block Kit page may declare their OWN icon
57
+ * server-side, and that is an arbitrary string (an emoji, in practice). It used to be
58
+ * prepended to the label — which read as part of the words, wrapped with them, and could
59
+ * not be aligned with anything. In a sidebar the icon is a column, so a declared emoji goes
60
+ * in that column and the built-in glyph is the fallback. */
61
+ export type NavIcon = { kind: "glyph"; name: NavGlyph } | { kind: "emoji"; char: string };
62
+
63
+ /** A server-declared icon if there is one, else the section's own glyph. */
64
+ function iconFor(declared: string | undefined, fallback: NavGlyph): NavIcon {
65
+ const char = declared?.trim();
66
+ return char ? { kind: "emoji", char } : { kind: "glyph", name: fallback };
67
+ }
68
+
34
69
  /** One entry in the primary nav.
35
70
  *
36
71
  * `kind` is what the layout switches on to render it; everything else here is the data it
@@ -38,8 +73,8 @@ export interface ExtraNavLink {
38
73
  * buzola page, and the layout is where navigation (and the unsaved-changes guard it runs
39
74
  * through) belongs. */
40
75
  export type NavEntry =
41
- | { kind: "route"; key: string; order: number; label: string; page: NavPage; params?: Record<string, string> }
42
- | { kind: "link"; key: string; order: number; link: ExtraNavLink };
76
+ | { kind: "route"; key: string; order: number; icon: NavIcon; label: string; page: NavPage; params?: Record<string, string> }
77
+ | { kind: "link"; key: string; order: number; icon: NavIcon; link: ExtraNavLink };
43
78
 
44
79
  /** What the nav is built from. All of it is already in the app context; passing it in keeps
45
80
  * this a pure function of the session's facts. */
@@ -76,10 +111,10 @@ export function buildNav(input: NavInput): NavEntry[] {
76
111
  // slug. The label is the type's own `name`, so a host that wants a plural tab writes
77
112
  // one.
78
113
  for (const t of contentTypes ?? []) {
79
- entries.push({ kind: "route", key: `type:${t.slug}`, order: NAV_ORDER.pages, label: t.name, page: "type", params: { slug: t.slug } });
114
+ entries.push({ kind: "route", key: `type:${t.slug}`, order: NAV_ORDER.pages, icon: { kind: "glyph", name: "pages" }, label: t.name, page: "type", params: { slug: t.slug } });
80
115
  }
81
116
  } else {
82
- entries.push({ kind: "route", key: "pages", order: NAV_ORDER.pages, label: "Pages", page: "home" });
117
+ entries.push({ kind: "route", key: "pages", order: NAV_ORDER.pages, icon: { kind: "glyph", name: "pages" }, label: "Pages", page: "home" });
83
118
  }
84
119
  }
85
120
 
@@ -88,22 +123,23 @@ export function buildNav(input: NavInput): NavEntry[] {
88
123
  kind: "route",
89
124
  key: `col:${c.slug}`,
90
125
  order: c.navOrder ?? NAV_ORDER.collections,
91
- label: `${c.icon ? `${c.icon} ` : ""}${c.pluralLabel}`,
126
+ icon: iconFor(c.icon, "collection"),
127
+ label: c.pluralLabel,
92
128
  page: "collection",
93
129
  params: { slug: c.slug },
94
130
  });
95
131
  }
96
132
 
97
- entries.push({ kind: "route", key: "media", order: NAV_ORDER.media, label: "Media", page: "media" });
133
+ entries.push({ kind: "route", key: "media", order: NAV_ORDER.media, icon: { kind: "glyph", name: "media" }, label: "Media", page: "media" });
98
134
 
99
135
  if (cms.siteFurniture) {
100
- entries.push({ kind: "route", key: "menus", order: NAV_ORDER.menus, label: "Menus", page: "menus" });
136
+ entries.push({ kind: "route", key: "menus", order: NAV_ORDER.menus, icon: { kind: "glyph", name: "menus" }, label: "Menus", page: "menus" });
101
137
  // Taxonomies classify PAGES — `cms_page_terms` links a term to a page and to nothing
102
138
  // else — so a collections-only deployment has nothing to classify and the section would
103
139
  // be a vocabulary editor with no subject.
104
- if (!hidePages) entries.push({ kind: "route", key: "taxonomies", order: NAV_ORDER.taxonomies, label: "Taxonomies", page: "taxonomies" });
105
- entries.push({ kind: "route", key: "widgets", order: NAV_ORDER.widgets, label: "Widgets", page: "widgets" });
106
- entries.push({ kind: "route", key: "redirects", order: NAV_ORDER.redirects, label: "Redirects", page: "redirects" });
140
+ if (!hidePages) entries.push({ kind: "route", key: "taxonomies", order: NAV_ORDER.taxonomies, icon: { kind: "glyph", name: "taxonomies" }, label: "Taxonomies", page: "taxonomies" });
141
+ entries.push({ kind: "route", key: "widgets", order: NAV_ORDER.widgets, icon: { kind: "glyph", name: "widgets" }, label: "Widgets", page: "widgets" });
142
+ entries.push({ kind: "route", key: "redirects", order: NAV_ORDER.redirects, icon: { kind: "glyph", name: "redirects" }, label: "Redirects", page: "redirects" });
107
143
  }
108
144
 
109
145
  // A project's own screens, INSIDE the chrome and at a position they choose. This is the
@@ -111,24 +147,122 @@ export function buildNav(input: NavInput): NavEntry[] {
111
147
  // the odd 10% of a client site was a separate deployment that looked nothing like the
112
148
  // admin it hung off.
113
149
  for (const p of adminPages) {
114
- entries.push({ kind: "route", key: `app:${p.slug}`, order: p.navOrder ?? NAV_ORDER.adminPages, label: `${p.icon ? `${p.icon} ` : ""}${p.label}`, page: "admin-page", params: { slug: p.slug } });
150
+ entries.push({ kind: "route", key: `app:${p.slug}`, order: p.navOrder ?? NAV_ORDER.adminPages, icon: iconFor(p.icon, "app"), label: p.label, page: "admin-page", params: { slug: p.slug } });
115
151
  }
116
152
 
117
153
  // Authoring the SCHEMA, not content — so it is gated on `canEdit` (every handler behind
118
154
  // it is editor-only) and hidden where there is no block/page builder to define types for.
119
155
  if (!hidePages && cms.canEdit) {
120
- entries.push({ kind: "route", key: "types", order: NAV_ORDER.types, label: "Types", page: "schema" });
156
+ entries.push({ kind: "route", key: "types", order: NAV_ORDER.types, icon: { kind: "glyph", name: "types" }, label: "Types", page: "schema" });
121
157
  }
122
158
 
123
- if (isAdmin) entries.push({ kind: "route", key: "users", order: NAV_ORDER.users, label: "Users", page: "users" });
124
- entries.push({ kind: "route", key: "settings", order: NAV_ORDER.settings, label: "Settings", page: "settings" });
159
+ if (isAdmin) entries.push({ kind: "route", key: "users", order: NAV_ORDER.users, icon: { kind: "glyph", name: "users" }, label: "Users", page: "users" });
160
+ entries.push({ kind: "route", key: "settings", order: NAV_ORDER.settings, icon: { kind: "glyph", name: "settings" }, label: "Settings", page: "settings" });
125
161
 
126
162
  for (const link of extraNav) {
127
163
  // Keyed on href AND label: two entries may legitimately point at the same href and
128
164
  // differ only in label or target, and keyed on href alone React reconciles them
129
165
  // together — the rendered label can end up on the other one's anchor.
130
- entries.push({ kind: "link", key: `extra:${link.href}|${link.label}`, order: link.order ?? NAV_ORDER.extra, link });
166
+ entries.push({ kind: "link", key: `extra:${link.href}|${link.label}`, order: link.order ?? NAV_ORDER.extra, icon: { kind: "glyph", name: "link" }, link });
131
167
  }
132
168
 
133
169
  return entries.sort((a, b) => a.order - b.order);
134
170
  }
171
+
172
+ // --- sections ---------------------------------------------------------------------------
173
+ //
174
+ // Twelve items in one flat list is the thing that stopped being legible — it overran the
175
+ // topbar, and a sidebar alone only turns a crowded row into a long column. So the nav is
176
+ // GROUPED. Grouping rather than hiding: everything a session may reach stays one click away
177
+ // (a submenu costs a click and hides the thing being looked for), and the headings answer
178
+ // "where would I look for this" instead of making the reader scan twelve equal rows.
179
+ //
180
+ // A section is a BAND OF `order`, not a hand-written list of keys. That keeps the one
181
+ // contract this module has — position is a number a host sets (`navOrder` on a collection
182
+ // or an admin page, `order` on an `extraNav` link) — the single thing that decides where an
183
+ // entry appears. A host placing a section at 250 lands in Content, at 450 in Site, exactly
184
+ // as the number reads. Enumerating keys instead would have made a host-placed entry land
185
+ // visually inside a group it was not a member of.
186
+
187
+ /** A group of nav entries, in order. */
188
+ export type NavSectionId = "content" | "site" | "apps" | "system";
189
+
190
+ export interface NavSection {
191
+ id: NavSectionId;
192
+ /** The heading. Rendered only when there is more than one section — see `navSections`. */
193
+ label: string;
194
+ entries: NavEntry[];
195
+ }
196
+
197
+ /** The bands, in order. `upTo` is EXCLUSIVE, and each is expressed against `NAV_ORDER`
198
+ * rather than a literal so the two cannot drift: a built-in moving to a new position moves
199
+ * with its band. */
200
+ const BANDS: readonly { id: NavSectionId; label: string; upTo: number }[] = [
201
+ // Pages / content types, collections, media — the things an editor came here to write.
202
+ { id: "content", label: "Content", upTo: NAV_ORDER.menus },
203
+ // Menus, taxonomies, widget areas, redirects — site-level furniture, not page content.
204
+ { id: "site", label: "Site", upTo: NAV_ORDER.adminPages },
205
+ // A project's own Block Kit screens. Their own band rather than a tail of "System",
206
+ // because they are the project's, and the whole point of `adminPage()` is that they are
207
+ // not administration of the CMS.
208
+ { id: "apps", label: "Apps", upTo: NAV_ORDER.types },
209
+ // Types, users, settings, and host links to companion tools.
210
+ { id: "system", label: "System", upTo: Number.POSITIVE_INFINITY },
211
+ ];
212
+
213
+ /** Every section id, in rail order. Derived from `BANDS` rather than written out again, so
214
+ * a new group cannot exist in the layout's eyes but not in the reader's stored folds (or the
215
+ * reverse). It is what makes a persisted fold PARSEABLE: a value read back out of
216
+ * localStorage is checked against this, so a stale id from an older version — or anything a
217
+ * hand-edit put there — is dropped rather than carried into state as "some string". */
218
+ export const NAV_SECTION_IDS: readonly NavSectionId[] = BANDS.map((b) => b.id);
219
+
220
+ /**
221
+ * Group the entries `buildNav` returned, dropping empty sections.
222
+ *
223
+ * Takes the already-ordered list rather than re-deriving it: the order IS the grouping, so
224
+ * a second traversal that could disagree with the first would be a bug waiting to happen.
225
+ * Entries stay in the order they came in, which is what carries the sub-orderings that have
226
+ * no numeric expression (content-type tabs among themselves, collections sharing a
227
+ * `navOrder`).
228
+ */
229
+ export function navSections(entries: NavEntry[]): NavSection[] {
230
+ const sections: NavSection[] = BANDS.map((b) => ({ id: b.id, label: b.label, entries: [] }));
231
+ for (const entry of entries) {
232
+ // The last band is unbounded, so `find` always hits; the `??` is for the type checker.
233
+ const i = BANDS.findIndex((b) => entry.order < b.upTo);
234
+ (sections[i === -1 ? sections.length - 1 : i] as NavSection).entries.push(entry);
235
+ }
236
+ return sections.filter((s) => s.entries.length > 0);
237
+ }
238
+
239
+ /**
240
+ * Should the sections be LABELLED?
241
+ *
242
+ * A single heading over the whole nav names nothing — it is a caption on a list with no
243
+ * sibling to distinguish it from — and a collections-only deployment (`hidePages`, no site
244
+ * furniture) genuinely is one group. So the headings appear once there are at least two,
245
+ * and the small deployment keeps a plain list.
246
+ */
247
+ export function navSectionsAreLabelled(sections: NavSection[]): boolean {
248
+ return sections.length > 1;
249
+ }
250
+
251
+ /**
252
+ * Is the rail ACTUALLY narrowed?
253
+ *
254
+ * A named function for `choice && wide`, because conflating those two is what broke it. The
255
+ * choice is per-browser and persisted; narrowing is expressed entirely in `md:`-scoped
256
+ * classes, so it only exists at desktop widths. A rail narrowed on a laptop therefore came
257
+ * back "narrowed" on a phone, where every one of those classes is inert — the rows kept their
258
+ * labels and their full width, while the JS gated on the stored choice removed all four group
259
+ * headings, and the hairline that stands in for a heading at 56px is `md:`-only too. One
260
+ * undifferentiated column of a dozen rows, and the toggle that would undo it is
261
+ * `hidden md:inline-flex`: no way back from that viewport.
262
+ *
263
+ * So JS has to agree with the breakpoint rather than ignore it, and everything conditional —
264
+ * headings, folding, the hairline — reads this instead of the stored value.
265
+ */
266
+ export function railIsNarrow(choice: boolean, wideViewport: boolean): boolean {
267
+ return choice && wideViewport;
268
+ }
@@ -0,0 +1,128 @@
1
+ // The screen header: a cover panel carrying the title and the primary action, which STAYS as
2
+ // the page scrolls.
3
+ //
4
+ // A LEAF module, and it has to be one. `components.tsx` already imports from `furniture.tsx`
5
+ // (for `flattenTerms`), so the two near-identical headers that used to live one in each — a
6
+ // 56px `Hero` and a 40px `Head` — could not be deduplicated by either importing the other
7
+ // without a cycle. Below both, they are one component.
8
+ //
9
+ // ONE type scale, and no `size` prop. The 40px variant was inherited from `Head`: the
10
+ // site-furniture screens (Menus, Taxonomies, Widgets, Redirects) had always been set quieter
11
+ // than the libraries, back when a header was type on the page and the difference read as
12
+ // hierarchy. It does not survive the cover: every screen now gets the same panel with the same
13
+ // generated artwork, so a smaller title on four of them reads as an oversight rather than a
14
+ // rank, and "Navigation" at 40px beside "Media" at 56px is a question a reader has to stop and
15
+ // ask.
16
+ //
17
+ // STICKY, and condensing. The header is the only thing on screen that says which of a dozen
18
+ // interchangeable list screens you are on, and scrolling a media library past the first row
19
+ // used to take it away — leaving a wall of thumbnails with no title and no primary action.
20
+ // Sticky alone would be worse than that: a 190px banner pinned to the top eats a third of the
21
+ // viewport on every scroll. So it condenses — same panel, same artwork, same button, a third
22
+ // of the height — which is the only version of "keep it" that a long list can afford.
23
+
24
+ import { useEffect, useState, type ReactNode } from "react";
25
+ import { BELOW_APP_BAR } from "./chrome";
26
+ import { CoverArt } from "./cover";
27
+
28
+ /** TWO thresholds, not one — this is hysteresis, and without it the header flickers.
29
+ *
30
+ * Condensing REMOVES about 90px of header, which shortens the document by the same amount. If
31
+ * the reader is anywhere the browser then has to clamp the scroll offset, `scrollY` moves on
32
+ * its own — back under a single threshold, which expands the header, which lengthens the
33
+ * document, which moves `scrollY` again. The state is an input to its own condition, so one
34
+ * threshold is a feedback loop by construction and no amount of debouncing fixes it: the
35
+ * oscillation is in the layout, not in the event rate.
36
+ *
37
+ * The gap has to exceed the height the switch removes, or the loop simply reappears further
38
+ * down. `EXPAND_BELOW` is deliberately small so that returning to the top always restores the
39
+ * full header.
40
+ */
41
+ const CONDENSE_ABOVE_PX = 120;
42
+ const EXPAND_BELOW_PX = 16;
43
+
44
+ /**
45
+ * Has the page scrolled far enough to condense the header?
46
+ *
47
+ * On `window`, because the editor's shell deliberately keeps the DOCUMENT as its scroller (the
48
+ * rail is a sticky grid column, not an inner scroll pane) — so this is the one scroll position
49
+ * there is. `passive` since nothing here can cancel the scroll, and React bails out of a
50
+ * re-render when the value is unchanged, so a scroll inside the dead band costs a comparison.
51
+ */
52
+ function useCondensed(): boolean {
53
+ const [condensed, setCondensed] = useState(false);
54
+ useEffect(() => {
55
+ // Reads the CURRENT value rather than closing over it, so the listener is registered once
56
+ // and the dead band is evaluated against what is actually on screen.
57
+ const onScroll = (): void =>
58
+ setCondensed((was) => (was ? window.scrollY > EXPAND_BELOW_PX : window.scrollY > CONDENSE_ABOVE_PX));
59
+ onScroll(); // a deep link can land already scrolled (browser scroll restoration)
60
+ window.addEventListener("scroll", onScroll, { passive: true });
61
+ return () => window.removeEventListener("scroll", onScroll);
62
+ }, []);
63
+ return condensed;
64
+ }
65
+
66
+ /**
67
+ * The header.
68
+ *
69
+ * Seeded on `lead`, NOT on `em`. `lead` is the screen's name ("Media", a collection's plural
70
+ * label) and `em` is its state ("None yet", "3 files") — seeding on what is displayed would
71
+ * redraw the artwork every time someone uploaded a file, which is the one thing a picture
72
+ * meant to make a screen RECOGNISABLE must never do.
73
+ *
74
+ * The mask over the art is a hard requirement, not polish: the title is large and set left, and
75
+ * `from-surface-card` fading to transparent is what guarantees it never lands on pattern
76
+ * whatever the seed produced. The `via` stop is the whole tuning knob — at `/85` the middle of
77
+ * the panel is still 85% panel colour and the artwork only ever appears in the last third,
78
+ * which is a cover you cannot see; `/70` clears the type (which ends around 26% of the width)
79
+ * and lets the field read across the rest.
80
+ */
81
+ export function PageHeader({ lead, em, children }: { lead: string; em: string; children?: ReactNode }) {
82
+ const condensed = useCondensed();
83
+ return (
84
+ // The GUTTER is what sticks, not the panel: pinning the panel alone would let rows scroll
85
+ // through the 28px of page margin either side of it and out the rounded corners. `bg-surface`
86
+ // on this wrapper is therefore load-bearing, not decoration.
87
+ //
88
+ // NO padding above: the panel meets the app bar directly, so the two read as one block of
89
+ // chrome rather than a bar with a panel parked under it. That also leaves nothing on this
90
+ // wrapper that changes between the two states, which is why the collapse transition lives
91
+ // only on the panel below — a transition on an element whose padding is constant is dead
92
+ // code that looks load-bearing.
93
+ //
94
+ // `z-20` sits above the list and below the rail's mobile disclosure (which is in flow above
95
+ // it) and every modal overlay (z-50).
96
+ <div className={`sticky ${BELOW_APP_BAR} z-20 mx-auto max-w-[1200px] bg-surface px-7 pb-4`}>
97
+ <div className="relative isolate overflow-hidden rounded-panel border border-border bg-surface-card">
98
+ <CoverArt seed={lead} />
99
+ <div className="absolute inset-0 bg-gradient-to-r from-surface-card via-surface-card/70 to-transparent" />
100
+ <div
101
+ className={`relative grid grid-cols-[1fr_auto] items-center gap-6 px-8 transition-[padding] duration-150 ease-out max-[820px]:grid-cols-1 ${
102
+ condensed ? "py-3.5" : "py-9"
103
+ }`}
104
+ >
105
+ {/* Condensed, the two halves run on ONE line. Stacked they would keep the full
106
+ header's height and defeat the point; and the title has to stay a single `<h1>`
107
+ across the switch, or a screen reader is handed a new heading every time the
108
+ reader scrolls. */}
109
+ <h1
110
+ // NO transition on the type. `transition-all` here animated `font-size` 56px -> 22px,
111
+ // which relayouts the panel on every frame of the tween — on a STICKY element whose
112
+ // height feeds back into the scroll position, which is the other half of the flicker.
113
+ // And `display` (two stacked lines -> one baseline row) cannot be tweened at all, so
114
+ // the line count snapped mid-animation regardless. The type switches at once; the
115
+ // padding below carries the motion.
116
+ className={`m-0 font-normal tracking-[-0.01em] ${
117
+ condensed ? "flex items-baseline gap-2 text-[22px] leading-tight" : "text-[56px] leading-[1.05] max-[820px]:text-[40px]"
118
+ }`}
119
+ >
120
+ <span className={condensed ? "text-fg-subtle" : "block text-fg-subtle"}>{lead}</span>
121
+ <span className={condensed ? "text-fg" : "block text-fg"}>{em}</span>
122
+ </h1>
123
+ {children}
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,67 @@
1
+ // The blast radius of a panel.
2
+ //
3
+ // A panel is a project's own component rendering inside THIS app's React tree, which is the
4
+ // whole point of it — and the cost of that is that a throw in its render is a throw in ours.
5
+ // React's answer to an uncaught render error is to unmount the entire root, so without a
6
+ // boundary one bad panel does not break a screen, it blanks the admin: no sidebar, no way to
7
+ // navigate off the route that is failing, and a reload lands straight back on it because the
8
+ // URL is a real route.
9
+ //
10
+ // So the panel route wraps it. The chrome survives, the reader is told which panel failed and
11
+ // with what, and every other section stays one click away.
12
+ //
13
+ // A CLASS, because `componentDidCatch`/`getDerivedStateFromError` have no hook equivalent —
14
+ // there is still no way to catch a render error from a function component. Its own module so
15
+ // the decision is testable without dragging in the router and the design system.
16
+
17
+ import { Component, type ErrorInfo, type ReactNode } from "react";
18
+
19
+ interface Props {
20
+ /** Named in the message — a deployment with three panels needs to know which one. */
21
+ slug: string;
22
+ children: ReactNode;
23
+ }
24
+
25
+ interface State {
26
+ /** The failure's message, or `null` while the panel is fine. */
27
+ failure: string | null;
28
+ }
29
+
30
+ /** What a caught error reads as. A named function because it is the one piece of judgement
31
+ * here: an error with no message (a thrown string, a thrown object) must still produce
32
+ * something a reader can act on, and `String(undefined)` is not it. */
33
+ export function panelFailureMessage(error: unknown): string {
34
+ const message = error instanceof Error ? error.message : String(error);
35
+ return message.trim() === "" ? "threw a value with no message" : message;
36
+ }
37
+
38
+ export class PanelBoundary extends Component<Props, State> {
39
+ override state: State = { failure: null };
40
+
41
+ static getDerivedStateFromError(error: unknown): State {
42
+ return { failure: panelFailureMessage(error) };
43
+ }
44
+
45
+ override componentDidCatch(error: unknown, info: ErrorInfo): void {
46
+ // The rendered message names the panel and the error; the console gets the component
47
+ // stack, which is the half a developer needs and a reader cannot use.
48
+ console.error(`pramen/cms-editor: panel '${this.props.slug}' failed to render`, error, info.componentStack);
49
+ }
50
+
51
+ override componentDidUpdate(prev: Props): void {
52
+ // Navigating to a different panel must clear the failure — the route keys on the slug so
53
+ // this rarely fires, but a boundary that latched would turn one panel's bug into every
54
+ // panel's bug for the rest of the session.
55
+ if (prev.slug !== this.props.slug && this.state.failure !== null) this.setState({ failure: null });
56
+ }
57
+
58
+ override render(): ReactNode {
59
+ if (this.state.failure === null) return this.props.children;
60
+ return (
61
+ <div role="alert" className="rounded-panel border border-border bg-surface-card px-6 py-5 text-sm text-fg-muted">
62
+ <p className="mb-1 text-fg">The <strong>{this.props.slug}</strong> panel failed to render.</p>
63
+ <p className="font-mono text-caption">{this.state.failure}</p>
64
+ </div>
65
+ );
66
+ }
67
+ }
@@ -0,0 +1,103 @@
1
+ // The three shim modules a PANEL bundle's bare imports resolve to.
2
+ //
3
+ // A panel is a project's own React screen, built as a separate bundle and rendered inside
4
+ // this editor's tree. It cannot contain React: two copies in one page share no hook
5
+ // dispatcher, so the panel's first `useState` throws "invalid hook call". It must instead
6
+ // link against the React the editor already loaded — which is published on a global by
7
+ // `panel-runtime.ts`, and reached from a panel's ordinary `import { useState } from "react"`
8
+ // through an import map in the shell (see `PramenAdmin.astro`) pointing at these files.
9
+ //
10
+ // GENERATED, not hand-written, and that is the whole reason this module exists rather than
11
+ // three checked-in files. A shim has to re-export every name STATICALLY — ESM named exports
12
+ // cannot be computed — so by hand the list would be a copy of React's export table that
13
+ // nobody would ever revisit. A name missing from it is not a build error anywhere: it is a
14
+ // browser link error ("does not provide an export named 'useDeferredValue'") in someone
15
+ // else's panel, months later, on the one deployment that used that hook. So the list is read
16
+ // off the very modules the editor bundles, at the moment it bundles them, and cannot drift
17
+ // from them by construction.
18
+ //
19
+ // Source-side rather than inside `scripts/build.ts` so the generator is typechecked and can
20
+ // be exercised by tests — `test/cms-editor-panel-globals.test.ts` builds a panel against the
21
+ // generated text and renders it, which is the only way to prove the mechanism end to end
22
+ // without a browser.
23
+
24
+ /** A module namespace as this generator must treat one: an opaque bag whose NAMES are the
25
+ * entire subject. There is no schema to decode it against — the point is that the list comes
26
+ * from React rather than from anything written here — so the contract is deliberately just
27
+ * "an object with a possible CJS-interop `default`". */
28
+ export interface ModuleNamespace {
29
+ readonly default?: ModuleNamespace;
30
+ }
31
+
32
+ /** One shim: which bare specifier it stands in for, which key on the published runtime it
33
+ * reads, and what it is written to. */
34
+ export interface PanelGlobalShim {
35
+ /** Filename under `dist/`, and the package export a shell imports with `?url`. */
36
+ readonly file: string;
37
+ /** The bare specifier an import map points at this file. */
38
+ readonly specifier: string;
39
+ /** The property of `PRAMEN_CMS_EDITOR_RUNTIME` holding the namespace. */
40
+ readonly runtimeKey: "react" | "reactDom" | "jsxRuntime" | "jsxDevRuntime";
41
+ }
42
+
43
+ /** The whole set. Four, and meant to stay four — see the surface note in `panel-runtime.ts`
44
+ * for why each is here and what is deliberately not. */
45
+ export const PANEL_GLOBAL_SHIMS: readonly PanelGlobalShim[] = [
46
+ { file: "panel-react.js", specifier: "react", runtimeKey: "react" },
47
+ { file: "panel-react-dom.js", specifier: "react-dom", runtimeKey: "reactDom" },
48
+ { file: "panel-jsx-runtime.js", specifier: "react/jsx-runtime", runtimeKey: "jsxRuntime" },
49
+ // The transform a panel built UNMINIFIED emits instead. Not an optional nicety: without
50
+ // it that specifier resolves from the consumer's own node_modules and a second React ends
51
+ // up in the bundle, silently, on exactly the build a developer iterates on.
52
+ { file: "panel-jsx-dev-runtime.js", specifier: "react/jsx-dev-runtime", runtimeKey: "jsxDevRuntime" },
53
+ ];
54
+
55
+ /**
56
+ * The names a shim may re-export, read off a module namespace: real identifiers only.
57
+ *
58
+ * The namespace is unwrapped through `default` first, because React and its JSX runtimes are
59
+ * CJS: a bundler's interop puts the real `module.exports` on `default` and mirrors the named
60
+ * exports beside it. Enumerating the unwrapped object means the list is the one `require`
61
+ * would have produced, rather than one that depends on which interop the editor happened to
62
+ * be built with — and it is the same unwrap the generated shim performs at runtime, so the
63
+ * names written out are exactly the names that will be there to destructure.
64
+ *
65
+ * `default` is excluded because it is a keyword (it gets its own `export default` line), and
66
+ * `__`-prefixed keys because React's namespaces carry interop bookkeeping and internals
67
+ * (`__esModule`, `__CLIENT_INTERNALS_…`) that are nobody's API — re-exporting them would put
68
+ * this package's name on a promise React itself does not make.
69
+ *
70
+ * Sorted, so a rebuild against the same React produces a byte-identical file and a diff of
71
+ * `dist/` says something.
72
+ */
73
+ export function exportableNames(ns: ModuleNamespace): string[] {
74
+ const real = ns.default ?? ns;
75
+ return Object.keys(real)
76
+ .filter((k) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) && k !== "default" && !k.startsWith("__"))
77
+ .sort();
78
+ }
79
+
80
+ /**
81
+ * The shim's source.
82
+ *
83
+ * The `?.` and the throw are not defensive noise. This is the first module a panel bundle
84
+ * imports, so it is the one place where "the editor did not publish its runtime" — a panel
85
+ * loaded outside the admin, or a shell that emitted the import map but not the editor — can
86
+ * be reported as itself instead of as `Cannot read properties of undefined (reading
87
+ * 'react')` from somewhere inside a stranger's bundle.
88
+ *
89
+ * `ns.default ?? ns` is the same CJS-interop unwrap `exportableNames` performs, and it has to
90
+ * be: the names written into this file were read off the unwrapped object, so the object
91
+ * destructured here must be that object and not the namespace around it.
92
+ */
93
+ export function panelShimSource(shim: PanelGlobalShim, names: readonly string[], runtimeGlobal: string): string {
94
+ return `// GENERATED by @pramen/cms-editor's build — do not edit.
95
+ // Resolves the bare specifier "${shim.specifier}" for a panel bundle, against the React the
96
+ // editor already loaded. Wired up by the shell's import map; see docs/cms.md.
97
+ const ns = globalThis.${runtimeGlobal}?.${shim.runtimeKey};
98
+ if (!ns) throw new Error("pramen/cms-editor: no editor runtime on this page — a panel bundle was loaded outside the CMS editor, or before it booted.");
99
+ const m = ns.default ?? ns;
100
+ export default m;
101
+ export const { ${names.join(", ")} } = m;
102
+ `;
103
+ }
@@ -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
+ }