@bettercms-ai/richtext 0.14.0 → 0.16.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.16.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/html.ts CHANGED
@@ -14,10 +14,14 @@ import { builtInTableToTable, designOf, footnoteNumbers, footnotesOf, highlighte
14
14
  import { portableTextToPlainText } from "./plain";
15
15
  import { safeHref } from "./href";
16
16
  import { isStyledTag, styledAttrEntries } from "./styled";
17
- import { richTextImageLayout } from "./image-layout";
17
+ import { richTextImageFrame, richTextImageLayout } from "./image-layout";
18
18
  import { keyTakeawaysView } from "./key-takeaways";
19
19
  import { GALLERY_STYLES, galleryView } from "./gallery";
20
20
  import { outlineOf, tocTree, tocView, type OutlineItem } from "./toc";
21
+ import { footnoteOrder } from "./footnote";
22
+ import { BUILTIN_COLUMNS_KEY, COLUMNS_STYLES, columnsView } from "./columns";
23
+ import { authorBioView, relatedPostsView } from "./references";
24
+ import type { RichTextResolved } from "@bettercms-ai/types";
21
25
  import { isEmbedAspect, resolveEmbed, type EmbedCard, type EmbedRender } from "./embed";
22
26
 
23
27
  /**
@@ -282,9 +286,13 @@ function imageHtml(value: unknown, classes: HtmlClasses): string {
282
286
  if (!src) return "";
283
287
  const caption = str(f.caption);
284
288
  const layout = richTextImageLayout(f);
289
+ // RT9: the crop rides the url, the focal point is object-position; what is stored rides data-*.
290
+ const frame = richTextImageFrame(src, f.crop, f.focal);
285
291
  // A placed image fills its figure; the figure carries the width and position (image-layout.ts).
286
- const imgStyle = layout ? ' style="display:block;width:100%;height:auto"' : "";
287
- const img = `<img${cls(classes, "img")} src="${esc(src)}" alt="${esc(str(f.alt))}"${imgStyle}>`;
292
+ const styles = [layout ? "display:block;width:100%;height:auto" : "", frame.position ? `object-position:${frame.position}` : ""].filter(Boolean);
293
+ const imgStyle = styles.length ? ` style="${styles.join(";")}"` : "";
294
+ const data = frame.data.map(([k, v]) => ` ${k}="${esc(v)}"`).join("");
295
+ const img = `<img${cls(classes, "img")} src="${esc(frame.src)}" alt="${esc(str(f.alt))}"${imgStyle}${data}>`;
288
296
  const figureClass = [classes.figure, layout?.className].filter(Boolean).join(" ");
289
297
  const figureAttrs = `${figureClass ? ` class="${esc(figureClass)}"` : ""}${
290
298
  layout ? ` style="${esc(layout.figureStyle)}"` : ""
@@ -434,7 +442,9 @@ function galleryHtml(value: unknown): string {
434
442
  const { layout, images } = galleryView(value);
435
443
  const s = GALLERY_STYLES[layout];
436
444
  const items = images
437
- .map((x) => `<li style="${s.li}"><figure style="${s.figure}"><img alt="${esc(x.alt)}" style="${s.img}" loading="lazy" src="${esc(x.src)}">${
445
+ .map((x) => `<li style="${s.li}"><figure style="${s.figure}"><img alt="${esc(x.alt)}" style="${s.img}${x.position ? `;object-position:${x.position}` : ""}" loading="lazy"${x.data
446
+ .map(([k, v]) => ` ${k}="${esc(v)}"`)
447
+ .join("")} src="${esc(x.src)}">${
438
448
  x.caption ? `<figcaption>${esc(x.caption)}</figcaption>` : ""
439
449
  }</figure></li>`)
440
450
  .join("");
@@ -448,6 +458,75 @@ function galleryHtml(value: unknown): string {
448
458
  * one); an inline render (a cell, a heading field) keeps the value's outline it sits in.
449
459
  */
450
460
  let outline: { items: OutlineItem[]; nodes: WeakMap<object, string> } = { items: [], nodes: new WeakMap() };
461
+ /** RT6: the footnotes of the value being rendered, numbered in text order; set and restored with `outline`. */
462
+ let footnotes: { notes: string[]; nodes: WeakMap<object, number> } = { notes: [], nodes: new WeakMap() };
463
+
464
+ /**
465
+ * RT6: a footnote's marker, V2's `<sup data-rt="fn" id="fnref-N" data-note><a href="#fn-N">N</a></sup>`.
466
+ * `data-note` carries the stored note, so the ingest reads it back; the number is its place in the
467
+ * value. A marker outside the value's body text (a table cell, which the write refuses) has none.
468
+ */
469
+ function footnoteMarkHtml(value: object): string {
470
+ const note = esc(typeof (value as { note?: unknown }).note === "string" ? (value as { note: string }).note : "");
471
+ const n = footnotes.nodes.get(value);
472
+ return n
473
+ ? `<sup data-rt="fn" id="fnref-${n}" data-note="${note}"><a href="#fn-${n}" aria-label="Footnote ${n}">${n}</a></sup>`
474
+ : `<sup data-rt="fn" data-note="${note}"></sup>`;
475
+ }
476
+
477
+ /**
478
+ * RT7: columns, V2's `<div data-rt="columns" data-gap data-cols><div data-col="true">…</div></div>`,
479
+ * each column's text rendered by the same components as the body. The flex row is inline (no
480
+ * stylesheet on a hosted page): side by side, wrapping one under another on a narrow screen. A
481
+ * legacy `builtin:columns` renders the same way.
482
+ */
483
+ function columnsHtml(value: unknown, components: () => Partial<PortableTextHtmlComponents>): string {
484
+ const view = columnsView(value);
485
+ if (!view) return "";
486
+ const s = COLUMNS_STYLES[view.gap];
487
+ const cols = view.columns
488
+ .map((c) => `<div data-col="true" style="${s.col}">${c.content.length ? toHTML(c.content as never, { components: components() }) : ""}</div>`)
489
+ .join("");
490
+ return `<div data-rt="columns" data-gap="${view.gap}" data-cols="${view.columns.length}" style="${s.row}">${cols}</div>`;
491
+ }
492
+
493
+ /**
494
+ * RT8: related posts, V2's `<aside data-rt="related" data-mode data-count data-posts><p data-title>…</p>
495
+ * <ul><li><a href>…</a></li></ul></aside>`, listing what the server resolved (relatedPostsView).
496
+ */
497
+ function relatedPostsHtml(value: unknown, resolved: RichTextResolved | undefined): string {
498
+ const { key, title, mode, count, picked, items } = relatedPostsView(value, resolved);
499
+ if (!resolved) {
500
+ return `<aside data-rt="related" data-key="${esc(key)}" data-mode="${mode}" data-count="${count}" data-posts="${esc(picked.join(","))}"${
501
+ title ? ` data-title="${esc(title)}"` : ""
502
+ }></aside>`;
503
+ }
504
+ if (!items.length) return "";
505
+ const list = items.map((p) => `<li><a href="${esc(p.url)}">${esc(p.title)}</a></li>`).join("");
506
+ return `<aside data-rt="related" data-mode="${mode}" data-count="${count}" data-posts="${esc(picked.join(","))}">${
507
+ title ? `<p data-title>${esc(title)}</p>` : ""
508
+ }${list ? `<ul>${list}</ul>` : ""}</aside>`;
509
+ }
510
+
511
+ /**
512
+ * RT8: an author bio, V2's `<aside data-rt="author" data-author><img><p data-name>…</p><p data-bio>…</p></aside>`.
513
+ * The `<img>` writes `src` last, as React does, so both renderers give the same bytes (R6).
514
+ */
515
+ function authorBioHtml(value: unknown, resolved: RichTextResolved | undefined): string {
516
+ const { key, id, author } = authorBioView(value, resolved);
517
+ if (!resolved) return `<aside data-rt="author" data-key="${esc(key)}" data-author="${esc(id)}"></aside>`;
518
+ if (!author) return "";
519
+ return `<aside data-rt="author" data-author="${esc(id)}">${author?.avatar ? `<img alt="" src="${esc(author.avatar)}">` : ""}${
520
+ author ? `<p data-name>${esc(author.name)}</p>` : ""
521
+ }${author?.bio ? `<p data-bio>${esc(author.bio)}</p>` : ""}</aside>`;
522
+ }
523
+
524
+ /** RT6: the end list, V2 `renderFootnotes` byte for byte: each note, then a link back to its marker. */
525
+ function footnoteListHtml(notes: readonly string[]): string {
526
+ if (!notes.length) return "";
527
+ const items = notes.map((n, i) => `<li id="fn-${i + 1}">${esc(n)} <a href="#fnref-${i + 1}" aria-label="Back to reference ${i + 1}">↩</a></li>`).join("");
528
+ return `<ol class="cs-footnotes" data-rt="footnotes" aria-label="Footnotes">${items}</ol>`;
529
+ }
451
530
 
452
531
  /** An h2 or h3 with its outline id, so a table of contents (or any link) can point at it. */
453
532
  const headingWithId = (tag: "h2" | "h3", classes: HtmlClasses) =>
@@ -511,9 +590,15 @@ export interface RenderOptions {
511
590
  inline?: boolean;
512
591
  /**
513
592
  * RT3: what an embed needs that the value does not hold: the X cards the server fetched for this
514
- * render (by url), and the site's opt-in to X's script. Absent: every X post is a link card.
593
+ * render (by canonical url). Absent: every X post is a link card.
515
594
  */
516
595
  embeds?: EmbedRender;
596
+ /**
597
+ * RT8: the entries this value's author bios and related posts point at, resolved by the caller
598
+ * whenever a page is produced; never stored. Absent: each block is a placeholder aside carrying
599
+ * only what is stored (the stored html cache). Present: a block with nothing to show draws nothing.
600
+ */
601
+ resolved?: RichTextResolved;
517
602
  /**
518
603
  * The site's own class per tag, for a value derived FROM that site.
519
604
  *
@@ -588,6 +673,7 @@ const buildComponents = (
588
673
  binding?: { base: string; keys: ReadonlySet<unknown> },
589
674
  renderBlock?: RenderOptions["renderBlock"],
590
675
  embeds: EmbedRender = {},
676
+ resolved?: RichTextResolved,
591
677
  ): Partial<PortableTextHtmlComponents> => ({
592
678
  block: { ...tagOverrides(classes, STYLE_TAGS), h2: headingWithId("h2", classes), h3: headingWithId("h3", classes) },
593
679
  list: {
@@ -624,6 +710,9 @@ const buildComponents = (
624
710
  [PT_TYPES.embed]: ({ value }) => embedFigureHtml(value, embeds),
625
711
  [PT_TYPES.gallery]: ({ value }) => galleryHtml(value),
626
712
  [PT_TYPES.tableOfContents]: ({ value }) => tocHtml(value),
713
+ [PT_TYPES.relatedPosts]: ({ value }) => relatedPostsHtml(value, resolved),
714
+ [PT_TYPES.authorBio]: ({ value }) => authorBioHtml(value, resolved),
715
+ [PT_TYPES.columns]: ({ value }) => columnsHtml(value, () => buildComponents(classes, undefined, renderBlock, embeds, resolved)),
627
716
  /**
628
717
  * A generic embedded block. A block whose payload this serialiser can READ renders as
629
718
  * content; one carrying a reference it would have to resolve against a project renders as a
@@ -640,6 +729,8 @@ const buildComponents = (
640
729
  if (v.schemaKey === BUILTIN_EMBED_KEY) return embedHtml(value, classes);
641
730
  if (v.schemaKey === BUILTIN_CALLOUT_KEY) return calloutHtml(value);
642
731
  if (v.schemaKey === BUILTIN_TOGGLE_KEY) return toggleHtml(value);
732
+ // A legacy columns block in the shape it was written in reads as columns; any other stays a marker.
733
+ if (v.schemaKey === BUILTIN_COLUMNS_KEY && columnsView(value)) return columnsHtml(value, () => buildComponents(classes, undefined, renderBlock, embeds, resolved));
643
734
  /**
644
735
  * A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
645
736
  * nothing. Its rows are text, held right here, so a marker would DELETE visible content.
@@ -659,6 +750,7 @@ const buildComponents = (
659
750
  : "block";
660
751
  return `<div class="bcms-block" data-bcms-schema-key="${esc(key)}"></div>`;
661
752
  },
753
+ [PT_TYPES.footnote]: ({ value }) => footnoteMarkHtml(value),
662
754
  [PT_TYPES.mention]: ({ value }) => {
663
755
  const v = value as { label?: unknown };
664
756
  return `<span class="bcms-mention">${esc(typeof v.label === "string" ? v.label : "")}</span>`;
@@ -734,24 +826,25 @@ export function portableTextToHtml(
734
826
  ): string {
735
827
  if (!Array.isArray(value) || value.length === 0) return "";
736
828
  if (opts.inline) return render(value, opts);
737
- const outer = outline;
829
+ const outer = { outline, footnotes };
738
830
  outline = outlineOf(value);
831
+ footnotes = footnoteOrder(value);
739
832
  try {
740
- return render(value, opts);
833
+ return render(value, opts) + footnoteListHtml(footnotes.notes);
741
834
  } finally {
742
- outline = outer;
835
+ ({ outline, footnotes } = outer);
743
836
  }
744
837
  }
745
838
 
746
839
  function render(value: PortableText, opts: RenderOptions): string {
747
840
  const components =
748
- opts.classes || opts.renderBlock || opts.embeds
749
- ? buildComponents(opts.classes, undefined, opts.renderBlock, opts.embeds)
841
+ opts.classes || opts.renderBlock || opts.embeds || opts.resolved
842
+ ? buildComponents(opts.classes, undefined, opts.renderBlock, opts.embeds, opts.resolved)
750
843
  : defaultComponents;
751
844
 
752
845
  if (opts.bindingBase) {
753
846
  const base = opts.bindingBase;
754
- const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock, opts.embeds);
847
+ const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock, opts.embeds, opts.resolved);
755
848
  const out: string[] = [];
756
849
  for (let i = 0; i < value.length; ) {
757
850
  const node = value[i];
@@ -1,3 +1,14 @@
1
+ import {
2
+ focalInCrop,
3
+ formatImageRect,
4
+ imageValueUrl,
5
+ normalizeImageCrop,
6
+ normalizeImageFocal,
7
+ objectPosition,
8
+ parseImageRect,
9
+ type ImageCrop,
10
+ type ImageFocal,
11
+ } from "@bettercms-ai/image-url";
1
12
  /**
2
13
  * Where a rich-text image sits and how wide it is — `align` and `size` on a `builtin:image`
3
14
  * block's fields.
@@ -67,3 +78,46 @@ export function richTextImageLayout(fields: Record<string, unknown>): RichTextIm
67
78
  figureStyle: `max-width:${maxWidth};margin-left:${ml};margin-right:${mr}`,
68
79
  };
69
80
  }
81
+
82
+ /**
83
+ * RT9: a rich-text image's framing, the platform's own image vocabulary (`@bettercms-ai/image-url`):
84
+ * `crop`, how much to cut from each edge as 0–1 fractions of the source, and `focal`, a 0–1 point
85
+ * of the source. Both optional on a `builtin:image`'s fields and on a gallery image.
86
+ *
87
+ * The crop is applied the way every lane applies it, through `imageValueUrl` (`?rect=` on a
88
+ * BetterCMS media url; any other url is drawn uncropped); the focal point is `object-position`
89
+ * inside that crop. What is stored rides the `<img>` as `data-src` / `data-crop` / `data-focal`
90
+ * whenever there is a frame, so the ingest reads back exactly what was stored.
91
+ */
92
+ export interface RichTextImageFrame {
93
+ /** The url to draw: the stored one, with the crop applied when it can be. */
94
+ src: string;
95
+ crop: ImageCrop | null;
96
+ focal: ImageFocal | null;
97
+ /** `object-position` for the focal point, or null when there is none. */
98
+ position: string | null;
99
+ /** The `data-*` attributes carrying what is stored, in render order; empty when there is no frame. */
100
+ data: [string, string][];
101
+ }
102
+
103
+ export function richTextImageFrame(src: string, crop: unknown, focal: unknown): RichTextImageFrame {
104
+ const c = normalizeImageCrop(crop);
105
+ const f = normalizeImageFocal(focal);
106
+ if (!c && !f) return { src, crop: null, focal: null, position: null, data: [] };
107
+ const drawn = imageValueUrl({ url: src, crop: c, focal: f }) ?? src;
108
+ const data: [string, string][] = [];
109
+ if (drawn !== src) data.push(["data-src", src]);
110
+ if (c) data.push(["data-crop", formatImageRect(c)]);
111
+ if (f) data.push(["data-focal", `${f.x},${f.y}`]);
112
+ return { src: drawn, crop: c, focal: f, position: f ? objectPosition(focalInCrop(f, c)) : null, data };
113
+ }
114
+
115
+ /** The stored framing an `<img>` carries back (`richTextImageFrame`'s `data`), for the ingest. */
116
+ export function readImageFrame(img: { getAttribute(name: string): string | null }): { src: string | null; crop: ImageCrop | null; focal: ImageFocal | null } {
117
+ const xy = (img.getAttribute("data-focal") ?? "").split(",").map(Number);
118
+ return {
119
+ src: img.getAttribute("data-src"),
120
+ crop: parseImageRect(img.getAttribute("data-crop")),
121
+ focal: xy.length === 2 ? normalizeImageFocal({ x: xy[0], y: xy[1] }) : null,
122
+ };
123
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,9 @@ export * from "./table";
8
8
  export * from "./key-takeaways";
9
9
  export * from "./gallery";
10
10
  export * from "./toc";
11
+ export * from "./footnote";
12
+ export * from "./columns";
13
+ export * from "./references";
11
14
  export * from "./styled";
12
15
  export * from "./read";
13
16
  export * from "./component-copy";