@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/package.json +3 -1
- package/src/accept.ts +63 -10
- package/src/embed.ts +242 -0
- package/src/features.ts +6 -23
- package/src/from-lexical.ts +2 -1
- package/src/gallery.ts +147 -0
- package/src/html.ts +138 -9
- package/src/index.ts +4 -0
- package/src/ingest.ts +249 -3
- package/src/plain.ts +6 -0
- package/src/read.ts +26 -0
- package/src/schema.ts +38 -1
- package/src/toc.ts +129 -0
- package/src/types.ts +85 -2
- package/src/write-refusals.ts +106 -0
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
|
|
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
|
-
:
|
|
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
|
+
}
|