@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/README.md +86 -0
- package/package.json +39 -0
- package/src/accept.ts +201 -0
- package/src/features.ts +104 -0
- package/src/from-lexical.ts +290 -0
- package/src/href.ts +21 -0
- package/src/html.ts +164 -0
- package/src/index.ts +9 -0
- package/src/ingest.ts +233 -0
- package/src/key.ts +48 -0
- package/src/plain.ts +45 -0
- package/src/read.ts +222 -0
- package/src/schema.ts +159 -0
- package/src/types.ts +127 -0
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
|
+
}
|