@bettercms-ai/richtext 0.8.3 → 0.10.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 +2 -1
- package/src/component-copy.ts +138 -0
- package/src/html.ts +140 -10
- package/src/index.ts +2 -0
- package/src/ingest.ts +333 -28
- package/src/key-takeaways.ts +93 -0
- package/src/plain.ts +55 -7
- package/src/read.ts +19 -1
- package/src/schema.ts +39 -1
- package/src/table.ts +291 -0
- package/src/types.ts +168 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"src",
|
|
11
11
|
"!src/__tests__",
|
|
12
|
+
"!src/__fixtures__",
|
|
12
13
|
"package.json",
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { EMBEDDED_COMPONENT_KEY } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A component embedded in rich text, kept as a SEPARATE COPY (V2 "Keep as a separate copy" /
|
|
5
|
+
* "Make a separate copy", bettercms-v2-demo features/blocks/instances.ts `instanceOps.detach`).
|
|
6
|
+
*
|
|
7
|
+
* Linked (every value stored before this existed):
|
|
8
|
+
* { _type: "bcmsBlock", schemaKey: "component", fields: { componentId, overrides?, variant? } }
|
|
9
|
+
* → renders the shared component with this place's overrides; edits to the component reach it.
|
|
10
|
+
*
|
|
11
|
+
* Detached:
|
|
12
|
+
* { _type: "bcmsBlock", schemaKey: "component", fields: {
|
|
13
|
+
* componentId, the component it was copied from (kept for "Use shared version again")
|
|
14
|
+
* detached: true,
|
|
15
|
+
* props, the values readers saw when it was copied (the component's ⊕ this place's)
|
|
16
|
+
* blocks, the block tree readers see, resolved and frozen: no `component` block inside
|
|
17
|
+
* sourcePublishedAt, the source's `lastPublishedAt` when it was copied (0454; older copies hold its first `publishedAt`): the version it was taken from
|
|
18
|
+
* variant?
|
|
19
|
+
* } }
|
|
20
|
+
* → renders `blocks` and never looks the component up again.
|
|
21
|
+
*
|
|
22
|
+
* V2 keeps only `props` because its renderer draws a catalog type from props. Here a component's
|
|
23
|
+
* markup is its own block tree, so a copy that kept only props would still follow the shared
|
|
24
|
+
* component's structure; `blocks` is what makes the copy separate.
|
|
25
|
+
*/
|
|
26
|
+
export interface DetachedComponentCopy {
|
|
27
|
+
/** The node's `_key`. */
|
|
28
|
+
id: string;
|
|
29
|
+
componentId: string;
|
|
30
|
+
props: Record<string, unknown>;
|
|
31
|
+
blocks: unknown[];
|
|
32
|
+
sourcePublishedAt?: string;
|
|
33
|
+
variant?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
37
|
+
|
|
38
|
+
const fieldsOf = (value: unknown): Record<string, unknown> | null =>
|
|
39
|
+
isRecord(value) && value.schemaKey === EMBEDDED_COMPONENT_KEY && isRecord(value.fields) ? value.fields : null;
|
|
40
|
+
|
|
41
|
+
/** True when the node is an embedded component stored as a separate copy (whatever else it holds). */
|
|
42
|
+
export function isDetachedComponent(value: unknown): boolean {
|
|
43
|
+
return fieldsOf(value)?.detached === true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The separate copy a node stands for, or null (a linked embed, any other node, a malformed copy). */
|
|
47
|
+
export function detachedComponentCopy(value: unknown): DetachedComponentCopy | null {
|
|
48
|
+
const f = fieldsOf(value);
|
|
49
|
+
if (!f || f.detached !== true || !Array.isArray(f.blocks)) return null;
|
|
50
|
+
return {
|
|
51
|
+
id: isRecord(value) && typeof value._key === "string" ? value._key : "",
|
|
52
|
+
componentId: typeof f.componentId === "string" ? f.componentId : "",
|
|
53
|
+
props: isRecord(f.props) ? f.props : {},
|
|
54
|
+
blocks: f.blocks,
|
|
55
|
+
...(typeof f.sourcePublishedAt === "string" ? { sourcePublishedAt: f.sourcePublishedAt } : {}),
|
|
56
|
+
...(typeof f.variant === "string" ? { variant: f.variant } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One reason a stored copy is refused, at its path inside the node's `fields`. */
|
|
61
|
+
export type ComponentCopyProblem = { path: Array<string | number>; code: string; message: string };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every reason a separate copy cannot be stored, named `component_copy_invalid` (the shape) or
|
|
65
|
+
* `component_copy_not_frozen` (its blocks still reference a component, so it would keep
|
|
66
|
+
* following one).
|
|
67
|
+
*
|
|
68
|
+
* 🔴 ONLY A NODE THAT CARRIES `detached` IS JUDGED. A linked embed answers `[]`, so every value
|
|
69
|
+
* stored before copies existed saves exactly as it did.
|
|
70
|
+
*/
|
|
71
|
+
export function componentCopyProblems(node: unknown): ComponentCopyProblem[] {
|
|
72
|
+
const f = fieldsOf(node);
|
|
73
|
+
if (!f || f.detached === undefined) return [];
|
|
74
|
+
const out: ComponentCopyProblem[] = [];
|
|
75
|
+
const bad = (path: Array<string | number>, message: string, code = "component_copy_invalid") =>
|
|
76
|
+
out.push({ path, code, message: `${code}: ${message}` });
|
|
77
|
+
if (f.detached !== true) bad(["detached"], "detached must be true (leave it out for a linked component)");
|
|
78
|
+
if (typeof f.componentId !== "string" || !f.componentId) bad(["componentId"], "needs the componentId it was copied from");
|
|
79
|
+
if (!isRecord(f.props)) bad(["props"], "props must be an object of the values readers see");
|
|
80
|
+
if (!Array.isArray(f.blocks)) {
|
|
81
|
+
bad(["blocks"], "needs blocks, the copy readers see; get_component { detachedCopy } returns them");
|
|
82
|
+
} else {
|
|
83
|
+
const live = componentRefs(f.blocks);
|
|
84
|
+
if (live.length) {
|
|
85
|
+
bad(["blocks"], `still holds ${live.length} component block(s) (${live.slice(0, 3).join(", ")}), so it would keep following the shared one`, "component_copy_not_frozen");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (f.sourcePublishedAt !== undefined && (typeof f.sourcePublishedAt !== "string" || Number.isNaN(Date.parse(f.sourcePublishedAt)))) {
|
|
89
|
+
bad(["sourcePublishedAt"], "sourcePublishedAt must be an ISO date");
|
|
90
|
+
}
|
|
91
|
+
if (isRecord(f.overrides) && Object.keys(f.overrides).length) {
|
|
92
|
+
bad(["overrides"], "a separate copy keeps its values in props; overrides belong to a linked component");
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `componentCopyProblems` for every embedded component at the top of a rich-text value, with its `_key`. */
|
|
98
|
+
export function portableTextComponentCopyProblems(value: unknown): Array<ComponentCopyProblem & { block: string }> {
|
|
99
|
+
if (!Array.isArray(value)) return [];
|
|
100
|
+
return value.flatMap((node) => {
|
|
101
|
+
const block = isRecord(node) && typeof node._key === "string" ? node._key : "";
|
|
102
|
+
return componentCopyProblems(node).map((p) => ({ ...p, block }));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The component ids a block tree still references, at any depth. */
|
|
107
|
+
function componentRefs(blocks: unknown, out: string[] = [], depth = 0): string[] {
|
|
108
|
+
if (depth > 32) return out;
|
|
109
|
+
if (Array.isArray(blocks)) {
|
|
110
|
+
for (const b of blocks) componentRefs(b, out, depth + 1);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
if (!isRecord(blocks)) return out;
|
|
114
|
+
if (blocks.type === "component") {
|
|
115
|
+
const p = isRecord(blocks.props) ? blocks.props : {};
|
|
116
|
+
out.push(typeof p.componentId === "string" && p.componentId ? p.componentId : "(no id)");
|
|
117
|
+
}
|
|
118
|
+
for (const v of Object.values(blocks)) if (v && typeof v === "object") componentRefs(v, out, depth + 1);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a separate copy says, one line per value, for search and plain text: its own copy is
|
|
124
|
+
* content this page owns, unlike a linked embed, whose words live in the component.
|
|
125
|
+
* Strings (markup stripped), numbers, and a link's or button's label; nothing else.
|
|
126
|
+
*/
|
|
127
|
+
export function componentCopyLines(copy: Pick<DetachedComponentCopy, "props">): string[] {
|
|
128
|
+
const line = (v: unknown): string | null => {
|
|
129
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
130
|
+
if (typeof v === "string") {
|
|
131
|
+
const t = v.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
|
132
|
+
return t || null;
|
|
133
|
+
}
|
|
134
|
+
if (isRecord(v)) return line(v.label ?? v.text ?? v.title);
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
return Object.values(copy.props).map(line).filter((s): s is string => !!s);
|
|
138
|
+
}
|
package/src/html.ts
CHANGED
|
@@ -3,14 +3,17 @@ import {
|
|
|
3
3
|
BUILTIN_EMBED_KEY,
|
|
4
4
|
BUILTIN_IMAGE_KEY,
|
|
5
5
|
PT_TYPES,
|
|
6
|
+
pullQuoteHref,
|
|
6
7
|
type PortableText,
|
|
7
8
|
type PortableTextTable,
|
|
8
9
|
} from "./types";
|
|
9
10
|
import { resolveVideoSource } from "./video";
|
|
10
|
-
import { builtInTableToTable } from "./table";
|
|
11
|
+
import { builtInTableToTable, designOf, highlightedColumns, jsonLdOf, sortableColumns, tableGroupLayout, tableSections } from "./table";
|
|
12
|
+
import { portableTextToPlainText } from "./plain";
|
|
11
13
|
import { safeHref } from "./href";
|
|
12
14
|
import { isStyledTag, styledAttrEntries } from "./styled";
|
|
13
15
|
import { richTextImageLayout } from "./image-layout";
|
|
16
|
+
import { keyTakeawaysView } from "./key-takeaways";
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Portable Text → HTML.
|
|
@@ -62,34 +65,116 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
|
62
65
|
const attr = (name: string, value: unknown): string =>
|
|
63
66
|
typeof value === "number" && Number.isInteger(value) && value > 1 ? ` ${name}="${value}"` : "";
|
|
64
67
|
|
|
68
|
+
/** V2 schema/table.ts withTableRoles, verbatim: a stacked table's explicit ARIA roles. */
|
|
69
|
+
function withTableRoles(html: string): string {
|
|
70
|
+
return html
|
|
71
|
+
.replace(/<table(?=[\s>])/, '<table role="table"')
|
|
72
|
+
.replace(/<(thead|tbody|tfoot)(?=[\s>])/g, '<$1 role="rowgroup"')
|
|
73
|
+
.replace(/<tr(?=[\s>])/g, '<tr role="row"')
|
|
74
|
+
.replace(/<th scope="(col|colgroup)"/g, '<th role="columnheader" scope="$1"')
|
|
75
|
+
.replace(/<th scope="(row|rowgroup)"/g, '<th role="rowheader" scope="$1"')
|
|
76
|
+
.replace(/<td(?=[\s>])/g, '<td role="cell"');
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
function tableHtml(table: PortableTextTable): string {
|
|
66
80
|
const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
|
|
67
81
|
if (rows.length === 0) return "";
|
|
68
82
|
|
|
69
|
-
|
|
83
|
+
// "Highlight this column": every cell starting in a marked column (V2 stamps each th/td of it).
|
|
84
|
+
const marked = highlightedColumns(table);
|
|
85
|
+
// "On phones" (stack / cards): each body cell names its column (`data-label`, V2 renderTableHtml), from the last head row
|
|
86
|
+
const design = designOf(table.design);
|
|
87
|
+
const stacked = design?.responsive === "stack" || design?.responsive === "cards";
|
|
88
|
+
const labels: string[] = [];
|
|
89
|
+
if (stacked) {
|
|
90
|
+
const head = tableSections(table).head;
|
|
91
|
+
const last = head[head.length - 1];
|
|
92
|
+
let at = 0;
|
|
93
|
+
for (const c of isRecord(last) && Array.isArray(last.cells) ? last.cells : []) {
|
|
94
|
+
const text = isRecord(c) ? portableTextToPlainText(c.content as PortableText).replace(/\s+/g, " ").trim() : "";
|
|
95
|
+
const w = isRecord(c) && typeof c.span === "number" && Number.isInteger(c.span) && c.span > 1 ? c.span : 1;
|
|
96
|
+
for (let i = 0; i < w; i += 1) labels[at + i] = text;
|
|
97
|
+
at += w;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const cellHtml = (raw: unknown, col: number): string => {
|
|
70
101
|
const cell = isRecord(raw) ? raw : {};
|
|
71
102
|
const tag = cell.header === true ? "th" : "td";
|
|
72
103
|
// `colspan` before `rowspan`, the order every table in the corpus writes them in: the
|
|
73
104
|
// comparison is on serialised markup, and a parser preserves the order it was given.
|
|
74
|
-
const
|
|
105
|
+
const label = stacked && tag === "td" ? ` data-label="${esc(labels[col] || `Column ${col + 1}`)}"` : "";
|
|
106
|
+
const attrs = `${attr("colspan", cell.span)}${attr("rowspan", cell.rowSpan)}${label}${marked.has(col) ? ' data-highlight="true"' : ""}`;
|
|
75
107
|
return `<${tag}${attrs}>${portableTextToHtml(cell.content as PortableText, { inline: true })}</${tag}>`;
|
|
76
108
|
};
|
|
77
109
|
const rowHtml = (raw: unknown): string => {
|
|
78
110
|
const cells = isRecord(raw) && Array.isArray(raw.cells) ? raw.cells : [];
|
|
79
|
-
|
|
111
|
+
// "Highlight this row": the V2 table's own attribute (src/schema/table.ts rowHtml).
|
|
112
|
+
const highlight = isRecord(raw) && raw.highlight === true ? ' data-highlight="true"' : "";
|
|
113
|
+
let col = 0;
|
|
114
|
+
const html = cells.map((c) => {
|
|
115
|
+
const out = cellHtml(c, col);
|
|
116
|
+
col += isRecord(c) && typeof c.span === "number" && Number.isInteger(c.span) && c.span > 1 ? c.span : 1;
|
|
117
|
+
return out;
|
|
118
|
+
});
|
|
119
|
+
return `<tr${highlight}>${html.join("")}</tr>`;
|
|
80
120
|
};
|
|
81
121
|
|
|
82
122
|
// The LEADING RUN of head rows, not every row flagged one: `<thead>` is a section, and a
|
|
83
123
|
// header row appearing after body rows cannot be lifted out of document order without moving
|
|
84
124
|
// content the author placed.
|
|
85
|
-
|
|
86
|
-
const head
|
|
87
|
-
const
|
|
125
|
+
// Likewise the TRAILING run of footer (totals) rows is `<tfoot>`, after every `<tbody>` (V2).
|
|
126
|
+
const { head, body, foot } = tableSections(table);
|
|
127
|
+
const layout = tableGroupLayout(table);
|
|
88
128
|
// The platform's own class, on migrated values only — see `PortableTextTable["legacy"]`.
|
|
89
129
|
const cls = table.legacy === true ? ' class="bcms-table"' : "";
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}</
|
|
130
|
+
const notes = typeof table.notes === "string" ? table.notes.trim() : "";
|
|
131
|
+
const notesId = notes && typeof table._key === "string" && table._key ? `${table._key}-notes` : "";
|
|
132
|
+
const caption = typeof table.caption === "string" && table.caption.trim() ? `<caption>${esc(table.caption.trim())}</caption>` : "";
|
|
133
|
+
|
|
134
|
+
// Column groups: one `<colgroup>` per run of columns, and a `scope="colgroup"` header over each
|
|
135
|
+
// grouped run, as the first row of `<thead>` (the V2 table's layout).
|
|
136
|
+
// "Visitors can sort columns": one `<col data-sortable="true">` per sortable column, as the V2 table writes it
|
|
137
|
+
// (src/schema/table.ts renderTableHtml); with `<col>`s a column group's `<colgroup>` holds them instead of a `span`.
|
|
138
|
+
const sortable = sortableColumns(table);
|
|
139
|
+
let colAt = 0;
|
|
140
|
+
const cols = (width: number): string => {
|
|
141
|
+
const out = Array.from({ length: width }, (_, i) => (sortable.has(colAt + i) ? '<col data-sortable="true">' : "<col>")).join("");
|
|
142
|
+
colAt += width;
|
|
143
|
+
return out;
|
|
144
|
+
};
|
|
145
|
+
const colgroups = sortable.size
|
|
146
|
+
? (layout.columns.length ? layout.columns.map((run) => run.width) : [layout.width]).map((w) => `<colgroup>${cols(w)}</colgroup>`).join("")
|
|
147
|
+
: layout.columns.map((run) => `<colgroup${attr("span", run.width)}></colgroup>`).join("");
|
|
148
|
+
const groupRow = layout.columns.length
|
|
149
|
+
? `<tr>${layout.columns
|
|
150
|
+
.map((run) => (run.label === undefined ? "<td></td>".repeat(run.width) : `<th scope="colgroup"${attr("colspan", run.width)} data-group="${esc(run.key ?? "")}">${esc(run.label)}</th>`))
|
|
151
|
+
.join("")}</tr>`
|
|
152
|
+
: "";
|
|
153
|
+
const thead = groupRow || head.length ? `<thead>${groupRow}${head.map(rowHtml).join("")}</thead>` : "";
|
|
154
|
+
|
|
155
|
+
// Row groups: each run of body rows sharing a group is its own `<tbody>`, under a
|
|
156
|
+
// `scope="rowgroup"` label row. With no grouped row, ONE `<tbody>`, exactly as before.
|
|
157
|
+
const tbody = layout.rows.length
|
|
158
|
+
? layout.rows
|
|
159
|
+
.map((run) => `<tbody${run.key === undefined ? "" : ` data-key="${esc(run.key)}"`}>${run.label === undefined ? "" : `<tr><th scope="rowgroup"${attr("colspan", layout.width)}>${esc(run.label)}</th></tr>`}${run.rows.map(rowHtml).join("")}</tbody>`)
|
|
160
|
+
.join("")
|
|
161
|
+
: body.length ? `<tbody>${body.map(rowHtml).join("")}</tbody>` : "";
|
|
162
|
+
const tfoot = foot.length ? `<tfoot>${foot.map(rowHtml).join("")}</tfoot>` : "";
|
|
163
|
+
|
|
164
|
+
const plainHtml = `<table${cls}${notesId ? ` aria-describedby="${esc(notesId)}"` : ""}>${caption}${colgroups}${thead}${tbody}${tfoot}</table>`;
|
|
165
|
+
// A note sits UNDER the table, so it needs a container to stay with it: the V2 kit's
|
|
166
|
+
// `<figure data-rt="table">`, which the ingest reads back.
|
|
167
|
+
// "Visitors can search rows" is the figure's `data-filter="true"` (V2 renderTableHtml), so it takes the figure too.
|
|
168
|
+
const filter = table.filterable === true ? ' data-filter="true"' : "";
|
|
169
|
+
// "Help search engines read this as": V2 renderTableHtml's data-jsonld (+ axis, price row) on the figure, after data-filter
|
|
170
|
+
const j = jsonLdOf(table.jsonLd);
|
|
171
|
+
const jsonLd = j ? ` data-jsonld="${j.type}"${j.axis ? ` data-jsonld-axis="${j.axis}"` : ""}${j.priceRow ? ` data-jsonld-price="${esc(j.priceRow)}"` : ""}` : "";
|
|
172
|
+
// "On phones" / "Keep column names in view": V2 renderTableHtml's data-responsive / data-sticky, before data-filter; a
|
|
173
|
+
// stacked table takes V2's withTableRoles (explicit ARIA roles, since display:block drops the implicit ones)
|
|
174
|
+
const designAttrs = design ? `${design.responsive ? ` data-responsive="${design.responsive}"` : ""}${design.sticky ? ' data-sticky="true"' : ""}` : "";
|
|
175
|
+
const html = stacked ? withTableRoles(plainHtml) : plainHtml;
|
|
176
|
+
if (!notes && !filter && !jsonLd && !designAttrs) return html;
|
|
177
|
+
return `<figure data-rt="table"${designAttrs}${filter}${jsonLd}>${html}${notes ? `<p data-notes${notesId ? ` id="${esc(notesId)}"` : ""}>${esc(notes)}</p>` : ""}</figure>`;
|
|
93
178
|
}
|
|
94
179
|
|
|
95
180
|
/**
|
|
@@ -215,6 +300,49 @@ function fileHtml(value: unknown, classes: HtmlClasses): string {
|
|
|
215
300
|
return `<a${cls(classes, "a")} href="${esc(src)}" download>${esc(name)}</a>`;
|
|
216
301
|
}
|
|
217
302
|
|
|
303
|
+
/**
|
|
304
|
+
* A pull quote, as the V2 kit renders it (`renderArticle("pullquote")`, article.ts): the same
|
|
305
|
+
* markup when no classes are passed, with one byte-level difference — a `"` in text comes out
|
|
306
|
+
* as `"` here (as everywhere in this serialiser, and as React writes it) where the kit
|
|
307
|
+
* leaves it bare. Both parse to the same DOM, and the kit reads the markup through the DOM, so
|
|
308
|
+
* only a raw string comparison could tell them apart. The `data-rt`, `data-name` and
|
|
309
|
+
* `data-role` hooks are the contract, not decoration.
|
|
310
|
+
*
|
|
311
|
+
* The name links to `link` (and the blockquote cites it) only through `safeHref`; a refused
|
|
312
|
+
* link keeps the name, unlinked.
|
|
313
|
+
*/
|
|
314
|
+
function pullQuoteHtml(value: unknown, classes: HtmlClasses): string {
|
|
315
|
+
const v = isRecord(value) ? value : {};
|
|
316
|
+
const quote = typeof v.quote === "string" ? v.quote : "";
|
|
317
|
+
const name = str(v.name);
|
|
318
|
+
const role = str(v.role);
|
|
319
|
+
const href = safeHref(pullQuoteHref(v.link));
|
|
320
|
+
const nameHtml = name
|
|
321
|
+
? href
|
|
322
|
+
? `<a href="${esc(href)}" data-name>${esc(name)}</a>`
|
|
323
|
+
: `<span data-name>${esc(name)}</span>`
|
|
324
|
+
: "";
|
|
325
|
+
const roleHtml = role ? `<span data-role>${esc(role)}</span>` : "";
|
|
326
|
+
const caption = nameHtml || roleHtml
|
|
327
|
+
? `<figcaption>${nameHtml}${nameHtml && roleHtml ? ", " : ""}${roleHtml}</figcaption>`
|
|
328
|
+
: "";
|
|
329
|
+
const cite = href ? ` cite="${esc(href)}"` : "";
|
|
330
|
+
return `<figure${cls(classes, "figure")} data-rt="pullquote"><blockquote${cls(classes, "blockquote")}${cite}><p>${esc(quote)}</p></blockquote>${caption}</figure>`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Key takeaways, as the V2 kit renders them (`renderArticle("takeaways")`, article.ts):
|
|
335
|
+
* `<aside data-rt="takeaways"><p data-title>…</p><ul><li>…</li></ul></aside>`, the title only
|
|
336
|
+
* when there is one, each point trimmed and the empty ones left out. The same bytes when no
|
|
337
|
+
* classes are passed, save the pull quote's one difference: a `"` in text is `"` here.
|
|
338
|
+
* A site's own list classes dress the `<ul>` and `<li>`, as they dress its other lists.
|
|
339
|
+
*/
|
|
340
|
+
function keyTakeawaysHtml(value: unknown, classes: HtmlClasses): string {
|
|
341
|
+
const { title, points } = keyTakeawaysView(value);
|
|
342
|
+
const items = points.map((p) => `<li${cls(classes, "li")}>${esc(p)}</li>`).join("");
|
|
343
|
+
return `<aside data-rt="takeaways">${title ? `<p data-title>${esc(title)}</p>` : ""}<ul${cls(classes, "ul")}>${items}</ul></aside>`;
|
|
344
|
+
}
|
|
345
|
+
|
|
218
346
|
export interface RenderOptions {
|
|
219
347
|
/**
|
|
220
348
|
* Render a single value with no wrapping `<p>`, for a field whose consumer supplies its own
|
|
@@ -303,6 +431,8 @@ const buildComponents = (
|
|
|
303
431
|
},
|
|
304
432
|
[PT_TYPES.video]: ({ value }) => videoHtml(value, classes),
|
|
305
433
|
[PT_TYPES.file]: ({ value }) => fileHtml(value, classes),
|
|
434
|
+
[PT_TYPES.pullQuote]: ({ value }) => pullQuoteHtml(value, classes),
|
|
435
|
+
[PT_TYPES.keyTakeaways]: ({ value }) => keyTakeawaysHtml(value, classes),
|
|
306
436
|
/**
|
|
307
437
|
* A generic embedded block. A block whose payload this serialiser can READ renders as
|
|
308
438
|
* content; one carrying a reference it would have to resolve against a project renders as a
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,10 @@ export * from "./key";
|
|
|
5
5
|
export * from "./from-lexical";
|
|
6
6
|
export * from "./plain";
|
|
7
7
|
export * from "./table";
|
|
8
|
+
export * from "./key-takeaways";
|
|
8
9
|
export * from "./styled";
|
|
9
10
|
export * from "./read";
|
|
11
|
+
export * from "./component-copy";
|
|
10
12
|
export * from "./accept";
|
|
11
13
|
export * from "./features";
|
|
12
14
|
export * from "./video";
|