@bettercms-ai/richtext 0.14.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 +3 -2
- package/src/accept.ts +12 -6
- package/src/columns.ts +117 -0
- package/src/footnote.ts +70 -0
- package/src/gallery.ts +24 -4
- package/src/href.ts +39 -0
- package/src/html.ts +152 -51
- package/src/image-layout.ts +54 -0
- package/src/index.ts +3 -0
- package/src/ingest.ts +210 -33
- package/src/plain.ts +10 -0
- package/src/read.ts +59 -1
- package/src/references.ts +147 -0
- package/src/schema.ts +32 -0
- package/src/toc.ts +25 -8
- package/src/types.ts +74 -2
- package/src/write-refusals.ts +14 -6
package/src/toc.ts
CHANGED
|
@@ -27,6 +27,13 @@ export interface OutlineItem {
|
|
|
27
27
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
28
28
|
!!v && typeof v === "object" && !Array.isArray(v);
|
|
29
29
|
|
|
30
|
+
/** A heading id kept from an imported page: a plain HTML id a link can name (`#…`) without escaping. */
|
|
31
|
+
export const isHeadingAnchor = (v: unknown): v is string => typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_:.-]{0,127}$/.test(v);
|
|
32
|
+
|
|
33
|
+
/** Does this value hold a heading anchor? A comparison reads the page with anchors only when it does (ingest `headingAnchors`). */
|
|
34
|
+
export const hasHeadingAnchors = (value: unknown): boolean =>
|
|
35
|
+
Array.isArray(value) && value.some((n) => isRecord(n) && n._type === "block" && isHeadingAnchor(n.anchor));
|
|
36
|
+
|
|
30
37
|
const headingText = (node: Record<string, unknown>): string =>
|
|
31
38
|
(Array.isArray(node.children) ? node.children : [])
|
|
32
39
|
.map((c) => (isRecord(c) && typeof c.text === "string" ? c.text : ""))
|
|
@@ -36,21 +43,31 @@ const headingText = (node: Record<string, unknown>): string =>
|
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
45
|
* 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.
|
|
40
|
-
*
|
|
46
|
+
* heading with no words is left out. A heading's own `anchor` (an imported page's id, RT9) is its
|
|
47
|
+
* id, the first time it appears; every other heading's slug steps around the anchors. `nodes` maps
|
|
48
|
+
* each heading node to its id, which is how a renderer finds the id of the heading it is drawing.
|
|
41
49
|
*/
|
|
42
50
|
export function outlineOf(value: unknown): { items: OutlineItem[]; nodes: WeakMap<object, string> } {
|
|
43
|
-
const
|
|
44
|
-
const nodes = new WeakMap<object, string>();
|
|
51
|
+
const heads: { node: Record<string, unknown>; text: string; anchor: string | null }[] = [];
|
|
45
52
|
const used = new Set<string>();
|
|
46
53
|
for (const node of Array.isArray(value) ? value : []) {
|
|
47
54
|
if (!isRecord(node) || node._type !== "block" || node.listItem || (node.style !== "h2" && node.style !== "h3")) continue;
|
|
48
55
|
const text = headingText(node);
|
|
49
56
|
if (!text) continue;
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
const anchor = isHeadingAnchor(node.anchor) && !used.has(node.anchor) ? node.anchor : null;
|
|
58
|
+
if (anchor) used.add(anchor);
|
|
59
|
+
heads.push({ node, text, anchor });
|
|
60
|
+
}
|
|
61
|
+
const items: OutlineItem[] = [];
|
|
62
|
+
const nodes = new WeakMap<object, string>();
|
|
63
|
+
for (const { node, text, anchor } of heads) {
|
|
64
|
+
let id = anchor;
|
|
65
|
+
if (!id) {
|
|
66
|
+
const base = slugify(text);
|
|
67
|
+
id = base;
|
|
68
|
+
for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
|
|
69
|
+
used.add(id);
|
|
70
|
+
}
|
|
54
71
|
nodes.set(node, id);
|
|
55
72
|
items.push({ id, text, depth: node.style === "h2" ? 2 : 3 });
|
|
56
73
|
}
|
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: {
|
|
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
|
-
:
|
|
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;
|
package/src/write-refusals.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
+
const judge = (node: PortableText[number]) => {
|
|
43
43
|
if (!isPortableTextBlock(node)) {
|
|
44
44
|
if (!blockObjects.has(node._type)) refuse(node._type);
|
|
45
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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. */
|