@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 ADDED
@@ -0,0 +1,86 @@
1
+ # @bettercms-ai/richtext
2
+
3
+ The one Portable Text contract for BetterCMS: schema, converters, and rendering. Shared by the
4
+ backend, the dashboard, and every delivery SDK.
5
+
6
+ BetterCMS shipped four hand-rolled "Portable Text" trees before this package, and all four
7
+ diverged from [the spec](https://portabletext.org) in the same expensive way: no `markDefs`.
8
+ Without markDefs a link has nowhere to live, so every one of them could render bold text and
9
+ none of them could render a hyperlink. This package is the spec, so the official
10
+ `@portabletext/*` packages can read and write our content without a shim.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @bettercms-ai/richtext
16
+ ```
17
+
18
+ ## Reading a stored value
19
+
20
+ `toPortableText` reads every shape BetterCMS has ever stored and **never throws** — a throw
21
+ here would be a blank field, and a blank field is one autosave away from being permanent.
22
+ Unrecognised input returns `[]` and is reported through `onUnreadable`.
23
+
24
+ ```ts
25
+ import { toPortableText, portableTextToPlainText } from "@bettercms-ai/richtext";
26
+
27
+ const value = toPortableText(entry.data.body);
28
+ const preview = portableTextToPlainText(value);
29
+ ```
30
+
31
+ It handles a bare `PortableText[]`, the `{format, value, html}` envelope, a Lexical state
32
+ (object or JSON string), the `bettercms-notion-doc` document format, a pre-envelope
33
+ `{html}` seed, bare HTML, and plain text — which stays **literal**, because running an
34
+ author's copy through a Markdown parser is how a line beginning `#` silently became a heading.
35
+
36
+ ## Rendering
37
+
38
+ ```ts
39
+ import { portableTextToHtml } from "@bettercms-ai/richtext/html";
40
+
41
+ const html = portableTextToHtml(value);
42
+ const inline = portableTextToHtml(value, { inline: true }); // unwraps a lone <p>
43
+ ```
44
+
45
+ Storage is structured data and HTML is only ever an output, so author text can never be
46
+ markup: `@portabletext/to-html` escapes text nodes by construction. The only attributes this
47
+ writes are an `href` through `safeHref` and a class token matched against a pattern.
48
+
49
+ ## Ingesting HTML
50
+
51
+ ```ts
52
+ import { htmlToPortableText } from "@bettercms-ai/richtext/ingest";
53
+ import { ptSchemaFor } from "@bettercms-ai/richtext";
54
+
55
+ htmlToPortableText(html, {
56
+ parseHtml: (h) => new JSDOM(h).window.document, // required outside a browser
57
+ schema: ptSchemaFor(["p", "h2", "link", "table"]),
58
+ });
59
+ ```
60
+
61
+ **The filter IS the schema.** A tag with no place in the schema produces *no node* — `<script>`
62
+ is not "sanitised away", it fails to parse into anything. One `ptSchemaFor(accept)` object
63
+ drives the editor's toolbar, the server's write validation, and this filter at once, so they
64
+ cannot drift.
65
+
66
+ ## Subpath exports
67
+
68
+ | import | pulls in | use it for |
69
+ | --- | --- | --- |
70
+ | `@bettercms-ai/richtext` | nothing heavy | types, schema, reading, plain text |
71
+ | `@bettercms-ai/richtext/html` | the HTML serialiser | rendering |
72
+ | `@bettercms-ai/richtext/ingest` | a DOM parser | HTML → Portable Text |
73
+
74
+ The split is deliberate: `ingest` needs a DOM and `html` needs the serialiser, and neither
75
+ should reach a dashboard bundle just because someone imported a type.
76
+
77
+ ## Ships as source
78
+
79
+ `main` points at `./src/index.ts`, like `@bettercms-ai/db` and `@bettercms-ai/content`. The
80
+ BetterCMS API runs Bun and consumes this directly; a `dist`-pointing export once crash-looped
81
+ the container. Consumers that need compiled output should bundle it as they would any
82
+ TypeScript source dependency.
83
+
84
+ ## Licence
85
+
86
+ Proprietary — part of BetterCMS.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@bettercms-ai/richtext",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "description": "The one Portable Text contract for BetterCMS: schema, converters, and rendering. Shared by the backend, the dashboard and every delivery SDK.",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "files": [
10
+ "src",
11
+ "!src/__tests__",
12
+ "package.json",
13
+ "README.md"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./html": "./src/html.ts",
18
+ "./ingest": "./src/ingest.ts"
19
+ },
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit",
22
+ "build": "tsup",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest"
25
+ },
26
+ "dependencies": {
27
+ "@portabletext/to-html": "^6.0.0",
28
+ "@portabletext/html": "^2.0.0",
29
+ "@portabletext/schema": "^3.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "tsup": "^8.5.1",
33
+ "vitest": "^4.1.4"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "registry": "https://registry.npmjs.org/"
38
+ }
39
+ }
package/src/accept.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { RICH_TEXT_CAPABILITY_MANIFEST, type RichTextFeature } from './features'
2
+ import type { AllowEntry } from './features'
3
+
4
+ /**
5
+ * The Accept model — which HTML elements a rich-text field is allowed to contain.
6
+ *
7
+ * ELEMENT-LEVEL, not feature-level, so `h2` and `h3` can be permitted independently — the
8
+ * repo's `RichTextFeature` catalog is one `headings` switch covering H1–H6, which cannot
9
+ * express that. Element names are also the vocabulary the SANITISER speaks, and the sanitiser
10
+ * is what makes this configuration real: a list only the toolbar honours is decorative the
11
+ * moment content arrives by paste, from the AI hook, or from before the list was narrowed.
12
+ */
13
+
14
+ /** The elements Prismic exposes — the default-visible set. */
15
+ export const CORE_ELEMENTS = [
16
+ 'p', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
17
+ 'b', 'i', 'link', 'image', 'code', 'ul', 'ol', 'rtl',
18
+ ] as const
19
+
20
+ /**
21
+ * What this repo can do beyond Prismic. Behind "Advanced" so the panel reads like the spec at
22
+ * rest, but present so enabling them is not a capability deletion.
23
+ */
24
+ export const ADVANCED_ELEMENTS = [
25
+ 'u', 'mark', 's', 'checklist', 'table', 'columns', 'embed', 'internalLink',
26
+ ] as const
27
+
28
+ export const ALL_ELEMENTS = [...CORE_ELEMENTS, ...ADVANCED_ELEMENTS] as const
29
+ export type AcceptElement = (typeof ALL_ELEMENTS)[number]
30
+
31
+ /** Labels name the CAPABILITY, never the key — an author reads "Headings", not `accept.h1`. */
32
+ export const ELEMENT_LABELS: Record<AcceptElement, string> = {
33
+ p: 'Paragraph', pre: 'Preformatted',
34
+ h1: 'H1', h2: 'H2', h3: 'H3', h4: 'H4', h5: 'H5', h6: 'H6',
35
+ b: 'Bold', i: 'Italic', link: 'Link', image: 'Image', code: 'Inline code',
36
+ ul: 'Bulleted list', ol: 'Numbered list', rtl: 'Right-to-left',
37
+ u: 'Underline', mark: 'Highlight', s: 'Strikethrough', checklist: 'Checklist',
38
+ table: 'Table', columns: 'Columns', embed: 'Video embed', internalLink: 'Internal link',
39
+ }
40
+
41
+ /**
42
+ * Grouped for the panel. Twenty-four flat switches is a wall, not a form — an author is
43
+ * choosing what KIND of writing this field is for, and the groups say that out loud.
44
+ */
45
+ export const ELEMENT_GROUPS: { id: string; label: string; elements: AcceptElement[] }[] = [
46
+ { id: 'structure', label: 'Structure', elements: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'pre'] },
47
+ { id: 'marks', label: 'Inline marks', elements: ['b', 'i', 'code', 'u', 'mark', 's'] },
48
+ // `internalLink` stays in ALL_ELEMENTS for stored-config compatibility, but is intentionally
49
+ // absent here: new authoring uses the ordinary URL Link until a real internal-entry contract exists.
50
+ { id: 'insert', label: 'Insert', elements: ['link', 'image', 'table', 'columns', 'embed', 'checklist'] },
51
+ // `rtl` stays in ALL_ELEMENTS for stored-config compatibility. There is no editor command
52
+ // for it yet, so new schema authoring must not advertise an inert toggle.
53
+ ]
54
+
55
+ /**
56
+ * Element → the HTML tags the sanitiser must permit for it.
57
+ *
58
+ * `p` CARRIES `hr` AND `blockquote`, and it has to. `featuresFor` grants `horizontalRule` and
59
+ * `blockquote` to any field that accepts `p` — neither has a Prismic element of its own — so
60
+ * leaving their tags out here made the editor and the sanitiser disagree about the same field:
61
+ * typing `---` really did insert a divider, and `lexicalToBlockHtml` then stripped it on the way
62
+ * to storage, every time. The transformer was fixed twice while the value was being destroyed one
63
+ * layer later, which is why it kept reading as "the shortcut does not work" (FLO-723).
64
+ *
65
+ * The rule this file already states cuts both ways: a list only the toolbar honours is
66
+ * decorative, AND a feature only the editor grants is a value the author loses on save.
67
+ */
68
+ const ELEMENT_TAGS: Record<AcceptElement, string[]> = {
69
+ p: ['p', 'br', 'hr', 'blockquote'], pre: ['pre'],
70
+ h1: ['h1'], h2: ['h2'], h3: ['h3'], h4: ['h4'], h5: ['h5'], h6: ['h6'],
71
+ b: ['strong', 'b'], i: ['em', 'i'], code: ['code'],
72
+ link: ['a'], internalLink: ['a'], image: ['img', 'figure', 'figcaption'],
73
+ ul: ['ul', 'li'], ol: ['ol', 'li'], rtl: ['bdo'],
74
+ u: ['u'], mark: ['mark'], s: ['s', 'strike', 'del'],
75
+ checklist: ['ul', 'li', 'input'], table: ['table', 'thead', 'tbody', 'tr', 'th', 'td'],
76
+ columns: ['section', 'div', 'p', 'br'],
77
+ embed: ['figure', 'iframe', 'video', 'source', 'figcaption'],
78
+ }
79
+
80
+ /**
81
+ * Visible but not yet authorable during the backend-first rollout. The panel renders these as
82
+ * focusable aria-disabled controls with persistent reasons instead of advertising dead toggles.
83
+ */
84
+ export const UNAVAILABLE_ACCEPT_REASONS: Partial<Record<AcceptElement, string>> = {}
85
+
86
+ /** Generic BlockNodes cannot round-trip through component props' legacy HTML storage. */
87
+ export const HTML_BACKED_UNAVAILABLE_ACCEPT_REASONS: Partial<Record<AcceptElement, string>> = {
88
+ image: 'Embedded blocks are available in entry rich text; component rich text stores HTML.',
89
+ embed: 'Embedded blocks are available in entry rich text; component rich text stores HTML.',
90
+ table: 'Embedded blocks are available in entry rich text; component rich text stores HTML.',
91
+ columns: 'Embedded blocks are available in entry rich text; component rich text stores HTML.',
92
+ }
93
+
94
+ const NEW_AUTHORING_EXCLUSIONS = new Set<AcceptElement>(['internalLink', 'rtl'])
95
+
96
+ /** New defaults contain every capability whose complete consumer pipeline is currently live. */
97
+ export const DEFAULT_ACCEPT: AcceptElement[] = ALL_ELEMENTS.filter(
98
+ (element) => !NEW_AUTHORING_EXCLUSIONS.has(element),
99
+ )
100
+
101
+ export interface RichTextConfig {
102
+ accept: AcceptElement[]
103
+ /** Links may open in a new tab. Off ⇒ `target` is stripped, not just hidden in the UI. */
104
+ allowTargetBlank: boolean
105
+ /**
106
+ * Off ⇒ a single-paragraph field: the value is stored INLINE with no `<p>` wrapper, so it
107
+ * can sit inside a consumer's own heading or button without nesting a block in it.
108
+ */
109
+ allowMultipleParagraphs: boolean
110
+ placeholder?: string
111
+ /**
112
+ * Which components the `/` menu may insert. ABSENT means all of them; an EMPTY array means
113
+ * none — an explicit author choice, deliberately distinct from "not configured".
114
+ */
115
+ components?: AllowEntry[]
116
+ }
117
+
118
+ export const DEFAULT_RICHTEXT_CONFIG: RichTextConfig = {
119
+ accept: DEFAULT_ACCEPT,
120
+ allowTargetBlank: true,
121
+ allowMultipleParagraphs: true,
122
+ }
123
+
124
+ /** The tag allowlist the sanitiser enforces for a given Accept set. */
125
+ export function allowedTags(accept: AcceptElement[]): string[] {
126
+ const tags = new Set<string>(['span']) // spans carry marks; never the reason content is stripped
127
+ for (const el of accept) for (const t of ELEMENT_TAGS[el] ?? []) tags.add(t)
128
+ return [...tags]
129
+ }
130
+
131
+ /** Read a stored config bag, filling defaults. Unknown elements are dropped, not trusted. */
132
+ export function readConfig(raw: unknown): RichTextConfig {
133
+ const c = (raw ?? {}) as Partial<RichTextConfig>
134
+ const accept = Array.isArray(c.accept)
135
+ ? (c.accept.filter((e) => (ALL_ELEMENTS as readonly string[]).includes(e)) as AcceptElement[])
136
+ : DEFAULT_ACCEPT
137
+ return {
138
+ accept: accept.length ? accept : DEFAULT_ACCEPT,
139
+ allowTargetBlank: c.allowTargetBlank ?? true,
140
+ allowMultipleParagraphs: c.allowMultipleParagraphs ?? true,
141
+ placeholder: typeof c.placeholder === 'string' ? c.placeholder : undefined,
142
+ // Absent stays absent — `readConfig` must NOT default this to an empty array, or
143
+ // "not configured" would silently become "no components allowed".
144
+ components: Array.isArray(c.components) ? (c.components as AllowEntry[]) : undefined,
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Accept elements → the Lexical FEATURES the editor must enable.
150
+ *
151
+ * Two vocabularies exist because they answer different questions: `accept` is what the
152
+ * SANITISER permits (HTML elements, per-heading-level), while `RichTextFeature` is what the
153
+ * EDITOR registers (plugins and nodes, headings as one switch). This is the only place they
154
+ * meet, so the translation lives here rather than being re-derived at each call site.
155
+ *
156
+ * Getting this wrong is not cosmetic: `createLexicalEditorConfig` registers NODES from the
157
+ * feature list and `markdownTransformers` gates every element transformer on it, so a missing
158
+ * feature means the markdown shortcut AND the slash-menu item both silently do nothing.
159
+ */
160
+ export function featuresFor(accept: AcceptElement[]): RichTextFeature[] {
161
+ const has = (e: AcceptElement) => accept.includes(e)
162
+ const out: RichTextFeature[] = ['bold', 'italic', 'underline', 'strikethrough', 'code', 'highlight']
163
+ if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].some((h) => has(h as AcceptElement))) out.push('headings')
164
+ if (has('pre')) out.push('codeBlock')
165
+ if (has('ul')) out.push('unorderedList')
166
+ if (has('ol')) out.push('orderedList')
167
+ if (has('checklist')) out.push('checklist')
168
+ if (has('link')) out.push('link')
169
+ if (has('internalLink')) out.push('internalLink')
170
+ if (has('image')) out.push('inlineImage')
171
+ if (has('table')) out.push('table')
172
+ if (has('columns')) out.push('columns')
173
+ if (has('embed')) out.push('videoEmbed')
174
+ // Blockquote and horizontal rule have no Prismic element of their own, so they ride with
175
+ // the structural set — a field that allows paragraphs allows a quote and a divider.
176
+ if (has('p')) out.push('blockquote', 'horizontalRule')
177
+ return out
178
+ }
179
+
180
+ /**
181
+ * The one resolution every richtext mount uses. Absent ≢ empty: an explicit `[]` really does mean
182
+ * no features, while `undefined` means EVERYTHING — because no stored field config carries a
183
+ * feature list, so reading undefined as "none" silently disables every shortcut in the field.
184
+ *
185
+ * It lives here, and call sites import it, because the same line re-typed at each mount is how
186
+ * `ArrayField` came to forward `features` raw: node registration then read undefined as NONE while
187
+ * the slash menu read it as ALL, and the mount offered "Heading 1" to an editor with no HeadingNode.
188
+ */
189
+ export function resolveFeatures(config: unknown, explicit?: readonly RichTextFeature[]): RichTextFeature[] {
190
+ return explicit ? [...explicit] : featuresFor(readConfig(config).accept)
191
+ }
192
+
193
+ /** Component props store HTML, which cannot reconstruct opaque generic BlockNodes on reload. */
194
+ export function featuresForHtmlStorage(features: readonly RichTextFeature[]): RichTextFeature[] {
195
+ return features.filter((feature) => {
196
+ const capability = RICH_TEXT_CAPABILITY_MANIFEST[
197
+ feature as keyof typeof RICH_TEXT_CAPABILITY_MANIFEST
198
+ ]
199
+ return !capability || capability.storage !== 'generic-block'
200
+ })
201
+ }
@@ -0,0 +1,104 @@
1
+ // ─── Feature Catalog ───────────────────────────────────────────────────────────
2
+
3
+ export const ALWAYS_ON_FEATURES = [
4
+ 'bold',
5
+ 'italic',
6
+ 'underline',
7
+ 'strikethrough',
8
+ 'code',
9
+ 'highlight',
10
+ ] as const
11
+
12
+ export const TOGGLEABLE_FEATURES = [
13
+ 'headings',
14
+ 'blockquote',
15
+ 'horizontalRule',
16
+ 'orderedList',
17
+ 'unorderedList',
18
+ 'checklist',
19
+ 'link',
20
+ 'internalLink',
21
+ 'inlineImage',
22
+ 'videoEmbed',
23
+ 'codeBlock',
24
+ 'table',
25
+ 'columns',
26
+ ] as const
27
+
28
+ export type ToggleableRichTextFeature = (typeof TOGGLEABLE_FEATURES)[number]
29
+
30
+ /**
31
+ * The authoring/storage contract for every configurable capability.
32
+ *
33
+ * This is deliberately exhaustive. A picker checkbox, an insertion surface, and a load-time
34
+ * node strategy are one product capability; keeping them in separate ad-hoc lists is how FLO-814
35
+ * shipped inert options. `featuresSchism.test.tsx` proves this manifest against the real slash
36
+ * catalog and editor registration.
37
+ */
38
+ export const RICH_TEXT_CAPABILITY_MANIFEST = {
39
+ headings: { authoring: 'slash-and-toolbar', storage: 'native', loader: 'feature-gated' },
40
+ blockquote: { authoring: 'slash-and-toolbar', storage: 'native', loader: 'feature-gated' },
41
+ horizontalRule: { authoring: 'slash', storage: 'native', loader: 'feature-gated' },
42
+ orderedList: { authoring: 'slash-and-toolbar', storage: 'native', loader: 'unconditional' },
43
+ unorderedList: { authoring: 'slash-and-toolbar', storage: 'native', loader: 'unconditional' },
44
+ checklist: { authoring: 'slash', storage: 'native', loader: 'unconditional' },
45
+ link: { authoring: 'toolbar', storage: 'native', loader: 'feature-gated' },
46
+ internalLink: { authoring: 'compatibility-only', storage: 'native', loader: 'feature-gated' },
47
+ inlineImage: { authoring: 'slash', storage: 'generic-block', loader: 'unconditional' },
48
+ videoEmbed: { authoring: 'slash', storage: 'generic-block', loader: 'unconditional' },
49
+ codeBlock: { authoring: 'slash', storage: 'native', loader: 'feature-gated' },
50
+ table: { authoring: 'slash', storage: 'generic-block', loader: 'unconditional' },
51
+ columns: { authoring: 'slash', storage: 'generic-block', loader: 'unconditional' },
52
+ } as const satisfies Record<
53
+ ToggleableRichTextFeature,
54
+ {
55
+ authoring: 'slash' | 'toolbar' | 'slash-and-toolbar' | 'compatibility-only'
56
+ storage: 'native' | 'generic-block'
57
+ loader: 'feature-gated' | 'unconditional'
58
+ }
59
+ >
60
+
61
+ /**
62
+ * What a NEW field may offer today. TOGGLEABLE_FEATURES remains the compatibility catalog:
63
+ * stored internalLink/videoEmbed/columns configs must still register their loaders while those
64
+ * capabilities are absent or gated from new authoring.
65
+ */
66
+ export const AUTHORABLE_TOGGLEABLE_FEATURES = TOGGLEABLE_FEATURES.filter(
67
+ (feature) => RICH_TEXT_CAPABILITY_MANIFEST[feature].authoring !== 'compatibility-only',
68
+ )
69
+
70
+ export const ALL_FEATURES = [
71
+ ...ALWAYS_ON_FEATURES,
72
+ ...TOGGLEABLE_FEATURES,
73
+ ] as const
74
+
75
+ export type RichTextFeature = (typeof ALL_FEATURES)[number]
76
+
77
+ export const FEATURE_LABELS: Record<RichTextFeature, string> = {
78
+ bold: 'Bold',
79
+ italic: 'Italic',
80
+ underline: 'Underline',
81
+ strikethrough: 'Strikethrough',
82
+ code: 'Inline code',
83
+ highlight: 'Highlight',
84
+ headings: 'Headings (H1–H6)',
85
+ blockquote: 'Blockquote',
86
+ horizontalRule: 'Horizontal rule',
87
+ orderedList: 'Ordered list',
88
+ unorderedList: 'Unordered list',
89
+ checklist: 'Checklist',
90
+ link: 'External link',
91
+ internalLink: 'Internal link',
92
+ inlineImage: 'Inline image',
93
+ videoEmbed: 'Video embed',
94
+ codeBlock: 'Code block',
95
+ table: 'Table',
96
+ columns: 'Columns',
97
+ }
98
+
99
+ // ─── Component allowlist (moved with `accept`, which references it) ───────────
100
+
101
+ /** The `/` menu's allowlist entry: a whole section-type family, or one component by id. */
102
+ export type AllowEntry =
103
+ | { kind: 'family'; sectionType: string; variantIds?: string[] }
104
+ | { kind: 'component'; id: string }