@pramen/cms 0.0.61 → 0.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.61",
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.61"
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
+ }