@bettercms-ai/richtext 0.12.0 → 0.14.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/schema.ts CHANGED
@@ -102,7 +102,7 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
102
102
  ],
103
103
  },
104
104
  ];
105
- if (has("link") || has("internalLink")) {
105
+ if (has("link") || has("internalLink") || has("button")) {
106
106
  annotations.push({
107
107
  name: PT_TYPES.link,
108
108
  title: "Link",
@@ -113,6 +113,9 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
113
113
  { name: "target", type: "string" },
114
114
  // The site's own class on the `<a>`, kept so a styled link round-trips.
115
115
  { name: "className", type: "string" },
116
+ // RT2: V2's button is a link the author marked as one (`<a data-button="true">`), edited as
117
+ // text in the flow. Only a field with the button feature has the field to set.
118
+ ...(has("button") ? [{ name: "button", type: "boolean" as const }] : []),
116
119
  ],
117
120
  });
118
121
  }
@@ -222,6 +225,40 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
222
225
  * hold and move it; it does not validate the payload, so a deeper declaration would buy
223
226
  * nothing and drift from the types that do.
224
227
  */
228
+ // RT3: the third-party embed; its provider and frame are derived from `url` (embed.ts).
229
+ if (has("providerEmbed")) {
230
+ blockObjects.push({
231
+ name: PT_TYPES.embed,
232
+ title: "Embed",
233
+ fields: [
234
+ { name: "url", type: "string" },
235
+ { name: "title", type: "string" },
236
+ { name: "aspect", type: "string" },
237
+ ],
238
+ });
239
+ }
240
+ // RT4: the gallery; each image is the rich-text image's `{ src, alt }` (gallery.ts).
241
+ if (has("gallery")) {
242
+ blockObjects.push({
243
+ name: PT_TYPES.gallery,
244
+ title: "Gallery",
245
+ fields: [
246
+ { name: "layout", type: "string" },
247
+ { name: "images", type: "object" },
248
+ ],
249
+ });
250
+ }
251
+ // RT5: the table of contents; its outline is derived from the headings (toc.ts).
252
+ if (has("tableOfContents")) {
253
+ blockObjects.push({
254
+ name: PT_TYPES.tableOfContents,
255
+ title: "Table of contents",
256
+ fields: [
257
+ { name: "title", type: "string" },
258
+ { name: "depth", type: "string" },
259
+ ],
260
+ });
261
+ }
225
262
  if (has("table")) {
226
263
  blockObjects.push({
227
264
  name: PT_TYPES.table,
package/src/toc.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { slugify } from "@bettercms-ai/types";
2
+ import { PT_TYPES } from "./types";
3
+ import { isPortableTextTable } from "./table";
4
+
5
+ /**
6
+ * RT5: the table of contents (`PortableTextTableOfContents`, types.ts): the one `slugify`, the
7
+ * outline a value's headings make, the write check, and the tree both renderers draw.
8
+ *
9
+ * Stored is what the author sets, `{ title?, depth }`; the outline is derived from the value's
10
+ * headings on every render and never stored (R8 refuses `outline`). Every top-level h2 and h3 is
11
+ * given its outline id on every render, with or without a table of contents, so a link to a
12
+ * heading keeps working when one is added or removed.
13
+ */
14
+ export const PT_TOC_LIMITS = { titleChars: 40 } as const;
15
+ export const TOC_DEPTHS = ["2", "3"] as const;
16
+ export type TocDepth = (typeof TOC_DEPTHS)[number];
17
+
18
+ /** The id a heading's words make: V2's `slugify`, whose one spelling lives in @bettercms-ai/types. */
19
+ export { slugify };
20
+
21
+ export interface OutlineItem {
22
+ id: string;
23
+ text: string;
24
+ depth: 2 | 3;
25
+ }
26
+
27
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
28
+ !!v && typeof v === "object" && !Array.isArray(v);
29
+
30
+ const headingText = (node: Record<string, unknown>): string =>
31
+ (Array.isArray(node.children) ? node.children : [])
32
+ .map((c) => (isRecord(c) && typeof c.text === "string" ? c.text : ""))
33
+ .join("")
34
+ .replace(/​/g, "")
35
+ .trim();
36
+
37
+ /**
38
+ * The value's top-level h2 and h3, in order, each with a de-duplicated id (V2 `outlineOf`): a
39
+ * heading with no words is left out. `nodes` maps each heading node to its id, which is how a
40
+ * renderer finds the id of the heading it is drawing.
41
+ */
42
+ export function outlineOf(value: unknown): { items: OutlineItem[]; nodes: WeakMap<object, string> } {
43
+ const items: OutlineItem[] = [];
44
+ const nodes = new WeakMap<object, string>();
45
+ const used = new Set<string>();
46
+ for (const node of Array.isArray(value) ? value : []) {
47
+ if (!isRecord(node) || node._type !== "block" || node.listItem || (node.style !== "h2" && node.style !== "h3")) continue;
48
+ const text = headingText(node);
49
+ if (!text) continue;
50
+ const base = slugify(text);
51
+ let id = base;
52
+ for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
53
+ used.add(id);
54
+ nodes.set(node, id);
55
+ items.push({ id, text, depth: node.style === "h2" ? 2 : 3 });
56
+ }
57
+ return { items, nodes };
58
+ }
59
+
60
+ export type TocEntry = OutlineItem & { children: OutlineItem[] };
61
+
62
+ /**
63
+ * The list a table of contents draws (V2 `renderArticle("toc")`): only h2 at depth "2"; an h3
64
+ * nests under the entry before it, and opens the list only when nothing comes before it.
65
+ */
66
+ export function tocTree(items: readonly OutlineItem[], depth: TocDepth): TocEntry[] {
67
+ const out: TocEntry[] = [];
68
+ for (const it of items) {
69
+ if (it.depth === 3 && depth === "2") continue;
70
+ const last = out[out.length - 1];
71
+ if (it.depth === 3 && last) last.children.push(it);
72
+ else out.push({ ...it, children: [] });
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** What a reader sees of the block itself: its title (trimmed), the label its nav carries, and its depth. */
78
+ export function tocView(value: unknown): { title: string; label: string; depth: TocDepth } {
79
+ const v = isRecord(value) ? value : {};
80
+ const title = typeof v.title === "string" ? v.title.trim() : "";
81
+ return { title, label: title || "Table of contents", depth: v.depth === "2" ? "2" : "3" };
82
+ }
83
+
84
+ /** One reason a stored table of contents is refused, at its own path inside the block. */
85
+ export type PortableTextTocProblem = { path: Array<string | number>; code: string; message: string };
86
+
87
+ const KEYS = new Set(["_type", "_key", "title", "depth"]);
88
+
89
+ /**
90
+ * Every reason `node` cannot be stored: `toc_invalid` (a shape problem) or `toc_limit_exceeded`
91
+ * (V2's 40-character title). A derived `outline` is refused by the R8 check, by name.
92
+ */
93
+ export function tableOfContentsProblems(node: unknown): PortableTextTocProblem[] {
94
+ const out: PortableTextTocProblem[] = [];
95
+ if (!isRecord(node) || node._type !== PT_TYPES.tableOfContents) return out;
96
+ const bad = (path: Array<string | number>, message: string, code = "toc_invalid") =>
97
+ out.push({ path, code, message: `${code}: ${message}` });
98
+ for (const k of Object.keys(node)) if (!KEYS.has(k) && k !== "outline") bad([k], `unknown key "${k}" (title, depth)`);
99
+ if (!(TOC_DEPTHS as readonly unknown[]).includes(node.depth)) bad(["depth"], 'depth must be "2" or "3"');
100
+ if (node.title !== undefined) {
101
+ if (typeof node.title !== "string") bad(["title"], "title must be a string");
102
+ else if (node.title.length > PT_TOC_LIMITS.titleChars) {
103
+ bad(["title"], `title is ${node.title.length} characters; at most ${PT_TOC_LIMITS.titleChars}`, "toc_limit_exceeded");
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** `tableOfContentsProblems` for every table of contents in a value (top level, and in table cells), with its block key. */
110
+ export function portableTextTableOfContentsProblems(value: unknown): Array<PortableTextTocProblem & { block: string }> {
111
+ const out: Array<PortableTextTocProblem & { block: string }> = [];
112
+ const walk = (blocks: unknown) => {
113
+ if (!Array.isArray(blocks)) return;
114
+ for (const node of blocks) {
115
+ if (isPortableTextTable(node)) {
116
+ for (const row of node.rows as unknown[]) {
117
+ if (!isRecord(row) || !Array.isArray(row.cells)) continue;
118
+ for (const cell of row.cells) if (isRecord(cell)) walk(cell.content);
119
+ }
120
+ continue;
121
+ }
122
+ if (!isRecord(node) || node._type !== PT_TYPES.tableOfContents) continue;
123
+ const block = typeof node._key === "string" ? node._key : "";
124
+ for (const p of tableOfContentsProblems(node)) out.push({ ...p, block });
125
+ }
126
+ };
127
+ walk(value);
128
+ return out;
129
+ }
package/src/types.ts CHANGED
@@ -338,6 +338,39 @@ export interface PortableTextKeyTakeaways extends PortableTextObject {
338
338
  points: string[];
339
339
  }
340
340
 
341
+ /**
342
+ * RT3: an embed, as V2's EmbedModel. Only what the author sets is stored: the link, the
343
+ * screen-reader title (≤ EMBED_TITLE_MAX) and the shape. The provider, its kind and the frame src
344
+ * are derived from `url` at render (`resolveEmbed`), never stored.
345
+ */
346
+ export interface PortableTextEmbed extends PortableTextObject {
347
+ _type: "embed";
348
+ url: string;
349
+ title: string;
350
+ aspect: "16:9" | "4:3" | "1:1" | "9:16";
351
+ }
352
+
353
+ /**
354
+ * RT4: a gallery, as V2's GalleryModel. Each image is the rich-text image's own `{ src, alt }`
355
+ * (+ an optional media id), so media rewrites, alt audits and CDN transforms read it the same way.
356
+ * Limits and the write check: gallery.ts.
357
+ */
358
+ export interface PortableTextGallery extends PortableTextObject {
359
+ _type: "gallery";
360
+ layout: "grid" | "carousel";
361
+ images: { _key: string; image: { src: string; alt: string; mediaId?: string } | null; caption?: string }[];
362
+ }
363
+
364
+ /**
365
+ * RT5: a table of contents, as V2's TocModel: a title (≤ 40) and which headings it lists. The
366
+ * outline is derived from the value's headings at render (toc.ts) and never stored.
367
+ */
368
+ export interface PortableTextTableOfContents extends PortableTextObject {
369
+ _type: "tableOfContents";
370
+ title?: string;
371
+ depth: "2" | "3";
372
+ }
373
+
341
374
  export const isPortableTextBlock = (n: unknown): n is PortableTextBlock =>
342
375
  !!n && typeof n === "object" && (n as PortableTextNode)._type === "block";
343
376
 
@@ -359,6 +392,8 @@ export interface RichTextEnvelope {
359
392
  format: typeof PORTABLE_TEXT_FORMAT;
360
393
  value: PortableText;
361
394
  html: string;
395
+ /** RT3: what its embeds need beside `html` (the X cards), written at html-cache build. */
396
+ embeds?: import("@bettercms-ai/types").RichTextEmbeds;
362
397
  }
363
398
 
364
399
  /**
@@ -389,6 +424,16 @@ export const PT_TYPES = {
389
424
  pullQuote: "pullQuote",
390
425
  /** Key takeaways — see `PortableTextKeyTakeaways`. The V2 kit's `<aside data-rt="takeaways">`. */
391
426
  keyTakeaways: "keyTakeaways",
427
+ /**
428
+ * A third-party embed (X, CodePen, Figma, Maps, Spotify, Podcasts, audio, a link card) — see
429
+ * `PortableTextEmbed` and `embed.ts`. The V2 kit's `<figure data-rt="embed">`. Gated by the
430
+ * `embed` feature; YouTube and Vimeo links still resolve here too, through the video resolver.
431
+ */
432
+ embed: "embed",
433
+ /** A gallery of images, a grid or a carousel — see `PortableTextGallery`. The V2 kit's `<figure data-rt="gallery">`. */
434
+ gallery: "gallery",
435
+ /** A table of contents built from the headings — see `PortableTextTableOfContents`. The V2 kit's `<nav data-rt="toc">`. */
436
+ tableOfContents: "tableOfContents",
392
437
  /**
393
438
  * A table. Spelled `table`, unprefixed, because that is what every other Portable Text
394
439
  * producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
@@ -446,6 +491,9 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
446
491
  PT_TYPES.file,
447
492
  PT_TYPES.pullQuote,
448
493
  PT_TYPES.keyTakeaways,
494
+ PT_TYPES.embed,
495
+ PT_TYPES.gallery,
496
+ PT_TYPES.tableOfContents,
449
497
  ]);
450
498
 
451
499
  /**
@@ -465,6 +513,13 @@ export const BUILTIN_EMBED_KEY = "builtin:video" as const;
465
513
  /** A callout (`{ tone: info|tip|warning, title, message }`) and a toggle (`{ title, body }`): plain text, read straight. */
466
514
  export const BUILTIN_CALLOUT_KEY = "builtin:callout" as const;
467
515
  export const BUILTIN_TOGGLE_KEY = "builtin:toggle" as const;
516
+ /**
517
+ * A part of a body the SITE's own code renders: an MDX component (`<Figure>…</Figure>`, a chart, a
518
+ * tabs widget) or an `{expression}`. Not content an author edits here: the page shows what the
519
+ * build drew, publish never writes over it, and the editor shows it as a locked card. `fields.source`
520
+ * keeps what the repo wrote, for the card; the delivered html carries only the empty marker.
521
+ */
522
+ export const BUILTIN_SITE_KEY = "builtin:site" as const;
468
523
 
469
524
  /**
470
525
  * The `schemaKey` of a component embedded in rich text. A wire value shared with the dashboard
@@ -493,7 +548,7 @@ export function foreignBlocks(value: unknown): { _type: string; _key: string }[]
493
548
  if (!node || typeof node !== "object" || Array.isArray(node)) continue;
494
549
  const type = (node as PortableTextNode)._type;
495
550
  if (typeof type !== "string") continue;
496
- // A table, a pull quote and key takeaways are admitted by SHAPE, never by `_type` alone —
551
+ // A table, a pull quote, key takeaways, a gallery and a table of contents are admitted by SHAPE, never by `_type` alone —
497
552
  // see `isStorableTable`.
498
553
  const storable = type === PT_TYPES.table
499
554
  ? isStorableTable(node)
@@ -501,7 +556,11 @@ export function foreignBlocks(value: unknown): { _type: string; _key: string }[]
501
556
  ? isStorablePullQuote(node)
502
557
  : type === PT_TYPES.keyTakeaways
503
558
  ? isStorableKeyTakeaways(node)
504
- : STORABLE_BLOCK_TYPES.has(type);
559
+ : type === PT_TYPES.gallery
560
+ ? isStorableGallery(node)
561
+ : type === PT_TYPES.tableOfContents
562
+ ? isStorableTableOfContents(node)
563
+ : STORABLE_BLOCK_TYPES.has(type);
505
564
  if (storable) continue;
506
565
  const key = (node as PortableTextNode)._key;
507
566
  out.push({ _type: type, _key: typeof key === "string" ? key : "" });
@@ -575,6 +634,30 @@ export function isStorableKeyTakeaways(node: unknown): boolean {
575
634
  return Array.isArray((node as { points?: unknown }).points);
576
635
  }
577
636
 
637
+ /** An embed holds a link, a title of at most 120 characters, and one of V2's four shapes. */
638
+ export function isStorableEmbed(node: unknown): boolean {
639
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false;
640
+ const n = node as { url?: unknown; title?: unknown; aspect?: unknown };
641
+ return typeof n.url === "string" && typeof n.title === "string" && n.title.length <= 120
642
+ && (n.aspect === "16:9" || n.aspect === "4:3" || n.aspect === "1:1" || n.aspect === "9:16");
643
+ }
644
+
645
+ /**
646
+ * Is this `_type:"gallery"` one we can render? An `images` array; what is in it and the limits
647
+ * are judged by `galleryProblems` (gallery.ts), which names each problem with a code.
648
+ */
649
+ export function isStorableGallery(node: unknown): boolean {
650
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false;
651
+ return Array.isArray((node as { images?: unknown }).images);
652
+ }
653
+
654
+ /** Is this `_type:"tableOfContents"` one we can render? One of V2's two depths; the title is judged by `tableOfContentsProblems`. */
655
+ export function isStorableTableOfContents(node: unknown): boolean {
656
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false;
657
+ const depth = (node as { depth?: unknown }).depth;
658
+ return depth === "2" || depth === "3";
659
+ }
660
+
578
661
  /**
579
662
  * @deprecated The marker a table-accepting field used to carry INSTEAD of a table.
580
663
  *
@@ -0,0 +1,106 @@
1
+ /**
2
+ * What a rich-text WRITE may not hold, refused by name and never removed (RT0, properties R4, R8).
3
+ *
4
+ * Both read the value and answer a list. Neither changes it, so a caller that writes after an
5
+ * empty answer writes exactly what it was given: there is no strip step for content to vanish in.
6
+ * Callers that judge a stored value should judge only what the write introduces, as the field-key
7
+ * rules do: values written before a rule existed must keep saving.
8
+ */
9
+ import { richTextRefusal, type UiRefusal } from "@bettercms-ai/types";
10
+ import { acceptFor } from "./accept";
11
+ import { ptSchemaFor } from "./schema";
12
+ import { isPortableTextBlock, type PortableText, type PortableTextNode } from "./types";
13
+
14
+ /**
15
+ * R4: every node, style, list, mark or annotation a field's features do not accept, judged against
16
+ * `ptSchemaFor(acceptFor(features))`, the same schema the editor offers. One refusal per name:
17
+ * `richtext.block_not_accepted:<name>`, where name is a block object's or inline object's `_type`,
18
+ * a style (`h2`), a list (`check`), a decorator (`strong`) or an annotation's `_type` (`link`).
19
+ */
20
+ export function notAcceptedProblems(value: PortableText, features: readonly string[]): UiRefusal[] {
21
+ const accepted = acceptFor(features);
22
+ const schema = ptSchemaFor(accepted);
23
+ // One `link` annotation carries both: a plain link needs a link element, a button the button one.
24
+ const linkOk = accepted.includes("link") || accepted.includes("internalLink");
25
+ const buttonOk = accepted.includes("button");
26
+ const names = (list: { name: string }[]) => new Set(list.map((x) => x.name));
27
+ const styles = names(schema.styles);
28
+ const lists = names(schema.lists);
29
+ const decorators = names(schema.decorators);
30
+ const annotations = names(schema.annotations);
31
+ const blockObjects = names(schema.blockObjects);
32
+ const inlineObjects = names(schema.inlineObjects);
33
+
34
+ const out: UiRefusal[] = [];
35
+ const refused = new Set<string>();
36
+ const refuse = (name: string) => {
37
+ if (refused.has(name)) return;
38
+ refused.add(name);
39
+ out.push(richTextRefusal("block_not_accepted", name));
40
+ };
41
+
42
+ for (const node of value) {
43
+ if (!isPortableTextBlock(node)) {
44
+ if (!blockObjects.has(node._type)) refuse(node._type);
45
+ continue;
46
+ }
47
+ if (!styles.has(node.style ?? "normal")) refuse(node.style);
48
+ if (node.listItem !== undefined && !lists.has(node.listItem)) refuse(node.listItem);
49
+ const defs = new Set<string>();
50
+ for (const def of node.markDefs ?? []) {
51
+ defs.add(def._key);
52
+ if (def._type === "link") {
53
+ if (def.button === true ? !buttonOk : !linkOk) refuse(def.button === true ? "button" : "link");
54
+ } else if (!annotations.has(def._type)) refuse(def._type);
55
+ }
56
+ for (const child of node.children ?? []) {
57
+ if (child._type !== "span") {
58
+ if (!inlineObjects.has(child._type)) refuse(child._type);
59
+ continue;
60
+ }
61
+ // A mark is a markDef key (its annotation was judged above) or a decorator name.
62
+ for (const mark of (child as { marks?: string[] }).marks ?? []) if (!defs.has(mark) && !decorators.has(mark)) refuse(mark);
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * What each V2 block computes at render time and never stores (kit-richtext-blocks-gap.md, the
70
+ * "Derived" line of each block). `posts[].title` means that key on every entry of `posts`. Each RT
71
+ * step confirms its row when it builds the type; a key outside its row is not judged here.
72
+ */
73
+ export const DERIVED_FIELDS: Readonly<Record<string, readonly string[]>> = {
74
+ footnote: ["number"],
75
+ embed: ["provider", "shape", "src"],
76
+ columns: ["cols"],
77
+ tableOfContents: ["outline"],
78
+ authorBio: ["name", "avatar", "bio"],
79
+ relatedPosts: ["posts[].title", "posts[].slug", "posts[].url", "posts[].cover", "posts[].category"],
80
+ };
81
+
82
+ /** Whether a node holds a derived path: `key`, or `list[].key` on any entry of the list. */
83
+ const holds = (node: Record<string, unknown>, path: string): boolean => {
84
+ const [list, key] = path.split("[].");
85
+ if (key === undefined) return Object.hasOwn(node, path);
86
+ const items = node[list];
87
+ return Array.isArray(items) && items.some((item) => item !== null && typeof item === "object" && Object.hasOwn(item, key));
88
+ };
89
+
90
+ /**
91
+ * R8: every derived key a write carries, top-level nodes and inline children alike, refused as
92
+ * `richtext.derived_field:<_type>.<path>` (`embed.provider`, `relatedPosts.posts[].title`).
93
+ */
94
+ export function derivedFieldProblems(value: PortableText): UiRefusal[] {
95
+ const out: UiRefusal[] = [];
96
+ const judge = (node: PortableTextNode) => {
97
+ for (const path of DERIVED_FIELDS[node._type] ?? []) {
98
+ if (holds(node as unknown as Record<string, unknown>, path)) out.push(richTextRefusal("derived_field", `${node._type}.${path}`));
99
+ }
100
+ };
101
+ for (const node of value) {
102
+ judge(node);
103
+ if (isPortableTextBlock(node)) for (const child of node.children ?? []) judge(child);
104
+ }
105
+ return out;
106
+ }