@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/schema.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ALL_ELEMENTS, type AcceptElement } from "./accept";
|
|
2
|
+
import { PT_TYPES, TABLE_CAPABILITY } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Accept elements → one Portable Text schema.
|
|
6
|
+
*
|
|
7
|
+
* THIS FILE EXISTS TO DELETE THREE COPIES OF ITSELF. The same mapping was written by hand in
|
|
8
|
+
* three places that had no way to notice each other drifting:
|
|
9
|
+
*
|
|
10
|
+
* `capsFor` (backend/src/lib/richtext/index.ts) — what the SERVER would accept
|
|
11
|
+
* `richtextFeatures` (backend/src/lib/content/…normalize) — what the WRITE PATH allowed
|
|
12
|
+
* `featuresFor` (dashboard/src/lib/richtext/accept) — what the EDITOR offered
|
|
13
|
+
*
|
|
14
|
+
* When they disagreed, the editor offered a control whose output the sanitiser then stripped
|
|
15
|
+
* on save, so the value was destroyed one layer below the thing everyone kept fixing (FLO-723
|
|
16
|
+
* is that bug, twice). One schema object now drives the editor's toolbar, the server's
|
|
17
|
+
* write-time validation AND the HTML ingest filter, because `htmlToPortableText` takes a
|
|
18
|
+
* schema directly — they cannot drift, because there is only one of them.
|
|
19
|
+
*
|
|
20
|
+
* A note on `PT_LOAD_SCHEMA` below: this is the read/write split, and Portable Text makes it
|
|
21
|
+
* almost free where Lexical made it dangerous.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface PtStyle { name: string; title?: string }
|
|
25
|
+
export interface PtDecorator { name: string; title?: string }
|
|
26
|
+
export interface PtList { name: string; title?: string }
|
|
27
|
+
export interface PtField { name: string; type: string }
|
|
28
|
+
export interface PtAnnotation { name: string; title?: string; fields?: PtField[] }
|
|
29
|
+
export interface PtObject { name: string; title?: string; fields?: PtField[] }
|
|
30
|
+
|
|
31
|
+
export interface PtSchema {
|
|
32
|
+
styles: PtStyle[];
|
|
33
|
+
decorators: PtDecorator[];
|
|
34
|
+
annotations: PtAnnotation[];
|
|
35
|
+
lists: PtList[];
|
|
36
|
+
blockObjects: PtObject[];
|
|
37
|
+
inlineObjects: PtObject[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"] as const;
|
|
41
|
+
|
|
42
|
+
/** Element → the decorator it grants. Bare names, because a decorator carries no data. */
|
|
43
|
+
const DECORATOR_FOR: Partial<Record<AcceptElement, PtDecorator>> = {
|
|
44
|
+
b: { name: "strong", title: "Bold" },
|
|
45
|
+
i: { name: "em", title: "Italic" },
|
|
46
|
+
u: { name: "underline", title: "Underline" },
|
|
47
|
+
s: { name: "strike-through", title: "Strikethrough" },
|
|
48
|
+
code: { name: "code", title: "Inline code" },
|
|
49
|
+
mark: { name: "highlight", title: "Highlight" },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What the editor OFFERS for a given Accept set.
|
|
54
|
+
*
|
|
55
|
+
* `p` carries `blockquote` and the divider, exactly as `featuresFor` did — neither has a
|
|
56
|
+
* Prismic element of its own, and changing that here would silently narrow every existing
|
|
57
|
+
* field on its next save.
|
|
58
|
+
*/
|
|
59
|
+
export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
60
|
+
const has = (e: AcceptElement) => accept.includes(e);
|
|
61
|
+
|
|
62
|
+
// 'normal' is always present. Portable Text has no concept of a block without a style, and
|
|
63
|
+
// a field that accepts nothing structural still has to be able to hold a line of text.
|
|
64
|
+
const styles: PtStyle[] = [{ name: "normal", title: "Paragraph" }];
|
|
65
|
+
for (const h of HEADINGS) if (has(h)) styles.push({ name: h, title: h.toUpperCase() });
|
|
66
|
+
if (has("p")) styles.push({ name: "blockquote", title: "Quote" });
|
|
67
|
+
|
|
68
|
+
const decorators: PtDecorator[] = [];
|
|
69
|
+
for (const el of ALL_ELEMENTS) {
|
|
70
|
+
const d = DECORATOR_FOR[el];
|
|
71
|
+
if (d && has(el)) decorators.push(d);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const annotations: PtAnnotation[] = [];
|
|
75
|
+
if (has("link") || has("internalLink")) {
|
|
76
|
+
annotations.push({
|
|
77
|
+
name: PT_TYPES.link,
|
|
78
|
+
title: "Link",
|
|
79
|
+
// `target` is a FIELD, not a separate annotation: `allowTargetBlank:false` has to strip
|
|
80
|
+
// it on write, and a value you strip has to be somewhere you can find it.
|
|
81
|
+
fields: [
|
|
82
|
+
{ name: "href", type: "string" },
|
|
83
|
+
{ name: "target", type: "string" },
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const lists: PtList[] = [];
|
|
89
|
+
if (has("ul")) lists.push({ name: "bullet", title: "Bulleted list" });
|
|
90
|
+
if (has("ol")) lists.push({ name: "number", title: "Numbered list" });
|
|
91
|
+
if (has("checklist")) lists.push({ name: "check", title: "Checklist" });
|
|
92
|
+
|
|
93
|
+
const blockObjects: PtObject[] = [];
|
|
94
|
+
if (has("p")) blockObjects.push({ name: PT_TYPES.divider, title: "Divider" });
|
|
95
|
+
if (has("pre")) {
|
|
96
|
+
blockObjects.push({
|
|
97
|
+
name: PT_TYPES.code,
|
|
98
|
+
title: "Code block",
|
|
99
|
+
fields: [
|
|
100
|
+
{ name: "code", type: "string" },
|
|
101
|
+
{ name: "language", type: "string" },
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// Image, embed, table and columns all store as ONE generic block object under their existing
|
|
106
|
+
// `builtin:` schemaKeys rather than four bespoke types. That keeps a single converter lane
|
|
107
|
+
// and leaves `BuiltInBlockCard` (799 lines of working editor UI) untouched.
|
|
108
|
+
if (has("image") || has("embed") || has("table") || has("columns")) {
|
|
109
|
+
blockObjects.push({
|
|
110
|
+
name: PT_TYPES.block,
|
|
111
|
+
title: "Embedded block",
|
|
112
|
+
fields: [
|
|
113
|
+
{ name: "schemaKey", type: "string" },
|
|
114
|
+
{ name: "schemaLabel", type: "string" },
|
|
115
|
+
{ name: "fields", type: "object" },
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* A SECOND, table-specific entry — the one exception to "one generic block object".
|
|
121
|
+
*
|
|
122
|
+
* It exists because the generic entry cannot answer the question the HTML ingest has to ask.
|
|
123
|
+
* A custom deserializer rule runs BEFORE the compiled schema is consulted, so the rule has to
|
|
124
|
+
* decide for itself whether this field permits a table; asking "is the generic block object
|
|
125
|
+
* present?" answers yes for a field that accepts only IMAGES, and a `<table>` then lands in a
|
|
126
|
+
* field that forbids one. Nothing stores under this name — it is a capability marker read by
|
|
127
|
+
* `allowsTables`, and the value still stores as `builtin:table` on the generic block.
|
|
128
|
+
*/
|
|
129
|
+
if (has("table")) blockObjects.push({ name: TABLE_CAPABILITY, title: "Table" });
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
styles,
|
|
133
|
+
decorators,
|
|
134
|
+
annotations,
|
|
135
|
+
lists,
|
|
136
|
+
blockObjects,
|
|
137
|
+
// Mentions are not gated on `accept`: they are an authoring affordance rather than a
|
|
138
|
+
// content element, and a stored mention must survive in a field that never offered one.
|
|
139
|
+
inlineObjects: [
|
|
140
|
+
{ name: PT_TYPES.mention, title: "Mention", fields: [{ name: "label", type: "string" }] },
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The READ schema: everything, always.
|
|
147
|
+
*
|
|
148
|
+
* Lexical needed this split and paid dearly for it. It registers NODE CLASSES from the feature
|
|
149
|
+
* list, so stored content using anything outside a field's configured features made
|
|
150
|
+
* `parseEditorState` THROW and the field opened EMPTY — silent data loss on the next autosave.
|
|
151
|
+
* That is why `RICHTEXT_LOAD_FEATURES` carries 15 entries against `RICHTEXT_FEATURES`' 10, and
|
|
152
|
+
* why `createLexicalEditor.ts` warns at length that table nodes must never be unregistered.
|
|
153
|
+
*
|
|
154
|
+
* Portable Text is plain JSON. An unknown `_type` is data, not a parse error — it round-trips
|
|
155
|
+
* untouched and renders through `unknownType`. So this schema is a superset used for READING
|
|
156
|
+
* and the whole failure class is gone; keeping the split is now only about what the toolbar
|
|
157
|
+
* OFFERS, which is a UI question with no way to destroy a value.
|
|
158
|
+
*/
|
|
159
|
+
export const PT_LOAD_SCHEMA: PtSchema = ptSchemaFor(ALL_ELEMENTS);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable Text, the real spec — https://portabletext.org
|
|
3
|
+
*
|
|
4
|
+
* BetterCMS shipped four hand-rolled "Portable Text" trees before this file, and all four
|
|
5
|
+
* diverged from the spec in the same expensive way: no `markDefs`. Without markDefs a link
|
|
6
|
+
* has nowhere to live, so every one of them could render bold text and none of them could
|
|
7
|
+
* render a hyperlink. Two also dropped unknown marks silently. This file is the spec, so the
|
|
8
|
+
* official `@portabletext/*` packages can read and write our content without a shim.
|
|
9
|
+
*
|
|
10
|
+
* THE SHAPE, and why each part earns its place:
|
|
11
|
+
*
|
|
12
|
+
* block ─ one paragraph/heading/quote. `style` says which. `listItem` + `level` make
|
|
13
|
+
* it a list item, because Portable Text keeps lists FLAT and reconstructs the
|
|
14
|
+
* nesting at render time; there is no `<ul>` node to lose.
|
|
15
|
+
* span ─ a run of text inside a block. `marks` are strings, and a mark is either a
|
|
16
|
+
* DECORATOR (a bare name like "strong") or an ANNOTATION (the `_key` of an
|
|
17
|
+
* entry in the block's `markDefs`). That one indirection is the whole reason
|
|
18
|
+
* links, and any other data-carrying mark, are expressible.
|
|
19
|
+
* markDefs ─ the data behind annotation marks. A link's `href` lives here, not on the span.
|
|
20
|
+
* blockObject─ anything that is not text: an image, an embed, a table, a placed component.
|
|
21
|
+
*
|
|
22
|
+
* `_key` is not decoration. It is the stable identity of a block, and it is what lets the
|
|
23
|
+
* Visual Editor address ONE paragraph inside a rich-text field instead of treating the whole
|
|
24
|
+
* field as a single opaque string. Everything the editor gained rests on it, so `_key` is
|
|
25
|
+
* minted on ingest and never trusted from foreign content.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Every Portable Text node carries a type and a stable key. */
|
|
29
|
+
export interface PortableTextNode {
|
|
30
|
+
_type: string;
|
|
31
|
+
_key: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A run of text. `marks` mixes decorator NAMES and markDef `_key`s; a consumer resolves the
|
|
36
|
+
* difference by looking each mark up in the parent block's `markDefs`. A mark that resolves to
|
|
37
|
+
* nothing is a decorator.
|
|
38
|
+
*/
|
|
39
|
+
export interface PortableTextSpan extends PortableTextNode {
|
|
40
|
+
_type: "span";
|
|
41
|
+
text: string;
|
|
42
|
+
marks: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The data behind an annotation mark. `_key` is what a span's `marks` entry points at. */
|
|
46
|
+
export interface PortableTextMarkDef extends PortableTextNode {
|
|
47
|
+
[field: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A text block. Lists are FLAT: `listItem` + `level`, never a nested container. */
|
|
51
|
+
export interface PortableTextBlock extends PortableTextNode {
|
|
52
|
+
_type: "block";
|
|
53
|
+
style: string;
|
|
54
|
+
markDefs: PortableTextMarkDef[];
|
|
55
|
+
children: (PortableTextSpan | PortableTextNode)[];
|
|
56
|
+
listItem?: string;
|
|
57
|
+
level?: number;
|
|
58
|
+
/** Only meaningful when `listItem` is "check". A checklist item's state is content, not style. */
|
|
59
|
+
checked?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Anything that is not text — an image, an embed, a table, a placed component. */
|
|
63
|
+
export interface PortableTextObject extends PortableTextNode {
|
|
64
|
+
[field: string]: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The value of a rich-text field: a flat array of blocks and block objects. */
|
|
68
|
+
export type PortableText = (PortableTextBlock | PortableTextObject)[];
|
|
69
|
+
|
|
70
|
+
export const isPortableTextBlock = (n: unknown): n is PortableTextBlock =>
|
|
71
|
+
!!n && typeof n === "object" && (n as PortableTextNode)._type === "block";
|
|
72
|
+
|
|
73
|
+
export const isPortableTextSpan = (n: unknown): n is PortableTextSpan =>
|
|
74
|
+
!!n && typeof n === "object" && (n as PortableTextNode)._type === "span";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The stored envelope.
|
|
78
|
+
*
|
|
79
|
+
* `value` is the source of truth; `html` is a DERIVED CACHE and is never trusted from a client
|
|
80
|
+
* when `value` is present. The envelope survives rather than storing a bare array because
|
|
81
|
+
* customer sites that shipped months ago read `.html` directly and cannot be recompiled —
|
|
82
|
+
* `packages/sdk/src/richtext.ts` `isRichText()` even returns false for an array, so a bare
|
|
83
|
+
* array would make every deployed `rich()` call return "" and blank the page at 200 OK.
|
|
84
|
+
*/
|
|
85
|
+
export const PORTABLE_TEXT_FORMAT = "portable-text-1" as const;
|
|
86
|
+
|
|
87
|
+
export interface RichTextEnvelope {
|
|
88
|
+
format: typeof PORTABLE_TEXT_FORMAT;
|
|
89
|
+
value: PortableText;
|
|
90
|
+
html: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Our block-object and annotation type names, spelled once.
|
|
95
|
+
*
|
|
96
|
+
* Two repos serialise these and four renderers read them. A typo in any one of them is a node
|
|
97
|
+
* that renders as nothing, with no error — the failure mode this whole migration exists to
|
|
98
|
+
* stop — so the strings live here and nowhere else.
|
|
99
|
+
*/
|
|
100
|
+
export const PT_TYPES = {
|
|
101
|
+
/** A generic embedded block: `{schemaKey, schemaLabel, fields}`, the successor to Lexical's BlockNode. */
|
|
102
|
+
block: "bcmsBlock",
|
|
103
|
+
/** An inline mention. A PT inline object is natively void, which is what Lexical's `token` mode was faking. */
|
|
104
|
+
mention: "bcmsMention",
|
|
105
|
+
divider: "bcmsDivider",
|
|
106
|
+
code: "bcmsCode",
|
|
107
|
+
/** The annotation carrying a hyperlink's `href`. Spelled `link` because that is the spec's convention. */
|
|
108
|
+
link: "link",
|
|
109
|
+
} as const;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The `schemaKey` of the one table BetterCMS has.
|
|
113
|
+
*
|
|
114
|
+
* Both table lanes converge here — pasted HTML through `ingest.ts` and a native Lexical
|
|
115
|
+
* `TableNode` through `from-lexical.ts` — and `plain.ts` reads it back out, so it is spelled
|
|
116
|
+
* once for the same reason `PT_TYPES` is: three copies of a string that renders as nothing
|
|
117
|
+
* when it is wrong.
|
|
118
|
+
*/
|
|
119
|
+
export const BUILTIN_TABLE_KEY = "builtin:table" as const;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A schema-only marker meaning "this field accepts tables". Never stored: a table's VALUE is a
|
|
123
|
+
* generic block object keyed `builtin:table`. It exists because the HTML ingest's table rule
|
|
124
|
+
* runs before the compiled schema is consulted and has to gate itself, and the generic block
|
|
125
|
+
* object cannot tell a table-accepting field from an image-accepting one.
|
|
126
|
+
*/
|
|
127
|
+
export const TABLE_CAPABILITY = "bcmsTableCapability" as const;
|