@bettercms-ai/richtext 0.12.1 → 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/package.json +4 -1
- package/src/accept.ts +68 -9
- package/src/columns.ts +117 -0
- package/src/embed.ts +242 -0
- package/src/features.ts +6 -23
- package/src/footnote.ts +70 -0
- package/src/gallery.ts +167 -0
- package/src/html.ts +234 -12
- package/src/image-layout.ts +54 -0
- package/src/index.ts +7 -0
- package/src/ingest.ts +406 -5
- package/src/plain.ts +16 -0
- package/src/read.ts +63 -0
- package/src/references.ts +147 -0
- package/src/schema.ts +67 -1
- package/src/toc.ts +146 -0
- package/src/types.ts +157 -2
- package/src/write-refusals.ts +114 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { ResolvedAuthor, ResolvedPost, RichTextResolved } from "@bettercms-ai/types";
|
|
2
|
+
import { PT_TYPES } from "./types";
|
|
3
|
+
import { safeHref } from "./href";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RT8: the two blocks that point at entries. An author bio stores its author as a reference;
|
|
7
|
+
* related posts store a title, a mode, a count and (hand-picked) references. What a reader sees
|
|
8
|
+
* of the entries (names, titles, urls, covers, bios) is derived, and NEVER stored (G-RT8): the
|
|
9
|
+
* caller resolves it whenever a page is produced (delivery, the hosted apply, a draft preview) and
|
|
10
|
+
* passes `RichTextResolved` in. The renderers never fetch. Three branches, the same on both:
|
|
11
|
+
* - no `resolved` at all: a placeholder aside carrying only what is stored, so a stored html
|
|
12
|
+
* cache stays a pure function of the value and reads back exactly;
|
|
13
|
+
* - resolved, nothing to show (no post resolves, the author is missing): nothing;
|
|
14
|
+
* - resolved: V2's aside, filled.
|
|
15
|
+
*/
|
|
16
|
+
export const PT_RELATED_LIMITS = { titleChars: 40, minCount: 1, maxCount: 6, posts: 6 } as const;
|
|
17
|
+
export const RELATED_MODES = ["topic", "newest", "manual"] as const;
|
|
18
|
+
export type RelatedMode = (typeof RELATED_MODES)[number];
|
|
19
|
+
|
|
20
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
21
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
22
|
+
const refOf = (v: unknown): string => (isRecord(v) && typeof v._ref === "string" ? v._ref : "");
|
|
23
|
+
|
|
24
|
+
/** The same widened check a rich-text image gets: http(s), or a site-relative path. */
|
|
25
|
+
const imageSrc = (raw: string): string | null =>
|
|
26
|
+
safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a reader sees of a related-posts block: its title, mode and count, the hand-picked ids
|
|
30
|
+
* (V2's `data-posts`), and the posts to list, in order: the block's own `posts` when manual,
|
|
31
|
+
* else the ids the server chose (`resolved.related[_key]`), at most `count`. An id the server
|
|
32
|
+
* could not resolve (deleted, unpublished) is skipped, and so is a post whose url is refused.
|
|
33
|
+
*/
|
|
34
|
+
export function relatedPostsView(value: unknown, resolved: RichTextResolved = {}) {
|
|
35
|
+
const v = isRecord(value) ? value : {};
|
|
36
|
+
const title = typeof v.title === "string" ? v.title.trim() : "";
|
|
37
|
+
const mode: RelatedMode = v.mode === "manual" || v.mode === "newest" ? v.mode : "topic";
|
|
38
|
+
const n = typeof v.count === "number" && Number.isInteger(v.count) ? v.count : 3;
|
|
39
|
+
const count = Math.min(PT_RELATED_LIMITS.maxCount, Math.max(PT_RELATED_LIMITS.minCount, n));
|
|
40
|
+
const picked = (Array.isArray(v.posts) ? v.posts : []).map(refOf).filter(Boolean);
|
|
41
|
+
const key = typeof v._key === "string" ? v._key : "";
|
|
42
|
+
const ids = mode === "manual" ? picked : (resolved.related?.[key] ?? []).slice(0, count);
|
|
43
|
+
const items: (ResolvedPost & { id: string })[] = [];
|
|
44
|
+
for (const id of ids) {
|
|
45
|
+
const post = resolved.posts?.[id];
|
|
46
|
+
const url = post && typeof post.url === "string" ? safeHref(post.url) : null;
|
|
47
|
+
if (post && typeof post.title === "string" && url) items.push({ ...post, id, url });
|
|
48
|
+
}
|
|
49
|
+
return { key, title, mode, count, picked, items };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a reader sees of an author bio: the referenced id, and the author when the server resolved one. */
|
|
53
|
+
export function authorBioView(value: unknown, resolved: RichTextResolved = {}): { key: string; id: string; author: (ResolvedAuthor & { avatar?: string }) | null } {
|
|
54
|
+
const key = isRecord(value) && typeof value._key === "string" ? value._key : "";
|
|
55
|
+
const id = refOf(isRecord(value) ? value.author : undefined);
|
|
56
|
+
const a = id ? resolved.authors?.[id] : undefined;
|
|
57
|
+
if (!a || typeof a.name !== "string") return { key, id, author: null };
|
|
58
|
+
const avatar = typeof a.avatar === "string" ? imageSrc(a.avatar.trim()) : null;
|
|
59
|
+
return {
|
|
60
|
+
key,
|
|
61
|
+
id,
|
|
62
|
+
author: {
|
|
63
|
+
name: a.name,
|
|
64
|
+
...(avatar ? { avatar } : {}),
|
|
65
|
+
...(typeof a.bio === "string" && a.bio.trim() ? { bio: a.bio.trim() } : {}),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One reason a stored author bio or related-posts block is refused, with its block key. */
|
|
71
|
+
export type PortableTextReferenceProblem = { block: string; path: Array<string | number>; code: string; message: string };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every reason a value's author bios and related posts cannot be stored, by block and path:
|
|
75
|
+
* `author_bio_invalid`, `related_posts_invalid` (a shape problem, a duplicated pick) or
|
|
76
|
+
* `related_posts_limit_exceeded` (V2's 40-character title, 1 to 6, at most 6 picks). As with key
|
|
77
|
+
* takeaways, V2's required author and its "at least one pick" are the editor's, not the write's:
|
|
78
|
+
* an author still choosing must be able to autosave. Derived keys are refused by R8, by name.
|
|
79
|
+
*/
|
|
80
|
+
export function portableTextReferenceProblems(value: unknown): PortableTextReferenceProblem[] {
|
|
81
|
+
const out: PortableTextReferenceProblem[] = [];
|
|
82
|
+
for (const node of Array.isArray(value) ? value : []) {
|
|
83
|
+
if (!isRecord(node)) continue;
|
|
84
|
+
const block = typeof node._key === "string" ? node._key : "";
|
|
85
|
+
const bad = (path: Array<string | number>, code: string, message: string) => out.push({ block, path, code, message: `${code}: ${message}` });
|
|
86
|
+
if (node._type === PT_TYPES.authorBio) {
|
|
87
|
+
// name, avatar and bio are derived: R8 refuses them by name.
|
|
88
|
+
for (const k of Object.keys(node)) if (!["_type", "_key", "author", "name", "avatar", "bio", "resolved"].includes(k)) bad([k], "author_bio_invalid", `unknown key "${k}" (author)`);
|
|
89
|
+
if (node.author !== undefined && (!isRecord(node.author) || typeof node.author._ref !== "string" || !node.author._ref)) {
|
|
90
|
+
bad(["author"], "author_bio_invalid", "author must be a reference { _ref }");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (node._type === PT_TYPES.relatedPosts) {
|
|
94
|
+
const keys = ["_type", "_key", "title", "mode", "count", "posts", "resolved"];
|
|
95
|
+
for (const k of Object.keys(node)) if (!keys.includes(k)) bad([k], "related_posts_invalid", `unknown key "${k}" (title, mode, count, posts)`);
|
|
96
|
+
if (node.title !== undefined) {
|
|
97
|
+
if (typeof node.title !== "string") bad(["title"], "related_posts_invalid", "title must be a string");
|
|
98
|
+
else if (node.title.length > PT_RELATED_LIMITS.titleChars) {
|
|
99
|
+
bad(["title"], "related_posts_limit_exceeded", `title is ${node.title.length} characters; at most ${PT_RELATED_LIMITS.titleChars}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!(RELATED_MODES as readonly unknown[]).includes(node.mode)) bad(["mode"], "related_posts_invalid", 'mode must be "topic", "newest" or "manual"');
|
|
103
|
+
if (typeof node.count !== "number" || !Number.isInteger(node.count) || node.count < PT_RELATED_LIMITS.minCount || node.count > PT_RELATED_LIMITS.maxCount) {
|
|
104
|
+
bad(["count"], "related_posts_limit_exceeded", `count must be a whole number from ${PT_RELATED_LIMITS.minCount} to ${PT_RELATED_LIMITS.maxCount}`);
|
|
105
|
+
}
|
|
106
|
+
// Absent is no picks (an automatic block needs none).
|
|
107
|
+
if (node.posts === undefined) continue;
|
|
108
|
+
if (!Array.isArray(node.posts)) {
|
|
109
|
+
bad(["posts"], "related_posts_invalid", "posts must be an array of references");
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const seen = new Set<string>();
|
|
113
|
+
node.posts.forEach((p, i) => {
|
|
114
|
+
const ref = refOf(p);
|
|
115
|
+
if (!ref || !isRecord(p) || typeof p._key !== "string") return bad(["posts", i], "related_posts_invalid", "a post is { _key, _ref }");
|
|
116
|
+
// A post's title, slug, url, cover and category are derived: R8 refuses them by name.
|
|
117
|
+
for (const k of Object.keys(p)) if (!["_key", "_ref", "title", "slug", "url", "cover", "category"].includes(k)) bad(["posts", i, k], "related_posts_invalid", `unknown key "${k}" (_ref)`);
|
|
118
|
+
if (seen.has(ref)) bad(["posts", i], "related_posts_invalid", `"${ref}" is picked twice`);
|
|
119
|
+
seen.add(ref);
|
|
120
|
+
});
|
|
121
|
+
if (node.posts.length > PT_RELATED_LIMITS.posts) {
|
|
122
|
+
bad(["posts"], "related_posts_limit_exceeded", `${node.posts.length} posts; at most ${PT_RELATED_LIMITS.posts}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `relatedPosts.no_topic_field`: a `topic` block in a collection with no topic field has nothing to
|
|
131
|
+
* rank by, so it is refused by name rather than quietly listing nothing. The caller knows the
|
|
132
|
+
* collection's config and says whether it has one.
|
|
133
|
+
*/
|
|
134
|
+
export function relatedPostsTopicProblems(value: unknown, opts: { topicField: boolean }): PortableTextReferenceProblem[] {
|
|
135
|
+
if (opts.topicField) return [];
|
|
136
|
+
const out: PortableTextReferenceProblem[] = [];
|
|
137
|
+
for (const node of Array.isArray(value) ? value : []) {
|
|
138
|
+
if (!isRecord(node) || node._type !== PT_TYPES.relatedPosts || node.mode !== "topic") continue;
|
|
139
|
+
out.push({
|
|
140
|
+
block: typeof node._key === "string" ? node._key : "",
|
|
141
|
+
path: ["mode"],
|
|
142
|
+
code: "relatedPosts.no_topic_field",
|
|
143
|
+
message: "relatedPosts.no_topic_field: the collection has no topic field to rank related posts by; choose newest or hand-picked",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
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,67 @@ 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
|
+
// RT7: columns of text; the generic block above still carries a legacy `builtin:columns`.
|
|
252
|
+
if (has("columns")) {
|
|
253
|
+
blockObjects.push({
|
|
254
|
+
name: PT_TYPES.columns,
|
|
255
|
+
title: "Columns",
|
|
256
|
+
fields: [
|
|
257
|
+
{ name: "gap", type: "string" },
|
|
258
|
+
{ name: "columns", type: "object" },
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
// RT8: the two reference blocks; what they point at is resolved at render (references.ts).
|
|
263
|
+
if (has("authorBio")) {
|
|
264
|
+
blockObjects.push({ name: PT_TYPES.authorBio, title: "Author bio", fields: [{ name: "author", type: "object" }] });
|
|
265
|
+
}
|
|
266
|
+
if (has("relatedPosts")) {
|
|
267
|
+
blockObjects.push({
|
|
268
|
+
name: PT_TYPES.relatedPosts,
|
|
269
|
+
title: "Related posts",
|
|
270
|
+
fields: [
|
|
271
|
+
{ name: "title", type: "string" },
|
|
272
|
+
{ name: "mode", type: "string" },
|
|
273
|
+
{ name: "count", type: "number" },
|
|
274
|
+
{ name: "posts", type: "object" },
|
|
275
|
+
],
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// RT5: the table of contents; its outline is derived from the headings (toc.ts).
|
|
279
|
+
if (has("tableOfContents")) {
|
|
280
|
+
blockObjects.push({
|
|
281
|
+
name: PT_TYPES.tableOfContents,
|
|
282
|
+
title: "Table of contents",
|
|
283
|
+
fields: [
|
|
284
|
+
{ name: "title", type: "string" },
|
|
285
|
+
{ name: "depth", type: "string" },
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
}
|
|
225
289
|
if (has("table")) {
|
|
226
290
|
blockObjects.push({
|
|
227
291
|
name: PT_TYPES.table,
|
|
@@ -247,6 +311,8 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
247
311
|
// content element, and a stored mention must survive in a field that never offered one.
|
|
248
312
|
inlineObjects: [
|
|
249
313
|
{ name: PT_TYPES.mention, title: "Mention", fields: [{ name: "label", type: "string" }] },
|
|
314
|
+
// RT6: a footnote stores only its note; its number and the end list are derived (footnote.ts).
|
|
315
|
+
...(has("footnotes") ? [{ name: PT_TYPES.footnote, title: "Footnote", fields: [{ name: "note", type: "string" as const }] }] : []),
|
|
250
316
|
],
|
|
251
317
|
};
|
|
252
318
|
}
|
package/src/toc.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
/** 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
|
+
|
|
37
|
+
const headingText = (node: Record<string, unknown>): string =>
|
|
38
|
+
(Array.isArray(node.children) ? node.children : [])
|
|
39
|
+
.map((c) => (isRecord(c) && typeof c.text === "string" ? c.text : ""))
|
|
40
|
+
.join("")
|
|
41
|
+
.replace(//g, "")
|
|
42
|
+
.trim();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The value's top-level h2 and h3, in order, each with a de-duplicated id (V2 `outlineOf`): a
|
|
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.
|
|
49
|
+
*/
|
|
50
|
+
export function outlineOf(value: unknown): { items: OutlineItem[]; nodes: WeakMap<object, string> } {
|
|
51
|
+
const heads: { node: Record<string, unknown>; text: string; anchor: string | null }[] = [];
|
|
52
|
+
const used = new Set<string>();
|
|
53
|
+
for (const node of Array.isArray(value) ? value : []) {
|
|
54
|
+
if (!isRecord(node) || node._type !== "block" || node.listItem || (node.style !== "h2" && node.style !== "h3")) continue;
|
|
55
|
+
const text = headingText(node);
|
|
56
|
+
if (!text) continue;
|
|
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
|
+
}
|
|
71
|
+
nodes.set(node, id);
|
|
72
|
+
items.push({ id, text, depth: node.style === "h2" ? 2 : 3 });
|
|
73
|
+
}
|
|
74
|
+
return { items, nodes };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type TocEntry = OutlineItem & { children: OutlineItem[] };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The list a table of contents draws (V2 `renderArticle("toc")`): only h2 at depth "2"; an h3
|
|
81
|
+
* nests under the entry before it, and opens the list only when nothing comes before it.
|
|
82
|
+
*/
|
|
83
|
+
export function tocTree(items: readonly OutlineItem[], depth: TocDepth): TocEntry[] {
|
|
84
|
+
const out: TocEntry[] = [];
|
|
85
|
+
for (const it of items) {
|
|
86
|
+
if (it.depth === 3 && depth === "2") continue;
|
|
87
|
+
const last = out[out.length - 1];
|
|
88
|
+
if (it.depth === 3 && last) last.children.push(it);
|
|
89
|
+
else out.push({ ...it, children: [] });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What a reader sees of the block itself: its title (trimmed), the label its nav carries, and its depth. */
|
|
95
|
+
export function tocView(value: unknown): { title: string; label: string; depth: TocDepth } {
|
|
96
|
+
const v = isRecord(value) ? value : {};
|
|
97
|
+
const title = typeof v.title === "string" ? v.title.trim() : "";
|
|
98
|
+
return { title, label: title || "Table of contents", depth: v.depth === "2" ? "2" : "3" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One reason a stored table of contents is refused, at its own path inside the block. */
|
|
102
|
+
export type PortableTextTocProblem = { path: Array<string | number>; code: string; message: string };
|
|
103
|
+
|
|
104
|
+
const KEYS = new Set(["_type", "_key", "title", "depth"]);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every reason `node` cannot be stored: `toc_invalid` (a shape problem) or `toc_limit_exceeded`
|
|
108
|
+
* (V2's 40-character title). A derived `outline` is refused by the R8 check, by name.
|
|
109
|
+
*/
|
|
110
|
+
export function tableOfContentsProblems(node: unknown): PortableTextTocProblem[] {
|
|
111
|
+
const out: PortableTextTocProblem[] = [];
|
|
112
|
+
if (!isRecord(node) || node._type !== PT_TYPES.tableOfContents) return out;
|
|
113
|
+
const bad = (path: Array<string | number>, message: string, code = "toc_invalid") =>
|
|
114
|
+
out.push({ path, code, message: `${code}: ${message}` });
|
|
115
|
+
for (const k of Object.keys(node)) if (!KEYS.has(k) && k !== "outline") bad([k], `unknown key "${k}" (title, depth)`);
|
|
116
|
+
if (!(TOC_DEPTHS as readonly unknown[]).includes(node.depth)) bad(["depth"], 'depth must be "2" or "3"');
|
|
117
|
+
if (node.title !== undefined) {
|
|
118
|
+
if (typeof node.title !== "string") bad(["title"], "title must be a string");
|
|
119
|
+
else if (node.title.length > PT_TOC_LIMITS.titleChars) {
|
|
120
|
+
bad(["title"], `title is ${node.title.length} characters; at most ${PT_TOC_LIMITS.titleChars}`, "toc_limit_exceeded");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `tableOfContentsProblems` for every table of contents in a value (top level, and in table cells), with its block key. */
|
|
127
|
+
export function portableTextTableOfContentsProblems(value: unknown): Array<PortableTextTocProblem & { block: string }> {
|
|
128
|
+
const out: Array<PortableTextTocProblem & { block: string }> = [];
|
|
129
|
+
const walk = (blocks: unknown) => {
|
|
130
|
+
if (!Array.isArray(blocks)) return;
|
|
131
|
+
for (const node of blocks) {
|
|
132
|
+
if (isPortableTextTable(node)) {
|
|
133
|
+
for (const row of node.rows as unknown[]) {
|
|
134
|
+
if (!isRecord(row) || !Array.isArray(row.cells)) continue;
|
|
135
|
+
for (const cell of row.cells) if (isRecord(cell)) walk(cell.content);
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!isRecord(node) || node._type !== PT_TYPES.tableOfContents) continue;
|
|
140
|
+
const block = typeof node._key === "string" ? node._key : "";
|
|
141
|
+
for (const p of tableOfContentsProblems(node)) out.push({ ...p, block });
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
walk(value);
|
|
145
|
+
return out;
|
|
146
|
+
}
|
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. */
|
|
@@ -338,6 +343,83 @@ export interface PortableTextKeyTakeaways extends PortableTextObject {
|
|
|
338
343
|
points: string[];
|
|
339
344
|
}
|
|
340
345
|
|
|
346
|
+
/**
|
|
347
|
+
* RT3: an embed, as V2's EmbedModel. Only what the author sets is stored: the link, the
|
|
348
|
+
* screen-reader title (≤ EMBED_TITLE_MAX) and the shape. The provider, its kind and the frame src
|
|
349
|
+
* are derived from `url` at render (`resolveEmbed`), never stored.
|
|
350
|
+
*/
|
|
351
|
+
export interface PortableTextEmbed extends PortableTextObject {
|
|
352
|
+
_type: "embed";
|
|
353
|
+
url: string;
|
|
354
|
+
title: string;
|
|
355
|
+
aspect: "16:9" | "4:3" | "1:1" | "9:16";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* RT4: a gallery, as V2's GalleryModel. Each image is the rich-text image's own `{ src, alt }`
|
|
360
|
+
* (+ an optional media id), so media rewrites, alt audits and CDN transforms read it the same way.
|
|
361
|
+
* Limits and the write check: gallery.ts.
|
|
362
|
+
*/
|
|
363
|
+
export interface PortableTextGallery extends PortableTextObject {
|
|
364
|
+
_type: "gallery";
|
|
365
|
+
layout: "grid" | "carousel";
|
|
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
|
+
}[];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* RT5: a table of contents, as V2's TocModel: a title (≤ 40) and which headings it lists. The
|
|
376
|
+
* outline is derived from the value's headings at render (toc.ts) and never stored.
|
|
377
|
+
*/
|
|
378
|
+
export interface PortableTextTableOfContents extends PortableTextObject {
|
|
379
|
+
_type: "tableOfContents";
|
|
380
|
+
title?: string;
|
|
381
|
+
depth: "2" | "3";
|
|
382
|
+
}
|
|
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
|
+
|
|
341
423
|
export const isPortableTextBlock = (n: unknown): n is PortableTextBlock =>
|
|
342
424
|
!!n && typeof n === "object" && (n as PortableTextNode)._type === "block";
|
|
343
425
|
|
|
@@ -359,6 +441,8 @@ export interface RichTextEnvelope {
|
|
|
359
441
|
format: typeof PORTABLE_TEXT_FORMAT;
|
|
360
442
|
value: PortableText;
|
|
361
443
|
html: string;
|
|
444
|
+
/** RT3: what its embeds need beside `html` (the X cards), written at html-cache build. */
|
|
445
|
+
embeds?: import("@bettercms-ai/types").RichTextEmbeds;
|
|
362
446
|
}
|
|
363
447
|
|
|
364
448
|
/**
|
|
@@ -389,6 +473,24 @@ export const PT_TYPES = {
|
|
|
389
473
|
pullQuote: "pullQuote",
|
|
390
474
|
/** Key takeaways — see `PortableTextKeyTakeaways`. The V2 kit's `<aside data-rt="takeaways">`. */
|
|
391
475
|
keyTakeaways: "keyTakeaways",
|
|
476
|
+
/**
|
|
477
|
+
* A third-party embed (X, CodePen, Figma, Maps, Spotify, Podcasts, audio, a link card) — see
|
|
478
|
+
* `PortableTextEmbed` and `embed.ts`. The V2 kit's `<figure data-rt="embed">`. Gated by the
|
|
479
|
+
* `embed` feature; YouTube and Vimeo links still resolve here too, through the video resolver.
|
|
480
|
+
*/
|
|
481
|
+
embed: "embed",
|
|
482
|
+
/** A gallery of images, a grid or a carousel — see `PortableTextGallery`. The V2 kit's `<figure data-rt="gallery">`. */
|
|
483
|
+
gallery: "gallery",
|
|
484
|
+
/** A table of contents built from the headings — see `PortableTextTableOfContents`. The V2 kit's `<nav data-rt="toc">`. */
|
|
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",
|
|
392
494
|
/**
|
|
393
495
|
* A table. Spelled `table`, unprefixed, because that is what every other Portable Text
|
|
394
496
|
* producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
|
|
@@ -446,6 +548,12 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
|
|
|
446
548
|
PT_TYPES.file,
|
|
447
549
|
PT_TYPES.pullQuote,
|
|
448
550
|
PT_TYPES.keyTakeaways,
|
|
551
|
+
PT_TYPES.embed,
|
|
552
|
+
PT_TYPES.gallery,
|
|
553
|
+
PT_TYPES.tableOfContents,
|
|
554
|
+
PT_TYPES.columns,
|
|
555
|
+
PT_TYPES.authorBio,
|
|
556
|
+
PT_TYPES.relatedPosts,
|
|
449
557
|
]);
|
|
450
558
|
|
|
451
559
|
/**
|
|
@@ -465,6 +573,13 @@ export const BUILTIN_EMBED_KEY = "builtin:video" as const;
|
|
|
465
573
|
/** A callout (`{ tone: info|tip|warning, title, message }`) and a toggle (`{ title, body }`): plain text, read straight. */
|
|
466
574
|
export const BUILTIN_CALLOUT_KEY = "builtin:callout" as const;
|
|
467
575
|
export const BUILTIN_TOGGLE_KEY = "builtin:toggle" as const;
|
|
576
|
+
/**
|
|
577
|
+
* A part of a body the SITE's own code renders: an MDX component (`<Figure>…</Figure>`, a chart, a
|
|
578
|
+
* tabs widget) or an `{expression}`. Not content an author edits here: the page shows what the
|
|
579
|
+
* build drew, publish never writes over it, and the editor shows it as a locked card. `fields.source`
|
|
580
|
+
* keeps what the repo wrote, for the card; the delivered html carries only the empty marker.
|
|
581
|
+
*/
|
|
582
|
+
export const BUILTIN_SITE_KEY = "builtin:site" as const;
|
|
468
583
|
|
|
469
584
|
/**
|
|
470
585
|
* The `schemaKey` of a component embedded in rich text. A wire value shared with the dashboard
|
|
@@ -493,7 +608,7 @@ export function foreignBlocks(value: unknown): { _type: string; _key: string }[]
|
|
|
493
608
|
if (!node || typeof node !== "object" || Array.isArray(node)) continue;
|
|
494
609
|
const type = (node as PortableTextNode)._type;
|
|
495
610
|
if (typeof type !== "string") continue;
|
|
496
|
-
// A table, a pull quote
|
|
611
|
+
// A table, a pull quote, key takeaways, a gallery and a table of contents are admitted by SHAPE, never by `_type` alone —
|
|
497
612
|
// see `isStorableTable`.
|
|
498
613
|
const storable = type === PT_TYPES.table
|
|
499
614
|
? isStorableTable(node)
|
|
@@ -501,7 +616,16 @@ export function foreignBlocks(value: unknown): { _type: string; _key: string }[]
|
|
|
501
616
|
? isStorablePullQuote(node)
|
|
502
617
|
: type === PT_TYPES.keyTakeaways
|
|
503
618
|
? isStorableKeyTakeaways(node)
|
|
504
|
-
:
|
|
619
|
+
: type === PT_TYPES.gallery
|
|
620
|
+
? isStorableGallery(node)
|
|
621
|
+
: type === PT_TYPES.tableOfContents
|
|
622
|
+
? isStorableTableOfContents(node)
|
|
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);
|
|
505
629
|
if (storable) continue;
|
|
506
630
|
const key = (node as PortableTextNode)._key;
|
|
507
631
|
out.push({ _type: type, _key: typeof key === "string" ? key : "" });
|
|
@@ -575,6 +699,37 @@ export function isStorableKeyTakeaways(node: unknown): boolean {
|
|
|
575
699
|
return Array.isArray((node as { points?: unknown }).points);
|
|
576
700
|
}
|
|
577
701
|
|
|
702
|
+
/** An embed holds a link, a title of at most 120 characters, and one of V2's four shapes. */
|
|
703
|
+
export function isStorableEmbed(node: unknown): boolean {
|
|
704
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return false;
|
|
705
|
+
const n = node as { url?: unknown; title?: unknown; aspect?: unknown };
|
|
706
|
+
return typeof n.url === "string" && typeof n.title === "string" && n.title.length <= 120
|
|
707
|
+
&& (n.aspect === "16:9" || n.aspect === "4:3" || n.aspect === "1:1" || n.aspect === "9:16");
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Is this `_type:"gallery"` one we can render? An `images` array; what is in it and the limits
|
|
712
|
+
* are judged by `galleryProblems` (gallery.ts), which names each problem with a code.
|
|
713
|
+
*/
|
|
714
|
+
export function isStorableGallery(node: unknown): boolean {
|
|
715
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return false;
|
|
716
|
+
return Array.isArray((node as { images?: unknown }).images);
|
|
717
|
+
}
|
|
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
|
+
|
|
726
|
+
/** Is this `_type:"tableOfContents"` one we can render? One of V2's two depths; the title is judged by `tableOfContentsProblems`. */
|
|
727
|
+
export function isStorableTableOfContents(node: unknown): boolean {
|
|
728
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return false;
|
|
729
|
+
const depth = (node as { depth?: unknown }).depth;
|
|
730
|
+
return depth === "2" || depth === "3";
|
|
731
|
+
}
|
|
732
|
+
|
|
578
733
|
/**
|
|
579
734
|
* @deprecated The marker a table-accepting field used to carry INSTEAD of a table.
|
|
580
735
|
*
|