@justanarthur/payload-plugin-translator 1.3.21 → 3.0.3

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/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # @justanarthur/payload-plugin-translator
2
+
3
+ [Payload CMS](https://payloadcms.com) plugin that keeps localized docs in sync. On every save of a
4
+ listed collection or global, it schedules background jobs that translate each field from the source
5
+ locale into every other declared locale, using a configurable resolver (Google Translate, OpenAI,
6
+ LibreTranslate, or your own).
7
+
8
+ Editor UX: the publish and save buttons in the affected collections/globals are swapped for a
9
+ custom variant that surfaces the job status. Locales without translations stay empty until the job
10
+ finishes — the plugin never blocks the editor's save.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pnpm add @justanarthur/payload-plugin-translator
16
+ ```
17
+
18
+ Requires `payload@^3.85.0` and `react@^19`. The plugin reads your `config.localization.locales` —
19
+ it short-circuits (returns the config unchanged) when there's only one locale or no localization.
20
+
21
+ ## Quick start
22
+
23
+ ```ts
24
+ // payload.config.ts
25
+ import { buildConfig } from 'payload'
26
+ import { translator, openAIResolver } from '@justanarthur/payload-plugin-translator'
27
+
28
+ export default buildConfig({
29
+ plugins: [
30
+ translator({
31
+ collections: ['pages', 'posts'],
32
+ globals: ['header', 'footer'],
33
+ resolvers: [
34
+ openAIResolver({ apiKey: process.env.OPENAI_API_KEY! })
35
+ ]
36
+ })
37
+ ]
38
+ })
39
+ ```
40
+
41
+ That's the whole integration. The first save of a Page doc schedules one translate job per non-source
42
+ locale; the job runs against your resolver and writes each translated field back into the
43
+ collection's `_locales` row.
44
+
45
+ ## Built-in resolvers
46
+
47
+ | Resolver | Import | Env / config | Notes |
48
+ |--------------------|-------------------------------------------------|---------------------------------|------------------------------------------------------------------|
49
+ | `googleResolver` | `@justanarthur/payload-plugin-translator/resolvers/google` | `apiKey` (Google Cloud Translation API) | Cheap, good for bulk text. Locale codes are remapped where useful (e.g. `ua` → `uk`). |
50
+ | `openAIResolver` | `@justanarthur/payload-plugin-translator/resolvers/openAI` | `apiKey`, optional `model`/`baseUrl`/`prompt`/`chunkLength` | Best fidelity. Default model `gpt-4o-mini`. gpt-5.x uses `max_completion_tokens` + `reasoning_effort: 'low'` automatically. Slugs get a transliteration rule baked into the prompt. |
51
+ | `libreResolver` | `@justanarthur/payload-plugin-translator/resolvers/libreTranslate` | `apiKey`, optional `url`/`chunkLength` | Self-host friendly. Same locale remap as Google. |
52
+ | `copyResolver` | `@justanarthur/payload-plugin-translator/resolvers/copy` | none | Returns the source text verbatim. Useful as a fallback or for testing. |
53
+
54
+ You can supply multiple resolvers — they run in the order declared, and the first one to return
55
+ `success: true` wins for the chunk.
56
+
57
+ ## Writing your own resolver
58
+
59
+ ```ts
60
+ import type { TranslateResolver } from '@justanarthur/payload-plugin-translator/resolvers/types'
61
+
62
+ export const myResolver: TranslateResolver = {
63
+ key: 'my-translator',
64
+ resolve: async ({ localeFrom, localeTo, texts, req }) => {
65
+ // call your translator of choice
66
+ return { success: true, translatedTexts: ['...'] }
67
+ }
68
+ }
69
+ ```
70
+
71
+ `texts` is the flat string array extracted from the doc; you return the same-length array in
72
+ `translatedTexts`. Returning `{ success: false }` makes the job retry.
73
+
74
+ ## Options reference
75
+
76
+ | Option | Type | Required | Notes |
77
+ |---------------|-----------------------|----------|------------------------------------------------------------------------|
78
+ | `collections` | `CollectionSlug[]` | yes | Collections to auto-translate. |
79
+ | `globals` | `GlobalSlug[]` | yes | Globals to auto-translate. |
80
+ | `resolvers` | `TranslateResolver[]` | yes | Tried in order; first to succeed wins per chunk. |
81
+ | `autoTranslate` | `boolean` | no | Default `true`. Set `false` to opt out of the auto-translate afterChange hook — useful when you drive translation manually via the `translateOperation` export. |
82
+ | `disabled` | `boolean` | no | Skip the plugin entirely (no overrides, no jobs). Useful in tests. |
83
+ | `_options.additionalTraverseRichText` | function | no | Hook to extend rich-text traversal — see below. |
84
+
85
+ ### Rich-text traversal hook
86
+
87
+ The plugin walks Lexical richText fields itself, but custom Lexical nodes (e.g. blocks the host
88
+ defines for landing pages) won't be visited. `_options.additionalTraverseRichText` lets you register
89
+ a custom walker:
90
+
91
+ ```ts
92
+ translator({
93
+ // ...
94
+ _options: {
95
+ additionalTraverseRichText: ({ root, onText }) => {
96
+ // call onText(siblingData, attribute?) for every leaf string you find
97
+ // siblingData mutates the doc tree in-place
98
+ }
99
+ }
100
+ })
101
+ ```
102
+
103
+ The hook receives `onText` which mutates the data tree, plus the root node. It's called for every
104
+ richText field before translation begins.
105
+
106
+ ## How it works under the hood
107
+
108
+ - On plugin init: registers a `translate` Payload **task** and a `translate` **workflow**, swapped
109
+ Publish/Save buttons per collection/global, and an admin-only endpoint at `/api/translator/translate`.
110
+ - On `afterChange` (when `autoTranslate: true`): the plugin enqueues one workflow run per non-source
111
+ locale. The job extracts translatable fields, asks each resolver in turn, writes the result back
112
+ into the `_locales` row.
113
+ - The plugin de-duplicates via `AUTO_TRANSLATE_MARKER` — re-running it on an already-attached hook
114
+ is a no-op, so it's safe to wrap multiple plugins around the same collection.
115
+
116
+ ## Manual translation
117
+
118
+ Two escape hatches when you don't want the auto-hook:
119
+
120
+ ```ts
121
+ // the operation (Payload `operation` you can call from custom endpoints)
122
+ import { translateOperation } from '@justanarthur/payload-plugin-translator'
123
+
124
+ // the job factories (advanced — you usually don't need these directly)
125
+ import { createTranslateTask, createTranslateWorkflow } from '@justanarthur/payload-plugin-translator/jobs'
126
+ ```
127
+
128
+ For most hosts the auto-hook + resolver list is enough.
129
+
130
+ ## Licence
131
+
132
+ MIT
@@ -0,0 +1,41 @@
1
+ /* src/client/components/CustomButton/styles.css */
2
+ .translator__custom-save-button {
3
+ display: inline-flex;
4
+ align-items: center;
5
+ gap: .5rem;
6
+ }
7
+
8
+ /* src/client/components/TranslatorModal/styles.css */
9
+ .translator__modal {
10
+ position: relative;
11
+ }
12
+
13
+ .translator__wrapper {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: 1rem;
17
+ padding: 1rem;
18
+ }
19
+
20
+ .translator__close {
21
+ position: absolute;
22
+ cursor: pointer;
23
+ background: none;
24
+ border: 0;
25
+ font-size: 1.25rem;
26
+ line-height: 1;
27
+ top: .5rem;
28
+ right: .5rem;
29
+ }
30
+
31
+ .translator__content {
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: .75rem;
35
+ }
36
+
37
+ .translator__buttons {
38
+ display: flex;
39
+ flex-wrap: wrap;
40
+ gap: .5rem;
41
+ }
package/dist/client.js CHANGED
@@ -54,7 +54,7 @@ var useTranslator = () => {
54
54
  };
55
55
 
56
56
  // src/client/providers/Translator/TranslatorProvider.tsx
57
- import { jsxDEV } from "react/jsx-dev-runtime";
57
+ import { jsx } from "react/jsx-runtime";
58
58
  var modalSlug = "translator-modal";
59
59
  var TranslatorProvider = ({ children }) => {
60
60
  const [resolver, setResolver] = useState(null);
@@ -141,7 +141,7 @@ var TranslatorProvider = ({ children }) => {
141
141
  }
142
142
  closeTranslator();
143
143
  };
144
- return /* @__PURE__ */ jsxDEV(TranslatorContext.Provider, {
144
+ return /* @__PURE__ */ jsx(TranslatorContext.Provider, {
145
145
  value: {
146
146
  closeTranslator,
147
147
  localeToTranslateFrom,
@@ -157,22 +157,22 @@ var TranslatorProvider = ({ children }) => {
157
157
  submit
158
158
  },
159
159
  children
160
- }, undefined, false, undefined, this);
160
+ });
161
161
  };
162
162
 
163
163
  // src/client/components/ResolverButton/ResolverButton.tsx
164
164
  import { Button, useTranslation as useTranslation2 } from "@payloadcms/ui";
165
- import { jsxDEV as jsxDEV2 } from "react/jsx-dev-runtime";
165
+ import { jsx as jsx2 } from "react/jsx-runtime";
166
166
  var ResolverButton = ({
167
167
  resolver: { key: resolverKey }
168
168
  }) => {
169
169
  const { openTranslator } = useTranslator();
170
170
  const { t } = useTranslation2();
171
171
  const handleClick = () => openTranslator({ resolverKey });
172
- return /* @__PURE__ */ jsxDEV2(Button, {
172
+ return /* @__PURE__ */ jsx2(Button, {
173
173
  onClick: handleClick,
174
174
  children: t(`plugin-translator:resolver_${resolverKey}_buttonLabel`)
175
- }, undefined, false, undefined, this);
175
+ });
176
176
  };
177
177
  // src/client/components/TranslatorModal/TranslatorModal.tsx
178
178
  import { Modal } from "@payloadcms/ui";
@@ -184,32 +184,32 @@ import { Button as Button2, Popup, PopupList, useTranslation as useTranslation4
184
184
  // src/client/components/LocaleLabel/LocaleLabel.tsx
185
185
  import { getTranslation } from "@payloadcms/translations";
186
186
  import { ChevronIcon, useTranslation as useTranslation3 } from "@payloadcms/ui";
187
- import { jsxDEV as jsxDEV3 } from "react/jsx-dev-runtime";
187
+ import { jsx as jsx3, jsxs } from "react/jsx-runtime";
188
188
  var baseClass = "localizer-button";
189
189
  var LocaleLabel = ({ locale }) => {
190
190
  const { i18n, t } = useTranslation3();
191
- return /* @__PURE__ */ jsxDEV3("div", {
191
+ return /* @__PURE__ */ jsxs("div", {
192
192
  "aria-label": t("general:locale"),
193
193
  className: baseClass,
194
194
  children: [
195
- /* @__PURE__ */ jsxDEV3("div", {
195
+ /* @__PURE__ */ jsx3("div", {
196
196
  className: `${baseClass}__label`,
197
197
  children: `${t("general:locale")}:`
198
- }, undefined, false, undefined, this),
198
+ }),
199
199
  "  ",
200
- /* @__PURE__ */ jsxDEV3("span", {
200
+ /* @__PURE__ */ jsx3("span", {
201
201
  className: `${baseClass}__current-label`,
202
202
  children: `${getTranslation(locale.label, i18n)}`
203
- }, undefined, false, undefined, this),
203
+ }),
204
204
  " ",
205
- /* @__PURE__ */ jsxDEV3(ChevronIcon, {
205
+ /* @__PURE__ */ jsx3(ChevronIcon, {
206
206
  className: `${baseClass}__chevron`
207
- }, undefined, false, undefined, this)
207
+ })
208
208
  ]
209
- }, undefined, true, undefined, this);
209
+ });
210
210
  };
211
211
  // src/client/components/TranslatorModal/Content.tsx
212
- import { jsxDEV as jsxDEV4 } from "react/jsx-dev-runtime";
212
+ import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
213
213
  var Content = () => {
214
214
  const {
215
215
  localeToTranslateFrom: localeCodeToTranslateFrom,
@@ -220,21 +220,21 @@ var Content = () => {
220
220
  } = useTranslator();
221
221
  const { i18n } = useTranslation4();
222
222
  const localeToTranslateFrom = localesOptions.find((each) => each.code === localeCodeToTranslateFrom);
223
- return /* @__PURE__ */ jsxDEV4("div", {
223
+ return /* @__PURE__ */ jsxs2("div", {
224
224
  className: "translator__content",
225
225
  children: [
226
- /* @__PURE__ */ jsxDEV4("h2", {
226
+ /* @__PURE__ */ jsx4("h2", {
227
227
  children: resolverT("modalTitle")
228
- }, undefined, false, undefined, this),
229
- localeToTranslateFrom && /* @__PURE__ */ jsxDEV4(Popup, {
230
- button: /* @__PURE__ */ jsxDEV4(LocaleLabel, {
228
+ }),
229
+ localeToTranslateFrom && /* @__PURE__ */ jsx4(Popup, {
230
+ button: /* @__PURE__ */ jsx4(LocaleLabel, {
231
231
  locale: localeToTranslateFrom
232
- }, undefined, false, undefined, this),
232
+ }),
233
233
  horizontalAlign: "center",
234
- render: ({ close }) => /* @__PURE__ */ jsxDEV4(PopupList.ButtonGroup, {
234
+ render: ({ close }) => /* @__PURE__ */ jsx4(PopupList.ButtonGroup, {
235
235
  children: localesOptions.map((option) => {
236
236
  const label = getTranslation2(option.label, i18n);
237
- return /* @__PURE__ */ jsxDEV4(PopupList.Button, {
237
+ return /* @__PURE__ */ jsxs2(PopupList.Button, {
238
238
  active: option.code === localeCodeToTranslateFrom,
239
239
  onClick: () => {
240
240
  setLocaleToTranslateFrom(option.code);
@@ -244,52 +244,52 @@ var Content = () => {
244
244
  label,
245
245
  label !== option.code && ` (${option.code})`
246
246
  ]
247
- }, option.code, true, undefined, this);
247
+ }, option.code);
248
248
  })
249
- }, undefined, false, undefined, this),
249
+ }),
250
250
  verticalAlign: "bottom"
251
- }, undefined, false, undefined, this),
252
- /* @__PURE__ */ jsxDEV4("div", {
251
+ }),
252
+ /* @__PURE__ */ jsxs2("div", {
253
253
  className: "translator__buttons",
254
254
  children: [
255
- /* @__PURE__ */ jsxDEV4(Button2, {
255
+ /* @__PURE__ */ jsx4(Button2, {
256
256
  onClick: () => submit({ emptyOnly: false }),
257
257
  children: resolverT("submitButtonLabelFull")
258
- }, undefined, false, undefined, this),
259
- /* @__PURE__ */ jsxDEV4(Button2, {
258
+ }),
259
+ /* @__PURE__ */ jsx4(Button2, {
260
260
  onClick: () => submit({ emptyOnly: true }),
261
261
  children: resolverT("submitButtonLabelEmpty")
262
- }, undefined, false, undefined, this)
262
+ })
263
263
  ]
264
- }, undefined, true, undefined, this)
264
+ })
265
265
  ]
266
- }, undefined, true, undefined, this);
266
+ });
267
267
  };
268
268
 
269
269
  // src/client/components/TranslatorModal/TranslatorModal.tsx
270
- import { jsxDEV as jsxDEV5 } from "react/jsx-dev-runtime";
270
+ import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
271
271
  var TranslatorModal = () => {
272
272
  const { closeTranslator, modalSlug: modalSlug2, resolver } = useTranslator();
273
273
  if (!resolver)
274
274
  return;
275
- return /* @__PURE__ */ jsxDEV5(Modal, {
275
+ return /* @__PURE__ */ jsx5(Modal, {
276
276
  className: "translator__modal",
277
277
  slug: modalSlug2,
278
- children: /* @__PURE__ */ jsxDEV5("div", {
278
+ children: /* @__PURE__ */ jsxs3("div", {
279
279
  className: "translator__wrapper",
280
280
  children: [
281
- /* @__PURE__ */ jsxDEV5("button", {
281
+ /* @__PURE__ */ jsx5("button", {
282
282
  "aria-label": "Close",
283
283
  className: "translator__close",
284
284
  onClick: closeTranslator
285
- }, undefined, false, undefined, this),
286
- /* @__PURE__ */ jsxDEV5(Content, {}, undefined, false, undefined, this)
285
+ }),
286
+ /* @__PURE__ */ jsx5(Content, {})
287
287
  ]
288
- }, undefined, true, undefined, this)
289
- }, undefined, false, undefined, this);
288
+ })
289
+ });
290
290
  };
291
291
  // src/client/components/CustomButton/CustomButtonWithTranslator.tsx
292
- import { jsxDEV as jsxDEV6 } from "react/jsx-dev-runtime";
292
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
293
293
 
294
294
  var CustomButtonWithTranslator = ({ type }) => {
295
295
  const { config } = useConfig2();
@@ -297,19 +297,19 @@ var CustomButtonWithTranslator = ({ type }) => {
297
297
  const { globalSlug, id } = useDocumentInfo2();
298
298
  const resolvers = config.admin?.custom?.translator?.resolvers ?? [];
299
299
  if (!id && !globalSlug)
300
- return /* @__PURE__ */ jsxDEV6(DefaultButton, {}, undefined, false, undefined, this);
301
- return /* @__PURE__ */ jsxDEV6(TranslatorProvider, {
302
- children: /* @__PURE__ */ jsxDEV6("div", {
300
+ return /* @__PURE__ */ jsx6(DefaultButton, {});
301
+ return /* @__PURE__ */ jsx6(TranslatorProvider, {
302
+ children: /* @__PURE__ */ jsxs4("div", {
303
303
  className: "translator__custom-save-button",
304
304
  children: [
305
- /* @__PURE__ */ jsxDEV6(TranslatorModal, {}, undefined, false, undefined, this),
306
- resolvers.map((resolver) => /* @__PURE__ */ jsxDEV6(ResolverButton, {
305
+ /* @__PURE__ */ jsx6(TranslatorModal, {}),
306
+ resolvers.map((resolver) => /* @__PURE__ */ jsx6(ResolverButton, {
307
307
  resolver
308
- }, resolver.key, false, undefined, this)),
309
- /* @__PURE__ */ jsxDEV6(DefaultButton, {}, undefined, false, undefined, this)
308
+ }, resolver.key)),
309
+ /* @__PURE__ */ jsx6(DefaultButton, {})
310
310
  ]
311
- }, undefined, true, undefined, this)
312
- }, undefined, false, undefined, this);
311
+ })
312
+ });
313
313
  };
314
314
 
315
315
  // src/exports/client.ts
package/dist/index.d.ts CHANGED
@@ -7,7 +7,6 @@ type TranslateArgs = {
7
7
  emptyOnly?: boolean;
8
8
  globalSlug?: string;
9
9
  id?: number | string;
10
- /** active locale */
11
10
  locale: string;
12
11
  localeFrom: string;
13
12
  overrideAccess?: boolean;
@@ -31,9 +30,7 @@ import { Plugin } from "payload";
31
30
  import { CollectionSlug, GlobalSlug } from "payload";
32
31
  import { PayloadRequest as PayloadRequest3 } from "payload";
33
32
  type TranslateResolverArgs = {
34
- /** Locale to translate from */
35
33
  localeFrom: string;
36
- /** Locale to translate to */
37
34
  localeTo: string;
38
35
  req: PayloadRequest3;
39
36
  texts: string[];
@@ -49,48 +46,12 @@ type TranslateResolver = {
49
46
  resolve: (args: TranslateResolverArgs) => Promise<TranslateResolverResponse> | TranslateResolverResponse;
50
47
  };
51
48
  type TranslatorConfig = {
52
- /**
53
- * Collections with the enabled translator in the admin UI
54
- */
55
49
  collections: CollectionSlug[];
56
- /**
57
- * Disable the plugin
58
- */
59
50
  disabled?: boolean;
60
- /**
61
- * Globals with the enabled translator in the admin UI
62
- */
63
51
  globals: GlobalSlug[];
64
- /**
65
- * Add resolvers that you want to include, examples on how to write your own in ./plugin/src/resolvers
66
- */
67
52
  resolvers: TranslateResolver[];
68
- /**
69
- * Auto-translate every save in the default locale to the other
70
- * configured locales via Payload's job queue.
71
- *
72
- * When `true`, the plugin:
73
- * - registers `createTranslateTask()` and `createTranslateWorkflow()`
74
- * in `config.jobs` (idempotent — skips if a task/workflow with
75
- * the same slug is already registered, so host-customized
76
- * factories survive),
77
- * - prepends `createAutoTranslateCollectionHook` /
78
- * `createAutoTranslateGlobalHook` to each collection/global
79
- * listed in `collections` / `globals`.
80
- *
81
- * Default: `false`. Public factories stay exported for hosts that
82
- * need per-entity control.
83
- */
84
53
  autoTranslate?: boolean;
85
- /**
86
- * Advanced traversal options. Mirrors the options accepted by the
87
- * internal field walker — keep in sync if you need the latest shape.
88
- */
89
54
  _options?: {
90
- /**
91
- * Hook invoked while walking each rich-text node. Lets you append
92
- * extra text segments to be translated (e.g. custom lexical nodes).
93
- */
94
55
  additionalTraverseRichText?: (args: {
95
56
  onText: (siblingData: Record<string, unknown>, attribute?: string) => void;
96
57
  root: Record<string, unknown>;