@justanarthur/payload-plugin-translator 3.0.3 → 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 CHANGED
@@ -79,6 +79,8 @@ export const myResolver: TranslateResolver = {
79
79
  | `globals` | `GlobalSlug[]` | yes | Globals to auto-translate. |
80
80
  | `resolvers` | `TranslateResolver[]` | yes | Tried in order; first to succeed wins per chunk. |
81
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). |
82
84
  | `disabled` | `boolean` | no | Skip the plugin entirely (no overrides, no jobs). Useful in tests. |
83
85
  | `_options.additionalTraverseRichText` | function | no | Hook to extend rich-text traversal — see below. |
84
86
 
@@ -100,8 +102,8 @@ translator({
100
102
  })
101
103
  ```
102
104
 
103
- The hook receives `onText` which mutates the data tree, plus the root node. It's called for every
104
- richText field before translation begins.
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.
105
107
 
106
108
  ## How it works under the hood
107
109
 
@@ -113,6 +115,22 @@ richText field before translation begins.
113
115
  - The plugin de-duplicates via `AUTO_TRANSLATE_MARKER` — re-running it on an already-attached hook
114
116
  is a no-op, so it's safe to wrap multiple plugins around the same collection.
115
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
+
116
134
  ## Manual translation
117
135
 
118
136
  Two escape hatches when you don't want the auto-hook:
package/dist/client.css CHANGED
@@ -39,3 +39,187 @@
39
39
  flex-wrap: wrap;
40
40
  gap: .5rem;
41
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 };
package/dist/client.js CHANGED
@@ -1,4 +1,8 @@
1
1
  "use client";
2
+ import {
3
+ REVIEW_VIEW_PATH
4
+ } from "./shared/chunk-31qgv1zk.js";
5
+
2
6
  // src/client/components/CustomButton/CustomButtonWithTranslator.tsx
3
7
  import { PublishButton, SaveButton, useConfig as useConfig2, useDocumentInfo as useDocumentInfo2 } from "@payloadcms/ui";
4
8
 
@@ -85,19 +89,18 @@ var TranslatorProvider = ({ children }) => {
85
89
  const resolvers = custom?.translator?.resolvers || undefined;
86
90
  if (!resolvers)
87
91
  return null;
88
- const resolverConfig2 = resolvers.find((each) => each.key === resolver);
89
- return resolverConfig2 ?? null;
92
+ const resolverConfig = resolvers.find((each) => each.key === resolver);
93
+ return resolverConfig ?? null;
90
94
  }, [custom, resolver]);
91
95
  if (!localization)
92
96
  throw new Error("Localization config is not provided and PluginTranslator is used");
93
97
  const localesOptions = localization.locales.filter((each) => each.code !== locale.code);
94
98
  const [localeToTranslateFrom, setLocaleToTranslateFrom] = useState("");
95
99
  useEffect(() => {
96
- const defaultFromOptions = localesOptions.find((each) => localization.defaultLocale === each.code);
97
- if (defaultFromOptions)
98
- setLocaleToTranslateFrom(defaultFromOptions.code);
99
- setLocaleToTranslateFrom(localesOptions[0].code);
100
- }, [locale, localesOptions, localization.defaultLocale]);
100
+ const options = localization.locales.filter((each) => each.code !== locale.code);
101
+ const defaultFromOptions = options.find((each) => localization.defaultLocale === each.code);
102
+ setLocaleToTranslateFrom((defaultFromOptions ?? options[0])?.code ?? "");
103
+ }, [locale.code, localization.defaultLocale, localization.locales]);
101
104
  const closeTranslator = () => modal.closeModal(modalSlug);
102
105
  const submit = async ({ emptyOnly }) => {
103
106
  if (!resolver)
@@ -269,12 +272,12 @@ var Content = () => {
269
272
  // src/client/components/TranslatorModal/TranslatorModal.tsx
270
273
  import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
271
274
  var TranslatorModal = () => {
272
- const { closeTranslator, modalSlug: modalSlug2, resolver } = useTranslator();
275
+ const { closeTranslator, modalSlug, resolver } = useTranslator();
273
276
  if (!resolver)
274
277
  return;
275
278
  return /* @__PURE__ */ jsx5(Modal, {
276
279
  className: "translator__modal",
277
- slug: modalSlug2,
280
+ slug: modalSlug,
278
281
  children: /* @__PURE__ */ jsxs3("div", {
279
282
  className: "translator__wrapper",
280
283
  children: [
@@ -312,9 +315,92 @@ var CustomButtonWithTranslator = ({ type }) => {
312
315
  });
313
316
  };
314
317
 
318
+ // src/client/components/Review/ReviewActions.tsx
319
+ import { Button as Button3, toast as toast2, useConfig as useConfig3 } from "@payloadcms/ui";
320
+ import { useState as useState2 } from "react";
321
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
322
+
323
+ var ReviewActions = ({ entity, locale, reviewed }) => {
324
+ const { config: { routes: { api }, serverURL } } = useConfig3();
325
+ const [pending, setPending] = useState2(null);
326
+ const run = async (action) => {
327
+ if (action === "translate-all" && !window.confirm(`Re-translate every field in ${locale}? Existing ${locale} copy is replaced.`))
328
+ return;
329
+ setPending(action);
330
+ try {
331
+ const path = action === "mark-reviewed" ? "mark-reviewed" : "translate";
332
+ const res = await fetch(`${serverURL ?? ""}${api}/translator/review/${path}`, {
333
+ method: "POST",
334
+ credentials: "include",
335
+ headers: { "Content-Type": "application/json" },
336
+ body: JSON.stringify({ entity, locale, mode: action === "translate-all" ? "all" : "missing" })
337
+ });
338
+ const result = await res.json().catch(() => ({}));
339
+ if (!res.ok || !result.success)
340
+ throw new Error(result?.errors?.[0]?.message ?? "request failed");
341
+ toast2.success(action === "mark-reviewed" ? `${locale} marked as reviewed` : `${locale} translated`);
342
+ window.location.reload();
343
+ } catch (error) {
344
+ toast2.error(`Could not ${action.replace("-", " ")}: ${error instanceof Error ? error.message : String(error)}`);
345
+ } finally {
346
+ setPending(null);
347
+ }
348
+ };
349
+ return /* @__PURE__ */ jsxs5("div", {
350
+ className: "translator-review__actions",
351
+ children: [
352
+ /* @__PURE__ */ jsx7(Button3, {
353
+ buttonStyle: "primary",
354
+ disabled: Boolean(pending),
355
+ onClick: () => run("translate-missing"),
356
+ size: "small",
357
+ children: pending === "translate-missing" ? "Translating…" : "Translate missing fields"
358
+ }),
359
+ /* @__PURE__ */ jsx7(Button3, {
360
+ buttonStyle: "secondary",
361
+ disabled: Boolean(pending),
362
+ onClick: () => run("translate-all"),
363
+ size: "small",
364
+ children: pending === "translate-all" ? "Translating…" : "Re-translate all"
365
+ }),
366
+ /* @__PURE__ */ jsx7(Button3, {
367
+ buttonStyle: "secondary",
368
+ disabled: Boolean(pending) || reviewed,
369
+ onClick: () => run("mark-reviewed"),
370
+ size: "small",
371
+ children: reviewed ? "Reviewed" : "Mark reviewed"
372
+ })
373
+ ]
374
+ });
375
+ };
376
+ // src/client/components/Review/ReviewStyles.tsx
377
+
378
+ var ReviewStyles = () => null;
379
+ // src/client/components/Review/TranslationsNavLink.tsx
380
+ import { useConfig as useConfig4 } from "@payloadcms/ui";
381
+ import { formatAdminURL } from "payload/shared";
382
+ import { jsx as jsx8 } from "react/jsx-runtime";
383
+
384
+ var TranslationsNavLink = () => {
385
+ const { config: { routes: { admin } } } = useConfig4();
386
+ const href = formatAdminURL({ adminRoute: admin, path: REVIEW_VIEW_PATH });
387
+ return /* @__PURE__ */ jsx8("a", {
388
+ className: "nav__link",
389
+ href,
390
+ id: "nav-translations",
391
+ children: /* @__PURE__ */ jsx8("span", {
392
+ className: "nav__link-label",
393
+ children: "Translations"
394
+ })
395
+ });
396
+ };
397
+
315
398
  // src/exports/client.ts
316
399
  var client_default = CustomButtonWithTranslator;
317
400
  export {
318
- client_default as default,
319
- CustomButtonWithTranslator
401
+ CustomButtonWithTranslator,
402
+ ReviewActions,
403
+ ReviewStyles,
404
+ TranslationsNavLink,
405
+ client_default as default
320
406
  };
package/dist/index.d.ts CHANGED
@@ -51,6 +51,14 @@ type TranslatorConfig = {
51
51
  globals: GlobalSlug[];
52
52
  resolvers: TranslateResolver[];
53
53
  autoTranslate?: boolean;
54
+ /**
55
+ * what a publish in the default locale does to the other locales.
56
+ * `missing` (default) fills empty fields and keeps existing translations and slugs;
57
+ * `all` re-translates every field.
58
+ */
59
+ autoTranslateMode?: "all" | "missing";
60
+ /** adds the /admin/translations review view, its endpoints and the status collection (default true) */
61
+ review?: boolean;
54
62
  _options?: {
55
63
  additionalTraverseRichText?: (args: {
56
64
  onText: (siblingData: Record<string, unknown>, attribute?: string) => void;
@@ -60,4 +68,8 @@ type TranslatorConfig = {
60
68
  };
61
69
  };
62
70
  declare const translator: (pluginConfig: TranslatorConfig) => Plugin;
63
- export { translator, translateOperation, translator as default, TranslateResolverResponse, TranslateResolverArgs, TranslateResolver };
71
+ import { CollectionConfig } from "payload";
72
+ /** one row per entity and target locale: what source it was translated from, and who reviewed it */
73
+ declare const createTranslationStatusCollection: () => CollectionConfig;
74
+ declare const TRANSLATION_STATUS_SLUG = "translation-status";
75
+ export { TRANSLATION_STATUS_SLUG, TranslateResolver, TranslateResolverArgs, TranslateResolverResponse, createTranslationStatusCollection, translator as default, translateOperation, translator };