@pramen/cms 0.0.58 → 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 +55 -0
- package/dist/blockkit.d.ts +258 -0
- package/dist/blockkit.js +195 -0
- package/dist/index.d.ts +944 -3
- package/dist/index.js +1896 -70
- package/dist/nav.d.ts +30 -0
- package/dist/nav.js +37 -0
- package/package.json +2 -2
- package/src/blockkit.ts +316 -0
- package/src/index.ts +2018 -83
- package/src/nav.ts +38 -0
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;
|
package/dist/blockkit.js
ADDED
|
@@ -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
|
+
}
|