@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/README.md CHANGED
@@ -126,6 +126,34 @@ entry, so doing it there made the returned array stop matching the `as const` li
126
126
  the tenant's `media/` prefix); the client PUTs the bytes, then `createMedia({ ref, alt? })`
127
127
  confirms the blob is in R2 and persists a `cms_media` row. `listMedia`/`getMedia`/`deleteMedia`
128
128
  (deleteMedia also removes the R2 blob) round it out. Editor-gated.
129
+ - **Browsing:** `listMedia({ limit, offset, q?, sort?, kind?, term? })`. `q` matches the filename **or**
130
+ the alt text (case-insensitive; a `%` or `_` in the needle is a literal), `sort` is one of
131
+ `newest`/`oldest`/`name`/`name_desc`/`largest`/`smallest`, and `kind` narrows to
132
+ `image`/`video`/`audio`/`document`/`other`. All three are applied in SQL rather than to the
133
+ page that arrived, so they mean what they say on a library larger than one page. `sort` and
134
+ `kind` are closed vocabularies — a caller never names a column — and an unrecognised value
135
+ falls back to the default instead of erroring.
136
+
137
+ This is what `cms_media.filename`/`contentType`/`size` are for: `file` is a `fileRef` (JSON in
138
+ a TEXT cell), which `orderBy` and `where` cannot see into, so the three fields the library
139
+ queries by are projected onto indexed columns when a row is created. **Spread `cmsMigrations`
140
+ into `app.migrations`** to backfill rows written before those columns existed — without it
141
+ they keep NULL, sort together under a name sort, and answer only the `other` filter.
142
+ - **Tagging:** files carry taxonomy terms from the same `cms_taxonomies`/`cms_terms` tables pages
143
+ use, through a `cms_media_terms` junction — one vocabulary, edited in one place, applied to
144
+ whichever of the two it declares. A vocabulary carries **`appliesTo`** — `["page"]`,
145
+ `["media"]`, both, or `null` for everything (which is what an un-narrowed one, and every row
146
+ written before the column existed, means). `listTaxonomies({ target })` narrows to it, and
147
+ `setPageTerms`/`setMediaTerms` REFUSE a term from a vocabulary that does not apply, so it is a
148
+ rule rather than a UI hint. Narrowing a vocabulary away from something it is still assigned to
149
+ is refused too — those assignments would stay stored and stop being reachable from the panel
150
+ that could remove them. `listMediaTerms({ mediaId })` reads a file's terms and `setMediaTerms({ mediaId, termIds })`
151
+ replaces them wholesale (set semantics, like `setPageTerms`); `listMedia({ term })` filters by
152
+ one, ANDed with `kind` and `q`. The filter is a relation traversal compiled to a subquery, so
153
+ it narrows in SQL like every other option here. Deleting a term takes its assignments with it
154
+ (a real `ON DELETE CASCADE`), and trashing a file does NOT — only `purgeMedia` does, so a
155
+ restored file keeps its tags. Declared to the editor as
156
+ `listCmsCapabilities().mediaTerms`.
129
157
  - **Reference from a block:** a `"media"` field stores a `cms_media` id. At assemble/publish time
130
158
  the id is resolved (recursively, through group/repeater nesting) to a `ResolvedMedia`
131
159
  `{ id, key, url, alt, contentType, filename }` in the snapshot — so the content API returns a
@@ -195,6 +223,12 @@ authorization. Spread `cmsRoutes()` into `app.routes` to serve `GET /cms/preview
195
223
  verifies the token in the Worker before any read and returns the live draft with
196
224
  `isPreview: true` and `Cache-Control: private, no-store`.
197
225
 
226
+ That route answers with **JSON** — this CMS is headless, so it has the draft and no idea what
227
+ it should look like. Your site renders it: redeem the same token with `client.getPreview(token)`
228
+ (`@pramen/cms-astro`) from a route of your own, through the same components the published page
229
+ uses, and point the editor's Preview link button at it with `admin.previewUrl`. A working one
230
+ is `example/site/src/pages/preview.astro`.
231
+
198
232
  If you pass custom roles to `createCmsHandlers`, hand `cmsRoutes` the **same options
199
233
  object** — it derives the route's identity from them, so the two cannot drift:
200
234
 
@@ -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
+ }