@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/dist/plugin.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { FILLER, SKIP_KEYS } from './text.js';
|
|
2
|
+
import { coverageEndpoint, syncEndpoint } from './endpoints.js';
|
|
3
|
+
import { loadSettings, settingsGlobal } from './settings.js';
|
|
4
|
+
import { included, translateDocument } from './translate.js';
|
|
5
|
+
/** Marks the listed fields localized (paths ignore tabs/rows; `a.b` reaches into array/group `a`). */
|
|
6
|
+
export function localizeFields(fields, targets, prefix = '') {
|
|
7
|
+
return fields.map((f) => {
|
|
8
|
+
if (f.type === 'tabs')
|
|
9
|
+
return {
|
|
10
|
+
...f,
|
|
11
|
+
tabs: f.tabs.map((t) => 'name' in t && t.name
|
|
12
|
+
? targets.has(`${prefix}${t.name}`)
|
|
13
|
+
? { ...t, localized: true }
|
|
14
|
+
: { ...t, fields: localizeFields(t.fields, targets, `${prefix}${t.name}.`) }
|
|
15
|
+
: { ...t, fields: localizeFields(t.fields, targets, prefix) }),
|
|
16
|
+
};
|
|
17
|
+
if (f.type === 'row' || f.type === 'collapsible')
|
|
18
|
+
return { ...f, fields: localizeFields(f.fields, targets, prefix) };
|
|
19
|
+
if (!('name' in f))
|
|
20
|
+
return f;
|
|
21
|
+
const path = `${prefix}${f.name}`;
|
|
22
|
+
if (targets.has(path))
|
|
23
|
+
return { ...f, localized: true };
|
|
24
|
+
if ((f.type === 'array' || f.type === 'group') && [...targets].some((t) => t.startsWith(`${path}.`)))
|
|
25
|
+
return { ...f, fields: localizeFields(f.fields, targets, `${path}.`) };
|
|
26
|
+
return f;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const stateCollection = (slug) => ({
|
|
30
|
+
slug,
|
|
31
|
+
labels: { singular: 'Translation status', plural: 'Translation status' },
|
|
32
|
+
admin: { hidden: true },
|
|
33
|
+
access: { read: ({ req }) => Boolean(req.user), create: () => false, update: () => false, delete: ({ req }) => Boolean(req.user) },
|
|
34
|
+
fields: [
|
|
35
|
+
{ name: 'key', type: 'text', required: true, unique: true },
|
|
36
|
+
{ name: 'collection', type: 'text', required: true, index: true },
|
|
37
|
+
{ name: 'doc', type: 'text', required: true, index: true },
|
|
38
|
+
{ name: 'locale', type: 'text', required: true },
|
|
39
|
+
{ name: 'hash', type: 'text', required: true },
|
|
40
|
+
{ name: 'stale', type: 'checkbox', defaultValue: false },
|
|
41
|
+
{ name: 'at', type: 'date' },
|
|
42
|
+
],
|
|
43
|
+
timestamps: false,
|
|
44
|
+
});
|
|
45
|
+
const memoryCollection = (slug) => ({
|
|
46
|
+
slug,
|
|
47
|
+
labels: { singular: 'Translation', plural: 'Translation memory' },
|
|
48
|
+
admin: { useAsTitle: 'source', defaultColumns: ['locale', 'source', 'text', 'createdAt'], description: 'Every machine-translated sentence, reused so nothing is translated twice.' },
|
|
49
|
+
access: { read: ({ req }) => Boolean(req.user), create: () => false, update: () => false, delete: ({ req }) => Boolean(req.user) },
|
|
50
|
+
fields: [
|
|
51
|
+
{ name: 'key', type: 'text', required: true, unique: true, admin: { hidden: true } },
|
|
52
|
+
{ name: 'locale', type: 'text', required: true, index: true },
|
|
53
|
+
{ name: 'source', type: 'textarea', required: true },
|
|
54
|
+
{ name: 'text', type: 'textarea', required: true },
|
|
55
|
+
],
|
|
56
|
+
timestamps: true,
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* Polyglot: keeps your content translated into every language of your Payload
|
|
60
|
+
* localization, a little at a time.
|
|
61
|
+
*
|
|
62
|
+
* - The fields you list become per-language (Payload localization).
|
|
63
|
+
* - Publishing in the default language translates the other languages (in
|
|
64
|
+
* Next.js after the response); `syncAll()` sweeps everything (cron/script),
|
|
65
|
+
* optionally through the translator's batch API.
|
|
66
|
+
* - A fingerprint of each document's text skips unchanged documents; a memory of
|
|
67
|
+
* every sentence skips known text, so edits only pay for what changed.
|
|
68
|
+
* - Placeholder copy (lorem ipsum) is never sent; languages marked "Reviewed by a
|
|
69
|
+
* human" are never overwritten (they are flagged when the source changes).
|
|
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.
|
|
76
|
+
*/
|
|
77
|
+
export const polyglotPlugin = (options) => (config) => {
|
|
78
|
+
const loc = config.localization;
|
|
79
|
+
if (!loc)
|
|
80
|
+
throw new Error('payload-plugin-polyglot: enable Payload `localization` first.');
|
|
81
|
+
const codes = loc.locales.map((l) => (typeof l === 'string' ? l : l.code));
|
|
82
|
+
const resolved = {
|
|
83
|
+
collections: options.collections,
|
|
84
|
+
translator: options.translator,
|
|
85
|
+
enabled: options.enabled,
|
|
86
|
+
include: options.include,
|
|
87
|
+
keep: options.keep ?? [],
|
|
88
|
+
translatorFor: options.translatorFor,
|
|
89
|
+
maxCharactersPerRun: options.maxCharactersPerRun,
|
|
90
|
+
skip: options.skip ?? FILLER,
|
|
91
|
+
skipKeys: [...SKIP_KEYS, ...(options.skipKeys ?? [])],
|
|
92
|
+
identityFields: options.identityFields ?? ['slug', 'generateSlug', 'path'],
|
|
93
|
+
memorySlug: options.memorySlug ?? 'polyglot-memory',
|
|
94
|
+
stateSlug: options.stateSlug ?? 'polyglot-state',
|
|
95
|
+
statusField: options.statusField ?? 'translations',
|
|
96
|
+
writeContext: options.writeContext,
|
|
97
|
+
describe: options.describe,
|
|
98
|
+
onPublish: options.onPublish ?? true,
|
|
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',
|
|
105
|
+
adminView: options.adminView ?? true,
|
|
106
|
+
defaultLocale: loc.defaultLocale,
|
|
107
|
+
locales: codes.filter((c) => c !== loc.defaultLocale),
|
|
108
|
+
};
|
|
109
|
+
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 });
|
|
110
|
+
const keepIdentity = ({ data, originalDoc, req }) => {
|
|
111
|
+
if (!originalDoc || !req.locale || req.locale === resolved.defaultLocale || req.locale === 'all')
|
|
112
|
+
return data;
|
|
113
|
+
for (const k of resolved.identityFields)
|
|
114
|
+
if (k in originalDoc)
|
|
115
|
+
data[k] = originalDoc[k];
|
|
116
|
+
return data;
|
|
117
|
+
};
|
|
118
|
+
const onPublish = async ({ collection, doc, req, context }) => {
|
|
119
|
+
if (!resolved.onPublish || context?.polyglot || (req.locale && req.locale !== resolved.defaultLocale))
|
|
120
|
+
return doc;
|
|
121
|
+
if (!included(resolved, collection.slug, doc))
|
|
122
|
+
return doc;
|
|
123
|
+
if (resolved.enabled && !(await resolved.enabled({ payload: req.payload, req })))
|
|
124
|
+
return doc;
|
|
125
|
+
if ((await loadSettings(req.payload, resolved)).paused)
|
|
126
|
+
return doc;
|
|
127
|
+
const run = () => translateDocument(req.payload, collection.slug, String(doc.id), { inRequest: true }).catch((err) => req.payload.logger.error({ err }, 'Polyglot: publish translation failed'));
|
|
128
|
+
try {
|
|
129
|
+
const { after } = await import('next/server.js');
|
|
130
|
+
after(run);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Not in a Next.js request (scripts, tests): the sweep picks it up.
|
|
134
|
+
}
|
|
135
|
+
return doc;
|
|
136
|
+
};
|
|
137
|
+
const statusField = {
|
|
138
|
+
name: resolved.statusField,
|
|
139
|
+
type: 'group',
|
|
140
|
+
admin: { position: 'sidebar', description: 'Machine translations show a notice until a person reviews them.' },
|
|
141
|
+
fields: [
|
|
142
|
+
{
|
|
143
|
+
name: 'reviewed',
|
|
144
|
+
label: 'Reviewed by a human',
|
|
145
|
+
type: 'select',
|
|
146
|
+
hasMany: true,
|
|
147
|
+
options: labels,
|
|
148
|
+
admin: { description: 'Reviewed languages are never re-translated automatically.' },
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
const components = config.admin?.components ?? {};
|
|
153
|
+
return {
|
|
154
|
+
...config,
|
|
155
|
+
custom: { ...config.custom, polyglot: resolved },
|
|
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,
|
|
158
|
+
admin: resolved.adminView
|
|
159
|
+
? {
|
|
160
|
+
...config.admin,
|
|
161
|
+
components: {
|
|
162
|
+
...components,
|
|
163
|
+
afterNavLinks: [...(components.afterNavLinks ?? []), '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotNavLink'],
|
|
164
|
+
beforeDashboard: [...(components.beforeDashboard ?? []), '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotStopBanner'],
|
|
165
|
+
views: { ...components.views, polyglot: { Component: '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotView', path: '/polyglot' } },
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
: config.admin,
|
|
169
|
+
collections: [
|
|
170
|
+
...(config.collections ?? []).map((c) => {
|
|
171
|
+
const targets = resolved.collections[c.slug];
|
|
172
|
+
if (!targets)
|
|
173
|
+
return c;
|
|
174
|
+
return {
|
|
175
|
+
...c,
|
|
176
|
+
fields: [...localizeFields(c.fields, new Set(targets)), statusField],
|
|
177
|
+
hooks: {
|
|
178
|
+
...c.hooks,
|
|
179
|
+
beforeChange: [...(c.hooks?.beforeChange ?? []), keepIdentity],
|
|
180
|
+
afterChange: [...(c.hooks?.afterChange ?? []), onPublish],
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}),
|
|
184
|
+
...((config.collections ?? []).some((c) => c.slug === resolved.memorySlug) ? [] : [memoryCollection(resolved.memorySlug)]),
|
|
185
|
+
...((config.collections ?? []).some((c) => c.slug === resolved.stateSlug) ? [] : [stateCollection(resolved.stateSlug)]),
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
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 (⚠ while translation is stopped). */
|
|
6
|
+
export declare function PolyglotNavLink({ payload, user }: {
|
|
7
|
+
payload: Payload;
|
|
8
|
+
user?: unknown;
|
|
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>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { DefaultTemplate } from '@payloadcms/next/templates';
|
|
3
|
+
import { PolyglotDashboard } from '../client/PolyglotDashboard.js';
|
|
4
|
+
import { loadSettings, STOP_HELP } from '../settings.js';
|
|
5
|
+
import { polyglotOf } from '../translate.js';
|
|
6
|
+
const labelOf = (payload, slug) => {
|
|
7
|
+
const l = payload.collections[slug]?.config.labels?.plural;
|
|
8
|
+
return typeof l === 'string' ? l : slug;
|
|
9
|
+
};
|
|
10
|
+
/** /admin/polyglot: coverage per collection and language, Translate now. */
|
|
11
|
+
export async function PolyglotView({ initPageResult, params, searchParams }) {
|
|
12
|
+
const { req, permissions, visibleEntities, locale } = initPageResult;
|
|
13
|
+
const o = polyglotOf(req.payload);
|
|
14
|
+
const allowed = Boolean(req.user) && (await (o.canManage ?? (() => true))({ req }));
|
|
15
|
+
const collections = Object.keys(o.collections);
|
|
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." }))] }) }));
|
|
17
|
+
}
|
|
18
|
+
const mayManage = async (payload, user) => {
|
|
19
|
+
const o = polyglotOf(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)))
|
|
25
|
+
return null;
|
|
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 })] }));
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PolyglotNavLink, PolyglotStopBanner, PolyglotView } from './PolyglotView.js';
|
|
@@ -0,0 +1 @@
|
|
|
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>;
|
package/dist/settings.js
ADDED
|
@@ -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
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Default placeholder copy that is never translated (nor paid for): any text with three or more lorem ipsum words, wherever it starts. */
|
|
2
|
+
export declare const FILLER: RegExp;
|
|
3
|
+
/** Keys whose string values are never visible text. */
|
|
4
|
+
export declare const SKIP_KEYS: string[];
|
|
5
|
+
export type Json = string | number | boolean | null | Json[] | {
|
|
6
|
+
[k: string]: Json;
|
|
7
|
+
};
|
|
8
|
+
type Rules = {
|
|
9
|
+
skip: RegExp;
|
|
10
|
+
skipKeys: Set<string>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Visits every visible string in a value: words of rich-text (Lexical) text
|
|
14
|
+
* nodes, and plain strings outside skipped keys. `set` replaces it in place.
|
|
15
|
+
*/
|
|
16
|
+
export declare function visitText(node: Json, rules: Rules, onText: (text: string, set: (t: string) => void) => void): void;
|
|
17
|
+
/** All visible strings of the listed paths, in order. */
|
|
18
|
+
export declare function textsOf(source: Record<string, Json>, paths: string[], rules: Rules): string[];
|
|
19
|
+
export declare const fingerprint: (texts: string[]) => string;
|
|
20
|
+
export declare const memoryKey: (locale: string, text: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* Visits the text of exactly the listed paths of a document: `title` visits the
|
|
23
|
+
* whole field; `sections.heading` only the heading of each section (other fields
|
|
24
|
+
* of the rows, shared across languages, are never touched).
|
|
25
|
+
*/
|
|
26
|
+
export declare function visitPaths(source: Record<string, Json>, paths: string[], rules: Rules, onText: (text: string, set: (t: string) => void) => void): void;
|
|
27
|
+
export {};
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
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');
|
|
5
|
+
/** Keys whose string values are never visible text. */
|
|
6
|
+
export const SKIP_KEYS = [
|
|
7
|
+
'id', 'url', 'href', 'slug', 'path', 'key', 'type', 'blockType', 'blockName', 'format', 'mode', 'direction', 'version',
|
|
8
|
+
'style', 'detail', 'relationTo', 'value', 'tag', 'listType', 'textFormat', 'textStyle', 'linkType', 'appearance',
|
|
9
|
+
'reference', 'icon', 'status', 'newTab', 'indent', 'start', 'checked', 'language', 'mediaType', 'collection',
|
|
10
|
+
];
|
|
11
|
+
/** Strings that are links, ids, slugs, option values or dates rather than text. */
|
|
12
|
+
const NOT_TEXT = /^(https?:\/\/|mailto:|\/|#)|^[a-f0-9]{24}$|^[a-z0-9]+(-[a-z0-9]+)*$|^\d{4}-\d{2}-\d{2}/;
|
|
13
|
+
const isText = (v, r) => Boolean(v.trim()) && !NOT_TEXT.test(v.trim()) && !r.skip.test(v);
|
|
14
|
+
/**
|
|
15
|
+
* Visits every visible string in a value: words of rich-text (Lexical) text
|
|
16
|
+
* nodes, and plain strings outside skipped keys. `set` replaces it in place.
|
|
17
|
+
*/
|
|
18
|
+
export function visitText(node, rules, onText) {
|
|
19
|
+
if (Array.isArray(node)) {
|
|
20
|
+
node.forEach((child, i) => {
|
|
21
|
+
if (typeof child === 'string') {
|
|
22
|
+
if (isText(child, rules))
|
|
23
|
+
onText(child, (t) => (node[i] = t));
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
visitText(child, rules, onText);
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!node || typeof node !== 'object')
|
|
31
|
+
return;
|
|
32
|
+
const lexicalText = node.type === 'text' && typeof node.text === 'string';
|
|
33
|
+
for (const [k, v] of Object.entries(node)) {
|
|
34
|
+
if (typeof v === 'string') {
|
|
35
|
+
const wanted = lexicalText ? k === 'text' && !rules.skip.test(v) && Boolean(v.trim()) : !rules.skipKeys.has(k) && isText(v, rules);
|
|
36
|
+
if (wanted)
|
|
37
|
+
onText(v, (t) => (node[k] = t));
|
|
38
|
+
}
|
|
39
|
+
else if (v && typeof v === 'object') {
|
|
40
|
+
if (lexicalText || rules.skipKeys.has(k))
|
|
41
|
+
continue;
|
|
42
|
+
visitText(v, rules, onText);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** All visible strings of the listed paths, in order. */
|
|
47
|
+
export function textsOf(source, paths, rules) {
|
|
48
|
+
const found = [];
|
|
49
|
+
visitPaths(source, paths, rules, (t) => found.push(t));
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
export const fingerprint = (texts) => crypto.createHash('sha256').update(JSON.stringify(texts)).digest('hex').slice(0, 32);
|
|
53
|
+
export const memoryKey = (locale, text) => crypto.createHash('sha256').update(`${locale}\u0000${text}`).digest('hex');
|
|
54
|
+
/**
|
|
55
|
+
* Visits the text of exactly the listed paths of a document: `title` visits the
|
|
56
|
+
* whole field; `sections.heading` only the heading of each section (other fields
|
|
57
|
+
* of the rows, shared across languages, are never touched).
|
|
58
|
+
*/
|
|
59
|
+
export function visitPaths(source, paths, rules, onText) {
|
|
60
|
+
const walk = (holder, segs) => {
|
|
61
|
+
const [head, ...rest] = segs;
|
|
62
|
+
if (!head || !(head in holder))
|
|
63
|
+
return;
|
|
64
|
+
const value = holder[head];
|
|
65
|
+
if (!rest.length) {
|
|
66
|
+
if (typeof value === 'string') {
|
|
67
|
+
if (isText(value, rules))
|
|
68
|
+
onText(value, (t) => (holder[head] = t));
|
|
69
|
+
}
|
|
70
|
+
else if (value && typeof value === 'object')
|
|
71
|
+
visitText(value, rules, onText);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(value))
|
|
75
|
+
for (const row of value)
|
|
76
|
+
if (row && typeof row === 'object' && !Array.isArray(row))
|
|
77
|
+
walk(row, rest);
|
|
78
|
+
else if (value && typeof value === 'object' && !Array.isArray(value))
|
|
79
|
+
walk(value, rest);
|
|
80
|
+
};
|
|
81
|
+
for (const p of paths)
|
|
82
|
+
walk(source, p.split('.'));
|
|
83
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Payload } from 'payload';
|
|
2
|
+
import { type Settings } from './settings.js';
|
|
3
|
+
import { type DocumentResult, type ResolvedPolyglot, type StopReason } from './types.js';
|
|
4
|
+
export declare const polyglotOf: (payload: Payload) => ResolvedPolyglot;
|
|
5
|
+
/** Is this document one Polyglot translates? */
|
|
6
|
+
export declare function included(o: ResolvedPolyglot, collection: string, doc: Record<string, unknown>): boolean;
|
|
7
|
+
/** A per-run character budget (free tiers). */
|
|
8
|
+
export declare class Budget {
|
|
9
|
+
private readonly max?;
|
|
10
|
+
used: number;
|
|
11
|
+
constructor(max?: number | undefined);
|
|
12
|
+
allows(chars: number): boolean;
|
|
13
|
+
spend(chars: number): void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Bring one document up to date in every language (or `locales`). Unchanged
|
|
17
|
+
* source costs nothing (the fingerprint matches); known sentences come from
|
|
18
|
+
* memory; only new ones reach the translator. Reviewed languages are never
|
|
19
|
+
* overwritten: they are marked stale. Languages are written one at a time.
|
|
20
|
+
*/
|
|
21
|
+
export declare function translateDocument(payload: Payload, collection: string, id: string, opts?: {
|
|
22
|
+
locales?: string[];
|
|
23
|
+
dryRun?: boolean;
|
|
24
|
+
memoryOnly?: boolean;
|
|
25
|
+
inRequest?: boolean;
|
|
26
|
+
budget?: Budget;
|
|
27
|
+
settings?: Settings;
|
|
28
|
+
}): Promise<DocumentResult>;
|
|
29
|
+
export type SyncReport = {
|
|
30
|
+
documents: number;
|
|
31
|
+
upToDate: number;
|
|
32
|
+
translated: number;
|
|
33
|
+
pending: number;
|
|
34
|
+
reviewedStale: number;
|
|
35
|
+
failed: number;
|
|
36
|
+
characters: number;
|
|
37
|
+
stopped: 'done' | 'budget' | 'quota' | 'paused';
|
|
38
|
+
/** With `stopped: 'quota'`: credits, quota or auth (see Translation settings). */
|
|
39
|
+
stopReason?: StopReason;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Find all content and bring it up to date (the sweep). With a translator that
|
|
43
|
+
* has `batch` and `useBatch`, every missing sentence of every language goes in
|
|
44
|
+
* one batch first (cheaper), then documents are written from memory. Stops
|
|
45
|
+
* cleanly at `maxCharactersPerRun` or when the engine's quota is used up; the
|
|
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).
|
|
49
|
+
*/
|
|
50
|
+
export declare function syncAll(payload: Payload, opts?: {
|
|
51
|
+
dryRun?: boolean;
|
|
52
|
+
useBatch?: boolean;
|
|
53
|
+
limit?: number;
|
|
54
|
+
collections?: string[];
|
|
55
|
+
locales?: string[];
|
|
56
|
+
force?: boolean;
|
|
57
|
+
}): Promise<SyncReport>;
|
|
58
|
+
/** Translation status of one document in one language, for notices: source, reviewed, stale, machine or pending. */
|
|
59
|
+
export declare function languageStatus(payload: Payload, collection: string, doc: Record<string, unknown>, locale: string): Promise<"source" | "stale" | "pending" | "reviewed" | "machine">;
|
|
60
|
+
export type CoverageRow = {
|
|
61
|
+
collection: string;
|
|
62
|
+
documents: number;
|
|
63
|
+
/** Per language: up to date, waiting, reviewed by a human, reviewed but the source changed. */
|
|
64
|
+
locales: Record<string, {
|
|
65
|
+
translated: number;
|
|
66
|
+
pending: number;
|
|
67
|
+
reviewed: number;
|
|
68
|
+
stale: number;
|
|
69
|
+
}>;
|
|
70
|
+
};
|
|
71
|
+
/** Translation coverage per collection and language (for the admin view and reports). */
|
|
72
|
+
export declare function coverage(payload: Payload, opts?: {
|
|
73
|
+
collections?: string[];
|
|
74
|
+
}): Promise<CoverageRow[]>;
|