@pramen/cms 0.0.14 → 0.0.15

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.
@@ -0,0 +1,1078 @@
1
+ import type { HandlerContext, Policy, FileRef } from "@pramen/server";
2
+ /** A field in a block type's (or content type's) field schema. Recursive: a `repeater`
3
+ * or `group` nests `fields`. Mirrors WollyCMS's FieldDefinition. */
4
+ export interface FieldDefinition {
5
+ name: string;
6
+ label?: string;
7
+ type: "text" | "textarea" | "richtext" | "url" | "number" | "boolean" | "media" | "select" | "repeater" | "group";
8
+ required?: boolean;
9
+ default?: unknown;
10
+ /** repeater/group only — the nested fields. */
11
+ fields?: FieldDefinition[];
12
+ /** repeater only — item count bounds. */
13
+ min?: number;
14
+ max?: number;
15
+ /** select only. */
16
+ options?: string[];
17
+ }
18
+ /** A named region on a content type; `allowedTypes` (block-type slugs) restricts what
19
+ * may be placed there — `null`/omitted means any. */
20
+ export interface RegionDefinition {
21
+ name: string;
22
+ label?: string;
23
+ allowedTypes?: string[] | null;
24
+ }
25
+ /** A block auto-created in a region when a page of this content type is created. */
26
+ export interface DefaultBlockDefinition {
27
+ region: string;
28
+ blockTypeSlug: string;
29
+ fields?: Record<string, unknown>;
30
+ }
31
+ /** A rich-text value — a serialized editor document (or a plain string). */
32
+ export type RichText = string | {
33
+ type: string;
34
+ content?: unknown[];
35
+ };
36
+ /** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
37
+ * Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
38
+ export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" ? string : D["type"] extends "richtext" ? RichText : D["type"] extends "number" ? number : D["type"] extends "boolean" ? boolean : D["type"] extends "media" ? ResolvedMedia | null : D["type"] extends "group" ? InferBlockFields<NonNullable<D["fields"]>> : D["type"] extends "repeater" ? InferBlockFields<NonNullable<D["fields"]>>[] : unknown;
39
+ /** Infer the `fields` object type from a const `FieldDefinition[]`. Required fields are
40
+ * present; optional ones are `| undefined`. */
41
+ export type InferBlockFields<T extends readonly FieldDefinition[]> = {
42
+ [D in T[number] as D["name"]]: D extends {
43
+ required: true;
44
+ } ? FieldTsType<D> : FieldTsType<D> | undefined;
45
+ };
46
+ /** A developer-authored block type: a slug + a const field schema, carrying enough type
47
+ * info to (a) create the DB block type and (b) type a rendering component's `fields`. */
48
+ export interface BlockTypeDef<S extends string = string, F extends readonly FieldDefinition[] = readonly FieldDefinition[]> {
49
+ readonly slug: S;
50
+ readonly name: string;
51
+ readonly fieldsSchema: F;
52
+ readonly description?: string;
53
+ readonly icon?: string;
54
+ readonly category?: string;
55
+ }
56
+ /** Declare a typed block type. Pass `fields as const` to preserve the literals so
57
+ * `BlockFieldsOf<typeof def>` infers the field shape:
58
+ *
59
+ * const hero = defineBlockType("hero", [
60
+ * { name: "heading", type: "text", required: true },
61
+ * { name: "image", type: "media" },
62
+ * ] as const);
63
+ * type HeroFields = BlockFieldsOf<typeof hero>; // { heading: string; image: ResolvedMedia|null|undefined }
64
+ *
65
+ * Spread `hero` (minus fieldsSchema key naming) into `createBlockType`, and use
66
+ * `BlockFieldsOf<typeof hero>` to type the block's React component. */
67
+ export declare function defineBlockType<S extends string, F extends readonly FieldDefinition[]>(slug: S, fields: F, opts?: {
68
+ name?: string;
69
+ description?: string;
70
+ icon?: string;
71
+ category?: string;
72
+ }): BlockTypeDef<S, F>;
73
+ /** The inferred `fields` type of a `defineBlockType` result. */
74
+ export type BlockFieldsOf<D extends BlockTypeDef> = InferBlockFields<D["fieldsSchema"]>;
75
+ /** Emit a `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry from
76
+ * DB-stored block types (`{ slug, fieldsSchema }` rows). The runtime counterpart to the
77
+ * compile-time `InferBlockFields`, for webmaster-authored (data-driven) block types. */
78
+ export declare function generateBlockTypes(blockTypes: Array<{
79
+ slug: string;
80
+ fieldsSchema?: FieldDefinition[] | null;
81
+ }>): string;
82
+ /** The block/page builder tables. All in the default partition (relations can't cross
83
+ * partitions). Prefixed `cms_` to avoid colliding with your own entities. */
84
+ export declare const cmsSchema: {
85
+ cms_content_types: import("@pramen/server").EntityDef<{
86
+ id: {
87
+ readonly type: "uuid";
88
+ } & {
89
+ readonly generated: true;
90
+ } & {
91
+ readonly primaryKey: true;
92
+ readonly notNull: true;
93
+ };
94
+ name: {
95
+ readonly type: "text";
96
+ } & {
97
+ readonly notNull: true;
98
+ };
99
+ slug: {
100
+ readonly type: "text";
101
+ } & {
102
+ readonly notNull: true;
103
+ } & {
104
+ readonly unique: true;
105
+ };
106
+ description: {
107
+ readonly type: "text";
108
+ };
109
+ fieldsSchema: {
110
+ readonly type: "json";
111
+ };
112
+ regions: {
113
+ readonly type: "json";
114
+ };
115
+ defaultBlocks: {
116
+ readonly type: "json";
117
+ };
118
+ createdAt: {
119
+ readonly type: "text";
120
+ } & {
121
+ readonly defaultExpr: string;
122
+ };
123
+ }, Record<string, never>>;
124
+ cms_block_types: import("@pramen/server").EntityDef<{
125
+ id: {
126
+ readonly type: "uuid";
127
+ } & {
128
+ readonly generated: true;
129
+ } & {
130
+ readonly primaryKey: true;
131
+ readonly notNull: true;
132
+ };
133
+ name: {
134
+ readonly type: "text";
135
+ } & {
136
+ readonly notNull: true;
137
+ };
138
+ slug: {
139
+ readonly type: "text";
140
+ } & {
141
+ readonly notNull: true;
142
+ } & {
143
+ readonly unique: true;
144
+ };
145
+ description: {
146
+ readonly type: "text";
147
+ };
148
+ fieldsSchema: {
149
+ readonly type: "json";
150
+ };
151
+ icon: {
152
+ readonly type: "text";
153
+ };
154
+ category: {
155
+ readonly type: "text";
156
+ };
157
+ createdAt: {
158
+ readonly type: "text";
159
+ } & {
160
+ readonly defaultExpr: string;
161
+ };
162
+ }, Record<string, never>>;
163
+ cms_blocks: import("@pramen/server").EntityDef<{
164
+ id: {
165
+ readonly type: "uuid";
166
+ } & {
167
+ readonly generated: true;
168
+ } & {
169
+ readonly primaryKey: true;
170
+ readonly notNull: true;
171
+ };
172
+ typeId: {
173
+ readonly type: "uuid";
174
+ } & {
175
+ readonly notNull: true;
176
+ };
177
+ title: {
178
+ readonly type: "text";
179
+ };
180
+ fields: {
181
+ readonly type: "json";
182
+ };
183
+ isReusable: {
184
+ readonly type: "boolean";
185
+ } & {
186
+ readonly default: false;
187
+ };
188
+ createdAt: {
189
+ readonly type: "text";
190
+ } & {
191
+ readonly defaultExpr: string;
192
+ };
193
+ updatedAt: {
194
+ readonly type: "text";
195
+ } & {
196
+ readonly defaultExpr: string;
197
+ };
198
+ }, {
199
+ type: {
200
+ readonly kind: "belongsTo";
201
+ readonly target: "cms_block_types";
202
+ readonly column: string;
203
+ };
204
+ }>;
205
+ cms_pages: import("@pramen/server").EntityDef<{
206
+ id: {
207
+ readonly type: "uuid";
208
+ } & {
209
+ readonly generated: true;
210
+ } & {
211
+ readonly primaryKey: true;
212
+ readonly notNull: true;
213
+ };
214
+ typeId: {
215
+ readonly type: "uuid";
216
+ } & {
217
+ readonly notNull: true;
218
+ };
219
+ title: {
220
+ readonly type: "text";
221
+ } & {
222
+ readonly notNull: true;
223
+ };
224
+ slug: {
225
+ readonly type: "text";
226
+ } & {
227
+ readonly notNull: true;
228
+ } & {
229
+ readonly index: true;
230
+ };
231
+ status: {
232
+ readonly type: "text";
233
+ } & {
234
+ readonly default: "draft";
235
+ };
236
+ locale: {
237
+ readonly type: "text";
238
+ } & {
239
+ readonly default: "en";
240
+ };
241
+ translationGroupId: {
242
+ readonly type: "uuid";
243
+ } & {
244
+ readonly generated: true;
245
+ };
246
+ fields: {
247
+ readonly type: "json";
248
+ };
249
+ publishedAt: {
250
+ readonly type: "text";
251
+ };
252
+ scheduledAt: {
253
+ readonly type: "text";
254
+ };
255
+ unpublishAt: {
256
+ readonly type: "text";
257
+ };
258
+ currentRevisionId: {
259
+ readonly type: "uuid";
260
+ };
261
+ metaTitle: {
262
+ readonly type: "text";
263
+ };
264
+ metaDescription: {
265
+ readonly type: "text";
266
+ };
267
+ canonicalUrl: {
268
+ readonly type: "text";
269
+ };
270
+ robots: {
271
+ readonly type: "text";
272
+ };
273
+ ogTitle: {
274
+ readonly type: "text";
275
+ };
276
+ ogDescription: {
277
+ readonly type: "text";
278
+ };
279
+ ogImage: {
280
+ readonly type: "uuid";
281
+ };
282
+ structuredData: {
283
+ readonly type: "json";
284
+ };
285
+ createdAt: {
286
+ readonly type: "text";
287
+ } & {
288
+ readonly defaultExpr: string;
289
+ };
290
+ updatedAt: {
291
+ readonly type: "text";
292
+ } & {
293
+ readonly defaultExpr: string;
294
+ };
295
+ }, {
296
+ type: {
297
+ readonly kind: "belongsTo";
298
+ readonly target: "cms_content_types";
299
+ readonly column: string;
300
+ };
301
+ placements: {
302
+ readonly kind: "hasMany";
303
+ readonly target: "cms_page_blocks";
304
+ readonly column: string;
305
+ };
306
+ }>;
307
+ cms_page_blocks: import("@pramen/server").EntityDef<{
308
+ id: {
309
+ readonly type: "uuid";
310
+ } & {
311
+ readonly generated: true;
312
+ } & {
313
+ readonly primaryKey: true;
314
+ readonly notNull: true;
315
+ };
316
+ pageId: {
317
+ readonly type: "uuid";
318
+ } & {
319
+ readonly notNull: true;
320
+ };
321
+ blockId: {
322
+ readonly type: "uuid";
323
+ } & {
324
+ readonly notNull: true;
325
+ };
326
+ region: {
327
+ readonly type: "text";
328
+ } & {
329
+ readonly notNull: true;
330
+ };
331
+ position: {
332
+ readonly type: "integer";
333
+ } & {
334
+ readonly notNull: true;
335
+ };
336
+ isShared: {
337
+ readonly type: "boolean";
338
+ } & {
339
+ readonly default: false;
340
+ };
341
+ overrides: {
342
+ readonly type: "json";
343
+ };
344
+ }, {
345
+ page: {
346
+ readonly kind: "belongsTo";
347
+ readonly target: "cms_pages";
348
+ readonly column: string;
349
+ };
350
+ block: {
351
+ readonly kind: "belongsTo";
352
+ readonly target: "cms_blocks";
353
+ readonly column: string;
354
+ };
355
+ }>;
356
+ cms_page_revisions: import("@pramen/server").EntityDef<{
357
+ id: {
358
+ readonly type: "uuid";
359
+ } & {
360
+ readonly generated: true;
361
+ } & {
362
+ readonly primaryKey: true;
363
+ readonly notNull: true;
364
+ };
365
+ pageId: {
366
+ readonly type: "uuid";
367
+ } & {
368
+ readonly notNull: true;
369
+ };
370
+ title: {
371
+ readonly type: "text";
372
+ };
373
+ status: {
374
+ readonly type: "text";
375
+ };
376
+ snapshot: {
377
+ readonly type: "json";
378
+ };
379
+ note: {
380
+ readonly type: "text";
381
+ };
382
+ actor: {
383
+ readonly type: "text";
384
+ };
385
+ createdAt: {
386
+ readonly type: "text";
387
+ } & {
388
+ readonly defaultExpr: string;
389
+ };
390
+ }, {
391
+ page: {
392
+ readonly kind: "belongsTo";
393
+ readonly target: "cms_pages";
394
+ readonly column: string;
395
+ };
396
+ }>;
397
+ cms_audit: import("@pramen/server").EntityDef<{
398
+ id: {
399
+ readonly type: "uuid";
400
+ } & {
401
+ readonly generated: true;
402
+ } & {
403
+ readonly primaryKey: true;
404
+ readonly notNull: true;
405
+ };
406
+ pageId: {
407
+ readonly type: "uuid";
408
+ } & {
409
+ readonly index: true;
410
+ };
411
+ action: {
412
+ readonly type: "text";
413
+ } & {
414
+ readonly notNull: true;
415
+ };
416
+ fromStatus: {
417
+ readonly type: "text";
418
+ };
419
+ toStatus: {
420
+ readonly type: "text";
421
+ };
422
+ actor: {
423
+ readonly type: "text";
424
+ };
425
+ note: {
426
+ readonly type: "text";
427
+ };
428
+ createdAt: {
429
+ readonly type: "text";
430
+ } & {
431
+ readonly defaultExpr: string;
432
+ };
433
+ }, Record<string, never>>;
434
+ cms_media: import("@pramen/server").EntityDef<{
435
+ id: {
436
+ readonly type: "uuid";
437
+ } & {
438
+ readonly generated: true;
439
+ } & {
440
+ readonly primaryKey: true;
441
+ readonly notNull: true;
442
+ };
443
+ file: {
444
+ readonly type: "fileRef";
445
+ };
446
+ alt: {
447
+ readonly type: "text";
448
+ };
449
+ createdAt: {
450
+ readonly type: "text";
451
+ } & {
452
+ readonly defaultExpr: string;
453
+ };
454
+ }, Record<string, never>>;
455
+ };
456
+ export interface ValidateOpts {
457
+ /** Enforce `required` fields (reject when missing). Default true. Editor-facing draft
458
+ * writes (addBlock/updateBlock/createPage) pass `false` — a DRAFT block may be incomplete;
459
+ * required is only mandatory when publishing. Type checks always run. */
460
+ requireRequired?: boolean;
461
+ }
462
+ /** Validate a block/page's `fields` payload against a field schema, throwing a 400 on
463
+ * the first violation. Recursive (repeater/group). Lenient on unknown field types. */
464
+ export declare function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path?: string, opts?: ValidateOpts): void;
465
+ export interface RenderedBlock {
466
+ /** The placement id (cms_page_blocks) — stable per position; used for reorder/remove. */
467
+ id: string;
468
+ /** The underlying block instance id (cms_blocks) — used to edit the block's content. */
469
+ block_id: string;
470
+ block_type: string;
471
+ title: string | null;
472
+ fields: Record<string, unknown>;
473
+ is_shared: boolean;
474
+ }
475
+ export interface PageTranslation {
476
+ locale: string;
477
+ slug: string;
478
+ }
479
+ export interface PageSeo {
480
+ metaTitle: string | null;
481
+ metaDescription: string | null;
482
+ canonicalUrl: string | null;
483
+ robots: string | null;
484
+ ogTitle: string | null;
485
+ ogDescription: string | null;
486
+ ogImage: ResolvedMedia | null;
487
+ structuredData: unknown | null;
488
+ }
489
+ export interface AssembledPage {
490
+ page: {
491
+ id: string;
492
+ title: string;
493
+ slug: string;
494
+ status: string;
495
+ locale: string;
496
+ translationGroupId: string | null;
497
+ /** Published sibling locales of this page (for hreflang alternates). */
498
+ translations: PageTranslation[];
499
+ fields: Record<string, unknown> | null;
500
+ /** Back-compat: mirrors seo.metaTitle/metaDescription. */
501
+ metaTitle: string | null;
502
+ metaDescription: string | null;
503
+ seo: PageSeo;
504
+ };
505
+ regions: Record<string, RenderedBlock[]>;
506
+ }
507
+ /** A `"media"` block field, resolved from a stored media id to a servable shape at
508
+ * assemble time. `url` is the raw (full-size) serving path; pass `key` to `imageUrl()`
509
+ * for on-the-fly transforms. `null` when the referenced media was deleted. */
510
+ export interface ResolvedMedia {
511
+ id: string;
512
+ key: string;
513
+ url: string;
514
+ alt: string | null;
515
+ contentType: string | null;
516
+ filename: string | null;
517
+ }
518
+ /** The public serving path for a media blob (relative; the client resolves it against
519
+ * its base). Served by the Worker's public `GET /media/<key>` route. */
520
+ export declare function mediaPath(key: string): string;
521
+ /** Build a URL for a media blob, optionally with Cloudflare Image Resizing transforms
522
+ * (`/cdn-cgi/image/<opts>/…`). With no transform opts it's just `mediaPath` (optionally
523
+ * prefixed by `origin`). Transforms need the deploy's origin to resolve the source path. */
524
+ export declare function imageUrl(key: string, opts?: {
525
+ origin?: string;
526
+ width?: number;
527
+ height?: number;
528
+ quality?: number;
529
+ format?: "auto" | "webp" | "avif";
530
+ }): string;
531
+ export interface CmsHandlerOpts {
532
+ /** Roles permitted to call the editor mutations (also enforced by the ACL). Default
533
+ * `["editor", "admin"]`. */
534
+ editorRoles?: readonly string[];
535
+ /** Max accepted media upload size in bytes (enforced at the Worker). Default 25 MB. */
536
+ mediaMaxSize?: number;
537
+ /** Default locale used when `getPage`/`createPage` omit one. Default `"en"`. */
538
+ defaultLocale?: string;
539
+ /** Roles permitted to approve/reject a page in review and publish (the editorial gate).
540
+ * Default `["reviewer", "admin"]`. */
541
+ reviewerRoles?: readonly string[];
542
+ }
543
+ /** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
544
+ * gated both by `auth` (fast 403 before the body) and by the row ACL (cmsPolicies). */
545
+ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
546
+ listBlockTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
547
+ createBlockType: import("@pramen/server").Handler<{
548
+ name: string;
549
+ slug: string;
550
+ fieldsSchema?: FieldDefinition[];
551
+ icon?: string;
552
+ category?: string;
553
+ description?: string;
554
+ }, Record<string, unknown>>;
555
+ createContentType: import("@pramen/server").Handler<{
556
+ name: string;
557
+ slug: string;
558
+ regions: RegionDefinition[];
559
+ fieldsSchema?: FieldDefinition[];
560
+ defaultBlocks?: DefaultBlockDefinition[];
561
+ }, Record<string, unknown>>;
562
+ /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
563
+ * prefix so the public /media route can serve it). The client PUTs the bytes to
564
+ * `url`, then calls `createMedia` with the returned `ref`. */
565
+ signMediaUpload: import("@pramen/server").Handler<{
566
+ contentType: string;
567
+ filename?: string;
568
+ }, {
569
+ url: string;
570
+ ref: FileRef;
571
+ }>;
572
+ /** Confirm an uploaded blob is really in storage (capturing its true size) and
573
+ * persist a `cms_media` row. Mirrors the notes attach flow. */
574
+ createMedia: import("@pramen/server").Handler<{
575
+ ref: FileRef;
576
+ alt?: string;
577
+ }, Record<string, unknown>>;
578
+ listMedia: import("@pramen/server").Handler<{
579
+ limit?: number;
580
+ offset?: number;
581
+ }, Record<string, unknown>[]>;
582
+ getMedia: import("@pramen/server").Handler<{
583
+ id: string;
584
+ }, Record<string, unknown>>;
585
+ /** Edit a media asset's metadata (currently just `alt` text). Editor-gated. */
586
+ updateMedia: import("@pramen/server").Handler<{
587
+ id: string;
588
+ alt: string | null;
589
+ }, Record<string, unknown>>;
590
+ /** Delete a media row AND its R2 blob. (Automatic orphan sweeping — media no longer
591
+ * referenced by any block — is future work; refs live inside opaque block JSON.) */
592
+ deleteMedia: import("@pramen/server").Handler<{
593
+ id: string;
594
+ }, {
595
+ ok: boolean;
596
+ }>;
597
+ listContentTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
598
+ getContentType: import("@pramen/server").Handler<{
599
+ id: string;
600
+ }, Record<string, unknown>>;
601
+ listPages: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
602
+ /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
603
+ * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
604
+ listPublishedPages: import("@pramen/server").Handler<unknown, {
605
+ slug: string;
606
+ locale: string;
607
+ updatedAt: string;
608
+ }[]>;
609
+ /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
610
+ updatePageSeo: import("@pramen/server").Handler<{
611
+ pageId: string;
612
+ }, {
613
+ ok: boolean;
614
+ page: Record<string, unknown>;
615
+ }>;
616
+ /** Create a page and auto-scaffold its content type's default blocks. */
617
+ createPage: import("@pramen/server").Handler<{
618
+ typeId: string;
619
+ title: string;
620
+ slug: string;
621
+ locale?: string;
622
+ fields?: Record<string, unknown>;
623
+ }, Record<string, unknown>>;
624
+ /** Create a translation of an existing page: a new page in `locale` sharing the
625
+ * source's translationGroupId (and content type). Content starts empty — the editor
626
+ * fills in the translated blocks. Slug defaults to the source's (allowed in a new locale). */
627
+ createTranslation: import("@pramen/server").Handler<{
628
+ pageId: string;
629
+ locale: string;
630
+ title?: string;
631
+ slug?: string;
632
+ }, Record<string, unknown>>;
633
+ /** List all locales of a page (the translation group), including the page itself. */
634
+ listTranslations: import("@pramen/server").Handler<{
635
+ pageId: string;
636
+ }, {
637
+ id: string;
638
+ locale: string;
639
+ slug: string;
640
+ title: string;
641
+ status: string;
642
+ }[]>;
643
+ /** Distinct locales present across all pages. */
644
+ listLocales: import("@pramen/server").Handler<unknown, string[]>;
645
+ /** Create a block instance and place it into a page region in one call (the common
646
+ * editor action). Validates the fields against the block type's schema and the region
647
+ * against the content type's allow-list. */
648
+ addBlock: import("@pramen/server").Handler<{
649
+ pageId: string;
650
+ blockTypeSlug: string;
651
+ region: string;
652
+ fields?: Record<string, unknown>;
653
+ title?: string;
654
+ position?: number;
655
+ isReusable?: boolean;
656
+ }, {
657
+ block: Record<string, unknown>;
658
+ placement: Record<string, unknown>;
659
+ }>;
660
+ /** Place an EXISTING (typically reusable) block into a page region as a SHARED
661
+ * placement, with optional per-placement `overrides` merged over the block's fields at
662
+ * read time. This is the "edit once, appear on many pages" workflow — the same block id
663
+ * can be placed on several pages; editing it updates them all, while `overrides` let one
664
+ * placement diverge. The merged (base + overrides) result is validated against the
665
+ * block type's field schema. */
666
+ placeBlock: import("@pramen/server").Handler<{
667
+ pageId: string;
668
+ blockId: string;
669
+ region: string;
670
+ position?: number;
671
+ overrides?: Record<string, unknown>;
672
+ }, Record<string, unknown>>;
673
+ /** Fetch a block's RAW content (media fields as ids, not resolved) — for editing. */
674
+ getBlock: import("@pramen/server").Handler<{
675
+ blockId: string;
676
+ }, Record<string, unknown>>;
677
+ /** Update a block's content (re-validated against its type's field schema). */
678
+ updateBlock: import("@pramen/server").Handler<{
679
+ blockId: string;
680
+ fields?: Record<string, unknown>;
681
+ title?: string;
682
+ }, Record<string, unknown> | undefined>;
683
+ /** Reorder a region: `order` is the page_block ids in their new order. It must cover
684
+ * EXACTLY the region's current placements (same set, no dups) — otherwise a partial or
685
+ * stale list would leave untouched placements colliding at a shared position. */
686
+ reorderRegion: import("@pramen/server").Handler<{
687
+ pageId: string;
688
+ region: string;
689
+ order: string[];
690
+ }, {
691
+ ok: boolean;
692
+ count: number;
693
+ }>;
694
+ /** Remove a placement. A reusable/shared block stays in the library (it may be placed
695
+ * elsewhere); a non-reusable block with no remaining placements is deleted too, so
696
+ * add/remove churn doesn't accumulate unreachable block rows. */
697
+ removeBlock: import("@pramen/server").Handler<{
698
+ pageBlockId: string;
699
+ }, {
700
+ ok: boolean;
701
+ }>;
702
+ /** Move a draft (or rejected) page into review. Editor-gated. */
703
+ submitForReview: import("@pramen/server").Handler<{
704
+ pageId: string;
705
+ note?: string;
706
+ }, {
707
+ ok: boolean;
708
+ page: Record<string, unknown> | undefined;
709
+ }>;
710
+ /** Approve a page in review → publish it (snapshot + currentRevisionId). Reviewer-gated. */
711
+ approve: import("@pramen/server").Handler<{
712
+ pageId: string;
713
+ note?: string;
714
+ }, {
715
+ ok: boolean;
716
+ page: Record<string, unknown> | undefined;
717
+ }>;
718
+ /** Reject a page in review → back to draft. Reviewer-gated. */
719
+ reject: import("@pramen/server").Handler<{
720
+ pageId: string;
721
+ note?: string;
722
+ }, {
723
+ ok: boolean;
724
+ page: Record<string, unknown> | undefined;
725
+ }>;
726
+ /** The workflow audit trail for a page (most recent first). Handler-gated to editors;
727
+ * reads the append-only log via `exec` (a plain admin-scoped read of a gated log). */
728
+ listPageAudit: import("@pramen/server").Handler<{
729
+ pageId: string;
730
+ limit?: number;
731
+ }, Record<string, unknown>[]>;
732
+ /** Publish a page directly: snapshot the assembled page into a revision and flip
733
+ * status to `published` (records an audit entry). The public content API serves the
734
+ * snapshot. `approve` is the review-gated path to the same outcome. */
735
+ publishPage: import("@pramen/server").Handler<{
736
+ pageId: string;
737
+ note?: string;
738
+ }, {
739
+ ok: boolean;
740
+ page: Record<string, unknown> | undefined;
741
+ }>;
742
+ unpublishPage: import("@pramen/server").Handler<{
743
+ pageId: string;
744
+ }, {
745
+ ok: boolean;
746
+ page: Record<string, unknown>;
747
+ }>;
748
+ /** Schedule a page to publish at `publishAt` (epoch ms), and optionally unpublish at
749
+ * `unpublishAt`. Enqueues delayed outbox tasks (atomic with this write).
750
+ *
751
+ * Outbox tasks can't be recalled, so cancellation/rescheduling is handled by INTENT
752
+ * TOKENS: the page stores the scheduled times (`scheduledAt`/`unpublishAt`, ISO), and
753
+ * each task carries the token it was enqueued for. At fire time the task acts ONLY if
754
+ * its token still equals the page's current token — so rescheduling (new token),
755
+ * manual publish/unpublish (token cleared), and duplicate deliveries all make a stale
756
+ * task a no-op. `unpublishAt` must be after `publishAt`. */
757
+ schedulePage: import("@pramen/server").Handler<{
758
+ pageId: string;
759
+ publishAt: number;
760
+ unpublishAt?: number;
761
+ }, {
762
+ ok: boolean;
763
+ publishInMs: number;
764
+ }>;
765
+ /** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
766
+ * snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
767
+ * `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
768
+ * to the configured default locale; a slug is unique per locale. */
769
+ getPage: import("@pramen/server").Handler<{
770
+ slug: string;
771
+ locale?: string;
772
+ preview?: boolean;
773
+ }, AssembledPage>;
774
+ };
775
+ /** The default CMS handlers (editor roles `["editor", "admin"]`). */
776
+ export declare const cmsHandlers: {
777
+ listBlockTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
778
+ createBlockType: import("@pramen/server").Handler<{
779
+ name: string;
780
+ slug: string;
781
+ fieldsSchema?: FieldDefinition[];
782
+ icon?: string;
783
+ category?: string;
784
+ description?: string;
785
+ }, Record<string, unknown>>;
786
+ createContentType: import("@pramen/server").Handler<{
787
+ name: string;
788
+ slug: string;
789
+ regions: RegionDefinition[];
790
+ fieldsSchema?: FieldDefinition[];
791
+ defaultBlocks?: DefaultBlockDefinition[];
792
+ }, Record<string, unknown>>;
793
+ /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
794
+ * prefix so the public /media route can serve it). The client PUTs the bytes to
795
+ * `url`, then calls `createMedia` with the returned `ref`. */
796
+ signMediaUpload: import("@pramen/server").Handler<{
797
+ contentType: string;
798
+ filename?: string;
799
+ }, {
800
+ url: string;
801
+ ref: FileRef;
802
+ }>;
803
+ /** Confirm an uploaded blob is really in storage (capturing its true size) and
804
+ * persist a `cms_media` row. Mirrors the notes attach flow. */
805
+ createMedia: import("@pramen/server").Handler<{
806
+ ref: FileRef;
807
+ alt?: string;
808
+ }, Record<string, unknown>>;
809
+ listMedia: import("@pramen/server").Handler<{
810
+ limit?: number;
811
+ offset?: number;
812
+ }, Record<string, unknown>[]>;
813
+ getMedia: import("@pramen/server").Handler<{
814
+ id: string;
815
+ }, Record<string, unknown>>;
816
+ /** Edit a media asset's metadata (currently just `alt` text). Editor-gated. */
817
+ updateMedia: import("@pramen/server").Handler<{
818
+ id: string;
819
+ alt: string | null;
820
+ }, Record<string, unknown>>;
821
+ /** Delete a media row AND its R2 blob. (Automatic orphan sweeping — media no longer
822
+ * referenced by any block — is future work; refs live inside opaque block JSON.) */
823
+ deleteMedia: import("@pramen/server").Handler<{
824
+ id: string;
825
+ }, {
826
+ ok: boolean;
827
+ }>;
828
+ listContentTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
829
+ getContentType: import("@pramen/server").Handler<{
830
+ id: string;
831
+ }, Record<string, unknown>>;
832
+ listPages: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
833
+ /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
834
+ * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
835
+ listPublishedPages: import("@pramen/server").Handler<unknown, {
836
+ slug: string;
837
+ locale: string;
838
+ updatedAt: string;
839
+ }[]>;
840
+ /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
841
+ updatePageSeo: import("@pramen/server").Handler<{
842
+ pageId: string;
843
+ }, {
844
+ ok: boolean;
845
+ page: Record<string, unknown>;
846
+ }>;
847
+ /** Create a page and auto-scaffold its content type's default blocks. */
848
+ createPage: import("@pramen/server").Handler<{
849
+ typeId: string;
850
+ title: string;
851
+ slug: string;
852
+ locale?: string;
853
+ fields?: Record<string, unknown>;
854
+ }, Record<string, unknown>>;
855
+ /** Create a translation of an existing page: a new page in `locale` sharing the
856
+ * source's translationGroupId (and content type). Content starts empty — the editor
857
+ * fills in the translated blocks. Slug defaults to the source's (allowed in a new locale). */
858
+ createTranslation: import("@pramen/server").Handler<{
859
+ pageId: string;
860
+ locale: string;
861
+ title?: string;
862
+ slug?: string;
863
+ }, Record<string, unknown>>;
864
+ /** List all locales of a page (the translation group), including the page itself. */
865
+ listTranslations: import("@pramen/server").Handler<{
866
+ pageId: string;
867
+ }, {
868
+ id: string;
869
+ locale: string;
870
+ slug: string;
871
+ title: string;
872
+ status: string;
873
+ }[]>;
874
+ /** Distinct locales present across all pages. */
875
+ listLocales: import("@pramen/server").Handler<unknown, string[]>;
876
+ /** Create a block instance and place it into a page region in one call (the common
877
+ * editor action). Validates the fields against the block type's schema and the region
878
+ * against the content type's allow-list. */
879
+ addBlock: import("@pramen/server").Handler<{
880
+ pageId: string;
881
+ blockTypeSlug: string;
882
+ region: string;
883
+ fields?: Record<string, unknown>;
884
+ title?: string;
885
+ position?: number;
886
+ isReusable?: boolean;
887
+ }, {
888
+ block: Record<string, unknown>;
889
+ placement: Record<string, unknown>;
890
+ }>;
891
+ /** Place an EXISTING (typically reusable) block into a page region as a SHARED
892
+ * placement, with optional per-placement `overrides` merged over the block's fields at
893
+ * read time. This is the "edit once, appear on many pages" workflow — the same block id
894
+ * can be placed on several pages; editing it updates them all, while `overrides` let one
895
+ * placement diverge. The merged (base + overrides) result is validated against the
896
+ * block type's field schema. */
897
+ placeBlock: import("@pramen/server").Handler<{
898
+ pageId: string;
899
+ blockId: string;
900
+ region: string;
901
+ position?: number;
902
+ overrides?: Record<string, unknown>;
903
+ }, Record<string, unknown>>;
904
+ /** Fetch a block's RAW content (media fields as ids, not resolved) — for editing. */
905
+ getBlock: import("@pramen/server").Handler<{
906
+ blockId: string;
907
+ }, Record<string, unknown>>;
908
+ /** Update a block's content (re-validated against its type's field schema). */
909
+ updateBlock: import("@pramen/server").Handler<{
910
+ blockId: string;
911
+ fields?: Record<string, unknown>;
912
+ title?: string;
913
+ }, Record<string, unknown> | undefined>;
914
+ /** Reorder a region: `order` is the page_block ids in their new order. It must cover
915
+ * EXACTLY the region's current placements (same set, no dups) — otherwise a partial or
916
+ * stale list would leave untouched placements colliding at a shared position. */
917
+ reorderRegion: import("@pramen/server").Handler<{
918
+ pageId: string;
919
+ region: string;
920
+ order: string[];
921
+ }, {
922
+ ok: boolean;
923
+ count: number;
924
+ }>;
925
+ /** Remove a placement. A reusable/shared block stays in the library (it may be placed
926
+ * elsewhere); a non-reusable block with no remaining placements is deleted too, so
927
+ * add/remove churn doesn't accumulate unreachable block rows. */
928
+ removeBlock: import("@pramen/server").Handler<{
929
+ pageBlockId: string;
930
+ }, {
931
+ ok: boolean;
932
+ }>;
933
+ /** Move a draft (or rejected) page into review. Editor-gated. */
934
+ submitForReview: import("@pramen/server").Handler<{
935
+ pageId: string;
936
+ note?: string;
937
+ }, {
938
+ ok: boolean;
939
+ page: Record<string, unknown> | undefined;
940
+ }>;
941
+ /** Approve a page in review → publish it (snapshot + currentRevisionId). Reviewer-gated. */
942
+ approve: import("@pramen/server").Handler<{
943
+ pageId: string;
944
+ note?: string;
945
+ }, {
946
+ ok: boolean;
947
+ page: Record<string, unknown> | undefined;
948
+ }>;
949
+ /** Reject a page in review → back to draft. Reviewer-gated. */
950
+ reject: import("@pramen/server").Handler<{
951
+ pageId: string;
952
+ note?: string;
953
+ }, {
954
+ ok: boolean;
955
+ page: Record<string, unknown> | undefined;
956
+ }>;
957
+ /** The workflow audit trail for a page (most recent first). Handler-gated to editors;
958
+ * reads the append-only log via `exec` (a plain admin-scoped read of a gated log). */
959
+ listPageAudit: import("@pramen/server").Handler<{
960
+ pageId: string;
961
+ limit?: number;
962
+ }, Record<string, unknown>[]>;
963
+ /** Publish a page directly: snapshot the assembled page into a revision and flip
964
+ * status to `published` (records an audit entry). The public content API serves the
965
+ * snapshot. `approve` is the review-gated path to the same outcome. */
966
+ publishPage: import("@pramen/server").Handler<{
967
+ pageId: string;
968
+ note?: string;
969
+ }, {
970
+ ok: boolean;
971
+ page: Record<string, unknown> | undefined;
972
+ }>;
973
+ unpublishPage: import("@pramen/server").Handler<{
974
+ pageId: string;
975
+ }, {
976
+ ok: boolean;
977
+ page: Record<string, unknown>;
978
+ }>;
979
+ /** Schedule a page to publish at `publishAt` (epoch ms), and optionally unpublish at
980
+ * `unpublishAt`. Enqueues delayed outbox tasks (atomic with this write).
981
+ *
982
+ * Outbox tasks can't be recalled, so cancellation/rescheduling is handled by INTENT
983
+ * TOKENS: the page stores the scheduled times (`scheduledAt`/`unpublishAt`, ISO), and
984
+ * each task carries the token it was enqueued for. At fire time the task acts ONLY if
985
+ * its token still equals the page's current token — so rescheduling (new token),
986
+ * manual publish/unpublish (token cleared), and duplicate deliveries all make a stale
987
+ * task a no-op. `unpublishAt` must be after `publishAt`. */
988
+ schedulePage: import("@pramen/server").Handler<{
989
+ pageId: string;
990
+ publishAt: number;
991
+ unpublishAt?: number;
992
+ }, {
993
+ ok: boolean;
994
+ publishInMs: number;
995
+ }>;
996
+ /** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
997
+ * snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
998
+ * `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
999
+ * to the configured default locale; a slug is unique per locale. */
1000
+ getPage: import("@pramen/server").Handler<{
1001
+ slug: string;
1002
+ locale?: string;
1003
+ preview?: boolean;
1004
+ }, AssembledPage>;
1005
+ };
1006
+ export interface CmsPolicyOpts {
1007
+ /** Prefix for policy names (unique across roles). Default `cms`. */
1008
+ prefix?: string;
1009
+ }
1010
+ /** ACL fragments. Spread `public` into your anonymous role and `editor` into your
1011
+ * editor/admin role:
1012
+ *
1013
+ * role("anonymous", [...cmsPolicies().public])
1014
+ * role("editor", [...cmsPolicies().editor])
1015
+ *
1016
+ * `public` grants read of PUBLISHED pages + their revision snapshots only (the public
1017
+ * content API reads the snapshot, so unpublished block rows are never exposed).
1018
+ * `editor` grants full CRUD across every cms_ table. */
1019
+ export declare function cmsPolicies(opts?: CmsPolicyOpts): {
1020
+ public: Policy[];
1021
+ editor: Policy[];
1022
+ };
1023
+ /** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
1024
+ * They run with a privileged, system-scoped ctx off the write path (the outbox drain).
1025
+ *
1026
+ * Each task validates its INTENT TOKEN against the page's current `scheduledAt`/`unpublishAt`
1027
+ * (set by `schedulePage`, cleared/overwritten by a manual publish/unpublish or a reschedule).
1028
+ * A task whose token no longer matches is a no-op — that's how a superseded/cancelled
1029
+ * schedule, and an at-least-once duplicate delivery, are neutralized (outbox tasks can't be
1030
+ * recalled). NOTE on atomicity: unlike the interactive `publishPage` (one mutation
1031
+ * transaction), the drain runs a handler WITHOUT a surrounding transaction, so a crash
1032
+ * between the revision insert and the page update leaves the page unpublished with an orphan
1033
+ * revision until the next at-least-once redelivery re-runs (the token still matches, so it
1034
+ * completes). Acceptable for a scheduled job; the interactive path is atomic. */
1035
+ export declare const cmsTasks: {
1036
+ "cms:publish": (ctx: HandlerContext, payload: unknown) => Promise<void>;
1037
+ "cms:unpublish": (ctx: HandlerContext, payload: unknown) => Promise<void>;
1038
+ };
1039
+ export interface SitemapEntry {
1040
+ slug: string;
1041
+ locale: string;
1042
+ updatedAt?: string;
1043
+ }
1044
+ export interface SitemapOpts {
1045
+ origin: string;
1046
+ /** Map an entry → its absolute URL. Default `${origin}/${locale}/${slug}`. */
1047
+ pageUrl?: (e: SitemapEntry, origin: string) => string;
1048
+ }
1049
+ /** Build a sitemap.xml body from published-page entries. */
1050
+ export declare function sitemapXml(entries: SitemapEntry[], opts: SitemapOpts): string;
1051
+ /** Build a robots.txt body pointing at the sitemap. */
1052
+ export declare function robotsTxt(opts: {
1053
+ origin: string;
1054
+ disallow?: string[];
1055
+ }): string;
1056
+ interface RouteCtx {
1057
+ callPrivileged: (opts: {
1058
+ name: string;
1059
+ input?: unknown;
1060
+ tenant?: string;
1061
+ roles?: string[];
1062
+ }) => Promise<Response>;
1063
+ }
1064
+ interface CmsRoute {
1065
+ method: string;
1066
+ path: string;
1067
+ handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteCtx) => Promise<Response>;
1068
+ }
1069
+ /** Turnkey public routes for `GET /sitemap.xml` and `GET /robots.txt`. Spread into
1070
+ * `app.routes`. The sitemap pulls published pages via `callPrivileged(listPublishedPages)`.
1071
+ * `origin` defaults to the request's origin; `pageUrl` customizes the URL shape. */
1072
+ export declare function cmsRoutes(opts?: {
1073
+ origin?: string;
1074
+ tenant?: string;
1075
+ pageUrl?: SitemapOpts["pageUrl"];
1076
+ disallow?: string[];
1077
+ }): CmsRoute[];
1078
+ export {};