@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.
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
@@ -0,0 +1,9 @@
1
+ export * from "./types";
2
+ export * from "./schema";
3
+ export * from "./href";
4
+ export * from "./key";
5
+ export * from "./from-lexical";
6
+ export * from "./plain";
7
+ export * from "./read";
8
+ export * from "./accept";
9
+ export * from "./features";