@pramen/cms 0.0.21 → 0.0.23

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 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. */
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
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.21",
44
+ "@pramen/server": "0.0.23",
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