@justanarthur/payload-plugin-translator 1.3.21 → 3.1.0
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 +150 -0
- package/dist/client.css +225 -0
- package/dist/client.d.ts +14 -2
- package/dist/client.js +149 -63
- package/dist/index.d.ts +11 -38
- package/dist/index.js +179 -1071
- package/dist/jobs.d.ts +1 -146
- package/dist/jobs.js +22 -817
- package/dist/resolvers/copy.d.ts +1 -3
- package/dist/resolvers/copy.js +6 -16
- package/dist/resolvers/google.d.ts +1 -7
- package/dist/resolvers/google.js +6 -57
- package/dist/resolvers/libreTranslate.d.ts +1 -11
- package/dist/resolvers/libreTranslate.js +6 -59
- package/dist/resolvers/openAI.d.ts +5 -13
- package/dist/resolvers/openAI.js +6 -128
- package/dist/resolvers/types.d.ts +1 -3
- package/dist/rsc.d.ts +5 -0
- package/dist/rsc.js +483 -0
- package/dist/shared/chunk-15n5q7wd.js +253 -0
- package/dist/shared/chunk-31qgv1zk.js +5 -0
- package/dist/shared/chunk-9d433kmv.js +359 -0
- package/dist/shared/chunk-e6c0qzkt.js +6 -0
- package/dist/shared/chunk-mvqymw6n.js +57 -0
- package/dist/shared/chunk-nxddacte.js +243 -0
- package/dist/shared/chunk-p03jz82h.js +623 -0
- package/dist/shared/chunk-zenq7s47.js +15 -0
- package/dist/shared/chunk-zs19h344.js +55 -0
- package/dist/types.d.ts +6 -36
- package/package.json +23 -11
- package/dist/styles-jt3fm5fh.scss +0 -5
- package/dist/styles-vs6m7h55.scss +0 -76
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# @justanarthur/payload-plugin-translator
|
|
2
|
+
|
|
3
|
+
[Payload CMS](https://payloadcms.com) plugin that keeps localized docs in sync. On every save of a
|
|
4
|
+
listed collection or global, it schedules background jobs that translate each field from the source
|
|
5
|
+
locale into every other declared locale, using a configurable resolver (Google Translate, OpenAI,
|
|
6
|
+
LibreTranslate, or your own).
|
|
7
|
+
|
|
8
|
+
Editor UX: the publish and save buttons in the affected collections/globals are swapped for a
|
|
9
|
+
custom variant that surfaces the job status. Locales without translations stay empty until the job
|
|
10
|
+
finishes — the plugin never blocks the editor's save.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add @justanarthur/payload-plugin-translator
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires `payload@^3.85.0` and `react@^19`. The plugin reads your `config.localization.locales` —
|
|
19
|
+
it short-circuits (returns the config unchanged) when there's only one locale or no localization.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// payload.config.ts
|
|
25
|
+
import { buildConfig } from 'payload'
|
|
26
|
+
import { translator, openAIResolver } from '@justanarthur/payload-plugin-translator'
|
|
27
|
+
|
|
28
|
+
export default buildConfig({
|
|
29
|
+
plugins: [
|
|
30
|
+
translator({
|
|
31
|
+
collections: ['pages', 'posts'],
|
|
32
|
+
globals: ['header', 'footer'],
|
|
33
|
+
resolvers: [
|
|
34
|
+
openAIResolver({ apiKey: process.env.OPENAI_API_KEY! })
|
|
35
|
+
]
|
|
36
|
+
})
|
|
37
|
+
]
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That's the whole integration. The first save of a Page doc schedules one translate job per non-source
|
|
42
|
+
locale; the job runs against your resolver and writes each translated field back into the
|
|
43
|
+
collection's `_locales` row.
|
|
44
|
+
|
|
45
|
+
## Built-in resolvers
|
|
46
|
+
|
|
47
|
+
| Resolver | Import | Env / config | Notes |
|
|
48
|
+
|--------------------|-------------------------------------------------|---------------------------------|------------------------------------------------------------------|
|
|
49
|
+
| `googleResolver` | `@justanarthur/payload-plugin-translator/resolvers/google` | `apiKey` (Google Cloud Translation API) | Cheap, good for bulk text. Locale codes are remapped where useful (e.g. `ua` → `uk`). |
|
|
50
|
+
| `openAIResolver` | `@justanarthur/payload-plugin-translator/resolvers/openAI` | `apiKey`, optional `model`/`baseUrl`/`prompt`/`chunkLength` | Best fidelity. Default model `gpt-4o-mini`. gpt-5.x uses `max_completion_tokens` + `reasoning_effort: 'low'` automatically. Slugs get a transliteration rule baked into the prompt. |
|
|
51
|
+
| `libreResolver` | `@justanarthur/payload-plugin-translator/resolvers/libreTranslate` | `apiKey`, optional `url`/`chunkLength` | Self-host friendly. Same locale remap as Google. |
|
|
52
|
+
| `copyResolver` | `@justanarthur/payload-plugin-translator/resolvers/copy` | none | Returns the source text verbatim. Useful as a fallback or for testing. |
|
|
53
|
+
|
|
54
|
+
You can supply multiple resolvers — they run in the order declared, and the first one to return
|
|
55
|
+
`success: true` wins for the chunk.
|
|
56
|
+
|
|
57
|
+
## Writing your own resolver
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import type { TranslateResolver } from '@justanarthur/payload-plugin-translator/resolvers/types'
|
|
61
|
+
|
|
62
|
+
export const myResolver: TranslateResolver = {
|
|
63
|
+
key: 'my-translator',
|
|
64
|
+
resolve: async ({ localeFrom, localeTo, texts, req }) => {
|
|
65
|
+
// call your translator of choice
|
|
66
|
+
return { success: true, translatedTexts: ['...'] }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`texts` is the flat string array extracted from the doc; you return the same-length array in
|
|
72
|
+
`translatedTexts`. Returning `{ success: false }` makes the job retry.
|
|
73
|
+
|
|
74
|
+
## Options reference
|
|
75
|
+
|
|
76
|
+
| Option | Type | Required | Notes |
|
|
77
|
+
|---------------|-----------------------|----------|------------------------------------------------------------------------|
|
|
78
|
+
| `collections` | `CollectionSlug[]` | yes | Collections to auto-translate. |
|
|
79
|
+
| `globals` | `GlobalSlug[]` | yes | Globals to auto-translate. |
|
|
80
|
+
| `resolvers` | `TranslateResolver[]` | yes | Tried in order; first to succeed wins per chunk. |
|
|
81
|
+
| `autoTranslate` | `boolean` | no | Default `true`. Set `false` to opt out of the auto-translate afterChange hook — useful when you drive translation manually via the `translateOperation` export. |
|
|
82
|
+
| `autoTranslateMode` | `'missing' \| 'all'` | no | Default `'missing'`: a publish in the default locale fills empty fields in other locales and keeps existing translations and slugs. `'all'` re-translates everything. |
|
|
83
|
+
| `review` | `boolean` | no | Default `true`. Adds the `/admin/translations` review view, its endpoints and the hidden `translation-status` collection (needs a migration on postgres). |
|
|
84
|
+
| `disabled` | `boolean` | no | Skip the plugin entirely (no overrides, no jobs). Useful in tests. |
|
|
85
|
+
| `_options.additionalTraverseRichText` | function | no | Hook to extend rich-text traversal — see below. |
|
|
86
|
+
|
|
87
|
+
### Rich-text traversal hook
|
|
88
|
+
|
|
89
|
+
The plugin walks Lexical richText fields itself, but custom Lexical nodes (e.g. blocks the host
|
|
90
|
+
defines for landing pages) won't be visited. `_options.additionalTraverseRichText` lets you register
|
|
91
|
+
a custom walker:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
translator({
|
|
95
|
+
// ...
|
|
96
|
+
_options: {
|
|
97
|
+
additionalTraverseRichText: ({ root, onText }) => {
|
|
98
|
+
// call onText(siblingData, attribute?) for every leaf string you find
|
|
99
|
+
// siblingData mutates the doc tree in-place
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The hook receives `onText` which mutates the data tree, plus the current node as `siblingData`.
|
|
106
|
+
It is called for every node without a `text` property, including `block` and `inlineBlock` nodes.
|
|
107
|
+
|
|
108
|
+
## How it works under the hood
|
|
109
|
+
|
|
110
|
+
- On plugin init: registers a `translate` Payload **task** and a `translate` **workflow**, swapped
|
|
111
|
+
Publish/Save buttons per collection/global, and an admin-only endpoint at `/api/translator/translate`.
|
|
112
|
+
- On `afterChange` (when `autoTranslate: true`): the plugin enqueues one workflow run per non-source
|
|
113
|
+
locale. The job extracts translatable fields, asks each resolver in turn, writes the result back
|
|
114
|
+
into the `_locales` row.
|
|
115
|
+
- The plugin de-duplicates via `AUTO_TRANSLATE_MARKER` — re-running it on an already-attached hook
|
|
116
|
+
is a no-op, so it's safe to wrap multiple plugins around the same collection.
|
|
117
|
+
|
|
118
|
+
## Reviewing translations
|
|
119
|
+
|
|
120
|
+
With `review` on, **Translations** appears in the admin nav (`/admin/translations`):
|
|
121
|
+
|
|
122
|
+
- **Overview**: one row per document (paged, newest first) or global, one column per target locale.
|
|
123
|
+
Each cell shows the share of translatable fields that have a translation. `404` means the locale
|
|
124
|
+
has no slug, `↻` means the source changed since the last translation, `✓` means someone reviewed
|
|
125
|
+
that locale against the current source.
|
|
126
|
+
- **Detail** (click a cell): every translatable field with the source and target text side by side,
|
|
127
|
+
marked `missing`, `same as source` (likely never translated) or `placeholders differ`, plus the
|
|
128
|
+
last auto-translate job and its error. Actions: *Translate missing fields*, *Re-translate all*,
|
|
129
|
+
*Mark reviewed*.
|
|
130
|
+
|
|
131
|
+
The view only lists collections and globals the signed-in user can read, and the endpoints behind
|
|
132
|
+
the actions require a user.
|
|
133
|
+
|
|
134
|
+
## Manual translation
|
|
135
|
+
|
|
136
|
+
Two escape hatches when you don't want the auto-hook:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// the operation (Payload `operation` you can call from custom endpoints)
|
|
140
|
+
import { translateOperation } from '@justanarthur/payload-plugin-translator'
|
|
141
|
+
|
|
142
|
+
// the job factories (advanced — you usually don't need these directly)
|
|
143
|
+
import { createTranslateTask, createTranslateWorkflow } from '@justanarthur/payload-plugin-translator/jobs'
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
For most hosts the auto-hook + resolver list is enough.
|
|
147
|
+
|
|
148
|
+
## Licence
|
|
149
|
+
|
|
150
|
+
MIT
|
package/dist/client.css
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/* src/client/components/CustomButton/styles.css */
|
|
2
|
+
.translator__custom-save-button {
|
|
3
|
+
display: inline-flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: .5rem;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* src/client/components/TranslatorModal/styles.css */
|
|
9
|
+
.translator__modal {
|
|
10
|
+
position: relative;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.translator__wrapper {
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
gap: 1rem;
|
|
17
|
+
padding: 1rem;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.translator__close {
|
|
21
|
+
position: absolute;
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
background: none;
|
|
24
|
+
border: 0;
|
|
25
|
+
font-size: 1.25rem;
|
|
26
|
+
line-height: 1;
|
|
27
|
+
top: .5rem;
|
|
28
|
+
right: .5rem;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.translator__content {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
gap: .75rem;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.translator__buttons {
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-wrap: wrap;
|
|
40
|
+
gap: .5rem;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* src/client/components/Review/styles.css */
|
|
44
|
+
.translator-review {
|
|
45
|
+
padding-bottom: calc(var(--base) * 3);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.translator-review__header {
|
|
49
|
+
display: flex;
|
|
50
|
+
justify-content: space-between;
|
|
51
|
+
align-items: baseline;
|
|
52
|
+
gap: var(--base);
|
|
53
|
+
margin: calc(var(--base) * 1.5) 0;
|
|
54
|
+
flex-wrap: wrap;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.translator-review__header h1 {
|
|
58
|
+
margin: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.translator-review__tabs {
|
|
62
|
+
display: flex;
|
|
63
|
+
gap: calc(var(--base) / 2);
|
|
64
|
+
margin-bottom: var(--base);
|
|
65
|
+
flex-wrap: wrap;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.translator-review__tab {
|
|
69
|
+
border: 1px solid var(--theme-elevation-150);
|
|
70
|
+
color: var(--theme-elevation-800);
|
|
71
|
+
text-decoration: none;
|
|
72
|
+
border-radius: 999px;
|
|
73
|
+
padding: 4px 12px;
|
|
74
|
+
font-size: 13px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.translator-review__tab[aria-current="true"] {
|
|
78
|
+
background: var(--theme-elevation-900);
|
|
79
|
+
border-color: var(--theme-elevation-900);
|
|
80
|
+
color: var(--theme-elevation-0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.translator-review__scroll {
|
|
84
|
+
overflow-x: auto;
|
|
85
|
+
border: 1px solid var(--theme-elevation-100);
|
|
86
|
+
border-radius: var(--style-radius-m);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.translator-review table {
|
|
90
|
+
border-collapse: collapse;
|
|
91
|
+
width: 100%;
|
|
92
|
+
font-size: 13px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.translator-review th, .translator-review td {
|
|
96
|
+
border-bottom: 1px solid var(--theme-elevation-100);
|
|
97
|
+
text-align: left;
|
|
98
|
+
vertical-align: top;
|
|
99
|
+
padding: 8px 10px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.translator-review th {
|
|
103
|
+
background: var(--theme-elevation-50);
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.translator-review tr:last-child td {
|
|
109
|
+
border-bottom: 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.translator-review__doc {
|
|
113
|
+
min-width: 220px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.translator-review__doc small {
|
|
117
|
+
display: block;
|
|
118
|
+
color: var(--theme-elevation-500);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.translator-review__cell {
|
|
122
|
+
display: inline-flex;
|
|
123
|
+
font-variant-numeric: tabular-nums;
|
|
124
|
+
text-decoration: none;
|
|
125
|
+
color: var(--theme-elevation-1000);
|
|
126
|
+
border: 1px solid #0000;
|
|
127
|
+
border-radius: 999px;
|
|
128
|
+
align-items: center;
|
|
129
|
+
gap: 4px;
|
|
130
|
+
min-width: 64px;
|
|
131
|
+
padding: 2px 8px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.translator-review__cell--ok {
|
|
135
|
+
background: var(--theme-success-100);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.translator-review__cell--partial {
|
|
139
|
+
background: var(--theme-warning-100);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.translator-review__cell--missing {
|
|
143
|
+
background: var(--theme-error-100);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.translator-review__cell--stale {
|
|
147
|
+
border-color: var(--theme-warning-500);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.translator-review__legend {
|
|
151
|
+
display: flex;
|
|
152
|
+
gap: var(--base);
|
|
153
|
+
color: var(--theme-elevation-600);
|
|
154
|
+
margin-top: calc(var(--base) / 2);
|
|
155
|
+
flex-wrap: wrap;
|
|
156
|
+
font-size: 12px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.translator-review__pager {
|
|
160
|
+
display: flex;
|
|
161
|
+
gap: var(--base);
|
|
162
|
+
margin-top: var(--base);
|
|
163
|
+
align-items: center;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.translator-review__state {
|
|
167
|
+
white-space: nowrap;
|
|
168
|
+
border-radius: 999px;
|
|
169
|
+
padding: 1px 8px;
|
|
170
|
+
font-size: 12px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.translator-review__state--ok {
|
|
174
|
+
background: var(--theme-success-100);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.translator-review__state--missing {
|
|
178
|
+
background: var(--theme-error-100);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.translator-review__state--identical, .translator-review__state--placeholders {
|
|
182
|
+
background: var(--theme-warning-100);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.translator-review__text {
|
|
186
|
+
white-space: pre-wrap;
|
|
187
|
+
word-break: break-word;
|
|
188
|
+
max-width: 420px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.translator-review__text--empty {
|
|
192
|
+
color: var(--theme-elevation-400);
|
|
193
|
+
font-style: italic;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.translator-review__path {
|
|
197
|
+
font-family: var(--font-mono, monospace);
|
|
198
|
+
color: var(--theme-elevation-600);
|
|
199
|
+
word-break: break-all;
|
|
200
|
+
max-width: 240px;
|
|
201
|
+
font-size: 12px;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.translator-review__meta {
|
|
205
|
+
display: flex;
|
|
206
|
+
gap: calc(var(--base) * 1.5);
|
|
207
|
+
color: var(--theme-elevation-600);
|
|
208
|
+
margin-bottom: var(--base);
|
|
209
|
+
flex-wrap: wrap;
|
|
210
|
+
font-size: 13px;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.translator-review__actions {
|
|
214
|
+
display: flex;
|
|
215
|
+
gap: calc(var(--base) / 2);
|
|
216
|
+
flex-wrap: wrap;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.translator-review__actions .btn {
|
|
220
|
+
margin: 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.translator-review__error {
|
|
224
|
+
color: var(--theme-error-500);
|
|
225
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
import { JSX as JSX_1kxb } from "react
|
|
1
|
+
import { JSX as JSX_1kxb } from "react";
|
|
2
2
|
type Element_12xgc = JSX_1kxb["Element"];
|
|
3
3
|
declare const CustomButtonWithTranslator: ({ type }: {
|
|
4
4
|
type: "publish" | "save";
|
|
5
5
|
}) => Element_12xgc;
|
|
6
|
-
|
|
6
|
+
import { JSX as JSX_1kxb2 } from "react";
|
|
7
|
+
type Element_12xgc2 = JSX_1kxb2["Element"];
|
|
8
|
+
declare const ReviewActions: ({ entity, locale, reviewed }: {
|
|
9
|
+
entity: string;
|
|
10
|
+
locale: string;
|
|
11
|
+
reviewed: boolean;
|
|
12
|
+
}) => Element_12xgc2;
|
|
13
|
+
/** loads the review view styles; the view itself is a server component */
|
|
14
|
+
declare const ReviewStyles: () => null;
|
|
15
|
+
import { JSX as JSX_1kxb3 } from "react";
|
|
16
|
+
type Element_12xgc3 = JSX_1kxb3["Element"];
|
|
17
|
+
declare const TranslationsNavLink: () => Element_12xgc3;
|
|
18
|
+
export { CustomButtonWithTranslator, ReviewActions, ReviewStyles, TranslationsNavLink, CustomButtonWithTranslator as default };
|