@bettercms-ai/richtext 0.9.0 → 0.11.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.9.0",
3
+ "version": "0.11.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
@@ -8,7 +8,8 @@ import {
8
8
  type PortableTextTable,
9
9
  } from "./types";
10
10
  import { resolveVideoSource } from "./video";
11
- import { builtInTableToTable, tableGroupLayout } from "./table";
11
+ import { builtInTableToTable, designOf, footnoteNumbers, footnotesOf, highlightedColumns, isTypedTable, jsonLdOf, sortableColumns, tableColumns, tableGroupLayout, tableSections, typedCellView } from "./table";
12
+ import { portableTextToPlainText } from "./plain";
12
13
  import { safeHref } from "./href";
13
14
  import { isStyledTag, styledAttrEntries } from "./styled";
14
15
  import { richTextImageLayout } from "./image-layout";
@@ -64,39 +65,118 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
64
65
  const attr = (name: string, value: unknown): string =>
65
66
  typeof value === "number" && Number.isInteger(value) && value > 1 ? ` ${name}="${value}"` : "";
66
67
 
68
+ /** V2 schema/table.ts withTableRoles, verbatim: a stacked table's explicit ARIA roles. */
69
+ function withTableRoles(html: string): string {
70
+ return html
71
+ .replace(/<table(?=[\s>])/, '<table role="table"')
72
+ .replace(/<(thead|tbody|tfoot)(?=[\s>])/g, '<$1 role="rowgroup"')
73
+ .replace(/<tr(?=[\s>])/g, '<tr role="row"')
74
+ .replace(/<th scope="(col|colgroup)"/g, '<th role="columnheader" scope="$1"')
75
+ .replace(/<th scope="(row|rowgroup)"/g, '<th role="rowheader" scope="$1"')
76
+ .replace(/<td(?=[\s>])/g, '<td role="cell"');
77
+ }
78
+
67
79
  function tableHtml(table: PortableTextTable): string {
68
80
  const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
69
81
  if (rows.length === 0) return "";
70
82
 
71
- const cellHtml = (raw: unknown): string => {
83
+ // "Highlight this column": every cell starting in a marked column (V2 stamps each th/td of it).
84
+ const marked = highlightedColumns(table);
85
+ // "On phones" (stack / cards): each body cell names its column (`data-label`, V2 renderTableHtml), from the last head row
86
+ const design = designOf(table.design);
87
+ const stacked = design?.responsive === "stack" || design?.responsive === "cards";
88
+ const labels: string[] = [];
89
+ if (stacked) {
90
+ const head = tableSections(table).head;
91
+ const last = head[head.length - 1];
92
+ let at = 0;
93
+ for (const c of isRecord(last) && Array.isArray(last.cells) ? last.cells : []) {
94
+ const text = isRecord(c) ? portableTextToPlainText(c.content as PortableText).replace(/\s+/g, " ").trim() : "";
95
+ const w = isRecord(c) && typeof c.span === "number" && Number.isInteger(c.span) && c.span > 1 ? c.span : 1;
96
+ for (let i = 0; i < w; i += 1) labels[at + i] = text;
97
+ at += w;
98
+ }
99
+ }
100
+ // The V2 table's typed columns (a first-row cell's `column`): a table with one renders V2's typed markup (cellHtml).
101
+ const columns = tableColumns(table);
102
+ const typed = isTypedTable(table);
103
+ const headRows = new Set(tableSections(table).head);
104
+ const fnNumber = footnoteNumbers(table);
105
+ const footnotes = footnotesOf(table.footnotes) ?? [];
106
+ const tableId = typeof table._key === "string" && table._key ? table._key : "tbl";
107
+ const cellHtml = (raw: unknown, col: number, row: unknown): string => {
72
108
  const cell = isRecord(raw) ? raw : {};
73
109
  const tag = cell.header === true ? "th" : "td";
74
110
  // `colspan` before `rowspan`, the order every table in the corpus writes them in: the
75
111
  // comparison is on serialised markup, and a parser preserves the order it was given.
76
- const attrs = `${attr("colspan", cell.span)}${attr("rowspan", cell.rowSpan)}`;
77
- return `<${tag}${attrs}>${portableTextToHtml(cell.content as PortableText, { inline: true })}</${tag}>`;
112
+ const label = stacked && tag === "td" ? ` data-label="${esc(labels[col] || `Column ${col + 1}`)}"` : "";
113
+ // A footnote: V2 renderTableHtml's data-note (after the value, before data-align) and its numbered marker after the
114
+ // cell's words (V2 cellHtml `inner + sup`)
115
+ const n = typeof cell.note === "string" ? fnNumber.get(cell.note) : undefined;
116
+ const sup = n ? `<sup><a href="#${esc(tableId)}-fn-${n}" aria-label="Note ${n}">${n}</a></sup>` : "";
117
+ const content = portableTextToHtml(cell.content as PortableText, { inline: true });
118
+ 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}>`;
119
+ const view = typedCellView(columns[col], row, cell, headRows.has(row));
120
+ 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;
121
+ const out = (list: [string, string][]) => list.map(([k, v]) => ` ${k}="${esc(v)}"`).join("");
122
+ 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}>`;
78
123
  };
79
124
  const rowHtml = (raw: unknown): string => {
80
125
  const cells = isRecord(raw) && Array.isArray(raw.cells) ? raw.cells : [];
81
- return `<tr>${cells.map(cellHtml).join("")}</tr>`;
126
+ // "Highlight this row": the V2 table's own attribute (src/schema/table.ts rowHtml), after the row's type (data-type).
127
+ const type = isRecord(raw) && typeof raw.type === "string" ? ` data-type="${esc(raw.type)}"` : "";
128
+ const highlight = isRecord(raw) && raw.highlight === true ? ' data-highlight="true"' : "";
129
+ let col = 0;
130
+ const html = cells.map((c) => {
131
+ const out = cellHtml(c, col, raw);
132
+ col += isRecord(c) && typeof c.span === "number" && Number.isInteger(c.span) && c.span > 1 ? c.span : 1;
133
+ return out;
134
+ });
135
+ return `<tr${type}${highlight}>${html.join("")}</tr>`;
82
136
  };
83
137
 
84
138
  // The LEADING RUN of head rows, not every row flagged one: `<thead>` is a section, and a
85
139
  // header row appearing after body rows cannot be lifted out of document order without moving
86
140
  // content the author placed.
87
- const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
88
- const head = rows.slice(0, headCount === -1 ? rows.length : headCount);
89
- const body = rows.slice(head.length);
141
+ // Likewise the TRAILING run of footer (totals) rows is `<tfoot>`, after every `<tbody>` (V2).
142
+ const { head, body, foot } = tableSections(table);
90
143
  const layout = tableGroupLayout(table);
91
144
  // The platform's own class, on migrated values only — see `PortableTextTable["legacy"]`.
92
145
  const cls = table.legacy === true ? ' class="bcms-table"' : "";
93
146
  const notes = typeof table.notes === "string" ? table.notes.trim() : "";
94
- const notesId = notes && typeof table._key === "string" && table._key ? `${table._key}-notes` : "";
147
+ const notesId = (notes || footnotes.length) && typeof table._key === "string" && table._key ? `${table._key}-notes` : "";
95
148
  const caption = typeof table.caption === "string" && table.caption.trim() ? `<caption>${esc(table.caption.trim())}</caption>` : "";
96
149
 
97
150
  // Column groups: one `<colgroup>` per run of columns, and a `scope="colgroup"` header over each
98
151
  // 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("");
152
+ // "Visitors can sort columns": one `<col data-sortable="true">` per sortable column, as the V2 table writes it
153
+ // (src/schema/table.ts renderTableHtml); with `<col>`s a column group's `<colgroup>` holds them instead of a `span`.
154
+ const sortable = sortableColumns(table);
155
+ let colAt = 0;
156
+ const cols = (width: number): string => {
157
+ const out = Array.from({ length: width }, (_, i) => `<col${colAttrs(colAt + i)}>`).join("");
158
+ colAt += width;
159
+ return out;
160
+ };
161
+ // A typed column's `<col>`: V2 renderTableHtml's attributes in its order (schema/table.ts:511-526), data-sortable among them
162
+ const colAttrs = (i: number): string => {
163
+ const c = columns[i];
164
+ const a = c
165
+ ? [
166
+ ` data-type="${c.type ?? "text"}"`,
167
+ c.unit ? ` data-unit="${esc(c.unit)}"` : "",
168
+ c.currency ? ` data-currency="${esc(c.currency)}"` : "",
169
+ c.decimals != null ? ` data-decimals="${c.decimals}"` : "",
170
+ c.align ? ` data-align="${c.align}"` : "",
171
+ ]
172
+ : [];
173
+ a.push(sortable.has(i) ? ' data-sortable="true"' : "");
174
+ 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)}"` : "");
175
+ return a.join("");
176
+ };
177
+ const colgroups = sortable.size || columns.some(Boolean)
178
+ ? (layout.columns.length ? layout.columns.map((run) => run.width) : [layout.width]).map((w) => `<colgroup>${cols(w)}</colgroup>`).join("")
179
+ : layout.columns.map((run) => `<colgroup${attr("span", run.width)}></colgroup>`).join("");
100
180
  const groupRow = layout.columns.length
101
181
  ? `<tr>${layout.columns
102
182
  .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>`))
@@ -111,11 +191,29 @@ function tableHtml(table: PortableTextTable): string {
111
191
  .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
192
  .join("")
113
193
  : body.length ? `<tbody>${body.map(rowHtml).join("")}</tbody>` : "";
194
+ const tfoot = foot.length ? `<tfoot>${foot.map(rowHtml).join("")}</tfoot>` : "";
114
195
 
115
- const html = `<table${cls}${notesId ? ` aria-describedby="${esc(notesId)}"` : ""}>${caption}${colgroups}${thead}${tbody}</table>`;
196
+ const plainHtml = `<table${cls}${notesId ? ` aria-describedby="${esc(notesId)}"` : ""}>${caption}${colgroups}${thead}${tbody}${tfoot}</table>`;
116
197
  // A note sits UNDER the table, so it needs a container to stay with it: the V2 kit's
117
198
  // `<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;
199
+ // "Visitors can search rows" is the figure's `data-filter="true"` (V2 renderTableHtml), so it takes the figure too.
200
+ const filter = table.filterable === true ? ' data-filter="true"' : "";
201
+ // "Help search engines read this as": V2 renderTableHtml's data-jsonld (+ axis, price row) on the figure, after data-filter
202
+ const j = jsonLdOf(table.jsonLd);
203
+ const jsonLd = j ? ` data-jsonld="${j.type}"${j.axis ? ` data-jsonld-axis="${j.axis}"` : ""}${j.priceRow ? ` data-jsonld-price="${esc(j.priceRow)}"` : ""}` : "";
204
+ // "On phones" / "Keep column names in view": V2 renderTableHtml's data-responsive / data-sticky, before data-filter; a
205
+ // stacked table takes V2's withTableRoles (explicit ARIA roles, since display:block drops the implicit ones)
206
+ const designAttrs = design ? `${design.responsive ? ` data-responsive="${design.responsive}"` : ""}${design.sticky ? ' data-sticky="true"' : ""}` : "";
207
+ const html = stacked ? withTableRoles(plainHtml) : plainHtml;
208
+ if (!notes && !filter && !jsonLd && !designAttrs && !footnotes.length) return html;
209
+ // Footnotes: V2 renderTableHtml's notes block, `<div class="bt-notes" id="<key>-notes">` holding the note and the
210
+ // `<ol data-footnotes>`, each `<li id="<key>-fn-<n>" data-key>`. A note with no footnotes keeps the one `<p>` it had.
211
+ const under = footnotes.length
212
+ ? `<div class="bt-notes" id="${esc(tableId)}-notes">${notes ? `<p data-notes>${esc(notes)}</p>` : ""}<ol data-footnotes>${footnotes
213
+ .map((f, i) => `<li id="${esc(tableId)}-fn-${i + 1}" data-key="${esc(f._key)}">${esc(f.text)}</li>`)
214
+ .join("")}</ol></div>`
215
+ : notes ? `<p data-notes${notesId ? ` id="${esc(notesId)}"` : ""}>${esc(notes)}</p>` : "";
216
+ return `<figure data-rt="table"${designAttrs}${filter}${jsonLd}>${html}${under}</figure>`;
119
217
  }
120
218
 
121
219
  /**
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 { 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
 
@@ -150,12 +152,13 @@ const cellsOf = (row: Element): Element[] =>
150
152
  [...row.querySelectorAll("th,td")].filter((c) => c.closest("tr") === row);
151
153
 
152
154
  /** A span attribute, recorded and not honoured — see `PortableTextTableCell`. */
153
- const spanOf = (cell: Element, name: "colspan" | "rowspan"): number | undefined => {
155
+ const spanOf = (cell: Element, name: "colspan" | "rowspan" | "span"): number | undefined => {
154
156
  const n = Number(cell.getAttribute(name));
155
157
  return Number.isInteger(n) && n > 1 ? n : undefined;
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();
@@ -208,6 +211,24 @@ const groupKey = (value: string | null | undefined, key: KeyGenerator, taken: Se
208
211
  */
209
212
  function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: string) => PortableText): PortableTextTable | undefined {
210
213
  const all = rowsOf(el);
214
+ // The columns a `<col data-sortable="true">` marks: a `<colgroup>` without `<col>`s spans its `span` columns.
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>();
218
+ let colAt = 0;
219
+ for (const group of [...el.children].filter((c) => isTag(c, "colgroup"))) {
220
+ const cols = [...group.children].filter((c) => isTag(c, "col"));
221
+ if (!cols.length) colAt += spanOf(group, "span") ?? 1;
222
+ for (const col of cols) {
223
+ const width = spanOf(col, "span") ?? 1;
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);
229
+ colAt += width;
230
+ }
231
+ }
211
232
  const groupRow = all.find(isColumnGroupRow);
212
233
  const taken = new Set<string>();
213
234
 
@@ -246,14 +267,35 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
246
267
  const first = rows.length === 0;
247
268
  // Minted BEFORE the cells, as it always was: a deterministic key sequence is a contract.
248
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");
249
273
  let at = 0;
250
274
  const cells = cellsOf(row).map((cell) => {
251
275
  const start = at;
252
276
  at += spanOf(cell, "colspan") ?? 1;
253
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");
254
296
  return {
255
297
  _key: key(),
256
- content: ingestCell(cell.innerHTML),
298
+ content: empty ? [] : value !== undefined ? ingestCell(escText(typedCellText(column, type, value))) : ingestCell(own.innerHTML),
257
299
  // A header is what the markup SAYS it is, cell by cell. Deriving it from position
258
300
  // would promote the first data row of a headerless table, and would lose the row
259
301
  // headers that live in a table's first COLUMN.
@@ -261,10 +303,21 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
261
303
  ...(spanOf(cell, "colspan") === undefined ? {} : { span: spanOf(cell, "colspan") }),
262
304
  ...(spanOf(cell, "rowspan") === undefined ? {} : { rowSpan: spanOf(cell, "rowspan") }),
263
305
  ...(group === undefined ? {} : { group }),
306
+ // "Highlight this column" is named on the column's first-row cell, as `group` is.
307
+ ...(first && cell.getAttribute("data-highlight") === "true" ? { highlight: true } : {}),
308
+ // "Visitors can sort columns": the column's `<col data-sortable="true">` (V2 renderTableHtml)
309
+ ...(first && sortableCols.has(start) ? { sortable: true } : {}),
310
+ ...(first && column ? { column } : {}),
311
+ ...(ownType && !inHead ? { type: ownType } : {}),
312
+ ...(value === undefined ? {} : { value }),
313
+ ...(note ? { note } : {}),
264
314
  };
265
315
  });
266
316
  if (cells.length === 0) continue;
267
317
  const head = isTag(row.parentElement, "thead");
318
+ // `<tfoot>` is the V2 table's "Totals row", `data-highlight` its "Highlight this row".
319
+ const footer = !head && isTag(row.parentElement, "tfoot");
320
+ const highlight = row.getAttribute("data-highlight") === "true";
268
321
  rows.push({
269
322
  _key: rowKey,
270
323
  cells,
@@ -272,7 +325,10 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
272
325
  // and never a `<thead>`, so inventing one at render time would make every bare
273
326
  // `<tr><th>` table diverge from its own stored markup.
274
327
  ...(head ? { head: true } : {}),
275
- ...(current && !head ? { group: current.key } : {}),
328
+ ...(current && !head && !footer ? { group: current.key } : {}),
329
+ ...(footer ? { footer: true } : {}),
330
+ ...(highlight ? { highlight: true } : {}),
331
+ ...(rowType ? { type: rowType } : {}),
276
332
  });
277
333
  }
278
334
  if (rows.length === 0) return undefined;
@@ -293,6 +349,32 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
293
349
  };
294
350
  }
295
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
+
296
378
  function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText): DeserializerRule {
297
379
  return {
298
380
  deserialize(el: Node, _next, createBlock) {
@@ -301,11 +383,18 @@ function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText
301
383
  // `createBlock` is what marks this BLOCK-level. Returning the object bare makes the
302
384
  // deserialiser treat it as inline and wrap it in a paragraph — the table survives, nested
303
385
  // one level down where no renderer looks for it.
304
- return table ? createBlock(table) : undefined;
386
+ return table ? createBlock(resolveNotes(table, [])) : undefined;
305
387
  },
306
388
  };
307
389
  }
308
390
 
391
+ /** The figure's `data-jsonld` (+ axis, price row) as the table's `jsonLd`, or nothing. */
392
+ function jsonLdIn(el: Element, rowKeys: string[]): { jsonLd?: PortableTextTable["jsonLd"] } {
393
+ const price = el.getAttribute("data-jsonld-price");
394
+ const j = jsonLdOf({ type: el.getAttribute("data-jsonld"), axis: el.getAttribute("data-jsonld-axis") ?? undefined, ...(price && rowKeys.includes(price) ? { priceRow: price } : {}) });
395
+ return j ? { jsonLd: j } : {};
396
+ }
397
+
309
398
  /**
310
399
  * `<figure data-rt="table">` → the table, with the note under it: the V2 kit's table island
311
400
  * (src/schema/table.ts renderTableHtml) and what `portableTextToHtml` writes for a table with
@@ -321,12 +410,27 @@ function tableFigureRule(key: KeyGenerator, ingestCell: (html: string) => Portab
321
410
  const table = inner ? readTableElement(inner, key, ingestCell) : undefined;
322
411
  if (!table) return undefined;
323
412
  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 } : {}) });
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 } : {}),
425
+ // "Help search engines read this as" (V2 parseTableElement: dataset.jsonld, jsonldAxis, jsonldPrice). The price row
426
+ // is kept only when it names a row read here: this renderer writes no row keys, so a round trip falls back to
427
+ // V2's own default (the first row of prices), never to a key that names nothing.
428
+ ...jsonLdIn(el, table.rows.map((r) => r._key)),
429
+ // "On phones" / "Keep column names in view" (V2 parseTableElement: dataset.responsive stack|cards, dataset.sticky)
430
+ ...(designOf({ responsive: el.getAttribute("data-responsive"), sticky: el.getAttribute("data-sticky") === "true" })
431
+ ? { design: designOf({ responsive: el.getAttribute("data-responsive"), sticky: el.getAttribute("data-sticky") === "true" }) }
432
+ : {}),
433
+ });
330
434
  },
331
435
  };
332
436
  }
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,10 +1,16 @@
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,
11
+ type PortableTextTableDesign,
12
+ type PortableTextTableFootnote,
13
+ type PortableTextTableJsonLd,
8
14
  type PortableTextTableRow,
9
15
  } from "./types";
10
16
  import { deterministicKeys, isValidKey, randomKey, type KeyGenerator } from "./key";
@@ -145,6 +151,13 @@ function normalizeCell(raw: unknown, at: [number, number], mint: Mint, read: Cel
145
151
  ...(span === undefined ? {} : { span }),
146
152
  ...(rowSpan === undefined ? {} : { rowSpan }),
147
153
  ...(typeof raw.group === "string" ? { group: raw.group } : {}),
154
+ ...(raw.highlight === true ? { highlight: true } : {}),
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 } : {}),
148
161
  };
149
162
  }
150
163
 
@@ -157,6 +170,10 @@ function normalizeRow(raw: unknown, at: number, mint: Mint, read: CellContentRea
157
170
  cells: cells.map((c, col) => normalizeCell(c, [at, col], mint, read)),
158
171
  ...(isRecord(raw) && raw.head === true ? { head: true } : {}),
159
172
  ...(isRecord(raw) && typeof raw.group === "string" ? { group: raw.group } : {}),
173
+ // The V2 table's two row flags ("Totals row", "Highlight this row"); see `PortableTextTableRow`.
174
+ ...(isRecord(raw) && raw.footer === true ? { footer: true } : {}),
175
+ ...(isRecord(raw) && raw.highlight === true ? { highlight: true } : {}),
176
+ ...(isRecord(raw) && isColumnType(raw.type) ? { type: raw.type } : {}),
160
177
  };
161
178
  }
162
179
 
@@ -255,6 +272,52 @@ function tableExtras(node: Record<string, unknown>): Partial<PortableTextTable>
255
272
  ...(typeof node.notes === "string" ? { notes: node.notes } : {}),
256
273
  ...(rowGroups ? { rowGroups } : {}),
257
274
  ...(columnGroups ? { columnGroups } : {}),
275
+ ...(node.filterable === true ? { filterable: true } : {}),
276
+ ...(jsonLdOf(node.jsonLd) ? { jsonLd: jsonLdOf(node.jsonLd) } : {}),
277
+ ...(designOf(node.design) ? { design: designOf(node.design) } : {}),
278
+ ...(footnotesOf(node.footnotes) ? { footnotes: footnotesOf(node.footnotes) } : {}),
279
+ };
280
+ }
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
+
296
+ const DESIGN_KEYS = new Set(["responsive", "sticky"]);
297
+
298
+ /**
299
+ * A stored `design` read permissively, as V2's parseTableElement reads the figure: `responsive` only when `stack` or `cards`
300
+ * (`scroll` is the default and is not kept), `sticky` only when true. Nothing left is nothing.
301
+ */
302
+ export function designOf(v: unknown): PortableTextTableDesign | undefined {
303
+ if (!isRecord(v)) return undefined;
304
+ const d: PortableTextTableDesign = {
305
+ ...(v.responsive === "stack" || v.responsive === "cards" ? { responsive: v.responsive } : {}),
306
+ ...(v.sticky === true ? { sticky: true } : {}),
307
+ };
308
+ return Object.keys(d).length ? d : undefined;
309
+ }
310
+
311
+ const JSONLD_TYPES = ["ItemList", "Dataset", "Offers"] as const;
312
+ const JSONLD_KEYS = new Set(["type", "axis", "priceRow"]);
313
+
314
+ /** A stored `jsonLd` read permissively: a type this table offers, its axis and price row when well formed, else nothing. */
315
+ export function jsonLdOf(v: unknown): PortableTextTableJsonLd | undefined {
316
+ if (!isRecord(v) || !(JSONLD_TYPES as readonly unknown[]).includes(v.type)) return undefined;
317
+ return {
318
+ type: v.type as PortableTextTableJsonLd["type"],
319
+ ...(v.axis === "rows" || v.axis === "columns" ? { axis: v.axis } : {}),
320
+ ...(typeof v.priceRow === "string" && v.priceRow ? { priceRow: v.priceRow } : {}),
258
321
  };
259
322
  }
260
323
 
@@ -290,6 +353,29 @@ export function tableProblems(node: unknown): PortableTextTableProblem[] {
290
353
  };
291
354
  text(node.caption, ["caption"], "caption", PT_TABLE_LIMITS.captionChars);
292
355
  text(node.notes, ["notes"], "notes", PT_TABLE_LIMITS.notesChars);
356
+ if (node.filterable !== undefined && typeof node.filterable !== "boolean") bad(["filterable"], "filterable must be true or false");
357
+ if (node.design !== undefined) {
358
+ const d = node.design;
359
+ if (!isRecord(d)) bad(["design"], "design must be an object { responsive?, sticky? }");
360
+ else {
361
+ for (const k of Object.keys(d)) if (!DESIGN_KEYS.has(k)) bad(["design", k], `unknown key "${k}" (responsive, sticky)`);
362
+ if (d.responsive !== undefined && !["scroll", "stack", "cards"].includes(d.responsive as string)) bad(["design", "responsive"], 'responsive must be "scroll", "stack" or "cards"');
363
+ if (d.sticky !== undefined && typeof d.sticky !== "boolean") bad(["design", "sticky"], "sticky must be true or false");
364
+ }
365
+ }
366
+ if (node.jsonLd !== undefined) {
367
+ const j = node.jsonLd;
368
+ if (!isRecord(j)) bad(["jsonLd"], "jsonLd must be an object { type, axis?, priceRow? }");
369
+ else {
370
+ for (const k of Object.keys(j)) if (!JSONLD_KEYS.has(k)) bad(["jsonLd", k], `unknown key "${k}" (type, axis, priceRow)`);
371
+ 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)`);
372
+ if (j.axis !== undefined && j.axis !== "rows" && j.axis !== "columns") bad(["jsonLd", "axis"], 'axis must be "rows" or "columns"');
373
+ if (j.priceRow !== undefined) {
374
+ const keys = (Array.isArray(node.rows) ? node.rows : []).map((r) => (isRecord(r) ? r._key : undefined));
375
+ if (typeof j.priceRow !== "string" || !keys.includes(j.priceRow)) bad(["jsonLd", "priceRow"], "priceRow must be the _key of one of the table's rows");
376
+ }
377
+ }
378
+ }
293
379
 
294
380
  const keyed = (list: unknown, at: "rowGroups" | "columnGroups"): Set<string> => {
295
381
  const seen = new Set<string>();
@@ -311,10 +397,32 @@ export function tableProblems(node: unknown): PortableTextTableProblem[] {
311
397
  };
312
398
  const rowGroups = keyed(node.rowGroups, "rowGroups");
313
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
+ }
314
414
 
315
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));
316
419
  rows.forEach((row, r) => {
317
420
  if (!isRecord(row)) return;
421
+ for (const flag of ["footer", "highlight"] as const) {
422
+ if (row[flag] !== undefined && typeof row[flag] !== "boolean") bad(["rows", r, flag], `${flag} must be true or false`);
423
+ }
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(", ")}`);
318
426
  if (row.group !== undefined) {
319
427
  if (row.head === true) bad(["rows", r, "group"], "a head row takes no group (row groups hold body rows)");
320
428
  else if (typeof row.group !== "string" || !rowGroups.has(row.group)) {
@@ -322,6 +430,16 @@ export function tableProblems(node: unknown): PortableTextTableProblem[] {
322
430
  }
323
431
  }
324
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
+ }
437
+ for (const flag of ["highlight", "sortable"] as const) {
438
+ if (!isRecord(cell) || cell[flag] === undefined) continue;
439
+ const at = ["rows", r, "cells", c, flag];
440
+ if (typeof cell[flag] !== "boolean") bad(at, `${flag} must be true or false`);
441
+ else if (r !== 0 && cell[flag]) bad(at, `a column's ${flag} is named on its cell in the first row only`);
442
+ }
325
443
  if (!isRecord(cell) || cell.group === undefined) return;
326
444
  const at = ["rows", r, "cells", c, "group"];
327
445
  if (r !== 0) bad(at, "a column's group is named on its cell in the first row only");
@@ -355,6 +473,47 @@ export function portableTextTableProblems(value: unknown): Array<PortableTextTab
355
473
  return out;
356
474
  }
357
475
 
476
+ /**
477
+ * A table's rows split into its three sections: the LEADING run of `head` rows (`<thead>`), the
478
+ * TRAILING run of `footer` rows (`<tfoot>`, the V2 table's "Totals row"), and the body between.
479
+ *
480
+ * Runs, not every flagged row, for the reason `<thead>` already is one: a section cannot be
481
+ * lifted out of document order without moving content the author placed. A `footer` row with a
482
+ * body row after it stays in the body (permissive read). Shared by every renderer so they agree.
483
+ */
484
+ export function tableSections(table: PortableTextTable): { head: unknown[]; body: unknown[]; foot: unknown[] } {
485
+ const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
486
+ const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
487
+ const head = rows.slice(0, headCount === -1 ? rows.length : headCount);
488
+ let footFrom = rows.length;
489
+ while (footFrom > head.length && isRecord(rows[footFrom - 1]) && (rows[footFrom - 1] as Record<string, unknown>).footer === true) footFrom -= 1;
490
+ return { head, body: rows.slice(head.length, footFrom), foot: rows.slice(footFrom) };
491
+ }
492
+
493
+ /**
494
+ * The columns "Highlight this column" marks: the start column of each FIRST-row cell carrying
495
+ * `highlight` (spans counted). A renderer stamps `data-highlight` on every cell starting there.
496
+ */
497
+ export function highlightedColumns(table: PortableTextTable): Set<number> {
498
+ return markedColumns(table, "highlight");
499
+ }
500
+
501
+ /** The columns "Visitors can sort columns" marks (a FIRST-row cell's `sortable`), as `highlightedColumns`. */
502
+ export function sortableColumns(table: PortableTextTable): Set<number> {
503
+ return markedColumns(table, "sortable");
504
+ }
505
+
506
+ function markedColumns(table: PortableTextTable, flag: "highlight" | "sortable"): Set<number> {
507
+ const out = new Set<number>();
508
+ const first = Array.isArray(table.rows) ? table.rows[0] : undefined;
509
+ let at = 0;
510
+ for (const cell of isRecord(first) && Array.isArray(first.cells) ? first.cells : []) {
511
+ if (isRecord(cell) && cell[flag] === true) out.add(at);
512
+ at += isRecord(cell) && typeof cell.span === "number" && Number.isInteger(cell.span) && cell.span > 1 ? cell.span : 1;
513
+ }
514
+ return out;
515
+ }
516
+
358
517
  /** A run of columns, or of body rows, that share one group; `label` is absent for an ungrouped run. */
359
518
  export type TableColumnRun = { width: number; key?: string; label?: string };
360
519
  export type TableRowRun = { rows: unknown[]; key?: string; label?: string };
@@ -392,8 +551,8 @@ export function tableGroupLayout(table: PortableTextTable): { columns: TableColu
392
551
  }
393
552
 
394
553
  const rowLabels = labels(table.rowGroups);
395
- const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
396
- const body = headCount === -1 ? [] : rows.slice(headCount);
554
+ // The body only: a footer (totals) run sits in `<tfoot>`, outside every row group.
555
+ const { body } = tableSections(table);
397
556
  const runs: TableRowRun[] = [];
398
557
  for (const row of body) {
399
558
  const key = isRecord(row) && typeof row.group === "string" && rowLabels.has(row.group) ? row.group : undefined;
@@ -408,3 +567,223 @@ export function tableGroupLayout(table: PortableTextTable): { columns: TableColu
408
567
  width,
409
568
  };
410
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
@@ -110,6 +110,62 @@ export interface PortableTextTableCell {
110
110
  * right when the editor inserts, deletes or moves a column: the cell moves with its column.
111
111
  */
112
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;
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;
113
169
  }
114
170
 
115
171
  export interface PortableTextTableRow {
@@ -119,6 +175,15 @@ export interface PortableTextTableRow {
119
175
  head?: boolean;
120
176
  /** The ROW GROUP this body row belongs to: a `_key` of the table's `rowGroups`. */
121
177
  group?: string;
178
+ /**
179
+ * A totals / summary row: the TRAILING run of these renders as `<tfoot>` (the V2 table's
180
+ * "Totals row", `TableRow.footer` in `@bettercms-ai/types` table-value.ts).
181
+ */
182
+ footer?: boolean;
183
+ /** "Highlight this row": `<tr data-highlight="true">`, as the V2 table renders it. */
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;
122
187
  }
123
188
 
124
189
  /**
@@ -160,6 +225,47 @@ export interface PortableTextTable extends PortableTextObject {
160
225
  rowGroups?: PortableTextTableGroup[];
161
226
  /** First-row cells name one with `cell.group`; a run of columns gets one `scope="colgroup"` header. */
162
227
  columnGroups?: PortableTextTableGroup[];
228
+ /**
229
+ * "Visitors can search rows" (the V2 table's `TableValue.filterable`): the table renders inside
230
+ * `<figure data-rt="table" data-filter="true">`, as the V2 table writes it.
231
+ */
232
+ filterable?: boolean;
233
+ /**
234
+ * "Help search engines read this as" (the V2 table's `TableValue.jsonLd`, the field-lane table's `jsonLd`): a
235
+ * comparison, a data report or a price list. Written on the figure as `data-jsonld="<type>"` (plus
236
+ * `data-jsonld-axis` / `data-jsonld-price`), as the V2 table writes it. `"none"` is not stored.
237
+ */
238
+ jsonLd?: PortableTextTableJsonLd;
239
+ /**
240
+ * "On phones" and "Keep column names in view" (the V2 table's `TableValue.design.responsive` / `.sticky`, the field-lane
241
+ * table's `design`). `responsive` is `stack` or `cards` (`scroll`, the default, is not stored, as V2 reads it); written
242
+ * on the figure as `data-responsive` / `data-sticky="true"`, as the V2 table writes them.
243
+ */
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;
256
+ }
257
+
258
+ /** The V2 table's `design` keys the rich-text table offers. */
259
+ export interface PortableTextTableDesign {
260
+ responsive?: "stack" | "cards";
261
+ sticky?: boolean;
262
+ }
263
+
264
+ /** The V2 table's JSON-LD choice: `priceRow` is a row's `_key` (Offers), `axis` what an ItemList lists. */
265
+ export interface PortableTextTableJsonLd {
266
+ type: "ItemList" | "Dataset" | "Offers";
267
+ axis?: "rows" | "columns";
268
+ priceRow?: string;
163
269
  }
164
270
 
165
271
  /**