@pramen/cms 0.0.35 → 0.0.37
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/index.d.ts +149 -0
- package/dist/index.js +175 -4
- package/package.json +2 -2
- package/src/index.ts +236 -3
package/dist/index.d.ts
CHANGED
|
@@ -241,6 +241,7 @@ export declare const cmsSchema: {
|
|
|
241
241
|
readonly kind: "belongsTo";
|
|
242
242
|
readonly target: "cms_block_types";
|
|
243
243
|
readonly column: string;
|
|
244
|
+
readonly onDelete: import("@pramen/server").OnDelete | undefined;
|
|
244
245
|
};
|
|
245
246
|
}>;
|
|
246
247
|
cms_pages: import("@pramen/server").EntityDef<{
|
|
@@ -338,6 +339,7 @@ export declare const cmsSchema: {
|
|
|
338
339
|
readonly kind: "belongsTo";
|
|
339
340
|
readonly target: "cms_content_types";
|
|
340
341
|
readonly column: string;
|
|
342
|
+
readonly onDelete: import("@pramen/server").OnDelete | undefined;
|
|
341
343
|
};
|
|
342
344
|
placements: {
|
|
343
345
|
readonly kind: "hasMany";
|
|
@@ -387,11 +389,13 @@ export declare const cmsSchema: {
|
|
|
387
389
|
readonly kind: "belongsTo";
|
|
388
390
|
readonly target: "cms_pages";
|
|
389
391
|
readonly column: string;
|
|
392
|
+
readonly onDelete: import("@pramen/server").OnDelete | undefined;
|
|
390
393
|
};
|
|
391
394
|
block: {
|
|
392
395
|
readonly kind: "belongsTo";
|
|
393
396
|
readonly target: "cms_blocks";
|
|
394
397
|
readonly column: string;
|
|
398
|
+
readonly onDelete: import("@pramen/server").OnDelete | undefined;
|
|
395
399
|
};
|
|
396
400
|
}>;
|
|
397
401
|
cms_page_revisions: import("@pramen/server").EntityDef<{
|
|
@@ -433,6 +437,7 @@ export declare const cmsSchema: {
|
|
|
433
437
|
readonly kind: "belongsTo";
|
|
434
438
|
readonly target: "cms_pages";
|
|
435
439
|
readonly column: string;
|
|
440
|
+
readonly onDelete: import("@pramen/server").OnDelete | undefined;
|
|
436
441
|
};
|
|
437
442
|
}>;
|
|
438
443
|
cms_audit: import("@pramen/server").EntityDef<{
|
|
@@ -681,6 +686,22 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
681
686
|
ok: boolean;
|
|
682
687
|
page: Record<string, unknown>;
|
|
683
688
|
}>;
|
|
689
|
+
/** Edit a page's own attributes — title, slug, locale, and content-type-level `fields`
|
|
690
|
+
* (the structured data of a non-block content type, e.g. a "Lecture" with date/speaker).
|
|
691
|
+
* Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
|
|
692
|
+
* page record itself, which was previously only settable at createPage. A slug/locale
|
|
693
|
+
* change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
|
|
694
|
+
* + sanitized against the content type's fieldsSchema, exactly like createPage. */
|
|
695
|
+
updatePage: import("@pramen/server").Handler<{
|
|
696
|
+
pageId: string;
|
|
697
|
+
title?: string;
|
|
698
|
+
slug?: string;
|
|
699
|
+
locale?: string;
|
|
700
|
+
fields?: Record<string, unknown>;
|
|
701
|
+
}, {
|
|
702
|
+
ok: boolean;
|
|
703
|
+
page: Record<string, unknown>;
|
|
704
|
+
}>;
|
|
684
705
|
/** Create a page and auto-scaffold its content type's default blocks. */
|
|
685
706
|
createPage: import("@pramen/server").Handler<{
|
|
686
707
|
typeId: string;
|
|
@@ -935,6 +956,22 @@ export declare const cmsHandlers: {
|
|
|
935
956
|
ok: boolean;
|
|
936
957
|
page: Record<string, unknown>;
|
|
937
958
|
}>;
|
|
959
|
+
/** Edit a page's own attributes — title, slug, locale, and content-type-level `fields`
|
|
960
|
+
* (the structured data of a non-block content type, e.g. a "Lecture" with date/speaker).
|
|
961
|
+
* Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
|
|
962
|
+
* page record itself, which was previously only settable at createPage. A slug/locale
|
|
963
|
+
* change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
|
|
964
|
+
* + sanitized against the content type's fieldsSchema, exactly like createPage. */
|
|
965
|
+
updatePage: import("@pramen/server").Handler<{
|
|
966
|
+
pageId: string;
|
|
967
|
+
title?: string;
|
|
968
|
+
slug?: string;
|
|
969
|
+
locale?: string;
|
|
970
|
+
fields?: Record<string, unknown>;
|
|
971
|
+
}, {
|
|
972
|
+
ok: boolean;
|
|
973
|
+
page: Record<string, unknown>;
|
|
974
|
+
}>;
|
|
938
975
|
/** Create a page and auto-scaffold its content type's default blocks. */
|
|
939
976
|
createPage: import("@pramen/server").Handler<{
|
|
940
977
|
typeId: string;
|
|
@@ -1111,6 +1148,118 @@ export declare function cmsPolicies(opts?: CmsPolicyOpts): {
|
|
|
1111
1148
|
public: Policy[];
|
|
1112
1149
|
editor: Policy[];
|
|
1113
1150
|
};
|
|
1151
|
+
/** Declares that a pramen entity is editable as a collection in the CMS editor. Both
|
|
1152
|
+
* halves live here: the runtime facts (entity, idField, validation via `fields`) and the
|
|
1153
|
+
* UI facts (labels, list columns, ordering). Mirror of {@link ContentTypeDef}. */
|
|
1154
|
+
export interface CollectionDef {
|
|
1155
|
+
/** The pramen entity (table) this collection edits — one of your own entities, spread
|
|
1156
|
+
* into `defineSchema` next to `cmsSchema`. The handler registry keys off `slug`, then
|
|
1157
|
+
* reads this; it is never taken from client input. */
|
|
1158
|
+
readonly entity: string;
|
|
1159
|
+
/** URL + nav key, e.g. `"lectures"`. Unique across collections. */
|
|
1160
|
+
readonly slug: string;
|
|
1161
|
+
/** Singular UI label, e.g. `"Lecture"`. */
|
|
1162
|
+
readonly label: string;
|
|
1163
|
+
/** Plural UI label; defaults to `label + "s"`. */
|
|
1164
|
+
readonly pluralLabel?: string;
|
|
1165
|
+
/** Optional nav icon (emoji or short string). */
|
|
1166
|
+
readonly icon?: string;
|
|
1167
|
+
/** The edit-form schema — the same DSL as blocks. Each scalar field is a real column
|
|
1168
|
+
* on `entity`; repeater/group map to a `t.json()` column. Also the WRITE WHITELIST:
|
|
1169
|
+
* only these field names are ever written to the entity. */
|
|
1170
|
+
readonly fields: readonly FieldDefinition[];
|
|
1171
|
+
/** Columns shown in the list view; defaults to `[titleField]`. */
|
|
1172
|
+
readonly list?: readonly string[];
|
|
1173
|
+
/** Column that titles a row in the list; defaults to `"title"`. */
|
|
1174
|
+
readonly titleField?: string;
|
|
1175
|
+
/** Primary-key column used to load/patch a single row; defaults to `"id"`. */
|
|
1176
|
+
readonly idField?: string;
|
|
1177
|
+
/** Default list ordering; defaults to `{ column: "createdAt", dir: "desc" }`. */
|
|
1178
|
+
readonly orderBy?: {
|
|
1179
|
+
column: string;
|
|
1180
|
+
dir?: "asc" | "desc";
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
/** Declare a collection. Spread the results into `createCollectionHandlers` +
|
|
1184
|
+
* `collectionPolicies`:
|
|
1185
|
+
*
|
|
1186
|
+
* const lectures = collection("lectures", {
|
|
1187
|
+
* entity: "lectures", label: "Lecture", titleField: "title",
|
|
1188
|
+
* list: ["title", "speaker", "date"],
|
|
1189
|
+
* fields: [
|
|
1190
|
+
* { name: "title", type: "text", required: true },
|
|
1191
|
+
* { name: "speaker", type: "text" },
|
|
1192
|
+
* { name: "date", type: "date" },
|
|
1193
|
+
* ],
|
|
1194
|
+
* }); */
|
|
1195
|
+
export declare function collection(slug: string, opts: Omit<CollectionDef, "slug">): CollectionDef;
|
|
1196
|
+
/** The client-facing subset of a collection the editor fetches via `listCollections` to
|
|
1197
|
+
* build its nav + generic list/edit views (no per-collection editor code, no rebuild to
|
|
1198
|
+
* add one). Excludes the server-only `entity` (the editor addresses a collection by `slug`
|
|
1199
|
+
* only, never by table name); `idField` IS included — it's just the PK column name, which
|
|
1200
|
+
* the editor needs to read a row's id from a list result. */
|
|
1201
|
+
export interface CollectionMeta {
|
|
1202
|
+
slug: string;
|
|
1203
|
+
label: string;
|
|
1204
|
+
pluralLabel: string;
|
|
1205
|
+
icon?: string;
|
|
1206
|
+
fields: readonly FieldDefinition[];
|
|
1207
|
+
list: readonly string[];
|
|
1208
|
+
titleField: string;
|
|
1209
|
+
idField: string;
|
|
1210
|
+
orderBy?: {
|
|
1211
|
+
column: string;
|
|
1212
|
+
dir?: "asc" | "desc";
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
/** Build generic CRUD handlers over the registered collections. Spread into your app's
|
|
1216
|
+
* handlers alongside `cmsHandlers`:
|
|
1217
|
+
*
|
|
1218
|
+
* const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
|
|
1219
|
+
*
|
|
1220
|
+
* Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
|
|
1221
|
+
* `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
|
|
1222
|
+
* (a fast 403 before the body) AND the row ACL (they go through `ctx.db`, so
|
|
1223
|
+
* `collectionPolicies` scopes them too). The `collection` param is resolved through the
|
|
1224
|
+
* registry — an unknown slug is a 400, never a raw table reference. */
|
|
1225
|
+
export declare function createCollectionHandlers(collections: readonly CollectionDef[], opts?: CmsHandlerOpts): {
|
|
1226
|
+
/** The registered collections (defaults filled) — editor discovery. Editor-gated so
|
|
1227
|
+
* the collection schemas aren't exposed to anonymous callers. */
|
|
1228
|
+
listCollections: import("@pramen/server").Handler<unknown, CollectionMeta[]>;
|
|
1229
|
+
collectionList: import("@pramen/server").Handler<{
|
|
1230
|
+
collection: string;
|
|
1231
|
+
limit?: number;
|
|
1232
|
+
offset?: number;
|
|
1233
|
+
}, Record<string, unknown>[]>;
|
|
1234
|
+
collectionGet: import("@pramen/server").Handler<{
|
|
1235
|
+
collection: string;
|
|
1236
|
+
id: string;
|
|
1237
|
+
}, Record<string, unknown>>;
|
|
1238
|
+
collectionCreate: import("@pramen/server").Handler<{
|
|
1239
|
+
collection: string;
|
|
1240
|
+
values: Record<string, unknown>;
|
|
1241
|
+
}, Record<string, unknown>>;
|
|
1242
|
+
collectionUpdate: import("@pramen/server").Handler<{
|
|
1243
|
+
collection: string;
|
|
1244
|
+
id: string;
|
|
1245
|
+
values: Record<string, unknown>;
|
|
1246
|
+
}, Record<string, unknown>>;
|
|
1247
|
+
collectionDelete: import("@pramen/server").Handler<{
|
|
1248
|
+
collection: string;
|
|
1249
|
+
id: string;
|
|
1250
|
+
}, {
|
|
1251
|
+
ok: true;
|
|
1252
|
+
}>;
|
|
1253
|
+
};
|
|
1254
|
+
/** ACL fragments granting the editor role full CRUD over each collection's entity. Spread
|
|
1255
|
+
* into your editor role next to `cmsPolicies().editor`:
|
|
1256
|
+
*
|
|
1257
|
+
* role("editor", [...cmsPolicies().editor, ...collectionPolicies([lectures])])
|
|
1258
|
+
*
|
|
1259
|
+
* The generic collection handlers go through `ctx.db`, so without these the row ACL denies
|
|
1260
|
+
* them. Your app may already declare its own policies over the entity (e.g. a public read
|
|
1261
|
+
* scope) — these only ADD the editor grant the CMS UI needs. */
|
|
1262
|
+
export declare function collectionPolicies(collections: readonly CollectionDef[], opts?: CmsPolicyOpts): Policy[];
|
|
1114
1263
|
/** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
|
|
1115
1264
|
* They run with a privileged, system-scoped ctx off the write path (the outbox drain).
|
|
1116
1265
|
*
|
package/dist/index.js
CHANGED
|
@@ -189,9 +189,10 @@ export const cmsSchema = {
|
|
|
189
189
|
id: primaryKey(generated(t.uuid())),
|
|
190
190
|
typeId: notNull(t.uuid()),
|
|
191
191
|
title: notNull(t.text()),
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
192
|
+
// A slug is unique PER LOCALE (`/en/about` + `/cs/about`) — enforced by the entity's
|
|
193
|
+
// composite `unique: [["slug","locale"]]` (below). createPage/updatePage/createTranslation
|
|
194
|
+
// also pre-check so the caller gets a clean 409 before hitting the constraint; the index
|
|
195
|
+
// also speeds slug lookups.
|
|
195
196
|
slug: indexed(notNull(t.text())),
|
|
196
197
|
status: defaultTo(t.text(), "draft"), // draft | published | archived
|
|
197
198
|
locale: defaultTo(t.text(), "en"),
|
|
@@ -220,7 +221,7 @@ export const cmsSchema = {
|
|
|
220
221
|
}), (r) => ({
|
|
221
222
|
type: r.belongsTo("cms_content_types", "typeId"),
|
|
222
223
|
placements: r.hasMany("cms_page_blocks", "pageId"),
|
|
223
|
-
})),
|
|
224
|
+
}), { unique: [["slug", "locale"]] }),
|
|
224
225
|
cms_page_blocks: Entity((t) => ({
|
|
225
226
|
id: primaryKey(generated(t.uuid())),
|
|
226
227
|
pageId: notNull(t.uuid()),
|
|
@@ -880,6 +881,53 @@ export function createCmsHandlers(opts = {}) {
|
|
|
880
881
|
return o;
|
|
881
882
|
},
|
|
882
883
|
}),
|
|
884
|
+
/** Edit a page's own attributes — title, slug, locale, and content-type-level `fields`
|
|
885
|
+
* (the structured data of a non-block content type, e.g. a "Lecture" with date/speaker).
|
|
886
|
+
* Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
|
|
887
|
+
* page record itself, which was previously only settable at createPage. A slug/locale
|
|
888
|
+
* change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
|
|
889
|
+
* + sanitized against the content type's fieldsSchema, exactly like createPage. */
|
|
890
|
+
updatePage: mutation(async (ctx, input) => {
|
|
891
|
+
const db = cdb(ctx);
|
|
892
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
893
|
+
const page = rows[0];
|
|
894
|
+
if (!page)
|
|
895
|
+
throw notFound("page");
|
|
896
|
+
const patch = { updatedAt: nowStamp() };
|
|
897
|
+
if (input.title !== undefined)
|
|
898
|
+
patch.title = input.title;
|
|
899
|
+
if (input.slug !== undefined || input.locale !== undefined) {
|
|
900
|
+
const nextSlug = input.slug ?? String(page.slug);
|
|
901
|
+
const nextLocale = input.locale ?? String(page.locale);
|
|
902
|
+
await assertSlugFree(db, nextSlug, nextLocale, String(page.id));
|
|
903
|
+
if (input.slug !== undefined)
|
|
904
|
+
patch.slug = input.slug;
|
|
905
|
+
if (input.locale !== undefined)
|
|
906
|
+
patch.locale = input.locale;
|
|
907
|
+
}
|
|
908
|
+
if (input.fields !== undefined) {
|
|
909
|
+
const ctRows = await db.find({ from: "cms_content_types", where: { id: page.typeId }, limit: 1 });
|
|
910
|
+
const schema = ctRows[0]?.fieldsSchema;
|
|
911
|
+
validateFields(schema, input.fields, "page.fields", { requireRequired: false });
|
|
912
|
+
patch.fields = await sanitizeFields(schema, input.fields);
|
|
913
|
+
}
|
|
914
|
+
const updated = await db.update("cms_pages", input.pageId, patch);
|
|
915
|
+
if (!updated)
|
|
916
|
+
throw notFound("page");
|
|
917
|
+
return { ok: true, page: updated };
|
|
918
|
+
}, {
|
|
919
|
+
...editor,
|
|
920
|
+
input: (raw) => {
|
|
921
|
+
const o = asObj(raw);
|
|
922
|
+
if (typeof o.pageId !== "string")
|
|
923
|
+
throw new BadRequest("pageId is required");
|
|
924
|
+
for (const k of ["title", "slug", "locale"]) {
|
|
925
|
+
if (o[k] !== undefined && typeof o[k] !== "string")
|
|
926
|
+
throw new BadRequest(`${k} must be a string`);
|
|
927
|
+
}
|
|
928
|
+
return o;
|
|
929
|
+
},
|
|
930
|
+
}),
|
|
883
931
|
/** Create a page and auto-scaffold its content type's default blocks. */
|
|
884
932
|
createPage: mutation(async (ctx, input) => {
|
|
885
933
|
const db = cdb(ctx);
|
|
@@ -1440,6 +1488,129 @@ export function cmsPolicies(opts = {}) {
|
|
|
1440
1488
|
editor: editorPolicies,
|
|
1441
1489
|
};
|
|
1442
1490
|
}
|
|
1491
|
+
/** Declare a collection. Spread the results into `createCollectionHandlers` +
|
|
1492
|
+
* `collectionPolicies`:
|
|
1493
|
+
*
|
|
1494
|
+
* const lectures = collection("lectures", {
|
|
1495
|
+
* entity: "lectures", label: "Lecture", titleField: "title",
|
|
1496
|
+
* list: ["title", "speaker", "date"],
|
|
1497
|
+
* fields: [
|
|
1498
|
+
* { name: "title", type: "text", required: true },
|
|
1499
|
+
* { name: "speaker", type: "text" },
|
|
1500
|
+
* { name: "date", type: "date" },
|
|
1501
|
+
* ],
|
|
1502
|
+
* }); */
|
|
1503
|
+
export function collection(slug, opts) {
|
|
1504
|
+
return { ...opts, slug };
|
|
1505
|
+
}
|
|
1506
|
+
/** The public view of a collection def (defaults filled). */
|
|
1507
|
+
function collectionMeta(c) {
|
|
1508
|
+
const titleField = c.titleField ?? "title";
|
|
1509
|
+
return {
|
|
1510
|
+
slug: c.slug,
|
|
1511
|
+
label: c.label,
|
|
1512
|
+
pluralLabel: c.pluralLabel ?? `${c.label}s`,
|
|
1513
|
+
icon: c.icon,
|
|
1514
|
+
fields: c.fields,
|
|
1515
|
+
list: c.list ?? [titleField],
|
|
1516
|
+
titleField,
|
|
1517
|
+
idField: c.idField ?? "id",
|
|
1518
|
+
orderBy: c.orderBy,
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
/** Build generic CRUD handlers over the registered collections. Spread into your app's
|
|
1522
|
+
* handlers alongside `cmsHandlers`:
|
|
1523
|
+
*
|
|
1524
|
+
* const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
|
|
1525
|
+
*
|
|
1526
|
+
* Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
|
|
1527
|
+
* `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
|
|
1528
|
+
* (a fast 403 before the body) AND the row ACL (they go through `ctx.db`, so
|
|
1529
|
+
* `collectionPolicies` scopes them too). The `collection` param is resolved through the
|
|
1530
|
+
* registry — an unknown slug is a 400, never a raw table reference. */
|
|
1531
|
+
export function createCollectionHandlers(collections, opts = {}) {
|
|
1532
|
+
const editor = { auth: opts.editorRoles ?? ["editor", "admin"] };
|
|
1533
|
+
const bySlug = new Map(collections.map((c) => [c.slug, c]));
|
|
1534
|
+
const metas = collections.map(collectionMeta);
|
|
1535
|
+
const def = (slug) => {
|
|
1536
|
+
const c = typeof slug === "string" ? bySlug.get(slug) : undefined;
|
|
1537
|
+
if (!c)
|
|
1538
|
+
throw new BadRequest(`unknown collection: ${String(slug)}`);
|
|
1539
|
+
return c;
|
|
1540
|
+
};
|
|
1541
|
+
const idOf = (c) => c.idField ?? "id";
|
|
1542
|
+
// Validate against the field schema, sanitize richtext, then PROJECT to declared field
|
|
1543
|
+
// names only — the write whitelist. `requireRequired` is off for updates (partial patch);
|
|
1544
|
+
// on for create. Nothing outside `c.fields` can reach the entity.
|
|
1545
|
+
const toColumns = (c, values, requireRequired) => {
|
|
1546
|
+
const obj = asObj(values);
|
|
1547
|
+
validateFields([...c.fields], obj, "", { requireRequired });
|
|
1548
|
+
const sanitized = sanitizeFields([...c.fields], obj);
|
|
1549
|
+
const out = {};
|
|
1550
|
+
for (const f of c.fields)
|
|
1551
|
+
if (f.name in sanitized)
|
|
1552
|
+
out[f.name] = sanitized[f.name];
|
|
1553
|
+
return out;
|
|
1554
|
+
};
|
|
1555
|
+
const idInput = (raw) => {
|
|
1556
|
+
const id = asObj(raw).id;
|
|
1557
|
+
if (typeof id !== "string" || id === "")
|
|
1558
|
+
throw new BadRequest("id is required");
|
|
1559
|
+
return id;
|
|
1560
|
+
};
|
|
1561
|
+
return {
|
|
1562
|
+
/** The registered collections (defaults filled) — editor discovery. Editor-gated so
|
|
1563
|
+
* the collection schemas aren't exposed to anonymous callers. */
|
|
1564
|
+
listCollections: query(() => metas, editor),
|
|
1565
|
+
collectionList: query((ctx, input) => {
|
|
1566
|
+
const c = def(input.collection);
|
|
1567
|
+
const limit = typeof input.limit === "number" ? input.limit : 100;
|
|
1568
|
+
const offset = typeof input.offset === "number" ? input.offset : undefined;
|
|
1569
|
+
return cdb(ctx).find({ from: c.entity, orderBy: c.orderBy ?? { column: "createdAt", dir: "desc" }, limit, offset });
|
|
1570
|
+
}, editor),
|
|
1571
|
+
collectionGet: query(async (ctx, input) => {
|
|
1572
|
+
const c = def(input.collection);
|
|
1573
|
+
const rows = await cdb(ctx).find({ from: c.entity, where: { [idOf(c)]: input.id }, limit: 1 });
|
|
1574
|
+
return rows[0] ?? null;
|
|
1575
|
+
}, editor),
|
|
1576
|
+
collectionCreate: mutation((ctx, input) => {
|
|
1577
|
+
const c = def(input.collection);
|
|
1578
|
+
return cdb(ctx).insert(c.entity, toColumns(c, input.values, true));
|
|
1579
|
+
}, editor),
|
|
1580
|
+
collectionUpdate: mutation(async (ctx, input) => {
|
|
1581
|
+
const c = def(input.collection);
|
|
1582
|
+
const updated = await cdb(ctx).update(c.entity, input.id, toColumns(c, input.values, false));
|
|
1583
|
+
if (updated === undefined)
|
|
1584
|
+
throw notFound(c.label);
|
|
1585
|
+
return updated;
|
|
1586
|
+
}, editor),
|
|
1587
|
+
collectionDelete: mutation(async (ctx, input) => {
|
|
1588
|
+
const c = def(input.collection);
|
|
1589
|
+
const ok = await cdb(ctx).delete(c.entity, input.id);
|
|
1590
|
+
if (!ok)
|
|
1591
|
+
throw notFound(c.label);
|
|
1592
|
+
return { ok: true };
|
|
1593
|
+
}, { ...editor, input: (raw) => ({ collection: asObj(raw).collection, id: idInput(raw) }) }),
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
/** ACL fragments granting the editor role full CRUD over each collection's entity. Spread
|
|
1597
|
+
* into your editor role next to `cmsPolicies().editor`:
|
|
1598
|
+
*
|
|
1599
|
+
* role("editor", [...cmsPolicies().editor, ...collectionPolicies([lectures])])
|
|
1600
|
+
*
|
|
1601
|
+
* The generic collection handlers go through `ctx.db`, so without these the row ACL denies
|
|
1602
|
+
* them. Your app may already declare its own policies over the entity (e.g. a public read
|
|
1603
|
+
* scope) — these only ADD the editor grant the CMS UI needs. */
|
|
1604
|
+
export function collectionPolicies(collections, opts = {}) {
|
|
1605
|
+
const p = opts.prefix ?? "cms";
|
|
1606
|
+
const out = [];
|
|
1607
|
+
for (const c of collections) {
|
|
1608
|
+
for (const action of ["read", "create", "update", "delete"]) {
|
|
1609
|
+
out.push(policy(`${p}:editor:collection:${c.entity}:${action}`, c.entity, action, allow()));
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return out;
|
|
1613
|
+
}
|
|
1443
1614
|
// --- deferred tasks (scheduled publish/unpublish) ----------------------------
|
|
1444
1615
|
/** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
|
|
1445
1616
|
* They run with a privileged, system-scoped ctx off the write path (the outbox drain).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.37",
|
|
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.37",
|
|
45
45
|
"xss": "^1.0.15"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -365,9 +365,10 @@ export const cmsSchema = {
|
|
|
365
365
|
id: primaryKey(generated(t.uuid())),
|
|
366
366
|
typeId: notNull(t.uuid()),
|
|
367
367
|
title: notNull(t.text()),
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
368
|
+
// A slug is unique PER LOCALE (`/en/about` + `/cs/about`) — enforced by the entity's
|
|
369
|
+
// composite `unique: [["slug","locale"]]` (below). createPage/updatePage/createTranslation
|
|
370
|
+
// also pre-check so the caller gets a clean 409 before hitting the constraint; the index
|
|
371
|
+
// also speeds slug lookups.
|
|
371
372
|
slug: indexed(notNull(t.text())),
|
|
372
373
|
status: defaultTo(t.text(), "draft"), // draft | published | archived
|
|
373
374
|
locale: defaultTo(t.text(), "en"),
|
|
@@ -398,6 +399,7 @@ export const cmsSchema = {
|
|
|
398
399
|
type: r.belongsTo("cms_content_types", "typeId"),
|
|
399
400
|
placements: r.hasMany("cms_page_blocks", "pageId"),
|
|
400
401
|
}),
|
|
402
|
+
{ unique: [["slug", "locale"]] }, // a slug is unique per locale (DB-enforced)
|
|
401
403
|
),
|
|
402
404
|
|
|
403
405
|
cms_page_blocks: Entity(
|
|
@@ -1174,6 +1176,47 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1174
1176
|
},
|
|
1175
1177
|
}),
|
|
1176
1178
|
|
|
1179
|
+
/** Edit a page's own attributes — title, slug, locale, and content-type-level `fields`
|
|
1180
|
+
* (the structured data of a non-block content type, e.g. a "Lecture" with date/speaker).
|
|
1181
|
+
* Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
|
|
1182
|
+
* page record itself, which was previously only settable at createPage. A slug/locale
|
|
1183
|
+
* change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
|
|
1184
|
+
* + sanitized against the content type's fieldsSchema, exactly like createPage. */
|
|
1185
|
+
updatePage: mutation(async (ctx, input: { pageId: string; title?: string; slug?: string; locale?: string; fields?: Record<string, unknown> }) => {
|
|
1186
|
+
const db = cdb(ctx);
|
|
1187
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1188
|
+
const page = rows[0];
|
|
1189
|
+
if (!page) throw notFound("page");
|
|
1190
|
+
const patch: Record<string, unknown> = { updatedAt: nowStamp() };
|
|
1191
|
+
if (input.title !== undefined) patch.title = input.title;
|
|
1192
|
+
if (input.slug !== undefined || input.locale !== undefined) {
|
|
1193
|
+
const nextSlug = input.slug ?? String(page.slug);
|
|
1194
|
+
const nextLocale = input.locale ?? String(page.locale);
|
|
1195
|
+
await assertSlugFree(db, nextSlug, nextLocale, String(page.id));
|
|
1196
|
+
if (input.slug !== undefined) patch.slug = input.slug;
|
|
1197
|
+
if (input.locale !== undefined) patch.locale = input.locale;
|
|
1198
|
+
}
|
|
1199
|
+
if (input.fields !== undefined) {
|
|
1200
|
+
const ctRows = await db.find({ from: "cms_content_types", where: { id: page.typeId }, limit: 1 });
|
|
1201
|
+
const schema = ctRows[0]?.fieldsSchema as FieldDefinition[] | undefined;
|
|
1202
|
+
validateFields(schema, input.fields, "page.fields", { requireRequired: false });
|
|
1203
|
+
patch.fields = await sanitizeFields(schema, input.fields);
|
|
1204
|
+
}
|
|
1205
|
+
const updated = await db.update("cms_pages", input.pageId, patch);
|
|
1206
|
+
if (!updated) throw notFound("page");
|
|
1207
|
+
return { ok: true, page: updated };
|
|
1208
|
+
}, {
|
|
1209
|
+
...editor,
|
|
1210
|
+
input: (raw): { pageId: string; title?: string; slug?: string; locale?: string; fields?: Record<string, unknown> } => {
|
|
1211
|
+
const o = asObj(raw);
|
|
1212
|
+
if (typeof o.pageId !== "string") throw new BadRequest("pageId is required");
|
|
1213
|
+
for (const k of ["title", "slug", "locale"] as const) {
|
|
1214
|
+
if (o[k] !== undefined && typeof o[k] !== "string") throw new BadRequest(`${k} must be a string`);
|
|
1215
|
+
}
|
|
1216
|
+
return o as never;
|
|
1217
|
+
},
|
|
1218
|
+
}),
|
|
1219
|
+
|
|
1177
1220
|
/** Create a page and auto-scaffold its content type's default blocks. */
|
|
1178
1221
|
createPage: mutation(async (ctx, input: { typeId: string; title: string; slug: string; locale?: string; fields?: Record<string, unknown> }) => {
|
|
1179
1222
|
const db = cdb(ctx);
|
|
@@ -1719,6 +1762,196 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
|
|
|
1719
1762
|
};
|
|
1720
1763
|
}
|
|
1721
1764
|
|
|
1765
|
+
// --- collections: edit arbitrary pramen entities in the CMS editor -----------
|
|
1766
|
+
//
|
|
1767
|
+
// The block/page model is one opinionated shape (a routable page with a mandatory slug +
|
|
1768
|
+
// regions of blocks). A COLLECTION is the generic escape hatch: it points the editor at
|
|
1769
|
+
// one of YOUR OWN pramen entities (spread into defineSchema alongside cmsSchema) and
|
|
1770
|
+
// describes how to edit it with the SAME field DSL that blocks use. So "Lectures" is a
|
|
1771
|
+
// first-class, queryable entity — real columns, relations, cell-ACL — that also gets a
|
|
1772
|
+
// list + form UI, without being bent into a cms_pages row.
|
|
1773
|
+
//
|
|
1774
|
+
// Column-mapped: each scalar FieldDefinition.name is a real column on the entity; a
|
|
1775
|
+
// repeater/group field maps to a t.json() column (the object↔JSON codec at the Db
|
|
1776
|
+
// chokepoint stores it transparently). The generic handlers dispatch through a registry
|
|
1777
|
+
// keyed by `slug`, so `collection`/`entity` can never be spoofed to reach an arbitrary
|
|
1778
|
+
// table, and writes are whitelisted to declared fields — the client can't set columns the
|
|
1779
|
+
// collection didn't declare (e.g. a `roles` or `passwordHash` column on the entity).
|
|
1780
|
+
|
|
1781
|
+
/** Declares that a pramen entity is editable as a collection in the CMS editor. Both
|
|
1782
|
+
* halves live here: the runtime facts (entity, idField, validation via `fields`) and the
|
|
1783
|
+
* UI facts (labels, list columns, ordering). Mirror of {@link ContentTypeDef}. */
|
|
1784
|
+
export interface CollectionDef {
|
|
1785
|
+
/** The pramen entity (table) this collection edits — one of your own entities, spread
|
|
1786
|
+
* into `defineSchema` next to `cmsSchema`. The handler registry keys off `slug`, then
|
|
1787
|
+
* reads this; it is never taken from client input. */
|
|
1788
|
+
readonly entity: string;
|
|
1789
|
+
/** URL + nav key, e.g. `"lectures"`. Unique across collections. */
|
|
1790
|
+
readonly slug: string;
|
|
1791
|
+
/** Singular UI label, e.g. `"Lecture"`. */
|
|
1792
|
+
readonly label: string;
|
|
1793
|
+
/** Plural UI label; defaults to `label + "s"`. */
|
|
1794
|
+
readonly pluralLabel?: string;
|
|
1795
|
+
/** Optional nav icon (emoji or short string). */
|
|
1796
|
+
readonly icon?: string;
|
|
1797
|
+
/** The edit-form schema — the same DSL as blocks. Each scalar field is a real column
|
|
1798
|
+
* on `entity`; repeater/group map to a `t.json()` column. Also the WRITE WHITELIST:
|
|
1799
|
+
* only these field names are ever written to the entity. */
|
|
1800
|
+
readonly fields: readonly FieldDefinition[];
|
|
1801
|
+
/** Columns shown in the list view; defaults to `[titleField]`. */
|
|
1802
|
+
readonly list?: readonly string[];
|
|
1803
|
+
/** Column that titles a row in the list; defaults to `"title"`. */
|
|
1804
|
+
readonly titleField?: string;
|
|
1805
|
+
/** Primary-key column used to load/patch a single row; defaults to `"id"`. */
|
|
1806
|
+
readonly idField?: string;
|
|
1807
|
+
/** Default list ordering; defaults to `{ column: "createdAt", dir: "desc" }`. */
|
|
1808
|
+
readonly orderBy?: { column: string; dir?: "asc" | "desc" };
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** Declare a collection. Spread the results into `createCollectionHandlers` +
|
|
1812
|
+
* `collectionPolicies`:
|
|
1813
|
+
*
|
|
1814
|
+
* const lectures = collection("lectures", {
|
|
1815
|
+
* entity: "lectures", label: "Lecture", titleField: "title",
|
|
1816
|
+
* list: ["title", "speaker", "date"],
|
|
1817
|
+
* fields: [
|
|
1818
|
+
* { name: "title", type: "text", required: true },
|
|
1819
|
+
* { name: "speaker", type: "text" },
|
|
1820
|
+
* { name: "date", type: "date" },
|
|
1821
|
+
* ],
|
|
1822
|
+
* }); */
|
|
1823
|
+
export function collection(slug: string, opts: Omit<CollectionDef, "slug">): CollectionDef {
|
|
1824
|
+
return { ...opts, slug };
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** The client-facing subset of a collection the editor fetches via `listCollections` to
|
|
1828
|
+
* build its nav + generic list/edit views (no per-collection editor code, no rebuild to
|
|
1829
|
+
* add one). Excludes the server-only `entity` (the editor addresses a collection by `slug`
|
|
1830
|
+
* only, never by table name); `idField` IS included — it's just the PK column name, which
|
|
1831
|
+
* the editor needs to read a row's id from a list result. */
|
|
1832
|
+
export interface CollectionMeta {
|
|
1833
|
+
slug: string;
|
|
1834
|
+
label: string;
|
|
1835
|
+
pluralLabel: string;
|
|
1836
|
+
icon?: string;
|
|
1837
|
+
fields: readonly FieldDefinition[];
|
|
1838
|
+
list: readonly string[];
|
|
1839
|
+
titleField: string;
|
|
1840
|
+
idField: string;
|
|
1841
|
+
orderBy?: { column: string; dir?: "asc" | "desc" };
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** The public view of a collection def (defaults filled). */
|
|
1845
|
+
function collectionMeta(c: CollectionDef): CollectionMeta {
|
|
1846
|
+
const titleField = c.titleField ?? "title";
|
|
1847
|
+
return {
|
|
1848
|
+
slug: c.slug,
|
|
1849
|
+
label: c.label,
|
|
1850
|
+
pluralLabel: c.pluralLabel ?? `${c.label}s`,
|
|
1851
|
+
icon: c.icon,
|
|
1852
|
+
fields: c.fields,
|
|
1853
|
+
list: c.list ?? [titleField],
|
|
1854
|
+
titleField,
|
|
1855
|
+
idField: c.idField ?? "id",
|
|
1856
|
+
orderBy: c.orderBy,
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/** Build generic CRUD handlers over the registered collections. Spread into your app's
|
|
1861
|
+
* handlers alongside `cmsHandlers`:
|
|
1862
|
+
*
|
|
1863
|
+
* const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
|
|
1864
|
+
*
|
|
1865
|
+
* Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
|
|
1866
|
+
* `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
|
|
1867
|
+
* (a fast 403 before the body) AND the row ACL (they go through `ctx.db`, so
|
|
1868
|
+
* `collectionPolicies` scopes them too). The `collection` param is resolved through the
|
|
1869
|
+
* registry — an unknown slug is a 400, never a raw table reference. */
|
|
1870
|
+
export function createCollectionHandlers(collections: readonly CollectionDef[], opts: CmsHandlerOpts = {}) {
|
|
1871
|
+
const editor = { auth: opts.editorRoles ?? ["editor", "admin"] };
|
|
1872
|
+
const bySlug = new Map(collections.map((c) => [c.slug, c] as const));
|
|
1873
|
+
const metas = collections.map(collectionMeta);
|
|
1874
|
+
const def = (slug: unknown): CollectionDef => {
|
|
1875
|
+
const c = typeof slug === "string" ? bySlug.get(slug) : undefined;
|
|
1876
|
+
if (!c) throw new BadRequest(`unknown collection: ${String(slug)}`);
|
|
1877
|
+
return c;
|
|
1878
|
+
};
|
|
1879
|
+
const idOf = (c: CollectionDef): string => c.idField ?? "id";
|
|
1880
|
+
// Validate against the field schema, sanitize richtext, then PROJECT to declared field
|
|
1881
|
+
// names only — the write whitelist. `requireRequired` is off for updates (partial patch);
|
|
1882
|
+
// on for create. Nothing outside `c.fields` can reach the entity.
|
|
1883
|
+
const toColumns = (c: CollectionDef, values: unknown, requireRequired: boolean): Record<string, unknown> => {
|
|
1884
|
+
const obj = asObj(values);
|
|
1885
|
+
validateFields([...c.fields], obj, "", { requireRequired });
|
|
1886
|
+
const sanitized = sanitizeFields([...c.fields], obj);
|
|
1887
|
+
const out: Record<string, unknown> = {};
|
|
1888
|
+
for (const f of c.fields) if (f.name in sanitized) out[f.name] = sanitized[f.name];
|
|
1889
|
+
return out;
|
|
1890
|
+
};
|
|
1891
|
+
const idInput = (raw: unknown): string => {
|
|
1892
|
+
const id = asObj(raw).id;
|
|
1893
|
+
if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
|
|
1894
|
+
return id;
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
return {
|
|
1898
|
+
/** The registered collections (defaults filled) — editor discovery. Editor-gated so
|
|
1899
|
+
* the collection schemas aren't exposed to anonymous callers. */
|
|
1900
|
+
listCollections: query((): CollectionMeta[] => metas, editor),
|
|
1901
|
+
|
|
1902
|
+
collectionList: query((ctx, input: { collection: string; limit?: number; offset?: number }) => {
|
|
1903
|
+
const c = def(input.collection);
|
|
1904
|
+
const limit = typeof input.limit === "number" ? input.limit : 100;
|
|
1905
|
+
const offset = typeof input.offset === "number" ? input.offset : undefined;
|
|
1906
|
+
return cdb(ctx).find({ from: c.entity, orderBy: c.orderBy ?? { column: "createdAt", dir: "desc" }, limit, offset });
|
|
1907
|
+
}, editor),
|
|
1908
|
+
|
|
1909
|
+
collectionGet: query(async (ctx, input: { collection: string; id: string }) => {
|
|
1910
|
+
const c = def(input.collection);
|
|
1911
|
+
const rows = await cdb(ctx).find({ from: c.entity, where: { [idOf(c)]: input.id }, limit: 1 });
|
|
1912
|
+
return rows[0] ?? null;
|
|
1913
|
+
}, editor),
|
|
1914
|
+
|
|
1915
|
+
collectionCreate: mutation((ctx, input: { collection: string; values: Record<string, unknown> }) => {
|
|
1916
|
+
const c = def(input.collection);
|
|
1917
|
+
return cdb(ctx).insert(c.entity, toColumns(c, input.values, true));
|
|
1918
|
+
}, editor),
|
|
1919
|
+
|
|
1920
|
+
collectionUpdate: mutation(async (ctx, input: { collection: string; id: string; values: Record<string, unknown> }) => {
|
|
1921
|
+
const c = def(input.collection);
|
|
1922
|
+
const updated = await cdb(ctx).update(c.entity, input.id, toColumns(c, input.values, false));
|
|
1923
|
+
if (updated === undefined) throw notFound(c.label);
|
|
1924
|
+
return updated;
|
|
1925
|
+
}, editor),
|
|
1926
|
+
|
|
1927
|
+
collectionDelete: mutation(async (ctx, input: { collection: string; id: string }) => {
|
|
1928
|
+
const c = def(input.collection);
|
|
1929
|
+
const ok = await cdb(ctx).delete(c.entity, input.id);
|
|
1930
|
+
if (!ok) throw notFound(c.label);
|
|
1931
|
+
return { ok: true as const };
|
|
1932
|
+
}, { ...editor, input: (raw) => ({ collection: asObj(raw).collection as string, id: idInput(raw) }) }),
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/** ACL fragments granting the editor role full CRUD over each collection's entity. Spread
|
|
1937
|
+
* into your editor role next to `cmsPolicies().editor`:
|
|
1938
|
+
*
|
|
1939
|
+
* role("editor", [...cmsPolicies().editor, ...collectionPolicies([lectures])])
|
|
1940
|
+
*
|
|
1941
|
+
* The generic collection handlers go through `ctx.db`, so without these the row ACL denies
|
|
1942
|
+
* them. Your app may already declare its own policies over the entity (e.g. a public read
|
|
1943
|
+
* scope) — these only ADD the editor grant the CMS UI needs. */
|
|
1944
|
+
export function collectionPolicies(collections: readonly CollectionDef[], opts: CmsPolicyOpts = {}): Policy[] {
|
|
1945
|
+
const p = opts.prefix ?? "cms";
|
|
1946
|
+
const out: Policy[] = [];
|
|
1947
|
+
for (const c of collections) {
|
|
1948
|
+
for (const action of ["read", "create", "update", "delete"] as const) {
|
|
1949
|
+
out.push(policy(`${p}:editor:collection:${c.entity}:${action}`, c.entity, action, allow()));
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
return out;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1722
1955
|
// --- deferred tasks (scheduled publish/unpublish) ----------------------------
|
|
1723
1956
|
|
|
1724
1957
|
/** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
|