@bettercms-ai/richtext 0.14.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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/href.ts +39 -0
- package/src/html.ts +152 -51
- package/src/image-layout.ts +54 -0
- package/src/index.ts +3 -0
- package/src/ingest.ts +210 -33
- package/src/plain.ts +10 -0
- package/src/read.ts +59 -1
- package/src/references.ts +147 -0
- package/src/schema.ts +32 -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.17.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "The one Portable Text contract for BetterCMS: schema, converters, and rendering. Shared by the backend, the dashboard and every delivery SDK.",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"test:watch": "vitest"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@bettercms-ai/
|
|
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/href.ts
CHANGED
|
@@ -25,3 +25,42 @@ export function safeHref(raw: string): string | null {
|
|
|
25
25
|
return null;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A MEDIA url: `safeHref`, plus a plain relative path.
|
|
31
|
+
*
|
|
32
|
+
* An imported page is full of `images/hero.png` and `../media/clip.mp4`, and `safeHref` refuses
|
|
33
|
+
* a path with no leading slash — so every one of those images would render as nothing. Widened
|
|
34
|
+
* only for `<img>`, `<video>` and a poster: they load a picture, while an `<iframe>` frames a
|
|
35
|
+
* whole page and keeps the strict check. A colon or whitespace is refused, so no scheme
|
|
36
|
+
* (`javascript:`) can ride this path, and a PROTOCOL-RELATIVE `//host/clip.mp4` is refused with
|
|
37
|
+
* it — it is off-origin, not relative, and `safeHref` rejects the same shape for the same reason.
|
|
38
|
+
*/
|
|
39
|
+
export const safeMediaSrc = (raw: string): string | null =>
|
|
40
|
+
safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
|
|
41
|
+
|
|
42
|
+
/** What a new tab always carries on render, so it is never the author's own `rel`. */
|
|
43
|
+
const NEW_TAB_REL = ["noopener", "noreferrer"];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A link's `rel` is author data (V2's link form writes `nofollow`, `sponsored`, `ugc`; the
|
|
47
|
+
* importer keeps `prev`/`next`): plain link-type tokens only, lowercased, once each. V2's
|
|
48
|
+
* `follow` is its "no rel" and is no token.
|
|
49
|
+
*/
|
|
50
|
+
const relTokens = (raw: unknown): string[] =>
|
|
51
|
+
typeof raw === "string"
|
|
52
|
+
? [...new Set(raw.toLowerCase().split(/\s+/).filter((t) => /^[a-z][a-z-]*$/.test(t) && t !== "follow"))]
|
|
53
|
+
: [];
|
|
54
|
+
|
|
55
|
+
/** The `rel` to store from a link's markup: a new tab's noopener/noreferrer are the renderer's, not kept. */
|
|
56
|
+
export function readLinkRel(raw: unknown, blank: boolean): string | undefined {
|
|
57
|
+
const tokens = relTokens(raw).filter((t) => !blank || !NEW_TAB_REL.includes(t));
|
|
58
|
+
return tokens.length ? tokens.join(" ") : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `rel` both renderers write: the stored tokens, and a new tab always adds noopener + noreferrer. */
|
|
62
|
+
export function writeLinkRel(raw: unknown, blank: boolean): string | undefined {
|
|
63
|
+
const tokens = new Set(relTokens(raw));
|
|
64
|
+
if (blank) for (const t of NEW_TAB_REL) tokens.add(t);
|
|
65
|
+
return tokens.size ? [...tokens].join(" ") : undefined;
|
|
66
|
+
}
|