@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,114 @@
|
|
|
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 { PT_TYPES, 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
|
+
const judge = (node: PortableText[number]) => {
|
|
43
|
+
if (!isPortableTextBlock(node)) {
|
|
44
|
+
if (!blockObjects.has(node._type)) refuse(node._type);
|
|
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;
|
|
52
|
+
}
|
|
53
|
+
if (!styles.has(node.style ?? "normal")) refuse(node.style);
|
|
54
|
+
if (node.listItem !== undefined && !lists.has(node.listItem)) refuse(node.listItem);
|
|
55
|
+
const defs = new Set<string>();
|
|
56
|
+
for (const def of node.markDefs ?? []) {
|
|
57
|
+
defs.add(def._key);
|
|
58
|
+
if (def._type === "link") {
|
|
59
|
+
if (def.button === true ? !buttonOk : !linkOk) refuse(def.button === true ? "button" : "link");
|
|
60
|
+
} else if (!annotations.has(def._type)) refuse(def._type);
|
|
61
|
+
}
|
|
62
|
+
for (const child of node.children ?? []) {
|
|
63
|
+
if (child._type !== "span") {
|
|
64
|
+
if (!inlineObjects.has(child._type)) refuse(child._type);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// A mark is a markDef key (its annotation was judged above) or a decorator name.
|
|
68
|
+
for (const mark of (child as { marks?: string[] }).marks ?? []) if (!defs.has(mark) && !decorators.has(mark)) refuse(mark);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
for (const node of value) judge(node);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What each V2 block computes at render time and never stores (kit-richtext-blocks-gap.md, the
|
|
77
|
+
* "Derived" line of each block). `posts[].title` means that key on every entry of `posts`. Each RT
|
|
78
|
+
* step confirms its row when it builds the type; a key outside its row is not judged here.
|
|
79
|
+
*/
|
|
80
|
+
export const DERIVED_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
|
81
|
+
footnote: ["number"],
|
|
82
|
+
embed: ["provider", "shape", "src"],
|
|
83
|
+
columns: ["cols"],
|
|
84
|
+
tableOfContents: ["outline"],
|
|
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"],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Whether a node holds a derived path: `key`, or `list[].key` on any entry of the list. */
|
|
91
|
+
const holds = (node: Record<string, unknown>, path: string): boolean => {
|
|
92
|
+
const [list, key] = path.split("[].");
|
|
93
|
+
if (key === undefined) return Object.hasOwn(node, path);
|
|
94
|
+
const items = node[list];
|
|
95
|
+
return Array.isArray(items) && items.some((item) => item !== null && typeof item === "object" && Object.hasOwn(item, key));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* R8: every derived key a write carries, top-level nodes and inline children alike, refused as
|
|
100
|
+
* `richtext.derived_field:<_type>.<path>` (`embed.provider`, `relatedPosts.posts[].title`).
|
|
101
|
+
*/
|
|
102
|
+
export function derivedFieldProblems(value: PortableText): UiRefusal[] {
|
|
103
|
+
const out: UiRefusal[] = [];
|
|
104
|
+
const judge = (node: PortableTextNode) => {
|
|
105
|
+
for (const path of DERIVED_FIELDS[node._type] ?? []) {
|
|
106
|
+
if (holds(node as unknown as Record<string, unknown>, path)) out.push(richTextRefusal("derived_field", `${node._type}.${path}`));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
for (const node of value) {
|
|
110
|
+
judge(node);
|
|
111
|
+
if (isPortableTextBlock(node)) for (const child of node.children ?? []) judge(child);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|