@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ - Translation: on a document with localized text that is empty in the open language but filled in
6
+ the default one, the panel offers "Translate from English" ("Översätt från engelska"). The
7
+ assistant fills every such field in one proposal, rich text with its formatting and links, and
8
+ leaves translated fields alone unless asked to translate them again. The model is told which
9
+ fields to fill and which are translated already, instead of an 80-character preview of each
10
+ source, and replies in the editor's language rather than the one it translates into.
11
+ - New endpoint `GET /api/editor-assistant/translation` says whether the open document has fields
12
+ to translate, and from which language.
13
+ - Other language versions are read without fallback, as Admin reads them, and a language with no
14
+ text of its own is left out of the model's context.
15
+
16
+ - Replies show their Markdown as formatting instead of asterisks: paragraphs, bullet and numbered
17
+ lists, bold, italic and code. Headings show as bold lines, and links as their text, with a web
18
+ address written out after it; the panel makes no link the model wrote clickable.
19
+ - A reply keeps its line breaks. Before, filtering the model's reply joined it into one line, so a
20
+ list ran together.
21
+
22
+ ## 0.8.0
23
+
24
+ - Rich text keeps its formatting. The model reads Lexical as Markdown blocks, one per paragraph,
25
+ heading or list, and changes single blocks (`set` on `content.2`, `array.insert`,
26
+ `array.remove`); every other block stays exactly as stored, alignment and styles included.
27
+ Headings, lists, bold, italic and links come back through Payload's own converters with the
28
+ field's editor config. Internal links read and write as `[text](doc:pages/12)` and are checked
29
+ against the field's `LinkFeature` collections and the editor's access. Parts Markdown cannot
30
+ express (embedded uploads, relationships, custom blocks) are kept, and may be moved or removed but
31
+ not changed. Before, any change to rich text turned it into plain paragraphs.
32
+ - New optional peer dependency `@payloadcms/richtext-lexical`; without it, rich text works as before.
33
+ - The change list shows which parts of a rich text field changed.
34
+ - A list that is not rich text blocks is refused for a rich text field instead of reaching the form.
35
+ - `validateProposal` receives the data as it will be stored, rich text as Lexical, instead of the
36
+ compiled operations' raw values.
37
+
3
38
  ## 0.7.0
4
39
 
5
40
  - Picks images for upload fields, such as a share image. `content.find` on an upload collection
package/README.md CHANGED
@@ -117,6 +117,27 @@ The suggested change names the image ("Image: empty → office-2024.jpg"), and t
117
117
  Preview show it. Images with alt texts are easier to find. Fields with several images (`hasMany`)
118
118
  are not picked yet.
119
119
 
120
+ ## Rich text
121
+
122
+ With `@payloadcms/richtext-lexical` installed, the assistant reads Lexical rich text as Markdown,
123
+ one block per paragraph, heading or list, and changes only the blocks it is asked to: headings,
124
+ lists, bold, italic and links survive, and every other block stays exactly as stored. It writes only
125
+ what the field's editor allows. Internal links keep pointing at their documents, and new ones must
126
+ point at a collection the field's `LinkFeature` allows and that the editor can read. Parts Markdown
127
+ cannot express, such as an embedded image, are kept as they are. Without the package, rich text is
128
+ written as plain paragraphs as before.
129
+
130
+ ## Translation
131
+
132
+ In a localized project, the assistant translates a document from the default language into the one
133
+ open in Admin. On a document with localized text that is empty here but filled in the default
134
+ language, the panel offers a button ("Translate from English"). The editor can also ask in the
135
+ chat, or start from content health's work list. It fills every empty localized field in one
136
+ proposal, rich text as Markdown blocks with headings, lists, bold, italic and links kept, and leaves
137
+ fields that already have text alone unless asked to translate them again. Fields shared between the
138
+ languages (blocks, images, slugs) never change. The button asks `GET /api/editor-assistant/translation`
139
+ whether there is anything to translate, with the editor's access.
140
+
120
141
  ## Keep or undo
121
142
 
122
143
  A proposed form change goes straight into the open form, so the fields and Live Preview show it
@@ -7,9 +7,11 @@ import { resolveChatLanguage } from "../runtime/language.js";
7
7
  import { parseAdminView } from "../view.js";
8
8
  import { assistantRequest, assistantStream } from "./assistant-api.js";
9
9
  import { useAssistantState, } from "./assistant-context.js";
10
- import { COPY, uiLanguage } from "./copy.js";
10
+ import { COPY, translateAction, uiLanguage } from "./copy.js";
11
11
  import { isFormProposal, proposalSteps, refineProposal, replaceLatestStep, } from "./proposal-chain.js";
12
+ import { ReplyText } from "./ReplyText.js";
12
13
  import { KEEP_SELECTION_ATTR } from "./selection-dom.js";
14
+ import { useTranslationOffer } from "./translation-offer.js";
13
15
  export function EditorAssistantAside() {
14
16
  const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, prefill, setPrefill, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
15
17
  const { config, getEntityConfig } = useConfig();
@@ -39,6 +41,12 @@ export function EditorAssistantAside() {
39
41
  const [step, setStep] = useState(null);
40
42
  const stickToBottomRef = useRef(true);
41
43
  const apiBase = config.routes.api;
44
+ // The offer compares saved versions, so it waits while the form has unsaved changes and asks
45
+ // again once they are saved or after a message: a kept translation takes the button away.
46
+ const offer = useTranslationOffer(apiBase, bridge ?? undefined, `${messages.length}:${bridge?.modified ? 1 : 0}`);
47
+ const translate = offer?.source && offer.empty > 0 && !pendingApproval && !bridge?.modified
48
+ ? translateAction(uiLanguage(i18n.language), offer.source)
49
+ : undefined;
42
50
  // Scroll when the thread grows. The values are triggers, not reads.
43
51
  // biome-ignore lint/correctness/useExhaustiveDependencies: pin to bottom after new messages, working state, or approval cards
44
52
  useLayoutEffect(() => {
@@ -434,15 +442,17 @@ export function EditorAssistantAside() {
434
442
  if (!open) {
435
443
  return null;
436
444
  }
437
- return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, [KEEP_SELECTION_ATTR]: "", children: [_jsxs("div", { className: "ea-aside__head", children: [_jsxs("div", { className: "ea-aside__title", children: [_jsx("h2", { children: copy.assistant }), bridge ? _jsx("p", { className: "ea-aside__context", children: contextLine(bridge, config) }) : null] }), (messages.length > 0 || pendingApproval) && !pendingApproval?.applied ? (_jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => resetConversation(), size: "medium", children: copy.newChat })) : null, _jsx(Button, { "aria-label": copy.collapseLabel, buttonStyle: "icon-label", className: "ea-aside__collapse", icon: _jsx(ChevronIcon, { direction: "right" }), margin: false, onClick: () => setOpen(false), tooltip: copy.collapse })] }), _jsxs("div", { className: "ea-aside__body", children: [_jsxs("div", { className: "ea-messages", ref: threadRef, onScroll: onThreadScroll, children: [messages.length === 0 ? (_jsx("div", { className: "ea-msg ea-msg--assistant", children: introText(view, bridge, collections, config, copy) })) : null, messages.map((message) => (_jsxs("div", { className: `ea-msg ea-msg--${message.role}`, children: [message.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx(Button, { buttonStyle: "pill", margin: false, onClick: () => {
445
+ return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, [KEEP_SELECTION_ATTR]: "", children: [_jsxs("div", { className: "ea-aside__head", children: [_jsxs("div", { className: "ea-aside__title", children: [_jsx("h2", { children: copy.assistant }), bridge ? _jsx("p", { className: "ea-aside__context", children: contextLine(bridge, config) }) : null] }), (messages.length > 0 || pendingApproval) && !pendingApproval?.applied ? (_jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => resetConversation(), size: "medium", children: copy.newChat })) : null, _jsx(Button, { "aria-label": copy.collapseLabel, buttonStyle: "icon-label", className: "ea-aside__collapse", icon: _jsx(ChevronIcon, { direction: "right" }), margin: false, onClick: () => setOpen(false), tooltip: copy.collapse })] }), _jsxs("div", { className: "ea-aside__body", children: [_jsxs("div", { className: "ea-messages", ref: threadRef, onScroll: onThreadScroll, children: [messages.length === 0 ? (_jsx("div", { className: "ea-msg ea-msg--assistant", children: introText(view, bridge, collections, config, copy) })) : null, messages.map((message) => (_jsxs("div", { className: `ea-msg ea-msg--${message.role}`, children: [message.role === "assistant" ? _jsx(ReplyText, { text: message.text }) : message.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx(Button, { buttonStyle: "pill", margin: false, onClick: () => {
438
446
  router.push(candidate.href);
439
447
  }, size: "small", children: candidate.label }, candidate.href))) })) : null] }, message.id))), status === "streaming" ? (_jsxs("div", { className: "ea-msg ea-msg--working", children: [_jsxs("span", { className: "ea-dots", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }), _jsx("span", { children: step ? talk.steps[step] : talk.working })] })) : null] }), pendingApproval ? (_jsxs("div", { className: "ea-approval", children: [_jsx("h3", { children: pendingApproval.title }), pendingApproval.applied ? (_jsxs("p", { className: "ea-approval__hint", children: [autosave ? copy.previewHintAutosave : copy.previewHint, pendingApproval.earlier?.length ? ` ${copy.refinedHint}` : ""] })) : null, _jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
440
448
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
441
449
  _jsx("li", { className: "ea-approval__sub", children: line.trim() }, index)) : (
442
450
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
443
- _jsx("li", { children: line }, index))) }), confirmUndo ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "ea-approval__warn", children: copy.undoLosesEdits }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void undoPreview(pendingApproval, true), size: "medium", children: copy.undoAnyway }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => setConfirmUndo(false), size: "medium", children: copy.cancel })] })] })) : (_jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", disabled: status === "streaming", margin: false, onClick: () => void decide("approve"), size: "medium", children: pendingApproval.applied ? copy.keep : copy.approve }), _jsx(Button, { buttonStyle: "secondary", disabled: status === "streaming", margin: false, onClick: () => void decide("reject"), size: "medium", children: pendingApproval.applied ? copy.undo : copy.reject })] }))] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: statusText(status, talk) })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 ? (_jsx("div", { className: "ea-shortcuts", children: visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
444
- void send(shortcut.prompt);
445
- }, size: "small", children: shortcut.label }, shortcut.id))) })) : null, selection && bridge ? (_jsxs("div", { className: "ea-selection", children: [_jsxs("span", { className: "ea-selection__label", children: [copy.selected, ": ", _jsx("strong", { children: selection.label })] }), _jsx("button", { "aria-label": copy.clearSelection, className: "ea-selection__clear", type: "button", onClick: () => setSelection(null), children: _jsx(XIcon, {}) })] })) : null, _jsxs("div", { className: "ea-composer__box", children: [_jsx("textarea", { disabled: status === "streaming", placeholder: copy.placeholder, rows: 2, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: onKeyDown }), _jsx("div", { className: "ea-composer__bar", children: status === "streaming" ? (
451
+ _jsx("li", { children: line }, index))) }), confirmUndo ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "ea-approval__warn", children: copy.undoLosesEdits }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void undoPreview(pendingApproval, true), size: "medium", children: copy.undoAnyway }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => setConfirmUndo(false), size: "medium", children: copy.cancel })] })] })) : (_jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", disabled: status === "streaming", margin: false, onClick: () => void decide("approve"), size: "medium", children: pendingApproval.applied ? copy.keep : copy.approve }), _jsx(Button, { buttonStyle: "secondary", disabled: status === "streaming", margin: false, onClick: () => void decide("reject"), size: "medium", children: pendingApproval.applied ? copy.undo : copy.reject })] }))] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: statusText(status, talk) })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 || translate ? (_jsxs("div", { className: "ea-shortcuts", children: [translate ? (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
452
+ void send(translate.prompt);
453
+ }, size: "small", children: translate.label })) : null, visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
454
+ void send(shortcut.prompt);
455
+ }, size: "small", children: shortcut.label }, shortcut.id)))] })) : null, selection && bridge ? (_jsxs("div", { className: "ea-selection", children: [_jsxs("span", { className: "ea-selection__label", children: [copy.selected, ": ", _jsx("strong", { children: selection.label })] }), _jsx("button", { "aria-label": copy.clearSelection, className: "ea-selection__clear", type: "button", onClick: () => setSelection(null), children: _jsx(XIcon, {}) })] })) : null, _jsxs("div", { className: "ea-composer__box", children: [_jsx("textarea", { disabled: status === "streaming", placeholder: copy.placeholder, rows: 2, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: onKeyDown }), _jsx("div", { className: "ea-composer__bar", children: status === "streaming" ? (
446
456
  // While the assistant works, Send becomes Stop, where the editor's eye already is.
447
457
  _jsx(Button, { "aria-label": copy.stop, buttonStyle: "secondary", className: "ea-composer__stop", icon: _jsx("span", { className: "ea-stop-icon", "aria-hidden": "true" }), iconPosition: "left", margin: false, onClick: () => stop(), size: "medium", type: "button", children: copy.stop })) : (_jsx(Button, { buttonStyle: "primary", disabled: draft.trim() === "", margin: false, size: "medium", type: "submit", children: copy.send })) })] })] })] })] }));
448
458
  }
@@ -0,0 +1,4 @@
1
+ /** An assistant reply with the model's Markdown shown as formatting instead of asterisks. */
2
+ export declare function ReplyText({ text }: {
3
+ text: string;
4
+ }): import("react").JSX.Element[];
@@ -0,0 +1,26 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { parseReply } from "./reply-markdown.js";
4
+ /** An assistant reply with the model's Markdown shown as formatting instead of asterisks. */
5
+ export function ReplyText({ text }) {
6
+ return parseReply(text).map((block, index) => {
7
+ const key = `${block.type}-${index}`;
8
+ if (block.type === "paragraph") {
9
+ return (_jsx("p", { className: "ea-reply__p", children: spans(block.spans) }, key));
10
+ }
11
+ const items = block.items.map((item, itemIndex) => _jsx("li", { children: spans(item) }, itemIndex));
12
+ return block.ordered ? (_jsx("ol", { className: "ea-reply__list", start: block.start, children: items }, key)) : (_jsx("ul", { className: "ea-reply__list", children: items }, key));
13
+ });
14
+ }
15
+ function spans(list) {
16
+ return list.map((span, index) => {
17
+ let node = span.text;
18
+ if (span.code)
19
+ node = _jsx("code", { children: node });
20
+ if (span.italic)
21
+ node = _jsx("em", { children: node });
22
+ if (span.bold)
23
+ node = _jsx("strong", { children: node });
24
+ return _jsx("span", { children: node }, index);
25
+ });
26
+ }
@@ -133,3 +133,11 @@ export declare const COPY: {
133
133
  };
134
134
  };
135
135
  export type Copy = (typeof COPY)[UiLanguage];
136
+ /**
137
+ * The button the panel offers on a document with fields not translated yet. Its prompt is the one
138
+ * content health's work list sends for a missing translation.
139
+ */
140
+ export declare function translateAction(language: UiLanguage, source: string): {
141
+ label: string;
142
+ prompt: string;
143
+ };
@@ -133,3 +133,21 @@ export const COPY = {
133
133
  },
134
134
  },
135
135
  };
136
+ /**
137
+ * The button the panel offers on a document with fields not translated yet. Its prompt is the one
138
+ * content health's work list sends for a missing translation.
139
+ */
140
+ export function translateAction(language, source) {
141
+ const name = languageName(language, source);
142
+ return language === "sv"
143
+ ? { label: `Översätt från ${name}`, prompt: `Översätt de tomma fälten från ${name}.` }
144
+ : { label: `Translate from ${name}`, prompt: `Translate the empty fields from ${name}.` };
145
+ }
146
+ function languageName(language, code) {
147
+ try {
148
+ return new Intl.DisplayNames([language], { type: "language" }).of(code) ?? code;
149
+ }
150
+ catch {
151
+ return code;
152
+ }
153
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The Markdown the model writes in its replies, read into a small tree the panel renders as React
3
+ * elements: paragraphs, lists, bold, italic and code. Headings show as bold paragraphs. Links show as
4
+ * their text, and a web address stays visible after it, so the panel never renders a link the model
5
+ * wrote. A marker that does not close stays as text, so a half-streamed reply reads the same.
6
+ */
7
+ export type ReplySpan = {
8
+ text: string;
9
+ bold?: true;
10
+ italic?: true;
11
+ code?: true;
12
+ };
13
+ export type ReplyBlock = {
14
+ type: "paragraph";
15
+ spans: ReplySpan[];
16
+ } | {
17
+ type: "list";
18
+ ordered: false;
19
+ items: ReplySpan[][];
20
+ } | {
21
+ type: "list";
22
+ ordered: true;
23
+ start: number;
24
+ items: ReplySpan[][];
25
+ };
26
+ export declare function parseReply(text: string): ReplyBlock[];
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The Markdown the model writes in its replies, read into a small tree the panel renders as React
3
+ * elements: paragraphs, lists, bold, italic and code. Headings show as bold paragraphs. Links show as
4
+ * their text, and a web address stays visible after it, so the panel never renders a link the model
5
+ * wrote. A marker that does not close stays as text, so a half-streamed reply reads the same.
6
+ */
7
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
8
+ const NUMBERED = /^\s*(\d{1,9})[.)]\s+(.*)$/;
9
+ const HEADING = /^\s*#{1,6}\s+(.*?)\s*#*\s*$/;
10
+ const WORD = /[\p{L}\p{N}]/u;
11
+ export function parseReply(text) {
12
+ const blocks = [];
13
+ let paragraph = [];
14
+ let list;
15
+ const flush = () => {
16
+ if (paragraph.length) {
17
+ blocks.push({ type: "paragraph", spans: parseInline(paragraph.join("\n")) });
18
+ }
19
+ if (list) {
20
+ const items = list.items.map((item) => parseInline(item));
21
+ blocks.push(list.ordered
22
+ ? { type: "list", ordered: true, start: list.start, items }
23
+ : { type: "list", ordered: false, items });
24
+ }
25
+ paragraph = [];
26
+ list = undefined;
27
+ };
28
+ for (const line of text.split(/\r?\n/)) {
29
+ const numbered = NUMBERED.exec(line);
30
+ const bullet = numbered ? undefined : BULLET.exec(line);
31
+ if (numbered || bullet) {
32
+ const ordered = Boolean(numbered);
33
+ if (paragraph.length || (list && list.ordered !== ordered))
34
+ flush();
35
+ list ??= { ordered, start: numbered ? Number(numbered[1]) : 1, items: [] };
36
+ list.items.push((numbered ? numbered[2] : bullet?.[1]) ?? "");
37
+ continue;
38
+ }
39
+ if (list)
40
+ flush();
41
+ const heading = HEADING.exec(line);
42
+ if (heading) {
43
+ flush();
44
+ blocks.push({ type: "paragraph", spans: parseInline(heading[1] ?? "", { bold: true }) });
45
+ }
46
+ else if (line.trim() === "") {
47
+ flush();
48
+ }
49
+ else {
50
+ paragraph.push(line);
51
+ }
52
+ }
53
+ flush();
54
+ return blocks;
55
+ }
56
+ function parseInline(text, format = {}) {
57
+ const spans = [];
58
+ const push = (value, spanFormat) => {
59
+ if (!value)
60
+ return;
61
+ const last = spans.at(-1);
62
+ if (last && sameFormat(last, spanFormat))
63
+ last.text += value;
64
+ else
65
+ spans.push({ text: value, ...spanFormat });
66
+ };
67
+ const pushAll = (inner) => {
68
+ for (const { text: value, ...spanFormat } of inner)
69
+ push(value, spanFormat);
70
+ };
71
+ let i = 0;
72
+ while (i < text.length) {
73
+ const ch = text[i];
74
+ if (ch === "`") {
75
+ const end = text.indexOf("`", i + 1);
76
+ if (end > i + 1) {
77
+ push(text.slice(i + 1, end), { ...format, code: true });
78
+ i = end + 1;
79
+ continue;
80
+ }
81
+ }
82
+ else if (ch === "[") {
83
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(text.slice(i));
84
+ if (link) {
85
+ pushAll(parseInline(link[1] ?? "", format));
86
+ const href = link[2] ?? "";
87
+ if (/^https?:\/\//i.test(href))
88
+ push(` (${href})`, format);
89
+ i += link[0].length;
90
+ continue;
91
+ }
92
+ }
93
+ else if (ch === "*" || ch === "_") {
94
+ const double = text[i + 1] === ch;
95
+ const size = double ? 2 : 1;
96
+ const end = opens(text, i, ch, size) ? closing(text, i + size, ch, size) : -1;
97
+ if (end > -1) {
98
+ const inner = double
99
+ ? { ...format, bold: true }
100
+ : { ...format, italic: true };
101
+ pushAll(parseInline(text.slice(i + size, end), inner));
102
+ i = end + size;
103
+ continue;
104
+ }
105
+ // A marker that does not open or close is text, and so is the whole run of it.
106
+ push(text.slice(i, i + size), format);
107
+ i += size;
108
+ continue;
109
+ }
110
+ push(ch ?? "", format);
111
+ i += 1;
112
+ }
113
+ return spans;
114
+ }
115
+ function opens(text, at, ch, size) {
116
+ const next = text[at + size];
117
+ if (!next || /\s/.test(next) || (size === 1 && next === ch))
118
+ return false;
119
+ return ch !== "_" || !WORD.test(text[at - 1] ?? "");
120
+ }
121
+ /** Where the marker that closes one opened before `from` starts, or -1. */
122
+ function closing(text, from, ch, size) {
123
+ for (let i = from + 1; i < text.length; i += 1) {
124
+ if (text[i] !== ch)
125
+ continue;
126
+ const before = text[i - 1] ?? "";
127
+ const after = text[i + size] ?? "";
128
+ if (/\s/.test(before) || after === ch)
129
+ continue;
130
+ if (size === 2 && text[i + 1] !== ch)
131
+ continue;
132
+ if (size === 1 && before === ch)
133
+ continue;
134
+ if (ch === "_" && WORD.test(after))
135
+ continue;
136
+ return i;
137
+ }
138
+ return -1;
139
+ }
140
+ function sameFormat(a, b) {
141
+ return a.bold === b.bold && a.italic === b.italic && a.code === b.code;
142
+ }
@@ -0,0 +1,16 @@
1
+ export type TranslationOffer = {
2
+ source?: string;
3
+ empty: number;
4
+ };
5
+ type Target = {
6
+ collectionSlug?: string;
7
+ globalSlug?: string;
8
+ documentId?: string | number;
9
+ locale: string;
10
+ };
11
+ /**
12
+ * Whether the open document has fields to translate from the default language, asked again when
13
+ * `refresh` changes (a new message, a kept or undone proposal). Undefined until known or on error.
14
+ */
15
+ export declare function useTranslationOffer(apiBase: string, target: Target | undefined, refresh: unknown): TranslationOffer | undefined;
16
+ export {};
@@ -0,0 +1,38 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { ENDPOINT_PREFIX } from "../package-name.js";
4
+ /**
5
+ * Whether the open document has fields to translate from the default language, asked again when
6
+ * `refresh` changes (a new message, a kept or undone proposal). Undefined until known or on error.
7
+ */
8
+ export function useTranslationOffer(apiBase, target, refresh) {
9
+ const [offer, setOffer] = useState();
10
+ const collection = target?.collectionSlug;
11
+ const global = target?.globalSlug;
12
+ const id = target?.documentId;
13
+ const locale = target?.locale;
14
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `refresh` only asks again.
15
+ useEffect(() => {
16
+ setOffer(undefined);
17
+ if (!locale || (!global && (!collection || id === undefined))) {
18
+ return;
19
+ }
20
+ const params = new URLSearchParams({ locale });
21
+ if (collection)
22
+ params.set("collection", collection);
23
+ if (global)
24
+ params.set("global", global);
25
+ if (id !== undefined)
26
+ params.set("id", String(id));
27
+ const controller = new AbortController();
28
+ fetch(`${apiBase}${ENDPOINT_PREFIX}/translation?${params}`, {
29
+ credentials: "include",
30
+ signal: controller.signal,
31
+ })
32
+ .then((response) => response.ok ? response.json() : undefined)
33
+ .then((result) => setOffer(result))
34
+ .catch(() => undefined);
35
+ return () => controller.abort();
36
+ }, [apiBase, collection, global, id, locale, refresh]);
37
+ return offer;
38
+ }
@@ -85,6 +85,8 @@ export async function findContentById(req, options, input) {
85
85
  slug: input.global,
86
86
  depth: 0,
87
87
  locale: input.locale,
88
+ // As Admin reads it: a field nobody filled in this language is empty, not another's text.
89
+ fallbackLocale: false,
88
90
  draft: true,
89
91
  req,
90
92
  user: req.user,
@@ -105,6 +107,7 @@ export async function findContentById(req, options, input) {
105
107
  id: input.documentId,
106
108
  depth: 0,
107
109
  locale: input.locale,
110
+ fallbackLocale: false,
108
111
  draft: true,
109
112
  req,
110
113
  user: req.user,
@@ -1,7 +1,8 @@
1
1
  import { compileOperations } from "../form/compile.js";
2
2
  import { hashFormRevision } from "../form/hash.js";
3
3
  import { isFormOperation } from "../form/operations.js";
4
- import { finalizeCompiled } from "../form/shape.js";
4
+ import { loadRichText } from "../form/rich-text.js";
5
+ import { finalizeCompiled, openRichText } from "../form/shape.js";
5
6
  import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
6
7
  import { entityFields } from "../schema/discover.js";
7
8
  import { hasCapability, resolveAllowlist } from "./allowlist.js";
@@ -9,6 +10,7 @@ import { writeAudit } from "./audit.js";
9
10
  import { localApi } from "./local-api.js";
10
11
  import { verifyDocumentRelations } from "./relations.js";
11
12
  export async function decideChange(req, options, input) {
13
+ await loadRichText();
12
14
  const actor = String(req.user?.id);
13
15
  const found = await localApi(req).findByID({
14
16
  collection: DEFAULT_APPROVAL_SLUG,
@@ -67,7 +69,11 @@ export async function decideChange(req, options, input) {
67
69
  return { ok: false, error: "stale_context", message: "The form changed before approval." };
68
70
  }
69
71
  }
70
- const compiled = compileOperations(current, operations, {
72
+ const fields = entityFields(req, options, {
73
+ collection: record.targetCollection ?? undefined,
74
+ global: record.targetGlobal ?? undefined,
75
+ });
76
+ const compiled = compileOperations(openRichText(current, fields), operations, {
71
77
  allowlist: resolved.allowlist.fields,
72
78
  denyFields: options.denyFields,
73
79
  });
@@ -80,16 +86,12 @@ export async function decideChange(req, options, input) {
80
86
  await consume(req, record.id, "failed");
81
87
  return { ok: false, error: "tool_denied", message: "Proposal hash mismatch." };
82
88
  }
83
- const fields = entityFields(req, options, {
84
- collection: record.targetCollection ?? undefined,
85
- global: record.targetGlobal ?? undefined,
86
- });
87
- const finalized = finalizeCompiled(compiled.data, fields);
89
+ const finalized = finalizeCompiled(compiled.data, fields, current);
88
90
  if (!finalized.ok) {
89
91
  await consume(req, record.id, "failed");
90
92
  return { ok: false, error: "tool_denied", message: finalized.error };
91
93
  }
92
- const relations = await verifyDocumentRelations(req, fields, finalized.data);
94
+ const relations = await verifyDocumentRelations(req, fields, finalized.data, finalized.links);
93
95
  if (!relations.ok) {
94
96
  await consume(req, record.id, "failed");
95
97
  return { ok: false, error: "tool_denied", message: relations.message };
@@ -1,12 +1,14 @@
1
1
  import { changedPaths } from "../form/changed-paths.js";
2
2
  import { compileOperations } from "../form/compile.js";
3
- import { humanDiff } from "../form/diff.js";
3
+ import { humanDiff, richTextLines } from "../form/diff.js";
4
4
  import { hashFormRevision } from "../form/hash.js";
5
5
  import { isFormOperation, normalizeOperations } from "../form/operations.js";
6
6
  import { getAtPath, parseFieldPath } from "../form/path.js";
7
- import { finalizeCompiled } from "../form/shape.js";
7
+ import { loadRichText, toBlocks } from "../form/rich-text.js";
8
+ import { finalizeCompiled, openRichText } from "../form/shape.js";
8
9
  import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
9
10
  import { entityFields, findCollectionConfig, hasDrafts } from "../schema/discover.js";
11
+ import { fieldAtPath } from "../schema/fields.js";
10
12
  import { hasCapability, resolveAllowlist } from "./allowlist.js";
11
13
  import { writeAudit } from "./audit.js";
12
14
  import { findContentById } from "./content.js";
@@ -15,6 +17,7 @@ import { labelRelations, verifyDocumentRelations } from "./relations.js";
15
17
  /** Long enough to look at a change in the preview before keeping it. */
16
18
  const TTL_MS = 30 * 60_000;
17
19
  export async function proposeChange(req, options, input) {
20
+ await loadRichText();
18
21
  const resolved = resolveAllowlist(options, input);
19
22
  if (!resolved || !hasCapability(resolved.allowlist, input.capability)) {
20
23
  return { ok: false, error: "tool_denied", message: "Capability is not allowlisted." };
@@ -25,37 +28,43 @@ export async function proposeChange(req, options, input) {
25
28
  }
26
29
  const typedOperations = operations;
27
30
  const current = await resolveCurrentForm(req, options, input);
28
- const compiled = compileOperations(current, typedOperations, {
31
+ const fields = entityFields(req, options, {
32
+ collection: input.collection,
33
+ global: input.global,
34
+ });
35
+ // Rich text opens into blocks, so operations can reach one block and leave the rest as stored.
36
+ const compiled = compileOperations(openRichText(current, fields), typedOperations, {
29
37
  allowlist: resolved.allowlist.fields,
30
38
  denyFields: options.denyFields,
31
39
  });
32
40
  if (!compiled.ok) {
33
41
  return { ok: false, error: "tool_denied", message: compiled.error };
34
42
  }
35
- if (options.validateProposal) {
36
- const extra = await options.validateProposal({
37
- operations: typedOperations,
38
- data: compiled.data,
39
- });
40
- if (!extra.ok) {
41
- return { ok: false, error: "tool_denied", message: extra.errors.join(" ") };
42
- }
43
- }
44
43
  if (input.capability === "draft.create") {
45
44
  const blocked = draftCreateDenied(req, input.collection);
46
45
  if (blocked) {
47
46
  return { ok: false, error: "tool_denied", message: blocked };
48
47
  }
49
48
  }
50
- const fields = entityFields(req, options, {
51
- collection: input.collection,
52
- global: input.global,
53
- });
54
- const finalized = finalizeCompiled(compiled.data, fields);
49
+ const richTextBefore = richTextPaths(typedOperations, fields, current).map((path) => ({
50
+ path,
51
+ blocks: storedBlocks(fields, path, current),
52
+ after: getAtPath(compiled.data, parseFieldPath(path)),
53
+ }));
54
+ const finalized = finalizeCompiled(compiled.data, fields, current);
55
55
  if (!finalized.ok) {
56
56
  return { ok: false, error: "tool_denied", message: finalized.error };
57
57
  }
58
- const relations = await verifyDocumentRelations(req, fields, finalized.data);
58
+ if (options.validateProposal) {
59
+ const extra = await options.validateProposal({
60
+ operations: typedOperations,
61
+ data: finalized.data,
62
+ });
63
+ if (!extra.ok) {
64
+ return { ok: false, error: "tool_denied", message: extra.errors.join(" ") };
65
+ }
66
+ }
67
+ const relations = await verifyDocumentRelations(req, fields, finalized.data, finalized.links);
59
68
  if (!relations.ok) {
60
69
  return { ok: false, error: "tool_denied", message: relations.message };
61
70
  }
@@ -72,7 +81,12 @@ export async function proposeChange(req, options, input) {
72
81
  const labels = setIds.size
73
82
  ? await labelRelations(req, fields, [finalized.data, current], setIds)
74
83
  : undefined;
75
- const diff = humanDiff(current, typedOperations, input.language, labels);
84
+ const inRichText = (operation) => "path" in operation &&
85
+ richTextBefore.some(({ path }) => operation.path === path || operation.path.startsWith(`${path}.`));
86
+ const diff = [
87
+ ...humanDiff(current, typedOperations.filter((operation) => !inRichText(operation)), input.language, labels),
88
+ ...richTextBefore.flatMap(({ path, blocks, after }) => richTextLines(path, blocks, Array.isArray(after) ? after : [after], input.language)),
89
+ ];
76
90
  let created;
77
91
  try {
78
92
  created = await localApi(req).create({
@@ -163,3 +177,26 @@ function draftCreateDenied(req, collection) {
163
177
  }
164
178
  return null;
165
179
  }
180
+ /** Rich text fields the operations reach, whole or by block, as form paths. */
181
+ function richTextPaths(operations, fields, current) {
182
+ const paths = new Set();
183
+ for (const operation of operations) {
184
+ if (!("path" in operation))
185
+ continue;
186
+ const segments = operation.path.split(".");
187
+ for (let end = 1; end <= segments.length; end++) {
188
+ const prefix = segments.slice(0, end).join(".");
189
+ if (fieldAtPath(fields, prefix, current)?.type === "richText") {
190
+ paths.add(prefix);
191
+ break;
192
+ }
193
+ }
194
+ }
195
+ return [...paths];
196
+ }
197
+ /** The stored rich text at `path` as the model read it: Markdown blocks and `{ keep, type }`. */
198
+ function storedBlocks(fields, path, current) {
199
+ const field = fieldAtPath(fields, path, current);
200
+ const blocks = toBlocks(getAtPath(current, parseFieldPath(path)), field?.editor);
201
+ return Array.isArray(blocks) ? blocks : [];
202
+ }
@@ -4,7 +4,11 @@ export type RelationRef = {
4
4
  collection: string;
5
5
  id: string;
6
6
  };
7
- export declare function verifyDocumentRelations(req: PayloadRequest, fields: FieldNode[], data: Record<string, unknown>): Promise<{
7
+ /**
8
+ * Every relation and upload in `data`, and `extra` refs such as internal links in rewritten rich
9
+ * text, must exist and be readable by the editor.
10
+ */
11
+ export declare function verifyDocumentRelations(req: PayloadRequest, fields: FieldNode[], data: Record<string, unknown>, extra?: RelationRef[]): Promise<{
8
12
  ok: true;
9
13
  } | {
10
14
  ok: false;
@@ -1,7 +1,11 @@
1
1
  import { findCollectionConfig } from "../schema/discover.js";
2
2
  import { localApi } from "./local-api.js";
3
- export async function verifyDocumentRelations(req, fields, data) {
4
- const refs = collectRefs(fields, data);
3
+ /**
4
+ * Every relation and upload in `data`, and `extra` refs such as internal links in rewritten rich
5
+ * text, must exist and be readable by the editor.
6
+ */
7
+ export async function verifyDocumentRelations(req, fields, data, extra = []) {
8
+ const refs = [...collectRefs(fields, data), ...extra];
5
9
  for (const ref of refs) {
6
10
  try {
7
11
  await localApi(req).findByID({