@pramen/cms 0.0.22 → 0.0.24
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 +44 -1
- package/dist/index.js +86 -5
- package/package.json +2 -2
- package/src/index.ts +130 -6
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HandlerContext, Policy, FileRef } from "@pramen/server";
|
|
1
|
+
import type { HandlerContext, Policy, FileRef, BootstrapFn } from "@pramen/server";
|
|
2
2
|
/** A field in a block type's (or content type's) field schema. Recursive: a `repeater`
|
|
3
3
|
* or `group` nests `fields`. Mirrors WollyCMS's FieldDefinition. */
|
|
4
4
|
export interface FieldDefinition {
|
|
@@ -72,6 +72,44 @@ export declare function defineBlockType<S extends string, F extends readonly Fie
|
|
|
72
72
|
}): BlockTypeDef<S, F>;
|
|
73
73
|
/** The inferred `fields` type of a `defineBlockType` result. */
|
|
74
74
|
export type BlockFieldsOf<D extends BlockTypeDef> = InferBlockFields<D["fieldsSchema"]>;
|
|
75
|
+
/** A developer-authored content type: a page template. Page-level `fields`, named `regions`
|
|
76
|
+
* (each with an optional block-type allow-list), and optional `defaultBlocks` scaffolded when
|
|
77
|
+
* a page of this type is created. Mirror of `BlockTypeDef`; feed to `cmsBootstrap`. */
|
|
78
|
+
export interface ContentTypeDef {
|
|
79
|
+
readonly slug: string;
|
|
80
|
+
readonly name: string;
|
|
81
|
+
readonly description?: string;
|
|
82
|
+
readonly fields?: readonly FieldDefinition[];
|
|
83
|
+
readonly regions: readonly RegionDefinition[];
|
|
84
|
+
readonly defaultBlocks?: readonly DefaultBlockDefinition[];
|
|
85
|
+
}
|
|
86
|
+
/** Declare a content type in code. Spread the result into `cmsBootstrap({ contentTypes })`:
|
|
87
|
+
*
|
|
88
|
+
* const article = defineContentType("article", {
|
|
89
|
+
* name: "Article",
|
|
90
|
+
* fields: [{ name: "perex", type: "textarea" }, { name: "date", type: "date" }],
|
|
91
|
+
* regions: [{ name: "content", allowedTypes: ["rich_text", "image"] }],
|
|
92
|
+
* }); */
|
|
93
|
+
export declare function defineContentType(slug: string, opts: {
|
|
94
|
+
name?: string;
|
|
95
|
+
description?: string;
|
|
96
|
+
fields?: readonly FieldDefinition[];
|
|
97
|
+
regions: readonly RegionDefinition[];
|
|
98
|
+
defaultBlocks?: readonly DefaultBlockDefinition[];
|
|
99
|
+
}): ContentTypeDef;
|
|
100
|
+
/** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
|
|
101
|
+
* `slug` on each boot. Idempotent: inserts a missing type, updates a drifted one, leaves an
|
|
102
|
+
* identical one untouched. Register it on your app:
|
|
103
|
+
*
|
|
104
|
+
* export const app = { schema, handlers, acl, tasks,
|
|
105
|
+
* bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
|
|
106
|
+
*
|
|
107
|
+
* Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
|
|
108
|
+
* code-declared types with no manual createContentType/createBlockType call. */
|
|
109
|
+
export declare function cmsBootstrap(defs: {
|
|
110
|
+
blockTypes?: readonly BlockTypeDef[];
|
|
111
|
+
contentTypes?: readonly ContentTypeDef[];
|
|
112
|
+
}): BootstrapFn;
|
|
75
113
|
/** Emit a `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry from
|
|
76
114
|
* DB-stored block types (`{ slug, fieldsSchema }` rows). The runtime counterpart to the
|
|
77
115
|
* compile-time `InferBlockFields`, for webmaster-authored (data-driven) block types. */
|
|
@@ -494,6 +532,9 @@ export interface AssembledPage {
|
|
|
494
532
|
slug: string;
|
|
495
533
|
status: string;
|
|
496
534
|
locale: string;
|
|
535
|
+
/** The page's content-type slug (e.g. "article", "page") — lets a frontend route/render
|
|
536
|
+
* by type. `null` if the type row is missing. */
|
|
537
|
+
contentType: string | null;
|
|
497
538
|
translationGroupId: string | null;
|
|
498
539
|
/** Published sibling locales of this page (for hreflang alternates). */
|
|
499
540
|
translations: PageTranslation[];
|
|
@@ -627,6 +668,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
627
668
|
listPublishedPages: import("@pramen/server").Handler<unknown, {
|
|
628
669
|
slug: string;
|
|
629
670
|
locale: string;
|
|
671
|
+
contentType: string | null;
|
|
630
672
|
updatedAt: string;
|
|
631
673
|
}[]>;
|
|
632
674
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
|
@@ -880,6 +922,7 @@ export declare const cmsHandlers: {
|
|
|
880
922
|
listPublishedPages: import("@pramen/server").Handler<unknown, {
|
|
881
923
|
slug: string;
|
|
882
924
|
locale: string;
|
|
925
|
+
contentType: string | null;
|
|
883
926
|
updatedAt: string;
|
|
884
927
|
}[]>;
|
|
885
928
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,69 @@ import { filterXSS } from "xss";
|
|
|
40
40
|
export function defineBlockType(slug, fields, opts = {}) {
|
|
41
41
|
return { slug, name: opts.name ?? slug, fieldsSchema: fields, description: opts.description, icon: opts.icon, category: opts.category };
|
|
42
42
|
}
|
|
43
|
+
/** Declare a content type in code. Spread the result into `cmsBootstrap({ contentTypes })`:
|
|
44
|
+
*
|
|
45
|
+
* const article = defineContentType("article", {
|
|
46
|
+
* name: "Article",
|
|
47
|
+
* fields: [{ name: "perex", type: "textarea" }, { name: "date", type: "date" }],
|
|
48
|
+
* regions: [{ name: "content", allowedTypes: ["rich_text", "image"] }],
|
|
49
|
+
* }); */
|
|
50
|
+
export function defineContentType(slug, opts) {
|
|
51
|
+
return { slug, name: opts.name ?? slug, description: opts.description, fields: opts.fields, regions: opts.regions, defaultBlocks: opts.defaultBlocks };
|
|
52
|
+
}
|
|
53
|
+
const sameJson = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
54
|
+
/** Insert `values` if no row has this `slug`, else patch only the columns that drifted
|
|
55
|
+
* (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op. */
|
|
56
|
+
async function upsertBySlug(db, table, slug, values) {
|
|
57
|
+
const existing = (await db.find({ from: table, where: { slug }, limit: 1 }))[0];
|
|
58
|
+
if (!existing) {
|
|
59
|
+
await db.insert(table, values);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const patch = {};
|
|
63
|
+
for (const [k, v] of Object.entries(values)) {
|
|
64
|
+
if (k === "slug")
|
|
65
|
+
continue;
|
|
66
|
+
if (!sameJson(existing[k], v))
|
|
67
|
+
patch[k] = v;
|
|
68
|
+
}
|
|
69
|
+
if (Object.keys(patch).length)
|
|
70
|
+
await db.update(table, String(existing.id), patch);
|
|
71
|
+
}
|
|
72
|
+
/** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
|
|
73
|
+
* `slug` on each boot. Idempotent: inserts a missing type, updates a drifted one, leaves an
|
|
74
|
+
* identical one untouched. Register it on your app:
|
|
75
|
+
*
|
|
76
|
+
* export const app = { schema, handlers, acl, tasks,
|
|
77
|
+
* bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
|
|
78
|
+
*
|
|
79
|
+
* Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
|
|
80
|
+
* code-declared types with no manual createContentType/createBlockType call. */
|
|
81
|
+
export function cmsBootstrap(defs) {
|
|
82
|
+
return async ({ db }) => {
|
|
83
|
+
const sys = db;
|
|
84
|
+
for (const bt of defs.blockTypes ?? []) {
|
|
85
|
+
await upsertBySlug(sys, "cms_block_types", bt.slug, {
|
|
86
|
+
name: bt.name,
|
|
87
|
+
slug: bt.slug,
|
|
88
|
+
description: bt.description ?? null,
|
|
89
|
+
fieldsSchema: bt.fieldsSchema ?? [],
|
|
90
|
+
icon: bt.icon ?? null,
|
|
91
|
+
category: bt.category ?? null,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
for (const ct of defs.contentTypes ?? []) {
|
|
95
|
+
await upsertBySlug(sys, "cms_content_types", ct.slug, {
|
|
96
|
+
name: ct.name,
|
|
97
|
+
slug: ct.slug,
|
|
98
|
+
description: ct.description ?? null,
|
|
99
|
+
fieldsSchema: ct.fields ?? [],
|
|
100
|
+
regions: ct.regions ?? [],
|
|
101
|
+
defaultBlocks: ct.defaultBlocks ?? [],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
43
106
|
// --- codegen: emit .ts field interfaces from DB-stored block schemas ------------------
|
|
44
107
|
//
|
|
45
108
|
// The data-driven half (webmaster-created block types) has no static type. This is the
|
|
@@ -402,6 +465,13 @@ const isEditor = (ctx, roles) => {
|
|
|
402
465
|
};
|
|
403
466
|
/** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
|
|
404
467
|
* by position, merging each shared placement's `overrides` over its block's fields. */
|
|
468
|
+
/** Resolve a page's content-type slug from its `typeId` (null when the type row is gone). */
|
|
469
|
+
async function contentTypeSlug(db, typeId) {
|
|
470
|
+
if (typeof typeId !== "string" || !typeId)
|
|
471
|
+
return null;
|
|
472
|
+
const rows = await db.find({ from: "cms_content_types", where: { id: typeId }, limit: 1 });
|
|
473
|
+
return rows[0] ? String(rows[0].slug) : null;
|
|
474
|
+
}
|
|
405
475
|
async function assembleLive(db, page) {
|
|
406
476
|
const placements = await db.find({
|
|
407
477
|
from: "cms_page_blocks",
|
|
@@ -449,8 +519,8 @@ async function assembleLive(db, page) {
|
|
|
449
519
|
is_shared: Boolean(m.p.isShared),
|
|
450
520
|
});
|
|
451
521
|
}
|
|
452
|
-
const [translations, ogImage] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage)]);
|
|
453
|
-
return { page: pageMeta(page, translations, ogImage), regions };
|
|
522
|
+
const [translations, ogImage, contentType] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage), contentTypeSlug(db, page.typeId)]);
|
|
523
|
+
return { page: pageMeta(page, translations, ogImage, contentType), regions };
|
|
454
524
|
}
|
|
455
525
|
/** Resolve a single media id to a ResolvedMedia (for og:image etc.), or null. */
|
|
456
526
|
async function resolveMediaId(db, id) {
|
|
@@ -496,7 +566,7 @@ async function siblingTranslations(db, page) {
|
|
|
496
566
|
.map((r) => ({ locale: String(r.locale ?? "en"), slug: String(r.slug) }));
|
|
497
567
|
}
|
|
498
568
|
/** Project a page row to the public AssembledPage.page shape. */
|
|
499
|
-
function pageMeta(page, translations = [], ogImage = null) {
|
|
569
|
+
function pageMeta(page, translations = [], ogImage = null, contentType = null) {
|
|
500
570
|
const metaTitle = page.metaTitle ?? null;
|
|
501
571
|
const metaDescription = page.metaDescription ?? null;
|
|
502
572
|
return {
|
|
@@ -505,6 +575,7 @@ function pageMeta(page, translations = [], ogImage = null) {
|
|
|
505
575
|
slug: String(page.slug),
|
|
506
576
|
status: String(page.status),
|
|
507
577
|
locale: String(page.locale ?? "en"),
|
|
578
|
+
contentType,
|
|
508
579
|
translationGroupId: page.translationGroupId ?? null,
|
|
509
580
|
translations,
|
|
510
581
|
fields: page.fields ?? null,
|
|
@@ -777,8 +848,14 @@ export function createCmsHandlers(opts = {}) {
|
|
|
777
848
|
/** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
|
|
778
849
|
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
|
|
779
850
|
listPublishedPages: query(async (ctx) => {
|
|
780
|
-
const
|
|
781
|
-
|
|
851
|
+
const db = cdb(ctx);
|
|
852
|
+
const rows = await db.find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
|
|
853
|
+
// Join typeId → content-type slug so a frontend can route/filter by type (e.g. articles
|
|
854
|
+
// vs pages) without a second round-trip.
|
|
855
|
+
const typeIds = [...new Set(rows.map((r) => r.typeId).filter((v) => typeof v === "string"))];
|
|
856
|
+
const types = typeIds.length ? await db.find({ from: "cms_content_types", where: { id: { in: typeIds } } }) : [];
|
|
857
|
+
const slugById = new Map(types.map((t) => [String(t.id), String(t.slug)]));
|
|
858
|
+
return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), contentType: slugById.get(String(r.typeId)) ?? null, updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
|
|
782
859
|
}),
|
|
783
860
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
|
784
861
|
updatePageSeo: mutation(async (ctx, input) => {
|
|
@@ -1346,6 +1423,10 @@ export function cmsPolicies(opts = {}) {
|
|
|
1346
1423
|
}
|
|
1347
1424
|
return {
|
|
1348
1425
|
public: [
|
|
1426
|
+
// Content-type metadata (slug/name) is public: the content API returns each published
|
|
1427
|
+
// page's content-type slug (via listPublishedPages' live typeId→slug join) so a frontend
|
|
1428
|
+
// can route/render by type. Slugs/names are structural, not sensitive.
|
|
1429
|
+
policy(`${p}:public:content-types:read`, "cms_content_types", "read", allow()),
|
|
1349
1430
|
// Only published pages are readable; the snapshot carries the content.
|
|
1350
1431
|
policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
|
|
1351
1432
|
// getPage reads the latest revision snapshot. Scope the grant by the revision's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
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.24",
|
|
45
45
|
"xss": "^1.0.15"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
Forbidden,
|
|
43
43
|
PramenError,
|
|
44
44
|
} from "@pramen/server";
|
|
45
|
-
import type { HandlerContext, Policy, FileRef } from "@pramen/server";
|
|
45
|
+
import type { HandlerContext, Policy, FileRef, BootstrapFn } from "@pramen/server";
|
|
46
46
|
import { filterXSS } from "xss";
|
|
47
47
|
|
|
48
48
|
// --- field schema DSL (the block-editor field language) ---------------------
|
|
@@ -159,6 +159,109 @@ export function defineBlockType<S extends string, F extends readonly FieldDefini
|
|
|
159
159
|
/** The inferred `fields` type of a `defineBlockType` result. */
|
|
160
160
|
export type BlockFieldsOf<D extends BlockTypeDef> = InferBlockFields<D["fieldsSchema"]>;
|
|
161
161
|
|
|
162
|
+
// --- code-defined content types + bootstrap reconcile ---------------------------------
|
|
163
|
+
//
|
|
164
|
+
// Block/content types are runtime rows (a webmaster can add one with no deploy), but a repo
|
|
165
|
+
// that hard-depends on a fixed shape (e.g. an Astro site whose build fails unless an
|
|
166
|
+
// `article` type with certain fields exists) wants them CODE-DEFINED and auto-applied. These
|
|
167
|
+
// helpers let you declare types in code and converge them into the store on boot via pramen's
|
|
168
|
+
// `app.bootstrap` — so a fresh / reprovisioned database has them without a manual
|
|
169
|
+
// createContentType/createBlockType call.
|
|
170
|
+
|
|
171
|
+
/** A developer-authored content type: a page template. Page-level `fields`, named `regions`
|
|
172
|
+
* (each with an optional block-type allow-list), and optional `defaultBlocks` scaffolded when
|
|
173
|
+
* a page of this type is created. Mirror of `BlockTypeDef`; feed to `cmsBootstrap`. */
|
|
174
|
+
export interface ContentTypeDef {
|
|
175
|
+
readonly slug: string;
|
|
176
|
+
readonly name: string;
|
|
177
|
+
readonly description?: string;
|
|
178
|
+
readonly fields?: readonly FieldDefinition[];
|
|
179
|
+
readonly regions: readonly RegionDefinition[];
|
|
180
|
+
readonly defaultBlocks?: readonly DefaultBlockDefinition[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Declare a content type in code. Spread the result into `cmsBootstrap({ contentTypes })`:
|
|
184
|
+
*
|
|
185
|
+
* const article = defineContentType("article", {
|
|
186
|
+
* name: "Article",
|
|
187
|
+
* fields: [{ name: "perex", type: "textarea" }, { name: "date", type: "date" }],
|
|
188
|
+
* regions: [{ name: "content", allowedTypes: ["rich_text", "image"] }],
|
|
189
|
+
* }); */
|
|
190
|
+
export function defineContentType(
|
|
191
|
+
slug: string,
|
|
192
|
+
opts: {
|
|
193
|
+
name?: string;
|
|
194
|
+
description?: string;
|
|
195
|
+
fields?: readonly FieldDefinition[];
|
|
196
|
+
regions: readonly RegionDefinition[];
|
|
197
|
+
defaultBlocks?: readonly DefaultBlockDefinition[];
|
|
198
|
+
},
|
|
199
|
+
): ContentTypeDef {
|
|
200
|
+
return { slug, name: opts.name ?? slug, description: opts.description, fields: opts.fields, regions: opts.regions, defaultBlocks: opts.defaultBlocks };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The narrow slice of the system Db a reconcile needs. `cmsBootstrap` runs with a SYSTEM
|
|
204
|
+
* Db (ACL bypassed), so these calls are unrestricted; kept loose to avoid threading the
|
|
205
|
+
* host app's schema generic through a library helper. */
|
|
206
|
+
interface ReconcileDb {
|
|
207
|
+
find(q: { from: string; where?: Record<string, unknown>; limit?: number }): Promise<Record<string, unknown>[]>;
|
|
208
|
+
insert(table: string, values: Record<string, unknown>): Promise<unknown>;
|
|
209
|
+
update(table: string, id: string, patch: Record<string, unknown>): Promise<unknown>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
213
|
+
|
|
214
|
+
/** Insert `values` if no row has this `slug`, else patch only the columns that drifted
|
|
215
|
+
* (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op. */
|
|
216
|
+
async function upsertBySlug(db: ReconcileDb, table: string, slug: string, values: Record<string, unknown>): Promise<void> {
|
|
217
|
+
const existing = (await db.find({ from: table, where: { slug }, limit: 1 }))[0];
|
|
218
|
+
if (!existing) {
|
|
219
|
+
await db.insert(table, values);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const patch: Record<string, unknown> = {};
|
|
223
|
+
for (const [k, v] of Object.entries(values)) {
|
|
224
|
+
if (k === "slug") continue;
|
|
225
|
+
if (!sameJson(existing[k], v)) patch[k] = v;
|
|
226
|
+
}
|
|
227
|
+
if (Object.keys(patch).length) await db.update(table, String(existing.id), patch);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
|
|
231
|
+
* `slug` on each boot. Idempotent: inserts a missing type, updates a drifted one, leaves an
|
|
232
|
+
* identical one untouched. Register it on your app:
|
|
233
|
+
*
|
|
234
|
+
* export const app = { schema, handlers, acl, tasks,
|
|
235
|
+
* bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
|
|
236
|
+
*
|
|
237
|
+
* Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
|
|
238
|
+
* code-declared types with no manual createContentType/createBlockType call. */
|
|
239
|
+
export function cmsBootstrap(defs: { blockTypes?: readonly BlockTypeDef[]; contentTypes?: readonly ContentTypeDef[] }): BootstrapFn {
|
|
240
|
+
return async ({ db }) => {
|
|
241
|
+
const sys = db as unknown as ReconcileDb;
|
|
242
|
+
for (const bt of defs.blockTypes ?? []) {
|
|
243
|
+
await upsertBySlug(sys, "cms_block_types", bt.slug, {
|
|
244
|
+
name: bt.name,
|
|
245
|
+
slug: bt.slug,
|
|
246
|
+
description: bt.description ?? null,
|
|
247
|
+
fieldsSchema: bt.fieldsSchema ?? [],
|
|
248
|
+
icon: bt.icon ?? null,
|
|
249
|
+
category: bt.category ?? null,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
for (const ct of defs.contentTypes ?? []) {
|
|
253
|
+
await upsertBySlug(sys, "cms_content_types", ct.slug, {
|
|
254
|
+
name: ct.name,
|
|
255
|
+
slug: ct.slug,
|
|
256
|
+
description: ct.description ?? null,
|
|
257
|
+
fieldsSchema: ct.fields ?? [],
|
|
258
|
+
regions: ct.regions ?? [],
|
|
259
|
+
defaultBlocks: ct.defaultBlocks ?? [],
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
162
265
|
// --- codegen: emit .ts field interfaces from DB-stored block schemas ------------------
|
|
163
266
|
//
|
|
164
267
|
// The data-driven half (webmaster-created block types) has no static type. This is the
|
|
@@ -502,6 +605,9 @@ export interface AssembledPage {
|
|
|
502
605
|
slug: string;
|
|
503
606
|
status: string;
|
|
504
607
|
locale: string;
|
|
608
|
+
/** The page's content-type slug (e.g. "article", "page") — lets a frontend route/render
|
|
609
|
+
* by type. `null` if the type row is missing. */
|
|
610
|
+
contentType: string | null;
|
|
505
611
|
translationGroupId: string | null;
|
|
506
612
|
/** Published sibling locales of this page (for hreflang alternates). */
|
|
507
613
|
translations: PageTranslation[];
|
|
@@ -628,6 +734,13 @@ const isEditor = (ctx: HandlerContext, roles: readonly string[]): boolean => {
|
|
|
628
734
|
|
|
629
735
|
/** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
|
|
630
736
|
* by position, merging each shared placement's `overrides` over its block's fields. */
|
|
737
|
+
/** Resolve a page's content-type slug from its `typeId` (null when the type row is gone). */
|
|
738
|
+
async function contentTypeSlug(db: CmsDb, typeId: unknown): Promise<string | null> {
|
|
739
|
+
if (typeof typeId !== "string" || !typeId) return null;
|
|
740
|
+
const rows = await db.find({ from: "cms_content_types", where: { id: typeId }, limit: 1 });
|
|
741
|
+
return rows[0] ? String(rows[0].slug) : null;
|
|
742
|
+
}
|
|
743
|
+
|
|
631
744
|
async function assembleLive(db: CmsDb, page: Record<string, unknown>): Promise<AssembledPage> {
|
|
632
745
|
const placements = await db.find({
|
|
633
746
|
from: "cms_page_blocks",
|
|
@@ -676,8 +789,8 @@ async function assembleLive(db: CmsDb, page: Record<string, unknown>): Promise<A
|
|
|
676
789
|
is_shared: Boolean(m.p.isShared),
|
|
677
790
|
});
|
|
678
791
|
}
|
|
679
|
-
const [translations, ogImage] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage)]);
|
|
680
|
-
return { page: pageMeta(page, translations, ogImage), regions };
|
|
792
|
+
const [translations, ogImage, contentType] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage), contentTypeSlug(db, page.typeId)]);
|
|
793
|
+
return { page: pageMeta(page, translations, ogImage, contentType), regions };
|
|
681
794
|
}
|
|
682
795
|
|
|
683
796
|
/** Resolve a single media id to a ResolvedMedia (for og:image etc.), or null. */
|
|
@@ -723,7 +836,7 @@ async function siblingTranslations(db: CmsDb, page: Record<string, unknown>): Pr
|
|
|
723
836
|
}
|
|
724
837
|
|
|
725
838
|
/** Project a page row to the public AssembledPage.page shape. */
|
|
726
|
-
function pageMeta(page: Record<string, unknown>, translations: PageTranslation[] = [], ogImage: ResolvedMedia | null = null): AssembledPage["page"] {
|
|
839
|
+
function pageMeta(page: Record<string, unknown>, translations: PageTranslation[] = [], ogImage: ResolvedMedia | null = null, contentType: string | null = null): AssembledPage["page"] {
|
|
727
840
|
const metaTitle = (page.metaTitle as string | null) ?? null;
|
|
728
841
|
const metaDescription = (page.metaDescription as string | null) ?? null;
|
|
729
842
|
return {
|
|
@@ -732,6 +845,7 @@ function pageMeta(page: Record<string, unknown>, translations: PageTranslation[]
|
|
|
732
845
|
slug: String(page.slug),
|
|
733
846
|
status: String(page.status),
|
|
734
847
|
locale: String(page.locale ?? "en"),
|
|
848
|
+
contentType,
|
|
735
849
|
translationGroupId: (page.translationGroupId as string | null) ?? null,
|
|
736
850
|
translations,
|
|
737
851
|
fields: (page.fields as Record<string, unknown> | null) ?? null,
|
|
@@ -1027,8 +1141,14 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1027
1141
|
/** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
|
|
1028
1142
|
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
|
|
1029
1143
|
listPublishedPages: query(async (ctx) => {
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1144
|
+
const db = cdb(ctx);
|
|
1145
|
+
const rows = await db.find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
|
|
1146
|
+
// Join typeId → content-type slug so a frontend can route/filter by type (e.g. articles
|
|
1147
|
+
// vs pages) without a second round-trip.
|
|
1148
|
+
const typeIds = [...new Set(rows.map((r) => r.typeId).filter((v): v is string => typeof v === "string"))];
|
|
1149
|
+
const types = typeIds.length ? await db.find({ from: "cms_content_types", where: { id: { in: typeIds } } }) : [];
|
|
1150
|
+
const slugById = new Map(types.map((t) => [String(t.id), String(t.slug)]));
|
|
1151
|
+
return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), contentType: slugById.get(String(r.typeId)) ?? null, updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
|
|
1032
1152
|
}),
|
|
1033
1153
|
|
|
1034
1154
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
|
@@ -1578,6 +1698,10 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
|
|
|
1578
1698
|
}
|
|
1579
1699
|
return {
|
|
1580
1700
|
public: [
|
|
1701
|
+
// Content-type metadata (slug/name) is public: the content API returns each published
|
|
1702
|
+
// page's content-type slug (via listPublishedPages' live typeId→slug join) so a frontend
|
|
1703
|
+
// can route/render by type. Slugs/names are structural, not sensitive.
|
|
1704
|
+
policy(`${p}:public:content-types:read`, "cms_content_types", "read", allow()),
|
|
1581
1705
|
// Only published pages are readable; the snapshot carries the content.
|
|
1582
1706
|
policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
|
|
1583
1707
|
// getPage reads the latest revision snapshot. Scope the grant by the revision's
|