@form-engine-ts/translator-google-v3 2.5.1
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 +32 -0
- package/dist/index.cjs +224 -0
- package/dist/index.d.cts +38 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +198 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nitta-a
|
|
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
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @form-engine-ts/translator-google-v3
|
|
2
|
+
|
|
3
|
+
Google Cloud Translation Advanced v3 adapter for form-engine-ts. It supports regional endpoints, glossaries, labels,
|
|
4
|
+
UTF-8-aware batch chunking, and transient network/HTTP retry with `Retry-After`-aware full jitter.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm add @form-engine-ts/core @form-engine-ts/translator-google-v3
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { createGoogleV3Translator } from "@form-engine-ts/translator-google-v3";
|
|
16
|
+
|
|
17
|
+
const translator = createGoogleV3Translator({
|
|
18
|
+
projectId: "my-project",
|
|
19
|
+
location: "us-central1",
|
|
20
|
+
getAccessToken: () => getGoogleAccessToken(),
|
|
21
|
+
glossaryConfig: {
|
|
22
|
+
glossary: "projects/my-project/locations/us-central1/glossaries/product-terms"
|
|
23
|
+
},
|
|
24
|
+
labels: { application: "survey" }
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const japanese = await translator.translateText("Thank you", "ja", "en");
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Keep OAuth access tokens on a trusted server. `translateBatch` defaults to at most 250 items and 25,000 UTF-8 bytes per
|
|
31
|
+
request (hard limits: 1,024 items and 30,000 bytes). Network errors, HTTP 429, and HTTP 5xx responses are retried; customize
|
|
32
|
+
batch and retry behavior with `batchLimits` and `retry`.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createGoogleV3Translator: () => createGoogleV3Translator,
|
|
24
|
+
splitTranslationBatch: () => splitTranslationBatch
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
var DEFAULT_ENDPOINT = "https://translation.googleapis.com/v3";
|
|
28
|
+
var DEFAULT_MAX_ITEMS = 250;
|
|
29
|
+
var DEFAULT_MAX_CHARACTERS = 25e3;
|
|
30
|
+
var HARD_MAX_ITEMS = 1024;
|
|
31
|
+
var HARD_MAX_CHARACTERS = 3e4;
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
function requireNonEmpty(value, name) {
|
|
36
|
+
if (typeof value !== "string" || value.trim().length === 0)
|
|
37
|
+
throw new TypeError(`${name} must be a non-empty string.`);
|
|
38
|
+
return value.trim();
|
|
39
|
+
}
|
|
40
|
+
function nonNegativeInteger(value, fallback, name) {
|
|
41
|
+
const resolved = value ?? fallback;
|
|
42
|
+
if (!Number.isSafeInteger(resolved) || resolved < 0) throw new TypeError(`${name} must be a non-negative integer.`);
|
|
43
|
+
return resolved;
|
|
44
|
+
}
|
|
45
|
+
function positiveInteger(value, fallback, name) {
|
|
46
|
+
const resolved = nonNegativeInteger(value, fallback, name);
|
|
47
|
+
if (resolved < 1) throw new TypeError(`${name} must be greater than zero.`);
|
|
48
|
+
return resolved;
|
|
49
|
+
}
|
|
50
|
+
function splitTranslationBatch(texts, limits = {}) {
|
|
51
|
+
if (!Array.isArray(texts) || texts.some((text) => typeof text !== "string")) {
|
|
52
|
+
throw new TypeError("texts must be an array of strings.");
|
|
53
|
+
}
|
|
54
|
+
const maxItems = positiveInteger(limits.maxItems, DEFAULT_MAX_ITEMS, "maxItems");
|
|
55
|
+
const maxCharacters = positiveInteger(limits.maxCharacters, DEFAULT_MAX_CHARACTERS, "maxCharacters");
|
|
56
|
+
if (maxItems > HARD_MAX_ITEMS) throw new TypeError(`maxItems cannot exceed ${HARD_MAX_ITEMS}.`);
|
|
57
|
+
if (maxCharacters > HARD_MAX_CHARACTERS) {
|
|
58
|
+
throw new TypeError(`maxCharacters cannot exceed ${HARD_MAX_CHARACTERS}.`);
|
|
59
|
+
}
|
|
60
|
+
const encoder = new TextEncoder();
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let chunk = [];
|
|
63
|
+
let chunkBytes = 0;
|
|
64
|
+
for (const text of texts) {
|
|
65
|
+
const textBytes = encoder.encode(text).length;
|
|
66
|
+
if (textBytes > maxCharacters) {
|
|
67
|
+
throw new TypeError(`A text item uses ${textBytes} UTF-8 bytes; maxCharacters is ${maxCharacters}.`);
|
|
68
|
+
}
|
|
69
|
+
if (chunk.length > 0 && (chunk.length >= maxItems || chunkBytes + textBytes > maxCharacters)) {
|
|
70
|
+
chunks.push(chunk);
|
|
71
|
+
chunk = [];
|
|
72
|
+
chunkBytes = 0;
|
|
73
|
+
}
|
|
74
|
+
chunk.push(text);
|
|
75
|
+
chunkBytes += textBytes;
|
|
76
|
+
}
|
|
77
|
+
if (chunk.length > 0) chunks.push(chunk);
|
|
78
|
+
return chunks;
|
|
79
|
+
}
|
|
80
|
+
function endpoint(value) {
|
|
81
|
+
try {
|
|
82
|
+
return new URL(value ?? DEFAULT_ENDPOINT).toString().replace(/\/$/, "");
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
throw new TypeError("apiEndpoint must be a valid absolute URL.", { cause });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function parseTranslations(body, expected, useGlossary) {
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(body);
|
|
91
|
+
} catch (cause) {
|
|
92
|
+
throw new Error("Google Translation Advanced returned invalid JSON.", { cause });
|
|
93
|
+
}
|
|
94
|
+
const preferredKey = useGlossary ? "glossaryTranslations" : "translations";
|
|
95
|
+
const fallbackKey = useGlossary ? "translations" : "glossaryTranslations";
|
|
96
|
+
const values = isRecord(parsed) ? Array.isArray(parsed[preferredKey]) ? parsed[preferredKey] : parsed[fallbackKey] : void 0;
|
|
97
|
+
if (!Array.isArray(values)) throw new Error("Google Translation Advanced response is missing translations.");
|
|
98
|
+
if (values.length !== expected) {
|
|
99
|
+
throw new Error(`Google Translation Advanced returned ${values.length} translations for ${expected} texts.`);
|
|
100
|
+
}
|
|
101
|
+
return values.map((value, index) => {
|
|
102
|
+
if (!isRecord(value) || typeof value.translatedText !== "string") {
|
|
103
|
+
throw new Error(`Google Translation Advanced result at index ${index} is invalid.`);
|
|
104
|
+
}
|
|
105
|
+
return value.translatedText;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function validateLabels(labels) {
|
|
109
|
+
if (labels === void 0) return void 0;
|
|
110
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
111
|
+
requireNonEmpty(key, "label key");
|
|
112
|
+
requireNonEmpty(value, `label ${key}`);
|
|
113
|
+
}
|
|
114
|
+
return { ...labels };
|
|
115
|
+
}
|
|
116
|
+
function retryAfterMilliseconds(value, now) {
|
|
117
|
+
if (value === null) return void 0;
|
|
118
|
+
const seconds = Number(value);
|
|
119
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
120
|
+
const timestamp = Date.parse(value);
|
|
121
|
+
return Number.isFinite(timestamp) ? Math.max(0, timestamp - now()) : void 0;
|
|
122
|
+
}
|
|
123
|
+
function createGoogleV3Translator(options) {
|
|
124
|
+
const projectId = requireNonEmpty(options?.projectId, "projectId");
|
|
125
|
+
const location = requireNonEmpty(options.location ?? "global", "location");
|
|
126
|
+
if (typeof options.getAccessToken !== "function") throw new TypeError("getAccessToken must be a function.");
|
|
127
|
+
const apiEndpoint = endpoint(options.apiEndpoint);
|
|
128
|
+
const fetchImpl = options.fetchFn ?? globalThis.fetch;
|
|
129
|
+
if (typeof fetchImpl !== "function")
|
|
130
|
+
throw new Error("Fetch is unavailable. Pass fetchFn when creating the translator.");
|
|
131
|
+
const batchLimits = {
|
|
132
|
+
maxItems: options.batchLimits?.maxItems ?? options.maxBatchSize ?? DEFAULT_MAX_ITEMS,
|
|
133
|
+
maxCharacters: options.batchLimits?.maxCharacters ?? DEFAULT_MAX_CHARACTERS
|
|
134
|
+
};
|
|
135
|
+
const maxRetries = nonNegativeInteger(options.retry?.maxRetries ?? options.maxRetries, 4, "maxRetries");
|
|
136
|
+
const retryBaseDelayMs = nonNegativeInteger(
|
|
137
|
+
options.retry?.baseDelayMs ?? options.retryBaseDelayMs,
|
|
138
|
+
500,
|
|
139
|
+
"baseDelayMs"
|
|
140
|
+
);
|
|
141
|
+
const maxRetryDelayMs = nonNegativeInteger(
|
|
142
|
+
options.retry?.maxDelayMs ?? options.maxRetryDelayMs,
|
|
143
|
+
1e4,
|
|
144
|
+
"maxDelayMs"
|
|
145
|
+
);
|
|
146
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
147
|
+
const random = options.random ?? Math.random;
|
|
148
|
+
const now = options.now ?? Date.now;
|
|
149
|
+
const labels = validateLabels(options.labels);
|
|
150
|
+
const glossaryConfig = options.glossaryConfig === void 0 ? void 0 : {
|
|
151
|
+
glossary: requireNonEmpty(options.glossaryConfig.glossary, "glossaryConfig.glossary"),
|
|
152
|
+
...options.glossaryConfig.ignoreCase === void 0 ? {} : { ignoreCase: options.glossaryConfig.ignoreCase }
|
|
153
|
+
};
|
|
154
|
+
const requestUrl = `${apiEndpoint}/projects/${encodeURIComponent(projectId)}/locations/${encodeURIComponent(location)}:translateText`;
|
|
155
|
+
const translateChunk = async (texts, targetLocale, sourceLocale) => {
|
|
156
|
+
const token = requireNonEmpty(await options.getAccessToken(), "accessToken");
|
|
157
|
+
const body = JSON.stringify({
|
|
158
|
+
contents: texts,
|
|
159
|
+
mimeType: "text/plain",
|
|
160
|
+
targetLanguageCode: targetLocale,
|
|
161
|
+
...sourceLocale === void 0 ? {} : { sourceLanguageCode: sourceLocale },
|
|
162
|
+
...glossaryConfig === void 0 ? {} : { glossaryConfig },
|
|
163
|
+
...labels === void 0 ? {} : { labels }
|
|
164
|
+
});
|
|
165
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
166
|
+
let response;
|
|
167
|
+
try {
|
|
168
|
+
response = await fetchImpl(requestUrl, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
171
|
+
body
|
|
172
|
+
});
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
if (attempt >= maxRetries) {
|
|
175
|
+
throw new Error("Google Translation Advanced request failed before receiving an HTTP response.", { cause });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (response?.ok === true) {
|
|
179
|
+
return parseTranslations(await response.text(), texts.length, glossaryConfig !== void 0);
|
|
180
|
+
}
|
|
181
|
+
const retryable = response === void 0 || response.status === 429 || response.status >= 500 && response.status <= 599;
|
|
182
|
+
if (retryable && attempt < maxRetries) {
|
|
183
|
+
const retryAfter = response === void 0 ? void 0 : retryAfterMilliseconds(response.headers.get("retry-after"), now);
|
|
184
|
+
if (response !== void 0) await response.text();
|
|
185
|
+
const exponentialCap = Math.min(maxRetryDelayMs, retryBaseDelayMs * 2 ** attempt);
|
|
186
|
+
const jitter = Math.floor(random() * exponentialCap);
|
|
187
|
+
await sleep(Math.max(retryAfter ?? 0, jitter));
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (response === void 0) throw new Error("Google Translation Advanced request failed.");
|
|
191
|
+
const detail = (await response.text()).replaceAll(token, "[redacted]");
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Google Translation Advanced request failed with HTTP ${response.status}${detail.length === 0 ? "." : `: ${detail}`}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const translateBatch = async (texts, targetLocale, sourceLocale) => {
|
|
198
|
+
if (!Array.isArray(texts) || texts.some((text) => typeof text !== "string")) {
|
|
199
|
+
throw new TypeError("texts must be an array of strings.");
|
|
200
|
+
}
|
|
201
|
+
const target = requireNonEmpty(targetLocale, "targetLocale");
|
|
202
|
+
const source = sourceLocale === void 0 ? void 0 : requireNonEmpty(sourceLocale, "sourceLocale");
|
|
203
|
+
const translated = [];
|
|
204
|
+
for (const chunk of splitTranslationBatch(texts, batchLimits)) {
|
|
205
|
+
translated.push(...await translateChunk(chunk, target, source));
|
|
206
|
+
}
|
|
207
|
+
return translated;
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
async translateText(text, targetLocale, sourceLocale) {
|
|
211
|
+
if (typeof text !== "string") throw new TypeError("text must be a string.");
|
|
212
|
+
const translated = await translateBatch([text], targetLocale, sourceLocale);
|
|
213
|
+
const value = translated[0];
|
|
214
|
+
if (value === void 0) throw new Error("Google Translation Advanced returned no translation.");
|
|
215
|
+
return value;
|
|
216
|
+
},
|
|
217
|
+
translateBatch
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
221
|
+
0 && (module.exports = {
|
|
222
|
+
createGoogleV3Translator,
|
|
223
|
+
splitTranslationBatch
|
|
224
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AsyncTranslationAdapter } from '@form-engine-ts/core';
|
|
2
|
+
|
|
3
|
+
interface GoogleV3GlossaryConfig {
|
|
4
|
+
readonly glossary: string;
|
|
5
|
+
readonly ignoreCase?: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface GoogleV3TranslatorOptions {
|
|
8
|
+
readonly projectId: string;
|
|
9
|
+
readonly location?: string;
|
|
10
|
+
readonly getAccessToken: () => Promise<string> | string;
|
|
11
|
+
readonly glossaryConfig?: GoogleV3GlossaryConfig;
|
|
12
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
13
|
+
readonly fetchFn?: typeof fetch;
|
|
14
|
+
readonly apiEndpoint?: string;
|
|
15
|
+
readonly batchLimits?: BatchSplitLimits;
|
|
16
|
+
readonly retry?: RetryConfig;
|
|
17
|
+
readonly maxBatchSize?: number;
|
|
18
|
+
readonly maxRetries?: number;
|
|
19
|
+
readonly retryBaseDelayMs?: number;
|
|
20
|
+
readonly maxRetryDelayMs?: number;
|
|
21
|
+
readonly sleep?: (milliseconds: number) => Promise<void>;
|
|
22
|
+
readonly random?: () => number;
|
|
23
|
+
readonly now?: () => number;
|
|
24
|
+
}
|
|
25
|
+
interface BatchSplitLimits {
|
|
26
|
+
readonly maxItems?: number;
|
|
27
|
+
/** UTF-8 bytes across all text items in a request. */
|
|
28
|
+
readonly maxCharacters?: number;
|
|
29
|
+
}
|
|
30
|
+
interface RetryConfig {
|
|
31
|
+
readonly maxRetries?: number;
|
|
32
|
+
readonly baseDelayMs?: number;
|
|
33
|
+
readonly maxDelayMs?: number;
|
|
34
|
+
}
|
|
35
|
+
declare function splitTranslationBatch(texts: readonly string[], limits?: BatchSplitLimits): string[][];
|
|
36
|
+
declare function createGoogleV3Translator(options: GoogleV3TranslatorOptions): AsyncTranslationAdapter;
|
|
37
|
+
|
|
38
|
+
export { type BatchSplitLimits, type GoogleV3GlossaryConfig, type GoogleV3TranslatorOptions, type RetryConfig, createGoogleV3Translator, splitTranslationBatch };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AsyncTranslationAdapter } from '@form-engine-ts/core';
|
|
2
|
+
|
|
3
|
+
interface GoogleV3GlossaryConfig {
|
|
4
|
+
readonly glossary: string;
|
|
5
|
+
readonly ignoreCase?: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface GoogleV3TranslatorOptions {
|
|
8
|
+
readonly projectId: string;
|
|
9
|
+
readonly location?: string;
|
|
10
|
+
readonly getAccessToken: () => Promise<string> | string;
|
|
11
|
+
readonly glossaryConfig?: GoogleV3GlossaryConfig;
|
|
12
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
13
|
+
readonly fetchFn?: typeof fetch;
|
|
14
|
+
readonly apiEndpoint?: string;
|
|
15
|
+
readonly batchLimits?: BatchSplitLimits;
|
|
16
|
+
readonly retry?: RetryConfig;
|
|
17
|
+
readonly maxBatchSize?: number;
|
|
18
|
+
readonly maxRetries?: number;
|
|
19
|
+
readonly retryBaseDelayMs?: number;
|
|
20
|
+
readonly maxRetryDelayMs?: number;
|
|
21
|
+
readonly sleep?: (milliseconds: number) => Promise<void>;
|
|
22
|
+
readonly random?: () => number;
|
|
23
|
+
readonly now?: () => number;
|
|
24
|
+
}
|
|
25
|
+
interface BatchSplitLimits {
|
|
26
|
+
readonly maxItems?: number;
|
|
27
|
+
/** UTF-8 bytes across all text items in a request. */
|
|
28
|
+
readonly maxCharacters?: number;
|
|
29
|
+
}
|
|
30
|
+
interface RetryConfig {
|
|
31
|
+
readonly maxRetries?: number;
|
|
32
|
+
readonly baseDelayMs?: number;
|
|
33
|
+
readonly maxDelayMs?: number;
|
|
34
|
+
}
|
|
35
|
+
declare function splitTranslationBatch(texts: readonly string[], limits?: BatchSplitLimits): string[][];
|
|
36
|
+
declare function createGoogleV3Translator(options: GoogleV3TranslatorOptions): AsyncTranslationAdapter;
|
|
37
|
+
|
|
38
|
+
export { type BatchSplitLimits, type GoogleV3GlossaryConfig, type GoogleV3TranslatorOptions, type RetryConfig, createGoogleV3Translator, splitTranslationBatch };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var DEFAULT_ENDPOINT = "https://translation.googleapis.com/v3";
|
|
3
|
+
var DEFAULT_MAX_ITEMS = 250;
|
|
4
|
+
var DEFAULT_MAX_CHARACTERS = 25e3;
|
|
5
|
+
var HARD_MAX_ITEMS = 1024;
|
|
6
|
+
var HARD_MAX_CHARACTERS = 3e4;
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function requireNonEmpty(value, name) {
|
|
11
|
+
if (typeof value !== "string" || value.trim().length === 0)
|
|
12
|
+
throw new TypeError(`${name} must be a non-empty string.`);
|
|
13
|
+
return value.trim();
|
|
14
|
+
}
|
|
15
|
+
function nonNegativeInteger(value, fallback, name) {
|
|
16
|
+
const resolved = value ?? fallback;
|
|
17
|
+
if (!Number.isSafeInteger(resolved) || resolved < 0) throw new TypeError(`${name} must be a non-negative integer.`);
|
|
18
|
+
return resolved;
|
|
19
|
+
}
|
|
20
|
+
function positiveInteger(value, fallback, name) {
|
|
21
|
+
const resolved = nonNegativeInteger(value, fallback, name);
|
|
22
|
+
if (resolved < 1) throw new TypeError(`${name} must be greater than zero.`);
|
|
23
|
+
return resolved;
|
|
24
|
+
}
|
|
25
|
+
function splitTranslationBatch(texts, limits = {}) {
|
|
26
|
+
if (!Array.isArray(texts) || texts.some((text) => typeof text !== "string")) {
|
|
27
|
+
throw new TypeError("texts must be an array of strings.");
|
|
28
|
+
}
|
|
29
|
+
const maxItems = positiveInteger(limits.maxItems, DEFAULT_MAX_ITEMS, "maxItems");
|
|
30
|
+
const maxCharacters = positiveInteger(limits.maxCharacters, DEFAULT_MAX_CHARACTERS, "maxCharacters");
|
|
31
|
+
if (maxItems > HARD_MAX_ITEMS) throw new TypeError(`maxItems cannot exceed ${HARD_MAX_ITEMS}.`);
|
|
32
|
+
if (maxCharacters > HARD_MAX_CHARACTERS) {
|
|
33
|
+
throw new TypeError(`maxCharacters cannot exceed ${HARD_MAX_CHARACTERS}.`);
|
|
34
|
+
}
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let chunk = [];
|
|
38
|
+
let chunkBytes = 0;
|
|
39
|
+
for (const text of texts) {
|
|
40
|
+
const textBytes = encoder.encode(text).length;
|
|
41
|
+
if (textBytes > maxCharacters) {
|
|
42
|
+
throw new TypeError(`A text item uses ${textBytes} UTF-8 bytes; maxCharacters is ${maxCharacters}.`);
|
|
43
|
+
}
|
|
44
|
+
if (chunk.length > 0 && (chunk.length >= maxItems || chunkBytes + textBytes > maxCharacters)) {
|
|
45
|
+
chunks.push(chunk);
|
|
46
|
+
chunk = [];
|
|
47
|
+
chunkBytes = 0;
|
|
48
|
+
}
|
|
49
|
+
chunk.push(text);
|
|
50
|
+
chunkBytes += textBytes;
|
|
51
|
+
}
|
|
52
|
+
if (chunk.length > 0) chunks.push(chunk);
|
|
53
|
+
return chunks;
|
|
54
|
+
}
|
|
55
|
+
function endpoint(value) {
|
|
56
|
+
try {
|
|
57
|
+
return new URL(value ?? DEFAULT_ENDPOINT).toString().replace(/\/$/, "");
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
throw new TypeError("apiEndpoint must be a valid absolute URL.", { cause });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function parseTranslations(body, expected, useGlossary) {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(body);
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
throw new Error("Google Translation Advanced returned invalid JSON.", { cause });
|
|
68
|
+
}
|
|
69
|
+
const preferredKey = useGlossary ? "glossaryTranslations" : "translations";
|
|
70
|
+
const fallbackKey = useGlossary ? "translations" : "glossaryTranslations";
|
|
71
|
+
const values = isRecord(parsed) ? Array.isArray(parsed[preferredKey]) ? parsed[preferredKey] : parsed[fallbackKey] : void 0;
|
|
72
|
+
if (!Array.isArray(values)) throw new Error("Google Translation Advanced response is missing translations.");
|
|
73
|
+
if (values.length !== expected) {
|
|
74
|
+
throw new Error(`Google Translation Advanced returned ${values.length} translations for ${expected} texts.`);
|
|
75
|
+
}
|
|
76
|
+
return values.map((value, index) => {
|
|
77
|
+
if (!isRecord(value) || typeof value.translatedText !== "string") {
|
|
78
|
+
throw new Error(`Google Translation Advanced result at index ${index} is invalid.`);
|
|
79
|
+
}
|
|
80
|
+
return value.translatedText;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function validateLabels(labels) {
|
|
84
|
+
if (labels === void 0) return void 0;
|
|
85
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
86
|
+
requireNonEmpty(key, "label key");
|
|
87
|
+
requireNonEmpty(value, `label ${key}`);
|
|
88
|
+
}
|
|
89
|
+
return { ...labels };
|
|
90
|
+
}
|
|
91
|
+
function retryAfterMilliseconds(value, now) {
|
|
92
|
+
if (value === null) return void 0;
|
|
93
|
+
const seconds = Number(value);
|
|
94
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
95
|
+
const timestamp = Date.parse(value);
|
|
96
|
+
return Number.isFinite(timestamp) ? Math.max(0, timestamp - now()) : void 0;
|
|
97
|
+
}
|
|
98
|
+
function createGoogleV3Translator(options) {
|
|
99
|
+
const projectId = requireNonEmpty(options?.projectId, "projectId");
|
|
100
|
+
const location = requireNonEmpty(options.location ?? "global", "location");
|
|
101
|
+
if (typeof options.getAccessToken !== "function") throw new TypeError("getAccessToken must be a function.");
|
|
102
|
+
const apiEndpoint = endpoint(options.apiEndpoint);
|
|
103
|
+
const fetchImpl = options.fetchFn ?? globalThis.fetch;
|
|
104
|
+
if (typeof fetchImpl !== "function")
|
|
105
|
+
throw new Error("Fetch is unavailable. Pass fetchFn when creating the translator.");
|
|
106
|
+
const batchLimits = {
|
|
107
|
+
maxItems: options.batchLimits?.maxItems ?? options.maxBatchSize ?? DEFAULT_MAX_ITEMS,
|
|
108
|
+
maxCharacters: options.batchLimits?.maxCharacters ?? DEFAULT_MAX_CHARACTERS
|
|
109
|
+
};
|
|
110
|
+
const maxRetries = nonNegativeInteger(options.retry?.maxRetries ?? options.maxRetries, 4, "maxRetries");
|
|
111
|
+
const retryBaseDelayMs = nonNegativeInteger(
|
|
112
|
+
options.retry?.baseDelayMs ?? options.retryBaseDelayMs,
|
|
113
|
+
500,
|
|
114
|
+
"baseDelayMs"
|
|
115
|
+
);
|
|
116
|
+
const maxRetryDelayMs = nonNegativeInteger(
|
|
117
|
+
options.retry?.maxDelayMs ?? options.maxRetryDelayMs,
|
|
118
|
+
1e4,
|
|
119
|
+
"maxDelayMs"
|
|
120
|
+
);
|
|
121
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
122
|
+
const random = options.random ?? Math.random;
|
|
123
|
+
const now = options.now ?? Date.now;
|
|
124
|
+
const labels = validateLabels(options.labels);
|
|
125
|
+
const glossaryConfig = options.glossaryConfig === void 0 ? void 0 : {
|
|
126
|
+
glossary: requireNonEmpty(options.glossaryConfig.glossary, "glossaryConfig.glossary"),
|
|
127
|
+
...options.glossaryConfig.ignoreCase === void 0 ? {} : { ignoreCase: options.glossaryConfig.ignoreCase }
|
|
128
|
+
};
|
|
129
|
+
const requestUrl = `${apiEndpoint}/projects/${encodeURIComponent(projectId)}/locations/${encodeURIComponent(location)}:translateText`;
|
|
130
|
+
const translateChunk = async (texts, targetLocale, sourceLocale) => {
|
|
131
|
+
const token = requireNonEmpty(await options.getAccessToken(), "accessToken");
|
|
132
|
+
const body = JSON.stringify({
|
|
133
|
+
contents: texts,
|
|
134
|
+
mimeType: "text/plain",
|
|
135
|
+
targetLanguageCode: targetLocale,
|
|
136
|
+
...sourceLocale === void 0 ? {} : { sourceLanguageCode: sourceLocale },
|
|
137
|
+
...glossaryConfig === void 0 ? {} : { glossaryConfig },
|
|
138
|
+
...labels === void 0 ? {} : { labels }
|
|
139
|
+
});
|
|
140
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
141
|
+
let response;
|
|
142
|
+
try {
|
|
143
|
+
response = await fetchImpl(requestUrl, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
146
|
+
body
|
|
147
|
+
});
|
|
148
|
+
} catch (cause) {
|
|
149
|
+
if (attempt >= maxRetries) {
|
|
150
|
+
throw new Error("Google Translation Advanced request failed before receiving an HTTP response.", { cause });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (response?.ok === true) {
|
|
154
|
+
return parseTranslations(await response.text(), texts.length, glossaryConfig !== void 0);
|
|
155
|
+
}
|
|
156
|
+
const retryable = response === void 0 || response.status === 429 || response.status >= 500 && response.status <= 599;
|
|
157
|
+
if (retryable && attempt < maxRetries) {
|
|
158
|
+
const retryAfter = response === void 0 ? void 0 : retryAfterMilliseconds(response.headers.get("retry-after"), now);
|
|
159
|
+
if (response !== void 0) await response.text();
|
|
160
|
+
const exponentialCap = Math.min(maxRetryDelayMs, retryBaseDelayMs * 2 ** attempt);
|
|
161
|
+
const jitter = Math.floor(random() * exponentialCap);
|
|
162
|
+
await sleep(Math.max(retryAfter ?? 0, jitter));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (response === void 0) throw new Error("Google Translation Advanced request failed.");
|
|
166
|
+
const detail = (await response.text()).replaceAll(token, "[redacted]");
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Google Translation Advanced request failed with HTTP ${response.status}${detail.length === 0 ? "." : `: ${detail}`}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const translateBatch = async (texts, targetLocale, sourceLocale) => {
|
|
173
|
+
if (!Array.isArray(texts) || texts.some((text) => typeof text !== "string")) {
|
|
174
|
+
throw new TypeError("texts must be an array of strings.");
|
|
175
|
+
}
|
|
176
|
+
const target = requireNonEmpty(targetLocale, "targetLocale");
|
|
177
|
+
const source = sourceLocale === void 0 ? void 0 : requireNonEmpty(sourceLocale, "sourceLocale");
|
|
178
|
+
const translated = [];
|
|
179
|
+
for (const chunk of splitTranslationBatch(texts, batchLimits)) {
|
|
180
|
+
translated.push(...await translateChunk(chunk, target, source));
|
|
181
|
+
}
|
|
182
|
+
return translated;
|
|
183
|
+
};
|
|
184
|
+
return {
|
|
185
|
+
async translateText(text, targetLocale, sourceLocale) {
|
|
186
|
+
if (typeof text !== "string") throw new TypeError("text must be a string.");
|
|
187
|
+
const translated = await translateBatch([text], targetLocale, sourceLocale);
|
|
188
|
+
const value = translated[0];
|
|
189
|
+
if (value === void 0) throw new Error("Google Translation Advanced returned no translation.");
|
|
190
|
+
return value;
|
|
191
|
+
},
|
|
192
|
+
translateBatch
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export {
|
|
196
|
+
createGoogleV3Translator,
|
|
197
|
+
splitTranslationBatch
|
|
198
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@form-engine-ts/translator-google-v3",
|
|
3
|
+
"version": "2.5.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"main": "./dist/index.cjs",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"require": "./dist/index.cjs"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/nitta-a/form-engine-ts.git",
|
|
28
|
+
"directory": "packages/translator-google-v3"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/nitta-a/form-engine-ts/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/nitta-a/form-engine-ts#readme",
|
|
34
|
+
"keywords": [
|
|
35
|
+
"form",
|
|
36
|
+
"translation",
|
|
37
|
+
"google-cloud",
|
|
38
|
+
"translation-advanced",
|
|
39
|
+
"translator",
|
|
40
|
+
"typescript"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@form-engine-ts/core": "2.5.1"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean --external @form-engine-ts/core",
|
|
47
|
+
"check": "biome check . && tsc --noEmit",
|
|
48
|
+
"test": "vitest run --globals",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
50
|
+
}
|
|
51
|
+
}
|