@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 +1 -1
- package/src/accept.ts +6 -1
- package/src/html.ts +20 -0
- package/src/index.ts +1 -0
- package/src/ingest.ts +57 -1
- package/src/schema.ts +21 -1
- package/src/styled.ts +138 -0
- package/src/types.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "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
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
/**
|