@pramen/cms-editor 0.0.48 → 0.0.50

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/src/fields.tsx CHANGED
@@ -3,13 +3,19 @@
3
3
 
4
4
  import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Text, Textarea } from "@podoba/react";
5
5
  import { BlockEditor } from "@podoba/react/editor";
6
+ import { generateHTML, generateJSON } from "@tiptap/core";
7
+ import Highlight from "@tiptap/extension-highlight";
8
+ import TaskItem from "@tiptap/extension-task-item";
9
+ import TaskList from "@tiptap/extension-task-list";
10
+ import StarterKit from "@tiptap/starter-kit";
6
11
  import { useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
7
12
  import type { Api } from "./api";
8
- import type { FieldDefinition, Media } from "./types";
13
+ import { isRichTextDoc, richTextToPlainText } from "./rich-text";
14
+ import type { FieldDefinition, FieldValue, FieldValues, Media, RichTextDoc } from "./types";
9
15
 
10
16
  // Tokenized bare control (podoba's filled-field skin) for the native inputs that
11
17
  // don't map cleanly onto a podoba primitive (number/date/select/file).
12
- const CONTROL = "h-10 w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-brand-green";
18
+ export const CONTROL = "h-10 w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-brand-green";
13
19
 
14
20
  function FieldShell({ label, children }: { label: ReactNode; children: ReactNode }) {
15
21
  return (
@@ -33,7 +39,7 @@ function FieldShell({ label, children }: { label: ReactNode; children: ReactNode
33
39
  * Returns "" for anything Date can't parse — a legacy or hand-written column value must
34
40
  * not reach the input as `NaN-NaN-NaNTNaN:NaN`, which the browser silently discards.
35
41
  */
36
- function toLocalInput(value: string): string {
42
+ export function toLocalInput(value: string): string {
37
43
  const d = new Date(value);
38
44
  if (Number.isNaN(d.getTime())) return "";
39
45
  const pad = (n: number) => String(n).padStart(2, "0");
@@ -41,12 +47,12 @@ function toLocalInput(value: string): string {
41
47
  }
42
48
 
43
49
  /** A `datetime-local` string (local wall clock) -> the UTC ISO instant we store. */
44
- function fromLocalInput(local: string): string | null {
50
+ export function fromLocalInput(local: string): string | null {
45
51
  const at = new Date(local);
46
52
  return Number.isNaN(at.getTime()) ? null : at.toISOString();
47
53
  }
48
54
 
49
- function formatWhen(value: string): string {
55
+ export function formatWhen(value: string): string {
50
56
  const at = new Date(value);
51
57
  return Number.isNaN(at.getTime()) ? value : at.toLocaleString();
52
58
  }
@@ -147,8 +153,8 @@ function PublishControl({ value, onChange }: { value: string; onChange: (v: stri
147
153
  );
148
154
  }
149
155
 
150
- export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value: Record<string, unknown>; onChange: (v: Record<string, unknown>) => void; api: Api }) {
151
- const set = (name: string, v: unknown) => onChange({ ...value, [name]: v });
156
+ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value: FieldValues; onChange: (v: FieldValues) => void; api: Api }) {
157
+ const set = (name: string, v: FieldValue) => onChange({ ...value, [name]: v });
152
158
  return (
153
159
  <div className="flex flex-col gap-4">
154
160
  {schema.map((def) => (
@@ -171,13 +177,13 @@ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefin
171
177
  * accessible-name computation via `aria-labelledby`. Those three go through the bare
172
178
  * `CONTROL` skin instead, the same way `number`/`date` already do.
173
179
  */
174
- function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api; hideLabelAs?: string; siblings?: Record<string, unknown> }) {
180
+ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def: FieldDefinition; value: FieldValue; onChange: (v: FieldValue) => void; api: Api; hideLabelAs?: string; siblings?: FieldValues }) {
175
181
  const label: ReactNode = hideLabelAs !== undefined ? undefined : (
176
182
  <>
177
183
  {def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
178
184
  </>
179
185
  );
180
- const asText = (v: unknown) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v));
186
+ const asText = (v: FieldValue) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v));
181
187
  switch (def.type) {
182
188
  case "text":
183
189
  case "url": {
@@ -195,11 +201,11 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
195
201
  <Textarea label={label} value={asText(value)} onChange={onChange} />
196
202
  );
197
203
  case "richtext":
198
- // A rich-text value is an HTML string (round-trips with the site's set:html
199
- // renderers). A legacy object value isn't editable here fall back to raw text.
204
+ // A rich-text value is a document tree. A legacy HTML string still opens (it seeds
205
+ // the editor as-is) and is upgraded to a doc by the first save.
200
206
  return (
201
207
  <FieldShell label={label}>
202
- <RichText value={typeof value === "string" ? value : ""} onChange={onChange as (v: string) => void} />
208
+ <RichText value={value as RichTextDoc | string | null} onChange={onChange as (v: RichTextDoc) => void} />
203
209
  </FieldShell>
204
210
  );
205
211
  case "number":
@@ -258,12 +264,12 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
258
264
  return (
259
265
  <FieldShell label={label}>
260
266
  <div className="rounded-lg border border-border bg-surface-muted p-3.5">
261
- <FieldForm schema={def.fields ?? []} value={(value as Record<string, unknown>) ?? {}} onChange={onChange as (v: Record<string, unknown>) => void} api={api} />
267
+ <FieldForm schema={def.fields ?? []} value={(value as FieldValues) ?? {}} onChange={onChange as (v: FieldValues) => void} api={api} />
262
268
  </div>
263
269
  </FieldShell>
264
270
  );
265
271
  case "repeater":
266
- return <Repeater def={def} value={(value as Record<string, unknown>[]) ?? []} onChange={onChange as (v: unknown[]) => void} api={api} label={label} />;
272
+ return <Repeater def={def} value={(value as FieldValues[]) ?? []} onChange={onChange as (v: FieldValues[]) => void} api={api} label={label} />;
267
273
  default:
268
274
  return null;
269
275
  }
@@ -271,13 +277,90 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
271
277
 
272
278
  // --- rich text (WYSIWYG) --------------------------------------------------------------
273
279
  // The `richtext` field is podoba's Notion-style BlockEditor (Tiptap): `/` slash palette,
274
- // block conversion, inline bubble toolbar. Still an HTML string in/out, so it round-trips
275
- // with existing rich_text content + every set:html renderer — no value migration. The
276
- // server's sanitizeRichText() (@pramen/cms) remains the XSS boundary on write; ProseMirror
277
- // parses HTML to its schema on load, so scripts never survive into the editor either.
280
+ // block conversion, inline bubble toolbar.
281
+ //
282
+ // The STORED value is a document tree (`RichTextDoc`), never HTML. BlockEditor's own
283
+ // value contract is an HTML string its docs call that presentation-only so the
284
+ // conversion happens here, at the boundary, and the HTML never leaves this component:
285
+ // seeded from the stored doc on mount, converted back to a doc on every change.
286
+ //
287
+ // The extension set MUST match BlockEditor's, or a round-trip silently drops whatever
288
+ // only its schema knows (task lists, highlights). Kept beside it here for that reason.
289
+ const RT_EXTENSIONS = [
290
+ StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: { openOnClick: false, autolink: true } }),
291
+ Highlight,
292
+ TaskList,
293
+ TaskItem.configure({ nested: true }),
294
+ ];
295
+
296
+ /** Parse editor HTML into a document. Never throws: a parse failure yields an empty
297
+ * document rather than taking the render down (this runs on every keystroke). */
298
+ function htmlToDoc(html: string): RichTextDoc {
299
+ try {
300
+ return generateJSON(html, RT_EXTENSIONS) as RichTextDoc;
301
+ } catch (e) {
302
+ console.error("pramen/cms-editor: could not parse editor HTML", e);
303
+ return { type: "doc", content: [] };
304
+ }
305
+ }
306
+
307
+ /** Seed HTML for the editor. A legacy HTML string passes through untouched — that is the
308
+ * migration ramp (see `RichText`, which upgrades it on mount).
309
+ *
310
+ * `generateHTML` throws a RangeError for any node or mark outside RT_EXTENSIONS, and this
311
+ * runs in a useState initializer with no ErrorBoundary above it — so an un-normalized
312
+ * document (a custom `richTextSchema`, an import, a bootstrap seed, `ctx.db.exec`) would
313
+ * throw during render and blank the whole SPA, not just this field. Fail to an empty
314
+ * editor and say so instead. */
315
+ function docToEditorHtml(value: RichTextDoc | string | null | undefined): string {
316
+ if (typeof value === "string") return value;
317
+ if (!isRichTextDoc(value)) return "";
318
+ try {
319
+ return generateHTML(value, RT_EXTENSIONS);
320
+ } catch (e) {
321
+ console.error("pramen/cms-editor: rich-text document uses nodes this editor cannot render", e);
322
+ return "";
323
+ }
324
+ }
325
+
326
+ export function RichText({ value, onChange }: { value: RichTextDoc | string | null; onChange: (v: RichTextDoc) => void }) {
327
+ // BlockEditor requires its own HTML echoed back VERBATIM — normalising in render would
328
+ // re-seed the document on every keystroke and throw the caret back to the start. So the
329
+ // HTML lives in local state and the doc goes upward.
330
+ const [html, setHtml] = useState(() => docToEditorHtml(value));
331
+ const emitted = useRef<RichTextDoc | null>(null);
332
+
333
+ // Upgrade a legacy HTML value to a document AS SOON AS IT OPENS, not on first edit of
334
+ // this field. The server only tolerates a legacy string that is byte-identical to what
335
+ // is stored, and both renderers emit nothing for a string — so a value that is never
336
+ // upgraded stays invisible on the site forever. Converting on mount means any ordinary
337
+ // save (even of a sibling field) writes it back as a document.
338
+ const upgraded = useRef(false);
339
+ useEffect(() => {
340
+ if (upgraded.current || typeof value !== "string" || value === "") return;
341
+ upgraded.current = true;
342
+ const doc = htmlToDoc(value);
343
+ emitted.current = doc;
344
+ onChange(doc);
345
+ }, [value, onChange]);
346
+
347
+ // Re-seed only when the parent hands us a doc that is not the one we last emitted —
348
+ // i.e. the form switched to a different block, not our own change coming back around.
349
+ useEffect(() => {
350
+ if (value !== null && value === emitted.current) return;
351
+ setHtml(docToEditorHtml(value));
352
+ // Only the incoming value should re-seed; `html` is this effect's output, not its input.
353
+ // eslint-disable-next-line react-hooks/exhaustive-deps
354
+ }, [value]);
355
+
356
+ const handleChange = (nextHtml: string) => {
357
+ setHtml(nextHtml);
358
+ const doc = htmlToDoc(nextHtml);
359
+ emitted.current = doc;
360
+ onChange(doc);
361
+ };
278
362
 
279
- export function RichText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
280
- return <BlockEditor value={value} onChange={onChange} minHeight={180} placeholder="Write, or press '/' for blocks…" />;
363
+ return <BlockEditor value={html} onChange={handleChange} minHeight={180} placeholder="Write, or press '/' for blocks…" />;
281
364
  }
282
365
 
283
366
  /**
@@ -292,7 +375,7 @@ export function RichText({ value, onChange }: { value: string; onChange: (v: str
292
375
  * Reordering is drag-and-drop from the ⠿ handle, mirroring the block canvas. ↑/↓ stay,
293
376
  * because dragging is unavailable to keyboard users and awkward on touch.
294
377
  */
295
- function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: Record<string, unknown>[]; onChange: (v: unknown[]) => void; api: Api; label: ReactNode }) {
378
+ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: FieldValues[]; onChange: (v: FieldValues[]) => void; api: Api; label: ReactNode }) {
296
379
  const items = Array.isArray(value) ? value : [];
297
380
  const fields = def.fields ?? [];
298
381
  // One field, and not itself a tall control — the case where the card is pure overhead.
@@ -300,7 +383,7 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
300
383
 
301
384
  const [drag, setDrag] = useState<{ from: number; over: number } | null>(null);
302
385
 
303
- const upd = (i: number, v: Record<string, unknown>) => onChange(items.map((it, j) => (j === i ? v : it)));
386
+ const upd = (i: number, v: FieldValues) => onChange(items.map((it, j) => (j === i ? v : it)));
304
387
  const add = () => onChange([...items, {}]);
305
388
  const del = (i: number) => onChange(items.filter((_, j) => j !== i));
306
389
  const moveTo = (from: number, to: number) => {
@@ -322,12 +405,16 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
322
405
  * instead titled every slide with the uuid of its image, which is worse than no summary
323
406
  * at all — the point is to tell two rows apart at a glance.
324
407
  */
325
- const summarise = (it: Record<string, unknown>): string => {
408
+ const summarise = (it: FieldValues): string => {
326
409
  const readable = ["text", "textarea", "richtext", "select", "url"];
327
410
  for (const f of fields) {
328
411
  if (!readable.includes(f.type)) continue;
329
412
  const v = it[f.name];
330
- if (typeof v === "string" && v.trim()) return v.replace(/<[^>]+>/g, " ").trim().slice(0, 80);
413
+ // A `richtext` value is a document tree, so a string check alone would skip the one
414
+ // prose field an item has — exactly the row this summary exists to distinguish.
415
+ const text = isRichTextDoc(v) ? richTextToPlainText(v) : typeof v === "string" ? v.replace(/<[^>]+>/g, " ") : "";
416
+ const trimmed = text.replace(/\s+/g, " ").trim();
417
+ if (trimmed) return trimmed.slice(0, 80);
331
418
  }
332
419
  return "";
333
420
  };
@@ -545,7 +632,7 @@ function SlugField({ def, label, value, source, onChange }: { def: FieldDefiniti
545
632
  // A `select` field. Static `options` render as-is; when `optionsFrom` is set, the options are
546
633
  // fetched once from that query handler (returns `{ value, label }[]`) — e.g. a live list of
547
634
  // campaigns — so the editor never has to hardcode or copy identifiers by hand.
548
- function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v: unknown) => void; api: Api; ariaLabel?: string }) {
635
+ function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v: FieldValue) => void; api: Api; ariaLabel?: string }) {
549
636
  const [dyn, setDyn] = useState<{ value: string; label: string }[] | null>(null);
550
637
  const from = def.optionsFrom;
551
638
  useEffect(() => {
@@ -0,0 +1,30 @@
1
+ // Rich-text helpers for the editor. Local mirrors of the @pramen/cms functions, kept here
2
+ // because the editor is a self-contained browser app with no server-package dependency —
3
+ // it speaks to the CMS purely over HTTP (see types.ts).
4
+
5
+ import type { RichTextDoc, RichTextNode } from "./types";
6
+
7
+ /** Is this value a rich-text document (rather than a legacy HTML string or a plain bag)? */
8
+ export function isRichTextDoc(v: unknown): v is RichTextDoc {
9
+ return typeof v === "object" && v !== null && !Array.isArray(v) && (v as RichTextDoc).type === "doc";
10
+ }
11
+
12
+ /** The block-level node types that end a line when flattening to plain text. */
13
+ const BLOCK_TYPES = new Set(["paragraph", "heading", "listItem", "taskItem", "blockquote", "codeBlock", "horizontalRule"]);
14
+
15
+ /** Flatten a rich-text document to plain text — for list cells and collapsed-block
16
+ * previews, which want the words without the structure. Mirrors `richTextToPlainText`
17
+ * in @pramen/cms. */
18
+ export function richTextToPlainText(value: RichTextDoc | null | undefined): string {
19
+ const parts: string[] = [];
20
+ const walk = (nodes: readonly RichTextNode[]): void => {
21
+ for (const node of nodes) {
22
+ if (node.type === "text") parts.push(node.text ?? "");
23
+ else if (node.type === "hardBreak") parts.push("\n");
24
+ if (node.content) walk(node.content);
25
+ if (BLOCK_TYPES.has(node.type)) parts.push("\n");
26
+ }
27
+ };
28
+ walk(value?.content ?? []);
29
+ return parts.join("").replace(/\n{2,}/g, "\n").trim();
30
+ }
package/src/types.ts CHANGED
@@ -2,6 +2,47 @@
2
2
  // @pramen/cms) so the editor stays a self-contained browser app with no server-package
3
3
  // dependency — it speaks to the CMS purely over HTTP.
4
4
 
5
+ /** Any JSON value — the wire form of everything the CMS stores. */
6
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
7
+
8
+ /** A rich-text document — the editor's structured JSON. Mirrors `RichTextDoc` in
9
+ * @pramen/cms; a `richtext` field is this tree, never an HTML string. */
10
+ export interface RichTextDoc {
11
+ type: "doc";
12
+ content?: RichTextNode[];
13
+ }
14
+
15
+ /** One node in a {@link RichTextDoc}. */
16
+ export interface RichTextNode {
17
+ type: string;
18
+ content?: RichTextNode[];
19
+ text?: string;
20
+ marks?: RichTextMark[];
21
+ attrs?: Record<string, JsonValue>;
22
+ }
23
+
24
+ /** An inline mark on a text node. */
25
+ export interface RichTextMark {
26
+ type: string;
27
+ attrs?: Record<string, JsonValue>;
28
+ }
29
+
30
+ /** One authored field value. Mirrors `FieldValue` in @pramen/cms: a `"media"` field
31
+ * arrives resolved to a `Media`, a `"richtext"` field is a `RichTextDoc`, and
32
+ * `group`/`repeater` fields nest further bags. */
33
+ export type FieldValue = JsonValue | Media | RichTextDoc | FieldValues | FieldValue[];
34
+
35
+ /** A block / collection / page `fields` bag — field name -> authored value. */
36
+ export interface FieldValues {
37
+ [field: string]: FieldValue;
38
+ }
39
+
40
+ /** The JSON body of an editor RPC call: an object of values, any of which may be
41
+ * omitted (an absent key is simply not sent). */
42
+ export interface RpcInput {
43
+ [key: string]: FieldValue | undefined;
44
+ }
45
+
5
46
  export type FieldType =
6
47
  | "text"
7
48
  | "textarea"
@@ -25,7 +66,7 @@ export interface FieldDefinition {
25
66
  label?: string;
26
67
  type: FieldType;
27
68
  required?: boolean;
28
- default?: unknown;
69
+ default?: FieldValue;
29
70
  fields?: FieldDefinition[];
30
71
  min?: number;
31
72
  max?: number;
@@ -47,7 +88,7 @@ export interface RegionDefinition {
47
88
  export interface DefaultBlockDefinition {
48
89
  region: string;
49
90
  blockTypeSlug: string;
50
- fields?: Record<string, unknown>;
91
+ fields?: FieldValues;
51
92
  }
52
93
 
53
94
  export interface BlockType {
@@ -85,8 +126,17 @@ export interface CollectionMeta {
85
126
  * from this to open/save/delete it. */
86
127
  idField: string;
87
128
  orderBy?: { column: string; dir?: "asc" | "desc" };
129
+ /** The workflow features the collection opted into server-side (`supports`), e.g.
130
+ * `["drafts", "scheduling"]`. Carried here so this mirror stays faithful to
131
+ * `CollectionMeta`. The editor renders the matching affordances (publish/unpublish, a
132
+ * schedule picker, a preview link, a revision list) in `CollectionWorkflow`; an empty
133
+ * list renders none of them. */
134
+ supports?: CollectionFeature[];
88
135
  }
89
136
 
137
+ /** Mirror of @pramen/cms `CollectionFeature`. */
138
+ export type CollectionFeature = "drafts" | "scheduling" | "revisions" | "preview";
139
+
90
140
  export interface Page {
91
141
  id: string;
92
142
  typeId: string;
@@ -94,7 +144,7 @@ export interface Page {
94
144
  slug: string;
95
145
  status: string;
96
146
  locale: string;
97
- fields?: Record<string, unknown> | null; // content-type-level structured data (fieldsSchema)
147
+ fields?: FieldValues | null; // content-type-level structured data (fieldsSchema)
98
148
  translationGroupId?: string | null;
99
149
  metaTitle?: string | null;
100
150
  metaDescription?: string | null;
@@ -122,13 +172,13 @@ export interface RenderedBlock {
122
172
  block_id: string; // block instance id (edit)
123
173
  block_type: string;
124
174
  title: string | null;
125
- fields: Record<string, unknown>;
175
+ fields: FieldValues;
126
176
  is_shared: boolean;
127
177
  pending?: boolean; // optimistic placeholder — not yet persisted (temp ids, no getBlock)
128
178
  }
129
179
 
130
180
  export interface AssembledPage {
131
- page: Page & { translations?: { locale: string; slug: string }[]; seo?: Record<string, unknown> };
181
+ page: Page & { translations?: { locale: string; slug: string }[]; seo?: FieldValues };
132
182
  regions: Record<string, RenderedBlock[]>;
133
183
  }
134
184