@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 +41 -19
- package/dist/index.js +17 -1
- package/package.json +2 -2
- package/src/index.ts +67 -29
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"
|
|
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?:
|
|
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:
|
|
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:
|
|
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:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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:
|
|
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 =
|
|
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.
|
|
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.
|
|
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?:
|
|
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
|
|
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:
|
|
620
|
+
export function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: FieldValues): FieldValues {
|
|
595
621
|
const defs = Array.isArray(schema) ? schema : [];
|
|
596
|
-
const out:
|
|
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
|
|
602
|
-
else if (def.type === "repeater" && Array.isArray(v)) out[def.name] = v.map((it) => (it && typeof it === "object" ? sanitizeFields(def.fields, it as
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
752
|
+
fields: FieldValues,
|
|
717
753
|
schema: FieldDefinition[] | undefined,
|
|
718
754
|
mediaById: Map<string, ResolvedMedia>,
|
|
719
|
-
):
|
|
755
|
+
): FieldValues {
|
|
720
756
|
if (!Array.isArray(schema)) return fields;
|
|
721
|
-
const out:
|
|
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):
|
|
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 =
|
|
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
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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:
|
|
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
|