@pramen/cms 0.0.47 → 0.0.49

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,5 @@
1
- import type { HandlerContext, Policy, FileRef, BootstrapFn } from "@pramen/server";
1
+ import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue } from "@pramen/server";
2
+ import type { EnvBag } from "@pramen/server";
2
3
  /** A field in a block type's (or content type's) field schema. Recursive: a `repeater`
3
4
  * or `group` nests `fields`. Mirrors WollyCMS's FieldDefinition. */
4
5
  export interface FieldDefinition {
@@ -31,7 +32,18 @@ export interface FieldDefinition {
31
32
  * next week would be anonymously readable the moment it was saved — the scheduling
32
33
  * affordance would be a UI label over no enforcement at all.
33
34
  */
34
- | "publish" | "media" | "select" | "repeater" | "group";
35
+ | "publish"
36
+ /**
37
+ * A URL segment, derived from another field as you type.
38
+ *
39
+ * Set `from` to the field it follows (usually the title). The editor keeps them in
40
+ * sync only while the slug is untouched — once it has been edited, or on a row that
41
+ * already has one, it stops following, because silently rewriting a slug changes a
42
+ * live URL and breaks every link to it.
43
+ *
44
+ * Stored as text. Uniqueness is the schema's job (`unique(t.text())`).
45
+ */
46
+ | "slug" | "media" | "select" | "repeater" | "group";
35
47
  required?: boolean;
36
48
  default?: unknown;
37
49
  /** repeater/group only — the nested fields. */
@@ -44,6 +56,8 @@ export interface FieldDefinition {
44
56
  /** select only — fetch options at edit time from a query handler of this name (returns
45
57
  * `{ value, label }[]`), e.g. a live list of campaigns. Takes precedence over `options`. */
46
58
  optionsFrom?: string;
59
+ /** slug only — the sibling field this one is derived from (e.g. `"title"`). */
60
+ from?: string;
47
61
  }
48
62
  /** A named region on a content type; `allowedTypes` (block-type slugs) restricts what
49
63
  * may be placed there — `null`/omitted means any. */
@@ -56,7 +70,7 @@ export interface RegionDefinition {
56
70
  export interface DefaultBlockDefinition {
57
71
  region: string;
58
72
  blockTypeSlug: string;
59
- fields?: Record<string, unknown>;
73
+ fields?: FieldValues;
60
74
  }
61
75
  /** A rich-text value — a serialized editor document (or a plain string). */
62
76
  export type RichText = string | {
@@ -65,7 +79,7 @@ export type RichText = string | {
65
79
  };
66
80
  /** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
67
81
  * Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
68
- export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime" | "publish" ? 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;
82
+ export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime" | "publish" | "slug" ? 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;
69
83
  /** Infer the `fields` object type from a const `FieldDefinition[]`. Required fields are
70
84
  * present; optional ones are `| undefined`. */
71
85
  export type InferBlockFields<T extends readonly FieldDefinition[]> = {
@@ -535,7 +549,7 @@ export interface ValidateOpts {
535
549
  export declare function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path?: string, opts?: ValidateOpts): void;
536
550
  /** Deep-sanitize the richtext fields in a values object against a field schema (recursing
537
551
  * into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
538
- export declare function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: Record<string, unknown>): Record<string, unknown>;
552
+ export declare function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: FieldValues): FieldValues;
539
553
  export interface RenderedBlock {
540
554
  /** The placement id (cms_page_blocks) — stable per position; used for reorder/remove. */
541
555
  id: string;
@@ -543,7 +557,7 @@ export interface RenderedBlock {
543
557
  block_id: string;
544
558
  block_type: string;
545
559
  title: string | null;
546
- fields: Record<string, unknown>;
560
+ fields: FieldValues;
547
561
  is_shared: boolean;
548
562
  }
549
563
  export interface PageTranslation {
@@ -573,7 +587,7 @@ export interface AssembledPage {
573
587
  translationGroupId: string | null;
574
588
  /** Published sibling locales of this page (for hreflang alternates). */
575
589
  translations: PageTranslation[];
576
- fields: Record<string, unknown> | null;
590
+ fields: FieldValues | null;
577
591
  /** Back-compat: mirrors seo.metaTitle/metaDescription. */
578
592
  metaTitle: string | null;
579
593
  metaDescription: string | null;
@@ -581,6 +595,14 @@ export interface AssembledPage {
581
595
  };
582
596
  regions: Record<string, RenderedBlock[]>;
583
597
  }
598
+ /** One authored field value inside a block / collection / page `fields` bag. Stored
599
+ * as JSON; a `"media"` field is resolved from its stored id to a `ResolvedMedia` at
600
+ * assemble time, and `group`/`repeater` fields nest further bags. */
601
+ export type FieldValue = JsonValue | ResolvedMedia | FieldValues | FieldValue[];
602
+ /** A block / collection / page `fields` bag — field name -> authored value. */
603
+ export interface FieldValues {
604
+ [field: string]: FieldValue;
605
+ }
584
606
  /** A `"media"` block field, resolved from a stored media id to a servable shape at
585
607
  * assemble time. `url` is the raw (full-size) serving path; pass `key` to `imageUrl()`
586
608
  * for on-the-fly transforms. `null` when the referenced media was deleted. */
@@ -724,7 +746,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
724
746
  title?: string;
725
747
  slug?: string;
726
748
  locale?: string;
727
- fields?: Record<string, unknown>;
749
+ fields?: FieldValues;
728
750
  }, {
729
751
  ok: boolean;
730
752
  page: Record<string, unknown>;
@@ -735,7 +757,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
735
757
  title: string;
736
758
  slug: string;
737
759
  locale?: string;
738
- fields?: Record<string, unknown>;
760
+ fields?: FieldValues;
739
761
  }, Record<string, unknown>>;
740
762
  /** Create a translation of an existing page: a new page in `locale` sharing the
741
763
  * source's translationGroupId (and content type). Content starts empty — the editor
@@ -765,7 +787,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
765
787
  pageId: string;
766
788
  blockTypeSlug: string;
767
789
  region: string;
768
- fields?: Record<string, unknown>;
790
+ fields?: FieldValues;
769
791
  title?: string;
770
792
  position?: number;
771
793
  isReusable?: boolean;
@@ -784,7 +806,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
784
806
  blockId: string;
785
807
  region: string;
786
808
  position?: number;
787
- overrides?: Record<string, unknown>;
809
+ overrides?: FieldValues;
788
810
  }, Record<string, unknown>>;
789
811
  /** Fetch a block's RAW content (media fields as ids, not resolved) — for editing. */
790
812
  getBlock: import("@pramen/server").Handler<{
@@ -793,7 +815,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
793
815
  /** Update a block's content (re-validated against its type's field schema). */
794
816
  updateBlock: import("@pramen/server").Handler<{
795
817
  blockId: string;
796
- fields?: Record<string, unknown>;
818
+ fields?: FieldValues;
797
819
  title?: string;
798
820
  }, Record<string, unknown> | undefined>;
799
821
  /** Reorder a region: `order` is the page_block ids in their new order. It must cover
@@ -994,7 +1016,7 @@ export declare const cmsHandlers: {
994
1016
  title?: string;
995
1017
  slug?: string;
996
1018
  locale?: string;
997
- fields?: Record<string, unknown>;
1019
+ fields?: FieldValues;
998
1020
  }, {
999
1021
  ok: boolean;
1000
1022
  page: Record<string, unknown>;
@@ -1005,7 +1027,7 @@ export declare const cmsHandlers: {
1005
1027
  title: string;
1006
1028
  slug: string;
1007
1029
  locale?: string;
1008
- fields?: Record<string, unknown>;
1030
+ fields?: FieldValues;
1009
1031
  }, Record<string, unknown>>;
1010
1032
  /** Create a translation of an existing page: a new page in `locale` sharing the
1011
1033
  * source's translationGroupId (and content type). Content starts empty — the editor
@@ -1035,7 +1057,7 @@ export declare const cmsHandlers: {
1035
1057
  pageId: string;
1036
1058
  blockTypeSlug: string;
1037
1059
  region: string;
1038
- fields?: Record<string, unknown>;
1060
+ fields?: FieldValues;
1039
1061
  title?: string;
1040
1062
  position?: number;
1041
1063
  isReusable?: boolean;
@@ -1054,7 +1076,7 @@ export declare const cmsHandlers: {
1054
1076
  blockId: string;
1055
1077
  region: string;
1056
1078
  position?: number;
1057
- overrides?: Record<string, unknown>;
1079
+ overrides?: FieldValues;
1058
1080
  }, Record<string, unknown>>;
1059
1081
  /** Fetch a block's RAW content (media fields as ids, not resolved) — for editing. */
1060
1082
  getBlock: import("@pramen/server").Handler<{
@@ -1063,7 +1085,7 @@ export declare const cmsHandlers: {
1063
1085
  /** Update a block's content (re-validated against its type's field schema). */
1064
1086
  updateBlock: import("@pramen/server").Handler<{
1065
1087
  blockId: string;
1066
- fields?: Record<string, unknown>;
1088
+ fields?: FieldValues;
1067
1089
  title?: string;
1068
1090
  }, Record<string, unknown> | undefined>;
1069
1091
  /** Reorder a region: `order` is the page_block ids in their new order. It must cover
@@ -1323,7 +1345,7 @@ export declare function robotsTxt(opts: {
1323
1345
  interface RouteCtx {
1324
1346
  callPrivileged: (opts: {
1325
1347
  name: string;
1326
- input?: unknown;
1348
+ input?: JsonValue;
1327
1349
  tenant?: string;
1328
1350
  roles?: string[];
1329
1351
  }) => Promise<Response>;
@@ -1331,7 +1353,7 @@ interface RouteCtx {
1331
1353
  interface CmsRoute {
1332
1354
  method: string;
1333
1355
  path: string;
1334
- handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteCtx) => Promise<Response>;
1356
+ handler: (request: Request, env: EnvBag, ctx: RouteCtx) => Promise<Response>;
1335
1357
  }
1336
1358
  /** Turnkey public routes for `GET /sitemap.xml` and `GET /robots.txt`. Spread into
1337
1359
  * `app.routes`. The sitemap pulls published pages via `callPrivileged(listPublishedPages)`.
package/dist/index.js CHANGED
@@ -119,6 +119,7 @@ function tsTypeOf(f) {
119
119
  case "date":
120
120
  case "datetime":
121
121
  case "publish":
122
+ case "slug":
122
123
  return "string";
123
124
  case "richtext":
124
125
  return "RichText";
@@ -275,6 +276,12 @@ export const cmsSchema = {
275
276
  function isDateString(v) {
276
277
  return /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v));
277
278
  }
279
+ /** A URL segment: lowercase a-z/0-9 groups joined by single hyphens, capped like the editor
280
+ * control caps it. Normalization lives in the editor, but the editor is not the only writer —
281
+ * a script or another client posting "Hello World/../x" would otherwise land it in a route. */
282
+ function isSlugString(v) {
283
+ return v.length <= 80 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v);
284
+ }
278
285
  /** A date-time: `YYYY-MM-DDTHH:MM[:SS[.sss]][Z|±HH:MM]` (ISO 8601 / datetime-local). */
279
286
  function isDateTimeString(v) {
280
287
  return /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/.test(v) && Number.isFinite(Date.parse(v));
@@ -305,6 +312,13 @@ export function validateFields(schema, values, path = "", opts = {}) {
305
312
  throw new BadRequest(`field '${at}' must be one of: ${def.options.join(", ")}`);
306
313
  }
307
314
  break;
315
+ // A slug is text on the wire; only the editor control differs.
316
+ case "slug":
317
+ if (typeof v !== "string")
318
+ throw new BadRequest(`field '${at}' must be a string`);
319
+ if (!isSlugString(v))
320
+ throw new BadRequest(`field '${at}' must be a slug (lowercase letters, digits and single hyphens)`);
321
+ break;
308
322
  case "richtext":
309
323
  if (typeof v !== "string" && typeof v !== "object")
310
324
  throw new BadRequest(`field '${at}' must be rich text`);
@@ -490,7 +504,9 @@ async function assembleLive(db, page) {
490
504
  // fields (id → ResolvedMedia) in one batched lookup across the whole page.
491
505
  const merged = placements.map((p) => {
492
506
  const block = asObj(p.block);
493
- const fields = { ...asObj(block.fields), ...(p.isShared ? asObj(p.overrides) : {}) };
507
+ const fields = p.isShared
508
+ ? { ...asObj(block.fields), ...asObj(p.overrides) }
509
+ : { ...asObj(block.fields) };
494
510
  return { p, block, fields, schema: typeById.get(String(block.typeId))?.fieldsSchema };
495
511
  });
496
512
  const mediaIds = new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
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.47",
44
+ "@pramen/server": "0.0.49",
45
45
  "xss": "^1.0.15"
46
46
  },
47
47
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -42,8 +42,9 @@ import {
42
42
  Forbidden,
43
43
  PramenError,
44
44
  } from "@pramen/server";
45
- import type { HandlerContext, Policy, FileRef, BootstrapFn } from "@pramen/server";
45
+ import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue } from "@pramen/server";
46
46
  import { filterXSS } from "xss";
47
+ import type { EnvBag } from "@pramen/server";
47
48
 
48
49
  // --- field schema DSL (the block-editor field language) ---------------------
49
50
 
@@ -88,6 +89,17 @@ export interface FieldDefinition {
88
89
  * affordance would be a UI label over no enforcement at all.
89
90
  */
90
91
  | "publish"
92
+ /**
93
+ * A URL segment, derived from another field as you type.
94
+ *
95
+ * Set `from` to the field it follows (usually the title). The editor keeps them in
96
+ * sync only while the slug is untouched — once it has been edited, or on a row that
97
+ * already has one, it stops following, because silently rewriting a slug changes a
98
+ * live URL and breaks every link to it.
99
+ *
100
+ * Stored as text. Uniqueness is the schema's job (`unique(t.text())`).
101
+ */
102
+ | "slug"
91
103
  | "media"
92
104
  | "select"
93
105
  | "repeater"
@@ -104,6 +116,8 @@ export interface FieldDefinition {
104
116
  /** select only — fetch options at edit time from a query handler of this name (returns
105
117
  * `{ value, label }[]`), e.g. a live list of campaigns. Takes precedence over `options`. */
106
118
  optionsFrom?: string;
119
+ /** slug only — the sibling field this one is derived from (e.g. `"title"`). */
120
+ from?: string;
107
121
  }
108
122
 
109
123
  /** A named region on a content type; `allowedTypes` (block-type slugs) restricts what
@@ -118,7 +132,7 @@ export interface RegionDefinition {
118
132
  export interface DefaultBlockDefinition {
119
133
  region: string;
120
134
  blockTypeSlug: string;
121
- fields?: Record<string, unknown>;
135
+ fields?: FieldValues;
122
136
  }
123
137
 
124
138
  // --- hybrid typed blocks: compile-time inference over a const FieldDefinition[] --------
@@ -134,7 +148,7 @@ export type RichText = string | { type: string; content?: unknown[] };
134
148
 
135
149
  /** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
136
150
  * Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
137
- export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime" | "publish"
151
+ export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime" | "publish" | "slug"
138
152
  ? string
139
153
  : D["type"] extends "richtext"
140
154
  ? RichText
@@ -310,6 +324,7 @@ function tsTypeOf(f: FieldDefinition): string {
310
324
  case "date":
311
325
  case "datetime":
312
326
  case "publish":
327
+ case "slug":
313
328
  return "string";
314
329
  case "richtext":
315
330
  return "RichText";
@@ -501,6 +516,12 @@ export interface ValidateOpts {
501
516
  function isDateString(v: string): boolean {
502
517
  return /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v));
503
518
  }
519
+ /** A URL segment: lowercase a-z/0-9 groups joined by single hyphens, capped like the editor
520
+ * control caps it. Normalization lives in the editor, but the editor is not the only writer —
521
+ * a script or another client posting "Hello World/../x" would otherwise land it in a route. */
522
+ function isSlugString(v: string): boolean {
523
+ return v.length <= 80 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v);
524
+ }
504
525
  /** A date-time: `YYYY-MM-DDTHH:MM[:SS[.sss]][Z|±HH:MM]` (ISO 8601 / datetime-local). */
505
526
  function isDateTimeString(v: string): boolean {
506
527
  return /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/.test(v) && Number.isFinite(Date.parse(v));
@@ -509,7 +530,7 @@ function isDateTimeString(v: string): boolean {
509
530
  export function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path = "", opts: ValidateOpts = {}): void {
510
531
  const requireRequired = opts.requireRequired !== false;
511
532
  const defs = Array.isArray(schema) ? schema : [];
512
- const obj = (values ?? {}) as Record<string, unknown>;
533
+ const obj = (values ?? {}) as FieldValues;
513
534
  if (typeof obj !== "object" || Array.isArray(obj)) throw new BadRequest(`${path || "fields"} must be an object`);
514
535
  for (const def of defs) {
515
536
  const at = path ? `${path}.${def.name}` : def.name;
@@ -529,6 +550,11 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
529
550
  throw new BadRequest(`field '${at}' must be one of: ${def.options.join(", ")}`);
530
551
  }
531
552
  break;
553
+ // A slug is text on the wire; only the editor control differs.
554
+ case "slug":
555
+ if (typeof v !== "string") throw new BadRequest(`field '${at}' must be a string`);
556
+ if (!isSlugString(v)) throw new BadRequest(`field '${at}' must be a slug (lowercase letters, digits and single hyphens)`);
557
+ break;
532
558
  case "richtext":
533
559
  if (typeof v !== "string" && typeof v !== "object") throw new BadRequest(`field '${at}' must be rich text`);
534
560
  break;
@@ -591,15 +617,15 @@ function sanitizeRichText(html: string): string {
591
617
 
592
618
  /** Deep-sanitize the richtext fields in a values object against a field schema (recursing
593
619
  * into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
594
- export function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: Record<string, unknown>): Record<string, unknown> {
620
+ export function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: FieldValues): FieldValues {
595
621
  const defs = Array.isArray(schema) ? schema : [];
596
- const out: Record<string, unknown> = { ...values };
622
+ const out: FieldValues = { ...values };
597
623
  for (const def of defs) {
598
624
  const v = out[def.name];
599
625
  if (v == null) continue;
600
626
  if (def.type === "richtext" && typeof v === "string") out[def.name] = sanitizeRichText(v);
601
- else if (def.type === "group" && typeof v === "object" && !Array.isArray(v)) out[def.name] = sanitizeFields(def.fields, v as Record<string, unknown>);
602
- else if (def.type === "repeater" && Array.isArray(v)) out[def.name] = v.map((it) => (it && typeof it === "object" ? sanitizeFields(def.fields, it as Record<string, unknown>) : it));
627
+ else if (def.type === "group" && typeof v === "object" && !Array.isArray(v)) out[def.name] = sanitizeFields(def.fields, v as FieldValues);
628
+ else if (def.type === "repeater" && Array.isArray(v)) out[def.name] = v.map((it) => (it && typeof it === "object" ? sanitizeFields(def.fields, it as FieldValues) : it));
603
629
  }
604
630
  return out;
605
631
  }
@@ -613,7 +639,7 @@ export interface RenderedBlock {
613
639
  block_id: string;
614
640
  block_type: string;
615
641
  title: string | null;
616
- fields: Record<string, unknown>;
642
+ fields: FieldValues;
617
643
  is_shared: boolean;
618
644
  }
619
645
 
@@ -646,7 +672,7 @@ export interface AssembledPage {
646
672
  translationGroupId: string | null;
647
673
  /** Published sibling locales of this page (for hreflang alternates). */
648
674
  translations: PageTranslation[];
649
- fields: Record<string, unknown> | null;
675
+ fields: FieldValues | null;
650
676
  /** Back-compat: mirrors seo.metaTitle/metaDescription. */
651
677
  metaTitle: string | null;
652
678
  metaDescription: string | null;
@@ -657,6 +683,16 @@ export interface AssembledPage {
657
683
 
658
684
  // --- media -------------------------------------------------------------------
659
685
 
686
+ /** One authored field value inside a block / collection / page `fields` bag. Stored
687
+ * as JSON; a `"media"` field is resolved from its stored id to a `ResolvedMedia` at
688
+ * assemble time, and `group`/`repeater` fields nest further bags. */
689
+ export type FieldValue = JsonValue | ResolvedMedia | FieldValues | FieldValue[];
690
+
691
+ /** A block / collection / page `fields` bag — field name -> authored value. */
692
+ export interface FieldValues {
693
+ [field: string]: FieldValue;
694
+ }
695
+
660
696
  /** A `"media"` block field, resolved from a stored media id to a servable shape at
661
697
  * assemble time. `url` is the raw (full-size) serving path; pass `key` to `imageUrl()`
662
698
  * for on-the-fly transforms. `null` when the referenced media was deleted. */
@@ -695,7 +731,7 @@ export function imageUrl(
695
731
  }
696
732
 
697
733
  /** Collect the media ids referenced by a fields payload, walking group/repeater nesting. */
698
- function collectMediaIds(fields: Record<string, unknown>, schema: FieldDefinition[] | undefined, acc: Set<string>): void {
734
+ function collectMediaIds(fields: FieldValues, schema: FieldDefinition[] | undefined, acc: Set<string>): void {
699
735
  if (!Array.isArray(schema)) return;
700
736
  for (const def of schema) {
701
737
  const v = fields[def.name];
@@ -713,12 +749,12 @@ function collectMediaIds(fields: Record<string, unknown>, schema: FieldDefinitio
713
749
  /** Return a copy of `fields` with every `"media"` field resolved from its id to a
714
750
  * `ResolvedMedia` (or null), recursing into group/repeater nesting. */
715
751
  function resolveMediaFields(
716
- fields: Record<string, unknown>,
752
+ fields: FieldValues,
717
753
  schema: FieldDefinition[] | undefined,
718
754
  mediaById: Map<string, ResolvedMedia>,
719
- ): Record<string, unknown> {
755
+ ): FieldValues {
720
756
  if (!Array.isArray(schema)) return fields;
721
- const out: Record<string, unknown> = { ...fields };
757
+ const out: FieldValues = { ...fields };
722
758
  for (const def of schema) {
723
759
  const v = out[def.name];
724
760
  if (v == null) continue;
@@ -757,7 +793,7 @@ interface CmsDb {
757
793
  const cdb = (ctx: HandlerContext): CmsDb => ctx.db as unknown as CmsDb;
758
794
 
759
795
  const notFound = (what: string) => new PramenError(`${what} not found`, 404, "not_found");
760
- const asObj = (v: unknown): Record<string, unknown> => (v && typeof v === "object" ? (v as Record<string, unknown>) : {});
796
+ const asObj = (v: unknown): FieldValues => (v && typeof v === "object" ? (v as FieldValues) : {});
761
797
  // Timestamps in the SAME shape as the `expr.now()` column default (`datetime('now')`:
762
798
  // "YYYY-MM-DD HH:MM:SS", UTC, second precision) so a column's insert-default and its
763
799
  // handler-written updates stay lexically comparable (an ISO `T`/`Z` string sorts wrong).
@@ -791,7 +827,9 @@ async function assembleLive(db: CmsDb, page: Record<string, unknown>): Promise<A
791
827
  // fields (id → ResolvedMedia) in one batched lookup across the whole page.
792
828
  const merged = placements.map((p) => {
793
829
  const block = asObj(p.block);
794
- const fields = { ...asObj(block.fields), ...(p.isShared ? asObj(p.overrides) : {}) };
830
+ const fields = p.isShared
831
+ ? { ...asObj(block.fields), ...asObj(p.overrides) }
832
+ : { ...asObj(block.fields) };
795
833
  return { p, block, fields, schema: typeById.get(String(block.typeId))?.fieldsSchema };
796
834
  });
797
835
  const mediaIds = new Set<string>();
@@ -883,7 +921,7 @@ function pageMeta(page: Record<string, unknown>, translations: PageTranslation[]
883
921
  contentType,
884
922
  translationGroupId: (page.translationGroupId as string | null) ?? null,
885
923
  translations,
886
- fields: (page.fields as Record<string, unknown> | null) ?? null,
924
+ fields: (page.fields as FieldValues | null) ?? null,
887
925
  metaTitle,
888
926
  metaDescription,
889
927
  seo: {
@@ -1212,7 +1250,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1212
1250
  * page record itself, which was previously only settable at createPage. A slug/locale
1213
1251
  * change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
1214
1252
  * + sanitized against the content type's fieldsSchema, exactly like createPage. */
1215
- updatePage: mutation(async (ctx, input: { pageId: string; title?: string; slug?: string; locale?: string; fields?: Record<string, unknown> }) => {
1253
+ updatePage: mutation(async (ctx, input: { pageId: string; title?: string; slug?: string; locale?: string; fields?: FieldValues }) => {
1216
1254
  const db = cdb(ctx);
1217
1255
  const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1218
1256
  const page = rows[0];
@@ -1237,7 +1275,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1237
1275
  return { ok: true, page: updated };
1238
1276
  }, {
1239
1277
  ...editor,
1240
- input: (raw): { pageId: string; title?: string; slug?: string; locale?: string; fields?: Record<string, unknown> } => {
1278
+ input: (raw): { pageId: string; title?: string; slug?: string; locale?: string; fields?: FieldValues } => {
1241
1279
  const o = asObj(raw);
1242
1280
  if (typeof o.pageId !== "string") throw new BadRequest("pageId is required");
1243
1281
  for (const k of ["title", "slug", "locale"] as const) {
@@ -1248,7 +1286,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1248
1286
  }),
1249
1287
 
1250
1288
  /** Create a page and auto-scaffold its content type's default blocks. */
1251
- createPage: mutation(async (ctx, input: { typeId: string; title: string; slug: string; locale?: string; fields?: Record<string, unknown> }) => {
1289
+ createPage: mutation(async (ctx, input: { typeId: string; title: string; slug: string; locale?: string; fields?: FieldValues }) => {
1252
1290
  const db = cdb(ctx);
1253
1291
  const ctRows = await db.find({ from: "cms_content_types", where: { id: input.typeId }, limit: 1 });
1254
1292
  const ct = ctRows[0];
@@ -1289,7 +1327,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1289
1327
  return page;
1290
1328
  }, {
1291
1329
  ...editor,
1292
- input: (raw): { typeId: string; title: string; slug: string; locale?: string; fields?: Record<string, unknown> } => {
1330
+ input: (raw): { typeId: string; title: string; slug: string; locale?: string; fields?: FieldValues } => {
1293
1331
  const o = asObj(raw);
1294
1332
  if (typeof o.typeId !== "string" || typeof o.title !== "string" || typeof o.slug !== "string") {
1295
1333
  throw new BadRequest("typeId, title and slug are required");
@@ -1368,7 +1406,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1368
1406
  /** Create a block instance and place it into a page region in one call (the common
1369
1407
  * editor action). Validates the fields against the block type's schema and the region
1370
1408
  * against the content type's allow-list. */
1371
- addBlock: mutation(async (ctx, input: { pageId: string; blockTypeSlug: string; region: string; fields?: Record<string, unknown>; title?: string; position?: number; isReusable?: boolean }) => {
1409
+ addBlock: mutation(async (ctx, input: { pageId: string; blockTypeSlug: string; region: string; fields?: FieldValues; title?: string; position?: number; isReusable?: boolean }) => {
1372
1410
  const db = cdb(ctx);
1373
1411
  const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1374
1412
  const page = pages[0];
@@ -1395,7 +1433,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1395
1433
  return { block, placement };
1396
1434
  }, {
1397
1435
  ...editor,
1398
- input: (raw): { pageId: string; blockTypeSlug: string; region: string; fields?: Record<string, unknown>; title?: string; position?: number; isReusable?: boolean } => {
1436
+ input: (raw): { pageId: string; blockTypeSlug: string; region: string; fields?: FieldValues; title?: string; position?: number; isReusable?: boolean } => {
1399
1437
  const o = asObj(raw);
1400
1438
  if (typeof o.pageId !== "string" || typeof o.blockTypeSlug !== "string" || typeof o.region !== "string") {
1401
1439
  throw new BadRequest("pageId, blockTypeSlug and region are required");
@@ -1410,7 +1448,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1410
1448
  * can be placed on several pages; editing it updates them all, while `overrides` let one
1411
1449
  * placement diverge. The merged (base + overrides) result is validated against the
1412
1450
  * block type's field schema. */
1413
- placeBlock: mutation(async (ctx, input: { pageId: string; blockId: string; region: string; position?: number; overrides?: Record<string, unknown> }) => {
1451
+ placeBlock: mutation(async (ctx, input: { pageId: string; blockId: string; region: string; position?: number; overrides?: FieldValues }) => {
1414
1452
  const db = cdb(ctx);
1415
1453
  const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1416
1454
  const page = pages[0];
@@ -1437,7 +1475,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1437
1475
  });
1438
1476
  }, {
1439
1477
  ...editor,
1440
- input: (raw): { pageId: string; blockId: string; region: string; position?: number; overrides?: Record<string, unknown> } => {
1478
+ input: (raw): { pageId: string; blockId: string; region: string; position?: number; overrides?: FieldValues } => {
1441
1479
  const o = asObj(raw);
1442
1480
  if (typeof o.pageId !== "string" || typeof o.blockId !== "string" || typeof o.region !== "string") {
1443
1481
  throw new BadRequest("pageId, blockId and region are required");
@@ -1460,7 +1498,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1460
1498
  }),
1461
1499
 
1462
1500
  /** Update a block's content (re-validated against its type's field schema). */
1463
- updateBlock: mutation(async (ctx, input: { blockId: string; fields?: Record<string, unknown>; title?: string }) => {
1501
+ updateBlock: mutation(async (ctx, input: { blockId: string; fields?: FieldValues; title?: string }) => {
1464
1502
  const db = cdb(ctx);
1465
1503
  const rows = await db.find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
1466
1504
  const block = rows[0];
@@ -1477,7 +1515,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1477
1515
  return db.update("cms_blocks", input.blockId, patch);
1478
1516
  }, {
1479
1517
  ...editor,
1480
- input: (raw): { blockId: string; fields?: Record<string, unknown>; title?: string } => {
1518
+ input: (raw): { blockId: string; fields?: FieldValues; title?: string } => {
1481
1519
  const o = asObj(raw);
1482
1520
  if (typeof o.blockId !== "string") throw new BadRequest("blockId is required");
1483
1521
  return o as never;
@@ -2064,12 +2102,12 @@ export function robotsTxt(opts: { origin: string; disallow?: string[] }): string
2064
2102
 
2065
2103
  // Minimal shape of a pramen public route (see @pramen/server/worker app.routes).
2066
2104
  interface RouteCtx {
2067
- callPrivileged: (opts: { name: string; input?: unknown; tenant?: string; roles?: string[] }) => Promise<Response>;
2105
+ callPrivileged: (opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[] }) => Promise<Response>;
2068
2106
  }
2069
2107
  interface CmsRoute {
2070
2108
  method: string;
2071
2109
  path: string;
2072
- handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteCtx) => Promise<Response>;
2110
+ handler: (request: Request, env: EnvBag, ctx: RouteCtx) => Promise<Response>;
2073
2111
  }
2074
2112
 
2075
2113
  /** Turnkey public routes for `GET /sitemap.xml` and `GET /robots.txt`. Spread into