i18ntk 3.2.0 → 4.0.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.
@@ -1,39 +1,11 @@
1
- const https = require('https');
2
1
  const { URL } = require('url');
2
+ const { safeHttpGet, safeHttpPost, buildGoogleTranslateUrl } = require('./safe-network');
3
3
 
4
4
  const DEFAULT_CONCURRENCY = 3;
5
5
  const DEFAULT_RETRY_COUNT = 3;
6
6
  const DEFAULT_RETRY_DELAY = 1000;
7
7
  const MAX_BACKOFF_DELAY = 30000;
8
8
 
9
- function httpGet(urlString, timeout = 15000) {
10
- return new Promise((resolve) => {
11
- const url = new URL(urlString);
12
- if (url.protocol !== 'https:') {
13
- resolve({ ok: false, error: 'ProtocolError', message: 'Only HTTPS requests are supported' });
14
- return;
15
- }
16
- const req = https.get(urlString, { headers: { 'User-Agent': 'i18ntk/3.2.0' } }, (res) => {
17
- let data = '';
18
- res.on('data', (chunk) => { data += chunk; });
19
- res.on('end', () => {
20
- try {
21
- const parsed = JSON.parse(data);
22
- resolve({ ok: true, data: parsed, status: res.statusCode });
23
- } catch (e) {
24
- resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
25
- }
26
- });
27
- });
28
- req.setTimeout(timeout, () => {
29
- req.destroy();
30
- resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
31
- });
32
- req.on('error', (e) => {
33
- resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
34
- });
35
- });
36
- }
37
9
 
38
10
  function extractTranslation(result) {
39
11
  if (result && Array.isArray(result) && result[0]) {
@@ -46,6 +18,154 @@ function extractTranslation(result) {
46
18
  return null;
47
19
  }
48
20
 
21
+ function normalizeProvider(provider) {
22
+ const value = String(provider || process.env.I18NTK_TRANSLATE_PROVIDER || 'google').trim().toLowerCase();
23
+ if (value === 'deepl-free' || value === 'deepl-pro') return 'deepl';
24
+ if (value === 'libre' || value === 'libretranslate') return 'libretranslate';
25
+ if (value === 'google' || value === 'gtx') return 'google';
26
+ return value;
27
+ }
28
+
29
+ function normalizeDeepLLanguage(code) {
30
+ return String(code || '').replace('-', '_').toUpperCase();
31
+ }
32
+
33
+ function getDeepLApiUrl(options = {}) {
34
+ if (options.deeplApiUrl) return options.deeplApiUrl;
35
+ if (process.env.DEEPL_API_URL) return process.env.DEEPL_API_URL;
36
+ return 'https://api-free.deepl.com/v2/translate';
37
+ }
38
+
39
+ function isEnabled(value) {
40
+ return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
41
+ }
42
+
43
+ function getDeepLAllowedHosts(url, options = {}) {
44
+ const hosts = ['api-free.deepl.com', 'api.deepl.com'];
45
+ if (options.allowCustomTranslateHosts || isEnabled(process.env.I18NTK_ALLOW_CUSTOM_TRANSLATE_HOSTS)) {
46
+ hosts.push(new URL(url).hostname);
47
+ }
48
+ return [...new Set(hosts)];
49
+ }
50
+
51
+ function parseProviderUrl(url, provider) {
52
+ try {
53
+ return { ok: true, parsed: new URL(url) };
54
+ } catch (error) {
55
+ return {
56
+ ok: false,
57
+ error: 'InvalidProviderUrl',
58
+ message: `${provider} provider URL is invalid: ${error.message}`,
59
+ };
60
+ }
61
+ }
62
+
63
+ function extractDeepLTranslation(data) {
64
+ const value = data && Array.isArray(data.translations) && data.translations[0] && data.translations[0].text;
65
+ return typeof value === 'string' && value.trim() ? value : null;
66
+ }
67
+
68
+ function getLibreTranslateUrl(options = {}) {
69
+ if (options.libreTranslateUrl) return options.libreTranslateUrl;
70
+ if (process.env.LIBRETRANSLATE_URL) return process.env.LIBRETRANSLATE_URL;
71
+ return 'https://libretranslate.com/translate';
72
+ }
73
+
74
+ function getLibreTranslateAllowedHosts(url) {
75
+ return [...new Set(['libretranslate.com', new URL(url).hostname])];
76
+ }
77
+
78
+ function extractLibreTranslateTranslation(data) {
79
+ const value = data && data.translatedText;
80
+ return typeof value === 'string' && value.trim() ? value : null;
81
+ }
82
+
83
+ function buildProviderRequest(text, targetLang, options = {}) {
84
+ const provider = normalizeProvider(options.provider);
85
+ const sourceLang = options.sourceLang || 'en';
86
+
87
+ if (provider === 'google') {
88
+ return {
89
+ provider,
90
+ method: 'GET',
91
+ url: buildGoogleTranslateUrl(text, sourceLang, targetLang),
92
+ extract: extractTranslation,
93
+ };
94
+ }
95
+
96
+ if (provider === 'deepl') {
97
+ const apiKey = options.deeplApiKey || process.env.DEEPL_API_KEY;
98
+ if (!apiKey) {
99
+ return {
100
+ provider,
101
+ error: 'MissingApiKey',
102
+ message: 'DeepL provider requires DEEPL_API_KEY in the environment or deeplApiKey in options.',
103
+ };
104
+ }
105
+
106
+ const url = getDeepLApiUrl(options);
107
+ const parsedUrl = parseProviderUrl(url, 'DeepL');
108
+ if (!parsedUrl.ok) return { provider, error: parsedUrl.error, message: parsedUrl.message };
109
+
110
+ return {
111
+ provider,
112
+ method: 'POST',
113
+ url,
114
+ body: {
115
+ text: [text],
116
+ target_lang: normalizeDeepLLanguage(targetLang),
117
+ source_lang: normalizeDeepLLanguage(sourceLang),
118
+ },
119
+ requestOptions: {
120
+ provider,
121
+ allowedHosts: getDeepLAllowedHosts(url, options),
122
+ allowedPaths: ['/v2/translate'],
123
+ headers: {
124
+ Authorization: `DeepL-Auth-Key ${apiKey}`,
125
+ },
126
+ },
127
+ extract: extractDeepLTranslation,
128
+ };
129
+ }
130
+
131
+ if (provider === 'libretranslate') {
132
+ const apiKey = options.libreTranslateApiKey || process.env.LIBRETRANSLATE_API_KEY || '';
133
+ const url = getLibreTranslateUrl(options);
134
+ const parsedUrl = parseProviderUrl(url, 'LibreTranslate');
135
+ if (!parsedUrl.ok) return { provider, error: parsedUrl.error, message: parsedUrl.message };
136
+
137
+ const params = new URLSearchParams({
138
+ q: text,
139
+ source: sourceLang,
140
+ target: targetLang,
141
+ format: 'text',
142
+ });
143
+ if (apiKey) params.set('api_key', apiKey);
144
+
145
+ return {
146
+ provider,
147
+ method: 'POST',
148
+ url,
149
+ body: params.toString(),
150
+ requestOptions: {
151
+ provider,
152
+ allowedHosts: getLibreTranslateAllowedHosts(url),
153
+ allowedPaths: ['/translate'],
154
+ headers: {
155
+ 'Content-Type': 'application/x-www-form-urlencoded',
156
+ },
157
+ },
158
+ extract: extractLibreTranslateTranslation,
159
+ };
160
+ }
161
+
162
+ return {
163
+ provider,
164
+ error: 'UnsupportedProvider',
165
+ message: `Unsupported translation provider "${provider}". Use google, deepl, or libretranslate.`,
166
+ };
167
+ }
168
+
49
169
  function detectRateLimitError(result) {
50
170
  if (!result.ok && result.status === 429) return true;
51
171
  if (result.ok && result.status === 429) return true;
@@ -61,6 +181,8 @@ async function translateText(text, targetLang, options = {}) {
61
181
  retryDelay = DEFAULT_RETRY_DELAY,
62
182
  customFn,
63
183
  timeout = 15000,
184
+ httpGet = safeHttpGet,
185
+ httpPost = safeHttpPost,
64
186
  } = options;
65
187
 
66
188
  if (!text || text.trim().length === 0) return { ok: true, translated: text };
@@ -74,14 +196,10 @@ async function translateText(text, targetLang, options = {}) {
74
196
  }
75
197
  }
76
198
 
77
- const params = new URLSearchParams({
78
- client: 'gtx',
79
- sl: sourceLang,
80
- tl: targetLang,
81
- dt: 't',
82
- q: text,
83
- });
84
- const url = `https://translate.googleapis.com/translate_a/single?${params.toString()}`;
199
+ const request = buildProviderRequest(text, targetLang, { ...options, sourceLang });
200
+ if (request.error) {
201
+ return { ok: false, translated: null, error: request.error, message: request.message };
202
+ }
85
203
 
86
204
  let lastError = null;
87
205
  for (let attempt = 0; attempt < retryCount; attempt++) {
@@ -90,10 +208,12 @@ async function translateText(text, targetLang, options = {}) {
90
208
  await new Promise((resolve) => setTimeout(resolve, delay));
91
209
  }
92
210
 
93
- const result = await httpGet(url, timeout);
211
+ const result = request.method === 'POST'
212
+ ? await httpPost(request.url, request.body, { ...(request.requestOptions || {}), timeout })
213
+ : await httpGet(request.url, timeout);
94
214
 
95
215
  if (result.ok) {
96
- const translated = extractTranslation(result.data);
216
+ const translated = request.extract(result.data);
97
217
  if (translated !== null && translated !== text) {
98
218
  return { ok: true, translated };
99
219
  }
@@ -101,7 +221,7 @@ async function translateText(text, targetLang, options = {}) {
101
221
  return { ok: true, translated: text };
102
222
  }
103
223
  if (result.status === 429 || (translated === null && result.status >= 400)) {
104
- lastError = { error: 'RateLimited', message: 'Google Translate rate limit hit' };
224
+ lastError = { error: 'RateLimited', message: `${request.provider} rate limit hit` };
105
225
  continue;
106
226
  }
107
227
  }
@@ -165,8 +285,11 @@ async function translateBatch(batch, targetLang, options = {}) {
165
285
  }
166
286
 
167
287
  module.exports = {
168
- httpGet,
169
288
  extractTranslation,
289
+ extractDeepLTranslation,
290
+ extractLibreTranslateTranslation,
291
+ buildProviderRequest,
292
+ normalizeProvider,
170
293
  detectRateLimitError,
171
294
  translateText,
172
295
  translateBatch,
@@ -3,6 +3,8 @@ const SecurityUtils = require('../security');
3
3
 
4
4
  const DEFAULT_PROTECTION_FILE = 'i18ntk-auto-translate.json';
5
5
  const TOKEN_PREFIX = '__I18NTK_KEEP_';
6
+ const MAX_CONTEXT_RULES = 100;
7
+ const MAX_CONTEXT_INPUT_LENGTH = 200;
6
8
 
7
9
  function defaultProtectionConfig() {
8
10
  return {
@@ -49,12 +51,85 @@ function compilePatterns(patterns) {
49
51
  return compiled;
50
52
  }
51
53
 
54
+ function parseContextString(context) {
55
+ if (typeof context !== 'string') return null;
56
+ if (context.length > MAX_CONTEXT_INPUT_LENGTH) return null;
57
+
58
+ const trimmed = context.trim();
59
+
60
+ if (trimmed === 'standalone') {
61
+ return { mode: 'standalone' };
62
+ }
63
+
64
+ const afterMatch = trimmed.match(/^after:(.+)$/i);
65
+ if (afterMatch) {
66
+ const words = afterMatch[1].split('|').map(w => w.trim()).filter(Boolean);
67
+ if (words.length === 0) return null;
68
+ return { mode: 'after', words };
69
+ }
70
+
71
+ const beforeMatch = trimmed.match(/^before:(.+)$/i);
72
+ if (beforeMatch) {
73
+ const words = beforeMatch[1].split('|').map(w => w.trim()).filter(Boolean);
74
+ if (words.length === 0) return null;
75
+ return { mode: 'before', words };
76
+ }
77
+
78
+ const surroundedMatch = trimmed.match(/^surrounded:(.+),(.+)$/i);
79
+ if (surroundedMatch) {
80
+ const leftWords = surroundedMatch[1].split('|').map(w => w.trim()).filter(Boolean);
81
+ const rightWords = surroundedMatch[2].split('|').map(w => w.trim()).filter(Boolean);
82
+ if (leftWords.length === 0 || rightWords.length === 0) return null;
83
+ return { mode: 'surrounded', left: leftWords, right: rightWords };
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ function parseContextRule(rule) {
90
+ if (typeof rule === 'string') {
91
+ const trimmed = rule.trim();
92
+ if (!trimmed || trimmed.length > MAX_CONTEXT_INPUT_LENGTH) return null;
93
+ return { value: trimmed, type: 'global' };
94
+ }
95
+
96
+ if (
97
+ typeof rule === 'object' &&
98
+ rule !== null &&
99
+ typeof rule.value === 'string' &&
100
+ typeof rule.context === 'string'
101
+ ) {
102
+ const value = rule.value.trim();
103
+ const rawContext = rule.context;
104
+
105
+ if (!value || value.length > MAX_CONTEXT_INPUT_LENGTH) return null;
106
+ if (!rawContext || rawContext.length > MAX_CONTEXT_INPUT_LENGTH) return null;
107
+
108
+ const parsed = parseContextString(rawContext);
109
+ if (!parsed) return null;
110
+
111
+ return { value, type: 'context', context: parsed };
112
+ }
113
+
114
+ return null;
115
+ }
116
+
52
117
  function normalizeProtectionConfig(config = {}, filePath = null) {
53
118
  const terms = normalizeList(config.terms);
54
119
  const keys = normalizeList(config.keys);
55
120
  const values = normalizeList(config.values);
56
121
  const patterns = normalizeList(config.patterns);
57
122
 
123
+ const rawTerms = Array.isArray(config.terms) ? config.terms : [];
124
+ const normalizedTerms = [];
125
+ for (const term of rawTerms) {
126
+ const normalized = parseContextRule(term);
127
+ if (normalized) {
128
+ normalizedTerms.push(normalized);
129
+ }
130
+ }
131
+ const cappedTerms = normalizedTerms.slice(0, MAX_CONTEXT_RULES);
132
+
58
133
  return {
59
134
  enabled: true,
60
135
  filePath,
@@ -62,7 +137,8 @@ function normalizeProtectionConfig(config = {}, filePath = null) {
62
137
  keys,
63
138
  values,
64
139
  patterns,
65
- compiledPatterns: compilePatterns(patterns)
140
+ compiledPatterns: compilePatterns(patterns),
141
+ normalizedTerms: cappedTerms
66
142
  };
67
143
  }
68
144
 
@@ -98,10 +174,20 @@ function saveProtectionFile(filePath, config, options = {}) {
98
174
  if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) {
99
175
  SecurityUtils.safeMkdirSync(dir, path.dirname(dir), { recursive: true });
100
176
  }
177
+
178
+ const rawTerms = Array.isArray(config?.terms) ? config.terms : [];
179
+ const sanitizedTerms = [];
180
+ for (const term of rawTerms) {
181
+ const normalized = parseContextRule(term);
182
+ if (normalized) {
183
+ sanitizedTerms.push(term);
184
+ }
185
+ }
186
+
101
187
  const nextConfig = {
102
188
  ...defaultProtectionConfig(),
103
189
  ...(config || {}),
104
- terms: normalizeList(config?.terms),
190
+ terms: sanitizedTerms.slice(0, MAX_CONTEXT_RULES),
105
191
  keys: normalizeList(config?.keys),
106
192
  values: normalizeList(config?.values),
107
193
  patterns: normalizeList(config?.patterns)
@@ -156,7 +242,8 @@ function shouldPreserveWholeValue(keyPath, value, protection) {
156
242
  if (!protection || protection.enabled === false) return false;
157
243
  if (protection.keys.some(rule => keyMatchesRule(keyPath, rule))) return true;
158
244
  const valueText = String(value);
159
- return protection.values.includes(valueText) || protection.terms.includes(valueText);
245
+ return protection.values.includes(valueText) ||
246
+ (protection.normalizedTerms || []).some(rule => rule.value === valueText);
160
247
  }
161
248
 
162
249
  function addReplacement(replacements, original) {
@@ -165,13 +252,65 @@ function addReplacement(replacements, original) {
165
252
  replacements.push({ original });
166
253
  }
167
254
 
255
+ function shouldProtectInContext(value, rule, index, fullText) {
256
+ if (rule.type === 'global') return true;
257
+
258
+ const { context } = rule;
259
+ const before = fullText.substring(0, index);
260
+ const after = fullText.substring(index + value.length);
261
+
262
+ if (context.mode === 'after') {
263
+ const alternation = context.words.map(escapeRegExp).join('|');
264
+ const regex = new RegExp(`\\b(${alternation})\\s+$`, 'i');
265
+ return regex.test(before);
266
+ }
267
+
268
+ if (context.mode === 'before') {
269
+ const alternation = context.words.map(escapeRegExp).join('|');
270
+ const regex = new RegExp(`^\\s+(${alternation})\\b`, 'i');
271
+ return regex.test(after);
272
+ }
273
+
274
+ if (context.mode === 'standalone') {
275
+ const isBoundaryBefore = before === '' || /\s$/.test(before);
276
+ const isBoundaryAfter = after === '' || /^[\s.,!?;:]/.test(after);
277
+ return isBoundaryBefore && isBoundaryAfter;
278
+ }
279
+
280
+ if (context.mode === 'surrounded') {
281
+ const leftAlternation = context.left.map(escapeRegExp).join('|');
282
+ const rightAlternation = context.right.map(escapeRegExp).join('|');
283
+ const leftRegex = new RegExp(`\\b(${leftAlternation})\\s+$`, 'i');
284
+ const rightRegex = new RegExp(`^\\s+(${rightAlternation})\\b`, 'i');
285
+ return leftRegex.test(before) && rightRegex.test(after);
286
+ }
287
+
288
+ return false;
289
+ }
290
+
168
291
  function collectReplacements(value, protection) {
169
292
  const text = String(value);
170
293
  const replacements = [];
171
294
 
172
- for (const term of protection.terms || []) {
173
- if (text.includes(term)) {
174
- addReplacement(replacements, term);
295
+ const rules = protection.normalizedTerms || (protection.terms || [])
296
+ .map(parseContextRule)
297
+ .filter(Boolean);
298
+ for (const rule of rules) {
299
+ if (rule.type === 'global') {
300
+ if (text.includes(rule.value)) {
301
+ addReplacement(replacements, rule.value);
302
+ }
303
+ } else {
304
+ if (!rule.value) continue;
305
+ const escaped = escapeRegExp(rule.value);
306
+ const regex = new RegExp(escaped, 'gi');
307
+ let match;
308
+ while ((match = regex.exec(text)) !== null) {
309
+ if (shouldProtectInContext(rule.value, rule, match.index, text)) {
310
+ addReplacement(replacements, match[0]);
311
+ }
312
+ if (match[0] === '') regex.lastIndex++;
313
+ }
175
314
  }
176
315
  }
177
316
 
@@ -221,6 +360,7 @@ function hasProtectionRules(protection) {
221
360
  return Boolean(
222
361
  protection &&
223
362
  (
363
+ (protection.normalizedTerms && protection.normalizedTerms.length) ||
224
364
  protection.terms.length ||
225
365
  protection.keys.length ||
226
366
  protection.values.length ||
@@ -235,6 +375,7 @@ module.exports = {
235
375
  defaultProtectionConfig,
236
376
  hasProtectionRules,
237
377
  loadProtectionConfig,
378
+ parseContextRule,
238
379
  protectText,
239
380
  readProtectionFile,
240
381
  restoreText,