@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 +3 -2
- package/src/accept.ts +12 -6
- package/src/columns.ts +117 -0
- package/src/footnote.ts +70 -0
- package/src/gallery.ts +24 -4
- package/src/html.ts +104 -11
- package/src/image-layout.ts +54 -0
- package/src/index.ts +3 -0
- package/src/ingest.ts +188 -5
- package/src/plain.ts +10 -0
- package/src/read.ts +38 -1
- package/src/references.ts +147 -0
- package/src/schema.ts +29 -0
- package/src/toc.ts +25 -8
- package/src/types.ts +74 -2
- package/src/write-refusals.ts +14 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "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/
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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'
|
|
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
|
+
}
|
package/src/footnote.ts
ADDED
|
@@ -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
|
|
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:
|
|
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
|
|
287
|
-
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}>`;
|
|
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"
|
|
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)
|
|
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];
|
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
|
@@ -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";
|