@focus-reactive/payload-plugin-translator 0.6.0 → 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 (2) hide show
  1. package/README.md +172 -306
  2. package/package.json +1 -1
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,87 +51,60 @@ 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).
94
- - `fieldLevel()` a synchronous **single-field** endpoint (`POST {basePath}/field`) that translates one field from a chosen source locale, with no persistence. _(Since v0.6.0.)_
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 |
95
81
 
96
- `documentLevel` and `collectionLevel` run through the configured `runner` (async Payload Jobs by default, or synchronous via `createSyncRunner()`) and share the same translation REST API. `fieldLevel` is always synchronous and uses no runner.
82
+ Omit `levels` for the default `[documentLevel(), collectionLevel()]` adopting the option is non-breaking.
97
83
 
98
84
  ```typescript
99
- import { translatorPlugin, documentLevel, collectionLevel, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
85
+ import { translatorPlugin, collectionLevel, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
100
86
 
101
87
  translatorPlugin({
102
88
  collections: [Posts],
103
- translationProvider: createOpenAIProvider({
104
- apiKey: process.env.OPENAI_API_KEY,
105
- }),
89
+ translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
106
90
  runner: createPayloadJobsRunner(),
107
91
  levels: [collectionLevel()], // bulk dashboard only — no per-document popup
108
92
  });
109
93
  ```
110
94
 
111
- Omit `levels` for the default `[documentLevel(), collectionLevel()]`, which is identical to the previous behaviour — so adopting this option is non-breaking.
95
+ The document and collection levels show a real-time **progress indicator** while jobs run.
112
96
 
113
- #### Field-level translation
97
+ ### Field-level translation
114
98
 
115
99
  _Since v0.6.0._
116
100
 
117
- `fieldLevel` adds a per-field **Translate** control. Two steps:
101
+ `fieldLevel()` adds a per-field **Translate** control. Two steps:
118
102
 
119
103
  1. Add `fieldLevel()` to `levels` (registers the endpoint).
120
104
  2. Wrap the fields that should get a control with `withFieldTranslation(field)`.
121
105
 
122
106
  ```typescript
123
- import { translatorPlugin, documentLevel, fieldLevel, withFieldTranslation } from "@focus-reactive/payload-plugin-translator";
107
+ import { translatorPlugin, documentLevel, fieldLevel, withFieldTranslation, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
124
108
 
125
109
  // In a collection:
126
110
  const Posts = {
@@ -137,331 +121,213 @@ translatorPlugin({
137
121
  });
138
122
  ```
139
123
 
140
- The control is an icon button (rendered just above the input) that opens a compact popup showing the translation **direction** a source-locale `Select`, an arrow, then the **current locale** (the fixed target): `en → fr`. You pick the **source** locale; 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 disabled on new documents). The source defaults to your Payload `defaultLocale` when that isn't the current locale, otherwise you choose it explicitly.
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.
141
125
 
142
- > This 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.
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.
143
127
 
144
- The translated value is written straight back to form state no save, no queue and an **Undo** restores the previous value. Wrapping for a control is allowed on **`text`, `textarea`, and `richText`** fields (a compile error on other types pass `{ exclude: true }` for those). For `richText` the Lexical editor is re-mounted with the translated content. Fields **inside blocks** are supported too: the server reads the source document's `blockType` to resolve the right block schema. Needs a **saved document** (the source value is read from it), so the control is hidden while creating a new document. The endpoint itself (`POST {basePath}/field`) is also usable directly by custom clients.
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`).
145
129
 
146
- > **Localized `blocks`/`array` containers.** Per-field translation works when the **container is not localized** — the block/array structure is then shared across locales and only the leaf values differ per locale (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 asking you to translate the whole document instead. (Whole-document translation handles this case by matching elements by `id`.)
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.
147
131
 
148
- ### OpenAIProviderConfig
132
+ ## Configuration
149
133
 
150
- Configuration for `createOpenAIProvider()`.
134
+ ### `translatorPlugin(config)`
151
135
 
152
- | Property | Type | Required | Default | Description |
153
- | -------------- | ------------------------- | -------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
154
- | `apiKey` | `string` | Yes | — | OpenAI API key |
155
- | `model` | `string \| ChatModel` | No | `'gpt-4o'` | OpenAI model to use for translation |
156
- | `systemPrompt` | `SystemPromptBuilder` | No | Built-in prompt | Custom function to build the system prompt |
157
- | `dryRun` | `boolean \| DryRunConfig` | No | `false` | Simulate translations without API calls |
158
- | `timeout` | `number` | No | SDK default (10 min) | Per-request timeout in ms. A job blocks on this call, so the 10-min SDK default is usually too long. _(Since v0.6.0.)_ |
159
- | `maxRetries` | `number` | No | SDK default (2) | Max automatic retries on transient errors (429/5xx/network). `0` disables. _(Since v0.6.0.)_ |
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). |
160
144
 
161
145
  ```typescript
162
- createOpenAIProvider({
163
- apiKey: process.env.OPENAI_API_KEY,
164
- model: "gpt-4o-mini",
165
- systemPrompt: ({ sourceLang, targetLang, defaultPrompt }) => `${defaultPrompt}\nUse formal language. Keep brand names unchanged.`,
166
- dryRun: false,
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" },
167
151
  });
168
152
  ```
169
153
 
170
- #### SystemPromptBuilder
154
+ ### Providers
171
155
 
172
- Function signature for custom system prompt:
156
+ #### OpenAI (built in) `createOpenAIProvider(config)`
173
157
 
174
- ```typescript
175
- type SystemPromptBuilder = (context: SystemPromptContext) => string;
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._ |
176
166
 
177
- type SystemPromptContext = {
178
- sourceLang: string;
179
- targetLang: string;
180
- defaultPrompt: string;
181
- };
167
+ ```typescript
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
+ });
182
173
  ```
183
174
 
184
- #### DryRunConfig
185
-
186
- When `dryRun` is an object, it allows custom transformation with optional delay:
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:
187
176
 
188
177
  ```typescript
189
- type DryRunTransformer = (text: string) => string | Promise<string>;
190
-
191
178
  type DryRunConfig = {
192
- transform: DryRunTransformer; // Custom transformer function
193
- timeout?: number; // Delay in ms (simulates API latency)
179
+ transform: (text: string) => string | Promise<string>;
180
+ timeout?: number; // ms, simulates API latency
194
181
  };
195
182
  ```
196
183
 
197
- ### PayloadJobsRunnerOptions
198
-
199
- Configuration for `createPayloadJobsRunner()`.
200
-
201
- | Property | Type | Required | Default | Description |
202
- | ----------- | -------------------------- | -------- | ---------------------------------- | ------------------------------------------------------- |
203
- | `taskName` | `string` | No | `'translate_document'` | Task name in Payload jobs collection |
204
- | `queueName` | `string` | No | `'translations'` | Queue name for grouping jobs |
205
- | `autoRun` | `false \| { cron, limit }` | No | `{ cron: '* * * * *', limit: 50 }` | Auto-run config, or `false` to disable (for serverless) |
206
-
207
- ```typescript
208
- createPayloadJobsRunner({
209
- taskName: "translate_document",
210
- queueName: "translations",
211
- autoRun: {
212
- cron: "* * * * *",
213
- limit: 50,
214
- },
215
- });
216
- ```
217
-
218
- ### FieldTranslationConfig
219
-
220
- Configuration for `withFieldTranslation()` helper or `field.custom.translateKit`.
184
+ #### Custom provider
221
185
 
222
- | Property | Type | Required | Default | Description |
223
- | --------- | --------- | -------- | ------- | ----------------------------------- |
224
- | `exclude` | `boolean` | No | `false` | Exclude this field from translation |
186
+ Implement the `TranslationProvider` interface a single `translate` method:
225
187
 
226
188
  ```typescript
227
- import { withFieldTranslation } from "@focus-reactive/payload-plugin-translator";
228
-
229
- withFieldTranslation({ name: "sku", type: "text", localized: true }, { exclude: true });
230
- ```
231
-
232
- ## Translation Strategies
233
-
234
- When translating, you can choose how to handle existing translations:
235
-
236
- | Strategy | Description |
237
- | ----------------- | ------------------------------------------------------------------------ |
238
- | `'overwrite'` | (Default) Replaces all existing translated content with new translations |
239
- | `'skip_existing'` | Only translates fields that are empty in the target locale |
240
-
241
- ## Important Notes
242
-
243
- ### Explicit `localized: true` for nested fields
189
+ import type { TranslationProvider, TranslationInput, TranslationOutput } from "@focus-reactive/payload-plugin-translator";
244
190
 
245
- 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) {}
246
196
 
247
- 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();
248
205
 
249
- ```typescript
250
- // Won't work — nested title is not explicitly localized
251
- {
252
- name: 'meta',
253
- type: 'group',
254
- localized: true,
255
- fields: [
256
- { name: 'title', type: 'text' }
257
- ]
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
+ }
258
215
  }
259
216
 
260
- // ✅ Correct — title is explicitly localized
261
- {
262
- name: 'meta',
263
- type: 'group',
264
- localized: true,
265
- fields: [
266
- { name: 'title', type: 'text', localized: true }
267
- ]
268
- }
217
+ translatorPlugin({
218
+ collections: [Posts],
219
+ translationProvider: new DeepLProvider(process.env.DEEPL_API_KEY),
220
+ runner: createPayloadJobsRunner(),
221
+ });
269
222
  ```
270
223
 
271
- ### Excluded fields behavior
272
-
273
- 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.
224
+ ### Runners
274
225
 
275
- This prevents validation errors when saving translation results (e.g., required fields that should not be translated but must have a value).
226
+ Document- and collection-level translation run through a **runner**.
276
227
 
277
- ### Field exclusion priority over strategy
228
+ #### `createPayloadJobsRunner(options)` (recommended)
278
229
 
279
- `withFieldTranslation` exclusion takes priority over the `overwrite` strategy. Even when using `overwrite` strategy, excluded fields will:
230
+ Background processing via Payload's job queue.
280
231
 
281
- - Keep their target value if it exists
282
- - Copy source value only if target is empty
283
- - Never be sent to the translation provider
284
-
285
- ### Preserving completed job status
286
-
287
- 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). |
288
237
 
289
238
  ```typescript
290
- export default buildConfig({
291
- // ... other config
292
- jobs: {
293
- deleteJobOnComplete: false,
294
- },
295
- });
239
+ createPayloadJobsRunner({ taskName: "translate_document", queueName: "translations", autoRun: { cron: "* * * * *", limit: 50 } });
296
240
  ```
297
241
 
298
- ## Task Runners
299
-
300
- ### PayloadJobsRunner (Recommended)
301
-
302
- Uses Payload's built-in job queue for background processing:
303
-
304
- ```typescript
305
- import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
306
-
307
- const runner = createPayloadJobsRunner({
308
- taskName: "translate_document",
309
- queueName: "translations",
310
- autoRun: {
311
- cron: "* * * * *",
312
- limit: 50,
313
- },
314
- });
315
- ```
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.
316
243
 
317
- ### SyncRunner
244
+ #### `createSyncRunner()`
318
245
 
319
- Executes translations synchronously (useful for development or small datasets):
246
+ Runs translations inline (no queue) — handy for development or small datasets.
320
247
 
321
248
  ```typescript
322
249
  import { createSyncRunner } from "@focus-reactive/payload-plugin-translator";
323
250
 
324
- const runner = createSyncRunner();
251
+ translatorPlugin({ collections: [Posts], translationProvider, runner: createSyncRunner() });
325
252
  ```
326
253
 
327
- ## Translation Providers
254
+ ### Field config — `withFieldTranslation(field, config?)`
328
255
 
329
- ### 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.
330
257
 
331
- Built-in provider using OpenAI's API:
258
+ | Property | Type | Required | Default | Description |
259
+ | --------- | --------- | -------- | ------- | ------------------------------------ |
260
+ | `exclude` | `boolean` | No | `false` | Exclude this field from translation. |
332
261
 
333
262
  ```typescript
334
- import { createOpenAIProvider } from "@focus-reactive/payload-plugin-translator";
263
+ import { withFieldTranslation } from "@focus-reactive/payload-plugin-translator";
335
264
 
336
- const provider = createOpenAIProvider({
337
- apiKey: process.env.OPENAI_API_KEY,
338
- model: "gpt-4o-mini",
339
- systemPrompt: ({ defaultPrompt }) => `${defaultPrompt}\nUse formal language.`,
340
- });
265
+ withFieldTranslation({ name: "title", type: "text", localized: true }); // adds the control
266
+ withFieldTranslation({ name: "sku", type: "text", localized: true }, { exclude: true }); // never translated
341
267
  ```
342
268
 
343
- ### Custom Provider
269
+ ### Strategies
344
270
 
345
- Create your own translation provider by implementing the `TranslationProvider` interface:
271
+ How existing target-locale content is treated when translating:
346
272
 
347
- #### 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. |
348
277
 
349
- | Method | Signature | Description |
350
- | ----------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
351
- | `translate` | `(content: TranslationInput, sourceLng: string, targetLng: string) => Promise<TranslationOutput \| null>` | Translates content from source to target language |
278
+ ## Notes & gotchas
352
279
 
353
- **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
354
281
 
355
- #### 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:
356
283
 
357
284
  ```typescript
358
- // Numeric index representing position in document structure
359
- type TranslationIndex = number;
360
-
361
- // Input: Map of numeric indices to text strings
362
- 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" }] }
363
287
 
364
- // Output: Same indices with translated values
365
- 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 }] }
366
290
  ```
367
291
 
368
- #### Example Implementation
369
-
370
- ```typescript
371
- import type { TranslationProvider, TranslationInput, TranslationOutput } from "@focus-reactive/payload-plugin-translator";
372
-
373
- class DeepLProvider implements TranslationProvider {
374
- constructor(private apiKey: string) {}
375
-
376
- async translate(content: TranslationInput, sourceLng: string, targetLng: string): Promise<TranslationOutput | null> {
377
- try {
378
- // content example: { "0": "Hello", "1": "World" }
379
- const texts = Object.values(content);
380
-
381
- const response = await fetch("https://api.deepl.com/v2/translate", {
382
- method: "POST",
383
- headers: {
384
- Authorization: `DeepL-Auth-Key ${this.apiKey}`,
385
- "Content-Type": "application/json",
386
- },
387
- body: JSON.stringify({
388
- text: texts,
389
- source_lang: sourceLng.toUpperCase(),
390
- target_lang: targetLng.toUpperCase(),
391
- }),
392
- });
393
-
394
- const data = await response.json();
395
-
396
- // Reconstruct the result with same keys
397
- const result: TranslationOutput = {};
398
- Object.keys(content).forEach((key, index) => {
399
- result[key] = data.translations[index].text;
400
- });
401
-
402
- return result;
403
- } catch {
404
- return null;
405
- }
406
- }
407
- }
408
-
409
- // Usage
410
- translatorPlugin({
411
- collections: [Posts],
412
- translationProvider: new DeepLProvider(process.env.DEEPL_API_KEY),
413
- runner: createPayloadJobsRunner(),
414
- });
415
- ```
292
+ ### Excluded fields are still backfilled
416
293
 
417
- ## UI Components
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.
418
295
 
419
- The plugin automatically adds:
296
+ ### Keeping completed-job status
420
297
 
421
- 1. **Document Translation Panel** Available in the document edit view sidebar
422
- 2. **Bulk Translation Dashboard** — Accessible from collection list view
423
- 3. **Translation Progress Indicator** — Shows real-time translation status
298
+ See the `deleteJobOnComplete: false` note under [Runners](#createpayloadjobsrunneroptions-recommended).
424
299
 
425
300
  ## TypeScript
426
301
 
427
- 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:
428
303
 
429
304
  ```typescript
430
305
  import type {
431
- // Plugin config
432
306
  TranslatorPluginConfig,
433
-
434
- // Provider types
435
307
  TranslationProvider,
436
308
  TranslationInput,
437
309
  TranslationOutput,
438
310
  OpenAIProviderConfig,
439
311
  DryRunConfig,
440
-
441
- // Runner types
442
312
  TaskRunnerProvider,
443
313
  PayloadJobsRunnerOptions,
444
-
445
- // Strategy types
446
- TranslationStrategy,
447
-
448
- // Access control
314
+ TranslationLevel,
315
+ FieldTranslationConfig,
449
316
  AccessGuard,
450
317
  AccessGuardRequest,
451
-
452
- // Field config
453
- FieldTranslationConfig,
454
-
455
- // Translation levels
456
- TranslationLevel,
457
318
  } from "@focus-reactive/payload-plugin-translator";
458
319
  ```
459
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
+
460
325
  ## Roadmap
461
326
 
462
- 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
463
332
 
464
- - **Field-level translation** — Granular translation control allowing users to translate individual fields rather than entire documents
465
- - **Global translation dashboard** — Translate all collections at once from a single interface, with progress tracking across the entire CMS
466
- - **Vercel Cron Jobs runner** — Built-in runner for seamless Vercel/serverless deployments without manual API route configuration
467
- - 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.",
5
5
  "type": "module",
6
6
  "license": "MIT",