@bettercms-ai/richtext 0.16.0 → 0.17.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.16.0",
3
+ "version": "0.17.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/href.ts CHANGED
@@ -25,3 +25,42 @@ export function safeHref(raw: string): string | null {
25
25
  return null;
26
26
  }
27
27
  }
28
+
29
+ /**
30
+ * A MEDIA url: `safeHref`, plus a plain relative path.
31
+ *
32
+ * An imported page is full of `images/hero.png` and `../media/clip.mp4`, and `safeHref` refuses
33
+ * a path with no leading slash — so every one of those images would render as nothing. Widened
34
+ * only for `<img>`, `<video>` and a poster: they load a picture, while an `<iframe>` frames a
35
+ * whole page and keeps the strict check. A colon or whitespace is refused, so no scheme
36
+ * (`javascript:`) can ride this path, and a PROTOCOL-RELATIVE `//host/clip.mp4` is refused with
37
+ * it — it is off-origin, not relative, and `safeHref` rejects the same shape for the same reason.
38
+ */
39
+ export const safeMediaSrc = (raw: string): string | null =>
40
+ safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
41
+
42
+ /** What a new tab always carries on render, so it is never the author's own `rel`. */
43
+ const NEW_TAB_REL = ["noopener", "noreferrer"];
44
+
45
+ /**
46
+ * A link's `rel` is author data (V2's link form writes `nofollow`, `sponsored`, `ugc`; the
47
+ * importer keeps `prev`/`next`): plain link-type tokens only, lowercased, once each. V2's
48
+ * `follow` is its "no rel" and is no token.
49
+ */
50
+ const relTokens = (raw: unknown): string[] =>
51
+ typeof raw === "string"
52
+ ? [...new Set(raw.toLowerCase().split(/\s+/).filter((t) => /^[a-z][a-z-]*$/.test(t) && t !== "follow"))]
53
+ : [];
54
+
55
+ /** The `rel` to store from a link's markup: a new tab's noopener/noreferrer are the renderer's, not kept. */
56
+ export function readLinkRel(raw: unknown, blank: boolean): string | undefined {
57
+ const tokens = relTokens(raw).filter((t) => !blank || !NEW_TAB_REL.includes(t));
58
+ return tokens.length ? tokens.join(" ") : undefined;
59
+ }
60
+
61
+ /** The `rel` both renderers write: the stored tokens, and a new tab always adds noopener + noreferrer. */
62
+ export function writeLinkRel(raw: unknown, blank: boolean): string | undefined {
63
+ const tokens = new Set(relTokens(raw));
64
+ if (blank) for (const t of NEW_TAB_REL) tokens.add(t);
65
+ return tokens.size ? [...tokens].join(" ") : undefined;
66
+ }
package/src/html.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  BUILTIN_CALLOUT_KEY,
4
4
  BUILTIN_EMBED_KEY,
5
5
  BUILTIN_IMAGE_KEY,
6
+ BUILTIN_TABLE_KEY,
6
7
  BUILTIN_TOGGLE_KEY,
7
8
  PT_TYPES,
8
9
  pullQuoteHref,
@@ -12,7 +13,7 @@ import {
12
13
  import { resolveVideoSource } from "./video";
13
14
  import { builtInTableToTable, designOf, footnoteNumbers, footnotesOf, highlightedColumns, isTypedTable, jsonLdOf, sortableColumns, tableColumns, tableGroupLayout, tableSections, typedCellView } from "./table";
14
15
  import { portableTextToPlainText } from "./plain";
15
- import { safeHref } from "./href";
16
+ import { safeHref, safeMediaSrc, writeLinkRel } from "./href";
16
17
  import { isStyledTag, styledAttrEntries } from "./styled";
17
18
  import { richTextImageFrame, richTextImageLayout } from "./image-layout";
18
19
  import { keyTakeawaysView } from "./key-takeaways";
@@ -253,19 +254,8 @@ const STYLE_TAGS: Record<string, ClassTag> = {
253
254
 
254
255
  const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
255
256
 
256
- /**
257
- * A MEDIA url: `safeHref`, plus a plain relative path.
258
- *
259
- * An imported page is full of `images/hero.png` and `../media/clip.mp4`, and `safeHref` refuses
260
- * a path with no leading slash — so every one of those images would render as nothing, which is
261
- * the loss this whole pass exists to stop. Widened only for `<img>`, `<video>` and a poster:
262
- * they load a picture, while an `<iframe>` frames a whole page and keeps the strict check.
263
- * A colon or whitespace is refused, so no scheme (`javascript:`) can ride this path, and a
264
- * PROTOCOL-RELATIVE `//host/clip.mp4` is refused with it — it is off-origin, not relative, and
265
- * `safeHref` rejects the same shape for the same reason (href.ts:20).
266
- */
267
- const mediaSrc = (raw: string): string | null =>
268
- safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
257
+ /** A media url (`<img>`, `<video>`, a poster): href.ts `safeMediaSrc`, shared with ui's renderer. */
258
+ const mediaSrc = safeMediaSrc;
269
259
 
270
260
  const fieldsOf = (value: unknown): Record<string, unknown> => {
271
261
  const f = isRecord(value) ? value.fields : undefined;
@@ -664,6 +654,35 @@ const checkItemHtml = (classes: HtmlClasses, value: { checked?: unknown }, child
664
654
  return `<li${cls(classes, "li")}${address} data-checked="${checked}"><input disabled type="checkbox"${checked ? " checked" : ""}>${children ?? ""}</li>`;
665
655
  };
666
656
 
657
+ /**
658
+ * The built-in blocks drawn from their OWN fields, by `schemaKey`: null falls through to the
659
+ * caller's `renderBlock`, then the marker. A map, so ui's twin can be checked key for key
660
+ * (portable-text-coverage.test.tsx): a built-in drawn here and not there is content one site loses.
661
+ *
662
+ * 🔴 AN IMAGE AND AN EMBED ARE READABLE, and rendering them as markers was silent loss: an
663
+ * imported article's illustrations and its YouTube embed disappeared from every delivered
664
+ * rendition while the stored value still held them. Their payload is a URL — there is nothing to
665
+ * resolve. A legacy columns block reads as columns only in the shape it was written in.
666
+ *
667
+ * A pre-P1c TABLE resolves against nothing either: its rows are text, held right here, so a marker
668
+ * would DELETE visible content. Migrating it rather than rendering the matrix directly is what
669
+ * keeps ONE table renderer: a value that came through the reader and one that did not produce the
670
+ * same page, which is the property four surfaces used to get wrong independently.
671
+ */
672
+ const SELF_RENDERED_BUILTINS = {
673
+ [BUILTIN_IMAGE_KEY]: (value: unknown, classes: HtmlClasses) => imageHtml(value, classes),
674
+ [BUILTIN_EMBED_KEY]: (value: unknown, classes: HtmlClasses) => embedHtml(value, classes),
675
+ [BUILTIN_CALLOUT_KEY]: (value: unknown) => calloutHtml(value),
676
+ [BUILTIN_TOGGLE_KEY]: (value: unknown) => toggleHtml(value),
677
+ [BUILTIN_COLUMNS_KEY]: (value: unknown, _classes: HtmlClasses, components: Parameters<typeof columnsHtml>[1]) =>
678
+ columnsView(value) ? columnsHtml(value, components) : null,
679
+ [BUILTIN_TABLE_KEY]: (value: unknown) => {
680
+ const legacy = builtInTableToTable(value);
681
+ return legacy ? tableHtml(legacy) : null;
682
+ },
683
+ } satisfies Record<string, (value: unknown, classes: HtmlClasses, components: Parameters<typeof columnsHtml>[1]) => string | null>;
684
+ type SelfRenderedBuiltin = keyof typeof SELF_RENDERED_BUILTINS;
685
+
667
686
  /**
668
687
  * `keys` is the set of `_key`s the value actually carries: to-html invents one (`li-0-1`) for an
669
688
  * item that has none, and an invented key is an address that resolves to nothing.
@@ -725,24 +744,10 @@ const buildComponents = (
725
744
  */
726
745
  [PT_TYPES.block]: ({ value }) => {
727
746
  const v = value as { schemaKey?: unknown };
728
- if (v.schemaKey === BUILTIN_IMAGE_KEY) return imageHtml(value, classes);
729
- if (v.schemaKey === BUILTIN_EMBED_KEY) return embedHtml(value, classes);
730
- if (v.schemaKey === BUILTIN_CALLOUT_KEY) return calloutHtml(value);
731
- if (v.schemaKey === BUILTIN_TOGGLE_KEY) return toggleHtml(value);
732
- // A legacy columns block in the shape it was written in reads as columns; any other stays a marker.
733
- if (v.schemaKey === BUILTIN_COLUMNS_KEY && columnsView(value)) return columnsHtml(value, () => buildComponents(classes, undefined, renderBlock, embeds, resolved));
734
- /**
735
- * A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
736
- * nothing. Its rows are text, held right here, so a marker would DELETE visible content.
737
- * Every other built-in block (an image, a video, a component) carries a reference this
738
- * serialiser cannot resolve, so those stay markers.
739
- *
740
- * Migrating it here rather than rendering the matrix directly is what keeps ONE table
741
- * renderer: a value that came through the reader and one that did not produce the same
742
- * page, which is the property four surfaces used to get wrong independently.
743
- */
744
- const legacy = builtInTableToTable(value);
745
- if (legacy) return tableHtml(legacy);
747
+ const own = typeof v.schemaKey === "string" && Object.hasOwn(SELF_RENDERED_BUILTINS, v.schemaKey)
748
+ ? SELF_RENDERED_BUILTINS[v.schemaKey as SelfRenderedBuiltin](value, classes, () => buildComponents(classes, undefined, renderBlock, embeds, resolved))
749
+ : null;
750
+ if (own !== null) return own;
746
751
  const rendered = renderBlock?.(value as Record<string, unknown>);
747
752
  if (rendered !== undefined) return rendered;
748
753
  const key = typeof v.schemaKey === "string" && TOKEN.test(v.schemaKey.replace(/^builtin:/, ""))
@@ -768,17 +773,10 @@ const buildComponents = (
768
773
  if (!href) return children;
769
774
  // RT2: a button is a link the author marked as one; V2's `data-button` is the site's style hook.
770
775
  const button = v.button === true ? ' data-button="true"' : "";
771
- // `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
772
- // plain link-type tokens pass; a new tab always adds noopener + noreferrer.
773
- const rel = new Set(
774
- typeof v.rel === "string" ? v.rel.toLowerCase().split(/\s+/).filter((t) => /^[a-z][a-z-]*$/.test(t)) : [],
775
- );
776
+ // `rel` is author data too: only plain link-type tokens pass; a new tab always adds noopener + noreferrer.
776
777
  const target = v.target === "_blank" ? ' target="_blank"' : "";
777
- if (target) {
778
- rel.add("noopener");
779
- rel.add("noreferrer");
780
- }
781
- const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
778
+ const rel = writeLinkRel(v.rel, !!target);
779
+ const relAttr = rel ? ` rel="${esc(rel)}"` : "";
782
780
  // The link's own class (kept on ingest) wins over the caller's default: it IS the site.
783
781
  const own = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
784
782
  return `<a${own || cls(classes, "a")}${button} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
@@ -817,6 +815,16 @@ const buildComponents = (
817
815
  /** The undressed components, built once: the overwhelming majority of calls pass no classes. */
818
816
  const defaultComponents = buildComponents();
819
817
 
818
+ /**
819
+ * What this serialiser draws: its node types, its marks, and the built-in blocks it draws from
820
+ * their own fields. ui's React renderer must draw every one of them (portable-text-coverage.test.tsx).
821
+ */
822
+ export const HTML_RENDERS = {
823
+ types: Object.keys(defaultComponents.types ?? {}),
824
+ marks: Object.keys(defaultComponents.marks ?? {}),
825
+ builtins: Object.keys(SELF_RENDERED_BUILTINS),
826
+ } as const;
827
+
820
828
  const isListItem = (node: unknown): boolean =>
821
829
  isRecord(node) && node._type === "block" && typeof node.listItem === "string" && !!node.listItem;
822
830
 
package/src/ingest.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  type PortableTextTableRow,
15
15
  } from "./types";
16
16
  import { randomKey, isValidKey, type KeyGenerator } from "./key";
17
- import { safeHref } from "./href";
17
+ import { readLinkRel, safeHref } from "./href";
18
18
  import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
19
19
  import { EMBED_TITLE_MAX, isEmbedAspect, nearestEmbedAspect, resolveEmbed } from "./embed";
20
20
  import { cellTypeOf, columnOf, designOf, isColumnType, isValueType, jsonLdOf, PT_TABLE_LIMITS, typedCellText } from "./table";
@@ -755,45 +755,39 @@ function buttonLinkRule(key: KeyGenerator, schema: PtSchema | undefined): Deseri
755
755
  const flag = el.getAttribute("data-button");
756
756
  const href = el.getAttribute("href")?.trim();
757
757
  if (flag === null || flag === "false" || !href) return undefined;
758
- const className = el.getAttribute("class")?.trim();
759
- const rel = el.getAttribute("rel")?.trim();
760
- const markDef = {
761
- _type: PT_TYPES.link,
762
- _key: key(),
763
- href,
764
- button: true,
765
- ...(className ? { className } : {}),
766
- ...(el.getAttribute("target") === "_blank" ? { target: "_blank" } : {}),
767
- ...(rel ? { rel } : {}),
768
- };
758
+ const markDef = { _type: PT_TYPES.link, _key: key(), href, button: true, ...linkExtras(el) };
769
759
  return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
770
760
  },
771
761
  };
772
762
  }
773
763
 
774
764
  /**
775
- * `<a class>` → a `link` annotation that keeps the site's class as `className`.
765
+ * What an `<a>` stores beyond its href: the site's class, a new tab (only `_blank`) and its
766
+ * `rel` (author tokens; the noopener/noreferrer a new tab adds on render are not kept).
767
+ */
768
+ function linkExtras(el: Element): { className?: string; target?: "_blank"; rel?: string } {
769
+ const className = el.getAttribute("class")?.trim();
770
+ const blank = el.getAttribute("target")?.trim().toLowerCase() === "_blank";
771
+ const rel = readLinkRel(el.getAttribute("rel"), blank);
772
+ return { ...(className ? { className } : {}), ...(blank ? { target: "_blank" } : {}), ...(rel ? { rel } : {}) };
773
+ }
774
+
775
+ /**
776
+ * `<a class>`, `<a target="_blank">`, `<a rel>` → a `link` annotation that keeps them.
776
777
  *
777
778
  * The built-in link rule records `href` and nothing else, so a styled link (`<a class="underline
778
- * text-brand">`) came back bare and the design died on ingest. Only a link WITH a class takes
779
- * this rule; a plain link still goes to the built-in one. `href` is checked by `normalise`.
779
+ * text-brand">`) came back bare and the design died on ingest, and a link's new tab and its
780
+ * `nofollow` (V2's link form writes both) were lost the same way. Only a link carrying one of
781
+ * them takes this rule; a bare link still goes to the built-in one. `href` is checked by `normalise`.
780
782
  */
781
- function classedLinkRule(key: KeyGenerator): DeserializerRule {
783
+ function linkRule(key: KeyGenerator): DeserializerRule {
782
784
  return {
783
785
  deserialize(el: Node, next) {
784
786
  if (!isElement(el) || el.nodeName.toLowerCase() !== "a") return undefined;
785
- const className = el.getAttribute("class")?.trim();
786
787
  const href = el.getAttribute("href")?.trim();
787
- if (!className || !href) return undefined;
788
- const rel = el.getAttribute("rel")?.trim();
789
- const markDef = {
790
- _type: PT_TYPES.link,
791
- _key: key(),
792
- href,
793
- className,
794
- ...(el.getAttribute("target") === "_blank" ? { target: "_blank" } : {}),
795
- ...(rel ? { rel } : {}),
796
- };
788
+ const extras = linkExtras(el);
789
+ if (!href || !Object.keys(extras).length) return undefined;
790
+ const markDef = { _type: PT_TYPES.link, _key: key(), href, ...extras };
797
791
  return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
798
792
  },
799
793
  };
@@ -1253,7 +1247,7 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
1253
1247
  ownDecoratorRule(opts.schema),
1254
1248
  ...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
1255
1249
  buttonLinkRule(key, opts.schema),
1256
- ...(allowsLinks(opts.schema) ? [classedLinkRule(key)] : []),
1250
+ ...(allowsLinks(opts.schema) ? [linkRule(key)] : []),
1257
1251
  ],
1258
1252
  keyGenerator: key,
1259
1253
  });
package/src/read.ts CHANGED
@@ -4,6 +4,7 @@ import { migrateTable } from "./table";
4
4
  import { detachedComponentCopy, type DetachedComponentCopy } from "./component-copy";
5
5
  import { randomKey, type KeyGenerator } from "./key";
6
6
  import type { EmbedCard, ResolvedAuthor, ResolvedPost, RichTextEmbeds, RichTextResolved } from "@bettercms-ai/types";
7
+ import type { ClassTag, HtmlClasses } from "./html";
7
8
 
8
9
  /**
9
10
  * THE reader. One function, every stored shape, and it never throws.
@@ -335,3 +336,23 @@ export function readStructuredResolved(props: unknown): RichTextResolved | undef
335
336
  }
336
337
  return { posts, authors, related };
337
338
  }
339
+
340
+ /** Every tag a caller may class (html.ts `ClassTag`); a Record so a tag added there must be added here. */
341
+ const CLASS_TAGS = Object.keys({
342
+ p: 1, h1: 1, h2: 1, h3: 1, h4: 1, h5: 1, h6: 1, ul: 1, ol: 1, li: 1, a: 1, img: 1, figure: 1, blockquote: 1,
343
+ } satisfies Record<ClassTag, 1>) as ClassTag[];
344
+
345
+ /**
346
+ * The site's own class per tag for a DELIVERED value (`value.classes`, the field's
347
+ * `config.htmlClasses`, which the delivery puts beside the value); undefined when there are none.
348
+ * Checked, not trusted: only the tags a renderer dresses, with non-empty string classes.
349
+ */
350
+ export function readStructuredClasses(props: unknown): HtmlClasses | undefined {
351
+ if (!isRecord(props)) return undefined;
352
+ const candidate = props.content ?? props.value;
353
+ const raw = isRecord(candidate) ? candidate.classes : undefined;
354
+ if (!isRecord(raw)) return undefined;
355
+ const out: HtmlClasses = {};
356
+ for (const tag of CLASS_TAGS) if (typeof raw[tag] === "string" && raw[tag]) out[tag] = raw[tag] as string;
357
+ return Object.keys(out).length ? out : undefined;
358
+ }
package/src/schema.ts CHANGED
@@ -111,6 +111,9 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
111
111
  fields: [
112
112
  { name: "href", type: "string" },
113
113
  { name: "target", type: "string" },
114
+ // The author's link-type tokens (V2's link form: nofollow, sponsored, ugc); a new tab's
115
+ // noopener/noreferrer are the renderer's and are not stored (href.ts `readLinkRel`).
116
+ { name: "rel", type: "string" },
114
117
  // The site's own class on the `<a>`, kept so a styled link round-trips.
115
118
  { name: "className", type: "string" },
116
119
  // RT2: V2's button is a link the author marked as one (`<a data-button="true">`), edited as