@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.
- package/README.md +172 -306
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,33 +1,44 @@
|
|
|
1
1
|
# @focus-reactive/payload-plugin-translator
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@focus-reactive/payload-plugin-translator)
|
|
4
|
+
[](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** —
|
|
8
|
-
- **Rich text
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **Field
|
|
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
|
-
##
|
|
24
|
+
## Requirements
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
pnpm add @focus-reactive/payload-plugin-translator
|
|
35
|
+
## Installation
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
`levels` controls which translation surfaces the plugin exposes. Each entry is a factory you import and list:
|
|
91
75
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
82
|
+
Omit `levels` for the default `[documentLevel(), collectionLevel()]` — adopting the option is non-breaking.
|
|
97
83
|
|
|
98
84
|
```typescript
|
|
99
|
-
import { translatorPlugin,
|
|
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
|
-
|
|
95
|
+
The document and collection levels show a real-time **progress indicator** while jobs run.
|
|
112
96
|
|
|
113
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
>
|
|
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
|
-
|
|
132
|
+
## Configuration
|
|
149
133
|
|
|
150
|
-
|
|
134
|
+
### `translatorPlugin(config)`
|
|
151
135
|
|
|
152
|
-
| Property
|
|
153
|
-
|
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
158
|
-
| `
|
|
159
|
-
| `
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
154
|
+
### Providers
|
|
171
155
|
|
|
172
|
-
|
|
156
|
+
#### OpenAI (built in) — `createOpenAIProvider(config)`
|
|
173
157
|
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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:
|
|
193
|
-
timeout?: number; //
|
|
179
|
+
transform: (text: string) => string | Promise<string>;
|
|
180
|
+
timeout?: number; // ms, simulates API latency
|
|
194
181
|
};
|
|
195
182
|
```
|
|
196
183
|
|
|
197
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
226
|
+
Document- and collection-level translation run through a **runner**.
|
|
276
227
|
|
|
277
|
-
|
|
228
|
+
#### `createPayloadJobsRunner(options)` (recommended)
|
|
278
229
|
|
|
279
|
-
|
|
230
|
+
Background processing via Payload's job queue.
|
|
280
231
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
+
#### `createSyncRunner()`
|
|
318
245
|
|
|
319
|
-
|
|
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
|
-
|
|
251
|
+
translatorPlugin({ collections: [Posts], translationProvider, runner: createSyncRunner() });
|
|
325
252
|
```
|
|
326
253
|
|
|
327
|
-
|
|
254
|
+
### Field config — `withFieldTranslation(field, config?)`
|
|
328
255
|
|
|
329
|
-
|
|
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
|
-
|
|
258
|
+
| Property | Type | Required | Default | Description |
|
|
259
|
+
| --------- | --------- | -------- | ------- | ------------------------------------ |
|
|
260
|
+
| `exclude` | `boolean` | No | `false` | Exclude this field from translation. |
|
|
332
261
|
|
|
333
262
|
```typescript
|
|
334
|
-
import {
|
|
263
|
+
import { withFieldTranslation } from "@focus-reactive/payload-plugin-translator";
|
|
335
264
|
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
###
|
|
269
|
+
### Strategies
|
|
344
270
|
|
|
345
|
-
|
|
271
|
+
How existing target-locale content is treated when translating:
|
|
346
272
|
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
+
### Mark nested fields `localized: true` explicitly
|
|
354
281
|
|
|
355
|
-
|
|
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
|
-
//
|
|
359
|
-
type
|
|
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
|
-
//
|
|
365
|
-
type
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
+
### Keeping completed-job status
|
|
420
297
|
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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.
|
|
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",
|