@pramen/cms 0.0.46 → 0.0.48
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 +42 -2
- package/dist/index.js +17 -0
- package/package.json +2 -2
- package/src/index.ts +56 -1
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,45 @@ import type { HandlerContext, Policy, FileRef, BootstrapFn } from "@pramen/serve
|
|
|
4
4
|
export interface FieldDefinition {
|
|
5
5
|
name: string;
|
|
6
6
|
label?: string;
|
|
7
|
-
type: "text" | "textarea" | "richtext" | "url" | "number" | "boolean" | "date" | "datetime"
|
|
7
|
+
type: "text" | "textarea" | "richtext" | "url" | "number" | "boolean" | "date" | "datetime"
|
|
8
|
+
/**
|
|
9
|
+
* A publication timestamp with three states: unset (hidden), a past time
|
|
10
|
+
* (published), or a future time (scheduled). The editor renders it as
|
|
11
|
+
* publish-now / schedule / unpublish rather than a bare date picker, because that is
|
|
12
|
+
* the decision an editor is actually making.
|
|
13
|
+
*
|
|
14
|
+
* Validated as a `datetime` on the wire, but NOT interchangeable with one: the
|
|
15
|
+
* `publish` control always writes a UTC instant with a `Z` suffix
|
|
16
|
+
* (`2026-08-20T12:00:00.000Z`), where the `datetime` control writes the picker's
|
|
17
|
+
* naive local string (`2026-08-20T14:00`). Both land in the same TEXT column and
|
|
18
|
+
* both pass validation, yet they sort and range-compare lexicographically against
|
|
19
|
+
* each other as if hours apart — so converting an existing `datetime` field to
|
|
20
|
+
* `publish` needs a backfill of the stored values, not just a type change.
|
|
21
|
+
*
|
|
22
|
+
* A collection has no page-style publish workflow, so this is how a row goes live.
|
|
23
|
+
* Scope the anonymous read policy to it with `$now()` from `@pramen/server`:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* policy("cms_lectures", { read: { where: { publishedAt: { lte: $now() } } } })
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* That, and not `{ publishedAt: { isNull: false } }`, is the real access boundary.
|
|
30
|
+
* `isNull: false` matches a FUTURE timestamp too, so a row the editor scheduled for
|
|
31
|
+
* next week would be anonymously readable the moment it was saved — the scheduling
|
|
32
|
+
* affordance would be a UI label over no enforcement at all.
|
|
33
|
+
*/
|
|
34
|
+
| "publish"
|
|
35
|
+
/**
|
|
36
|
+
* A URL segment, derived from another field as you type.
|
|
37
|
+
*
|
|
38
|
+
* Set `from` to the field it follows (usually the title). The editor keeps them in
|
|
39
|
+
* sync only while the slug is untouched — once it has been edited, or on a row that
|
|
40
|
+
* already has one, it stops following, because silently rewriting a slug changes a
|
|
41
|
+
* live URL and breaks every link to it.
|
|
42
|
+
*
|
|
43
|
+
* Stored as text. Uniqueness is the schema's job (`unique(t.text())`).
|
|
44
|
+
*/
|
|
45
|
+
| "slug" | "media" | "select" | "repeater" | "group";
|
|
8
46
|
required?: boolean;
|
|
9
47
|
default?: unknown;
|
|
10
48
|
/** repeater/group only — the nested fields. */
|
|
@@ -17,6 +55,8 @@ export interface FieldDefinition {
|
|
|
17
55
|
/** select only — fetch options at edit time from a query handler of this name (returns
|
|
18
56
|
* `{ value, label }[]`), e.g. a live list of campaigns. Takes precedence over `options`. */
|
|
19
57
|
optionsFrom?: string;
|
|
58
|
+
/** slug only — the sibling field this one is derived from (e.g. `"title"`). */
|
|
59
|
+
from?: string;
|
|
20
60
|
}
|
|
21
61
|
/** A named region on a content type; `allowedTypes` (block-type slugs) restricts what
|
|
22
62
|
* may be placed there — `null`/omitted means any. */
|
|
@@ -38,7 +78,7 @@ export type RichText = string | {
|
|
|
38
78
|
};
|
|
39
79
|
/** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
|
|
40
80
|
* Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
|
|
41
|
-
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;
|
|
81
|
+
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;
|
|
42
82
|
/** Infer the `fields` object type from a const `FieldDefinition[]`. Required fields are
|
|
43
83
|
* present; optional ones are `| undefined`. */
|
|
44
84
|
export type InferBlockFields<T extends readonly FieldDefinition[]> = {
|
package/dist/index.js
CHANGED
|
@@ -118,6 +118,8 @@ function tsTypeOf(f) {
|
|
|
118
118
|
case "select":
|
|
119
119
|
case "date":
|
|
120
120
|
case "datetime":
|
|
121
|
+
case "publish":
|
|
122
|
+
case "slug":
|
|
121
123
|
return "string";
|
|
122
124
|
case "richtext":
|
|
123
125
|
return "RichText";
|
|
@@ -274,6 +276,12 @@ export const cmsSchema = {
|
|
|
274
276
|
function isDateString(v) {
|
|
275
277
|
return /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v));
|
|
276
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
|
+
}
|
|
277
285
|
/** A date-time: `YYYY-MM-DDTHH:MM[:SS[.sss]][Z|±HH:MM]` (ISO 8601 / datetime-local). */
|
|
278
286
|
function isDateTimeString(v) {
|
|
279
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));
|
|
@@ -304,6 +312,13 @@ export function validateFields(schema, values, path = "", opts = {}) {
|
|
|
304
312
|
throw new BadRequest(`field '${at}' must be one of: ${def.options.join(", ")}`);
|
|
305
313
|
}
|
|
306
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;
|
|
307
322
|
case "richtext":
|
|
308
323
|
if (typeof v !== "string" && typeof v !== "object")
|
|
309
324
|
throw new BadRequest(`field '${at}' must be rich text`);
|
|
@@ -321,6 +336,8 @@ export function validateFields(schema, values, path = "", opts = {}) {
|
|
|
321
336
|
throw new BadRequest(`field '${at}' must be a date (YYYY-MM-DD)`);
|
|
322
337
|
break;
|
|
323
338
|
case "datetime":
|
|
339
|
+
// `publish` is a datetime on the wire; only the editor control differs.
|
|
340
|
+
case "publish":
|
|
324
341
|
if (typeof v !== "string" || !isDateTimeString(v))
|
|
325
342
|
throw new BadRequest(`field '${at}' must be a date-time (ISO 8601)`);
|
|
326
343
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.48",
|
|
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.48",
|
|
45
45
|
"xss": "^1.0.15"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,44 @@ export interface FieldDefinition {
|
|
|
61
61
|
| "boolean"
|
|
62
62
|
| "date"
|
|
63
63
|
| "datetime"
|
|
64
|
+
/**
|
|
65
|
+
* A publication timestamp with three states: unset (hidden), a past time
|
|
66
|
+
* (published), or a future time (scheduled). The editor renders it as
|
|
67
|
+
* publish-now / schedule / unpublish rather than a bare date picker, because that is
|
|
68
|
+
* the decision an editor is actually making.
|
|
69
|
+
*
|
|
70
|
+
* Validated as a `datetime` on the wire, but NOT interchangeable with one: the
|
|
71
|
+
* `publish` control always writes a UTC instant with a `Z` suffix
|
|
72
|
+
* (`2026-08-20T12:00:00.000Z`), where the `datetime` control writes the picker's
|
|
73
|
+
* naive local string (`2026-08-20T14:00`). Both land in the same TEXT column and
|
|
74
|
+
* both pass validation, yet they sort and range-compare lexicographically against
|
|
75
|
+
* each other as if hours apart — so converting an existing `datetime` field to
|
|
76
|
+
* `publish` needs a backfill of the stored values, not just a type change.
|
|
77
|
+
*
|
|
78
|
+
* A collection has no page-style publish workflow, so this is how a row goes live.
|
|
79
|
+
* Scope the anonymous read policy to it with `$now()` from `@pramen/server`:
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* policy("cms_lectures", { read: { where: { publishedAt: { lte: $now() } } } })
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* That, and not `{ publishedAt: { isNull: false } }`, is the real access boundary.
|
|
86
|
+
* `isNull: false` matches a FUTURE timestamp too, so a row the editor scheduled for
|
|
87
|
+
* next week would be anonymously readable the moment it was saved — the scheduling
|
|
88
|
+
* affordance would be a UI label over no enforcement at all.
|
|
89
|
+
*/
|
|
90
|
+
| "publish"
|
|
91
|
+
/**
|
|
92
|
+
* A URL segment, derived from another field as you type.
|
|
93
|
+
*
|
|
94
|
+
* Set `from` to the field it follows (usually the title). The editor keeps them in
|
|
95
|
+
* sync only while the slug is untouched — once it has been edited, or on a row that
|
|
96
|
+
* already has one, it stops following, because silently rewriting a slug changes a
|
|
97
|
+
* live URL and breaks every link to it.
|
|
98
|
+
*
|
|
99
|
+
* Stored as text. Uniqueness is the schema's job (`unique(t.text())`).
|
|
100
|
+
*/
|
|
101
|
+
| "slug"
|
|
64
102
|
| "media"
|
|
65
103
|
| "select"
|
|
66
104
|
| "repeater"
|
|
@@ -77,6 +115,8 @@ export interface FieldDefinition {
|
|
|
77
115
|
/** select only — fetch options at edit time from a query handler of this name (returns
|
|
78
116
|
* `{ value, label }[]`), e.g. a live list of campaigns. Takes precedence over `options`. */
|
|
79
117
|
optionsFrom?: string;
|
|
118
|
+
/** slug only — the sibling field this one is derived from (e.g. `"title"`). */
|
|
119
|
+
from?: string;
|
|
80
120
|
}
|
|
81
121
|
|
|
82
122
|
/** A named region on a content type; `allowedTypes` (block-type slugs) restricts what
|
|
@@ -107,7 +147,7 @@ export type RichText = string | { type: string; content?: unknown[] };
|
|
|
107
147
|
|
|
108
148
|
/** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
|
|
109
149
|
* Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
|
|
110
|
-
export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime"
|
|
150
|
+
export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime" | "publish" | "slug"
|
|
111
151
|
? string
|
|
112
152
|
: D["type"] extends "richtext"
|
|
113
153
|
? RichText
|
|
@@ -282,6 +322,8 @@ function tsTypeOf(f: FieldDefinition): string {
|
|
|
282
322
|
case "select":
|
|
283
323
|
case "date":
|
|
284
324
|
case "datetime":
|
|
325
|
+
case "publish":
|
|
326
|
+
case "slug":
|
|
285
327
|
return "string";
|
|
286
328
|
case "richtext":
|
|
287
329
|
return "RichText";
|
|
@@ -473,6 +515,12 @@ export interface ValidateOpts {
|
|
|
473
515
|
function isDateString(v: string): boolean {
|
|
474
516
|
return /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v));
|
|
475
517
|
}
|
|
518
|
+
/** A URL segment: lowercase a-z/0-9 groups joined by single hyphens, capped like the editor
|
|
519
|
+
* control caps it. Normalization lives in the editor, but the editor is not the only writer —
|
|
520
|
+
* a script or another client posting "Hello World/../x" would otherwise land it in a route. */
|
|
521
|
+
function isSlugString(v: string): boolean {
|
|
522
|
+
return v.length <= 80 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v);
|
|
523
|
+
}
|
|
476
524
|
/** A date-time: `YYYY-MM-DDTHH:MM[:SS[.sss]][Z|±HH:MM]` (ISO 8601 / datetime-local). */
|
|
477
525
|
function isDateTimeString(v: string): boolean {
|
|
478
526
|
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));
|
|
@@ -501,6 +549,11 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
|
|
|
501
549
|
throw new BadRequest(`field '${at}' must be one of: ${def.options.join(", ")}`);
|
|
502
550
|
}
|
|
503
551
|
break;
|
|
552
|
+
// A slug is text on the wire; only the editor control differs.
|
|
553
|
+
case "slug":
|
|
554
|
+
if (typeof v !== "string") throw new BadRequest(`field '${at}' must be a string`);
|
|
555
|
+
if (!isSlugString(v)) throw new BadRequest(`field '${at}' must be a slug (lowercase letters, digits and single hyphens)`);
|
|
556
|
+
break;
|
|
504
557
|
case "richtext":
|
|
505
558
|
if (typeof v !== "string" && typeof v !== "object") throw new BadRequest(`field '${at}' must be rich text`);
|
|
506
559
|
break;
|
|
@@ -514,6 +567,8 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
|
|
|
514
567
|
if (typeof v !== "string" || !isDateString(v)) throw new BadRequest(`field '${at}' must be a date (YYYY-MM-DD)`);
|
|
515
568
|
break;
|
|
516
569
|
case "datetime":
|
|
570
|
+
// `publish` is a datetime on the wire; only the editor control differs.
|
|
571
|
+
case "publish":
|
|
517
572
|
if (typeof v !== "string" || !isDateTimeString(v)) throw new BadRequest(`field '${at}' must be a date-time (ISO 8601)`);
|
|
518
573
|
break;
|
|
519
574
|
case "media":
|