i18ntk 2.6.0 → 3.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.
@@ -0,0 +1,168 @@
1
+ const https = require('https');
2
+ const http = require('http');
3
+ const { URL } = require('url');
4
+
5
+ const DEFAULT_CONCURRENCY = 3;
6
+ const DEFAULT_RETRY_COUNT = 3;
7
+ const DEFAULT_RETRY_DELAY = 1000;
8
+ const MAX_BACKOFF_DELAY = 30000;
9
+
10
+ function httpGet(urlString, timeout = 15000) {
11
+ return new Promise((resolve) => {
12
+ const url = new URL(urlString);
13
+ const client = url.protocol === 'https:' ? https : http;
14
+ const req = client.get(urlString, { timeout }, (res) => {
15
+ let data = '';
16
+ res.on('data', (chunk) => { data += chunk; });
17
+ res.on('end', () => {
18
+ try {
19
+ const parsed = JSON.parse(data);
20
+ resolve({ ok: true, data: parsed, status: res.statusCode });
21
+ } catch (e) {
22
+ resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
23
+ }
24
+ });
25
+ });
26
+ req.on('error', (e) => {
27
+ resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
28
+ });
29
+ req.on('timeout', () => {
30
+ req.destroy();
31
+ resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
32
+ });
33
+ });
34
+ }
35
+
36
+ function extractTranslation(result) {
37
+ if (result && Array.isArray(result) && result[0]) {
38
+ return result[0]
39
+ .filter((part) => part && typeof part[0] === 'string')
40
+ .map((part) => part[0])
41
+ .join('')
42
+ .trim() || null;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function detectRateLimitError(result) {
48
+ if (!result.ok && result.status === 429) return true;
49
+ if (result.ok && result.status === 429) return true;
50
+ if (!result.ok && result.error === 'TimeoutError') return false;
51
+ if (result.data && (result.data.error || result.data.error_description)) return true;
52
+ return false;
53
+ }
54
+
55
+ async function translateText(text, targetLang, options = {}) {
56
+ const {
57
+ sourceLang = 'en',
58
+ retryCount = DEFAULT_RETRY_COUNT,
59
+ retryDelay = DEFAULT_RETRY_DELAY,
60
+ customFn,
61
+ timeout = 15000,
62
+ } = options;
63
+
64
+ if (!text || text.trim().length === 0) return { ok: true, translated: text };
65
+
66
+ if (typeof customFn === 'function') {
67
+ try {
68
+ const translated = await customFn(text, { sourceLang, targetLang });
69
+ return { ok: true, translated: translated || text };
70
+ } catch (e) {
71
+ return { ok: false, error: 'CustomFnError', message: e.message };
72
+ }
73
+ }
74
+
75
+ const params = new URLSearchParams({
76
+ client: 'gtx',
77
+ sl: sourceLang,
78
+ tl: targetLang,
79
+ dt: 't',
80
+ q: text,
81
+ });
82
+ const url = `https://translate.googleapis.com/translate_a/single?${params.toString()}`;
83
+
84
+ let lastError = null;
85
+ for (let attempt = 0; attempt < retryCount; attempt++) {
86
+ if (attempt > 0) {
87
+ const delay = Math.min(retryDelay * Math.pow(2, attempt - 1), MAX_BACKOFF_DELAY);
88
+ await new Promise((resolve) => setTimeout(resolve, delay));
89
+ }
90
+
91
+ const result = await httpGet(url, timeout);
92
+
93
+ if (result.ok) {
94
+ const translated = extractTranslation(result.data);
95
+ if (translated !== null && translated !== text) {
96
+ return { ok: true, translated };
97
+ }
98
+ if (translated === text) {
99
+ return { ok: true, translated: text };
100
+ }
101
+ if (result.status === 429) {
102
+ lastError = { error: 'RateLimited', message: 'Google Translate rate limit hit' };
103
+ continue;
104
+ }
105
+ }
106
+
107
+ if (detectRateLimitError(result)) {
108
+ lastError = { error: 'RateLimited', message: 'Rate limit detected' };
109
+ continue;
110
+ }
111
+
112
+ lastError = { error: result.error || 'UnknownError', message: result.message || 'Request failed' };
113
+ }
114
+
115
+ return { ok: false, translated: null, ...lastError };
116
+ }
117
+
118
+ async function translateBatch(batch, targetLang, options = {}) {
119
+ const {
120
+ concurrency = DEFAULT_CONCURRENCY,
121
+ onProgress,
122
+ onError,
123
+ } = options;
124
+
125
+ const results = new Array(batch.length).fill(null);
126
+ let idx = 0;
127
+ let completed = 0;
128
+
129
+ async function worker() {
130
+ while (idx < batch.length) {
131
+ const i = idx++;
132
+ const item = batch[i];
133
+ const value = typeof item === 'string' ? item : item.value;
134
+ const result = await translateText(value, targetLang, options);
135
+
136
+ if (result.ok) {
137
+ results[i] = result.translated;
138
+ } else {
139
+ results[i] = value;
140
+ if (typeof onError === 'function') {
141
+ onError({ index: i, item, error: result.error, message: result.message });
142
+ }
143
+ }
144
+
145
+ completed++;
146
+ if (typeof onProgress === 'function') {
147
+ onProgress({ completed, total: batch.length, index: i, ok: result.ok });
148
+ }
149
+ }
150
+ }
151
+
152
+ const workerCount = Math.min(concurrency, batch.length);
153
+ const workers = Array.from({ length: workerCount }, () => worker());
154
+ await Promise.all(workers);
155
+
156
+ return results;
157
+ }
158
+
159
+ module.exports = {
160
+ httpGet,
161
+ extractTranslation,
162
+ detectRateLimitError,
163
+ translateText,
164
+ translateBatch,
165
+ DEFAULT_CONCURRENCY,
166
+ DEFAULT_RETRY_COUNT,
167
+ DEFAULT_RETRY_DELAY,
168
+ };
@@ -0,0 +1,91 @@
1
+ const { ask } = require('../cli');
2
+
3
+ const PLACEHOLDER_WARNING = [
4
+ '',
5
+ '============================================================',
6
+ ' WARNING: DYNAMIC PLACEHOLDER TOKENS DETECTED',
7
+ '============================================================',
8
+ '',
9
+ ' Google Translate will attempt to translate the ENTIRE',
10
+ ' string value, including any placeholder tokens like:',
11
+ '',
12
+ ' {name} {{count}} %d %s :param ${var}',
13
+ '',
14
+ ' This WILL corrupt or alter your placeholders, which',
15
+ ' will break runtime substitution in your application.',
16
+ '',
17
+ ' You have two choices for strings containing placeholders:',
18
+ '',
19
+ ' SKIP - Copy verbatim (safe); manually translate later',
20
+ ' SEND - Translate anyway (risky); may corrupt placeholders',
21
+ '',
22
+ '============================================================',
23
+ ].join('\n');
24
+
25
+ async function confirmGlobalChoice() {
26
+ console.log(PLACEHOLDER_WARNING);
27
+ console.log('');
28
+ console.log(' What should we do with ALL strings that contain');
29
+ console.log(' dynamic placeholder tokens?');
30
+ console.log('');
31
+ console.log(' [s] SKIP all - Copy verbatim, translate nothing with placeholders');
32
+ console.log(' [t] SEND all - Translate everything, accept corruption risk');
33
+ console.log(' [i] ASK each - Decide individually for each key');
34
+ console.log('');
35
+
36
+ while (true) {
37
+ const answer = await ask(' Choice [s/t/i]: ');
38
+ const lower = answer.toLowerCase().trim();
39
+ if (lower === 's' || lower === 'skip') return { strategy: 'skip', interactive: false };
40
+ if (lower === 't' || lower === 'send') return { strategy: 'send', interactive: false };
41
+ if (lower === 'i' || lower === 'ask' || lower === 'interactive') return { strategy: 'skip', interactive: true };
42
+ console.log(' Please enter s, t, or i.');
43
+ }
44
+ }
45
+
46
+ async function confirmPerKey(keyPath, value, placeholders) {
47
+ const displayVal = value.length > 60 ? value.substring(0, 57) + '...' : value;
48
+ console.log('');
49
+ console.log(` Key: ${keyPath}`);
50
+ console.log(` Value: "${displayVal}"`);
51
+ console.log(` Placeholders: ${placeholders.join(', ')}`);
52
+ console.log('');
53
+
54
+ while (true) {
55
+ const answer = await ask(' [s]kip / [t]ranslate anyway / s[k]ip all remaining / [a]ll remaining? ');
56
+ const lower = answer.toLowerCase().trim();
57
+ if (lower === 's' || lower === 'skip') return 'skip';
58
+ if (lower === 't' || lower === 'translate') return 'send';
59
+ if (lower === 'k' || lower === 'skipall') return 'skip-all';
60
+ if (lower === 'a' || lower === 'all') return 'send-all';
61
+ console.log(' Please enter s, t, k, or a.');
62
+ }
63
+ }
64
+
65
+ async function previewSkipped(skippedLeaves) {
66
+ console.log('');
67
+ console.log('============================================================');
68
+ console.log(' DRY-RUN: Keys that would be skipped (placeholders found):');
69
+ console.log('============================================================');
70
+ console.log('');
71
+ if (skippedLeaves.length === 0) {
72
+ console.log(' No keys would be skipped.');
73
+ } else {
74
+ for (const leaf of skippedLeaves) {
75
+ const displayVal = leaf.value.length > 60 ? leaf.value.substring(0, 57) + '...' : leaf.value;
76
+ console.log(` ${leaf.keyPath}`);
77
+ console.log(` "${displayVal}"`);
78
+ console.log(` Placeholders: ${leaf.placeholders.join(', ')}`);
79
+ console.log('');
80
+ }
81
+ console.log(` Total keys that would be skipped: ${skippedLeaves.length}`);
82
+ }
83
+ console.log('');
84
+ }
85
+
86
+ module.exports = {
87
+ PLACEHOLDER_WARNING,
88
+ confirmGlobalChoice,
89
+ confirmPerKey,
90
+ previewSkipped,
91
+ };
@@ -0,0 +1,93 @@
1
+ const DEFAULT_PLACEHOLDER_PATTERNS = [
2
+ /\{\{[^}]+\}\}/g, // {{variable}} - double curly (Handlebars, Mustache)
3
+ /\{[a-zA-Z_]\w*\}/g, // {name} - single curly (i18next, Python format named)
4
+ /\{\d+\}/g, // {0} - indexed curly
5
+ /%\d?\$?[sd]/g, // %d, %s, %1$s, %2$d (printf-style)
6
+ /:[a-zA-Z_]\w*/g, // :param - colon-style (Rails, Swift)
7
+ /%\{[a-zA-Z_]\w*\}/g, // %{name} - Ruby/Perl named
8
+ /%\([a-zA-Z_]\w*\)[sd]/g, // %(name)s - Python named format (with type)
9
+ /\$\{[a-zA-Z_]\w*\}/g, // ${variable} - JS template literal style
10
+ /<[a-zA-Z_]\w*>/g, // <name> - XML/HTML-style
11
+ /@[a-zA-Z_]\w*/g, // @param - Java/Spring-style
12
+ /&[a-zA-Z_]\w*;?/g, // &amp; HTML entity style (careful, broad match)
13
+ ];
14
+
15
+ function compilePatterns(customPatterns) {
16
+ const patterns = [...DEFAULT_PLACEHOLDER_PATTERNS];
17
+ if (customPatterns) {
18
+ const customs = Array.isArray(customPatterns) ? customPatterns : [customPatterns];
19
+ for (const pat of customs) {
20
+ if (typeof pat === 'string') {
21
+ try {
22
+ patterns.push(new RegExp(pat, 'g'));
23
+ } catch (e) {
24
+ console.warn('Invalid custom regex pattern ignored:', pat, e.message);
25
+ }
26
+ } else if (pat instanceof RegExp) {
27
+ if (!pat.global) {
28
+ patterns.push(new RegExp(pat.source, 'g'));
29
+ } else {
30
+ patterns.push(pat);
31
+ }
32
+ }
33
+ }
34
+ }
35
+ return patterns;
36
+ }
37
+
38
+ function detectPlaceholders(value, customPatterns) {
39
+ if (!value || typeof value !== 'string') return [];
40
+ const patterns = compilePatterns(customPatterns);
41
+ const found = new Set();
42
+ for (const pattern of patterns) {
43
+ const matches = value.match(pattern);
44
+ if (matches) {
45
+ for (const m of matches) found.add(m);
46
+ }
47
+ }
48
+ return Array.from(found);
49
+ }
50
+
51
+ function hasPlaceholders(value, customPatterns) {
52
+ if (!value || typeof value !== 'string') return false;
53
+ const patterns = compilePatterns(customPatterns);
54
+ for (const pattern of patterns) {
55
+ if (pattern.test(value)) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ function maskPlaceholders(value, customPatterns) {
61
+ if (!value || typeof value !== 'string') return { masked: value, map: new Map() };
62
+ const patterns = compilePatterns(customPatterns);
63
+ const map = new Map();
64
+ let idx = 0;
65
+ let masked = value;
66
+ for (const pattern of patterns) {
67
+ masked = masked.replace(pattern, (match) => {
68
+ const ph = `\uE000${idx}\uE001`;
69
+ map.set(ph, match);
70
+ idx++;
71
+ return ph;
72
+ });
73
+ }
74
+ return { masked, map };
75
+ }
76
+
77
+ function unmaskPlaceholders(value, map) {
78
+ if (!map || map.size === 0) return value;
79
+ let result = value;
80
+ map.forEach((original, placeholder) => {
81
+ result = result.split(placeholder).join(original);
82
+ });
83
+ return result;
84
+ }
85
+
86
+ module.exports = {
87
+ DEFAULT_PLACEHOLDER_PATTERNS,
88
+ compilePatterns,
89
+ detectPlaceholders,
90
+ hasPlaceholders,
91
+ maskPlaceholders,
92
+ unmaskPlaceholders,
93
+ };
@@ -0,0 +1,90 @@
1
+ const fs = require('fs');
2
+
3
+ function generateReport(skippedKeys, translatedCount, totalCount, options = {}) {
4
+ const {
5
+ sourceFile,
6
+ targetLang,
7
+ dryRun = false,
8
+ timestamp = new Date().toISOString(),
9
+ } = options;
10
+
11
+ const lines = [];
12
+ lines.push('='.repeat(72));
13
+ lines.push(' I18NTK POST-TRANSLATION REPORT');
14
+ lines.push('='.repeat(72));
15
+ lines.push(` Generated: ${timestamp}`);
16
+ lines.push(` Source file: ${sourceFile || 'N/A'}`);
17
+ lines.push(` Target language: ${targetLang || 'N/A'}`);
18
+ if (dryRun) {
19
+ lines.push(` Mode: DRY-RUN (no API calls made)`);
20
+ }
21
+ lines.push(` Total keys: ${totalCount}`);
22
+ lines.push(` Translated: ${translatedCount}`);
23
+ lines.push(` Skipped: ${skippedKeys.length}`);
24
+ lines.push('='.repeat(72));
25
+
26
+ if (skippedKeys.length === 0) {
27
+ lines.push('');
28
+ lines.push(' All strings were processed. No keys were skipped.');
29
+ lines.push('');
30
+ } else {
31
+ lines.push('');
32
+ lines.push(' WARNING: The following keys were SKIPPED because they contain');
33
+ lines.push(' dynamic placeholder tokens that should be manually translated');
34
+ lines.push(' to avoid runtime substitution breakage.');
35
+ lines.push('');
36
+ lines.push(' These entries were copied verbatim into the output file.');
37
+ lines.push(' You MUST manually translate them before using the file.');
38
+ lines.push('');
39
+ lines.push(` ${'-'.repeat(64)}`);
40
+ lines.push(` Key Path Original Value`);
41
+ lines.push(` ${'-'.repeat(64)}`);
42
+
43
+ for (const skip of skippedKeys) {
44
+ const keyDisplay = skip.keyPath.length > 50
45
+ ? skip.keyPath.substring(0, 47) + '...'
46
+ : skip.keyPath.padEnd(50);
47
+ const valDisplay = skip.value.length > 80
48
+ ? skip.value.substring(0, 77) + '...'
49
+ : skip.value;
50
+ lines.push(` ${keyDisplay} ${valDisplay}`);
51
+ }
52
+
53
+ lines.push(` ${'-'.repeat(64)}`);
54
+ lines.push('');
55
+ lines.push(' REMINDER:');
56
+ lines.push(' 1. Open the target JSON file');
57
+ lines.push(' 2. Search for the keys listed above');
58
+ lines.push(' 3. Manually translate each value, preserving all placeholders');
59
+ lines.push(' exactly as they appear in the original');
60
+ lines.push(' 4. Verify placeholder integrity before runtime use');
61
+ lines.push('');
62
+ }
63
+
64
+ lines.push('');
65
+ lines.push(' The generated file can be used immediately for all');
66
+ lines.push(' non-placeholder text. Only the skipped keys need');
67
+ lines.push(' manual attention.');
68
+ lines.push('='.repeat(72));
69
+
70
+ return lines.join('\n');
71
+ }
72
+
73
+ function writeReport(reportText, filePath) {
74
+ if (!filePath) return;
75
+ try {
76
+ fs.writeFileSync(filePath, reportText + '\n', 'utf-8');
77
+ } catch (e) {
78
+ console.error('Failed to write report file:', e.message);
79
+ }
80
+ }
81
+
82
+ function formatSummaryLine(skippedCount, translatedCount, totalCount) {
83
+ return `[translate] ${translatedCount} translated, ${skippedCount} skipped (of ${totalCount} total keys)`;
84
+ }
85
+
86
+ module.exports = {
87
+ generateReport,
88
+ writeReport,
89
+ formatSummaryLine,
90
+ };
@@ -0,0 +1,148 @@
1
+ function deepTraverse(obj, visitor, keyPath = '') {
2
+ if (obj === null || obj === undefined) {
3
+ visitor({ type: 'null', keyPath, value: null });
4
+ return;
5
+ }
6
+ if (typeof obj === 'string') {
7
+ visitor({ type: 'string', keyPath, value: obj });
8
+ return;
9
+ }
10
+ if (typeof obj === 'number' || typeof obj === 'boolean') {
11
+ visitor({ type: 'leaf', keyPath, value: obj });
12
+ return;
13
+ }
14
+ if (Array.isArray(obj)) {
15
+ visitor({ type: 'array-start', keyPath, value: obj });
16
+ for (let i = 0; i < obj.length; i++) {
17
+ const childPath = keyPath ? `${keyPath}[${i}]` : `[${i}]`;
18
+ deepTraverse(obj[i], visitor, childPath);
19
+ }
20
+ visitor({ type: 'array-end', keyPath, value: obj });
21
+ return;
22
+ }
23
+ if (typeof obj === 'object') {
24
+ visitor({ type: 'object-start', keyPath, value: obj });
25
+ for (const key of Object.keys(obj)) {
26
+ const childPath = keyPath ? `${keyPath}.${key}` : key;
27
+ deepTraverse(obj[key], visitor, childPath);
28
+ }
29
+ visitor({ type: 'object-end', keyPath, value: obj });
30
+ return;
31
+ }
32
+ visitor({ type: 'unknown', keyPath, value: String(obj) });
33
+ }
34
+
35
+ function collectLeaves(obj, prefix = '') {
36
+ const entries = [];
37
+ for (const [key, value] of Object.entries(obj)) {
38
+ const fullKey = prefix ? `${prefix}.${key}` : key;
39
+ if (typeof value === 'string') {
40
+ entries.push({ keyPath: fullKey, value });
41
+ } else if (Array.isArray(value)) {
42
+ for (let i = 0; i < value.length; i++) {
43
+ const itemPath = `${fullKey}[${i}]`;
44
+ if (typeof value[i] === 'string') {
45
+ entries.push({ keyPath: itemPath, value: value[i] });
46
+ } else if (typeof value[i] === 'object' && value[i] !== null && !Array.isArray(value[i])) {
47
+ entries.push(...collectLeaves(value[i], itemPath));
48
+ }
49
+ }
50
+ } else if (typeof value === 'object' && value !== null) {
51
+ entries.push(...collectLeaves(value, fullKey));
52
+ }
53
+ }
54
+ return entries;
55
+ }
56
+
57
+ function setLeaf(obj, keyPath, value) {
58
+ const parts = [];
59
+ let current = '';
60
+ for (let i = 0; i < keyPath.length; i++) {
61
+ const ch = keyPath[i];
62
+ if (ch === '.' && keyPath[i - 1] !== '\\') {
63
+ if (current) parts.push(current);
64
+ current = '';
65
+ } else if (ch === '[') {
66
+ if (current) parts.push(current);
67
+ current = '';
68
+ i++;
69
+ while (i < keyPath.length && keyPath[i] !== ']') {
70
+ current += keyPath[i];
71
+ i++;
72
+ }
73
+ parts.push(`[${current}]`);
74
+ current = '';
75
+ } else {
76
+ current += ch;
77
+ }
78
+ }
79
+ if (current) parts.push(current);
80
+
81
+ let target = obj;
82
+ for (let i = 0; i < parts.length - 1; i++) {
83
+ const part = parts[i];
84
+ if (part.startsWith('[') && part.endsWith(']')) {
85
+ const idx = parseInt(part.slice(1, -1), 10);
86
+ if (!(idx in target)) target[idx] = {};
87
+ target = target[idx];
88
+ } else {
89
+ if (!(part in target)) target[part] = {};
90
+ target = target[part];
91
+ }
92
+ }
93
+
94
+ const last = parts[parts.length - 1];
95
+ if (last.startsWith('[') && last.endsWith(']')) {
96
+ target[parseInt(last.slice(1, -1), 10)] = value;
97
+ } else {
98
+ target[last] = value;
99
+ }
100
+ }
101
+
102
+ function getLeaf(obj, keyPath) {
103
+ const parts = [];
104
+ let current = '';
105
+ for (let i = 0; i < keyPath.length; i++) {
106
+ const ch = keyPath[i];
107
+ if (ch === '.' && keyPath[i - 1] !== '\\') {
108
+ if (current) parts.push(current);
109
+ current = '';
110
+ } else if (ch === '[') {
111
+ if (current) parts.push(current);
112
+ current = '';
113
+ i++;
114
+ while (i < keyPath.length && keyPath[i] !== ']') {
115
+ current += keyPath[i];
116
+ i++;
117
+ }
118
+ parts.push(`[${current}]`);
119
+ current = '';
120
+ } else {
121
+ current += ch;
122
+ }
123
+ }
124
+ if (current) parts.push(current);
125
+
126
+ let target = obj;
127
+ for (const part of parts) {
128
+ if (target === null || target === undefined) return undefined;
129
+ if (part.startsWith('[') && part.endsWith(']')) {
130
+ target = target[parseInt(part.slice(1, -1), 10)];
131
+ } else {
132
+ target = target[part];
133
+ }
134
+ }
135
+ return target;
136
+ }
137
+
138
+ function deepClone(obj) {
139
+ return JSON.parse(JSON.stringify(obj));
140
+ }
141
+
142
+ module.exports = {
143
+ deepTraverse,
144
+ collectLeaves,
145
+ setLeaf,
146
+ getLeaf,
147
+ deepClone,
148
+ };