@pramen/cms 0.0.14 → 0.0.16

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,1076 @@
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" | "date" | "datetime" | "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" | "date" | "datetime" ? 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
+ export declare function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path?: string, opts?: ValidateOpts): void;
463
+ export interface RenderedBlock {
464
+ /** The placement id (cms_page_blocks) — stable per position; used for reorder/remove. */
465
+ id: string;
466
+ /** The underlying block instance id (cms_blocks) — used to edit the block's content. */
467
+ block_id: string;
468
+ block_type: string;
469
+ title: string | null;
470
+ fields: Record<string, unknown>;
471
+ is_shared: boolean;
472
+ }
473
+ export interface PageTranslation {
474
+ locale: string;
475
+ slug: string;
476
+ }
477
+ export interface PageSeo {
478
+ metaTitle: string | null;
479
+ metaDescription: string | null;
480
+ canonicalUrl: string | null;
481
+ robots: string | null;
482
+ ogTitle: string | null;
483
+ ogDescription: string | null;
484
+ ogImage: ResolvedMedia | null;
485
+ structuredData: unknown | null;
486
+ }
487
+ export interface AssembledPage {
488
+ page: {
489
+ id: string;
490
+ title: string;
491
+ slug: string;
492
+ status: string;
493
+ locale: string;
494
+ translationGroupId: string | null;
495
+ /** Published sibling locales of this page (for hreflang alternates). */
496
+ translations: PageTranslation[];
497
+ fields: Record<string, unknown> | null;
498
+ /** Back-compat: mirrors seo.metaTitle/metaDescription. */
499
+ metaTitle: string | null;
500
+ metaDescription: string | null;
501
+ seo: PageSeo;
502
+ };
503
+ regions: Record<string, RenderedBlock[]>;
504
+ }
505
+ /** A `"media"` block field, resolved from a stored media id to a servable shape at
506
+ * assemble time. `url` is the raw (full-size) serving path; pass `key` to `imageUrl()`
507
+ * for on-the-fly transforms. `null` when the referenced media was deleted. */
508
+ export interface ResolvedMedia {
509
+ id: string;
510
+ key: string;
511
+ url: string;
512
+ alt: string | null;
513
+ contentType: string | null;
514
+ filename: string | null;
515
+ }
516
+ /** The public serving path for a media blob (relative; the client resolves it against
517
+ * its base). Served by the Worker's public `GET /media/<key>` route. */
518
+ export declare function mediaPath(key: string): string;
519
+ /** Build a URL for a media blob, optionally with Cloudflare Image Resizing transforms
520
+ * (`/cdn-cgi/image/<opts>/…`). With no transform opts it's just `mediaPath` (optionally
521
+ * prefixed by `origin`). Transforms need the deploy's origin to resolve the source path. */
522
+ export declare function imageUrl(key: string, opts?: {
523
+ origin?: string;
524
+ width?: number;
525
+ height?: number;
526
+ quality?: number;
527
+ format?: "auto" | "webp" | "avif";
528
+ }): string;
529
+ export interface CmsHandlerOpts {
530
+ /** Roles permitted to call the editor mutations (also enforced by the ACL). Default
531
+ * `["editor", "admin"]`. */
532
+ editorRoles?: readonly string[];
533
+ /** Max accepted media upload size in bytes (enforced at the Worker). Default 25 MB. */
534
+ mediaMaxSize?: number;
535
+ /** Default locale used when `getPage`/`createPage` omit one. Default `"en"`. */
536
+ defaultLocale?: string;
537
+ /** Roles permitted to approve/reject a page in review and publish (the editorial gate).
538
+ * Default `["reviewer", "admin"]`. */
539
+ reviewerRoles?: readonly string[];
540
+ }
541
+ /** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
542
+ * gated both by `auth` (fast 403 before the body) and by the row ACL (cmsPolicies). */
543
+ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
544
+ listBlockTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
545
+ createBlockType: import("@pramen/server").Handler<{
546
+ name: string;
547
+ slug: string;
548
+ fieldsSchema?: FieldDefinition[];
549
+ icon?: string;
550
+ category?: string;
551
+ description?: string;
552
+ }, Record<string, unknown>>;
553
+ createContentType: import("@pramen/server").Handler<{
554
+ name: string;
555
+ slug: string;
556
+ regions: RegionDefinition[];
557
+ fieldsSchema?: FieldDefinition[];
558
+ defaultBlocks?: DefaultBlockDefinition[];
559
+ }, Record<string, unknown>>;
560
+ /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
561
+ * prefix so the public /media route can serve it). The client PUTs the bytes to
562
+ * `url`, then calls `createMedia` with the returned `ref`. */
563
+ signMediaUpload: import("@pramen/server").Handler<{
564
+ contentType: string;
565
+ filename?: string;
566
+ }, {
567
+ url: string;
568
+ ref: FileRef;
569
+ }>;
570
+ /** Confirm an uploaded blob is really in storage (capturing its true size) and
571
+ * persist a `cms_media` row. Mirrors the notes attach flow. */
572
+ createMedia: import("@pramen/server").Handler<{
573
+ ref: FileRef;
574
+ alt?: string;
575
+ }, Record<string, unknown>>;
576
+ listMedia: import("@pramen/server").Handler<{
577
+ limit?: number;
578
+ offset?: number;
579
+ }, Record<string, unknown>[]>;
580
+ getMedia: import("@pramen/server").Handler<{
581
+ id: string;
582
+ }, Record<string, unknown>>;
583
+ /** Edit a media asset's metadata (currently just `alt` text). Editor-gated. */
584
+ updateMedia: import("@pramen/server").Handler<{
585
+ id: string;
586
+ alt: string | null;
587
+ }, Record<string, unknown>>;
588
+ /** Delete a media row AND its R2 blob. (Automatic orphan sweeping — media no longer
589
+ * referenced by any block — is future work; refs live inside opaque block JSON.) */
590
+ deleteMedia: import("@pramen/server").Handler<{
591
+ id: string;
592
+ }, {
593
+ ok: boolean;
594
+ }>;
595
+ listContentTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
596
+ getContentType: import("@pramen/server").Handler<{
597
+ id: string;
598
+ }, Record<string, unknown>>;
599
+ listPages: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
600
+ /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
601
+ * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
602
+ listPublishedPages: import("@pramen/server").Handler<unknown, {
603
+ slug: string;
604
+ locale: string;
605
+ updatedAt: string;
606
+ }[]>;
607
+ /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
608
+ updatePageSeo: import("@pramen/server").Handler<{
609
+ pageId: string;
610
+ }, {
611
+ ok: boolean;
612
+ page: Record<string, unknown>;
613
+ }>;
614
+ /** Create a page and auto-scaffold its content type's default blocks. */
615
+ createPage: import("@pramen/server").Handler<{
616
+ typeId: string;
617
+ title: string;
618
+ slug: string;
619
+ locale?: string;
620
+ fields?: Record<string, unknown>;
621
+ }, Record<string, unknown>>;
622
+ /** Create a translation of an existing page: a new page in `locale` sharing the
623
+ * source's translationGroupId (and content type). Content starts empty — the editor
624
+ * fills in the translated blocks. Slug defaults to the source's (allowed in a new locale). */
625
+ createTranslation: import("@pramen/server").Handler<{
626
+ pageId: string;
627
+ locale: string;
628
+ title?: string;
629
+ slug?: string;
630
+ }, Record<string, unknown>>;
631
+ /** List all locales of a page (the translation group), including the page itself. */
632
+ listTranslations: import("@pramen/server").Handler<{
633
+ pageId: string;
634
+ }, {
635
+ id: string;
636
+ locale: string;
637
+ slug: string;
638
+ title: string;
639
+ status: string;
640
+ }[]>;
641
+ /** Distinct locales present across all pages. */
642
+ listLocales: import("@pramen/server").Handler<unknown, string[]>;
643
+ /** Create a block instance and place it into a page region in one call (the common
644
+ * editor action). Validates the fields against the block type's schema and the region
645
+ * against the content type's allow-list. */
646
+ addBlock: import("@pramen/server").Handler<{
647
+ pageId: string;
648
+ blockTypeSlug: string;
649
+ region: string;
650
+ fields?: Record<string, unknown>;
651
+ title?: string;
652
+ position?: number;
653
+ isReusable?: boolean;
654
+ }, {
655
+ block: Record<string, unknown>;
656
+ placement: Record<string, unknown>;
657
+ }>;
658
+ /** Place an EXISTING (typically reusable) block into a page region as a SHARED
659
+ * placement, with optional per-placement `overrides` merged over the block's fields at
660
+ * read time. This is the "edit once, appear on many pages" workflow — the same block id
661
+ * can be placed on several pages; editing it updates them all, while `overrides` let one
662
+ * placement diverge. The merged (base + overrides) result is validated against the
663
+ * block type's field schema. */
664
+ placeBlock: import("@pramen/server").Handler<{
665
+ pageId: string;
666
+ blockId: string;
667
+ region: string;
668
+ position?: number;
669
+ overrides?: Record<string, unknown>;
670
+ }, Record<string, unknown>>;
671
+ /** Fetch a block's RAW content (media fields as ids, not resolved) — for editing. */
672
+ getBlock: import("@pramen/server").Handler<{
673
+ blockId: string;
674
+ }, Record<string, unknown>>;
675
+ /** Update a block's content (re-validated against its type's field schema). */
676
+ updateBlock: import("@pramen/server").Handler<{
677
+ blockId: string;
678
+ fields?: Record<string, unknown>;
679
+ title?: string;
680
+ }, Record<string, unknown> | undefined>;
681
+ /** Reorder a region: `order` is the page_block ids in their new order. It must cover
682
+ * EXACTLY the region's current placements (same set, no dups) — otherwise a partial or
683
+ * stale list would leave untouched placements colliding at a shared position. */
684
+ reorderRegion: import("@pramen/server").Handler<{
685
+ pageId: string;
686
+ region: string;
687
+ order: string[];
688
+ }, {
689
+ ok: boolean;
690
+ count: number;
691
+ }>;
692
+ /** Remove a placement. A reusable/shared block stays in the library (it may be placed
693
+ * elsewhere); a non-reusable block with no remaining placements is deleted too, so
694
+ * add/remove churn doesn't accumulate unreachable block rows. */
695
+ removeBlock: import("@pramen/server").Handler<{
696
+ pageBlockId: string;
697
+ }, {
698
+ ok: boolean;
699
+ }>;
700
+ /** Move a draft (or rejected) page into review. Editor-gated. */
701
+ submitForReview: import("@pramen/server").Handler<{
702
+ pageId: string;
703
+ note?: string;
704
+ }, {
705
+ ok: boolean;
706
+ page: Record<string, unknown> | undefined;
707
+ }>;
708
+ /** Approve a page in review → publish it (snapshot + currentRevisionId). Reviewer-gated. */
709
+ approve: import("@pramen/server").Handler<{
710
+ pageId: string;
711
+ note?: string;
712
+ }, {
713
+ ok: boolean;
714
+ page: Record<string, unknown> | undefined;
715
+ }>;
716
+ /** Reject a page in review → back to draft. Reviewer-gated. */
717
+ reject: import("@pramen/server").Handler<{
718
+ pageId: string;
719
+ note?: string;
720
+ }, {
721
+ ok: boolean;
722
+ page: Record<string, unknown> | undefined;
723
+ }>;
724
+ /** The workflow audit trail for a page (most recent first). Handler-gated to editors;
725
+ * reads the append-only log via `exec` (a plain admin-scoped read of a gated log). */
726
+ listPageAudit: import("@pramen/server").Handler<{
727
+ pageId: string;
728
+ limit?: number;
729
+ }, Record<string, unknown>[]>;
730
+ /** Publish a page directly: snapshot the assembled page into a revision and flip
731
+ * status to `published` (records an audit entry). The public content API serves the
732
+ * snapshot. `approve` is the review-gated path to the same outcome. */
733
+ publishPage: import("@pramen/server").Handler<{
734
+ pageId: string;
735
+ note?: string;
736
+ }, {
737
+ ok: boolean;
738
+ page: Record<string, unknown> | undefined;
739
+ }>;
740
+ unpublishPage: import("@pramen/server").Handler<{
741
+ pageId: string;
742
+ }, {
743
+ ok: boolean;
744
+ page: Record<string, unknown>;
745
+ }>;
746
+ /** Schedule a page to publish at `publishAt` (epoch ms), and optionally unpublish at
747
+ * `unpublishAt`. Enqueues delayed outbox tasks (atomic with this write).
748
+ *
749
+ * Outbox tasks can't be recalled, so cancellation/rescheduling is handled by INTENT
750
+ * TOKENS: the page stores the scheduled times (`scheduledAt`/`unpublishAt`, ISO), and
751
+ * each task carries the token it was enqueued for. At fire time the task acts ONLY if
752
+ * its token still equals the page's current token — so rescheduling (new token),
753
+ * manual publish/unpublish (token cleared), and duplicate deliveries all make a stale
754
+ * task a no-op. `unpublishAt` must be after `publishAt`. */
755
+ schedulePage: import("@pramen/server").Handler<{
756
+ pageId: string;
757
+ publishAt: number;
758
+ unpublishAt?: number;
759
+ }, {
760
+ ok: boolean;
761
+ publishInMs: number;
762
+ }>;
763
+ /** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
764
+ * snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
765
+ * `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
766
+ * to the configured default locale; a slug is unique per locale. */
767
+ getPage: import("@pramen/server").Handler<{
768
+ slug: string;
769
+ locale?: string;
770
+ preview?: boolean;
771
+ }, AssembledPage>;
772
+ };
773
+ /** The default CMS handlers (editor roles `["editor", "admin"]`). */
774
+ export declare const cmsHandlers: {
775
+ listBlockTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
776
+ createBlockType: import("@pramen/server").Handler<{
777
+ name: string;
778
+ slug: string;
779
+ fieldsSchema?: FieldDefinition[];
780
+ icon?: string;
781
+ category?: string;
782
+ description?: string;
783
+ }, Record<string, unknown>>;
784
+ createContentType: import("@pramen/server").Handler<{
785
+ name: string;
786
+ slug: string;
787
+ regions: RegionDefinition[];
788
+ fieldsSchema?: FieldDefinition[];
789
+ defaultBlocks?: DefaultBlockDefinition[];
790
+ }, Record<string, unknown>>;
791
+ /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
792
+ * prefix so the public /media route can serve it). The client PUTs the bytes to
793
+ * `url`, then calls `createMedia` with the returned `ref`. */
794
+ signMediaUpload: import("@pramen/server").Handler<{
795
+ contentType: string;
796
+ filename?: string;
797
+ }, {
798
+ url: string;
799
+ ref: FileRef;
800
+ }>;
801
+ /** Confirm an uploaded blob is really in storage (capturing its true size) and
802
+ * persist a `cms_media` row. Mirrors the notes attach flow. */
803
+ createMedia: import("@pramen/server").Handler<{
804
+ ref: FileRef;
805
+ alt?: string;
806
+ }, Record<string, unknown>>;
807
+ listMedia: import("@pramen/server").Handler<{
808
+ limit?: number;
809
+ offset?: number;
810
+ }, Record<string, unknown>[]>;
811
+ getMedia: import("@pramen/server").Handler<{
812
+ id: string;
813
+ }, Record<string, unknown>>;
814
+ /** Edit a media asset's metadata (currently just `alt` text). Editor-gated. */
815
+ updateMedia: import("@pramen/server").Handler<{
816
+ id: string;
817
+ alt: string | null;
818
+ }, Record<string, unknown>>;
819
+ /** Delete a media row AND its R2 blob. (Automatic orphan sweeping — media no longer
820
+ * referenced by any block — is future work; refs live inside opaque block JSON.) */
821
+ deleteMedia: import("@pramen/server").Handler<{
822
+ id: string;
823
+ }, {
824
+ ok: boolean;
825
+ }>;
826
+ listContentTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
827
+ getContentType: import("@pramen/server").Handler<{
828
+ id: string;
829
+ }, Record<string, unknown>>;
830
+ listPages: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
831
+ /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
832
+ * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
833
+ listPublishedPages: import("@pramen/server").Handler<unknown, {
834
+ slug: string;
835
+ locale: string;
836
+ updatedAt: string;
837
+ }[]>;
838
+ /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
839
+ updatePageSeo: import("@pramen/server").Handler<{
840
+ pageId: string;
841
+ }, {
842
+ ok: boolean;
843
+ page: Record<string, unknown>;
844
+ }>;
845
+ /** Create a page and auto-scaffold its content type's default blocks. */
846
+ createPage: import("@pramen/server").Handler<{
847
+ typeId: string;
848
+ title: string;
849
+ slug: string;
850
+ locale?: string;
851
+ fields?: Record<string, unknown>;
852
+ }, Record<string, unknown>>;
853
+ /** Create a translation of an existing page: a new page in `locale` sharing the
854
+ * source's translationGroupId (and content type). Content starts empty — the editor
855
+ * fills in the translated blocks. Slug defaults to the source's (allowed in a new locale). */
856
+ createTranslation: import("@pramen/server").Handler<{
857
+ pageId: string;
858
+ locale: string;
859
+ title?: string;
860
+ slug?: string;
861
+ }, Record<string, unknown>>;
862
+ /** List all locales of a page (the translation group), including the page itself. */
863
+ listTranslations: import("@pramen/server").Handler<{
864
+ pageId: string;
865
+ }, {
866
+ id: string;
867
+ locale: string;
868
+ slug: string;
869
+ title: string;
870
+ status: string;
871
+ }[]>;
872
+ /** Distinct locales present across all pages. */
873
+ listLocales: import("@pramen/server").Handler<unknown, string[]>;
874
+ /** Create a block instance and place it into a page region in one call (the common
875
+ * editor action). Validates the fields against the block type's schema and the region
876
+ * against the content type's allow-list. */
877
+ addBlock: import("@pramen/server").Handler<{
878
+ pageId: string;
879
+ blockTypeSlug: string;
880
+ region: string;
881
+ fields?: Record<string, unknown>;
882
+ title?: string;
883
+ position?: number;
884
+ isReusable?: boolean;
885
+ }, {
886
+ block: Record<string, unknown>;
887
+ placement: Record<string, unknown>;
888
+ }>;
889
+ /** Place an EXISTING (typically reusable) block into a page region as a SHARED
890
+ * placement, with optional per-placement `overrides` merged over the block's fields at
891
+ * read time. This is the "edit once, appear on many pages" workflow — the same block id
892
+ * can be placed on several pages; editing it updates them all, while `overrides` let one
893
+ * placement diverge. The merged (base + overrides) result is validated against the
894
+ * block type's field schema. */
895
+ placeBlock: import("@pramen/server").Handler<{
896
+ pageId: string;
897
+ blockId: string;
898
+ region: string;
899
+ position?: number;
900
+ overrides?: Record<string, unknown>;
901
+ }, Record<string, unknown>>;
902
+ /** Fetch a block's RAW content (media fields as ids, not resolved) — for editing. */
903
+ getBlock: import("@pramen/server").Handler<{
904
+ blockId: string;
905
+ }, Record<string, unknown>>;
906
+ /** Update a block's content (re-validated against its type's field schema). */
907
+ updateBlock: import("@pramen/server").Handler<{
908
+ blockId: string;
909
+ fields?: Record<string, unknown>;
910
+ title?: string;
911
+ }, Record<string, unknown> | undefined>;
912
+ /** Reorder a region: `order` is the page_block ids in their new order. It must cover
913
+ * EXACTLY the region's current placements (same set, no dups) — otherwise a partial or
914
+ * stale list would leave untouched placements colliding at a shared position. */
915
+ reorderRegion: import("@pramen/server").Handler<{
916
+ pageId: string;
917
+ region: string;
918
+ order: string[];
919
+ }, {
920
+ ok: boolean;
921
+ count: number;
922
+ }>;
923
+ /** Remove a placement. A reusable/shared block stays in the library (it may be placed
924
+ * elsewhere); a non-reusable block with no remaining placements is deleted too, so
925
+ * add/remove churn doesn't accumulate unreachable block rows. */
926
+ removeBlock: import("@pramen/server").Handler<{
927
+ pageBlockId: string;
928
+ }, {
929
+ ok: boolean;
930
+ }>;
931
+ /** Move a draft (or rejected) page into review. Editor-gated. */
932
+ submitForReview: import("@pramen/server").Handler<{
933
+ pageId: string;
934
+ note?: string;
935
+ }, {
936
+ ok: boolean;
937
+ page: Record<string, unknown> | undefined;
938
+ }>;
939
+ /** Approve a page in review → publish it (snapshot + currentRevisionId). Reviewer-gated. */
940
+ approve: import("@pramen/server").Handler<{
941
+ pageId: string;
942
+ note?: string;
943
+ }, {
944
+ ok: boolean;
945
+ page: Record<string, unknown> | undefined;
946
+ }>;
947
+ /** Reject a page in review → back to draft. Reviewer-gated. */
948
+ reject: import("@pramen/server").Handler<{
949
+ pageId: string;
950
+ note?: string;
951
+ }, {
952
+ ok: boolean;
953
+ page: Record<string, unknown> | undefined;
954
+ }>;
955
+ /** The workflow audit trail for a page (most recent first). Handler-gated to editors;
956
+ * reads the append-only log via `exec` (a plain admin-scoped read of a gated log). */
957
+ listPageAudit: import("@pramen/server").Handler<{
958
+ pageId: string;
959
+ limit?: number;
960
+ }, Record<string, unknown>[]>;
961
+ /** Publish a page directly: snapshot the assembled page into a revision and flip
962
+ * status to `published` (records an audit entry). The public content API serves the
963
+ * snapshot. `approve` is the review-gated path to the same outcome. */
964
+ publishPage: import("@pramen/server").Handler<{
965
+ pageId: string;
966
+ note?: string;
967
+ }, {
968
+ ok: boolean;
969
+ page: Record<string, unknown> | undefined;
970
+ }>;
971
+ unpublishPage: import("@pramen/server").Handler<{
972
+ pageId: string;
973
+ }, {
974
+ ok: boolean;
975
+ page: Record<string, unknown>;
976
+ }>;
977
+ /** Schedule a page to publish at `publishAt` (epoch ms), and optionally unpublish at
978
+ * `unpublishAt`. Enqueues delayed outbox tasks (atomic with this write).
979
+ *
980
+ * Outbox tasks can't be recalled, so cancellation/rescheduling is handled by INTENT
981
+ * TOKENS: the page stores the scheduled times (`scheduledAt`/`unpublishAt`, ISO), and
982
+ * each task carries the token it was enqueued for. At fire time the task acts ONLY if
983
+ * its token still equals the page's current token — so rescheduling (new token),
984
+ * manual publish/unpublish (token cleared), and duplicate deliveries all make a stale
985
+ * task a no-op. `unpublishAt` must be after `publishAt`. */
986
+ schedulePage: import("@pramen/server").Handler<{
987
+ pageId: string;
988
+ publishAt: number;
989
+ unpublishAt?: number;
990
+ }, {
991
+ ok: boolean;
992
+ publishInMs: number;
993
+ }>;
994
+ /** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
995
+ * snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
996
+ * `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
997
+ * to the configured default locale; a slug is unique per locale. */
998
+ getPage: import("@pramen/server").Handler<{
999
+ slug: string;
1000
+ locale?: string;
1001
+ preview?: boolean;
1002
+ }, AssembledPage>;
1003
+ };
1004
+ export interface CmsPolicyOpts {
1005
+ /** Prefix for policy names (unique across roles). Default `cms`. */
1006
+ prefix?: string;
1007
+ }
1008
+ /** ACL fragments. Spread `public` into your anonymous role and `editor` into your
1009
+ * editor/admin role:
1010
+ *
1011
+ * role("anonymous", [...cmsPolicies().public])
1012
+ * role("editor", [...cmsPolicies().editor])
1013
+ *
1014
+ * `public` grants read of PUBLISHED pages + their revision snapshots only (the public
1015
+ * content API reads the snapshot, so unpublished block rows are never exposed).
1016
+ * `editor` grants full CRUD across every cms_ table. */
1017
+ export declare function cmsPolicies(opts?: CmsPolicyOpts): {
1018
+ public: Policy[];
1019
+ editor: Policy[];
1020
+ };
1021
+ /** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
1022
+ * They run with a privileged, system-scoped ctx off the write path (the outbox drain).
1023
+ *
1024
+ * Each task validates its INTENT TOKEN against the page's current `scheduledAt`/`unpublishAt`
1025
+ * (set by `schedulePage`, cleared/overwritten by a manual publish/unpublish or a reschedule).
1026
+ * A task whose token no longer matches is a no-op — that's how a superseded/cancelled
1027
+ * schedule, and an at-least-once duplicate delivery, are neutralized (outbox tasks can't be
1028
+ * recalled). NOTE on atomicity: unlike the interactive `publishPage` (one mutation
1029
+ * transaction), the drain runs a handler WITHOUT a surrounding transaction, so a crash
1030
+ * between the revision insert and the page update leaves the page unpublished with an orphan
1031
+ * revision until the next at-least-once redelivery re-runs (the token still matches, so it
1032
+ * completes). Acceptable for a scheduled job; the interactive path is atomic. */
1033
+ export declare const cmsTasks: {
1034
+ "cms:publish": (ctx: HandlerContext, payload: unknown) => Promise<void>;
1035
+ "cms:unpublish": (ctx: HandlerContext, payload: unknown) => Promise<void>;
1036
+ };
1037
+ export interface SitemapEntry {
1038
+ slug: string;
1039
+ locale: string;
1040
+ updatedAt?: string;
1041
+ }
1042
+ export interface SitemapOpts {
1043
+ origin: string;
1044
+ /** Map an entry → its absolute URL. Default `${origin}/${locale}/${slug}`. */
1045
+ pageUrl?: (e: SitemapEntry, origin: string) => string;
1046
+ }
1047
+ /** Build a sitemap.xml body from published-page entries. */
1048
+ export declare function sitemapXml(entries: SitemapEntry[], opts: SitemapOpts): string;
1049
+ /** Build a robots.txt body pointing at the sitemap. */
1050
+ export declare function robotsTxt(opts: {
1051
+ origin: string;
1052
+ disallow?: string[];
1053
+ }): string;
1054
+ interface RouteCtx {
1055
+ callPrivileged: (opts: {
1056
+ name: string;
1057
+ input?: unknown;
1058
+ tenant?: string;
1059
+ roles?: string[];
1060
+ }) => Promise<Response>;
1061
+ }
1062
+ interface CmsRoute {
1063
+ method: string;
1064
+ path: string;
1065
+ handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteCtx) => Promise<Response>;
1066
+ }
1067
+ /** Turnkey public routes for `GET /sitemap.xml` and `GET /robots.txt`. Spread into
1068
+ * `app.routes`. The sitemap pulls published pages via `callPrivileged(listPublishedPages)`.
1069
+ * `origin` defaults to the request's origin; `pageUrl` customizes the URL shape. */
1070
+ export declare function cmsRoutes(opts?: {
1071
+ origin?: string;
1072
+ tenant?: string;
1073
+ pageUrl?: SitemapOpts["pageUrl"];
1074
+ disallow?: string[];
1075
+ }): CmsRoute[];
1076
+ export {};