@pramen/cms-editor 0.0.63 → 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/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/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,8 @@
|
|
|
1
|
+
// GENERATED by @pramen/cms-editor's build — do not edit.
|
|
2
|
+
// Resolves the bare specifier "react/jsx-dev-runtime" for a panel bundle, against the React the
|
|
3
|
+
// editor already loaded. Wired up by the shell's import map; see docs/cms.md.
|
|
4
|
+
const ns = globalThis.PRAMEN_CMS_EDITOR_RUNTIME?.jsxDevRuntime;
|
|
5
|
+
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.");
|
|
6
|
+
const m = ns.default ?? ns;
|
|
7
|
+
export default m;
|
|
8
|
+
export const { Fragment, jsxDEV } = m;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// GENERATED by @pramen/cms-editor's build — do not edit.
|
|
2
|
+
// Resolves the bare specifier "react/jsx-runtime" for a panel bundle, against the React the
|
|
3
|
+
// editor already loaded. Wired up by the shell's import map; see docs/cms.md.
|
|
4
|
+
const ns = globalThis.PRAMEN_CMS_EDITOR_RUNTIME?.jsxRuntime;
|
|
5
|
+
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.");
|
|
6
|
+
const m = ns.default ?? ns;
|
|
7
|
+
export default m;
|
|
8
|
+
export const { Fragment, jsx, jsxs } = m;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// GENERATED by @pramen/cms-editor's build — do not edit.
|
|
2
|
+
// Resolves the bare specifier "react-dom" for a panel bundle, against the React the
|
|
3
|
+
// editor already loaded. Wired up by the shell's import map; see docs/cms.md.
|
|
4
|
+
const ns = globalThis.PRAMEN_CMS_EDITOR_RUNTIME?.reactDom;
|
|
5
|
+
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.");
|
|
6
|
+
const m = ns.default ?? ns;
|
|
7
|
+
export default m;
|
|
8
|
+
export const { createPortal, flushSync, preconnect, prefetchDNS, preinit, preinitModule, preload, preloadModule, requestFormReset, unstable_batchedUpdates, useFormState, useFormStatus, version } = m;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// GENERATED by @pramen/cms-editor's build — do not edit.
|
|
2
|
+
// Resolves the bare specifier "react" for a panel bundle, against the React the
|
|
3
|
+
// editor already loaded. Wired up by the shell's import map; see docs/cms.md.
|
|
4
|
+
const ns = globalThis.PRAMEN_CMS_EDITOR_RUNTIME?.react;
|
|
5
|
+
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.");
|
|
6
|
+
const m = ns.default ?? ns;
|
|
7
|
+
export default m;
|
|
8
|
+
export const { Activity, Children, Component, Fragment, Profiler, PureComponent, StrictMode, Suspense, act, cache, cacheSignal, captureOwnerStack, cloneElement, createContext, createElement, createRef, forwardRef, isValidElement, lazy, memo, startTransition, unstable_useCacheRefresh, use, useActionState, useCallback, useContext, useDebugValue, useDeferredValue, useEffect, useEffectEvent, useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, useMemo, useOptimistic, useReducer, useRef, useState, useSyncExternalStore, useTransition, version } = m;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms-editor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.64",
|
|
4
4
|
"description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
"type": "module",
|
|
14
14
|
"exports": {
|
|
15
15
|
"./editor.js": "./dist/editor.js",
|
|
16
|
-
"./editor.css": "./dist/editor.css"
|
|
16
|
+
"./editor.css": "./dist/editor.css",
|
|
17
|
+
"./panel-react.js": "./dist/panel-react.js",
|
|
18
|
+
"./panel-react-dom.js": "./dist/panel-react-dom.js",
|
|
19
|
+
"./panel-jsx-runtime.js": "./dist/panel-jsx-runtime.js",
|
|
20
|
+
"./panel-jsx-dev-runtime.js": "./dist/panel-jsx-dev-runtime.js"
|
|
17
21
|
},
|
|
18
22
|
"files": [
|
|
19
23
|
"dist",
|
package/src/app-context.tsx
CHANGED
|
@@ -29,6 +29,11 @@ declare global {
|
|
|
29
29
|
* Rendered as plain external `<a>` links, positioned by `order` (see `NAV_ORDER`) and
|
|
30
30
|
* defaulting to after the built-in tabs. */
|
|
31
31
|
extraNav?: { label: string; href: string; target?: "_blank" | "_self"; order?: number }[];
|
|
32
|
+
/** Module URLs of this deployment's PANEL bundles — the project's own React screens,
|
|
33
|
+
* rendered inside the chrome at `/apps/:slug`. Imported by the editor at boot (see
|
|
34
|
+
* `main.tsx`), not by a script tag of the shell's, because the shared React they
|
|
35
|
+
* import has to be published first. */
|
|
36
|
+
panels?: string[];
|
|
32
37
|
/** The wordmark in the topbar, on the Setup screen, and in the browser tab.
|
|
33
38
|
*
|
|
34
39
|
* This editor ships as a package an agency deploys FOR ITS CLIENT, so the default
|
|
@@ -98,9 +103,10 @@ interface AppContextValue {
|
|
|
98
103
|
/** Collections registered on the server (from `listCollections`) — drives the nav + the
|
|
99
104
|
* generic list/edit routes. Empty when the server registers none. */
|
|
100
105
|
collections: CollectionMeta[];
|
|
101
|
-
/** Custom admin
|
|
102
|
-
* project registered with `adminPage()
|
|
103
|
-
* nav position they choose, which is the
|
|
106
|
+
/** Custom admin screens the CALLER may open (from `listAdminPages`) — Block Kit pages a
|
|
107
|
+
* project registered with `adminPage()`, and panels it registered with `adminPanel()`.
|
|
108
|
+
* They render inside the editor's own chrome, at a nav position they choose, which is the
|
|
109
|
+
* difference from an `extraNav` link.
|
|
104
110
|
*
|
|
105
111
|
* Filtered server-side by role, so there is no entry here the caller cannot open. An app
|
|
106
112
|
* that registers none (or a server without the handler) leaves this empty; a failure is
|
package/src/blockkit.tsx
CHANGED
|
@@ -20,7 +20,8 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
20
20
|
import type { Api } from "./api";
|
|
21
21
|
import { CONTROL } from "./fields";
|
|
22
22
|
import { WRAP } from "./chrome";
|
|
23
|
-
import
|
|
23
|
+
import { ADMIN_ELEMENT_TYPES } from "./types";
|
|
24
|
+
import type { AdminBlock, AdminCell, AdminElement, AdminInput, AdminPageResponse, JsonValue } from "./types";
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
/** Values held for the inputs of one block, keyed by `action_id`. */
|
|
@@ -138,10 +139,10 @@ export function BlockList({ blocks, values, setValue, disabled, onFire }: { bloc
|
|
|
138
139
|
);
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
/** A stable React key for a block. `form` carries a required `block_id`; `actions`
|
|
142
|
-
* Prefixed so a block_id can never collide with a bare index from a sibling. */
|
|
142
|
+
/** A stable React key for a block. `form` carries a required `block_id`; `actions` and
|
|
143
|
+
* `table` may. Prefixed so a block_id can never collide with a bare index from a sibling. */
|
|
143
144
|
function blockKey(block: AdminBlock, i: number): string {
|
|
144
|
-
const id = block.type === "form"
|
|
145
|
+
const id = block.type === "form" || block.type === "actions" || block.type === "table" ? block.block_id : undefined;
|
|
145
146
|
return id ? `id:${id}` : `${block.type}:${i}`;
|
|
146
147
|
}
|
|
147
148
|
|
|
@@ -188,7 +189,10 @@ function BlockView({ block, values, setValue, disabled, onFire }: { block: Admin
|
|
|
188
189
|
</div>
|
|
189
190
|
);
|
|
190
191
|
case "table":
|
|
191
|
-
|
|
192
|
+
// The only interactive-capable block that used NOT to get the value bag. A row could
|
|
193
|
+
// show that a venue is hidden and could not offer the switch, so a list of 800 rows
|
|
194
|
+
// had to be written as 800 `actions` blocks — a table with the table taken out.
|
|
195
|
+
return <TableBlock block={block} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />;
|
|
192
196
|
case "image":
|
|
193
197
|
return (
|
|
194
198
|
<figure className="m-0">
|
|
@@ -222,7 +226,7 @@ function BlockView({ block, values, setValue, disabled, onFire }: { block: Admin
|
|
|
222
226
|
}
|
|
223
227
|
}
|
|
224
228
|
|
|
225
|
-
function TableBlock({ block }: { block: Extract<AdminBlock, { type: "table" }
|
|
229
|
+
function TableBlock({ block, values, setValue, disabled, onFire }: { block: Extract<AdminBlock, { type: "table" }>; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
226
230
|
if (block.rows.length === 0) return <p className="text-sm text-fg-subtle">{block.empty ?? "Nothing here."}</p>;
|
|
227
231
|
return (
|
|
228
232
|
// Wide tables scroll INSIDE their own container; the page must not scroll sideways.
|
|
@@ -239,7 +243,9 @@ function TableBlock({ block }: { block: Extract<AdminBlock, { type: "table" }> }
|
|
|
239
243
|
{block.rows.map((row, i) => (
|
|
240
244
|
<tr key={i}>
|
|
241
245
|
{block.columns.map((c) => (
|
|
242
|
-
<td key={c.key} className="border-b border-border px-3 py-2 text-fg">
|
|
246
|
+
<td key={c.key} className="border-b border-border px-3 py-2 text-fg">
|
|
247
|
+
<CellView value={row[c.key]} blockId={block.block_id} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />
|
|
248
|
+
</td>
|
|
243
249
|
))}
|
|
244
250
|
</tr>
|
|
245
251
|
))}
|
|
@@ -249,9 +255,31 @@ function TableBlock({ block }: { block: Extract<AdminBlock, { type: "table" }> }
|
|
|
249
255
|
);
|
|
250
256
|
}
|
|
251
257
|
|
|
252
|
-
|
|
258
|
+
/** One cell: a value to read, or a control to act with.
|
|
259
|
+
*
|
|
260
|
+
* A cell's element is the SAME `ElementView` an `actions` block renders, on the same page
|
|
261
|
+
* value bag and the same `onFire` — a row's button is not a special kind of button, it is a
|
|
262
|
+
* button that happens to sit in a row. What identifies the row is the button's `value`,
|
|
263
|
+
* which is the idiom that already existed ("one `action_id` can serve a row"); an input in a
|
|
264
|
+
* cell has to carry a per-row `action_id` instead, since the bag is keyed by it, and the
|
|
265
|
+
* server refuses a response where two of them collide. */
|
|
266
|
+
function CellView({ value, blockId, values, setValue, disabled, onFire }: { value: AdminCell | undefined; blockId?: string; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
267
|
+
if (isElementCell(value)) return <ElementView el={value} blockId={blockId} compact values={values} setValue={setValue} disabled={disabled} onFire={onFire} />;
|
|
268
|
+
return <>{cell(value)}</>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A cell is a value or an element, told apart by shape — the same discrimination the server
|
|
272
|
+
* enforces on the way out, so nothing else can be an object by the time it gets here. */
|
|
273
|
+
function isElementCell(v: AdminCell | undefined): v is AdminElement {
|
|
274
|
+
return v !== null && typeof v === "object" && (ADMIN_ELEMENT_TYPES as readonly string[]).includes(v.type);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function cell(v: AdminCell | undefined): string {
|
|
253
278
|
if (v === null || v === undefined) return "";
|
|
254
279
|
if (typeof v === "boolean") return v ? "yes" : "no";
|
|
280
|
+
// An object that is not an element cannot reach a browser through a checked response; if
|
|
281
|
+
// one does, it is named rather than stringified into `[object Object]`.
|
|
282
|
+
if (typeof v === "object") return `[unsupported cell: ${String(v.type)}]`;
|
|
255
283
|
return String(v);
|
|
256
284
|
}
|
|
257
285
|
|
|
@@ -260,28 +288,46 @@ function ActionsBlock({ block, values, setValue, disabled, onFire }: { block: Ex
|
|
|
260
288
|
// `onFire` attaches the whole bag, so a filter in this block reaches a button in another.
|
|
261
289
|
return (
|
|
262
290
|
<div className="flex flex-wrap items-end gap-3">
|
|
263
|
-
{block.elements.map((el, i) =>
|
|
264
|
-
el.type === "button" ?
|
|
265
|
-
|
|
266
|
-
key={i}
|
|
267
|
-
variant={el.style === "primary" ? "primary" : el.style === "danger" ? "ghost" : "secondary"}
|
|
268
|
-
className={el.style === "danger" ? "text-danger" : undefined}
|
|
269
|
-
isDisabled={disabled}
|
|
270
|
-
onPress={() => {
|
|
271
|
-
if (el.confirm && !confirm(el.confirm)) return;
|
|
272
|
-
onFire({ type: "block_action", action_id: el.action_id, block_id: block.block_id, value: el.value ?? null });
|
|
273
|
-
}}
|
|
274
|
-
>
|
|
275
|
-
{el.label}
|
|
276
|
-
</Button>
|
|
277
|
-
) : (
|
|
278
|
-
<InputView key={el.action_id} input={el} value={values[el.action_id]} onChange={(v) => setValue(el.action_id, v)} disabled={disabled} />
|
|
279
|
-
),
|
|
280
|
-
)}
|
|
291
|
+
{block.elements.map((el, i) => (
|
|
292
|
+
<ElementView key={el.type === "button" ? `b:${i}` : `i:${el.action_id}`} el={el} blockId={block.block_id} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />
|
|
293
|
+
))}
|
|
281
294
|
</div>
|
|
282
295
|
);
|
|
283
296
|
}
|
|
284
297
|
|
|
298
|
+
/**
|
|
299
|
+
* One element — a button that fires, or an input bound to the page's value bag.
|
|
300
|
+
*
|
|
301
|
+
* Shared by `actions` and by a table cell so that the two cannot drift: a row's control has
|
|
302
|
+
* to reach the handler with exactly what a toolbar control reaches it with, or "the button
|
|
303
|
+
* in the row" becomes a second, weaker kind of button.
|
|
304
|
+
*
|
|
305
|
+
* `compact` is a rendering decision, not vocabulary: a control inside a data row is not a
|
|
306
|
+
* toolbar call-to-action, and 800 pill buttons at CTA size make a table unreadable. The
|
|
307
|
+
* server says what the control IS; the host decides how big it draws.
|
|
308
|
+
*/
|
|
309
|
+
function ElementView({ el, blockId, compact, values, setValue, disabled, onFire }: { el: AdminElement; blockId?: string; compact?: boolean; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
310
|
+
if (el.type === "button") {
|
|
311
|
+
return (
|
|
312
|
+
<Button
|
|
313
|
+
size={compact ? "sm" : undefined}
|
|
314
|
+
variant={el.style === "primary" ? "primary" : el.style === "danger" ? "ghost" : "secondary"}
|
|
315
|
+
className={el.style === "danger" ? "text-danger" : undefined}
|
|
316
|
+
isDisabled={disabled}
|
|
317
|
+
onPress={() => {
|
|
318
|
+
if (el.confirm && !confirm(el.confirm)) return;
|
|
319
|
+
// `value` is what identifies the ROW: one `action_id` serves every row of a table,
|
|
320
|
+
// and the value says which one it was.
|
|
321
|
+
onFire({ type: "block_action", action_id: el.action_id, block_id: blockId, value: el.value ?? null });
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
{el.label}
|
|
325
|
+
</Button>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return <InputView input={el} value={values[el.action_id]} onChange={(v) => setValue(el.action_id, v)} disabled={disabled} />;
|
|
329
|
+
}
|
|
330
|
+
|
|
285
331
|
function FormBlock({ block, values, setValue, disabled, onFire }: { block: Extract<AdminBlock, { type: "form" }>; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
286
332
|
// No local state: the page owns the bag and re-seeds it from every response, so a form
|
|
287
333
|
// cannot keep values the server has since replaced. (The `block_id` key in `BlockList` is
|
|
@@ -313,20 +359,27 @@ function FormBlock({ block, values, setValue, disabled, onFire }: { block: Extra
|
|
|
313
359
|
|
|
314
360
|
const isInput = (el: AdminElement): el is AdminInput => el.type !== "button";
|
|
315
361
|
|
|
316
|
-
/** Every input on a page, in declaration order, including those
|
|
317
|
-
* `accordion`. The page seeds its whole value bag from this on every
|
|
362
|
+
/** Every input on a page, in declaration order, including those in table cells and those
|
|
363
|
+
* nested in `columns` and `accordion`. The page seeds its whole value bag from this on every
|
|
364
|
+
* response — an input the walk misses renders empty however good its `initial_value` was,
|
|
365
|
+
* and then submits that emptiness. */
|
|
318
366
|
function collectInputs(blocks: readonly AdminBlock[], out: AdminInput[] = []): AdminInput[] {
|
|
319
367
|
for (const b of blocks) {
|
|
320
368
|
if (b.type === "form") out.push(...b.fields);
|
|
321
369
|
else if (b.type === "actions") out.push(...b.elements.filter(isInput));
|
|
370
|
+
// Only cells a COLUMN names, because only those are rendered — the same set the server
|
|
371
|
+
// checks for colliding `action_id`s, so the two halves agree on what is on the page.
|
|
372
|
+
else if (b.type === "table") for (const row of b.rows) for (const c of b.columns) { const v = row[c.key]; if (isElementCell(v) && isInput(v)) out.push(v); }
|
|
322
373
|
else if (b.type === "columns") for (const col of b.columns) collectInputs(col, out);
|
|
323
374
|
else if (b.type === "accordion") collectInputs(b.blocks, out);
|
|
324
375
|
}
|
|
325
376
|
return out;
|
|
326
377
|
}
|
|
327
378
|
|
|
328
|
-
/** The page's value bag as a fresh response describes it.
|
|
329
|
-
|
|
379
|
+
/** The page's value bag as a fresh response describes it. Exported for the test that holds
|
|
380
|
+
* the seeding contract: it is a pure function of one response, and a table cell's input is
|
|
381
|
+
* only reachable through it. */
|
|
382
|
+
export function seedValues(blocks: readonly AdminBlock[]): BlockValues {
|
|
330
383
|
return initialValues(collectInputs(blocks));
|
|
331
384
|
}
|
|
332
385
|
|
|
@@ -353,11 +406,31 @@ function InputView({ input, value, onChange, disabled }: { input: AdminInput; va
|
|
|
353
406
|
</span>
|
|
354
407
|
) : null;
|
|
355
408
|
|
|
409
|
+
/**
|
|
410
|
+
* The server's verdict on THIS field, under the field it is about.
|
|
411
|
+
*
|
|
412
|
+
* The page-level `toast` was the only failure surface there was, and it is the wrong one
|
|
413
|
+
* for "25:00 is not a time" or "the end is before the start": it names no field, it is
|
|
414
|
+
* gone in three seconds, and it floats at the top of a form whose sixth input is the
|
|
415
|
+
* problem. It cannot be worked around from the page either — `form` renders a flat list of
|
|
416
|
+
* inputs, so a page cannot interleave a `context` block to put the message where it
|
|
417
|
+
* belongs.
|
|
418
|
+
*
|
|
419
|
+
* It is drawn from the response, not from state: the whole page re-renders on every
|
|
420
|
+
* interaction, so the error is exactly as old as the values beside it and there is nothing
|
|
421
|
+
* to invalidate. `role="alert"` because it appears in answer to something the user just
|
|
422
|
+
* did, so a screen reader has to be told without being asked.
|
|
423
|
+
*/
|
|
424
|
+
const error = input.error ? (
|
|
425
|
+
<span role="alert" className="text-caption text-danger">{input.error}</span>
|
|
426
|
+
) : null;
|
|
427
|
+
|
|
356
428
|
if (input.type === "toggle") {
|
|
357
429
|
return (
|
|
358
430
|
<label className="flex items-center gap-2">
|
|
359
|
-
<input type="checkbox" checked={value === true} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
|
|
431
|
+
<input type="checkbox" checked={value === true} disabled={disabled} aria-invalid={input.error ? true : undefined} onChange={(e) => onChange(e.target.checked)} />
|
|
360
432
|
<span className="text-sm text-fg">{input.label ?? input.action_id}</span>
|
|
433
|
+
{error}
|
|
361
434
|
</label>
|
|
362
435
|
);
|
|
363
436
|
}
|
|
@@ -366,7 +439,7 @@ function InputView({ input, value, onChange, disabled }: { input: AdminInput; va
|
|
|
366
439
|
<label className="flex min-w-[180px] flex-col gap-1.5">
|
|
367
440
|
{label}
|
|
368
441
|
{input.type === "select" ? (
|
|
369
|
-
<select className={CONTROL} value={typeof value === "string" ? value : ""} disabled={disabled} aria-label={input.label ?? input.action_id} onChange={(e) => onChange(e.target.value)}>
|
|
442
|
+
<select className={CONTROL} value={typeof value === "string" ? value : ""} disabled={disabled} aria-invalid={input.error ? true : undefined} aria-label={input.label ?? input.action_id} onChange={(e) => onChange(e.target.value)}>
|
|
370
443
|
<option value="">—</option>
|
|
371
444
|
{input.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
372
445
|
</select>
|
|
@@ -378,6 +451,7 @@ function InputView({ input, value, onChange, disabled }: { input: AdminInput; va
|
|
|
378
451
|
max={input.max}
|
|
379
452
|
placeholder={input.placeholder}
|
|
380
453
|
disabled={disabled}
|
|
454
|
+
aria-invalid={input.error ? true : undefined}
|
|
381
455
|
aria-label={input.label ?? input.action_id}
|
|
382
456
|
value={typeof value === "number" ? String(value) : ""}
|
|
383
457
|
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
|
|
@@ -387,6 +461,7 @@ function InputView({ input, value, onChange, disabled }: { input: AdminInput; va
|
|
|
387
461
|
className={`${CONTROL} h-auto min-h-24 py-2.5`}
|
|
388
462
|
placeholder={input.placeholder}
|
|
389
463
|
disabled={disabled}
|
|
464
|
+
aria-invalid={input.error ? true : undefined}
|
|
390
465
|
aria-label={input.label ?? input.action_id}
|
|
391
466
|
value={typeof value === "string" ? value : ""}
|
|
392
467
|
onChange={(e) => onChange(e.target.value)}
|
|
@@ -400,11 +475,13 @@ function InputView({ input, value, onChange, disabled }: { input: AdminInput; va
|
|
|
400
475
|
autoComplete={input.type === "secret_input" ? "new-password" : undefined}
|
|
401
476
|
placeholder={input.placeholder}
|
|
402
477
|
disabled={disabled}
|
|
478
|
+
aria-invalid={input.error ? true : undefined}
|
|
403
479
|
aria-label={input.label ?? input.action_id}
|
|
404
480
|
value={typeof value === "string" ? value : ""}
|
|
405
481
|
onChange={(e) => onChange(e.target.value)}
|
|
406
482
|
/>
|
|
407
483
|
)}
|
|
484
|
+
{error}
|
|
408
485
|
</label>
|
|
409
486
|
);
|
|
410
487
|
}
|
package/src/main.tsx
CHANGED
|
@@ -5,6 +5,9 @@ import { pageRegistry, routes } from "virtual:buzola/routes";
|
|
|
5
5
|
import { AppProvider } from "./app-context";
|
|
6
6
|
import { DOCUMENT_TITLE } from "./brand";
|
|
7
7
|
import { isWithinBasePath, readBasePath, scopeToBasePath } from "./mount";
|
|
8
|
+
import { publishPanelRuntime, type PanelRuntimeHost } from "./panel-runtime";
|
|
9
|
+
import { loadPanelBundles, readPanelUrls, type PanelHost } from "./panels";
|
|
10
|
+
import { initTheme } from "./theme";
|
|
8
11
|
|
|
9
12
|
// Styling is podoba: the compiled Tailwind (podoba preset, with @podoba/tokens' variables
|
|
10
13
|
// and the web font inlined) is a single stylesheet the SHELL links — see scripts/build.ts
|
|
@@ -19,6 +22,21 @@ import { isWithinBasePath, readBasePath, scopeToBasePath } from "./mount";
|
|
|
19
22
|
// tab, and `suffix: null` ("just our name") could never drop it.
|
|
20
23
|
document.title = DOCUMENT_TITLE;
|
|
21
24
|
|
|
25
|
+
// Before the first paint, so an editor left in dark mode does not flash white on every load
|
|
26
|
+
// — which is what an effect inside the root layout could never avoid.
|
|
27
|
+
initTheme();
|
|
28
|
+
|
|
29
|
+
// The shared React, published FIRST: a panel bundle's very first `import "react"` resolves
|
|
30
|
+
// through the shell's import map to a shim that reads this back out, so the global has to
|
|
31
|
+
// exist before any bundle is imported. See `panel-runtime.ts`.
|
|
32
|
+
publishPanelRuntime(globalThis as PanelRuntimeHost);
|
|
33
|
+
|
|
34
|
+
// …then the bundles themselves, NOT awaited. Every other fact the chrome is built from
|
|
35
|
+
// arrives a round trip late too (`listAdminPages` is what puts a panel in the nav at all),
|
|
36
|
+
// and awaiting a third-party fetch here would mean one hanging request is a blank admin.
|
|
37
|
+
// A panel's route re-reads the registry as registrations land — see `panels.ts`.
|
|
38
|
+
void loadPanelBundles(readPanelUrls(globalThis as PanelHost, typeof location === "undefined" ? "" : location.href));
|
|
39
|
+
|
|
22
40
|
const el = document.getElementById("app");
|
|
23
41
|
const basePath = readBasePath(el);
|
|
24
42
|
|
|
@@ -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
|
+
}
|