@captiveorigins/payload-plugin-polyglot 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/dist/claude.d.ts +18 -0
- package/dist/claude.js +90 -0
- package/dist/client/PolyglotDashboard.d.ts +7 -0
- package/dist/client/PolyglotDashboard.js +39 -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 +42 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/microsoft.d.ts +17 -0
- package/dist/microsoft.js +67 -0
- package/dist/plugin.d.ts +19 -0
- package/dist/plugin.js +173 -0
- package/dist/rsc/PolyglotView.d.ts +9 -0
- package/dist/rsc/PolyglotView.js +23 -0
- package/dist/rsc/index.d.ts +1 -0
- package/dist/rsc/index.js +1 -0
- package/dist/text.d.ts +27 -0
- package/dist/text.js +82 -0
- package/dist/translate.d.ts +67 -0
- package/dist/translate.js +300 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.js +7 -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,94 @@
|
|
|
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
|
+
- **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.
|
|
20
|
+
A per-run character cap and clean stops on a used-up quota keep you inside free tiers: the
|
|
21
|
+
next run continues where the last stopped.
|
|
22
|
+
- **Do-not-translate list** for product names, brands and scientific names.
|
|
23
|
+
- **Engine per language** (`translatorFor`), e.g. one engine for European languages and another
|
|
24
|
+
for the rest.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @captiveorigins/payload-plugin-polyglot
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { microsoftTranslator, polyglotPlugin } from '@captiveorigins/payload-plugin-polyglot'
|
|
34
|
+
|
|
35
|
+
export default buildConfig({
|
|
36
|
+
localization: { locales: ['en', 'de', 'fr', 'ja'], defaultLocale: 'en', fallback: true },
|
|
37
|
+
plugins: [
|
|
38
|
+
// Last, so it sees collections other plugins add.
|
|
39
|
+
polyglotPlugin({
|
|
40
|
+
collections: {
|
|
41
|
+
pages: ['title', 'layout', 'meta'],
|
|
42
|
+
posts: ['title', 'content', 'meta'],
|
|
43
|
+
guides: ['title', 'summary', 'sections.heading', 'sections.body'], // only these parts of each row
|
|
44
|
+
},
|
|
45
|
+
translator: microsoftTranslator({ key: process.env.MS_TRANSLATOR_KEY!, region: 'westeurope' }),
|
|
46
|
+
keep: ['Acme', 'Python regius'],
|
|
47
|
+
maxCharactersPerRun: 200_000,
|
|
48
|
+
}),
|
|
49
|
+
],
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Run the sweep from a cron job or script:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { syncAll } from '@captiveorigins/payload-plugin-polyglot'
|
|
57
|
+
|
|
58
|
+
const report = await syncAll(payload) // { translated, pending, stopped: 'done' | 'budget' | 'quota', … }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Claude instead of Microsoft:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { claudeTranslator } from '@captiveorigins/payload-plugin-polyglot/claude'
|
|
65
|
+
|
|
66
|
+
translator: claudeTranslator({ instructions: 'A website for reptile keepers.' }) // ANTHROPIC_API_KEY
|
|
67
|
+
// syncAll(payload, { useBatch: true }) uses the Message Batches API (half price)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Show visitors what they are reading: `languageStatus(payload, collection, doc, locale)` returns
|
|
71
|
+
`source`, `machine`, `reviewed`, `stale` or `pending`.
|
|
72
|
+
|
|
73
|
+
## Options
|
|
74
|
+
|
|
75
|
+
| Option | Default | |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `collections` | (required) | Fields per collection; dotted paths reach into arrays/groups |
|
|
78
|
+
| `translator` | (required) | `microsoftTranslator()`, `claudeTranslator()` or your own `{ translate, batch? }` |
|
|
79
|
+
| `translatorFor` | | Engine per language |
|
|
80
|
+
| `keep` | `[]` | Terms never translated |
|
|
81
|
+
| `maxCharactersPerRun` | no cap | Stop a sweep after this many characters |
|
|
82
|
+
| `enabled` | always | Whether a request/run may call the engine (keys, environments) |
|
|
83
|
+
| `include` | published docs | Which documents to translate |
|
|
84
|
+
| `skip` | lorem ipsum | Text never sent |
|
|
85
|
+
| `skipKeys` | urls, ids, slugs… | Keys whose strings are never text |
|
|
86
|
+
| `identityFields` | `slug`, `generateSlug`, `path` | Never change when another language is saved |
|
|
87
|
+
| `memorySlug` / `stateSlug` | `polyglot-memory` / `polyglot-state` | Collections it adds |
|
|
88
|
+
| `statusField` | `translations` | Per-document "Reviewed by a human" languages |
|
|
89
|
+
| `writeContext` | | Context for writes (e.g. `{ disableRevalidate: true }` in scripts) |
|
|
90
|
+
| `onPublish` | `true` | Translate after publishing |
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
package/dist/claude.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import type { Item, 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
|
+
export type ClaudeTranslatorOptions = {
|
|
6
|
+
/** An Anthropic client; default `new Anthropic()` (reads ANTHROPIC_API_KEY). */
|
|
7
|
+
client?: Anthropic;
|
|
8
|
+
/** Default `claude-opus-5-5`. */
|
|
9
|
+
model?: string;
|
|
10
|
+
/** What your site is about, tone, terms not to translate… */
|
|
11
|
+
instructions?: string;
|
|
12
|
+
/** Name a locale code for the model, e.g. `de` → `German (Deutsch)`. */
|
|
13
|
+
languageName?: (locale: string) => string;
|
|
14
|
+
/** Thinking effort. Default `low` (translation rarely needs more). */
|
|
15
|
+
effort?: 'low' | 'medium' | 'high';
|
|
16
|
+
};
|
|
17
|
+
/** Translation with Claude: structured output, placeholder checks, server-side fallback on refusals, and the Batch API for sweeps. */
|
|
18
|
+
export declare function claudeTranslator(options?: ClaudeTranslatorOptions): Translator;
|
package/dist/claude.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
/** Response shape: the same keys, translated (structured output keeps it parseable). */
|
|
3
|
+
const SCHEMA = {
|
|
4
|
+
type: 'object',
|
|
5
|
+
properties: {
|
|
6
|
+
items: {
|
|
7
|
+
type: 'array',
|
|
8
|
+
items: { type: 'object', properties: { key: { type: 'string' }, text: { type: 'string' } }, required: ['key', 'text'], additionalProperties: false },
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
required: ['items'],
|
|
12
|
+
additionalProperties: false,
|
|
13
|
+
};
|
|
14
|
+
const RULES = `Rules:
|
|
15
|
+
- Translate every item's "text" into the target language; return every key exactly once, unchanged.
|
|
16
|
+
- Keep placeholders such as {count} or {name} exactly as written, and keep HTML tags and Markdown.
|
|
17
|
+
- Never translate product names, scientific names, codes or URLs.
|
|
18
|
+
- Use the forms of address and punctuation natural for websites in that language.
|
|
19
|
+
- Keep it concise: interface text should fit where the source fits.`;
|
|
20
|
+
const placeholders = (s) => [...s.matchAll(/\{[a-zA-Z0-9_]+\}/g)].map((m) => m[0]).sort().join(',');
|
|
21
|
+
/** Keeps translations whose key was asked for and whose placeholders match the source. */
|
|
22
|
+
export function acceptTranslations(source, raw) {
|
|
23
|
+
const asked = new Map(source.map((i) => [i.key, i.text]));
|
|
24
|
+
const accepted = new Map();
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return accepted;
|
|
31
|
+
}
|
|
32
|
+
for (const it of parsed.items ?? []) {
|
|
33
|
+
const src = asked.get(it.key);
|
|
34
|
+
if (src !== undefined && typeof it.text === 'string' && it.text.trim() && placeholders(src) === placeholders(it.text))
|
|
35
|
+
accepted.set(it.key, it.text);
|
|
36
|
+
}
|
|
37
|
+
return accepted;
|
|
38
|
+
}
|
|
39
|
+
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. */
|
|
41
|
+
export function claudeTranslator(options = {}) {
|
|
42
|
+
const client = () => options.client ?? new Anthropic();
|
|
43
|
+
const model = options.model ?? 'claude-opus-5-5';
|
|
44
|
+
const system = [options.instructions?.trim(), RULES].filter(Boolean).join('\n\n');
|
|
45
|
+
const params = ({ locale, items, context, keep, sourceLocale }) => ({
|
|
46
|
+
model,
|
|
47
|
+
max_tokens: 32000,
|
|
48
|
+
output_config: { effort: options.effort ?? 'low', format: { type: 'json_schema', schema: SCHEMA } },
|
|
49
|
+
system,
|
|
50
|
+
messages: [
|
|
51
|
+
{
|
|
52
|
+
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 })}`,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
async translate(args) {
|
|
59
|
+
if (!args.items.length)
|
|
60
|
+
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());
|
|
64
|
+
if (m.stop_reason !== 'end_turn')
|
|
65
|
+
return new Map();
|
|
66
|
+
return acceptTranslations(args.items, textOf(m));
|
|
67
|
+
},
|
|
68
|
+
async batch(requests) {
|
|
69
|
+
if (!requests.length)
|
|
70
|
+
return [];
|
|
71
|
+
const c = client();
|
|
72
|
+
const created = await c.messages.batches.create({
|
|
73
|
+
requests: requests.map((r, i) => ({ custom_id: `r${i}`, params: params(r) })),
|
|
74
|
+
});
|
|
75
|
+
for (;;) {
|
|
76
|
+
const b = await c.messages.batches.retrieve(created.id);
|
|
77
|
+
if (b.processing_status === 'ended')
|
|
78
|
+
break;
|
|
79
|
+
await new Promise((res) => setTimeout(res, 30_000));
|
|
80
|
+
}
|
|
81
|
+
const out = requests.map(() => new Map());
|
|
82
|
+
for await (const result of await c.messages.batches.results(created.id)) {
|
|
83
|
+
const i = Number(result.custom_id.slice(1));
|
|
84
|
+
if (result.result.type === 'succeeded' && result.result.message.stop_reason === 'end_turn')
|
|
85
|
+
out[i] = acceptTranslations(requests[i].items, textOf(result.result.message));
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -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,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } 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" })] }), 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
|
+
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." })] }));
|
|
39
|
+
}
|
|
@@ -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,42 @@
|
|
|
1
|
+
import { APIError, addDataAndFileToRequest } from 'payload';
|
|
2
|
+
import { coverage, polyglotOf, syncAll } from './translate.js';
|
|
3
|
+
const allowed = async (req) => {
|
|
4
|
+
const o = polyglotOf(req.payload);
|
|
5
|
+
if (!req.user)
|
|
6
|
+
throw new APIError('Sign in first.', 401, undefined, true);
|
|
7
|
+
if (!(await (o.canManage ?? (() => true))({ req })))
|
|
8
|
+
throw new APIError('Not allowed.', 403, undefined, true);
|
|
9
|
+
return o;
|
|
10
|
+
};
|
|
11
|
+
const list = (v) => (typeof v === 'string' && v ? v.split(',') : Array.isArray(v) ? v.map(String) : undefined);
|
|
12
|
+
/** GET /api/polyglot/coverage?collections=pages,posts */
|
|
13
|
+
export const coverageEndpoint = {
|
|
14
|
+
path: '/polyglot/coverage',
|
|
15
|
+
method: 'get',
|
|
16
|
+
handler: async (req) => {
|
|
17
|
+
const o = await allowed(req);
|
|
18
|
+
const rows = await coverage(req.payload, { collections: list(req.searchParams?.get('collections')) });
|
|
19
|
+
return Response.json({ locales: o.locales, rows });
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
/** POST /api/polyglot/sync { collections?, locales? }: translate now (after the response in Next.js). */
|
|
23
|
+
export const syncEndpoint = {
|
|
24
|
+
path: '/polyglot/sync',
|
|
25
|
+
method: 'post',
|
|
26
|
+
handler: async (req) => {
|
|
27
|
+
const o = await allowed(req);
|
|
28
|
+
if (o.enabled && !(await o.enabled({ payload: req.payload, req })))
|
|
29
|
+
throw new APIError('Translation is turned off here.', 400, undefined, true);
|
|
30
|
+
await addDataAndFileToRequest(req);
|
|
31
|
+
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'));
|
|
33
|
+
try {
|
|
34
|
+
const { after } = await import('next/server.js');
|
|
35
|
+
after(run);
|
|
36
|
+
return Response.json({ started: true });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return Response.json({ started: true, report: await run() });
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
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';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
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';
|
|
@@ -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,67 @@
|
|
|
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 === 403)
|
|
52
|
+
throw new QuotaExceededError(`Microsoft Translator: ${await res.text()}`);
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
throw new Error(`Microsoft Translator ${res.status}: ${await res.text()}`);
|
|
55
|
+
const body = (await res.json());
|
|
56
|
+
group.forEach((it, i) => {
|
|
57
|
+
const t = body[i]?.translations[0]?.text;
|
|
58
|
+
if (t)
|
|
59
|
+
out.set(it.key, unprotect(t));
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
*/
|
|
19
|
+
export declare const polyglotPlugin: (options: PolyglotOptions) => Plugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { FILLER, SKIP_KEYS } from './text.js';
|
|
2
|
+
import { coverageEndpoint, syncEndpoint } from './endpoints.js';
|
|
3
|
+
import { included, translateDocument } from './translate.js';
|
|
4
|
+
/** Marks the listed fields localized (paths ignore tabs/rows; `a.b` reaches into array/group `a`). */
|
|
5
|
+
export function localizeFields(fields, targets, prefix = '') {
|
|
6
|
+
return fields.map((f) => {
|
|
7
|
+
if (f.type === 'tabs')
|
|
8
|
+
return {
|
|
9
|
+
...f,
|
|
10
|
+
tabs: f.tabs.map((t) => 'name' in t && t.name
|
|
11
|
+
? targets.has(`${prefix}${t.name}`)
|
|
12
|
+
? { ...t, localized: true }
|
|
13
|
+
: { ...t, fields: localizeFields(t.fields, targets, `${prefix}${t.name}.`) }
|
|
14
|
+
: { ...t, fields: localizeFields(t.fields, targets, prefix) }),
|
|
15
|
+
};
|
|
16
|
+
if (f.type === 'row' || f.type === 'collapsible')
|
|
17
|
+
return { ...f, fields: localizeFields(f.fields, targets, prefix) };
|
|
18
|
+
if (!('name' in f))
|
|
19
|
+
return f;
|
|
20
|
+
const path = `${prefix}${f.name}`;
|
|
21
|
+
if (targets.has(path))
|
|
22
|
+
return { ...f, localized: true };
|
|
23
|
+
if ((f.type === 'array' || f.type === 'group') && [...targets].some((t) => t.startsWith(`${path}.`)))
|
|
24
|
+
return { ...f, fields: localizeFields(f.fields, targets, `${path}.`) };
|
|
25
|
+
return f;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const stateCollection = (slug) => ({
|
|
29
|
+
slug,
|
|
30
|
+
labels: { singular: 'Translation status', plural: 'Translation status' },
|
|
31
|
+
admin: { hidden: true },
|
|
32
|
+
access: { read: ({ req }) => Boolean(req.user), create: () => false, update: () => false, delete: ({ req }) => Boolean(req.user) },
|
|
33
|
+
fields: [
|
|
34
|
+
{ name: 'key', type: 'text', required: true, unique: true },
|
|
35
|
+
{ name: 'collection', type: 'text', required: true, index: true },
|
|
36
|
+
{ name: 'doc', type: 'text', required: true, index: true },
|
|
37
|
+
{ name: 'locale', type: 'text', required: true },
|
|
38
|
+
{ name: 'hash', type: 'text', required: true },
|
|
39
|
+
{ name: 'stale', type: 'checkbox', defaultValue: false },
|
|
40
|
+
{ name: 'at', type: 'date' },
|
|
41
|
+
],
|
|
42
|
+
timestamps: false,
|
|
43
|
+
});
|
|
44
|
+
const memoryCollection = (slug) => ({
|
|
45
|
+
slug,
|
|
46
|
+
labels: { singular: 'Translation', plural: 'Translation memory' },
|
|
47
|
+
admin: { useAsTitle: 'source', defaultColumns: ['locale', 'source', 'text', 'createdAt'], description: 'Every machine-translated sentence, reused so nothing is translated twice.' },
|
|
48
|
+
access: { read: ({ req }) => Boolean(req.user), create: () => false, update: () => false, delete: ({ req }) => Boolean(req.user) },
|
|
49
|
+
fields: [
|
|
50
|
+
{ name: 'key', type: 'text', required: true, unique: true, admin: { hidden: true } },
|
|
51
|
+
{ name: 'locale', type: 'text', required: true, index: true },
|
|
52
|
+
{ name: 'source', type: 'textarea', required: true },
|
|
53
|
+
{ name: 'text', type: 'textarea', required: true },
|
|
54
|
+
],
|
|
55
|
+
timestamps: true,
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* Polyglot: keeps your content translated into every language of your Payload
|
|
59
|
+
* localization, a little at a time.
|
|
60
|
+
*
|
|
61
|
+
* - The fields you list become per-language (Payload localization).
|
|
62
|
+
* - Publishing in the default language translates the other languages (in
|
|
63
|
+
* Next.js after the response); `syncAll()` sweeps everything (cron/script),
|
|
64
|
+
* optionally through the translator's batch API.
|
|
65
|
+
* - A fingerprint of each document's text skips unchanged documents; a memory of
|
|
66
|
+
* every sentence skips known text, so edits only pay for what changed.
|
|
67
|
+
* - Placeholder copy (lorem ipsum) is never sent; languages marked "Reviewed by a
|
|
68
|
+
* human" are never overwritten (they are flagged when the source changes).
|
|
69
|
+
* - Identity fields (slug, path…) never change when another language is saved.
|
|
70
|
+
*/
|
|
71
|
+
export const polyglotPlugin = (options) => (config) => {
|
|
72
|
+
const loc = config.localization;
|
|
73
|
+
if (!loc)
|
|
74
|
+
throw new Error('payload-plugin-polyglot: enable Payload `localization` first.');
|
|
75
|
+
const codes = loc.locales.map((l) => (typeof l === 'string' ? l : l.code));
|
|
76
|
+
const resolved = {
|
|
77
|
+
collections: options.collections,
|
|
78
|
+
translator: options.translator,
|
|
79
|
+
enabled: options.enabled,
|
|
80
|
+
include: options.include,
|
|
81
|
+
keep: options.keep ?? [],
|
|
82
|
+
translatorFor: options.translatorFor,
|
|
83
|
+
maxCharactersPerRun: options.maxCharactersPerRun,
|
|
84
|
+
skip: options.skip ?? FILLER,
|
|
85
|
+
skipKeys: [...SKIP_KEYS, ...(options.skipKeys ?? [])],
|
|
86
|
+
identityFields: options.identityFields ?? ['slug', 'generateSlug', 'path'],
|
|
87
|
+
memorySlug: options.memorySlug ?? 'polyglot-memory',
|
|
88
|
+
stateSlug: options.stateSlug ?? 'polyglot-state',
|
|
89
|
+
statusField: options.statusField ?? 'translations',
|
|
90
|
+
writeContext: options.writeContext,
|
|
91
|
+
describe: options.describe,
|
|
92
|
+
onPublish: options.onPublish ?? true,
|
|
93
|
+
canManage: options.canManage,
|
|
94
|
+
adminView: options.adminView ?? true,
|
|
95
|
+
defaultLocale: loc.defaultLocale,
|
|
96
|
+
locales: codes.filter((c) => c !== loc.defaultLocale),
|
|
97
|
+
};
|
|
98
|
+
const labels = loc.locales.filter((l) => (typeof l === 'string' ? l : l.code) !== loc.defaultLocale).map((l) => typeof l === 'string' ? { label: l, value: l } : { label: typeof l.label === 'string' ? l.label : l.code, value: l.code });
|
|
99
|
+
const keepIdentity = ({ data, originalDoc, req }) => {
|
|
100
|
+
if (!originalDoc || !req.locale || req.locale === resolved.defaultLocale || req.locale === 'all')
|
|
101
|
+
return data;
|
|
102
|
+
for (const k of resolved.identityFields)
|
|
103
|
+
if (k in originalDoc)
|
|
104
|
+
data[k] = originalDoc[k];
|
|
105
|
+
return data;
|
|
106
|
+
};
|
|
107
|
+
const onPublish = async ({ collection, doc, req, context }) => {
|
|
108
|
+
if (!resolved.onPublish || context?.polyglot || (req.locale && req.locale !== resolved.defaultLocale))
|
|
109
|
+
return doc;
|
|
110
|
+
if (!included(resolved, collection.slug, doc))
|
|
111
|
+
return doc;
|
|
112
|
+
if (resolved.enabled && !(await resolved.enabled({ payload: req.payload, req })))
|
|
113
|
+
return doc;
|
|
114
|
+
const run = () => translateDocument(req.payload, collection.slug, String(doc.id), { inRequest: true }).catch((err) => req.payload.logger.error({ err }, 'Polyglot: publish translation failed'));
|
|
115
|
+
try {
|
|
116
|
+
const { after } = await import('next/server.js');
|
|
117
|
+
after(run);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Not in a Next.js request (scripts, tests): the sweep picks it up.
|
|
121
|
+
}
|
|
122
|
+
return doc;
|
|
123
|
+
};
|
|
124
|
+
const statusField = {
|
|
125
|
+
name: resolved.statusField,
|
|
126
|
+
type: 'group',
|
|
127
|
+
admin: { position: 'sidebar', description: 'Machine translations show a notice until a person reviews them.' },
|
|
128
|
+
fields: [
|
|
129
|
+
{
|
|
130
|
+
name: 'reviewed',
|
|
131
|
+
label: 'Reviewed by a human',
|
|
132
|
+
type: 'select',
|
|
133
|
+
hasMany: true,
|
|
134
|
+
options: labels,
|
|
135
|
+
admin: { description: 'Reviewed languages are never re-translated automatically.' },
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
const components = config.admin?.components ?? {};
|
|
140
|
+
return {
|
|
141
|
+
...config,
|
|
142
|
+
custom: { ...config.custom, polyglot: resolved },
|
|
143
|
+
endpoints: [...(config.endpoints ?? []), coverageEndpoint, syncEndpoint],
|
|
144
|
+
admin: resolved.adminView
|
|
145
|
+
? {
|
|
146
|
+
...config.admin,
|
|
147
|
+
components: {
|
|
148
|
+
...components,
|
|
149
|
+
afterNavLinks: [...(components.afterNavLinks ?? []), '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotNavLink'],
|
|
150
|
+
views: { ...components.views, polyglot: { Component: '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotView', path: '/polyglot' } },
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
: config.admin,
|
|
154
|
+
collections: [
|
|
155
|
+
...(config.collections ?? []).map((c) => {
|
|
156
|
+
const targets = resolved.collections[c.slug];
|
|
157
|
+
if (!targets)
|
|
158
|
+
return c;
|
|
159
|
+
return {
|
|
160
|
+
...c,
|
|
161
|
+
fields: [...localizeFields(c.fields, new Set(targets)), statusField],
|
|
162
|
+
hooks: {
|
|
163
|
+
...c.hooks,
|
|
164
|
+
beforeChange: [...(c.hooks?.beforeChange ?? []), keepIdentity],
|
|
165
|
+
afterChange: [...(c.hooks?.afterChange ?? []), onPublish],
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}),
|
|
169
|
+
...((config.collections ?? []).some((c) => c.slug === resolved.memorySlug) ? [] : [memoryCollection(resolved.memorySlug)]),
|
|
170
|
+
...((config.collections ?? []).some((c) => c.slug === resolved.stateSlug) ? [] : [stateCollection(resolved.stateSlug)]),
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AdminViewServerProps, Payload } from 'payload';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
/** /admin/polyglot: coverage per collection and language, Translate now. */
|
|
4
|
+
export declare function PolyglotView({ initPageResult, params, searchParams }: AdminViewServerProps): Promise<React.JSX.Element>;
|
|
5
|
+
/** Nav link, for people who may manage translations. */
|
|
6
|
+
export declare function PolyglotNavLink({ payload, user }: {
|
|
7
|
+
payload: Payload;
|
|
8
|
+
user?: unknown;
|
|
9
|
+
}): Promise<React.JSX.Element | null>;
|