@bettercms-ai/richtext 0.2.0 → 0.4.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 +2 -1
- package/src/accept.ts +6 -1
- package/src/from-lexical.ts +3 -0
- package/src/heading.ts +132 -0
- package/src/href.ts +6 -0
- package/src/html.ts +33 -3
- 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.4.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.",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/index.ts",
|
|
17
17
|
"./html": "./src/html.ts",
|
|
18
|
+
"./heading": "./src/heading.ts",
|
|
18
19
|
"./ingest": "./src/ingest.ts"
|
|
19
20
|
},
|
|
20
21
|
"scripts": {
|
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/from-lexical.ts
CHANGED
|
@@ -49,6 +49,7 @@ interface LexNode {
|
|
|
49
49
|
checked?: boolean;
|
|
50
50
|
url?: string;
|
|
51
51
|
target?: string | null;
|
|
52
|
+
rel?: string | null;
|
|
52
53
|
mentionId?: string;
|
|
53
54
|
children?: unknown[];
|
|
54
55
|
blockAttrs?: Record<string, unknown>;
|
|
@@ -102,6 +103,8 @@ function spansFrom(
|
|
|
102
103
|
if (href) {
|
|
103
104
|
const def: PortableTextMarkDef = { _type: PT_TYPES.link, _key: ctx.key(), href };
|
|
104
105
|
if (typeof node.target === "string" && node.target) def.target = node.target;
|
|
106
|
+
// `rel` rides next to `target` — the importer keeps `prev`/`next` on pagination links.
|
|
107
|
+
if (typeof node.rel === "string" && node.rel) (def as PortableTextMarkDef & { rel?: string }).rel = node.rel;
|
|
105
108
|
markDefs.push(def);
|
|
106
109
|
marks = [...inherited, def._key];
|
|
107
110
|
}
|
package/src/heading.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `richtext` block rendered AS a heading — `props.level` (1–6).
|
|
3
|
+
*
|
|
4
|
+
* Closes the gap between the two blocks that each held half of a title: `heading` has a level
|
|
5
|
+
* but a PLAIN `text` (the renderer escapes it, so `Equip Your Projects with <span
|
|
6
|
+
* class="text-yellow-500">ScrewFast</span>` loses its brand span), and `richtext` keeps the
|
|
7
|
+
* markup but had no level. With `level` set, the renderer emits `<hN>` around the rich copy
|
|
8
|
+
* instead of `<div class="bcms-text">`, so the marks survive AND the element is a real heading —
|
|
9
|
+
* the one site CSS sizes (`.bcms-page h1{font-size:…}` keys on the tag, not a class).
|
|
10
|
+
*
|
|
11
|
+
* WHY A LEVEL ON `richtext` RATHER THAN MARKS ON `heading.text`: `props.html` is already the one
|
|
12
|
+
* MARKUP slot every writer, override and coercion rule knows about (`MARKUP_PATH` in
|
|
13
|
+
* prop-value.ts, block-pass.ts and @bettercms-ai/ui/resolve). Making `heading.props.text` a
|
|
14
|
+
* Portable Text value would add a second markup slot, and every reader that does
|
|
15
|
+
* `String(p.text)` would print `[object Object]`. A number beside `html` touches nothing that
|
|
16
|
+
* reads `html`.
|
|
17
|
+
*
|
|
18
|
+
* ── The one rule both the validator and the renderer apply ──────────────────────────────────
|
|
19
|
+
* A heading holds PHRASING content only. `<h1><p>…</p><p>…</p></h1>` is not a heading, it is
|
|
20
|
+
* two paragraphs in a heading-shaped box, and it is what a naïve wrapper would emit for every
|
|
21
|
+
* editor that stores a lone line as `<p>…</p>`. So the value must reduce to ONE line of inline
|
|
22
|
+
* copy: a lone Portable Text text block, or html with at most a bare `<p>`/`<hN>` wrapper around
|
|
23
|
+
* inline markup. The write schema refuses anything else with a message naming the fix; the
|
|
24
|
+
* renderer, which also sees values no block schema checked (component overrides), falls back to
|
|
25
|
+
* the ordinary `<div class="bcms-text">` rather than emit invalid markup or drop copy.
|
|
26
|
+
*/
|
|
27
|
+
import { isPortableTextBlock, type PortableTextBlock } from "./types";
|
|
28
|
+
import { readStructuredContent } from "./read";
|
|
29
|
+
import { portableTextToHtml } from "./html";
|
|
30
|
+
|
|
31
|
+
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The level a `richtext` block renders at, or null for "not a heading" — which is every block
|
|
35
|
+
* stored before this existed. No clamping: `heading` clamps because its level is always a
|
|
36
|
+
* heading, but here an absent/invalid level must mean the ordinary paragraph render, so a
|
|
37
|
+
* stray `0`, `null` or `""` cannot quietly turn body copy into an `<h1>`.
|
|
38
|
+
*/
|
|
39
|
+
export function richtextHeadingLevel(value: unknown): HeadingLevel | null {
|
|
40
|
+
if (value === null || value === undefined || value === "" || typeof value === "boolean") return null;
|
|
41
|
+
const n = Number(value);
|
|
42
|
+
return Number.isInteger(n) && n >= 1 && n <= 6 ? (n as HeadingLevel) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Flow-content tags a heading may not contain. Phrasing tags (`span`, `a`, `strong`, `br`) pass. */
|
|
46
|
+
const BLOCK_TAG =
|
|
47
|
+
/<\/?(?:address|article|aside|blockquote|details|dialog|div|dl|dd|dt|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)\b/i;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* An ATTRIBUTE-LESS lone `<p>` or `<hN>`. With attributes it is not unwrapped: dropping a
|
|
51
|
+
* `<p class="lead">` would silently change the design, so that value is refused instead.
|
|
52
|
+
*/
|
|
53
|
+
const LONE_WRAPPER = /^<(p|h[1-6])>([\s\S]*)<\/\1>$/i;
|
|
54
|
+
|
|
55
|
+
/** `html` as the inner content of a heading, or null when it is not one line of inline copy. */
|
|
56
|
+
export function inlineHeadingHtml(html: string): string | null {
|
|
57
|
+
const m = LONE_WRAPPER.exec(html.trim());
|
|
58
|
+
const inner = m ? m[2]! : html;
|
|
59
|
+
return BLOCK_TAG.test(inner) ? null : inner;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The ONE Portable Text block a heading can be made of, or null. A list item, a quote, a table
|
|
64
|
+
* or a second block is not a heading; `normal` and an existing `h1`–`h6` style are (the level
|
|
65
|
+
* on the block, not the style in the value, decides the tag).
|
|
66
|
+
*/
|
|
67
|
+
export function loneHeadingBlock(content: unknown[]): PortableTextBlock | null {
|
|
68
|
+
if (content.length !== 1) return null;
|
|
69
|
+
const node = content[0];
|
|
70
|
+
if (!isPortableTextBlock(node) || node.listItem !== undefined) return null;
|
|
71
|
+
const style = node.style ?? "normal";
|
|
72
|
+
return style === "normal" || /^h[1-6]$/.test(style) ? node : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when these richtext props can render as a heading. What the write schema enforces. */
|
|
76
|
+
export function canRenderAsHeading(props: Record<string, unknown>): boolean {
|
|
77
|
+
const structured = readStructuredContent(props);
|
|
78
|
+
if (structured) return loneHeadingBlock(structured) !== null;
|
|
79
|
+
return inlineHeadingHtml(typeof props.html === "string" ? props.html : "") !== null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The level the VALUE spells for itself — a lone `<h3>` wrapper, or a lone `h3`-styled Portable
|
|
84
|
+
* Text block — or null when it names none (a `<p>`, bare inline copy, `normal`).
|
|
85
|
+
*
|
|
86
|
+
* `props.level` decides the tag, so a value that names a DIFFERENT one is a contradiction the
|
|
87
|
+
* write schema refuses rather than silently overriding: `{ html: "<h3>T</h3>", level: 1 }` would
|
|
88
|
+
* store as an h3 and render as an h1, and the editor's block-style control reads the VALUE.
|
|
89
|
+
* Same class of defect as `headingLevelMismatch` in publish-static.ts, one layer up.
|
|
90
|
+
*/
|
|
91
|
+
export function valueHeadingLevel(props: Record<string, unknown>): HeadingLevel | null {
|
|
92
|
+
const structured = readStructuredContent(props);
|
|
93
|
+
const tag = structured
|
|
94
|
+
? loneHeadingBlock(structured)?.style
|
|
95
|
+
: LONE_WRAPPER.exec(String(props.html ?? "").trim())?.[1];
|
|
96
|
+
return tag && /^h[1-6]$/i.test(tag) ? (Number(tag[1]) as HeadingLevel) : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The inner markup + binding ADDRESS of a levelled richtext value, or null when the value is
|
|
101
|
+
* not one line of inline copy (the caller then renders it as ordinary rich text).
|
|
102
|
+
*
|
|
103
|
+
* Shared by all three renderers — render-page.ts, @bettercms-ai/next and @bettercms-ai/astro —
|
|
104
|
+
* because a heading assembled once per lane is exactly the divergence `parity.test.ts` exists to
|
|
105
|
+
* catch: three green suites and one lane shipping a `<div>` where the others ship an `<hN>`.
|
|
106
|
+
* The lanes differ only in how they SPELL attributes (a string, a React prop, an Astro spread),
|
|
107
|
+
* so the address comes back raw and each lane formats it the way it already does.
|
|
108
|
+
*
|
|
109
|
+
* The binding is the one each lane already emits, moved from the wrapper onto the heading so no
|
|
110
|
+
* `<div>` lands inside an `<hN>`: the html lane binds the whole field (`<blockId>__html`); the
|
|
111
|
+
* Portable Text lane binds the lone block by its `_key` (`<base>#<_key>`), exactly as
|
|
112
|
+
* `portableTextToHtml({ bindingBase })` addresses it — so the editor's writer is unchanged.
|
|
113
|
+
*/
|
|
114
|
+
export function headingParts(
|
|
115
|
+
/** Any block's props — `{ html?, content? }`, as loose as the three lanes' own types are. */
|
|
116
|
+
props: { html?: unknown },
|
|
117
|
+
base: string | null,
|
|
118
|
+
): { inner: string; address: string | null } | null {
|
|
119
|
+
const structured = readStructuredContent(props);
|
|
120
|
+
if (structured) {
|
|
121
|
+
const node = loneHeadingBlock(structured);
|
|
122
|
+
if (!node) return null;
|
|
123
|
+
// Style normalised to `normal` so the serialiser emits a lone <p> that `inline` unwraps; the
|
|
124
|
+
// block's LEVEL, not any h1–h6 style inside the value, decides the tag.
|
|
125
|
+
return {
|
|
126
|
+
inner: portableTextToHtml([{ ...node, style: "normal" }], { inline: true }),
|
|
127
|
+
address: base && node._key ? `${base}#${node._key}` : null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const inner = inlineHeadingHtml(String(props.html ?? ""));
|
|
131
|
+
return inner === null ? null : { inner, address: base };
|
|
132
|
+
}
|
package/src/href.ts
CHANGED
|
@@ -11,6 +11,12 @@ const SAFE_HREF_SCHEMES = ["http:", "https:", "mailto:", "tel:"];
|
|
|
11
11
|
/** The href if it is safe to emit, otherwise null. Callers render the label as plain text. */
|
|
12
12
|
export function safeHref(raw: string): string | null {
|
|
13
13
|
const trimmed = raw.trim();
|
|
14
|
+
// An in-page FRAGMENT (`#where-it-comes-from`) can only move within the page, so it is as safe
|
|
15
|
+
// as a site-relative path. Refusing it cost every imported Contents list its links: the
|
|
16
|
+
// importer's Lexical anchors were dropped on conversion, on ingest and on render alike
|
|
17
|
+
// (astro-sumi, 2026-09-14). No whitespace or markup characters, so it cannot break out of the
|
|
18
|
+
// attribute it is written into.
|
|
19
|
+
if (trimmed.startsWith("#")) return /^#[^\s<>"'`]*$/.test(trimmed) ? trimmed : null;
|
|
14
20
|
// A site-relative path has no scheme to check and cannot navigate off-origin.
|
|
15
21
|
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
|
|
16
22
|
try {
|
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.
|
|
@@ -149,11 +150,40 @@ const components: Partial<PortableTextHtmlComponents> = {
|
|
|
149
150
|
* renders the LABEL rather than deleting it — a blocked link must not lose the words.
|
|
150
151
|
*/
|
|
151
152
|
[PT_TYPES.link]: ({ value, children }) => {
|
|
152
|
-
const v = (value ?? {}) as { href?: unknown; target?: unknown };
|
|
153
|
+
const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown };
|
|
153
154
|
const href = typeof v.href === "string" ? safeHref(v.href) : null;
|
|
154
155
|
if (!href) return children;
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
// `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
|
|
157
|
+
// plain link-type tokens pass; a new tab always adds noopener + noreferrer.
|
|
158
|
+
const rel = new Set(
|
|
159
|
+
typeof v.rel === "string" ? v.rel.toLowerCase().split(/\s+/).filter((t) => /^[a-z][a-z-]*$/.test(t)) : [],
|
|
160
|
+
);
|
|
161
|
+
const target = v.target === "_blank" ? ' target="_blank"' : "";
|
|
162
|
+
if (target) {
|
|
163
|
+
rel.add("noopener");
|
|
164
|
+
rel.add("noreferrer");
|
|
165
|
+
}
|
|
166
|
+
const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
|
|
167
|
+
return `<a href="${esc(href)}"${target}${relAttr}>${children}</a>`;
|
|
168
|
+
},
|
|
169
|
+
/**
|
|
170
|
+
* The site's own inline markup, written back out as itself.
|
|
171
|
+
*
|
|
172
|
+
* Every value here came from content, so every value here is escaped and the tag is
|
|
173
|
+
* re-checked against `STYLED_TAGS` — a markDef can arrive from an import, the Management
|
|
174
|
+
* API or an AI write, and `<${value.tag}>` with no check is how a stored string becomes a
|
|
175
|
+
* `<script>`. An unrecognised tag renders its CHILDREN rather than nothing: the words are
|
|
176
|
+
* the author's, the wrapper is only the design.
|
|
177
|
+
*/
|
|
178
|
+
[PT_TYPES.styled]: ({ value, children }) => {
|
|
179
|
+
const v = (value ?? {}) as { tag?: unknown; className?: unknown; attrs?: unknown };
|
|
180
|
+
const tag = typeof v.tag === "string" ? v.tag.toLowerCase() : "";
|
|
181
|
+
if (!isStyledTag(tag)) return children;
|
|
182
|
+
const cls = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
|
|
183
|
+
const attrs = styledAttrEntries(v.attrs)
|
|
184
|
+
.map(([name, val]) => ` ${name}="${esc(val)}"`)
|
|
185
|
+
.join("");
|
|
186
|
+
return `<${tag}${cls}${attrs}>${children}</${tag}>`;
|
|
157
187
|
},
|
|
158
188
|
highlight: ({ children }) => `<mark>${children}</mark>`,
|
|
159
189
|
"strike-through": ({ children }) => `<s>${children}</s>`,
|
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
|
/**
|