@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.
@@ -0,0 +1,23 @@
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 { polyglotOf } from '../translate.js';
5
+ const labelOf = (payload, slug) => {
6
+ const l = payload.collections[slug]?.config.labels?.plural;
7
+ return typeof l === 'string' ? l : slug;
8
+ };
9
+ /** /admin/polyglot: coverage per collection and language, Translate now. */
10
+ export async function PolyglotView({ initPageResult, params, searchParams }) {
11
+ const { req, permissions, visibleEntities, locale } = initPageResult;
12
+ const o = polyglotOf(req.payload);
13
+ const allowed = Boolean(req.user) && (await (o.canManage ?? (() => true))({ req }));
14
+ const collections = Object.keys(o.collections);
15
+ return (_jsx(DefaultTemplate, { i18n: req.i18n, locale: locale, params: params, payload: req.payload, permissions: permissions, req: req, searchParams: searchParams, user: req.user ?? undefined, visibleEntities: visibleEntities, children: _jsxs("div", { className: "gutter--left gutter--right", style: { paddingBlock: 'calc(var(--base) * 2)' }, children: [_jsx("h1", { children: "Translations" }), allowed ? (_jsx(PolyglotDashboard, { apiRoute: req.payload.config.routes.api, collections: collections, labels: Object.fromEntries(collections.map((c) => [c, labelOf(req.payload, c)])) })) : (_jsx("p", { children: "You don\u2019t have access to translations." }))] }) }));
16
+ }
17
+ /** Nav link, for people who may manage translations. */
18
+ export async function PolyglotNavLink({ payload, user }) {
19
+ const o = polyglotOf(payload);
20
+ if (!user || !(await (o.canManage ?? (() => true))({ req: { user, payload } })))
21
+ return null;
22
+ return (_jsx("a", { className: "nav__link", href: `${payload.config.routes.admin}/polyglot`, style: { display: 'block', marginTop: 'calc(var(--base) / 2)' }, children: _jsx("span", { className: "nav__link-label", children: "Translations" }) }));
23
+ }
@@ -0,0 +1 @@
1
+ export { PolyglotNavLink, PolyglotView } from './PolyglotView.js';
@@ -0,0 +1 @@
1
+ export { PolyglotNavLink, PolyglotView } from './PolyglotView.js';
package/dist/text.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /** Default placeholder copy that is never translated (nor paid for). */
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,82 @@
1
+ import crypto from 'node:crypto';
2
+ /** Default placeholder copy that is never translated (nor paid for). */
3
+ export const FILLER = /lorem ipsum|dolor sit amet|consectetur adipiscing|sed do eiusmod|ut labore et dolore/i;
4
+ /** Keys whose string values are never visible text. */
5
+ export const SKIP_KEYS = [
6
+ 'id', 'url', 'href', 'slug', 'path', 'key', 'type', 'blockType', 'blockName', 'format', 'mode', 'direction', 'version',
7
+ 'style', 'detail', 'relationTo', 'value', 'tag', 'listType', 'textFormat', 'textStyle', 'linkType', 'appearance',
8
+ 'reference', 'icon', 'status', 'newTab', 'indent', 'start', 'checked', 'language', 'mediaType', 'collection',
9
+ ];
10
+ /** Strings that are links, ids, slugs, option values or dates rather than text. */
11
+ const NOT_TEXT = /^(https?:\/\/|mailto:|\/|#)|^[a-f0-9]{24}$|^[a-z0-9]+(-[a-z0-9]+)*$|^\d{4}-\d{2}-\d{2}/;
12
+ const isText = (v, r) => Boolean(v.trim()) && !NOT_TEXT.test(v.trim()) && !r.skip.test(v);
13
+ /**
14
+ * Visits every visible string in a value: words of rich-text (Lexical) text
15
+ * nodes, and plain strings outside skipped keys. `set` replaces it in place.
16
+ */
17
+ export function visitText(node, rules, onText) {
18
+ if (Array.isArray(node)) {
19
+ node.forEach((child, i) => {
20
+ if (typeof child === 'string') {
21
+ if (isText(child, rules))
22
+ onText(child, (t) => (node[i] = t));
23
+ }
24
+ else
25
+ visitText(child, rules, onText);
26
+ });
27
+ return;
28
+ }
29
+ if (!node || typeof node !== 'object')
30
+ return;
31
+ const lexicalText = node.type === 'text' && typeof node.text === 'string';
32
+ for (const [k, v] of Object.entries(node)) {
33
+ if (typeof v === 'string') {
34
+ const wanted = lexicalText ? k === 'text' && !rules.skip.test(v) && Boolean(v.trim()) : !rules.skipKeys.has(k) && isText(v, rules);
35
+ if (wanted)
36
+ onText(v, (t) => (node[k] = t));
37
+ }
38
+ else if (v && typeof v === 'object') {
39
+ if (lexicalText || rules.skipKeys.has(k))
40
+ continue;
41
+ visitText(v, rules, onText);
42
+ }
43
+ }
44
+ }
45
+ /** All visible strings of the listed paths, in order. */
46
+ export function textsOf(source, paths, rules) {
47
+ const found = [];
48
+ visitPaths(source, paths, rules, (t) => found.push(t));
49
+ return found;
50
+ }
51
+ export const fingerprint = (texts) => crypto.createHash('sha256').update(JSON.stringify(texts)).digest('hex').slice(0, 32);
52
+ export const memoryKey = (locale, text) => crypto.createHash('sha256').update(`${locale}\u0000${text}`).digest('hex');
53
+ /**
54
+ * Visits the text of exactly the listed paths of a document: `title` visits the
55
+ * whole field; `sections.heading` only the heading of each section (other fields
56
+ * of the rows, shared across languages, are never touched).
57
+ */
58
+ export function visitPaths(source, paths, rules, onText) {
59
+ const walk = (holder, segs) => {
60
+ const [head, ...rest] = segs;
61
+ if (!head || !(head in holder))
62
+ return;
63
+ const value = holder[head];
64
+ if (!rest.length) {
65
+ if (typeof value === 'string') {
66
+ if (isText(value, rules))
67
+ onText(value, (t) => (holder[head] = t));
68
+ }
69
+ else if (value && typeof value === 'object')
70
+ visitText(value, rules, onText);
71
+ return;
72
+ }
73
+ if (Array.isArray(value))
74
+ for (const row of value)
75
+ if (row && typeof row === 'object' && !Array.isArray(row))
76
+ walk(row, rest);
77
+ else if (value && typeof value === 'object' && !Array.isArray(value))
78
+ walk(value, rest);
79
+ };
80
+ for (const p of paths)
81
+ walk(source, p.split('.'));
82
+ }
@@ -0,0 +1,67 @@
1
+ import type { Payload } from 'payload';
2
+ import { type DocumentResult, type ResolvedPolyglot } from './types.js';
3
+ export declare const polyglotOf: (payload: Payload) => ResolvedPolyglot;
4
+ /** Is this document one Polyglot translates? */
5
+ export declare function included(o: ResolvedPolyglot, collection: string, doc: Record<string, unknown>): boolean;
6
+ /** A per-run character budget (free tiers). */
7
+ export declare class Budget {
8
+ private readonly max?;
9
+ used: number;
10
+ constructor(max?: number | undefined);
11
+ allows(chars: number): boolean;
12
+ spend(chars: number): void;
13
+ }
14
+ /**
15
+ * Bring one document up to date in every language (or `locales`). Unchanged
16
+ * source costs nothing (the fingerprint matches); known sentences come from
17
+ * memory; only new ones reach the translator. Reviewed languages are never
18
+ * overwritten: they are marked stale. Languages are written one at a time.
19
+ */
20
+ export declare function translateDocument(payload: Payload, collection: string, id: string, opts?: {
21
+ locales?: string[];
22
+ dryRun?: boolean;
23
+ memoryOnly?: boolean;
24
+ inRequest?: boolean;
25
+ budget?: Budget;
26
+ }): Promise<DocumentResult>;
27
+ export type SyncReport = {
28
+ documents: number;
29
+ upToDate: number;
30
+ translated: number;
31
+ pending: number;
32
+ reviewedStale: number;
33
+ failed: number;
34
+ characters: number;
35
+ stopped: 'done' | 'budget' | 'quota';
36
+ };
37
+ /**
38
+ * Find all content and bring it up to date (the sweep). With a translator that
39
+ * has `batch` and `useBatch`, every missing sentence of every language goes in
40
+ * one batch first (cheaper), then documents are written from memory. Stops
41
+ * cleanly at `maxCharactersPerRun` or when the engine's quota is used up; the
42
+ * next run continues. `dryRun` only reports what is pending.
43
+ */
44
+ export declare function syncAll(payload: Payload, opts?: {
45
+ dryRun?: boolean;
46
+ useBatch?: boolean;
47
+ limit?: number;
48
+ collections?: string[];
49
+ locales?: string[];
50
+ }): Promise<SyncReport>;
51
+ /** Translation status of one document in one language, for notices: source, reviewed, stale, machine or pending. */
52
+ export declare function languageStatus(payload: Payload, collection: string, doc: Record<string, unknown>, locale: string): Promise<"source" | "stale" | "pending" | "reviewed" | "machine">;
53
+ export type CoverageRow = {
54
+ collection: string;
55
+ documents: number;
56
+ /** Per language: up to date, waiting, reviewed by a human, reviewed but the source changed. */
57
+ locales: Record<string, {
58
+ translated: number;
59
+ pending: number;
60
+ reviewed: number;
61
+ stale: number;
62
+ }>;
63
+ };
64
+ /** Translation coverage per collection and language (for the admin view and reports). */
65
+ export declare function coverage(payload: Payload, opts?: {
66
+ collections?: string[];
67
+ }): Promise<CoverageRow[]>;
@@ -0,0 +1,300 @@
1
+ import { fingerprint, memoryKey, textsOf, visitPaths } from './text.js';
2
+ import { QuotaExceededError } from './types.js';
3
+ export const polyglotOf = (payload) => payload.config.custom?.polyglot;
4
+ const rulesOf = (o) => ({ skip: o.skip, skipKeys: new Set(o.skipKeys) });
5
+ const topFields = (o, slug) => [...new Set((o.collections[slug] ?? []).map((p) => p.split('.')[0]))];
6
+ const stateKey = (collection, id, locale) => `${collection}:${id}:${locale}`;
7
+ const engineFor = (o, locale) => o.translatorFor?.(locale) ?? o.translator;
8
+ /** Is this document one Polyglot translates? */
9
+ export function included(o, collection, doc) {
10
+ if (!o.collections[collection])
11
+ return false;
12
+ if (o.include)
13
+ return o.include({ collection, doc });
14
+ return doc._status === undefined || doc._status === 'published';
15
+ }
16
+ async function loadSource(payload, o, slug, id) {
17
+ const doc = (await payload.findByID({
18
+ collection: slug,
19
+ id,
20
+ locale: o.defaultLocale,
21
+ depth: 0,
22
+ draft: false,
23
+ overrideAccess: true,
24
+ disableErrors: true,
25
+ }));
26
+ if (!doc || !included(o, slug, doc))
27
+ return null;
28
+ const source = Object.fromEntries(topFields(o, slug).filter((k) => doc[k] !== undefined && doc[k] !== null).map((k) => [k, structuredClone(doc[k])]));
29
+ const texts = textsOf(source, o.collections[slug], rulesOf(o));
30
+ const reviewed = new Set((doc[o.statusField]?.reviewed ?? []));
31
+ const { docs } = await payload.find({
32
+ collection: o.stateSlug,
33
+ where: { and: [{ collection: { equals: slug } }, { doc: { equals: id } }] },
34
+ pagination: false,
35
+ depth: 0,
36
+ overrideAccess: true,
37
+ });
38
+ const state = new Map(docs.map((r) => [r.locale, r]));
39
+ return { doc, source, texts, hash: fingerprint(texts), state, reviewed };
40
+ }
41
+ async function saveState(payload, o, collection, id, locale, hash, stale, existing) {
42
+ const data = { key: stateKey(collection, id, locale), collection, doc: id, locale, hash, stale, at: new Date().toISOString() };
43
+ if (existing)
44
+ await payload.update({ collection: o.stateSlug, id: existing.id, data: data, overrideAccess: true });
45
+ else
46
+ await payload.create({ collection: o.stateSlug, data: data, overrideAccess: true });
47
+ }
48
+ /** Translations known in memory for these texts in one language. */
49
+ async function recall(payload, o, locale, texts) {
50
+ const known = new Map();
51
+ if (!texts.length)
52
+ return known;
53
+ const { docs } = await payload.find({
54
+ collection: o.memorySlug,
55
+ where: { key: { in: texts.map((t) => memoryKey(locale, t)) } },
56
+ pagination: false,
57
+ depth: 0,
58
+ overrideAccess: true,
59
+ select: { key: true, text: true },
60
+ });
61
+ const byKey = new Map(docs.map((d) => [d.key, d.text]));
62
+ for (const t of texts) {
63
+ const hit = byKey.get(memoryKey(locale, t));
64
+ if (hit)
65
+ known.set(t, hit);
66
+ }
67
+ return known;
68
+ }
69
+ async function remember(payload, o, locale, pairs) {
70
+ for (const [source, text] of pairs)
71
+ await payload
72
+ .create({ collection: o.memorySlug, data: { key: memoryKey(locale, source), locale, source, text }, overrideAccess: true })
73
+ .catch(() => undefined); // already remembered (unique key)
74
+ }
75
+ const CHUNK = 80;
76
+ const chunks = (list) => Array.from({ length: Math.ceil(list.length / CHUNK) }, (_, i) => list.slice(i * CHUNK, (i + 1) * CHUNK));
77
+ const argsFor = (o, locale, items, context) => ({
78
+ locale,
79
+ items,
80
+ context,
81
+ sourceLocale: o.defaultLocale,
82
+ keep: o.keep,
83
+ });
84
+ /** A per-run character budget (free tiers). */
85
+ export class Budget {
86
+ max;
87
+ used = 0;
88
+ constructor(max) {
89
+ this.max = max;
90
+ }
91
+ allows(chars) {
92
+ return this.max === undefined || this.used + chars <= this.max;
93
+ }
94
+ spend(chars) {
95
+ this.used += chars;
96
+ }
97
+ }
98
+ /**
99
+ * Bring one document up to date in every language (or `locales`). Unchanged
100
+ * source costs nothing (the fingerprint matches); known sentences come from
101
+ * memory; only new ones reach the translator. Reviewed languages are never
102
+ * overwritten: they are marked stale. Languages are written one at a time.
103
+ */
104
+ export async function translateDocument(payload, collection, id, opts = {}) {
105
+ const o = polyglotOf(payload);
106
+ const result = { translated: [], upToDate: [], reviewedStale: [], failed: [], pending: [] };
107
+ const src = await loadSource(payload, o, collection, id);
108
+ if (!src)
109
+ return result;
110
+ const todo = [];
111
+ for (const locale of opts.locales ?? o.locales) {
112
+ const row = src.state.get(locale);
113
+ if (row?.hash === src.hash)
114
+ result.upToDate.push(locale);
115
+ else if (src.reviewed.has(locale)) {
116
+ result.reviewedStale.push(locale);
117
+ if (!opts.dryRun && row && !row.stale)
118
+ await saveState(payload, o, collection, id, locale, row.hash, true, row);
119
+ }
120
+ else
121
+ todo.push(locale);
122
+ }
123
+ if (opts.dryRun)
124
+ return { ...result, pending: todo };
125
+ const unique = [...new Set(src.texts)];
126
+ const context = { ...(o.writeContext?.({ inRequest: Boolean(opts.inRequest) }) ?? {}), polyglot: true };
127
+ for (const [n, locale] of todo.entries()) {
128
+ try {
129
+ let known = await recall(payload, o, locale, unique);
130
+ const missing = unique.filter((t) => !known.has(t));
131
+ if (missing.length && !opts.memoryOnly) {
132
+ const chars = missing.reduce((s, t) => s + t.length, 0);
133
+ if (opts.budget && !opts.budget.allows(chars)) {
134
+ result.pending.push(...todo.slice(n));
135
+ break;
136
+ }
137
+ for (const part of chunks(missing)) {
138
+ const items = part.map((text, i) => ({ key: String(i), text }));
139
+ const out = await engineFor(o, locale).translate(argsFor(o, locale, items, o.describe?.(collection)));
140
+ opts.budget?.spend(part.reduce((s, t) => s + t.length, 0));
141
+ await remember(payload, o, locale, items.flatMap((it) => (out.get(it.key) ? [[it.text, out.get(it.key)]] : [])));
142
+ }
143
+ known = await recall(payload, o, locale, unique);
144
+ }
145
+ if (unique.some((t) => !known.has(t))) {
146
+ ;
147
+ (opts.memoryOnly ? result.pending : result.failed).push(locale);
148
+ continue;
149
+ }
150
+ const data = structuredClone(src.source);
151
+ visitPaths(data, o.collections[collection], rulesOf(o), (t, set) => set(known.get(t) ?? t));
152
+ await payload.update({
153
+ collection: collection,
154
+ id,
155
+ locale: locale,
156
+ data: { ...data, ...(src.doc._status ? { _status: 'published' } : {}) },
157
+ overrideAccess: true,
158
+ context,
159
+ });
160
+ await saveState(payload, o, collection, id, locale, src.hash, false, src.state.get(locale));
161
+ result.translated.push(locale);
162
+ }
163
+ catch (error) {
164
+ if (error instanceof QuotaExceededError) {
165
+ result.pending.push(...todo.slice(n));
166
+ throw Object.assign(error, { result });
167
+ }
168
+ payload.logger.error({ err: error, collection, id, locale }, 'Polyglot: translation failed');
169
+ result.failed.push(locale);
170
+ }
171
+ }
172
+ return result;
173
+ }
174
+ /**
175
+ * Find all content and bring it up to date (the sweep). With a translator that
176
+ * has `batch` and `useBatch`, every missing sentence of every language goes in
177
+ * one batch first (cheaper), then documents are written from memory. Stops
178
+ * cleanly at `maxCharactersPerRun` or when the engine's quota is used up; the
179
+ * next run continues. `dryRun` only reports what is pending.
180
+ */
181
+ export async function syncAll(payload, opts = {}) {
182
+ const o = polyglotOf(payload);
183
+ const report = { documents: 0, upToDate: 0, translated: 0, pending: 0, reviewedStale: 0, failed: 0, characters: 0, stopped: 'done' };
184
+ const budget = new Budget(o.maxCharactersPerRun);
185
+ const targets = [];
186
+ for (const collection of Object.keys(o.collections).filter((c) => !opts.collections || opts.collections.includes(c))) {
187
+ const { docs } = await payload.find({ collection: collection, locale: o.defaultLocale, depth: 0, pagination: false, overrideAccess: true, draft: false });
188
+ for (const d of docs)
189
+ if (included(o, collection, d))
190
+ targets.push({ collection, id: String(d.id) });
191
+ }
192
+ const locales = opts.locales ?? o.locales;
193
+ const work = opts.limit ? targets.slice(0, opts.limit) : targets;
194
+ const batching = Boolean(opts.useBatch && o.translator.batch && !o.translatorFor && !opts.dryRun);
195
+ if (batching) {
196
+ const missing = new Map();
197
+ for (const t of work) {
198
+ const src = await loadSource(payload, o, t.collection, t.id);
199
+ if (!src)
200
+ continue;
201
+ for (const locale of locales) {
202
+ if (src.state.get(locale)?.hash === src.hash || src.reviewed.has(locale))
203
+ continue;
204
+ const set = missing.get(locale) ?? new Set();
205
+ for (const text of src.texts)
206
+ set.add(text);
207
+ missing.set(locale, set);
208
+ }
209
+ }
210
+ const requests = [];
211
+ for (const [locale, set] of missing) {
212
+ const known = await recall(payload, o, locale, [...set]);
213
+ for (const part of chunks([...set].filter((t) => !known.has(t)))) {
214
+ const chars = part.reduce((s, t) => s + t.length, 0);
215
+ if (!budget.allows(chars)) {
216
+ report.stopped = 'budget';
217
+ break;
218
+ }
219
+ budget.spend(chars);
220
+ requests.push(argsFor(o, locale, part.map((text, i) => ({ key: String(i), text }))));
221
+ }
222
+ }
223
+ if (requests.length) {
224
+ const results = await o.translator.batch(requests);
225
+ for (const [i, r] of requests.entries())
226
+ await remember(payload, o, r.locale, r.items.flatMap((it) => (results[i]?.get(it.key) ? [[it.text, results[i].get(it.key)]] : [])));
227
+ }
228
+ }
229
+ for (const t of work) {
230
+ let r;
231
+ try {
232
+ r = await translateDocument(payload, t.collection, t.id, { locales, dryRun: opts.dryRun, memoryOnly: batching, budget: batching ? undefined : budget });
233
+ }
234
+ catch (error) {
235
+ if (!(error instanceof QuotaExceededError))
236
+ throw error;
237
+ r = error.result;
238
+ report.stopped = 'quota';
239
+ }
240
+ report.documents++;
241
+ report.upToDate += r.upToDate.length;
242
+ report.translated += r.translated.length;
243
+ report.pending += r.pending.length;
244
+ report.reviewedStale += r.reviewedStale.length;
245
+ report.failed += r.failed.length;
246
+ if (report.stopped === 'quota')
247
+ break;
248
+ if (!batching && r.pending.length && !opts.dryRun)
249
+ report.stopped = 'budget';
250
+ }
251
+ report.characters = budget.used;
252
+ return report;
253
+ }
254
+ /** Translation status of one document in one language, for notices: source, reviewed, stale, machine or pending. */
255
+ export async function languageStatus(payload, collection, doc, locale) {
256
+ const o = polyglotOf(payload);
257
+ if (locale === o.defaultLocale)
258
+ return 'source';
259
+ const reviewed = (doc[o.statusField]?.reviewed ?? []).includes(locale);
260
+ const { docs } = await payload.find({
261
+ collection: o.stateSlug,
262
+ where: { key: { equals: stateKey(collection, String(doc.id), locale) } },
263
+ limit: 1,
264
+ depth: 0,
265
+ overrideAccess: true,
266
+ });
267
+ const row = docs[0];
268
+ if (reviewed)
269
+ return row?.stale ? 'stale' : 'reviewed';
270
+ return row ? 'machine' : 'pending';
271
+ }
272
+ /** Translation coverage per collection and language (for the admin view and reports). */
273
+ export async function coverage(payload, opts = {}) {
274
+ const o = polyglotOf(payload);
275
+ const rows = [];
276
+ for (const collection of Object.keys(o.collections).filter((c) => !opts.collections || opts.collections.includes(c))) {
277
+ const row = { collection, documents: 0, locales: Object.fromEntries(o.locales.map((l) => [l, { translated: 0, pending: 0, reviewed: 0, stale: 0 }])) };
278
+ const { docs } = await payload.find({ collection: collection, locale: o.defaultLocale, depth: 0, pagination: false, overrideAccess: true, draft: false });
279
+ for (const d of docs) {
280
+ if (!included(o, collection, d))
281
+ continue;
282
+ const src = await loadSource(payload, o, collection, String(d.id));
283
+ if (!src)
284
+ continue;
285
+ row.documents++;
286
+ for (const locale of o.locales) {
287
+ const r = src.state.get(locale);
288
+ const c = row.locales[locale];
289
+ if (src.reviewed.has(locale))
290
+ r && r.hash !== src.hash ? c.stale++ : c.reviewed++;
291
+ else if (r?.hash === src.hash)
292
+ c.translated++;
293
+ else
294
+ c.pending++;
295
+ }
296
+ }
297
+ rows.push(row);
298
+ }
299
+ return rows;
300
+ }
@@ -0,0 +1,84 @@
1
+ import type { Payload, PayloadRequest, RequestContext } from 'payload';
2
+ /** One piece of text to translate, with a caller-chosen key. */
3
+ export type Item = {
4
+ key: string;
5
+ text: string;
6
+ };
7
+ /**
8
+ * A translation engine. `translate` returns translations by key (missing keys
9
+ * stay untranslated). `batch` (optional) translates many requests at once,
10
+ * usually at a lower price and more slowly; the sweep prefers it.
11
+ */
12
+ export type TranslateArgs = {
13
+ locale: string;
14
+ items: Item[];
15
+ context?: string;
16
+ sourceLocale: string;
17
+ keep: string[];
18
+ };
19
+ export type Translator = {
20
+ translate: (args: TranslateArgs) => Promise<Map<string, string>>;
21
+ batch?: (requests: TranslateArgs[]) => Promise<Map<string, string>[]>;
22
+ };
23
+ /** Thrown by a translator when its quota (e.g. a free tier) is used up: the run stops and resumes next time. */
24
+ export declare class QuotaExceededError extends Error {
25
+ constructor(message?: string);
26
+ }
27
+ export type PolyglotOptions = {
28
+ /** Fields to keep in every language, per collection. Dotted paths reach into arrays/groups: `sections.heading`. */
29
+ collections: Record<string, string[]>;
30
+ translator: Translator;
31
+ /** May this request/run call the translator? Default: always. Use it for keys, budgets or environments. */
32
+ enabled?: (args: {
33
+ payload: Payload;
34
+ req?: PayloadRequest;
35
+ }) => boolean | Promise<boolean>;
36
+ /** Which documents to translate. Default: published ones (or all, without drafts). */
37
+ include?: (args: {
38
+ collection: string;
39
+ doc: Record<string, unknown>;
40
+ }) => boolean;
41
+ /** Terms never translated: product names, brands, scientific names… */
42
+ keep?: string[];
43
+ /** Engine per language (e.g. DeepL for European languages, another for the rest). Default: `translator`. */
44
+ translatorFor?: (locale: string) => Translator | undefined;
45
+ /** Stop a sweep after sending this many characters (stay inside a free tier). Default: no cap. */
46
+ maxCharactersPerRun?: number;
47
+ /** Text never sent (placeholders). Default: lorem ipsum and friends. */
48
+ skip?: RegExp;
49
+ /** Keys whose string values are never text (urls, ids, option values…). Added to the defaults. */
50
+ skipKeys?: string[];
51
+ /** Fields that must never change when another language is saved (computed from the default language). */
52
+ identityFields?: string[];
53
+ /** Slug of the translation memory collection. Default `polyglot-memory`. */
54
+ memorySlug?: string;
55
+ /** Slug of the per-language fingerprint collection. Default `polyglot-state`. */
56
+ stateSlug?: string;
57
+ /** Name of the per-document status group (reviewed languages + fingerprints). Default `translations`. */
58
+ statusField?: string;
59
+ /** Context for writes of translated languages, e.g. `{ disableRevalidate: true }` outside a web request. */
60
+ writeContext?: (args: {
61
+ inRequest: boolean;
62
+ }) => RequestContext;
63
+ /** Short description for the translator ("Content of a page on a reptile-care website"). */
64
+ describe?: (collection: string) => string;
65
+ /** Who may see coverage and start translations in the admin. Default: any signed-in user. */
66
+ canManage?: (args: {
67
+ req: PayloadRequest;
68
+ }) => boolean | Promise<boolean>;
69
+ /** Add the Polyglot admin view (coverage per collection and language, Translate now). Default true. */
70
+ adminView?: boolean;
71
+ /** Translate after a document is published (in Next.js, after the response). Default true. */
72
+ onPublish?: boolean;
73
+ };
74
+ export type ResolvedPolyglot = Required<Omit<PolyglotOptions, 'enabled' | 'include' | 'writeContext' | 'describe' | 'translatorFor' | 'maxCharactersPerRun' | 'canManage'>> & Pick<PolyglotOptions, 'enabled' | 'include' | 'writeContext' | 'describe' | 'translatorFor' | 'maxCharactersPerRun' | 'canManage'> & {
75
+ defaultLocale: string;
76
+ locales: string[];
77
+ };
78
+ export type DocumentResult = {
79
+ translated: string[];
80
+ upToDate: string[];
81
+ reviewedStale: string[];
82
+ failed: string[];
83
+ pending: string[];
84
+ };
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ /** Thrown by a translator when its quota (e.g. a free tier) is used up: the run stops and resumes next time. */
2
+ export class QuotaExceededError extends Error {
3
+ constructor(message = 'Translation quota used up') {
4
+ super(message);
5
+ this.name = 'QuotaExceededError';
6
+ }
7
+ }