@pramen/cms 0.0.59 → 0.0.60

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
@@ -30,6 +30,61 @@ compile-time field typing two ways:
30
30
  for webmaster-created types. (A `pramen cms codegen` CLI that fetches the rows over HTTP and
31
31
  writes the file is the remaining thin wrapper.)
32
32
 
33
+ ### Code-defined types are read-only in the editor
34
+
35
+ `cmsBootstrap({ blockTypes, contentTypes })` reconciles what `defineBlockType` /
36
+ `defineContentType` declare into the store on **every boot**, and the editor authors the same
37
+ two tables. So every row it writes is stamped `managedBy: <owner>`:
38
+
39
+ - `updateBlockType` / `updateContentType` answer **409** for one, naming the `define*` call to
40
+ edit instead. Without that, a save returned 200 and was patched back to the literal in
41
+ `app.ts` at the next cold start — taking any block content authored against the added field
42
+ with it.
43
+ - The type builder renders an owned type read-only, with a note saying where its definition
44
+ lives, and marks it `code` in the overview lists. Gated on
45
+ `listCmsCapabilities().codeDefinedTypes`, so an editor newer than its server fails closed.
46
+ - Drop a type from the declaration and its row is **released** — it keeps existing, because
47
+ pages are built out of it, and becomes editable again.
48
+ - A row this owner did not write is never touched. Adopting an editor-authored type of the
49
+ same slug (replacing its name and schema, then locking it) was the mirror image of the bug
50
+ above, and left nothing the editor could do about it.
51
+
52
+ `blockTypes: []` **declares none**, and so releases everything this owner holds; an absent
53
+ `blockTypes` key says nothing about that table and sweeps nothing. The empty array is the
54
+ in-band way to hand every code-defined type back to the editor — deploy it once before
55
+ removing the `cmsBootstrap` call, or the rows stay locked with nothing behind them.
56
+
57
+ Two reconcilers compose as long as they pass distinct owners, which is what lets a package
58
+ ship block types beside the app's:
59
+
60
+ ```ts
61
+ bootstrap: [
62
+ cmsBootstrap({ blockTypes: appBlocks }, { owner: "app" }),
63
+ cmsBootstrap({ blockTypes: pkgBlocks }, { owner: "some-package" }),
64
+ ]
65
+ ```
66
+
67
+ (With a shared owner each call would release the other's rows on every boot, which is exactly
68
+ why this is an owner id and not a `managed` boolean.)
69
+
70
+ #### Definitions are validated where they are written
71
+
72
+ `cmsBootstrap` validates and canonicalizes every definition it will write, at app
73
+ construction, reporting **all** the problems at once — the same rules the editor's handlers
74
+ enforce (`normalizeFieldSchema`, `normalizeRegions`, `normalizeDefaultBlocks`), so a
75
+ code-declared type cannot store a schema the builder would then refuse to save.
76
+
77
+ The check is deliberately *not* in `defineBlockType` / `defineContentType`. Those helpers are
78
+ optional: `BlockTypeDef` and `ContentTypeDef` are ordinary interfaces, so an object literal, a
79
+ `.map` over a config file or a codegen step reaches the store without going near them.
80
+ Guarding the helper guards the convenient path and leaves the sink open — and the row it
81
+ writes is then locked, so an invalid schema could not be repaired through the product at all.
82
+
83
+ They stay **pure constructors** for a second reason: canonicalization rebuilds every field
84
+ entry, so doing it there made the returned array stop matching the `as const` literal
85
+ `BlockFieldsOf<typeof def>` is inferred from — a cast a component would follow into
86
+ `fields[" title "] === undefined` with tsc insisting it was fine.
87
+
33
88
  ## SEO & sitemap
34
89
 
35
90
  - Per-page SEO on `cms_pages`: `metaTitle`, `metaDescription`, `canonicalUrl`, `robots`,
@@ -0,0 +1,258 @@
1
+ import type { HandlerContext, JsonValue, SchemaDef } from "@pramen/server";
2
+ /** Text with no formatting. Rendered as text, never as markup — the editor puts every
3
+ * string through React, so there is no HTML path here to sanitize. */
4
+ export type AdminText = string;
5
+ /** An input a form (or an actions row) can carry. */
6
+ export type AdminInput = {
7
+ type: "text_input";
8
+ action_id: string;
9
+ label?: AdminText;
10
+ placeholder?: AdminText;
11
+ initial_value?: string;
12
+ multiline?: boolean;
13
+ required?: boolean;
14
+ } | {
15
+ type: "number_input";
16
+ action_id: string;
17
+ label?: AdminText;
18
+ placeholder?: AdminText;
19
+ initial_value?: number;
20
+ min?: number;
21
+ max?: number;
22
+ required?: boolean;
23
+ } | {
24
+ type: "select";
25
+ action_id: string;
26
+ label?: AdminText;
27
+ options: {
28
+ value: string;
29
+ label: AdminText;
30
+ }[];
31
+ initial_value?: string;
32
+ required?: boolean;
33
+ } | {
34
+ type: "toggle";
35
+ action_id: string;
36
+ label?: AdminText;
37
+ initial_value?: boolean;
38
+ }
39
+ /** Write-only: never echoed back to the browser once stored. The editor renders it as a
40
+ * password field and sends it only on submit; a page that stores one must NOT put it back
41
+ * in `initial_value` on the next render, which is why there is no such key here. */
42
+ | {
43
+ type: "secret_input";
44
+ action_id: string;
45
+ label?: AdminText;
46
+ placeholder?: AdminText;
47
+ required?: boolean;
48
+ };
49
+ /** A button. `value` rides back on the interaction, so one `action_id` can serve a row. */
50
+ export interface AdminButton {
51
+ type: "button";
52
+ action_id: string;
53
+ label: AdminText;
54
+ style?: "primary" | "secondary" | "danger";
55
+ value?: string;
56
+ /** Ask before firing. Any destructive action should set it — the page cannot put up its
57
+ * own dialog, because it has no code in the browser. */
58
+ confirm?: AdminText;
59
+ }
60
+ export type AdminElement = AdminButton | AdminInput;
61
+ /** One block in a rendered admin page. */
62
+ export type AdminBlock = {
63
+ type: "header";
64
+ text: AdminText;
65
+ level?: 1 | 2 | 3;
66
+ } | {
67
+ type: "section";
68
+ text: AdminText;
69
+ } | {
70
+ type: "divider";
71
+ }
72
+ /** Small muted text — a caption, a timestamp, a hint. */
73
+ | {
74
+ type: "context";
75
+ text: AdminText;
76
+ }
77
+ /** Label/value pairs, for a record's details. */
78
+ | {
79
+ type: "fields";
80
+ fields: {
81
+ label: AdminText;
82
+ value: AdminText;
83
+ }[];
84
+ } | {
85
+ type: "table";
86
+ columns: {
87
+ key: string;
88
+ label: AdminText;
89
+ }[];
90
+ rows: Record<string, AdminText | number | boolean | null>[];
91
+ empty?: AdminText;
92
+ } | {
93
+ type: "stats";
94
+ stats: {
95
+ label: AdminText;
96
+ value: AdminText;
97
+ hint?: AdminText;
98
+ }[];
99
+ } | {
100
+ type: "actions";
101
+ block_id?: string;
102
+ elements: AdminElement[];
103
+ } | {
104
+ type: "form";
105
+ block_id: string;
106
+ fields: AdminInput[];
107
+ submit: {
108
+ label: AdminText;
109
+ action_id: string;
110
+ };
111
+ } | {
112
+ type: "image";
113
+ url: string;
114
+ alt?: AdminText;
115
+ caption?: AdminText;
116
+ } | {
117
+ type: "columns";
118
+ columns: AdminBlock[][];
119
+ } | {
120
+ type: "empty";
121
+ text: AdminText;
122
+ hint?: AdminText;
123
+ } | {
124
+ type: "accordion";
125
+ title: AdminText;
126
+ blocks: AdminBlock[];
127
+ open?: boolean;
128
+ };
129
+ /** Why the page is being rendered. */
130
+ export type AdminInteractionType = "page_load" | "block_action" | "form_submit";
131
+ /** What the editor sends. */
132
+ export interface AdminPageInteraction {
133
+ /** The registry key. Resolved to a `AdminPageDef` server-side; an unknown one is a 400,
134
+ * never a raw dispatch to something the client named. */
135
+ page: string;
136
+ type: AdminInteractionType;
137
+ /** `block_action` / `form_submit`: which control fired. */
138
+ action_id?: string;
139
+ /** The block the control belongs to. */
140
+ block_id?: string;
141
+ /** A button's `value`. */
142
+ value?: JsonValue;
143
+ /** `form_submit`: `action_id` -> the input's value. */
144
+ values?: Record<string, JsonValue>;
145
+ }
146
+ /** What a page answers with. The WHOLE page is re-rendered on every interaction — there is
147
+ * no patch protocol — because a page that returns only what changed has to agree with the
148
+ * host about what is currently on screen, and the two drift the first time a render depends
149
+ * on data that moved underneath it. */
150
+ export interface AdminPageResponse {
151
+ blocks: AdminBlock[];
152
+ /** A transient message shown over the page. */
153
+ toast?: {
154
+ text: AdminText;
155
+ tone?: "info" | "success" | "error";
156
+ };
157
+ }
158
+ /** How deep `columns` / `accordion` may nest blocks. Rendering is recursive and the
159
+ * response is server-authored but not necessarily hand-written, so it is capped. */
160
+ export declare const MAX_ADMIN_BLOCK_DEPTH = 4;
161
+ /** One custom admin page.
162
+ *
163
+ * Generic over the app's schema so `render`'s `ctx.db` is TYPED against it —
164
+ * `adminPage<typeof schema>("…", …)`, the same shape `MigrationContext<typeof schema>` uses.
165
+ * Without it `ctx.db.find({ from: "lectures", where: { title: { contains: q } } })` resolves
166
+ * the table against the default `SchemaDef` and every column reads as a number. */
167
+ export interface AdminPageDef<S extends SchemaDef = SchemaDef> {
168
+ /** URL + registry key: the page is served at `/apps/:slug` in the editor. */
169
+ readonly slug: string;
170
+ /** Nav label. */
171
+ readonly label: string;
172
+ /** Optional nav icon (emoji or short string). */
173
+ readonly icon?: string;
174
+ /** Where it sits in the nav — see {@link NAV_ORDER}. Defaults to `NAV_ORDER.adminPages`.
175
+ * This is the half of #44 that makes a project section part of the admin rather than a
176
+ * link at the end of it. */
177
+ readonly navOrder?: number;
178
+ /** Roles that may open and interact with it. Defaults to the deployment's `editorRoles`.
179
+ *
180
+ * A per-page list rather than one gate for all of them: these are project surfaces, and
181
+ * "the finance screen is admin-only while the dispatch screen is not" is the ordinary
182
+ * case. Enforced in the handler, before `render` runs. */
183
+ readonly roles?: readonly string[];
184
+ /** Build the page. An ordinary handler body: `ctx.db` is the caller's, ACL and all. */
185
+ render(ctx: HandlerContext<S>, interaction: AdminPageInteraction): Promise<AdminPageResponse> | AdminPageResponse;
186
+ }
187
+ /** Declare a custom admin page. Spread the results into `createAdminPageHandlers`:
188
+ *
189
+ * const dispatch = adminPage("dispatch", {
190
+ * label: "Dispatch",
191
+ * icon: "🚚",
192
+ * navOrder: NAV_ORDER.media + 10,
193
+ * async render(ctx, i) {
194
+ * if (i.type === "form_submit" && i.action_id === "assign") { … }
195
+ * const rows = await ctx.db.find({ from: "orders", limit: 50 });
196
+ * return { blocks: [{ type: "header", text: "Today" }, { type: "table", columns, rows }] };
197
+ * },
198
+ * });
199
+ */
200
+ 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). */
204
+ export interface AdminPageMeta {
205
+ slug: string;
206
+ label: string;
207
+ icon?: string;
208
+ navOrder: number;
209
+ }
210
+ /** Validate a registry at boot: slugs are unique and routable, and every page can be
211
+ * 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;
214
+ export interface AdminPageHandlerOpts {
215
+ /** Default roles for a page that declares none. Pass the same `editorRoles` the rest of
216
+ * the CMS uses, so one deployment has one answer to "who may author". */
217
+ editorRoles?: readonly string[];
218
+ }
219
+ /**
220
+ * Build the two handlers a Block Kit deployment needs. Spread into your app's handlers.
221
+ *
222
+ * ...createAdminPageHandlers([dispatch, reconciliation], { editorRoles })
223
+ *
224
+ * There is no ACL fragment to spread: a page reads through `ctx.db` under whatever policies
225
+ * the caller already has, so there is nothing here to grant.
226
+ */
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. */
231
+ listAdminPages: import("@pramen/server").Handler<unknown, AdminPageMeta[]>;
232
+ /**
233
+ * Render a page, or act on it and render the result.
234
+ *
235
+ * A MUTATION, always — including `page_load`. A page's `render` is arbitrary handler
236
+ * code and a `form_submit` writes, so the call has to run inside the transaction the
237
+ * dispatcher wraps a mutation in. Splitting loads into a query would mean one of the
238
+ * two entry points into the same function was not transactional, and which one you got
239
+ * would depend on the `type` field the CLIENT sent.
240
+ */
241
+ adminPageInteract: import("@pramen/server").Handler<AdminPageInteraction, AdminPageResponse>;
242
+ };
243
+ /**
244
+ * Check a page's response on the way OUT.
245
+ *
246
+ * Server-authored is not the same as trustworthy: a page builds blocks from data — a row's
247
+ * title, a URL out of an external API — so the values inside a block can be anything the
248
+ * store holds. Text is safe by construction (the editor renders every string through React,
249
+ * so there is no markup path), which leaves the attributes that are NOT text:
250
+ *
251
+ * - `image.url` becomes an `<img src>`, so it goes through the same `isSafeHref`
252
+ * allow-list a rich-text link mark does.
253
+ * - nesting is capped, because rendering is recursive.
254
+ *
255
+ * A bad block throws rather than being dropped: this is the page author's own output, and a
256
+ * block that silently vanishes is a bug that reads as "the data isn't there".
257
+ */
258
+ export declare function normalizeAdminResponse(res: AdminPageResponse): AdminPageResponse;
@@ -0,0 +1,195 @@
1
+ // Block Kit — a custom admin PAGE, described by the server as JSON and rendered by the
2
+ // editor. No project JavaScript ever runs in the admin (GitHub #33, motivated by #44).
3
+ //
4
+ // WHY THIS RATHER THAN "SHIP THE COMPONENT TREE"
5
+ //
6
+ // Client sites are mostly conventional, but nearly every one grows one section that is not:
7
+ // a screen over an external API, a filtered browse UI over data we do not own, a bespoke
8
+ // picker. Before this the only seam was `extraNav`, which renders last and opens a NEW TAB
9
+ // — so the odd 10% was a separate deployment with its own chrome, and the admin read as
10
+ // "the CMS, plus a bolted-on other thing".
11
+ //
12
+ // The tempting fix is to publish `@pramen/cms-editor`'s components so each project
13
+ // assembles its own admin. That trades one maintained application for N per-project forks:
14
+ // a public component API to keep stable, version skew between editor internals and CMS
15
+ // handlers becoming every project's problem, and the same 80% reassembled everywhere.
16
+ //
17
+ // So the registry is widened instead, which is how WordPress actually works — plugins there
18
+ // do not work because they may ship PHP, they work because there is a registry of named
19
+ // hook points and core renders them in its own chrome. `collection()` is already that trick
20
+ // at the routing level (one generic editor, N collections, zero per-collection code) and
21
+ // `FieldDefinition[]` -> `FieldForm` is already "server-described form, host-rendered". Block
22
+ // Kit is those two taken all the way: arbitrary admin PAGES, not just forms over rows.
23
+ //
24
+ // WHAT IT IS NOT
25
+ //
26
+ // It is not a way to reach past the ACL. A page's `render` is an ordinary handler body: it
27
+ // gets the caller's own `HandlerContext`, so `ctx.db` is scoped by the same policies as
28
+ // everywhere else. What Block Kit removes is the browser code, not the boundary.
29
+ //
30
+ // It is also not a "virtual collection". A `collection()` promises ACL through `ctx.db`,
31
+ // row scope, cell-level projection and `where` traversal — all of which follow from it
32
+ // being a REAL TABLE. A page here promises none of those, and says so by not being called a
33
+ // collection.
34
+ import { BadRequest, mutation, query } from "@pramen/server";
35
+ import { isSafeHref, normalizeHref } from "./href";
36
+ import { NAV_ORDER } from "./nav";
37
+ /** How deep `columns` / `accordion` may nest blocks. Rendering is recursive and the
38
+ * response is server-authored but not necessarily hand-written, so it is capped. */
39
+ export const MAX_ADMIN_BLOCK_DEPTH = 4;
40
+ /** Declare a custom admin page. Spread the results into `createAdminPageHandlers`:
41
+ *
42
+ * const dispatch = adminPage("dispatch", {
43
+ * label: "Dispatch",
44
+ * icon: "🚚",
45
+ * navOrder: NAV_ORDER.media + 10,
46
+ * async render(ctx, i) {
47
+ * if (i.type === "form_submit" && i.action_id === "assign") { … }
48
+ * const rows = await ctx.db.find({ from: "orders", limit: 50 });
49
+ * return { blocks: [{ type: "header", text: "Today" }, { type: "table", columns, rows }] };
50
+ * },
51
+ * });
52
+ */
53
+ export function adminPage(slug, opts) {
54
+ return { ...opts, slug };
55
+ }
56
+ /** Validate a registry at boot: slugs are unique and routable, and every page can be
57
+ * 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. */
59
+ export function validateAdminPages(pages) {
60
+ const seen = new Set();
61
+ for (const p of pages) {
62
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(p.slug) || p.slug.length > 80) {
63
+ throw new Error(`pramen/cms: admin page slug '${p.slug}' must be a URL segment (lowercase letters, digits and single hyphens) — it is routed at /apps/:slug`);
64
+ }
65
+ if (seen.has(p.slug))
66
+ throw new Error(`pramen/cms: duplicate admin page slug '${p.slug}' — the slug is the registry's key`);
67
+ seen.add(p.slug);
68
+ if (p.label.trim() === "")
69
+ 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")
71
+ throw new Error(`pramen/cms: admin page '${p.slug}' has no render function`);
72
+ }
73
+ }
74
+ const asObj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
75
+ const held = (ctx) => {
76
+ const identity = ctx.identity;
77
+ if (Array.isArray(identity?.roles))
78
+ return identity.roles.map(String);
79
+ return typeof identity?.role === "string" ? [identity.role] : [];
80
+ };
81
+ /**
82
+ * Build the two handlers a Block Kit deployment needs. Spread into your app's handlers.
83
+ *
84
+ * ...createAdminPageHandlers([dispatch, reconciliation], { editorRoles })
85
+ *
86
+ * There is no ACL fragment to spread: a page reads through `ctx.db` under whatever policies
87
+ * the caller already has, so there is nothing here to grant.
88
+ */
89
+ export function createAdminPageHandlers(pages, opts = {}) {
90
+ validateAdminPages(pages);
91
+ const defaultRoles = opts.editorRoles ?? ["editor", "admin"];
92
+ const bySlug = new Map(pages.map((p) => [p.slug, p]));
93
+ const rolesFor = (p) => p.roles ?? defaultRoles;
94
+ const mayOpen = (ctx, p) => held(ctx).some((r) => rolesFor(p).includes(r));
95
+ 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. */
99
+ listAdminPages: query((ctx) => pages
100
+ .filter((p) => mayOpen(ctx, p))
101
+ .map((p) => ({ slug: p.slug, label: p.label, icon: p.icon, navOrder: p.navOrder ?? NAV_ORDER.adminPages }))),
102
+ /**
103
+ * Render a page, or act on it and render the result.
104
+ *
105
+ * A MUTATION, always — including `page_load`. A page's `render` is arbitrary handler
106
+ * code and a `form_submit` writes, so the call has to run inside the transaction the
107
+ * dispatcher wraps a mutation in. Splitting loads into a query would mean one of the
108
+ * two entry points into the same function was not transactional, and which one you got
109
+ * would depend on the `type` field the CLIENT sent.
110
+ */
111
+ adminPageInteract: mutation(async (ctx, input) => {
112
+ // The registry, keyed by slug — the same defence `collectionList` uses. An unknown
113
+ // slug is a 400 naming nothing, never a dispatch to something the client chose.
114
+ const page = bySlug.get(input.page);
115
+ if (!page)
116
+ throw new BadRequest(`unknown admin page '${input.page}'`);
117
+ // Before `render`, so a page's own code never runs for a caller who may not open it.
118
+ if (!mayOpen(ctx, page))
119
+ throw new BadRequest(`unknown admin page '${input.page}'`);
120
+ const res = await page.render(ctx, input);
121
+ return normalizeAdminResponse(res);
122
+ }, {
123
+ input: (raw) => {
124
+ const o = asObj(raw);
125
+ const slug = typeof o.page === "string" ? o.page : "";
126
+ if (!slug)
127
+ throw new BadRequest("page is required");
128
+ const type = (typeof o.type === "string" ? o.type : "page_load");
129
+ if (!["page_load", "block_action", "form_submit"].includes(type))
130
+ throw new BadRequest(`unknown interaction type '${String(o.type)}'`);
131
+ const out = { page: slug, type };
132
+ if (typeof o.action_id === "string")
133
+ out.action_id = o.action_id;
134
+ if (typeof o.block_id === "string")
135
+ out.block_id = o.block_id;
136
+ if (o.value !== undefined)
137
+ out.value = o.value;
138
+ if (o.values !== undefined) {
139
+ if (o.values === null || typeof o.values !== "object" || Array.isArray(o.values))
140
+ throw new BadRequest("values must be an object");
141
+ out.values = o.values;
142
+ }
143
+ return out;
144
+ },
145
+ }),
146
+ };
147
+ }
148
+ // --- response normalization --------------------------------------------------------------
149
+ /**
150
+ * Check a page's response on the way OUT.
151
+ *
152
+ * Server-authored is not the same as trustworthy: a page builds blocks from data — a row's
153
+ * title, a URL out of an external API — so the values inside a block can be anything the
154
+ * store holds. Text is safe by construction (the editor renders every string through React,
155
+ * so there is no markup path), which leaves the attributes that are NOT text:
156
+ *
157
+ * - `image.url` becomes an `<img src>`, so it goes through the same `isSafeHref`
158
+ * allow-list a rich-text link mark does.
159
+ * - nesting is capped, because rendering is recursive.
160
+ *
161
+ * A bad block throws rather than being dropped: this is the page author's own output, and a
162
+ * block that silently vanishes is a bug that reads as "the data isn't there".
163
+ */
164
+ export function normalizeAdminResponse(res) {
165
+ if (!res || !Array.isArray(res.blocks))
166
+ throw new Error("pramen/cms: an admin page must return { blocks: [...] }");
167
+ const out = { blocks: res.blocks.map((b) => normalizeAdminBlock(b, 0)) };
168
+ if (res.toast)
169
+ out.toast = { text: String(res.toast.text), tone: res.toast.tone };
170
+ return out;
171
+ }
172
+ function normalizeAdminBlock(block, depth) {
173
+ if (depth >= MAX_ADMIN_BLOCK_DEPTH)
174
+ throw new Error(`pramen/cms: admin blocks nest deeper than ${MAX_ADMIN_BLOCK_DEPTH} levels`);
175
+ switch (block.type) {
176
+ case "image": {
177
+ const url = normalizeHref(block.url);
178
+ if (!isSafeHref(url))
179
+ throw new Error(`pramen/cms: admin page image url ${JSON.stringify(block.url)} is not an allowed href`);
180
+ return { ...block, url };
181
+ }
182
+ case "columns":
183
+ return { ...block, columns: block.columns.map((col) => col.map((b) => normalizeAdminBlock(b, depth + 1))) };
184
+ case "accordion":
185
+ return { ...block, blocks: block.blocks.map((b) => normalizeAdminBlock(b, depth + 1)) };
186
+ case "form":
187
+ // `block_id` is how the editor keys a form's local values. Two forms sharing one would
188
+ // share their state, so the second would submit the first one's inputs.
189
+ if (!block.block_id)
190
+ throw new Error("pramen/cms: a `form` block needs a block_id");
191
+ return block;
192
+ default:
193
+ return block;
194
+ }
195
+ }