@focus-reactive/payload-plugin-translator 0.5.1 → 0.6.1

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 (64) hide show
  1. package/README.md +191 -287
  2. package/dist/client/entities/translation/api/mutations/useTranslateField.d.ts +22 -0
  3. package/dist/client/entities/translation/api/mutations/useTranslateField.js +31 -0
  4. package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.d.ts +3 -3
  5. package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.js +15 -13
  6. package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.d.ts +2 -2
  7. package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.js +10 -10
  8. package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.d.ts +1 -1
  9. package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.js +9 -9
  10. package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.d.ts +2 -2
  11. package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.js +10 -8
  12. package/dist/client/shared/lib/assets/icons/SendIcon.d.ts +1 -0
  13. package/dist/client/shared/lib/assets/icons/SendIcon.js +17 -0
  14. package/dist/client/shared/ui/Button/Button.d.ts +2 -1
  15. package/dist/client/shared/ui/Button/Button.js +1 -1
  16. package/dist/client/shared/ui/Button/styles.module.scss +26 -3
  17. package/dist/client/widgets/translate-field-control/index.d.ts +1 -0
  18. package/dist/client/widgets/translate-field-control/index.js +3 -0
  19. package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.d.ts +26 -0
  20. package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.export.d.ts +13 -0
  21. package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.export.js +14 -0
  22. package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.js +204 -0
  23. package/dist/client/widgets/translate-field-control/ui/styles.module.scss +48 -0
  24. package/dist/field-actions.d.ts +25 -0
  25. package/dist/field-actions.js +23 -0
  26. package/dist/field-config.d.ts +26 -26
  27. package/dist/field-config.js +14 -37
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/plugin.d.ts +1 -1
  31. package/dist/plugin.js +3 -1
  32. package/dist/server/features/translate-field/handler.d.ts +17 -0
  33. package/dist/server/features/translate-field/handler.js +85 -0
  34. package/dist/server/features/translate-field/index.d.ts +6 -0
  35. package/dist/server/features/translate-field/index.js +5 -0
  36. package/dist/server/features/translate-field/model.d.ts +62 -0
  37. package/dist/server/features/translate-field/model.js +26 -0
  38. package/dist/server/features/translate-field/resolveFieldSubtree.d.ts +15 -7
  39. package/dist/server/features/translate-field/resolveFieldSubtree.js +14 -8
  40. package/dist/server/features/translate-field/route.d.ts +12 -0
  41. package/dist/server/features/translate-field/route.js +18 -0
  42. package/dist/server/modules/translation-levels/PluginConfigBuilder.d.ts +6 -0
  43. package/dist/server/modules/translation-levels/PluginConfigBuilder.js +4 -0
  44. package/dist/server/modules/translation-levels/fieldLevel.d.ts +23 -0
  45. package/dist/server/modules/translation-levels/fieldLevel.js +37 -0
  46. package/dist/server/modules/translation-levels/index.d.ts +1 -0
  47. package/dist/server/modules/translation-levels/index.js +1 -0
  48. package/dist/server/modules/translation-levels/types.d.ts +6 -1
  49. package/dist/server/modules/translation-levels/types.js +0 -1
  50. package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.js +10 -4
  51. package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.stage.d.ts +1 -1
  52. package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.stage.js +1 -1
  53. package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.d.ts +5 -0
  54. package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.js +13 -4
  55. package/dist/server/modules/translation-providers/OpenAITranslation.provider.d.ts +17 -0
  56. package/dist/server/modules/translation-providers/OpenAITranslation.provider.js +8 -2
  57. package/dist/server/shared/field-config/types.js +1 -1
  58. package/dist/server/shared/field-traversal/findFieldByPath.d.ts +25 -10
  59. package/dist/server/shared/field-traversal/findFieldByPath.js +59 -14
  60. package/dist/server/shared/field-traversal/index.d.ts +1 -1
  61. package/dist/server/shared/field-traversal/index.js +1 -1
  62. package/dist/server/shared/field-traversal/kernel.d.ts +22 -0
  63. package/dist/server/shared/field-traversal/kernel.js +27 -0
  64. package/package.json +2 -2
package/README.md CHANGED
@@ -1,33 +1,44 @@
1
1
  # @focus-reactive/payload-plugin-translator
2
2
 
3
- Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.
3
+ [![npm version](https://img.shields.io/npm/v/@focus-reactive/payload-plugin-translator)](https://www.npmjs.com/package/@focus-reactive/payload-plugin-translator)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/focusreactive/payload-plugins/blob/main/LICENSE)
5
+
6
+ Translate localized content in **Payload CMS 3** with any provider — a whole document, a whole collection, or a single field — straight from the admin UI.
7
+
8
+ ## About
9
+
10
+ Payload localizes your content, but it doesn't translate it — you still copy text between locales by hand. This plugin closes that gap: it walks every localized field (including deeply nested groups, arrays, blocks, tabs, and Lexical rich text), sends the text to a translation provider, and writes the result back to the target locale.
11
+
12
+ It works at three levels — translate the **document** you're editing, **bulk-translate** a collection from its list view, or translate a **single field** in place. Providers are pluggable (OpenAI is built in), and translation runs through a configurable runner (async Payload Jobs by default, or synchronously).
4
13
 
5
14
  ## Features
6
15
 
7
- - **Deep translation** — translates all localized fields at any nesting level (groups, arrays, blocks, tabs)
8
- - **Rich text support** — full Lexical rich text translation preserving formatting and structure
9
- - **Bulk translation** — translate multiple documents at once from collection list view
10
- - **Progress tracking** — real-time translation status indicators in admin UI
11
- - **Async processing** — queue-based background jobs (Payload Jobs) or synchronous mode
12
- - **Pluggable providers** — use OpenAI or create custom translation providers
13
- - **Field exclusion** — exclude specific fields from translation via `withFieldTranslation`
14
- - **Translation strategies** — choose between overwrite all or skip existing translations
15
- - **Configurable surfaces** — enable the per-document popup and/or the bulk-collection dashboard via the `levels` option _(since v0.5.0)_
16
+ - **Deep translation** — every localized leaf field at any nesting level (groups, arrays, blocks, tabs).
17
+ - **Rich text** — full Lexical translation, preserving formatting and structure.
18
+ - **Three surfaces** — a per-document popup, a bulk-collection dashboard, and a per-field control, toggled via `levels`.
19
+ - **Async or sync** — queue-based background jobs (Payload Jobs) by default, or run inline.
20
+ - **Pluggable providers** — OpenAI built in, or implement your own.
21
+ - **Strategies** — overwrite everything or skip locales that already have content.
22
+ - **Field control** — add a per-field Translate button, or exclude a field from translation.
16
23
 
17
- ## Installation
24
+ ## Requirements
18
25
 
19
- ```bash
20
- # npm
21
- npm install @focus-reactive/payload-plugin-translator
26
+ | Peer dependency | Version |
27
+ | ------------------------------ | -------------- |
28
+ | `payload` | `^3.76.0` |
29
+ | `@payloadcms/ui` | `^3.76.0` |
30
+ | `@payloadcms/richtext-lexical` | `^3.76.0` |
31
+ | `react` | `^18` or `^19` |
22
32
 
23
- # bun
24
- bun add @focus-reactive/payload-plugin-translator
33
+ Your Payload config must have [localization](https://payloadcms.com/docs/configuration/localization) enabled.
25
34
 
26
- # pnpm
27
- pnpm add @focus-reactive/payload-plugin-translator
35
+ ## Installation
28
36
 
29
- # yarn
30
- yarn add @focus-reactive/payload-plugin-translator
37
+ ```bash
38
+ npm install @focus-reactive/payload-plugin-translator
39
+ # pnpm add @focus-reactive/payload-plugin-translator
40
+ # bun add @focus-reactive/payload-plugin-translator
41
+ # yarn add @focus-reactive/payload-plugin-translator
31
42
  ```
32
43
 
33
44
  ## Quick Start
@@ -40,390 +51,283 @@ import { Pages } from "./collections/Pages";
40
51
 
41
52
  export default buildConfig({
42
53
  collections: [Posts, Pages],
54
+ localization: {
55
+ locales: ["en", "de", "fr"],
56
+ defaultLocale: "en",
57
+ },
43
58
  plugins: [
44
59
  translatorPlugin({
45
- collections: [Posts, Pages],
46
- translationProvider: createOpenAIProvider({
47
- apiKey: process.env.OPENAI_API_KEY,
48
- }),
60
+ collections: [Posts, Pages], // the same config objects you pass to buildConfig
61
+ translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
49
62
  runner: createPayloadJobsRunner(),
50
63
  }),
51
64
  ],
52
- localization: {
53
- locales: ["en", "de", "fr"],
54
- defaultLocale: "en",
55
- },
56
65
  });
57
66
  ```
58
67
 
59
- ## Configuration
60
-
61
- ### TranslatorPluginConfig
62
-
63
- Configuration for `translatorPlugin()`.
64
-
65
- | Property | Type | Required | Default | Description |
66
- | --------------------- | --------------------- | -------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
67
- | `collections` | `CollectionConfig[]` | Yes | — | Original collection configs to enable translation for. Must be the same objects passed to `buildConfig`, not slugs. |
68
- | `translationProvider` | `TranslationProvider` | Yes | — | Translation provider instance (e.g., `createOpenAIProvider(...)`) |
69
- | `runner` | `TaskRunnerProvider` | Yes | — | Task runner provider for background processing (e.g., `createPayloadJobsRunner()`) |
70
- | `access` | `AccessGuard` | No | `undefined` | Access guard (`{ check }`) for the translation endpoints; omit to leave them open |
71
- | `basePath` | `string` | No | `'/translate'` | Base path for all API endpoints |
72
- | `levels` | `TranslationLevel[]` | No | `[documentLevel(), collectionLevel()]` | Which translation surfaces to enable. See [Translation Levels](#translation-levels) |
68
+ Open a localized document in the admin — a **Translate** control appears, and the collection list view gains a **bulk** dashboard.
73
69
 
74
- ```typescript
75
- translatorPlugin({
76
- collections: [Posts, Pages],
77
- translationProvider: createOpenAIProvider({
78
- apiKey: process.env.OPENAI_API_KEY,
79
- }),
80
- runner: createPayloadJobsRunner(),
81
- access: { check: ({ req }) => req.user?.role === "admin" },
82
- basePath: "/translate",
83
- });
84
- ```
85
-
86
- ### Translation Levels
70
+ ## Translation surfaces (`levels`)
87
71
 
88
72
  _Since v0.5.0._
89
73
 
90
- The `levels` option controls which translation surfaces the plugin exposes. Each entry is a factory you import and list:
74
+ `levels` controls which translation surfaces the plugin exposes. Each entry is a factory you import and list:
91
75
 
92
- - `documentLevel()` a **Translate** popup on the document edit view (translate one document).
93
- - `collectionLevel()` a **bulk dashboard** on the collection list view (translate many at once).
76
+ | Level | Surface | Runs |
77
+ | ------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------- |
78
+ | `documentLevel()` | A **Translate** popup on the document edit view (one document). | via `runner` |
79
+ | `collectionLevel()` | A **bulk dashboard** on the collection list view (many at once). | via `runner` |
80
+ | `fieldLevel()` | A per-field **Translate** control + a synchronous `POST {basePath}/field` endpoint (one field). _Since v0.6.0._ | synchronous, no runner |
94
81
 
95
- Both run through the configured `runner` (async Payload Jobs by default, or synchronous via `createSyncRunner()`) and share the same translation REST API.
82
+ Omit `levels` for the default `[documentLevel(), collectionLevel()]` adopting the option is non-breaking.
96
83
 
97
84
  ```typescript
98
- import { translatorPlugin, documentLevel, collectionLevel, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
85
+ import { translatorPlugin, collectionLevel, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
99
86
 
100
87
  translatorPlugin({
101
88
  collections: [Posts],
102
- translationProvider: createOpenAIProvider({
103
- apiKey: process.env.OPENAI_API_KEY,
104
- }),
89
+ translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
105
90
  runner: createPayloadJobsRunner(),
106
91
  levels: [collectionLevel()], // bulk dashboard only — no per-document popup
107
92
  });
108
93
  ```
109
94
 
110
- Omit `levels` for the default `[documentLevel(), collectionLevel()]`, which is identical to the previous behaviour — so adopting this option is non-breaking. A synchronous `fieldLevel()` (in-place field translation) is planned for a later release.
111
-
112
- ### OpenAIProviderConfig
95
+ The document and collection levels show a real-time **progress indicator** while jobs run.
113
96
 
114
- Configuration for `createOpenAIProvider()`.
97
+ ### Field-level translation
115
98
 
116
- | Property | Type | Required | Default | Description |
117
- | -------------- | ------------------------- | -------- | --------------- | ------------------------------------------ |
118
- | `apiKey` | `string` | Yes | — | OpenAI API key |
119
- | `model` | `string \| ChatModel` | No | `'gpt-4o'` | OpenAI model to use for translation |
120
- | `systemPrompt` | `SystemPromptBuilder` | No | Built-in prompt | Custom function to build the system prompt |
121
- | `dryRun` | `boolean \| DryRunConfig` | No | `false` | Simulate translations without API calls |
99
+ _Since v0.6.0._
122
100
 
123
- ```typescript
124
- createOpenAIProvider({
125
- apiKey: process.env.OPENAI_API_KEY,
126
- model: "gpt-4o-mini",
127
- systemPrompt: ({ sourceLang, targetLang, defaultPrompt }) => `${defaultPrompt}\nUse formal language. Keep brand names unchanged.`,
128
- dryRun: false,
129
- });
130
- ```
101
+ `fieldLevel()` adds a per-field **Translate** control. Two steps:
131
102
 
132
- #### SystemPromptBuilder
133
-
134
- Function signature for custom system prompt:
103
+ 1. Add `fieldLevel()` to `levels` (registers the endpoint).
104
+ 2. Wrap the fields that should get a control with `withFieldTranslation(field)`.
135
105
 
136
106
  ```typescript
137
- type SystemPromptBuilder = (context: SystemPromptContext) => string;
107
+ import { translatorPlugin, documentLevel, fieldLevel, withFieldTranslation, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
138
108
 
139
- type SystemPromptContext = {
140
- sourceLang: string;
141
- targetLang: string;
142
- defaultPrompt: string;
109
+ // In a collection:
110
+ const Posts = {
111
+ slug: "posts",
112
+ fields: [withFieldTranslation({ name: "title", type: "text", localized: true })],
143
113
  };
114
+
115
+ // In the plugin:
116
+ translatorPlugin({
117
+ collections: [Posts],
118
+ translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
119
+ runner: createPayloadJobsRunner(),
120
+ levels: [documentLevel(), fieldLevel()],
121
+ });
144
122
  ```
145
123
 
146
- #### DryRunConfig
124
+ The control is an icon button (just above the input) that opens a compact popup with the translation **direction**: a source-locale `Select`, an arrow, then the **current locale** (the fixed target) — `en → fr`. You pick the **source**; the **target is always the locale you're editing**. The server reads the source locale's _saved_ value and translates it into the current locale, so the control needs a **saved document** (it's hidden while creating one). The result is written straight to form state — no save, no queue — and an **Undo** restores the previous value.
147
125
 
148
- When `dryRun` is an object, it allows custom transformation with optional delay:
126
+ Allowed on **`text`, `textarea`, and `richText`** fields (a compile error on other types — pass `{ exclude: true }` for those). For `richText` the Lexical editor re-mounts with the translated content. Fields **inside blocks** are supported: the server reads the source document's `blockType` to resolve the right block schema.
149
127
 
150
- ```typescript
151
- type DryRunTransformer = (text: string) => string | Promise<string>;
128
+ > **Localized `blocks`/`array` containers.** Per-field translation works when the **container is not localized** — the structure is then shared across locales and only the leaf values differ, so wrap the leaves, not the container. If a `blocks`/`array` field is itself `localized`, each locale has an independent structure (different order/content), so a field inside it can't be matched to the source locale by position — the control no-ops with a notice to translate the whole document instead (whole-document translation handles this by matching elements by `id`).
152
129
 
153
- type DryRunConfig = {
154
- transform: DryRunTransformer; // Custom transformer function
155
- timeout?: number; // Delay in ms (simulates API latency)
156
- };
157
- ```
130
+ > This direction is intentionally the reverse of the document/collection level (which translates _from_ the current locale _to_ chosen targets): the field control pulls content _into_ the locale you're standing in.
158
131
 
159
- ### PayloadJobsRunnerOptions
132
+ ## Configuration
160
133
 
161
- Configuration for `createPayloadJobsRunner()`.
134
+ ### `translatorPlugin(config)`
162
135
 
163
- | Property | Type | Required | Default | Description |
164
- | ----------- | -------------------------- | -------- | ---------------------------------- | ------------------------------------------------------- |
165
- | `taskName` | `string` | No | `'translate_document'` | Task name in Payload jobs collection |
166
- | `queueName` | `string` | No | `'translations'` | Queue name for grouping jobs |
167
- | `autoRun` | `false \| { cron, limit }` | No | `{ cron: '* * * * *', limit: 50 }` | Auto-run config, or `false` to disable (for serverless) |
136
+ | Property | Type | Required | Default | Description |
137
+ | --------------------- | --------------------- | -------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
138
+ | `collections` | `CollectionConfig[]` | Yes | | Collection configs to enable translation for. Must be the **same objects** passed to `buildConfig`, not slugs. |
139
+ | `translationProvider` | `TranslationProvider` | Yes | | Provider instance (e.g. `createOpenAIProvider(...)`). |
140
+ | `runner` | `TaskRunnerProvider` | Yes | | Runner for background processing (e.g. `createPayloadJobsRunner()`). |
141
+ | `access` | `AccessGuard` | No | `undefined` | Access guard (`{ check }`) for the translation endpoints; omit to leave them open. |
142
+ | `basePath` | `string` | No | `'/translate'` | Base path for the plugin's API endpoints. |
143
+ | `levels` | `TranslationLevel[]` | No | `[documentLevel(), collectionLevel()]` | Which surfaces to enable — see [Translation surfaces](#translation-surfaces-levels). |
168
144
 
169
145
  ```typescript
170
- createPayloadJobsRunner({
171
- taskName: "translate_document",
172
- queueName: "translations",
173
- autoRun: {
174
- cron: "* * * * *",
175
- limit: 50,
176
- },
146
+ translatorPlugin({
147
+ collections: [Posts, Pages],
148
+ translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
149
+ runner: createPayloadJobsRunner(),
150
+ access: { check: ({ req }) => req.user?.role === "admin" },
177
151
  });
178
152
  ```
179
153
 
180
- ### FieldTranslationConfig
154
+ ### Providers
181
155
 
182
- Configuration for `withFieldTranslation()` helper or `field.custom.translateKit`.
156
+ #### OpenAI (built in) `createOpenAIProvider(config)`
183
157
 
184
- | Property | Type | Required | Default | Description |
185
- | --------- | --------- | -------- | ------- | ----------------------------------- |
186
- | `exclude` | `boolean` | No | `false` | Exclude this field from translation |
158
+ | Property | Type | Required | Default | Description |
159
+ | -------------- | ------------------------- | -------- | -------------------- | --------------------------------------------------------------------------------------------------------------- |
160
+ | `apiKey` | `string` | Yes | | OpenAI API key. |
161
+ | `model` | `string \| ChatModel` | No | `'gpt-4o'` | Model used for translation. |
162
+ | `systemPrompt` | `SystemPromptBuilder` | No | Built-in prompt | Custom system-prompt builder. |
163
+ | `dryRun` | `boolean \| DryRunConfig` | No | `false` | Simulate translations without API calls. |
164
+ | `timeout` | `number` | No | SDK default (10 min) | Per-request timeout (ms). A job blocks on this call, so the 10-min default is usually too long. _Since v0.6.0._ |
165
+ | `maxRetries` | `number` | No | SDK default (2) | Max automatic retries on transient errors (429/5xx/network). `0` disables. _Since v0.6.0._ |
187
166
 
188
167
  ```typescript
189
- import { withFieldTranslation } from "@focus-reactive/payload-plugin-translator";
190
-
191
- withFieldTranslation({ name: "sku", type: "text", localized: true }, { exclude: true });
168
+ createOpenAIProvider({
169
+ apiKey: process.env.OPENAI_API_KEY,
170
+ model: "gpt-4o-mini",
171
+ systemPrompt: ({ sourceLang, targetLang, defaultPrompt }) => `${defaultPrompt}\nUse formal language. Keep brand names unchanged.`,
172
+ });
192
173
  ```
193
174
 
194
- ## Translation Strategies
175
+ `systemPrompt` receives `{ sourceLang, targetLang, defaultPrompt }` and returns the prompt string. When `dryRun` is an object it can transform text locally with an optional delay:
195
176
 
196
- When translating, you can choose how to handle existing translations:
177
+ ```typescript
178
+ type DryRunConfig = {
179
+ transform: (text: string) => string | Promise<string>;
180
+ timeout?: number; // ms, simulates API latency
181
+ };
182
+ ```
197
183
 
198
- | Strategy | Description |
199
- | ----------------- | ------------------------------------------------------------------------ |
200
- | `'overwrite'` | (Default) Replaces all existing translated content with new translations |
201
- | `'skip_existing'` | Only translates fields that are empty in the target locale |
184
+ #### Custom provider
202
185
 
203
- ## Important Notes
186
+ Implement the `TranslationProvider` interface — a single `translate` method:
204
187
 
205
- ### Explicit `localized: true` for nested fields
188
+ ```typescript
189
+ import type { TranslationProvider, TranslationInput, TranslationOutput } from "@focus-reactive/payload-plugin-translator";
206
190
 
207
- Payload CMS allows wrapper fields (group, array, blocks, tabs) to be marked as `localized`, which makes nested fields inherit localization without explicit declaration. However, the plugin only translates **leaf fields** (primitive data fields like text, textarea, richText).
191
+ // TranslationInput / TranslationOutput are Record<number, string> a map of
192
+ // numeric indices to text. The indices map to positions in the document; the
193
+ // provider MUST return the same keys with translated values.
194
+ class DeepLProvider implements TranslationProvider {
195
+ constructor(private apiKey: string) {}
208
196
 
209
- For correct plugin operation, you must explicitly set `localized: true` on each nested field you want to translate:
197
+ async translate(content: TranslationInput, sourceLng: string, targetLng: string): Promise<TranslationOutput | null> {
198
+ try {
199
+ const response = await fetch("https://api.deepl.com/v2/translate", {
200
+ method: "POST",
201
+ headers: { Authorization: `DeepL-Auth-Key ${this.apiKey}`, "Content-Type": "application/json" },
202
+ body: JSON.stringify({ text: Object.values(content), source_lang: sourceLng.toUpperCase(), target_lang: targetLng.toUpperCase() }),
203
+ });
204
+ const data = await response.json();
210
205
 
211
- ```typescript
212
- // Won't work — nested title is not explicitly localized
213
- {
214
- name: 'meta',
215
- type: 'group',
216
- localized: true,
217
- fields: [
218
- { name: 'title', type: 'text' }
219
- ]
206
+ const result: TranslationOutput = {};
207
+ Object.keys(content).forEach((key, i) => {
208
+ result[key] = data.translations[i].text;
209
+ });
210
+ return result;
211
+ } catch {
212
+ return null; // null aborts the translation for this chunk
213
+ }
214
+ }
220
215
  }
221
216
 
222
- // ✅ Correct — title is explicitly localized
223
- {
224
- name: 'meta',
225
- type: 'group',
226
- localized: true,
227
- fields: [
228
- { name: 'title', type: 'text', localized: true }
229
- ]
230
- }
217
+ translatorPlugin({
218
+ collections: [Posts],
219
+ translationProvider: new DeepLProvider(process.env.DEEPL_API_KEY),
220
+ runner: createPayloadJobsRunner(),
221
+ });
231
222
  ```
232
223
 
233
- ### Excluded fields behavior
234
-
235
- Using `withFieldTranslation({ ... }, { exclude: true })` does not mean the field will be completely untouched during translation. If the excluded field is empty in the target locale, it will be populated with data from the source locale.
236
-
237
- This prevents validation errors when saving translation results (e.g., required fields that should not be translated but must have a value).
224
+ ### Runners
238
225
 
239
- ### Field exclusion priority over strategy
226
+ Document- and collection-level translation run through a **runner**.
240
227
 
241
- `withFieldTranslation` exclusion takes priority over the `overwrite` strategy. Even when using `overwrite` strategy, excluded fields will:
228
+ #### `createPayloadJobsRunner(options)` (recommended)
242
229
 
243
- - Keep their target value if it exists
244
- - Copy source value only if target is empty
245
- - Never be sent to the translation provider
230
+ Background processing via Payload's job queue.
246
231
 
247
- ### Preserving completed job status
248
-
249
- By default, Payload deletes jobs immediately after completion. This means the "Completed" status won't be visible in the UI. To preserve completed jobs and show their status, add this to your Payload config:
232
+ | Property | Type | Required | Default | Description |
233
+ | ----------- | -------------------------- | -------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------- |
234
+ | `taskName` | `string` | No | `'translate_document'` | Task name in the Payload jobs collection. |
235
+ | `queueName` | `string` | No | `'translations'` | Queue name for grouping jobs. |
236
+ | `autoRun` | `false \| { cron, limit }` | No | `{ cron: '* * * * *', limit: 50 }` | Auto-run schedule, or `false` to disable (e.g. for serverless, where you trigger the queue yourself). |
250
237
 
251
238
  ```typescript
252
- export default buildConfig({
253
- // ... other config
254
- jobs: {
255
- deleteJobOnComplete: false,
256
- },
257
- });
239
+ createPayloadJobsRunner({ taskName: "translate_document", queueName: "translations", autoRun: { cron: "* * * * *", limit: 50 } });
258
240
  ```
259
241
 
260
- ## Task Runners
242
+ > By default Payload deletes a job as soon as it completes, so the "Completed" status never shows in the UI. Set `jobs: { deleteJobOnComplete: false }` in your Payload config to keep it.
261
243
 
262
- ### PayloadJobsRunner (Recommended)
244
+ #### `createSyncRunner()`
263
245
 
264
- Uses Payload's built-in job queue for background processing:
265
-
266
- ```typescript
267
- import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
268
-
269
- const runner = createPayloadJobsRunner({
270
- taskName: "translate_document",
271
- queueName: "translations",
272
- autoRun: {
273
- cron: "* * * * *",
274
- limit: 50,
275
- },
276
- });
277
- ```
278
-
279
- ### SyncRunner
280
-
281
- Executes translations synchronously (useful for development or small datasets):
246
+ Runs translations inline (no queue) — handy for development or small datasets.
282
247
 
283
248
  ```typescript
284
249
  import { createSyncRunner } from "@focus-reactive/payload-plugin-translator";
285
250
 
286
- const runner = createSyncRunner();
251
+ translatorPlugin({ collections: [Posts], translationProvider, runner: createSyncRunner() });
287
252
  ```
288
253
 
289
- ## Translation Providers
254
+ ### Field config — `withFieldTranslation(field, config?)`
290
255
 
291
- ### OpenAI Provider
256
+ A plain wrap on a `text` / `textarea` / `richText` field adds the per-field Translate control (requires `fieldLevel()`); `{ exclude: true }` opts a field out of translation entirely.
292
257
 
293
- Built-in provider using OpenAI's API:
258
+ | Property | Type | Required | Default | Description |
259
+ | --------- | --------- | -------- | ------- | ------------------------------------ |
260
+ | `exclude` | `boolean` | No | `false` | Exclude this field from translation. |
294
261
 
295
262
  ```typescript
296
- import { createOpenAIProvider } from "@focus-reactive/payload-plugin-translator";
263
+ import { withFieldTranslation } from "@focus-reactive/payload-plugin-translator";
297
264
 
298
- const provider = createOpenAIProvider({
299
- apiKey: process.env.OPENAI_API_KEY,
300
- model: "gpt-4o-mini",
301
- systemPrompt: ({ defaultPrompt }) => `${defaultPrompt}\nUse formal language.`,
302
- });
265
+ withFieldTranslation({ name: "title", type: "text", localized: true }); // adds the control
266
+ withFieldTranslation({ name: "sku", type: "text", localized: true }, { exclude: true }); // never translated
303
267
  ```
304
268
 
305
- ### Custom Provider
269
+ ### Strategies
306
270
 
307
- Create your own translation provider by implementing the `TranslationProvider` interface:
271
+ How existing target-locale content is treated when translating:
308
272
 
309
- #### TranslationProvider Interface
273
+ | Strategy | Behavior |
274
+ | ----------------- | ---------------------------------------------------------- |
275
+ | `'overwrite'` | _(Default)_ Replace all existing translated content. |
276
+ | `'skip_existing'` | Only translate fields that are empty in the target locale. |
310
277
 
311
- | Method | Signature | Description |
312
- | ----------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
313
- | `translate` | `(content: TranslationInput, sourceLng: string, targetLng: string) => Promise<TranslationOutput \| null>` | Translates content from source to target language |
278
+ ## Notes & gotchas
314
279
 
315
- **Important:** The numeric indices in `TranslationInput` must be preserved exactly in `TranslationOutput`. Each index maps to a specific field in the document structure, so the provider must return the same keys with translated values.
280
+ ### Mark nested fields `localized: true` explicitly
316
281
 
317
- #### Types
282
+ Payload lets a wrapper field (group, array, blocks, tabs) be `localized`, which makes nested fields inherit localization. The plugin, however, only translates **leaf** fields (text, textarea, richText), so each one you want translated must carry `localized: true` itself:
318
283
 
319
284
  ```typescript
320
- // Numeric index representing position in document structure
321
- type TranslationIndex = number;
322
-
323
- // Input: Map of numeric indices to text strings
324
- type TranslationInput = Record<TranslationIndex, string>;
285
+ // nested title is not explicitly localized — skipped
286
+ { name: "meta", type: "group", localized: true, fields: [{ name: "title", type: "text" }] }
325
287
 
326
- // Output: Same indices with translated values
327
- type TranslationOutput = Record<TranslationIndex, string>;
288
+ // title is explicitly localized — translated
289
+ { name: "meta", type: "group", localized: true, fields: [{ name: "title", type: "text", localized: true }] }
328
290
  ```
329
291
 
330
- #### Example Implementation
292
+ ### Excluded fields are still backfilled
331
293
 
332
- ```typescript
333
- import type { TranslationProvider, TranslationInput, TranslationOutput } from "@focus-reactive/payload-plugin-translator";
294
+ `{ exclude: true }` means "never send this field to the provider" — not "leave it untouched." If an excluded field is empty in the target locale, it's filled from the source locale (so required fields don't fail validation on save). Exclusion takes priority over the `overwrite` strategy: an excluded field keeps its target value if present, copies the source value only when target is empty, and is never sent to the provider.
334
295
 
335
- class DeepLProvider implements TranslationProvider {
336
- constructor(private apiKey: string) {}
296
+ ### Keeping completed-job status
337
297
 
338
- async translate(content: TranslationInput, sourceLng: string, targetLng: string): Promise<TranslationOutput | null> {
339
- try {
340
- // content example: { "0": "Hello", "1": "World" }
341
- const texts = Object.values(content);
342
-
343
- const response = await fetch("https://api.deepl.com/v2/translate", {
344
- method: "POST",
345
- headers: {
346
- Authorization: `DeepL-Auth-Key ${this.apiKey}`,
347
- "Content-Type": "application/json",
348
- },
349
- body: JSON.stringify({
350
- text: texts,
351
- source_lang: sourceLng.toUpperCase(),
352
- target_lang: targetLng.toUpperCase(),
353
- }),
354
- });
355
-
356
- const data = await response.json();
357
-
358
- // Reconstruct the result with same keys
359
- const result: TranslationOutput = {};
360
- Object.keys(content).forEach((key, index) => {
361
- result[key] = data.translations[index].text;
362
- });
363
-
364
- return result;
365
- } catch {
366
- return null;
367
- }
368
- }
369
- }
370
-
371
- // Usage
372
- translatorPlugin({
373
- collections: [Posts],
374
- translationProvider: new DeepLProvider(process.env.DEEPL_API_KEY),
375
- runner: createPayloadJobsRunner(),
376
- });
377
- ```
378
-
379
- ## UI Components
380
-
381
- The plugin automatically adds:
382
-
383
- 1. **Document Translation Panel** — Available in the document edit view sidebar
384
- 2. **Bulk Translation Dashboard** — Accessible from collection list view
385
- 3. **Translation Progress Indicator** — Shows real-time translation status
298
+ See the `deleteJobOnComplete: false` note under [Runners](#createpayloadjobsrunneroptions-recommended).
386
299
 
387
300
  ## TypeScript
388
301
 
389
- All types are exported for TypeScript users:
302
+ The package ships its types. Besides the factories, the following are exported for typing your own code:
390
303
 
391
304
  ```typescript
392
305
  import type {
393
- // Plugin config
394
306
  TranslatorPluginConfig,
395
-
396
- // Provider types
397
307
  TranslationProvider,
398
308
  TranslationInput,
399
309
  TranslationOutput,
400
310
  OpenAIProviderConfig,
401
311
  DryRunConfig,
402
-
403
- // Runner types
404
312
  TaskRunnerProvider,
405
313
  PayloadJobsRunnerOptions,
406
-
407
- // Strategy types
408
- TranslationStrategy,
409
-
410
- // Access control
314
+ TranslationLevel,
315
+ FieldTranslationConfig,
411
316
  AccessGuard,
412
317
  AccessGuardRequest,
413
-
414
- // Field config
415
- FieldTranslationConfig,
416
-
417
- // Translation levels
418
- TranslationLevel,
419
318
  } from "@focus-reactive/payload-plugin-translator";
420
319
  ```
421
320
 
321
+ ## Versioning
322
+
323
+ Every public API is annotated with `@since x.y.z` in its JSDoc, and features carry a `Since vX.Y.Z` note here — so you can tell at a glance whether your installed version has a given capability without cross-referencing the changelog. Releases follow semver.
324
+
422
325
  ## Roadmap
423
326
 
424
- Planned features for future releases:
327
+ - **Global translation dashboard** — translate across all collections from one place, with project-wide progress.
328
+ - **Vercel Cron runner** — a built-in runner for serverless deploys without manual API-route wiring.
329
+ - **Auto-translate on source change** — trigger translation automatically when default-locale content changes.
330
+
331
+ ## License
425
332
 
426
- - **Field-level translation** — Granular translation control allowing users to translate individual fields rather than entire documents
427
- - **Global translation dashboard** — Translate all collections at once from a single interface, with progress tracking across the entire CMS
428
- - **Vercel Cron Jobs runner** — Built-in runner for seamless Vercel/serverless deployments without manual API route configuration
429
- - Auto-translate on source change — Automatically trigger translation when the default locale content is updated
333
+ [MIT](https://github.com/focusreactive/payload-plugins/blob/main/LICENSE) © Focus Reactive.