@bettercms-ai/richtext 0.1.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/src/ingest.ts ADDED
@@ -0,0 +1,233 @@
1
+ import { htmlToPortableText as ptHtmlToPortableText, type DeserializerRule } from "@portabletext/html";
2
+ import { compileSchema } from "@portabletext/schema";
3
+ import { BUILTIN_TABLE_KEY, PT_TYPES, TABLE_CAPABILITY, type PortableText, type PortableTextBlock } from "./types";
4
+ import { randomKey, isValidKey, type KeyGenerator } from "./key";
5
+ import { safeHref } from "./href";
6
+ import type { PtSchema } from "./schema";
7
+
8
+ /**
9
+ * HTML → Portable Text.
10
+ *
11
+ * This is the INGEST boundary: paste, the Management API, MCP writes, markdown, and every
12
+ * pre-envelope `{html}` seed still sitting in the database arrive here.
13
+ *
14
+ * ── The filter IS the schema ─────────────────────────────────────────────────────────────
15
+ * `htmlToPortableText` takes a schema directly, and a tag with no place in that schema
16
+ * produces NO NODE — `<script>` is not "sanitised away", it fails to parse into anything.
17
+ * That is a stronger guarantee than an allowlist, and it is why one `ptSchemaFor(accept)`
18
+ * object can drive the editor's toolbar, the server's write validation and this filter at
19
+ * once. Before, only the DASHBOARD enforced a field's Accept config, so every MCP,
20
+ * Management-API and AI write bypassed it entirely.
21
+ *
22
+ * ── Keys are re-minted, never trusted ────────────────────────────────────────────────────
23
+ * A `_key` ends up inside a Visual Editor address, which is parsed by splitting on `__`, `.`
24
+ * and `#`. Imported HTML can carry any key at all, so anything that is not already in our
25
+ * charset is replaced rather than escaped.
26
+ */
27
+
28
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
29
+ !!v && typeof v === "object" && !Array.isArray(v);
30
+
31
+ export interface IngestOptions {
32
+ /** Required in Node: `(html) => new JSDOM(html).window.document`. Browsers pass DOMParser. */
33
+ parseHtml?: (html: string) => Document;
34
+ /** What the field accepts. Anything outside it produces no node. */
35
+ schema?: PtSchema;
36
+ keyGenerator?: KeyGenerator;
37
+ }
38
+
39
+ /**
40
+ * Walk the converted tree and make it ours: valid keys everywhere, and no `href` we would
41
+ * refuse to render. Doing it here rather than at render time means an unsafe URL never
42
+ * reaches storage, so it cannot be served by an older consumer that forgot to check.
43
+ */
44
+ function normalise(value: unknown, key: KeyGenerator): PortableText {
45
+ if (!Array.isArray(value)) return [];
46
+ const out: PortableText = [];
47
+
48
+ for (const raw of value) {
49
+ if (!isRecord(raw) || typeof raw._type !== "string") continue;
50
+ const node: Record<string, unknown> = { ...raw };
51
+ if (!isValidKey(node._key)) node._key = key();
52
+
53
+ if (node._type === "block") {
54
+ // Two maps, because a markDef meets two fates and a span's marks must follow both:
55
+ // remap — kept defs, original key -> the key we minted for it
56
+ // dropped— defs we removed (an unsafe href), whose marks must go with them
57
+ // A mark left pointing at a dropped def is a mark referring to nothing, which renders
58
+ // as an unknown mark forever.
59
+ const remap = new Map<string, string>();
60
+ const dropped = new Set<string>();
61
+ const keptDefs: Record<string, unknown>[] = [];
62
+
63
+ for (const rawDef of Array.isArray(node.markDefs) ? node.markDefs : []) {
64
+ if (!isRecord(rawDef)) continue;
65
+ const def: Record<string, unknown> = { ...rawDef };
66
+ const original = typeof def._key === "string" ? def._key : "";
67
+
68
+ if (def._type === PT_TYPES.link) {
69
+ const href = typeof def.href === "string" ? safeHref(def.href) : null;
70
+ // Drop the annotation, keep the text: a blocked link must not delete the words.
71
+ if (!href) {
72
+ if (original) dropped.add(original);
73
+ continue;
74
+ }
75
+ def.href = href;
76
+ }
77
+
78
+ if (!isValidKey(def._key)) def._key = key();
79
+ if (original) remap.set(original, def._key as string);
80
+ keptDefs.push(def);
81
+ }
82
+
83
+ node.markDefs = keptDefs;
84
+ node.children = (Array.isArray(node.children) ? node.children : []).map((rawChild) => {
85
+ if (!isRecord(rawChild)) return rawChild;
86
+ const child: Record<string, unknown> = { ...rawChild };
87
+ if (!isValidKey(child._key)) child._key = key();
88
+ if (Array.isArray(child.marks)) {
89
+ child.marks = (child.marks as unknown[])
90
+ .filter((m): m is string => typeof m === "string")
91
+ .filter((m) => !dropped.has(m))
92
+ // A decorator ("strong") is not in the map and passes through untouched.
93
+ .map((m) => remap.get(m) ?? m);
94
+ }
95
+ return child;
96
+ });
97
+ if (typeof node.style !== "string") node.style = "normal";
98
+ out.push(node as unknown as PortableTextBlock);
99
+ continue;
100
+ }
101
+
102
+ out.push(node as never);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * `<table>` → the `builtin:table` block, which is the ONLY table BetterCMS has.
109
+ *
110
+ * Without this rule a table produces no node — the deserialiser has no table in its schema, so
111
+ * the cells come through as loose paragraphs and the grid is gone. Measured before it existed:
112
+ * two published pages (privacy, gdpr) lost a whole table on conversion, and the text check
113
+ * could not see it because the CELLS survived as text.
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.
120
+ *
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
+ */
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
+
145
+ /**
146
+ * Rows and cells belonging to THIS table.
147
+ *
148
+ * `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.
151
+ */
152
+ const rowsOf = (table: Element): Element[] =>
153
+ [...table.querySelectorAll("tr")].filter((r) => r.closest("table") === table);
154
+
155
+ const cellsOf = (row: Element): string[] =>
156
+ [...row.querySelectorAll("th,td")].filter((c) => c.closest("tr") === row).map(cellText);
157
+
158
+ function tableRule(key: KeyGenerator): DeserializerRule {
159
+ return {
160
+ deserialize(el: Node, _next, createBlock) {
161
+ if (!isElement(el) || el.nodeName.toLowerCase() !== "table") return undefined;
162
+ const rows = rowsOf(el).map(cellsOf).filter((r) => r.length > 0);
163
+ 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
+ // `createBlock` is what marks this BLOCK-level. Returning the object bare makes the
169
+ // deserialiser treat it as inline and wrap it in a paragraph — the table survives, nested
170
+ // 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
+ });
178
+ },
179
+ };
180
+ }
181
+
182
+ const allowsTables = (schema: PtSchema | undefined): boolean =>
183
+ !schema || schema.blockObjects.some((o) => o.name === TABLE_CAPABILITY);
184
+
185
+ const isElement = (n: unknown): n is Element =>
186
+ !!n && typeof n === "object" && (n as Node).nodeType === 1;
187
+
188
+ export function htmlToPortableText(html: string, opts: IngestOptions = {}): PortableText {
189
+ if (typeof html !== "string" || html.trim() === "") return [];
190
+ const key = opts.keyGenerator ?? randomKey;
191
+ const converted = ptHtmlToPortableText(html, {
192
+ ...(opts.parseHtml ? { parseHtml: opts.parseHtml } : {}),
193
+ // `ptSchemaFor` returns a schema DEFINITION — the same object `EditorProvider` takes, so
194
+ // the editor and this filter are literally configured from one value. The deserialiser
195
+ // wants the COMPILED form, so the compile happens here rather than at every call site.
196
+ ...(opts.schema ? { schema: compileSchema(opts.schema as never) } : {}),
197
+ /**
198
+ * 🔴 A custom rule is NOT filtered by the compiled schema — it runs before the schema is
199
+ * consulted and its output is taken as given. So an unconditional table rule would hand a
200
+ * `<table>` to a field whose Accept config forbids one, which is exactly the Management
201
+ * API / MCP bypass this boundary exists to close (see the header).
202
+ *
203
+ * 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.
208
+ */
209
+ ...(allowsTables(opts.schema) ? { rules: [tableRule(key)] } : {}),
210
+ keyGenerator: key,
211
+ });
212
+ return normalise(converted, key);
213
+ }
214
+
215
+ /**
216
+ * Plain text → Portable Text, one block per line.
217
+ *
218
+ * Kept separate from the HTML path on purpose, and the distinction has to survive: a bare
219
+ * string typed by an author is LITERAL. Running it through a markdown or HTML parser is how a
220
+ * line starting with "#" silently became a heading and "1." became a list — the author's copy
221
+ * rewritten by a parser they never invoked.
222
+ */
223
+ export function plainTextToPortableText(text: string, opts: { keyGenerator?: KeyGenerator } = {}): PortableText {
224
+ const key = opts.keyGenerator ?? randomKey;
225
+ if (typeof text !== "string" || text === "") return [];
226
+ return text.split("\n").map((line) => ({
227
+ _type: "block" as const,
228
+ _key: key(),
229
+ style: "normal",
230
+ markDefs: [],
231
+ children: [{ _type: "span" as const, _key: key(), text: line, marks: [] }],
232
+ }));
233
+ }
package/src/key.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `_key` minting.
3
+ *
4
+ * THE CHARSET IS LOAD-BEARING, not cosmetic. A `_key` ends up inside a Visual Editor address
5
+ * (`<blockId>__<prop>#<key>`), and that address is parsed by splitting on `__`, `.`, `[`, `]`
6
+ * and `#`. A key containing any of those characters would silently retarget an edit to the
7
+ * wrong node, so we mint from `[A-Za-z0-9-]` only — no underscore, no dot, no separator.
8
+ *
9
+ * Foreign keys are RE-MINTED, never trusted: imported content and pasted HTML can carry
10
+ * anything, and the address grammar is not a place to find out.
11
+ *
12
+ * Determinism matters at migration time. Converting a field twice — once for `data`, once for
13
+ * `published_data` — with random keys makes two semantically identical copies compare unequal,
14
+ * which lights up the change feed and the parity check on every entry in the project. The
15
+ * backfill converts once and writes the same object to both columns; `deterministicKeys()`
16
+ * exists for the cases that cannot.
17
+ */
18
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
19
+
20
+ export const KEY_PATTERN = /^[A-Za-z0-9-]{1,32}$/;
21
+
22
+ export const isValidKey = (k: unknown): k is string =>
23
+ typeof k === "string" && KEY_PATTERN.test(k);
24
+
25
+ /** A fresh random key. 12 chars of this alphabet is ~71 bits; collisions are not a concern. */
26
+ export function randomKey(length = 12): string {
27
+ let out = "";
28
+ for (let i = 0; i < length; i += 1) {
29
+ out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /**
35
+ * A counter-based generator, for conversions that must be byte-reproducible.
36
+ *
37
+ * Same input, same keys, every run — so a value converted for the draft column and the
38
+ * published column produces identical output and `jsonbEqual` still says they match.
39
+ */
40
+ export function deterministicKeys(prefix = "k"): () => string {
41
+ let n = 0;
42
+ return () => {
43
+ n += 1;
44
+ return `${prefix}${n}`;
45
+ };
46
+ }
47
+
48
+ export type KeyGenerator = () => string;
package/src/plain.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { BUILTIN_TABLE_KEY, isPortableTextBlock, isPortableTextSpan, PT_TYPES, type PortableText } from "./types";
2
+
3
+ /**
4
+ * Portable Text → plain text.
5
+ *
6
+ * Replaces `lexicalJsonToText`, and is the one place the repo's oldest formatting trap gets
7
+ * settled: joining spans with `""` versus `" "`. Spans are runs WITHIN one paragraph, so they
8
+ * join with nothing — inserting spaces would put a gap in the middle of a bolded word. Blocks
9
+ * are separate paragraphs, so they join with a newline.
10
+ */
11
+ export function portableTextToPlainText(value: PortableText | null | undefined): string {
12
+ if (!Array.isArray(value)) return "";
13
+ const lines: string[] = [];
14
+ for (const node of value) {
15
+ if (isPortableTextBlock(node)) {
16
+ lines.push(node.children.map((c) => (isPortableTextSpan(c) ? c.text : "")).join(""));
17
+ continue;
18
+ }
19
+ if (node._type === PT_TYPES.code && typeof (node as Record<string, unknown>).code === "string") {
20
+ lines.push((node as Record<string, unknown>).code as string);
21
+ }
22
+ /**
23
+ * A TABLE is the exception, and it has to be: a reader sees every cell. Left out, the
24
+ * corpus audit compared a table's stored text against a conversion that reported none and
25
+ * called 104 sound values a text loss. Cells join with a space and rows with a newline, the
26
+ * same shape `portableTextToHtml` gives them.
27
+ *
28
+ * Every other embedded block — an image, an embed, a component — resolves against a project
29
+ * this function does not have, so it still contributes nothing.
30
+ */
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
+ }
40
+ continue;
41
+ }
42
+ // Dividers and other embedded blocks contribute no text, which is what a reader wants.
43
+ }
44
+ return lines.join("\n");
45
+ }
package/src/read.ts ADDED
@@ -0,0 +1,222 @@
1
+ import { PORTABLE_TEXT_FORMAT, PT_TYPES, type PortableText } from "./types";
2
+ import { lexicalToPortableText } from "./from-lexical";
3
+ import { randomKey, type KeyGenerator } from "./key";
4
+
5
+ /**
6
+ * THE reader. One function, every stored shape, and it never throws.
7
+ *
8
+ * ── What this replaces ───────────────────────────────────────────────────────────────────
9
+ * Five readers, each guessing at the same six shapes, each maintained by hand, each with its
10
+ * own idea of what an unrecognised value meant:
11
+ *
12
+ * `RichTextField.toEditorContent` (dashboard, the dock)
13
+ * `pageDocAdapter.richtextToEditorString`(dashboard, the canvas)
14
+ * the `LexicalEditor` import path (dashboard, parse-then-fallback)
15
+ * `lexicalJsonToText` (dashboard, plain text)
16
+ * `renderRichtextValue` (dashboard, reflection)
17
+ *
18
+ * A comment in the codebase says they "must agree on every shape or a field loads on one
19
+ * surface and not the other". They did not agree, five separate times, and the symptom each
20
+ * time was a field that opened blank and then autosaved the blank over real content. Keeping
21
+ * five copies in sync by hand was the bug; there is now one copy, and it lives in a package
22
+ * both repos import.
23
+ *
24
+ * ── Never throws, by contract ────────────────────────────────────────────────────────────
25
+ * This runs on every read during the dual-read window. A throw here is a blank field, and a
26
+ * blank field is one autosave away from being permanent. Unrecognised input returns `[]` and
27
+ * is reported through `onUnreadable`, never raised.
28
+ */
29
+
30
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
31
+ !!v && typeof v === "object" && !Array.isArray(v);
32
+
33
+ const looksLikeHtml = (s: string): boolean => /<[a-z!/][\s\S]*>/i.test(s);
34
+
35
+ export interface ReadOptions {
36
+ keyGenerator?: KeyGenerator;
37
+ /** HTML ingest, injected: this module must stay free of any DOM dependency. */
38
+ fromHtml?: (html: string) => PortableText;
39
+ /** Called instead of throwing. The migration turns this into a quarantine report. */
40
+ onUnreadable?: (reason: string, value: unknown) => void;
41
+ }
42
+
43
+ /**
44
+ * Make a Portable Text array OURS: a `_key` on every node, `markDefs` on every block.
45
+ *
46
+ * Values reach this from two places that predate the spec — a bare array persisted before
47
+ * `_key` was minted, and the hand-rolled `{version:1, content}` tree the `ui` package's old
48
+ * renderer emitted. Both are structurally Portable Text and neither carries markDefs, so
49
+ * `@portabletext/react` would treat a block without them as malformed. Filling them in is
50
+ * cheaper than a migration and makes every historical value renderable today.
51
+ */
52
+ export function normalizePortableText(value: unknown, key: KeyGenerator = randomKey): PortableText {
53
+ if (!Array.isArray(value)) return [];
54
+ return value.flatMap((raw) => {
55
+ if (!isRecord(raw) || typeof raw._type !== "string") return [];
56
+ const node: Record<string, unknown> = { ...raw, _key: isRecord(raw) && typeof raw._key === "string" && raw._key ? raw._key : key() };
57
+ if (node._type === "block") {
58
+ if (!Array.isArray(node.markDefs)) node.markDefs = [];
59
+ if (typeof node.style !== "string") node.style = "normal";
60
+ node.children = (Array.isArray(node.children) ? node.children : []).map((c) => {
61
+ if (!isRecord(c)) return c;
62
+ const child: Record<string, unknown> = { ...c, _key: typeof c._key === "string" && c._key ? c._key : key() };
63
+ if (child._type === "span" && !Array.isArray(child.marks)) child.marks = [];
64
+ return child;
65
+ });
66
+ }
67
+ return [node as never];
68
+ });
69
+ }
70
+
71
+ function plainBlocks(text: string, key: KeyGenerator): PortableText {
72
+ if (text === "") return [];
73
+ return text.split("\n").map((line) => ({
74
+ _type: "block" as const,
75
+ _key: key(),
76
+ style: "normal",
77
+ markDefs: [],
78
+ children: [{ _type: "span" as const, _key: key(), text: line, marks: [] }],
79
+ }));
80
+ }
81
+
82
+ function fromString(raw: string, key: KeyGenerator, opts: ReadOptions): PortableText {
83
+ const trimmed = raw.trim();
84
+ if (trimmed === "") return [];
85
+
86
+ // A serialized Lexical state arrives as a JSON STRING, which is why this is tried before
87
+ // the HTML sniff — `{"root":…}` contains no angle brackets but is not plain text either.
88
+ if (trimmed.startsWith("{")) {
89
+ try {
90
+ const parsed: unknown = JSON.parse(trimmed);
91
+ if (isRecord(parsed) && isRecord(parsed.root)) {
92
+ return lexicalToPortableText(parsed, { keyGenerator: key }).value;
93
+ }
94
+ } catch {
95
+ // Not JSON after all. Fall through: it is text that happens to start with a brace.
96
+ }
97
+ }
98
+
99
+ if (looksLikeHtml(trimmed) && opts.fromHtml) return opts.fromHtml(raw);
100
+
101
+ // A bare string is LITERAL. It is NOT run through a markdown parser, because that is how
102
+ // a line beginning "#" silently became a heading in copy the author never marked up.
103
+ return plainBlocks(raw, key);
104
+ }
105
+
106
+ /**
107
+ * Read any stored rich-text value as Portable Text.
108
+ *
109
+ * Shapes handled, in the order they are tested:
110
+ * 1. `null` / `""` → []
111
+ * 2. `PortableText[]` → as-is
112
+ * 3. `{format:"portable-text-1", value}` → `.value`
113
+ * 4. `{format:"lexical-…", value:{root}}` → converted
114
+ * 5. `{root:…}` bare Lexical state → converted
115
+ * 6. `{format:"bettercms-notion-doc", blocks}` → walked, recursing per block
116
+ * 7. `{html:"…"}` pre-envelope seed → ingested
117
+ * 8. `"{\"root\":…}"` Lexical JSON string → parsed, converted
118
+ * 9. `"<p>…</p>"` bare HTML → ingested
119
+ * 10. `"plain text"` → one block per line, literal
120
+ */
121
+ export function toPortableText(value: unknown, opts: ReadOptions = {}): PortableText {
122
+ const key = opts.keyGenerator ?? randomKey;
123
+
124
+ if (value == null || value === "") return [];
125
+ if (Array.isArray(value)) return normalizePortableText(value, key);
126
+ if (typeof value === "string") return fromString(value, key, opts);
127
+
128
+ if (!isRecord(value)) {
129
+ opts.onUnreadable?.(`unsupported ${typeof value}`, value);
130
+ return [];
131
+ }
132
+
133
+ if (value.format === PORTABLE_TEXT_FORMAT && Array.isArray(value.value)) {
134
+ return normalizePortableText(value.value, key);
135
+ }
136
+
137
+ // The hand-rolled `{version:1, content}` tree the old `ui` renderer emitted. Structurally
138
+ // Portable Text, missing only `_key` and `markDefs`, so normalising is the whole conversion.
139
+ if (value.version === 1 && Array.isArray(value.content)) {
140
+ return normalizePortableText(value.content, key);
141
+ }
142
+
143
+ if (Array.isArray(value.blocks)) return fromNotionDoc(value.blocks, key, opts);
144
+
145
+ // A Lexical envelope carries the state under `value`; a bare state IS the state.
146
+ if (isRecord(value.value) && isRecord((value.value as Record<string, unknown>).root)) {
147
+ return lexicalToPortableText(value.value, { keyGenerator: key }).value;
148
+ }
149
+ if (isRecord(value.root)) return lexicalToPortableText(value, { keyGenerator: key }).value;
150
+
151
+ // The pre-envelope seed. Last, so a real envelope is never mistaken for one.
152
+ if (typeof value.html === "string") return fromString(value.html, key, opts);
153
+
154
+ opts.onUnreadable?.("unrecognised object shape", value);
155
+ return [];
156
+ }
157
+
158
+ /**
159
+ * The `document` / Body format — `{format:"bettercms-notion-doc", version:1, blocks}`.
160
+ *
161
+ * Its blocks are heterogeneous and one of them (`richtext`) nests a whole rich-text value of
162
+ * its own, so this recurses through `toPortableText` rather than duplicating the ladder. That
163
+ * recursion is the point: a Body containing a rich-text block containing a link now produces
164
+ * real markDefs, which the old renderer could not express at any depth.
165
+ */
166
+ function fromNotionDoc(blocks: unknown[], key: KeyGenerator, opts: ReadOptions): PortableText {
167
+ const out: PortableText = [];
168
+ for (const raw of blocks) {
169
+ if (!isRecord(raw)) continue;
170
+ const type = typeof raw.type === "string" ? raw.type : "";
171
+
172
+ if (type === "paragraph") {
173
+ out.push(...plainBlocks(typeof raw.text === "string" ? raw.text : "", key));
174
+ continue;
175
+ }
176
+ if (type === "richtext") {
177
+ out.push(...toPortableText(raw.value, { ...opts, keyGenerator: key }));
178
+ continue;
179
+ }
180
+ // `field`, `component` and `reference` blocks reference something that has to be RESOLVED
181
+ // against the project before it renders. They keep their whole payload in a declared
182
+ // block object so the resolution can happen at publish time, where the project is known.
183
+ out.push({
184
+ _type: PT_TYPES.block,
185
+ _key: key(),
186
+ schemaKey: type || "block",
187
+ schemaLabel: typeof raw.label === "string" ? raw.label : "",
188
+ fields: {
189
+ ...(raw.value !== undefined ? { value: raw.value } : {}),
190
+ ...(typeof raw.fieldType === "string" ? { fieldType: raw.fieldType } : {}),
191
+ },
192
+ });
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * The structured value on a block's props, or null when there is none.
199
+ *
200
+ * Every delivery lane asks this same question — "is there structured content, or do I fall
201
+ * back to the html cache?" — and each one answering it locally is how the lanes drifted in the
202
+ * first place: the hosted renderer preferred `content`, the shared React renderer preferred
203
+ * `content`, and Next and Astro did not look for it at all. One implementation, four callers.
204
+ *
205
+ * Accepts every spelling in the wild: a bare array, `{value}` (the new envelope), and
206
+ * `{content}` (the legacy hand-rolled tree). EMPTY is treated as absent on purpose — an empty
207
+ * array must fall back to `html` rather than render a blank region.
208
+ */
209
+ export function readStructuredContent(props: unknown): PortableText | null {
210
+ if (!isRecord(props)) return null;
211
+ const candidate = props.content ?? props.value;
212
+ const arr = Array.isArray(candidate)
213
+ ? candidate
214
+ : isRecord(candidate)
215
+ ? Array.isArray(candidate.content)
216
+ ? candidate.content
217
+ : Array.isArray(candidate.value)
218
+ ? candidate.value
219
+ : null
220
+ : null;
221
+ return arr && arr.length > 0 ? (arr as PortableText) : null;
222
+ }