@bettercms-ai/richtext 0.14.0 → 0.17.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/richtext",
3
- "version": "0.14.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "The one Portable Text contract for BetterCMS: schema, converters, and rendering. Shared by the backend, the dashboard and every delivery SDK.",
@@ -26,7 +26,8 @@
26
26
  "test:watch": "vitest"
27
27
  },
28
28
  "dependencies": {
29
- "@bettercms-ai/types": "^1.55.0",
29
+ "@bettercms-ai/image-url": "^0.4.1",
30
+ "@bettercms-ai/types": "^1.57.0",
30
31
  "@portabletext/to-html": "^6.0.0",
31
32
  "@portabletext/html": "^2.0.0",
32
33
  "@portabletext/schema": "^3.0.0"
package/src/accept.ts CHANGED
@@ -30,9 +30,9 @@ export const CORE_ELEMENTS = RICHTEXT_CORE_ELEMENTS
30
30
  export const ADVANCED_ELEMENTS = RICHTEXT_ADVANCED_ELEMENTS
31
31
 
32
32
  /**
33
- * The V2 blocks' elements. Each is reachable from its feature through `acceptFor`, but none is in
34
- * the panel, the defaults or the sanitiser until its RT step ships the renderer and the ingest:
35
- * a toggle with nothing behind it is the fake button the owner rule forbids.
33
+ * The V2 blocks' elements (RT2–RT8). Each one has its Portable Text type, both renderers, the
34
+ * ingest and the write check, so each is in the panel, the defaults and the sanitiser like any
35
+ * other capability.
36
36
  */
37
37
  export const BLOCK_ELEMENTS = RICHTEXT_BLOCK_ELEMENTS
38
38
 
@@ -61,6 +61,8 @@ export const ELEMENT_GROUPS: { id: string; label: string; elements: AcceptElemen
61
61
  // `internalLink` stays in ALL_ELEMENTS for stored-config compatibility, but is intentionally
62
62
  // absent here: new authoring uses the ordinary URL Link until a real internal-entry contract exists.
63
63
  { id: 'insert', label: 'Insert', elements: ['link', 'image', 'table', 'columns', 'embed', 'checklist'] },
64
+ // The V2 article blocks (RT2–RT8), each with its type, renderers, ingest and write check.
65
+ { id: 'blocks', label: 'Article blocks', elements: ['button', 'providerEmbed', 'gallery', 'tableOfContents', 'footnotes', 'relatedPosts', 'authorBio'] },
64
66
  // `rtl` stays in ALL_ELEMENTS for stored-config compatibility. There is no editor command
65
67
  // for it yet, so new schema authoring must not advertise an inert toggle.
66
68
  ]
@@ -88,7 +90,7 @@ const ELEMENT_TAGS: Record<AcceptElement, string[]> = {
88
90
  checklist: ['ul', 'li', 'input'], table: ['table', 'thead', 'tbody', 'tr', 'th', 'td'],
89
91
  columns: ['section', 'div', 'p', 'br'],
90
92
  embed: ['figure', 'iframe', 'video', 'source', 'figcaption'],
91
- // None until its RT step teaches the ingest the block's markup: a tag the sanitiser lets through
93
+ // Each V2 block's tags are exactly what its ingest reads back: a tag the sanitiser lets through
92
94
  // and the ingest cannot read is content an author loses on save.
93
95
  // RT3: the embed's figure and what it draws. Never `script`: X's widget script is written by the
94
96
  // renderer for a site that opted in, and no submitted markup may bring one.
@@ -97,7 +99,11 @@ const ELEMENT_TAGS: Record<AcceptElement, string[]> = {
97
99
  gallery: ['figure', 'ul', 'li', 'img', 'figcaption'],
98
100
  // RT5: the contents' nav, its title and its (nested) list of links.
99
101
  tableOfContents: ['nav', 'p', 'ol', 'li', 'a'],
100
- relatedPosts: [], authorBio: [], footnotes: [],
102
+ // RT6: a footnote's marker and link, and the end list its notes make.
103
+ footnotes: ['sup', 'a', 'ol', 'li'],
104
+ // RT8: related posts' aside, title and list of links; an author bio's aside, avatar and lines.
105
+ relatedPosts: ['aside', 'p', 'ul', 'li', 'a'],
106
+ authorBio: ['aside', 'img', 'p'],
101
107
  // RT2: a button is an `<a data-button>`; the ingest reads it back (buttonLinkRule).
102
108
  button: ['a'],
103
109
  }
@@ -116,7 +122,7 @@ export const HTML_BACKED_UNAVAILABLE_ACCEPT_REASONS: Partial<Record<AcceptElemen
116
122
  columns: 'Embedded blocks are available in entry rich text; component rich text stores HTML.',
117
123
  }
118
124
 
119
- const NEW_AUTHORING_EXCLUSIONS = new Set<AcceptElement>(['internalLink', 'rtl', ...BLOCK_ELEMENTS])
125
+ const NEW_AUTHORING_EXCLUSIONS = new Set<AcceptElement>(['internalLink', 'rtl'])
120
126
 
121
127
  /** New defaults contain every capability whose complete consumer pipeline is currently live. */
122
128
  export const DEFAULT_ACCEPT: AcceptElement[] = ALL_ELEMENTS.filter(
package/src/columns.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { PT_TYPES, type PortableText } from "./types";
2
+
3
+ /**
4
+ * RT7: columns (`PortableTextColumns`, types.ts), the first block that holds Portable Text of its
5
+ * own: `{ gap, columns: [{ _key, content }] }`. A V2 column is an editable run of text, so a
6
+ * column holds text blocks only, and never another block (R5: depth 1). The column count is
7
+ * derived (`data-cols`), never stored.
8
+ *
9
+ * A stored `bcmsBlock` under `builtin:columns` (plain text per column, `gap: small|medium|large`)
10
+ * is read the same way, so it renders instead of the empty marker it used to; nothing writes it.
11
+ * A legacy block in any other shape stays a marker.
12
+ */
13
+ export const PT_COLUMNS_LIMITS = { min: 2, max: 4 } as const;
14
+ export const COLUMNS_GAPS = ["s", "m", "l"] as const;
15
+ export type ColumnsGap = (typeof COLUMNS_GAPS)[number];
16
+ export const BUILTIN_COLUMNS_KEY = "builtin:columns" as const;
17
+
18
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
19
+ !!v && typeof v === "object" && !Array.isArray(v);
20
+
21
+ /** The inline layout both renderers write (a hosted page has no stylesheet for it): legacy columns' flex row. */
22
+ export const COLUMNS_STYLES: Record<ColumnsGap, { row: string; col: string }> = {
23
+ s: { row: "display:flex;flex-wrap:wrap;gap:0.75rem", col: "flex:1 1 14rem;min-width:0" },
24
+ m: { row: "display:flex;flex-wrap:wrap;gap:1.25rem", col: "flex:1 1 14rem;min-width:0" },
25
+ l: { row: "display:flex;flex-wrap:wrap;gap:2rem", col: "flex:1 1 14rem;min-width:0" },
26
+ };
27
+
28
+ /** A legacy column's plain text as text blocks: a paragraph per blank-line-separated run. */
29
+ const legacyContent = (text: string, key: string): PortableText =>
30
+ text
31
+ .split(/\n{2,}/)
32
+ .filter((p) => p.trim() !== "")
33
+ .map((p, i) => ({ _type: "block", _key: `${key}p${i}`, style: "normal", markDefs: [], children: [{ _type: "span", _key: `${key}s${i}`, text: p, marks: [] }] }) as never);
34
+
35
+ /**
36
+ * What a reader sees of a columns block, ours or a legacy `builtin:columns`: its gap and each
37
+ * column's content. Null for anything else. Read defensively: a column without content renders empty.
38
+ */
39
+ export function columnsView(node: unknown): { gap: ColumnsGap; columns: { key: string; content: PortableText }[] } | null {
40
+ if (!isRecord(node)) return null;
41
+ if (node._type === PT_TYPES.columns) {
42
+ const gap: ColumnsGap = node.gap === "s" || node.gap === "l" ? node.gap : "m";
43
+ const columns = (Array.isArray(node.columns) ? node.columns : []).map((c, i) => ({
44
+ key: isRecord(c) && typeof c._key === "string" && c._key ? c._key : `c${i}`,
45
+ content: (isRecord(c) && Array.isArray(c.content) ? c.content : []) as PortableText,
46
+ }));
47
+ return { gap, columns };
48
+ }
49
+ if (node._type === PT_TYPES.block && node.schemaKey === BUILTIN_COLUMNS_KEY) {
50
+ const f = isRecord(node.fields) ? node.fields : {};
51
+ // Only the shape the legacy renderer wrote (plain text per column) reads as columns; any other
52
+ // stays the marker, which keeps "something is here" visible rather than drawing it empty.
53
+ if (!Array.isArray(f.columns) || !f.columns.every((c) => typeof c === "string")) return null;
54
+ const gap: ColumnsGap = f.gap === "small" ? "s" : f.gap === "large" ? "l" : "m";
55
+ const columns = (Array.isArray(f.columns) ? f.columns : []).slice(0, PT_COLUMNS_LIMITS.max).map((c, i) => ({
56
+ key: `c${i}`,
57
+ content: legacyContent(typeof c === "string" ? c : "", `c${i}`),
58
+ }));
59
+ return { gap, columns };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /** One reason a stored columns block is refused, at its own path inside the block. */
65
+ export type PortableTextColumnsProblem = { path: Array<string | number>; code: string; message: string };
66
+
67
+ const KEYS = new Set(["_type", "_key", "gap", "columns"]);
68
+
69
+ /**
70
+ * Every reason `node` cannot be stored: `columns_invalid` (a shape problem, a column holding
71
+ * anything but text blocks, which includes another columns block (R5) and a footnote) or
72
+ * `columns_limit_exceeded` (outside V2's 2 to 4 columns). A derived `cols` is refused by R8, by name.
73
+ */
74
+ export function columnsProblems(node: unknown): PortableTextColumnsProblem[] {
75
+ const out: PortableTextColumnsProblem[] = [];
76
+ if (!isRecord(node) || node._type !== PT_TYPES.columns) return out;
77
+ const bad = (path: Array<string | number>, message: string, code = "columns_invalid") =>
78
+ out.push({ path, code, message: `${code}: ${message}` });
79
+ for (const k of Object.keys(node)) if (!KEYS.has(k) && k !== "cols") bad([k], `unknown key "${k}" (gap, columns)`);
80
+ if (!(COLUMNS_GAPS as readonly unknown[]).includes(node.gap)) bad(["gap"], 'gap must be "s", "m" or "l"');
81
+ if (!Array.isArray(node.columns)) {
82
+ bad(["columns"], "columns must be an array");
83
+ return out;
84
+ }
85
+ const n = node.columns.length;
86
+ if (n < PT_COLUMNS_LIMITS.min || n > PT_COLUMNS_LIMITS.max) {
87
+ bad(["columns"], `${n} columns; ${PT_COLUMNS_LIMITS.min} to ${PT_COLUMNS_LIMITS.max}`, "columns_limit_exceeded");
88
+ }
89
+ node.columns.forEach((col, i) => {
90
+ const at = ["columns", i];
91
+ if (!isRecord(col)) return bad(at, `columns[${i}] must be an object`);
92
+ if (typeof col._key !== "string" || !col._key) bad([...at, "_key"], `columns[${i}] needs a _key`);
93
+ for (const k of Object.keys(col)) if (k !== "_key" && k !== "content") bad([...at, k], `unknown key "${k}" (content)`);
94
+ if (!Array.isArray(col.content)) return bad([...at, "content"], "content must be an array of text blocks");
95
+ col.content.forEach((b, j) => {
96
+ const bp = [...at, "content", j];
97
+ if (!isRecord(b) || b._type !== "block") {
98
+ return bad(bp, `a column holds text blocks only, not ${isRecord(b) && typeof b._type === "string" ? `"${b._type}"` : "this"}`);
99
+ }
100
+ (Array.isArray(b.children) ? b.children : []).forEach((c, k) => {
101
+ if (isRecord(c) && c._type === PT_TYPES.footnote) bad([...bp, "children", k], "a footnote belongs in the body text, not a column");
102
+ });
103
+ });
104
+ });
105
+ return out;
106
+ }
107
+
108
+ /** `columnsProblems` for every columns block at the top level of a value, with its block key. */
109
+ export function portableTextColumnsProblems(value: unknown): Array<PortableTextColumnsProblem & { block: string }> {
110
+ const out: Array<PortableTextColumnsProblem & { block: string }> = [];
111
+ for (const node of Array.isArray(value) ? value : []) {
112
+ if (!isRecord(node) || node._type !== PT_TYPES.columns) continue;
113
+ const block = typeof node._key === "string" ? node._key : "";
114
+ for (const p of columnsProblems(node)) out.push({ ...p, block });
115
+ }
116
+ return out;
117
+ }
@@ -0,0 +1,70 @@
1
+ import { PT_TYPES } from "./types";
2
+ import { isPortableTextTable } from "./table";
3
+
4
+ /**
5
+ * RT6: the footnote (`PortableTextFootnote`, types.ts), an inline object in body text that stores
6
+ * only its `note` (plain text, as V2 keeps it on the marker). Everything else is derived on every
7
+ * render: its number (its place in text order across the value's top-level text blocks), the
8
+ * `fnref-N` / `fn-N` ids, the end list and its back links.
9
+ *
10
+ * Not the table's footnote (`PortableTextTableFootnote`), which belongs to a table and stays apart.
11
+ */
12
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
13
+ !!v && typeof v === "object" && !Array.isArray(v);
14
+
15
+ /**
16
+ * Each footnote in the value's top-level text blocks, numbered in text order (V2
17
+ * `renumberFootnotes`), and the notes in that order. `nodes` maps each marker to its number, which
18
+ * is how a renderer finds the number of the marker it is drawing.
19
+ */
20
+ export function footnoteOrder(value: unknown): { notes: string[]; nodes: WeakMap<object, number> } {
21
+ const notes: string[] = [];
22
+ const nodes = new WeakMap<object, number>();
23
+ for (const node of Array.isArray(value) ? value : []) {
24
+ if (!isRecord(node) || node._type !== "block" || !Array.isArray(node.children)) continue;
25
+ for (const child of node.children) {
26
+ if (!isRecord(child) || child._type !== PT_TYPES.footnote) continue;
27
+ nodes.set(child, notes.push(typeof child.note === "string" ? child.note : ""));
28
+ }
29
+ }
30
+ return { notes, nodes };
31
+ }
32
+
33
+ /** One reason a stored footnote is refused, with the block it sits in. */
34
+ export type PortableTextFootnoteProblem = { block: string; path: Array<string | number>; code: string; message: string };
35
+
36
+ const KEYS = new Set(["_type", "_key", "note"]);
37
+
38
+ /**
39
+ * Every reason a value's footnotes cannot be stored, by block and path: `footnote_invalid` for a
40
+ * shape problem, and for a footnote in a table cell (V2 refuses one inside a block: a cell has no
41
+ * place in the body's numbering). A derived `number` is refused by the R8 check, by name.
42
+ */
43
+ export function portableTextFootnoteProblems(value: unknown): PortableTextFootnoteProblem[] {
44
+ const out: PortableTextFootnoteProblem[] = [];
45
+ const bad = (block: string, path: Array<string | number>, message: string) =>
46
+ out.push({ block, path, code: "footnote_invalid", message: `footnote_invalid: ${message}` });
47
+ const blockKey = (n: Record<string, unknown>) => (typeof n._key === "string" ? n._key : "");
48
+ const each = (blocks: unknown, inCell: string | null) => {
49
+ for (const node of Array.isArray(blocks) ? blocks : []) {
50
+ if (isPortableTextTable(node)) {
51
+ for (const row of node.rows as unknown[]) {
52
+ if (!isRecord(row) || !Array.isArray(row.cells)) continue;
53
+ for (const cell of row.cells) if (isRecord(cell)) each(cell.content, blockKey(node as Record<string, unknown>));
54
+ }
55
+ continue;
56
+ }
57
+ if (!isRecord(node) || node._type !== "block" || !Array.isArray(node.children)) continue;
58
+ node.children.forEach((child, i) => {
59
+ if (!isRecord(child) || child._type !== PT_TYPES.footnote) return;
60
+ const block = inCell ?? blockKey(node);
61
+ const path = inCell === null ? ["children", i] : [blockKey(node), "children", i];
62
+ if (inCell !== null) bad(block, path, "a footnote belongs in the body text, not a table cell");
63
+ for (const k of Object.keys(child)) if (!KEYS.has(k) && k !== "number") bad(block, [...path, k], `unknown key "${k}" (note)`);
64
+ if (typeof child.note !== "string") bad(block, [...path, "note"], "note must be a string");
65
+ });
66
+ }
67
+ };
68
+ each(value, null);
69
+ return out;
70
+ }
package/src/gallery.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { PT_TYPES } from "./types";
2
2
  import { isPortableTextTable } from "./table";
3
3
  import { safeHref } from "./href";
4
+ import { richTextImageFrame } from "./image-layout";
4
5
 
5
6
  /**
6
7
  * RT4: the gallery (`PortableTextGallery`, types.ts): the limits, the write check, and the read
@@ -27,7 +28,8 @@ export type PortableTextGalleryProblem = { path: Array<string | number>; code: s
27
28
 
28
29
  const KEYS = new Set(["_type", "_key", "layout", "images"]);
29
30
  const ITEM_KEYS = new Set(["_key", "image", "caption"]);
30
- const IMAGE_KEYS = new Set(["src", "alt", "mediaId"]);
31
+ const IMAGE_KEYS = new Set(["src", "alt", "mediaId", "crop", "focal"]);
32
+ const fraction = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1;
31
33
 
32
34
  /**
33
35
  * Every reason `node` cannot be stored, each named with a code: `gallery_invalid` (a shape
@@ -63,6 +65,18 @@ export function galleryProblems(node: unknown): PortableTextGalleryProblem[] {
63
65
  if (typeof image.src !== "string") bad([...at, "image", "src"], "src must be a string");
64
66
  if (typeof image.alt !== "string") bad([...at, "image", "alt"], "alt must be a string");
65
67
  if (image.mediaId !== undefined && typeof image.mediaId !== "string") bad([...at, "image", "mediaId"], "mediaId must be a string");
68
+ // RT9: the rich-text image's framing, image-url's shape: edges cut as 0–1 fractions, a 0–1 point.
69
+ const crop = image.crop;
70
+ if (crop !== undefined && crop !== null) {
71
+ const c = isRecord(crop) ? crop : {};
72
+ if (![c.top, c.right, c.bottom, c.left].every(fraction) || (c.top as number) + (c.bottom as number) >= 1 || (c.left as number) + (c.right as number) >= 1) {
73
+ bad([...at, "image", "crop"], "crop must be { top, right, bottom, left }, 0–1 fractions that leave some picture");
74
+ }
75
+ }
76
+ const focal = image.focal;
77
+ if (focal !== undefined && focal !== null && !(isRecord(focal) && fraction(focal.x) && fraction(focal.y))) {
78
+ bad([...at, "image", "focal"], "focal must be { x, y }, 0–1 fractions");
79
+ }
66
80
  });
67
81
  if (node.images.length > PT_GALLERY_LIMITS.images) {
68
82
  bad(["images"], `${node.images.length} images; at most ${PT_GALLERY_LIMITS.images}`, "gallery_limit_exceeded");
@@ -128,17 +142,23 @@ export const GALLERY_STYLES: Record<GalleryLayout, { ul: string; li: string; fig
128
142
  * `renderArticle("gallery")` leaves it out. Read defensively: a value that never passed the write
129
143
  * check renders what it can.
130
144
  */
131
- export function galleryView(value: unknown): { layout: GalleryLayout; images: { key: string; src: string; alt: string; caption: string }[] } {
145
+ export type GalleryViewImage = { key: string; src: string; alt: string; caption: string; position: string | null; data: [string, string][] };
146
+
147
+ export function galleryView(value: unknown): { layout: GalleryLayout; images: GalleryViewImage[] } {
132
148
  const v = isRecord(value) ? value : {};
133
149
  const layout: GalleryLayout = v.layout === "carousel" ? "carousel" : "grid";
134
- const images: { key: string; src: string; alt: string; caption: string }[] = [];
150
+ const images: GalleryViewImage[] = [];
135
151
  (Array.isArray(v.images) ? v.images : []).forEach((item, i) => {
136
152
  if (!isRecord(item) || !isRecord(item.image)) return;
137
153
  const src = galleryMediaSrc(typeof item.image.src === "string" ? item.image.src.trim() : "");
138
154
  if (!src) return;
155
+ // RT9: the crop and focal point, as a rich-text image draws them (image-layout.ts).
156
+ const frame = richTextImageFrame(src, item.image.crop, item.image.focal);
139
157
  images.push({
140
158
  key: typeof item._key === "string" && item._key ? item._key : `i${i}`,
141
- src,
159
+ src: frame.src,
160
+ position: frame.position,
161
+ data: frame.data,
142
162
  alt: typeof item.image.alt === "string" ? item.image.alt : "",
143
163
  caption: typeof item.caption === "string" ? item.caption.trim() : "",
144
164
  });
package/src/href.ts CHANGED
@@ -25,3 +25,42 @@ export function safeHref(raw: string): string | null {
25
25
  return null;
26
26
  }
27
27
  }
28
+
29
+ /**
30
+ * A MEDIA url: `safeHref`, plus a plain relative path.
31
+ *
32
+ * An imported page is full of `images/hero.png` and `../media/clip.mp4`, and `safeHref` refuses
33
+ * a path with no leading slash — so every one of those images would render as nothing. Widened
34
+ * only for `<img>`, `<video>` and a poster: they load a picture, while an `<iframe>` frames a
35
+ * whole page and keeps the strict check. A colon or whitespace is refused, so no scheme
36
+ * (`javascript:`) can ride this path, and a PROTOCOL-RELATIVE `//host/clip.mp4` is refused with
37
+ * it — it is off-origin, not relative, and `safeHref` rejects the same shape for the same reason.
38
+ */
39
+ export const safeMediaSrc = (raw: string): string | null =>
40
+ safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
41
+
42
+ /** What a new tab always carries on render, so it is never the author's own `rel`. */
43
+ const NEW_TAB_REL = ["noopener", "noreferrer"];
44
+
45
+ /**
46
+ * A link's `rel` is author data (V2's link form writes `nofollow`, `sponsored`, `ugc`; the
47
+ * importer keeps `prev`/`next`): plain link-type tokens only, lowercased, once each. V2's
48
+ * `follow` is its "no rel" and is no token.
49
+ */
50
+ const relTokens = (raw: unknown): string[] =>
51
+ typeof raw === "string"
52
+ ? [...new Set(raw.toLowerCase().split(/\s+/).filter((t) => /^[a-z][a-z-]*$/.test(t) && t !== "follow"))]
53
+ : [];
54
+
55
+ /** The `rel` to store from a link's markup: a new tab's noopener/noreferrer are the renderer's, not kept. */
56
+ export function readLinkRel(raw: unknown, blank: boolean): string | undefined {
57
+ const tokens = relTokens(raw).filter((t) => !blank || !NEW_TAB_REL.includes(t));
58
+ return tokens.length ? tokens.join(" ") : undefined;
59
+ }
60
+
61
+ /** The `rel` both renderers write: the stored tokens, and a new tab always adds noopener + noreferrer. */
62
+ export function writeLinkRel(raw: unknown, blank: boolean): string | undefined {
63
+ const tokens = new Set(relTokens(raw));
64
+ if (blank) for (const t of NEW_TAB_REL) tokens.add(t);
65
+ return tokens.size ? [...tokens].join(" ") : undefined;
66
+ }