@simmalugnt-se/payload-editor-assistant 0.7.0 → 0.9.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +21 -0
  3. package/dist/components/EditorAssistantAside.js +15 -5
  4. package/dist/components/ReplyText.d.ts +4 -0
  5. package/dist/components/ReplyText.js +26 -0
  6. package/dist/components/copy.d.ts +8 -0
  7. package/dist/components/copy.js +18 -0
  8. package/dist/components/reply-markdown.d.ts +26 -0
  9. package/dist/components/reply-markdown.js +142 -0
  10. package/dist/components/translation-offer.d.ts +16 -0
  11. package/dist/components/translation-offer.js +38 -0
  12. package/dist/domain/content.js +3 -0
  13. package/dist/domain/decide.js +10 -8
  14. package/dist/domain/propose.js +56 -19
  15. package/dist/domain/relations.d.ts +5 -1
  16. package/dist/domain/relations.js +6 -2
  17. package/dist/endpoints/translation.d.ts +11 -0
  18. package/dist/endpoints/translation.js +56 -0
  19. package/dist/form/diff.d.ts +6 -1
  20. package/dist/form/diff.js +35 -1
  21. package/dist/form/empty.d.ts +0 -1
  22. package/dist/form/empty.js +0 -20
  23. package/dist/form/rich-text.d.ts +72 -0
  24. package/dist/form/rich-text.js +254 -0
  25. package/dist/form/shape.d.ts +12 -3
  26. package/dist/form/shape.js +92 -13
  27. package/dist/form/translation.d.ts +14 -0
  28. package/dist/form/translation.js +81 -0
  29. package/dist/plugin.js +2 -0
  30. package/dist/runtime/agent.js +3 -0
  31. package/dist/runtime/context.d.ts +7 -0
  32. package/dist/runtime/context.js +25 -7
  33. package/dist/runtime/system-prompt.js +32 -21
  34. package/dist/runtime/tools.js +1 -1
  35. package/dist/runtime/translation-source.d.ts +10 -0
  36. package/dist/runtime/translation-source.js +12 -0
  37. package/dist/runtime/voice.js +20 -7
  38. package/dist/schema/discover.d.ts +2 -0
  39. package/dist/schema/discover.js +9 -0
  40. package/dist/schema/fields.d.ts +7 -0
  41. package/dist/schema/fields.js +26 -0
  42. package/dist/schema/project.js +4 -0
  43. package/dist/styles/assistant.css +30 -1
  44. package/package.json +8 -1
@@ -0,0 +1,11 @@
1
+ import type { Endpoint } from "payload";
2
+ import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
+ /**
4
+ * Whether the open document has fields to translate, so the panel can offer it: the language they
5
+ * come from and how many, compared on the saved version. Read only, with the editor's access.
6
+ */
7
+ export declare function createTranslationEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
8
+ export type TranslationOffer = {
9
+ source?: string;
10
+ empty: number;
11
+ };
@@ -0,0 +1,56 @@
1
+ import { hasCapability, resolveAllowlist } from "../domain/allowlist.js";
2
+ import { findContentById } from "../domain/content.js";
3
+ import { loadRichText } from "../form/rich-text.js";
4
+ import { translationPaths } from "../form/translation.js";
5
+ import { ENDPOINT_PREFIX } from "../package-name.js";
6
+ import { sourceLocale } from "../runtime/translation-source.js";
7
+ import { configuredDefaultLocale, configuredLocales, entityFields } from "../schema/discover.js";
8
+ /**
9
+ * Whether the open document has fields to translate, so the panel can offer it: the language they
10
+ * come from and how many, compared on the saved version. Read only, with the editor's access.
11
+ */
12
+ export function createTranslationEndpoint(options) {
13
+ return {
14
+ path: `${ENDPOINT_PREFIX}/translation`,
15
+ method: "get",
16
+ handler: async (req) => {
17
+ if (!req.user) {
18
+ return Response.json({ error: "unauthorized" }, { status: 401 });
19
+ }
20
+ if (options.enabled && !(await options.enabled({ userId: req.user.id }))) {
21
+ return Response.json({ error: "unavailable" }, { status: 403 });
22
+ }
23
+ const params = req.searchParams ?? new URL(req.url ?? "", "http://localhost").searchParams;
24
+ const target = {
25
+ collection: params.get("collection") ?? undefined,
26
+ global: params.get("global") ?? undefined,
27
+ documentId: params.get("id") ?? undefined,
28
+ locale: params.get("locale") ?? "",
29
+ };
30
+ return Response.json(await translationOffer(req, options, target));
31
+ },
32
+ };
33
+ }
34
+ async function translationOffer(req, options, target) {
35
+ const resolved = resolveAllowlist(options, target);
36
+ const source = sourceLocale({
37
+ open: target.locale,
38
+ locales: configuredLocales(req),
39
+ defaultLocale: configuredDefaultLocale(req),
40
+ });
41
+ if (!source ||
42
+ !resolved ||
43
+ !hasCapability(resolved.allowlist, "form.propose") ||
44
+ (target.collection && !target.documentId)) {
45
+ return { empty: 0 };
46
+ }
47
+ await loadRichText();
48
+ // One after the other: both reads share the request and its transaction.
49
+ const from = await findContentById(req, options, { ...target, locale: source });
50
+ const to = await findContentById(req, options, target);
51
+ if (!from.ok || !to.ok) {
52
+ return { empty: 0 };
53
+ }
54
+ const fields = entityFields(req, options, target);
55
+ return { source, empty: translationPaths(fields, to.data, from.data).empty.length };
56
+ }
@@ -6,8 +6,13 @@ export type HumanDiffLine = {
6
6
  export type DiffLanguage = "sv" | "en";
7
7
  /**
8
8
  * `labels` names related documents by id (see `labelRelations`), so a chosen image reads as its
9
- * filename or alt text instead of an id.
9
+ * filename or alt text instead of an id. Rich text is listed by `richTextLines` instead.
10
10
  */
11
11
  export declare function humanDiff(current: Record<string, unknown>, operations: FormOperation[], language?: DiffLanguage, labels?: Map<string, string>): HumanDiffLine[];
12
+ /**
13
+ * The parts of a rich text field that changed: `before` as the model read it (Markdown blocks),
14
+ * `after` as compiled, where `{ keep: i }` is block `i` unchanged.
15
+ */
16
+ export declare function richTextLines(path: string, before: unknown[], after: unknown[], language?: DiffLanguage): HumanDiffLine[];
12
17
  /** Sub-lines are indented two spaces; the approval panel nests them under the header. */
13
18
  export declare const DIFF_SUBLINE_INDENT = " ";
package/dist/form/diff.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isLexical, lexicalPlaintext } from "./lexical.js";
2
2
  import { getAtPath, parseFieldPath } from "./path.js";
3
+ import { richTextDiff } from "./rich-text.js";
3
4
  const COPY = {
4
5
  en: {
5
6
  empty: "empty",
@@ -12,6 +13,9 @@ const COPY = {
12
13
  removeBlock: (n, field) => `Remove block ${n} from ${field}`,
13
14
  moveBlock: (from, to) => `Move block ${from} to ${to}`,
14
15
  replaceBlock: (n, block) => `Replace block ${n} with ${block}`,
16
+ part: (field, n) => `${field}, part ${n}`,
17
+ addPart: (field, n) => `${field}, new part ${n}`,
18
+ removePart: (field, n) => `${field}, part ${n} removed`,
15
19
  unknown: "Unknown change",
16
20
  },
17
21
  sv: {
@@ -25,6 +29,9 @@ const COPY = {
25
29
  removeBlock: (n, field) => `Ta bort block ${n} från ${field}`,
26
30
  moveBlock: (from, to) => `Flytta block ${from} till ${to}`,
27
31
  replaceBlock: (n, block) => `Ersätt block ${n} med ${block}`,
32
+ part: (field, n) => `${field}, del ${n}`,
33
+ addPart: (field, n) => `${field}, ny del ${n}`,
34
+ removePart: (field, n) => `${field}, del ${n} tas bort`,
28
35
  unknown: "Okänd ändring",
29
36
  },
30
37
  };
@@ -32,7 +39,7 @@ const HEADER_PREVIEW = 80;
32
39
  const FIELD_PREVIEW = 300;
33
40
  /**
34
41
  * `labels` names related documents by id (see `labelRelations`), so a chosen image reads as its
35
- * filename or alt text instead of an id.
42
+ * filename or alt text instead of an id. Rich text is listed by `richTextLines` instead.
36
43
  */
37
44
  export function humanDiff(current, operations, language = "en", labels) {
38
45
  const copy = COPY[language];
@@ -93,6 +100,33 @@ export function humanDiff(current, operations, language = "en", labels) {
93
100
  }
94
101
  });
95
102
  }
103
+ /**
104
+ * The parts of a rich text field that changed: `before` as the model read it (Markdown blocks),
105
+ * `after` as compiled, where `{ keep: i }` is block `i` unchanged.
106
+ */
107
+ export function richTextLines(path, before, after, language = "en") {
108
+ const copy = COPY[language];
109
+ const field = label(path);
110
+ const part = (block) => typeof block === "string"
111
+ ? preview(block, copy, FIELD_PREVIEW)
112
+ : `[${block.type ?? "?"}]`;
113
+ return richTextDiff(before, after).map((change) => {
114
+ switch (change.kind) {
115
+ case "changed":
116
+ return {
117
+ path,
118
+ text: `${copy.part(field, change.index + 1)}: ${part(change.before)} → ${part(change.after)}`,
119
+ };
120
+ case "added":
121
+ return { path, text: `${copy.addPart(field, change.index + 1)}: ${part(change.after)}` };
122
+ default:
123
+ return {
124
+ path,
125
+ text: `${copy.removePart(field, change.index + 1)}: ${part(change.before)}`,
126
+ };
127
+ }
128
+ });
129
+ }
96
130
  /** Sub-lines are indented two spaces; the approval panel nests them under the header. */
97
131
  export const DIFF_SUBLINE_INDENT = " ";
98
132
  function withFields(header, value, copy) {
@@ -1,2 +1 @@
1
1
  export declare function listEmptyTextPaths(data: Record<string, unknown>): string[];
2
- export declare function previewAtPath(data: Record<string, unknown>, path: string): string | undefined;
@@ -1,6 +1,5 @@
1
1
  import { isDeniedFieldName } from "../config/denied-fields.js";
2
2
  import { lexicalPlaintext } from "./lexical.js";
3
- import { getAtPath, parseFieldPath } from "./path.js";
4
3
  const SKIP = new Set(["id", "slug", "blockType", "_status", "parent"]);
5
4
  const MAX_PATHS = 12;
6
5
  export function listEmptyTextPaths(data) {
@@ -8,22 +7,6 @@ export function listEmptyTextPaths(data) {
8
7
  walk(data, "", paths);
9
8
  return paths;
10
9
  }
11
- export function previewAtPath(data, path) {
12
- try {
13
- const value = getAtPath(data, parseFieldPath(path));
14
- if (typeof value === "string" && value.trim()) {
15
- return truncate(value.trim());
16
- }
17
- if (value && typeof value === "object" && "root" in value) {
18
- const text = lexicalPlaintext(value);
19
- return text ? truncate(text) : undefined;
20
- }
21
- }
22
- catch {
23
- return undefined;
24
- }
25
- return undefined;
26
- }
27
10
  function walk(value, path, into) {
28
11
  if (into.length >= MAX_PATHS) {
29
12
  return;
@@ -56,6 +39,3 @@ function walk(value, path, into) {
56
39
  }
57
40
  }
58
41
  }
59
- function truncate(value) {
60
- return value.length > 80 ? `${value.slice(0, 77)}…` : value;
61
- }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Rich text for the model: Lexical editor state becomes a list of Markdown blocks, one per top-level
3
+ * node, and back. Payload's own converters do the work, with each field's own editor config, so the
4
+ * model can only write what the field allows. `{ keep: i }` stands for stored block `i` as it is:
5
+ * the model sends it for every block it leaves alone, and gets it for what Markdown cannot say
6
+ * (uploads, relationships, custom blocks), which it may move or drop but never create or change.
7
+ * A kept block, or one sent back unchanged, reuses the stored node exactly, keeping alignment,
8
+ * indent and styles.
9
+ *
10
+ * `@payloadcms/richtext-lexical` is an optional peer: without it, rich text stays as it was (plain
11
+ * text in, paragraphs out). Call `loadRichText` before converting.
12
+ */
13
+ export type KeptBlock = {
14
+ keep: number;
15
+ type?: string;
16
+ };
17
+ export type RichTextBlock = string | KeptBlock;
18
+ export type RichTextLink = {
19
+ collection: string;
20
+ id: string;
21
+ };
22
+ type LexicalNode = Record<string, unknown> & {
23
+ type?: unknown;
24
+ children?: unknown;
25
+ };
26
+ type LexicalState = {
27
+ root: Record<string, unknown> & {
28
+ children: LexicalNode[];
29
+ };
30
+ };
31
+ /** Loads Payload's converters once; false when `@payloadcms/richtext-lexical` is not installed. */
32
+ export declare function loadRichText(): Promise<boolean>;
33
+ export declare function isLexicalState(value: unknown): value is LexicalState;
34
+ export declare function isRichTextBlocks(value: unknown): value is RichTextBlock[];
35
+ /**
36
+ * The model's view of stored rich text, or null when it cannot be converted (no converters, no
37
+ * editor config). An empty field is `""`, so it reads as an empty text to fill.
38
+ */
39
+ export declare function toBlocks(value: unknown, editor: unknown): RichTextBlock[] | "" | null;
40
+ /**
41
+ * Rich text from what the model sent: blocks (see `toBlocks`) or a Markdown string. `original` is
42
+ * the stored value the blocks were made from. `links` are the internal links in rebuilt blocks, to
43
+ * check against the editor's access; links in reused blocks were there already.
44
+ */
45
+ export declare function fromBlocks(value: RichTextBlock[] | string, original: unknown, editor: unknown): {
46
+ ok: true;
47
+ state: LexicalState;
48
+ links: RichTextLink[];
49
+ } | {
50
+ ok: false;
51
+ error: string;
52
+ };
53
+ export type BlockChange = {
54
+ kind: "changed";
55
+ index: number;
56
+ before: RichTextBlock;
57
+ after: RichTextBlock;
58
+ } | {
59
+ kind: "added";
60
+ index: number;
61
+ after: RichTextBlock;
62
+ } | {
63
+ kind: "removed";
64
+ index: number;
65
+ before: RichTextBlock;
66
+ };
67
+ /**
68
+ * Which blocks changed between two block lists, by position in the new list for added and changed
69
+ * blocks and in the old list for removed ones. A removal next to an addition reads as a change.
70
+ */
71
+ export declare function richTextDiff(before: unknown[], sent: unknown[]): BlockChange[];
72
+ export {};
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Rich text for the model: Lexical editor state becomes a list of Markdown blocks, one per top-level
3
+ * node, and back. Payload's own converters do the work, with each field's own editor config, so the
4
+ * model can only write what the field allows. `{ keep: i }` stands for stored block `i` as it is:
5
+ * the model sends it for every block it leaves alone, and gets it for what Markdown cannot say
6
+ * (uploads, relationships, custom blocks), which it may move or drop but never create or change.
7
+ * A kept block, or one sent back unchanged, reuses the stored node exactly, keeping alignment,
8
+ * indent and styles.
9
+ *
10
+ * `@payloadcms/richtext-lexical` is an optional peer: without it, rich text stays as it was (plain
11
+ * text in, paragraphs out). Call `loadRichText` before converting.
12
+ */
13
+ /** Top-level nodes whose Markdown round trip keeps them as they are. */
14
+ const MARKDOWN_NODES = new Set(["paragraph", "heading", "list", "quote", "horizontalrule"]);
15
+ /** Inline nodes Markdown can carry; a block holding anything else is kept whole. */
16
+ const MARKDOWN_INLINE = new Set(["text", "link", "autolink", "linebreak", "tab", "listitem"]);
17
+ /** How the model writes an internal link: `[text](doc:pages/12)`. */
18
+ const DOC_LINK = /^doc:([\w-]+)\/(.+)$/;
19
+ /**
20
+ * Payload's Markdown export drops unknown URL schemes, so internal links leave as this address and
21
+ * become `doc:` in the Markdown. Its import keeps `doc:` as written.
22
+ */
23
+ const DOC_EXPORT = "https://doc.invalid/";
24
+ let converters;
25
+ /** Loads Payload's converters once; false when `@payloadcms/richtext-lexical` is not installed. */
26
+ export async function loadRichText() {
27
+ if (converters === undefined) {
28
+ try {
29
+ const lexical = await import("@payloadcms/richtext-lexical");
30
+ converters = {
31
+ toMarkdown: lexical.convertLexicalToMarkdown,
32
+ toLexical: lexical.convertMarkdownToLexical,
33
+ };
34
+ }
35
+ catch {
36
+ converters = null;
37
+ }
38
+ }
39
+ return converters !== null;
40
+ }
41
+ export function isLexicalState(value) {
42
+ const root = value?.root;
43
+ return Boolean(root && typeof root === "object" && Array.isArray(root.children));
44
+ }
45
+ export function isRichTextBlocks(value) {
46
+ return Array.isArray(value) && value.every((item) => typeof item === "string" || isKept(item));
47
+ }
48
+ /**
49
+ * The model's view of stored rich text, or null when it cannot be converted (no converters, no
50
+ * editor config). An empty field is `""`, so it reads as an empty text to fill.
51
+ */
52
+ export function toBlocks(value, editor) {
53
+ const editorConfig = editorConfigOf(editor);
54
+ if (!converters || !editorConfig || !isLexicalState(value)) {
55
+ return null;
56
+ }
57
+ const blocks = value.root.children.map((node, index) => canWrite(node)
58
+ ? nodeMarkdown(node, value.root, editorConfig)
59
+ : { keep: index, type: String(node.type ?? "node") });
60
+ const empty = blocks.every((block) => block === "");
61
+ return empty ? "" : blocks;
62
+ }
63
+ /**
64
+ * Rich text from what the model sent: blocks (see `toBlocks`) or a Markdown string. `original` is
65
+ * the stored value the blocks were made from. `links` are the internal links in rebuilt blocks, to
66
+ * check against the editor's access; links in reused blocks were there already.
67
+ */
68
+ export function fromBlocks(value, original, editor) {
69
+ const editorConfig = editorConfigOf(editor);
70
+ if (!converters || !editorConfig) {
71
+ return { ok: false, error: "Rich text cannot be converted here." };
72
+ }
73
+ const stored = isLexicalState(original) ? original : undefined;
74
+ const storedBlocks = stored ? (toBlocks(stored, editor) ?? []) : [];
75
+ const blocks = typeof value === "string" ? [value] : value;
76
+ if (typeof value === "string" && Array.isArray(storedBlocks) && storedBlocks.some(isKept)) {
77
+ return {
78
+ ok: false,
79
+ error: "This rich text holds parts plain text would drop. Send it as a list of blocks, with { keep: i } for each block you leave as it is.",
80
+ };
81
+ }
82
+ const children = [];
83
+ const used = new Set();
84
+ const links = [];
85
+ const allowed = linkCollections(editorConfig);
86
+ for (const block of blocks) {
87
+ if (isKept(block)) {
88
+ const node = stored?.root.children[block.keep];
89
+ if (!node || used.has(block.keep)) {
90
+ return {
91
+ ok: false,
92
+ error: `{ keep: ${block.keep} } is not a block of this rich text, or appears twice.`,
93
+ };
94
+ }
95
+ used.add(block.keep);
96
+ children.push(node);
97
+ continue;
98
+ }
99
+ const reused = Array.isArray(storedBlocks)
100
+ ? storedBlocks.findIndex((entry, index) => entry === block && !used.has(index))
101
+ : -1;
102
+ const storedNode = reused >= 0 ? stored?.root.children[reused] : undefined;
103
+ if (storedNode) {
104
+ used.add(reused);
105
+ children.push(storedNode);
106
+ continue;
107
+ }
108
+ if (!block.trim()) {
109
+ continue;
110
+ }
111
+ const converted = converters.toLexical({ markdown: block, editorConfig });
112
+ const nodes = isLexicalState(converted) ? converted.root.children : [];
113
+ for (const node of nodes) {
114
+ const error = internalLinks(node, allowed, links);
115
+ if (error) {
116
+ return { ok: false, error };
117
+ }
118
+ children.push(node);
119
+ }
120
+ }
121
+ const root = stored?.root ?? {
122
+ type: "root",
123
+ format: "",
124
+ indent: 0,
125
+ version: 1,
126
+ direction: "ltr",
127
+ };
128
+ return { ok: true, state: { root: { ...root, children } }, links };
129
+ }
130
+ /**
131
+ * Which blocks changed between two block lists, by position in the new list for added and changed
132
+ * blocks and in the old list for removed ones. A removal next to an addition reads as a change.
133
+ */
134
+ export function richTextDiff(before, sent) {
135
+ // { keep: i } in what the model sent stands for block i as it was.
136
+ const after = sent.map((block) => (isKept(block) ? (before[block.keep] ?? block) : block));
137
+ const key = (block) => (typeof block === "string" ? block : JSON.stringify(block));
138
+ const a = before.map(key);
139
+ const b = after.map(key);
140
+ // Longest common subsequence, small enough for the blocks of one field.
141
+ const lengths = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
142
+ for (let i = a.length - 1; i >= 0; i--) {
143
+ for (let j = b.length - 1; j >= 0; j--) {
144
+ lengths[i][j] =
145
+ a[i] === b[j] ? lengths[i + 1][j + 1] + 1 : Math.max(lengths[i + 1][j], lengths[i][j + 1]);
146
+ }
147
+ }
148
+ const changes = [];
149
+ let i = 0;
150
+ let j = 0;
151
+ while (i < a.length || j < b.length) {
152
+ if (i < a.length && j < b.length && a[i] === b[j]) {
153
+ i++;
154
+ j++;
155
+ }
156
+ else if (i < a.length &&
157
+ j < b.length &&
158
+ lengths[i + 1][j] === lengths[i][j + 1] &&
159
+ lengths[i + 1][j + 1] === lengths[i][j]) {
160
+ changes.push({
161
+ kind: "changed",
162
+ index: j,
163
+ before: before[i],
164
+ after: after[j],
165
+ });
166
+ i++;
167
+ j++;
168
+ }
169
+ else if (j < b.length && (i >= a.length || lengths[i][j + 1] >= lengths[i + 1][j])) {
170
+ changes.push({ kind: "added", index: j, after: after[j] });
171
+ j++;
172
+ }
173
+ else {
174
+ changes.push({ kind: "removed", index: i, before: before[i] });
175
+ i++;
176
+ }
177
+ }
178
+ return changes;
179
+ }
180
+ function canWrite(node) {
181
+ if (!MARKDOWN_NODES.has(String(node.type))) {
182
+ return false;
183
+ }
184
+ const inline = (child) => {
185
+ const record = child;
186
+ return (MARKDOWN_INLINE.has(String(record?.type)) &&
187
+ (!Array.isArray(record.children) || record.children.every(inline)));
188
+ };
189
+ return !Array.isArray(node.children) || node.children.every(inline);
190
+ }
191
+ /** One top-level node as Markdown, with internal links written as `doc:` links. */
192
+ function nodeMarkdown(node, root, editorConfig) {
193
+ const data = { root: { ...root, children: [withDocLinks(node)] } };
194
+ const markdown = converters?.toMarkdown({ data, editorConfig }).trim() ?? "";
195
+ return markdown.replaceAll(`](${DOC_EXPORT}`, "](doc:");
196
+ }
197
+ function withDocLinks(node) {
198
+ const fields = node.fields;
199
+ const next = { ...node };
200
+ if (node.type === "link" && fields?.linkType === "internal") {
201
+ const doc = fields.doc;
202
+ const id = doc?.value && typeof doc.value === "object" ? doc.value.id : doc?.value;
203
+ next.fields = {
204
+ ...fields,
205
+ linkType: "custom",
206
+ url: `${DOC_EXPORT}${String(doc?.relationTo)}/${String(id)}`,
207
+ };
208
+ }
209
+ if (Array.isArray(node.children)) {
210
+ next.children = node.children.map((child) => withDocLinks(child));
211
+ }
212
+ return next;
213
+ }
214
+ /** Turns `doc:` links into Payload's internal links, collecting them; an error names the rule. */
215
+ function internalLinks(node, allowed, into) {
216
+ const fields = node.fields;
217
+ const match = node.type === "link" && typeof fields?.url === "string" ? DOC_LINK.exec(fields.url) : null;
218
+ if (match) {
219
+ const [, collection = "", id = ""] = match;
220
+ // Without enabledCollections, Payload lets links point at any collection; access decides.
221
+ if (allowed && !allowed.includes(collection)) {
222
+ return allowed.length
223
+ ? `Links in this text can only point at ${allowed.join(", ")}.`
224
+ : "Links in this text cannot point at documents.";
225
+ }
226
+ node.fields = {
227
+ linkType: "internal",
228
+ doc: { relationTo: collection, value: /^\d+$/.test(id) ? Number(id) : id },
229
+ newTab: fields?.newTab === true,
230
+ };
231
+ into.push({ collection, id });
232
+ }
233
+ for (const child of Array.isArray(node.children) ? node.children : []) {
234
+ const error = internalLinks(child, allowed, into);
235
+ if (error) {
236
+ return error;
237
+ }
238
+ }
239
+ return undefined;
240
+ }
241
+ function linkCollections(editorConfig) {
242
+ const features = editorConfig
243
+ .resolvedFeatureMap;
244
+ const link = features?.get("link");
245
+ const enabled = link?.sanitizedServerFeatureProps?.enabledCollections;
246
+ return Array.isArray(enabled) ? enabled.filter((slug) => typeof slug === "string") : undefined;
247
+ }
248
+ function editorConfigOf(editor) {
249
+ return editor?.editorConfig;
250
+ }
251
+ function isKept(value) {
252
+ const record = value;
253
+ return Boolean(record && typeof record === "object" && Number.isInteger(record.keep));
254
+ }
@@ -1,11 +1,20 @@
1
1
  import type { FieldNode } from "../schema/fields.ts";
2
+ import { type RichTextLink } from "./rich-text.ts";
2
3
  /**
3
- * Make compiled data safe to store: plain text in rich text fields becomes editor state,
4
- * then every block must be typed. Mutates `data`.
4
+ * A copy of `data` where each rich text field the converters can read is a list of `{ keep: i }`,
5
+ * one per stored block, so operations can set, insert or remove single blocks (`layout.1.content.2`)
6
+ * while every other block stays exactly as it was. `finalizeCompiled` turns the lists back.
5
7
  */
6
- export declare function finalizeCompiled(data: Record<string, unknown>, fields: FieldNode[]): {
8
+ export declare function openRichText(data: Record<string, unknown>, fields: FieldNode[]): Record<string, unknown>;
9
+ /**
10
+ * Make compiled data safe to store: rich text written as Markdown blocks or text becomes editor
11
+ * state, reusing what `original` (the form before the change) held, then every block must be
12
+ * typed. Mutates `data`. `links` are internal links in rewritten rich text, to check for access.
13
+ */
14
+ export declare function finalizeCompiled(data: Record<string, unknown>, fields: FieldNode[], original?: Record<string, unknown>): {
7
15
  ok: true;
8
16
  data: Record<string, unknown>;
17
+ links: RichTextLink[];
9
18
  } | {
10
19
  ok: false;
11
20
  error: string;