@pramen/cms 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.
@@ -1,8 +1,16 @@
1
1
  import type { HandlerContext, JsonValue, SchemaDef } from "@pramen/server";
2
+ import { type AdminPanelDef } from "./panel";
2
3
  /** Text with no formatting. Rendered as text, never as markup — the editor puts every
3
4
  * string through React, so there is no HTML path here to sanitize. */
4
5
  export type AdminText = string;
5
- /** An input a form (or an actions row) can carry. */
6
+ /** An input a form, an actions row or a table cell can carry.
7
+ *
8
+ * `error` is the per-FIELD failure, rendered under the offending input. The page-level
9
+ * `toast` cannot do that job: it names no field, it is gone in three seconds while the bad
10
+ * value is still on screen, and a form with six inputs gives the reader no way to tell which
11
+ * one "25:00 is not a time" is about. It is a plain part of the render — the whole page
12
+ * comes back on every interaction, so an error lives exactly as long as the response that
13
+ * carried it, and there is nothing to clear. */
6
14
  export type AdminInput = {
7
15
  type: "text_input";
8
16
  action_id: string;
@@ -11,6 +19,7 @@ export type AdminInput = {
11
19
  initial_value?: string;
12
20
  multiline?: boolean;
13
21
  required?: boolean;
22
+ error?: AdminText;
14
23
  } | {
15
24
  type: "number_input";
16
25
  action_id: string;
@@ -20,6 +29,7 @@ export type AdminInput = {
20
29
  min?: number;
21
30
  max?: number;
22
31
  required?: boolean;
32
+ error?: AdminText;
23
33
  } | {
24
34
  type: "select";
25
35
  action_id: string;
@@ -30,11 +40,13 @@ export type AdminInput = {
30
40
  }[];
31
41
  initial_value?: string;
32
42
  required?: boolean;
43
+ error?: AdminText;
33
44
  } | {
34
45
  type: "toggle";
35
46
  action_id: string;
36
47
  label?: AdminText;
37
48
  initial_value?: boolean;
49
+ error?: AdminText;
38
50
  }
39
51
  /** Write-only: never echoed back to the browser once stored. The editor renders it as a
40
52
  * password field and sends it only on submit; a page that stores one must NOT put it back
@@ -45,6 +57,7 @@ export type AdminInput = {
45
57
  label?: AdminText;
46
58
  placeholder?: AdminText;
47
59
  required?: boolean;
60
+ error?: AdminText;
48
61
  };
49
62
  /** A button. `value` rides back on the interaction, so one `action_id` can serve a row. */
50
63
  export interface AdminButton {
@@ -58,6 +71,30 @@ export interface AdminButton {
58
71
  confirm?: AdminText;
59
72
  }
60
73
  export type AdminElement = AdminButton | AdminInput;
74
+ /** Every `AdminElement` tag, as a runtime set.
75
+ *
76
+ * Both halves need this at RUNTIME, which is why it is a value and not only a union: the
77
+ * server checks that an object sitting in a table cell really is an element, and the editor
78
+ * decides from the same set whether a cell draws as text or as a control. The editor keeps
79
+ * its own copy (it has no dependency on this package) and `test/cms-editor-mirrors.test.ts`
80
+ * fails if the two drift. */
81
+ export declare const ADMIN_ELEMENT_TYPES: readonly ["button", "text_input", "number_input", "select", "toggle", "secret_input"];
82
+ /** What one table cell holds: a value to READ, or an element to ACT with.
83
+ *
84
+ * The alternative shape was a per-COLUMN element declaration — `columns: [{ key, label,
85
+ * element }]` — and it is the wrong unit. Everything about a row's control is a fact of the
86
+ * ROW: the button's `value` is that row's id, its label is "Hide" or "Show" depending on
87
+ * that row's state, and a row that must not be touched carries no control at all. A column
88
+ * declaration would have to be a template with a substitution language, which is a second
89
+ * vocabulary to design and to escape. A cell already varies per row, so the element goes in
90
+ * the cell and the column keeps saying only where it lands. A cell is a value OR an element,
91
+ * never both; a column that wants both is two columns.
92
+ *
93
+ * The two are told apart by SHAPE: a display value is a primitive, an element is an object,
94
+ * and nothing else may be an object. `normalizeAdminResponse` enforces that, so a page that
95
+ * splats a whole row (`rows: found`) into the table is named at the boundary instead of
96
+ * rendering a column of `[object Object]` — or, worse, of half-elements. */
97
+ export type AdminCell = AdminText | number | boolean | null | AdminElement;
61
98
  /** One block in a rendered admin page. */
62
99
  export type AdminBlock = {
63
100
  type: "header";
@@ -81,13 +118,17 @@ export type AdminBlock = {
81
118
  label: AdminText;
82
119
  value: AdminText;
83
120
  }[];
84
- } | {
121
+ }
122
+ /** `block_id` rides back on an interaction a CELL fired, the same way an `actions` block's
123
+ * does — so a page with two tables can tell which one a shared `action_id` came from. */
124
+ | {
85
125
  type: "table";
126
+ block_id?: string;
86
127
  columns: {
87
128
  key: string;
88
129
  label: AdminText;
89
130
  }[];
90
- rows: Record<string, AdminText | number | boolean | null>[];
131
+ rows: Record<string, AdminCell>[];
91
132
  empty?: AdminText;
92
133
  } | {
93
134
  type: "stats";
@@ -165,6 +206,10 @@ export declare const MAX_ADMIN_BLOCK_DEPTH = 4;
165
206
  * Without it `ctx.db.find({ from: "lectures", where: { title: { contains: q } } })` resolves
166
207
  * the table against the default `SchemaDef` and every column reads as a number. */
167
208
  export interface AdminPageDef<S extends SchemaDef = SchemaDef> {
209
+ /** Discriminates this from an `AdminPanelDef` in the registry the two share. Optional and
210
+ * defaulted, because a Block Kit page is what `adminPage()` has always produced and
211
+ * nothing should have to start saying so. */
212
+ readonly kind?: "blocks";
168
213
  /** URL + registry key: the page is served at `/apps/:slug` in the editor. */
169
214
  readonly slug: string;
170
215
  /** Nav label. */
@@ -198,19 +243,39 @@ export interface AdminPageDef<S extends SchemaDef = SchemaDef> {
198
243
  * });
199
244
  */
200
245
  export declare function adminPage<S extends SchemaDef = SchemaDef>(slug: string, opts: Omit<AdminPageDef<S>, "slug">): AdminPageDef<S>;
201
- /** The client-facing view of a page what the editor needs to put it in the nav. Never
202
- * the `render` function, and never the role list (which is a server fact; a page the caller
203
- * may not open is simply absent from the listing). */
246
+ /** One entry in the custom-screens registry: a Block Kit page, or a panel the browser
247
+ * renders. They share a registry and so a slug space, a route and a nav band — because
248
+ * from the editor's side they are the same thing (a project's own screen inside the chrome)
249
+ * differing only in where the rendering happens. Two registries would have made a slug
250
+ * collision between them a runtime surprise instead of a boot error. */
251
+ export type AdminScreenDef<S extends SchemaDef = SchemaDef> = AdminPageDef<S> | AdminPanelDef;
252
+ /** Every screen kind, as a runtime set. A value and not only a union because the editor
253
+ * keeps its own copy and `test/cms-editor-mirrors.test.ts` fails if the two drift — a kind
254
+ * the server sends and the editor has not heard of is a nav entry that renders nothing. */
255
+ export declare const ADMIN_PAGE_KINDS: readonly ["blocks", "panel"];
256
+ export type AdminPageKind = (typeof ADMIN_PAGE_KINDS)[number];
257
+ /** The client-facing view of a registered screen — what the editor needs to put it in the
258
+ * nav and decide how to render it. Never the `render` function, and never the role list
259
+ * (which is a server fact; a screen the caller may not open is simply absent from the
260
+ * listing). */
204
261
  export interface AdminPageMeta {
205
262
  slug: string;
206
263
  label: string;
207
264
  icon?: string;
208
265
  navOrder: number;
266
+ /** `"blocks"` (Block Kit, rendered from this response) or `"panel"` (a component the
267
+ * deployment's panel bundle registered under this slug). */
268
+ kind: AdminPageKind;
209
269
  }
210
- /** Validate a registry at boot: slugs are unique and routable, and every page can be
270
+ /** Validate a registry at boot: slugs are unique and routable, and every screen can be
211
271
  * addressed. Called by `createAdminPageHandlers`, so a mistake surfaces when the Worker
212
- * starts rather than as a 404 the first time someone opens the one page nobody exercised. */
213
- export declare function validateAdminPages(pages: readonly AdminPageDef[]): void;
272
+ * starts rather than as a 404 the first time someone opens the one page nobody exercised.
273
+ *
274
+ * Pages and panels are validated TOGETHER, against one `seen` set: they share `/apps/:slug`,
275
+ * so two entries with the same slug are a collision whatever their kinds — and the one that
276
+ * would win is decided by insertion order into a Map, which is not a thing to leave to
277
+ * chance. */
278
+ export declare function validateAdminPages(pages: readonly AdminScreenDef[]): void;
214
279
  export interface AdminPageHandlerOpts {
215
280
  /** Default roles for a page that declares none. Pass the same `editorRoles` the rest of
216
281
  * the CMS uses, so one deployment has one answer to "who may author". */
@@ -224,10 +289,14 @@ export interface AdminPageHandlerOpts {
224
289
  * There is no ACL fragment to spread: a page reads through `ctx.db` under whatever policies
225
290
  * the caller already has, so there is nothing here to grant.
226
291
  */
227
- export declare function createAdminPageHandlers(pages: readonly AdminPageDef[], opts?: AdminPageHandlerOpts): {
228
- /** The pages THIS caller may open. Filtered rather than role-annotated: a nav entry
229
- * that 403s when clicked is worse than one that is not there, and the role list is a
230
- * server fact the browser has no use for. */
292
+ export declare function createAdminPageHandlers(pages: readonly AdminScreenDef[], opts?: AdminPageHandlerOpts): {
293
+ /** The screens THIS caller may open Block Kit pages and panels alike. Filtered rather
294
+ * than role-annotated: a nav entry that 403s when clicked is worse than one that is not
295
+ * there, and the role list is a server fact the browser has no use for.
296
+ *
297
+ * A panel is filtered by exactly the same gate, which is the whole reason it is a
298
+ * registry entry rather than a client-side registration: the browser bundle decides
299
+ * only how the screen DRAWS, never whether this caller has one. */
231
300
  listAdminPages: import("@pramen/server").Handler<unknown, AdminPageMeta[]>;
232
301
  /**
233
302
  * Render a page, or act on it and render the result.
@@ -251,6 +320,9 @@ export declare function createAdminPageHandlers(pages: readonly AdminPageDef[],
251
320
  * - `image.url` becomes an `<img src>`, so it goes through the same `isSafeHref`
252
321
  * allow-list a rich-text link mark does.
253
322
  * - nesting is capped, because rendering is recursive.
323
+ * - a table cell that is an OBJECT is claiming to be an element, and is checked as one.
324
+ * - every input's `action_id` is claimed once per page, because the editor keys the
325
+ * page's whole value bag by it.
254
326
  *
255
327
  * A bad block throws rather than being dropped: this is the page author's own output, and a
256
328
  * block that silently vanishes is a bug that reads as "the data isn't there".
package/dist/blockkit.js CHANGED
@@ -21,6 +21,46 @@
21
21
  // `FieldDefinition[]` -> `FieldForm` is already "server-described form, host-rendered". Block
22
22
  // Kit is those two taken all the way: arbitrary admin PAGES, not just forms over rows.
23
23
  //
24
+ // AND YET `adminPanel()` SHIPS BESIDE IT, WHICH IS PROJECT CODE IN THE BROWSER
25
+ //
26
+ // It does, and the paragraph above is not quietly wrong — the two are one position, not two.
27
+ // What that paragraph rejects is publishing the EDITOR'S COMPONENT LIBRARY so that every
28
+ // project assembles its own admin out of it: that is what makes N forks of the same 80%,
29
+ // because each project then owns the chrome, the nav, the page list, the login and the media
30
+ // browser, and every fix has to be made N times. A panel is the opposite trade — ONE SCREEN,
31
+ // rendered inside chrome that is still ours, at a route that is still ours, from a registry
32
+ // entry that is still the server's. No component API is published: a panel is handed React,
33
+ // four props and nothing else (`panel-runtime.ts` in @pramen/cms-editor is a long argument
34
+ // about what is deliberately absent from that list), so there is nothing to reassemble an
35
+ // admin out of and no reason to fork one.
36
+ //
37
+ // What the objection DOES carry over is version skew, and it is not waved away: a panel is
38
+ // compiled at the project's build, against whichever React they had, and linked at runtime
39
+ // against whichever React the editor loaded. That is why the seam is a numbered contract a
40
+ // bundle must STATE and the editor refuses on mismatch, rather than a promise in a README.
41
+ // Skew becomes one refusal naming the slug and the fix, on the screen the panel should have
42
+ // been — which is precisely what "N per-project forks" never had.
43
+ //
44
+ // WHICH TO REACH FOR, AND WHAT THE SECOND ONE COSTS
45
+ //
46
+ // `adminPage()` for a list-and-form screen: rows, filters, a form, a confirm. No build step,
47
+ // no bundle to keep deployed in step with the admin, no React version to keep aligned, and no
48
+ // project code in the browser at all. `adminPanel()` when the screen IS the interaction —
49
+ // something that responds as you type, a row that expands, a dialog, a canvas, a date input.
50
+ // Those are not elements Block Kit happens to be missing; they are things a server-driven
51
+ // vocabulary cannot express, and the list does not shrink by adding blocks.
52
+ //
53
+ // The cost, stated plainly, because it is what makes that order more than a preference: A
54
+ // PANEL IS TRUSTED CODE. Its bundle runs in the editor's own page with the editor's own
55
+ // session in scope — it can read the stored token, make any call the caller could make, and
56
+ // render anything anywhere on the page. `roles` on `adminPanel()` decides who is SHOWN the
57
+ // screen and the ACL still bounds what the server will do for whoever is asking, but neither
58
+ // constrains the bundle. `PanelApi` is a CONVENIENCE — the one obvious way to make an
59
+ // authenticated call — and not a sandbox; there is no sandbox to be had short of a
60
+ // cross-origin iframe, which would give up the shared chrome that is the entire point. Ship a
61
+ // panel you wrote, from your own origin, and treat its bundle as part of the admin. Block
62
+ // Kit's headline property is that none of this paragraph is ever needed.
63
+ //
24
64
  // WHAT IT IS NOT
25
65
  //
26
66
  // It is not a way to reach past the ACL. A page's `render` is an ordinary handler body: it
@@ -34,6 +74,15 @@
34
74
  import { BadRequest, mutation, query } from "@pramen/server";
35
75
  import { isSafeHref, normalizeHref } from "./href";
36
76
  import { NAV_ORDER } from "./nav";
77
+ import { isAdminPanel } from "./panel";
78
+ /** Every `AdminElement` tag, as a runtime set.
79
+ *
80
+ * Both halves need this at RUNTIME, which is why it is a value and not only a union: the
81
+ * server checks that an object sitting in a table cell really is an element, and the editor
82
+ * decides from the same set whether a cell draws as text or as a control. The editor keeps
83
+ * its own copy (it has no dependency on this package) and `test/cms-editor-mirrors.test.ts`
84
+ * fails if the two drift. */
85
+ export const ADMIN_ELEMENT_TYPES = ["button", "text_input", "number_input", "select", "toggle", "secret_input"];
37
86
  /** How deep `columns` / `accordion` may nest blocks. Rendering is recursive and the
38
87
  * response is server-authored but not necessarily hand-written, so it is capped. */
39
88
  export const MAX_ADMIN_BLOCK_DEPTH = 4;
@@ -53,9 +102,18 @@ export const MAX_ADMIN_BLOCK_DEPTH = 4;
53
102
  export function adminPage(slug, opts) {
54
103
  return { ...opts, slug };
55
104
  }
56
- /** Validate a registry at boot: slugs are unique and routable, and every page can be
105
+ /** Every screen kind, as a runtime set. A value and not only a union because the editor
106
+ * keeps its own copy and `test/cms-editor-mirrors.test.ts` fails if the two drift — a kind
107
+ * the server sends and the editor has not heard of is a nav entry that renders nothing. */
108
+ export const ADMIN_PAGE_KINDS = ["blocks", "panel"];
109
+ /** Validate a registry at boot: slugs are unique and routable, and every screen can be
57
110
  * addressed. Called by `createAdminPageHandlers`, so a mistake surfaces when the Worker
58
- * starts rather than as a 404 the first time someone opens the one page nobody exercised. */
111
+ * starts rather than as a 404 the first time someone opens the one page nobody exercised.
112
+ *
113
+ * Pages and panels are validated TOGETHER, against one `seen` set: they share `/apps/:slug`,
114
+ * so two entries with the same slug are a collision whatever their kinds — and the one that
115
+ * would win is decided by insertion order into a Map, which is not a thing to leave to
116
+ * chance. */
59
117
  export function validateAdminPages(pages) {
60
118
  const seen = new Set();
61
119
  for (const p of pages) {
@@ -67,7 +125,10 @@ export function validateAdminPages(pages) {
67
125
  seen.add(p.slug);
68
126
  if (p.label.trim() === "")
69
127
  throw new Error(`pramen/cms: admin page '${p.slug}' has an empty label — it would render an unnamed nav entry`);
70
- if (typeof p.render !== "function")
128
+ // A panel has no server render by definition, so the check is skipped for it rather
129
+ // than relaxed for everyone: "no render function" stays a boot error for a Block Kit
130
+ // page, which is the one it was written to catch.
131
+ if (!isAdminPanel(p) && typeof p.render !== "function")
71
132
  throw new Error(`pramen/cms: admin page '${p.slug}' has no render function`);
72
133
  }
73
134
  }
@@ -93,12 +154,16 @@ export function createAdminPageHandlers(pages, opts = {}) {
93
154
  const rolesFor = (p) => p.roles ?? defaultRoles;
94
155
  const mayOpen = (ctx, p) => held(ctx).some((r) => rolesFor(p).includes(r));
95
156
  return {
96
- /** The pages THIS caller may open. Filtered rather than role-annotated: a nav entry
97
- * that 403s when clicked is worse than one that is not there, and the role list is a
98
- * server fact the browser has no use for. */
157
+ /** The screens THIS caller may open Block Kit pages and panels alike. Filtered rather
158
+ * than role-annotated: a nav entry that 403s when clicked is worse than one that is not
159
+ * there, and the role list is a server fact the browser has no use for.
160
+ *
161
+ * A panel is filtered by exactly the same gate, which is the whole reason it is a
162
+ * registry entry rather than a client-side registration: the browser bundle decides
163
+ * only how the screen DRAWS, never whether this caller has one. */
99
164
  listAdminPages: query((ctx) => pages
100
165
  .filter((p) => mayOpen(ctx, p))
101
- .map((p) => ({ slug: p.slug, label: p.label, icon: p.icon, navOrder: p.navOrder ?? NAV_ORDER.adminPages }))),
166
+ .map((p) => ({ slug: p.slug, label: p.label, icon: p.icon, navOrder: p.navOrder ?? NAV_ORDER.adminPages, kind: isAdminPanel(p) ? "panel" : "blocks" }))),
102
167
  /**
103
168
  * Render a page, or act on it and render the result.
104
169
  *
@@ -117,6 +182,13 @@ export function createAdminPageHandlers(pages, opts = {}) {
117
182
  // Before `render`, so a page's own code never runs for a caller who may not open it.
118
183
  if (!mayOpen(ctx, page))
119
184
  throw new BadRequest(`unknown admin page '${input.page}'`);
185
+ // A panel renders in the BROWSER, so there is nothing here to interact with. Said
186
+ // plainly rather than folded into "unknown admin page": the caller may open this
187
+ // screen (it passed the gate above), so hiding its existence would only send whoever
188
+ // wired the call looking for a registration mistake that is not there. It is a client
189
+ // bug — the editor routes a panel to its component and never calls this.
190
+ if (isAdminPanel(page))
191
+ throw new BadRequest(`admin page '${input.page}' is a panel — it renders in the browser and has no server-side render`);
120
192
  const res = await page.render(ctx, input);
121
193
  return normalizeAdminResponse(res);
122
194
  }, {
@@ -157,6 +229,9 @@ export function createAdminPageHandlers(pages, opts = {}) {
157
229
  * - `image.url` becomes an `<img src>`, so it goes through the same `isSafeHref`
158
230
  * allow-list a rich-text link mark does.
159
231
  * - nesting is capped, because rendering is recursive.
232
+ * - a table cell that is an OBJECT is claiming to be an element, and is checked as one.
233
+ * - every input's `action_id` is claimed once per page, because the editor keys the
234
+ * page's whole value bag by it.
160
235
  *
161
236
  * A bad block throws rather than being dropped: this is the page author's own output, and a
162
237
  * block that silently vanishes is a bug that reads as "the data isn't there".
@@ -164,12 +239,16 @@ export function createAdminPageHandlers(pages, opts = {}) {
164
239
  export function normalizeAdminResponse(res) {
165
240
  if (!res || !Array.isArray(res.blocks))
166
241
  throw new Error("pramen/cms: an admin page must return { blocks: [...] }");
167
- const out = { blocks: res.blocks.map((b) => normalizeAdminBlock(b, 0)) };
242
+ // One set for the WHOLE response, because the value bag it guards is per PAGE, not per
243
+ // block — an input in a form and an input in a table cell collide just as hard as two in
244
+ // one form.
245
+ const inputIds = new Set();
246
+ const out = { blocks: res.blocks.map((b) => normalizeAdminBlock(b, 0, inputIds)) };
168
247
  if (res.toast)
169
248
  out.toast = { text: String(res.toast.text), tone: res.toast.tone };
170
249
  return out;
171
250
  }
172
- function normalizeAdminBlock(block, depth) {
251
+ function normalizeAdminBlock(block, depth, inputIds) {
173
252
  if (depth >= MAX_ADMIN_BLOCK_DEPTH)
174
253
  throw new Error(`pramen/cms: admin blocks nest deeper than ${MAX_ADMIN_BLOCK_DEPTH} levels`);
175
254
  switch (block.type) {
@@ -180,16 +259,66 @@ function normalizeAdminBlock(block, depth) {
180
259
  return { ...block, url };
181
260
  }
182
261
  case "columns":
183
- return { ...block, columns: block.columns.map((col) => col.map((b) => normalizeAdminBlock(b, depth + 1))) };
262
+ return { ...block, columns: block.columns.map((col) => col.map((b) => normalizeAdminBlock(b, depth + 1, inputIds))) };
184
263
  case "accordion":
185
- return { ...block, blocks: block.blocks.map((b) => normalizeAdminBlock(b, depth + 1)) };
264
+ return { ...block, blocks: block.blocks.map((b) => normalizeAdminBlock(b, depth + 1, inputIds)) };
186
265
  case "form":
187
266
  // `block_id` is how the editor keys a form's local values. Two forms sharing one would
188
267
  // share their state, so the second would submit the first one's inputs.
189
268
  if (!block.block_id)
190
269
  throw new Error("pramen/cms: a `form` block needs a block_id");
270
+ for (const f of block.fields)
271
+ claimInputId(f, `form '${block.block_id}'`, inputIds);
191
272
  return block;
273
+ case "actions":
274
+ for (const el of block.elements)
275
+ if (el.type !== "button")
276
+ claimInputId(el, "an `actions` block", inputIds);
277
+ return block;
278
+ case "table": {
279
+ // Only the cells a COLUMN names are looked at, because only those are rendered. A key
280
+ // in `rows` with no column is data the table happens to carry along; checking it would
281
+ // reject rows a page is free to build wide and display narrow.
282
+ for (const [i, row] of block.rows.entries()) {
283
+ for (const c of block.columns) {
284
+ const cell = row[c.key];
285
+ if (!isElementCell(cell))
286
+ continue;
287
+ checkCellElement(cell, c.key, i);
288
+ if (cell.type !== "button")
289
+ claimInputId(cell, `table column '${c.key}'`, inputIds);
290
+ }
291
+ }
292
+ return block;
293
+ }
192
294
  default:
193
295
  return block;
194
296
  }
195
297
  }
298
+ /** An object in a cell is claiming to be an element — nothing else may be one. Narrowed to
299
+ * `AdminElement` here only so the check that follows can read its tag; whether it IS one is
300
+ * exactly what {@link checkCellElement} decides. */
301
+ const isElementCell = (cell) => cell !== null && typeof cell === "object";
302
+ function checkCellElement(cell, column, rowIndex) {
303
+ const where = `table column '${column}', row ${rowIndex}`;
304
+ const kinds = ADMIN_ELEMENT_TYPES;
305
+ if (!kinds.includes(cell.type)) {
306
+ throw new Error(`pramen/cms: ${where} holds an object that is not an admin element (type ${JSON.stringify(cell.type)}). A cell is a value or one of ${kinds.join(", ")} — a whole row object put in a cell would render as [object Object]`);
307
+ }
308
+ if (!cell.action_id)
309
+ throw new Error(`pramen/cms: the element in ${where} has no action_id — nothing would come back when it fires`);
310
+ if (cell.type === "button" && !cell.label)
311
+ throw new Error(`pramen/cms: the button in ${where} has no label — it would draw as an empty control`);
312
+ }
313
+ /** Claim one input's `action_id` for the page.
314
+ *
315
+ * The editor holds ONE value bag for the whole page, keyed by `action_id`, because that is
316
+ * what makes a filter in one block reach a button in another. So two inputs sharing an id
317
+ * are one field wearing two hats, which is only ever a bug — and the way to write it by
318
+ * accident is to build a table and put the same input literal in every row. */
319
+ function claimInputId(input, where, seen) {
320
+ if (seen.has(input.action_id)) {
321
+ throw new Error(`pramen/cms: action_id '${input.action_id}' is used by more than one input on this page (${where}) — the editor keys the page's value bag by action_id, so they would be ONE field: same value shown in every copy, and the last write wins on submit. A per-ROW input has to mint a per-row id (\`hours:\${row.id}\`); a per-row BUTTON does not, because its \`value\` rides back on the interaction instead`);
322
+ }
323
+ seen.add(input.action_id);
324
+ }
package/dist/index.d.ts CHANGED
@@ -1149,8 +1149,12 @@ export declare function taxonomyApplies(row: {
1149
1149
  }, target: TaxonomyTarget): boolean;
1150
1150
  /** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
1151
1151
  * `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
1152
- export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
1153
- export type { AdminBlock, AdminButton, AdminElement, AdminInput, AdminInteractionType, AdminPageDef, AdminPageHandlerOpts, AdminPageInteraction, AdminPageMeta, AdminPageResponse, AdminText, } from "./blockkit";
1152
+ export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, ADMIN_ELEMENT_TYPES, ADMIN_PAGE_KINDS, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
1153
+ export type { AdminBlock, AdminButton, AdminCell, AdminElement, AdminInput, AdminInteractionType, AdminPageDef, AdminPageHandlerOpts, AdminPageInteraction, AdminPageKind, AdminPageMeta, AdminPageResponse, AdminScreenDef, AdminText, } from "./blockkit";
1154
+ /** Custom admin PANELS — a project's own React screen inside the editor's chrome, for the
1155
+ * screens a server-driven vocabulary cannot carry. See `./panel`. */
1156
+ export { adminPanel, isAdminPanel } from "./panel";
1157
+ export type { AdminPanelDef } from "./panel";
1154
1158
  /**
1155
1159
  * Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
1156
1160
  *
package/dist/index.js CHANGED
@@ -851,7 +851,10 @@ async function assertTermsApplyTo(db, termIds, target) {
851
851
  }
852
852
  /** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
853
853
  * `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
854
- export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
854
+ export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, ADMIN_ELEMENT_TYPES, ADMIN_PAGE_KINDS, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
855
+ /** Custom admin PANELS — a project's own React screen inside the editor's chrome, for the
856
+ * screens a server-driven vocabulary cannot carry. See `./panel`. */
857
+ export { adminPanel, isAdminPanel } from "./panel";
855
858
  /**
856
859
  * Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
857
860
  *
@@ -0,0 +1,53 @@
1
+ /** One custom admin panel: a nav entry the browser bundle fills in.
2
+ *
3
+ * There is no `render` here and there is not meant to be one — the rendering half is a
4
+ * React component the deployment's panel bundle registers under the same `slug`. Everything
5
+ * that decides whether the entry EXISTS is here, on the server, where it can be enforced.
6
+ */
7
+ export interface AdminPanelDef {
8
+ /** Discriminates a panel from an `AdminPageDef` in the one registry they share. Set by
9
+ * {@link adminPanel}; it is a required field rather than an inferred one so a hand-built
10
+ * object literal cannot be a half-declared panel. */
11
+ readonly kind: "panel";
12
+ /** URL + registry key: served at `/apps/:slug` in the editor, and the id the browser
13
+ * bundle registers its component under. */
14
+ readonly slug: string;
15
+ /** Nav label. */
16
+ readonly label: string;
17
+ /** Optional nav icon (emoji or short string). */
18
+ readonly icon?: string;
19
+ /** Where it sits in the nav — see `NAV_ORDER`. Defaults to `NAV_ORDER.adminPages`. */
20
+ readonly navOrder?: number;
21
+ /** Roles that may open it. Defaults to the deployment's `editorRoles`, exactly as a Block
22
+ * Kit page's does — one registry, one gate.
23
+ *
24
+ * This is the ONLY authorization a panel gets for free. A panel's own code runs in the
25
+ * browser, so every call it makes is an ordinary RPC under the caller's own identity and
26
+ * ACL; this list decides who is shown the screen, not what the screen may do. */
27
+ readonly roles?: readonly string[];
28
+ }
29
+ /**
30
+ * Declare a custom admin panel. Spread the result into `createAdminPageHandlers` alongside
31
+ * any `adminPage()`s:
32
+ *
33
+ * const curation = adminPanel("curation", {
34
+ * label: "Curation",
35
+ * icon: "🎛",
36
+ * navOrder: NAV_ORDER.media + 10,
37
+ * roles: ["editor", "admin"],
38
+ * });
39
+ *
40
+ * handlers = { ...createAdminPageHandlers([desk, curation], { editorRoles }) };
41
+ *
42
+ * The matching component is registered by the deployment's panel bundle — see the
43
+ * "Custom admin panels" section of the CMS docs.
44
+ */
45
+ export declare function adminPanel(slug: string, opts: Omit<AdminPanelDef, "slug" | "kind">): AdminPanelDef;
46
+ /** Whether a registry entry is a panel (and so has no server-side render).
47
+ *
48
+ * Reads the discriminant rather than testing for the ABSENCE of `render`: "no render" is
49
+ * also what a malformed page looks like, and `validateAdminPages` has to be able to tell a
50
+ * panel from a page someone forgot to finish. */
51
+ export declare function isAdminPanel(def: {
52
+ readonly kind?: string;
53
+ }): def is AdminPanelDef;
package/dist/panel.js ADDED
@@ -0,0 +1,74 @@
1
+ // A custom admin PANEL — a project's own React screen, rendered inside the editor's chrome
2
+ // at a real route with a real nav entry.
3
+ //
4
+ // WHY A SECOND KIND, WHEN `adminPage()` EXISTS
5
+ //
6
+ // Block Kit (`adminPage()`) is a server-driven vocabulary: the handler returns JSON, the
7
+ // editor renders it, and the whole page comes back on every interaction. That is exactly
8
+ // right for a list-and-form screen, and it is the reason no project JavaScript runs in the
9
+ // admin. But the properties that make it safe are the same ones that cap it — an input
10
+ // cannot fire an interaction, every control is disabled for the round trip (so focus and
11
+ // caret are lost on each keystroke that matters), a table row cannot expand, and there is
12
+ // no link, no redirect, no dialog, no autofocus and no date input. Those are not gaps to
13
+ // patch one element at a time; a screen that needs local interaction needs local code.
14
+ //
15
+ // The alternative a project reaches for when Block Kit runs out is what this replaces: a
16
+ // standalone React SPA served next to the editor with the chrome rebuilt by hand. It goes
17
+ // out of the application, it does not have the same layout, and every chrome fix has to be
18
+ // made twice.
19
+ //
20
+ // So a panel is the SAME registry entry as a Block Kit page with the render moved to the
21
+ // browser. Same slug space, same `/apps/:slug` route, same "Apps" band in the nav, and —
22
+ // the part that matters — the same server-side role filter: a panel the caller may not open
23
+ // is absent from `listAdminPages`, so there is no nav entry to click and no route to reach.
24
+ //
25
+ // WHAT LIVES WHERE. The server owns everything a nav entry is made of (slug, label, icon,
26
+ // position, roles); the browser bundle owns only the component. That split is deliberate:
27
+ // if the bundle declared the label and the position, a client that failed to load would
28
+ // take the nav entry with it, and a client that loaded would be declaring its own placement
29
+ // with nothing to check it against. A panel whose bundle never registers is a listed entry
30
+ // that renders a diagnostic — which is a legible failure — rather than a section that
31
+ // silently ceases to exist.
32
+ //
33
+ // WHAT IS GIVEN UP. Block Kit's headline property is that no project JavaScript ever runs in
34
+ // the admin (#33). A panel gives that up, deliberately and only where a deployment asks for
35
+ // it: the bundle runs in the editor's own page with the editor's own session in scope, so it
36
+ // can read the stored token and call anything the caller can. `roles` below and the ACL still
37
+ // bound what the SERVER will do, and `PanelApi` is a small surface to write against, but
38
+ // neither is a sandbox — a panel is part of the admin, not a guest in it. That is the reason
39
+ // `adminPage()` remains the first thing to reach for and this the second.
40
+ //
41
+ // The same reconciliation is written from the other side at the top of `blockkit.ts` — why
42
+ // "do not ship the component tree" and this are one position rather than two, and which of
43
+ // the pair to reach for first. Read together; changing one without the other leaves the
44
+ // framework arguing with itself.
45
+ //
46
+ // A LEAF module, like `href.ts` and `nav.ts`: `blockkit.ts` imports it to widen the
47
+ // registry, and it imports nothing back.
48
+ /**
49
+ * Declare a custom admin panel. Spread the result into `createAdminPageHandlers` alongside
50
+ * any `adminPage()`s:
51
+ *
52
+ * const curation = adminPanel("curation", {
53
+ * label: "Curation",
54
+ * icon: "🎛",
55
+ * navOrder: NAV_ORDER.media + 10,
56
+ * roles: ["editor", "admin"],
57
+ * });
58
+ *
59
+ * handlers = { ...createAdminPageHandlers([desk, curation], { editorRoles }) };
60
+ *
61
+ * The matching component is registered by the deployment's panel bundle — see the
62
+ * "Custom admin panels" section of the CMS docs.
63
+ */
64
+ export function adminPanel(slug, opts) {
65
+ return { ...opts, kind: "panel", slug };
66
+ }
67
+ /** Whether a registry entry is a panel (and so has no server-side render).
68
+ *
69
+ * Reads the discriminant rather than testing for the ABSENCE of `render`: "no render" is
70
+ * also what a malformed page looks like, and `validateAdminPages` has to be able to tell a
71
+ * panel from a page someone forgot to finish. */
72
+ export function isAdminPanel(def) {
73
+ return def.kind === "panel";
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms",
3
- "version": "0.0.63",
3
+ "version": "0.0.64",
4
4
  "description": "Optional block/page builder for pramen — Drupal-Paragraphs-style typed blocks in named regions, reusable blocks, scheduled publishing, built entirely from pramen primitives.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@pramen/server": "0.0.63"
44
+ "@pramen/server": "0.0.64"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "react": ">=18"
package/src/blockkit.ts CHANGED
@@ -21,6 +21,46 @@
21
21
  // `FieldDefinition[]` -> `FieldForm` is already "server-described form, host-rendered". Block
22
22
  // Kit is those two taken all the way: arbitrary admin PAGES, not just forms over rows.
23
23
  //
24
+ // AND YET `adminPanel()` SHIPS BESIDE IT, WHICH IS PROJECT CODE IN THE BROWSER
25
+ //
26
+ // It does, and the paragraph above is not quietly wrong — the two are one position, not two.
27
+ // What that paragraph rejects is publishing the EDITOR'S COMPONENT LIBRARY so that every
28
+ // project assembles its own admin out of it: that is what makes N forks of the same 80%,
29
+ // because each project then owns the chrome, the nav, the page list, the login and the media
30
+ // browser, and every fix has to be made N times. A panel is the opposite trade — ONE SCREEN,
31
+ // rendered inside chrome that is still ours, at a route that is still ours, from a registry
32
+ // entry that is still the server's. No component API is published: a panel is handed React,
33
+ // four props and nothing else (`panel-runtime.ts` in @pramen/cms-editor is a long argument
34
+ // about what is deliberately absent from that list), so there is nothing to reassemble an
35
+ // admin out of and no reason to fork one.
36
+ //
37
+ // What the objection DOES carry over is version skew, and it is not waved away: a panel is
38
+ // compiled at the project's build, against whichever React they had, and linked at runtime
39
+ // against whichever React the editor loaded. That is why the seam is a numbered contract a
40
+ // bundle must STATE and the editor refuses on mismatch, rather than a promise in a README.
41
+ // Skew becomes one refusal naming the slug and the fix, on the screen the panel should have
42
+ // been — which is precisely what "N per-project forks" never had.
43
+ //
44
+ // WHICH TO REACH FOR, AND WHAT THE SECOND ONE COSTS
45
+ //
46
+ // `adminPage()` for a list-and-form screen: rows, filters, a form, a confirm. No build step,
47
+ // no bundle to keep deployed in step with the admin, no React version to keep aligned, and no
48
+ // project code in the browser at all. `adminPanel()` when the screen IS the interaction —
49
+ // something that responds as you type, a row that expands, a dialog, a canvas, a date input.
50
+ // Those are not elements Block Kit happens to be missing; they are things a server-driven
51
+ // vocabulary cannot express, and the list does not shrink by adding blocks.
52
+ //
53
+ // The cost, stated plainly, because it is what makes that order more than a preference: A
54
+ // PANEL IS TRUSTED CODE. Its bundle runs in the editor's own page with the editor's own
55
+ // session in scope — it can read the stored token, make any call the caller could make, and
56
+ // render anything anywhere on the page. `roles` on `adminPanel()` decides who is SHOWN the
57
+ // screen and the ACL still bounds what the server will do for whoever is asking, but neither
58
+ // constrains the bundle. `PanelApi` is a CONVENIENCE — the one obvious way to make an
59
+ // authenticated call — and not a sandbox; there is no sandbox to be had short of a
60
+ // cross-origin iframe, which would give up the shared chrome that is the entire point. Ship a
61
+ // panel you wrote, from your own origin, and treat its bundle as part of the admin. Block
62
+ // Kit's headline property is that none of this paragraph is ever needed.
63
+ //
24
64
  // WHAT IT IS NOT
25
65
  //
26
66
  // It is not a way to reach past the ACL. A page's `render` is an ordinary handler body: it
@@ -36,6 +76,7 @@ import { BadRequest, mutation, query } from "@pramen/server";
36
76
  import type { HandlerContext, JsonValue, SchemaDef } from "@pramen/server";
37
77
  import { isSafeHref, normalizeHref } from "./href";
38
78
  import { NAV_ORDER } from "./nav";
79
+ import { isAdminPanel, type AdminPanelDef } from "./panel";
39
80
 
40
81
  // --- the block/element vocabulary ------------------------------------------------------
41
82
 
@@ -43,16 +84,23 @@ import { NAV_ORDER } from "./nav";
43
84
  * string through React, so there is no HTML path here to sanitize. */
44
85
  export type AdminText = string;
45
86
 
46
- /** An input a form (or an actions row) can carry. */
87
+ /** An input a form, an actions row or a table cell can carry.
88
+ *
89
+ * `error` is the per-FIELD failure, rendered under the offending input. The page-level
90
+ * `toast` cannot do that job: it names no field, it is gone in three seconds while the bad
91
+ * value is still on screen, and a form with six inputs gives the reader no way to tell which
92
+ * one "25:00 is not a time" is about. It is a plain part of the render — the whole page
93
+ * comes back on every interaction, so an error lives exactly as long as the response that
94
+ * carried it, and there is nothing to clear. */
47
95
  export type AdminInput =
48
- | { type: "text_input"; action_id: string; label?: AdminText; placeholder?: AdminText; initial_value?: string; multiline?: boolean; required?: boolean }
49
- | { type: "number_input"; action_id: string; label?: AdminText; placeholder?: AdminText; initial_value?: number; min?: number; max?: number; required?: boolean }
50
- | { type: "select"; action_id: string; label?: AdminText; options: { value: string; label: AdminText }[]; initial_value?: string; required?: boolean }
51
- | { type: "toggle"; action_id: string; label?: AdminText; initial_value?: boolean }
96
+ | { type: "text_input"; action_id: string; label?: AdminText; placeholder?: AdminText; initial_value?: string; multiline?: boolean; required?: boolean; error?: AdminText }
97
+ | { type: "number_input"; action_id: string; label?: AdminText; placeholder?: AdminText; initial_value?: number; min?: number; max?: number; required?: boolean; error?: AdminText }
98
+ | { type: "select"; action_id: string; label?: AdminText; options: { value: string; label: AdminText }[]; initial_value?: string; required?: boolean; error?: AdminText }
99
+ | { type: "toggle"; action_id: string; label?: AdminText; initial_value?: boolean; error?: AdminText }
52
100
  /** Write-only: never echoed back to the browser once stored. The editor renders it as a
53
101
  * password field and sends it only on submit; a page that stores one must NOT put it back
54
102
  * in `initial_value` on the next render, which is why there is no such key here. */
55
- | { type: "secret_input"; action_id: string; label?: AdminText; placeholder?: AdminText; required?: boolean };
103
+ | { type: "secret_input"; action_id: string; label?: AdminText; placeholder?: AdminText; required?: boolean; error?: AdminText };
56
104
 
57
105
  /** A button. `value` rides back on the interaction, so one `action_id` can serve a row. */
58
106
  export interface AdminButton {
@@ -68,6 +116,32 @@ export interface AdminButton {
68
116
 
69
117
  export type AdminElement = AdminButton | AdminInput;
70
118
 
119
+ /** Every `AdminElement` tag, as a runtime set.
120
+ *
121
+ * Both halves need this at RUNTIME, which is why it is a value and not only a union: the
122
+ * server checks that an object sitting in a table cell really is an element, and the editor
123
+ * decides from the same set whether a cell draws as text or as a control. The editor keeps
124
+ * its own copy (it has no dependency on this package) and `test/cms-editor-mirrors.test.ts`
125
+ * fails if the two drift. */
126
+ export const ADMIN_ELEMENT_TYPES = ["button", "text_input", "number_input", "select", "toggle", "secret_input"] as const;
127
+
128
+ /** What one table cell holds: a value to READ, or an element to ACT with.
129
+ *
130
+ * The alternative shape was a per-COLUMN element declaration — `columns: [{ key, label,
131
+ * element }]` — and it is the wrong unit. Everything about a row's control is a fact of the
132
+ * ROW: the button's `value` is that row's id, its label is "Hide" or "Show" depending on
133
+ * that row's state, and a row that must not be touched carries no control at all. A column
134
+ * declaration would have to be a template with a substitution language, which is a second
135
+ * vocabulary to design and to escape. A cell already varies per row, so the element goes in
136
+ * the cell and the column keeps saying only where it lands. A cell is a value OR an element,
137
+ * never both; a column that wants both is two columns.
138
+ *
139
+ * The two are told apart by SHAPE: a display value is a primitive, an element is an object,
140
+ * and nothing else may be an object. `normalizeAdminResponse` enforces that, so a page that
141
+ * splats a whole row (`rows: found`) into the table is named at the boundary instead of
142
+ * rendering a column of `[object Object]` — or, worse, of half-elements. */
143
+ export type AdminCell = AdminText | number | boolean | null | AdminElement;
144
+
71
145
  /** One block in a rendered admin page. */
72
146
  export type AdminBlock =
73
147
  | { type: "header"; text: AdminText; level?: 1 | 2 | 3 }
@@ -77,7 +151,9 @@ export type AdminBlock =
77
151
  | { type: "context"; text: AdminText }
78
152
  /** Label/value pairs, for a record's details. */
79
153
  | { type: "fields"; fields: { label: AdminText; value: AdminText }[] }
80
- | { type: "table"; columns: { key: string; label: AdminText }[]; rows: Record<string, AdminText | number | boolean | null>[]; empty?: AdminText }
154
+ /** `block_id` rides back on an interaction a CELL fired, the same way an `actions` block's
155
+ * does — so a page with two tables can tell which one a shared `action_id` came from. */
156
+ | { type: "table"; block_id?: string; columns: { key: string; label: AdminText }[]; rows: Record<string, AdminCell>[]; empty?: AdminText }
81
157
  | { type: "stats"; stats: { label: AdminText; value: AdminText; hint?: AdminText }[] }
82
158
  | { type: "actions"; block_id?: string; elements: AdminElement[] }
83
159
  | { type: "form"; block_id: string; fields: AdminInput[]; submit: { label: AdminText; action_id: string } }
@@ -128,6 +204,10 @@ export const MAX_ADMIN_BLOCK_DEPTH = 4;
128
204
  * Without it `ctx.db.find({ from: "lectures", where: { title: { contains: q } } })` resolves
129
205
  * the table against the default `SchemaDef` and every column reads as a number. */
130
206
  export interface AdminPageDef<S extends SchemaDef = SchemaDef> {
207
+ /** Discriminates this from an `AdminPanelDef` in the registry the two share. Optional and
208
+ * defaulted, because a Block Kit page is what `adminPage()` has always produced and
209
+ * nothing should have to start saying so. */
210
+ readonly kind?: "blocks";
131
211
  /** URL + registry key: the page is served at `/apps/:slug` in the editor. */
132
212
  readonly slug: string;
133
213
  /** Nav label. */
@@ -165,20 +245,42 @@ export function adminPage<S extends SchemaDef = SchemaDef>(slug: string, opts: O
165
245
  return { ...opts, slug };
166
246
  }
167
247
 
168
- /** The client-facing view of a page what the editor needs to put it in the nav. Never
169
- * the `render` function, and never the role list (which is a server fact; a page the caller
170
- * may not open is simply absent from the listing). */
248
+ /** One entry in the custom-screens registry: a Block Kit page, or a panel the browser
249
+ * renders. They share a registry and so a slug space, a route and a nav band — because
250
+ * from the editor's side they are the same thing (a project's own screen inside the chrome)
251
+ * differing only in where the rendering happens. Two registries would have made a slug
252
+ * collision between them a runtime surprise instead of a boot error. */
253
+ export type AdminScreenDef<S extends SchemaDef = SchemaDef> = AdminPageDef<S> | AdminPanelDef;
254
+
255
+ /** Every screen kind, as a runtime set. A value and not only a union because the editor
256
+ * keeps its own copy and `test/cms-editor-mirrors.test.ts` fails if the two drift — a kind
257
+ * the server sends and the editor has not heard of is a nav entry that renders nothing. */
258
+ export const ADMIN_PAGE_KINDS = ["blocks", "panel"] as const;
259
+ export type AdminPageKind = (typeof ADMIN_PAGE_KINDS)[number];
260
+
261
+ /** The client-facing view of a registered screen — what the editor needs to put it in the
262
+ * nav and decide how to render it. Never the `render` function, and never the role list
263
+ * (which is a server fact; a screen the caller may not open is simply absent from the
264
+ * listing). */
171
265
  export interface AdminPageMeta {
172
266
  slug: string;
173
267
  label: string;
174
268
  icon?: string;
175
269
  navOrder: number;
270
+ /** `"blocks"` (Block Kit, rendered from this response) or `"panel"` (a component the
271
+ * deployment's panel bundle registered under this slug). */
272
+ kind: AdminPageKind;
176
273
  }
177
274
 
178
- /** Validate a registry at boot: slugs are unique and routable, and every page can be
275
+ /** Validate a registry at boot: slugs are unique and routable, and every screen can be
179
276
  * addressed. Called by `createAdminPageHandlers`, so a mistake surfaces when the Worker
180
- * starts rather than as a 404 the first time someone opens the one page nobody exercised. */
181
- export function validateAdminPages(pages: readonly AdminPageDef[]): void {
277
+ * starts rather than as a 404 the first time someone opens the one page nobody exercised.
278
+ *
279
+ * Pages and panels are validated TOGETHER, against one `seen` set: they share `/apps/:slug`,
280
+ * so two entries with the same slug are a collision whatever their kinds — and the one that
281
+ * would win is decided by insertion order into a Map, which is not a thing to leave to
282
+ * chance. */
283
+ export function validateAdminPages(pages: readonly AdminScreenDef[]): void {
182
284
  const seen = new Set<string>();
183
285
  for (const p of pages) {
184
286
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(p.slug) || p.slug.length > 80) {
@@ -187,7 +289,10 @@ export function validateAdminPages(pages: readonly AdminPageDef[]): void {
187
289
  if (seen.has(p.slug)) throw new Error(`pramen/cms: duplicate admin page slug '${p.slug}' — the slug is the registry's key`);
188
290
  seen.add(p.slug);
189
291
  if (p.label.trim() === "") throw new Error(`pramen/cms: admin page '${p.slug}' has an empty label — it would render an unnamed nav entry`);
190
- if (typeof p.render !== "function") throw new Error(`pramen/cms: admin page '${p.slug}' has no render function`);
292
+ // A panel has no server render by definition, so the check is skipped for it rather
293
+ // than relaxed for everyone: "no render function" stays a boot error for a Block Kit
294
+ // page, which is the one it was written to catch.
295
+ if (!isAdminPanel(p) && typeof p.render !== "function") throw new Error(`pramen/cms: admin page '${p.slug}' has no render function`);
191
296
  }
192
297
  }
193
298
 
@@ -213,21 +318,25 @@ export interface AdminPageHandlerOpts {
213
318
  * There is no ACL fragment to spread: a page reads through `ctx.db` under whatever policies
214
319
  * the caller already has, so there is nothing here to grant.
215
320
  */
216
- export function createAdminPageHandlers(pages: readonly AdminPageDef[], opts: AdminPageHandlerOpts = {}) {
321
+ export function createAdminPageHandlers(pages: readonly AdminScreenDef[], opts: AdminPageHandlerOpts = {}) {
217
322
  validateAdminPages(pages);
218
323
  const defaultRoles = opts.editorRoles ?? ["editor", "admin"];
219
324
  const bySlug = new Map(pages.map((p) => [p.slug, p]));
220
- const rolesFor = (p: AdminPageDef): readonly string[] => p.roles ?? defaultRoles;
221
- const mayOpen = (ctx: HandlerContext, p: AdminPageDef): boolean => held(ctx).some((r) => rolesFor(p).includes(r));
325
+ const rolesFor = (p: AdminScreenDef): readonly string[] => p.roles ?? defaultRoles;
326
+ const mayOpen = (ctx: HandlerContext, p: AdminScreenDef): boolean => held(ctx).some((r) => rolesFor(p).includes(r));
222
327
 
223
328
  return {
224
- /** The pages THIS caller may open. Filtered rather than role-annotated: a nav entry
225
- * that 403s when clicked is worse than one that is not there, and the role list is a
226
- * server fact the browser has no use for. */
329
+ /** The screens THIS caller may open Block Kit pages and panels alike. Filtered rather
330
+ * than role-annotated: a nav entry that 403s when clicked is worse than one that is not
331
+ * there, and the role list is a server fact the browser has no use for.
332
+ *
333
+ * A panel is filtered by exactly the same gate, which is the whole reason it is a
334
+ * registry entry rather than a client-side registration: the browser bundle decides
335
+ * only how the screen DRAWS, never whether this caller has one. */
227
336
  listAdminPages: query((ctx): AdminPageMeta[] =>
228
337
  pages
229
338
  .filter((p) => mayOpen(ctx, p))
230
- .map((p) => ({ slug: p.slug, label: p.label, icon: p.icon, navOrder: p.navOrder ?? NAV_ORDER.adminPages })),
339
+ .map((p) => ({ slug: p.slug, label: p.label, icon: p.icon, navOrder: p.navOrder ?? NAV_ORDER.adminPages, kind: isAdminPanel(p) ? "panel" : "blocks" })),
231
340
  ),
232
341
 
233
342
  /**
@@ -246,6 +355,12 @@ export function createAdminPageHandlers(pages: readonly AdminPageDef[], opts: Ad
246
355
  if (!page) throw new BadRequest(`unknown admin page '${input.page}'`);
247
356
  // Before `render`, so a page's own code never runs for a caller who may not open it.
248
357
  if (!mayOpen(ctx, page)) throw new BadRequest(`unknown admin page '${input.page}'`);
358
+ // A panel renders in the BROWSER, so there is nothing here to interact with. Said
359
+ // plainly rather than folded into "unknown admin page": the caller may open this
360
+ // screen (it passed the gate above), so hiding its existence would only send whoever
361
+ // wired the call looking for a registration mistake that is not there. It is a client
362
+ // bug — the editor routes a panel to its component and never calls this.
363
+ if (isAdminPanel(page)) throw new BadRequest(`admin page '${input.page}' is a panel — it renders in the browser and has no server-side render`);
249
364
  const res = await page.render(ctx, input);
250
365
  return normalizeAdminResponse(res);
251
366
  }, {
@@ -282,18 +397,25 @@ export function createAdminPageHandlers(pages: readonly AdminPageDef[], opts: Ad
282
397
  * - `image.url` becomes an `<img src>`, so it goes through the same `isSafeHref`
283
398
  * allow-list a rich-text link mark does.
284
399
  * - nesting is capped, because rendering is recursive.
400
+ * - a table cell that is an OBJECT is claiming to be an element, and is checked as one.
401
+ * - every input's `action_id` is claimed once per page, because the editor keys the
402
+ * page's whole value bag by it.
285
403
  *
286
404
  * A bad block throws rather than being dropped: this is the page author's own output, and a
287
405
  * block that silently vanishes is a bug that reads as "the data isn't there".
288
406
  */
289
407
  export function normalizeAdminResponse(res: AdminPageResponse): AdminPageResponse {
290
408
  if (!res || !Array.isArray(res.blocks)) throw new Error("pramen/cms: an admin page must return { blocks: [...] }");
291
- const out: AdminPageResponse = { blocks: res.blocks.map((b) => normalizeAdminBlock(b, 0)) };
409
+ // One set for the WHOLE response, because the value bag it guards is per PAGE, not per
410
+ // block — an input in a form and an input in a table cell collide just as hard as two in
411
+ // one form.
412
+ const inputIds = new Set<string>();
413
+ const out: AdminPageResponse = { blocks: res.blocks.map((b) => normalizeAdminBlock(b, 0, inputIds)) };
292
414
  if (res.toast) out.toast = { text: String(res.toast.text), tone: res.toast.tone };
293
415
  return out;
294
416
  }
295
417
 
296
- function normalizeAdminBlock(block: AdminBlock, depth: number): AdminBlock {
418
+ function normalizeAdminBlock(block: AdminBlock, depth: number, inputIds: Set<string>): AdminBlock {
297
419
  if (depth >= MAX_ADMIN_BLOCK_DEPTH) throw new Error(`pramen/cms: admin blocks nest deeper than ${MAX_ADMIN_BLOCK_DEPTH} levels`);
298
420
  switch (block.type) {
299
421
  case "image": {
@@ -302,15 +424,63 @@ function normalizeAdminBlock(block: AdminBlock, depth: number): AdminBlock {
302
424
  return { ...block, url };
303
425
  }
304
426
  case "columns":
305
- return { ...block, columns: block.columns.map((col) => col.map((b) => normalizeAdminBlock(b, depth + 1))) };
427
+ return { ...block, columns: block.columns.map((col) => col.map((b) => normalizeAdminBlock(b, depth + 1, inputIds))) };
306
428
  case "accordion":
307
- return { ...block, blocks: block.blocks.map((b) => normalizeAdminBlock(b, depth + 1)) };
429
+ return { ...block, blocks: block.blocks.map((b) => normalizeAdminBlock(b, depth + 1, inputIds)) };
308
430
  case "form":
309
431
  // `block_id` is how the editor keys a form's local values. Two forms sharing one would
310
432
  // share their state, so the second would submit the first one's inputs.
311
433
  if (!block.block_id) throw new Error("pramen/cms: a `form` block needs a block_id");
434
+ for (const f of block.fields) claimInputId(f, `form '${block.block_id}'`, inputIds);
312
435
  return block;
436
+ case "actions":
437
+ for (const el of block.elements) if (el.type !== "button") claimInputId(el, "an `actions` block", inputIds);
438
+ return block;
439
+ case "table": {
440
+ // Only the cells a COLUMN names are looked at, because only those are rendered. A key
441
+ // in `rows` with no column is data the table happens to carry along; checking it would
442
+ // reject rows a page is free to build wide and display narrow.
443
+ for (const [i, row] of block.rows.entries()) {
444
+ for (const c of block.columns) {
445
+ const cell = row[c.key];
446
+ if (!isElementCell(cell)) continue;
447
+ checkCellElement(cell, c.key, i);
448
+ if (cell.type !== "button") claimInputId(cell, `table column '${c.key}'`, inputIds);
449
+ }
450
+ }
451
+ return block;
452
+ }
313
453
  default:
314
454
  return block;
315
455
  }
316
456
  }
457
+
458
+ /** An object in a cell is claiming to be an element — nothing else may be one. Narrowed to
459
+ * `AdminElement` here only so the check that follows can read its tag; whether it IS one is
460
+ * exactly what {@link checkCellElement} decides. */
461
+ const isElementCell = (cell: AdminCell | undefined): cell is AdminElement => cell !== null && typeof cell === "object";
462
+
463
+ function checkCellElement(cell: AdminElement, column: string, rowIndex: number): void {
464
+ const where = `table column '${column}', row ${rowIndex}`;
465
+ const kinds = ADMIN_ELEMENT_TYPES as readonly string[];
466
+ if (!kinds.includes(cell.type)) {
467
+ throw new Error(`pramen/cms: ${where} holds an object that is not an admin element (type ${JSON.stringify(cell.type)}). A cell is a value or one of ${kinds.join(", ")} — a whole row object put in a cell would render as [object Object]`);
468
+ }
469
+ if (!cell.action_id) throw new Error(`pramen/cms: the element in ${where} has no action_id — nothing would come back when it fires`);
470
+ if (cell.type === "button" && !cell.label) throw new Error(`pramen/cms: the button in ${where} has no label — it would draw as an empty control`);
471
+ }
472
+
473
+ /** Claim one input's `action_id` for the page.
474
+ *
475
+ * The editor holds ONE value bag for the whole page, keyed by `action_id`, because that is
476
+ * what makes a filter in one block reach a button in another. So two inputs sharing an id
477
+ * are one field wearing two hats, which is only ever a bug — and the way to write it by
478
+ * accident is to build a table and put the same input literal in every row. */
479
+ function claimInputId(input: AdminInput, where: string, seen: Set<string>): void {
480
+ if (seen.has(input.action_id)) {
481
+ throw new Error(
482
+ `pramen/cms: action_id '${input.action_id}' is used by more than one input on this page (${where}) — the editor keys the page's value bag by action_id, so they would be ONE field: same value shown in every copy, and the last write wins on submit. A per-ROW input has to mint a per-row id (\`hours:\${row.id}\`); a per-row BUTTON does not, because its \`value\` rides back on the interaction instead`,
483
+ );
484
+ }
485
+ seen.add(input.action_id);
486
+ }
package/src/index.ts CHANGED
@@ -1306,22 +1306,32 @@ export {
1306
1306
  createAdminPageHandlers,
1307
1307
  normalizeAdminResponse,
1308
1308
  validateAdminPages,
1309
+ ADMIN_ELEMENT_TYPES,
1310
+ ADMIN_PAGE_KINDS,
1309
1311
  MAX_ADMIN_BLOCK_DEPTH,
1310
1312
  } from "./blockkit";
1311
1313
  export type {
1312
1314
  AdminBlock,
1313
1315
  AdminButton,
1316
+ AdminCell,
1314
1317
  AdminElement,
1315
1318
  AdminInput,
1316
1319
  AdminInteractionType,
1317
1320
  AdminPageDef,
1318
1321
  AdminPageHandlerOpts,
1319
1322
  AdminPageInteraction,
1323
+ AdminPageKind,
1320
1324
  AdminPageMeta,
1321
1325
  AdminPageResponse,
1326
+ AdminScreenDef,
1322
1327
  AdminText,
1323
1328
  } from "./blockkit";
1324
1329
 
1330
+ /** Custom admin PANELS — a project's own React screen inside the editor's chrome, for the
1331
+ * screens a server-driven vocabulary cannot carry. See `./panel`. */
1332
+ export { adminPanel, isAdminPanel } from "./panel";
1333
+ export type { AdminPanelDef } from "./panel";
1334
+
1325
1335
  /**
1326
1336
  * Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
1327
1337
  *
package/src/panel.ts ADDED
@@ -0,0 +1,105 @@
1
+ // A custom admin PANEL — a project's own React screen, rendered inside the editor's chrome
2
+ // at a real route with a real nav entry.
3
+ //
4
+ // WHY A SECOND KIND, WHEN `adminPage()` EXISTS
5
+ //
6
+ // Block Kit (`adminPage()`) is a server-driven vocabulary: the handler returns JSON, the
7
+ // editor renders it, and the whole page comes back on every interaction. That is exactly
8
+ // right for a list-and-form screen, and it is the reason no project JavaScript runs in the
9
+ // admin. But the properties that make it safe are the same ones that cap it — an input
10
+ // cannot fire an interaction, every control is disabled for the round trip (so focus and
11
+ // caret are lost on each keystroke that matters), a table row cannot expand, and there is
12
+ // no link, no redirect, no dialog, no autofocus and no date input. Those are not gaps to
13
+ // patch one element at a time; a screen that needs local interaction needs local code.
14
+ //
15
+ // The alternative a project reaches for when Block Kit runs out is what this replaces: a
16
+ // standalone React SPA served next to the editor with the chrome rebuilt by hand. It goes
17
+ // out of the application, it does not have the same layout, and every chrome fix has to be
18
+ // made twice.
19
+ //
20
+ // So a panel is the SAME registry entry as a Block Kit page with the render moved to the
21
+ // browser. Same slug space, same `/apps/:slug` route, same "Apps" band in the nav, and —
22
+ // the part that matters — the same server-side role filter: a panel the caller may not open
23
+ // is absent from `listAdminPages`, so there is no nav entry to click and no route to reach.
24
+ //
25
+ // WHAT LIVES WHERE. The server owns everything a nav entry is made of (slug, label, icon,
26
+ // position, roles); the browser bundle owns only the component. That split is deliberate:
27
+ // if the bundle declared the label and the position, a client that failed to load would
28
+ // take the nav entry with it, and a client that loaded would be declaring its own placement
29
+ // with nothing to check it against. A panel whose bundle never registers is a listed entry
30
+ // that renders a diagnostic — which is a legible failure — rather than a section that
31
+ // silently ceases to exist.
32
+ //
33
+ // WHAT IS GIVEN UP. Block Kit's headline property is that no project JavaScript ever runs in
34
+ // the admin (#33). A panel gives that up, deliberately and only where a deployment asks for
35
+ // it: the bundle runs in the editor's own page with the editor's own session in scope, so it
36
+ // can read the stored token and call anything the caller can. `roles` below and the ACL still
37
+ // bound what the SERVER will do, and `PanelApi` is a small surface to write against, but
38
+ // neither is a sandbox — a panel is part of the admin, not a guest in it. That is the reason
39
+ // `adminPage()` remains the first thing to reach for and this the second.
40
+ //
41
+ // The same reconciliation is written from the other side at the top of `blockkit.ts` — why
42
+ // "do not ship the component tree" and this are one position rather than two, and which of
43
+ // the pair to reach for first. Read together; changing one without the other leaves the
44
+ // framework arguing with itself.
45
+ //
46
+ // A LEAF module, like `href.ts` and `nav.ts`: `blockkit.ts` imports it to widen the
47
+ // registry, and it imports nothing back.
48
+
49
+ /** One custom admin panel: a nav entry the browser bundle fills in.
50
+ *
51
+ * There is no `render` here and there is not meant to be one — the rendering half is a
52
+ * React component the deployment's panel bundle registers under the same `slug`. Everything
53
+ * that decides whether the entry EXISTS is here, on the server, where it can be enforced.
54
+ */
55
+ export interface AdminPanelDef {
56
+ /** Discriminates a panel from an `AdminPageDef` in the one registry they share. Set by
57
+ * {@link adminPanel}; it is a required field rather than an inferred one so a hand-built
58
+ * object literal cannot be a half-declared panel. */
59
+ readonly kind: "panel";
60
+ /** URL + registry key: served at `/apps/:slug` in the editor, and the id the browser
61
+ * bundle registers its component under. */
62
+ readonly slug: string;
63
+ /** Nav label. */
64
+ readonly label: string;
65
+ /** Optional nav icon (emoji or short string). */
66
+ readonly icon?: string;
67
+ /** Where it sits in the nav — see `NAV_ORDER`. Defaults to `NAV_ORDER.adminPages`. */
68
+ readonly navOrder?: number;
69
+ /** Roles that may open it. Defaults to the deployment's `editorRoles`, exactly as a Block
70
+ * Kit page's does — one registry, one gate.
71
+ *
72
+ * This is the ONLY authorization a panel gets for free. A panel's own code runs in the
73
+ * browser, so every call it makes is an ordinary RPC under the caller's own identity and
74
+ * ACL; this list decides who is shown the screen, not what the screen may do. */
75
+ readonly roles?: readonly string[];
76
+ }
77
+
78
+ /**
79
+ * Declare a custom admin panel. Spread the result into `createAdminPageHandlers` alongside
80
+ * any `adminPage()`s:
81
+ *
82
+ * const curation = adminPanel("curation", {
83
+ * label: "Curation",
84
+ * icon: "🎛",
85
+ * navOrder: NAV_ORDER.media + 10,
86
+ * roles: ["editor", "admin"],
87
+ * });
88
+ *
89
+ * handlers = { ...createAdminPageHandlers([desk, curation], { editorRoles }) };
90
+ *
91
+ * The matching component is registered by the deployment's panel bundle — see the
92
+ * "Custom admin panels" section of the CMS docs.
93
+ */
94
+ export function adminPanel(slug: string, opts: Omit<AdminPanelDef, "slug" | "kind">): AdminPanelDef {
95
+ return { ...opts, kind: "panel", slug };
96
+ }
97
+
98
+ /** Whether a registry entry is a panel (and so has no server-side render).
99
+ *
100
+ * Reads the discriminant rather than testing for the ABSENCE of `render`: "no render" is
101
+ * also what a malformed page looks like, and `validateAdminPages` has to be able to tell a
102
+ * panel from a page someone forgot to finish. */
103
+ export function isAdminPanel(def: { readonly kind?: string }): def is AdminPanelDef {
104
+ return def.kind === "panel";
105
+ }