@pramen/cms 0.0.47 → 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
@@ -31,7 +31,18 @@ export interface FieldDefinition {
31
31
  * next week would be anonymously readable the moment it was saved — the scheduling
32
32
  * affordance would be a UI label over no enforcement at all.
33
33
  */
34
- | "publish" | "media" | "select" | "repeater" | "group";
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";
35
46
  required?: boolean;
36
47
  default?: unknown;
37
48
  /** repeater/group only — the nested fields. */
@@ -44,6 +55,8 @@ export interface FieldDefinition {
44
55
  /** select only — fetch options at edit time from a query handler of this name (returns
45
56
  * `{ value, label }[]`), e.g. a live list of campaigns. Takes precedence over `options`. */
46
57
  optionsFrom?: string;
58
+ /** slug only — the sibling field this one is derived from (e.g. `"title"`). */
59
+ from?: string;
47
60
  }
48
61
  /** A named region on a content type; `allowedTypes` (block-type slugs) restricts what
49
62
  * may be placed there — `null`/omitted means any. */
@@ -65,7 +78,7 @@ export type RichText = string | {
65
78
  };
66
79
  /** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
67
80
  * 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;
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;
69
82
  /** Infer the `fields` object type from a const `FieldDefinition[]`. Required fields are
70
83
  * present; optional ones are `| undefined`. */
71
84
  export type InferBlockFields<T extends readonly FieldDefinition[]> = {
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`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms",
3
- "version": "0.0.47",
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.47",
44
+ "@pramen/server": "0.0.48",
45
45
  "xss": "^1.0.15"
46
46
  },
47
47
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -88,6 +88,17 @@ export interface FieldDefinition {
88
88
  * affordance would be a UI label over no enforcement at all.
89
89
  */
90
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"
91
102
  | "media"
92
103
  | "select"
93
104
  | "repeater"
@@ -104,6 +115,8 @@ export interface FieldDefinition {
104
115
  /** select only — fetch options at edit time from a query handler of this name (returns
105
116
  * `{ value, label }[]`), e.g. a live list of campaigns. Takes precedence over `options`. */
106
117
  optionsFrom?: string;
118
+ /** slug only — the sibling field this one is derived from (e.g. `"title"`). */
119
+ from?: string;
107
120
  }
108
121
 
109
122
  /** A named region on a content type; `allowedTypes` (block-type slugs) restricts what
@@ -134,7 +147,7 @@ export type RichText = string | { type: string; content?: unknown[] };
134
147
 
135
148
  /** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
136
149
  * 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"
150
+ export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime" | "publish" | "slug"
138
151
  ? string
139
152
  : D["type"] extends "richtext"
140
153
  ? RichText
@@ -310,6 +323,7 @@ function tsTypeOf(f: FieldDefinition): string {
310
323
  case "date":
311
324
  case "datetime":
312
325
  case "publish":
326
+ case "slug":
313
327
  return "string";
314
328
  case "richtext":
315
329
  return "RichText";
@@ -501,6 +515,12 @@ export interface ValidateOpts {
501
515
  function isDateString(v: string): boolean {
502
516
  return /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v));
503
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
+ }
504
524
  /** A date-time: `YYYY-MM-DDTHH:MM[:SS[.sss]][Z|±HH:MM]` (ISO 8601 / datetime-local). */
505
525
  function isDateTimeString(v: string): boolean {
506
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));
@@ -529,6 +549,11 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
529
549
  throw new BadRequest(`field '${at}' must be one of: ${def.options.join(", ")}`);
530
550
  }
531
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;
532
557
  case "richtext":
533
558
  if (typeof v !== "string" && typeof v !== "object") throw new BadRequest(`field '${at}' must be rich text`);
534
559
  break;