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