@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/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,9 @@ import {
4
4
  type PortableText,
5
5
  type PortableTextTable,
6
6
  type PortableTextTableCell,
7
+ type PortableTextTableGroup,
8
+ type PortableTextTableDesign,
9
+ type PortableTextTableJsonLd,
7
10
  type PortableTextTableRow,
8
11
  } from "./types";
9
12
  import { deterministicKeys, isValidKey, randomKey, type KeyGenerator } from "./key";
@@ -143,6 +146,9 @@ function normalizeCell(raw: unknown, at: [number, number], mint: Mint, read: Cel
143
146
  ...(raw.header === true ? { header: true } : {}),
144
147
  ...(span === undefined ? {} : { span }),
145
148
  ...(rowSpan === undefined ? {} : { rowSpan }),
149
+ ...(typeof raw.group === "string" ? { group: raw.group } : {}),
150
+ ...(raw.highlight === true ? { highlight: true } : {}),
151
+ ...(raw.sortable === true ? { sortable: true } : {}),
146
152
  };
147
153
  }
148
154
 
@@ -154,6 +160,10 @@ function normalizeRow(raw: unknown, at: number, mint: Mint, read: CellContentRea
154
160
  _key: isRecord(raw) && isValidKey(raw._key) ? raw._key : mint("r", at),
155
161
  cells: cells.map((c, col) => normalizeCell(c, [at, col], mint, read)),
156
162
  ...(isRecord(raw) && raw.head === true ? { head: true } : {}),
163
+ ...(isRecord(raw) && typeof raw.group === "string" ? { group: raw.group } : {}),
164
+ // The V2 table's two row flags ("Totals row", "Highlight this row"); see `PortableTextTableRow`.
165
+ ...(isRecord(raw) && raw.footer === true ? { footer: true } : {}),
166
+ ...(isRecord(raw) && raw.highlight === true ? { highlight: true } : {}),
157
167
  };
158
168
  }
159
169
 
@@ -227,5 +237,286 @@ export function migrateTable(
227
237
  // Carried through: a migrated table that an author saves back is still the same table, and
228
238
  // dropping the flag on that save would change the page the next time it renders.
229
239
  ...(node.legacy === true ? { legacy: true } : {}),
240
+ ...tableExtras(node),
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Caption, notes and the two group lists, carried through the reader. Each is written only when
246
+ * the stored value has it, so a table without them reads back byte for byte as it did before
247
+ * they existed. Permissive like the rest of the read: a group entry that is not an object with a
248
+ * string `_key` is skipped (nothing could name it), and a missing label reads as "". The write
249
+ * refuses all of it by name — see `tableProblems`.
250
+ */
251
+ function tableExtras(node: Record<string, unknown>): Partial<PortableTextTable> {
252
+ const groups = (list: unknown): PortableTextTableGroup[] | undefined =>
253
+ Array.isArray(list)
254
+ ? list
255
+ .filter((g): g is Record<string, unknown> => isRecord(g) && typeof g._key === "string" && g._key !== "")
256
+ .map((g) => ({ _key: g._key as string, label: typeof g.label === "string" ? g.label : "" }))
257
+ : undefined;
258
+ const rowGroups = groups(node.rowGroups);
259
+ const columnGroups = groups(node.columnGroups);
260
+ return {
261
+ ...(typeof node.caption === "string" ? { caption: node.caption } : {}),
262
+ ...(typeof node.notes === "string" ? { notes: node.notes } : {}),
263
+ ...(rowGroups ? { rowGroups } : {}),
264
+ ...(columnGroups ? { columnGroups } : {}),
265
+ ...(node.filterable === true ? { filterable: true } : {}),
266
+ ...(jsonLdOf(node.jsonLd) ? { jsonLd: jsonLdOf(node.jsonLd) } : {}),
267
+ ...(designOf(node.design) ? { design: designOf(node.design) } : {}),
268
+ };
269
+ }
270
+
271
+ const DESIGN_KEYS = new Set(["responsive", "sticky"]);
272
+
273
+ /**
274
+ * A stored `design` read permissively, as V2's parseTableElement reads the figure: `responsive` only when `stack` or `cards`
275
+ * (`scroll` is the default and is not kept), `sticky` only when true. Nothing left is nothing.
276
+ */
277
+ export function designOf(v: unknown): PortableTextTableDesign | undefined {
278
+ if (!isRecord(v)) return undefined;
279
+ const d: PortableTextTableDesign = {
280
+ ...(v.responsive === "stack" || v.responsive === "cards" ? { responsive: v.responsive } : {}),
281
+ ...(v.sticky === true ? { sticky: true } : {}),
282
+ };
283
+ return Object.keys(d).length ? d : undefined;
284
+ }
285
+
286
+ const JSONLD_TYPES = ["ItemList", "Dataset", "Offers"] as const;
287
+ const JSONLD_KEYS = new Set(["type", "axis", "priceRow"]);
288
+
289
+ /** A stored `jsonLd` read permissively: a type this table offers, its axis and price row when well formed, else nothing. */
290
+ export function jsonLdOf(v: unknown): PortableTextTableJsonLd | undefined {
291
+ if (!isRecord(v) || !(JSONLD_TYPES as readonly unknown[]).includes(v.type)) return undefined;
292
+ return {
293
+ type: v.type as PortableTextTableJsonLd["type"],
294
+ ...(v.axis === "rows" || v.axis === "columns" ? { axis: v.axis } : {}),
295
+ ...(typeof v.priceRow === "string" && v.priceRow ? { priceRow: v.priceRow } : {}),
296
+ };
297
+ }
298
+
299
+ /**
300
+ * The limits a table's caption, notes and group labels are held to — the V2 table's
301
+ * (`TABLE_LIMITS` in `@bettercms-ai/types` table-value.ts), restated so this package keeps no
302
+ * dependency on that one.
303
+ */
304
+ export const PT_TABLE_LIMITS = { captionChars: 300, notesChars: 2000, labelChars: 300, keyChars: 64 } as const;
305
+
306
+ /** One reason a stored table is refused, at its own path inside the table. The V2 table's shape. */
307
+ export type PortableTextTableProblem = { path: Array<string | number>; code: string; message: string };
308
+
309
+ const GROUP_KEYS = new Set(["_key", "label"]);
310
+
311
+ /**
312
+ * Every reason the caption, notes and groups of `node` cannot be stored, each named with the V2
313
+ * table's codes (`table_value_invalid`, `table_limit_exceeded`) and its path inside the table.
314
+ *
315
+ * 🔴 ONLY THE NEW KEYS ARE JUDGED. A table that carries none of them answers `[]` whatever else it
316
+ * holds, so every table stored before they existed keeps saving exactly as it did. None of these
317
+ * checks reads field config, so there is nothing to grandfather.
318
+ */
319
+ export function tableProblems(node: unknown): PortableTextTableProblem[] {
320
+ const out: PortableTextTableProblem[] = [];
321
+ if (!isRecord(node)) return out;
322
+ const bad = (path: Array<string | number>, message: string, code = "table_value_invalid") =>
323
+ out.push({ path, code, message: `${code}: ${message}` });
324
+ const text = (v: unknown, at: Array<string | number>, what: string, max: number, required = false) => {
325
+ if (v === undefined && !required) return;
326
+ if (typeof v !== "string") bad(at, `${what} must be a string`);
327
+ else if (v.length > max) bad(at, `${what} is ${v.length} characters; at most ${max}`, "table_limit_exceeded");
328
+ };
329
+ text(node.caption, ["caption"], "caption", PT_TABLE_LIMITS.captionChars);
330
+ text(node.notes, ["notes"], "notes", PT_TABLE_LIMITS.notesChars);
331
+ if (node.filterable !== undefined && typeof node.filterable !== "boolean") bad(["filterable"], "filterable must be true or false");
332
+ if (node.design !== undefined) {
333
+ const d = node.design;
334
+ if (!isRecord(d)) bad(["design"], "design must be an object { responsive?, sticky? }");
335
+ else {
336
+ for (const k of Object.keys(d)) if (!DESIGN_KEYS.has(k)) bad(["design", k], `unknown key "${k}" (responsive, sticky)`);
337
+ if (d.responsive !== undefined && !["scroll", "stack", "cards"].includes(d.responsive as string)) bad(["design", "responsive"], 'responsive must be "scroll", "stack" or "cards"');
338
+ if (d.sticky !== undefined && typeof d.sticky !== "boolean") bad(["design", "sticky"], "sticky must be true or false");
339
+ }
340
+ }
341
+ if (node.jsonLd !== undefined) {
342
+ const j = node.jsonLd;
343
+ if (!isRecord(j)) bad(["jsonLd"], "jsonLd must be an object { type, axis?, priceRow? }");
344
+ else {
345
+ for (const k of Object.keys(j)) if (!JSONLD_KEYS.has(k)) bad(["jsonLd", k], `unknown key "${k}" (type, axis, priceRow)`);
346
+ if (!(JSONLD_TYPES as readonly unknown[]).includes(j.type)) bad(["jsonLd", "type"], `type must be one of ${JSONLD_TYPES.join(", ")} (leave jsonLd out for none)`);
347
+ if (j.axis !== undefined && j.axis !== "rows" && j.axis !== "columns") bad(["jsonLd", "axis"], 'axis must be "rows" or "columns"');
348
+ if (j.priceRow !== undefined) {
349
+ const keys = (Array.isArray(node.rows) ? node.rows : []).map((r) => (isRecord(r) ? r._key : undefined));
350
+ if (typeof j.priceRow !== "string" || !keys.includes(j.priceRow)) bad(["jsonLd", "priceRow"], "priceRow must be the _key of one of the table's rows");
351
+ }
352
+ }
353
+ }
354
+
355
+ const keyed = (list: unknown, at: "rowGroups" | "columnGroups"): Set<string> => {
356
+ const seen = new Set<string>();
357
+ if (list === undefined) return seen;
358
+ if (!Array.isArray(list)) {
359
+ bad([at], `${at} must be an array of { _key, label }`);
360
+ return seen;
361
+ }
362
+ list.forEach((g, i) => {
363
+ if (!isRecord(g)) return bad([at, i], `${at}[${i}] must be an object { _key, label }`);
364
+ for (const k of Object.keys(g)) if (!GROUP_KEYS.has(k)) bad([at, i, k], `unknown key "${k}" (_key, label)`);
365
+ if (typeof g._key !== "string" || g._key === "" || g._key.length > PT_TABLE_LIMITS.keyChars) {
366
+ bad([at, i, "_key"], `needs a _key string of 1 to ${PT_TABLE_LIMITS.keyChars} characters`);
367
+ } else if (seen.has(g._key)) bad([at, i, "_key"], `_key "${g._key}" is used twice`);
368
+ else seen.add(g._key);
369
+ text(g.label, [at, i, "label"], "label", PT_TABLE_LIMITS.labelChars, true);
370
+ });
371
+ return seen;
372
+ };
373
+ const rowGroups = keyed(node.rowGroups, "rowGroups");
374
+ const columnGroups = keyed(node.columnGroups, "columnGroups");
375
+
376
+ const rows = Array.isArray(node.rows) ? node.rows : [];
377
+ rows.forEach((row, r) => {
378
+ if (!isRecord(row)) return;
379
+ for (const flag of ["footer", "highlight"] as const) {
380
+ if (row[flag] !== undefined && typeof row[flag] !== "boolean") bad(["rows", r, flag], `${flag} must be true or false`);
381
+ }
382
+ if (row.head === true && row.footer === true) bad(["rows", r, "footer"], "a head row cannot also be a footer (totals) row");
383
+ if (row.group !== undefined) {
384
+ if (row.head === true) bad(["rows", r, "group"], "a head row takes no group (row groups hold body rows)");
385
+ else if (typeof row.group !== "string" || !rowGroups.has(row.group)) {
386
+ bad(["rows", r, "group"], `group "${String(row.group)}" is not a key of rowGroups`);
387
+ }
388
+ }
389
+ (Array.isArray(row.cells) ? row.cells : []).forEach((cell, c) => {
390
+ for (const flag of ["highlight", "sortable"] as const) {
391
+ if (!isRecord(cell) || cell[flag] === undefined) continue;
392
+ const at = ["rows", r, "cells", c, flag];
393
+ if (typeof cell[flag] !== "boolean") bad(at, `${flag} must be true or false`);
394
+ else if (r !== 0 && cell[flag]) bad(at, `a column's ${flag} is named on its cell in the first row only`);
395
+ }
396
+ if (!isRecord(cell) || cell.group === undefined) return;
397
+ const at = ["rows", r, "cells", c, "group"];
398
+ if (r !== 0) bad(at, "a column's group is named on its cell in the first row only");
399
+ else if (typeof cell.group !== "string" || !columnGroups.has(cell.group)) {
400
+ bad(at, `group "${String(cell.group)}" is not a key of columnGroups`);
401
+ }
402
+ });
403
+ });
404
+ return out;
405
+ }
406
+
407
+ /**
408
+ * `tableProblems` for every table in a rich-text value — top level, and inside cells — with the
409
+ * table's `_key` so a refusal names the table an author has to find.
410
+ */
411
+ export function portableTextTableProblems(value: unknown): Array<PortableTextTableProblem & { table: string }> {
412
+ const out: Array<PortableTextTableProblem & { table: string }> = [];
413
+ const walk = (blocks: unknown) => {
414
+ if (!Array.isArray(blocks)) return;
415
+ for (const node of blocks) {
416
+ if (!isPortableTextTable(node)) continue;
417
+ const table = typeof node._key === "string" ? node._key : "";
418
+ for (const p of tableProblems(node)) out.push({ ...p, table });
419
+ for (const row of node.rows as unknown[]) {
420
+ if (!isRecord(row) || !Array.isArray(row.cells)) continue;
421
+ for (const cell of row.cells) if (isRecord(cell)) walk(cell.content);
422
+ }
423
+ }
424
+ };
425
+ walk(value);
426
+ return out;
427
+ }
428
+
429
+ /**
430
+ * A table's rows split into its three sections: the LEADING run of `head` rows (`<thead>`), the
431
+ * TRAILING run of `footer` rows (`<tfoot>`, the V2 table's "Totals row"), and the body between.
432
+ *
433
+ * Runs, not every flagged row, for the reason `<thead>` already is one: a section cannot be
434
+ * lifted out of document order without moving content the author placed. A `footer` row with a
435
+ * body row after it stays in the body (permissive read). Shared by every renderer so they agree.
436
+ */
437
+ export function tableSections(table: PortableTextTable): { head: unknown[]; body: unknown[]; foot: unknown[] } {
438
+ const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
439
+ const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
440
+ const head = rows.slice(0, headCount === -1 ? rows.length : headCount);
441
+ let footFrom = rows.length;
442
+ while (footFrom > head.length && isRecord(rows[footFrom - 1]) && (rows[footFrom - 1] as Record<string, unknown>).footer === true) footFrom -= 1;
443
+ return { head, body: rows.slice(head.length, footFrom), foot: rows.slice(footFrom) };
444
+ }
445
+
446
+ /**
447
+ * The columns "Highlight this column" marks: the start column of each FIRST-row cell carrying
448
+ * `highlight` (spans counted). A renderer stamps `data-highlight` on every cell starting there.
449
+ */
450
+ export function highlightedColumns(table: PortableTextTable): Set<number> {
451
+ return markedColumns(table, "highlight");
452
+ }
453
+
454
+ /** The columns "Visitors can sort columns" marks (a FIRST-row cell's `sortable`), as `highlightedColumns`. */
455
+ export function sortableColumns(table: PortableTextTable): Set<number> {
456
+ return markedColumns(table, "sortable");
457
+ }
458
+
459
+ function markedColumns(table: PortableTextTable, flag: "highlight" | "sortable"): Set<number> {
460
+ const out = new Set<number>();
461
+ const first = Array.isArray(table.rows) ? table.rows[0] : undefined;
462
+ let at = 0;
463
+ for (const cell of isRecord(first) && Array.isArray(first.cells) ? first.cells : []) {
464
+ if (isRecord(cell) && cell[flag] === true) out.add(at);
465
+ at += isRecord(cell) && typeof cell.span === "number" && Number.isInteger(cell.span) && cell.span > 1 ? cell.span : 1;
466
+ }
467
+ return out;
468
+ }
469
+
470
+ /** A run of columns, or of body rows, that share one group; `label` is absent for an ungrouped run. */
471
+ export type TableColumnRun = { width: number; key?: string; label?: string };
472
+ export type TableRowRun = { rows: unknown[]; key?: string; label?: string };
473
+
474
+ /**
475
+ * How a table's groups lay out, or two empty lists when it has none.
476
+ *
477
+ * Shared by both renderers (`@bettercms-ai/ui` draws the same runs), so they cannot disagree
478
+ * about where a group starts. A group key that names no group is ignored here rather than
479
+ * thrown on: the read is permissive, and the write refuses it by name (`tableProblems`).
480
+ *
481
+ * `columns` is empty unless some first-row cell names a known column group; `rows` is empty
482
+ * unless some body row names a known row group. `width` is the widest row, spans counted.
483
+ */
484
+ export function tableGroupLayout(table: PortableTextTable): { columns: TableColumnRun[]; rows: TableRowRun[]; width: number } {
485
+ const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
486
+ const labels = (list: unknown): Map<string, string> =>
487
+ new Map(
488
+ (Array.isArray(list) ? list : [])
489
+ .filter((g): g is Record<string, unknown> => isRecord(g) && typeof g._key === "string")
490
+ .map((g) => [g._key as string, typeof g.label === "string" ? g.label : ""]),
491
+ );
492
+ const cellsOf = (row: unknown): unknown[] => (isRecord(row) && Array.isArray(row.cells) ? row.cells : []);
493
+ const spanOf = (cell: unknown): number =>
494
+ isRecord(cell) && typeof cell.span === "number" && Number.isInteger(cell.span) && cell.span > 1 ? cell.span : 1;
495
+ const width = rows.reduce<number>((max, row) => Math.max(max, cellsOf(row).reduce<number>((n, c) => n + spanOf(c), 0)), 0);
496
+
497
+ const colLabels = labels(table.columnGroups);
498
+ const columns: TableColumnRun[] = [];
499
+ for (const cell of cellsOf(rows[0])) {
500
+ const key = isRecord(cell) && typeof cell.group === "string" && colLabels.has(cell.group) ? cell.group : undefined;
501
+ const last = columns[columns.length - 1];
502
+ if (last && last.key === key) last.width += spanOf(cell);
503
+ else columns.push({ width: spanOf(cell), ...(key === undefined ? {} : { key, label: colLabels.get(key) }) });
504
+ }
505
+
506
+ const rowLabels = labels(table.rowGroups);
507
+ // The body only: a footer (totals) run sits in `<tfoot>`, outside every row group.
508
+ const { body } = tableSections(table);
509
+ const runs: TableRowRun[] = [];
510
+ for (const row of body) {
511
+ const key = isRecord(row) && typeof row.group === "string" && rowLabels.has(row.group) ? row.group : undefined;
512
+ const last = runs[runs.length - 1];
513
+ if (last && last.key === key) last.rows.push(row);
514
+ else runs.push({ rows: [row], ...(key === undefined ? {} : { key, label: rowLabels.get(key) }) });
515
+ }
516
+
517
+ return {
518
+ columns: columns.some((r) => r.key !== undefined) ? columns : [],
519
+ rows: runs.some((r) => r.key !== undefined) ? runs : [],
520
+ width,
230
521
  };
231
522
  }
package/src/types.ts CHANGED
@@ -102,6 +102,24 @@ 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;
113
+ /**
114
+ * "Highlight this column" (the V2 table's `TableColumn.highlight`): named, like `group`, on the
115
+ * column's cell in the FIRST row only. Every cell of that column renders `data-highlight="true"`.
116
+ */
117
+ highlight?: boolean;
118
+ /**
119
+ * "Visitors can sort columns" (the V2 table's `TableColumn.sortable`): named on the column's FIRST-row cell, like
120
+ * `highlight`. Renders as the column's `<col data-sortable="true">`, as the V2 table writes it.
121
+ */
122
+ sortable?: boolean;
105
123
  }
106
124
 
107
125
  export interface PortableTextTableRow {
@@ -109,6 +127,24 @@ export interface PortableTextTableRow {
109
127
  cells: PortableTextTableCell[];
110
128
  /** The row sat in `<thead>`. Absent means `<tbody>`, which is what a parser assumes. */
111
129
  head?: boolean;
130
+ /** The ROW GROUP this body row belongs to: a `_key` of the table's `rowGroups`. */
131
+ group?: string;
132
+ /**
133
+ * A totals / summary row: the TRAILING run of these renders as `<tfoot>` (the V2 table's
134
+ * "Totals row", `TableRow.footer` in `@bettercms-ai/types` table-value.ts).
135
+ */
136
+ footer?: boolean;
137
+ /** "Highlight this row": `<tr data-highlight="true">`, as the V2 table renders it. */
138
+ highlight?: boolean;
139
+ }
140
+
141
+ /**
142
+ * A row or column group, the V2 table's `TableGroup` (`@bettercms-ai/types` table-value.ts), so
143
+ * both tables speak one language: a stable `_key` and the label a reader sees.
144
+ */
145
+ export interface PortableTextTableGroup {
146
+ _key: string;
147
+ label: string;
112
148
  }
113
149
 
114
150
  /** A table. The ONE table BetterCMS has — see `PT_TYPES.table`. */
@@ -129,6 +165,48 @@ export interface PortableTextTable extends PortableTextObject {
129
165
  * authored or pasted since P1c carries neither the flag nor the class.
130
166
  */
131
167
  legacy?: boolean;
168
+ /**
169
+ * The four keys below are the V2 table's (`TableValue`), spelled the same way. All optional,
170
+ * and a table that has none of them renders byte for byte as it did before they existed.
171
+ */
172
+ /** What the table compares, written as `<caption>`. At most 300 characters. */
173
+ caption?: string;
174
+ /** A note under the table (`<p data-notes>`), plain text. At most 2,000 characters. */
175
+ notes?: string;
176
+ /** Body rows name one with `row.group`; each run of rows is its own `<tbody>` under a label row. */
177
+ rowGroups?: PortableTextTableGroup[];
178
+ /** First-row cells name one with `cell.group`; a run of columns gets one `scope="colgroup"` header. */
179
+ columnGroups?: PortableTextTableGroup[];
180
+ /**
181
+ * "Visitors can search rows" (the V2 table's `TableValue.filterable`): the table renders inside
182
+ * `<figure data-rt="table" data-filter="true">`, as the V2 table writes it.
183
+ */
184
+ filterable?: boolean;
185
+ /**
186
+ * "Help search engines read this as" (the V2 table's `TableValue.jsonLd`, the field-lane table's `jsonLd`): a
187
+ * comparison, a data report or a price list. Written on the figure as `data-jsonld="<type>"` (plus
188
+ * `data-jsonld-axis` / `data-jsonld-price`), as the V2 table writes it. `"none"` is not stored.
189
+ */
190
+ jsonLd?: PortableTextTableJsonLd;
191
+ /**
192
+ * "On phones" and "Keep column names in view" (the V2 table's `TableValue.design.responsive` / `.sticky`, the field-lane
193
+ * table's `design`). `responsive` is `stack` or `cards` (`scroll`, the default, is not stored, as V2 reads it); written
194
+ * on the figure as `data-responsive` / `data-sticky="true"`, as the V2 table writes them.
195
+ */
196
+ design?: PortableTextTableDesign;
197
+ }
198
+
199
+ /** The V2 table's `design` keys the rich-text table offers. */
200
+ export interface PortableTextTableDesign {
201
+ responsive?: "stack" | "cards";
202
+ sticky?: boolean;
203
+ }
204
+
205
+ /** The V2 table's JSON-LD choice: `priceRow` is a row's `_key` (Offers), `axis` what an ItemList lists. */
206
+ export interface PortableTextTableJsonLd {
207
+ type: "ItemList" | "Dataset" | "Offers";
208
+ axis?: "rows" | "columns";
209
+ priceRow?: string;
132
210
  }
133
211
 
134
212
  /**
@@ -157,6 +235,50 @@ export interface PortableTextFile extends PortableTextObject {
157
235
  mime?: string;
158
236
  }
159
237
 
238
+ /**
239
+ * A pull quote: a quotation lifted out of the body, with who said it.
240
+ *
241
+ * 🔴 THE SHAPE IS THE V2 KIT'S, 1:1 — `PullQuoteModel` in the dashboard's
242
+ * `kit/controls/rich-text/article.ts`, which renders `<figure data-rt="pullquote">`. The kit
243
+ * writes these four fields and every renderer here draws them, so the names are spelled once.
244
+ *
245
+ * `link` is where the NAME links (and the blockquote's `cite`): a URL string, as the kit stores
246
+ * it, or a link object carrying `href` (the shape a `link` field value takes). Anything
247
+ * `safeHref` refuses renders the name unlinked rather than dropping it.
248
+ */
249
+ export interface PortableTextPullQuote extends PortableTextObject {
250
+ _type: "pullQuote";
251
+ quote: string;
252
+ name: string;
253
+ role?: string;
254
+ link?: string | { href: string; [k: string]: unknown };
255
+ }
256
+
257
+ /** The href a pull quote's `link` carries — a string, or a link object's `href`. "" for none. */
258
+ export function pullQuoteHref(link: unknown): string {
259
+ if (typeof link === "string") return link.trim();
260
+ if (link && typeof link === "object" && typeof (link as { href?: unknown }).href === "string") {
261
+ return ((link as { href: string }).href).trim();
262
+ }
263
+ return "";
264
+ }
265
+
266
+ /**
267
+ * Key takeaways: the story in a few points, set apart near the top of a body.
268
+ *
269
+ * 🔴 THE SHAPE IS THE V2 KIT'S, 1:1 — `TakeawaysModel` and `ARTICLE_SCHEMAS.takeaways` (`name:
270
+ * "keyTakeaways"`, fields `title` and `points`) in the kit's `controls/rich-text/article.ts`,
271
+ * which renders `<aside data-rt="takeaways">`. `title` is the label over the list ("Key
272
+ * takeaways" by default), ABSENT rather than empty when an author cleared it. `points` are plain
273
+ * strings, one per bullet; V2 holds at most 8 and a title of at most 40 characters
274
+ * (`PT_KEY_TAKEAWAYS_LIMITS`, key-takeaways.ts).
275
+ */
276
+ export interface PortableTextKeyTakeaways extends PortableTextObject {
277
+ _type: "keyTakeaways";
278
+ title?: string;
279
+ points: string[];
280
+ }
281
+
160
282
  export const isPortableTextBlock = (n: unknown): n is PortableTextBlock =>
161
283
  !!n && typeof n === "object" && (n as PortableTextNode)._type === "block";
162
284
 
@@ -204,6 +326,10 @@ export const PT_TYPES = {
204
326
  video: "video",
205
327
  /** A downloadable document — a PDF, a spec sheet. `<a href download>`, nothing more. */
206
328
  file: "file",
329
+ /** A pull quote — see `PortableTextPullQuote`. The V2 kit's `<figure data-rt="pullquote">`. */
330
+ pullQuote: "pullQuote",
331
+ /** Key takeaways — see `PortableTextKeyTakeaways`. The V2 kit's `<aside data-rt="takeaways">`. */
332
+ keyTakeaways: "keyTakeaways",
207
333
  /**
208
334
  * A table. Spelled `table`, unprefixed, because that is what every other Portable Text
209
335
  * producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
@@ -259,6 +385,8 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
259
385
  PT_TYPES.mention,
260
386
  PT_TYPES.video,
261
387
  PT_TYPES.file,
388
+ PT_TYPES.pullQuote,
389
+ PT_TYPES.keyTakeaways,
262
390
  ]);
263
391
 
264
392
  /**
@@ -303,8 +431,15 @@ export function foreignBlocks(value: unknown): { _type: string; _key: string }[]
303
431
  if (!node || typeof node !== "object" || Array.isArray(node)) continue;
304
432
  const type = (node as PortableTextNode)._type;
305
433
  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);
434
+ // A table, a pull quote and key takeaways are admitted by SHAPE, never by `_type` alone —
435
+ // see `isStorableTable`.
436
+ const storable = type === PT_TYPES.table
437
+ ? isStorableTable(node)
438
+ : type === PT_TYPES.pullQuote
439
+ ? isStorablePullQuote(node)
440
+ : type === PT_TYPES.keyTakeaways
441
+ ? isStorableKeyTakeaways(node)
442
+ : STORABLE_BLOCK_TYPES.has(type);
308
443
  if (storable) continue;
309
444
  const key = (node as PortableTextNode)._key;
310
445
  out.push({ _type: type, _key: typeof key === "string" ? key : "" });
@@ -347,6 +482,37 @@ export function isStorableTable(node: unknown): boolean {
347
482
  });
348
483
  }
349
484
 
485
+ /**
486
+ * Is this `_type:"pullQuote"` one we can render?
487
+ *
488
+ * Same reason as the table: `pullQuote` is a name other producers use too, and a renderer that
489
+ * met `{_type:"pullQuote", text}` would draw an empty figure. `quote` and `name` are strings
490
+ * (either may be empty while an author is still typing); `role`, when present, is a string; and
491
+ * `link`, when present, is a string or an object carrying a string `href`.
492
+ */
493
+ export function isStorablePullQuote(node: unknown): boolean {
494
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false;
495
+ const v = node as Record<string, unknown>;
496
+ if (typeof v.quote !== "string" || typeof v.name !== "string") return false;
497
+ if (v.role !== undefined && typeof v.role !== "string") return false;
498
+ if (v.link === undefined || typeof v.link === "string") return true;
499
+ return !!v.link && typeof v.link === "object" && typeof (v.link as { href?: unknown }).href === "string";
500
+ }
501
+
502
+ /**
503
+ * Is this `_type:"keyTakeaways"` one we can render?
504
+ *
505
+ * Same reason as the table: the name alone is not a contract, and a renderer that met another
506
+ * producer's `{_type:"keyTakeaways", items}` would draw an empty aside. Ours carries `points`, an
507
+ * array. What is IN it, the title and the limits are judged by `keyTakeawaysProblems`
508
+ * (key-takeaways.ts), which names each problem with a code; this only decides whether the node is
509
+ * ours at all.
510
+ */
511
+ export function isStorableKeyTakeaways(node: unknown): boolean {
512
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false;
513
+ return Array.isArray((node as { points?: unknown }).points);
514
+ }
515
+
350
516
  /**
351
517
  * @deprecated The marker a table-accepting field used to carry INSTEAD of a table.
352
518
  *