@captiveorigins/payload-plugin-polyglot 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/claude.d.ts +22 -0
- package/dist/claude.js +127 -0
- package/dist/client/PolyglotDashboard.d.ts +7 -0
- package/dist/client/PolyglotDashboard.js +41 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/endpoints.d.ts +5 -0
- package/dist/endpoints.js +56 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/microsoft.d.ts +17 -0
- package/dist/microsoft.js +69 -0
- package/dist/plugin.d.ts +24 -0
- package/dist/plugin.js +188 -0
- package/dist/rsc/PolyglotView.d.ts +14 -0
- package/dist/rsc/PolyglotView.js +40 -0
- package/dist/rsc/index.d.ts +1 -0
- package/dist/rsc/index.js +1 -0
- package/dist/settings.d.ts +37 -0
- package/dist/settings.js +152 -0
- package/dist/text.d.ts +27 -0
- package/dist/text.js +83 -0
- package/dist/translate.d.ts +74 -0
- package/dist/translate.js +337 -0
- package/dist/types.d.ts +119 -0
- package/dist/types.js +13 -0
- package/package.json +86 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Captive Origins
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,115 @@
|
|
|
1
|
-
#
|
|
1
|
+
# payload-plugin-polyglot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Keeps all your [Payload](https://payloadcms.com) 3 content translated into every language you
|
|
4
|
+
have, a little at a time.
|
|
5
|
+
|
|
6
|
+
> **Beta (0.x).** Used by Captive Origins. Feedback and issues welcome.
|
|
7
|
+
|
|
8
|
+
- **Finds your content.** List the fields to translate per collection; they become per-language
|
|
9
|
+
(Payload localization). Slugs, paths, relationships and images stay shared.
|
|
10
|
+
- **Translates on publish** (in Next.js, after the response, so saving stays fast) and in a
|
|
11
|
+
**sweep** (`syncAll()`, from a cron or script) that catches anything missed or changed.
|
|
12
|
+
- **Pays only for what changed.** A fingerprint of each document's text skips unchanged
|
|
13
|
+
documents; a translation memory of every sentence skips known text, so an edit only sends
|
|
14
|
+
the edited sentences.
|
|
15
|
+
- **Never wastes calls on placeholders.** Lorem ipsum (and friends) is never sent.
|
|
16
|
+
- **Respects people.** Languages marked "Reviewed by a human" are never overwritten; when the
|
|
17
|
+
source changes they are flagged as stale instead.
|
|
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.
|
|
22
|
+
A per-run character cap and clean stops on a used-up quota keep you inside free tiers: the
|
|
23
|
+
next run continues where the last stopped.
|
|
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.
|
|
34
|
+
- **Engine per language** (`translatorFor`), e.g. one engine for European languages and another
|
|
35
|
+
for the rest.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install @captiveorigins/payload-plugin-polyglot
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { microsoftTranslator, polyglotPlugin } from '@captiveorigins/payload-plugin-polyglot'
|
|
45
|
+
|
|
46
|
+
export default buildConfig({
|
|
47
|
+
localization: { locales: ['en', 'de', 'fr', 'ja'], defaultLocale: 'en', fallback: true },
|
|
48
|
+
plugins: [
|
|
49
|
+
// Last, so it sees collections other plugins add.
|
|
50
|
+
polyglotPlugin({
|
|
51
|
+
collections: {
|
|
52
|
+
pages: ['title', 'layout', 'meta'],
|
|
53
|
+
posts: ['title', 'content', 'meta'],
|
|
54
|
+
guides: ['title', 'summary', 'sections.heading', 'sections.body'], // only these parts of each row
|
|
55
|
+
},
|
|
56
|
+
translator: microsoftTranslator({ key: process.env.MS_TRANSLATOR_KEY!, region: 'westeurope' }),
|
|
57
|
+
keep: ['Acme', 'Python regius'],
|
|
58
|
+
maxCharactersPerRun: 200_000,
|
|
59
|
+
}),
|
|
60
|
+
],
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run the sweep from a cron job or script:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { syncAll } from '@captiveorigins/payload-plugin-polyglot'
|
|
68
|
+
|
|
69
|
+
const report = await syncAll(payload) // { translated, pending, stopped: 'done' | 'budget' | 'quota' | 'paused', … }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Claude instead of Microsoft:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { claudeTranslator } from '@captiveorigins/payload-plugin-polyglot/claude'
|
|
76
|
+
|
|
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.
|
|
79
|
+
// syncAll(payload, { useBatch: true }) uses the Message Batches API (half price)
|
|
80
|
+
```
|
|
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
|
+
|
|
87
|
+
Show visitors what they are reading: `languageStatus(payload, collection, doc, locale)` returns
|
|
88
|
+
`source`, `machine`, `reviewed`, `stale` or `pending`.
|
|
89
|
+
|
|
90
|
+
## Options
|
|
91
|
+
|
|
92
|
+
| Option | Default | |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `collections` | (required) | Fields per collection; dotted paths reach into arrays/groups |
|
|
95
|
+
| `translator` | (required) | `microsoftTranslator()`, `claudeTranslator()` or your own `{ translate, batch? }` |
|
|
96
|
+
| `translatorFor` | | Engine per language |
|
|
97
|
+
| `keep` | `[]` | Terms never translated |
|
|
98
|
+
| `maxCharactersPerRun` | no cap | Stop a sweep after this many characters |
|
|
99
|
+
| `enabled` | always | Whether a request/run may call the engine (keys, environments) |
|
|
100
|
+
| `include` | published docs | Which documents to translate |
|
|
101
|
+
| `skip` | lorem ipsum | Text never sent |
|
|
102
|
+
| `skipKeys` | urls, ids, slugs… | Keys whose strings are never text |
|
|
103
|
+
| `identityFields` | `slug`, `generateSlug`, `path` | Never change when another language is saved |
|
|
104
|
+
| `memorySlug` / `stateSlug` | `polyglot-memory` / `polyglot-state` | Collections it adds |
|
|
105
|
+
| `statusField` | `translations` | Per-document "Reviewed by a human" languages |
|
|
106
|
+
| `writeContext` | | Context for writes (e.g. `{ disableRevalidate: true }` in scripts) |
|
|
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`) |
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
package/dist/claude.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { type Item, type StopReason, type Translator } from './types.js';
|
|
3
|
+
/** Keeps translations whose key was asked for and whose placeholders match the source. */
|
|
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;
|
|
7
|
+
export type ClaudeTranslatorOptions = {
|
|
8
|
+
/** An Anthropic client; default `new Anthropic()` (reads ANTHROPIC_API_KEY). */
|
|
9
|
+
client?: Anthropic;
|
|
10
|
+
/** Default `claude-haiku-5-5`: fast, very low cost, and close to Opus for website translation. */
|
|
11
|
+
model?: string;
|
|
12
|
+
/** Retry refusals on a fallback model (server-side fallback). Default on, except for Haiku models (not supported). */
|
|
13
|
+
fallbacks?: boolean;
|
|
14
|
+
/** What your site is about, tone, terms not to translate… */
|
|
15
|
+
instructions?: string;
|
|
16
|
+
/** Name a locale code for the model, e.g. `de` → `German (Deutsch)`. */
|
|
17
|
+
languageName?: (locale: string) => string;
|
|
18
|
+
/** Thinking effort. Default `low` (translation rarely needs more). */
|
|
19
|
+
effort?: 'low' | 'medium' | 'high';
|
|
20
|
+
};
|
|
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. */
|
|
22
|
+
export declare function claudeTranslator(options?: ClaudeTranslatorOptions): Translator;
|
package/dist/claude.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { QuotaExceededError } from './types.js';
|
|
3
|
+
/** Response shape: the same keys, translated (structured output keeps it parseable). */
|
|
4
|
+
const SCHEMA = {
|
|
5
|
+
type: 'object',
|
|
6
|
+
properties: {
|
|
7
|
+
items: {
|
|
8
|
+
type: 'array',
|
|
9
|
+
items: { type: 'object', properties: { key: { type: 'string' }, text: { type: 'string' } }, required: ['key', 'text'], additionalProperties: false },
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
required: ['items'],
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
};
|
|
15
|
+
const RULES = `Rules:
|
|
16
|
+
- Translate every item's "text" into the target language; return every key exactly once, unchanged.
|
|
17
|
+
- Keep placeholders such as {count} or {name} exactly as written, and keep HTML tags and Markdown.
|
|
18
|
+
- Never translate product names, scientific names, codes or URLs.
|
|
19
|
+
- Use the forms of address and punctuation natural for websites in that language.
|
|
20
|
+
- Keep it concise: interface text should fit where the source fits.`;
|
|
21
|
+
const placeholders = (s) => [...s.matchAll(/\{[a-zA-Z0-9_]+\}/g)].map((m) => m[0]).sort().join(',');
|
|
22
|
+
/** Keeps translations whose key was asked for and whose placeholders match the source. */
|
|
23
|
+
export function acceptTranslations(source, raw) {
|
|
24
|
+
const asked = new Map(source.map((i) => [i.key, i.text]));
|
|
25
|
+
const accepted = new Map();
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return accepted;
|
|
32
|
+
}
|
|
33
|
+
for (const it of parsed.items ?? []) {
|
|
34
|
+
const src = asked.get(it.key);
|
|
35
|
+
if (src !== undefined && typeof it.text === 'string' && it.text.trim() && placeholders(src) === placeholders(it.text))
|
|
36
|
+
accepted.set(it.key, it.text);
|
|
37
|
+
}
|
|
38
|
+
return accepted;
|
|
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
|
+
};
|
|
55
|
+
const textOf = (m) => m.content.flatMap((b) => (b.type === 'text' ? [b.text] : [])).join('');
|
|
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. */
|
|
57
|
+
export function claudeTranslator(options = {}) {
|
|
58
|
+
const client = () => options.client ?? new Anthropic();
|
|
59
|
+
const model = options.model ?? 'claude-haiku-5-5';
|
|
60
|
+
const fallbacks = options.fallbacks ?? !model.includes('haiku');
|
|
61
|
+
const system = [options.instructions?.trim(), RULES].filter(Boolean).join('\n\n');
|
|
62
|
+
const params = ({ locale, items, context, keep, sourceLocale, instructions, glossary }) => ({
|
|
63
|
+
model,
|
|
64
|
+
max_tokens: 32000,
|
|
65
|
+
output_config: { effort: options.effort ?? 'low', format: { type: 'json_schema', schema: SCHEMA } },
|
|
66
|
+
system,
|
|
67
|
+
messages: [
|
|
68
|
+
{
|
|
69
|
+
role: 'user',
|
|
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 })}`,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
async translate(args) {
|
|
76
|
+
if (!args.items.length)
|
|
77
|
+
return new Map();
|
|
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
|
+
}
|
|
89
|
+
if (m.stop_reason !== 'end_turn')
|
|
90
|
+
return new Map();
|
|
91
|
+
return acceptTranslations(args.items, textOf(m));
|
|
92
|
+
},
|
|
93
|
+
async batch(requests) {
|
|
94
|
+
if (!requests.length)
|
|
95
|
+
return [];
|
|
96
|
+
const c = client();
|
|
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);
|
|
101
|
+
});
|
|
102
|
+
for (;;) {
|
|
103
|
+
const b = await c.messages.batches.retrieve(created.id);
|
|
104
|
+
if (b.processing_status === 'ended')
|
|
105
|
+
break;
|
|
106
|
+
await new Promise((res) => setTimeout(res, 30_000));
|
|
107
|
+
}
|
|
108
|
+
const out = requests.map(() => new Map());
|
|
109
|
+
let stop;
|
|
110
|
+
for await (const result of await c.messages.batches.results(created.id)) {
|
|
111
|
+
const i = Number(result.custom_id.slice(1));
|
|
112
|
+
if (result.result.type === 'succeeded' && result.result.message.stop_reason === 'end_turn')
|
|
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
|
+
}
|
|
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;
|
|
124
|
+
return out;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/** Coverage per collection and language, filtered by collection, with Translate now. */
|
|
3
|
+
export declare function PolyglotDashboard({ apiRoute, collections, labels }: {
|
|
4
|
+
apiRoute: string;
|
|
5
|
+
collections: string[];
|
|
6
|
+
labels: Record<string, string>;
|
|
7
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
/** Coverage per collection and language, filtered by collection, with Translate now. */
|
|
5
|
+
export function PolyglotDashboard({ apiRoute, collections, labels }) {
|
|
6
|
+
const [filter, setFilter] = useState('');
|
|
7
|
+
const [data, setData] = useState(null);
|
|
8
|
+
const [message, setMessage] = useState(null);
|
|
9
|
+
const load = useCallback(async () => {
|
|
10
|
+
setData(null);
|
|
11
|
+
const res = await fetch(`${apiRoute}/polyglot/coverage${filter ? `?collections=${filter}` : ''}`, { credentials: 'include' });
|
|
12
|
+
if (res.ok)
|
|
13
|
+
setData(await res.json());
|
|
14
|
+
else
|
|
15
|
+
setMessage('Could not load coverage.');
|
|
16
|
+
}, [apiRoute, filter]);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
void load();
|
|
19
|
+
}, [load]);
|
|
20
|
+
const run = async (body, what) => {
|
|
21
|
+
setMessage(`Translating ${what}…`);
|
|
22
|
+
const res = await fetch(`${apiRoute}/polyglot/sync`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
credentials: 'include',
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
const out = (await res.json().catch(() => null));
|
|
29
|
+
setMessage(res.ok ? `Started: ${what}. Refresh in a minute to see progress.` : (out?.errors?.[0]?.message ?? 'Could not start.'));
|
|
30
|
+
};
|
|
31
|
+
const scope = filter ? [filter] : undefined;
|
|
32
|
+
const pct = (c, docs) => (docs ? Math.round(((c.translated + c.reviewed) / docs) * 100) : 100);
|
|
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" })] }), 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
|
+
const c = r.locales[l];
|
|
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));
|
|
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));
|
|
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." })] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PolyglotDashboard } from './PolyglotDashboard.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PolyglotDashboard } from './PolyglotDashboard.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Endpoint } from 'payload';
|
|
2
|
+
/** GET /api/polyglot/coverage?collections=pages,posts */
|
|
3
|
+
export declare const coverageEndpoint: Endpoint;
|
|
4
|
+
/** POST /api/polyglot/sync { collections?, locales? }: translate now (after the response in Next.js). */
|
|
5
|
+
export declare const syncEndpoint: Endpoint;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { APIError, addDataAndFileToRequest } from 'payload';
|
|
2
|
+
import { charactersThisMonth, loadSettings, STOP_HELP } from './settings.js';
|
|
3
|
+
import { coverage, polyglotOf, syncAll } from './translate.js';
|
|
4
|
+
const allowed = async (req) => {
|
|
5
|
+
const o = polyglotOf(req.payload);
|
|
6
|
+
if (!req.user)
|
|
7
|
+
throw new APIError('Sign in first.', 401, undefined, true);
|
|
8
|
+
if (!(await (o.canManage ?? (() => true))({ req })))
|
|
9
|
+
throw new APIError('Not allowed.', 403, undefined, true);
|
|
10
|
+
return o;
|
|
11
|
+
};
|
|
12
|
+
const list = (v) => (typeof v === 'string' && v ? v.split(',') : Array.isArray(v) ? v.map(String) : undefined);
|
|
13
|
+
/** GET /api/polyglot/coverage?collections=pages,posts */
|
|
14
|
+
export const coverageEndpoint = {
|
|
15
|
+
path: '/polyglot/coverage',
|
|
16
|
+
method: 'get',
|
|
17
|
+
handler: async (req) => {
|
|
18
|
+
const o = await allowed(req);
|
|
19
|
+
const rows = await coverage(req.payload, { collections: list(req.searchParams?.get('collections')) });
|
|
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
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
/** POST /api/polyglot/sync { collections?, locales? }: translate now (after the response in Next.js). */
|
|
37
|
+
export const syncEndpoint = {
|
|
38
|
+
path: '/polyglot/sync',
|
|
39
|
+
method: 'post',
|
|
40
|
+
handler: async (req) => {
|
|
41
|
+
const o = await allowed(req);
|
|
42
|
+
if (o.enabled && !(await o.enabled({ payload: req.payload, req })))
|
|
43
|
+
throw new APIError('Translation is turned off here.', 400, undefined, true);
|
|
44
|
+
await addDataAndFileToRequest(req);
|
|
45
|
+
const filters = { collections: list(req.data?.collections), locales: list(req.data?.locales) };
|
|
46
|
+
const run = () => syncAll(req.payload, { ...filters, force: true }).then((r) => req.payload.logger.info({ ...r, ...filters }, 'Polyglot: translate now'));
|
|
47
|
+
try {
|
|
48
|
+
const { after } = await import('next/server.js');
|
|
49
|
+
after(run);
|
|
50
|
+
return Response.json({ started: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return Response.json({ started: true, report: await run() });
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { polyglotPlugin, localizeFields } from './plugin.js';
|
|
2
|
+
export { Budget, coverage, included, languageStatus, polyglotOf, syncAll, translateDocument, type CoverageRow, type SyncReport } from './translate.js';
|
|
3
|
+
export { coverageEndpoint, syncEndpoint } from './endpoints.js';
|
|
4
|
+
export { FILLER, SKIP_KEYS, textsOf, visitPaths, visitText, fingerprint, memoryKey } from './text.js';
|
|
5
|
+
export { microsoftTranslator, type MicrosoftTranslatorOptions } from './microsoft.js';
|
|
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
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { polyglotPlugin, localizeFields } from './plugin.js';
|
|
2
|
+
export { Budget, coverage, included, languageStatus, polyglotOf, syncAll, translateDocument } from './translate.js';
|
|
3
|
+
export { coverageEndpoint, syncEndpoint } from './endpoints.js';
|
|
4
|
+
export { FILLER, SKIP_KEYS, textsOf, visitPaths, visitText, fingerprint, memoryKey } from './text.js';
|
|
5
|
+
export { microsoftTranslator } from './microsoft.js';
|
|
6
|
+
export { QuotaExceededError } from './types.js';
|
|
7
|
+
export { loadSettings } from './settings.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Translator } from './types.js';
|
|
2
|
+
export type MicrosoftTranslatorOptions = {
|
|
3
|
+
/** Azure AI Translator key (the free F0 tier covers about 2 million characters a month). */
|
|
4
|
+
key: string;
|
|
5
|
+
/** The resource's region, e.g. `westeurope` (omit for global resources). */
|
|
6
|
+
region?: string;
|
|
7
|
+
/** Default `https://api.cognitive.microsofttranslator.com`. */
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
/** Map a site locale to Microsoft's code. Defaults handle zh → zh-Hans, zh-TW → zh-Hant. */
|
|
10
|
+
languageCode?: (locale: string) => string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Microsoft Translator (Azure AI Translator, free tier available). Placeholders
|
|
14
|
+
* like {count} and the `keep` terms are wrapped so they are never translated.
|
|
15
|
+
* A used-up quota stops the run (QuotaExceededError); rate limits are retried.
|
|
16
|
+
*/
|
|
17
|
+
export declare function microsoftTranslator(options: MicrosoftTranslatorOptions): Translator;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { QuotaExceededError } from './types.js';
|
|
2
|
+
const DEFAULT_CODES = { zh: 'zh-Hans', 'zh-TW': 'zh-Hant', 'zh-CN': 'zh-Hans', nb: 'nb', pt: 'pt' };
|
|
3
|
+
const escapeHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
4
|
+
const unescapeHtml = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
5
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
6
|
+
/**
|
|
7
|
+
* Microsoft Translator (Azure AI Translator, free tier available). Placeholders
|
|
8
|
+
* like {count} and the `keep` terms are wrapped so they are never translated.
|
|
9
|
+
* A used-up quota stops the run (QuotaExceededError); rate limits are retried.
|
|
10
|
+
*/
|
|
11
|
+
export function microsoftTranslator(options) {
|
|
12
|
+
const endpoint = options.endpoint ?? 'https://api.cognitive.microsofttranslator.com';
|
|
13
|
+
const code = (l) => options.languageCode?.(l) ?? DEFAULT_CODES[l] ?? l;
|
|
14
|
+
const protect = (text, keep) => {
|
|
15
|
+
let html = escapeHtml(text).replace(/\{[a-zA-Z0-9_]+\}/g, (m) => `<span translate="no">${m}</span>`);
|
|
16
|
+
for (const term of [...keep].sort((a, b) => b.length - a.length))
|
|
17
|
+
html = html.replace(new RegExp(`(?<![\\w>])${escapeRe(escapeHtml(term))}(?![\\w<])`, 'g'), (m) => `<span translate="no">${m}</span>`);
|
|
18
|
+
return html;
|
|
19
|
+
};
|
|
20
|
+
const unprotect = (html) => unescapeHtml(html.replace(/<span translate="no">([\s\S]*?)<\/span>/g, '$1'));
|
|
21
|
+
return {
|
|
22
|
+
async translate({ locale, items, keep, sourceLocale }) {
|
|
23
|
+
const out = new Map();
|
|
24
|
+
// Requests are limited to 1,000 items and 50,000 characters.
|
|
25
|
+
const groups = [[]];
|
|
26
|
+
let size = 0;
|
|
27
|
+
for (const it of items) {
|
|
28
|
+
if (groups.at(-1).length >= 900 || size + it.text.length > 40_000) {
|
|
29
|
+
groups.push([]);
|
|
30
|
+
size = 0;
|
|
31
|
+
}
|
|
32
|
+
groups.at(-1).push(it);
|
|
33
|
+
size += it.text.length;
|
|
34
|
+
}
|
|
35
|
+
for (const group of groups.filter((g) => g.length)) {
|
|
36
|
+
const url = `${endpoint}/translate?api-version=3.0&from=${encodeURIComponent(code(sourceLocale))}&to=${encodeURIComponent(code(locale))}&textType=html`;
|
|
37
|
+
for (let attempt = 0;; attempt++) {
|
|
38
|
+
const res = await fetch(url, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
'Ocp-Apim-Subscription-Key': options.key,
|
|
43
|
+
...(options.region ? { 'Ocp-Apim-Subscription-Region': options.region } : {}),
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify(group.map((it) => ({ Text: protect(it.text, keep) }))),
|
|
46
|
+
});
|
|
47
|
+
if (res.status === 429 && attempt < 5) {
|
|
48
|
+
await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 2 ** attempt * 5) * 1000));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (res.status === 401)
|
|
52
|
+
throw new QuotaExceededError(`Microsoft Translator refused the key: ${await res.text()}`, 'auth');
|
|
53
|
+
if (res.status === 403)
|
|
54
|
+
throw new QuotaExceededError(`Microsoft Translator quota used up: ${await res.text()}`, 'quota');
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
throw new Error(`Microsoft Translator ${res.status}: ${await res.text()}`);
|
|
57
|
+
const body = (await res.json());
|
|
58
|
+
group.forEach((it, i) => {
|
|
59
|
+
const t = body[i]?.translations[0]?.text;
|
|
60
|
+
if (t)
|
|
61
|
+
out.set(it.key, unprotect(t));
|
|
62
|
+
});
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Field, Plugin } from 'payload';
|
|
2
|
+
import type { PolyglotOptions } from './types.js';
|
|
3
|
+
/** Marks the listed fields localized (paths ignore tabs/rows; `a.b` reaches into array/group `a`). */
|
|
4
|
+
export declare function localizeFields(fields: Field[], targets: Set<string>, prefix?: string): Field[];
|
|
5
|
+
/**
|
|
6
|
+
* Polyglot: keeps your content translated into every language of your Payload
|
|
7
|
+
* localization, a little at a time.
|
|
8
|
+
*
|
|
9
|
+
* - The fields you list become per-language (Payload localization).
|
|
10
|
+
* - Publishing in the default language translates the other languages (in
|
|
11
|
+
* Next.js after the response); `syncAll()` sweeps everything (cron/script),
|
|
12
|
+
* optionally through the translator's batch API.
|
|
13
|
+
* - A fingerprint of each document's text skips unchanged documents; a memory of
|
|
14
|
+
* every sentence skips known text, so edits only pay for what changed.
|
|
15
|
+
* - Placeholder copy (lorem ipsum) is never sent; languages marked "Reviewed by a
|
|
16
|
+
* human" are never overwritten (they are flagged when the source changes).
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
export declare const polyglotPlugin: (options: PolyglotOptions) => Plugin;
|