@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 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
@@ -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/jsx-runtime";
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
- export { CustomButtonWithTranslator as default, CustomButtonWithTranslator };
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 };