@captiveorigins/payload-plugin-polyglot 0.1.0 → 0.2.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
@@ -15,11 +15,22 @@ have, a little at a time.
15
15
  - **Never wastes calls on placeholders.** Lorem ipsum (and friends) is never sent.
16
16
  - **Respects people.** Languages marked "Reviewed by a human" are never overwritten; when the
17
17
  source changes they are flagged as stale instead.
18
- - **Free tiers welcome.** Microsoft Translator (free tier, about 2 million characters a month)
19
- built in; Claude built in (with the half-price Batch API for sweeps); bring your own engine.
18
+ - **Cheap or free engines.** Claude built in (Haiku by default: in our test a 24,000-character
19
+ site into 21 languages cost $0.30, about half that through the Batch API, with quality close
20
+ to Opus); Microsoft Translator (free tier, about 2 million characters a month) built in; bring
21
+ your own engine.
20
22
  A per-run character cap and clean stops on a used-up quota keep you inside free tiers: the
21
23
  next run continues where the last stopped.
22
24
  - **Do-not-translate list** for product names, brands and scientific names.
25
+ - **Translation settings in the admin** (no deploy): pause automatic translation, languages not
26
+ machine-translated, extra never-translate terms, notes for the translator (tone, audience), a
27
+ glossary of preferred translations per language, a per-sweep character cap, and characters
28
+ sent per month. The Translations view shows coverage per collection and language, with
29
+ Translate now.
30
+ - **Tells admins when it stops.** Out of credits, a free quota used up or a refused key stops
31
+ translation cleanly (nothing is lost) and shows why on the admin dashboard, as ⚠ next to
32
+ Translations in the menu, and on the Translations view; `onStop` runs once (e.g. to email
33
+ admins) and everything clears with the next successful translation.
23
34
  - **Engine per language** (`translatorFor`), e.g. one engine for European languages and another
24
35
  for the rest.
25
36
 
@@ -55,7 +66,7 @@ Run the sweep from a cron job or script:
55
66
  ```ts
56
67
  import { syncAll } from '@captiveorigins/payload-plugin-polyglot'
57
68
 
58
- const report = await syncAll(payload) // { translated, pending, stopped: 'done' | 'budget' | 'quota', … }
69
+ const report = await syncAll(payload) // { translated, pending, stopped: 'done' | 'budget' | 'quota' | 'paused', … }
59
70
  ```
60
71
 
61
72
  Claude instead of Microsoft:
@@ -64,9 +75,15 @@ Claude instead of Microsoft:
64
75
  import { claudeTranslator } from '@captiveorigins/payload-plugin-polyglot/claude'
65
76
 
66
77
  translator: claudeTranslator({ instructions: 'A website for reptile keepers.' }) // ANTHROPIC_API_KEY
78
+ // Default model claude-haiku-5-5; pass { model: 'claude-sonnet-5-5' } for more care.
67
79
  // syncAll(payload, { useBatch: true }) uses the Message Batches API (half price)
68
80
  ```
69
81
 
82
+ Notes and glossary from Translation settings reach AI engines (Claude); Microsoft ignores them.
83
+ Engines we tried and did not build in: MyMemory (no key, but about 50,000 characters a day),
84
+ Firefox's Bergamot models and MADLAD-400 (free and local, but noticeably weaker, and MADLAD
85
+ repeats sentences), LibreTranslate's public server (now needs a key).
86
+
70
87
  Show visitors what they are reading: `languageStatus(payload, collection, doc, locale)` returns
71
88
  `source`, `machine`, `reviewed`, `stale` or `pending`.
72
89
 
@@ -88,6 +105,10 @@ Show visitors what they are reading: `languageStatus(payload, collection, doc, l
88
105
  | `statusField` | `translations` | Per-document "Reviewed by a human" languages |
89
106
  | `writeContext` | | Context for writes (e.g. `{ disableRevalidate: true }` in scripts) |
90
107
  | `onPublish` | `true` | Translate after publishing |
108
+ | `onStop` | | Called once when translation stops (`{ payload, stop: { reason, message, at } }`), e.g. to email admins |
109
+ | `canManage` | signed-in users | Who sees the Translations view and can Translate now |
110
+ | `canConfigure` | `canManage` | Who may change Translation settings |
111
+ | `settings` | `true` | Add the Translation settings global (`settingsSlug` `polyglot-settings`, `settingsGroup` `Settings`) |
91
112
 
92
113
  ## License
93
114
 
package/dist/claude.d.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
- import type { Item, Translator } from './types.js';
2
+ import { type Item, type StopReason, type Translator } from './types.js';
3
3
  /** Keeps translations whose key was asked for and whose placeholders match the source. */
4
4
  export declare function acceptTranslations(source: Item[], raw: string): Map<string, string>;
5
+ /** Errors no retry fixes: no credits, key refused, spend limit reached. */
6
+ export declare function stopReasonOf(status: number | undefined, type: string | undefined, message: string): StopReason | undefined;
5
7
  export type ClaudeTranslatorOptions = {
6
8
  /** An Anthropic client; default `new Anthropic()` (reads ANTHROPIC_API_KEY). */
7
9
  client?: Anthropic;
8
- /** Default `claude-opus-5-5`. */
10
+ /** Default `claude-haiku-5-5`: fast, very low cost, and close to Opus for website translation. */
9
11
  model?: string;
12
+ /** Retry refusals on a fallback model (server-side fallback). Default on, except for Haiku models (not supported). */
13
+ fallbacks?: boolean;
10
14
  /** What your site is about, tone, terms not to translate… */
11
15
  instructions?: string;
12
16
  /** Name a locale code for the model, e.g. `de` → `German (Deutsch)`. */
@@ -14,5 +18,5 @@ export type ClaudeTranslatorOptions = {
14
18
  /** Thinking effort. Default `low` (translation rarely needs more). */
15
19
  effort?: 'low' | 'medium' | 'high';
16
20
  };
17
- /** Translation with Claude: structured output, placeholder checks, server-side fallback on refusals, and the Batch API for sweeps. */
21
+ /** Translation with Claude (Haiku by default): structured output, placeholder checks, editors' notes and glossary, server-side fallback on refusals where supported, and the Batch API for sweeps. */
18
22
  export declare function claudeTranslator(options?: ClaudeTranslatorOptions): Translator;
package/dist/claude.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
+ import { QuotaExceededError } from './types.js';
2
3
  /** Response shape: the same keys, translated (structured output keeps it parseable). */
3
4
  const SCHEMA = {
4
5
  type: 'object',
@@ -36,13 +37,29 @@ export function acceptTranslations(source, raw) {
36
37
  }
37
38
  return accepted;
38
39
  }
40
+ /** Errors no retry fixes: no credits, key refused, spend limit reached. */
41
+ export function stopReasonOf(status, type, message) {
42
+ if (status === 402 || type === 'billing_error' || /credit balance|billing|spend limit|usage limit/i.test(message))
43
+ return 'credits';
44
+ if (status === 401 || status === 403 || type === 'authentication_error' || type === 'permission_error')
45
+ return 'auth';
46
+ return undefined;
47
+ }
48
+ const stopping = (error) => {
49
+ if (!(error instanceof Anthropic.APIError))
50
+ return error;
51
+ const type = error.error?.error?.type;
52
+ const reason = stopReasonOf(error.status, type, error.message);
53
+ return reason ? new QuotaExceededError(`Claude: ${error.message}`, reason) : error;
54
+ };
39
55
  const textOf = (m) => m.content.flatMap((b) => (b.type === 'text' ? [b.text] : [])).join('');
40
- /** Translation with Claude: structured output, placeholder checks, server-side fallback on refusals, and the Batch API for sweeps. */
56
+ /** Translation with Claude (Haiku by default): structured output, placeholder checks, editors' notes and glossary, server-side fallback on refusals where supported, and the Batch API for sweeps. */
41
57
  export function claudeTranslator(options = {}) {
42
58
  const client = () => options.client ?? new Anthropic();
43
- const model = options.model ?? 'claude-opus-5-5';
59
+ const model = options.model ?? 'claude-haiku-5-5';
60
+ const fallbacks = options.fallbacks ?? !model.includes('haiku');
44
61
  const system = [options.instructions?.trim(), RULES].filter(Boolean).join('\n\n');
45
- const params = ({ locale, items, context, keep, sourceLocale }) => ({
62
+ const params = ({ locale, items, context, keep, sourceLocale, instructions, glossary }) => ({
46
63
  model,
47
64
  max_tokens: 32000,
48
65
  output_config: { effort: options.effort ?? 'low', format: { type: 'json_schema', schema: SCHEMA } },
@@ -50,7 +67,7 @@ export function claudeTranslator(options = {}) {
50
67
  messages: [
51
68
  {
52
69
  role: 'user',
53
- content: `Source language: ${options.languageName?.(sourceLocale) ?? sourceLocale}. Target language: ${options.languageName?.(locale) ?? locale}.${context ? `\nContext: ${context}` : ''}${keep.length ? `\nNever translate these terms: ${keep.join(', ')}` : ''}\n\nItems (JSON):\n${JSON.stringify({ items })}`,
70
+ content: `Source language: ${options.languageName?.(sourceLocale) ?? sourceLocale}. Target language: ${options.languageName?.(locale) ?? locale}.${context ? `\nContext: ${context}` : ''}${keep.length ? `\nNever translate these terms: ${keep.join(', ')}` : ''}${instructions ? `\nNotes from the site's editors: ${instructions}` : ''}${glossary?.length ? `\nUse these translations: ${glossary.map((g) => `${g.term} → ${g.translation}`).join('; ')}` : ''}\n\nItems (JSON):\n${JSON.stringify({ items })}`,
54
71
  },
55
72
  ],
56
73
  });
@@ -58,9 +75,17 @@ export function claudeTranslator(options = {}) {
58
75
  async translate(args) {
59
76
  if (!args.items.length)
60
77
  return new Map();
61
- const m = (await client()
62
- .beta.messages.stream({ ...params(args), betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' })
63
- .finalMessage());
78
+ let m;
79
+ try {
80
+ m = (fallbacks
81
+ ? await client()
82
+ .beta.messages.stream({ ...params(args), betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' })
83
+ .finalMessage()
84
+ : await client().messages.stream(params(args)).finalMessage());
85
+ }
86
+ catch (error) {
87
+ throw stopping(error);
88
+ }
64
89
  if (m.stop_reason !== 'end_turn')
65
90
  return new Map();
66
91
  return acceptTranslations(args.items, textOf(m));
@@ -69,8 +94,10 @@ export function claudeTranslator(options = {}) {
69
94
  if (!requests.length)
70
95
  return [];
71
96
  const c = client();
72
- const created = await c.messages.batches.create({
73
- requests: requests.map((r, i) => ({ custom_id: `r${i}`, params: params(r) })),
97
+ const created = await c.messages.batches
98
+ .create({ requests: requests.map((r, i) => ({ custom_id: `r${i}`, params: params(r) })) })
99
+ .catch((error) => {
100
+ throw stopping(error);
74
101
  });
75
102
  for (;;) {
76
103
  const b = await c.messages.batches.retrieve(created.id);
@@ -79,11 +106,21 @@ export function claudeTranslator(options = {}) {
79
106
  await new Promise((res) => setTimeout(res, 30_000));
80
107
  }
81
108
  const out = requests.map(() => new Map());
109
+ let stop;
82
110
  for await (const result of await c.messages.batches.results(created.id)) {
83
111
  const i = Number(result.custom_id.slice(1));
84
112
  if (result.result.type === 'succeeded' && result.result.message.stop_reason === 'end_turn')
85
113
  out[i] = acceptTranslations(requests[i].items, textOf(result.result.message));
114
+ if (result.result.type === 'errored') {
115
+ const e = result.result.error.error;
116
+ const reason = stopReasonOf(undefined, e?.type, e?.message ?? '');
117
+ if (reason)
118
+ stop ??= new QuotaExceededError(`Claude: ${e?.message ?? e?.type}`, reason);
119
+ }
86
120
  }
121
+ // Nothing came back because of credits or the key: stop and tell admins (what did succeed is kept by the caller next run).
122
+ if (stop && out.every((m) => m.size === 0))
123
+ throw stop;
87
124
  return out;
88
125
  },
89
126
  };
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useState } from 'react';
4
4
  /** Coverage per collection and language, filtered by collection, with Translate now. */
5
5
  export function PolyglotDashboard({ apiRoute, collections, labels }) {
@@ -31,9 +31,11 @@ export function PolyglotDashboard({ apiRoute, collections, labels }) {
31
31
  const scope = filter ? [filter] : undefined;
32
32
  const pct = (c, docs) => (docs ? Math.round(((c.translated + c.reviewed) / docs) * 100) : 100);
33
33
  const cell = { padding: '4px 8px', borderBottom: '1px solid var(--theme-elevation-100)', textAlign: 'center' };
34
- return (_jsxs("div", { style: { display: 'grid', gap: 'var(--base)' }, children: [_jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center' }, children: [_jsxs("label", { children: ["Collection", ' ', _jsxs("select", { value: filter, onChange: (e) => setFilter(e.target.value), style: { padding: '4px 8px' }, children: [_jsx("option", { value: "", children: "All collections" }), collections.map((c) => (_jsx("option", { value: c, children: labels[c] ?? c }, c)))] })] }), _jsx("button", { type: "button", className: "btn btn--style-primary btn--size-small", onClick: () => run({ collections: scope }, filter ? labels[filter] ?? filter : 'everything'), children: "Translate now" }), _jsx("button", { type: "button", className: "btn btn--style-secondary btn--size-small", onClick: () => void load(), children: "Refresh" })] }), message && _jsx("p", { children: message }), !data ? (_jsx("p", { children: "Loading coverage\u2026" })) : (_jsx("div", { style: { overflowX: 'auto' }, children: _jsxs("table", { style: { borderCollapse: 'collapse', fontSize: '0.9em' }, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: { ...cell, textAlign: 'start' }, children: "Collection" }), data.locales.map((l) => (_jsx("th", { style: cell, children: _jsx("button", { type: "button", title: `Translate ${l} now`, onClick: () => run({ collections: scope, locales: [l] }, l), style: { background: 'none', border: 0, cursor: 'pointer', fontWeight: 600 }, children: l }) }, l)))] }) }), _jsx("tbody", { children: data.rows.map((r) => (_jsxs("tr", { children: [_jsxs("td", { style: { ...cell, textAlign: 'start' }, children: [_jsx("button", { type: "button", title: "Translate this collection now", onClick: () => run({ collections: [r.collection] }, labels[r.collection] ?? r.collection), style: { background: 'none', border: 0, cursor: 'pointer', textAlign: 'start' }, children: labels[r.collection] ?? r.collection }), ' ', _jsxs("span", { style: { color: 'var(--theme-elevation-500)' }, children: ["(", r.documents, ")"] })] }), data.locales.map((l) => {
34
+ return (_jsxs("div", { style: { display: 'grid', gap: 'var(--base)' }, children: [_jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center' }, children: [_jsxs("label", { children: ["Collection", ' ', _jsxs("select", { value: filter, onChange: (e) => setFilter(e.target.value), style: { padding: '4px 8px' }, children: [_jsx("option", { value: "", children: "All collections" }), collections.map((c) => (_jsx("option", { value: c, children: labels[c] ?? c }, c)))] })] }), _jsx("button", { type: "button", className: "btn btn--style-primary btn--size-small", onClick: () => run({ collections: scope }, filter ? labels[filter] ?? filter : 'everything'), children: "Translate now" }), _jsx("button", { type: "button", className: "btn btn--style-secondary btn--size-small", onClick: () => void load(), children: "Refresh" })] }), data?.settings?.stop && (_jsxs("div", { role: "alert", style: { border: '1px solid var(--theme-error-500)', background: 'var(--theme-error-50)', color: 'var(--theme-error-800)', borderRadius: 4, padding: 'calc(var(--base) / 2) var(--base)' }, children: [_jsxs("strong", { children: ["Translation stopped ", new Date(data.settings.stop.at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }), "."] }), " ", data.settings.stop.help, _jsx("div", { style: { fontSize: '0.85em', opacity: 0.8, marginTop: 4 }, children: data.settings.stop.message })] })), data?.settings && (_jsxs("p", { style: { margin: 0 }, children: [_jsx("strong", { children: "Automatic translation:" }), ' ', !data.settings.enabled ? 'off here (no engine, or turned off for this environment)' : data.settings.stop ? 'stopped (see above)' : data.settings.paused ? 'paused' : 'on', ' · ', data.settings.charactersThisMonth.toLocaleString(), " characters sent this month", data.settings.skipped.length ? ` · not machine-translated: ${data.settings.skipped.join(', ')}` : '', data.settings.url && (_jsxs(_Fragment, { children: [' · ', _jsx("a", { href: data.settings.url, children: "Translation settings" })] }))] })), message && _jsx("p", { children: message }), !data ? (_jsx("p", { children: "Loading coverage\u2026" })) : (_jsx("div", { style: { overflowX: 'auto' }, children: _jsxs("table", { style: { borderCollapse: 'collapse', fontSize: '0.9em' }, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: { ...cell, textAlign: 'start' }, children: "Collection" }), data.locales.map((l) => (_jsx("th", { style: cell, children: _jsx("button", { type: "button", title: `Translate ${l} now`, onClick: () => run({ collections: scope, locales: [l] }, l), style: { background: 'none', border: 0, cursor: 'pointer', fontWeight: 600 }, children: l }) }, l)))] }) }), _jsx("tbody", { children: data.rows.map((r) => (_jsxs("tr", { children: [_jsxs("td", { style: { ...cell, textAlign: 'start' }, children: [_jsx("button", { type: "button", title: "Translate this collection now", onClick: () => run({ collections: [r.collection] }, labels[r.collection] ?? r.collection), style: { background: 'none', border: 0, cursor: 'pointer', textAlign: 'start' }, children: labels[r.collection] ?? r.collection }), ' ', _jsxs("span", { style: { color: 'var(--theme-elevation-500)' }, children: ["(", r.documents, ")"] })] }), data.locales.map((l) => {
35
35
  const c = r.locales[l];
36
36
  const p = pct(c, r.documents);
37
+ if (data.settings?.skipped.includes(l))
38
+ return (_jsx("td", { style: { ...cell, color: 'var(--theme-elevation-400)' }, title: "Not machine-translated (Translation settings)", children: "\u2013" }, l));
37
39
  return (_jsxs("td", { style: { ...cell, color: p === 100 ? 'var(--theme-success-600)' : undefined }, title: `${c.translated} machine, ${c.reviewed} reviewed, ${c.stale} reviewed but source changed, ${c.pending} pending`, children: [p, "%", c.stale ? ' ⚠' : ''] }, l));
38
- })] }, r.collection))) })] }) })), _jsx("p", { style: { color: 'var(--theme-elevation-500)', fontSize: '0.85em' }, children: "Percent of documents translated (machine or reviewed). \u26A0 marks reviewed translations whose source has changed. Click a language or collection to translate just that." })] }));
40
+ })] }, r.collection))) })] }) })), _jsx("p", { style: { color: 'var(--theme-elevation-500)', fontSize: '0.85em' }, children: "Percent of documents translated (machine or reviewed). \u26A0 marks reviewed translations whose source has changed. Click a language or collection to translate just that. \u2013 marks languages not machine-translated." })] }));
39
41
  }
package/dist/endpoints.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { APIError, addDataAndFileToRequest } from 'payload';
2
+ import { charactersThisMonth, loadSettings, STOP_HELP } from './settings.js';
2
3
  import { coverage, polyglotOf, syncAll } from './translate.js';
3
4
  const allowed = async (req) => {
4
5
  const o = polyglotOf(req.payload);
@@ -16,7 +17,20 @@ export const coverageEndpoint = {
16
17
  handler: async (req) => {
17
18
  const o = await allowed(req);
18
19
  const rows = await coverage(req.payload, { collections: list(req.searchParams?.get('collections')) });
19
- return Response.json({ locales: o.locales, rows });
20
+ const s = await loadSettings(req.payload, o);
21
+ const enabled = o.enabled ? await o.enabled({ payload: req.payload, req }) : true;
22
+ return Response.json({
23
+ locales: o.locales,
24
+ rows,
25
+ settings: {
26
+ stop: s.stop ? { ...s.stop, help: STOP_HELP[s.stop.reason] } : null,
27
+ enabled,
28
+ paused: s.paused,
29
+ skipped: [...s.skipLocales],
30
+ charactersThisMonth: charactersThisMonth(s),
31
+ url: o.settings ? `${req.payload.config.routes.admin}/globals/${o.settingsSlug}` : null,
32
+ },
33
+ });
20
34
  },
21
35
  };
22
36
  /** POST /api/polyglot/sync { collections?, locales? }: translate now (after the response in Next.js). */
@@ -29,7 +43,7 @@ export const syncEndpoint = {
29
43
  throw new APIError('Translation is turned off here.', 400, undefined, true);
30
44
  await addDataAndFileToRequest(req);
31
45
  const filters = { collections: list(req.data?.collections), locales: list(req.data?.locales) };
32
- const run = () => syncAll(req.payload, filters).then((r) => req.payload.logger.info({ ...r, ...filters }, 'Polyglot: translate now'));
46
+ const run = () => syncAll(req.payload, { ...filters, force: true }).then((r) => req.payload.logger.info({ ...r, ...filters }, 'Polyglot: translate now'));
33
47
  try {
34
48
  const { after } = await import('next/server.js');
35
49
  after(run);
package/dist/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export { coverageEndpoint, syncEndpoint } from './endpoints.js';
4
4
  export { FILLER, SKIP_KEYS, textsOf, visitPaths, visitText, fingerprint, memoryKey } from './text.js';
5
5
  export { microsoftTranslator, type MicrosoftTranslatorOptions } from './microsoft.js';
6
6
  export { QuotaExceededError, type DocumentResult, type Item, type PolyglotOptions, type ResolvedPolyglot, type TranslateArgs, type Translator } from './types.js';
7
+ export { loadSettings, type GlossaryEntry, type Settings } from './settings.js';
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export { coverageEndpoint, syncEndpoint } from './endpoints.js';
4
4
  export { FILLER, SKIP_KEYS, textsOf, visitPaths, visitText, fingerprint, memoryKey } from './text.js';
5
5
  export { microsoftTranslator } from './microsoft.js';
6
6
  export { QuotaExceededError } from './types.js';
7
+ export { loadSettings } from './settings.js';
package/dist/microsoft.js CHANGED
@@ -48,8 +48,10 @@ export function microsoftTranslator(options) {
48
48
  await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 2 ** attempt * 5) * 1000));
49
49
  continue;
50
50
  }
51
+ if (res.status === 401)
52
+ throw new QuotaExceededError(`Microsoft Translator refused the key: ${await res.text()}`, 'auth');
51
53
  if (res.status === 403)
52
- throw new QuotaExceededError(`Microsoft Translator: ${await res.text()}`);
54
+ throw new QuotaExceededError(`Microsoft Translator quota used up: ${await res.text()}`, 'quota');
53
55
  if (!res.ok)
54
56
  throw new Error(`Microsoft Translator ${res.status}: ${await res.text()}`);
55
57
  const body = (await res.json());
package/dist/plugin.d.ts CHANGED
@@ -15,5 +15,10 @@ export declare function localizeFields(fields: Field[], targets: Set<string>, pr
15
15
  * - Placeholder copy (lorem ipsum) is never sent; languages marked "Reviewed by a
16
16
  * human" are never overwritten (they are flagged when the source changes).
17
17
  * - Identity fields (slug, path…) never change when another language is saved.
18
+ * - Translation settings (a global) let admins pause translation, skip languages,
19
+ * add never-translate terms, notes and a glossary, and see characters sent.
20
+ * - When the engine stops (out of credits, quota used up, key refused) admins
21
+ * see why on the dashboard, the menu and the Translations view; `onStop`
22
+ * runs once (e.g. email); everything clears on the next success.
18
23
  */
19
24
  export declare const polyglotPlugin: (options: PolyglotOptions) => Plugin;
package/dist/plugin.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { FILLER, SKIP_KEYS } from './text.js';
2
2
  import { coverageEndpoint, syncEndpoint } from './endpoints.js';
3
+ import { loadSettings, settingsGlobal } from './settings.js';
3
4
  import { included, translateDocument } from './translate.js';
4
5
  /** Marks the listed fields localized (paths ignore tabs/rows; `a.b` reaches into array/group `a`). */
5
6
  export function localizeFields(fields, targets, prefix = '') {
@@ -67,6 +68,11 @@ const memoryCollection = (slug) => ({
67
68
  * - Placeholder copy (lorem ipsum) is never sent; languages marked "Reviewed by a
68
69
  * human" are never overwritten (they are flagged when the source changes).
69
70
  * - Identity fields (slug, path…) never change when another language is saved.
71
+ * - Translation settings (a global) let admins pause translation, skip languages,
72
+ * add never-translate terms, notes and a glossary, and see characters sent.
73
+ * - When the engine stops (out of credits, quota used up, key refused) admins
74
+ * see why on the dashboard, the menu and the Translations view; `onStop`
75
+ * runs once (e.g. email); everything clears on the next success.
70
76
  */
71
77
  export const polyglotPlugin = (options) => (config) => {
72
78
  const loc = config.localization;
@@ -91,6 +97,11 @@ export const polyglotPlugin = (options) => (config) => {
91
97
  describe: options.describe,
92
98
  onPublish: options.onPublish ?? true,
93
99
  canManage: options.canManage,
100
+ canConfigure: options.canConfigure,
101
+ onStop: options.onStop,
102
+ settings: options.settings ?? true,
103
+ settingsSlug: options.settingsSlug ?? 'polyglot-settings',
104
+ settingsGroup: options.settingsGroup ?? 'Settings',
94
105
  adminView: options.adminView ?? true,
95
106
  defaultLocale: loc.defaultLocale,
96
107
  locales: codes.filter((c) => c !== loc.defaultLocale),
@@ -111,6 +122,8 @@ export const polyglotPlugin = (options) => (config) => {
111
122
  return doc;
112
123
  if (resolved.enabled && !(await resolved.enabled({ payload: req.payload, req })))
113
124
  return doc;
125
+ if ((await loadSettings(req.payload, resolved)).paused)
126
+ return doc;
114
127
  const run = () => translateDocument(req.payload, collection.slug, String(doc.id), { inRequest: true }).catch((err) => req.payload.logger.error({ err }, 'Polyglot: publish translation failed'));
115
128
  try {
116
129
  const { after } = await import('next/server.js');
@@ -141,12 +154,14 @@ export const polyglotPlugin = (options) => (config) => {
141
154
  ...config,
142
155
  custom: { ...config.custom, polyglot: resolved },
143
156
  endpoints: [...(config.endpoints ?? []), coverageEndpoint, syncEndpoint],
157
+ globals: resolved.settings && !(config.globals ?? []).some((g) => g.slug === resolved.settingsSlug) ? [...(config.globals ?? []), settingsGlobal(resolved, labels)] : config.globals,
144
158
  admin: resolved.adminView
145
159
  ? {
146
160
  ...config.admin,
147
161
  components: {
148
162
  ...components,
149
163
  afterNavLinks: [...(components.afterNavLinks ?? []), '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotNavLink'],
164
+ beforeDashboard: [...(components.beforeDashboard ?? []), '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotStopBanner'],
150
165
  views: { ...components.views, polyglot: { Component: '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotView', path: '/polyglot' } },
151
166
  },
152
167
  }
@@ -2,8 +2,13 @@ import type { AdminViewServerProps, Payload } from 'payload';
2
2
  import React from 'react';
3
3
  /** /admin/polyglot: coverage per collection and language, Translate now. */
4
4
  export declare function PolyglotView({ initPageResult, params, searchParams }: AdminViewServerProps): Promise<React.JSX.Element>;
5
- /** Nav link, for people who may manage translations. */
5
+ /** Nav link, for people who may manage translations (⚠ while translation is stopped). */
6
6
  export declare function PolyglotNavLink({ payload, user }: {
7
7
  payload: Payload;
8
8
  user?: unknown;
9
9
  }): Promise<React.JSX.Element | null>;
10
+ /** Dashboard banner while translation is stopped (credits, quota, key), for people who may manage translations. */
11
+ export declare function PolyglotStopBanner({ payload, user }: {
12
+ payload: Payload;
13
+ user?: unknown;
14
+ }): Promise<React.JSX.Element | null>;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { DefaultTemplate } from '@payloadcms/next/templates';
3
3
  import { PolyglotDashboard } from '../client/PolyglotDashboard.js';
4
+ import { loadSettings, STOP_HELP } from '../settings.js';
4
5
  import { polyglotOf } from '../translate.js';
5
6
  const labelOf = (payload, slug) => {
6
7
  const l = payload.collections[slug]?.config.labels?.plural;
@@ -14,10 +15,26 @@ export async function PolyglotView({ initPageResult, params, searchParams }) {
14
15
  const collections = Object.keys(o.collections);
15
16
  return (_jsx(DefaultTemplate, { i18n: req.i18n, locale: locale, params: params, payload: req.payload, permissions: permissions, req: req, searchParams: searchParams, user: req.user ?? undefined, visibleEntities: visibleEntities, children: _jsxs("div", { className: "gutter--left gutter--right", style: { paddingBlock: 'calc(var(--base) * 2)' }, children: [_jsx("h1", { children: "Translations" }), allowed ? (_jsx(PolyglotDashboard, { apiRoute: req.payload.config.routes.api, collections: collections, labels: Object.fromEntries(collections.map((c) => [c, labelOf(req.payload, c)])) })) : (_jsx("p", { children: "You don\u2019t have access to translations." }))] }) }));
16
17
  }
17
- /** Nav link, for people who may manage translations. */
18
- export async function PolyglotNavLink({ payload, user }) {
18
+ const mayManage = async (payload, user) => {
19
19
  const o = polyglotOf(payload);
20
- if (!user || !(await (o.canManage ?? (() => true))({ req: { user, payload } })))
20
+ return Boolean(user) && (await (o.canManage ?? (() => true))({ req: { user, payload } }));
21
+ };
22
+ /** Nav link, for people who may manage translations (⚠ while translation is stopped). */
23
+ export async function PolyglotNavLink({ payload, user }) {
24
+ if (!(await mayManage(payload, user)))
21
25
  return null;
22
- return (_jsx("a", { className: "nav__link", href: `${payload.config.routes.admin}/polyglot`, style: { display: 'block', marginTop: 'calc(var(--base) / 2)' }, children: _jsx("span", { className: "nav__link-label", children: "Translations" }) }));
26
+ const { stop } = await loadSettings(payload, polyglotOf(payload));
27
+ return (_jsx("a", { className: "nav__link", href: `${payload.config.routes.admin}/polyglot`, style: { display: 'block', marginTop: 'calc(var(--base) / 2)' }, title: stop ? 'Translation has stopped' : undefined, children: _jsxs("span", { className: "nav__link-label", children: ["Translations", stop ? ' ⚠' : ''] }) }));
28
+ }
29
+ /** Dashboard banner while translation is stopped (credits, quota, key), for people who may manage translations. */
30
+ export async function PolyglotStopBanner({ payload, user }) {
31
+ if (!(await mayManage(payload, user)))
32
+ return null;
33
+ const { stop } = await loadSettings(payload, polyglotOf(payload));
34
+ if (!stop)
35
+ return null;
36
+ return _jsx(StopNotice, { stop: stop, href: `${payload.config.routes.admin}/polyglot` });
37
+ }
38
+ function StopNotice({ stop, href }) {
39
+ return (_jsxs("div", { role: "alert", style: { border: '1px solid var(--theme-error-500)', background: 'var(--theme-error-50)', color: 'var(--theme-error-800)', borderRadius: 4, padding: 'calc(var(--base) / 2) var(--base)', marginBottom: 'var(--base)' }, children: [_jsxs("strong", { children: ["Translation stopped ", new Date(stop.at).toLocaleString('en', { dateStyle: 'medium', timeStyle: 'short' }), "."] }), " ", STOP_HELP[stop.reason], ' ', href && _jsx("a", { href: href, children: "Open Translations" }), _jsx("div", { style: { fontSize: '0.85em', opacity: 0.8, marginTop: 4 }, children: stop.message })] }));
23
40
  }
@@ -1 +1 @@
1
- export { PolyglotNavLink, PolyglotView } from './PolyglotView.js';
1
+ export { PolyglotNavLink, PolyglotStopBanner, PolyglotView } from './PolyglotView.js';
package/dist/rsc/index.js CHANGED
@@ -1 +1 @@
1
- export { PolyglotNavLink, PolyglotView } from './PolyglotView.js';
1
+ export { PolyglotNavLink, PolyglotStopBanner, PolyglotView } from './PolyglotView.js';
@@ -0,0 +1,37 @@
1
+ import type { GlobalConfig, Payload } from 'payload';
2
+ import type { EngineStop, QuotaExceededError, ResolvedPolyglot } from './types.js';
3
+ export type GlossaryEntry = {
4
+ locale: string;
5
+ term: string;
6
+ translation: string;
7
+ };
8
+ /** What admins can change without a deploy (the Translation settings global). */
9
+ export type Settings = {
10
+ paused: boolean;
11
+ skipLocales: Set<string>;
12
+ keep: string[];
13
+ instructions?: string;
14
+ glossary: GlossaryEntry[];
15
+ maxCharactersPerRun?: number;
16
+ usage: {
17
+ month: string;
18
+ characters: number;
19
+ }[];
20
+ /** Set while translation is stopped (credits, quota, key); cleared by the next success. */
21
+ stop?: EngineStop;
22
+ };
23
+ export declare function settingsGlobal(o: ResolvedPolyglot, labels: {
24
+ label: string;
25
+ value: string;
26
+ }[]): GlobalConfig;
27
+ /** Current settings (defaults when the settings global is off). */
28
+ export declare function loadSettings(payload: Payload, o: ResolvedPolyglot): Promise<Settings>;
29
+ /** Adds characters sent this month (keeps a year of months). */
30
+ export declare function recordUsage(payload: Payload, o: ResolvedPolyglot, characters: number): Promise<void>;
31
+ export declare const charactersThisMonth: (s: Settings) => number;
32
+ /** What admins should do about a stop. */
33
+ export declare const STOP_HELP: Record<EngineStop['reason'], string>;
34
+ /** Records that translation stopped; calls `onStop` once (not again until it has recovered). */
35
+ export declare function recordStop(payload: Payload, o: ResolvedPolyglot, error: QuotaExceededError): Promise<void>;
36
+ /** Clears a recorded stop after a successful translation. */
37
+ export declare function clearStop(payload: Payload, o: ResolvedPolyglot): Promise<void>;
@@ -0,0 +1,152 @@
1
+ const DEFAULTS = { paused: false, skipLocales: new Set(), keep: [], glossary: [], usage: [] };
2
+ const month = () => new Date().toISOString().slice(0, 7);
3
+ const hasGlobal = (payload, slug) => payload.config.globals?.some((g) => g.slug === slug);
4
+ export function settingsGlobal(o, labels) {
5
+ const access = async ({ req }) => Boolean(req.user) && (await (o.canConfigure ?? o.canManage ?? (() => true))({ req }));
6
+ return {
7
+ slug: o.settingsSlug,
8
+ label: 'Translation settings',
9
+ admin: { group: o.settingsGroup, description: 'How automatic translation behaves. Changes apply to the next translation; nothing needs a deploy.' },
10
+ access: { read: access, update: access },
11
+ fields: [
12
+ {
13
+ name: 'paused',
14
+ label: 'Pause automatic translation',
15
+ type: 'checkbox',
16
+ defaultValue: false,
17
+ admin: { description: 'Publishing and the scheduled sweep stop translating. “Translate now” in Translations still works.' },
18
+ },
19
+ {
20
+ name: 'skipLocales',
21
+ label: 'Languages not machine-translated',
22
+ type: 'select',
23
+ hasMany: true,
24
+ options: labels,
25
+ admin: { description: 'Visitors see the default language there until someone translates by hand. Existing translations stay.' },
26
+ },
27
+ {
28
+ name: 'keep',
29
+ label: 'Never translate',
30
+ type: 'textarea',
31
+ admin: { description: 'One term per line (names, brands, product names). Added to the terms set in code.' },
32
+ },
33
+ {
34
+ name: 'instructions',
35
+ label: 'Notes for the translator',
36
+ type: 'textarea',
37
+ admin: { description: 'Tone, audience, words to prefer, e.g. “Address readers formally in German.” Used by AI engines (Claude); ignored by Microsoft.' },
38
+ },
39
+ {
40
+ name: 'glossary',
41
+ label: 'Preferred translations',
42
+ type: 'array',
43
+ admin: { description: 'How a term should be translated in one language. Used by AI engines (Claude); changing it does not re-translate existing text.' },
44
+ fields: [
45
+ {
46
+ type: 'row',
47
+ fields: [
48
+ { name: 'locale', type: 'select', options: labels, required: true },
49
+ { name: 'term', type: 'text', required: true },
50
+ { name: 'translation', type: 'text', required: true },
51
+ ],
52
+ },
53
+ ],
54
+ },
55
+ {
56
+ name: 'maxCharactersPerRun',
57
+ label: 'Characters per sweep (cap)',
58
+ type: 'number',
59
+ min: 0,
60
+ admin: { description: `Stops a sweep after sending this many characters; the next run continues. Empty: ${o.maxCharactersPerRun ? `${o.maxCharactersPerRun.toLocaleString('en')} (set in code)` : 'no cap'}.` },
61
+ },
62
+ {
63
+ name: 'stop',
64
+ label: 'Stopped',
65
+ type: 'group',
66
+ admin: {
67
+ readOnly: true,
68
+ condition: (data) => Boolean(data?.stop?.reason),
69
+ description: 'Translation stopped and is waiting for someone to act. Cleared automatically by the next successful translation.',
70
+ },
71
+ fields: [
72
+ {
73
+ type: 'row',
74
+ fields: [
75
+ { name: 'reason', type: 'select', options: [{ label: 'Out of credits', value: 'credits' }, { label: 'Free quota used up', value: 'quota' }, { label: 'Key refused', value: 'auth' }] },
76
+ { name: 'at', type: 'date', admin: { date: { pickerAppearance: 'dayAndTime' } } },
77
+ ],
78
+ },
79
+ { name: 'message', type: 'textarea' },
80
+ ],
81
+ },
82
+ {
83
+ name: 'usage',
84
+ label: 'Characters sent, by month',
85
+ type: 'array',
86
+ admin: { readOnly: true, description: 'Recorded by Polyglot: characters sent to the translation engine (memory hits and skipped text are free).' },
87
+ fields: [
88
+ {
89
+ type: 'row',
90
+ fields: [
91
+ { name: 'month', type: 'text' },
92
+ { name: 'characters', type: 'number' },
93
+ ],
94
+ },
95
+ ],
96
+ },
97
+ ],
98
+ };
99
+ }
100
+ /** Current settings (defaults when the settings global is off). */
101
+ export async function loadSettings(payload, o) {
102
+ if (!hasGlobal(payload, o.settingsSlug))
103
+ return { ...DEFAULTS, skipLocales: new Set() };
104
+ const g = (await payload.findGlobal({ slug: o.settingsSlug, depth: 0, overrideAccess: true }));
105
+ return {
106
+ paused: Boolean(g.paused),
107
+ skipLocales: new Set(g.skipLocales ?? []),
108
+ keep: String(g.keep ?? '')
109
+ .split('\n')
110
+ .map((t) => t.trim())
111
+ .filter(Boolean),
112
+ instructions: typeof g.instructions === 'string' && g.instructions.trim() ? g.instructions.trim() : undefined,
113
+ glossary: (g.glossary ?? []).filter((e) => e.locale && e.term && e.translation),
114
+ maxCharactersPerRun: typeof g.maxCharactersPerRun === 'number' ? g.maxCharactersPerRun : undefined,
115
+ usage: (g.usage ?? []).map(({ month, characters }) => ({ month, characters })),
116
+ stop: g.stop?.reason ? g.stop : undefined,
117
+ };
118
+ }
119
+ /** Adds characters sent this month (keeps a year of months). */
120
+ export async function recordUsage(payload, o, characters) {
121
+ if (!characters || !hasGlobal(payload, o.settingsSlug))
122
+ return;
123
+ const { usage } = await loadSettings(payload, o);
124
+ const now = month();
125
+ const rows = usage.some((u) => u.month === now) ? usage.map((u) => (u.month === now ? { ...u, characters: u.characters + characters } : u)) : [...usage, { month: now, characters }];
126
+ await payload.updateGlobal({ slug: o.settingsSlug, data: { usage: rows.slice(-12) }, overrideAccess: true, context: { polyglot: true } });
127
+ }
128
+ export const charactersThisMonth = (s) => s.usage.find((u) => u.month === month())?.characters ?? 0;
129
+ /** What admins should do about a stop. */
130
+ export const STOP_HELP = {
131
+ credits: 'The translation engine’s account is out of credits (or hit its spending limit). Add credits, then click Translate now; nothing is lost meanwhile.',
132
+ quota: 'The engine’s free quota for this period is used up. Translation resumes on its own next period, or upgrade the plan.',
133
+ auth: 'The engine refused its key (missing, wrong or revoked). Update the key, then click Translate now.',
134
+ };
135
+ /** Records that translation stopped; calls `onStop` once (not again until it has recovered). */
136
+ export async function recordStop(payload, o, error) {
137
+ const current = (await loadSettings(payload, o)).stop;
138
+ const stop = { reason: error.reason, message: error.message.slice(0, 1000), at: new Date().toISOString() };
139
+ payload.logger.error({ reason: stop.reason, message: stop.message }, 'Polyglot: translation stopped');
140
+ if (current?.reason === stop.reason)
141
+ return;
142
+ if (hasGlobal(payload, o.settingsSlug))
143
+ await payload.updateGlobal({ slug: o.settingsSlug, data: { stop }, overrideAccess: true, context: { polyglot: true } });
144
+ await o.onStop?.({ payload, stop });
145
+ }
146
+ /** Clears a recorded stop after a successful translation. */
147
+ export async function clearStop(payload, o) {
148
+ if (!hasGlobal(payload, o.settingsSlug))
149
+ return;
150
+ await payload.updateGlobal({ slug: o.settingsSlug, data: { stop: { reason: null, message: null, at: null } }, overrideAccess: true, context: { polyglot: true } });
151
+ payload.logger.info('Polyglot: translation running again');
152
+ }
package/dist/text.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** Default placeholder copy that is never translated (nor paid for). */
1
+ /** Default placeholder copy that is never translated (nor paid for): any text with three or more lorem ipsum words, wherever it starts. */
2
2
  export declare const FILLER: RegExp;
3
3
  /** Keys whose string values are never visible text. */
4
4
  export declare const SKIP_KEYS: string[];
package/dist/text.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
- /** Default placeholder copy that is never translated (nor paid for). */
3
- export const FILLER = /lorem ipsum|dolor sit amet|consectetur adipiscing|sed do eiusmod|ut labore et dolore/i;
2
+ const LOREM_WORDS = 'lorem|ipsum|dolor|amet|consectetur|adipiscing|eiusmod|tempor|incididunt|labore|dolore|aliqua|veniam|nostrud|exercitation|ullamco|laboris|aliquip|commodo|consequat|reprehenderit|voluptate|cillum|fugiat|pariatur|excepteur|occaecat|cupidatat|proident|culpa|officia|deserunt|mollit';
3
+ /** Default placeholder copy that is never translated (nor paid for): any text with three or more lorem ipsum words, wherever it starts. */
4
+ export const FILLER = new RegExp(`(?:\\b(?:${LOREM_WORDS})\\b[\\s\\S]*?){3}`, 'i');
4
5
  /** Keys whose string values are never visible text. */
5
6
  export const SKIP_KEYS = [
6
7
  'id', 'url', 'href', 'slug', 'path', 'key', 'type', 'blockType', 'blockName', 'format', 'mode', 'direction', 'version',
@@ -1,5 +1,6 @@
1
1
  import type { Payload } from 'payload';
2
- import { type DocumentResult, type ResolvedPolyglot } from './types.js';
2
+ import { type Settings } from './settings.js';
3
+ import { type DocumentResult, type ResolvedPolyglot, type StopReason } from './types.js';
3
4
  export declare const polyglotOf: (payload: Payload) => ResolvedPolyglot;
4
5
  /** Is this document one Polyglot translates? */
5
6
  export declare function included(o: ResolvedPolyglot, collection: string, doc: Record<string, unknown>): boolean;
@@ -23,6 +24,7 @@ export declare function translateDocument(payload: Payload, collection: string,
23
24
  memoryOnly?: boolean;
24
25
  inRequest?: boolean;
25
26
  budget?: Budget;
27
+ settings?: Settings;
26
28
  }): Promise<DocumentResult>;
27
29
  export type SyncReport = {
28
30
  documents: number;
@@ -32,14 +34,18 @@ export type SyncReport = {
32
34
  reviewedStale: number;
33
35
  failed: number;
34
36
  characters: number;
35
- stopped: 'done' | 'budget' | 'quota';
37
+ stopped: 'done' | 'budget' | 'quota' | 'paused';
38
+ /** With `stopped: 'quota'`: credits, quota or auth (see Translation settings). */
39
+ stopReason?: StopReason;
36
40
  };
37
41
  /**
38
42
  * Find all content and bring it up to date (the sweep). With a translator that
39
43
  * has `batch` and `useBatch`, every missing sentence of every language goes in
40
44
  * one batch first (cheaper), then documents are written from memory. Stops
41
45
  * cleanly at `maxCharactersPerRun` or when the engine's quota is used up; the
42
- * next run continues. `dryRun` only reports what is pending.
46
+ * next run continues. `dryRun` only reports what is pending. Respects the
47
+ * Translation settings (paused, skipped languages, cap); `force` runs even when
48
+ * paused (Translate now).
43
49
  */
44
50
  export declare function syncAll(payload: Payload, opts?: {
45
51
  dryRun?: boolean;
@@ -47,6 +53,7 @@ export declare function syncAll(payload: Payload, opts?: {
47
53
  limit?: number;
48
54
  collections?: string[];
49
55
  locales?: string[];
56
+ force?: boolean;
50
57
  }): Promise<SyncReport>;
51
58
  /** Translation status of one document in one language, for notices: source, reviewed, stale, machine or pending. */
52
59
  export declare function languageStatus(payload: Payload, collection: string, doc: Record<string, unknown>, locale: string): Promise<"source" | "stale" | "pending" | "reviewed" | "machine">;
package/dist/translate.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { fingerprint, memoryKey, textsOf, visitPaths } from './text.js';
2
+ import { clearStop, loadSettings, recordStop, recordUsage } from './settings.js';
2
3
  import { QuotaExceededError } from './types.js';
3
4
  export const polyglotOf = (payload) => payload.config.custom?.polyglot;
4
5
  const rulesOf = (o) => ({ skip: o.skip, skipKeys: new Set(o.skipKeys) });
@@ -74,13 +75,16 @@ async function remember(payload, o, locale, pairs) {
74
75
  }
75
76
  const CHUNK = 80;
76
77
  const chunks = (list) => Array.from({ length: Math.ceil(list.length / CHUNK) }, (_, i) => list.slice(i * CHUNK, (i + 1) * CHUNK));
77
- const argsFor = (o, locale, items, context) => ({
78
+ const argsFor = (o, s, locale, items, context) => ({
78
79
  locale,
79
80
  items,
80
81
  context,
81
82
  sourceLocale: o.defaultLocale,
82
- keep: o.keep,
83
+ keep: [...new Set([...o.keep, ...s.keep])],
84
+ instructions: s.instructions,
85
+ glossary: s.glossary.filter((g) => g.locale === locale).map(({ term, translation }) => ({ term, translation })),
83
86
  });
87
+ const localesOf = (o, s, wanted) => (wanted ?? o.locales).filter((l) => !s.skipLocales.has(l));
84
88
  /** A per-run character budget (free tiers). */
85
89
  export class Budget {
86
90
  max;
@@ -103,12 +107,13 @@ export class Budget {
103
107
  */
104
108
  export async function translateDocument(payload, collection, id, opts = {}) {
105
109
  const o = polyglotOf(payload);
110
+ const settings = opts.settings ?? (await loadSettings(payload, o));
106
111
  const result = { translated: [], upToDate: [], reviewedStale: [], failed: [], pending: [] };
107
112
  const src = await loadSource(payload, o, collection, id);
108
113
  if (!src)
109
114
  return result;
110
115
  const todo = [];
111
- for (const locale of opts.locales ?? o.locales) {
116
+ for (const locale of localesOf(o, settings, opts.locales)) {
112
117
  const row = src.state.get(locale);
113
118
  if (row?.hash === src.hash)
114
119
  result.upToDate.push(locale);
@@ -124,51 +129,65 @@ export async function translateDocument(payload, collection, id, opts = {}) {
124
129
  return { ...result, pending: todo };
125
130
  const unique = [...new Set(src.texts)];
126
131
  const context = { ...(o.writeContext?.({ inRequest: Boolean(opts.inRequest) }) ?? {}), polyglot: true };
127
- for (const [n, locale] of todo.entries()) {
128
- try {
129
- let known = await recall(payload, o, locale, unique);
130
- const missing = unique.filter((t) => !known.has(t));
131
- if (missing.length && !opts.memoryOnly) {
132
- const chars = missing.reduce((s, t) => s + t.length, 0);
133
- if (opts.budget && !opts.budget.allows(chars)) {
134
- result.pending.push(...todo.slice(n));
135
- break;
132
+ let sent = 0;
133
+ let recovered = !settings.stop;
134
+ try {
135
+ for (const [n, locale] of todo.entries()) {
136
+ try {
137
+ let known = await recall(payload, o, locale, unique);
138
+ const missing = unique.filter((t) => !known.has(t));
139
+ if (missing.length && !opts.memoryOnly) {
140
+ const chars = missing.reduce((s, t) => s + t.length, 0);
141
+ if (opts.budget && !opts.budget.allows(chars)) {
142
+ result.pending.push(...todo.slice(n));
143
+ break;
144
+ }
145
+ for (const part of chunks(missing)) {
146
+ const items = part.map((text, i) => ({ key: String(i), text }));
147
+ const out = await engineFor(o, locale).translate(argsFor(o, settings, locale, items, o.describe?.(collection)));
148
+ const chars = part.reduce((s, t) => s + t.length, 0);
149
+ opts.budget?.spend(chars);
150
+ sent += chars;
151
+ if (!recovered && out.size) {
152
+ recovered = true;
153
+ await clearStop(payload, o);
154
+ }
155
+ await remember(payload, o, locale, items.flatMap((it) => (out.get(it.key) ? [[it.text, out.get(it.key)]] : [])));
156
+ }
157
+ known = await recall(payload, o, locale, unique);
136
158
  }
137
- for (const part of chunks(missing)) {
138
- const items = part.map((text, i) => ({ key: String(i), text }));
139
- const out = await engineFor(o, locale).translate(argsFor(o, locale, items, o.describe?.(collection)));
140
- opts.budget?.spend(part.reduce((s, t) => s + t.length, 0));
141
- await remember(payload, o, locale, items.flatMap((it) => (out.get(it.key) ? [[it.text, out.get(it.key)]] : [])));
159
+ if (unique.some((t) => !known.has(t))) {
160
+ ;
161
+ (opts.memoryOnly ? result.pending : result.failed).push(locale);
162
+ continue;
142
163
  }
143
- known = await recall(payload, o, locale, unique);
164
+ const data = structuredClone(src.source);
165
+ visitPaths(data, o.collections[collection], rulesOf(o), (t, set) => set(known.get(t) ?? t));
166
+ await payload.update({
167
+ collection: collection,
168
+ id,
169
+ locale: locale,
170
+ data: { ...data, ...(src.doc._status ? { _status: 'published' } : {}) },
171
+ overrideAccess: true,
172
+ context,
173
+ });
174
+ await saveState(payload, o, collection, id, locale, src.hash, false, src.state.get(locale));
175
+ result.translated.push(locale);
144
176
  }
145
- if (unique.some((t) => !known.has(t))) {
146
- ;
147
- (opts.memoryOnly ? result.pending : result.failed).push(locale);
148
- continue;
149
- }
150
- const data = structuredClone(src.source);
151
- visitPaths(data, o.collections[collection], rulesOf(o), (t, set) => set(known.get(t) ?? t));
152
- await payload.update({
153
- collection: collection,
154
- id,
155
- locale: locale,
156
- data: { ...data, ...(src.doc._status ? { _status: 'published' } : {}) },
157
- overrideAccess: true,
158
- context,
159
- });
160
- await saveState(payload, o, collection, id, locale, src.hash, false, src.state.get(locale));
161
- result.translated.push(locale);
162
- }
163
- catch (error) {
164
- if (error instanceof QuotaExceededError) {
165
- result.pending.push(...todo.slice(n));
166
- throw Object.assign(error, { result });
177
+ catch (error) {
178
+ if (error instanceof QuotaExceededError) {
179
+ result.pending.push(...todo.slice(n));
180
+ await recordStop(payload, o, error).catch((err) => payload.logger.warn({ err }, 'Polyglot: could not record the stop'));
181
+ throw Object.assign(error, { result });
182
+ }
183
+ payload.logger.error({ err: error, collection, id, locale }, 'Polyglot: translation failed');
184
+ result.failed.push(locale);
167
185
  }
168
- payload.logger.error({ err: error, collection, id, locale }, 'Polyglot: translation failed');
169
- result.failed.push(locale);
170
186
  }
171
187
  }
188
+ finally {
189
+ await recordUsage(payload, o, sent).catch((err) => payload.logger.warn({ err }, 'Polyglot: could not record usage'));
190
+ }
172
191
  return result;
173
192
  }
174
193
  /**
@@ -176,12 +195,17 @@ export async function translateDocument(payload, collection, id, opts = {}) {
176
195
  * has `batch` and `useBatch`, every missing sentence of every language goes in
177
196
  * one batch first (cheaper), then documents are written from memory. Stops
178
197
  * cleanly at `maxCharactersPerRun` or when the engine's quota is used up; the
179
- * next run continues. `dryRun` only reports what is pending.
198
+ * next run continues. `dryRun` only reports what is pending. Respects the
199
+ * Translation settings (paused, skipped languages, cap); `force` runs even when
200
+ * paused (Translate now).
180
201
  */
181
202
  export async function syncAll(payload, opts = {}) {
182
203
  const o = polyglotOf(payload);
183
204
  const report = { documents: 0, upToDate: 0, translated: 0, pending: 0, reviewedStale: 0, failed: 0, characters: 0, stopped: 'done' };
184
- const budget = new Budget(o.maxCharactersPerRun);
205
+ const settings = await loadSettings(payload, o);
206
+ if (settings.paused && !opts.force && !opts.dryRun)
207
+ return { ...report, stopped: 'paused' };
208
+ const budget = new Budget(settings.maxCharactersPerRun ?? o.maxCharactersPerRun);
185
209
  const targets = [];
186
210
  for (const collection of Object.keys(o.collections).filter((c) => !opts.collections || opts.collections.includes(c))) {
187
211
  const { docs } = await payload.find({ collection: collection, locale: o.defaultLocale, depth: 0, pagination: false, overrideAccess: true, draft: false });
@@ -189,7 +213,7 @@ export async function syncAll(payload, opts = {}) {
189
213
  if (included(o, collection, d))
190
214
  targets.push({ collection, id: String(d.id) });
191
215
  }
192
- const locales = opts.locales ?? o.locales;
216
+ const locales = localesOf(o, settings, opts.locales);
193
217
  const work = opts.limit ? targets.slice(0, opts.limit) : targets;
194
218
  const batching = Boolean(opts.useBatch && o.translator.batch && !o.translatorFor && !opts.dryRun);
195
219
  if (batching) {
@@ -217,11 +241,23 @@ export async function syncAll(payload, opts = {}) {
217
241
  break;
218
242
  }
219
243
  budget.spend(chars);
220
- requests.push(argsFor(o, locale, part.map((text, i) => ({ key: String(i), text }))));
244
+ requests.push(argsFor(o, settings, locale, part.map((text, i) => ({ key: String(i), text }))));
221
245
  }
222
246
  }
223
247
  if (requests.length) {
224
- const results = await o.translator.batch(requests);
248
+ let results;
249
+ try {
250
+ results = await o.translator.batch(requests);
251
+ }
252
+ catch (error) {
253
+ if (!(error instanceof QuotaExceededError))
254
+ throw error;
255
+ await recordStop(payload, o, error);
256
+ return { ...report, stopped: 'quota', stopReason: error.reason, pending: requests.length };
257
+ }
258
+ await recordUsage(payload, o, budget.used);
259
+ if (settings.stop && results.some((m) => m.size))
260
+ await clearStop(payload, o);
225
261
  for (const [i, r] of requests.entries())
226
262
  await remember(payload, o, r.locale, r.items.flatMap((it) => (results[i]?.get(it.key) ? [[it.text, results[i].get(it.key)]] : [])));
227
263
  }
@@ -229,13 +265,14 @@ export async function syncAll(payload, opts = {}) {
229
265
  for (const t of work) {
230
266
  let r;
231
267
  try {
232
- r = await translateDocument(payload, t.collection, t.id, { locales, dryRun: opts.dryRun, memoryOnly: batching, budget: batching ? undefined : budget });
268
+ r = await translateDocument(payload, t.collection, t.id, { locales, dryRun: opts.dryRun, memoryOnly: batching, budget: batching ? undefined : budget, settings });
233
269
  }
234
270
  catch (error) {
235
271
  if (!(error instanceof QuotaExceededError))
236
272
  throw error;
237
273
  r = error.result;
238
274
  report.stopped = 'quota';
275
+ report.stopReason = error.reason;
239
276
  }
240
277
  report.documents++;
241
278
  report.upToDate += r.upToDate.length;
package/dist/types.d.ts CHANGED
@@ -15,15 +15,35 @@ export type TranslateArgs = {
15
15
  context?: string;
16
16
  sourceLocale: string;
17
17
  keep: string[];
18
+ /** Editors' notes (tone, audience); AI engines use them, others may ignore them. */
19
+ instructions?: string;
20
+ /** Preferred translations for this language; AI engines use them, others may ignore them. */
21
+ glossary?: {
22
+ term: string;
23
+ translation: string;
24
+ }[];
18
25
  };
19
26
  export type Translator = {
20
27
  translate: (args: TranslateArgs) => Promise<Map<string, string>>;
21
28
  batch?: (requests: TranslateArgs[]) => Promise<Map<string, string>[]>;
22
29
  };
23
- /** Thrown by a translator when its quota (e.g. a free tier) is used up: the run stops and resumes next time. */
30
+ /** Why an engine stopped: out of credits (paid), a free quota used up, or the key refused. */
31
+ export type StopReason = 'credits' | 'quota' | 'auth';
32
+ /**
33
+ * Thrown by a translator when it cannot continue until someone acts (credits,
34
+ * quota, key): the run stops cleanly, admins are told why, and the next run
35
+ * after the fix continues.
36
+ */
24
37
  export declare class QuotaExceededError extends Error {
25
- constructor(message?: string);
38
+ readonly reason: StopReason;
39
+ constructor(message?: string, reason?: StopReason);
26
40
  }
41
+ /** A recorded stop, shown to admins until a translation succeeds again. */
42
+ export type EngineStop = {
43
+ reason: StopReason;
44
+ message: string;
45
+ at: string;
46
+ };
27
47
  export type PolyglotOptions = {
28
48
  /** Fields to keep in every language, per collection. Dotted paths reach into arrays/groups: `sections.heading`. */
29
49
  collections: Record<string, string[]>;
@@ -66,12 +86,27 @@ export type PolyglotOptions = {
66
86
  canManage?: (args: {
67
87
  req: PayloadRequest;
68
88
  }) => boolean | Promise<boolean>;
89
+ /** Who may change Translation settings (pause, languages, glossary…). Default: `canManage`. */
90
+ canConfigure?: (args: {
91
+ req: PayloadRequest;
92
+ }) => boolean | Promise<boolean>;
93
+ /** Add the Translation settings global (admin-editable pause, skipped languages, glossary, usage). Default true. */
94
+ settings?: boolean;
95
+ /** Slug of the settings global. Default `polyglot-settings`. */
96
+ settingsSlug?: string;
97
+ /** Admin group of the settings global. Default `Settings`. */
98
+ settingsGroup?: string;
69
99
  /** Add the Polyglot admin view (coverage per collection and language, Translate now). Default true. */
70
100
  adminView?: boolean;
71
101
  /** Translate after a document is published (in Next.js, after the response). Default true. */
72
102
  onPublish?: boolean;
103
+ /** Called once when translation stops (credits, quota, key), e.g. to email admins. Not called again until it has recovered. */
104
+ onStop?: (args: {
105
+ payload: Payload;
106
+ stop: EngineStop;
107
+ }) => void | Promise<void>;
73
108
  };
74
- export type ResolvedPolyglot = Required<Omit<PolyglotOptions, 'enabled' | 'include' | 'writeContext' | 'describe' | 'translatorFor' | 'maxCharactersPerRun' | 'canManage'>> & Pick<PolyglotOptions, 'enabled' | 'include' | 'writeContext' | 'describe' | 'translatorFor' | 'maxCharactersPerRun' | 'canManage'> & {
109
+ export type ResolvedPolyglot = Required<Omit<PolyglotOptions, 'enabled' | 'include' | 'writeContext' | 'describe' | 'translatorFor' | 'maxCharactersPerRun' | 'canManage' | 'canConfigure' | 'onStop'>> & Pick<PolyglotOptions, 'enabled' | 'include' | 'writeContext' | 'describe' | 'translatorFor' | 'maxCharactersPerRun' | 'canManage' | 'canConfigure' | 'onStop'> & {
75
110
  defaultLocale: string;
76
111
  locales: string[];
77
112
  };
package/dist/types.js CHANGED
@@ -1,7 +1,13 @@
1
- /** Thrown by a translator when its quota (e.g. a free tier) is used up: the run stops and resumes next time. */
1
+ /**
2
+ * Thrown by a translator when it cannot continue until someone acts (credits,
3
+ * quota, key): the run stops cleanly, admins are told why, and the next run
4
+ * after the fix continues.
5
+ */
2
6
  export class QuotaExceededError extends Error {
3
- constructor(message = 'Translation quota used up') {
7
+ reason;
8
+ constructor(message = 'Translation quota used up', reason = 'quota') {
4
9
  super(message);
10
+ this.reason = reason;
5
11
  this.name = 'QuotaExceededError';
6
12
  }
7
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@captiveorigins/payload-plugin-polyglot",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Keeps all your Payload content translated into every language you have, a little at a time: finds content on its own, translates on publish and in a sweep, remembers every sentence, skips placeholder text, and never overwrites a human-reviewed translation.",
5
5
  "keywords": [
6
6
  "payload",