@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 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.1.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.",
@@ -1,4 +1,4 @@
1
- import { BUILTIN_TABLE_KEY, PT_TYPES, type PortableText, type PortableTextBlock, type PortableTextMarkDef, type PortableTextSpan } from "./types";
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
- /** Cells hold TEXT: `builtin:table` stores a string matrix, and matching it keeps one table. */
194
- function tableBlock(node: LexNode, ctx: Ctx): PortableTextBlock {
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
- // A cell's own children are BLOCKS (Lexical wraps cell content in paragraphs), and `textOf`
197
- // concatenates. Joining the blocks with a space keeps "First" and "Second" two words rather
198
- // than the portmanteau a straight concatenation makes of them.
199
- const rows = rowNodes.map((r) =>
200
- ((r as LexNode).children ?? []).filter(isRecord).map((c) =>
201
- ((c as LexNode).children ?? [])
202
- .map((b) => textOf(b))
203
- .join(" ")
204
- .replace(/\s+/g, " ")
205
- .trim(),
206
- ),
207
- );
208
- const firstCells = ((rowNodes[0] as LexNode | undefined)?.children ?? []).filter(isRecord);
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
- * versioned `builtin:table` an author inserts. It quarantined here, which was correct while
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 `builtin:table` block the authored one uses, so the product has one
249
- * table rather than two with different powers, and `BuiltInBlockCard` can edit a pasted
250
- * table the day it converts. `headerState` is Lexical's own flag; `> 0` means the cell is a
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 { BUILTIN_TABLE_KEY, PT_TYPES, type PortableText } from "./types";
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
- /** Rows are read defensively: `fields` is `unknown` by type, and one bad row must not throw. */
31
- const tableRows = (value: Record<string, unknown>): string[][] => {
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
- function tableHtml(value: Record<string, unknown>): string {
40
- const rows = tableRows(value);
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
- const width = rows.reduce((max, r) => Math.max(max, r.length), 0);
43
- const cells = (row: string[], tag: "th" | "td") =>
44
- Array.from({ length: width }, (_, i) => `<${tag}>${esc(row[i] ?? "")}</${tag}>`).join("");
45
- // `hasHeader` defaults TRUE when absent, matching the built-in renderer, so a value written
46
- // before the flag existed keeps the header row it has always displayed.
47
- const hasHeader = (value.fields as { hasHeader?: unknown } | undefined)?.hasHeader !== false;
48
- const head = hasHeader ? `<thead><tr>${cells(rows[0]!, "th")}</tr></thead>` : "";
49
- const body = rows.slice(hasHeader ? 1 : 0).map((r) => `<tr>${cells(r, "td")}</tr>`).join("");
50
- return `<table class="bcms-table">${head}${body ? `<tbody>${body}</tbody>` : ""}</table>`;
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 nothing.
91
- * Its `rows` are text, held right here, so a marker would DELETE visible content — and
92
- * that is a regression rather than a status quo, because before tables were ingested at
93
- * all their cells at least survived as paragraphs. Every other built-in block (an image,
94
- * a video, a component) carries a reference this serialiser cannot resolve, so those stay
95
- * markers.
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
- if (v.schemaKey === BUILTIN_TABLE_KEY) return tableHtml(value as Record<string, unknown>);
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
@@ -4,6 +4,7 @@ export * from "./href";
4
4
  export * from "./key";
5
5
  export * from "./from-lexical";
6
6
  export * from "./plain";
7
+ export * from "./table";
7
8
  export * from "./read";
8
9
  export * from "./accept";
9
10
  export * from "./features";
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 { BUILTIN_TABLE_KEY, PT_TYPES, TABLE_CAPABILITY, type PortableText, type PortableTextBlock } from "./types";
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 `builtin:table` block, which is the ONLY table BetterCMS has.
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
- * It lands on the generic block object rather than a bespoke `table` type on purpose, and the
116
- * reason is written down in `schema.ts`: image, embed, table and columns already store as one
117
- * `builtin:` block, so this reuses a lane four renderers and `BuiltInBlockCard` already speak.
118
- * A `table` type would have meant a new schema entry, a new html component and four new
119
- * renderer branches to carry content that has a home today.
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
- * `rows` is a string matrix because that IS the built-in table's model — a cell holds text,
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 matrix gains phantom columns and the
150
- * nested rows are emitted twice.
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): string[] =>
156
- [...row.querySelectorAll("th,td")].filter((c) => c.closest("tr") === row).map(cellText);
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).map(cellsOf).filter((r) => r.length > 0);
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
- return createBlock({
172
- _type: PT_TYPES.block,
173
- _key: key(),
174
- schemaKey: BUILTIN_TABLE_KEY,
175
- schemaLabel: "Table",
176
- fields: { schemaVersion: 1, rows, hasHeader },
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 === TABLE_CAPABILITY);
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 carries
205
- * the table capability marker. The GENERIC block object is not that test: `ptSchemaFor`
206
- * emits it for image, embed, table or columns alike, so gating on it would let a
207
- * `<table>` into a field that accepts only images.
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 { BUILTIN_TABLE_KEY, isPortableTextBlock, isPortableTextSpan, PT_TYPES, type PortableText } from "./types";
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
- if (node._type === PT_TYPES.block && (node as Record<string, unknown>).schemaKey === BUILTIN_TABLE_KEY) {
32
- // `fields` is `unknown` by type and normalisation does not police it, so a table whose
33
- // `rows` is an object would throw here and abort the WHOLE corpus audit on one bad row.
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, TABLE_CAPABILITY } from "./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
- export interface PtField { name: string; type: string }
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, table and columns all store as ONE generic block object under their existing
106
- // `builtin:` schemaKeys rather than four bespoke types. That keeps a single converter lane
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("table") || has("columns")) {
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
- * A SECOND, table-specific entry — the one exception to "one generic block object".
130
+ * The table, and it is a REAL block object now rather than the capability marker it used to be.
121
131
  *
122
- * It exists because the generic entry cannot answer the question the HTML ingest has to ask.
123
- * A custom deserializer rule runs BEFORE the compiled schema is consulted, so the rule has to
124
- * decide for itself whether this field permits a table; asking "is the generic block object
125
- * present?" answers yes for a field that accepts only IMAGES, and a `<table>` then lands in a
126
- * field that forbids one. Nothing stores under this name — it is a capability marker read by
127
- * `allowsTables`, and the value still stores as `builtin:table` on the generic block.
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")) blockObjects.push({ name: TABLE_CAPABILITY, title: "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` of the one table BetterCMS has.
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
- * Both table lanes converge here — pasted HTML through `ingest.ts` and a native Lexical
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
- * A schema-only marker meaning "this field accepts tables". Never stored: a table's VALUE is a
123
- * generic block object keyed `builtin:table`. It exists because the HTML ingest's table rule
124
- * runs before the compiled schema is consulted and has to gate itself, and the generic block
125
- * object cannot tell a table-accepting field from an image-accepting one.
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;