@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/package.json +4 -1
- package/src/accept.ts +68 -9
- package/src/columns.ts +117 -0
- package/src/embed.ts +242 -0
- package/src/features.ts +6 -23
- package/src/footnote.ts +70 -0
- package/src/gallery.ts +167 -0
- package/src/html.ts +234 -12
- package/src/image-layout.ts +54 -0
- package/src/index.ts +7 -0
- package/src/ingest.ts +406 -5
- package/src/plain.ts +16 -0
- package/src/read.ts +63 -0
- package/src/references.ts +147 -0
- package/src/schema.ts +67 -1
- package/src/toc.ts +146 -0
- package/src/types.ts +157 -2
- package/src/write-refusals.ts +114 -0
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
|
|
284
|
-
const
|
|
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>— ${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:
|
|
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
|
-
|
|
498
|
-
|
|
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
|
|
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];
|
package/src/image-layout.ts
CHANGED
|
@@ -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";
|