@bettercms-ai/richtext 0.1.0 → 0.3.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.3.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/accept.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { RICH_TEXT_CAPABILITY_MANIFEST, type RichTextFeature } from './features'
2
2
  import type { AllowEntry } from './features'
3
+ import { STYLED_TAGS } from './styled'
3
4
 
4
5
  /**
5
6
  * The Accept model — which HTML elements a rich-text field is allowed to contain.
@@ -123,7 +124,11 @@ export const DEFAULT_RICHTEXT_CONFIG: RichTextConfig = {
123
124
 
124
125
  /** The tag allowlist the sanitiser enforces for a given Accept set. */
125
126
  export function allowedTags(accept: AcceptElement[]): string[] {
126
- const tags = new Set<string>(['span']) // spans carry marks; never the reason content is stripped
127
+ // `span` carries marks and is never the reason content is stripped; the rest of `STYLED_TAGS`
128
+ // is the site's OWN inline markup, admitted on the same terms and for the same reason the
129
+ // `styled` annotation is granted to every field — see `styled.ts`. Gating them on an Accept
130
+ // element would let the sanitiser undo the round trip the annotation exists to make lossless.
131
+ const tags = new Set<string>(['span', ...STYLED_TAGS])
127
132
  for (const el of accept) for (const t of ELEMENT_TAGS[el] ?? []) tags.add(t)
128
133
  return [...tags]
129
134
  }
@@ -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,6 +1,8 @@
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";
5
+ import { isStyledTag, styledAttrEntries } from "./styled";
4
6
 
5
7
  /**
6
8
  * Portable Text → HTML.
@@ -27,27 +29,59 @@ const esc = (s: string): string =>
27
29
  /** Colour marks carry a TOKEN name, never a baked hex, so a rebrand does not orphan content. */
28
30
  const TOKEN = /^[a-z0-9-]+$/i;
29
31
 
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
- };
32
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
33
+ !!v && typeof v === "object" && !Array.isArray(v);
34
+
35
+ /**
36
+ * A table, back to the markup it was ingested from.
37
+ *
38
+ * 🔴 NO CLASS, NO INVENTED SECTIONS. This renderer emits `<thead>` only where a row carries
39
+ * `head`, `<th>` only where a cell carries `header`, and `colspan` only where a cell carries a
40
+ * span greater than one — because the html it produces is COMPARED against the stored markup,
41
+ * by the block-props parity gate and by the corpus audit's table pass. Its predecessor added
42
+ * `class="bcms-table"` and promoted the first row to a header, so every stored table diverged
43
+ * from its own rendition and stayed quarantined html-only forever. Rendering exactly what the
44
+ * value says is what lets a table convert at all.
45
+ *
46
+ * `<tbody>` is the one thing written that the value does not name, and it is not an invention:
47
+ * every HTML parser inserts one, so the stored markup already has it by the time either side of
48
+ * that comparison is reparsed.
49
+ *
50
+ * Read defensively throughout — `rows` is `unknown` by type, and one bad row must not throw
51
+ * mid-audit.
52
+ */
53
+ /** A span attribute, written only when it spans more than one — as the markup writes it. */
54
+ const attr = (name: string, value: unknown): string =>
55
+ typeof value === "number" && Number.isInteger(value) && value > 1 ? ` ${name}="${value}"` : "";
38
56
 
39
- function tableHtml(value: Record<string, unknown>): string {
40
- const rows = tableRows(value);
57
+ function tableHtml(table: PortableTextTable): string {
58
+ const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
41
59
  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>`;
60
+
61
+ const cellHtml = (raw: unknown): string => {
62
+ const cell = isRecord(raw) ? raw : {};
63
+ const tag = cell.header === true ? "th" : "td";
64
+ // `colspan` before `rowspan`, the order every table in the corpus writes them in: the
65
+ // comparison is on serialised markup, and a parser preserves the order it was given.
66
+ const attrs = `${attr("colspan", cell.span)}${attr("rowspan", cell.rowSpan)}`;
67
+ return `<${tag}${attrs}>${portableTextToHtml(cell.content as PortableText, { inline: true })}</${tag}>`;
68
+ };
69
+ const rowHtml = (raw: unknown): string => {
70
+ const cells = isRecord(raw) && Array.isArray(raw.cells) ? raw.cells : [];
71
+ return `<tr>${cells.map(cellHtml).join("")}</tr>`;
72
+ };
73
+
74
+ // The LEADING RUN of head rows, not every row flagged one: `<thead>` is a section, and a
75
+ // header row appearing after body rows cannot be lifted out of document order without moving
76
+ // content the author placed.
77
+ const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
78
+ const head = rows.slice(0, headCount === -1 ? rows.length : headCount);
79
+ const body = rows.slice(head.length);
80
+ // The platform's own class, on migrated values only — see `PortableTextTable["legacy"]`.
81
+ const cls = table.legacy === true ? ' class="bcms-table"' : "";
82
+ return `<table${cls}>${head.length ? `<thead>${head.map(rowHtml).join("")}</thead>` : ""}${
83
+ body.length ? `<tbody>${body.map(rowHtml).join("")}</tbody>` : ""
84
+ }</table>`;
51
85
  }
52
86
 
53
87
  export interface RenderOptions {
@@ -71,6 +105,7 @@ export interface RenderOptions {
71
105
  const components: Partial<PortableTextHtmlComponents> = {
72
106
  types: {
73
107
  [PT_TYPES.divider]: () => "<hr>",
108
+ [PT_TYPES.table]: ({ value }) => tableHtml(value as PortableTextTable),
74
109
  [PT_TYPES.code]: ({ value }) => {
75
110
  const v = value as { code?: unknown; language?: unknown };
76
111
  const code = typeof v.code === "string" ? v.code : "";
@@ -87,14 +122,17 @@ const components: Partial<PortableTextHtmlComponents> = {
87
122
  [PT_TYPES.block]: ({ value }) => {
88
123
  const v = value as { schemaKey?: unknown };
89
124
  /**
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.
125
+ * A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
126
+ * nothing. Its rows are text, held right here, so a marker would DELETE visible content.
127
+ * Every other built-in block (an image, a video, a component) carries a reference this
128
+ * serialiser cannot resolve, so those stay markers.
129
+ *
130
+ * Migrating it here rather than rendering the matrix directly is what keeps ONE table
131
+ * renderer: a value that came through the reader and one that did not produce the same
132
+ * page, which is the property four surfaces used to get wrong independently.
96
133
  */
97
- if (v.schemaKey === BUILTIN_TABLE_KEY) return tableHtml(value as Record<string, unknown>);
134
+ const legacy = builtInTableToTable(value);
135
+ if (legacy) return tableHtml(legacy);
98
136
  const key = typeof v.schemaKey === "string" && TOKEN.test(v.schemaKey.replace(/^builtin:/, ""))
99
137
  ? v.schemaKey
100
138
  : "block";
@@ -118,6 +156,25 @@ const components: Partial<PortableTextHtmlComponents> = {
118
156
  const target = v.target === "_blank" ? ' target="_blank" rel="noopener noreferrer"' : "";
119
157
  return `<a href="${esc(href)}"${target}>${children}</a>`;
120
158
  },
159
+ /**
160
+ * The site's own inline markup, written back out as itself.
161
+ *
162
+ * Every value here came from content, so every value here is escaped and the tag is
163
+ * re-checked against `STYLED_TAGS` — a markDef can arrive from an import, the Management
164
+ * API or an AI write, and `<${value.tag}>` with no check is how a stored string becomes a
165
+ * `<script>`. An unrecognised tag renders its CHILDREN rather than nothing: the words are
166
+ * the author's, the wrapper is only the design.
167
+ */
168
+ [PT_TYPES.styled]: ({ value, children }) => {
169
+ const v = (value ?? {}) as { tag?: unknown; className?: unknown; attrs?: unknown };
170
+ const tag = typeof v.tag === "string" ? v.tag.toLowerCase() : "";
171
+ if (!isStyledTag(tag)) return children;
172
+ const cls = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
173
+ const attrs = styledAttrEntries(v.attrs)
174
+ .map(([name, val]) => ` ${name}="${esc(val)}"`)
175
+ .join("");
176
+ return `<${tag}${cls}${attrs}>${children}</${tag}>`;
177
+ },
121
178
  highlight: ({ children }) => `<mark>${children}</mark>`,
122
179
  "strike-through": ({ children }) => `<s>${children}</s>`,
123
180
  },
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ export * from "./href";
4
4
  export * from "./key";
5
5
  export * from "./from-lexical";
6
6
  export * from "./plain";
7
+ export * from "./table";
8
+ export * from "./styled";
7
9
  export * from "./read";
8
10
  export * from "./accept";
9
11
  export * from "./features";
package/src/ingest.ts CHANGED
@@ -1,8 +1,9 @@
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
+ import { isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
6
7
  import type { PtSchema } from "./schema";
7
8
 
8
9
  /**
@@ -105,82 +106,136 @@ function normalise(value: unknown, key: KeyGenerator): PortableText {
105
106
  }
106
107
 
107
108
  /**
108
- * `<table>` → the `builtin:table` block, which is the ONLY table BetterCMS has.
109
+ * `<table>` → the `table` block object, which is the ONLY table BetterCMS has.
109
110
  *
110
111
  * Without this rule a table produces no node — the deserialiser has no table in its schema, so
111
112
  * the cells come through as loose paragraphs and the grid is gone. Measured before it existed:
112
113
  * two published pages (privacy, gdpr) lost a whole table on conversion, and the text check
113
114
  * could not see it because the CELLS survived as text.
114
115
  *
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.
116
+ * ── A cell is ingested by INGESTING IT ────────────────────────────────────────────────────
117
+ * Each cell's own markup goes back through `htmlToPortableText`, with the same schema, the same
118
+ * key generator and the same rules. That is what makes nested markup inside a cell take the
119
+ * NORMAL span/mark path: a link keeps its markDef, `<strong>` keeps its decorator, two
120
+ * paragraphs stay two blocks, and a table nested in a cell keeps its own grid. The alternative —
121
+ * the deserialiser's `next()` on the cell's child nodes — returns spans whose annotation marks
122
+ * point at markDefs it never hands back, so every link inside a cell would silently become
123
+ * plain text.
120
124
  *
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.
125
+ * The recursion terminates because a cell's markup is strictly smaller than the table's.
124
126
  */
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
127
 
145
128
  /**
146
129
  * Rows and cells belonging to THIS table.
147
130
  *
148
131
  * `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.
132
+ * the outer table and its cells to the outer row: the grid gains phantom columns and the nested
133
+ * rows are emitted twice.
151
134
  */
152
135
  const rowsOf = (table: Element): Element[] =>
153
136
  [...table.querySelectorAll("tr")].filter((r) => r.closest("table") === table);
154
137
 
155
- const cellsOf = (row: Element): string[] =>
156
- [...row.querySelectorAll("th,td")].filter((c) => c.closest("tr") === row).map(cellText);
138
+ const cellsOf = (row: Element): Element[] =>
139
+ [...row.querySelectorAll("th,td")].filter((c) => c.closest("tr") === row);
157
140
 
158
- function tableRule(key: KeyGenerator): DeserializerRule {
141
+ /** A span attribute, recorded and not honoured — see `PortableTextTableCell`. */
142
+ const spanOf = (cell: Element, name: "colspan" | "rowspan"): number | undefined => {
143
+ const n = Number(cell.getAttribute(name));
144
+ return Number.isInteger(n) && n > 1 ? n : undefined;
145
+ };
146
+
147
+ function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText): DeserializerRule {
159
148
  return {
160
149
  deserialize(el: Node, _next, createBlock) {
161
150
  if (!isElement(el) || el.nodeName.toLowerCase() !== "table") return undefined;
162
- const rows = rowsOf(el).map(cellsOf).filter((r) => r.length > 0);
151
+ const rows = rowsOf(el)
152
+ .map((row) => ({
153
+ _key: key(),
154
+ cells: cellsOf(row).map((cell) => ({
155
+ _key: key(),
156
+ content: ingestCell(cell.innerHTML),
157
+ // A header is what the markup SAYS it is, cell by cell. Deriving it from position
158
+ // would promote the first data row of a headerless table, and would lose the row
159
+ // headers that live in a table's first COLUMN.
160
+ ...(cell.nodeName.toLowerCase() === "th" ? { header: true } : {}),
161
+ ...(spanOf(cell, "colspan") === undefined ? {} : { span: spanOf(cell, "colspan") }),
162
+ ...(spanOf(cell, "rowspan") === undefined ? {} : { rowSpan: spanOf(cell, "rowspan") }),
163
+ })),
164
+ // `<thead>` is recorded rather than inferred: a browser inserts `<tbody>` on its own
165
+ // and never a `<thead>`, so inventing one at render time would make every bare
166
+ // `<tr><th>` table diverge from its own stored markup.
167
+ ...(row.parentElement?.nodeName.toLowerCase() === "thead" ? { head: true } : {}),
168
+ }))
169
+ .filter((r) => r.cells.length > 0);
163
170
  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
171
  // `createBlock` is what marks this BLOCK-level. Returning the object bare makes the
169
172
  // deserialiser treat it as inline and wrap it in a paragraph — the table survives, nested
170
173
  // one level down where no renderer looks for it.
171
- return createBlock({
172
- _type: PT_TYPES.block,
174
+ // `class="bcms-table"` and nothing else: that class only ever appears in markup the
175
+ // pre-P1c renderer produced, so recognising it is what lets such markup round-trip
176
+ // instead of coming back a class short. Any other class is not representable and the
177
+ // audit reports it, which is the honest answer.
178
+ const legacy = el.getAttribute("class") === "bcms-table";
179
+ return createBlock({ _type: PT_TYPES.table, _key: key(), rows, ...(legacy ? { legacy } : {}) });
180
+ },
181
+ };
182
+ }
183
+
184
+ /**
185
+ * An inline element Portable Text does not model → the `styled` annotation.
186
+ *
187
+ * 🔴 THE REASON THIS EXISTS is measured, not theoretical: 13–28% of the visible text on an
188
+ * imported site sits inside a `<span class>` in a heading or a paragraph, and none of it could
189
+ * be made editable while this round trip was lossy — the span came back as bare words and the
190
+ * design died on the author's first save. `derive-schema` refused those elements ON PURPOSE
191
+ * for exactly that reason.
192
+ *
193
+ * A `<span>` with nothing on it is NOT annotated: it carries no design, so recording it would
194
+ * mint a markDef per stray wrapper and bloat every value for nothing. The other tags are
195
+ * annotated bare, because there the TAG is the design (`<small>`, `<sup>`, `<kbd>`).
196
+ *
197
+ * Decorator tags (`b/strong`, `i/em`, `u`, `s`, `mark`, `code`) are deliberately absent: they
198
+ * have real decorators and the standard rules run right after this one.
199
+ *
200
+ * `__annotation` is the deserialiser's own internal placeholder — the same one its built-in
201
+ * link rule returns — so nesting composes for free: `<strong><span class>` comes back as one
202
+ * span carrying both marks, in that order, and renders back out nested the same way.
203
+ */
204
+ function styledRule(key: KeyGenerator): DeserializerRule {
205
+ return {
206
+ deserialize(el: Node, next) {
207
+ if (!isElement(el)) return undefined;
208
+ const tag = el.nodeName.toLowerCase();
209
+ if (!isStyledTag(tag)) return undefined;
210
+ const className = el.getAttribute("class")?.trim() || undefined;
211
+ const attrs = styledAttrsOf(el);
212
+ if (tag === "span" && !className && !attrs) return undefined;
213
+ const markDef: StyledMarkDef = {
214
+ _type: PT_TYPES.styled,
173
215
  _key: key(),
174
- schemaKey: BUILTIN_TABLE_KEY,
175
- schemaLabel: "Table",
176
- fields: { schemaVersion: 1, rows, hasHeader },
177
- });
216
+ tag,
217
+ ...(className ? { className } : {}),
218
+ ...(attrs ? { attrs } : {}),
219
+ };
220
+ return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
178
221
  },
179
222
  };
180
223
  }
181
224
 
225
+ /**
226
+ * Does this field's schema carry the `styled` annotation?
227
+ *
228
+ * Gated for the same reason the table rule is: a custom rule runs BEFORE the compiled schema
229
+ * and its output is taken as given, so an unconditional rule would be a way past the Accept
230
+ * config. `ptSchemaFor` grants `styled` to every field, because it is the site's design rather
231
+ * than an authoring capability — but a caller passing a hand-built schema still decides.
232
+ */
233
+ const allowsStyled = (schema: PtSchema | undefined): boolean =>
234
+ !schema || schema.annotations.some((a) => a.name === PT_TYPES.styled);
235
+
236
+ /** Does this field's schema carry the table block object? Asked by name, not by capability. */
182
237
  const allowsTables = (schema: PtSchema | undefined): boolean =>
183
- !schema || schema.blockObjects.some((o) => o.name === TABLE_CAPABILITY);
238
+ !schema || schema.blockObjects.some((o) => o.name === PT_TYPES.table);
184
239
 
185
240
  const isElement = (n: unknown): n is Element =>
186
241
  !!n && typeof n === "object" && (n as Node).nodeType === 1;
@@ -201,12 +256,15 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
201
256
  * API / MCP bypass this boundary exists to close (see the header).
202
257
  *
203
258
  * 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.
259
+ * full node set is correct. With one, the rule is installed only when that schema declares
260
+ * the TABLE block object. The GENERIC block object is not that test: `ptSchemaFor` emits it
261
+ * for an image or an embed alike, so gating on it would let a `<table>` into a field that
262
+ * accepts only images.
208
263
  */
209
- ...(allowsTables(opts.schema) ? { rules: [tableRule(key)] } : {}),
264
+ rules: [
265
+ ...(allowsTables(opts.schema) ? [tableRule(key, (cell) => htmlToPortableText(cell, opts))] : []),
266
+ ...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
267
+ ],
210
268
  keyGenerator: key,
211
269
  });
212
270
  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
 
@@ -71,7 +81,27 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
71
81
  if (d && has(el)) decorators.push(d);
72
82
  }
73
83
 
74
- const annotations: PtAnnotation[] = [];
84
+ /**
85
+ * `styled` is granted to EVERY field, and that is not a leak in the Accept model.
86
+ *
87
+ * It is not a capability an author can reach — no toolbar button offers it, and no ingest
88
+ * mints one from author input. It records the SITE'S OWN inline markup (`<span class>`,
89
+ * `<time datetime>`) so that a heading an importer bound as a field survives being saved.
90
+ * Gating it on an Accept element would mean a field that happens not to accept `b` silently
91
+ * strips the design out of the copy it is holding — the loss this annotation exists to stop,
92
+ * moved one layer down.
93
+ */
94
+ const annotations: PtAnnotation[] = [
95
+ {
96
+ name: PT_TYPES.styled,
97
+ title: "Styled",
98
+ fields: [
99
+ { name: "tag", type: "string" },
100
+ { name: "className", type: "string" },
101
+ { name: "attrs", type: "object" },
102
+ ],
103
+ },
104
+ ];
75
105
  if (has("link") || has("internalLink")) {
76
106
  annotations.push({
77
107
  name: PT_TYPES.link,
@@ -102,10 +132,10 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
102
132
  ],
103
133
  });
104
134
  }
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
135
+ // Image, embed and columns all store as ONE generic block object under their existing
136
+ // `builtin:` schemaKeys rather than three bespoke types. That keeps a single converter lane
107
137
  // and leaves `BuiltInBlockCard` (799 lines of working editor UI) untouched.
108
- if (has("image") || has("embed") || has("table") || has("columns")) {
138
+ if (has("image") || has("embed") || has("columns")) {
109
139
  blockObjects.push({
110
140
  name: PT_TYPES.block,
111
141
  title: "Embedded block",
@@ -117,16 +147,22 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
117
147
  });
118
148
  }
119
149
  /**
120
- * A SECOND, table-specific entry — the one exception to "one generic block object".
150
+ * The table, and it is a REAL block object now rather than the capability marker it used to be.
121
151
  *
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.
152
+ * The marker existed because a table stored on the generic block, so this schema had nothing
153
+ * table-shaped for the HTML ingest to gate on — and gating on the generic entry answers yes
154
+ * for a field that accepts only IMAGES, dropping a `<table>` into a field that forbids one.
155
+ * Asking for `table` by name answers the same question honestly, and the editor gets an
156
+ * insertable Table that inserts something, instead of a marker it had to filter out.
157
+ *
158
+ * ONE field, and it is the whole payload: `rows: [{_key, cells: [{_key, content, header?,
159
+ * span?}]}]`, spelled in `types.ts`. `defineSchema` describes a block object so the editor can
160
+ * hold and move it; it does not validate the payload, so a deeper declaration would buy
161
+ * nothing and drift from the types that do.
128
162
  */
129
- if (has("table")) blockObjects.push({ name: TABLE_CAPABILITY, title: "Table" });
163
+ if (has("table")) {
164
+ blockObjects.push({ name: PT_TYPES.table, title: "Table", fields: [{ name: "rows", type: "object" }] });
165
+ }
130
166
 
131
167
  return {
132
168
  styles,
package/src/styled.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The `styled` annotation — a site's own inline markup, carried through Portable Text.
3
+ *
4
+ * WHY IT EXISTS. An imported site writes `<h1>Notes from a <span class="serif italic">web
5
+ * studio</span></h1>`. Portable Text models `strong`/`em`/`underline`/`strike`/`code`/
6
+ * `highlight` and `link`, and NOTHING ELSE inline — so that span had no node to become,
7
+ * `unknownMark` rendered its children, and the class was gone the first time an author saved.
8
+ * That loss is why `derive-schema` deliberately refused to bind any heading containing a
9
+ * `<span class>`: a field that destroys the design on save is worse than no field at all.
10
+ *
11
+ * So this is NOT an authoring mark. There is no toolbar button and no editing UI: it is the
12
+ * site's design, recorded so the round trip is lossless, and it rides along with whatever the
13
+ * author actually types inside it.
14
+ *
15
+ * ── The attribute allowlist IS the threat model ──────────────────────────────────────────
16
+ * A markDef is data, and data reaches this platform from an import, the Management API, MCP
17
+ * and AI writes. Rendering an arbitrary attribute bag would be an XSS primitive wearing a
18
+ * design system's clothes, so exactly four names plus `data-*` are admitted, and the render
19
+ * escapes every value. `id` is refused because it is an addressing surface (a duplicate id
20
+ * breaks anchors and label association), `data-bcms-*` because those attributes ARE the Visual
21
+ * Editor's binding addresses and content must not be able to mint one. `style` is admitted but
22
+ * NOT passed through — its value is a language of its own, bounded by `safeStyle` below.
23
+ */
24
+
25
+ /** Inline tags a `styled` annotation may wear. Nothing outside this list is ever rendered. */
26
+ export const STYLED_TAGS = ["span", "small", "sup", "sub", "kbd", "abbr", "time"] as const;
27
+
28
+ export type StyledTag = (typeof STYLED_TAGS)[number];
29
+
30
+ const TAG_SET: ReadonlySet<string> = new Set<string>(STYLED_TAGS);
31
+
32
+ export const isStyledTag = (tag: unknown): tag is StyledTag =>
33
+ typeof tag === "string" && TAG_SET.has(tag);
34
+
35
+ /**
36
+ * Attribute names a `styled` annotation may carry.
37
+ *
38
+ * Presentational or descriptive only. Never `href`/`src` (a navigation or fetch surface),
39
+ * never `id` (an addressing surface), never `on*` (script), and never `data-bcms-*` — those
40
+ * are binding addresses the editor reads, and content that could write one could point an
41
+ * author's edit at somebody else's field.
42
+ */
43
+ const ATTR_NAMES: ReadonlySet<string> = new Set(["title", "datetime", "style", "aria-label"]);
44
+
45
+ const DATA_ATTR = /^data-[a-z][a-z0-9-]*$/;
46
+
47
+ export const isStyledAttr = (name: string): boolean =>
48
+ ATTR_NAMES.has(name) || (DATA_ATTR.test(name) && !name.startsWith("data-bcms-"));
49
+
50
+ /**
51
+ * CSS an inline run may carry: how the words LOOK inside their own box, and nothing else.
52
+ *
53
+ * `style` is the one admitted attribute whose value is itself a language, so the tag allowlist
54
+ * buys nothing here — a bare passthrough lets any markDef write
55
+ * `position:fixed;inset:0;z-index:9999`, which is a full-viewport overlay over the published
56
+ * page (clickjacking, defacement) minted by anyone who can write content, and
57
+ * `background:url(https://…)` which fetches a third-party URL on every page view. Neither is a
58
+ * design an import needs, so neither is on the list. Properties that only paint glyphs are.
59
+ *
60
+ * Values are checked as well as names, because `url()`, a CSS escape (`\75 rl(`), a comment and
61
+ * `expression()` are all ways to smuggle a fetch past a name check.
62
+ */
63
+ const STYLE_PROPS: ReadonlySet<string> = new Set([
64
+ "color", "background-color", "opacity",
65
+ "font", "font-family", "font-size", "font-style", "font-weight", "font-variant",
66
+ "font-feature-settings", "font-variation-settings",
67
+ "letter-spacing", "line-height", "word-spacing", "white-space", "word-break",
68
+ "text-align", "text-decoration", "text-decoration-color", "text-decoration-line",
69
+ "text-decoration-style", "text-transform", "text-underline-offset", "vertical-align",
70
+ ]);
71
+
72
+ const STYLE_VALUE_BAN = /url\(|expression\(|@import|\\|\/\*|[<>]/i;
73
+
74
+ /**
75
+ * The declarations of a `style` attribute that survive, rejoined — or undefined when none do.
76
+ * Custom properties (`--brand`) are kept: they paint nothing on their own, and a site's own CSS
77
+ * is what decides whether they mean anything.
78
+ */
79
+ export function safeStyle(css: string): string | undefined {
80
+ const kept: string[] = [];
81
+ for (const declaration of css.split(";")) {
82
+ const at = declaration.indexOf(":");
83
+ if (at < 0) continue;
84
+ const name = declaration.slice(0, at).trim().toLowerCase();
85
+ const value = declaration.slice(at + 1).trim();
86
+ if (!name || !value) continue;
87
+ if (!name.startsWith("--") && !STYLE_PROPS.has(name)) continue;
88
+ if (STYLE_VALUE_BAN.test(value)) continue;
89
+ kept.push(`${name}:${value}`);
90
+ }
91
+ return kept.length ? kept.join(";") : undefined;
92
+ }
93
+
94
+ /** The annotation's payload, as stored. `tag` is validated again at render time. */
95
+ export interface StyledMarkDef {
96
+ _type: "styled";
97
+ _key: string;
98
+ tag: StyledTag;
99
+ className?: string;
100
+ attrs?: Record<string, string>;
101
+ }
102
+
103
+ /**
104
+ * The admitted attributes of an element, in source order, or undefined when there are none.
105
+ *
106
+ * `class` is deliberately absent — it lives on the markDef as `className`, because it is the
107
+ * one attribute every consumer of this annotation needs to reach without parsing a bag.
108
+ */
109
+ export function styledAttrsOf(el: Element): Record<string, string> | undefined {
110
+ const out: Record<string, string> = {};
111
+ for (const attr of Array.from(el.attributes)) {
112
+ const name = attr.name.toLowerCase();
113
+ if (name === "class" || !isStyledAttr(name)) continue;
114
+ const value = name === "style" ? safeStyle(attr.value) : attr.value;
115
+ if (value === undefined) continue;
116
+ out[name] = value;
117
+ }
118
+ return Object.keys(out).length ? out : undefined;
119
+ }
120
+
121
+ /**
122
+ * The same filter applied to a STORED bag, which may have arrived from anywhere.
123
+ *
124
+ * Both ends, and this is the end that matters: a markDef reaches storage from an import, the
125
+ * Management API, MCP and AI writes, so the ingest filter is a courtesy and this one is the
126
+ * control. Every renderer of the annotation goes through here.
127
+ */
128
+ export function styledAttrEntries(attrs: unknown): [string, string][] {
129
+ if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) return [];
130
+ const out: [string, string][] = [];
131
+ for (const [name, raw] of Object.entries(attrs as Record<string, unknown>)) {
132
+ if (!isStyledAttr(name) || typeof raw !== "string") continue;
133
+ const value = name === "style" ? safeStyle(raw) : raw;
134
+ if (value === undefined) continue;
135
+ out.push([name, value]);
136
+ }
137
+ return out;
138
+ }
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,135 @@ 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",
179
+ /**
180
+ * The annotation carrying a site's OWN inline markup — `<span class>`, `<small>`, `<time
181
+ * datetime>`. Unprefixed, like `table`, because it describes HTML rather than a BetterCMS
182
+ * concept. See `styled.ts` for what it may hold and why the list is that short.
183
+ */
184
+ styled: "styled",
109
185
  } as const;
110
186
 
111
187
  /**
112
- * The `schemaKey` of the one table BetterCMS has.
188
+ * The `schemaKey` a table stored under BEFORE it had a type of its own.
189
+ *
190
+ * Every table written up to P1c is a generic `bcmsBlock` carrying a string matrix under this
191
+ * key. Those rows are still in the database and nothing rewrites them, so this stays READABLE
192
+ * forever: `migrateTable` (table.ts) turns one into a `table` on the way out, which is why the
193
+ * reader, the renderer and the plain-text pass all still recognise it.
113
194
  *
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.
195
+ * Nothing WRITES it any more.
118
196
  */
119
197
  export const BUILTIN_TABLE_KEY = "builtin:table" as const;
120
198
 
121
199
  /**
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.
200
+ * Every `_type` that may appear at the TOP LEVEL of a stored value.
201
+ *
202
+ * Closed on purpose, and short: a text block, the table, the two standalone block objects, and
203
+ * the generic `bcmsBlock` envelope that carries an image, an embed or columns behind its
204
+ * `schemaKey` (see `ptSchemaFor`). Anything else is content from another system and has no
205
+ * renderer here.
206
+ *
207
+ * `table` joined the set in P1c, and it is the one member tested by SHAPE as well as by name —
208
+ * see `isStorableTable`. The Sanity-shaped `{_type:"table", rows:[{cells}]}` that prompted this
209
+ * list is the reason: refusing it was right while nothing could draw a table, and became wrong
210
+ * the moment something could. `migrateTable` adopts that shape on read, so the write it used to
211
+ * refuse now renders every cell.
212
+ *
213
+ * `bcmsMention` is included although it is an INLINE object: a mention that has somehow been
214
+ * hoisted to the top level is ours, harmless, and refusing an author's save over it would be
215
+ * the cure being worse than the disease.
216
+ */
217
+ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
218
+ "block",
219
+ PT_TYPES.block,
220
+ PT_TYPES.table,
221
+ PT_TYPES.code,
222
+ PT_TYPES.divider,
223
+ PT_TYPES.mention,
224
+ ]);
225
+
226
+ /**
227
+ * Top-level nodes whose `_type` this platform cannot store or render, in document order.
228
+ *
229
+ * WHY THIS IS A WRITE-TIME CHECK AND NOT A RENDER-TIME SHRUG. An unrecognised block survives
230
+ * the JSON round trip perfectly — it is data, and every reader here hands it back untouched —
231
+ * but `portableTextToHtml` has no component for it, so it renders as NOTHING. The `html` on
232
+ * the envelope is a cache that every delivery surface reads, so the node silently disappears
233
+ * from the live site while the stored value still looks complete. That is the "renders as
234
+ * nothing at 200 OK" failure this codebase keeps paying for, and the only place to catch it
235
+ * cheaply is the write, where the author is still present to be told.
236
+ *
237
+ * Returns the offenders rather than a boolean so the refusal can NAME them — `_type` and
238
+ * `_key` are what let an author find the block in a body of eighty.
239
+ */
240
+ export function foreignBlocks(value: unknown): { _type: string; _key: string }[] {
241
+ if (!Array.isArray(value)) return [];
242
+ const out: { _type: string; _key: string }[] = [];
243
+ for (const node of value) {
244
+ if (!node || typeof node !== "object" || Array.isArray(node)) continue;
245
+ const type = (node as PortableTextNode)._type;
246
+ if (typeof type !== "string") continue;
247
+ // A table is admitted by SHAPE, never by its `_type` alone — see `isStorableTable`.
248
+ const storable = type === PT_TYPES.table ? isStorableTable(node) : STORABLE_BLOCK_TYPES.has(type);
249
+ if (storable) continue;
250
+ const key = (node as PortableTextNode)._key;
251
+ out.push({ _type: type, _key: typeof key === "string" ? key : "" });
252
+ }
253
+ return out;
254
+ }
255
+
256
+ /**
257
+ * Is this `_type:"table"` a table we can actually RENDER?
258
+ *
259
+ * 🔴 THE TYPE NAME IS NOT A CONTRACT. `table` is what every Portable Text producer calls a
260
+ * table, and they do not agree on what is inside one. Admitting the name alone would let any
261
+ * foreign `rows` through the write guard, and the reader — permissive by design, because a
262
+ * reader that throws is a field that opens blank — would then coerce whatever it did not
263
+ * recognise into EMPTY cells. That is the silent-emptying failure the guard exists to stop,
264
+ * wearing our own type name.
265
+ *
266
+ * So exactly two shapes pass, tested cell by cell:
267
+ * · ours — `cells: [{ content: PortableText }]`
268
+ * · Sanity's — `cells: ["a", "b"]`, a row of strings
269
+ *
270
+ * Everything else is refused at the write, where the author is present to be told, and stays
271
+ * readable through `migrateTable` for anything already stored.
272
+ */
273
+ export function isStorableTable(node: unknown): boolean {
274
+ if (!node || typeof node !== "object") return false;
275
+ const rows = (node as { rows?: unknown }).rows;
276
+ if (!Array.isArray(rows)) return false;
277
+ return rows.every((row) => {
278
+ if (!row || typeof row !== "object" || Array.isArray(row)) return false;
279
+ const cells = (row as { cells?: unknown }).cells;
280
+ if (!Array.isArray(cells)) return false;
281
+ return cells.every((cell) => {
282
+ if (typeof cell === "string") return true;
283
+ if (!cell || typeof cell !== "object" || Array.isArray(cell)) return false;
284
+ const content = (cell as { content?: unknown }).content;
285
+ return Array.isArray(content)
286
+ && content.every((n) => !!n && typeof n === "object" && typeof (n as PortableTextNode)._type === "string");
287
+ });
288
+ });
289
+ }
290
+
291
+ /**
292
+ * @deprecated The marker a table-accepting field used to carry INSTEAD of a table.
293
+ *
294
+ * It existed because a table stored as a generic block object, so `ptSchemaFor` had nothing
295
+ * table-shaped to gate the HTML ingest on. `PT_TYPES.table` is that thing now, and it is a real
296
+ * block object rather than a marker, so the ingest asks for it by name and nothing emits this.
297
+ *
298
+ * Kept exported for one release: the dashboard filters it out of the schema it hands
299
+ * `defineSchema` (`src/lib/richtext/ptSchema.ts`), and removing the export would fail that
300
+ * import rather than make the filter a harmless no-op. Delete it once that filter is gone.
126
301
  */
127
302
  export const TABLE_CAPABILITY = "bcmsTableCapability" as const;