@bettercms-ai/richtext 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/html.ts +69 -10
- package/src/ingest.ts +41 -4
- package/src/table.ts +114 -2
- package/src/types.ts +47 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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, highlightedColumns, jsonLdOf, sortableColumns, tableGroupLayout, tableSections } 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,29 +65,65 @@ 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
|
+
const cellHtml = (raw: unknown, col: number): string => {
|
|
72
101
|
const cell = isRecord(raw) ? raw : {};
|
|
73
102
|
const tag = cell.header === true ? "th" : "td";
|
|
74
103
|
// `colspan` before `rowspan`, the order every table in the corpus writes them in: the
|
|
75
104
|
// comparison is on serialised markup, and a parser preserves the order it was given.
|
|
76
|
-
const
|
|
105
|
+
const label = stacked && tag === "td" ? ` data-label="${esc(labels[col] || `Column ${col + 1}`)}"` : "";
|
|
106
|
+
const attrs = `${attr("colspan", cell.span)}${attr("rowspan", cell.rowSpan)}${label}${marked.has(col) ? ' data-highlight="true"' : ""}`;
|
|
77
107
|
return `<${tag}${attrs}>${portableTextToHtml(cell.content as PortableText, { inline: true })}</${tag}>`;
|
|
78
108
|
};
|
|
79
109
|
const rowHtml = (raw: unknown): string => {
|
|
80
110
|
const cells = isRecord(raw) && Array.isArray(raw.cells) ? raw.cells : [];
|
|
81
|
-
|
|
111
|
+
// "Highlight this row": the V2 table's own attribute (src/schema/table.ts rowHtml).
|
|
112
|
+
const highlight = isRecord(raw) && raw.highlight === true ? ' data-highlight="true"' : "";
|
|
113
|
+
let col = 0;
|
|
114
|
+
const html = cells.map((c) => {
|
|
115
|
+
const out = cellHtml(c, col);
|
|
116
|
+
col += isRecord(c) && typeof c.span === "number" && Number.isInteger(c.span) && c.span > 1 ? c.span : 1;
|
|
117
|
+
return out;
|
|
118
|
+
});
|
|
119
|
+
return `<tr${highlight}>${html.join("")}</tr>`;
|
|
82
120
|
};
|
|
83
121
|
|
|
84
122
|
// The LEADING RUN of head rows, not every row flagged one: `<thead>` is a section, and a
|
|
85
123
|
// header row appearing after body rows cannot be lifted out of document order without moving
|
|
86
124
|
// content the author placed.
|
|
87
|
-
|
|
88
|
-
const head
|
|
89
|
-
const body = rows.slice(head.length);
|
|
125
|
+
// Likewise the TRAILING run of footer (totals) rows is `<tfoot>`, after every `<tbody>` (V2).
|
|
126
|
+
const { head, body, foot } = tableSections(table);
|
|
90
127
|
const layout = tableGroupLayout(table);
|
|
91
128
|
// The platform's own class, on migrated values only — see `PortableTextTable["legacy"]`.
|
|
92
129
|
const cls = table.legacy === true ? ' class="bcms-table"' : "";
|
|
@@ -96,7 +133,18 @@ function tableHtml(table: PortableTextTable): string {
|
|
|
96
133
|
|
|
97
134
|
// Column groups: one `<colgroup>` per run of columns, and a `scope="colgroup"` header over each
|
|
98
135
|
// grouped run, as the first row of `<thead>` (the V2 table's layout).
|
|
99
|
-
|
|
136
|
+
// "Visitors can sort columns": one `<col data-sortable="true">` per sortable column, as the V2 table writes it
|
|
137
|
+
// (src/schema/table.ts renderTableHtml); with `<col>`s a column group's `<colgroup>` holds them instead of a `span`.
|
|
138
|
+
const sortable = sortableColumns(table);
|
|
139
|
+
let colAt = 0;
|
|
140
|
+
const cols = (width: number): string => {
|
|
141
|
+
const out = Array.from({ length: width }, (_, i) => (sortable.has(colAt + i) ? '<col data-sortable="true">' : "<col>")).join("");
|
|
142
|
+
colAt += width;
|
|
143
|
+
return out;
|
|
144
|
+
};
|
|
145
|
+
const colgroups = sortable.size
|
|
146
|
+
? (layout.columns.length ? layout.columns.map((run) => run.width) : [layout.width]).map((w) => `<colgroup>${cols(w)}</colgroup>`).join("")
|
|
147
|
+
: layout.columns.map((run) => `<colgroup${attr("span", run.width)}></colgroup>`).join("");
|
|
100
148
|
const groupRow = layout.columns.length
|
|
101
149
|
? `<tr>${layout.columns
|
|
102
150
|
.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 +159,22 @@ function tableHtml(table: PortableTextTable): string {
|
|
|
111
159
|
.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
160
|
.join("")
|
|
113
161
|
: body.length ? `<tbody>${body.map(rowHtml).join("")}</tbody>` : "";
|
|
162
|
+
const tfoot = foot.length ? `<tfoot>${foot.map(rowHtml).join("")}</tfoot>` : "";
|
|
114
163
|
|
|
115
|
-
const
|
|
164
|
+
const plainHtml = `<table${cls}${notesId ? ` aria-describedby="${esc(notesId)}"` : ""}>${caption}${colgroups}${thead}${tbody}${tfoot}</table>`;
|
|
116
165
|
// A note sits UNDER the table, so it needs a container to stay with it: the V2 kit's
|
|
117
166
|
// `<figure data-rt="table">`, which the ingest reads back.
|
|
118
|
-
|
|
167
|
+
// "Visitors can search rows" is the figure's `data-filter="true"` (V2 renderTableHtml), so it takes the figure too.
|
|
168
|
+
const filter = table.filterable === true ? ' data-filter="true"' : "";
|
|
169
|
+
// "Help search engines read this as": V2 renderTableHtml's data-jsonld (+ axis, price row) on the figure, after data-filter
|
|
170
|
+
const j = jsonLdOf(table.jsonLd);
|
|
171
|
+
const jsonLd = j ? ` data-jsonld="${j.type}"${j.axis ? ` data-jsonld-axis="${j.axis}"` : ""}${j.priceRow ? ` data-jsonld-price="${esc(j.priceRow)}"` : ""}` : "";
|
|
172
|
+
// "On phones" / "Keep column names in view": V2 renderTableHtml's data-responsive / data-sticky, before data-filter; a
|
|
173
|
+
// stacked table takes V2's withTableRoles (explicit ARIA roles, since display:block drops the implicit ones)
|
|
174
|
+
const designAttrs = design ? `${design.responsive ? ` data-responsive="${design.responsive}"` : ""}${design.sticky ? ' data-sticky="true"' : ""}` : "";
|
|
175
|
+
const html = stacked ? withTableRoles(plainHtml) : plainHtml;
|
|
176
|
+
if (!notes && !filter && !jsonLd && !designAttrs) return html;
|
|
177
|
+
return `<figure data-rt="table"${designAttrs}${filter}${jsonLd}>${html}${notes ? `<p data-notes${notesId ? ` id="${esc(notesId)}"` : ""}>${esc(notes)}</p>` : ""}</figure>`;
|
|
119
178
|
}
|
|
120
179
|
|
|
121
180
|
/**
|
package/src/ingest.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { randomKey, isValidKey, type KeyGenerator } from "./key";
|
|
14
14
|
import { safeHref } from "./href";
|
|
15
15
|
import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
|
|
16
|
-
import { PT_TABLE_LIMITS } from "./table";
|
|
16
|
+
import { designOf, jsonLdOf, PT_TABLE_LIMITS } from "./table";
|
|
17
17
|
import { PT_KEY_TAKEAWAYS_LIMITS } from "./key-takeaways";
|
|
18
18
|
import type { PtSchema } from "./schema";
|
|
19
19
|
|
|
@@ -150,7 +150,7 @@ const cellsOf = (row: Element): Element[] =>
|
|
|
150
150
|
[...row.querySelectorAll("th,td")].filter((c) => c.closest("tr") === row);
|
|
151
151
|
|
|
152
152
|
/** A span attribute, recorded and not honoured — see `PortableTextTableCell`. */
|
|
153
|
-
const spanOf = (cell: Element, name: "colspan" | "rowspan"): number | undefined => {
|
|
153
|
+
const spanOf = (cell: Element, name: "colspan" | "rowspan" | "span"): number | undefined => {
|
|
154
154
|
const n = Number(cell.getAttribute(name));
|
|
155
155
|
return Number.isInteger(n) && n > 1 ? n : undefined;
|
|
156
156
|
};
|
|
@@ -208,6 +208,18 @@ const groupKey = (value: string | null | undefined, key: KeyGenerator, taken: Se
|
|
|
208
208
|
*/
|
|
209
209
|
function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: string) => PortableText): PortableTextTable | undefined {
|
|
210
210
|
const all = rowsOf(el);
|
|
211
|
+
// The columns a `<col data-sortable="true">` marks: a `<colgroup>` without `<col>`s spans its `span` columns.
|
|
212
|
+
const sortableCols = new Set<number>();
|
|
213
|
+
let colAt = 0;
|
|
214
|
+
for (const group of [...el.children].filter((c) => isTag(c, "colgroup"))) {
|
|
215
|
+
const cols = [...group.children].filter((c) => isTag(c, "col"));
|
|
216
|
+
if (!cols.length) colAt += spanOf(group, "span") ?? 1;
|
|
217
|
+
for (const col of cols) {
|
|
218
|
+
const width = spanOf(col, "span") ?? 1;
|
|
219
|
+
if (col.getAttribute("data-sortable") === "true") for (let i = 0; i < width; i += 1) sortableCols.add(colAt + i);
|
|
220
|
+
colAt += width;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
211
223
|
const groupRow = all.find(isColumnGroupRow);
|
|
212
224
|
const taken = new Set<string>();
|
|
213
225
|
|
|
@@ -261,10 +273,17 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
|
|
|
261
273
|
...(spanOf(cell, "colspan") === undefined ? {} : { span: spanOf(cell, "colspan") }),
|
|
262
274
|
...(spanOf(cell, "rowspan") === undefined ? {} : { rowSpan: spanOf(cell, "rowspan") }),
|
|
263
275
|
...(group === undefined ? {} : { group }),
|
|
276
|
+
// "Highlight this column" is named on the column's first-row cell, as `group` is.
|
|
277
|
+
...(first && cell.getAttribute("data-highlight") === "true" ? { highlight: true } : {}),
|
|
278
|
+
// "Visitors can sort columns": the column's `<col data-sortable="true">` (V2 renderTableHtml)
|
|
279
|
+
...(first && sortableCols.has(start) ? { sortable: true } : {}),
|
|
264
280
|
};
|
|
265
281
|
});
|
|
266
282
|
if (cells.length === 0) continue;
|
|
267
283
|
const head = isTag(row.parentElement, "thead");
|
|
284
|
+
// `<tfoot>` is the V2 table's "Totals row", `data-highlight` its "Highlight this row".
|
|
285
|
+
const footer = !head && isTag(row.parentElement, "tfoot");
|
|
286
|
+
const highlight = row.getAttribute("data-highlight") === "true";
|
|
268
287
|
rows.push({
|
|
269
288
|
_key: rowKey,
|
|
270
289
|
cells,
|
|
@@ -272,7 +291,9 @@ function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: str
|
|
|
272
291
|
// and never a `<thead>`, so inventing one at render time would make every bare
|
|
273
292
|
// `<tr><th>` table diverge from its own stored markup.
|
|
274
293
|
...(head ? { head: true } : {}),
|
|
275
|
-
...(current && !head ? { group: current.key } : {}),
|
|
294
|
+
...(current && !head && !footer ? { group: current.key } : {}),
|
|
295
|
+
...(footer ? { footer: true } : {}),
|
|
296
|
+
...(highlight ? { highlight: true } : {}),
|
|
276
297
|
});
|
|
277
298
|
}
|
|
278
299
|
if (rows.length === 0) return undefined;
|
|
@@ -306,6 +327,13 @@ function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText
|
|
|
306
327
|
};
|
|
307
328
|
}
|
|
308
329
|
|
|
330
|
+
/** The figure's `data-jsonld` (+ axis, price row) as the table's `jsonLd`, or nothing. */
|
|
331
|
+
function jsonLdIn(el: Element, rowKeys: string[]): { jsonLd?: PortableTextTable["jsonLd"] } {
|
|
332
|
+
const price = el.getAttribute("data-jsonld-price");
|
|
333
|
+
const j = jsonLdOf({ type: el.getAttribute("data-jsonld"), axis: el.getAttribute("data-jsonld-axis") ?? undefined, ...(price && rowKeys.includes(price) ? { priceRow: price } : {}) });
|
|
334
|
+
return j ? { jsonLd: j } : {};
|
|
335
|
+
}
|
|
336
|
+
|
|
309
337
|
/**
|
|
310
338
|
* `<figure data-rt="table">` → the table, with the note under it: the V2 kit's table island
|
|
311
339
|
* (src/schema/table.ts renderTableHtml) and what `portableTextToHtml` writes for a table with
|
|
@@ -326,7 +354,16 @@ function tableFigureRule(key: KeyGenerator, ingestCell: (html: string) => Portab
|
|
|
326
354
|
// read back as a space and the value would not survive its own round trip.
|
|
327
355
|
const notes = clip([wordsOf(noteEl), ...footnotes].filter(Boolean).join(" "), PT_TABLE_LIMITS.notesChars);
|
|
328
356
|
const own = [el.getAttribute("data-id"), noteEl?.getAttribute("id")?.replace(/-notes$/, "")].find((k) => isValidKey(k));
|
|
329
|
-
return createBlock({ ...table, ...(own ? { _key: own } : {}), ...(notes ? { notes } : {}) })
|
|
357
|
+
return createBlock({ ...table, ...(own ? { _key: own } : {}), ...(notes ? { notes } : {}), ...(el.getAttribute("data-filter") === "true" ? { filterable: true } : {}),
|
|
358
|
+
// "Help search engines read this as" (V2 parseTableElement: dataset.jsonld, jsonldAxis, jsonldPrice). The price row
|
|
359
|
+
// is kept only when it names a row read here: this renderer writes no row keys, so a round trip falls back to
|
|
360
|
+
// V2's own default (the first row of prices), never to a key that names nothing.
|
|
361
|
+
...jsonLdIn(el, table.rows.map((r) => r._key)),
|
|
362
|
+
// "On phones" / "Keep column names in view" (V2 parseTableElement: dataset.responsive stack|cards, dataset.sticky)
|
|
363
|
+
...(designOf({ responsive: el.getAttribute("data-responsive"), sticky: el.getAttribute("data-sticky") === "true" })
|
|
364
|
+
? { design: designOf({ responsive: el.getAttribute("data-responsive"), sticky: el.getAttribute("data-sticky") === "true" }) }
|
|
365
|
+
: {}),
|
|
366
|
+
});
|
|
330
367
|
},
|
|
331
368
|
};
|
|
332
369
|
}
|
package/src/table.ts
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
type PortableTextTable,
|
|
6
6
|
type PortableTextTableCell,
|
|
7
7
|
type PortableTextTableGroup,
|
|
8
|
+
type PortableTextTableDesign,
|
|
9
|
+
type PortableTextTableJsonLd,
|
|
8
10
|
type PortableTextTableRow,
|
|
9
11
|
} from "./types";
|
|
10
12
|
import { deterministicKeys, isValidKey, randomKey, type KeyGenerator } from "./key";
|
|
@@ -145,6 +147,8 @@ function normalizeCell(raw: unknown, at: [number, number], mint: Mint, read: Cel
|
|
|
145
147
|
...(span === undefined ? {} : { span }),
|
|
146
148
|
...(rowSpan === undefined ? {} : { rowSpan }),
|
|
147
149
|
...(typeof raw.group === "string" ? { group: raw.group } : {}),
|
|
150
|
+
...(raw.highlight === true ? { highlight: true } : {}),
|
|
151
|
+
...(raw.sortable === true ? { sortable: true } : {}),
|
|
148
152
|
};
|
|
149
153
|
}
|
|
150
154
|
|
|
@@ -157,6 +161,9 @@ function normalizeRow(raw: unknown, at: number, mint: Mint, read: CellContentRea
|
|
|
157
161
|
cells: cells.map((c, col) => normalizeCell(c, [at, col], mint, read)),
|
|
158
162
|
...(isRecord(raw) && raw.head === true ? { head: true } : {}),
|
|
159
163
|
...(isRecord(raw) && typeof raw.group === "string" ? { group: raw.group } : {}),
|
|
164
|
+
// The V2 table's two row flags ("Totals row", "Highlight this row"); see `PortableTextTableRow`.
|
|
165
|
+
...(isRecord(raw) && raw.footer === true ? { footer: true } : {}),
|
|
166
|
+
...(isRecord(raw) && raw.highlight === true ? { highlight: true } : {}),
|
|
160
167
|
};
|
|
161
168
|
}
|
|
162
169
|
|
|
@@ -255,6 +262,37 @@ function tableExtras(node: Record<string, unknown>): Partial<PortableTextTable>
|
|
|
255
262
|
...(typeof node.notes === "string" ? { notes: node.notes } : {}),
|
|
256
263
|
...(rowGroups ? { rowGroups } : {}),
|
|
257
264
|
...(columnGroups ? { columnGroups } : {}),
|
|
265
|
+
...(node.filterable === true ? { filterable: true } : {}),
|
|
266
|
+
...(jsonLdOf(node.jsonLd) ? { jsonLd: jsonLdOf(node.jsonLd) } : {}),
|
|
267
|
+
...(designOf(node.design) ? { design: designOf(node.design) } : {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const DESIGN_KEYS = new Set(["responsive", "sticky"]);
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* A stored `design` read permissively, as V2's parseTableElement reads the figure: `responsive` only when `stack` or `cards`
|
|
275
|
+
* (`scroll` is the default and is not kept), `sticky` only when true. Nothing left is nothing.
|
|
276
|
+
*/
|
|
277
|
+
export function designOf(v: unknown): PortableTextTableDesign | undefined {
|
|
278
|
+
if (!isRecord(v)) return undefined;
|
|
279
|
+
const d: PortableTextTableDesign = {
|
|
280
|
+
...(v.responsive === "stack" || v.responsive === "cards" ? { responsive: v.responsive } : {}),
|
|
281
|
+
...(v.sticky === true ? { sticky: true } : {}),
|
|
282
|
+
};
|
|
283
|
+
return Object.keys(d).length ? d : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const JSONLD_TYPES = ["ItemList", "Dataset", "Offers"] as const;
|
|
287
|
+
const JSONLD_KEYS = new Set(["type", "axis", "priceRow"]);
|
|
288
|
+
|
|
289
|
+
/** A stored `jsonLd` read permissively: a type this table offers, its axis and price row when well formed, else nothing. */
|
|
290
|
+
export function jsonLdOf(v: unknown): PortableTextTableJsonLd | undefined {
|
|
291
|
+
if (!isRecord(v) || !(JSONLD_TYPES as readonly unknown[]).includes(v.type)) return undefined;
|
|
292
|
+
return {
|
|
293
|
+
type: v.type as PortableTextTableJsonLd["type"],
|
|
294
|
+
...(v.axis === "rows" || v.axis === "columns" ? { axis: v.axis } : {}),
|
|
295
|
+
...(typeof v.priceRow === "string" && v.priceRow ? { priceRow: v.priceRow } : {}),
|
|
258
296
|
};
|
|
259
297
|
}
|
|
260
298
|
|
|
@@ -290,6 +328,29 @@ export function tableProblems(node: unknown): PortableTextTableProblem[] {
|
|
|
290
328
|
};
|
|
291
329
|
text(node.caption, ["caption"], "caption", PT_TABLE_LIMITS.captionChars);
|
|
292
330
|
text(node.notes, ["notes"], "notes", PT_TABLE_LIMITS.notesChars);
|
|
331
|
+
if (node.filterable !== undefined && typeof node.filterable !== "boolean") bad(["filterable"], "filterable must be true or false");
|
|
332
|
+
if (node.design !== undefined) {
|
|
333
|
+
const d = node.design;
|
|
334
|
+
if (!isRecord(d)) bad(["design"], "design must be an object { responsive?, sticky? }");
|
|
335
|
+
else {
|
|
336
|
+
for (const k of Object.keys(d)) if (!DESIGN_KEYS.has(k)) bad(["design", k], `unknown key "${k}" (responsive, sticky)`);
|
|
337
|
+
if (d.responsive !== undefined && !["scroll", "stack", "cards"].includes(d.responsive as string)) bad(["design", "responsive"], 'responsive must be "scroll", "stack" or "cards"');
|
|
338
|
+
if (d.sticky !== undefined && typeof d.sticky !== "boolean") bad(["design", "sticky"], "sticky must be true or false");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (node.jsonLd !== undefined) {
|
|
342
|
+
const j = node.jsonLd;
|
|
343
|
+
if (!isRecord(j)) bad(["jsonLd"], "jsonLd must be an object { type, axis?, priceRow? }");
|
|
344
|
+
else {
|
|
345
|
+
for (const k of Object.keys(j)) if (!JSONLD_KEYS.has(k)) bad(["jsonLd", k], `unknown key "${k}" (type, axis, priceRow)`);
|
|
346
|
+
if (!(JSONLD_TYPES as readonly unknown[]).includes(j.type)) bad(["jsonLd", "type"], `type must be one of ${JSONLD_TYPES.join(", ")} (leave jsonLd out for none)`);
|
|
347
|
+
if (j.axis !== undefined && j.axis !== "rows" && j.axis !== "columns") bad(["jsonLd", "axis"], 'axis must be "rows" or "columns"');
|
|
348
|
+
if (j.priceRow !== undefined) {
|
|
349
|
+
const keys = (Array.isArray(node.rows) ? node.rows : []).map((r) => (isRecord(r) ? r._key : undefined));
|
|
350
|
+
if (typeof j.priceRow !== "string" || !keys.includes(j.priceRow)) bad(["jsonLd", "priceRow"], "priceRow must be the _key of one of the table's rows");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
293
354
|
|
|
294
355
|
const keyed = (list: unknown, at: "rowGroups" | "columnGroups"): Set<string> => {
|
|
295
356
|
const seen = new Set<string>();
|
|
@@ -315,6 +376,10 @@ export function tableProblems(node: unknown): PortableTextTableProblem[] {
|
|
|
315
376
|
const rows = Array.isArray(node.rows) ? node.rows : [];
|
|
316
377
|
rows.forEach((row, r) => {
|
|
317
378
|
if (!isRecord(row)) return;
|
|
379
|
+
for (const flag of ["footer", "highlight"] as const) {
|
|
380
|
+
if (row[flag] !== undefined && typeof row[flag] !== "boolean") bad(["rows", r, flag], `${flag} must be true or false`);
|
|
381
|
+
}
|
|
382
|
+
if (row.head === true && row.footer === true) bad(["rows", r, "footer"], "a head row cannot also be a footer (totals) row");
|
|
318
383
|
if (row.group !== undefined) {
|
|
319
384
|
if (row.head === true) bad(["rows", r, "group"], "a head row takes no group (row groups hold body rows)");
|
|
320
385
|
else if (typeof row.group !== "string" || !rowGroups.has(row.group)) {
|
|
@@ -322,6 +387,12 @@ export function tableProblems(node: unknown): PortableTextTableProblem[] {
|
|
|
322
387
|
}
|
|
323
388
|
}
|
|
324
389
|
(Array.isArray(row.cells) ? row.cells : []).forEach((cell, c) => {
|
|
390
|
+
for (const flag of ["highlight", "sortable"] as const) {
|
|
391
|
+
if (!isRecord(cell) || cell[flag] === undefined) continue;
|
|
392
|
+
const at = ["rows", r, "cells", c, flag];
|
|
393
|
+
if (typeof cell[flag] !== "boolean") bad(at, `${flag} must be true or false`);
|
|
394
|
+
else if (r !== 0 && cell[flag]) bad(at, `a column's ${flag} is named on its cell in the first row only`);
|
|
395
|
+
}
|
|
325
396
|
if (!isRecord(cell) || cell.group === undefined) return;
|
|
326
397
|
const at = ["rows", r, "cells", c, "group"];
|
|
327
398
|
if (r !== 0) bad(at, "a column's group is named on its cell in the first row only");
|
|
@@ -355,6 +426,47 @@ export function portableTextTableProblems(value: unknown): Array<PortableTextTab
|
|
|
355
426
|
return out;
|
|
356
427
|
}
|
|
357
428
|
|
|
429
|
+
/**
|
|
430
|
+
* A table's rows split into its three sections: the LEADING run of `head` rows (`<thead>`), the
|
|
431
|
+
* TRAILING run of `footer` rows (`<tfoot>`, the V2 table's "Totals row"), and the body between.
|
|
432
|
+
*
|
|
433
|
+
* Runs, not every flagged row, for the reason `<thead>` already is one: a section cannot be
|
|
434
|
+
* lifted out of document order without moving content the author placed. A `footer` row with a
|
|
435
|
+
* body row after it stays in the body (permissive read). Shared by every renderer so they agree.
|
|
436
|
+
*/
|
|
437
|
+
export function tableSections(table: PortableTextTable): { head: unknown[]; body: unknown[]; foot: unknown[] } {
|
|
438
|
+
const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
|
|
439
|
+
const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
|
|
440
|
+
const head = rows.slice(0, headCount === -1 ? rows.length : headCount);
|
|
441
|
+
let footFrom = rows.length;
|
|
442
|
+
while (footFrom > head.length && isRecord(rows[footFrom - 1]) && (rows[footFrom - 1] as Record<string, unknown>).footer === true) footFrom -= 1;
|
|
443
|
+
return { head, body: rows.slice(head.length, footFrom), foot: rows.slice(footFrom) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The columns "Highlight this column" marks: the start column of each FIRST-row cell carrying
|
|
448
|
+
* `highlight` (spans counted). A renderer stamps `data-highlight` on every cell starting there.
|
|
449
|
+
*/
|
|
450
|
+
export function highlightedColumns(table: PortableTextTable): Set<number> {
|
|
451
|
+
return markedColumns(table, "highlight");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The columns "Visitors can sort columns" marks (a FIRST-row cell's `sortable`), as `highlightedColumns`. */
|
|
455
|
+
export function sortableColumns(table: PortableTextTable): Set<number> {
|
|
456
|
+
return markedColumns(table, "sortable");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function markedColumns(table: PortableTextTable, flag: "highlight" | "sortable"): Set<number> {
|
|
460
|
+
const out = new Set<number>();
|
|
461
|
+
const first = Array.isArray(table.rows) ? table.rows[0] : undefined;
|
|
462
|
+
let at = 0;
|
|
463
|
+
for (const cell of isRecord(first) && Array.isArray(first.cells) ? first.cells : []) {
|
|
464
|
+
if (isRecord(cell) && cell[flag] === true) out.add(at);
|
|
465
|
+
at += isRecord(cell) && typeof cell.span === "number" && Number.isInteger(cell.span) && cell.span > 1 ? cell.span : 1;
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
|
|
358
470
|
/** A run of columns, or of body rows, that share one group; `label` is absent for an ungrouped run. */
|
|
359
471
|
export type TableColumnRun = { width: number; key?: string; label?: string };
|
|
360
472
|
export type TableRowRun = { rows: unknown[]; key?: string; label?: string };
|
|
@@ -392,8 +504,8 @@ export function tableGroupLayout(table: PortableTextTable): { columns: TableColu
|
|
|
392
504
|
}
|
|
393
505
|
|
|
394
506
|
const rowLabels = labels(table.rowGroups);
|
|
395
|
-
|
|
396
|
-
const body =
|
|
507
|
+
// The body only: a footer (totals) run sits in `<tfoot>`, outside every row group.
|
|
508
|
+
const { body } = tableSections(table);
|
|
397
509
|
const runs: TableRowRun[] = [];
|
|
398
510
|
for (const row of body) {
|
|
399
511
|
const key = isRecord(row) && typeof row.group === "string" && rowLabels.has(row.group) ? row.group : undefined;
|
package/src/types.ts
CHANGED
|
@@ -110,6 +110,16 @@ 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;
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
export interface PortableTextTableRow {
|
|
@@ -119,6 +129,13 @@ export interface PortableTextTableRow {
|
|
|
119
129
|
head?: boolean;
|
|
120
130
|
/** The ROW GROUP this body row belongs to: a `_key` of the table's `rowGroups`. */
|
|
121
131
|
group?: string;
|
|
132
|
+
/**
|
|
133
|
+
* A totals / summary row: the TRAILING run of these renders as `<tfoot>` (the V2 table's
|
|
134
|
+
* "Totals row", `TableRow.footer` in `@bettercms-ai/types` table-value.ts).
|
|
135
|
+
*/
|
|
136
|
+
footer?: boolean;
|
|
137
|
+
/** "Highlight this row": `<tr data-highlight="true">`, as the V2 table renders it. */
|
|
138
|
+
highlight?: boolean;
|
|
122
139
|
}
|
|
123
140
|
|
|
124
141
|
/**
|
|
@@ -160,6 +177,36 @@ export interface PortableTextTable extends PortableTextObject {
|
|
|
160
177
|
rowGroups?: PortableTextTableGroup[];
|
|
161
178
|
/** First-row cells name one with `cell.group`; a run of columns gets one `scope="colgroup"` header. */
|
|
162
179
|
columnGroups?: PortableTextTableGroup[];
|
|
180
|
+
/**
|
|
181
|
+
* "Visitors can search rows" (the V2 table's `TableValue.filterable`): the table renders inside
|
|
182
|
+
* `<figure data-rt="table" data-filter="true">`, as the V2 table writes it.
|
|
183
|
+
*/
|
|
184
|
+
filterable?: boolean;
|
|
185
|
+
/**
|
|
186
|
+
* "Help search engines read this as" (the V2 table's `TableValue.jsonLd`, the field-lane table's `jsonLd`): a
|
|
187
|
+
* comparison, a data report or a price list. Written on the figure as `data-jsonld="<type>"` (plus
|
|
188
|
+
* `data-jsonld-axis` / `data-jsonld-price`), as the V2 table writes it. `"none"` is not stored.
|
|
189
|
+
*/
|
|
190
|
+
jsonLd?: PortableTextTableJsonLd;
|
|
191
|
+
/**
|
|
192
|
+
* "On phones" and "Keep column names in view" (the V2 table's `TableValue.design.responsive` / `.sticky`, the field-lane
|
|
193
|
+
* table's `design`). `responsive` is `stack` or `cards` (`scroll`, the default, is not stored, as V2 reads it); written
|
|
194
|
+
* on the figure as `data-responsive` / `data-sticky="true"`, as the V2 table writes them.
|
|
195
|
+
*/
|
|
196
|
+
design?: PortableTextTableDesign;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The V2 table's `design` keys the rich-text table offers. */
|
|
200
|
+
export interface PortableTextTableDesign {
|
|
201
|
+
responsive?: "stack" | "cards";
|
|
202
|
+
sticky?: boolean;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The V2 table's JSON-LD choice: `priceRow` is a row's `_key` (Offers), `axis` what an ItemList lists. */
|
|
206
|
+
export interface PortableTextTableJsonLd {
|
|
207
|
+
type: "ItemList" | "Dataset" | "Offers";
|
|
208
|
+
axis?: "rows" | "columns";
|
|
209
|
+
priceRow?: string;
|
|
163
210
|
}
|
|
164
211
|
|
|
165
212
|
/**
|