@bettercms-ai/richtext 0.5.0 → 0.6.1

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.5.0",
3
+ "version": "0.6.1",
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
@@ -230,6 +230,9 @@ export interface RenderOptions {
230
230
  * Wrapping at the top level rather than inside the block components is deliberate: a block
231
231
  * component only ever sees TEXT blocks, so a divider or an embedded image would get no
232
232
  * address — and those are exactly the nodes an editor most wants to click.
233
+ *
234
+ * List items are the one exception: a run of them renders as ONE `<ul>`/`<ol>` and the address
235
+ * sits on each `<li>`, because a wrapper per item would split the list into one-item lists.
233
236
  */
234
237
  bindingBase?: string;
235
238
  }
@@ -253,10 +256,27 @@ const tagOverrides = <T extends string>(
253
256
  return out;
254
257
  };
255
258
 
256
- const buildComponents = (classes: HtmlClasses = {}): Partial<PortableTextHtmlComponents> => ({
259
+ /** The per-node address `portableTextToHtml({ bindingBase })` stamps. "" for a node with no key. */
260
+ const bindingAttrs = (base: string, key: unknown): string =>
261
+ typeof key === "string" && key
262
+ ? ` data-bcms-field="${esc(base)}#${esc(key)}" data-bcms-kind="richtext"`
263
+ : "";
264
+
265
+ /**
266
+ * `keys` is the set of `_key`s the value actually carries: to-html invents one (`li-0-1`) for an
267
+ * item that has none, and an invented key is an address that resolves to nothing.
268
+ */
269
+ const buildComponents = (
270
+ classes: HtmlClasses = {},
271
+ binding?: { base: string; keys: ReadonlySet<unknown> },
272
+ ): Partial<PortableTextHtmlComponents> => ({
257
273
  block: tagOverrides(classes, STYLE_TAGS),
258
274
  list: tagOverrides(classes, { bullet: "ul", number: "ol", check: "ul" }),
259
- listItem: tagOverrides(classes, { bullet: "li", number: "li", check: "li" }),
275
+ // With a base, every item carries its own address ON the `<li>` — see `bindingBase`.
276
+ listItem: binding
277
+ ? ({ value, children }: { value: { _key?: unknown }; children?: string }) =>
278
+ `<li${cls(classes, "li")}${binding.keys.has(value._key) ? bindingAttrs(binding.base, value._key) : ""}>${children ?? ""}</li>`
279
+ : tagOverrides(classes, { bullet: "li", number: "li", check: "li" }),
260
280
  types: {
261
281
  [PT_TYPES.divider]: () => "<hr>",
262
282
  [PT_TYPES.table]: ({ value }) => tableHtml(value as PortableTextTable),
@@ -312,7 +332,7 @@ const buildComponents = (classes: HtmlClasses = {}): Partial<PortableTextHtmlCom
312
332
  * renders the LABEL rather than deleting it — a blocked link must not lose the words.
313
333
  */
314
334
  [PT_TYPES.link]: ({ value, children }) => {
315
- const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown };
335
+ const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown; className?: unknown };
316
336
  const href = typeof v.href === "string" ? safeHref(v.href) : null;
317
337
  if (!href) return children;
318
338
  // `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
@@ -326,7 +346,9 @@ const buildComponents = (classes: HtmlClasses = {}): Partial<PortableTextHtmlCom
326
346
  rel.add("noreferrer");
327
347
  }
328
348
  const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
329
- return `<a${cls(classes, "a")} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
349
+ // The link's own class (kept on ingest) wins over the caller's default: it IS the site.
350
+ const own = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
351
+ return `<a${own || cls(classes, "a")} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
330
352
  },
331
353
  /**
332
354
  * The site's own inline markup, written back out as itself.
@@ -362,6 +384,9 @@ const buildComponents = (classes: HtmlClasses = {}): Partial<PortableTextHtmlCom
362
384
  /** The undressed components, built once: the overwhelming majority of calls pass no classes. */
363
385
  const defaultComponents = buildComponents();
364
386
 
387
+ const isListItem = (node: unknown): boolean =>
388
+ isRecord(node) && node._type === "block" && typeof node.listItem === "string" && !!node.listItem;
389
+
365
390
  export function portableTextToHtml(
366
391
  value: PortableText | null | undefined,
367
392
  opts: RenderOptions = {},
@@ -370,16 +395,28 @@ export function portableTextToHtml(
370
395
  const components = opts.classes ? buildComponents(opts.classes) : defaultComponents;
371
396
 
372
397
  if (opts.bindingBase) {
373
- const base = esc(opts.bindingBase);
374
- return value
375
- .map((node) => {
376
- const inner = toHTML([node] as never, { components });
377
- // A node with no key cannot be addressed. Render it rather than dropping it — an
378
- // unaddressable paragraph is a small loss, a missing one is content disappearing.
379
- if (!node._key) return inner;
380
- return `<div data-bcms-field="${base}#${esc(node._key)}" data-bcms-kind="richtext">${inner}</div>`;
381
- })
382
- .join("");
398
+ const base = opts.bindingBase;
399
+ const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) });
400
+ const out: string[] = [];
401
+ for (let i = 0; i < value.length; ) {
402
+ const node = value[i];
403
+ // A RUN of list items renders as ONE list, each `<li>` carrying its own address. Wrapping
404
+ // items one by one gave every item its own `<ol>`, so numbering restarted at 1 on every line
405
+ // and bullets sat a list-margin apart — the canvas no longer looked like the site.
406
+ if (isListItem(node)) {
407
+ let end = i + 1;
408
+ while (end < value.length && isListItem(value[end])) end++;
409
+ out.push(toHTML(value.slice(i, end) as never, { components: bound }));
410
+ i = end;
411
+ continue;
412
+ }
413
+ const inner = toHTML([node] as never, { components });
414
+ // A node with no key cannot be addressed. Render it rather than dropping it — an
415
+ // unaddressable paragraph is a small loss, a missing one is content disappearing.
416
+ out.push(node._key ? `<div${bindingAttrs(base, node._key)}>${inner}</div>` : inner);
417
+ i++;
418
+ }
419
+ return out.join("");
383
420
  }
384
421
 
385
422
  const html = toHTML(value as never, { components });
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/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