@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/richtext",
3
- "version": "0.2.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
- 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
  }
@@ -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
- const target = v.target === "_blank" ? ' target="_blank" rel="noopener noreferrer"' : "";
156
- return `<a href="${esc(href)}"${target}>${children}</a>`;
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
@@ -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
  /**