@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 +1 -1
- package/src/html.ts +110 -12
- package/src/ingest.ts +115 -11
- package/src/plain.ts +4 -1
- package/src/table.ts +381 -2
- package/src/types.ts +106 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "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
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
const head
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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(
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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
|
-
|
|
396
|
-
const body =
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
/**
|