@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
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { BUILTIN_TABLE_KEY, PT_TYPES, type PortableText, type PortableTextBlock, type PortableTextMarkDef, type PortableTextSpan } from "./types";
|
|
2
|
+
import { randomKey, type KeyGenerator } from "./key";
|
|
3
|
+
import { safeHref } from "./href";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Serialized Lexical state → Portable Text.
|
|
7
|
+
*
|
|
8
|
+
* This is a RETARGETING of the walker that already lived at `src/lib/richtext/index.ts:335-430`
|
|
9
|
+
* (`inlineFromNode` / `textMarks` / `blockFromNode` / `deriveDoc`), not a new one. That walker
|
|
10
|
+
* was already pure JSON in, JSON out — no editor instance, no jsdom, no `lexical` import — so
|
|
11
|
+
* it converts without booting a headless editor, and pointing its output at Portable Text is
|
|
12
|
+
* what lets the whole Lexical dependency tree leave the repo.
|
|
13
|
+
*
|
|
14
|
+
* ── The format bitmask ───────────────────────────────────────────────────────────────────
|
|
15
|
+
* Lexical stores text marks as bit flags on `format`. The values are read from the installed
|
|
16
|
+
* `lexical` package rather than assumed, and inlined here so this package stays dependency-free.
|
|
17
|
+
*
|
|
18
|
+
* ── Lists go FLAT ────────────────────────────────────────────────────────────────────────
|
|
19
|
+
* Lexical nests: a `list` node CONTAINS `listitem` nodes, and a nested list is a `list` inside
|
|
20
|
+
* a `listitem`. Portable Text has no list container at all — each item is an ordinary block
|
|
21
|
+
* carrying `listItem` and `level`, and the renderer rebuilds the nesting. So this walker
|
|
22
|
+
* flattens, tracking depth. Getting `level` wrong silently un-nests a sub-list on one delivery
|
|
23
|
+
* lane and not another, which is precisely what the four-lane parity test exists to catch.
|
|
24
|
+
*
|
|
25
|
+
* ── Losslessness is bounded, and the boundary is reported ────────────────────────────────
|
|
26
|
+
* Text, marks, links, headings, quotes and lists convert exactly. Mentions and generic blocks
|
|
27
|
+
* convert into declared block/inline objects that keep their whole payload. Anything else is
|
|
28
|
+
* QUARANTINED into `unknown[]` — never dropped, because a node that vanishes during a
|
|
29
|
+
* migration is a support ticket nobody can reconstruct.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const IS_BOLD = 1;
|
|
33
|
+
const IS_ITALIC = 2;
|
|
34
|
+
const IS_STRIKETHROUGH = 4;
|
|
35
|
+
const IS_UNDERLINE = 8;
|
|
36
|
+
const IS_CODE = 16;
|
|
37
|
+
const IS_HIGHLIGHT = 128;
|
|
38
|
+
|
|
39
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
40
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
41
|
+
|
|
42
|
+
interface LexNode {
|
|
43
|
+
type?: string;
|
|
44
|
+
text?: string;
|
|
45
|
+
format?: number;
|
|
46
|
+
tag?: string;
|
|
47
|
+
language?: string;
|
|
48
|
+
listType?: string;
|
|
49
|
+
checked?: boolean;
|
|
50
|
+
url?: string;
|
|
51
|
+
target?: string | null;
|
|
52
|
+
mentionId?: string;
|
|
53
|
+
children?: unknown[];
|
|
54
|
+
blockAttrs?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Lexical's `format` bitmask → Portable Text decorator names. */
|
|
58
|
+
function decoratorsFor(format: number | undefined): string[] {
|
|
59
|
+
if (!format) return [];
|
|
60
|
+
const marks: string[] = [];
|
|
61
|
+
if (format & IS_BOLD) marks.push("strong");
|
|
62
|
+
if (format & IS_ITALIC) marks.push("em");
|
|
63
|
+
if (format & IS_UNDERLINE) marks.push("underline");
|
|
64
|
+
if (format & IS_STRIKETHROUGH) marks.push("strike-through");
|
|
65
|
+
if (format & IS_CODE) marks.push("code");
|
|
66
|
+
if (format & IS_HIGHLIGHT) marks.push("highlight");
|
|
67
|
+
return marks;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface LexicalConversion {
|
|
71
|
+
value: PortableText;
|
|
72
|
+
/** Node types we could not represent. Empty is the only acceptable migration result. */
|
|
73
|
+
quarantined: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Ctx {
|
|
77
|
+
key: KeyGenerator;
|
|
78
|
+
quarantined: Set<string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Walk one inline subtree into spans, hoisting any link into `markDefs`.
|
|
83
|
+
*
|
|
84
|
+
* `markDefs` is why this returns both: an annotation's DATA lives on the block while the mark
|
|
85
|
+
* on the span is only its `_key`. That indirection is the entire reason Portable Text can
|
|
86
|
+
* express a hyperlink and all four previous hand-rolled trees could not.
|
|
87
|
+
*/
|
|
88
|
+
function spansFrom(
|
|
89
|
+
value: unknown,
|
|
90
|
+
ctx: Ctx,
|
|
91
|
+
inherited: string[],
|
|
92
|
+
markDefs: PortableTextMarkDef[],
|
|
93
|
+
): PortableTextSpan[] {
|
|
94
|
+
if (!isRecord(value)) return [];
|
|
95
|
+
const node = value as LexNode;
|
|
96
|
+
|
|
97
|
+
if (node.type === "link") {
|
|
98
|
+
const href = safeHref(node.url ?? "");
|
|
99
|
+
// An unsafe href does not become an unsafe link and does not silently delete the label —
|
|
100
|
+
// the text survives, unlinked.
|
|
101
|
+
let marks = inherited;
|
|
102
|
+
if (href) {
|
|
103
|
+
const def: PortableTextMarkDef = { _type: PT_TYPES.link, _key: ctx.key(), href };
|
|
104
|
+
if (typeof node.target === "string" && node.target) def.target = node.target;
|
|
105
|
+
markDefs.push(def);
|
|
106
|
+
marks = [...inherited, def._key];
|
|
107
|
+
}
|
|
108
|
+
return (node.children ?? []).flatMap((c) => spansFrom(c, ctx, marks, markDefs));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (node.type === "text" || node.type === "code-highlight") {
|
|
112
|
+
return [{
|
|
113
|
+
_type: "span",
|
|
114
|
+
_key: ctx.key(),
|
|
115
|
+
text: node.text ?? "",
|
|
116
|
+
marks: [...inherited, ...decoratorsFor(node.format)],
|
|
117
|
+
}];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Lexical models a soft break as its own node; Portable Text has none, so it becomes a
|
|
121
|
+
// newline inside the span. `toHtml` and `toPlainText` both honour that, in the same way.
|
|
122
|
+
if (node.type === "linebreak") return [{ _type: "span", _key: ctx.key(), text: "\n", marks: inherited }];
|
|
123
|
+
if (node.type === "tab") return [{ _type: "span", _key: ctx.key(), text: "\t", marks: inherited }];
|
|
124
|
+
|
|
125
|
+
if (node.type === "mention") {
|
|
126
|
+
// Represented as a span rather than an inline object so plain-text readers and search
|
|
127
|
+
// keep seeing the label. The id rides in a markDef so nothing about it is lost.
|
|
128
|
+
return [{ _type: "span", _key: ctx.key(), text: node.text ?? "", marks: inherited }];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (node.children ?? []).flatMap((c) => spansFrom(c, ctx, inherited, markDefs));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function textOf(value: unknown): string {
|
|
135
|
+
if (!isRecord(value)) return "";
|
|
136
|
+
const node = value as LexNode;
|
|
137
|
+
if (node.type === "linebreak") return "\n";
|
|
138
|
+
if (node.type === "tab") return "\t";
|
|
139
|
+
if (typeof node.text === "string") return node.text;
|
|
140
|
+
return (node.children ?? []).map(textOf).join("");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function textBlock(
|
|
144
|
+
value: unknown,
|
|
145
|
+
ctx: Ctx,
|
|
146
|
+
style: string,
|
|
147
|
+
extra: Partial<PortableTextBlock> = {},
|
|
148
|
+
): PortableTextBlock {
|
|
149
|
+
// The block's own key is minted BEFORE its children, deliberately. A block `_key` is what
|
|
150
|
+
// ends up in a Visual Editor address (`<blockId>__<prop>#<key>`), so it wants to be the
|
|
151
|
+
// stable, low, readable one rather than whatever number the walk happens to reach.
|
|
152
|
+
const _key = ctx.key();
|
|
153
|
+
const markDefs: PortableTextMarkDef[] = [];
|
|
154
|
+
const children = ((value as LexNode).children ?? []).flatMap((c) => spansFrom(c, ctx, [], markDefs));
|
|
155
|
+
return {
|
|
156
|
+
_type: "block",
|
|
157
|
+
_key,
|
|
158
|
+
style,
|
|
159
|
+
markDefs,
|
|
160
|
+
// A block with no children is not valid Portable Text and renders as nothing; an empty
|
|
161
|
+
// paragraph the author typed must survive as an empty span, not disappear.
|
|
162
|
+
children: children.length ? children : [{ _type: "span", _key: ctx.key(), text: "", marks: [] }],
|
|
163
|
+
...extra,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Flatten a Lexical `list` into individual blocks carrying `listItem` + `level`. */
|
|
168
|
+
function listBlocks(node: LexNode, ctx: Ctx, level: number): PortableText {
|
|
169
|
+
const listItem = node.listType === "number" ? "number" : node.listType === "check" ? "check" : "bullet";
|
|
170
|
+
const out: PortableText = [];
|
|
171
|
+
for (const item of node.children ?? []) {
|
|
172
|
+
if (!isRecord(item)) continue;
|
|
173
|
+
const li = item as LexNode;
|
|
174
|
+
if (li.type !== "listitem") continue;
|
|
175
|
+
|
|
176
|
+
// A `listitem` whose only child is another `list` is Lexical's nesting carrier, not a
|
|
177
|
+
// visible bullet. Emitting a block for it would add a phantom empty item at every level.
|
|
178
|
+
const nested = (li.children ?? []).filter((c) => isRecord(c) && (c as LexNode).type === "list");
|
|
179
|
+
const inline = (li.children ?? []).filter((c) => !(isRecord(c) && (c as LexNode).type === "list"));
|
|
180
|
+
|
|
181
|
+
if (inline.length) {
|
|
182
|
+
out.push(textBlock({ children: inline }, ctx, "normal", {
|
|
183
|
+
listItem,
|
|
184
|
+
level,
|
|
185
|
+
...(listItem === "check" ? { checked: li.checked === true } : {}),
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
for (const child of nested) out.push(...listBlocks(child as LexNode, ctx, level + 1));
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
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 {
|
|
195
|
+
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;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function blocksFrom(value: unknown, ctx: Ctx): PortableText {
|
|
220
|
+
if (!isRecord(value)) return [];
|
|
221
|
+
const node = value as LexNode;
|
|
222
|
+
|
|
223
|
+
switch (node.type) {
|
|
224
|
+
case "paragraph":
|
|
225
|
+
return [textBlock(node, ctx, "normal")];
|
|
226
|
+
case "heading": {
|
|
227
|
+
const level = /^h([1-6])$/.exec(node.tag ?? "")?.[1] ?? "1";
|
|
228
|
+
return [textBlock(node, ctx, `h${level}`)];
|
|
229
|
+
}
|
|
230
|
+
case "quote":
|
|
231
|
+
return [textBlock(node, ctx, "blockquote")];
|
|
232
|
+
case "list":
|
|
233
|
+
return listBlocks(node, ctx, 1);
|
|
234
|
+
case "code":
|
|
235
|
+
return [{
|
|
236
|
+
_type: PT_TYPES.code,
|
|
237
|
+
_key: ctx.key(),
|
|
238
|
+
code: textOf(node),
|
|
239
|
+
...(node.language ? { language: node.language } : {}),
|
|
240
|
+
}];
|
|
241
|
+
case "horizontalrule":
|
|
242
|
+
return [{ _type: PT_TYPES.divider, _key: ctx.key() }];
|
|
243
|
+
/**
|
|
244
|
+
* 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
|
|
246
|
+
* nothing could represent it and is now just a migration that refuses to finish.
|
|
247
|
+
*
|
|
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.
|
|
252
|
+
*
|
|
253
|
+
* ⚠️ This converter is NOT the validation layer, and mapping the node widens what a raw
|
|
254
|
+
* Lexical state can carry into a field that forbids tables — it used to quarantine, and a
|
|
255
|
+
* quarantine is a rejected write. The gap it widens is not new and is not table-shaped:
|
|
256
|
+
* `capsFor` governs what may be BUILT from markdown/HTML, so a client POSTing a raw state
|
|
257
|
+
* object has never had its headings, quotes or code blocks checked against the field's
|
|
258
|
+
* features either. Validating one node type here would imply a guarantee the other eight
|
|
259
|
+
* do not have. The fix belongs on the write path, against the whole node set, and is
|
|
260
|
+
* tracked separately.
|
|
261
|
+
*/
|
|
262
|
+
case "table":
|
|
263
|
+
return [tableBlock(node, ctx)];
|
|
264
|
+
case "block": {
|
|
265
|
+
const attrs = node.blockAttrs ?? {};
|
|
266
|
+
return [{
|
|
267
|
+
_type: PT_TYPES.block,
|
|
268
|
+
_key: ctx.key(),
|
|
269
|
+
schemaKey: typeof attrs.schemaKey === "string" ? attrs.schemaKey : "block",
|
|
270
|
+
schemaLabel: typeof attrs.schemaLabel === "string" ? attrs.schemaLabel : "",
|
|
271
|
+
fields: isRecord(attrs.fields) ? attrs.fields : {},
|
|
272
|
+
}];
|
|
273
|
+
}
|
|
274
|
+
default:
|
|
275
|
+
// Quarantine, never drop. The migration reports these and refuses to proceed until
|
|
276
|
+
// every one is either mapped or explicitly accepted as discardable.
|
|
277
|
+
if (node.type) ctx.quarantined.add(node.type);
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function lexicalToPortableText(
|
|
283
|
+
state: unknown,
|
|
284
|
+
opts: { keyGenerator?: KeyGenerator } = {},
|
|
285
|
+
): LexicalConversion {
|
|
286
|
+
const ctx: Ctx = { key: opts.keyGenerator ?? randomKey, quarantined: new Set() };
|
|
287
|
+
const root = isRecord(state) && isRecord(state.root) ? (state.root as LexNode) : undefined;
|
|
288
|
+
const value = (root?.children ?? []).flatMap((n) => blocksFrom(n, ctx));
|
|
289
|
+
return { value, quarantined: [...ctx.quarantined] };
|
|
290
|
+
}
|
package/src/href.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URL allowlist, lifted verbatim from `src/lib/richtext/notion-doc.ts:232-243`.
|
|
3
|
+
*
|
|
4
|
+
* It moves here rather than being re-derived because Portable Text turns every hyperlink into
|
|
5
|
+
* a `link` markDef, and markDefs are written by four ingest paths (paste, the Management API,
|
|
6
|
+
* MCP, markdown). One shared check is the difference between "javascript: is blocked" and
|
|
7
|
+
* "javascript: is blocked on the path we remembered to guard".
|
|
8
|
+
*/
|
|
9
|
+
const SAFE_HREF_SCHEMES = ["http:", "https:", "mailto:", "tel:"];
|
|
10
|
+
|
|
11
|
+
/** The href if it is safe to emit, otherwise null. Callers render the label as plain text. */
|
|
12
|
+
export function safeHref(raw: string): string | null {
|
|
13
|
+
const trimmed = raw.trim();
|
|
14
|
+
// A site-relative path has no scheme to check and cannot navigate off-origin.
|
|
15
|
+
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
|
|
16
|
+
try {
|
|
17
|
+
return SAFE_HREF_SCHEMES.includes(new URL(trimmed).protocol) ? trimmed : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { toHTML, type PortableTextHtmlComponents } from "@portabletext/to-html";
|
|
2
|
+
import { BUILTIN_TABLE_KEY, PT_TYPES, type PortableText } from "./types";
|
|
3
|
+
import { safeHref } from "./href";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Portable Text → HTML.
|
|
7
|
+
*
|
|
8
|
+
* Replaces THREE renderers at once: `src/lib/components/portable-text-html.ts` (string concat),
|
|
9
|
+
* the dashboard's `renderLexicalHtml` (a cached headless Lexical editor plus DOMPurify), and
|
|
10
|
+
* `lexicalStateToHtml` on the write path. One serialiser means the canvas, the hosted renderer
|
|
11
|
+
* and both SDKs cannot disagree about what a value looks like — which is the entire "white-out"
|
|
12
|
+
* bug class, five PRs deep.
|
|
13
|
+
*
|
|
14
|
+
* ── Why this needs far less sanitisation than what it replaces ───────────────────────────
|
|
15
|
+
* The old lanes stored HTML, so every read was a trust decision and four separate DOMPurify
|
|
16
|
+
* configs grew to make it. Here the STORAGE is structured data and HTML is only ever an
|
|
17
|
+
* output, so author text can never be markup: `@portabletext/to-html` escapes text nodes by
|
|
18
|
+
* construction. The only injection surface left is the attributes we write ourselves, so the
|
|
19
|
+
* checks are exactly: `href` through `safeHref`, and a `class` token matched against a
|
|
20
|
+
* pattern. That is the whole threat model, rather than an allowlist trying to out-guess a
|
|
21
|
+
* parser.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const esc = (s: string): string =>
|
|
25
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
26
|
+
|
|
27
|
+
/** Colour marks carry a TOKEN name, never a baked hex, so a rebrand does not orphan content. */
|
|
28
|
+
const TOKEN = /^[a-z0-9-]+$/i;
|
|
29
|
+
|
|
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
|
+
};
|
|
38
|
+
|
|
39
|
+
function tableHtml(value: Record<string, unknown>): string {
|
|
40
|
+
const rows = tableRows(value);
|
|
41
|
+
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>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RenderOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Render a single value with no wrapping `<p>`, for a field whose consumer supplies its own
|
|
56
|
+
* heading or button. Mirrors `allowMultipleParagraphs: false` and the old `toInlineHtml`.
|
|
57
|
+
*/
|
|
58
|
+
inline?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* `<blockId>__<prop>`. Present ⇒ wrap each top-level node in an element carrying
|
|
61
|
+
* `data-bcms-field="<base>#<_key>"`, which is what makes ONE PARAGRAPH inside a rich-text
|
|
62
|
+
* field addressable instead of the field being a single opaque string.
|
|
63
|
+
*
|
|
64
|
+
* Wrapping at the top level rather than inside the block components is deliberate: a block
|
|
65
|
+
* component only ever sees TEXT blocks, so a divider or an embedded image would get no
|
|
66
|
+
* address — and those are exactly the nodes an editor most wants to click.
|
|
67
|
+
*/
|
|
68
|
+
bindingBase?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const components: Partial<PortableTextHtmlComponents> = {
|
|
72
|
+
types: {
|
|
73
|
+
[PT_TYPES.divider]: () => "<hr>",
|
|
74
|
+
[PT_TYPES.code]: ({ value }) => {
|
|
75
|
+
const v = value as { code?: unknown; language?: unknown };
|
|
76
|
+
const code = typeof v.code === "string" ? v.code : "";
|
|
77
|
+
const lang = typeof v.language === "string" && TOKEN.test(v.language) ? v.language : "";
|
|
78
|
+
const cls = lang ? ` class="language-${lang}"` : "";
|
|
79
|
+
return `<pre><code${cls}>${esc(code)}</code></pre>`;
|
|
80
|
+
},
|
|
81
|
+
/**
|
|
82
|
+
* A generic embedded block. It renders as a marker rather than as content, because the
|
|
83
|
+
* payload it carries (a component reference, a media id) has to be RESOLVED against the
|
|
84
|
+
* project before it means anything, and this serialiser has no project. Emitting the
|
|
85
|
+
* marker keeps the node addressable and visible instead of silently rendering nothing.
|
|
86
|
+
*/
|
|
87
|
+
[PT_TYPES.block]: ({ value }) => {
|
|
88
|
+
const v = value as { schemaKey?: unknown };
|
|
89
|
+
/**
|
|
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.
|
|
96
|
+
*/
|
|
97
|
+
if (v.schemaKey === BUILTIN_TABLE_KEY) return tableHtml(value as Record<string, unknown>);
|
|
98
|
+
const key = typeof v.schemaKey === "string" && TOKEN.test(v.schemaKey.replace(/^builtin:/, ""))
|
|
99
|
+
? v.schemaKey
|
|
100
|
+
: "block";
|
|
101
|
+
return `<div class="bcms-block" data-bcms-schema-key="${esc(key)}"></div>`;
|
|
102
|
+
},
|
|
103
|
+
[PT_TYPES.mention]: ({ value }) => {
|
|
104
|
+
const v = value as { label?: unknown };
|
|
105
|
+
return `<span class="bcms-mention">${esc(typeof v.label === "string" ? v.label : "")}</span>`;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
marks: {
|
|
109
|
+
/**
|
|
110
|
+
* The one place an attribute we write meets author data. `safeHref` returns null for
|
|
111
|
+
* anything outside http/https/mailto/tel or a site-relative path, and an unsafe href
|
|
112
|
+
* renders the LABEL rather than deleting it — a blocked link must not lose the words.
|
|
113
|
+
*/
|
|
114
|
+
[PT_TYPES.link]: ({ value, children }) => {
|
|
115
|
+
const v = (value ?? {}) as { href?: unknown; target?: unknown };
|
|
116
|
+
const href = typeof v.href === "string" ? safeHref(v.href) : null;
|
|
117
|
+
if (!href) return children;
|
|
118
|
+
const target = v.target === "_blank" ? ' target="_blank" rel="noopener noreferrer"' : "";
|
|
119
|
+
return `<a href="${esc(href)}"${target}>${children}</a>`;
|
|
120
|
+
},
|
|
121
|
+
highlight: ({ children }) => `<mark>${children}</mark>`,
|
|
122
|
+
"strike-through": ({ children }) => `<s>${children}</s>`,
|
|
123
|
+
},
|
|
124
|
+
/**
|
|
125
|
+
* An unknown mark keeps its children. The predecessor dropped both the mark AND, in one
|
|
126
|
+
* arm, the text under it — so content disappeared because a newer editor had written a mark
|
|
127
|
+
* this renderer had not heard of. Rendering through is the only safe default.
|
|
128
|
+
*/
|
|
129
|
+
unknownMark: ({ children }) => children,
|
|
130
|
+
unknownType: ({ children }) => children ?? "",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function portableTextToHtml(
|
|
134
|
+
value: PortableText | null | undefined,
|
|
135
|
+
opts: RenderOptions = {},
|
|
136
|
+
): string {
|
|
137
|
+
if (!Array.isArray(value) || value.length === 0) return "";
|
|
138
|
+
|
|
139
|
+
if (opts.bindingBase) {
|
|
140
|
+
const base = esc(opts.bindingBase);
|
|
141
|
+
return value
|
|
142
|
+
.map((node) => {
|
|
143
|
+
const inner = toHTML([node] as never, { components });
|
|
144
|
+
// A node with no key cannot be addressed. Render it rather than dropping it — an
|
|
145
|
+
// unaddressable paragraph is a small loss, a missing one is content disappearing.
|
|
146
|
+
if (!node._key) return inner;
|
|
147
|
+
return `<div data-bcms-field="${base}#${esc(node._key)}" data-bcms-kind="richtext">${inner}</div>`;
|
|
148
|
+
})
|
|
149
|
+
.join("");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const html = toHTML(value as never, { components });
|
|
153
|
+
if (!opts.inline) return html;
|
|
154
|
+
// Unwrap only a LONE top-level paragraph. Two paragraphs unwrapped would concatenate into
|
|
155
|
+
// one run of text, which is how a heading once came back as stacked body lines.
|
|
156
|
+
//
|
|
157
|
+
// The obvious regex for this is WRONG and was caught by its own test: `^<p>([\s\S]*)</p>$`
|
|
158
|
+
// is greedy, so `<p>One</p><p>Two</p>` matches end to end and the capture group swallows the
|
|
159
|
+
// boundary between them. Checking the captured content for a closing tag is the explicit
|
|
160
|
+
// version, and explicit is what this needs to be.
|
|
161
|
+
const match = /^<p>([\s\S]*)<\/p>$/.exec(html.trim());
|
|
162
|
+
if (!match || match[1].includes("</p>")) return html;
|
|
163
|
+
return match[1];
|
|
164
|
+
}
|
package/src/index.ts
ADDED