@bettercms-ai/richtext 0.12.1 → 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/src/gallery.ts ADDED
@@ -0,0 +1,167 @@
1
+ import { PT_TYPES } from "./types";
2
+ import { isPortableTextTable } from "./table";
3
+ import { safeHref } from "./href";
4
+ import { richTextImageFrame } from "./image-layout";
5
+
6
+ /**
7
+ * RT4: the gallery (`PortableTextGallery`, types.ts): the limits, the write check, and the read
8
+ * both renderers share.
9
+ *
10
+ * The limits are the V2 kit's (`ARTICLE_SCHEMAS.gallery` in `controls/rich-text/article.ts`): 2 to
11
+ * 24 images, a caption of at most 160 characters. As with key takeaways, V2's `min: 2` and its
12
+ * required image are the editor's, not the write's: an author adds images one at a time, and
13
+ * refusing the autosave in between would refuse the gallery they are building. So a gallery of
14
+ * one, or an image not yet chosen, stores; the view leaves an unchosen image out, as V2 does.
15
+ *
16
+ * The image is the rich-text image's own `{ src, alt }` (+ an optional media id), so whatever
17
+ * reads a rich-text image's src and alt reads a gallery image's the same way.
18
+ */
19
+ export const PT_GALLERY_LIMITS = { minImages: 2, images: 24, captionChars: 160 } as const;
20
+ export const GALLERY_LAYOUTS = ["grid", "carousel"] as const;
21
+ export type GalleryLayout = (typeof GALLERY_LAYOUTS)[number];
22
+
23
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
24
+ !!v && typeof v === "object" && !Array.isArray(v);
25
+
26
+ /** One reason a stored gallery is refused, at its own path inside the block. */
27
+ export type PortableTextGalleryProblem = { path: Array<string | number>; code: string; message: string };
28
+
29
+ const KEYS = new Set(["_type", "_key", "layout", "images"]);
30
+ const ITEM_KEYS = new Set(["_key", "image", "caption"]);
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;
33
+
34
+ /**
35
+ * Every reason `node` cannot be stored, each named with a code: `gallery_invalid` (a shape
36
+ * problem) or `gallery_limit_exceeded` (a V2 limit). Only a `gallery` block is judged; the type is
37
+ * new, so there is nothing stored before it to grandfather.
38
+ */
39
+ export function galleryProblems(node: unknown): PortableTextGalleryProblem[] {
40
+ const out: PortableTextGalleryProblem[] = [];
41
+ if (!isRecord(node) || node._type !== PT_TYPES.gallery) return out;
42
+ const bad = (path: Array<string | number>, message: string, code = "gallery_invalid") =>
43
+ out.push({ path, code, message: `${code}: ${message}` });
44
+ for (const k of Object.keys(node)) if (!KEYS.has(k)) bad([k], `unknown key "${k}" (layout, images)`);
45
+ if (!(GALLERY_LAYOUTS as readonly unknown[]).includes(node.layout)) bad(["layout"], 'layout must be "grid" or "carousel"');
46
+ if (!Array.isArray(node.images)) {
47
+ bad(["images"], "images must be an array");
48
+ return out;
49
+ }
50
+ node.images.forEach((item, i) => {
51
+ const at = ["images", i];
52
+ if (!isRecord(item)) return bad(at, `images[${i}] must be an object`);
53
+ for (const k of Object.keys(item)) if (!ITEM_KEYS.has(k)) bad([...at, k], `unknown key "${k}" (image, caption)`);
54
+ if (typeof item._key !== "string" || !item._key) bad([...at, "_key"], `images[${i}] needs a _key`);
55
+ if (item.caption !== undefined) {
56
+ if (typeof item.caption !== "string") bad([...at, "caption"], "caption must be a string");
57
+ else if (item.caption.length > PT_GALLERY_LIMITS.captionChars) {
58
+ bad([...at, "caption"], `caption is ${item.caption.length} characters; at most ${PT_GALLERY_LIMITS.captionChars}`, "gallery_limit_exceeded");
59
+ }
60
+ }
61
+ const image = item.image;
62
+ if (image === undefined || image === null) return;
63
+ if (!isRecord(image)) return bad([...at, "image"], "image must be { src, alt }");
64
+ for (const k of Object.keys(image)) if (!IMAGE_KEYS.has(k)) bad([...at, "image", k], `unknown key "${k}" (src, alt, mediaId)`);
65
+ if (typeof image.src !== "string") bad([...at, "image", "src"], "src must be a string");
66
+ if (typeof image.alt !== "string") bad([...at, "image", "alt"], "alt must be a string");
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
+ }
80
+ });
81
+ if (node.images.length > PT_GALLERY_LIMITS.images) {
82
+ bad(["images"], `${node.images.length} images; at most ${PT_GALLERY_LIMITS.images}`, "gallery_limit_exceeded");
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * `galleryProblems` for every gallery in a rich-text value (top level, and inside table cells),
89
+ * with the block's `_key` so a refusal names the block an author has to find.
90
+ */
91
+ export function portableTextGalleryProblems(value: unknown): Array<PortableTextGalleryProblem & { block: string }> {
92
+ const out: Array<PortableTextGalleryProblem & { block: string }> = [];
93
+ const walk = (blocks: unknown) => {
94
+ if (!Array.isArray(blocks)) return;
95
+ for (const node of blocks) {
96
+ if (isPortableTextTable(node)) {
97
+ for (const row of node.rows as unknown[]) {
98
+ if (!isRecord(row) || !Array.isArray(row.cells)) continue;
99
+ for (const cell of row.cells) if (isRecord(cell)) walk(cell.content);
100
+ }
101
+ continue;
102
+ }
103
+ if (!isRecord(node) || node._type !== PT_TYPES.gallery) continue;
104
+ const block = typeof node._key === "string" ? node._key : "";
105
+ for (const p of galleryProblems(node)) out.push({ ...p, block });
106
+ }
107
+ };
108
+ walk(value);
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * The same widened check the rich-text image's `<img>` gets (html.ts `mediaSrc`): http(s), or a
114
+ * site-relative path with no scheme, whitespace or protocol-relative `//`.
115
+ */
116
+ export const galleryMediaSrc = (raw: string): string | null =>
117
+ safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
118
+
119
+ /**
120
+ * The layout, as inline CSS both renderers write (ui turns each into a React style object). A grid
121
+ * of equal cells; a carousel is one image per view in a scroll-snap row, its no-JS fallback being
122
+ * itself.
123
+ */
124
+ export const GALLERY_STYLES: Record<GalleryLayout, { ul: string; li: string; figure: string; img: string }> = {
125
+ grid: {
126
+ ul: "display:grid;grid-template-columns:repeat(auto-fill,minmax(12rem,1fr));gap:1rem;list-style:none;margin:0;padding:0",
127
+ li: "margin:0",
128
+ figure: "margin:0",
129
+ img: "display:block;width:100%;height:auto",
130
+ },
131
+ carousel: {
132
+ ul: "display:flex;gap:1rem;overflow-x:auto;scroll-snap-type:x mandatory;list-style:none;margin:0;padding:0",
133
+ li: "flex:0 0 100%;scroll-snap-align:start;margin:0",
134
+ figure: "margin:0",
135
+ img: "display:block;width:100%;height:auto",
136
+ },
137
+ };
138
+
139
+ /**
140
+ * What a reader sees of a gallery: its layout (grid unless it says carousel) and each image that
141
+ * has a safe src, with its alt and trimmed caption. An unchosen image is left out, as V2's
142
+ * `renderArticle("gallery")` leaves it out. Read defensively: a value that never passed the write
143
+ * check renders what it can.
144
+ */
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[] } {
148
+ const v = isRecord(value) ? value : {};
149
+ const layout: GalleryLayout = v.layout === "carousel" ? "carousel" : "grid";
150
+ const images: GalleryViewImage[] = [];
151
+ (Array.isArray(v.images) ? v.images : []).forEach((item, i) => {
152
+ if (!isRecord(item) || !isRecord(item.image)) return;
153
+ const src = galleryMediaSrc(typeof item.image.src === "string" ? item.image.src.trim() : "");
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);
157
+ images.push({
158
+ key: typeof item._key === "string" && item._key ? item._key : `i${i}`,
159
+ src: frame.src,
160
+ position: frame.position,
161
+ data: frame.data,
162
+ alt: typeof item.image.alt === "string" ? item.image.alt : "",
163
+ caption: typeof item.caption === "string" ? item.caption.trim() : "",
164
+ });
165
+ });
166
+ return { layout, images };
167
+ }
package/src/html.ts CHANGED
@@ -14,8 +14,15 @@ 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
+ import { GALLERY_STYLES, galleryView } from "./gallery";
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";
25
+ import { isEmbedAspect, resolveEmbed, type EmbedCard, type EmbedRender } from "./embed";
19
26
 
20
27
  /**
21
28
  * Portable Text → HTML.
@@ -279,9 +286,13 @@ function imageHtml(value: unknown, classes: HtmlClasses): string {
279
286
  if (!src) return "";
280
287
  const caption = str(f.caption);
281
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);
282
291
  // A placed image fills its figure; the figure carries the width and position (image-layout.ts).
283
- const imgStyle = layout ? ' style="display:block;width:100%;height:auto"' : "";
284
- 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}>`;
285
296
  const figureClass = [classes.figure, layout?.className].filter(Boolean).join(" ");
286
297
  const figureAttrs = `${figureClass ? ` class="${esc(figureClass)}"` : ""}${
287
298
  layout ? ` style="${esc(layout.figureStyle)}"` : ""
@@ -420,12 +431,174 @@ function keyTakeawaysHtml(value: unknown, classes: HtmlClasses): string {
420
431
  return `<aside data-rt="takeaways">${title ? `<p data-title>${esc(title)}</p>` : ""}<ul${cls(classes, "ul")}>${items}</ul></aside>`;
421
432
  }
422
433
 
434
+ /**
435
+ * RT4: a gallery, V2's `<figure data-rt="gallery" data-layout><ul><li><figure><img><figcaption>`.
436
+ * The layout needs no script: a grid, or a carousel that is a scroll-snap row (swipe, scroll, or
437
+ * the keyboard once the row has focus). The styles are inline because a hosted page has no
438
+ * stylesheet for them; the ingest reads only the markup back. `src` comes last on the `<img>`, as
439
+ * React writes a lazy image's, so both renderers give the same bytes (R6).
440
+ */
441
+ function galleryHtml(value: unknown): string {
442
+ const { layout, images } = galleryView(value);
443
+ const s = GALLERY_STYLES[layout];
444
+ const items = images
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)}">${
448
+ x.caption ? `<figcaption>${esc(x.caption)}</figcaption>` : ""
449
+ }</figure></li>`)
450
+ .join("");
451
+ const focus = layout === "carousel" ? ' tabindex="0"' : "";
452
+ return `<figure data-rt="gallery" data-layout="${layout}"><ul style="${s.ul}"${focus}>${items}</ul></figure>`;
453
+ }
454
+
455
+ /**
456
+ * RT5: the outline of the value being rendered. Set by `portableTextToHtml` for a whole-value
457
+ * render and restored after it (a render is synchronous, and a table cell renders inside another
458
+ * one); an inline render (a cell, a heading field) keeps the value's outline it sits in.
459
+ */
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
+ }
530
+
531
+ /** An h2 or h3 with its outline id, so a table of contents (or any link) can point at it. */
532
+ const headingWithId = (tag: "h2" | "h3", classes: HtmlClasses) =>
533
+ ({ value, children }: { value: object; children?: string }) => {
534
+ const id = outline.nodes.get(value);
535
+ return `<${tag}${id ? ` id="${esc(id)}"` : ""}${cls(classes, tag)}>${children ?? ""}</${tag}>`;
536
+ };
537
+
538
+ /**
539
+ * RT5: a table of contents, V2's `<nav data-rt="toc" data-depth aria-label><p data-title>…</p>
540
+ * <ol><li><a href="#id">…</a><ol>…</ol></li></ol></nav>`, listing the outline of the value it is in.
541
+ */
542
+ function tocHtml(value: unknown): string {
543
+ const { title, label, depth } = tocView(value);
544
+ const link = (o: OutlineItem) => `<a href="#${esc(o.id)}">${esc(o.text)}</a>`;
545
+ const items = tocTree(outline.items, depth)
546
+ .map((e) => `<li>${link(e)}${e.children.length ? `<ol>${e.children.map((c) => `<li>${link(c)}</li>`).join("")}</ol>` : ""}</li>`)
547
+ .join("");
548
+ return `<nav data-rt="toc" data-depth="${depth}" aria-label="${esc(label)}">${title ? `<p data-title>${esc(title)}</p>` : ""}<ol>${items}</ol></nav>`;
549
+ }
550
+
551
+ /**
552
+ * An X post's card, from the oEmbed data the server fetched (plain strings, escaped here; never
553
+ * X's markup). Always the blockquote X's widget recognises: it reads as a quote without
554
+ * JavaScript, and on a site that opted in, the page's one widgets.js (next `BcmsEmbedScripts`)
555
+ * upgrades it in place. No script here: the html is a function of the value and its cards only,
556
+ * so turning the opt-in on or off re-bakes no html cache.
557
+ */
558
+ function xCardHtml(url: string, card: EmbedCard): string {
559
+ return `<blockquote class="twitter-tweet bcms-embed-card" cite="${esc(url)}"><p>${esc(card.text)}</p>&mdash; ${esc(card.authorName)} <a href="${esc(url)}">${esc(card.date || "View on X")}</a></blockquote>`;
560
+ }
561
+
562
+ /**
563
+ * RT3: an embed, V2's `<figure data-rt="embed" data-url data-provider data-aspect>` around what
564
+ * its url resolves to (`resolveEmbed`): the provider's frame, an audio player, an X card, or a
565
+ * link card. `aria-label` carries the stored title, present even when empty, so the ingest reads
566
+ * back exactly what was stored (V2's own markup has the title in the link instead).
567
+ */
568
+ function embedFigureHtml(value: unknown, embeds: EmbedRender): string {
569
+ const v = (value ?? {}) as { url?: unknown; title?: unknown; aspect?: unknown };
570
+ const url = typeof v.url === "string" ? v.url.trim() : "";
571
+ const aspect = isEmbedAspect(v.aspect) ? v.aspect : "16:9";
572
+ const title = typeof v.title === "string" ? v.title.trim() : "";
573
+ const r = resolveEmbed(url, aspect);
574
+ const label = title || r.label;
575
+ let body: string;
576
+ if (r.kind === "frame") body = `<iframe src="${esc(r.src)}" title="${esc(label)}" width="${r.width}" height="${r.height}"${IFRAME_ATTRS}></iframe>`;
577
+ else if (r.kind === "audio") body = `<audio controls preload="none" src="${esc(r.src)}" title="${esc(label)}"></audio>`;
578
+ // Cards are keyed by the canonical url (`r.url`), so a post pasted two ways finds its card.
579
+ else if (r.kind === "card" && embeds.cards?.[r.url]) body = xCardHtml(r.url, embeds.cards[r.url]);
580
+ // A link card: no rule frames this url, or it is an X post with no card data for this render.
581
+ else body = r.url ? `<a href="${esc(r.url)}">${esc(label)}</a>` : "";
582
+ return `<figure data-rt="embed" data-url="${esc(url)}" data-provider="${r.provider}" data-aspect="${aspect}" aria-label="${esc(title)}">${body}</figure>`;
583
+ }
584
+
423
585
  export interface RenderOptions {
424
586
  /**
425
587
  * Render a single value with no wrapping `<p>`, for a field whose consumer supplies its own
426
588
  * heading or button. Mirrors `allowMultipleParagraphs: false` and the old `toInlineHtml`.
427
589
  */
428
590
  inline?: boolean;
591
+ /**
592
+ * RT3: what an embed needs that the value does not hold: the X cards the server fetched for this
593
+ * render (by canonical url). Absent: every X post is a link card.
594
+ */
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;
429
602
  /**
430
603
  * The site's own class per tag, for a value derived FROM that site.
431
604
  *
@@ -480,6 +653,17 @@ const bindingAttrs = (base: string, key: unknown): string =>
480
653
  ? ` data-bcms-field="${esc(base)}#${esc(key)}" data-bcms-kind="richtext"`
481
654
  : "";
482
655
 
656
+ /**
657
+ * A checklist item, as V2 stores it (`<li data-checked="true|false">`), with a disabled checkbox in
658
+ * front of the words so a page without the V2 stylesheet still shows the box and its state, and a
659
+ * screen reader announces it. The ingest reads `data-checked` back and skips the box.
660
+ */
661
+ const checkItemHtml = (classes: HtmlClasses, value: { checked?: unknown }, children: string | undefined, address: string): string => {
662
+ const checked = value.checked === true;
663
+ // `disabled` before `type`: the order React writes an input's attributes in, so the two renderers match (R6).
664
+ return `<li${cls(classes, "li")}${address} data-checked="${checked}"><input disabled type="checkbox"${checked ? " checked" : ""}>${children ?? ""}</li>`;
665
+ };
666
+
483
667
  /**
484
668
  * `keys` is the set of `_key`s the value actually carries: to-html invents one (`li-0-1`) for an
485
669
  * item that has none, and an invented key is an address that resolves to nothing.
@@ -488,14 +672,27 @@ const buildComponents = (
488
672
  classes: HtmlClasses = {},
489
673
  binding?: { base: string; keys: ReadonlySet<unknown> },
490
674
  renderBlock?: RenderOptions["renderBlock"],
675
+ embeds: EmbedRender = {},
676
+ resolved?: RichTextResolved,
491
677
  ): Partial<PortableTextHtmlComponents> => ({
492
- block: tagOverrides(classes, STYLE_TAGS),
493
- list: tagOverrides(classes, { bullet: "ul", number: "ol", check: "ul" }),
678
+ block: { ...tagOverrides(classes, STYLE_TAGS), h2: headingWithId("h2", classes), h3: headingWithId("h3", classes) },
679
+ list: {
680
+ ...tagOverrides(classes, { bullet: "ul", number: "ol" }),
681
+ // A checklist is V2's `<ul data-task="true">`, dressed or not: it is not a bullet list.
682
+ check: ({ children }: { children?: string }) => `<ul data-task="true"${cls(classes, "ul")}>${children ?? ""}</ul>`,
683
+ },
494
684
  // With a base, every item carries its own address ON the `<li>` — see `bindingBase`.
495
685
  listItem: binding
496
- ? ({ value, children }: { value: { _key?: unknown }; children?: string }) =>
497
- `<li${cls(classes, "li")}${binding.keys.has(value._key) ? bindingAttrs(binding.base, value._key) : ""}>${children ?? ""}</li>`
498
- : tagOverrides(classes, { bullet: "li", number: "li", check: "li" }),
686
+ ? ({ value, children }: { value: { _key?: unknown; listItem?: unknown; checked?: unknown }; children?: string }) => {
687
+ const address = binding.keys.has(value._key) ? bindingAttrs(binding.base, value._key) : "";
688
+ return value.listItem === "check"
689
+ ? checkItemHtml(classes, value, children, address)
690
+ : `<li${cls(classes, "li")}${address}>${children ?? ""}</li>`;
691
+ }
692
+ : {
693
+ ...tagOverrides(classes, { bullet: "li", number: "li" }),
694
+ check: ({ value, children }) => checkItemHtml(classes, value as { checked?: unknown }, children, ""),
695
+ },
499
696
  types: {
500
697
  [PT_TYPES.divider]: () => "<hr>",
501
698
  [PT_TYPES.table]: ({ value }) => tableHtml(value as PortableTextTable),
@@ -510,6 +707,12 @@ const buildComponents = (
510
707
  [PT_TYPES.file]: ({ value }) => fileHtml(value, classes),
511
708
  [PT_TYPES.pullQuote]: ({ value }) => pullQuoteHtml(value, classes),
512
709
  [PT_TYPES.keyTakeaways]: ({ value }) => keyTakeawaysHtml(value, classes),
710
+ [PT_TYPES.embed]: ({ value }) => embedFigureHtml(value, embeds),
711
+ [PT_TYPES.gallery]: ({ value }) => galleryHtml(value),
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)),
513
716
  /**
514
717
  * A generic embedded block. A block whose payload this serialiser can READ renders as
515
718
  * content; one carrying a reference it would have to resolve against a project renders as a
@@ -526,6 +729,8 @@ const buildComponents = (
526
729
  if (v.schemaKey === BUILTIN_EMBED_KEY) return embedHtml(value, classes);
527
730
  if (v.schemaKey === BUILTIN_CALLOUT_KEY) return calloutHtml(value);
528
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));
529
734
  /**
530
735
  * A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
531
736
  * nothing. Its rows are text, held right here, so a marker would DELETE visible content.
@@ -545,6 +750,7 @@ const buildComponents = (
545
750
  : "block";
546
751
  return `<div class="bcms-block" data-bcms-schema-key="${esc(key)}"></div>`;
547
752
  },
753
+ [PT_TYPES.footnote]: ({ value }) => footnoteMarkHtml(value),
548
754
  [PT_TYPES.mention]: ({ value }) => {
549
755
  const v = value as { label?: unknown };
550
756
  return `<span class="bcms-mention">${esc(typeof v.label === "string" ? v.label : "")}</span>`;
@@ -557,9 +763,11 @@ const buildComponents = (
557
763
  * renders the LABEL rather than deleting it — a blocked link must not lose the words.
558
764
  */
559
765
  [PT_TYPES.link]: ({ value, children }) => {
560
- const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown; className?: unknown };
766
+ const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown; className?: unknown; button?: unknown };
561
767
  const href = typeof v.href === "string" ? safeHref(v.href) : null;
562
768
  if (!href) return children;
769
+ // RT2: a button is a link the author marked as one; V2's `data-button` is the site's style hook.
770
+ const button = v.button === true ? ' data-button="true"' : "";
563
771
  // `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
564
772
  // plain link-type tokens pass; a new tab always adds noopener + noreferrer.
565
773
  const rel = new Set(
@@ -573,7 +781,7 @@ const buildComponents = (
573
781
  const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
574
782
  // The link's own class (kept on ingest) wins over the caller's default: it IS the site.
575
783
  const own = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
576
- return `<a${own || cls(classes, "a")} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
784
+ return `<a${own || cls(classes, "a")}${button} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
577
785
  },
578
786
  /**
579
787
  * The site's own inline markup, written back out as itself.
@@ -617,12 +825,26 @@ export function portableTextToHtml(
617
825
  opts: RenderOptions = {},
618
826
  ): string {
619
827
  if (!Array.isArray(value) || value.length === 0) return "";
828
+ if (opts.inline) return render(value, opts);
829
+ const outer = { outline, footnotes };
830
+ outline = outlineOf(value);
831
+ footnotes = footnoteOrder(value);
832
+ try {
833
+ return render(value, opts) + footnoteListHtml(footnotes.notes);
834
+ } finally {
835
+ ({ outline, footnotes } = outer);
836
+ }
837
+ }
838
+
839
+ function render(value: PortableText, opts: RenderOptions): string {
620
840
  const components =
621
- opts.classes || opts.renderBlock ? buildComponents(opts.classes, undefined, opts.renderBlock) : defaultComponents;
841
+ opts.classes || opts.renderBlock || opts.embeds || opts.resolved
842
+ ? buildComponents(opts.classes, undefined, opts.renderBlock, opts.embeds, opts.resolved)
843
+ : defaultComponents;
622
844
 
623
845
  if (opts.bindingBase) {
624
846
  const base = opts.bindingBase;
625
- const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock);
847
+ const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock, opts.embeds, opts.resolved);
626
848
  const out: string[] = [];
627
849
  for (let i = 0; i < value.length; ) {
628
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
@@ -6,10 +6,17 @@ export * from "./from-lexical";
6
6
  export * from "./plain";
7
7
  export * from "./table";
8
8
  export * from "./key-takeaways";
9
+ export * from "./gallery";
10
+ export * from "./toc";
11
+ export * from "./footnote";
12
+ export * from "./columns";
13
+ export * from "./references";
9
14
  export * from "./styled";
10
15
  export * from "./read";
11
16
  export * from "./component-copy";
12
17
  export * from "./accept";
13
18
  export * from "./features";
19
+ export * from "./write-refusals";
14
20
  export * from "./video";
21
+ export * from "./embed";
15
22
  export * from "./image-layout";