@bettercms-ai/richtext 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/richtext",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "The one Portable Text contract for BetterCMS: schema, converters, and rendering. Shared by the backend, the dashboard and every delivery SDK.",
package/src/accept.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { RICH_TEXT_CAPABILITY_MANIFEST, type RichTextFeature } from './features'
2
2
  import type { AllowEntry } from './features'
3
+ import { STYLED_TAGS } from './styled'
3
4
 
4
5
  /**
5
6
  * The Accept model — which HTML elements a rich-text field is allowed to contain.
@@ -123,7 +124,11 @@ export const DEFAULT_RICHTEXT_CONFIG: RichTextConfig = {
123
124
 
124
125
  /** The tag allowlist the sanitiser enforces for a given Accept set. */
125
126
  export function allowedTags(accept: AcceptElement[]): string[] {
126
- const tags = new Set<string>(['span']) // spans carry marks; never the reason content is stripped
127
+ // `span` carries marks and is never the reason content is stripped; the rest of `STYLED_TAGS`
128
+ // is the site's OWN inline markup, admitted on the same terms and for the same reason the
129
+ // `styled` annotation is granted to every field — see `styled.ts`. Gating them on an Accept
130
+ // element would let the sanitiser undo the round trip the annotation exists to make lossless.
131
+ const tags = new Set<string>(['span', ...STYLED_TAGS])
127
132
  for (const el of accept) for (const t of ELEMENT_TAGS[el] ?? []) tags.add(t)
128
133
  return [...tags]
129
134
  }
package/src/html.ts CHANGED
@@ -2,6 +2,7 @@ import { toHTML, type PortableTextHtmlComponents } from "@portabletext/to-html";
2
2
  import { PT_TYPES, type PortableText, type PortableTextTable } from "./types";
3
3
  import { builtInTableToTable } from "./table";
4
4
  import { safeHref } from "./href";
5
+ import { isStyledTag, styledAttrEntries } from "./styled";
5
6
 
6
7
  /**
7
8
  * Portable Text → HTML.
@@ -155,6 +156,25 @@ const components: Partial<PortableTextHtmlComponents> = {
155
156
  const target = v.target === "_blank" ? ' target="_blank" rel="noopener noreferrer"' : "";
156
157
  return `<a href="${esc(href)}"${target}>${children}</a>`;
157
158
  },
159
+ /**
160
+ * The site's own inline markup, written back out as itself.
161
+ *
162
+ * Every value here came from content, so every value here is escaped and the tag is
163
+ * re-checked against `STYLED_TAGS` — a markDef can arrive from an import, the Management
164
+ * API or an AI write, and `<${value.tag}>` with no check is how a stored string becomes a
165
+ * `<script>`. An unrecognised tag renders its CHILDREN rather than nothing: the words are
166
+ * the author's, the wrapper is only the design.
167
+ */
168
+ [PT_TYPES.styled]: ({ value, children }) => {
169
+ const v = (value ?? {}) as { tag?: unknown; className?: unknown; attrs?: unknown };
170
+ const tag = typeof v.tag === "string" ? v.tag.toLowerCase() : "";
171
+ if (!isStyledTag(tag)) return children;
172
+ const cls = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
173
+ const attrs = styledAttrEntries(v.attrs)
174
+ .map(([name, val]) => ` ${name}="${esc(val)}"`)
175
+ .join("");
176
+ return `<${tag}${cls}${attrs}>${children}</${tag}>`;
177
+ },
158
178
  highlight: ({ children }) => `<mark>${children}</mark>`,
159
179
  "strike-through": ({ children }) => `<s>${children}</s>`,
160
180
  },
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./key";
5
5
  export * from "./from-lexical";
6
6
  export * from "./plain";
7
7
  export * from "./table";
8
+ export * from "./styled";
8
9
  export * from "./read";
9
10
  export * from "./accept";
10
11
  export * from "./features";
package/src/ingest.ts CHANGED
@@ -3,6 +3,7 @@ import { compileSchema } from "@portabletext/schema";
3
3
  import { PT_TYPES, type PortableText, type PortableTextBlock } from "./types";
4
4
  import { randomKey, isValidKey, type KeyGenerator } from "./key";
5
5
  import { safeHref } from "./href";
6
+ import { isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
6
7
  import type { PtSchema } from "./schema";
7
8
 
8
9
  /**
@@ -180,6 +181,58 @@ function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText
180
181
  };
181
182
  }
182
183
 
184
+ /**
185
+ * An inline element Portable Text does not model → the `styled` annotation.
186
+ *
187
+ * 🔴 THE REASON THIS EXISTS is measured, not theoretical: 13–28% of the visible text on an
188
+ * imported site sits inside a `<span class>` in a heading or a paragraph, and none of it could
189
+ * be made editable while this round trip was lossy — the span came back as bare words and the
190
+ * design died on the author's first save. `derive-schema` refused those elements ON PURPOSE
191
+ * for exactly that reason.
192
+ *
193
+ * A `<span>` with nothing on it is NOT annotated: it carries no design, so recording it would
194
+ * mint a markDef per stray wrapper and bloat every value for nothing. The other tags are
195
+ * annotated bare, because there the TAG is the design (`<small>`, `<sup>`, `<kbd>`).
196
+ *
197
+ * Decorator tags (`b/strong`, `i/em`, `u`, `s`, `mark`, `code`) are deliberately absent: they
198
+ * have real decorators and the standard rules run right after this one.
199
+ *
200
+ * `__annotation` is the deserialiser's own internal placeholder — the same one its built-in
201
+ * link rule returns — so nesting composes for free: `<strong><span class>` comes back as one
202
+ * span carrying both marks, in that order, and renders back out nested the same way.
203
+ */
204
+ function styledRule(key: KeyGenerator): DeserializerRule {
205
+ return {
206
+ deserialize(el: Node, next) {
207
+ if (!isElement(el)) return undefined;
208
+ const tag = el.nodeName.toLowerCase();
209
+ if (!isStyledTag(tag)) return undefined;
210
+ const className = el.getAttribute("class")?.trim() || undefined;
211
+ const attrs = styledAttrsOf(el);
212
+ if (tag === "span" && !className && !attrs) return undefined;
213
+ const markDef: StyledMarkDef = {
214
+ _type: PT_TYPES.styled,
215
+ _key: key(),
216
+ tag,
217
+ ...(className ? { className } : {}),
218
+ ...(attrs ? { attrs } : {}),
219
+ };
220
+ return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
221
+ },
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Does this field's schema carry the `styled` annotation?
227
+ *
228
+ * Gated for the same reason the table rule is: a custom rule runs BEFORE the compiled schema
229
+ * and its output is taken as given, so an unconditional rule would be a way past the Accept
230
+ * config. `ptSchemaFor` grants `styled` to every field, because it is the site's design rather
231
+ * than an authoring capability — but a caller passing a hand-built schema still decides.
232
+ */
233
+ const allowsStyled = (schema: PtSchema | undefined): boolean =>
234
+ !schema || schema.annotations.some((a) => a.name === PT_TYPES.styled);
235
+
183
236
  /** Does this field's schema carry the table block object? Asked by name, not by capability. */
184
237
  const allowsTables = (schema: PtSchema | undefined): boolean =>
185
238
  !schema || schema.blockObjects.some((o) => o.name === PT_TYPES.table);
@@ -208,7 +261,10 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
208
261
  * for an image or an embed alike, so gating on it would let a `<table>` into a field that
209
262
  * accepts only images.
210
263
  */
211
- ...(allowsTables(opts.schema) ? { rules: [tableRule(key, (cell) => htmlToPortableText(cell, opts))] } : {}),
264
+ rules: [
265
+ ...(allowsTables(opts.schema) ? [tableRule(key, (cell) => htmlToPortableText(cell, opts))] : []),
266
+ ...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
267
+ ],
212
268
  keyGenerator: key,
213
269
  });
214
270
  return normalise(converted, key);
package/src/schema.ts CHANGED
@@ -81,7 +81,27 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
81
81
  if (d && has(el)) decorators.push(d);
82
82
  }
83
83
 
84
- const annotations: PtAnnotation[] = [];
84
+ /**
85
+ * `styled` is granted to EVERY field, and that is not a leak in the Accept model.
86
+ *
87
+ * It is not a capability an author can reach — no toolbar button offers it, and no ingest
88
+ * mints one from author input. It records the SITE'S OWN inline markup (`<span class>`,
89
+ * `<time datetime>`) so that a heading an importer bound as a field survives being saved.
90
+ * Gating it on an Accept element would mean a field that happens not to accept `b` silently
91
+ * strips the design out of the copy it is holding — the loss this annotation exists to stop,
92
+ * moved one layer down.
93
+ */
94
+ const annotations: PtAnnotation[] = [
95
+ {
96
+ name: PT_TYPES.styled,
97
+ title: "Styled",
98
+ fields: [
99
+ { name: "tag", type: "string" },
100
+ { name: "className", type: "string" },
101
+ { name: "attrs", type: "object" },
102
+ ],
103
+ },
104
+ ];
85
105
  if (has("link") || has("internalLink")) {
86
106
  annotations.push({
87
107
  name: PT_TYPES.link,
package/src/styled.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The `styled` annotation — a site's own inline markup, carried through Portable Text.
3
+ *
4
+ * WHY IT EXISTS. An imported site writes `<h1>Notes from a <span class="serif italic">web
5
+ * studio</span></h1>`. Portable Text models `strong`/`em`/`underline`/`strike`/`code`/
6
+ * `highlight` and `link`, and NOTHING ELSE inline — so that span had no node to become,
7
+ * `unknownMark` rendered its children, and the class was gone the first time an author saved.
8
+ * That loss is why `derive-schema` deliberately refused to bind any heading containing a
9
+ * `<span class>`: a field that destroys the design on save is worse than no field at all.
10
+ *
11
+ * So this is NOT an authoring mark. There is no toolbar button and no editing UI: it is the
12
+ * site's design, recorded so the round trip is lossless, and it rides along with whatever the
13
+ * author actually types inside it.
14
+ *
15
+ * ── The attribute allowlist IS the threat model ──────────────────────────────────────────
16
+ * A markDef is data, and data reaches this platform from an import, the Management API, MCP
17
+ * and AI writes. Rendering an arbitrary attribute bag would be an XSS primitive wearing a
18
+ * design system's clothes, so exactly four names plus `data-*` are admitted, and the render
19
+ * escapes every value. `id` is refused because it is an addressing surface (a duplicate id
20
+ * breaks anchors and label association), `data-bcms-*` because those attributes ARE the Visual
21
+ * Editor's binding addresses and content must not be able to mint one. `style` is admitted but
22
+ * NOT passed through — its value is a language of its own, bounded by `safeStyle` below.
23
+ */
24
+
25
+ /** Inline tags a `styled` annotation may wear. Nothing outside this list is ever rendered. */
26
+ export const STYLED_TAGS = ["span", "small", "sup", "sub", "kbd", "abbr", "time"] as const;
27
+
28
+ export type StyledTag = (typeof STYLED_TAGS)[number];
29
+
30
+ const TAG_SET: ReadonlySet<string> = new Set<string>(STYLED_TAGS);
31
+
32
+ export const isStyledTag = (tag: unknown): tag is StyledTag =>
33
+ typeof tag === "string" && TAG_SET.has(tag);
34
+
35
+ /**
36
+ * Attribute names a `styled` annotation may carry.
37
+ *
38
+ * Presentational or descriptive only. Never `href`/`src` (a navigation or fetch surface),
39
+ * never `id` (an addressing surface), never `on*` (script), and never `data-bcms-*` — those
40
+ * are binding addresses the editor reads, and content that could write one could point an
41
+ * author's edit at somebody else's field.
42
+ */
43
+ const ATTR_NAMES: ReadonlySet<string> = new Set(["title", "datetime", "style", "aria-label"]);
44
+
45
+ const DATA_ATTR = /^data-[a-z][a-z0-9-]*$/;
46
+
47
+ export const isStyledAttr = (name: string): boolean =>
48
+ ATTR_NAMES.has(name) || (DATA_ATTR.test(name) && !name.startsWith("data-bcms-"));
49
+
50
+ /**
51
+ * CSS an inline run may carry: how the words LOOK inside their own box, and nothing else.
52
+ *
53
+ * `style` is the one admitted attribute whose value is itself a language, so the tag allowlist
54
+ * buys nothing here — a bare passthrough lets any markDef write
55
+ * `position:fixed;inset:0;z-index:9999`, which is a full-viewport overlay over the published
56
+ * page (clickjacking, defacement) minted by anyone who can write content, and
57
+ * `background:url(https://…)` which fetches a third-party URL on every page view. Neither is a
58
+ * design an import needs, so neither is on the list. Properties that only paint glyphs are.
59
+ *
60
+ * Values are checked as well as names, because `url()`, a CSS escape (`\75 rl(`), a comment and
61
+ * `expression()` are all ways to smuggle a fetch past a name check.
62
+ */
63
+ const STYLE_PROPS: ReadonlySet<string> = new Set([
64
+ "color", "background-color", "opacity",
65
+ "font", "font-family", "font-size", "font-style", "font-weight", "font-variant",
66
+ "font-feature-settings", "font-variation-settings",
67
+ "letter-spacing", "line-height", "word-spacing", "white-space", "word-break",
68
+ "text-align", "text-decoration", "text-decoration-color", "text-decoration-line",
69
+ "text-decoration-style", "text-transform", "text-underline-offset", "vertical-align",
70
+ ]);
71
+
72
+ const STYLE_VALUE_BAN = /url\(|expression\(|@import|\\|\/\*|[<>]/i;
73
+
74
+ /**
75
+ * The declarations of a `style` attribute that survive, rejoined — or undefined when none do.
76
+ * Custom properties (`--brand`) are kept: they paint nothing on their own, and a site's own CSS
77
+ * is what decides whether they mean anything.
78
+ */
79
+ export function safeStyle(css: string): string | undefined {
80
+ const kept: string[] = [];
81
+ for (const declaration of css.split(";")) {
82
+ const at = declaration.indexOf(":");
83
+ if (at < 0) continue;
84
+ const name = declaration.slice(0, at).trim().toLowerCase();
85
+ const value = declaration.slice(at + 1).trim();
86
+ if (!name || !value) continue;
87
+ if (!name.startsWith("--") && !STYLE_PROPS.has(name)) continue;
88
+ if (STYLE_VALUE_BAN.test(value)) continue;
89
+ kept.push(`${name}:${value}`);
90
+ }
91
+ return kept.length ? kept.join(";") : undefined;
92
+ }
93
+
94
+ /** The annotation's payload, as stored. `tag` is validated again at render time. */
95
+ export interface StyledMarkDef {
96
+ _type: "styled";
97
+ _key: string;
98
+ tag: StyledTag;
99
+ className?: string;
100
+ attrs?: Record<string, string>;
101
+ }
102
+
103
+ /**
104
+ * The admitted attributes of an element, in source order, or undefined when there are none.
105
+ *
106
+ * `class` is deliberately absent — it lives on the markDef as `className`, because it is the
107
+ * one attribute every consumer of this annotation needs to reach without parsing a bag.
108
+ */
109
+ export function styledAttrsOf(el: Element): Record<string, string> | undefined {
110
+ const out: Record<string, string> = {};
111
+ for (const attr of Array.from(el.attributes)) {
112
+ const name = attr.name.toLowerCase();
113
+ if (name === "class" || !isStyledAttr(name)) continue;
114
+ const value = name === "style" ? safeStyle(attr.value) : attr.value;
115
+ if (value === undefined) continue;
116
+ out[name] = value;
117
+ }
118
+ return Object.keys(out).length ? out : undefined;
119
+ }
120
+
121
+ /**
122
+ * The same filter applied to a STORED bag, which may have arrived from anywhere.
123
+ *
124
+ * Both ends, and this is the end that matters: a markDef reaches storage from an import, the
125
+ * Management API, MCP and AI writes, so the ingest filter is a courtesy and this one is the
126
+ * control. Every renderer of the annotation goes through here.
127
+ */
128
+ export function styledAttrEntries(attrs: unknown): [string, string][] {
129
+ if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) return [];
130
+ const out: [string, string][] = [];
131
+ for (const [name, raw] of Object.entries(attrs as Record<string, unknown>)) {
132
+ if (!isStyledAttr(name) || typeof raw !== "string") continue;
133
+ const value = name === "style" ? safeStyle(raw) : raw;
134
+ if (value === undefined) continue;
135
+ out.push([name, value]);
136
+ }
137
+ return out;
138
+ }
package/src/types.ts CHANGED
@@ -176,6 +176,12 @@ export const PT_TYPES = {
176
176
  table: "table",
177
177
  /** The annotation carrying a hyperlink's `href`. Spelled `link` because that is the spec's convention. */
178
178
  link: "link",
179
+ /**
180
+ * The annotation carrying a site's OWN inline markup — `<span class>`, `<small>`, `<time
181
+ * datetime>`. Unprefixed, like `table`, because it describes HTML rather than a BetterCMS
182
+ * concept. See `styled.ts` for what it may hold and why the list is that short.
183
+ */
184
+ styled: "styled",
179
185
  } as const;
180
186
 
181
187
  /**