@pramen/cms 0.0.59 → 0.0.61

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/nav.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Positions for the editor's built-in nav sections.
3
+ *
4
+ * Spaced 100 apart so anything can be placed BETWEEN two built-ins without renumbering
5
+ * them — `navOrder: 150` puts a section after Pages and before collections. The values
6
+ * themselves are ordinals with no other meaning; only their relative order is contract.
7
+ *
8
+ * Declared server-side, and not only in the editor, because the ordering key travels on
9
+ * `CollectionMeta` and `AdminPageMeta`: a host choosing a position has to be able to name
10
+ * what it is placing against, and it is writing `app.ts`, not editor code. The editor keeps
11
+ * a mirror — it is a standalone browser app that speaks to the CMS purely over HTTP, so it
12
+ * cannot import this.
13
+ */
14
+ export declare const NAV_ORDER: {
15
+ readonly pages: 100;
16
+ readonly collections: 200;
17
+ readonly media: 300;
18
+ readonly menus: 400;
19
+ readonly taxonomies: 500;
20
+ readonly widgets: 600;
21
+ readonly redirects: 700;
22
+ /** Custom admin pages (`adminPage()`), which are project surfaces, not CMS furniture. */
23
+ readonly adminPages: 800;
24
+ /** Block/content-type authoring — schema, so it sits with the admin tools, not content. */
25
+ readonly types: 900;
26
+ readonly users: 1000;
27
+ readonly settings: 1100;
28
+ /** Host-configured `extraNav` links, which are still last by default. */
29
+ readonly extra: 1200;
30
+ };
package/dist/nav.js ADDED
@@ -0,0 +1,37 @@
1
+ // Where each built-in section sits in the CMS editor's primary nav.
2
+ //
3
+ // A LEAF module, like `href.ts`: both `index.ts` (for `CollectionDef.navOrder`) and
4
+ // `blockkit.ts` (for `AdminPageDef.navOrder`) need it, and importing it from `index.ts`
5
+ // would make `blockkit.ts` depend on a module that evaluates `Entity(...)` calls at module
6
+ // scope — a cycle, and one whose evaluation order would decide whether `NAV_ORDER` is
7
+ // defined when `adminPage()` reads it.
8
+ /**
9
+ * Positions for the editor's built-in nav sections.
10
+ *
11
+ * Spaced 100 apart so anything can be placed BETWEEN two built-ins without renumbering
12
+ * them — `navOrder: 150` puts a section after Pages and before collections. The values
13
+ * themselves are ordinals with no other meaning; only their relative order is contract.
14
+ *
15
+ * Declared server-side, and not only in the editor, because the ordering key travels on
16
+ * `CollectionMeta` and `AdminPageMeta`: a host choosing a position has to be able to name
17
+ * what it is placing against, and it is writing `app.ts`, not editor code. The editor keeps
18
+ * a mirror — it is a standalone browser app that speaks to the CMS purely over HTTP, so it
19
+ * cannot import this.
20
+ */
21
+ export const NAV_ORDER = {
22
+ pages: 100,
23
+ collections: 200,
24
+ media: 300,
25
+ menus: 400,
26
+ taxonomies: 500,
27
+ widgets: 600,
28
+ redirects: 700,
29
+ /** Custom admin pages (`adminPage()`), which are project surfaces, not CMS furniture. */
30
+ adminPages: 800,
31
+ /** Block/content-type authoring — schema, so it sits with the admin tools, not content. */
32
+ types: 900,
33
+ users: 1000,
34
+ settings: 1100,
35
+ /** Host-configured `extraNav` links, which are still last by default. */
36
+ extra: 1200,
37
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms",
3
- "version": "0.0.59",
3
+ "version": "0.0.61",
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.59"
44
+ "@pramen/server": "0.0.61"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "react": ">=18"
@@ -0,0 +1,316 @@
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
+
35
+ import { BadRequest, mutation, query } from "@pramen/server";
36
+ import type { HandlerContext, JsonValue, SchemaDef } from "@pramen/server";
37
+ import { isSafeHref, normalizeHref } from "./href";
38
+ import { NAV_ORDER } from "./nav";
39
+
40
+ // --- the block/element vocabulary ------------------------------------------------------
41
+
42
+ /** Text with no formatting. Rendered as text, never as markup — the editor puts every
43
+ * string through React, so there is no HTML path here to sanitize. */
44
+ export type AdminText = string;
45
+
46
+ /** An input a form (or an actions row) can carry. */
47
+ 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 }
52
+ /** Write-only: never echoed back to the browser once stored. The editor renders it as a
53
+ * password field and sends it only on submit; a page that stores one must NOT put it back
54
+ * 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 };
56
+
57
+ /** A button. `value` rides back on the interaction, so one `action_id` can serve a row. */
58
+ export interface AdminButton {
59
+ type: "button";
60
+ action_id: string;
61
+ label: AdminText;
62
+ style?: "primary" | "secondary" | "danger";
63
+ value?: string;
64
+ /** Ask before firing. Any destructive action should set it — the page cannot put up its
65
+ * own dialog, because it has no code in the browser. */
66
+ confirm?: AdminText;
67
+ }
68
+
69
+ export type AdminElement = AdminButton | AdminInput;
70
+
71
+ /** One block in a rendered admin page. */
72
+ export type AdminBlock =
73
+ | { type: "header"; text: AdminText; level?: 1 | 2 | 3 }
74
+ | { type: "section"; text: AdminText }
75
+ | { type: "divider" }
76
+ /** Small muted text — a caption, a timestamp, a hint. */
77
+ | { type: "context"; text: AdminText }
78
+ /** Label/value pairs, for a record's details. */
79
+ | { type: "fields"; fields: { label: AdminText; value: AdminText }[] }
80
+ | { type: "table"; columns: { key: string; label: AdminText }[]; rows: Record<string, AdminText | number | boolean | null>[]; empty?: AdminText }
81
+ | { type: "stats"; stats: { label: AdminText; value: AdminText; hint?: AdminText }[] }
82
+ | { type: "actions"; block_id?: string; elements: AdminElement[] }
83
+ | { type: "form"; block_id: string; fields: AdminInput[]; submit: { label: AdminText; action_id: string } }
84
+ | { type: "image"; url: string; alt?: AdminText; caption?: AdminText }
85
+ | { type: "columns"; columns: AdminBlock[][] }
86
+ | { type: "empty"; text: AdminText; hint?: AdminText }
87
+ | { type: "accordion"; title: AdminText; blocks: AdminBlock[]; open?: boolean };
88
+
89
+ /** Why the page is being rendered. */
90
+ export type AdminInteractionType = "page_load" | "block_action" | "form_submit";
91
+
92
+ /** What the editor sends. */
93
+ export interface AdminPageInteraction {
94
+ /** The registry key. Resolved to a `AdminPageDef` server-side; an unknown one is a 400,
95
+ * never a raw dispatch to something the client named. */
96
+ page: string;
97
+ type: AdminInteractionType;
98
+ /** `block_action` / `form_submit`: which control fired. */
99
+ action_id?: string;
100
+ /** The block the control belongs to. */
101
+ block_id?: string;
102
+ /** A button's `value`. */
103
+ value?: JsonValue;
104
+ /** `form_submit`: `action_id` -> the input's value. */
105
+ values?: Record<string, JsonValue>;
106
+ }
107
+
108
+ /** What a page answers with. The WHOLE page is re-rendered on every interaction — there is
109
+ * no patch protocol — because a page that returns only what changed has to agree with the
110
+ * host about what is currently on screen, and the two drift the first time a render depends
111
+ * on data that moved underneath it. */
112
+ export interface AdminPageResponse {
113
+ blocks: AdminBlock[];
114
+ /** A transient message shown over the page. */
115
+ toast?: { text: AdminText; tone?: "info" | "success" | "error" };
116
+ }
117
+
118
+ /** How deep `columns` / `accordion` may nest blocks. Rendering is recursive and the
119
+ * response is server-authored but not necessarily hand-written, so it is capped. */
120
+ export const MAX_ADMIN_BLOCK_DEPTH = 4;
121
+
122
+ // --- the registry ----------------------------------------------------------------------
123
+
124
+ /** One custom admin page.
125
+ *
126
+ * Generic over the app's schema so `render`'s `ctx.db` is TYPED against it —
127
+ * `adminPage<typeof schema>("…", …)`, the same shape `MigrationContext<typeof schema>` uses.
128
+ * Without it `ctx.db.find({ from: "lectures", where: { title: { contains: q } } })` resolves
129
+ * the table against the default `SchemaDef` and every column reads as a number. */
130
+ export interface AdminPageDef<S extends SchemaDef = SchemaDef> {
131
+ /** URL + registry key: the page is served at `/apps/:slug` in the editor. */
132
+ readonly slug: string;
133
+ /** Nav label. */
134
+ readonly label: string;
135
+ /** Optional nav icon (emoji or short string). */
136
+ readonly icon?: string;
137
+ /** Where it sits in the nav — see {@link NAV_ORDER}. Defaults to `NAV_ORDER.adminPages`.
138
+ * This is the half of #44 that makes a project section part of the admin rather than a
139
+ * link at the end of it. */
140
+ readonly navOrder?: number;
141
+ /** Roles that may open and interact with it. Defaults to the deployment's `editorRoles`.
142
+ *
143
+ * A per-page list rather than one gate for all of them: these are project surfaces, and
144
+ * "the finance screen is admin-only while the dispatch screen is not" is the ordinary
145
+ * case. Enforced in the handler, before `render` runs. */
146
+ readonly roles?: readonly string[];
147
+ /** Build the page. An ordinary handler body: `ctx.db` is the caller's, ACL and all. */
148
+ render(ctx: HandlerContext<S>, interaction: AdminPageInteraction): Promise<AdminPageResponse> | AdminPageResponse;
149
+ }
150
+
151
+ /** Declare a custom admin page. Spread the results into `createAdminPageHandlers`:
152
+ *
153
+ * const dispatch = adminPage("dispatch", {
154
+ * label: "Dispatch",
155
+ * icon: "🚚",
156
+ * navOrder: NAV_ORDER.media + 10,
157
+ * async render(ctx, i) {
158
+ * if (i.type === "form_submit" && i.action_id === "assign") { … }
159
+ * const rows = await ctx.db.find({ from: "orders", limit: 50 });
160
+ * return { blocks: [{ type: "header", text: "Today" }, { type: "table", columns, rows }] };
161
+ * },
162
+ * });
163
+ */
164
+ export function adminPage<S extends SchemaDef = SchemaDef>(slug: string, opts: Omit<AdminPageDef<S>, "slug">): AdminPageDef<S> {
165
+ return { ...opts, slug };
166
+ }
167
+
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). */
171
+ export interface AdminPageMeta {
172
+ slug: string;
173
+ label: string;
174
+ icon?: string;
175
+ navOrder: number;
176
+ }
177
+
178
+ /** Validate a registry at boot: slugs are unique and routable, and every page can be
179
+ * 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 {
182
+ const seen = new Set<string>();
183
+ for (const p of pages) {
184
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(p.slug) || p.slug.length > 80) {
185
+ 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`);
186
+ }
187
+ if (seen.has(p.slug)) throw new Error(`pramen/cms: duplicate admin page slug '${p.slug}' — the slug is the registry's key`);
188
+ seen.add(p.slug);
189
+ 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`);
191
+ }
192
+ }
193
+
194
+ const asObj = (v: unknown): Record<string, unknown> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
195
+
196
+ const held = (ctx: HandlerContext): string[] => {
197
+ const identity = ctx.identity as { roles?: unknown; role?: unknown } | undefined;
198
+ if (Array.isArray(identity?.roles)) return identity.roles.map(String);
199
+ return typeof identity?.role === "string" ? [identity.role] : [];
200
+ };
201
+
202
+ export interface AdminPageHandlerOpts {
203
+ /** Default roles for a page that declares none. Pass the same `editorRoles` the rest of
204
+ * the CMS uses, so one deployment has one answer to "who may author". */
205
+ editorRoles?: readonly string[];
206
+ }
207
+
208
+ /**
209
+ * Build the two handlers a Block Kit deployment needs. Spread into your app's handlers.
210
+ *
211
+ * ...createAdminPageHandlers([dispatch, reconciliation], { editorRoles })
212
+ *
213
+ * There is no ACL fragment to spread: a page reads through `ctx.db` under whatever policies
214
+ * the caller already has, so there is nothing here to grant.
215
+ */
216
+ export function createAdminPageHandlers(pages: readonly AdminPageDef[], opts: AdminPageHandlerOpts = {}) {
217
+ validateAdminPages(pages);
218
+ const defaultRoles = opts.editorRoles ?? ["editor", "admin"];
219
+ 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));
222
+
223
+ 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. */
227
+ listAdminPages: query((ctx): AdminPageMeta[] =>
228
+ pages
229
+ .filter((p) => mayOpen(ctx, p))
230
+ .map((p) => ({ slug: p.slug, label: p.label, icon: p.icon, navOrder: p.navOrder ?? NAV_ORDER.adminPages })),
231
+ ),
232
+
233
+ /**
234
+ * Render a page, or act on it and render the result.
235
+ *
236
+ * A MUTATION, always — including `page_load`. A page's `render` is arbitrary handler
237
+ * code and a `form_submit` writes, so the call has to run inside the transaction the
238
+ * dispatcher wraps a mutation in. Splitting loads into a query would mean one of the
239
+ * two entry points into the same function was not transactional, and which one you got
240
+ * would depend on the `type` field the CLIENT sent.
241
+ */
242
+ adminPageInteract: mutation(async (ctx, input: AdminPageInteraction): Promise<AdminPageResponse> => {
243
+ // The registry, keyed by slug — the same defence `collectionList` uses. An unknown
244
+ // slug is a 400 naming nothing, never a dispatch to something the client chose.
245
+ const page = bySlug.get(input.page);
246
+ if (!page) throw new BadRequest(`unknown admin page '${input.page}'`);
247
+ // Before `render`, so a page's own code never runs for a caller who may not open it.
248
+ if (!mayOpen(ctx, page)) throw new BadRequest(`unknown admin page '${input.page}'`);
249
+ const res = await page.render(ctx, input);
250
+ return normalizeAdminResponse(res);
251
+ }, {
252
+ input: (raw): AdminPageInteraction => {
253
+ const o = asObj(raw);
254
+ const slug = typeof o.page === "string" ? o.page : "";
255
+ if (!slug) throw new BadRequest("page is required");
256
+ const type = (typeof o.type === "string" ? o.type : "page_load") as AdminInteractionType;
257
+ if (!["page_load", "block_action", "form_submit"].includes(type)) throw new BadRequest(`unknown interaction type '${String(o.type)}'`);
258
+ const out: AdminPageInteraction = { page: slug, type };
259
+ if (typeof o.action_id === "string") out.action_id = o.action_id;
260
+ if (typeof o.block_id === "string") out.block_id = o.block_id;
261
+ if (o.value !== undefined) out.value = o.value as JsonValue;
262
+ if (o.values !== undefined) {
263
+ if (o.values === null || typeof o.values !== "object" || Array.isArray(o.values)) throw new BadRequest("values must be an object");
264
+ out.values = o.values as Record<string, JsonValue>;
265
+ }
266
+ return out;
267
+ },
268
+ }),
269
+ };
270
+ }
271
+
272
+ // --- response normalization --------------------------------------------------------------
273
+
274
+ /**
275
+ * Check a page's response on the way OUT.
276
+ *
277
+ * Server-authored is not the same as trustworthy: a page builds blocks from data — a row's
278
+ * title, a URL out of an external API — so the values inside a block can be anything the
279
+ * store holds. Text is safe by construction (the editor renders every string through React,
280
+ * so there is no markup path), which leaves the attributes that are NOT text:
281
+ *
282
+ * - `image.url` becomes an `<img src>`, so it goes through the same `isSafeHref`
283
+ * allow-list a rich-text link mark does.
284
+ * - nesting is capped, because rendering is recursive.
285
+ *
286
+ * A bad block throws rather than being dropped: this is the page author's own output, and a
287
+ * block that silently vanishes is a bug that reads as "the data isn't there".
288
+ */
289
+ export function normalizeAdminResponse(res: AdminPageResponse): AdminPageResponse {
290
+ 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)) };
292
+ if (res.toast) out.toast = { text: String(res.toast.text), tone: res.toast.tone };
293
+ return out;
294
+ }
295
+
296
+ function normalizeAdminBlock(block: AdminBlock, depth: number): AdminBlock {
297
+ if (depth >= MAX_ADMIN_BLOCK_DEPTH) throw new Error(`pramen/cms: admin blocks nest deeper than ${MAX_ADMIN_BLOCK_DEPTH} levels`);
298
+ switch (block.type) {
299
+ case "image": {
300
+ const url = normalizeHref(block.url);
301
+ if (!isSafeHref(url)) throw new Error(`pramen/cms: admin page image url ${JSON.stringify(block.url)} is not an allowed href`);
302
+ return { ...block, url };
303
+ }
304
+ case "columns":
305
+ return { ...block, columns: block.columns.map((col) => col.map((b) => normalizeAdminBlock(b, depth + 1))) };
306
+ case "accordion":
307
+ return { ...block, blocks: block.blocks.map((b) => normalizeAdminBlock(b, depth + 1)) };
308
+ case "form":
309
+ // `block_id` is how the editor keys a form's local values. Two forms sharing one would
310
+ // share their state, so the second would submit the first one's inputs.
311
+ if (!block.block_id) throw new Error("pramen/cms: a `form` block needs a block_id");
312
+ return block;
313
+ default:
314
+ return block;
315
+ }
316
+ }