@bettercms-ai/richtext 0.1.0 → 0.2.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/README.md +21 -0
- package/package.json +1 -1
- package/src/from-lexical.ts +30 -29
- package/src/html.ts +64 -27
- package/src/index.ts +1 -0
- package/src/ingest.ts +54 -52
- package/src/plain.ts +25 -10
- package/src/read.ts +11 -0
- package/src/schema.ts +29 -13
- package/src/table.ts +231 -0
- package/src/types.ts +178 -9
package/README.md
CHANGED
|
@@ -63,6 +63,27 @@ is not "sanitised away", it fails to parse into anything. One `ptSchemaFor(accep
|
|
|
63
63
|
drives the editor's toolbar, the server's write validation, and this filter at once, so they
|
|
64
64
|
cannot drift.
|
|
65
65
|
|
|
66
|
+
## Tables
|
|
67
|
+
|
|
68
|
+
A table is a block object of its own — `{_type:"table", rows:[{_key, cells:[{_key, content}]}]}` —
|
|
69
|
+
and a cell holds **blocks**, not a string. So a link, a bolded word or a second paragraph inside a
|
|
70
|
+
cell is ordinary rich text, and the ingest keeps it by ingesting each cell through the same
|
|
71
|
+
converter as the document around it.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
htmlToPortableText('<table><tbody><tr><td>Pro</td></tr></tbody></table>', { parseHtml, schema })
|
|
75
|
+
// → [{ _type: "table", _key: "…", rows: [{ _key: "…", cells: [{ _key: "…", content: [/* blocks */] }] }] }]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`header` sits on the cell (`<th>`), `head` on the row (`<thead>`), and `span` records a `colspan`
|
|
79
|
+
without collapsing the grid. All three are recorded rather than inferred so that rendering a table
|
|
80
|
+
reproduces the markup it came from — the property the corpus audit's `--tables` pass measures over
|
|
81
|
+
every stored value, and the reason a table may be converted at all.
|
|
82
|
+
|
|
83
|
+
Tables stored before this — a generic `bcmsBlock` keyed `builtin:table`, holding a string matrix —
|
|
84
|
+
are migrated **on read** by `migrateTable`. Nothing rewrites the database; the reader, the renderer
|
|
85
|
+
and the plain-text pass all recognise the old shape and return the new one.
|
|
86
|
+
|
|
66
87
|
## Subpath exports
|
|
67
88
|
|
|
68
89
|
| import | pulls in | use it for |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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/from-lexical.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PT_TYPES, type PortableText, type PortableTextBlock, type PortableTextMarkDef, type PortableTextSpan, type PortableTextTable } from "./types";
|
|
2
2
|
import { randomKey, type KeyGenerator } from "./key";
|
|
3
3
|
import { safeHref } from "./href";
|
|
4
4
|
|
|
@@ -190,30 +190,32 @@ function listBlocks(node: LexNode, ctx: Ctx, level: number): PortableText {
|
|
|
190
190
|
return out;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
/**
|
|
194
|
-
|
|
193
|
+
/**
|
|
194
|
+
* A Lexical table → the `table` block object.
|
|
195
|
+
*
|
|
196
|
+
* A cell's own children are BLOCKS — Lexical wraps cell content in paragraphs — and they go
|
|
197
|
+
* through `blocksFrom` like any other content, so a bolded word, a link or a second paragraph
|
|
198
|
+
* inside a cell survives the import. Its predecessor flattened every cell to a string, because
|
|
199
|
+
* the string matrix it wrote had nowhere else to put them.
|
|
200
|
+
*
|
|
201
|
+
* `headerState` is Lexical's own flag; `> 0` means the cell is a header. A row whose cells are
|
|
202
|
+
* ALL headers is a `<thead>` row, which is the only header a Lexical table expresses.
|
|
203
|
+
*/
|
|
204
|
+
function tableBlock(node: LexNode, ctx: Ctx): PortableTextTable {
|
|
195
205
|
const rowNodes = (node.children ?? []).filter(isRecord).filter((r) => (r as LexNode).type === "tablerow");
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
);
|
|
208
|
-
|
|
209
|
-
const hasHeader = firstCells.length > 0 && firstCells.every((c) => Number((c as { headerState?: unknown }).headerState ?? 0) > 0);
|
|
210
|
-
return {
|
|
211
|
-
_type: PT_TYPES.block,
|
|
212
|
-
_key: ctx.key(),
|
|
213
|
-
schemaKey: BUILTIN_TABLE_KEY,
|
|
214
|
-
schemaLabel: "Table",
|
|
215
|
-
fields: { schemaVersion: 1, rows, hasHeader },
|
|
216
|
-
} as unknown as PortableTextBlock;
|
|
206
|
+
const rows = rowNodes.map((r) => {
|
|
207
|
+
const cells = ((r as LexNode).children ?? []).filter(isRecord).map((c) => ({
|
|
208
|
+
_key: ctx.key(),
|
|
209
|
+
content: ((c as LexNode).children ?? []).flatMap((b) => blocksFrom(b, ctx)),
|
|
210
|
+
...(Number((c as { headerState?: unknown }).headerState ?? 0) > 0 ? { header: true } : {}),
|
|
211
|
+
}));
|
|
212
|
+
return {
|
|
213
|
+
_key: ctx.key(),
|
|
214
|
+
cells,
|
|
215
|
+
...(cells.length > 0 && cells.every((c) => c.header) ? { head: true } : {}),
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
return { _type: PT_TYPES.table, _key: ctx.key(), rows };
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
function blocksFrom(value: unknown, ctx: Ctx): PortableText {
|
|
@@ -242,13 +244,12 @@ function blocksFrom(value: unknown, ctx: Ctx): PortableText {
|
|
|
242
244
|
return [{ _type: PT_TYPES.divider, _key: ctx.key() }];
|
|
243
245
|
/**
|
|
244
246
|
* A NATIVE Lexical table — the kind that arrives through paste or import, as opposed to the
|
|
245
|
-
*
|
|
247
|
+
* table an author inserts. It quarantined here, which was correct while
|
|
246
248
|
* nothing could represent it and is now just a migration that refuses to finish.
|
|
247
249
|
*
|
|
248
|
-
* It lands on the SAME `
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* header, which is exactly what `hasHeader` records.
|
|
250
|
+
* It lands on the SAME `table` block object an author inserts, so the product has one table
|
|
251
|
+
* rather than two with different powers — and because that block object carries real blocks
|
|
252
|
+
* per cell, a pasted table keeps the marks and links Lexical had in it.
|
|
252
253
|
*
|
|
253
254
|
* ⚠️ This converter is NOT the validation layer, and mapping the node widens what a raw
|
|
254
255
|
* Lexical state can carry into a field that forbids tables — it used to quarantine, and a
|
package/src/html.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { toHTML, type PortableTextHtmlComponents } from "@portabletext/to-html";
|
|
2
|
-
import {
|
|
2
|
+
import { PT_TYPES, type PortableText, type PortableTextTable } from "./types";
|
|
3
|
+
import { builtInTableToTable } from "./table";
|
|
3
4
|
import { safeHref } from "./href";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -27,27 +28,59 @@ const esc = (s: string): string =>
|
|
|
27
28
|
/** Colour marks carry a TOKEN name, never a baked hex, so a rebrand does not orphan content. */
|
|
28
29
|
const TOKEN = /^[a-z0-9-]+$/i;
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const raw = (value.fields as { rows?: unknown } | undefined)?.rows;
|
|
33
|
-
if (!Array.isArray(raw)) return [];
|
|
34
|
-
return raw
|
|
35
|
-
.filter(Array.isArray)
|
|
36
|
-
.map((row) => (row as unknown[]).map((c) => (typeof c === "string" ? c : "")));
|
|
37
|
-
};
|
|
31
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
32
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
38
33
|
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
/**
|
|
35
|
+
* A table, back to the markup it was ingested from.
|
|
36
|
+
*
|
|
37
|
+
* 🔴 NO CLASS, NO INVENTED SECTIONS. This renderer emits `<thead>` only where a row carries
|
|
38
|
+
* `head`, `<th>` only where a cell carries `header`, and `colspan` only where a cell carries a
|
|
39
|
+
* span greater than one — because the html it produces is COMPARED against the stored markup,
|
|
40
|
+
* by the block-props parity gate and by the corpus audit's table pass. Its predecessor added
|
|
41
|
+
* `class="bcms-table"` and promoted the first row to a header, so every stored table diverged
|
|
42
|
+
* from its own rendition and stayed quarantined html-only forever. Rendering exactly what the
|
|
43
|
+
* value says is what lets a table convert at all.
|
|
44
|
+
*
|
|
45
|
+
* `<tbody>` is the one thing written that the value does not name, and it is not an invention:
|
|
46
|
+
* every HTML parser inserts one, so the stored markup already has it by the time either side of
|
|
47
|
+
* that comparison is reparsed.
|
|
48
|
+
*
|
|
49
|
+
* Read defensively throughout — `rows` is `unknown` by type, and one bad row must not throw
|
|
50
|
+
* mid-audit.
|
|
51
|
+
*/
|
|
52
|
+
/** A span attribute, written only when it spans more than one — as the markup writes it. */
|
|
53
|
+
const attr = (name: string, value: unknown): string =>
|
|
54
|
+
typeof value === "number" && Number.isInteger(value) && value > 1 ? ` ${name}="${value}"` : "";
|
|
55
|
+
|
|
56
|
+
function tableHtml(table: PortableTextTable): string {
|
|
57
|
+
const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
|
|
41
58
|
if (rows.length === 0) return "";
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
|
|
60
|
+
const cellHtml = (raw: unknown): string => {
|
|
61
|
+
const cell = isRecord(raw) ? raw : {};
|
|
62
|
+
const tag = cell.header === true ? "th" : "td";
|
|
63
|
+
// `colspan` before `rowspan`, the order every table in the corpus writes them in: the
|
|
64
|
+
// comparison is on serialised markup, and a parser preserves the order it was given.
|
|
65
|
+
const attrs = `${attr("colspan", cell.span)}${attr("rowspan", cell.rowSpan)}`;
|
|
66
|
+
return `<${tag}${attrs}>${portableTextToHtml(cell.content as PortableText, { inline: true })}</${tag}>`;
|
|
67
|
+
};
|
|
68
|
+
const rowHtml = (raw: unknown): string => {
|
|
69
|
+
const cells = isRecord(raw) && Array.isArray(raw.cells) ? raw.cells : [];
|
|
70
|
+
return `<tr>${cells.map(cellHtml).join("")}</tr>`;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// The LEADING RUN of head rows, not every row flagged one: `<thead>` is a section, and a
|
|
74
|
+
// header row appearing after body rows cannot be lifted out of document order without moving
|
|
75
|
+
// content the author placed.
|
|
76
|
+
const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
|
|
77
|
+
const head = rows.slice(0, headCount === -1 ? rows.length : headCount);
|
|
78
|
+
const body = rows.slice(head.length);
|
|
79
|
+
// The platform's own class, on migrated values only — see `PortableTextTable["legacy"]`.
|
|
80
|
+
const cls = table.legacy === true ? ' class="bcms-table"' : "";
|
|
81
|
+
return `<table${cls}>${head.length ? `<thead>${head.map(rowHtml).join("")}</thead>` : ""}${
|
|
82
|
+
body.length ? `<tbody>${body.map(rowHtml).join("")}</tbody>` : ""
|
|
83
|
+
}</table>`;
|
|
51
84
|
}
|
|
52
85
|
|
|
53
86
|
export interface RenderOptions {
|
|
@@ -71,6 +104,7 @@ export interface RenderOptions {
|
|
|
71
104
|
const components: Partial<PortableTextHtmlComponents> = {
|
|
72
105
|
types: {
|
|
73
106
|
[PT_TYPES.divider]: () => "<hr>",
|
|
107
|
+
[PT_TYPES.table]: ({ value }) => tableHtml(value as PortableTextTable),
|
|
74
108
|
[PT_TYPES.code]: ({ value }) => {
|
|
75
109
|
const v = value as { code?: unknown; language?: unknown };
|
|
76
110
|
const code = typeof v.code === "string" ? v.code : "";
|
|
@@ -87,14 +121,17 @@ const components: Partial<PortableTextHtmlComponents> = {
|
|
|
87
121
|
[PT_TYPES.block]: ({ value }) => {
|
|
88
122
|
const v = value as { schemaKey?: unknown };
|
|
89
123
|
/**
|
|
90
|
-
* A TABLE is the exception to the marker, and it has to be: it resolves against
|
|
91
|
-
* Its
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
124
|
+
* A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
|
|
125
|
+
* nothing. Its rows are text, held right here, so a marker would DELETE visible content.
|
|
126
|
+
* Every other built-in block (an image, a video, a component) carries a reference this
|
|
127
|
+
* serialiser cannot resolve, so those stay markers.
|
|
128
|
+
*
|
|
129
|
+
* Migrating it here rather than rendering the matrix directly is what keeps ONE table
|
|
130
|
+
* renderer: a value that came through the reader and one that did not produce the same
|
|
131
|
+
* page, which is the property four surfaces used to get wrong independently.
|
|
96
132
|
*/
|
|
97
|
-
|
|
133
|
+
const legacy = builtInTableToTable(value);
|
|
134
|
+
if (legacy) return tableHtml(legacy);
|
|
98
135
|
const key = typeof v.schemaKey === "string" && TOKEN.test(v.schemaKey.replace(/^builtin:/, ""))
|
|
99
136
|
? v.schemaKey
|
|
100
137
|
: "block";
|
package/src/index.ts
CHANGED
package/src/ingest.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { htmlToPortableText as ptHtmlToPortableText, type DeserializerRule } from "@portabletext/html";
|
|
2
2
|
import { compileSchema } from "@portabletext/schema";
|
|
3
|
-
import {
|
|
3
|
+
import { PT_TYPES, type PortableText, type PortableTextBlock } from "./types";
|
|
4
4
|
import { randomKey, isValidKey, type KeyGenerator } from "./key";
|
|
5
5
|
import { safeHref } from "./href";
|
|
6
6
|
import type { PtSchema } from "./schema";
|
|
@@ -105,82 +105,84 @@ function normalise(value: unknown, key: KeyGenerator): PortableText {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* `<table>` → the `
|
|
108
|
+
* `<table>` → the `table` block object, which is the ONLY table BetterCMS has.
|
|
109
109
|
*
|
|
110
110
|
* Without this rule a table produces no node — the deserialiser has no table in its schema, so
|
|
111
111
|
* the cells come through as loose paragraphs and the grid is gone. Measured before it existed:
|
|
112
112
|
* two published pages (privacy, gdpr) lost a whole table on conversion, and the text check
|
|
113
113
|
* could not see it because the CELLS survived as text.
|
|
114
114
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
115
|
+
* ── A cell is ingested by INGESTING IT ────────────────────────────────────────────────────
|
|
116
|
+
* Each cell's own markup goes back through `htmlToPortableText`, with the same schema, the same
|
|
117
|
+
* key generator and the same rules. That is what makes nested markup inside a cell take the
|
|
118
|
+
* NORMAL span/mark path: a link keeps its markDef, `<strong>` keeps its decorator, two
|
|
119
|
+
* paragraphs stay two blocks, and a table nested in a cell keeps its own grid. The alternative —
|
|
120
|
+
* the deserialiser's `next()` on the cell's child nodes — returns spans whose annotation marks
|
|
121
|
+
* point at markDefs it never hands back, so every link inside a cell would silently become
|
|
122
|
+
* plain text.
|
|
120
123
|
*
|
|
121
|
-
*
|
|
122
|
-
* not marks. Matching it keeps one table in the product instead of two with different powers.
|
|
123
|
-
* `colspan`/`rowspan` collapse to one cell each; short rows are padded by the renderer.
|
|
124
|
+
* The recursion terminates because a cell's markup is strictly smaller than the table's.
|
|
124
125
|
*/
|
|
125
|
-
const BLOCK_TAGS = new Set(["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "ul", "ol", "blockquote", "pre"]);
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* A cell's text, in order, with a separator only at block boundaries.
|
|
129
|
-
*
|
|
130
|
-
* Two ways to get this wrong, both of which LOSE WORDS. `textContent` alone merges across
|
|
131
|
-
* blocks, so `<p>First</p><p>Second</p>` reads "FirstSecond". Taking only the block children
|
|
132
|
-
* drops everything outside them, so `Before<p>Middle</p>After` keeps just "Middle" — a cell
|
|
133
|
-
* that the deserialiser used to carry whole. Walking the child nodes in order is the version
|
|
134
|
-
* with neither hole.
|
|
135
|
-
*/
|
|
136
|
-
const cellText = (cell: Node): string => {
|
|
137
|
-
let out = "";
|
|
138
|
-
for (const child of [...cell.childNodes]) {
|
|
139
|
-
const isBlock = child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName.toLowerCase());
|
|
140
|
-
out += (isBlock ? " " : "") + (child.textContent ?? "") + (isBlock ? " " : "");
|
|
141
|
-
}
|
|
142
|
-
return out.replace(/\s+/g, " ").trim();
|
|
143
|
-
};
|
|
144
126
|
|
|
145
127
|
/**
|
|
146
128
|
* Rows and cells belonging to THIS table.
|
|
147
129
|
*
|
|
148
130
|
* `querySelectorAll` is a DESCENDANT search, so a table nested inside a cell hands its rows to
|
|
149
|
-
* the outer table and its cells to the outer row: the
|
|
150
|
-
*
|
|
131
|
+
* the outer table and its cells to the outer row: the grid gains phantom columns and the nested
|
|
132
|
+
* rows are emitted twice.
|
|
151
133
|
*/
|
|
152
134
|
const rowsOf = (table: Element): Element[] =>
|
|
153
135
|
[...table.querySelectorAll("tr")].filter((r) => r.closest("table") === table);
|
|
154
136
|
|
|
155
|
-
const cellsOf = (row: Element):
|
|
156
|
-
[...row.querySelectorAll("th,td")].filter((c) => c.closest("tr") === row)
|
|
137
|
+
const cellsOf = (row: Element): Element[] =>
|
|
138
|
+
[...row.querySelectorAll("th,td")].filter((c) => c.closest("tr") === row);
|
|
139
|
+
|
|
140
|
+
/** A span attribute, recorded and not honoured — see `PortableTextTableCell`. */
|
|
141
|
+
const spanOf = (cell: Element, name: "colspan" | "rowspan"): number | undefined => {
|
|
142
|
+
const n = Number(cell.getAttribute(name));
|
|
143
|
+
return Number.isInteger(n) && n > 1 ? n : undefined;
|
|
144
|
+
};
|
|
157
145
|
|
|
158
|
-
function tableRule(key: KeyGenerator): DeserializerRule {
|
|
146
|
+
function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText): DeserializerRule {
|
|
159
147
|
return {
|
|
160
148
|
deserialize(el: Node, _next, createBlock) {
|
|
161
149
|
if (!isElement(el) || el.nodeName.toLowerCase() !== "table") return undefined;
|
|
162
|
-
const rows = rowsOf(el)
|
|
150
|
+
const rows = rowsOf(el)
|
|
151
|
+
.map((row) => ({
|
|
152
|
+
_key: key(),
|
|
153
|
+
cells: cellsOf(row).map((cell) => ({
|
|
154
|
+
_key: key(),
|
|
155
|
+
content: ingestCell(cell.innerHTML),
|
|
156
|
+
// A header is what the markup SAYS it is, cell by cell. Deriving it from position
|
|
157
|
+
// would promote the first data row of a headerless table, and would lose the row
|
|
158
|
+
// headers that live in a table's first COLUMN.
|
|
159
|
+
...(cell.nodeName.toLowerCase() === "th" ? { header: true } : {}),
|
|
160
|
+
...(spanOf(cell, "colspan") === undefined ? {} : { span: spanOf(cell, "colspan") }),
|
|
161
|
+
...(spanOf(cell, "rowspan") === undefined ? {} : { rowSpan: spanOf(cell, "rowspan") }),
|
|
162
|
+
})),
|
|
163
|
+
// `<thead>` is recorded rather than inferred: a browser inserts `<tbody>` on its own
|
|
164
|
+
// and never a `<thead>`, so inventing one at render time would make every bare
|
|
165
|
+
// `<tr><th>` table diverge from its own stored markup.
|
|
166
|
+
...(row.parentElement?.nodeName.toLowerCase() === "thead" ? { head: true } : {}),
|
|
167
|
+
}))
|
|
168
|
+
.filter((r) => r.cells.length > 0);
|
|
163
169
|
if (rows.length === 0) return undefined;
|
|
164
|
-
// A header is what the markup SAYS it is. Defaulting to true (as the renderer does when
|
|
165
|
-
// the flag is absent) would promote the first data row of a headerless table.
|
|
166
|
-
const headerCell = el.querySelector("thead th, thead td, tr:first-child th");
|
|
167
|
-
const hasHeader = !!headerCell && headerCell.closest("table") === el;
|
|
168
170
|
// `createBlock` is what marks this BLOCK-level. Returning the object bare makes the
|
|
169
171
|
// deserialiser treat it as inline and wrap it in a paragraph — the table survives, nested
|
|
170
172
|
// one level down where no renderer looks for it.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
});
|
|
173
|
+
// `class="bcms-table"` and nothing else: that class only ever appears in markup the
|
|
174
|
+
// pre-P1c renderer produced, so recognising it is what lets such markup round-trip
|
|
175
|
+
// instead of coming back a class short. Any other class is not representable and the
|
|
176
|
+
// audit reports it, which is the honest answer.
|
|
177
|
+
const legacy = el.getAttribute("class") === "bcms-table";
|
|
178
|
+
return createBlock({ _type: PT_TYPES.table, _key: key(), rows, ...(legacy ? { legacy } : {}) });
|
|
178
179
|
},
|
|
179
180
|
};
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
/** Does this field's schema carry the table block object? Asked by name, not by capability. */
|
|
182
184
|
const allowsTables = (schema: PtSchema | undefined): boolean =>
|
|
183
|
-
!schema || schema.blockObjects.some((o) => o.name ===
|
|
185
|
+
!schema || schema.blockObjects.some((o) => o.name === PT_TYPES.table);
|
|
184
186
|
|
|
185
187
|
const isElement = (n: unknown): n is Element =>
|
|
186
188
|
!!n && typeof n === "object" && (n as Node).nodeType === 1;
|
|
@@ -201,12 +203,12 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
|
|
|
201
203
|
* API / MCP bypass this boundary exists to close (see the header).
|
|
202
204
|
*
|
|
203
205
|
* With no schema there is no field context — the backfill and direct callers — and the
|
|
204
|
-
* full node set is correct. With one, the rule is installed only when that schema
|
|
205
|
-
* the
|
|
206
|
-
*
|
|
207
|
-
*
|
|
206
|
+
* full node set is correct. With one, the rule is installed only when that schema declares
|
|
207
|
+
* the TABLE block object. The GENERIC block object is not that test: `ptSchemaFor` emits it
|
|
208
|
+
* for an image or an embed alike, so gating on it would let a `<table>` into a field that
|
|
209
|
+
* accepts only images.
|
|
208
210
|
*/
|
|
209
|
-
...(allowsTables(opts.schema) ? { rules: [tableRule(key)] } : {}),
|
|
211
|
+
...(allowsTables(opts.schema) ? { rules: [tableRule(key, (cell) => htmlToPortableText(cell, opts))] } : {}),
|
|
210
212
|
keyGenerator: key,
|
|
211
213
|
});
|
|
212
214
|
return normalise(converted, key);
|
package/src/plain.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isPortableTextBlock, isPortableTextSpan, PT_TYPES, type PortableText, type PortableTextTable } from "./types";
|
|
2
|
+
import { migrateTable } from "./table";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Portable Text → plain text.
|
|
@@ -25,21 +26,35 @@ export function portableTextToPlainText(value: PortableText | null | undefined):
|
|
|
25
26
|
* called 104 sound values a text loss. Cells join with a space and rows with a newline, the
|
|
26
27
|
* same shape `portableTextToHtml` gives them.
|
|
27
28
|
*
|
|
29
|
+
* Both spellings arrive here — the `table` and the pre-P1c `bcmsBlock` — because this runs
|
|
30
|
+
* over values that never passed through the reader, the corpus audit's above all.
|
|
31
|
+
* `migrateTable` answers null for everything else, so the cost of asking is one comparison.
|
|
32
|
+
*
|
|
28
33
|
* Every other embedded block — an image, an embed, a component — resolves against a project
|
|
29
34
|
* this function does not have, so it still contributes nothing.
|
|
30
35
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const rows = (node as { fields?: { rows?: unknown } }).fields?.rows;
|
|
35
|
-
if (Array.isArray(rows)) {
|
|
36
|
-
for (const row of rows) {
|
|
37
|
-
if (Array.isArray(row)) lines.push(row.map((c) => (typeof c === "string" ? c : "")).join(" "));
|
|
38
|
-
}
|
|
39
|
-
}
|
|
36
|
+
const table = migrateTable(node);
|
|
37
|
+
if (table) {
|
|
38
|
+
lines.push(...tableLines(table));
|
|
40
39
|
continue;
|
|
41
40
|
}
|
|
42
41
|
// Dividers and other embedded blocks contribute no text, which is what a reader wants.
|
|
43
42
|
}
|
|
44
43
|
return lines.join("\n");
|
|
45
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One line per row, cells joined with a space.
|
|
48
|
+
*
|
|
49
|
+
* A cell's own blocks go back through this function — a cell is a rich-text field, so a link or
|
|
50
|
+
* a second paragraph inside one is text a reader sees — and its newlines flatten to spaces,
|
|
51
|
+
* because a row is a LINE and a cell that broke it would make the audit's before/after
|
|
52
|
+
* comparison depend on where a paragraph happened to fall.
|
|
53
|
+
*/
|
|
54
|
+
const tableLines = (table: PortableTextTable): string[] =>
|
|
55
|
+
(Array.isArray(table.rows) ? table.rows : []).map((row) =>
|
|
56
|
+
(Array.isArray(row?.cells) ? row.cells : [])
|
|
57
|
+
.map((cell) => portableTextToPlainText(cell?.content).replace(/\s+/g, " ").trim())
|
|
58
|
+
.join(" ")
|
|
59
|
+
.trim(),
|
|
60
|
+
);
|
package/src/read.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PORTABLE_TEXT_FORMAT, PT_TYPES, type PortableText } from "./types";
|
|
2
2
|
import { lexicalToPortableText } from "./from-lexical";
|
|
3
|
+
import { migrateTable } from "./table";
|
|
3
4
|
import { randomKey, type KeyGenerator } from "./key";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -48,11 +49,21 @@ export interface ReadOptions {
|
|
|
48
49
|
* renderer emitted. Both are structurally Portable Text and neither carries markDefs, so
|
|
49
50
|
* `@portabletext/react` would treat a block without them as malformed. Filling them in is
|
|
50
51
|
* cheaper than a migration and makes every historical value renderable today.
|
|
52
|
+
*
|
|
53
|
+
* A TABLE is migrated here too, and that is the whole of the P1c read path: every table stored
|
|
54
|
+
* before P1c is a generic `bcmsBlock` holding a string matrix, and every one of them comes back
|
|
55
|
+
* out as a `table`. Nothing rewrites the database — the shape a reader sees is settled on the
|
|
56
|
+
* way out, so a value converted years ago and a value pasted this morning are the same thing to
|
|
57
|
+
* everything downstream.
|
|
51
58
|
*/
|
|
52
59
|
export function normalizePortableText(value: unknown, key: KeyGenerator = randomKey): PortableText {
|
|
53
60
|
if (!Array.isArray(value)) return [];
|
|
54
61
|
return value.flatMap((raw) => {
|
|
55
62
|
if (!isRecord(raw) || typeof raw._type !== "string") return [];
|
|
63
|
+
// Recursing through this same function is what gives a cell everything a block has: keys,
|
|
64
|
+
// markDefs, and a nested table in the rare cell that holds one.
|
|
65
|
+
const table = migrateTable(raw, key, (v, cellKey) => normalizePortableText(v, cellKey));
|
|
66
|
+
if (table) return [table as never];
|
|
56
67
|
const node: Record<string, unknown> = { ...raw, _key: isRecord(raw) && typeof raw._key === "string" && raw._key ? raw._key : key() };
|
|
57
68
|
if (node._type === "block") {
|
|
58
69
|
if (!Array.isArray(node.markDefs)) node.markDefs = [];
|
package/src/schema.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ALL_ELEMENTS, type AcceptElement } from "./accept";
|
|
2
|
-
import { PT_TYPES
|
|
2
|
+
import { PT_TYPES } from "./types";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Accept elements → one Portable Text schema.
|
|
@@ -24,7 +24,17 @@ import { PT_TYPES, TABLE_CAPABILITY } from "./types";
|
|
|
24
24
|
export interface PtStyle { name: string; title?: string }
|
|
25
25
|
export interface PtDecorator { name: string; title?: string }
|
|
26
26
|
export interface PtList { name: string; title?: string }
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* A field on an annotation or block object.
|
|
29
|
+
*
|
|
30
|
+
* `type` is the LITERAL UNION `defineSchema` requires, not `string`. Declared wider, every
|
|
31
|
+
* consumer had to cast at the boundary — the dashboard carried a documented one, and the hand
|
|
32
|
+
* copy this file replaced needed `as const` on every entry to compile at all. A cast at a
|
|
33
|
+
* boundary is where a contract gap gets written down and then forgotten, so the contract is
|
|
34
|
+
* narrowed instead.
|
|
35
|
+
*/
|
|
36
|
+
export type PtFieldType = "string" | "number" | "boolean" | "object";
|
|
37
|
+
export interface PtField { name: string; type: PtFieldType }
|
|
28
38
|
export interface PtAnnotation { name: string; title?: string; fields?: PtField[] }
|
|
29
39
|
export interface PtObject { name: string; title?: string; fields?: PtField[] }
|
|
30
40
|
|
|
@@ -102,10 +112,10 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
102
112
|
],
|
|
103
113
|
});
|
|
104
114
|
}
|
|
105
|
-
// Image, embed
|
|
106
|
-
// `builtin:` schemaKeys rather than
|
|
115
|
+
// Image, embed and columns all store as ONE generic block object under their existing
|
|
116
|
+
// `builtin:` schemaKeys rather than three bespoke types. That keeps a single converter lane
|
|
107
117
|
// and leaves `BuiltInBlockCard` (799 lines of working editor UI) untouched.
|
|
108
|
-
if (has("image") || has("embed") || has("
|
|
118
|
+
if (has("image") || has("embed") || has("columns")) {
|
|
109
119
|
blockObjects.push({
|
|
110
120
|
name: PT_TYPES.block,
|
|
111
121
|
title: "Embedded block",
|
|
@@ -117,16 +127,22 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
117
127
|
});
|
|
118
128
|
}
|
|
119
129
|
/**
|
|
120
|
-
*
|
|
130
|
+
* The table, and it is a REAL block object now rather than the capability marker it used to be.
|
|
121
131
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
132
|
+
* The marker existed because a table stored on the generic block, so this schema had nothing
|
|
133
|
+
* table-shaped for the HTML ingest to gate on — and gating on the generic entry answers yes
|
|
134
|
+
* for a field that accepts only IMAGES, dropping a `<table>` into a field that forbids one.
|
|
135
|
+
* Asking for `table` by name answers the same question honestly, and the editor gets an
|
|
136
|
+
* insertable Table that inserts something, instead of a marker it had to filter out.
|
|
137
|
+
*
|
|
138
|
+
* ONE field, and it is the whole payload: `rows: [{_key, cells: [{_key, content, header?,
|
|
139
|
+
* span?}]}]`, spelled in `types.ts`. `defineSchema` describes a block object so the editor can
|
|
140
|
+
* hold and move it; it does not validate the payload, so a deeper declaration would buy
|
|
141
|
+
* nothing and drift from the types that do.
|
|
128
142
|
*/
|
|
129
|
-
if (has("table"))
|
|
143
|
+
if (has("table")) {
|
|
144
|
+
blockObjects.push({ name: PT_TYPES.table, title: "Table", fields: [{ name: "rows", type: "object" }] });
|
|
145
|
+
}
|
|
130
146
|
|
|
131
147
|
return {
|
|
132
148
|
styles,
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUILTIN_TABLE_KEY,
|
|
3
|
+
PT_TYPES,
|
|
4
|
+
type PortableText,
|
|
5
|
+
type PortableTextTable,
|
|
6
|
+
type PortableTextTableCell,
|
|
7
|
+
type PortableTextTableRow,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { deterministicKeys, isValidKey, randomKey, type KeyGenerator } from "./key";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The one table, and every older spelling of it.
|
|
13
|
+
*
|
|
14
|
+
* THREE SHAPES REACH THIS FILE, and only one of them is written any more:
|
|
15
|
+
*
|
|
16
|
+
* `{_type:"table", rows:[{cells:[{content}]}]}` ours, since P1c
|
|
17
|
+
* `bcmsBlock` + `schemaKey:"builtin:table"` every table stored BEFORE P1c: a string
|
|
18
|
+
* matrix under `fields.rows`, plus `hasHeader`
|
|
19
|
+
* `{_type:"table", rows:[{cells:["a","b"]}]}` content from another system (Sanity's is the
|
|
20
|
+
* one found in this corpus): string cells
|
|
21
|
+
*
|
|
22
|
+
* They converge HERE rather than at each of the four readers, because the alternative is what
|
|
23
|
+
* this package was created to end — four places deciding independently what a stored value means,
|
|
24
|
+
* and a table rendering on one surface and vanishing on another. Nothing rewrites the database:
|
|
25
|
+
* a legacy row is migrated on the way OUT, every time it is read.
|
|
26
|
+
*
|
|
27
|
+
* A string cell becomes one paragraph holding one span. That is lossless in the direction that
|
|
28
|
+
* matters — the matrix never held marks to lose — and it means the rest of the pipeline has
|
|
29
|
+
* exactly one cell shape to render, not two.
|
|
30
|
+
*
|
|
31
|
+
* ── 🔴 KEYS ARE DERIVED, NEVER MINTED AT RANDOM ──────────────────────────────────────────
|
|
32
|
+
* This runs on EVERY READ of a value nothing rewrites, so a random `_key` here is a different
|
|
33
|
+
* key each time the same row is read. Three things break at once: React remounts every cell on
|
|
34
|
+
* every render because its `key` changed; a Visual Editor address minted against one read points
|
|
35
|
+
* at nothing on the next; and the migration backfill converting `data` and `published_data`
|
|
36
|
+
* produces two values that compare unequal, lighting up the change feed for every entry in the
|
|
37
|
+
* project — the exact trap `deterministicKeys` was added for.
|
|
38
|
+
*
|
|
39
|
+
* So a migrated table's keys come from the table's OWN `_key` plus the row and cell INDEX, and
|
|
40
|
+
* any key the value already carries is kept and reserved so a derived one cannot collide with it.
|
|
41
|
+
* Same value in, same keys out, on every read, in every process.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
45
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
46
|
+
|
|
47
|
+
export const isPortableTextTable = (n: unknown): n is PortableTextTable =>
|
|
48
|
+
isRecord(n) && n._type === PT_TYPES.table && Array.isArray(n.rows);
|
|
49
|
+
|
|
50
|
+
/** A table as it was stored before it had a type: the generic block object under its schemaKey. */
|
|
51
|
+
export const isBuiltInTableBlock = (n: unknown): boolean =>
|
|
52
|
+
isRecord(n) && n._type === PT_TYPES.block && n.schemaKey === BUILTIN_TABLE_KEY;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How a cell's `content` is read back.
|
|
56
|
+
*
|
|
57
|
+
* Injected rather than imported so this file stays free of `read.ts` — the reader already
|
|
58
|
+
* depends on the table, and a cycle between the two is the kind of thing that resolves at type
|
|
59
|
+
* level and explodes at runtime. The reader passes `normalizePortableText`; a caller with no
|
|
60
|
+
* reader (the renderer, the plain-text pass) gets the identity, which is all a legacy string
|
|
61
|
+
* matrix ever needs.
|
|
62
|
+
*
|
|
63
|
+
* It takes the cell's OWN key generator, so a block inside a cell that arrives without a `_key`
|
|
64
|
+
* is keyed from the cell rather than from the document's random minter.
|
|
65
|
+
*/
|
|
66
|
+
export type CellContentReader = (value: unknown, key: KeyGenerator) => PortableText;
|
|
67
|
+
|
|
68
|
+
const asIs: CellContentReader = (v) => (Array.isArray(v) ? (v as PortableText) : []);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `<table key>-r0c1` — deterministic, unique among siblings, and inside `KEY_PATTERN`.
|
|
72
|
+
*
|
|
73
|
+
* The base is TRUNCATED because a `_key` is capped at 32 characters and an address is parsed by
|
|
74
|
+
* splitting on the separators; 16 leaves room for `-r99c99-9`, which is longer than any table a
|
|
75
|
+
* person writes. A collision with a key the value already carries is resolved by suffixing,
|
|
76
|
+
* never by falling back to randomness.
|
|
77
|
+
*/
|
|
78
|
+
function keyMinter(base: string, reserved: Set<string>) {
|
|
79
|
+
const prefix = (base.match(/[A-Za-z0-9-]/g) ?? []).join("").slice(0, 16) || "t";
|
|
80
|
+
return (...parts: (string | number)[]): string => {
|
|
81
|
+
const candidate = `${prefix}-${parts.join("")}`.slice(0, 30);
|
|
82
|
+
let key = candidate;
|
|
83
|
+
for (let n = 1; reserved.has(key); n += 1) key = `${candidate}-${n}`;
|
|
84
|
+
reserved.add(key);
|
|
85
|
+
return key;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Every `_key` the stored value already carries, so a derived key cannot land on one. */
|
|
90
|
+
function reservedKeys(rows: unknown[]): Set<string> {
|
|
91
|
+
const out = new Set<string>();
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
if (!isRecord(row)) continue;
|
|
94
|
+
if (isValidKey(row._key)) out.add(row._key);
|
|
95
|
+
for (const cell of Array.isArray(row.cells) ? row.cells : []) {
|
|
96
|
+
if (isRecord(cell) && isValidKey(cell._key)) out.add(cell._key);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One line of text as the single block a cell holds. Empty text holds NO block, not an empty one. */
|
|
103
|
+
const textContent = (text: string, key: KeyGenerator): PortableText =>
|
|
104
|
+
text === ""
|
|
105
|
+
? []
|
|
106
|
+
: [{
|
|
107
|
+
_type: "block",
|
|
108
|
+
_key: key(),
|
|
109
|
+
style: "normal",
|
|
110
|
+
markDefs: [],
|
|
111
|
+
children: [{ _type: "span", _key: key(), text, marks: [] }],
|
|
112
|
+
}];
|
|
113
|
+
|
|
114
|
+
/** A positive integer span, or nothing. A `colspan="0"` or `"two"` is not a span. */
|
|
115
|
+
const spanOf = (value: unknown): number | undefined => {
|
|
116
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
117
|
+
return Number.isInteger(n) && n > 1 ? n : undefined;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
type Mint = (...parts: (string | number)[]) => string;
|
|
121
|
+
|
|
122
|
+
function normalizeCell(raw: unknown, at: [number, number], mint: Mint, read: CellContentReader): PortableTextTableCell {
|
|
123
|
+
const [row, col] = at;
|
|
124
|
+
const key = isRecord(raw) && isValidKey(raw._key) ? raw._key : mint("r", row, "c", col);
|
|
125
|
+
// The cell's own generator: a block inside it that has no key is keyed from the CELL, so the
|
|
126
|
+
// whole subtree is reproducible from the stored value alone.
|
|
127
|
+
const inner = deterministicKeys(`${key}-`);
|
|
128
|
+
|
|
129
|
+
if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
|
|
130
|
+
return { _key: key, content: textContent(String(raw), inner) };
|
|
131
|
+
}
|
|
132
|
+
if (!isRecord(raw)) return { _key: key, content: [] };
|
|
133
|
+
|
|
134
|
+
const span = spanOf(raw.span ?? raw.colSpan ?? raw.colspan);
|
|
135
|
+
const rowSpan = spanOf(raw.rowSpan ?? raw.rowspan);
|
|
136
|
+
return {
|
|
137
|
+
_key: key,
|
|
138
|
+
// `text` is the spelling a string matrix uses once its cells have been given objects; it is
|
|
139
|
+
// read here so a half-migrated value cannot lose its words.
|
|
140
|
+
content: Array.isArray(raw.content)
|
|
141
|
+
? read(raw.content, inner)
|
|
142
|
+
: textContent(typeof raw.text === "string" ? raw.text : "", inner),
|
|
143
|
+
...(raw.header === true ? { header: true } : {}),
|
|
144
|
+
...(span === undefined ? {} : { span }),
|
|
145
|
+
...(rowSpan === undefined ? {} : { rowSpan }),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeRow(raw: unknown, at: number, mint: Mint, read: CellContentReader): PortableTextTableRow {
|
|
150
|
+
// A bare array IS a row: that is how the legacy matrix spells one, and how a hand-written
|
|
151
|
+
// value most often arrives.
|
|
152
|
+
const cells = Array.isArray(raw) ? raw : isRecord(raw) && Array.isArray(raw.cells) ? raw.cells : [];
|
|
153
|
+
return {
|
|
154
|
+
_key: isRecord(raw) && isValidKey(raw._key) ? raw._key : mint("r", at),
|
|
155
|
+
cells: cells.map((c, col) => normalizeCell(c, [at, col], mint, read)),
|
|
156
|
+
...(isRecord(raw) && raw.head === true ? { head: true } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A pre-P1c `builtin:table` block → the table.
|
|
162
|
+
*
|
|
163
|
+
* `hasHeader` defaults TRUE when absent, exactly as the renderer it replaces did, so a value
|
|
164
|
+
* written before that flag existed keeps the header row it has always displayed. It is a WHOLE
|
|
165
|
+
* ROW of headers, which is the only header the string matrix could express.
|
|
166
|
+
*/
|
|
167
|
+
export function builtInTableToTable(node: unknown, key: KeyGenerator = randomKey): PortableTextTable | null {
|
|
168
|
+
if (!isBuiltInTableBlock(node)) return null;
|
|
169
|
+
const fields = (node as { fields?: unknown }).fields;
|
|
170
|
+
const raw = isRecord(fields) && Array.isArray(fields.rows) ? fields.rows : [];
|
|
171
|
+
const hasHeader = isRecord(fields) ? fields.hasHeader !== false : true;
|
|
172
|
+
const own = (node as { _key?: unknown })._key;
|
|
173
|
+
const tableKey = isValidKey(own) ? own : key();
|
|
174
|
+
const mint = keyMinter(tableKey, reservedKeys(raw));
|
|
175
|
+
|
|
176
|
+
const rows = raw.map((row, i) => {
|
|
177
|
+
const normalized = normalizeRow(row, i, mint, asIs);
|
|
178
|
+
if (!(hasHeader && i === 0)) return normalized;
|
|
179
|
+
return {
|
|
180
|
+
...normalized,
|
|
181
|
+
head: true,
|
|
182
|
+
cells: normalized.cells.map((c) => ({ ...c, header: true })),
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// 🔴 SQUARE THE GRID. The renderer this replaces padded every row to the widest one, so a
|
|
187
|
+
// ragged matrix has always DISPLAYED as a full rectangle. Migrating without the padding drops
|
|
188
|
+
// those cells from the page — a value that rendered five columns rendering four.
|
|
189
|
+
const width = rows.reduce((max, r) => Math.max(max, r.cells.length), 0);
|
|
190
|
+
for (const [i, row] of rows.entries()) {
|
|
191
|
+
while (row.cells.length < width) {
|
|
192
|
+
row.cells.push({
|
|
193
|
+
_key: mint("r", i, "c", row.cells.length),
|
|
194
|
+
content: [],
|
|
195
|
+
...(row.head === true ? { header: true } : {}),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { _type: PT_TYPES.table, _key: tableKey, rows, legacy: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Any stored table shape → the one table, or `null` when the node is not a table at all.
|
|
204
|
+
*
|
|
205
|
+
* Returning `null` rather than throwing or coercing is what lets the reader call this on EVERY
|
|
206
|
+
* node cheaply: a non-table answers no, and the caller carries on.
|
|
207
|
+
*
|
|
208
|
+
* PERMISSIVE ON READ, by design: a shape it does not recognise reads as far as it can rather
|
|
209
|
+
* than throwing, because a reader that throws is a field that opens blank. The strict test lives
|
|
210
|
+
* on the WRITE, where the author is present to be told — see `isStorableTable` in `types.ts`.
|
|
211
|
+
*/
|
|
212
|
+
export function migrateTable(
|
|
213
|
+
node: unknown,
|
|
214
|
+
key: KeyGenerator = randomKey,
|
|
215
|
+
read: CellContentReader = asIs,
|
|
216
|
+
): PortableTextTable | null {
|
|
217
|
+
const builtIn = builtInTableToTable(node, key);
|
|
218
|
+
if (builtIn) return builtIn;
|
|
219
|
+
if (!isPortableTextTable(node)) return null;
|
|
220
|
+
const rows = node.rows as unknown[];
|
|
221
|
+
const tableKey = isValidKey(node._key) ? node._key : key();
|
|
222
|
+
const mint = keyMinter(tableKey, reservedKeys(rows));
|
|
223
|
+
return {
|
|
224
|
+
_type: PT_TYPES.table,
|
|
225
|
+
_key: tableKey,
|
|
226
|
+
rows: rows.map((r, i) => normalizeRow(r, i, mint, read)),
|
|
227
|
+
// Carried through: a migrated table that an author saves back is still the same table, and
|
|
228
|
+
// dropping the flag on that save would change the page the next time it renders.
|
|
229
|
+
...(node.legacy === true ? { legacy: true } : {}),
|
|
230
|
+
};
|
|
231
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -67,6 +67,70 @@ export interface PortableTextObject extends PortableTextNode {
|
|
|
67
67
|
/** The value of a rich-text field: a flat array of blocks and block objects. */
|
|
68
68
|
export type PortableText = (PortableTextBlock | PortableTextObject)[];
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* ── THE TABLE ────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
*
|
|
73
|
+
* A cell holds BLOCKS, not a string. The predecessor stored a string matrix, and the price was
|
|
74
|
+
* paid on every edge of it: a link, a bolded word or a second paragraph inside a cell was
|
|
75
|
+
* flattened to text on the way in and could never be authored on the way out. A cell is a small
|
|
76
|
+
* rich-text field, so it is spelled as one, and every mark, annotation and list the rest of the
|
|
77
|
+
* document can express works inside it for free.
|
|
78
|
+
*
|
|
79
|
+
* `header` is per CELL because a row-header table is a COLUMN of `<th>`; `head` is per ROW
|
|
80
|
+
* because `<thead>` is a section of the table and a browser does not invent one. Both exist so
|
|
81
|
+
* the four spellings of a header (`thead`+`th`, `thead`+`td`, bare `th`, none) survive a round
|
|
82
|
+
* trip instead of being normalised into whichever one we happened to prefer — the html cache is
|
|
83
|
+
* compared against the stored markup, so an invented `<thead>` reads as content drift.
|
|
84
|
+
*/
|
|
85
|
+
export interface PortableTextTableCell {
|
|
86
|
+
_key: string;
|
|
87
|
+
/** The cell's own blocks. Usually one paragraph; a cell may hold several, or none. */
|
|
88
|
+
content: PortableText;
|
|
89
|
+
/** `<th>` rather than `<td>`. */
|
|
90
|
+
header?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* The COLUMN span (`colspan`), RECORDED but not honoured: the grid keeps exactly one cell per
|
|
93
|
+
* `<td>`, and this is what the renderer writes back out. Honouring it would mean a ragged
|
|
94
|
+
* model every consumer has to re-square, for a layout the editor cannot author anyway.
|
|
95
|
+
*/
|
|
96
|
+
span?: number;
|
|
97
|
+
/**
|
|
98
|
+
* The ROW span (`rowspan`), recorded on the same terms.
|
|
99
|
+
*
|
|
100
|
+
* It is here because DROPPING it is lossy in a way the eye catches immediately: a spanning
|
|
101
|
+
* cell that comes back spanning one row leaves a hole in the grid, and the round-trip audit
|
|
102
|
+
* would refuse the whole table over an attribute we simply forgot to carry.
|
|
103
|
+
*/
|
|
104
|
+
rowSpan?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface PortableTextTableRow {
|
|
108
|
+
_key: string;
|
|
109
|
+
cells: PortableTextTableCell[];
|
|
110
|
+
/** The row sat in `<thead>`. Absent means `<tbody>`, which is what a parser assumes. */
|
|
111
|
+
head?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A table. The ONE table BetterCMS has — see `PT_TYPES.table`. */
|
|
115
|
+
export interface PortableTextTable extends PortableTextObject {
|
|
116
|
+
_type: "table";
|
|
117
|
+
rows: PortableTextTableRow[];
|
|
118
|
+
/**
|
|
119
|
+
* Render with the platform's own `class="bcms-table"`, and with every row squared off.
|
|
120
|
+
*
|
|
121
|
+
* 🔴 THIS IS A COMPATIBILITY FLAG, and it is the whole of what a pre-P1c table's rendition
|
|
122
|
+
* needs. The renderer this block object replaces emitted that class on every table and padded
|
|
123
|
+
* short rows to the widest one, so a value migrated from `builtin:table` MUST keep both or it
|
|
124
|
+
* renders differently than the `html` sitting beside it in the same row — which is drift, and
|
|
125
|
+
* the parity gate is right to refuse it.
|
|
126
|
+
*
|
|
127
|
+
* Set on every migrated value, and recognised on ingest (`class="bcms-table"` and nothing
|
|
128
|
+
* else), because that class only ever appears in markup that renderer produced. A table
|
|
129
|
+
* authored or pasted since P1c carries neither the flag nor the class.
|
|
130
|
+
*/
|
|
131
|
+
legacy?: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
70
134
|
export const isPortableTextBlock = (n: unknown): n is PortableTextBlock =>
|
|
71
135
|
!!n && typeof n === "object" && (n as PortableTextNode)._type === "block";
|
|
72
136
|
|
|
@@ -104,24 +168,129 @@ export const PT_TYPES = {
|
|
|
104
168
|
mention: "bcmsMention",
|
|
105
169
|
divider: "bcmsDivider",
|
|
106
170
|
code: "bcmsCode",
|
|
171
|
+
/**
|
|
172
|
+
* A table. Spelled `table`, unprefixed, because that is what every other Portable Text
|
|
173
|
+
* producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
|
|
174
|
+
* as foreign content now lands on OUR type and renders, rather than being data nothing draws.
|
|
175
|
+
*/
|
|
176
|
+
table: "table",
|
|
107
177
|
/** The annotation carrying a hyperlink's `href`. Spelled `link` because that is the spec's convention. */
|
|
108
178
|
link: "link",
|
|
109
179
|
} as const;
|
|
110
180
|
|
|
111
181
|
/**
|
|
112
|
-
* The `schemaKey`
|
|
182
|
+
* The `schemaKey` a table stored under BEFORE it had a type of its own.
|
|
183
|
+
*
|
|
184
|
+
* Every table written up to P1c is a generic `bcmsBlock` carrying a string matrix under this
|
|
185
|
+
* key. Those rows are still in the database and nothing rewrites them, so this stays READABLE
|
|
186
|
+
* forever: `migrateTable` (table.ts) turns one into a `table` on the way out, which is why the
|
|
187
|
+
* reader, the renderer and the plain-text pass all still recognise it.
|
|
113
188
|
*
|
|
114
|
-
*
|
|
115
|
-
* `TableNode` through `from-lexical.ts` — and `plain.ts` reads it back out, so it is spelled
|
|
116
|
-
* once for the same reason `PT_TYPES` is: three copies of a string that renders as nothing
|
|
117
|
-
* when it is wrong.
|
|
189
|
+
* Nothing WRITES it any more.
|
|
118
190
|
*/
|
|
119
191
|
export const BUILTIN_TABLE_KEY = "builtin:table" as const;
|
|
120
192
|
|
|
121
193
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
194
|
+
* Every `_type` that may appear at the TOP LEVEL of a stored value.
|
|
195
|
+
*
|
|
196
|
+
* Closed on purpose, and short: a text block, the table, the two standalone block objects, and
|
|
197
|
+
* the generic `bcmsBlock` envelope that carries an image, an embed or columns behind its
|
|
198
|
+
* `schemaKey` (see `ptSchemaFor`). Anything else is content from another system and has no
|
|
199
|
+
* renderer here.
|
|
200
|
+
*
|
|
201
|
+
* `table` joined the set in P1c, and it is the one member tested by SHAPE as well as by name —
|
|
202
|
+
* see `isStorableTable`. The Sanity-shaped `{_type:"table", rows:[{cells}]}` that prompted this
|
|
203
|
+
* list is the reason: refusing it was right while nothing could draw a table, and became wrong
|
|
204
|
+
* the moment something could. `migrateTable` adopts that shape on read, so the write it used to
|
|
205
|
+
* refuse now renders every cell.
|
|
206
|
+
*
|
|
207
|
+
* `bcmsMention` is included although it is an INLINE object: a mention that has somehow been
|
|
208
|
+
* hoisted to the top level is ours, harmless, and refusing an author's save over it would be
|
|
209
|
+
* the cure being worse than the disease.
|
|
210
|
+
*/
|
|
211
|
+
export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
|
|
212
|
+
"block",
|
|
213
|
+
PT_TYPES.block,
|
|
214
|
+
PT_TYPES.table,
|
|
215
|
+
PT_TYPES.code,
|
|
216
|
+
PT_TYPES.divider,
|
|
217
|
+
PT_TYPES.mention,
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Top-level nodes whose `_type` this platform cannot store or render, in document order.
|
|
222
|
+
*
|
|
223
|
+
* WHY THIS IS A WRITE-TIME CHECK AND NOT A RENDER-TIME SHRUG. An unrecognised block survives
|
|
224
|
+
* the JSON round trip perfectly — it is data, and every reader here hands it back untouched —
|
|
225
|
+
* but `portableTextToHtml` has no component for it, so it renders as NOTHING. The `html` on
|
|
226
|
+
* the envelope is a cache that every delivery surface reads, so the node silently disappears
|
|
227
|
+
* from the live site while the stored value still looks complete. That is the "renders as
|
|
228
|
+
* nothing at 200 OK" failure this codebase keeps paying for, and the only place to catch it
|
|
229
|
+
* cheaply is the write, where the author is still present to be told.
|
|
230
|
+
*
|
|
231
|
+
* Returns the offenders rather than a boolean so the refusal can NAME them — `_type` and
|
|
232
|
+
* `_key` are what let an author find the block in a body of eighty.
|
|
233
|
+
*/
|
|
234
|
+
export function foreignBlocks(value: unknown): { _type: string; _key: string }[] {
|
|
235
|
+
if (!Array.isArray(value)) return [];
|
|
236
|
+
const out: { _type: string; _key: string }[] = [];
|
|
237
|
+
for (const node of value) {
|
|
238
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) continue;
|
|
239
|
+
const type = (node as PortableTextNode)._type;
|
|
240
|
+
if (typeof type !== "string") continue;
|
|
241
|
+
// A table is admitted by SHAPE, never by its `_type` alone — see `isStorableTable`.
|
|
242
|
+
const storable = type === PT_TYPES.table ? isStorableTable(node) : STORABLE_BLOCK_TYPES.has(type);
|
|
243
|
+
if (storable) continue;
|
|
244
|
+
const key = (node as PortableTextNode)._key;
|
|
245
|
+
out.push({ _type: type, _key: typeof key === "string" ? key : "" });
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Is this `_type:"table"` a table we can actually RENDER?
|
|
252
|
+
*
|
|
253
|
+
* 🔴 THE TYPE NAME IS NOT A CONTRACT. `table` is what every Portable Text producer calls a
|
|
254
|
+
* table, and they do not agree on what is inside one. Admitting the name alone would let any
|
|
255
|
+
* foreign `rows` through the write guard, and the reader — permissive by design, because a
|
|
256
|
+
* reader that throws is a field that opens blank — would then coerce whatever it did not
|
|
257
|
+
* recognise into EMPTY cells. That is the silent-emptying failure the guard exists to stop,
|
|
258
|
+
* wearing our own type name.
|
|
259
|
+
*
|
|
260
|
+
* So exactly two shapes pass, tested cell by cell:
|
|
261
|
+
* · ours — `cells: [{ content: PortableText }]`
|
|
262
|
+
* · Sanity's — `cells: ["a", "b"]`, a row of strings
|
|
263
|
+
*
|
|
264
|
+
* Everything else is refused at the write, where the author is present to be told, and stays
|
|
265
|
+
* readable through `migrateTable` for anything already stored.
|
|
266
|
+
*/
|
|
267
|
+
export function isStorableTable(node: unknown): boolean {
|
|
268
|
+
if (!node || typeof node !== "object") return false;
|
|
269
|
+
const rows = (node as { rows?: unknown }).rows;
|
|
270
|
+
if (!Array.isArray(rows)) return false;
|
|
271
|
+
return rows.every((row) => {
|
|
272
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return false;
|
|
273
|
+
const cells = (row as { cells?: unknown }).cells;
|
|
274
|
+
if (!Array.isArray(cells)) return false;
|
|
275
|
+
return cells.every((cell) => {
|
|
276
|
+
if (typeof cell === "string") return true;
|
|
277
|
+
if (!cell || typeof cell !== "object" || Array.isArray(cell)) return false;
|
|
278
|
+
const content = (cell as { content?: unknown }).content;
|
|
279
|
+
return Array.isArray(content)
|
|
280
|
+
&& content.every((n) => !!n && typeof n === "object" && typeof (n as PortableTextNode)._type === "string");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* @deprecated The marker a table-accepting field used to carry INSTEAD of a table.
|
|
287
|
+
*
|
|
288
|
+
* It existed because a table stored as a generic block object, so `ptSchemaFor` had nothing
|
|
289
|
+
* table-shaped to gate the HTML ingest on. `PT_TYPES.table` is that thing now, and it is a real
|
|
290
|
+
* block object rather than a marker, so the ingest asks for it by name and nothing emits this.
|
|
291
|
+
*
|
|
292
|
+
* Kept exported for one release: the dashboard filters it out of the schema it hands
|
|
293
|
+
* `defineSchema` (`src/lib/richtext/ptSchema.ts`), and removing the export would fail that
|
|
294
|
+
* import rather than make the filter a harmless no-op. Delete it once that filter is gone.
|
|
126
295
|
*/
|
|
127
296
|
export const TABLE_CAPABILITY = "bcmsTableCapability" as const;
|