@bettercms-ai/richtext 0.8.2 → 0.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/richtext",
3
- "version": "0.8.2",
3
+ "version": "0.9.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(/&nbsp;/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,16 @@ 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, tableGroupLayout } from "./table";
11
12
  import { safeHref } from "./href";
12
13
  import { isStyledTag, styledAttrEntries } from "./styled";
13
14
  import { richTextImageLayout } from "./image-layout";
15
+ import { keyTakeawaysView } from "./key-takeaways";
14
16
 
15
17
  /**
16
18
  * Portable Text → HTML.
@@ -85,11 +87,35 @@ function tableHtml(table: PortableTextTable): string {
85
87
  const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
86
88
  const head = rows.slice(0, headCount === -1 ? rows.length : headCount);
87
89
  const body = rows.slice(head.length);
90
+ const layout = tableGroupLayout(table);
88
91
  // The platform's own class, on migrated values only — see `PortableTextTable["legacy"]`.
89
92
  const cls = table.legacy === true ? ' class="bcms-table"' : "";
90
- return `<table${cls}>${head.length ? `<thead>${head.map(rowHtml).join("")}</thead>` : ""}${
91
- body.length ? `<tbody>${body.map(rowHtml).join("")}</tbody>` : ""
92
- }</table>`;
93
+ const notes = typeof table.notes === "string" ? table.notes.trim() : "";
94
+ const notesId = notes && typeof table._key === "string" && table._key ? `${table._key}-notes` : "";
95
+ const caption = typeof table.caption === "string" && table.caption.trim() ? `<caption>${esc(table.caption.trim())}</caption>` : "";
96
+
97
+ // Column groups: one `<colgroup>` per run of columns, and a `scope="colgroup"` header over each
98
+ // grouped run, as the first row of `<thead>` (the V2 table's layout).
99
+ const colgroups = layout.columns.map((run) => `<colgroup${attr("span", run.width)}></colgroup>`).join("");
100
+ const groupRow = layout.columns.length
101
+ ? `<tr>${layout.columns
102
+ .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>`))
103
+ .join("")}</tr>`
104
+ : "";
105
+ const thead = groupRow || head.length ? `<thead>${groupRow}${head.map(rowHtml).join("")}</thead>` : "";
106
+
107
+ // Row groups: each run of body rows sharing a group is its own `<tbody>`, under a
108
+ // `scope="rowgroup"` label row. With no grouped row, ONE `<tbody>`, exactly as before.
109
+ const tbody = layout.rows.length
110
+ ? layout.rows
111
+ .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>`)
112
+ .join("")
113
+ : body.length ? `<tbody>${body.map(rowHtml).join("")}</tbody>` : "";
114
+
115
+ const html = `<table${cls}${notesId ? ` aria-describedby="${esc(notesId)}"` : ""}>${caption}${colgroups}${thead}${tbody}</table>`;
116
+ // A note sits UNDER the table, so it needs a container to stay with it: the V2 kit's
117
+ // `<figure data-rt="table">`, which the ingest reads back.
118
+ return notes ? `<figure data-rt="table">${html}<p data-notes${notesId ? ` id="${esc(notesId)}"` : ""}>${esc(notes)}</p></figure>` : html;
93
119
  }
94
120
 
95
121
  /**
@@ -215,6 +241,49 @@ function fileHtml(value: unknown, classes: HtmlClasses): string {
215
241
  return `<a${cls(classes, "a")} href="${esc(src)}" download>${esc(name)}</a>`;
216
242
  }
217
243
 
244
+ /**
245
+ * A pull quote, as the V2 kit renders it (`renderArticle("pullquote")`, article.ts): the same
246
+ * markup when no classes are passed, with one byte-level difference — a `"` in text comes out
247
+ * as `&quot;` here (as everywhere in this serialiser, and as React writes it) where the kit
248
+ * leaves it bare. Both parse to the same DOM, and the kit reads the markup through the DOM, so
249
+ * only a raw string comparison could tell them apart. The `data-rt`, `data-name` and
250
+ * `data-role` hooks are the contract, not decoration.
251
+ *
252
+ * The name links to `link` (and the blockquote cites it) only through `safeHref`; a refused
253
+ * link keeps the name, unlinked.
254
+ */
255
+ function pullQuoteHtml(value: unknown, classes: HtmlClasses): string {
256
+ const v = isRecord(value) ? value : {};
257
+ const quote = typeof v.quote === "string" ? v.quote : "";
258
+ const name = str(v.name);
259
+ const role = str(v.role);
260
+ const href = safeHref(pullQuoteHref(v.link));
261
+ const nameHtml = name
262
+ ? href
263
+ ? `<a href="${esc(href)}" data-name>${esc(name)}</a>`
264
+ : `<span data-name>${esc(name)}</span>`
265
+ : "";
266
+ const roleHtml = role ? `<span data-role>${esc(role)}</span>` : "";
267
+ const caption = nameHtml || roleHtml
268
+ ? `<figcaption>${nameHtml}${nameHtml && roleHtml ? ", " : ""}${roleHtml}</figcaption>`
269
+ : "";
270
+ const cite = href ? ` cite="${esc(href)}"` : "";
271
+ return `<figure${cls(classes, "figure")} data-rt="pullquote"><blockquote${cls(classes, "blockquote")}${cite}><p>${esc(quote)}</p></blockquote>${caption}</figure>`;
272
+ }
273
+
274
+ /**
275
+ * Key takeaways, as the V2 kit renders them (`renderArticle("takeaways")`, article.ts):
276
+ * `<aside data-rt="takeaways"><p data-title>…</p><ul><li>…</li></ul></aside>`, the title only
277
+ * when there is one, each point trimmed and the empty ones left out. The same bytes when no
278
+ * classes are passed, save the pull quote's one difference: a `"` in text is `&quot;` here.
279
+ * A site's own list classes dress the `<ul>` and `<li>`, as they dress its other lists.
280
+ */
281
+ function keyTakeawaysHtml(value: unknown, classes: HtmlClasses): string {
282
+ const { title, points } = keyTakeawaysView(value);
283
+ const items = points.map((p) => `<li${cls(classes, "li")}>${esc(p)}</li>`).join("");
284
+ return `<aside data-rt="takeaways">${title ? `<p data-title>${esc(title)}</p>` : ""}<ul${cls(classes, "ul")}>${items}</ul></aside>`;
285
+ }
286
+
218
287
  export interface RenderOptions {
219
288
  /**
220
289
  * Render a single value with no wrapping `<p>`, for a field whose consumer supplies its own
@@ -303,6 +372,8 @@ const buildComponents = (
303
372
  },
304
373
  [PT_TYPES.video]: ({ value }) => videoHtml(value, classes),
305
374
  [PT_TYPES.file]: ({ value }) => fileHtml(value, classes),
375
+ [PT_TYPES.pullQuote]: ({ value }) => pullQuoteHtml(value, classes),
376
+ [PT_TYPES.keyTakeaways]: ({ value }) => keyTakeawaysHtml(value, classes),
306
377
  /**
307
378
  * A generic embedded block. A block whose payload this serialiser can READ renders as
308
379
  * 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";
package/src/ingest.ts CHANGED
@@ -6,10 +6,15 @@ import {
6
6
  PT_TYPES,
7
7
  type PortableText,
8
8
  type PortableTextBlock,
9
+ type PortableTextTable,
10
+ type PortableTextTableGroup,
11
+ type PortableTextTableRow,
9
12
  } from "./types";
10
13
  import { randomKey, isValidKey, type KeyGenerator } from "./key";
11
14
  import { safeHref } from "./href";
12
15
  import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
16
+ import { PT_TABLE_LIMITS } from "./table";
17
+ import { PT_KEY_TAKEAWAYS_LIMITS } from "./key-takeaways";
13
18
  import type { PtSchema } from "./schema";
14
19
 
15
20
  /**
@@ -150,50 +155,196 @@ const spanOf = (cell: Element, name: "colspan" | "rowspan"): number | undefined
150
155
  return Number.isInteger(n) && n > 1 ? n : undefined;
151
156
  };
152
157
 
158
+ /** An element's words, whitespace collapsed. */
159
+ const wordsOf = (el: Element | null | undefined): string => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
160
+
161
+ const scopeOf = (cell: Element): string => (cell.getAttribute("scope") ?? "").toLowerCase();
162
+ const isTag = (el: Element | null | undefined, tag: string): boolean => (el?.nodeName ?? "").toLowerCase() === tag;
163
+
164
+ /**
165
+ * The V2 table's column-group row: in `<thead>`, at least one `<th scope="colgroup">`, and every
166
+ * other cell an EMPTY `<td>` (the corner over ungrouped columns). Anything looser stays a row, so
167
+ * a site's own two-row header keeps round-tripping exactly as it always has.
168
+ */
169
+ const isColumnGroupRow = (row: Element): boolean => {
170
+ const cells = cellsOf(row);
171
+ return isTag(row.parentElement, "thead")
172
+ && cells.some((c) => isTag(c, "th") && scopeOf(c) === "colgroup")
173
+ && cells.every((c) => (isTag(c, "th") && scopeOf(c) === "colgroup") || (isTag(c, "td") && wordsOf(c) === ""));
174
+ };
175
+
176
+ /**
177
+ * The V2 table's row-group label row: one `<th scope="rowgroup">` and nothing else, OPENING its
178
+ * own `<tbody>` with at least one body row after it — the shape V2 and `portableTextToHtml` write.
179
+ * A site's own `scope="rowgroup"` header anywhere else (a lone last row, inside `<thead>`, a
180
+ * second label in the same `<tbody>`) stays an ordinary row, so that markup round-trips as before.
181
+ */
182
+ const isRowGroupRow = (row: Element): boolean => {
183
+ const cells = cellsOf(row);
184
+ if (cells.length !== 1 || !isTag(cells[0], "th") || scopeOf(cells[0]!) !== "rowgroup") return false;
185
+ const body = row.parentElement;
186
+ if (!body || !isTag(body, "tbody")) return false;
187
+ const bodyRows = [...body.children].filter((r) => isTag(r, "tr"));
188
+ return bodyRows[0] === row && bodyRows.length > 1;
189
+ };
190
+
191
+ /** Ingested text clipped to its stored limit, so a long caption or note never fails the whole write. */
192
+ const clip = (text: string, max: number): string => (text.length > max ? text.slice(0, max).trimEnd() : text);
193
+
194
+ /** A group `_key` the markup carries (`data-group`, a `<tbody data-key>`), or a minted one. */
195
+ const groupKey = (value: string | null | undefined, key: KeyGenerator, taken: Set<string>): string => {
196
+ const own = (value ?? "").trim();
197
+ const k = own && own.length <= 64 && !taken.has(own) ? own : key();
198
+ taken.add(k);
199
+ return k;
200
+ };
201
+
202
+ /**
203
+ * A `<table>` element → the table, or `undefined` when it holds no row with a cell.
204
+ *
205
+ * Caption and groups are read only where the markup says them in the V2 table's own terms
206
+ * (`<caption>`, `th[scope=colgroup]`, `th[scope=rowgroup]`), so a table without them reads exactly
207
+ * as it did before they existed.
208
+ */
209
+ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: string) => PortableText): PortableTextTable | undefined {
210
+ const all = rowsOf(el);
211
+ const groupRow = all.find(isColumnGroupRow);
212
+ const taken = new Set<string>();
213
+
214
+ // Column groups: the column range each `scope="colgroup"` header spans.
215
+ const columnGroups: PortableTextTableGroup[] = [];
216
+ const ranges: { from: number; to: number; key: string }[] = [];
217
+ if (groupRow) {
218
+ let at = 0;
219
+ for (const cell of cellsOf(groupRow)) {
220
+ const width = spanOf(cell, "colspan") ?? 1;
221
+ if (isTag(cell, "th")) {
222
+ const own = cell.getAttribute("data-group");
223
+ // The same group split by an ungrouped column comes back as one group, not two.
224
+ const k = columnGroups.find((g) => own && g._key === own)?._key ?? groupKey(own, key, taken);
225
+ if (!columnGroups.some((g) => g._key === k)) columnGroups.push({ _key: k, label: clip(wordsOf(cell), PT_TABLE_LIMITS.labelChars) });
226
+ ranges.push({ from: at, to: at + width, key: k });
227
+ }
228
+ at += width;
229
+ }
230
+ }
231
+
232
+ const rowGroups: PortableTextTableGroup[] = [];
233
+ let current: { key: string; body: Element | null } | undefined;
234
+ const rows: PortableTextTableRow[] = [];
235
+ for (const row of all) {
236
+ if (row === groupRow) continue;
237
+ if (isRowGroupRow(row)) {
238
+ const own = row.parentElement?.getAttribute("data-key");
239
+ // The same group split by an ungrouped run comes back as one group, not two.
240
+ const k = rowGroups.find((g) => own && g._key === own)?._key ?? groupKey(own, key, taken);
241
+ if (!rowGroups.some((g) => g._key === k)) rowGroups.push({ _key: k, label: clip(wordsOf(cellsOf(row)[0]), PT_TABLE_LIMITS.labelChars) });
242
+ current = { key: k, body: row.parentElement };
243
+ continue;
244
+ }
245
+ if (current && current.body !== row.parentElement) current = undefined;
246
+ const first = rows.length === 0;
247
+ // Minted BEFORE the cells, as it always was: a deterministic key sequence is a contract.
248
+ const rowKey = key();
249
+ let at = 0;
250
+ const cells = cellsOf(row).map((cell) => {
251
+ const start = at;
252
+ at += spanOf(cell, "colspan") ?? 1;
253
+ const group = first ? ranges.find((r) => start >= r.from && start < r.to)?.key : undefined;
254
+ return {
255
+ _key: key(),
256
+ content: ingestCell(cell.innerHTML),
257
+ // A header is what the markup SAYS it is, cell by cell. Deriving it from position
258
+ // would promote the first data row of a headerless table, and would lose the row
259
+ // headers that live in a table's first COLUMN.
260
+ ...(isTag(cell, "th") ? { header: true } : {}),
261
+ ...(spanOf(cell, "colspan") === undefined ? {} : { span: spanOf(cell, "colspan") }),
262
+ ...(spanOf(cell, "rowspan") === undefined ? {} : { rowSpan: spanOf(cell, "rowspan") }),
263
+ ...(group === undefined ? {} : { group }),
264
+ };
265
+ });
266
+ if (cells.length === 0) continue;
267
+ const head = isTag(row.parentElement, "thead");
268
+ rows.push({
269
+ _key: rowKey,
270
+ cells,
271
+ // `<thead>` is recorded rather than inferred: a browser inserts `<tbody>` on its own
272
+ // and never a `<thead>`, so inventing one at render time would make every bare
273
+ // `<tr><th>` table diverge from its own stored markup.
274
+ ...(head ? { head: true } : {}),
275
+ ...(current && !head ? { group: current.key } : {}),
276
+ });
277
+ }
278
+ if (rows.length === 0) return undefined;
279
+ const caption = clip(wordsOf([...el.children].find((c) => isTag(c, "caption"))), PT_TABLE_LIMITS.captionChars);
280
+ // `class="bcms-table"` and nothing else: that class only ever appears in markup the
281
+ // pre-P1c renderer produced, so recognising it is what lets such markup round-trip
282
+ // instead of coming back a class short. Any other class is not representable and the
283
+ // audit reports it, which is the honest answer.
284
+ const legacy = el.getAttribute("class") === "bcms-table";
285
+ return {
286
+ _type: PT_TYPES.table,
287
+ _key: key(),
288
+ rows,
289
+ ...(legacy ? { legacy } : {}),
290
+ ...(caption ? { caption } : {}),
291
+ ...(rowGroups.length ? { rowGroups } : {}),
292
+ ...(columnGroups.length ? { columnGroups } : {}),
293
+ };
294
+ }
295
+
153
296
  function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText): DeserializerRule {
154
297
  return {
155
298
  deserialize(el: Node, _next, createBlock) {
156
299
  if (!isElement(el) || el.nodeName.toLowerCase() !== "table") return undefined;
157
- const rows = rowsOf(el)
158
- .map((row) => ({
159
- _key: key(),
160
- cells: cellsOf(row).map((cell) => ({
161
- _key: key(),
162
- content: ingestCell(cell.innerHTML),
163
- // A header is what the markup SAYS it is, cell by cell. Deriving it from position
164
- // would promote the first data row of a headerless table, and would lose the row
165
- // headers that live in a table's first COLUMN.
166
- ...(cell.nodeName.toLowerCase() === "th" ? { header: true } : {}),
167
- ...(spanOf(cell, "colspan") === undefined ? {} : { span: spanOf(cell, "colspan") }),
168
- ...(spanOf(cell, "rowspan") === undefined ? {} : { rowSpan: spanOf(cell, "rowspan") }),
169
- })),
170
- // `<thead>` is recorded rather than inferred: a browser inserts `<tbody>` on its own
171
- // and never a `<thead>`, so inventing one at render time would make every bare
172
- // `<tr><th>` table diverge from its own stored markup.
173
- ...(row.parentElement?.nodeName.toLowerCase() === "thead" ? { head: true } : {}),
174
- }))
175
- .filter((r) => r.cells.length > 0);
176
- if (rows.length === 0) return undefined;
300
+ const table = readTableElement(el, key, ingestCell);
177
301
  // `createBlock` is what marks this BLOCK-level. Returning the object bare makes the
178
302
  // deserialiser treat it as inline and wrap it in a paragraph — the table survives, nested
179
303
  // one level down where no renderer looks for it.
180
- // `class="bcms-table"` and nothing else: that class only ever appears in markup the
181
- // pre-P1c renderer produced, so recognising it is what lets such markup round-trip
182
- // instead of coming back a class short. Any other class is not representable and the
183
- // audit reports it, which is the honest answer.
184
- const legacy = el.getAttribute("class") === "bcms-table";
185
- return createBlock({ _type: PT_TYPES.table, _key: key(), rows, ...(legacy ? { legacy } : {}) });
304
+ return table ? createBlock(table) : undefined;
186
305
  },
187
306
  };
188
307
  }
189
308
 
190
- /** A code block's text, line breaks included: Lexical writes them as `<br>`, not newlines. */
309
+ /**
310
+ * `<figure data-rt="table">` → the table, with the note under it: the V2 kit's table island
311
+ * (src/schema/table.ts renderTableHtml) and what `portableTextToHtml` writes for a table with
312
+ * `notes`. The note is `[data-notes]`; the kit's footnotes have no place of their own here, so
313
+ * they FOLD INTO the note, numbered as the kit numbers them (`[1] …`), rather than vanish. The
314
+ * table's `_key` comes back from the figure's `data-id` or the note's `id` (`<key>-notes`).
315
+ */
316
+ function tableFigureRule(key: KeyGenerator, ingestCell: (html: string) => PortableText): DeserializerRule {
317
+ return {
318
+ deserialize(el: Node, _next, createBlock) {
319
+ if (!isElement(el) || el.nodeName.toLowerCase() !== "figure" || el.getAttribute("data-rt") !== "table") return undefined;
320
+ const inner = el.querySelector("table");
321
+ const table = inner ? readTableElement(inner, key, ingestCell) : undefined;
322
+ if (!table) return undefined;
323
+ const noteEl = el.querySelector("[data-notes]");
324
+ const footnotes = [...el.querySelectorAll("[data-footnotes] li")].map((li, i) => `[${i + 1}] ${wordsOf(li)}`);
325
+ // Joined with a space, not a line break: the note renders as one `<p>`, so a break would
326
+ // read back as a space and the value would not survive its own round trip.
327
+ const notes = clip([wordsOf(noteEl), ...footnotes].filter(Boolean).join(" "), PT_TABLE_LIMITS.notesChars);
328
+ const own = [el.getAttribute("data-id"), noteEl?.getAttribute("id")?.replace(/-notes$/, "")].find((k) => isValidKey(k));
329
+ return createBlock({ ...table, ...(own ? { _key: own } : {}), ...(notes ? { notes } : {}) });
330
+ },
331
+ };
332
+ }
333
+
334
+ /** A highlighter's line box (Expressive Code): a `<div>` directly under the `<code>` or the `<pre>`. */
335
+ const isCodeLine = (node: Node): boolean =>
336
+ node.nodeName.toLowerCase() === "div" && /^(code|pre)$/i.test(node.parentNode?.nodeName ?? "");
337
+
338
+ /**
339
+ * A code block's text, line breaks included: Lexical writes them as `<br>`, a highlighter that
340
+ * boxes each line writes none at all, and read without one two commands became one word.
341
+ */
191
342
  const codeText = (node: Node): string =>
192
343
  node.nodeName.toLowerCase() === "br"
193
344
  ? "\n"
194
345
  : node.nodeType === 3
195
346
  ? (node.textContent ?? "")
196
- : [...node.childNodes].map(codeText).join("");
347
+ : [...node.childNodes].map(codeText).join("") + (isCodeLine(node) ? "\n" : "");
197
348
 
198
349
  /** `language-x` on the `<code>` (Markdown, our renderer) or `data-language` on the `<pre>` (Lexical, Shiki). */
199
350
  const codeLanguage = (pre: Element): string | undefined =>
@@ -402,6 +553,126 @@ function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): Deserialize
402
553
  };
403
554
  }
404
555
 
556
+ /**
557
+ * `<figure data-rt="pullquote">` → the `pullQuote` block, the inverse of `pullQuoteHtml`.
558
+ *
559
+ * Read as the V2 kit reads it (`parseArticle("pullquote")`, article.ts): the quote is the
560
+ * blockquote's text (any `<cite>` removed), the name is the caption's `[data-name]` or else the
561
+ * `<cite>`, the role is the caption's `[data-role]`, and the link is the blockquote's `cite` or
562
+ * the name's `href`. With no figcaption at all, a `<cite>Name, role</cite>` splits on the first
563
+ * comma (the kit's legacy rule). One step past the kit, so nothing is dropped silently: a
564
+ * figcaption with neither hook nor `<cite>` keeps its whole text as the name (the kit reads
565
+ * `""` there). Only the marked figure takes this rule — a bare `<blockquote>` stays the
566
+ * blockquote STYLE it has always been.
567
+ */
568
+ function pullQuoteRule(key: KeyGenerator): DeserializerRule {
569
+ const text = (el: Element | null | undefined): string => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
570
+ return {
571
+ deserialize(el: Node, _next, createBlock) {
572
+ if (!isElement(el) || el.nodeName.toLowerCase() !== "figure" || el.getAttribute("data-rt") !== "pullquote") {
573
+ return undefined;
574
+ }
575
+ const bq = el.querySelector("blockquote");
576
+ const quoteEl = bq?.cloneNode(true) as Element | undefined;
577
+ quoteEl?.querySelectorAll("cite").forEach((c) => c.remove());
578
+ const cap = el.querySelector("figcaption");
579
+ const nameEl = cap?.querySelector("[data-name]") ?? el.querySelector("blockquote > cite, cite");
580
+ const roleEl = cap?.querySelector("[data-role]") ?? null;
581
+ let name = text(nameEl);
582
+ let role = text(roleEl);
583
+ if (!cap && name.includes(",")) {
584
+ const comma = name.indexOf(",");
585
+ [name, role] = [name.slice(0, comma).trim(), name.slice(comma + 1).trim()];
586
+ }
587
+ if (cap && !nameEl && !roleEl) name = text(cap);
588
+ const rawLink = (bq?.getAttribute("cite") ?? nameEl?.getAttribute("href") ?? "").trim();
589
+ const link = rawLink ? safeHref(rawLink) : null;
590
+ return createBlock({
591
+ _type: PT_TYPES.pullQuote,
592
+ _key: key(),
593
+ quote: text(quoteEl),
594
+ name,
595
+ ...(role ? { role } : {}),
596
+ ...(link ? { link } : {}),
597
+ });
598
+ },
599
+ };
600
+ }
601
+
602
+ /**
603
+ * Key takeaways → the `keyTakeaways` block, the inverse of `keyTakeawaysHtml`. Two sources:
604
+ *
605
+ * · V2's own markup, `<aside data-rt="takeaways">`, read as V2 reads it (`parseArticle
606
+ * ("takeaways")`, article.ts): the title is `[data-title]`'s text, each point an `<li>`'s.
607
+ * · A site's own: an `<aside>`, `<div>` or `<section>` that SAYS it holds takeaways — a class
608
+ * or id ENDING in "takeaways" (`takeaways`, `key-takeaways`, `keyTakeaways`,
609
+ * `article__takeaways`; never a `takeaway-menu`), or an `aria-label` of "Takeaways" or "Key
610
+ * takeaways" — holding an optional title (a heading, `<p>`, `<strong>` or `<b>`) and then ONE
611
+ * bulleted list, and nothing else.
612
+ *
613
+ * 🔴 NO FALSE POSITIVES, AND NOTHING LOST. An ordinary list is never takeaways: only a marked
614
+ * container is read, and a `<ul>` alone is a list as it always was. And the block is taken only
615
+ * when it holds the whole container: a site's point that carries a link, an image, a nested list
616
+ * bold, italic or classed words, or other markup the block's plain strings cannot keep, a numbered list, text loose beside the
617
+ * list, more than V2's 8 points or a title over 40 characters — each leaves the container to the
618
+ * deserialiser, which reads it as the title paragraph and the list it was, word for word.
619
+ */
620
+ const TAKEAWAYS_NAME = /^(?:[a-z0-9]+(?:-|_|__))*(?:key[-_]?)?takeaways$/i;
621
+ const TAKEAWAYS_LABEL = /^(?:key )?takeaways$/i;
622
+ const TITLE_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6", "p", "strong", "b"]);
623
+ /**
624
+ * Bare inline wrappers a title may sit in (the title renders as its own label, so its weight is the block's). A point
625
+ * is a plain string and keeps no formatting, so a point may sit only in a bare `<span>`: a bold or italic word, or any
626
+ * wrapper with a class or attribute, is design the list item keeps and the block would lose.
627
+ */
628
+ const TITLE_INLINE = new Set(["span", "strong", "b", "em", "i"]);
629
+ const POINT_INLINE = new Set(["span"]);
630
+
631
+ function keyTakeawaysRule(key: KeyGenerator): DeserializerRule {
632
+ const words = (el: Element | null | undefined): string => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
633
+ const tag = (el: Element): string => el.nodeName.toLowerCase();
634
+ /** Only text and bare wrappers from `allowed`, at any depth. */
635
+ const plain = (el: Element, allowed: ReadonlySet<string>): boolean =>
636
+ [...el.children].every((c) => allowed.has(tag(c)) && c.attributes.length === 0 && plain(c, allowed));
637
+ const fits = (title: string, points: string[]): boolean =>
638
+ title.length <= PT_KEY_TAKEAWAYS_LIMITS.titleChars && points.length <= PT_KEY_TAKEAWAYS_LIMITS.points;
639
+ const block = (title: string, points: string[]) => ({
640
+ _type: PT_TYPES.keyTakeaways,
641
+ _key: key(),
642
+ ...(title ? { title } : {}),
643
+ points,
644
+ });
645
+ /** A site's container → its title and points, or null when it is anything but those. */
646
+ const siteTakeaways = (el: Element): { title: string; points: string[] } | null => {
647
+ const names = [...(el.getAttribute("class") ?? "").split(/\s+/), el.getAttribute("id") ?? ""];
648
+ const marked = names.some((n) => TAKEAWAYS_NAME.test(n)) || TAKEAWAYS_LABEL.test((el.getAttribute("aria-label") ?? "").trim());
649
+ if (!marked) return null;
650
+ if ([...el.childNodes].some((n) => n.nodeType === 3 && (n.textContent ?? "").trim() !== "")) return null;
651
+ const kids = [...el.children];
652
+ const list = kids[kids.length - 1];
653
+ if (!list || tag(list) !== "ul" || kids.length > 2) return null;
654
+ const head = kids.length === 2 ? kids[0]! : null;
655
+ if (head && (!TITLE_TAGS.has(tag(head)) || !plain(head, TITLE_INLINE))) return null;
656
+ const items = [...list.children];
657
+ if (items.length === 0 || items.some((li) => tag(li) !== "li" || !plain(li, POINT_INLINE))) return null;
658
+ return { title: words(head), points: items.map((li) => words(li)).filter(Boolean) };
659
+ };
660
+ return {
661
+ deserialize(el: Node, _next, createBlock) {
662
+ if (!isElement(el)) return undefined;
663
+ const t = tag(el);
664
+ if (t !== "aside" && t !== "div" && t !== "section") return undefined;
665
+ if (t === "aside" && el.getAttribute("data-rt") === "takeaways") {
666
+ const title = words(el.querySelector("[data-title]"));
667
+ const points = [...el.querySelectorAll("li")].map((li) => words(li)).filter(Boolean);
668
+ return fits(title, points) ? createBlock(block(title, points)) : undefined;
669
+ }
670
+ const site = siteTakeaways(el);
671
+ return site && fits(site.title, site.points) ? createBlock(block(site.title, site.points)) : undefined;
672
+ },
673
+ };
674
+ }
675
+
405
676
  /**
406
677
  * Does this field's schema carry the `styled` annotation?
407
678
  *
@@ -442,8 +713,12 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
442
713
  * accepts only images.
443
714
  */
444
715
  rules: [
445
- ...(allowsBlockObject(opts.schema, PT_TYPES.table) ? [tableRule(key, (cell) => htmlToPortableText(cell, opts))] : []),
716
+ ...(allowsBlockObject(opts.schema, PT_TYPES.table)
717
+ ? [tableFigureRule(key, (cell) => htmlToPortableText(cell, opts)), tableRule(key, (cell) => htmlToPortableText(cell, opts))]
718
+ : []),
446
719
  ...(allowsBlockObject(opts.schema, PT_TYPES.code) ? [codeRule(key)] : []),
720
+ ...(allowsBlockObject(opts.schema, PT_TYPES.pullQuote) ? [pullQuoteRule(key)] : []),
721
+ ...(allowsBlockObject(opts.schema, PT_TYPES.keyTakeaways) ? [keyTakeawaysRule(key)] : []),
447
722
  mediaRule(key, opts.schema),
448
723
  ...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
449
724
  ...(allowsLinks(opts.schema) ? [classedLinkRule(key)] : []),
@@ -0,0 +1,93 @@
1
+ import { PT_TYPES } from "./types";
2
+ import { isPortableTextTable } from "./table";
3
+
4
+ /**
5
+ * Key takeaways (`PortableTextKeyTakeaways`, types.ts): the limits, the write check, and the
6
+ * read every renderer shares.
7
+ *
8
+ * The limits are the V2 kit's (`ARTICLE_SCHEMAS.takeaways` in `controls/rich-text/article.ts`):
9
+ * a title of at most 40 characters and at most 8 points. V2's `min: 1` on points is the
10
+ * editor's, not the write's: its editor always holds a line to type in, and the value it renders
11
+ * while that line is still empty has no point in it. Refusing that would refuse an autosave
12
+ * mid-typing, so an empty list stores, and renders as V2 renders it.
13
+ */
14
+ export const PT_KEY_TAKEAWAYS_LIMITS = { titleChars: 40, points: 8 } as const;
15
+
16
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
17
+ !!v && typeof v === "object" && !Array.isArray(v);
18
+
19
+ /** One reason a stored key-takeaways block is refused, at its own path inside the block. */
20
+ export type PortableTextKeyTakeawaysProblem = { path: Array<string | number>; code: string; message: string };
21
+
22
+ const KEYS = new Set(["_type", "_key", "title", "points"]);
23
+
24
+ /**
25
+ * Every reason `node` cannot be stored, each named with a code: `key_takeaways_invalid` (a
26
+ * shape problem) or `key_takeaways_limit_exceeded` (a V2 limit).
27
+ *
28
+ * Only a `keyTakeaways` block is judged. The type is new, so no value stored before it existed
29
+ * can hold one: there is nothing to grandfather.
30
+ */
31
+ export function keyTakeawaysProblems(node: unknown): PortableTextKeyTakeawaysProblem[] {
32
+ const out: PortableTextKeyTakeawaysProblem[] = [];
33
+ if (!isRecord(node) || node._type !== PT_TYPES.keyTakeaways) return out;
34
+ const bad = (path: Array<string | number>, message: string, code = "key_takeaways_invalid") =>
35
+ out.push({ path, code, message: `${code}: ${message}` });
36
+ for (const k of Object.keys(node)) if (!KEYS.has(k)) bad([k], `unknown key "${k}" (title, points)`);
37
+ if (node.title !== undefined) {
38
+ if (typeof node.title !== "string") bad(["title"], "title must be a string");
39
+ else if (node.title.length > PT_KEY_TAKEAWAYS_LIMITS.titleChars) {
40
+ bad(["title"], `title is ${node.title.length} characters; at most ${PT_KEY_TAKEAWAYS_LIMITS.titleChars}`, "key_takeaways_limit_exceeded");
41
+ }
42
+ }
43
+ if (!Array.isArray(node.points)) {
44
+ bad(["points"], "points must be an array of strings");
45
+ return out;
46
+ }
47
+ node.points.forEach((p, i) => {
48
+ if (typeof p !== "string") bad(["points", i], `points[${i}] must be a string`);
49
+ });
50
+ if (node.points.length > PT_KEY_TAKEAWAYS_LIMITS.points) {
51
+ bad(["points"], `${node.points.length} points; at most ${PT_KEY_TAKEAWAYS_LIMITS.points}`, "key_takeaways_limit_exceeded");
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * `keyTakeawaysProblems` for every key-takeaways block in a rich-text value — top level, and
58
+ * inside table cells — with the block's `_key` so a refusal names the block an author has to find.
59
+ */
60
+ export function portableTextKeyTakeawaysProblems(value: unknown): Array<PortableTextKeyTakeawaysProblem & { block: string }> {
61
+ const out: Array<PortableTextKeyTakeawaysProblem & { block: string }> = [];
62
+ const walk = (blocks: unknown) => {
63
+ if (!Array.isArray(blocks)) return;
64
+ for (const node of blocks) {
65
+ if (isPortableTextTable(node)) {
66
+ for (const row of node.rows as unknown[]) {
67
+ if (!isRecord(row) || !Array.isArray(row.cells)) continue;
68
+ for (const cell of row.cells) if (isRecord(cell)) walk(cell.content);
69
+ }
70
+ continue;
71
+ }
72
+ if (!isRecord(node) || node._type !== PT_TYPES.keyTakeaways) continue;
73
+ const block = typeof node._key === "string" ? node._key : "";
74
+ for (const p of keyTakeawaysProblems(node)) out.push({ ...p, block });
75
+ }
76
+ };
77
+ walk(value);
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * What a reader sees of a key-takeaways block: the title, trimmed ("" for none), and the points,
83
+ * each trimmed, the empty ones left out (V2 `renderArticle("takeaways")`). Read defensively: a
84
+ * value that never passed the write check renders what it can.
85
+ */
86
+ export function keyTakeawaysView(value: unknown): { title: string; points: string[] } {
87
+ const v = isRecord(value) ? value : {};
88
+ const title = typeof v.title === "string" ? v.title.trim() : "";
89
+ const points = (Array.isArray(v.points) ? v.points : [])
90
+ .map((p) => (typeof p === "string" ? p.trim() : ""))
91
+ .filter(Boolean);
92
+ return { title, points };
93
+ }
package/src/plain.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { isPortableTextBlock, isPortableTextSpan, PT_TYPES, type PortableText, type PortableTextTable } from "./types";
2
- import { migrateTable } from "./table";
2
+ import { migrateTable, tableGroupLayout } from "./table";
3
+ import { keyTakeawaysView } from "./key-takeaways";
4
+ import { componentCopyLines, detachedComponentCopy } from "./component-copy";
3
5
 
4
6
  /**
5
7
  * Portable Text → plain text.
@@ -30,14 +32,34 @@ export function portableTextToPlainText(value: PortableText | null | undefined):
30
32
  * over values that never passed through the reader, the corpus audit's above all.
31
33
  * `migrateTable` answers null for everything else, so the cost of asking is one comparison.
32
34
  *
33
- * Every other embedded block — an image, an embed, a component — resolves against a project
34
- * this function does not have, so it still contributes nothing.
35
+ * Every other embedded block — an image, an embed, a LINKED component — resolves against a
36
+ * project this function does not have, so it still contributes nothing.
35
37
  */
38
+ /**
39
+ * A pull quote is words a reader sees — the quote, then who said it — so search and the
40
+ * audit count them. The attribution line is the one the Markdown export writes.
41
+ */
42
+ if (node._type === PT_TYPES.pullQuote) {
43
+ lines.push(...pullQuoteLines(node as Record<string, unknown>));
44
+ continue;
45
+ }
46
+ /** Key takeaways are words a reader sees too: the title, then one line per point. */
47
+ if (node._type === PT_TYPES.keyTakeaways) {
48
+ const { title, points } = keyTakeawaysView(node);
49
+ lines.push(...(title ? [title] : []), ...points);
50
+ continue;
51
+ }
36
52
  const table = migrateTable(node);
37
53
  if (table) {
38
54
  lines.push(...tableLines(table));
39
55
  continue;
40
56
  }
57
+ // A separate copy of a component holds its own words (a linked one's live in the component).
58
+ const copy = detachedComponentCopy(node);
59
+ if (copy) {
60
+ lines.push(...componentCopyLines(copy));
61
+ continue;
62
+ }
41
63
  // Dividers and other embedded blocks contribute no text, which is what a reader wants.
42
64
  }
43
65
  return lines.join("\n");
@@ -51,10 +73,36 @@ export function portableTextToPlainText(value: PortableText | null | undefined):
51
73
  * because a row is a LINE and a cell that broke it would make the audit's before/after
52
74
  * comparison depend on where a paragraph happened to fall.
53
75
  */
54
- const tableLines = (table: PortableTextTable): string[] =>
55
- (Array.isArray(table.rows) ? table.rows : []).map((row) =>
76
+ const tableLines = (table: PortableTextTable): string[] => {
77
+ const rows = Array.isArray(table.rows) ? table.rows : [];
78
+ const rowLine = (row: (typeof rows)[number]): string =>
56
79
  (Array.isArray(row?.cells) ? row.cells : [])
57
80
  .map((cell) => portableTextToPlainText(cell?.content).replace(/\s+/g, " ").trim())
58
81
  .join(" ")
59
- .trim(),
60
- );
82
+ .trim();
83
+ /**
84
+ * Caption, column-group labels, then the rows with each row group's label before its run, then
85
+ * the note: the order the html draws them in. A table without them is one line per row, as
86
+ * before they existed.
87
+ */
88
+ const layout = tableGroupLayout(table);
89
+ const one = (s: unknown): string => (typeof s === "string" ? s.replace(/\s+/g, " ").trim() : "");
90
+ const head = layout.rows.length ? rows.slice(0, rows.length - layout.rows.reduce((n, r) => n + r.rows.length, 0)) : rows;
91
+ const lines = [
92
+ one(table.caption),
93
+ layout.columns.map((r) => one(r.label)).filter(Boolean).join(" "),
94
+ ...head.map(rowLine),
95
+ ...layout.rows.flatMap((run) => [...(run.label === undefined ? [] : [one(run.label)]), ...(run.rows as typeof rows).map(rowLine)]),
96
+ one(table.notes),
97
+ ];
98
+ // Only the NEW lines are dropped when empty: an empty row has always contributed its "" line.
99
+ const extras = new Set([0, 1, lines.length - 1]);
100
+ return lines.filter((l, i) => l !== "" || !extras.has(i));
101
+ };
102
+
103
+ /** The quote, then `— name, role` when either is set. */
104
+ const pullQuoteLines = (v: Record<string, unknown>): string[] => {
105
+ const text = (x: unknown): string => (typeof x === "string" ? x.trim() : "");
106
+ const by = [text(v.name), text(v.role)].filter(Boolean).join(", ");
107
+ return [text(v.quote), ...(by ? [`— ${by}`] : [])].filter(Boolean);
108
+ };
package/src/read.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { EMBEDDED_COMPONENT_KEY, PORTABLE_TEXT_FORMAT, PT_TYPES, type PortableText } from "./types";
2
2
  import { lexicalToPortableText } from "./from-lexical";
3
3
  import { migrateTable } from "./table";
4
+ import { detachedComponentCopy, type DetachedComponentCopy } from "./component-copy";
4
5
  import { randomKey, type KeyGenerator } from "./key";
5
6
 
6
7
  /**
@@ -243,10 +244,13 @@ export interface EmbeddedComponentBlock {
243
244
  /**
244
245
  * A component embedded in rich text, as the ordinary `component` block every renderer already
245
246
  * draws — so an embed gets the same resolver, cycle guard and identity attributes as a placed
246
- * instance. Null for any other node.
247
+ * instance. Null for any other node, and for a separate copy (`fields.detached`): a copy is not
248
+ * a reference, and resolving its `componentId` would draw the shared component it left.
249
+ * `embeddedComponentRender` answers for both.
247
250
  */
248
251
  export function embeddedComponentBlock(value: unknown): EmbeddedComponentBlock | null {
249
252
  if (!isRecord(value) || value.schemaKey !== EMBEDDED_COMPONENT_KEY || !isRecord(value.fields)) return null;
253
+ if (value.fields.detached !== undefined) return null;
250
254
  const { componentId, overrides } = value.fields;
251
255
  return {
252
256
  type: "component",
@@ -254,3 +258,17 @@ export function embeddedComponentBlock(value: unknown): EmbeddedComponentBlock |
254
258
  props: { componentId: String(componentId ?? ""), overrides: isRecord(overrides) ? overrides : {} },
255
259
  };
256
260
  }
261
+
262
+ /**
263
+ * What an embedded component renders as: a linked one, its `component` block (resolved by the
264
+ * host); a separate copy, its own frozen blocks, which need no lookup. Null for any other node,
265
+ * and for a copy whose blocks are missing (the write refuses one; a reader draws nothing for it).
266
+ */
267
+ export function embeddedComponentRender(
268
+ value: unknown,
269
+ ): { kind: "linked"; block: EmbeddedComponentBlock } | { kind: "copy"; copy: DetachedComponentCopy } | null {
270
+ const block = embeddedComponentBlock(value);
271
+ if (block) return { kind: "linked", block };
272
+ const copy = detachedComponentCopy(value);
273
+ return copy ? { kind: "copy", copy } : null;
274
+ }
package/src/schema.ts CHANGED
@@ -124,6 +124,33 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
124
124
 
125
125
  const blockObjects: PtObject[] = [];
126
126
  if (has("p")) blockObjects.push({ name: PT_TYPES.divider, title: "Divider" });
127
+ // The pull quote rides `p` as the blockquote style does: a quotation is prose, and no stored
128
+ // field config has to change for an author to get one. `link` is declared as the kit writes
129
+ // it, a URL string; a link object carrying `href` is read too (`pullQuoteHref`).
130
+ if (has("p")) {
131
+ blockObjects.push({
132
+ name: PT_TYPES.pullQuote,
133
+ title: "Pull quote",
134
+ fields: [
135
+ { name: "quote", type: "string" },
136
+ { name: "name", type: "string" },
137
+ { name: "role", type: "string" },
138
+ { name: "link", type: "string" },
139
+ ],
140
+ });
141
+ }
142
+ // Key takeaways ride `ul`: they ARE a bulleted list, under a title, so a field that forbids a
143
+ // list is not handed one in another shape. `points` is an array of strings (types.ts).
144
+ if (has("ul")) {
145
+ blockObjects.push({
146
+ name: PT_TYPES.keyTakeaways,
147
+ title: "Key takeaways",
148
+ fields: [
149
+ { name: "title", type: "string" },
150
+ { name: "points", type: "object" },
151
+ ],
152
+ });
153
+ }
127
154
  if (has("pre")) {
128
155
  blockObjects.push({
129
156
  name: PT_TYPES.code,
@@ -196,7 +223,18 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
196
223
  * nothing and drift from the types that do.
197
224
  */
198
225
  if (has("table")) {
199
- blockObjects.push({ name: PT_TYPES.table, title: "Table", fields: [{ name: "rows", type: "object" }] });
226
+ blockObjects.push({
227
+ name: PT_TYPES.table,
228
+ title: "Table",
229
+ // `caption`, `notes` and the two group lists are the V2 table's, optional; see `types.ts`.
230
+ fields: [
231
+ { name: "rows", type: "object" },
232
+ { name: "caption", type: "string" },
233
+ { name: "notes", type: "string" },
234
+ { name: "rowGroups", type: "object" },
235
+ { name: "columnGroups", type: "object" },
236
+ ],
237
+ });
200
238
  }
201
239
 
202
240
  return {
package/src/table.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type PortableText,
5
5
  type PortableTextTable,
6
6
  type PortableTextTableCell,
7
+ type PortableTextTableGroup,
7
8
  type PortableTextTableRow,
8
9
  } from "./types";
9
10
  import { deterministicKeys, isValidKey, randomKey, type KeyGenerator } from "./key";
@@ -143,6 +144,7 @@ function normalizeCell(raw: unknown, at: [number, number], mint: Mint, read: Cel
143
144
  ...(raw.header === true ? { header: true } : {}),
144
145
  ...(span === undefined ? {} : { span }),
145
146
  ...(rowSpan === undefined ? {} : { rowSpan }),
147
+ ...(typeof raw.group === "string" ? { group: raw.group } : {}),
146
148
  };
147
149
  }
148
150
 
@@ -154,6 +156,7 @@ function normalizeRow(raw: unknown, at: number, mint: Mint, read: CellContentRea
154
156
  _key: isRecord(raw) && isValidKey(raw._key) ? raw._key : mint("r", at),
155
157
  cells: cells.map((c, col) => normalizeCell(c, [at, col], mint, read)),
156
158
  ...(isRecord(raw) && raw.head === true ? { head: true } : {}),
159
+ ...(isRecord(raw) && typeof raw.group === "string" ? { group: raw.group } : {}),
157
160
  };
158
161
  }
159
162
 
@@ -227,5 +230,181 @@ export function migrateTable(
227
230
  // Carried through: a migrated table that an author saves back is still the same table, and
228
231
  // dropping the flag on that save would change the page the next time it renders.
229
232
  ...(node.legacy === true ? { legacy: true } : {}),
233
+ ...tableExtras(node),
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Caption, notes and the two group lists, carried through the reader. Each is written only when
239
+ * the stored value has it, so a table without them reads back byte for byte as it did before
240
+ * they existed. Permissive like the rest of the read: a group entry that is not an object with a
241
+ * string `_key` is skipped (nothing could name it), and a missing label reads as "". The write
242
+ * refuses all of it by name — see `tableProblems`.
243
+ */
244
+ function tableExtras(node: Record<string, unknown>): Partial<PortableTextTable> {
245
+ const groups = (list: unknown): PortableTextTableGroup[] | undefined =>
246
+ Array.isArray(list)
247
+ ? list
248
+ .filter((g): g is Record<string, unknown> => isRecord(g) && typeof g._key === "string" && g._key !== "")
249
+ .map((g) => ({ _key: g._key as string, label: typeof g.label === "string" ? g.label : "" }))
250
+ : undefined;
251
+ const rowGroups = groups(node.rowGroups);
252
+ const columnGroups = groups(node.columnGroups);
253
+ return {
254
+ ...(typeof node.caption === "string" ? { caption: node.caption } : {}),
255
+ ...(typeof node.notes === "string" ? { notes: node.notes } : {}),
256
+ ...(rowGroups ? { rowGroups } : {}),
257
+ ...(columnGroups ? { columnGroups } : {}),
258
+ };
259
+ }
260
+
261
+ /**
262
+ * The limits a table's caption, notes and group labels are held to — the V2 table's
263
+ * (`TABLE_LIMITS` in `@bettercms-ai/types` table-value.ts), restated so this package keeps no
264
+ * dependency on that one.
265
+ */
266
+ export const PT_TABLE_LIMITS = { captionChars: 300, notesChars: 2000, labelChars: 300, keyChars: 64 } as const;
267
+
268
+ /** One reason a stored table is refused, at its own path inside the table. The V2 table's shape. */
269
+ export type PortableTextTableProblem = { path: Array<string | number>; code: string; message: string };
270
+
271
+ const GROUP_KEYS = new Set(["_key", "label"]);
272
+
273
+ /**
274
+ * Every reason the caption, notes and groups of `node` cannot be stored, each named with the V2
275
+ * table's codes (`table_value_invalid`, `table_limit_exceeded`) and its path inside the table.
276
+ *
277
+ * 🔴 ONLY THE NEW KEYS ARE JUDGED. A table that carries none of them answers `[]` whatever else it
278
+ * holds, so every table stored before they existed keeps saving exactly as it did. None of these
279
+ * checks reads field config, so there is nothing to grandfather.
280
+ */
281
+ export function tableProblems(node: unknown): PortableTextTableProblem[] {
282
+ const out: PortableTextTableProblem[] = [];
283
+ if (!isRecord(node)) return out;
284
+ const bad = (path: Array<string | number>, message: string, code = "table_value_invalid") =>
285
+ out.push({ path, code, message: `${code}: ${message}` });
286
+ const text = (v: unknown, at: Array<string | number>, what: string, max: number, required = false) => {
287
+ if (v === undefined && !required) return;
288
+ if (typeof v !== "string") bad(at, `${what} must be a string`);
289
+ else if (v.length > max) bad(at, `${what} is ${v.length} characters; at most ${max}`, "table_limit_exceeded");
290
+ };
291
+ text(node.caption, ["caption"], "caption", PT_TABLE_LIMITS.captionChars);
292
+ text(node.notes, ["notes"], "notes", PT_TABLE_LIMITS.notesChars);
293
+
294
+ const keyed = (list: unknown, at: "rowGroups" | "columnGroups"): Set<string> => {
295
+ const seen = new Set<string>();
296
+ if (list === undefined) return seen;
297
+ if (!Array.isArray(list)) {
298
+ bad([at], `${at} must be an array of { _key, label }`);
299
+ return seen;
300
+ }
301
+ list.forEach((g, i) => {
302
+ if (!isRecord(g)) return bad([at, i], `${at}[${i}] must be an object { _key, label }`);
303
+ for (const k of Object.keys(g)) if (!GROUP_KEYS.has(k)) bad([at, i, k], `unknown key "${k}" (_key, label)`);
304
+ if (typeof g._key !== "string" || g._key === "" || g._key.length > PT_TABLE_LIMITS.keyChars) {
305
+ bad([at, i, "_key"], `needs a _key string of 1 to ${PT_TABLE_LIMITS.keyChars} characters`);
306
+ } else if (seen.has(g._key)) bad([at, i, "_key"], `_key "${g._key}" is used twice`);
307
+ else seen.add(g._key);
308
+ text(g.label, [at, i, "label"], "label", PT_TABLE_LIMITS.labelChars, true);
309
+ });
310
+ return seen;
311
+ };
312
+ const rowGroups = keyed(node.rowGroups, "rowGroups");
313
+ const columnGroups = keyed(node.columnGroups, "columnGroups");
314
+
315
+ const rows = Array.isArray(node.rows) ? node.rows : [];
316
+ rows.forEach((row, r) => {
317
+ if (!isRecord(row)) return;
318
+ if (row.group !== undefined) {
319
+ if (row.head === true) bad(["rows", r, "group"], "a head row takes no group (row groups hold body rows)");
320
+ else if (typeof row.group !== "string" || !rowGroups.has(row.group)) {
321
+ bad(["rows", r, "group"], `group "${String(row.group)}" is not a key of rowGroups`);
322
+ }
323
+ }
324
+ (Array.isArray(row.cells) ? row.cells : []).forEach((cell, c) => {
325
+ if (!isRecord(cell) || cell.group === undefined) return;
326
+ const at = ["rows", r, "cells", c, "group"];
327
+ if (r !== 0) bad(at, "a column's group is named on its cell in the first row only");
328
+ else if (typeof cell.group !== "string" || !columnGroups.has(cell.group)) {
329
+ bad(at, `group "${String(cell.group)}" is not a key of columnGroups`);
330
+ }
331
+ });
332
+ });
333
+ return out;
334
+ }
335
+
336
+ /**
337
+ * `tableProblems` for every table in a rich-text value — top level, and inside cells — with the
338
+ * table's `_key` so a refusal names the table an author has to find.
339
+ */
340
+ export function portableTextTableProblems(value: unknown): Array<PortableTextTableProblem & { table: string }> {
341
+ const out: Array<PortableTextTableProblem & { table: string }> = [];
342
+ const walk = (blocks: unknown) => {
343
+ if (!Array.isArray(blocks)) return;
344
+ for (const node of blocks) {
345
+ if (!isPortableTextTable(node)) continue;
346
+ const table = typeof node._key === "string" ? node._key : "";
347
+ for (const p of tableProblems(node)) out.push({ ...p, table });
348
+ for (const row of node.rows as unknown[]) {
349
+ if (!isRecord(row) || !Array.isArray(row.cells)) continue;
350
+ for (const cell of row.cells) if (isRecord(cell)) walk(cell.content);
351
+ }
352
+ }
353
+ };
354
+ walk(value);
355
+ return out;
356
+ }
357
+
358
+ /** A run of columns, or of body rows, that share one group; `label` is absent for an ungrouped run. */
359
+ export type TableColumnRun = { width: number; key?: string; label?: string };
360
+ export type TableRowRun = { rows: unknown[]; key?: string; label?: string };
361
+
362
+ /**
363
+ * How a table's groups lay out, or two empty lists when it has none.
364
+ *
365
+ * Shared by both renderers (`@bettercms-ai/ui` draws the same runs), so they cannot disagree
366
+ * about where a group starts. A group key that names no group is ignored here rather than
367
+ * thrown on: the read is permissive, and the write refuses it by name (`tableProblems`).
368
+ *
369
+ * `columns` is empty unless some first-row cell names a known column group; `rows` is empty
370
+ * unless some body row names a known row group. `width` is the widest row, spans counted.
371
+ */
372
+ export function tableGroupLayout(table: PortableTextTable): { columns: TableColumnRun[]; rows: TableRowRun[]; width: number } {
373
+ const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
374
+ const labels = (list: unknown): Map<string, string> =>
375
+ new Map(
376
+ (Array.isArray(list) ? list : [])
377
+ .filter((g): g is Record<string, unknown> => isRecord(g) && typeof g._key === "string")
378
+ .map((g) => [g._key as string, typeof g.label === "string" ? g.label : ""]),
379
+ );
380
+ const cellsOf = (row: unknown): unknown[] => (isRecord(row) && Array.isArray(row.cells) ? row.cells : []);
381
+ const spanOf = (cell: unknown): number =>
382
+ isRecord(cell) && typeof cell.span === "number" && Number.isInteger(cell.span) && cell.span > 1 ? cell.span : 1;
383
+ const width = rows.reduce<number>((max, row) => Math.max(max, cellsOf(row).reduce<number>((n, c) => n + spanOf(c), 0)), 0);
384
+
385
+ const colLabels = labels(table.columnGroups);
386
+ const columns: TableColumnRun[] = [];
387
+ for (const cell of cellsOf(rows[0])) {
388
+ const key = isRecord(cell) && typeof cell.group === "string" && colLabels.has(cell.group) ? cell.group : undefined;
389
+ const last = columns[columns.length - 1];
390
+ if (last && last.key === key) last.width += spanOf(cell);
391
+ else columns.push({ width: spanOf(cell), ...(key === undefined ? {} : { key, label: colLabels.get(key) }) });
392
+ }
393
+
394
+ const rowLabels = labels(table.rowGroups);
395
+ const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
396
+ const body = headCount === -1 ? [] : rows.slice(headCount);
397
+ const runs: TableRowRun[] = [];
398
+ for (const row of body) {
399
+ const key = isRecord(row) && typeof row.group === "string" && rowLabels.has(row.group) ? row.group : undefined;
400
+ const last = runs[runs.length - 1];
401
+ if (last && last.key === key) last.rows.push(row);
402
+ else runs.push({ rows: [row], ...(key === undefined ? {} : { key, label: rowLabels.get(key) }) });
403
+ }
404
+
405
+ return {
406
+ columns: columns.some((r) => r.key !== undefined) ? columns : [],
407
+ rows: runs.some((r) => r.key !== undefined) ? runs : [],
408
+ width,
230
409
  };
231
410
  }
package/src/types.ts CHANGED
@@ -102,6 +102,14 @@ export interface PortableTextTableCell {
102
102
  * would refuse the whole table over an attribute we simply forgot to carry.
103
103
  */
104
104
  rowSpan?: number;
105
+ /**
106
+ * The COLUMN GROUP this cell's column belongs to: a `_key` of the table's `columnGroups`.
107
+ *
108
+ * Read from the FIRST row only (the V2 table puts `group` on the column; this grid has no
109
+ * column object, so the column's first cell carries it). Living on a cell is what keeps it
110
+ * right when the editor inserts, deletes or moves a column: the cell moves with its column.
111
+ */
112
+ group?: string;
105
113
  }
106
114
 
107
115
  export interface PortableTextTableRow {
@@ -109,6 +117,17 @@ export interface PortableTextTableRow {
109
117
  cells: PortableTextTableCell[];
110
118
  /** The row sat in `<thead>`. Absent means `<tbody>`, which is what a parser assumes. */
111
119
  head?: boolean;
120
+ /** The ROW GROUP this body row belongs to: a `_key` of the table's `rowGroups`. */
121
+ group?: string;
122
+ }
123
+
124
+ /**
125
+ * A row or column group, the V2 table's `TableGroup` (`@bettercms-ai/types` table-value.ts), so
126
+ * both tables speak one language: a stable `_key` and the label a reader sees.
127
+ */
128
+ export interface PortableTextTableGroup {
129
+ _key: string;
130
+ label: string;
112
131
  }
113
132
 
114
133
  /** A table. The ONE table BetterCMS has — see `PT_TYPES.table`. */
@@ -129,6 +148,18 @@ export interface PortableTextTable extends PortableTextObject {
129
148
  * authored or pasted since P1c carries neither the flag nor the class.
130
149
  */
131
150
  legacy?: boolean;
151
+ /**
152
+ * The four keys below are the V2 table's (`TableValue`), spelled the same way. All optional,
153
+ * and a table that has none of them renders byte for byte as it did before they existed.
154
+ */
155
+ /** What the table compares, written as `<caption>`. At most 300 characters. */
156
+ caption?: string;
157
+ /** A note under the table (`<p data-notes>`), plain text. At most 2,000 characters. */
158
+ notes?: string;
159
+ /** Body rows name one with `row.group`; each run of rows is its own `<tbody>` under a label row. */
160
+ rowGroups?: PortableTextTableGroup[];
161
+ /** First-row cells name one with `cell.group`; a run of columns gets one `scope="colgroup"` header. */
162
+ columnGroups?: PortableTextTableGroup[];
132
163
  }
133
164
 
134
165
  /**
@@ -157,6 +188,50 @@ export interface PortableTextFile extends PortableTextObject {
157
188
  mime?: string;
158
189
  }
159
190
 
191
+ /**
192
+ * A pull quote: a quotation lifted out of the body, with who said it.
193
+ *
194
+ * 🔴 THE SHAPE IS THE V2 KIT'S, 1:1 — `PullQuoteModel` in the dashboard's
195
+ * `kit/controls/rich-text/article.ts`, which renders `<figure data-rt="pullquote">`. The kit
196
+ * writes these four fields and every renderer here draws them, so the names are spelled once.
197
+ *
198
+ * `link` is where the NAME links (and the blockquote's `cite`): a URL string, as the kit stores
199
+ * it, or a link object carrying `href` (the shape a `link` field value takes). Anything
200
+ * `safeHref` refuses renders the name unlinked rather than dropping it.
201
+ */
202
+ export interface PortableTextPullQuote extends PortableTextObject {
203
+ _type: "pullQuote";
204
+ quote: string;
205
+ name: string;
206
+ role?: string;
207
+ link?: string | { href: string; [k: string]: unknown };
208
+ }
209
+
210
+ /** The href a pull quote's `link` carries — a string, or a link object's `href`. "" for none. */
211
+ export function pullQuoteHref(link: unknown): string {
212
+ if (typeof link === "string") return link.trim();
213
+ if (link && typeof link === "object" && typeof (link as { href?: unknown }).href === "string") {
214
+ return ((link as { href: string }).href).trim();
215
+ }
216
+ return "";
217
+ }
218
+
219
+ /**
220
+ * Key takeaways: the story in a few points, set apart near the top of a body.
221
+ *
222
+ * 🔴 THE SHAPE IS THE V2 KIT'S, 1:1 — `TakeawaysModel` and `ARTICLE_SCHEMAS.takeaways` (`name:
223
+ * "keyTakeaways"`, fields `title` and `points`) in the kit's `controls/rich-text/article.ts`,
224
+ * which renders `<aside data-rt="takeaways">`. `title` is the label over the list ("Key
225
+ * takeaways" by default), ABSENT rather than empty when an author cleared it. `points` are plain
226
+ * strings, one per bullet; V2 holds at most 8 and a title of at most 40 characters
227
+ * (`PT_KEY_TAKEAWAYS_LIMITS`, key-takeaways.ts).
228
+ */
229
+ export interface PortableTextKeyTakeaways extends PortableTextObject {
230
+ _type: "keyTakeaways";
231
+ title?: string;
232
+ points: string[];
233
+ }
234
+
160
235
  export const isPortableTextBlock = (n: unknown): n is PortableTextBlock =>
161
236
  !!n && typeof n === "object" && (n as PortableTextNode)._type === "block";
162
237
 
@@ -204,6 +279,10 @@ export const PT_TYPES = {
204
279
  video: "video",
205
280
  /** A downloadable document — a PDF, a spec sheet. `<a href download>`, nothing more. */
206
281
  file: "file",
282
+ /** A pull quote — see `PortableTextPullQuote`. The V2 kit's `<figure data-rt="pullquote">`. */
283
+ pullQuote: "pullQuote",
284
+ /** Key takeaways — see `PortableTextKeyTakeaways`. The V2 kit's `<aside data-rt="takeaways">`. */
285
+ keyTakeaways: "keyTakeaways",
207
286
  /**
208
287
  * A table. Spelled `table`, unprefixed, because that is what every other Portable Text
209
288
  * producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
@@ -259,6 +338,8 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
259
338
  PT_TYPES.mention,
260
339
  PT_TYPES.video,
261
340
  PT_TYPES.file,
341
+ PT_TYPES.pullQuote,
342
+ PT_TYPES.keyTakeaways,
262
343
  ]);
263
344
 
264
345
  /**
@@ -303,8 +384,15 @@ export function foreignBlocks(value: unknown): { _type: string; _key: string }[]
303
384
  if (!node || typeof node !== "object" || Array.isArray(node)) continue;
304
385
  const type = (node as PortableTextNode)._type;
305
386
  if (typeof type !== "string") continue;
306
- // A table is admitted by SHAPE, never by its `_type` alone — see `isStorableTable`.
307
- const storable = type === PT_TYPES.table ? isStorableTable(node) : STORABLE_BLOCK_TYPES.has(type);
387
+ // A table, a pull quote and key takeaways are admitted by SHAPE, never by `_type` alone —
388
+ // see `isStorableTable`.
389
+ const storable = type === PT_TYPES.table
390
+ ? isStorableTable(node)
391
+ : type === PT_TYPES.pullQuote
392
+ ? isStorablePullQuote(node)
393
+ : type === PT_TYPES.keyTakeaways
394
+ ? isStorableKeyTakeaways(node)
395
+ : STORABLE_BLOCK_TYPES.has(type);
308
396
  if (storable) continue;
309
397
  const key = (node as PortableTextNode)._key;
310
398
  out.push({ _type: type, _key: typeof key === "string" ? key : "" });
@@ -347,6 +435,37 @@ export function isStorableTable(node: unknown): boolean {
347
435
  });
348
436
  }
349
437
 
438
+ /**
439
+ * Is this `_type:"pullQuote"` one we can render?
440
+ *
441
+ * Same reason as the table: `pullQuote` is a name other producers use too, and a renderer that
442
+ * met `{_type:"pullQuote", text}` would draw an empty figure. `quote` and `name` are strings
443
+ * (either may be empty while an author is still typing); `role`, when present, is a string; and
444
+ * `link`, when present, is a string or an object carrying a string `href`.
445
+ */
446
+ export function isStorablePullQuote(node: unknown): boolean {
447
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false;
448
+ const v = node as Record<string, unknown>;
449
+ if (typeof v.quote !== "string" || typeof v.name !== "string") return false;
450
+ if (v.role !== undefined && typeof v.role !== "string") return false;
451
+ if (v.link === undefined || typeof v.link === "string") return true;
452
+ return !!v.link && typeof v.link === "object" && typeof (v.link as { href?: unknown }).href === "string";
453
+ }
454
+
455
+ /**
456
+ * Is this `_type:"keyTakeaways"` one we can render?
457
+ *
458
+ * Same reason as the table: the name alone is not a contract, and a renderer that met another
459
+ * producer's `{_type:"keyTakeaways", items}` would draw an empty aside. Ours carries `points`, an
460
+ * array. What is IN it, the title and the limits are judged by `keyTakeawaysProblems`
461
+ * (key-takeaways.ts), which names each problem with a code; this only decides whether the node is
462
+ * ours at all.
463
+ */
464
+ export function isStorableKeyTakeaways(node: unknown): boolean {
465
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false;
466
+ return Array.isArray((node as { points?: unknown }).points);
467
+ }
468
+
350
469
  /**
351
470
  * @deprecated The marker a table-accepting field used to carry INSTEAD of a table.
352
471
  *