@simmalugnt-se/payload-editor-assistant 0.8.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
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
+
3
22
  ## 0.8.0
4
23
 
5
24
  - Rich text keeps its formatting. The model reads Lexical as Markdown blocks, one per paragraph,
package/README.md CHANGED
@@ -127,6 +127,17 @@ point at a collection the field's `LinkFeature` allows and that the editor can r
127
127
  cannot express, such as an embedded image, are kept as they are. Without the package, rich text is
128
128
  written as plain paragraphs as before.
129
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
+
130
141
  ## Keep or undo
131
142
 
132
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,
@@ -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
+ }
@@ -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,14 @@
1
+ import type { FieldNode } from "../schema/fields.ts";
2
+ /** Localized text in the open language, by whether the source language has text for it. */
3
+ export type TranslationPaths = {
4
+ /** Filled in the source, empty here: what "translate" fills in. */
5
+ empty: string[];
6
+ /** Filled in both: what "translate again" replaces. */
7
+ translated: string[];
8
+ };
9
+ /**
10
+ * Localized text, textarea and rich text fields of the open form, with array and block indexes, that
11
+ * the source language has text for. Rich text may be Lexical (the form) or Markdown blocks (the
12
+ * source as the model reads it). Blocks whose type differs between the languages are skipped.
13
+ */
14
+ export declare function translationPaths(fields: FieldNode[], form: Record<string, unknown>, source: Record<string, unknown>): TranslationPaths;
@@ -0,0 +1,81 @@
1
+ import { isLexical, lexicalPlaintext } from "./lexical.js";
2
+ const TEXT_TYPES = new Set(["text", "textarea", "richText"]);
3
+ /**
4
+ * Localized text, textarea and rich text fields of the open form, with array and block indexes, that
5
+ * the source language has text for. Rich text may be Lexical (the form) or Markdown blocks (the
6
+ * source as the model reads it). Blocks whose type differs between the languages are skipped.
7
+ */
8
+ export function translationPaths(fields, form, source) {
9
+ const paths = { empty: [], translated: [] };
10
+ walkFields(fields, source, form, { data: "", template: "" }, false, paths);
11
+ return paths;
12
+ }
13
+ function walkFields(fields, source, target, prefix, inherited, paths) {
14
+ for (const field of fields) {
15
+ const relative = prefix.template ? field.path.slice(prefix.template.length + 1) : field.path;
16
+ const path = prefix.data ? `${prefix.data}.${relative}` : relative;
17
+ const localized = inherited || Boolean(field.localized);
18
+ const from = child(source, relative);
19
+ const to = child(target, relative);
20
+ const nested = (data) => ({ data, template: field.path });
21
+ if (TEXT_TYPES.has(field.type)) {
22
+ if (localized && hasText(from)) {
23
+ (hasText(to) ? paths.translated : paths.empty).push(path);
24
+ }
25
+ continue;
26
+ }
27
+ if (field.type === "group") {
28
+ walkFields(field.fields ?? [], from, to, nested(path), localized, paths);
29
+ continue;
30
+ }
31
+ if (field.type !== "array" && field.type !== "blocks") {
32
+ continue;
33
+ }
34
+ const sourceRows = Array.isArray(from) ? from : [];
35
+ const targetRows = Array.isArray(to) ? to : [];
36
+ // A localized list that is empty here is one field to translate; its rows do not exist yet.
37
+ if (field.localized && sourceRows.length > 0 && targetRows.length === 0) {
38
+ paths.empty.push(path);
39
+ continue;
40
+ }
41
+ for (const [index, sourceRow] of sourceRows.entries()) {
42
+ const targetRow = targetRows[index];
43
+ if (!targetRow || typeof targetRow !== "object") {
44
+ continue;
45
+ }
46
+ const rowPath = `${path}.${index}`;
47
+ if (field.type === "array") {
48
+ walkFields(field.fields ?? [], sourceRow, targetRow, nested(rowPath), localized, paths);
49
+ continue;
50
+ }
51
+ const type = sourceRow.blockType;
52
+ if (typeof type !== "string" || targetRow.blockType !== type) {
53
+ continue;
54
+ }
55
+ const block = field.blocks?.find((entry) => entry.slug === type);
56
+ if (block) {
57
+ walkFields(block.fields, sourceRow, targetRow, nested(rowPath), localized, paths);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ function child(value, path) {
63
+ let current = value;
64
+ for (const segment of path.split(".")) {
65
+ if (!current || typeof current !== "object") {
66
+ return undefined;
67
+ }
68
+ current = current[segment];
69
+ }
70
+ return current;
71
+ }
72
+ function hasText(value) {
73
+ if (typeof value === "string") {
74
+ return value.trim().length > 0;
75
+ }
76
+ if (Array.isArray(value)) {
77
+ // Markdown blocks; a kept part (`{ keep }`) is not text to translate.
78
+ return value.some((block) => typeof block === "string" && block.trim().length > 0);
79
+ }
80
+ return isLexical(value) ? lexicalPlaintext(value).length > 0 : false;
81
+ }
package/dist/plugin.js CHANGED
@@ -6,6 +6,7 @@ import { createChatEndpoint } from "./endpoints/chat.js";
6
6
  import { createCsrfEndpoint } from "./endpoints/csrf.js";
7
7
  import { createDecideEndpoint } from "./endpoints/decide.js";
8
8
  import { createProposeEndpoint } from "./endpoints/propose.js";
9
+ import { createTranslationEndpoint } from "./endpoints/translation.js";
9
10
  import { ADMIN_CUSTOM_KEY, CLIENT_ENTRY, DEFAULT_APPROVAL_SLUG } from "./package-name.js";
10
11
  export function editorAssistantPlugin(options = {}) {
11
12
  const validated = validateEditorAssistantOptions(options);
@@ -75,6 +76,7 @@ export function editorAssistantPlugin(options = {}) {
75
76
  createChatEndpoint(validated),
76
77
  createProposeEndpoint(validated),
77
78
  createDecideEndpoint(validated),
79
+ createTranslationEndpoint(validated),
78
80
  ],
79
81
  };
80
82
  };
@@ -37,7 +37,14 @@ export type TurnContext = {
37
37
  form?: Record<string, unknown>;
38
38
  /** What the editor clicked or focused; "this" in their message refers to it. */
39
39
  selection?: Selection;
40
+ /** Other languages of the open document that have text of their own, as the model reads them. */
40
41
  otherLanguages?: Record<string, Record<string, unknown>>;
42
+ /** Localized text of the open form that the default language has text for. */
43
+ translation?: {
44
+ source: string;
45
+ empty: string[];
46
+ translated: string[];
47
+ };
41
48
  documents: Record<string, unknown>[];
42
49
  };
43
50
  export declare function loadTurnContext(req: PayloadRequest, options: ValidatedEditorAssistantOptions, bridge: ToolBridge, formData?: Record<string, unknown>): Promise<TurnContext>;
@@ -1,7 +1,9 @@
1
1
  import { findContent, findContentById } from "../domain/content.js";
2
- import { configuredLocales, discoverSchema, entityFields, } from "../schema/discover.js";
2
+ import { translationPaths } from "../form/translation.js";
3
+ import { configuredDefaultLocale, configuredLocales, discoverSchema, entityFields, } from "../schema/discover.js";
3
4
  import { resolveSelection } from "./selection.js";
4
5
  import { executeTool } from "./tools.js";
6
+ import { sourceLocale } from "./translation-source.js";
5
7
  export async function loadTurnContext(req, options, bridge, formData) {
6
8
  const ctx = { req, options, bridge, formData };
7
9
  const schema = discoverSchema(req, options);
@@ -17,15 +19,17 @@ export async function loadTurnContext(req, options, bridge, formData) {
17
19
  const form = open
18
20
  ? ((await readOpenForm(ctx)) ?? (await readSavedDocument(req, options, bridge)))
19
21
  : undefined;
20
- const selection = open
21
- ? resolveSelection(bridge.selection, form, entityFields(req, options, { collection: bridge.collection, global: bridge.global }))
22
- : undefined;
22
+ const fields = open
23
+ ? entityFields(req, options, { collection: bridge.collection, global: bridge.global })
24
+ : [];
25
+ const selection = open ? resolveSelection(bridge.selection, form, fields) : undefined;
23
26
  const listed = await findContent(req, options, {
24
27
  collection: bridge.collection ?? (bridge.view?.kind === "list" ? bridge.view.collection : undefined),
25
28
  query: "",
26
29
  locale: bridge.locale,
27
30
  });
28
- const otherLanguages = open ? await readOtherLanguages(req, options, bridge) : undefined;
31
+ const otherLanguages = open ? await readOtherLanguages(req, options, bridge, fields) : undefined;
32
+ const translation = form ? translationFor(req, bridge, fields, form, otherLanguages) : undefined;
29
33
  return {
30
34
  allowlist: schema.map(compactEntity),
31
35
  open,
@@ -33,6 +37,7 @@ export async function loadTurnContext(req, options, bridge, formData) {
33
37
  form,
34
38
  selection,
35
39
  otherLanguages,
40
+ translation,
36
41
  documents: listed.ok ? listed.results : [],
37
42
  };
38
43
  }
@@ -57,7 +62,7 @@ async function readSavedDocument(req, options, bridge) {
57
62
  }
58
63
  return result.data;
59
64
  }
60
- async function readOtherLanguages(req, options, bridge) {
65
+ async function readOtherLanguages(req, options, bridge, fields) {
61
66
  const locales = configuredLocales(req).filter((code) => code !== bridge.locale);
62
67
  if (locales.length === 0) {
63
68
  return undefined;
@@ -70,7 +75,8 @@ async function readOtherLanguages(req, options, bridge) {
70
75
  documentId: bridge.documentId,
71
76
  locale,
72
77
  });
73
- if (result.ok && Object.keys(result.data).length > 0) {
78
+ // A language nobody wrote in yet is left out: it has nothing to translate from.
79
+ if (result.ok && translationPaths(fields, {}, result.data).empty.length > 0) {
74
80
  otherLanguages[locale] = result.data;
75
81
  }
76
82
  }
@@ -100,3 +106,15 @@ function compactField(field) {
100
106
  relationTo: field.type === "upload" || field.type === "relationship" ? field.relationTo : undefined,
101
107
  };
102
108
  }
109
+ function translationFor(req, bridge, fields, form, otherLanguages) {
110
+ const source = sourceLocale({
111
+ open: bridge.locale,
112
+ locales: configuredLocales(req),
113
+ defaultLocale: configuredDefaultLocale(req),
114
+ });
115
+ const data = source ? otherLanguages?.[source] : undefined;
116
+ if (!source || !data) {
117
+ return undefined;
118
+ }
119
+ return { source, ...translationPaths(fields, form, data) };
120
+ }
@@ -1,4 +1,4 @@
1
- import { listEmptyTextPaths, previewAtPath } from "../form/empty.js";
1
+ import { listEmptyTextPaths } from "../form/empty.js";
2
2
  export function buildSystemPrompt(instructions, bridge, turnContext) {
3
3
  const base = [
4
4
  "You are a colleague helping a content editor. They are not a developer.",
@@ -30,7 +30,7 @@ function openDocumentLine(bridge, turnContext) {
30
30
  const title = bridge.title ? `"${bridge.title}"` : "the open document";
31
31
  const language = languageName(bridge.locale);
32
32
  const sources = turnContext?.otherLanguages
33
- ? " Other language versions of this same page are in the quoted context. If the editor asks to fill or translate empty fields, call form.propose with text written in the open language. Translate from the source language; do not copy the source wording unless they asked to copy it unchanged. Do not ask them to paste. Do not say the source is missing if it is in the context. If a layout block already exists, set its empty text fields; do not insert a new block."
33
+ ? " Other language versions of this same page are in the quoted context under otherLanguages. Do not ask the editor to paste them, and do not say a source is missing if it is there."
34
34
  : "";
35
35
  return `The editor is looking at ${title} in ${language}. Do not ask them which document it is.${sources}${imageLine(bridge, turnContext)}${selectionLine(turnContext)}`;
36
36
  }
@@ -111,34 +111,44 @@ function thisTurnBlock(bridge, turnContext) {
111
111
  const empty = listEmptyTextPaths(form);
112
112
  if (empty.length > 0) {
113
113
  lines.push(`Empty fields: ${empty.join(", ")}.`);
114
- const source = sourceLine(turnContext?.otherLanguages, empty);
115
- if (source) {
116
- lines.push(source);
117
- }
118
114
  lines.push("Fill them only when the editor asks for that (fill in, translate, write, fyll i, översätt). A greeting, a question or small talk is not such a request: answer it and propose nothing. When they do ask, call form.propose now with text in the open language. Do not ask.");
119
115
  }
120
116
  }
117
+ const translation = translationLines(bridge, turnContext);
118
+ if (translation) {
119
+ lines.push(translation);
120
+ }
121
121
  return `\n\n${lines.join(" ")}`;
122
122
  }
123
- function sourceLine(otherLanguages, empty) {
124
- if (!otherLanguages) {
123
+ const MAX_LISTED_PATHS = 60;
124
+ /** What a translation fills in, from which language, and how rich text and shared fields go. */
125
+ function translationLines(bridge, turnContext) {
126
+ const translation = turnContext?.translation;
127
+ if (!translation || (!translation.empty.length && !translation.translated.length)) {
125
128
  return undefined;
126
129
  }
127
- const preferred = otherLanguages.en
128
- ? ["en", otherLanguages.en]
129
- : Object.entries(otherLanguages)[0];
130
- if (!preferred) {
131
- return undefined;
130
+ const source = languageName(translation.source);
131
+ const target = languageName(bridge?.locale ?? turnContext?.open?.locale);
132
+ const lines = [];
133
+ if (translation.empty.length) {
134
+ lines.push(`Not translated from ${source} yet (${translation.empty.length} fields): ${listPaths(translation.empty)}.`);
132
135
  }
133
- const [code, data] = preferred;
134
- const parts = empty.flatMap((path) => {
135
- const preview = previewAtPath(data, path);
136
- return preview ? [`${path}=${JSON.stringify(preview)}`] : [];
137
- });
138
- if (parts.length === 0) {
139
- return undefined;
136
+ if (translation.translated.length) {
137
+ lines.push(`Already translated: ${listPaths(translation.translated)}.`);
140
138
  }
141
- return `${languageName(code)} source: ${parts.join("; ")}.`;
139
+ lines.push(`The ${source} text is in the quoted context under otherLanguages.${translation.source}. When the editor asks to translate (translate, översätt, fyll i), translate from ${source} into ${target} and set every field not translated yet, all in one form.propose. Leave already translated fields as they are unless the editor asks to translate them again; then replace those too. Translate; do not copy the ${source} wording. Rich text: set the whole field to a list of Markdown blocks, one per ${source} block, keeping headings, lists, bold, italic and links, with link addresses unchanged. A ${source} block shown as { "keep" } cannot be copied: leave it out and say so. Never change fields that are not listed here, and never add, move or remove blocks or rows. Do not ask.`);
140
+ const chat = languageName(bridge?.language ?? bridge?.locale);
141
+ if (chat !== target) {
142
+ // Asked to translate into one language, models also replied in it.
143
+ lines.push(`Write your reply in ${chat}; only the field values are in ${target}.`);
144
+ }
145
+ return lines.join(" ");
146
+ }
147
+ function listPaths(paths) {
148
+ const listed = paths.slice(0, MAX_LISTED_PATHS).join(", ");
149
+ return paths.length > MAX_LISTED_PATHS
150
+ ? `${listed} and ${paths.length - MAX_LISTED_PATHS} more`
151
+ : listed;
142
152
  }
143
153
  function languageName(code) {
144
154
  if (code === "sv" || code?.startsWith("sv-")) {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The language a translation comes from: the project's default one, unless that is the language
3
+ * already open. An editor who wants another source names it; the model reads it with
4
+ * `content.findById`.
5
+ */
6
+ export declare function sourceLocale(input: {
7
+ open: string;
8
+ locales: string[];
9
+ defaultLocale: string | undefined;
10
+ }): string | undefined;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The language a translation comes from: the project's default one, unless that is the language
3
+ * already open. An editor who wants another source names it; the model reads it with
4
+ * `content.findById`.
5
+ */
6
+ export function sourceLocale(input) {
7
+ const { defaultLocale } = input;
8
+ if (!defaultLocale || defaultLocale === input.open || !input.locales.includes(defaultLocale)) {
9
+ return undefined;
10
+ }
11
+ return defaultLocale;
12
+ }
@@ -20,7 +20,7 @@ export function editorFacingText(input) {
20
20
  }
21
21
  export function stripInternalSpeech(text, locale) {
22
22
  const swedish = isSwedish(locale);
23
- const kept = splitSentences(text).filter((sentence) => {
23
+ return keepSentences(text, (sentence) => {
24
24
  if (INTERNAL.test(sentence)) {
25
25
  return false;
26
26
  }
@@ -29,17 +29,13 @@ export function stripInternalSpeech(text, locale) {
29
29
  }
30
30
  return true;
31
31
  });
32
- return kept.join(" ").replace(/\s+/g, " ").trim();
33
32
  }
34
33
  /** Keeping, saving and publishing are the editor's; a sentence that claims or asks for them goes. */
35
34
  const OVERCLAIM = /godkänn|godkann|approve|spara|sparat|sparad|sparats|publicer|\bsaved?\b|publish|behåll|\bkeep\b|klicka|\bclick|(?:^|\s)är nu\b|\bis now\b/i;
36
35
  const MAX_OWN_TEXT = 600;
37
36
  export function withoutOverclaims(text) {
38
- const kept = splitSentences(text)
39
- // A quoted proposal ("Spara 20 %") is the editor's content, not a claim about the panel.
40
- .filter((sentence) => !OVERCLAIM.test(withoutQuotes(sentence)))
41
- .join(" ")
42
- .trim();
37
+ // A quoted proposal ("Spara 20 %") is the editor's content, not a claim about the panel.
38
+ const kept = keepSentences(text, (sentence) => !OVERCLAIM.test(withoutQuotes(sentence)));
43
39
  return kept.length <= MAX_OWN_TEXT ? kept : "";
44
40
  }
45
41
  export function approvalCopy(locale, capability) {
@@ -65,6 +61,23 @@ function isSwedish(locale) {
65
61
  }
66
62
  const QUOTES = '"“”„«»';
67
63
  const QUOTED = /["“”„«»][^"“”„«»]*["“”„«»]/g;
64
+ /**
65
+ * The sentences that pass `keep`, line by line: the panel shows the reply's Markdown, so its line
66
+ * breaks, lists and blank lines between paragraphs stay. A line that loses every sentence goes.
67
+ */
68
+ function keepSentences(text, keep) {
69
+ return text
70
+ .split(/\r?\n/)
71
+ .flatMap((line) => {
72
+ if (line.trim() === "")
73
+ return [""];
74
+ const kept = splitSentences(line).filter(keep).join(" ");
75
+ return kept ? [kept.replace(/[ \t]+/g, " ")] : [];
76
+ })
77
+ .join("\n")
78
+ .replace(/\n{3,}/g, "\n\n")
79
+ .trim();
80
+ }
68
81
  /**
69
82
  * Sentences, keeping a quoted proposal ("Click. Edit. Done.") inside the sentence that quotes it.
70
83
  * A line with an unclosed quote is split as if it had none, so the quote cannot hide a claim.
@@ -32,4 +32,6 @@ export declare function entityFields(req: PayloadRequest, options: ValidatedEdit
32
32
  global?: string;
33
33
  }): FieldNode[];
34
34
  export declare function hasDrafts(versions: unknown): boolean;
35
+ /** Payload's `localization.defaultLocale`, when the project is localized. */
36
+ export declare function configuredDefaultLocale(req: PayloadRequest): string | undefined;
35
37
  export declare function configuredLocales(req: PayloadRequest): string[];
@@ -71,6 +71,15 @@ export function hasDrafts(versions) {
71
71
  function asString(value) {
72
72
  return typeof value === "string" && value.trim() ? value : undefined;
73
73
  }
74
+ /** Payload's `localization.defaultLocale`, when the project is localized. */
75
+ export function configuredDefaultLocale(req) {
76
+ const localization = req.payload.config.localization;
77
+ if (!localization || typeof localization !== "object") {
78
+ return undefined;
79
+ }
80
+ const code = localization.defaultLocale;
81
+ return typeof code === "string" && code ? code : undefined;
82
+ }
74
83
  export function configuredLocales(req) {
75
84
  const localization = req.payload.config.localization;
76
85
  if (!localization || typeof localization !== "object") {
@@ -108,7 +108,7 @@ html[data-theme="dark"] .ea-aside {
108
108
  gap: calc(var(--base) * 0.8);
109
109
  }
110
110
 
111
- /* The assistant writes plain text, the editor's messages sit in a quiet bubble. */
111
+ /* The assistant's replies sit on the panel, the editor's messages in a quiet bubble. */
112
112
  .ea-msg {
113
113
  max-width: 100%;
114
114
  line-height: 1.5;
@@ -128,6 +128,35 @@ html[data-theme="dark"] .ea-aside {
128
128
  align-self: flex-start;
129
129
  }
130
130
 
131
+ /* A reply's Markdown: paragraphs and lists spaced like the thread, a line break kept inside one. */
132
+ .ea-reply__p,
133
+ .ea-reply__list {
134
+ margin: 0;
135
+ }
136
+
137
+ .ea-reply__p + .ea-reply__p,
138
+ .ea-reply__p + .ea-reply__list,
139
+ .ea-reply__list + .ea-reply__p,
140
+ .ea-reply__list + .ea-reply__list {
141
+ margin-top: calc(var(--base) * 0.5);
142
+ }
143
+
144
+ .ea-reply__list {
145
+ padding-inline-start: calc(var(--base) * 1.2);
146
+ white-space: normal;
147
+ }
148
+
149
+ .ea-reply__list li + li {
150
+ margin-top: calc(var(--base) * 0.2);
151
+ }
152
+
153
+ .ea-msg code {
154
+ font-size: 0.9em;
155
+ padding: 0 0.25em;
156
+ border-radius: var(--style-radius-s);
157
+ background: var(--theme-elevation-100);
158
+ }
159
+
131
160
  .ea-msg--system {
132
161
  color: var(--theme-elevation-500);
133
162
  font-size: 12px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-editor-assistant",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Context-aware editor assistant plugin for Payload Admin",
5
5
  "keywords": [
6
6
  "payload",