@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 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" | "media" | "select" | "repeater" | "group";
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.46",
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.46",
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":