@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.
- package/README.md +191 -287
- package/dist/client/entities/translation/api/mutations/useTranslateField.d.ts +22 -0
- package/dist/client/entities/translation/api/mutations/useTranslateField.js +31 -0
- package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.d.ts +3 -3
- package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.js +15 -13
- package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.d.ts +2 -2
- package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.js +10 -10
- package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.d.ts +1 -1
- package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.js +9 -9
- package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.d.ts +2 -2
- package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.js +10 -8
- package/dist/client/shared/lib/assets/icons/SendIcon.d.ts +1 -0
- package/dist/client/shared/lib/assets/icons/SendIcon.js +17 -0
- package/dist/client/shared/ui/Button/Button.d.ts +2 -1
- package/dist/client/shared/ui/Button/Button.js +1 -1
- package/dist/client/shared/ui/Button/styles.module.scss +26 -3
- package/dist/client/widgets/translate-field-control/index.d.ts +1 -0
- package/dist/client/widgets/translate-field-control/index.js +3 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.d.ts +26 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.export.d.ts +13 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.export.js +14 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.js +204 -0
- package/dist/client/widgets/translate-field-control/ui/styles.module.scss +48 -0
- package/dist/field-actions.d.ts +25 -0
- package/dist/field-actions.js +23 -0
- package/dist/field-config.d.ts +26 -26
- package/dist/field-config.js +14 -37
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +3 -1
- package/dist/server/features/translate-field/handler.d.ts +17 -0
- package/dist/server/features/translate-field/handler.js +85 -0
- package/dist/server/features/translate-field/index.d.ts +6 -0
- package/dist/server/features/translate-field/index.js +5 -0
- package/dist/server/features/translate-field/model.d.ts +62 -0
- package/dist/server/features/translate-field/model.js +26 -0
- package/dist/server/features/translate-field/resolveFieldSubtree.d.ts +15 -7
- package/dist/server/features/translate-field/resolveFieldSubtree.js +14 -8
- package/dist/server/features/translate-field/route.d.ts +12 -0
- package/dist/server/features/translate-field/route.js +18 -0
- package/dist/server/modules/translation-levels/PluginConfigBuilder.d.ts +6 -0
- package/dist/server/modules/translation-levels/PluginConfigBuilder.js +4 -0
- package/dist/server/modules/translation-levels/fieldLevel.d.ts +23 -0
- package/dist/server/modules/translation-levels/fieldLevel.js +37 -0
- package/dist/server/modules/translation-levels/index.d.ts +1 -0
- package/dist/server/modules/translation-levels/index.js +1 -0
- package/dist/server/modules/translation-levels/types.d.ts +6 -1
- package/dist/server/modules/translation-levels/types.js +0 -1
- package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.js +10 -4
- package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.stage.d.ts +1 -1
- package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.stage.js +1 -1
- package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.d.ts +5 -0
- package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.js +13 -4
- package/dist/server/modules/translation-providers/OpenAITranslation.provider.d.ts +17 -0
- package/dist/server/modules/translation-providers/OpenAITranslation.provider.js +8 -2
- package/dist/server/shared/field-config/types.js +1 -1
- package/dist/server/shared/field-traversal/findFieldByPath.d.ts +25 -10
- package/dist/server/shared/field-traversal/findFieldByPath.js +59 -14
- package/dist/server/shared/field-traversal/index.d.ts +1 -1
- package/dist/server/shared/field-traversal/index.js +1 -1
- package/dist/server/shared/field-traversal/kernel.d.ts +22 -0
- package/dist/server/shared/field-traversal/kernel.js +27 -0
- package/package.json +2 -2
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
+
Omit `levels` for the default `[documentLevel(), collectionLevel()]` — adopting the option is non-breaking.
|
|
96
83
|
|
|
97
84
|
```typescript
|
|
98
|
-
import { translatorPlugin,
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
### OpenAIProviderConfig
|
|
95
|
+
The document and collection levels show a real-time **progress indicator** while jobs run.
|
|
113
96
|
|
|
114
|
-
|
|
97
|
+
### Field-level translation
|
|
115
98
|
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
import { translatorPlugin, documentLevel, fieldLevel, withFieldTranslation, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
|
|
138
108
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
+
## Configuration
|
|
160
133
|
|
|
161
|
-
|
|
134
|
+
### `translatorPlugin(config)`
|
|
162
135
|
|
|
163
|
-
| Property
|
|
164
|
-
|
|
|
165
|
-
| `
|
|
166
|
-
| `
|
|
167
|
-
| `
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
###
|
|
154
|
+
### Providers
|
|
181
155
|
|
|
182
|
-
|
|
156
|
+
#### OpenAI (built in) — `createOpenAIProvider(config)`
|
|
183
157
|
|
|
184
|
-
| Property
|
|
185
|
-
|
|
|
186
|
-
| `
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
+
```typescript
|
|
178
|
+
type DryRunConfig = {
|
|
179
|
+
transform: (text: string) => string | Promise<string>;
|
|
180
|
+
timeout?: number; // ms, simulates API latency
|
|
181
|
+
};
|
|
182
|
+
```
|
|
197
183
|
|
|
198
|
-
|
|
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
|
-
|
|
186
|
+
Implement the `TranslationProvider` interface — a single `translate` method:
|
|
204
187
|
|
|
205
|
-
|
|
188
|
+
```typescript
|
|
189
|
+
import type { TranslationProvider, TranslationInput, TranslationOutput } from "@focus-reactive/payload-plugin-translator";
|
|
206
190
|
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
226
|
+
Document- and collection-level translation run through a **runner**.
|
|
240
227
|
|
|
241
|
-
|
|
228
|
+
#### `createPayloadJobsRunner(options)` (recommended)
|
|
242
229
|
|
|
243
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
+
#### `createSyncRunner()`
|
|
263
245
|
|
|
264
|
-
|
|
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
|
-
|
|
251
|
+
translatorPlugin({ collections: [Posts], translationProvider, runner: createSyncRunner() });
|
|
287
252
|
```
|
|
288
253
|
|
|
289
|
-
|
|
254
|
+
### Field config — `withFieldTranslation(field, config?)`
|
|
290
255
|
|
|
291
|
-
|
|
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
|
-
|
|
258
|
+
| Property | Type | Required | Default | Description |
|
|
259
|
+
| --------- | --------- | -------- | ------- | ------------------------------------ |
|
|
260
|
+
| `exclude` | `boolean` | No | `false` | Exclude this field from translation. |
|
|
294
261
|
|
|
295
262
|
```typescript
|
|
296
|
-
import {
|
|
263
|
+
import { withFieldTranslation } from "@focus-reactive/payload-plugin-translator";
|
|
297
264
|
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
###
|
|
269
|
+
### Strategies
|
|
306
270
|
|
|
307
|
-
|
|
271
|
+
How existing target-locale content is treated when translating:
|
|
308
272
|
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
+
### Mark nested fields `localized: true` explicitly
|
|
316
281
|
|
|
317
|
-
|
|
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
|
-
//
|
|
321
|
-
type
|
|
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
|
-
//
|
|
327
|
-
type
|
|
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
|
-
|
|
292
|
+
### Excluded fields are still backfilled
|
|
331
293
|
|
|
332
|
-
|
|
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
|
-
|
|
336
|
-
constructor(private apiKey: string) {}
|
|
296
|
+
### Keeping completed-job status
|
|
337
297
|
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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.
|