@bettercms-ai/richtext 0.10.0 → 0.12.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.10.0",
3
+ "version": "0.12.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.",
package/src/html.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import { toHTML, type PortableTextHtmlComponents } from "@portabletext/to-html";
2
2
  import {
3
+ BUILTIN_CALLOUT_KEY,
3
4
  BUILTIN_EMBED_KEY,
4
5
  BUILTIN_IMAGE_KEY,
6
+ BUILTIN_TOGGLE_KEY,
5
7
  PT_TYPES,
6
8
  pullQuoteHref,
7
9
  type PortableText,
8
10
  type PortableTextTable,
9
11
  } from "./types";
10
12
  import { resolveVideoSource } from "./video";
11
- import { builtInTableToTable, designOf, highlightedColumns, jsonLdOf, sortableColumns, tableGroupLayout, tableSections } from "./table";
13
+ import { builtInTableToTable, designOf, footnoteNumbers, footnotesOf, highlightedColumns, isTypedTable, jsonLdOf, sortableColumns, tableColumns, tableGroupLayout, tableSections, typedCellView } from "./table";
12
14
  import { portableTextToPlainText } from "./plain";
13
15
  import { safeHref } from "./href";
14
16
  import { isStyledTag, styledAttrEntries } from "./styled";
@@ -97,26 +99,42 @@ function tableHtml(table: PortableTextTable): string {
97
99
  at += w;
98
100
  }
99
101
  }
100
- const cellHtml = (raw: unknown, col: number): string => {
102
+ // The V2 table's typed columns (a first-row cell's `column`): a table with one renders V2's typed markup (cellHtml).
103
+ const columns = tableColumns(table);
104
+ const typed = isTypedTable(table);
105
+ const headRows = new Set(tableSections(table).head);
106
+ const fnNumber = footnoteNumbers(table);
107
+ const footnotes = footnotesOf(table.footnotes) ?? [];
108
+ const tableId = typeof table._key === "string" && table._key ? table._key : "tbl";
109
+ const cellHtml = (raw: unknown, col: number, row: unknown): string => {
101
110
  const cell = isRecord(raw) ? raw : {};
102
111
  const tag = cell.header === true ? "th" : "td";
103
112
  // `colspan` before `rowspan`, the order every table in the corpus writes them in: the
104
113
  // comparison is on serialised markup, and a parser preserves the order it was given.
105
114
  const label = stacked && tag === "td" ? ` data-label="${esc(labels[col] || `Column ${col + 1}`)}"` : "";
106
- const attrs = `${attr("colspan", cell.span)}${attr("rowspan", cell.rowSpan)}${label}${marked.has(col) ? ' data-highlight="true"' : ""}`;
107
- return `<${tag}${attrs}>${portableTextToHtml(cell.content as PortableText, { inline: true })}</${tag}>`;
115
+ // A footnote: V2 renderTableHtml's data-note (after the value, before data-align) and its numbered marker after the
116
+ // cell's words (V2 cellHtml `inner + sup`)
117
+ const n = typeof cell.note === "string" ? fnNumber.get(cell.note) : undefined;
118
+ const sup = n ? `<sup><a href="#${esc(tableId)}-fn-${n}" aria-label="Note ${n}">${n}</a></sup>` : "";
119
+ const content = portableTextToHtml(cell.content as PortableText, { inline: true });
120
+ if (!typed) return `<${tag}${attr("colspan", cell.span)}${attr("rowspan", cell.rowSpan)}${label}${marked.has(col) ? ' data-highlight="true"' : ""}${n ? ` data-note="${esc(cell.note as string)}"` : ""}>${content}${sup}</${tag}>`;
121
+ const view = typedCellView(columns[col], row, cell, headRows.has(row));
122
+ const tail: [string, string][] = n ? [...view.tail.filter(([k]) => k !== "data-align"), ["data-note", cell.note as string], ...view.tail.filter(([k]) => k === "data-align")] : view.tail;
123
+ const out = (list: [string, string][]) => list.map(([k, v]) => ` ${k}="${esc(v)}"`).join("");
124
+ return `<${tag}${attr("colspan", cell.span)}${attr("rowspan", cell.rowSpan)}${out(view.lead)}${label}${marked.has(col) ? ' data-highlight="true"' : ""}${out(tail)}>${view.inner ?? content}${sup}</${tag}>`;
108
125
  };
109
126
  const rowHtml = (raw: unknown): string => {
110
127
  const cells = isRecord(raw) && Array.isArray(raw.cells) ? raw.cells : [];
111
- // "Highlight this row": the V2 table's own attribute (src/schema/table.ts rowHtml).
128
+ // "Highlight this row": the V2 table's own attribute (src/schema/table.ts rowHtml), after the row's type (data-type).
129
+ const type = isRecord(raw) && typeof raw.type === "string" ? ` data-type="${esc(raw.type)}"` : "";
112
130
  const highlight = isRecord(raw) && raw.highlight === true ? ' data-highlight="true"' : "";
113
131
  let col = 0;
114
132
  const html = cells.map((c) => {
115
- const out = cellHtml(c, col);
133
+ const out = cellHtml(c, col, raw);
116
134
  col += isRecord(c) && typeof c.span === "number" && Number.isInteger(c.span) && c.span > 1 ? c.span : 1;
117
135
  return out;
118
136
  });
119
- return `<tr${highlight}>${html.join("")}</tr>`;
137
+ return `<tr${type}${highlight}>${html.join("")}</tr>`;
120
138
  };
121
139
 
122
140
  // The LEADING RUN of head rows, not every row flagged one: `<thead>` is a section, and a
@@ -128,7 +146,7 @@ function tableHtml(table: PortableTextTable): string {
128
146
  // The platform's own class, on migrated values only — see `PortableTextTable["legacy"]`.
129
147
  const cls = table.legacy === true ? ' class="bcms-table"' : "";
130
148
  const notes = typeof table.notes === "string" ? table.notes.trim() : "";
131
- const notesId = notes && typeof table._key === "string" && table._key ? `${table._key}-notes` : "";
149
+ const notesId = (notes || footnotes.length) && typeof table._key === "string" && table._key ? `${table._key}-notes` : "";
132
150
  const caption = typeof table.caption === "string" && table.caption.trim() ? `<caption>${esc(table.caption.trim())}</caption>` : "";
133
151
 
134
152
  // Column groups: one `<colgroup>` per run of columns, and a `scope="colgroup"` header over each
@@ -138,11 +156,27 @@ function tableHtml(table: PortableTextTable): string {
138
156
  const sortable = sortableColumns(table);
139
157
  let colAt = 0;
140
158
  const cols = (width: number): string => {
141
- const out = Array.from({ length: width }, (_, i) => (sortable.has(colAt + i) ? '<col data-sortable="true">' : "<col>")).join("");
159
+ const out = Array.from({ length: width }, (_, i) => `<col${colAttrs(colAt + i)}>`).join("");
142
160
  colAt += width;
143
161
  return out;
144
162
  };
145
- const colgroups = sortable.size
163
+ // A typed column's `<col>`: V2 renderTableHtml's attributes in its order (schema/table.ts:511-526), data-sortable among them
164
+ const colAttrs = (i: number): string => {
165
+ const c = columns[i];
166
+ const a = c
167
+ ? [
168
+ ` data-type="${c.type ?? "text"}"`,
169
+ c.unit ? ` data-unit="${esc(c.unit)}"` : "",
170
+ c.currency ? ` data-currency="${esc(c.currency)}"` : "",
171
+ c.decimals != null ? ` data-decimals="${c.decimals}"` : "",
172
+ c.align ? ` data-align="${c.align}"` : "",
173
+ ]
174
+ : [];
175
+ a.push(sortable.has(i) ? ' data-sortable="true"' : "");
176
+ if (c) a.push(c.trueLabel ? ` data-true="${esc(c.trueLabel)}"` : "", c.falseLabel ? ` data-false="${esc(c.falseLabel)}"` : "", c.max != null ? ` data-max="${c.max}"` : "", c.description ? ` data-description="${esc(c.description)}"` : "");
177
+ return a.join("");
178
+ };
179
+ const colgroups = sortable.size || columns.some(Boolean)
146
180
  ? (layout.columns.length ? layout.columns.map((run) => run.width) : [layout.width]).map((w) => `<colgroup>${cols(w)}</colgroup>`).join("")
147
181
  : layout.columns.map((run) => `<colgroup${attr("span", run.width)}></colgroup>`).join("");
148
182
  const groupRow = layout.columns.length
@@ -173,8 +207,15 @@ function tableHtml(table: PortableTextTable): string {
173
207
  // stacked table takes V2's withTableRoles (explicit ARIA roles, since display:block drops the implicit ones)
174
208
  const designAttrs = design ? `${design.responsive ? ` data-responsive="${design.responsive}"` : ""}${design.sticky ? ' data-sticky="true"' : ""}` : "";
175
209
  const html = stacked ? withTableRoles(plainHtml) : plainHtml;
176
- if (!notes && !filter && !jsonLd && !designAttrs) return html;
177
- return `<figure data-rt="table"${designAttrs}${filter}${jsonLd}>${html}${notes ? `<p data-notes${notesId ? ` id="${esc(notesId)}"` : ""}>${esc(notes)}</p>` : ""}</figure>`;
210
+ if (!notes && !filter && !jsonLd && !designAttrs && !footnotes.length) return html;
211
+ // Footnotes: V2 renderTableHtml's notes block, `<div class="bt-notes" id="<key>-notes">` holding the note and the
212
+ // `<ol data-footnotes>`, each `<li id="<key>-fn-<n>" data-key>`. A note with no footnotes keeps the one `<p>` it had.
213
+ const under = footnotes.length
214
+ ? `<div class="bt-notes" id="${esc(tableId)}-notes">${notes ? `<p data-notes>${esc(notes)}</p>` : ""}<ol data-footnotes>${footnotes
215
+ .map((f, i) => `<li id="${esc(tableId)}-fn-${i + 1}" data-key="${esc(f._key)}">${esc(f.text)}</li>`)
216
+ .join("")}</ol></div>`
217
+ : notes ? `<p data-notes${notesId ? ` id="${esc(notesId)}"` : ""}>${esc(notes)}</p>` : "";
218
+ return `<figure data-rt="table"${designAttrs}${filter}${jsonLd}>${html}${under}</figure>`;
178
219
  }
179
220
 
180
221
  /**
@@ -268,6 +309,42 @@ function embedHtml(value: unknown, _classes: HtmlClasses): string {
268
309
  return `<iframe src="${esc(src)}"${title ? ` title="${esc(title)}"` : ""}${IFRAME_ATTRS}></iframe>`;
269
310
  }
270
311
 
312
+ /** Plain multi-line text as paragraphs: a blank line starts a new one, a single newline is a `<br>`. */
313
+ const paragraphs = (text: string): string =>
314
+ text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean)
315
+ .map((p) => `<p>${p.split("\n").map(esc).join("<br>")}</p>`).join("");
316
+
317
+ const CALLOUT_TONES = new Set(["info", "tip", "warning"]);
318
+
319
+ /**
320
+ * A callout: a titled note in its tone. Its payload is its own text, so it renders as content — a marker
321
+ * here was the whole note missing from every delivered page. An unknown tone renders as `info`.
322
+ */
323
+ function calloutHtml(value: unknown): string {
324
+ const f = fieldsOf(value);
325
+ const tone = CALLOUT_TONES.has(str(f.tone)) ? str(f.tone) : "info";
326
+ const title = str(f.title);
327
+ const body = paragraphs(typeof f.message === "string" ? f.message : "");
328
+ if (!title && !body) return "";
329
+ return `<div class="bcms-callout bcms-callout--${tone}" data-rt="callout" data-callout="${tone}" role="note">`
330
+ + (title ? `<p class="bcms-callout__title" data-title>${esc(title)}</p>` : "")
331
+ + `<div class="bcms-callout__body" data-body>${body}</div></div>`;
332
+ }
333
+
334
+ /** A toggle: `<details>`, so it opens and closes on a page with no script. */
335
+ function toggleHtml(value: unknown): string {
336
+ const f = fieldsOf(value);
337
+ const title = str(f.title);
338
+ const body = paragraphs(typeof f.body === "string" ? f.body : "");
339
+ if (!title && !body) return "";
340
+ return `<details class="bcms-toggle" data-rt="toggle"><summary class="bcms-toggle__title">${esc(title)}</summary>`
341
+ + `<div class="bcms-toggle__body" data-body>${body}</div></details>`;
342
+ }
343
+
344
+ /** Built-in blocks this serialiser renders from their own fields (no project lookup): read-time html re-derives them. */
345
+ export const isSelfRenderedBlock = (node: unknown): boolean =>
346
+ isRecord(node) && (node.schemaKey === BUILTIN_CALLOUT_KEY || node.schemaKey === BUILTIN_TOGGLE_KEY);
347
+
271
348
  /**
272
349
  * A video: the file itself where the URL is one, the provider's player where it is a provider's.
273
350
  *
@@ -447,6 +524,8 @@ const buildComponents = (
447
524
  const v = value as { schemaKey?: unknown };
448
525
  if (v.schemaKey === BUILTIN_IMAGE_KEY) return imageHtml(value, classes);
449
526
  if (v.schemaKey === BUILTIN_EMBED_KEY) return embedHtml(value, classes);
527
+ if (v.schemaKey === BUILTIN_CALLOUT_KEY) return calloutHtml(value);
528
+ if (v.schemaKey === BUILTIN_TOGGLE_KEY) return toggleHtml(value);
450
529
  /**
451
530
  * A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
452
531
  * nothing. Its rows are text, held right here, so a marker would DELETE visible content.
package/src/ingest.ts CHANGED
@@ -7,13 +7,15 @@ import {
7
7
  type PortableText,
8
8
  type PortableTextBlock,
9
9
  type PortableTextTable,
10
+ type PortableTextTableColumn,
11
+ type PortableTextTableColumnType,
10
12
  type PortableTextTableGroup,
11
13
  type PortableTextTableRow,
12
14
  } from "./types";
13
15
  import { randomKey, isValidKey, type KeyGenerator } from "./key";
14
16
  import { safeHref } from "./href";
15
17
  import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
16
- import { designOf, jsonLdOf, PT_TABLE_LIMITS } from "./table";
18
+ import { cellTypeOf, columnOf, designOf, isColumnType, isValueType, jsonLdOf, PT_TABLE_LIMITS, typedCellText } from "./table";
17
19
  import { PT_KEY_TAKEAWAYS_LIMITS } from "./key-takeaways";
18
20
  import type { PtSchema } from "./schema";
19
21
 
@@ -156,6 +158,7 @@ const spanOf = (cell: Element, name: "colspan" | "rowspan" | "span"): number | u
156
158
  };
157
159
 
158
160
  /** An element's words, whitespace collapsed. */
161
+ const escText = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
159
162
  const wordsOf = (el: Element | null | undefined): string => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
160
163
 
161
164
  const scopeOf = (cell: Element): string => (cell.getAttribute("scope") ?? "").toLowerCase();
@@ -210,6 +213,8 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
210
213
  const all = rowsOf(el);
211
214
  // The columns a `<col data-sortable="true">` marks: a `<colgroup>` without `<col>`s spans its `span` columns.
212
215
  const sortableCols = new Set<number>();
216
+ // A typed column's `<col data-type …>` (V2 renderTableHtml) → the column, as V2 parseTableElement reads it.
217
+ const typedCols = new Map<number, PortableTextTableColumn>();
213
218
  let colAt = 0;
214
219
  for (const group of [...el.children].filter((c) => isTag(c, "colgroup"))) {
215
220
  const cols = [...group.children].filter((c) => isTag(c, "col"));
@@ -217,6 +222,10 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
217
222
  for (const col of cols) {
218
223
  const width = spanOf(col, "span") ?? 1;
219
224
  if (col.getAttribute("data-sortable") === "true") for (let i = 0; i < width; i += 1) sortableCols.add(colAt + i);
225
+ const d = (name: string) => col.getAttribute(`data-${name}`) ?? undefined;
226
+ const num = (name: string) => (d(name) === undefined ? undefined : Number(d(name)));
227
+ const typed = d("type") === undefined ? undefined : columnOf({ type: d("type"), unit: d("unit"), currency: d("currency"), decimals: num("decimals"), align: d("align"), trueLabel: d("true"), falseLabel: d("false"), max: num("max"), description: d("description") });
228
+ if (typed) for (let i = 0; i < width; i += 1) typedCols.set(colAt + i, typed);
220
229
  colAt += width;
221
230
  }
222
231
  }
@@ -258,14 +267,35 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
258
267
  const first = rows.length === 0;
259
268
  // Minted BEFORE the cells, as it always was: a deterministic key sequence is a contract.
260
269
  const rowKey = key();
270
+ // The V2 table's per-row type (`<tr data-type>`)
271
+ const rowType = isColumnType(row.getAttribute("data-type")) ? (row.getAttribute("data-type") as PortableTextTableColumnType) : undefined;
272
+ const inHead = isTag(row.parentElement, "thead");
261
273
  let at = 0;
262
274
  const cells = cellsOf(row).map((cell) => {
263
275
  const start = at;
264
276
  at += spanOf(cell, "colspan") ?? 1;
265
277
  const group = first ? ranges.find((r) => start >= r.from && start < r.to)?.key : undefined;
278
+ // A footnote marker (V2: `<sup><a href="#<key>-fn-<n>">`) is the cell's note, not its words: taken out, and the note
279
+ // kept as the cell's `data-note`, else as `#<n>` for the figure to resolve against its footnotes (`resolveNotes`).
280
+ const own = cell.cloneNode(true) as Element;
281
+ let mark: string | undefined;
282
+ for (const sup of [...own.querySelectorAll("sup")]) {
283
+ const n = /-fn-(\d+)$/.exec(sup.querySelector("a")?.getAttribute("href") ?? "")?.[1];
284
+ if (!n) continue;
285
+ mark ??= `#${n}`;
286
+ sup.remove();
287
+ }
288
+ const note = cell.getAttribute("data-note") || mark;
289
+ // A typed cell (V2 cellHtml): its per-cell type, its value (`data-value`), its words as content; `data-empty` is no words.
290
+ const column = typedCols.get(start);
291
+ const ownType = isColumnType(cell.getAttribute("data-type")) ? (cell.getAttribute("data-type") as PortableTextTableColumnType) : undefined;
292
+ const type = cellTypeOf(column, { type: rowType }, { type: ownType });
293
+ const raw = cell.getAttribute("data-value");
294
+ const value = inHead || raw === null || !isValueType(type) ? undefined : type === "boolean" ? raw === "true" : Number.isFinite(Number(raw)) && raw.trim() !== "" ? Number(raw) : undefined;
295
+ const empty = !inHead && cell.hasAttribute("data-empty");
266
296
  return {
267
297
  _key: key(),
268
- content: ingestCell(cell.innerHTML),
298
+ content: empty ? [] : value !== undefined ? ingestCell(escText(typedCellText(column, type, value))) : ingestCell(own.innerHTML),
269
299
  // A header is what the markup SAYS it is, cell by cell. Deriving it from position
270
300
  // would promote the first data row of a headerless table, and would lose the row
271
301
  // headers that live in a table's first COLUMN.
@@ -277,6 +307,10 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
277
307
  ...(first && cell.getAttribute("data-highlight") === "true" ? { highlight: true } : {}),
278
308
  // "Visitors can sort columns": the column's `<col data-sortable="true">` (V2 renderTableHtml)
279
309
  ...(first && sortableCols.has(start) ? { sortable: true } : {}),
310
+ ...(first && column ? { column } : {}),
311
+ ...(ownType && !inHead ? { type: ownType } : {}),
312
+ ...(value === undefined ? {} : { value }),
313
+ ...(note ? { note } : {}),
280
314
  };
281
315
  });
282
316
  if (cells.length === 0) continue;
@@ -294,6 +328,7 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
294
328
  ...(current && !head && !footer ? { group: current.key } : {}),
295
329
  ...(footer ? { footer: true } : {}),
296
330
  ...(highlight ? { highlight: true } : {}),
331
+ ...(rowType ? { type: rowType } : {}),
297
332
  });
298
333
  }
299
334
  if (rows.length === 0) return undefined;
@@ -314,6 +349,32 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
314
349
  };
315
350
  }
316
351
 
352
+ /**
353
+ * A cell's note against the table's footnotes: a `data-note` key kept when it names one, a `#<n>` marker resolved to the
354
+ * n-th footnote's key, anything else dropped (a note must name a footnote; a bare table has none).
355
+ */
356
+ function resolveNotes(table: PortableTextTable, footnotes: { _key: string }[]): PortableTextTable {
357
+ const keys = new Set(footnotes.map((f) => f._key));
358
+ const resolve = (note: string | undefined): string | undefined => {
359
+ if (!note) return undefined;
360
+ const n = /^#(\d+)$/.exec(note)?.[1];
361
+ const k = n ? footnotes[Number(n) - 1]?._key : note;
362
+ return k && keys.has(k) ? k : undefined;
363
+ };
364
+ return {
365
+ ...table,
366
+ rows: table.rows.map((r) => ({
367
+ ...r,
368
+ cells: r.cells.map((c) => {
369
+ if (c.note === undefined) return c;
370
+ const { note, ...rest } = c;
371
+ const k = resolve(note);
372
+ return k ? { ...rest, note: k } : rest;
373
+ }),
374
+ })),
375
+ };
376
+ }
377
+
317
378
  function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText): DeserializerRule {
318
379
  return {
319
380
  deserialize(el: Node, _next, createBlock) {
@@ -322,7 +383,7 @@ function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText
322
383
  // `createBlock` is what marks this BLOCK-level. Returning the object bare makes the
323
384
  // deserialiser treat it as inline and wrap it in a paragraph — the table survives, nested
324
385
  // one level down where no renderer looks for it.
325
- return table ? createBlock(table) : undefined;
386
+ return table ? createBlock(resolveNotes(table, [])) : undefined;
326
387
  },
327
388
  };
328
389
  }
@@ -349,12 +410,18 @@ function tableFigureRule(key: KeyGenerator, ingestCell: (html: string) => Portab
349
410
  const table = inner ? readTableElement(inner, key, ingestCell) : undefined;
350
411
  if (!table) return undefined;
351
412
  const noteEl = el.querySelector("[data-notes]");
352
- const footnotes = [...el.querySelectorAll("[data-footnotes] li")].map((li, i) => `[${i + 1}] ${wordsOf(li)}`);
353
- // Joined with a space, not a line break: the note renders as one `<p>`, so a break would
354
- // read back as a space and the value would not survive its own round trip.
355
- const notes = clip([wordsOf(noteEl), ...footnotes].filter(Boolean).join(" "), PT_TABLE_LIMITS.notesChars);
356
- const own = [el.getAttribute("data-id"), noteEl?.getAttribute("id")?.replace(/-notes$/, "")].find((k) => isValidKey(k));
357
- return createBlock({ ...table, ...(own ? { _key: own } : {}), ...(notes ? { notes } : {}), ...(el.getAttribute("data-filter") === "true" ? { filterable: true } : {}),
413
+ // Footnotes (V2 `<ol data-footnotes>`): kept as footnotes, each with its `data-key` (else a minted one).
414
+ const taken = new Set<string>();
415
+ const footnotes = [...el.querySelectorAll("[data-footnotes] li")].map((li) => {
416
+ const k = li.getAttribute("data-key");
417
+ const _key = k && k.length <= PT_TABLE_LIMITS.keyChars && !taken.has(k) ? k : key();
418
+ taken.add(_key);
419
+ return { _key, text: clip(wordsOf(li), PT_TABLE_LIMITS.notesChars) };
420
+ });
421
+ const notes = clip(wordsOf(noteEl), PT_TABLE_LIMITS.notesChars);
422
+ const notesBox = noteEl?.closest(".bt-notes") ?? el.querySelector(".bt-notes");
423
+ const own = [el.getAttribute("data-id"), (notesBox?.getAttribute("id") ?? noteEl?.getAttribute("id"))?.replace(/-notes$/, "")].find((k) => isValidKey(k));
424
+ return createBlock({ ...resolveNotes(table, footnotes), ...(own ? { _key: own } : {}), ...(notes ? { notes } : {}), ...(footnotes.length ? { footnotes } : {}), ...(el.getAttribute("data-filter") === "true" ? { filterable: true } : {}),
358
425
  // "Help search engines read this as" (V2 parseTableElement: dataset.jsonld, jsonldAxis, jsonldPrice). The price row
359
426
  // is kept only when it names a row read here: this renderer writes no row keys, so a round trip falls back to
360
427
  // V2's own default (the first row of prices), never to a key that names nothing.
package/src/plain.ts CHANGED
@@ -94,9 +94,12 @@ const tableLines = (table: PortableTextTable): string[] => {
94
94
  ...head.map(rowLine),
95
95
  ...layout.rows.flatMap((run) => [...(run.label === undefined ? [] : [one(run.label)]), ...(run.rows as typeof rows).map(rowLine)]),
96
96
  one(table.notes),
97
+ // footnotes, numbered as the html numbers them (V2 tableSearchText reads them too)
98
+ ...(Array.isArray(table.footnotes) ? table.footnotes : []).map((f, i) => one(`[${i + 1}] ${typeof f?.text === "string" ? f.text : ""}`)),
97
99
  ];
98
100
  // 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]);
101
+ const fnCount = Array.isArray(table.footnotes) ? table.footnotes.length : 0;
102
+ const extras = new Set([0, 1, lines.length - 1 - fnCount]);
100
103
  return lines.filter((l, i) => l !== "" || !extras.has(i));
101
104
  };
102
105
 
package/src/table.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import {
2
2
  BUILTIN_TABLE_KEY,
3
+ PT_TABLE_COLUMN_TYPES,
3
4
  PT_TYPES,
4
5
  type PortableText,
5
6
  type PortableTextTable,
6
7
  type PortableTextTableCell,
8
+ type PortableTextTableColumn,
9
+ type PortableTextTableColumnType,
7
10
  type PortableTextTableGroup,
8
11
  type PortableTextTableDesign,
12
+ type PortableTextTableFootnote,
9
13
  type PortableTextTableJsonLd,
10
14
  type PortableTextTableRow,
11
15
  } from "./types";
@@ -149,6 +153,11 @@ function normalizeCell(raw: unknown, at: [number, number], mint: Mint, read: Cel
149
153
  ...(typeof raw.group === "string" ? { group: raw.group } : {}),
150
154
  ...(raw.highlight === true ? { highlight: true } : {}),
151
155
  ...(raw.sortable === true ? { sortable: true } : {}),
156
+ // The V2 table's typed cells: the column (first-row cell), the per-cell type, the typed value.
157
+ ...(columnOf(raw.column) ? { column: columnOf(raw.column) } : {}),
158
+ ...(isColumnType(raw.type) ? { type: raw.type } : {}),
159
+ ...(typeof raw.value === "boolean" || (typeof raw.value === "number" && Number.isFinite(raw.value)) ? { value: raw.value } : {}),
160
+ ...(typeof raw.note === "string" && raw.note ? { note: raw.note } : {}),
152
161
  };
153
162
  }
154
163
 
@@ -164,6 +173,7 @@ function normalizeRow(raw: unknown, at: number, mint: Mint, read: CellContentRea
164
173
  // The V2 table's two row flags ("Totals row", "Highlight this row"); see `PortableTextTableRow`.
165
174
  ...(isRecord(raw) && raw.footer === true ? { footer: true } : {}),
166
175
  ...(isRecord(raw) && raw.highlight === true ? { highlight: true } : {}),
176
+ ...(isRecord(raw) && isColumnType(raw.type) ? { type: raw.type } : {}),
167
177
  };
168
178
  }
169
179
 
@@ -265,9 +275,24 @@ function tableExtras(node: Record<string, unknown>): Partial<PortableTextTable>
265
275
  ...(node.filterable === true ? { filterable: true } : {}),
266
276
  ...(jsonLdOf(node.jsonLd) ? { jsonLd: jsonLdOf(node.jsonLd) } : {}),
267
277
  ...(designOf(node.design) ? { design: designOf(node.design) } : {}),
278
+ ...(footnotesOf(node.footnotes) ? { footnotes: footnotesOf(node.footnotes) } : {}),
268
279
  };
269
280
  }
270
281
 
282
+ /** Stored footnotes read permissively: entries with a string `_key`, their text ("" when missing); none is nothing. */
283
+ export function footnotesOf(v: unknown): PortableTextTableFootnote[] | undefined {
284
+ if (!Array.isArray(v)) return undefined;
285
+ const out = v
286
+ .filter((f): f is Record<string, unknown> => isRecord(f) && typeof f._key === "string" && f._key !== "")
287
+ .map((f) => ({ _key: f._key as string, text: typeof f.text === "string" ? f.text : "" }));
288
+ return out.length ? out : undefined;
289
+ }
290
+
291
+ /** footnote `_key` → its number (1-based, in order), as V2's footnoteIndex. */
292
+ export function footnoteNumbers(table: PortableTextTable): Map<string, number> {
293
+ return new Map((footnotesOf(table.footnotes) ?? []).map((f, i) => [f._key, i + 1]));
294
+ }
295
+
271
296
  const DESIGN_KEYS = new Set(["responsive", "sticky"]);
272
297
 
273
298
  /**
@@ -372,14 +397,32 @@ export function tableProblems(node: unknown): PortableTextTableProblem[] {
372
397
  };
373
398
  const rowGroups = keyed(node.rowGroups, "rowGroups");
374
399
  const columnGroups = keyed(node.columnGroups, "columnGroups");
400
+ // Footnotes: [{ _key, text }], the field-lane table's (`text` held to the note's limit)
401
+ const footnoteKeys = new Set<string>();
402
+ if (node.footnotes !== undefined) {
403
+ if (!Array.isArray(node.footnotes)) bad(["footnotes"], "footnotes must be an array of { _key, text }");
404
+ else
405
+ node.footnotes.forEach((f, i) => {
406
+ if (!isRecord(f)) return bad(["footnotes", i], `footnotes[${i}] must be an object { _key, text }`);
407
+ for (const k of Object.keys(f)) if (k !== "_key" && k !== "text") bad(["footnotes", i, k], `unknown key "${k}" (_key, text)`);
408
+ if (typeof f._key !== "string" || f._key === "" || f._key.length > PT_TABLE_LIMITS.keyChars) bad(["footnotes", i, "_key"], `needs a _key string of 1 to ${PT_TABLE_LIMITS.keyChars} characters`);
409
+ else if (footnoteKeys.has(f._key)) bad(["footnotes", i, "_key"], `_key "${f._key}" is used twice`);
410
+ else footnoteKeys.add(f._key);
411
+ text(f.text, ["footnotes", i, "text"], "text", PT_TABLE_LIMITS.notesChars, true);
412
+ });
413
+ }
375
414
 
376
415
  const rows = Array.isArray(node.rows) ? node.rows : [];
416
+ // The columns' types, by cell index, as the first row names them (a typed value is judged against its column).
417
+ const firstCells = isRecord(rows[0]) && Array.isArray(rows[0].cells) ? (rows[0].cells as unknown[]) : [];
418
+ const columns = firstCells.map((c) => (isRecord(c) ? columnOf(c.column) : undefined));
377
419
  rows.forEach((row, r) => {
378
420
  if (!isRecord(row)) return;
379
421
  for (const flag of ["footer", "highlight"] as const) {
380
422
  if (row[flag] !== undefined && typeof row[flag] !== "boolean") bad(["rows", r, flag], `${flag} must be true or false`);
381
423
  }
382
424
  if (row.head === true && row.footer === true) bad(["rows", r, "footer"], "a head row cannot also be a footer (totals) row");
425
+ if (row.type !== undefined && !isColumnType(row.type)) bad(["rows", r, "type"], `type must be one of ${PT_TABLE_COLUMN_TYPES.join(", ")}`);
383
426
  if (row.group !== undefined) {
384
427
  if (row.head === true) bad(["rows", r, "group"], "a head row takes no group (row groups hold body rows)");
385
428
  else if (typeof row.group !== "string" || !rowGroups.has(row.group)) {
@@ -387,6 +430,10 @@ export function tableProblems(node: unknown): PortableTextTableProblem[] {
387
430
  }
388
431
  }
389
432
  (Array.isArray(row.cells) ? row.cells : []).forEach((cell, c) => {
433
+ if (isRecord(cell)) for (const p of typedCellProblems(cell, row, r, c, columns[c])) bad(p.path, p.message);
434
+ if (isRecord(cell) && cell.note !== undefined && (typeof cell.note !== "string" || !footnoteKeys.has(cell.note))) {
435
+ bad(["rows", r, "cells", c, "note"], `note "${String(cell.note)}" is not a key of footnotes`);
436
+ }
390
437
  for (const flag of ["highlight", "sortable"] as const) {
391
438
  if (!isRecord(cell) || cell[flag] === undefined) continue;
392
439
  const at = ["rows", r, "cells", c, flag];
@@ -520,3 +567,223 @@ export function tableGroupLayout(table: PortableTextTable): { columns: TableColu
520
567
  width,
521
568
  };
522
569
  }
570
+
571
+ /* ── Typed cells (the V2 table's column types, src/schema/table.ts) ─────────────────────── */
572
+
573
+ const COLUMN_KEYS = new Set(["type", "description", "unit", "currency", "decimals", "align", "trueLabel", "falseLabel", "max"]);
574
+ const NUMERIC_TYPES = new Set<string>(["number", "money", "rating"]);
575
+
576
+ export const isColumnType = (v: unknown): v is PortableTextTableColumnType => (PT_TABLE_COLUMN_TYPES as readonly unknown[]).includes(v);
577
+
578
+ /** A stored `column` read permissively: each V2 key kept when well formed, a type this table offers; nothing left is nothing. */
579
+ export function columnOf(v: unknown): PortableTextTableColumn | undefined {
580
+ if (!isRecord(v)) return undefined;
581
+ const str = (x: unknown) => (typeof x === "string" && x !== "" ? x : undefined);
582
+ const int = (x: unknown, lo: number, hi: number) => (Number.isInteger(x) && (x as number) >= lo && (x as number) <= hi ? (x as number) : undefined);
583
+ const c: PortableTextTableColumn = {
584
+ ...(isColumnType(v.type) ? { type: v.type } : {}),
585
+ ...(str(v.description) ? { description: str(v.description) } : {}),
586
+ ...(str(v.unit) ? { unit: str(v.unit) } : {}),
587
+ ...(typeof v.currency === "string" && /^[A-Z]{3}$/.test(v.currency) ? { currency: v.currency } : {}),
588
+ ...(int(v.decimals, 0, 10) !== undefined ? { decimals: int(v.decimals, 0, 10) } : {}),
589
+ ...(v.align === "start" || v.align === "center" || v.align === "end" ? { align: v.align } : {}),
590
+ ...(str(v.trueLabel) ? { trueLabel: str(v.trueLabel) } : {}),
591
+ ...(str(v.falseLabel) ? { falseLabel: str(v.falseLabel) } : {}),
592
+ ...(int(v.max, 1, 10) !== undefined ? { max: int(v.max, 1, 10) } : {}),
593
+ };
594
+ return Object.keys(c).length ? c : undefined;
595
+ }
596
+
597
+ /** Each column's `column`, by column index (spans counted), from the FIRST-row cells. */
598
+ export function tableColumns(table: PortableTextTable): (PortableTextTableColumn | undefined)[] {
599
+ const out: (PortableTextTableColumn | undefined)[] = [];
600
+ const first = Array.isArray(table.rows) ? table.rows[0] : undefined;
601
+ for (const cell of isRecord(first) && Array.isArray(first.cells) ? first.cells : []) {
602
+ const w = isRecord(cell) && typeof cell.span === "number" && Number.isInteger(cell.span) && cell.span > 1 ? cell.span : 1;
603
+ const col = isRecord(cell) ? columnOf(cell.column) : undefined;
604
+ for (let i = 0; i < w; i += 1) out.push(col);
605
+ }
606
+ return out;
607
+ }
608
+
609
+ /**
610
+ * A table that draws V2's typed markup: its first row names a `column`, or a row or cell carries a type or a value (a "Write
611
+ * words instead" cell in a link column is typed even when no column is).
612
+ */
613
+ export function isTypedTable(table: PortableTextTable): boolean {
614
+ if (tableColumns(table).some(Boolean)) return true;
615
+ return (Array.isArray(table.rows) ? (table.rows as unknown[]) : []).some(
616
+ (r) => isRecord(r) && (r.type !== undefined || (Array.isArray(r.cells) && r.cells.some((c: unknown) => isRecord(c) && (c.type !== undefined || c.value !== undefined)))),
617
+ );
618
+ }
619
+
620
+ /** V2 `cellType`: the cell's own type, else the row's, else the column's (text when none says). */
621
+ export const cellTypeOf = (col: PortableTextTableColumn | undefined, row: unknown, cell: unknown): PortableTextTableColumnType =>
622
+ (isRecord(cell) && isColumnType(cell.type) ? cell.type : undefined) ?? (isRecord(row) && isColumnType(row.type) ? row.type : undefined) ?? col?.type ?? "text";
623
+
624
+ /** V2 `alignOf` (schema/table.ts:365): numbers, money and ratings align end unless the column says otherwise. */
625
+ export const columnAlign = (col: PortableTextTableColumn | undefined, type: PortableTextTableColumnType): "start" | "center" | "end" =>
626
+ col?.align ?? (NUMERIC_TYPES.has(type) ? "end" : "start");
627
+
628
+ /** The types whose cell is a value rather than words: V2 renders them from the value (cellHtml). */
629
+ export const isValueType = (t: PortableTextTableColumnType): boolean => t === "boolean" || NUMERIC_TYPES.has(t);
630
+
631
+ const TRUE_WORDS = new Set(["✓", "✔", "✔️", "☑", "✅", "yes", "y", "true", "included", "available", "supported", "x ✓", "check", "●"]);
632
+ const FALSE_WORDS = new Set(["✗", "✕", "✖", "❌", "×", "x", "no", "n", "false", "not included", "unavailable", "not available", "unsupported", "—", "–", "-", "○"]);
633
+ /** V2 `toBool` (schema/table.ts:315). */
634
+ export function toBool(v: unknown): boolean | null {
635
+ if (typeof v === "boolean") return v;
636
+ if (typeof v !== "string") return null;
637
+ const s = v.trim().toLowerCase();
638
+ return TRUE_WORDS.has(s) ? true : FALSE_WORDS.has(s) ? false : null;
639
+ }
640
+ /** V2 `toNumber` (schema/table.ts:273). */
641
+ export function toNumber(v: unknown): number | null {
642
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
643
+ if (typeof v !== "string") return null;
644
+ const s = v.replace(/[,\s]/g, "").replace(/[^\d.+-]/g, "");
645
+ if (!s || !/^[+-]?\d*\.?\d+$/.test(s)) return null;
646
+ return Number(s);
647
+ }
648
+ /** V2 `formatNumber` (schema/table.ts:262). */
649
+ export const formatNumber = (n: number, decimals?: number): string =>
650
+ new Intl.NumberFormat("en-US", decimals != null ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : { maximumFractionDigits: 6 }).format(n);
651
+ /** V2 `formatMoney` (schema/table.ts:265). */
652
+ export function formatMoney(n: number, currency = "USD", decimals?: number): string {
653
+ try {
654
+ const whole = Number.isInteger(n) && decimals == null;
655
+ return new Intl.NumberFormat("en-US", { style: "currency", currency, minimumFractionDigits: whole ? 0 : decimals ?? 2, maximumFractionDigits: whole ? 0 : decimals ?? 2 }).format(n);
656
+ } catch {
657
+ return `${formatNumber(n, decimals)} ${currency}`;
658
+ }
659
+ }
660
+ /** V2 `boolLabels` (schema/table.ts:252). */
661
+ export const boolLabels = (col: PortableTextTableColumn | undefined) => ({ yes: col?.trueLabel || "Included", no: col?.falseLabel || "Not included" });
662
+
663
+ /**
664
+ * V2 `cellText` (schema/table.ts:282) for a value type: the words a Number, Price, Yes / No or Rating cell says, which is what a
665
+ * typed cell's `content` holds. `v` is the typed value, or the cell's words when it has none.
666
+ */
667
+ export function typedCellText(col: PortableTextTableColumn | undefined, type: PortableTextTableColumnType, v: unknown): string {
668
+ if (v == null || v === "") return "";
669
+ const words = String(v).replace(/\s+/g, " ").trim();
670
+ switch (type) {
671
+ case "boolean": {
672
+ const b = toBool(v);
673
+ return b == null ? words : b ? boolLabels(col).yes : boolLabels(col).no;
674
+ }
675
+ case "number": {
676
+ const n = toNumber(v);
677
+ return n == null ? words : `${formatNumber(n, col?.decimals)}${col?.unit ? (/^[%°]/.test(col.unit) ? col.unit : ` ${col.unit}`) : ""}`;
678
+ }
679
+ case "money": {
680
+ const n = toNumber(v);
681
+ return n == null ? words : formatMoney(n, col?.currency || "USD", col?.decimals);
682
+ }
683
+ case "rating": {
684
+ const n = toNumber(v);
685
+ return n == null ? words : `${formatNumber(n, 1).replace(/\.0$/, "")} out of ${col?.max ?? 5}`;
686
+ }
687
+ default:
688
+ return words;
689
+ }
690
+ }
691
+
692
+ const escHtml = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
693
+
694
+ /**
695
+ * What a cell of a TYPED table (one whose first row names a `column`) draws, shared by both renderers so they cannot disagree:
696
+ * a head-row cell → `lead` data-align (V2: not when start), `tail` the column's description as `title`
697
+ * (V2 renderTableHtml's `<th scope="col" data-align data-highlight title>`, schema/table.ts:564)
698
+ * any other cell → `tail` data-type (its own type), data-value / data-empty, data-align, and `inner`, V2 cellHtml's markup
699
+ * (schema/table.ts:369-437) when the cell is empty or of a value type; text and link cells draw their blocks.
700
+ * Attribute values are raw (the caller escapes them); `inner` is markup, its text already escaped.
701
+ */
702
+ export function typedCellView(
703
+ column: PortableTextTableColumn | undefined,
704
+ row: unknown,
705
+ cell: Record<string, unknown>,
706
+ inHead: boolean,
707
+ ): { lead: [string, string][]; tail: [string, string][]; inner?: string } {
708
+ if (inHead) {
709
+ const align = columnAlign(column, column?.type ?? "text");
710
+ return { lead: align !== "start" ? [["data-align", align]] : [], tail: column?.description ? [["title", column.description]] : [] };
711
+ }
712
+ const type = cellTypeOf(column, row, cell);
713
+ const words = portableTextWords(cell.content);
714
+ const v = typeof cell.value === "number" || typeof cell.value === "boolean" ? cell.value : words;
715
+ const tail: [string, string][] = isColumnType(cell.type) ? [["data-type", cell.type]] : [];
716
+ let inner: string | undefined;
717
+ if (v === "") {
718
+ inner = cell.header === true ? "" : '<span aria-hidden="true" class="bt-dash">—</span><span class="bt-sr">No value</span>';
719
+ tail.push(["data-empty", ""]);
720
+ } else if (type === "boolean") {
721
+ const b = toBool(v);
722
+ if (b == null) inner = escHtml(words);
723
+ else {
724
+ const { yes, no } = boolLabels(column);
725
+ inner = `<span aria-hidden="true" class="bt-bool" data-bool="${b}">${b ? "✓" : "–"}</span><span class="bt-sr">${escHtml(b ? yes : no)}</span>`;
726
+ tail.push(["data-value", String(b)]);
727
+ }
728
+ } else if (type === "rating") {
729
+ const n = toNumber(v);
730
+ if (n == null) inner = escHtml(words);
731
+ else {
732
+ inner = `<span aria-hidden="true" class="bt-star">★</span> ${escHtml(formatNumber(n, 1).replace(/\.0$/, ""))}<span class="bt-dim"> / ${column?.max ?? 5}</span>`;
733
+ tail.push(["data-value", String(n)]);
734
+ }
735
+ } else if (type === "number" || type === "money") {
736
+ const n = toNumber(v);
737
+ inner = escHtml(typedCellText(column, type, v));
738
+ if (n != null) tail.push(["data-value", String(n)]);
739
+ }
740
+ const align = columnAlign(column, type);
741
+ if (align !== "start") tail.push(["data-align", align]);
742
+ return { lead: [], tail, ...(inner === undefined ? {} : { inner }) };
743
+ }
744
+
745
+ /** A cell's words: the text of its blocks, whitespace collapsed (as `portableTextToPlainText` reads a paragraph). */
746
+ function portableTextWords(content: unknown): string {
747
+ return (Array.isArray(content) ? content : [])
748
+ .map((b) => (isRecord(b) && Array.isArray(b.children) ? b.children.map((c) => (isRecord(c) && typeof c.text === "string" ? c.text : "")).join("") : ""))
749
+ .join(" ")
750
+ .replace(/\s+/g, " ")
751
+ .trim();
752
+ }
753
+
754
+ /** Why a cell's typed keys cannot be stored (`column` only on the first row, a known type, a value its type holds). */
755
+ function typedCellProblems(cell: Record<string, unknown>, row: Record<string, unknown>, r: number, c: number, col: PortableTextTableColumn | undefined): PortableTextTableProblem[] {
756
+ const out: PortableTextTableProblem[] = [];
757
+ const bad = (k: string | string[], message: string, code = "table_value_invalid") =>
758
+ out.push({ path: ["rows", r, "cells", c, ...(Array.isArray(k) ? k : [k])], code, message: `${code}: ${message}` });
759
+ if (cell.column !== undefined) {
760
+ const v = cell.column;
761
+ if (r !== 0) bad("column", "a column's type is named on its cell in the first row only");
762
+ else if (!isRecord(v)) bad("column", "column must be an object { type, unit?, currency?, decimals?, align?, trueLabel?, falseLabel?, max?, description? }");
763
+ else {
764
+ for (const k of Object.keys(v)) if (!COLUMN_KEYS.has(k)) bad(["column", k], `unknown key "${k}" (${[...COLUMN_KEYS].join(", ")})`);
765
+ if (v.type !== undefined && !isColumnType(v.type)) bad(["column", "type"], `type must be one of ${PT_TABLE_COLUMN_TYPES.join(", ")}`);
766
+ const text = (k: string, max: number) => {
767
+ if (v[k] === undefined) return;
768
+ if (typeof v[k] !== "string") bad(["column", k], `${k} must be a string`);
769
+ else if ((v[k] as string).length > max) bad(["column", k], `${k} is ${(v[k] as string).length} characters; at most ${max}`, "table_limit_exceeded");
770
+ };
771
+ text("description", PT_TABLE_LIMITS.notesChars);
772
+ text("unit", 16);
773
+ text("trueLabel", PT_TABLE_LIMITS.labelChars);
774
+ text("falseLabel", PT_TABLE_LIMITS.labelChars);
775
+ if (v.currency !== undefined && (typeof v.currency !== "string" || !/^[A-Z]{3}$/.test(v.currency))) bad(["column", "currency"], "currency must be an ISO 4217 code such as USD");
776
+ if (v.decimals !== undefined && (!Number.isInteger(v.decimals) || (v.decimals as number) < 0 || (v.decimals as number) > 10)) bad(["column", "decimals"], "decimals must be a whole number from 0 to 10");
777
+ if (v.max !== undefined && (!Number.isInteger(v.max) || (v.max as number) < 1 || (v.max as number) > 10)) bad(["column", "max"], "max must be a whole number from 1 to 10");
778
+ if (v.align !== undefined && v.align !== "start" && v.align !== "center" && v.align !== "end") bad(["column", "align"], 'align must be "start", "center" or "end"');
779
+ }
780
+ }
781
+ if (cell.type !== undefined && !isColumnType(cell.type)) bad("type", `type must be one of ${PT_TABLE_COLUMN_TYPES.join(", ")}`);
782
+ if (cell.value !== undefined) {
783
+ const type = cellTypeOf(col, row, cell);
784
+ if (type === "boolean" ? typeof cell.value !== "boolean" : NUMERIC_TYPES.has(type) ? typeof cell.value !== "number" || !Number.isFinite(cell.value) : true) {
785
+ bad("value", `a ${type} cell's value must be ${type === "boolean" ? "true or false" : NUMERIC_TYPES.has(type) ? "a number" : "absent (its words are its content)"}`);
786
+ }
787
+ }
788
+ return out;
789
+ }
package/src/types.ts CHANGED
@@ -120,6 +120,52 @@ export interface PortableTextTableCell {
120
120
  * `highlight`. Renders as the column's `<col data-sortable="true">`, as the V2 table writes it.
121
121
  */
122
122
  sortable?: boolean;
123
+ /**
124
+ * The column's type and its settings (the V2 table's `TableColumn` type, unit, currency, decimals, align, trueLabel,
125
+ * falseLabel, max, description): named, like `group`, on the column's FIRST-row cell. Nested rather than spread on the
126
+ * cell because the cell's own `type` is the per-cell type. Renders as the column's `<col data-type …>`, as V2 writes it.
127
+ */
128
+ column?: PortableTextTableColumn;
129
+ /** The per-cell type (the V2 table's `TableCellObject.type`): wins over the row's and the column's. */
130
+ type?: PortableTextTableColumnType;
131
+ /**
132
+ * The typed value of a Number, Price, Yes / No or Rating cell (the V2 table's `TableCellValue`): a number, or true /
133
+ * false. `content` still holds the words the cell says (V2's `cellText`), so every reader that reads blocks reads it.
134
+ */
135
+ value?: number | boolean;
136
+ /**
137
+ * A footnote on this cell (the V2 table's `TableCellObject.note`): a `_key` of the table's `footnotes`. The cell renders
138
+ * `data-note` and the numbered marker V2 writes, `<sup><a href="#<table key>-fn-<n>" aria-label="Note <n>"><n></a></sup>`.
139
+ */
140
+ note?: string;
141
+ }
142
+
143
+ /**
144
+ * The column types a rich-text table offers: the V2 table's `TableColumnType`s its island offers (table-editor.tsx:80-86
145
+ * TYPE_UI without the two `dev` ones; V2's rich-text TableBlock passes no `developer`).
146
+ */
147
+ export const PT_TABLE_COLUMN_TYPES = ["text", "number", "money", "boolean", "rating", "link"] as const;
148
+ export type PortableTextTableColumnType = (typeof PT_TABLE_COLUMN_TYPES)[number];
149
+
150
+ /** A column's type and settings, the V2 table's `TableColumn` keys (schema/table.ts:44-72), spelled the same way. */
151
+ export interface PortableTextTableColumn {
152
+ /** Absent reads as `text`. */
153
+ type?: PortableTextTableColumnType;
154
+ /** Explains the column: the header's `title`. */
155
+ description?: string;
156
+ /** number: written after every value ("kg", "%", "min"). */
157
+ unit?: string;
158
+ /** money: ISO 4217 code. Default USD. */
159
+ currency?: string;
160
+ /** number / money: fixed decimals. */
161
+ decimals?: number;
162
+ /** Default: numbers, money and ratings align end, everything else start. */
163
+ align?: "start" | "center" | "end";
164
+ /** boolean: the words. Defaults "Included" / "Not included". */
165
+ trueLabel?: string;
166
+ falseLabel?: string;
167
+ /** rating: the scale. Default 5. */
168
+ max?: number;
123
169
  }
124
170
 
125
171
  export interface PortableTextTableRow {
@@ -136,6 +182,8 @@ export interface PortableTextTableRow {
136
182
  footer?: boolean;
137
183
  /** "Highlight this row": `<tr data-highlight="true">`, as the V2 table renders it. */
138
184
  highlight?: boolean;
185
+ /** The per-row type (the V2 table's `TableRow.type`): a pricing matrix mixes Yes / No rows with limit rows. `<tr data-type>`. */
186
+ type?: PortableTextTableColumnType;
139
187
  }
140
188
 
141
189
  /**
@@ -194,6 +242,17 @@ export interface PortableTextTable extends PortableTextObject {
194
242
  * on the figure as `data-responsive` / `data-sticky="true"`, as the V2 table writes them.
195
243
  */
196
244
  design?: PortableTextTableDesign;
245
+ /**
246
+ * Footnotes (the V2 table's `TableValue.footnotes`, the field-lane table's `footnotes`): numbered in order, named by a
247
+ * cell's `note`, written under the table as V2's `<ol data-footnotes>` inside `<div class="bt-notes">`.
248
+ */
249
+ footnotes?: PortableTextTableFootnote[];
250
+ }
251
+
252
+ /** One table footnote: a stable `_key` (what a cell's `note` names) and its text. */
253
+ export interface PortableTextTableFootnote {
254
+ _key: string;
255
+ text: string;
197
256
  }
198
257
 
199
258
  /** The V2 table's `design` keys the rich-text table offers. */
@@ -403,6 +462,9 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
403
462
  */
404
463
  export const BUILTIN_IMAGE_KEY = "builtin:image" as const;
405
464
  export const BUILTIN_EMBED_KEY = "builtin:video" as const;
465
+ /** A callout (`{ tone: info|tip|warning, title, message }`) and a toggle (`{ title, body }`): plain text, read straight. */
466
+ export const BUILTIN_CALLOUT_KEY = "builtin:callout" as const;
467
+ export const BUILTIN_TOGGLE_KEY = "builtin:toggle" as const;
406
468
 
407
469
  /**
408
470
  * The `schemaKey` of a component embedded in rich text. A wire value shared with the dashboard