@bettercms-ai/richtext 0.8.3 → 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 +2 -1
- package/src/component-copy.ts +138 -0
- package/src/html.ts +140 -10
- package/src/index.ts +2 -0
- package/src/ingest.ts +333 -28
- package/src/key-takeaways.ts +93 -0
- package/src/plain.ts +55 -7
- package/src/read.ts +19 -1
- package/src/schema.ts +39 -1
- package/src/table.ts +291 -0
- package/src/types.ts +168 -2
package/src/schema.ts
CHANGED
|
@@ -124,6 +124,33 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
124
124
|
|
|
125
125
|
const blockObjects: PtObject[] = [];
|
|
126
126
|
if (has("p")) blockObjects.push({ name: PT_TYPES.divider, title: "Divider" });
|
|
127
|
+
// The pull quote rides `p` as the blockquote style does: a quotation is prose, and no stored
|
|
128
|
+
// field config has to change for an author to get one. `link` is declared as the kit writes
|
|
129
|
+
// it, a URL string; a link object carrying `href` is read too (`pullQuoteHref`).
|
|
130
|
+
if (has("p")) {
|
|
131
|
+
blockObjects.push({
|
|
132
|
+
name: PT_TYPES.pullQuote,
|
|
133
|
+
title: "Pull quote",
|
|
134
|
+
fields: [
|
|
135
|
+
{ name: "quote", type: "string" },
|
|
136
|
+
{ name: "name", type: "string" },
|
|
137
|
+
{ name: "role", type: "string" },
|
|
138
|
+
{ name: "link", type: "string" },
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Key takeaways ride `ul`: they ARE a bulleted list, under a title, so a field that forbids a
|
|
143
|
+
// list is not handed one in another shape. `points` is an array of strings (types.ts).
|
|
144
|
+
if (has("ul")) {
|
|
145
|
+
blockObjects.push({
|
|
146
|
+
name: PT_TYPES.keyTakeaways,
|
|
147
|
+
title: "Key takeaways",
|
|
148
|
+
fields: [
|
|
149
|
+
{ name: "title", type: "string" },
|
|
150
|
+
{ name: "points", type: "object" },
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
127
154
|
if (has("pre")) {
|
|
128
155
|
blockObjects.push({
|
|
129
156
|
name: PT_TYPES.code,
|
|
@@ -196,7 +223,18 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
196
223
|
* nothing and drift from the types that do.
|
|
197
224
|
*/
|
|
198
225
|
if (has("table")) {
|
|
199
|
-
blockObjects.push({
|
|
226
|
+
blockObjects.push({
|
|
227
|
+
name: PT_TYPES.table,
|
|
228
|
+
title: "Table",
|
|
229
|
+
// `caption`, `notes` and the two group lists are the V2 table's, optional; see `types.ts`.
|
|
230
|
+
fields: [
|
|
231
|
+
{ name: "rows", type: "object" },
|
|
232
|
+
{ name: "caption", type: "string" },
|
|
233
|
+
{ name: "notes", type: "string" },
|
|
234
|
+
{ name: "rowGroups", type: "object" },
|
|
235
|
+
{ name: "columnGroups", type: "object" },
|
|
236
|
+
],
|
|
237
|
+
});
|
|
200
238
|
}
|
|
201
239
|
|
|
202
240
|
return {
|
package/src/table.ts
CHANGED
|
@@ -4,6 +4,9 @@ import {
|
|
|
4
4
|
type PortableText,
|
|
5
5
|
type PortableTextTable,
|
|
6
6
|
type PortableTextTableCell,
|
|
7
|
+
type PortableTextTableGroup,
|
|
8
|
+
type PortableTextTableDesign,
|
|
9
|
+
type PortableTextTableJsonLd,
|
|
7
10
|
type PortableTextTableRow,
|
|
8
11
|
} from "./types";
|
|
9
12
|
import { deterministicKeys, isValidKey, randomKey, type KeyGenerator } from "./key";
|
|
@@ -143,6 +146,9 @@ function normalizeCell(raw: unknown, at: [number, number], mint: Mint, read: Cel
|
|
|
143
146
|
...(raw.header === true ? { header: true } : {}),
|
|
144
147
|
...(span === undefined ? {} : { span }),
|
|
145
148
|
...(rowSpan === undefined ? {} : { rowSpan }),
|
|
149
|
+
...(typeof raw.group === "string" ? { group: raw.group } : {}),
|
|
150
|
+
...(raw.highlight === true ? { highlight: true } : {}),
|
|
151
|
+
...(raw.sortable === true ? { sortable: true } : {}),
|
|
146
152
|
};
|
|
147
153
|
}
|
|
148
154
|
|
|
@@ -154,6 +160,10 @@ function normalizeRow(raw: unknown, at: number, mint: Mint, read: CellContentRea
|
|
|
154
160
|
_key: isRecord(raw) && isValidKey(raw._key) ? raw._key : mint("r", at),
|
|
155
161
|
cells: cells.map((c, col) => normalizeCell(c, [at, col], mint, read)),
|
|
156
162
|
...(isRecord(raw) && raw.head === true ? { head: true } : {}),
|
|
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 } : {}),
|
|
157
167
|
};
|
|
158
168
|
}
|
|
159
169
|
|
|
@@ -227,5 +237,286 @@ export function migrateTable(
|
|
|
227
237
|
// Carried through: a migrated table that an author saves back is still the same table, and
|
|
228
238
|
// dropping the flag on that save would change the page the next time it renders.
|
|
229
239
|
...(node.legacy === true ? { legacy: true } : {}),
|
|
240
|
+
...tableExtras(node),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Caption, notes and the two group lists, carried through the reader. Each is written only when
|
|
246
|
+
* the stored value has it, so a table without them reads back byte for byte as it did before
|
|
247
|
+
* they existed. Permissive like the rest of the read: a group entry that is not an object with a
|
|
248
|
+
* string `_key` is skipped (nothing could name it), and a missing label reads as "". The write
|
|
249
|
+
* refuses all of it by name — see `tableProblems`.
|
|
250
|
+
*/
|
|
251
|
+
function tableExtras(node: Record<string, unknown>): Partial<PortableTextTable> {
|
|
252
|
+
const groups = (list: unknown): PortableTextTableGroup[] | undefined =>
|
|
253
|
+
Array.isArray(list)
|
|
254
|
+
? list
|
|
255
|
+
.filter((g): g is Record<string, unknown> => isRecord(g) && typeof g._key === "string" && g._key !== "")
|
|
256
|
+
.map((g) => ({ _key: g._key as string, label: typeof g.label === "string" ? g.label : "" }))
|
|
257
|
+
: undefined;
|
|
258
|
+
const rowGroups = groups(node.rowGroups);
|
|
259
|
+
const columnGroups = groups(node.columnGroups);
|
|
260
|
+
return {
|
|
261
|
+
...(typeof node.caption === "string" ? { caption: node.caption } : {}),
|
|
262
|
+
...(typeof node.notes === "string" ? { notes: node.notes } : {}),
|
|
263
|
+
...(rowGroups ? { rowGroups } : {}),
|
|
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 } : {}),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The limits a table's caption, notes and group labels are held to — the V2 table's
|
|
301
|
+
* (`TABLE_LIMITS` in `@bettercms-ai/types` table-value.ts), restated so this package keeps no
|
|
302
|
+
* dependency on that one.
|
|
303
|
+
*/
|
|
304
|
+
export const PT_TABLE_LIMITS = { captionChars: 300, notesChars: 2000, labelChars: 300, keyChars: 64 } as const;
|
|
305
|
+
|
|
306
|
+
/** One reason a stored table is refused, at its own path inside the table. The V2 table's shape. */
|
|
307
|
+
export type PortableTextTableProblem = { path: Array<string | number>; code: string; message: string };
|
|
308
|
+
|
|
309
|
+
const GROUP_KEYS = new Set(["_key", "label"]);
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Every reason the caption, notes and groups of `node` cannot be stored, each named with the V2
|
|
313
|
+
* table's codes (`table_value_invalid`, `table_limit_exceeded`) and its path inside the table.
|
|
314
|
+
*
|
|
315
|
+
* 🔴 ONLY THE NEW KEYS ARE JUDGED. A table that carries none of them answers `[]` whatever else it
|
|
316
|
+
* holds, so every table stored before they existed keeps saving exactly as it did. None of these
|
|
317
|
+
* checks reads field config, so there is nothing to grandfather.
|
|
318
|
+
*/
|
|
319
|
+
export function tableProblems(node: unknown): PortableTextTableProblem[] {
|
|
320
|
+
const out: PortableTextTableProblem[] = [];
|
|
321
|
+
if (!isRecord(node)) return out;
|
|
322
|
+
const bad = (path: Array<string | number>, message: string, code = "table_value_invalid") =>
|
|
323
|
+
out.push({ path, code, message: `${code}: ${message}` });
|
|
324
|
+
const text = (v: unknown, at: Array<string | number>, what: string, max: number, required = false) => {
|
|
325
|
+
if (v === undefined && !required) return;
|
|
326
|
+
if (typeof v !== "string") bad(at, `${what} must be a string`);
|
|
327
|
+
else if (v.length > max) bad(at, `${what} is ${v.length} characters; at most ${max}`, "table_limit_exceeded");
|
|
328
|
+
};
|
|
329
|
+
text(node.caption, ["caption"], "caption", PT_TABLE_LIMITS.captionChars);
|
|
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
|
+
}
|
|
354
|
+
|
|
355
|
+
const keyed = (list: unknown, at: "rowGroups" | "columnGroups"): Set<string> => {
|
|
356
|
+
const seen = new Set<string>();
|
|
357
|
+
if (list === undefined) return seen;
|
|
358
|
+
if (!Array.isArray(list)) {
|
|
359
|
+
bad([at], `${at} must be an array of { _key, label }`);
|
|
360
|
+
return seen;
|
|
361
|
+
}
|
|
362
|
+
list.forEach((g, i) => {
|
|
363
|
+
if (!isRecord(g)) return bad([at, i], `${at}[${i}] must be an object { _key, label }`);
|
|
364
|
+
for (const k of Object.keys(g)) if (!GROUP_KEYS.has(k)) bad([at, i, k], `unknown key "${k}" (_key, label)`);
|
|
365
|
+
if (typeof g._key !== "string" || g._key === "" || g._key.length > PT_TABLE_LIMITS.keyChars) {
|
|
366
|
+
bad([at, i, "_key"], `needs a _key string of 1 to ${PT_TABLE_LIMITS.keyChars} characters`);
|
|
367
|
+
} else if (seen.has(g._key)) bad([at, i, "_key"], `_key "${g._key}" is used twice`);
|
|
368
|
+
else seen.add(g._key);
|
|
369
|
+
text(g.label, [at, i, "label"], "label", PT_TABLE_LIMITS.labelChars, true);
|
|
370
|
+
});
|
|
371
|
+
return seen;
|
|
372
|
+
};
|
|
373
|
+
const rowGroups = keyed(node.rowGroups, "rowGroups");
|
|
374
|
+
const columnGroups = keyed(node.columnGroups, "columnGroups");
|
|
375
|
+
|
|
376
|
+
const rows = Array.isArray(node.rows) ? node.rows : [];
|
|
377
|
+
rows.forEach((row, r) => {
|
|
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");
|
|
383
|
+
if (row.group !== undefined) {
|
|
384
|
+
if (row.head === true) bad(["rows", r, "group"], "a head row takes no group (row groups hold body rows)");
|
|
385
|
+
else if (typeof row.group !== "string" || !rowGroups.has(row.group)) {
|
|
386
|
+
bad(["rows", r, "group"], `group "${String(row.group)}" is not a key of rowGroups`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
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
|
+
}
|
|
396
|
+
if (!isRecord(cell) || cell.group === undefined) return;
|
|
397
|
+
const at = ["rows", r, "cells", c, "group"];
|
|
398
|
+
if (r !== 0) bad(at, "a column's group is named on its cell in the first row only");
|
|
399
|
+
else if (typeof cell.group !== "string" || !columnGroups.has(cell.group)) {
|
|
400
|
+
bad(at, `group "${String(cell.group)}" is not a key of columnGroups`);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* `tableProblems` for every table in a rich-text value — top level, and inside cells — with the
|
|
409
|
+
* table's `_key` so a refusal names the table an author has to find.
|
|
410
|
+
*/
|
|
411
|
+
export function portableTextTableProblems(value: unknown): Array<PortableTextTableProblem & { table: string }> {
|
|
412
|
+
const out: Array<PortableTextTableProblem & { table: string }> = [];
|
|
413
|
+
const walk = (blocks: unknown) => {
|
|
414
|
+
if (!Array.isArray(blocks)) return;
|
|
415
|
+
for (const node of blocks) {
|
|
416
|
+
if (!isPortableTextTable(node)) continue;
|
|
417
|
+
const table = typeof node._key === "string" ? node._key : "";
|
|
418
|
+
for (const p of tableProblems(node)) out.push({ ...p, table });
|
|
419
|
+
for (const row of node.rows as unknown[]) {
|
|
420
|
+
if (!isRecord(row) || !Array.isArray(row.cells)) continue;
|
|
421
|
+
for (const cell of row.cells) if (isRecord(cell)) walk(cell.content);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
walk(value);
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
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
|
+
|
|
470
|
+
/** A run of columns, or of body rows, that share one group; `label` is absent for an ungrouped run. */
|
|
471
|
+
export type TableColumnRun = { width: number; key?: string; label?: string };
|
|
472
|
+
export type TableRowRun = { rows: unknown[]; key?: string; label?: string };
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* How a table's groups lay out, or two empty lists when it has none.
|
|
476
|
+
*
|
|
477
|
+
* Shared by both renderers (`@bettercms-ai/ui` draws the same runs), so they cannot disagree
|
|
478
|
+
* about where a group starts. A group key that names no group is ignored here rather than
|
|
479
|
+
* thrown on: the read is permissive, and the write refuses it by name (`tableProblems`).
|
|
480
|
+
*
|
|
481
|
+
* `columns` is empty unless some first-row cell names a known column group; `rows` is empty
|
|
482
|
+
* unless some body row names a known row group. `width` is the widest row, spans counted.
|
|
483
|
+
*/
|
|
484
|
+
export function tableGroupLayout(table: PortableTextTable): { columns: TableColumnRun[]; rows: TableRowRun[]; width: number } {
|
|
485
|
+
const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
|
|
486
|
+
const labels = (list: unknown): Map<string, string> =>
|
|
487
|
+
new Map(
|
|
488
|
+
(Array.isArray(list) ? list : [])
|
|
489
|
+
.filter((g): g is Record<string, unknown> => isRecord(g) && typeof g._key === "string")
|
|
490
|
+
.map((g) => [g._key as string, typeof g.label === "string" ? g.label : ""]),
|
|
491
|
+
);
|
|
492
|
+
const cellsOf = (row: unknown): unknown[] => (isRecord(row) && Array.isArray(row.cells) ? row.cells : []);
|
|
493
|
+
const spanOf = (cell: unknown): number =>
|
|
494
|
+
isRecord(cell) && typeof cell.span === "number" && Number.isInteger(cell.span) && cell.span > 1 ? cell.span : 1;
|
|
495
|
+
const width = rows.reduce<number>((max, row) => Math.max(max, cellsOf(row).reduce<number>((n, c) => n + spanOf(c), 0)), 0);
|
|
496
|
+
|
|
497
|
+
const colLabels = labels(table.columnGroups);
|
|
498
|
+
const columns: TableColumnRun[] = [];
|
|
499
|
+
for (const cell of cellsOf(rows[0])) {
|
|
500
|
+
const key = isRecord(cell) && typeof cell.group === "string" && colLabels.has(cell.group) ? cell.group : undefined;
|
|
501
|
+
const last = columns[columns.length - 1];
|
|
502
|
+
if (last && last.key === key) last.width += spanOf(cell);
|
|
503
|
+
else columns.push({ width: spanOf(cell), ...(key === undefined ? {} : { key, label: colLabels.get(key) }) });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const rowLabels = labels(table.rowGroups);
|
|
507
|
+
// The body only: a footer (totals) run sits in `<tfoot>`, outside every row group.
|
|
508
|
+
const { body } = tableSections(table);
|
|
509
|
+
const runs: TableRowRun[] = [];
|
|
510
|
+
for (const row of body) {
|
|
511
|
+
const key = isRecord(row) && typeof row.group === "string" && rowLabels.has(row.group) ? row.group : undefined;
|
|
512
|
+
const last = runs[runs.length - 1];
|
|
513
|
+
if (last && last.key === key) last.rows.push(row);
|
|
514
|
+
else runs.push({ rows: [row], ...(key === undefined ? {} : { key, label: rowLabels.get(key) }) });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
columns: columns.some((r) => r.key !== undefined) ? columns : [],
|
|
519
|
+
rows: runs.some((r) => r.key !== undefined) ? runs : [],
|
|
520
|
+
width,
|
|
230
521
|
};
|
|
231
522
|
}
|
package/src/types.ts
CHANGED
|
@@ -102,6 +102,24 @@ export interface PortableTextTableCell {
|
|
|
102
102
|
* would refuse the whole table over an attribute we simply forgot to carry.
|
|
103
103
|
*/
|
|
104
104
|
rowSpan?: number;
|
|
105
|
+
/**
|
|
106
|
+
* The COLUMN GROUP this cell's column belongs to: a `_key` of the table's `columnGroups`.
|
|
107
|
+
*
|
|
108
|
+
* Read from the FIRST row only (the V2 table puts `group` on the column; this grid has no
|
|
109
|
+
* column object, so the column's first cell carries it). Living on a cell is what keeps it
|
|
110
|
+
* right when the editor inserts, deletes or moves a column: the cell moves with its column.
|
|
111
|
+
*/
|
|
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;
|
|
105
123
|
}
|
|
106
124
|
|
|
107
125
|
export interface PortableTextTableRow {
|
|
@@ -109,6 +127,24 @@ export interface PortableTextTableRow {
|
|
|
109
127
|
cells: PortableTextTableCell[];
|
|
110
128
|
/** The row sat in `<thead>`. Absent means `<tbody>`, which is what a parser assumes. */
|
|
111
129
|
head?: boolean;
|
|
130
|
+
/** The ROW GROUP this body row belongs to: a `_key` of the table's `rowGroups`. */
|
|
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;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A row or column group, the V2 table's `TableGroup` (`@bettercms-ai/types` table-value.ts), so
|
|
143
|
+
* both tables speak one language: a stable `_key` and the label a reader sees.
|
|
144
|
+
*/
|
|
145
|
+
export interface PortableTextTableGroup {
|
|
146
|
+
_key: string;
|
|
147
|
+
label: string;
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
/** A table. The ONE table BetterCMS has — see `PT_TYPES.table`. */
|
|
@@ -129,6 +165,48 @@ export interface PortableTextTable extends PortableTextObject {
|
|
|
129
165
|
* authored or pasted since P1c carries neither the flag nor the class.
|
|
130
166
|
*/
|
|
131
167
|
legacy?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* The four keys below are the V2 table's (`TableValue`), spelled the same way. All optional,
|
|
170
|
+
* and a table that has none of them renders byte for byte as it did before they existed.
|
|
171
|
+
*/
|
|
172
|
+
/** What the table compares, written as `<caption>`. At most 300 characters. */
|
|
173
|
+
caption?: string;
|
|
174
|
+
/** A note under the table (`<p data-notes>`), plain text. At most 2,000 characters. */
|
|
175
|
+
notes?: string;
|
|
176
|
+
/** Body rows name one with `row.group`; each run of rows is its own `<tbody>` under a label row. */
|
|
177
|
+
rowGroups?: PortableTextTableGroup[];
|
|
178
|
+
/** First-row cells name one with `cell.group`; a run of columns gets one `scope="colgroup"` header. */
|
|
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;
|
|
132
210
|
}
|
|
133
211
|
|
|
134
212
|
/**
|
|
@@ -157,6 +235,50 @@ export interface PortableTextFile extends PortableTextObject {
|
|
|
157
235
|
mime?: string;
|
|
158
236
|
}
|
|
159
237
|
|
|
238
|
+
/**
|
|
239
|
+
* A pull quote: a quotation lifted out of the body, with who said it.
|
|
240
|
+
*
|
|
241
|
+
* 🔴 THE SHAPE IS THE V2 KIT'S, 1:1 — `PullQuoteModel` in the dashboard's
|
|
242
|
+
* `kit/controls/rich-text/article.ts`, which renders `<figure data-rt="pullquote">`. The kit
|
|
243
|
+
* writes these four fields and every renderer here draws them, so the names are spelled once.
|
|
244
|
+
*
|
|
245
|
+
* `link` is where the NAME links (and the blockquote's `cite`): a URL string, as the kit stores
|
|
246
|
+
* it, or a link object carrying `href` (the shape a `link` field value takes). Anything
|
|
247
|
+
* `safeHref` refuses renders the name unlinked rather than dropping it.
|
|
248
|
+
*/
|
|
249
|
+
export interface PortableTextPullQuote extends PortableTextObject {
|
|
250
|
+
_type: "pullQuote";
|
|
251
|
+
quote: string;
|
|
252
|
+
name: string;
|
|
253
|
+
role?: string;
|
|
254
|
+
link?: string | { href: string; [k: string]: unknown };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The href a pull quote's `link` carries — a string, or a link object's `href`. "" for none. */
|
|
258
|
+
export function pullQuoteHref(link: unknown): string {
|
|
259
|
+
if (typeof link === "string") return link.trim();
|
|
260
|
+
if (link && typeof link === "object" && typeof (link as { href?: unknown }).href === "string") {
|
|
261
|
+
return ((link as { href: string }).href).trim();
|
|
262
|
+
}
|
|
263
|
+
return "";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Key takeaways: the story in a few points, set apart near the top of a body.
|
|
268
|
+
*
|
|
269
|
+
* 🔴 THE SHAPE IS THE V2 KIT'S, 1:1 — `TakeawaysModel` and `ARTICLE_SCHEMAS.takeaways` (`name:
|
|
270
|
+
* "keyTakeaways"`, fields `title` and `points`) in the kit's `controls/rich-text/article.ts`,
|
|
271
|
+
* which renders `<aside data-rt="takeaways">`. `title` is the label over the list ("Key
|
|
272
|
+
* takeaways" by default), ABSENT rather than empty when an author cleared it. `points` are plain
|
|
273
|
+
* strings, one per bullet; V2 holds at most 8 and a title of at most 40 characters
|
|
274
|
+
* (`PT_KEY_TAKEAWAYS_LIMITS`, key-takeaways.ts).
|
|
275
|
+
*/
|
|
276
|
+
export interface PortableTextKeyTakeaways extends PortableTextObject {
|
|
277
|
+
_type: "keyTakeaways";
|
|
278
|
+
title?: string;
|
|
279
|
+
points: string[];
|
|
280
|
+
}
|
|
281
|
+
|
|
160
282
|
export const isPortableTextBlock = (n: unknown): n is PortableTextBlock =>
|
|
161
283
|
!!n && typeof n === "object" && (n as PortableTextNode)._type === "block";
|
|
162
284
|
|
|
@@ -204,6 +326,10 @@ export const PT_TYPES = {
|
|
|
204
326
|
video: "video",
|
|
205
327
|
/** A downloadable document — a PDF, a spec sheet. `<a href download>`, nothing more. */
|
|
206
328
|
file: "file",
|
|
329
|
+
/** A pull quote — see `PortableTextPullQuote`. The V2 kit's `<figure data-rt="pullquote">`. */
|
|
330
|
+
pullQuote: "pullQuote",
|
|
331
|
+
/** Key takeaways — see `PortableTextKeyTakeaways`. The V2 kit's `<aside data-rt="takeaways">`. */
|
|
332
|
+
keyTakeaways: "keyTakeaways",
|
|
207
333
|
/**
|
|
208
334
|
* A table. Spelled `table`, unprefixed, because that is what every other Portable Text
|
|
209
335
|
* producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
|
|
@@ -259,6 +385,8 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
|
|
|
259
385
|
PT_TYPES.mention,
|
|
260
386
|
PT_TYPES.video,
|
|
261
387
|
PT_TYPES.file,
|
|
388
|
+
PT_TYPES.pullQuote,
|
|
389
|
+
PT_TYPES.keyTakeaways,
|
|
262
390
|
]);
|
|
263
391
|
|
|
264
392
|
/**
|
|
@@ -303,8 +431,15 @@ export function foreignBlocks(value: unknown): { _type: string; _key: string }[]
|
|
|
303
431
|
if (!node || typeof node !== "object" || Array.isArray(node)) continue;
|
|
304
432
|
const type = (node as PortableTextNode)._type;
|
|
305
433
|
if (typeof type !== "string") continue;
|
|
306
|
-
// A table
|
|
307
|
-
|
|
434
|
+
// A table, a pull quote and key takeaways are admitted by SHAPE, never by `_type` alone —
|
|
435
|
+
// see `isStorableTable`.
|
|
436
|
+
const storable = type === PT_TYPES.table
|
|
437
|
+
? isStorableTable(node)
|
|
438
|
+
: type === PT_TYPES.pullQuote
|
|
439
|
+
? isStorablePullQuote(node)
|
|
440
|
+
: type === PT_TYPES.keyTakeaways
|
|
441
|
+
? isStorableKeyTakeaways(node)
|
|
442
|
+
: STORABLE_BLOCK_TYPES.has(type);
|
|
308
443
|
if (storable) continue;
|
|
309
444
|
const key = (node as PortableTextNode)._key;
|
|
310
445
|
out.push({ _type: type, _key: typeof key === "string" ? key : "" });
|
|
@@ -347,6 +482,37 @@ export function isStorableTable(node: unknown): boolean {
|
|
|
347
482
|
});
|
|
348
483
|
}
|
|
349
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Is this `_type:"pullQuote"` one we can render?
|
|
487
|
+
*
|
|
488
|
+
* Same reason as the table: `pullQuote` is a name other producers use too, and a renderer that
|
|
489
|
+
* met `{_type:"pullQuote", text}` would draw an empty figure. `quote` and `name` are strings
|
|
490
|
+
* (either may be empty while an author is still typing); `role`, when present, is a string; and
|
|
491
|
+
* `link`, when present, is a string or an object carrying a string `href`.
|
|
492
|
+
*/
|
|
493
|
+
export function isStorablePullQuote(node: unknown): boolean {
|
|
494
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return false;
|
|
495
|
+
const v = node as Record<string, unknown>;
|
|
496
|
+
if (typeof v.quote !== "string" || typeof v.name !== "string") return false;
|
|
497
|
+
if (v.role !== undefined && typeof v.role !== "string") return false;
|
|
498
|
+
if (v.link === undefined || typeof v.link === "string") return true;
|
|
499
|
+
return !!v.link && typeof v.link === "object" && typeof (v.link as { href?: unknown }).href === "string";
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Is this `_type:"keyTakeaways"` one we can render?
|
|
504
|
+
*
|
|
505
|
+
* Same reason as the table: the name alone is not a contract, and a renderer that met another
|
|
506
|
+
* producer's `{_type:"keyTakeaways", items}` would draw an empty aside. Ours carries `points`, an
|
|
507
|
+
* array. What is IN it, the title and the limits are judged by `keyTakeawaysProblems`
|
|
508
|
+
* (key-takeaways.ts), which names each problem with a code; this only decides whether the node is
|
|
509
|
+
* ours at all.
|
|
510
|
+
*/
|
|
511
|
+
export function isStorableKeyTakeaways(node: unknown): boolean {
|
|
512
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return false;
|
|
513
|
+
return Array.isArray((node as { points?: unknown }).points);
|
|
514
|
+
}
|
|
515
|
+
|
|
350
516
|
/**
|
|
351
517
|
* @deprecated The marker a table-accepting field used to carry INSTEAD of a table.
|
|
352
518
|
*
|