@bettercms-ai/richtext 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.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/html.ts CHANGED
@@ -235,6 +235,11 @@ export interface RenderOptions {
235
235
  * sits on each `<li>`, because a wrapper per item would split the list into one-item lists.
236
236
  */
237
237
  bindingBase?: string;
238
+ /**
239
+ * Renders an embedded block this serialiser cannot resolve itself (a component, say). A
240
+ * string replaces the marker; `undefined` keeps it.
241
+ */
242
+ renderBlock?: (value: Record<string, unknown>) => string | undefined;
238
243
  }
239
244
 
240
245
  /**
@@ -269,6 +274,7 @@ const bindingAttrs = (base: string, key: unknown): string =>
269
274
  const buildComponents = (
270
275
  classes: HtmlClasses = {},
271
276
  binding?: { base: string; keys: ReadonlySet<unknown> },
277
+ renderBlock?: RenderOptions["renderBlock"],
272
278
  ): Partial<PortableTextHtmlComponents> => ({
273
279
  block: tagOverrides(classes, STYLE_TAGS),
274
280
  list: tagOverrides(classes, { bullet: "ul", number: "ol", check: "ul" }),
@@ -315,6 +321,8 @@ const buildComponents = (
315
321
  */
316
322
  const legacy = builtInTableToTable(value);
317
323
  if (legacy) return tableHtml(legacy);
324
+ const rendered = renderBlock?.(value as Record<string, unknown>);
325
+ if (rendered !== undefined) return rendered;
318
326
  const key = typeof v.schemaKey === "string" && TOKEN.test(v.schemaKey.replace(/^builtin:/, ""))
319
327
  ? v.schemaKey
320
328
  : "block";
@@ -332,7 +340,7 @@ const buildComponents = (
332
340
  * renders the LABEL rather than deleting it — a blocked link must not lose the words.
333
341
  */
334
342
  [PT_TYPES.link]: ({ value, children }) => {
335
- const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown };
343
+ const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown; className?: unknown };
336
344
  const href = typeof v.href === "string" ? safeHref(v.href) : null;
337
345
  if (!href) return children;
338
346
  // `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
@@ -346,7 +354,9 @@ const buildComponents = (
346
354
  rel.add("noreferrer");
347
355
  }
348
356
  const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
349
- return `<a${cls(classes, "a")} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
357
+ // The link's own class (kept on ingest) wins over the caller's default: it IS the site.
358
+ const own = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
359
+ return `<a${own || cls(classes, "a")} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
350
360
  },
351
361
  /**
352
362
  * The site's own inline markup, written back out as itself.
@@ -390,11 +400,12 @@ export function portableTextToHtml(
390
400
  opts: RenderOptions = {},
391
401
  ): string {
392
402
  if (!Array.isArray(value) || value.length === 0) return "";
393
- const components = opts.classes ? buildComponents(opts.classes) : defaultComponents;
403
+ const components =
404
+ opts.classes || opts.renderBlock ? buildComponents(opts.classes, undefined, opts.renderBlock) : defaultComponents;
394
405
 
395
406
  if (opts.bindingBase) {
396
407
  const base = opts.bindingBase;
397
- const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) });
408
+ const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock);
398
409
  const out: string[] = [];
399
410
  for (let i = 0; i < value.length; ) {
400
411
  const node = value[i];
package/src/ingest.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "./types";
10
10
  import { randomKey, isValidKey, type KeyGenerator } from "./key";
11
11
  import { safeHref } from "./href";
12
- import { isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
12
+ import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
13
13
  import type { PtSchema } from "./schema";
14
14
 
15
15
  /**
@@ -197,11 +197,12 @@ function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText
197
197
  * for exactly that reason.
198
198
  *
199
199
  * A `<span>` with nothing on it is NOT annotated: it carries no design, so recording it would
200
- * mint a markDef per stray wrapper and bloat every value for nothing. The other tags are
201
- * annotated bare, because there the TAG is the design (`<small>`, `<sup>`, `<kbd>`).
200
+ * mint a markDef per stray wrapper and bloat every value for nothing. `<small>`, `<sup>`,
201
+ * `<kbd>` … are annotated bare, because there the TAG is the design.
202
202
  *
203
- * Decorator tags (`b/strong`, `i/em`, `u`, `s`, `mark`, `code`) are deliberately absent: they
204
- * have real decorators and the standard rules run right after this one.
203
+ * Decorator tags (`b/strong`, `i/em`, `u`, `s`, `mark`, `code`, …) take this rule ONLY when
204
+ * they carry a class or an admitted attribute — the one thing their decorator cannot hold.
205
+ * Bare, they fall through to the standard rules and become the decorator as before.
205
206
  *
206
207
  * `__annotation` is the deserialiser's own internal placeholder — the same one its built-in
207
208
  * link rule returns — so nesting composes for free: `<strong><span class>` comes back as one
@@ -215,7 +216,7 @@ function styledRule(key: KeyGenerator): DeserializerRule {
215
216
  if (!isStyledTag(tag)) return undefined;
216
217
  const className = el.getAttribute("class")?.trim() || undefined;
217
218
  const attrs = styledAttrsOf(el);
218
- if (tag === "span" && !className && !attrs) return undefined;
219
+ if (!className && !attrs && !BARE_IS_DESIGN.has(tag)) return undefined;
219
220
  const markDef: StyledMarkDef = {
220
221
  _type: PT_TYPES.styled,
221
222
  _key: key(),
@@ -228,6 +229,38 @@ function styledRule(key: KeyGenerator): DeserializerRule {
228
229
  };
229
230
  }
230
231
 
232
+ /**
233
+ * `<a class>` → a `link` annotation that keeps the site's class as `className`.
234
+ *
235
+ * The built-in link rule records `href` and nothing else, so a styled link (`<a class="underline
236
+ * text-brand">`) came back bare and the design died on ingest. Only a link WITH a class takes
237
+ * this rule; a plain link still goes to the built-in one. `href` is checked by `normalise`.
238
+ */
239
+ function classedLinkRule(key: KeyGenerator): DeserializerRule {
240
+ return {
241
+ deserialize(el: Node, next) {
242
+ if (!isElement(el) || el.nodeName.toLowerCase() !== "a") return undefined;
243
+ const className = el.getAttribute("class")?.trim();
244
+ const href = el.getAttribute("href")?.trim();
245
+ if (!className || !href) return undefined;
246
+ const rel = el.getAttribute("rel")?.trim();
247
+ const markDef = {
248
+ _type: PT_TYPES.link,
249
+ _key: key(),
250
+ href,
251
+ className,
252
+ ...(el.getAttribute("target") === "_blank" ? { target: "_blank" } : {}),
253
+ ...(rel ? { rel } : {}),
254
+ };
255
+ return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
256
+ },
257
+ };
258
+ }
259
+
260
+ /** Does this field's schema carry the link annotation? */
261
+ const allowsLinks = (schema: PtSchema | undefined): boolean =>
262
+ !schema || schema.annotations.some((a) => a.name === PT_TYPES.link);
263
+
231
264
  /**
232
265
  * ── MEDIA, WHICH USED TO PRODUCE NO NODE AT ALL ──────────────────────────────────────────
233
266
  *
@@ -383,6 +416,7 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
383
416
  ...(allowsTables(opts.schema) ? [tableRule(key, (cell) => htmlToPortableText(cell, opts))] : []),
384
417
  mediaRule(key, opts.schema),
385
418
  ...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
419
+ ...(allowsLinks(opts.schema) ? [classedLinkRule(key)] : []),
386
420
  ],
387
421
  keyGenerator: key,
388
422
  });
package/src/read.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PORTABLE_TEXT_FORMAT, PT_TYPES, type PortableText } from "./types";
1
+ import { EMBEDDED_COMPONENT_KEY, PORTABLE_TEXT_FORMAT, PT_TYPES, type PortableText } from "./types";
2
2
  import { lexicalToPortableText } from "./from-lexical";
3
3
  import { migrateTable } from "./table";
4
4
  import { randomKey, type KeyGenerator } from "./key";
@@ -231,3 +231,26 @@ export function readStructuredContent(props: unknown): PortableText | null {
231
231
  : null;
232
232
  return arr && arr.length > 0 ? (arr as PortableText) : null;
233
233
  }
234
+
235
+ /** The `component` block an embed in rich text stands for. */
236
+ export interface EmbeddedComponentBlock {
237
+ type: "component";
238
+ /** The node's `_key` — unique within its rich-text field only. */
239
+ id: string;
240
+ props: { componentId: string; overrides: Record<string, unknown> };
241
+ }
242
+
243
+ /**
244
+ * A component embedded in rich text, as the ordinary `component` block every renderer already
245
+ * draws — so an embed gets the same resolver, cycle guard and identity attributes as a placed
246
+ * instance. Null for any other node.
247
+ */
248
+ export function embeddedComponentBlock(value: unknown): EmbeddedComponentBlock | null {
249
+ if (!isRecord(value) || value.schemaKey !== EMBEDDED_COMPONENT_KEY || !isRecord(value.fields)) return null;
250
+ const { componentId, overrides } = value.fields;
251
+ return {
252
+ type: "component",
253
+ id: typeof value._key === "string" ? value._key : "",
254
+ props: { componentId: String(componentId ?? ""), overrides: isRecord(overrides) ? overrides : {} },
255
+ };
256
+ }
package/src/schema.ts CHANGED
@@ -111,6 +111,8 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
111
111
  fields: [
112
112
  { name: "href", type: "string" },
113
113
  { name: "target", type: "string" },
114
+ // The site's own class on the `<a>`, kept so a styled link round-trips.
115
+ { name: "className", type: "string" },
114
116
  ],
115
117
  });
116
118
  }
package/src/styled.ts CHANGED
@@ -22,8 +22,22 @@
22
22
  * NOT passed through — its value is a language of its own, bounded by `safeStyle` below.
23
23
  */
24
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;
25
+ /**
26
+ * Inline tags a `styled` annotation may wear. Nothing outside this list is ever rendered.
27
+ *
28
+ * The decorator tags (`strong` … `code`) are here for the one case their decorator cannot
29
+ * hold: the site put a CLASS on them (`<strong class="text-yellow-500">`). A decorator is a
30
+ * bit with nowhere to keep a class, so without this the design died on ingest — which is what
31
+ * made agents rewrite a site's markup to dodge the loss. A bare `<strong>` still becomes the
32
+ * decorator (see `BARE_IS_DESIGN`).
33
+ */
34
+ export const STYLED_TAGS = [
35
+ "span", "small", "sup", "sub", "kbd", "abbr", "time",
36
+ "strong", "b", "em", "i", "u", "s", "mark", "code", "q", "cite", "dfn", "var", "samp", "ins", "del",
37
+ ] as const;
38
+
39
+ /** Tags whose bare form IS the design, so they are annotated even with no class. */
40
+ export const BARE_IS_DESIGN: ReadonlySet<string> = new Set(["small", "sup", "sub", "kbd", "abbr", "time"]);
27
41
 
28
42
  export type StyledTag = (typeof STYLED_TAGS)[number];
29
43
 
package/src/types.ts CHANGED
@@ -276,6 +276,12 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
276
276
  export const BUILTIN_IMAGE_KEY = "builtin:image" as const;
277
277
  export const BUILTIN_EMBED_KEY = "builtin:video" as const;
278
278
 
279
+ /**
280
+ * The `schemaKey` of a component embedded in rich text. A wire value shared with the dashboard
281
+ * (`COMPONENT_SCHEMA_KEY`) and the backend (`RICHTEXT_COMPONENT_KEY`).
282
+ */
283
+ export const EMBEDDED_COMPONENT_KEY = "component" as const;
284
+
279
285
  /**
280
286
  * Top-level nodes whose `_type` this platform cannot store or render, in document order.
281
287
  *