@bettercms-ai/richtext 0.14.0 → 0.16.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/src/types.ts CHANGED
@@ -57,6 +57,11 @@ export interface PortableTextBlock extends PortableTextNode {
57
57
  level?: number;
58
58
  /** Only meaningful when `listItem` is "check". A checklist item's state is content, not style. */
59
59
  checked?: boolean;
60
+ /**
61
+ * RT9: a top-level h2/h3's own id from the page it was imported from, kept so the site's links
62
+ * to it keep working. Only when it differs from the id its words would make (toc.ts `outlineOf`).
63
+ */
64
+ anchor?: string;
60
65
  }
61
66
 
62
67
  /** Anything that is not text — an image, an embed, a table, a placed component. */
@@ -358,7 +363,12 @@ export interface PortableTextEmbed extends PortableTextObject {
358
363
  export interface PortableTextGallery extends PortableTextObject {
359
364
  _type: "gallery";
360
365
  layout: "grid" | "carousel";
361
- images: { _key: string; image: { src: string; alt: string; mediaId?: string } | null; caption?: string }[];
366
+ images: {
367
+ _key: string;
368
+ /** The rich-text image's own shape, its RT9 framing (`crop`, `focal`; @bettercms-ai/image-url) included. */
369
+ image: { src: string; alt: string; mediaId?: string; crop?: { top: number; right: number; bottom: number; left: number }; focal?: { x: number; y: number } } | null;
370
+ caption?: string;
371
+ }[];
362
372
  }
363
373
 
364
374
  /**
@@ -371,6 +381,45 @@ export interface PortableTextTableOfContents extends PortableTextObject {
371
381
  depth: "2" | "3";
372
382
  }
373
383
 
384
+ /**
385
+ * RT6: a footnote, an inline object in body text (V2's `<sup data-rt="fn" data-note>`). Only the
386
+ * note is stored; its number, ids, the end list and the back links are derived (footnote.ts).
387
+ */
388
+ export interface PortableTextFootnote {
389
+ _type: "footnote";
390
+ _key: string;
391
+ note: string;
392
+ }
393
+
394
+ /**
395
+ * RT7: columns, as V2's ColumnsModel: a gap and 2 to 4 columns, each a run of text blocks of its
396
+ * own (depth 1). The column count is derived. Limits and the write check: columns.ts.
397
+ */
398
+ export interface PortableTextColumns extends PortableTextObject {
399
+ _type: "columns";
400
+ gap: "s" | "m" | "l";
401
+ columns: { _key: string; content: PortableTextBlock[] }[];
402
+ }
403
+
404
+ /** RT8: an author bio, a reference to the author's entry; name, avatar and bio are resolved at render (references.ts). */
405
+ export interface PortableTextAuthorBio extends PortableTextObject {
406
+ _type: "authorBio";
407
+ author?: { _ref: string };
408
+ }
409
+
410
+ /**
411
+ * RT8: related posts: a title, how they are chosen (`topic`: shared topics; `newest`: the
412
+ * collection's newest; `manual`: the hand-picked `posts`), and how many. Each post is resolved at render.
413
+ */
414
+ export interface PortableTextRelatedPosts extends PortableTextObject {
415
+ _type: "relatedPosts";
416
+ title?: string;
417
+ mode: "topic" | "newest" | "manual";
418
+ count: number;
419
+ /** The hand-picked posts. Absent is none, which is what an automatic (topic, newest) block holds. */
420
+ posts?: { _key: string; _ref: string }[];
421
+ }
422
+
374
423
  export const isPortableTextBlock = (n: unknown): n is PortableTextBlock =>
375
424
  !!n && typeof n === "object" && (n as PortableTextNode)._type === "block";
376
425
 
@@ -434,6 +483,14 @@ export const PT_TYPES = {
434
483
  gallery: "gallery",
435
484
  /** A table of contents built from the headings — see `PortableTextTableOfContents`. The V2 kit's `<nav data-rt="toc">`. */
436
485
  tableOfContents: "tableOfContents",
486
+ /** A footnote, an INLINE object in body text — see `PortableTextFootnote`. The V2 kit's `<sup data-rt="fn">`. */
487
+ footnote: "footnote",
488
+ /** Columns of text — see `PortableTextColumns`. The V2 kit's `<div data-rt="columns">`; the legacy `builtin:columns` reads the same way. */
489
+ columns: "columns",
490
+ /** An author bio — see `PortableTextAuthorBio`. The V2 kit's `<aside data-rt="author">`. */
491
+ authorBio: "authorBio",
492
+ /** Related posts — see `PortableTextRelatedPosts`. The V2 kit's `<aside data-rt="related">`. */
493
+ relatedPosts: "relatedPosts",
437
494
  /**
438
495
  * A table. Spelled `table`, unprefixed, because that is what every other Portable Text
439
496
  * producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
@@ -494,6 +551,9 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
494
551
  PT_TYPES.embed,
495
552
  PT_TYPES.gallery,
496
553
  PT_TYPES.tableOfContents,
554
+ PT_TYPES.columns,
555
+ PT_TYPES.authorBio,
556
+ PT_TYPES.relatedPosts,
497
557
  ]);
498
558
 
499
559
  /**
@@ -560,7 +620,12 @@ export function foreignBlocks(value: unknown): { _type: string; _key: string }[]
560
620
  ? isStorableGallery(node)
561
621
  : type === PT_TYPES.tableOfContents
562
622
  ? isStorableTableOfContents(node)
563
- : STORABLE_BLOCK_TYPES.has(type);
623
+ : type === PT_TYPES.columns
624
+ ? isStorableColumns(node)
625
+ : type === PT_TYPES.relatedPosts
626
+ ? // No `posts` is no picks: an automatic block (topic, newest) needs none.
627
+ (node as { posts?: unknown }).posts === undefined || Array.isArray((node as { posts?: unknown }).posts)
628
+ : STORABLE_BLOCK_TYPES.has(type);
564
629
  if (storable) continue;
565
630
  const key = (node as PortableTextNode)._key;
566
631
  out.push({ _type: type, _key: typeof key === "string" ? key : "" });
@@ -651,6 +716,13 @@ export function isStorableGallery(node: unknown): boolean {
651
716
  return Array.isArray((node as { images?: unknown }).images);
652
717
  }
653
718
 
719
+ /** Is this `_type:"columns"` one we can render? A `columns` array whose entries hold a `content` array; the rest is `columnsProblems`'. */
720
+ export function isStorableColumns(node: unknown): boolean {
721
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false;
722
+ const columns = (node as { columns?: unknown }).columns;
723
+ return Array.isArray(columns) && columns.every((c) => !!c && typeof c === "object" && Array.isArray((c as { content?: unknown }).content));
724
+ }
725
+
654
726
  /** Is this `_type:"tableOfContents"` one we can render? One of V2's two depths; the title is judged by `tableOfContentsProblems`. */
655
727
  export function isStorableTableOfContents(node: unknown): boolean {
656
728
  if (!node || typeof node !== "object" || Array.isArray(node)) return false;
@@ -9,7 +9,7 @@
9
9
  import { richTextRefusal, type UiRefusal } from "@bettercms-ai/types";
10
10
  import { acceptFor } from "./accept";
11
11
  import { ptSchemaFor } from "./schema";
12
- import { isPortableTextBlock, type PortableText, type PortableTextNode } from "./types";
12
+ import { PT_TYPES, isPortableTextBlock, type PortableText, type PortableTextNode } from "./types";
13
13
 
14
14
  /**
15
15
  * R4: every node, style, list, mark or annotation a field's features do not accept, judged against
@@ -39,10 +39,16 @@ export function notAcceptedProblems(value: PortableText, features: readonly stri
39
39
  out.push(richTextRefusal("block_not_accepted", name));
40
40
  };
41
41
 
42
- for (const node of value) {
42
+ const judge = (node: PortableText[number]) => {
43
43
  if (!isPortableTextBlock(node)) {
44
44
  if (!blockObjects.has(node._type)) refuse(node._type);
45
- continue;
45
+ // RT7: a column's text is the field's text too, judged by the same features.
46
+ if (node._type === PT_TYPES.columns && Array.isArray((node as { columns?: unknown }).columns)) {
47
+ for (const col of (node as unknown as { columns: { content?: unknown }[] }).columns) {
48
+ if (Array.isArray(col?.content)) for (const b of col.content as PortableText) judge(b);
49
+ }
50
+ }
51
+ return;
46
52
  }
47
53
  if (!styles.has(node.style ?? "normal")) refuse(node.style);
48
54
  if (node.listItem !== undefined && !lists.has(node.listItem)) refuse(node.listItem);
@@ -61,7 +67,8 @@ export function notAcceptedProblems(value: PortableText, features: readonly stri
61
67
  // A mark is a markDef key (its annotation was judged above) or a decorator name.
62
68
  for (const mark of (child as { marks?: string[] }).marks ?? []) if (!defs.has(mark) && !decorators.has(mark)) refuse(mark);
63
69
  }
64
- }
70
+ };
71
+ for (const node of value) judge(node);
65
72
  return out;
66
73
  }
67
74
 
@@ -75,8 +82,9 @@ export const DERIVED_FIELDS: Readonly<Record<string, readonly string[]>> = {
75
82
  embed: ["provider", "shape", "src"],
76
83
  columns: ["cols"],
77
84
  tableOfContents: ["outline"],
78
- authorBio: ["name", "avatar", "bio"],
79
- relatedPosts: ["posts[].title", "posts[].slug", "posts[].url", "posts[].cover", "posts[].category"],
85
+ // RT8 (G-RT8): what a reference resolves to is never stored, on the block or as a `resolved` map.
86
+ authorBio: ["name", "avatar", "bio", "resolved"],
87
+ relatedPosts: ["posts[].title", "posts[].slug", "posts[].url", "posts[].cover", "posts[].category", "resolved"],
80
88
  };
81
89
 
82
90
  /** Whether a node holds a derived path: `key`, or `list[].key` on any entry of the list. */