@shipi18n/cli 1.1.0 → 1.1.2
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/bin/shipi18n.js +5 -2
- package/package.json +1 -1
- package/src/commands/init.js +443 -0
- package/src/commands/translate.js +1 -67
- package/src/lib/api.js +1 -1
- package/src/utils/incremental.js +90 -0
package/bin/shipi18n.js
CHANGED
|
@@ -5,6 +5,7 @@ import chalk from 'chalk';
|
|
|
5
5
|
import { translateCommand } from '../src/commands/translate.js';
|
|
6
6
|
import { keysCommand } from '../src/commands/keys.js';
|
|
7
7
|
import { configCommand } from '../src/commands/config.js';
|
|
8
|
+
import { initCommand } from '../src/commands/init.js';
|
|
8
9
|
import { readFileSync } from 'fs';
|
|
9
10
|
import { dirname, join } from 'path';
|
|
10
11
|
import { fileURLToPath } from 'url';
|
|
@@ -25,13 +26,14 @@ program
|
|
|
25
26
|
.version(packageJson.version, '-v, --version', 'Output the current version')
|
|
26
27
|
.addHelpText('after', `
|
|
27
28
|
${chalk.cyan('Examples:')}
|
|
29
|
+
$ shipi18n init
|
|
28
30
|
$ shipi18n translate en.json --target es,fr,de
|
|
29
31
|
$ shipi18n keys list
|
|
30
32
|
$ shipi18n config set apiKey sk_live_...
|
|
31
33
|
|
|
32
34
|
${chalk.cyan('Get started:')}
|
|
33
|
-
1.
|
|
34
|
-
2.
|
|
35
|
+
1. Run ${chalk.yellow('shipi18n init')} to detect your i18n setup
|
|
36
|
+
2. Sign up at ${chalk.underline('https://shipi18n.com')} and get your API key
|
|
35
37
|
3. Run: ${chalk.yellow('shipi18n config set apiKey YOUR_KEY')}
|
|
36
38
|
4. Translate: ${chalk.yellow('shipi18n translate en.json --target es,fr')}
|
|
37
39
|
|
|
@@ -39,6 +41,7 @@ ${chalk.gray('Documentation: https://shipi18n.com/docs/cli')}
|
|
|
39
41
|
`);
|
|
40
42
|
|
|
41
43
|
// Add commands
|
|
44
|
+
initCommand(program);
|
|
42
45
|
translateCommand(program);
|
|
43
46
|
keysCommand(program);
|
|
44
47
|
configCommand(program);
|
package/package.json
CHANGED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
4
|
+
import { join, dirname, basename, extname } from 'path';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
|
|
7
|
+
// Known i18n frameworks and their signatures
|
|
8
|
+
const I18N_FRAMEWORKS = {
|
|
9
|
+
'i18next': {
|
|
10
|
+
packages: ['i18next', 'react-i18next', 'next-i18next', 'i18next-http-backend', 'i18next-browser-languagedetector'],
|
|
11
|
+
placeholderPattern: 'i18next',
|
|
12
|
+
},
|
|
13
|
+
'react-intl': {
|
|
14
|
+
packages: ['react-intl', 'formatjs', '@formatjs/intl'],
|
|
15
|
+
placeholderPattern: 'icu',
|
|
16
|
+
},
|
|
17
|
+
'vue-i18n': {
|
|
18
|
+
packages: ['vue-i18n', '@intlify/vue-i18n'],
|
|
19
|
+
placeholderPattern: 'i18next',
|
|
20
|
+
},
|
|
21
|
+
'next-intl': {
|
|
22
|
+
packages: ['next-intl'],
|
|
23
|
+
placeholderPattern: 'icu',
|
|
24
|
+
},
|
|
25
|
+
'lingui': {
|
|
26
|
+
packages: ['@lingui/core', '@lingui/react', '@lingui/macro'],
|
|
27
|
+
placeholderPattern: 'icu',
|
|
28
|
+
},
|
|
29
|
+
'polyglot': {
|
|
30
|
+
packages: ['node-polyglot'],
|
|
31
|
+
placeholderPattern: 'printf',
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Common locale directory patterns
|
|
36
|
+
const LOCALE_DIR_PATTERNS = [
|
|
37
|
+
'locales', 'locale', 'lang', 'langs', 'languages', 'i18n',
|
|
38
|
+
'translations', 'messages', 'public/locales', 'src/locales',
|
|
39
|
+
'src/i18n', 'src/translations', 'app/locales', 'assets/locales'
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Common language codes
|
|
43
|
+
const LANGUAGE_CODES = [
|
|
44
|
+
'en', 'es', 'fr', 'de', 'it', 'pt', 'ja', 'ko', 'zh', 'ru', 'ar', 'hi', 'nl', 'pl', 'tr', 'vi', 'th',
|
|
45
|
+
'en-US', 'en-GB', 'es-ES', 'es-MX', 'fr-FR', 'fr-CA', 'de-DE', 'pt-BR', 'pt-PT', 'zh-CN', 'zh-TW'
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Detect i18n framework from package.json
|
|
50
|
+
*/
|
|
51
|
+
function detectFramework(packageJson) {
|
|
52
|
+
const allDeps = {
|
|
53
|
+
...(packageJson.dependencies || {}),
|
|
54
|
+
...(packageJson.devDependencies || {})
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const detected = [];
|
|
58
|
+
|
|
59
|
+
for (const [framework, config] of Object.entries(I18N_FRAMEWORKS)) {
|
|
60
|
+
const matchedPackages = config.packages.filter(pkg => allDeps[pkg]);
|
|
61
|
+
if (matchedPackages.length > 0) {
|
|
62
|
+
detected.push({
|
|
63
|
+
framework,
|
|
64
|
+
confidence: matchedPackages.length / config.packages.length,
|
|
65
|
+
matchedPackages,
|
|
66
|
+
placeholderPattern: config.placeholderPattern
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
detected.sort((a, b) => b.confidence - a.confidence);
|
|
72
|
+
return { detected, primary: detected[0] || null };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Recursively get all files in a directory
|
|
77
|
+
*/
|
|
78
|
+
function getAllFiles(dirPath, basePath = '', files = []) {
|
|
79
|
+
if (!existsSync(dirPath)) return files;
|
|
80
|
+
|
|
81
|
+
const items = readdirSync(dirPath);
|
|
82
|
+
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
// Skip node_modules, .git, etc.
|
|
85
|
+
if (item === 'node_modules' || item === '.git' || item === 'dist' || item === 'build') {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const fullPath = join(dirPath, item);
|
|
90
|
+
const relativePath = basePath ? `${basePath}/${item}` : item;
|
|
91
|
+
|
|
92
|
+
if (statSync(fullPath).isDirectory()) {
|
|
93
|
+
getAllFiles(fullPath, relativePath, files);
|
|
94
|
+
} else {
|
|
95
|
+
files.push(relativePath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Detect locale file structure
|
|
104
|
+
*/
|
|
105
|
+
function detectFileStructure(files) {
|
|
106
|
+
const result = {
|
|
107
|
+
localeDirectories: [],
|
|
108
|
+
sourceLanguage: null,
|
|
109
|
+
targetLanguages: [],
|
|
110
|
+
namespaces: [],
|
|
111
|
+
fileFormat: 'json',
|
|
112
|
+
structure: 'flat'
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Find locale directories
|
|
116
|
+
const dirCounts = {};
|
|
117
|
+
for (const file of files) {
|
|
118
|
+
const parts = file.split('/');
|
|
119
|
+
for (let i = 0; i < parts.length; i++) {
|
|
120
|
+
const dir = parts.slice(0, i + 1).join('/');
|
|
121
|
+
const dirName = parts[i].toLowerCase();
|
|
122
|
+
if (LOCALE_DIR_PATTERNS.includes(dirName) || LANGUAGE_CODES.includes(parts[i])) {
|
|
123
|
+
dirCounts[dir] = (dirCounts[dir] || 0) + 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sortedDirs = Object.entries(dirCounts).sort((a, b) => b[1] - a[1]);
|
|
129
|
+
if (sortedDirs.length > 0) {
|
|
130
|
+
result.localeDirectories = sortedDirs.slice(0, 3).map(([dir]) => dir);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Find language files
|
|
134
|
+
const langFiles = files.filter(f => {
|
|
135
|
+
const filename = f.split('/').pop();
|
|
136
|
+
const ext = filename.split('.').pop();
|
|
137
|
+
const name = filename.replace(`.${ext}`, '');
|
|
138
|
+
return (ext === 'json' || ext === 'yaml' || ext === 'yml') &&
|
|
139
|
+
(LANGUAGE_CODES.includes(name) || LANGUAGE_CODES.includes(name.toLowerCase()));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const languages = new Set();
|
|
143
|
+
const namespaces = new Set();
|
|
144
|
+
|
|
145
|
+
for (const file of langFiles) {
|
|
146
|
+
const parts = file.split('/');
|
|
147
|
+
const filename = parts.pop();
|
|
148
|
+
const ext = filename.split('.').pop();
|
|
149
|
+
const name = filename.replace(`.${ext}`, '');
|
|
150
|
+
|
|
151
|
+
if (LANGUAGE_CODES.includes(name) || LANGUAGE_CODES.includes(name.toLowerCase())) {
|
|
152
|
+
languages.add(name.toLowerCase());
|
|
153
|
+
result.structure = 'flat';
|
|
154
|
+
} else {
|
|
155
|
+
const parentDir = parts[parts.length - 1];
|
|
156
|
+
if (parentDir && (LANGUAGE_CODES.includes(parentDir) || LANGUAGE_CODES.includes(parentDir.toLowerCase()))) {
|
|
157
|
+
languages.add(parentDir.toLowerCase());
|
|
158
|
+
namespaces.add(name);
|
|
159
|
+
result.structure = 'nested';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (ext === 'yaml' || ext === 'yml') {
|
|
164
|
+
result.fileFormat = 'yaml';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
result.targetLanguages = Array.from(languages).filter(l => l !== 'en');
|
|
169
|
+
result.sourceLanguage = languages.has('en') ? 'en' : Array.from(languages)[0] || 'en';
|
|
170
|
+
result.namespaces = Array.from(namespaces);
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Detect placeholder patterns from translation content
|
|
177
|
+
*/
|
|
178
|
+
function detectPlaceholderPatterns(content) {
|
|
179
|
+
const PLACEHOLDER_PATTERNS = {
|
|
180
|
+
i18next: { regex: /\{\{[^}]+\}\}/g },
|
|
181
|
+
icu: { regex: /\{[a-zA-Z_][a-zA-Z0-9_]*(?:,\s*(?:number|date|time|plural|select|selectordinal))?[^}]*\}/g },
|
|
182
|
+
printf: { regex: /%[sd@]|%\d+\$[sd]/g },
|
|
183
|
+
ruby: { regex: /%\{[^}]+\}/g }
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const flatContent = flattenObject(content);
|
|
187
|
+
const values = Object.values(flatContent).filter(v => typeof v === 'string');
|
|
188
|
+
|
|
189
|
+
const patternCounts = {};
|
|
190
|
+
|
|
191
|
+
for (const [patternName, patternConfig] of Object.entries(PLACEHOLDER_PATTERNS)) {
|
|
192
|
+
patternCounts[patternName] = 0;
|
|
193
|
+
for (const value of values) {
|
|
194
|
+
const matches = value.match(patternConfig.regex);
|
|
195
|
+
if (matches) {
|
|
196
|
+
patternCounts[patternName] += matches.length;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const sortedPatterns = Object.entries(patternCounts)
|
|
202
|
+
.filter(([_, count]) => count > 0)
|
|
203
|
+
.sort((a, b) => b[1] - a[1]);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
patterns: patternCounts,
|
|
207
|
+
primary: sortedPatterns[0]?.[0] || 'icu'
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function flattenObject(obj, prefix = '') {
|
|
212
|
+
const result = {};
|
|
213
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
214
|
+
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
215
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
216
|
+
Object.assign(result, flattenObject(value, newKey));
|
|
217
|
+
} else {
|
|
218
|
+
result[newKey] = value;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Generate GitHub Action workflow
|
|
226
|
+
*/
|
|
227
|
+
function generateGitHubActionWorkflow({ sourceDir, targetLanguages, sourceLanguage }) {
|
|
228
|
+
return `name: Translate
|
|
229
|
+
|
|
230
|
+
on:
|
|
231
|
+
push:
|
|
232
|
+
branches: [main]
|
|
233
|
+
paths:
|
|
234
|
+
- '${sourceDir}/**'
|
|
235
|
+
workflow_dispatch:
|
|
236
|
+
|
|
237
|
+
jobs:
|
|
238
|
+
translate:
|
|
239
|
+
runs-on: ubuntu-latest
|
|
240
|
+
steps:
|
|
241
|
+
- uses: actions/checkout@v4
|
|
242
|
+
|
|
243
|
+
- name: Translate locale files
|
|
244
|
+
uses: shipi18n/shipi18n-github-action@v1
|
|
245
|
+
with:
|
|
246
|
+
api-key: \${{ secrets.SHIPI18N_API_KEY }}
|
|
247
|
+
source-dir: '${sourceDir}'
|
|
248
|
+
target-languages: '${targetLanguages.join(',')}'
|
|
249
|
+
source-language: '${sourceLanguage}'
|
|
250
|
+
incremental: 'true'
|
|
251
|
+
verify: 'true'
|
|
252
|
+
create-pr: 'true'
|
|
253
|
+
`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function initCommand(program) {
|
|
257
|
+
program
|
|
258
|
+
.command('init')
|
|
259
|
+
.description('Analyze your project and generate shipi18n configuration')
|
|
260
|
+
.option('-y, --yes', 'Skip prompts and use detected defaults')
|
|
261
|
+
.option('--no-workflow', 'Skip GitHub Action workflow generation')
|
|
262
|
+
.action(async (options) => {
|
|
263
|
+
logger.log('');
|
|
264
|
+
logger.log(chalk.cyan.bold('🔍 Shipi18n Project Analyzer'));
|
|
265
|
+
logger.log(chalk.gray('Detecting your i18n setup...'));
|
|
266
|
+
logger.log('');
|
|
267
|
+
|
|
268
|
+
const cwd = process.cwd();
|
|
269
|
+
|
|
270
|
+
// Step 1: Read package.json
|
|
271
|
+
let packageJson = null;
|
|
272
|
+
let frameworkResult = { detected: [], primary: null };
|
|
273
|
+
const packageJsonPath = join(cwd, 'package.json');
|
|
274
|
+
|
|
275
|
+
if (existsSync(packageJsonPath)) {
|
|
276
|
+
try {
|
|
277
|
+
packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
278
|
+
frameworkResult = detectFramework(packageJson);
|
|
279
|
+
|
|
280
|
+
if (frameworkResult.primary) {
|
|
281
|
+
logger.success(`Framework: ${chalk.cyan(frameworkResult.primary.framework)}`);
|
|
282
|
+
logger.log(chalk.gray(` Detected packages: ${frameworkResult.primary.matchedPackages.join(', ')}`));
|
|
283
|
+
} else {
|
|
284
|
+
logger.warn('No i18n framework detected in package.json');
|
|
285
|
+
}
|
|
286
|
+
} catch (e) {
|
|
287
|
+
logger.warn('Could not parse package.json');
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
logger.warn('No package.json found');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Step 2: Scan file structure
|
|
294
|
+
logger.log('');
|
|
295
|
+
const files = getAllFiles(cwd);
|
|
296
|
+
const fileStructure = detectFileStructure(files);
|
|
297
|
+
|
|
298
|
+
if (fileStructure.localeDirectories.length > 0) {
|
|
299
|
+
logger.success(`Locale directory: ${chalk.cyan(fileStructure.localeDirectories[0])}`);
|
|
300
|
+
logger.log(chalk.gray(` Structure: ${fileStructure.structure}`));
|
|
301
|
+
logger.log(chalk.gray(` Format: ${fileStructure.fileFormat}`));
|
|
302
|
+
} else {
|
|
303
|
+
logger.warn('No locale directory detected');
|
|
304
|
+
fileStructure.localeDirectories = ['locales'];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (fileStructure.sourceLanguage) {
|
|
308
|
+
logger.success(`Source language: ${chalk.cyan(fileStructure.sourceLanguage)}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (fileStructure.targetLanguages.length > 0) {
|
|
312
|
+
logger.success(`Target languages: ${chalk.cyan(fileStructure.targetLanguages.join(', '))}`);
|
|
313
|
+
} else {
|
|
314
|
+
logger.info('No target languages detected - using defaults (es, fr, de)');
|
|
315
|
+
fileStructure.targetLanguages = ['es', 'fr', 'de'];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (fileStructure.namespaces.length > 0) {
|
|
319
|
+
logger.log(chalk.gray(` Namespaces: ${fileStructure.namespaces.join(', ')}`));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Step 3: Detect placeholder patterns from sample file
|
|
323
|
+
let placeholderResult = { primary: 'icu' };
|
|
324
|
+
|
|
325
|
+
if (fileStructure.localeDirectories.length > 0) {
|
|
326
|
+
const localeDir = fileStructure.localeDirectories[0];
|
|
327
|
+
const sourceFile = files.find(f =>
|
|
328
|
+
f.startsWith(localeDir) &&
|
|
329
|
+
f.endsWith('.json') &&
|
|
330
|
+
(f.includes('/en') || f.includes('/en.') || f.endsWith('en.json'))
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
if (sourceFile) {
|
|
334
|
+
try {
|
|
335
|
+
const content = JSON.parse(readFileSync(join(cwd, sourceFile), 'utf8'));
|
|
336
|
+
placeholderResult = detectPlaceholderPatterns(content);
|
|
337
|
+
logger.success(`Placeholder format: ${chalk.cyan(placeholderResult.primary)}`);
|
|
338
|
+
} catch (e) {
|
|
339
|
+
// Use framework default if available
|
|
340
|
+
if (frameworkResult.primary?.placeholderPattern) {
|
|
341
|
+
placeholderResult.primary = frameworkResult.primary.placeholderPattern;
|
|
342
|
+
logger.info(`Placeholder format: ${chalk.cyan(placeholderResult.primary)} (from framework)`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
logger.log('');
|
|
349
|
+
|
|
350
|
+
// Build configuration
|
|
351
|
+
const sourceDir = fileStructure.structure === 'nested'
|
|
352
|
+
? `${fileStructure.localeDirectories[0]}/${fileStructure.sourceLanguage || 'en'}`
|
|
353
|
+
: fileStructure.localeDirectories[0];
|
|
354
|
+
|
|
355
|
+
const config = {
|
|
356
|
+
sourceLanguage: fileStructure.sourceLanguage || 'en',
|
|
357
|
+
targetLanguages: fileStructure.targetLanguages.length > 0
|
|
358
|
+
? fileStructure.targetLanguages
|
|
359
|
+
: ['es', 'fr', 'de'],
|
|
360
|
+
sourceDir,
|
|
361
|
+
outputDir: fileStructure.localeDirectories[0] || 'locales',
|
|
362
|
+
fileFormat: fileStructure.fileFormat || 'json',
|
|
363
|
+
placeholderFormat: placeholderResult.primary,
|
|
364
|
+
incremental: true,
|
|
365
|
+
verify: true,
|
|
366
|
+
selfCorrect: false
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// Confirmation or auto-accept
|
|
370
|
+
let confirmed = options.yes;
|
|
371
|
+
|
|
372
|
+
if (!confirmed) {
|
|
373
|
+
logger.log(chalk.cyan.bold('📝 Proposed Configuration:'));
|
|
374
|
+
logger.log('');
|
|
375
|
+
logger.log(JSON.stringify(config, null, 2));
|
|
376
|
+
logger.log('');
|
|
377
|
+
|
|
378
|
+
const { proceed } = await inquirer.prompt([{
|
|
379
|
+
type: 'confirm',
|
|
380
|
+
name: 'proceed',
|
|
381
|
+
message: 'Create shipi18n.config.json with these settings?',
|
|
382
|
+
default: true
|
|
383
|
+
}]);
|
|
384
|
+
confirmed = proceed;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!confirmed) {
|
|
388
|
+
logger.info('Cancelled. Run again with different options or manually create config.');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Write config file
|
|
393
|
+
const configPath = join(cwd, 'shipi18n.config.json');
|
|
394
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
395
|
+
logger.success(`Created ${chalk.cyan('shipi18n.config.json')}`);
|
|
396
|
+
|
|
397
|
+
// GitHub Action workflow
|
|
398
|
+
if (options.workflow !== false) {
|
|
399
|
+
let createWorkflow = options.yes;
|
|
400
|
+
|
|
401
|
+
if (!createWorkflow) {
|
|
402
|
+
const { workflow } = await inquirer.prompt([{
|
|
403
|
+
type: 'confirm',
|
|
404
|
+
name: 'workflow',
|
|
405
|
+
message: 'Create GitHub Action workflow for automatic translations?',
|
|
406
|
+
default: true
|
|
407
|
+
}]);
|
|
408
|
+
createWorkflow = workflow;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (createWorkflow) {
|
|
412
|
+
const workflowDir = join(cwd, '.github', 'workflows');
|
|
413
|
+
const workflowPath = join(workflowDir, 'translate.yml');
|
|
414
|
+
|
|
415
|
+
// Create directories if needed
|
|
416
|
+
const { mkdirSync } = await import('fs');
|
|
417
|
+
mkdirSync(workflowDir, { recursive: true });
|
|
418
|
+
|
|
419
|
+
const workflow = generateGitHubActionWorkflow({
|
|
420
|
+
sourceDir: config.sourceDir,
|
|
421
|
+
targetLanguages: config.targetLanguages,
|
|
422
|
+
sourceLanguage: config.sourceLanguage
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
writeFileSync(workflowPath, workflow);
|
|
426
|
+
logger.success(`Created ${chalk.cyan('.github/workflows/translate.yml')}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Next steps
|
|
431
|
+
logger.log('');
|
|
432
|
+
logger.log(chalk.cyan.bold('🚀 Next Steps:'));
|
|
433
|
+
logger.log('');
|
|
434
|
+
logger.log(` 1. Get your API key at ${chalk.underline('https://shipi18n.com')}`);
|
|
435
|
+
logger.log(` 2. Add secret: ${chalk.yellow('SHIPI18N_API_KEY')} to your GitHub repository`);
|
|
436
|
+
logger.log(` 3. Push changes to trigger automatic translations`);
|
|
437
|
+
logger.log('');
|
|
438
|
+
logger.log(chalk.gray('Or translate manually:'));
|
|
439
|
+
logger.log(` ${chalk.yellow(`shipi18n config set apiKey YOUR_KEY`)}`);
|
|
440
|
+
logger.log(` ${chalk.yellow(`shipi18n translate ${config.sourceDir} --target ${config.targetLanguages.join(',')}`)}`);
|
|
441
|
+
logger.log('');
|
|
442
|
+
});
|
|
443
|
+
}
|
|
@@ -4,73 +4,7 @@ import chalk from 'chalk';
|
|
|
4
4
|
import { Shipi18nAPI } from '../lib/api.js';
|
|
5
5
|
import { getConfig } from '../lib/config.js';
|
|
6
6
|
import { logger, formatError } from '../utils/logger.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Flatten a nested object into dot-notation keys
|
|
10
|
-
*/
|
|
11
|
-
function flattenObject(obj, prefix = '') {
|
|
12
|
-
const result = {};
|
|
13
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
14
|
-
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
15
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
16
|
-
Object.assign(result, flattenObject(value, newKey));
|
|
17
|
-
} else {
|
|
18
|
-
result[newKey] = value;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return result;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Unflatten dot-notation keys back into nested object
|
|
26
|
-
*/
|
|
27
|
-
function unflattenObject(obj) {
|
|
28
|
-
const result = {};
|
|
29
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
30
|
-
const keys = key.split('.');
|
|
31
|
-
let current = result;
|
|
32
|
-
for (let i = 0; i < keys.length - 1; i++) {
|
|
33
|
-
if (!current[keys[i]]) {
|
|
34
|
-
current[keys[i]] = {};
|
|
35
|
-
}
|
|
36
|
-
current = current[keys[i]];
|
|
37
|
-
}
|
|
38
|
-
current[keys[keys.length - 1]] = value;
|
|
39
|
-
}
|
|
40
|
-
return result;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Deep merge two objects
|
|
45
|
-
*/
|
|
46
|
-
function deepMerge(target, source) {
|
|
47
|
-
const result = { ...target };
|
|
48
|
-
for (const [key, value] of Object.entries(source)) {
|
|
49
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
50
|
-
result[key] = deepMerge(result[key] || {}, value);
|
|
51
|
-
} else {
|
|
52
|
-
result[key] = value;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return result;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Find keys that exist in source but not in target
|
|
60
|
-
*/
|
|
61
|
-
function findMissingKeys(sourceJson, targetJson) {
|
|
62
|
-
const sourceFlat = flattenObject(sourceJson);
|
|
63
|
-
const targetFlat = flattenObject(targetJson);
|
|
64
|
-
|
|
65
|
-
const missingKeys = {};
|
|
66
|
-
for (const [key, value] of Object.entries(sourceFlat)) {
|
|
67
|
-
if (!(key in targetFlat)) {
|
|
68
|
-
missingKeys[key] = value;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return unflattenObject(missingKeys);
|
|
73
|
-
}
|
|
7
|
+
import { flattenObject, unflattenObject, deepMerge, findMissingKeys } from '../utils/incremental.js';
|
|
74
8
|
|
|
75
9
|
export function translateCommand(program) {
|
|
76
10
|
program
|
package/src/lib/api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import dotenv from 'dotenv';
|
|
2
2
|
dotenv.config();
|
|
3
3
|
|
|
4
|
-
const API_BASE_URL = process.env.SHIPI18N_API_URL || 'https://
|
|
4
|
+
const API_BASE_URL = process.env.SHIPI18N_API_URL || 'https://ydjkwckq3f.execute-api.us-east-1.amazonaws.com';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Shipi18n API Client
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for incremental translation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Flatten a nested object into dot-notation keys
|
|
7
|
+
* @param {Object} obj - Nested object
|
|
8
|
+
* @param {string} prefix - Key prefix (used for recursion)
|
|
9
|
+
* @returns {Object} Flattened object with dot-notation keys
|
|
10
|
+
*/
|
|
11
|
+
export function flattenObject(obj, prefix = '') {
|
|
12
|
+
const result = {};
|
|
13
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
14
|
+
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
15
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
16
|
+
Object.assign(result, flattenObject(value, newKey));
|
|
17
|
+
} else {
|
|
18
|
+
result[newKey] = value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Unflatten dot-notation keys back into nested object
|
|
26
|
+
* @param {Object} obj - Flattened object with dot-notation keys
|
|
27
|
+
* @returns {Object} Nested object
|
|
28
|
+
*/
|
|
29
|
+
export function unflattenObject(obj) {
|
|
30
|
+
const result = {};
|
|
31
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
32
|
+
const keys = key.split('.');
|
|
33
|
+
let current = result;
|
|
34
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
35
|
+
if (!current[keys[i]]) {
|
|
36
|
+
current[keys[i]] = {};
|
|
37
|
+
}
|
|
38
|
+
current = current[keys[i]];
|
|
39
|
+
}
|
|
40
|
+
current[keys[keys.length - 1]] = value;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Deep merge two objects
|
|
47
|
+
* @param {Object} target - Target object
|
|
48
|
+
* @param {Object} source - Source object to merge in
|
|
49
|
+
* @returns {Object} Merged object
|
|
50
|
+
*/
|
|
51
|
+
export function deepMerge(target, source) {
|
|
52
|
+
const result = { ...target };
|
|
53
|
+
for (const [key, value] of Object.entries(source)) {
|
|
54
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
55
|
+
result[key] = deepMerge(result[key] || {}, value);
|
|
56
|
+
} else {
|
|
57
|
+
result[key] = value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Find keys that exist in source but not in target
|
|
65
|
+
* @param {Object} sourceJson - Source JSON object
|
|
66
|
+
* @param {Object} targetJson - Target JSON object
|
|
67
|
+
* @returns {Object} Object containing only missing keys
|
|
68
|
+
*/
|
|
69
|
+
export function findMissingKeys(sourceJson, targetJson) {
|
|
70
|
+
const sourceFlat = flattenObject(sourceJson);
|
|
71
|
+
const targetFlat = flattenObject(targetJson);
|
|
72
|
+
|
|
73
|
+
const missingKeys = {};
|
|
74
|
+
for (const [key, value] of Object.entries(sourceFlat)) {
|
|
75
|
+
if (!(key in targetFlat)) {
|
|
76
|
+
missingKeys[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return unflattenObject(missingKeys);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Count the number of leaf keys in a nested object
|
|
85
|
+
* @param {Object} obj - Object to count keys in
|
|
86
|
+
* @returns {number} Number of leaf keys
|
|
87
|
+
*/
|
|
88
|
+
export function countKeys(obj) {
|
|
89
|
+
return Object.keys(flattenObject(obj)).length;
|
|
90
|
+
}
|