@pramen/cms 0.0.63 → 0.0.65
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/blockkit.d.ts +85 -13
- package/dist/blockkit.js +140 -11
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/panel.d.ts +53 -0
- package/dist/panel.js +74 -0
- package/package.json +2 -2
- package/src/blockkit.ts +195 -25
- package/src/index.ts +10 -0
- package/src/panel.ts +105 -0
package/dist/blockkit.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
228
|
-
/** The
|
|
229
|
-
* that 403s when clicked is worse than one that is not
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
97
|
-
* that 403s when clicked is worse than one that is not
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1149,8 +1149,12 @@ export declare function taxonomyApplies(row: {
|
|
|
1149
1149
|
}, target: TaxonomyTarget): boolean;
|
|
1150
1150
|
/** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
|
|
1151
1151
|
* `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
|
|
1152
|
-
export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
|
|
1153
|
-
export type { AdminBlock, AdminButton, AdminElement, AdminInput, AdminInteractionType, AdminPageDef, AdminPageHandlerOpts, AdminPageInteraction, AdminPageMeta, AdminPageResponse, AdminText, } from "./blockkit";
|
|
1152
|
+
export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, ADMIN_ELEMENT_TYPES, ADMIN_PAGE_KINDS, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
|
|
1153
|
+
export type { AdminBlock, AdminButton, AdminCell, AdminElement, AdminInput, AdminInteractionType, AdminPageDef, AdminPageHandlerOpts, AdminPageInteraction, AdminPageKind, AdminPageMeta, AdminPageResponse, AdminScreenDef, AdminText, } from "./blockkit";
|
|
1154
|
+
/** Custom admin PANELS — a project's own React screen inside the editor's chrome, for the
|
|
1155
|
+
* screens a server-driven vocabulary cannot carry. See `./panel`. */
|
|
1156
|
+
export { adminPanel, isAdminPanel } from "./panel";
|
|
1157
|
+
export type { AdminPanelDef } from "./panel";
|
|
1154
1158
|
/**
|
|
1155
1159
|
* Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
|
|
1156
1160
|
*
|
package/dist/index.js
CHANGED
|
@@ -851,7 +851,10 @@ async function assertTermsApplyTo(db, termIds, target) {
|
|
|
851
851
|
}
|
|
852
852
|
/** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
|
|
853
853
|
* `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
|
|
854
|
-
export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
|
|
854
|
+
export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, ADMIN_ELEMENT_TYPES, ADMIN_PAGE_KINDS, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
|
|
855
|
+
/** Custom admin PANELS — a project's own React screen inside the editor's chrome, for the
|
|
856
|
+
* screens a server-driven vocabulary cannot carry. See `./panel`. */
|
|
857
|
+
export { adminPanel, isAdminPanel } from "./panel";
|
|
855
858
|
/**
|
|
856
859
|
* Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
|
|
857
860
|
*
|
package/dist/panel.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** One custom admin panel: a nav entry the browser bundle fills in.
|
|
2
|
+
*
|
|
3
|
+
* There is no `render` here and there is not meant to be one — the rendering half is a
|
|
4
|
+
* React component the deployment's panel bundle registers under the same `slug`. Everything
|
|
5
|
+
* that decides whether the entry EXISTS is here, on the server, where it can be enforced.
|
|
6
|
+
*/
|
|
7
|
+
export interface AdminPanelDef {
|
|
8
|
+
/** Discriminates a panel from an `AdminPageDef` in the one registry they share. Set by
|
|
9
|
+
* {@link adminPanel}; it is a required field rather than an inferred one so a hand-built
|
|
10
|
+
* object literal cannot be a half-declared panel. */
|
|
11
|
+
readonly kind: "panel";
|
|
12
|
+
/** URL + registry key: served at `/apps/:slug` in the editor, and the id the browser
|
|
13
|
+
* bundle registers its component under. */
|
|
14
|
+
readonly slug: string;
|
|
15
|
+
/** Nav label. */
|
|
16
|
+
readonly label: string;
|
|
17
|
+
/** Optional nav icon (emoji or short string). */
|
|
18
|
+
readonly icon?: string;
|
|
19
|
+
/** Where it sits in the nav — see `NAV_ORDER`. Defaults to `NAV_ORDER.adminPages`. */
|
|
20
|
+
readonly navOrder?: number;
|
|
21
|
+
/** Roles that may open it. Defaults to the deployment's `editorRoles`, exactly as a Block
|
|
22
|
+
* Kit page's does — one registry, one gate.
|
|
23
|
+
*
|
|
24
|
+
* This is the ONLY authorization a panel gets for free. A panel's own code runs in the
|
|
25
|
+
* browser, so every call it makes is an ordinary RPC under the caller's own identity and
|
|
26
|
+
* ACL; this list decides who is shown the screen, not what the screen may do. */
|
|
27
|
+
readonly roles?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Declare a custom admin panel. Spread the result into `createAdminPageHandlers` alongside
|
|
31
|
+
* any `adminPage()`s:
|
|
32
|
+
*
|
|
33
|
+
* const curation = adminPanel("curation", {
|
|
34
|
+
* label: "Curation",
|
|
35
|
+
* icon: "🎛",
|
|
36
|
+
* navOrder: NAV_ORDER.media + 10,
|
|
37
|
+
* roles: ["editor", "admin"],
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* handlers = { ...createAdminPageHandlers([desk, curation], { editorRoles }) };
|
|
41
|
+
*
|
|
42
|
+
* The matching component is registered by the deployment's panel bundle — see the
|
|
43
|
+
* "Custom admin panels" section of the CMS docs.
|
|
44
|
+
*/
|
|
45
|
+
export declare function adminPanel(slug: string, opts: Omit<AdminPanelDef, "slug" | "kind">): AdminPanelDef;
|
|
46
|
+
/** Whether a registry entry is a panel (and so has no server-side render).
|
|
47
|
+
*
|
|
48
|
+
* Reads the discriminant rather than testing for the ABSENCE of `render`: "no render" is
|
|
49
|
+
* also what a malformed page looks like, and `validateAdminPages` has to be able to tell a
|
|
50
|
+
* panel from a page someone forgot to finish. */
|
|
51
|
+
export declare function isAdminPanel(def: {
|
|
52
|
+
readonly kind?: string;
|
|
53
|
+
}): def is AdminPanelDef;
|
package/dist/panel.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// A custom admin PANEL — a project's own React screen, rendered inside the editor's chrome
|
|
2
|
+
// at a real route with a real nav entry.
|
|
3
|
+
//
|
|
4
|
+
// WHY A SECOND KIND, WHEN `adminPage()` EXISTS
|
|
5
|
+
//
|
|
6
|
+
// Block Kit (`adminPage()`) is a server-driven vocabulary: the handler returns JSON, the
|
|
7
|
+
// editor renders it, and the whole page comes back on every interaction. That is exactly
|
|
8
|
+
// right for a list-and-form screen, and it is the reason no project JavaScript runs in the
|
|
9
|
+
// admin. But the properties that make it safe are the same ones that cap it — an input
|
|
10
|
+
// cannot fire an interaction, every control is disabled for the round trip (so focus and
|
|
11
|
+
// caret are lost on each keystroke that matters), a table row cannot expand, and there is
|
|
12
|
+
// no link, no redirect, no dialog, no autofocus and no date input. Those are not gaps to
|
|
13
|
+
// patch one element at a time; a screen that needs local interaction needs local code.
|
|
14
|
+
//
|
|
15
|
+
// The alternative a project reaches for when Block Kit runs out is what this replaces: a
|
|
16
|
+
// standalone React SPA served next to the editor with the chrome rebuilt by hand. It goes
|
|
17
|
+
// out of the application, it does not have the same layout, and every chrome fix has to be
|
|
18
|
+
// made twice.
|
|
19
|
+
//
|
|
20
|
+
// So a panel is the SAME registry entry as a Block Kit page with the render moved to the
|
|
21
|
+
// browser. Same slug space, same `/apps/:slug` route, same "Apps" band in the nav, and —
|
|
22
|
+
// the part that matters — the same server-side role filter: a panel the caller may not open
|
|
23
|
+
// is absent from `listAdminPages`, so there is no nav entry to click and no route to reach.
|
|
24
|
+
//
|
|
25
|
+
// WHAT LIVES WHERE. The server owns everything a nav entry is made of (slug, label, icon,
|
|
26
|
+
// position, roles); the browser bundle owns only the component. That split is deliberate:
|
|
27
|
+
// if the bundle declared the label and the position, a client that failed to load would
|
|
28
|
+
// take the nav entry with it, and a client that loaded would be declaring its own placement
|
|
29
|
+
// with nothing to check it against. A panel whose bundle never registers is a listed entry
|
|
30
|
+
// that renders a diagnostic — which is a legible failure — rather than a section that
|
|
31
|
+
// silently ceases to exist.
|
|
32
|
+
//
|
|
33
|
+
// WHAT IS GIVEN UP. Block Kit's headline property is that no project JavaScript ever runs in
|
|
34
|
+
// the admin (#33). A panel gives that up, deliberately and only where a deployment asks for
|
|
35
|
+
// it: the bundle runs in the editor's own page with the editor's own session in scope, so it
|
|
36
|
+
// can read the stored token and call anything the caller can. `roles` below and the ACL still
|
|
37
|
+
// bound what the SERVER will do, and `PanelApi` is a small surface to write against, but
|
|
38
|
+
// neither is a sandbox — a panel is part of the admin, not a guest in it. That is the reason
|
|
39
|
+
// `adminPage()` remains the first thing to reach for and this the second.
|
|
40
|
+
//
|
|
41
|
+
// The same reconciliation is written from the other side at the top of `blockkit.ts` — why
|
|
42
|
+
// "do not ship the component tree" and this are one position rather than two, and which of
|
|
43
|
+
// the pair to reach for first. Read together; changing one without the other leaves the
|
|
44
|
+
// framework arguing with itself.
|
|
45
|
+
//
|
|
46
|
+
// A LEAF module, like `href.ts` and `nav.ts`: `blockkit.ts` imports it to widen the
|
|
47
|
+
// registry, and it imports nothing back.
|
|
48
|
+
/**
|
|
49
|
+
* Declare a custom admin panel. Spread the result into `createAdminPageHandlers` alongside
|
|
50
|
+
* any `adminPage()`s:
|
|
51
|
+
*
|
|
52
|
+
* const curation = adminPanel("curation", {
|
|
53
|
+
* label: "Curation",
|
|
54
|
+
* icon: "🎛",
|
|
55
|
+
* navOrder: NAV_ORDER.media + 10,
|
|
56
|
+
* roles: ["editor", "admin"],
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* handlers = { ...createAdminPageHandlers([desk, curation], { editorRoles }) };
|
|
60
|
+
*
|
|
61
|
+
* The matching component is registered by the deployment's panel bundle — see the
|
|
62
|
+
* "Custom admin panels" section of the CMS docs.
|
|
63
|
+
*/
|
|
64
|
+
export function adminPanel(slug, opts) {
|
|
65
|
+
return { ...opts, kind: "panel", slug };
|
|
66
|
+
}
|
|
67
|
+
/** Whether a registry entry is a panel (and so has no server-side render).
|
|
68
|
+
*
|
|
69
|
+
* Reads the discriminant rather than testing for the ABSENCE of `render`: "no render" is
|
|
70
|
+
* also what a malformed page looks like, and `validateAdminPages` has to be able to tell a
|
|
71
|
+
* panel from a page someone forgot to finish. */
|
|
72
|
+
export function isAdminPanel(def) {
|
|
73
|
+
return def.kind === "panel";
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.65",
|
|
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.
|
|
44
|
+
"@pramen/server": "0.0.65"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"react": ">=18"
|
package/src/blockkit.ts
CHANGED
|
@@ -21,6 +21,46 @@
|
|
|
21
21
|
// `FieldDefinition[]` -> `FieldForm` is already "server-described form, host-rendered". Block
|
|
22
22
|
// Kit is those two taken all the way: arbitrary admin PAGES, not just forms over rows.
|
|
23
23
|
//
|
|
24
|
+
// AND YET `adminPanel()` SHIPS BESIDE IT, WHICH IS PROJECT CODE IN THE BROWSER
|
|
25
|
+
//
|
|
26
|
+
// It does, and the paragraph above is not quietly wrong — the two are one position, not two.
|
|
27
|
+
// What that paragraph rejects is publishing the EDITOR'S COMPONENT LIBRARY so that every
|
|
28
|
+
// project assembles its own admin out of it: that is what makes N forks of the same 80%,
|
|
29
|
+
// because each project then owns the chrome, the nav, the page list, the login and the media
|
|
30
|
+
// browser, and every fix has to be made N times. A panel is the opposite trade — ONE SCREEN,
|
|
31
|
+
// rendered inside chrome that is still ours, at a route that is still ours, from a registry
|
|
32
|
+
// entry that is still the server's. No component API is published: a panel is handed React,
|
|
33
|
+
// four props and nothing else (`panel-runtime.ts` in @pramen/cms-editor is a long argument
|
|
34
|
+
// about what is deliberately absent from that list), so there is nothing to reassemble an
|
|
35
|
+
// admin out of and no reason to fork one.
|
|
36
|
+
//
|
|
37
|
+
// What the objection DOES carry over is version skew, and it is not waved away: a panel is
|
|
38
|
+
// compiled at the project's build, against whichever React they had, and linked at runtime
|
|
39
|
+
// against whichever React the editor loaded. That is why the seam is a numbered contract a
|
|
40
|
+
// bundle must STATE and the editor refuses on mismatch, rather than a promise in a README.
|
|
41
|
+
// Skew becomes one refusal naming the slug and the fix, on the screen the panel should have
|
|
42
|
+
// been — which is precisely what "N per-project forks" never had.
|
|
43
|
+
//
|
|
44
|
+
// WHICH TO REACH FOR, AND WHAT THE SECOND ONE COSTS
|
|
45
|
+
//
|
|
46
|
+
// `adminPage()` for a list-and-form screen: rows, filters, a form, a confirm. No build step,
|
|
47
|
+
// no bundle to keep deployed in step with the admin, no React version to keep aligned, and no
|
|
48
|
+
// project code in the browser at all. `adminPanel()` when the screen IS the interaction —
|
|
49
|
+
// something that responds as you type, a row that expands, a dialog, a canvas, a date input.
|
|
50
|
+
// Those are not elements Block Kit happens to be missing; they are things a server-driven
|
|
51
|
+
// vocabulary cannot express, and the list does not shrink by adding blocks.
|
|
52
|
+
//
|
|
53
|
+
// The cost, stated plainly, because it is what makes that order more than a preference: A
|
|
54
|
+
// PANEL IS TRUSTED CODE. Its bundle runs in the editor's own page with the editor's own
|
|
55
|
+
// session in scope — it can read the stored token, make any call the caller could make, and
|
|
56
|
+
// render anything anywhere on the page. `roles` on `adminPanel()` decides who is SHOWN the
|
|
57
|
+
// screen and the ACL still bounds what the server will do for whoever is asking, but neither
|
|
58
|
+
// constrains the bundle. `PanelApi` is a CONVENIENCE — the one obvious way to make an
|
|
59
|
+
// authenticated call — and not a sandbox; there is no sandbox to be had short of a
|
|
60
|
+
// cross-origin iframe, which would give up the shared chrome that is the entire point. Ship a
|
|
61
|
+
// panel you wrote, from your own origin, and treat its bundle as part of the admin. Block
|
|
62
|
+
// Kit's headline property is that none of this paragraph is ever needed.
|
|
63
|
+
//
|
|
24
64
|
// WHAT IT IS NOT
|
|
25
65
|
//
|
|
26
66
|
// It is not a way to reach past the ACL. A page's `render` is an ordinary handler body: it
|
|
@@ -36,6 +76,7 @@ import { BadRequest, mutation, query } from "@pramen/server";
|
|
|
36
76
|
import type { HandlerContext, JsonValue, SchemaDef } from "@pramen/server";
|
|
37
77
|
import { isSafeHref, normalizeHref } from "./href";
|
|
38
78
|
import { NAV_ORDER } from "./nav";
|
|
79
|
+
import { isAdminPanel, type AdminPanelDef } from "./panel";
|
|
39
80
|
|
|
40
81
|
// --- the block/element vocabulary ------------------------------------------------------
|
|
41
82
|
|
|
@@ -43,16 +84,23 @@ import { NAV_ORDER } from "./nav";
|
|
|
43
84
|
* string through React, so there is no HTML path here to sanitize. */
|
|
44
85
|
export type AdminText = string;
|
|
45
86
|
|
|
46
|
-
/** An input a form
|
|
87
|
+
/** An input a form, an actions row or a table cell can carry.
|
|
88
|
+
*
|
|
89
|
+
* `error` is the per-FIELD failure, rendered under the offending input. The page-level
|
|
90
|
+
* `toast` cannot do that job: it names no field, it is gone in three seconds while the bad
|
|
91
|
+
* value is still on screen, and a form with six inputs gives the reader no way to tell which
|
|
92
|
+
* one "25:00 is not a time" is about. It is a plain part of the render — the whole page
|
|
93
|
+
* comes back on every interaction, so an error lives exactly as long as the response that
|
|
94
|
+
* carried it, and there is nothing to clear. */
|
|
47
95
|
export type AdminInput =
|
|
48
|
-
| { type: "text_input"; action_id: string; label?: AdminText; placeholder?: AdminText; initial_value?: string; multiline?: boolean; required?: boolean }
|
|
49
|
-
| { type: "number_input"; action_id: string; label?: AdminText; placeholder?: AdminText; initial_value?: number; min?: number; max?: number; required?: boolean }
|
|
50
|
-
| { type: "select"; action_id: string; label?: AdminText; options: { value: string; label: AdminText }[]; initial_value?: string; required?: boolean }
|
|
51
|
-
| { type: "toggle"; action_id: string; label?: AdminText; initial_value?: boolean }
|
|
96
|
+
| { type: "text_input"; action_id: string; label?: AdminText; placeholder?: AdminText; initial_value?: string; multiline?: boolean; required?: boolean; error?: AdminText }
|
|
97
|
+
| { type: "number_input"; action_id: string; label?: AdminText; placeholder?: AdminText; initial_value?: number; min?: number; max?: number; required?: boolean; error?: AdminText }
|
|
98
|
+
| { type: "select"; action_id: string; label?: AdminText; options: { value: string; label: AdminText }[]; initial_value?: string; required?: boolean; error?: AdminText }
|
|
99
|
+
| { type: "toggle"; action_id: string; label?: AdminText; initial_value?: boolean; error?: AdminText }
|
|
52
100
|
/** Write-only: never echoed back to the browser once stored. The editor renders it as a
|
|
53
101
|
* password field and sends it only on submit; a page that stores one must NOT put it back
|
|
54
102
|
* in `initial_value` on the next render, which is why there is no such key here. */
|
|
55
|
-
| { type: "secret_input"; action_id: string; label?: AdminText; placeholder?: AdminText; required?: boolean };
|
|
103
|
+
| { type: "secret_input"; action_id: string; label?: AdminText; placeholder?: AdminText; required?: boolean; error?: AdminText };
|
|
56
104
|
|
|
57
105
|
/** A button. `value` rides back on the interaction, so one `action_id` can serve a row. */
|
|
58
106
|
export interface AdminButton {
|
|
@@ -68,6 +116,32 @@ export interface AdminButton {
|
|
|
68
116
|
|
|
69
117
|
export type AdminElement = AdminButton | AdminInput;
|
|
70
118
|
|
|
119
|
+
/** Every `AdminElement` tag, as a runtime set.
|
|
120
|
+
*
|
|
121
|
+
* Both halves need this at RUNTIME, which is why it is a value and not only a union: the
|
|
122
|
+
* server checks that an object sitting in a table cell really is an element, and the editor
|
|
123
|
+
* decides from the same set whether a cell draws as text or as a control. The editor keeps
|
|
124
|
+
* its own copy (it has no dependency on this package) and `test/cms-editor-mirrors.test.ts`
|
|
125
|
+
* fails if the two drift. */
|
|
126
|
+
export const ADMIN_ELEMENT_TYPES = ["button", "text_input", "number_input", "select", "toggle", "secret_input"] as const;
|
|
127
|
+
|
|
128
|
+
/** What one table cell holds: a value to READ, or an element to ACT with.
|
|
129
|
+
*
|
|
130
|
+
* The alternative shape was a per-COLUMN element declaration — `columns: [{ key, label,
|
|
131
|
+
* element }]` — and it is the wrong unit. Everything about a row's control is a fact of the
|
|
132
|
+
* ROW: the button's `value` is that row's id, its label is "Hide" or "Show" depending on
|
|
133
|
+
* that row's state, and a row that must not be touched carries no control at all. A column
|
|
134
|
+
* declaration would have to be a template with a substitution language, which is a second
|
|
135
|
+
* vocabulary to design and to escape. A cell already varies per row, so the element goes in
|
|
136
|
+
* the cell and the column keeps saying only where it lands. A cell is a value OR an element,
|
|
137
|
+
* never both; a column that wants both is two columns.
|
|
138
|
+
*
|
|
139
|
+
* The two are told apart by SHAPE: a display value is a primitive, an element is an object,
|
|
140
|
+
* and nothing else may be an object. `normalizeAdminResponse` enforces that, so a page that
|
|
141
|
+
* splats a whole row (`rows: found`) into the table is named at the boundary instead of
|
|
142
|
+
* rendering a column of `[object Object]` — or, worse, of half-elements. */
|
|
143
|
+
export type AdminCell = AdminText | number | boolean | null | AdminElement;
|
|
144
|
+
|
|
71
145
|
/** One block in a rendered admin page. */
|
|
72
146
|
export type AdminBlock =
|
|
73
147
|
| { type: "header"; text: AdminText; level?: 1 | 2 | 3 }
|
|
@@ -77,7 +151,9 @@ export type AdminBlock =
|
|
|
77
151
|
| { type: "context"; text: AdminText }
|
|
78
152
|
/** Label/value pairs, for a record's details. */
|
|
79
153
|
| { type: "fields"; fields: { label: AdminText; value: AdminText }[] }
|
|
80
|
-
|
|
154
|
+
/** `block_id` rides back on an interaction a CELL fired, the same way an `actions` block's
|
|
155
|
+
* does — so a page with two tables can tell which one a shared `action_id` came from. */
|
|
156
|
+
| { type: "table"; block_id?: string; columns: { key: string; label: AdminText }[]; rows: Record<string, AdminCell>[]; empty?: AdminText }
|
|
81
157
|
| { type: "stats"; stats: { label: AdminText; value: AdminText; hint?: AdminText }[] }
|
|
82
158
|
| { type: "actions"; block_id?: string; elements: AdminElement[] }
|
|
83
159
|
| { type: "form"; block_id: string; fields: AdminInput[]; submit: { label: AdminText; action_id: string } }
|
|
@@ -128,6 +204,10 @@ export const MAX_ADMIN_BLOCK_DEPTH = 4;
|
|
|
128
204
|
* Without it `ctx.db.find({ from: "lectures", where: { title: { contains: q } } })` resolves
|
|
129
205
|
* the table against the default `SchemaDef` and every column reads as a number. */
|
|
130
206
|
export interface AdminPageDef<S extends SchemaDef = SchemaDef> {
|
|
207
|
+
/** Discriminates this from an `AdminPanelDef` in the registry the two share. Optional and
|
|
208
|
+
* defaulted, because a Block Kit page is what `adminPage()` has always produced and
|
|
209
|
+
* nothing should have to start saying so. */
|
|
210
|
+
readonly kind?: "blocks";
|
|
131
211
|
/** URL + registry key: the page is served at `/apps/:slug` in the editor. */
|
|
132
212
|
readonly slug: string;
|
|
133
213
|
/** Nav label. */
|
|
@@ -165,20 +245,42 @@ export function adminPage<S extends SchemaDef = SchemaDef>(slug: string, opts: O
|
|
|
165
245
|
return { ...opts, slug };
|
|
166
246
|
}
|
|
167
247
|
|
|
168
|
-
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
248
|
+
/** One entry in the custom-screens registry: a Block Kit page, or a panel the browser
|
|
249
|
+
* renders. They share a registry — and so a slug space, a route and a nav band — because
|
|
250
|
+
* from the editor's side they are the same thing (a project's own screen inside the chrome)
|
|
251
|
+
* differing only in where the rendering happens. Two registries would have made a slug
|
|
252
|
+
* collision between them a runtime surprise instead of a boot error. */
|
|
253
|
+
export type AdminScreenDef<S extends SchemaDef = SchemaDef> = AdminPageDef<S> | AdminPanelDef;
|
|
254
|
+
|
|
255
|
+
/** Every screen kind, as a runtime set. A value and not only a union because the editor
|
|
256
|
+
* keeps its own copy and `test/cms-editor-mirrors.test.ts` fails if the two drift — a kind
|
|
257
|
+
* the server sends and the editor has not heard of is a nav entry that renders nothing. */
|
|
258
|
+
export const ADMIN_PAGE_KINDS = ["blocks", "panel"] as const;
|
|
259
|
+
export type AdminPageKind = (typeof ADMIN_PAGE_KINDS)[number];
|
|
260
|
+
|
|
261
|
+
/** The client-facing view of a registered screen — what the editor needs to put it in the
|
|
262
|
+
* nav and decide how to render it. Never the `render` function, and never the role list
|
|
263
|
+
* (which is a server fact; a screen the caller may not open is simply absent from the
|
|
264
|
+
* listing). */
|
|
171
265
|
export interface AdminPageMeta {
|
|
172
266
|
slug: string;
|
|
173
267
|
label: string;
|
|
174
268
|
icon?: string;
|
|
175
269
|
navOrder: number;
|
|
270
|
+
/** `"blocks"` (Block Kit, rendered from this response) or `"panel"` (a component the
|
|
271
|
+
* deployment's panel bundle registered under this slug). */
|
|
272
|
+
kind: AdminPageKind;
|
|
176
273
|
}
|
|
177
274
|
|
|
178
|
-
/** Validate a registry at boot: slugs are unique and routable, and every
|
|
275
|
+
/** Validate a registry at boot: slugs are unique and routable, and every screen can be
|
|
179
276
|
* addressed. Called by `createAdminPageHandlers`, so a mistake surfaces when the Worker
|
|
180
|
-
* starts rather than as a 404 the first time someone opens the one page nobody exercised.
|
|
181
|
-
|
|
277
|
+
* starts rather than as a 404 the first time someone opens the one page nobody exercised.
|
|
278
|
+
*
|
|
279
|
+
* Pages and panels are validated TOGETHER, against one `seen` set: they share `/apps/:slug`,
|
|
280
|
+
* so two entries with the same slug are a collision whatever their kinds — and the one that
|
|
281
|
+
* would win is decided by insertion order into a Map, which is not a thing to leave to
|
|
282
|
+
* chance. */
|
|
283
|
+
export function validateAdminPages(pages: readonly AdminScreenDef[]): void {
|
|
182
284
|
const seen = new Set<string>();
|
|
183
285
|
for (const p of pages) {
|
|
184
286
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(p.slug) || p.slug.length > 80) {
|
|
@@ -187,7 +289,10 @@ export function validateAdminPages(pages: readonly AdminPageDef[]): void {
|
|
|
187
289
|
if (seen.has(p.slug)) throw new Error(`pramen/cms: duplicate admin page slug '${p.slug}' — the slug is the registry's key`);
|
|
188
290
|
seen.add(p.slug);
|
|
189
291
|
if (p.label.trim() === "") throw new Error(`pramen/cms: admin page '${p.slug}' has an empty label — it would render an unnamed nav entry`);
|
|
190
|
-
|
|
292
|
+
// A panel has no server render by definition, so the check is skipped for it rather
|
|
293
|
+
// than relaxed for everyone: "no render function" stays a boot error for a Block Kit
|
|
294
|
+
// page, which is the one it was written to catch.
|
|
295
|
+
if (!isAdminPanel(p) && typeof p.render !== "function") throw new Error(`pramen/cms: admin page '${p.slug}' has no render function`);
|
|
191
296
|
}
|
|
192
297
|
}
|
|
193
298
|
|
|
@@ -213,21 +318,25 @@ export interface AdminPageHandlerOpts {
|
|
|
213
318
|
* There is no ACL fragment to spread: a page reads through `ctx.db` under whatever policies
|
|
214
319
|
* the caller already has, so there is nothing here to grant.
|
|
215
320
|
*/
|
|
216
|
-
export function createAdminPageHandlers(pages: readonly
|
|
321
|
+
export function createAdminPageHandlers(pages: readonly AdminScreenDef[], opts: AdminPageHandlerOpts = {}) {
|
|
217
322
|
validateAdminPages(pages);
|
|
218
323
|
const defaultRoles = opts.editorRoles ?? ["editor", "admin"];
|
|
219
324
|
const bySlug = new Map(pages.map((p) => [p.slug, p]));
|
|
220
|
-
const rolesFor = (p:
|
|
221
|
-
const mayOpen = (ctx: HandlerContext, p:
|
|
325
|
+
const rolesFor = (p: AdminScreenDef): readonly string[] => p.roles ?? defaultRoles;
|
|
326
|
+
const mayOpen = (ctx: HandlerContext, p: AdminScreenDef): boolean => held(ctx).some((r) => rolesFor(p).includes(r));
|
|
222
327
|
|
|
223
328
|
return {
|
|
224
|
-
/** The
|
|
225
|
-
* that 403s when clicked is worse than one that is not
|
|
226
|
-
* server fact the browser has no use for.
|
|
329
|
+
/** The screens THIS caller may open — Block Kit pages and panels alike. Filtered rather
|
|
330
|
+
* than role-annotated: a nav entry that 403s when clicked is worse than one that is not
|
|
331
|
+
* there, and the role list is a server fact the browser has no use for.
|
|
332
|
+
*
|
|
333
|
+
* A panel is filtered by exactly the same gate, which is the whole reason it is a
|
|
334
|
+
* registry entry rather than a client-side registration: the browser bundle decides
|
|
335
|
+
* only how the screen DRAWS, never whether this caller has one. */
|
|
227
336
|
listAdminPages: query((ctx): AdminPageMeta[] =>
|
|
228
337
|
pages
|
|
229
338
|
.filter((p) => mayOpen(ctx, p))
|
|
230
|
-
.map((p) => ({ slug: p.slug, label: p.label, icon: p.icon, navOrder: p.navOrder ?? NAV_ORDER.adminPages })),
|
|
339
|
+
.map((p) => ({ slug: p.slug, label: p.label, icon: p.icon, navOrder: p.navOrder ?? NAV_ORDER.adminPages, kind: isAdminPanel(p) ? "panel" : "blocks" })),
|
|
231
340
|
),
|
|
232
341
|
|
|
233
342
|
/**
|
|
@@ -246,6 +355,12 @@ export function createAdminPageHandlers(pages: readonly AdminPageDef[], opts: Ad
|
|
|
246
355
|
if (!page) throw new BadRequest(`unknown admin page '${input.page}'`);
|
|
247
356
|
// Before `render`, so a page's own code never runs for a caller who may not open it.
|
|
248
357
|
if (!mayOpen(ctx, page)) throw new BadRequest(`unknown admin page '${input.page}'`);
|
|
358
|
+
// A panel renders in the BROWSER, so there is nothing here to interact with. Said
|
|
359
|
+
// plainly rather than folded into "unknown admin page": the caller may open this
|
|
360
|
+
// screen (it passed the gate above), so hiding its existence would only send whoever
|
|
361
|
+
// wired the call looking for a registration mistake that is not there. It is a client
|
|
362
|
+
// bug — the editor routes a panel to its component and never calls this.
|
|
363
|
+
if (isAdminPanel(page)) throw new BadRequest(`admin page '${input.page}' is a panel — it renders in the browser and has no server-side render`);
|
|
249
364
|
const res = await page.render(ctx, input);
|
|
250
365
|
return normalizeAdminResponse(res);
|
|
251
366
|
}, {
|
|
@@ -282,18 +397,25 @@ export function createAdminPageHandlers(pages: readonly AdminPageDef[], opts: Ad
|
|
|
282
397
|
* - `image.url` becomes an `<img src>`, so it goes through the same `isSafeHref`
|
|
283
398
|
* allow-list a rich-text link mark does.
|
|
284
399
|
* - nesting is capped, because rendering is recursive.
|
|
400
|
+
* - a table cell that is an OBJECT is claiming to be an element, and is checked as one.
|
|
401
|
+
* - every input's `action_id` is claimed once per page, because the editor keys the
|
|
402
|
+
* page's whole value bag by it.
|
|
285
403
|
*
|
|
286
404
|
* A bad block throws rather than being dropped: this is the page author's own output, and a
|
|
287
405
|
* block that silently vanishes is a bug that reads as "the data isn't there".
|
|
288
406
|
*/
|
|
289
407
|
export function normalizeAdminResponse(res: AdminPageResponse): AdminPageResponse {
|
|
290
408
|
if (!res || !Array.isArray(res.blocks)) throw new Error("pramen/cms: an admin page must return { blocks: [...] }");
|
|
291
|
-
|
|
409
|
+
// One set for the WHOLE response, because the value bag it guards is per PAGE, not per
|
|
410
|
+
// block — an input in a form and an input in a table cell collide just as hard as two in
|
|
411
|
+
// one form.
|
|
412
|
+
const inputIds = new Set<string>();
|
|
413
|
+
const out: AdminPageResponse = { blocks: res.blocks.map((b) => normalizeAdminBlock(b, 0, inputIds)) };
|
|
292
414
|
if (res.toast) out.toast = { text: String(res.toast.text), tone: res.toast.tone };
|
|
293
415
|
return out;
|
|
294
416
|
}
|
|
295
417
|
|
|
296
|
-
function normalizeAdminBlock(block: AdminBlock, depth: number): AdminBlock {
|
|
418
|
+
function normalizeAdminBlock(block: AdminBlock, depth: number, inputIds: Set<string>): AdminBlock {
|
|
297
419
|
if (depth >= MAX_ADMIN_BLOCK_DEPTH) throw new Error(`pramen/cms: admin blocks nest deeper than ${MAX_ADMIN_BLOCK_DEPTH} levels`);
|
|
298
420
|
switch (block.type) {
|
|
299
421
|
case "image": {
|
|
@@ -302,15 +424,63 @@ function normalizeAdminBlock(block: AdminBlock, depth: number): AdminBlock {
|
|
|
302
424
|
return { ...block, url };
|
|
303
425
|
}
|
|
304
426
|
case "columns":
|
|
305
|
-
return { ...block, columns: block.columns.map((col) => col.map((b) => normalizeAdminBlock(b, depth + 1))) };
|
|
427
|
+
return { ...block, columns: block.columns.map((col) => col.map((b) => normalizeAdminBlock(b, depth + 1, inputIds))) };
|
|
306
428
|
case "accordion":
|
|
307
|
-
return { ...block, blocks: block.blocks.map((b) => normalizeAdminBlock(b, depth + 1)) };
|
|
429
|
+
return { ...block, blocks: block.blocks.map((b) => normalizeAdminBlock(b, depth + 1, inputIds)) };
|
|
308
430
|
case "form":
|
|
309
431
|
// `block_id` is how the editor keys a form's local values. Two forms sharing one would
|
|
310
432
|
// share their state, so the second would submit the first one's inputs.
|
|
311
433
|
if (!block.block_id) throw new Error("pramen/cms: a `form` block needs a block_id");
|
|
434
|
+
for (const f of block.fields) claimInputId(f, `form '${block.block_id}'`, inputIds);
|
|
312
435
|
return block;
|
|
436
|
+
case "actions":
|
|
437
|
+
for (const el of block.elements) if (el.type !== "button") claimInputId(el, "an `actions` block", inputIds);
|
|
438
|
+
return block;
|
|
439
|
+
case "table": {
|
|
440
|
+
// Only the cells a COLUMN names are looked at, because only those are rendered. A key
|
|
441
|
+
// in `rows` with no column is data the table happens to carry along; checking it would
|
|
442
|
+
// reject rows a page is free to build wide and display narrow.
|
|
443
|
+
for (const [i, row] of block.rows.entries()) {
|
|
444
|
+
for (const c of block.columns) {
|
|
445
|
+
const cell = row[c.key];
|
|
446
|
+
if (!isElementCell(cell)) continue;
|
|
447
|
+
checkCellElement(cell, c.key, i);
|
|
448
|
+
if (cell.type !== "button") claimInputId(cell, `table column '${c.key}'`, inputIds);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return block;
|
|
452
|
+
}
|
|
313
453
|
default:
|
|
314
454
|
return block;
|
|
315
455
|
}
|
|
316
456
|
}
|
|
457
|
+
|
|
458
|
+
/** An object in a cell is claiming to be an element — nothing else may be one. Narrowed to
|
|
459
|
+
* `AdminElement` here only so the check that follows can read its tag; whether it IS one is
|
|
460
|
+
* exactly what {@link checkCellElement} decides. */
|
|
461
|
+
const isElementCell = (cell: AdminCell | undefined): cell is AdminElement => cell !== null && typeof cell === "object";
|
|
462
|
+
|
|
463
|
+
function checkCellElement(cell: AdminElement, column: string, rowIndex: number): void {
|
|
464
|
+
const where = `table column '${column}', row ${rowIndex}`;
|
|
465
|
+
const kinds = ADMIN_ELEMENT_TYPES as readonly string[];
|
|
466
|
+
if (!kinds.includes(cell.type)) {
|
|
467
|
+
throw new Error(`pramen/cms: ${where} holds an object that is not an admin element (type ${JSON.stringify(cell.type)}). A cell is a value or one of ${kinds.join(", ")} — a whole row object put in a cell would render as [object Object]`);
|
|
468
|
+
}
|
|
469
|
+
if (!cell.action_id) throw new Error(`pramen/cms: the element in ${where} has no action_id — nothing would come back when it fires`);
|
|
470
|
+
if (cell.type === "button" && !cell.label) throw new Error(`pramen/cms: the button in ${where} has no label — it would draw as an empty control`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Claim one input's `action_id` for the page.
|
|
474
|
+
*
|
|
475
|
+
* The editor holds ONE value bag for the whole page, keyed by `action_id`, because that is
|
|
476
|
+
* what makes a filter in one block reach a button in another. So two inputs sharing an id
|
|
477
|
+
* are one field wearing two hats, which is only ever a bug — and the way to write it by
|
|
478
|
+
* accident is to build a table and put the same input literal in every row. */
|
|
479
|
+
function claimInputId(input: AdminInput, where: string, seen: Set<string>): void {
|
|
480
|
+
if (seen.has(input.action_id)) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`pramen/cms: action_id '${input.action_id}' is used by more than one input on this page (${where}) — the editor keys the page's value bag by action_id, so they would be ONE field: same value shown in every copy, and the last write wins on submit. A per-ROW input has to mint a per-row id (\`hours:\${row.id}\`); a per-row BUTTON does not, because its \`value\` rides back on the interaction instead`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
seen.add(input.action_id);
|
|
486
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1306,22 +1306,32 @@ export {
|
|
|
1306
1306
|
createAdminPageHandlers,
|
|
1307
1307
|
normalizeAdminResponse,
|
|
1308
1308
|
validateAdminPages,
|
|
1309
|
+
ADMIN_ELEMENT_TYPES,
|
|
1310
|
+
ADMIN_PAGE_KINDS,
|
|
1309
1311
|
MAX_ADMIN_BLOCK_DEPTH,
|
|
1310
1312
|
} from "./blockkit";
|
|
1311
1313
|
export type {
|
|
1312
1314
|
AdminBlock,
|
|
1313
1315
|
AdminButton,
|
|
1316
|
+
AdminCell,
|
|
1314
1317
|
AdminElement,
|
|
1315
1318
|
AdminInput,
|
|
1316
1319
|
AdminInteractionType,
|
|
1317
1320
|
AdminPageDef,
|
|
1318
1321
|
AdminPageHandlerOpts,
|
|
1319
1322
|
AdminPageInteraction,
|
|
1323
|
+
AdminPageKind,
|
|
1320
1324
|
AdminPageMeta,
|
|
1321
1325
|
AdminPageResponse,
|
|
1326
|
+
AdminScreenDef,
|
|
1322
1327
|
AdminText,
|
|
1323
1328
|
} from "./blockkit";
|
|
1324
1329
|
|
|
1330
|
+
/** Custom admin PANELS — a project's own React screen inside the editor's chrome, for the
|
|
1331
|
+
* screens a server-driven vocabulary cannot carry. See `./panel`. */
|
|
1332
|
+
export { adminPanel, isAdminPanel } from "./panel";
|
|
1333
|
+
export type { AdminPanelDef } from "./panel";
|
|
1334
|
+
|
|
1325
1335
|
/**
|
|
1326
1336
|
* Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
|
|
1327
1337
|
*
|
package/src/panel.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// A custom admin PANEL — a project's own React screen, rendered inside the editor's chrome
|
|
2
|
+
// at a real route with a real nav entry.
|
|
3
|
+
//
|
|
4
|
+
// WHY A SECOND KIND, WHEN `adminPage()` EXISTS
|
|
5
|
+
//
|
|
6
|
+
// Block Kit (`adminPage()`) is a server-driven vocabulary: the handler returns JSON, the
|
|
7
|
+
// editor renders it, and the whole page comes back on every interaction. That is exactly
|
|
8
|
+
// right for a list-and-form screen, and it is the reason no project JavaScript runs in the
|
|
9
|
+
// admin. But the properties that make it safe are the same ones that cap it — an input
|
|
10
|
+
// cannot fire an interaction, every control is disabled for the round trip (so focus and
|
|
11
|
+
// caret are lost on each keystroke that matters), a table row cannot expand, and there is
|
|
12
|
+
// no link, no redirect, no dialog, no autofocus and no date input. Those are not gaps to
|
|
13
|
+
// patch one element at a time; a screen that needs local interaction needs local code.
|
|
14
|
+
//
|
|
15
|
+
// The alternative a project reaches for when Block Kit runs out is what this replaces: a
|
|
16
|
+
// standalone React SPA served next to the editor with the chrome rebuilt by hand. It goes
|
|
17
|
+
// out of the application, it does not have the same layout, and every chrome fix has to be
|
|
18
|
+
// made twice.
|
|
19
|
+
//
|
|
20
|
+
// So a panel is the SAME registry entry as a Block Kit page with the render moved to the
|
|
21
|
+
// browser. Same slug space, same `/apps/:slug` route, same "Apps" band in the nav, and —
|
|
22
|
+
// the part that matters — the same server-side role filter: a panel the caller may not open
|
|
23
|
+
// is absent from `listAdminPages`, so there is no nav entry to click and no route to reach.
|
|
24
|
+
//
|
|
25
|
+
// WHAT LIVES WHERE. The server owns everything a nav entry is made of (slug, label, icon,
|
|
26
|
+
// position, roles); the browser bundle owns only the component. That split is deliberate:
|
|
27
|
+
// if the bundle declared the label and the position, a client that failed to load would
|
|
28
|
+
// take the nav entry with it, and a client that loaded would be declaring its own placement
|
|
29
|
+
// with nothing to check it against. A panel whose bundle never registers is a listed entry
|
|
30
|
+
// that renders a diagnostic — which is a legible failure — rather than a section that
|
|
31
|
+
// silently ceases to exist.
|
|
32
|
+
//
|
|
33
|
+
// WHAT IS GIVEN UP. Block Kit's headline property is that no project JavaScript ever runs in
|
|
34
|
+
// the admin (#33). A panel gives that up, deliberately and only where a deployment asks for
|
|
35
|
+
// it: the bundle runs in the editor's own page with the editor's own session in scope, so it
|
|
36
|
+
// can read the stored token and call anything the caller can. `roles` below and the ACL still
|
|
37
|
+
// bound what the SERVER will do, and `PanelApi` is a small surface to write against, but
|
|
38
|
+
// neither is a sandbox — a panel is part of the admin, not a guest in it. That is the reason
|
|
39
|
+
// `adminPage()` remains the first thing to reach for and this the second.
|
|
40
|
+
//
|
|
41
|
+
// The same reconciliation is written from the other side at the top of `blockkit.ts` — why
|
|
42
|
+
// "do not ship the component tree" and this are one position rather than two, and which of
|
|
43
|
+
// the pair to reach for first. Read together; changing one without the other leaves the
|
|
44
|
+
// framework arguing with itself.
|
|
45
|
+
//
|
|
46
|
+
// A LEAF module, like `href.ts` and `nav.ts`: `blockkit.ts` imports it to widen the
|
|
47
|
+
// registry, and it imports nothing back.
|
|
48
|
+
|
|
49
|
+
/** One custom admin panel: a nav entry the browser bundle fills in.
|
|
50
|
+
*
|
|
51
|
+
* There is no `render` here and there is not meant to be one — the rendering half is a
|
|
52
|
+
* React component the deployment's panel bundle registers under the same `slug`. Everything
|
|
53
|
+
* that decides whether the entry EXISTS is here, on the server, where it can be enforced.
|
|
54
|
+
*/
|
|
55
|
+
export interface AdminPanelDef {
|
|
56
|
+
/** Discriminates a panel from an `AdminPageDef` in the one registry they share. Set by
|
|
57
|
+
* {@link adminPanel}; it is a required field rather than an inferred one so a hand-built
|
|
58
|
+
* object literal cannot be a half-declared panel. */
|
|
59
|
+
readonly kind: "panel";
|
|
60
|
+
/** URL + registry key: served at `/apps/:slug` in the editor, and the id the browser
|
|
61
|
+
* bundle registers its component under. */
|
|
62
|
+
readonly slug: string;
|
|
63
|
+
/** Nav label. */
|
|
64
|
+
readonly label: string;
|
|
65
|
+
/** Optional nav icon (emoji or short string). */
|
|
66
|
+
readonly icon?: string;
|
|
67
|
+
/** Where it sits in the nav — see `NAV_ORDER`. Defaults to `NAV_ORDER.adminPages`. */
|
|
68
|
+
readonly navOrder?: number;
|
|
69
|
+
/** Roles that may open it. Defaults to the deployment's `editorRoles`, exactly as a Block
|
|
70
|
+
* Kit page's does — one registry, one gate.
|
|
71
|
+
*
|
|
72
|
+
* This is the ONLY authorization a panel gets for free. A panel's own code runs in the
|
|
73
|
+
* browser, so every call it makes is an ordinary RPC under the caller's own identity and
|
|
74
|
+
* ACL; this list decides who is shown the screen, not what the screen may do. */
|
|
75
|
+
readonly roles?: readonly string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Declare a custom admin panel. Spread the result into `createAdminPageHandlers` alongside
|
|
80
|
+
* any `adminPage()`s:
|
|
81
|
+
*
|
|
82
|
+
* const curation = adminPanel("curation", {
|
|
83
|
+
* label: "Curation",
|
|
84
|
+
* icon: "🎛",
|
|
85
|
+
* navOrder: NAV_ORDER.media + 10,
|
|
86
|
+
* roles: ["editor", "admin"],
|
|
87
|
+
* });
|
|
88
|
+
*
|
|
89
|
+
* handlers = { ...createAdminPageHandlers([desk, curation], { editorRoles }) };
|
|
90
|
+
*
|
|
91
|
+
* The matching component is registered by the deployment's panel bundle — see the
|
|
92
|
+
* "Custom admin panels" section of the CMS docs.
|
|
93
|
+
*/
|
|
94
|
+
export function adminPanel(slug: string, opts: Omit<AdminPanelDef, "slug" | "kind">): AdminPanelDef {
|
|
95
|
+
return { ...opts, kind: "panel", slug };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether a registry entry is a panel (and so has no server-side render).
|
|
99
|
+
*
|
|
100
|
+
* Reads the discriminant rather than testing for the ABSENCE of `render`: "no render" is
|
|
101
|
+
* also what a malformed page looks like, and `validateAdminPages` has to be able to tell a
|
|
102
|
+
* panel from a page someone forgot to finish. */
|
|
103
|
+
export function isAdminPanel(def: { readonly kind?: string }): def is AdminPanelDef {
|
|
104
|
+
return def.kind === "panel";
|
|
105
|
+
}
|