@localheroai/cli 0.0.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/package.json +62 -0
- package/src/api/auth.js +15 -0
- package/src/api/client.js +42 -0
- package/src/api/imports.js +20 -0
- package/src/api/projects.js +24 -0
- package/src/api/translations.js +37 -0
- package/src/cli.js +71 -0
- package/src/commands/init.js +285 -0
- package/src/commands/login.js +69 -0
- package/src/commands/translate.js +282 -0
- package/src/utils/auth.js +23 -0
- package/src/utils/config.js +96 -0
- package/src/utils/defaults.js +7 -0
- package/src/utils/files.js +139 -0
- package/src/utils/git.js +16 -0
- package/src/utils/github.js +65 -0
- package/src/utils/helpers.js +3 -0
- package/src/utils/import-service.js +146 -0
- package/src/utils/project-service.js +11 -0
- package/src/utils/prompt-service.js +51 -0
- package/src/utils/translation-updater.js +154 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { configService } from '../utils/config.js';
|
|
3
|
+
import { findTranslationFiles } from '../utils/files.js';
|
|
4
|
+
import { createTranslationJob, checkJobStatus } from '../api/translations.js';
|
|
5
|
+
import { updateTranslationFile } from '../utils/translation-updater.js';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { checkAuth } from '../utils/auth.js';
|
|
8
|
+
import { autoCommitChanges } from '../utils/github.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LOCALE_REGEX = '.*?([a-z]{2}(?:-[A-Z]{2})?)\\.(?:yml|yaml|json)$';
|
|
11
|
+
const MAX_RETRY_ATTEMPTS = 3;
|
|
12
|
+
const BATCH_SIZE = 100;
|
|
13
|
+
|
|
14
|
+
function validateConfig(config) {
|
|
15
|
+
const required = ['projectId', 'sourceLocale', 'outputLocales', 'translationFiles'];
|
|
16
|
+
const missing = required.filter(key => !config[key]);
|
|
17
|
+
|
|
18
|
+
if (missing.length) {
|
|
19
|
+
throw new Error(`Missing required config: ${missing.join(', ')}. Run 'npx localhero init' to set up your project.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!Array.isArray(config.outputLocales) || config.outputLocales.length === 0) {
|
|
23
|
+
throw new Error('outputLocales must be an array with at least one locale');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (config.outputLocales.length > 10) {
|
|
27
|
+
throw new Error('Maximum 10 target languages allowed per request');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!config.translationFiles.paths || !Array.isArray(config.translationFiles.paths)) {
|
|
31
|
+
throw new Error('translationFiles.paths must be an array of paths');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findMissingTranslations(sourceKeys, targetKeys) {
|
|
36
|
+
const missing = {};
|
|
37
|
+
for (const [key, value] of Object.entries(sourceKeys)) {
|
|
38
|
+
if (!targetKeys[key]) {
|
|
39
|
+
missing[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return missing;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function retryWithBackoff(operation, attempt = 1) {
|
|
46
|
+
try {
|
|
47
|
+
return await operation();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (attempt >= MAX_RETRY_ATTEMPTS) throw error;
|
|
50
|
+
|
|
51
|
+
const backoffTime = Math.min(1000 * Math.pow(2, attempt), 30000);
|
|
52
|
+
const jitter = Math.random() * 1000;
|
|
53
|
+
const waitTime = backoffTime + jitter;
|
|
54
|
+
|
|
55
|
+
console.log(chalk.yellow(`⚠️ API error, retrying in ${Math.round(waitTime / 1000)}s (attempt ${attempt}/${MAX_RETRY_ATTEMPTS})`));
|
|
56
|
+
console.log(error);
|
|
57
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
58
|
+
|
|
59
|
+
return retryWithBackoff(operation, attempt + 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function batchKeysWithMissing(sourceFiles, missingByLocale, batchSize = BATCH_SIZE) {
|
|
64
|
+
const batches = [];
|
|
65
|
+
const sourceFileEntries = new Map();
|
|
66
|
+
|
|
67
|
+
for (const [locale, localeData] of Object.entries(missingByLocale)) {
|
|
68
|
+
const sourceFile = sourceFiles.find(f => f.path === localeData.path);
|
|
69
|
+
if (!sourceFile) continue;
|
|
70
|
+
|
|
71
|
+
if (!sourceFileEntries.has(sourceFile.path)) {
|
|
72
|
+
sourceFileEntries.set(sourceFile.path, {
|
|
73
|
+
path: path.relative(process.cwd(), sourceFile.path),
|
|
74
|
+
keys: {},
|
|
75
|
+
locales: new Set()
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const entry = sourceFileEntries.get(sourceFile.path);
|
|
80
|
+
entry.keys = { ...entry.keys, ...localeData.keys };
|
|
81
|
+
entry.locales.add(locale);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const entry of sourceFileEntries.values()) {
|
|
85
|
+
const keys = entry.keys;
|
|
86
|
+
const keyEntries = Object.entries(keys);
|
|
87
|
+
for (let i = 0; i < keyEntries.length; i += batchSize) {
|
|
88
|
+
const batchKeys = Object.fromEntries(keyEntries.slice(i, i + batchSize));
|
|
89
|
+
batches.push([{
|
|
90
|
+
path: entry.path,
|
|
91
|
+
content: Buffer.from(JSON.stringify({ keys: batchKeys })).toString('base64')
|
|
92
|
+
}]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return batches;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function translate(options = {}) {
|
|
100
|
+
const { verbose = false, commit = false } = options;
|
|
101
|
+
const log = verbose ? console.log : () => { };
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const config = await configService.getValidProjectConfig();
|
|
105
|
+
const isAuthenticated = await checkAuth();
|
|
106
|
+
|
|
107
|
+
if (!isAuthenticated) {
|
|
108
|
+
throw new Error('No API key found. Run `npx localhero login` or set LOCALHERO_API_KEY');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log(chalk.blue('ℹ️ Loading configuration from localhero.json'));
|
|
112
|
+
|
|
113
|
+
const { translationFiles } = config;
|
|
114
|
+
const fileLocaleRegex = DEFAULT_LOCALE_REGEX;
|
|
115
|
+
let allFiles = [];
|
|
116
|
+
|
|
117
|
+
for (const translationPath of translationFiles.paths) {
|
|
118
|
+
const filesInPath = await findTranslationFiles(translationPath, fileLocaleRegex);
|
|
119
|
+
allFiles = allFiles.concat(filesInPath);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!allFiles.length) {
|
|
123
|
+
console.error(chalk.red('❌ No translation files found'));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const sourceFiles = allFiles.filter(f => f.locale === config.sourceLocale);
|
|
128
|
+
const targetFiles = allFiles.filter(f => config.outputLocales.includes(f.locale));
|
|
129
|
+
|
|
130
|
+
if (!sourceFiles.length) {
|
|
131
|
+
console.error(chalk.red(`❌ No source files found for locale ${config.sourceLocale}`));
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
log(chalk.blue(`✓ Found ${allFiles.length} translation files`));
|
|
136
|
+
log(chalk.blue(`ℹ️ Analyzing target translations...`));
|
|
137
|
+
|
|
138
|
+
const missingByLocale = {};
|
|
139
|
+
for (const sourceFile of sourceFiles) {
|
|
140
|
+
const sourceContent = JSON.parse(Buffer.from(sourceFile.content, 'base64').toString());
|
|
141
|
+
|
|
142
|
+
for (const targetLocale of config.outputLocales) {
|
|
143
|
+
const targetFile = targetFiles.find(f => f.locale === targetLocale);
|
|
144
|
+
const targetContent = targetFile ?
|
|
145
|
+
JSON.parse(Buffer.from(targetFile.content, 'base64').toString()) :
|
|
146
|
+
{ keys: {} };
|
|
147
|
+
|
|
148
|
+
const missing = findMissingTranslations(sourceContent.keys, targetContent.keys);
|
|
149
|
+
const missingCount = Object.keys(missing).length;
|
|
150
|
+
|
|
151
|
+
if (missingCount > 0) {
|
|
152
|
+
if (!missingByLocale[targetLocale]) {
|
|
153
|
+
missingByLocale[targetLocale] = {
|
|
154
|
+
keys: {},
|
|
155
|
+
path: sourceFile.path
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
missingByLocale[targetLocale].keys = {
|
|
159
|
+
...missingByLocale[targetLocale].keys,
|
|
160
|
+
...missing
|
|
161
|
+
};
|
|
162
|
+
console.log(chalk.blue(` ${targetLocale} (${path.basename(sourceFile.path)}): ${missingCount} missing keys`));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (Object.keys(missingByLocale).length === 0) {
|
|
168
|
+
console.log(chalk.green('✓ All translations are up to date!'));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const updatedFiles = new Set();
|
|
173
|
+
const batches = batchKeysWithMissing(sourceFiles, missingByLocale);
|
|
174
|
+
let totalKeysProcessed = 0;
|
|
175
|
+
let totalErrors = 0;
|
|
176
|
+
const processedKeys = new Set();
|
|
177
|
+
let hasShownUpdateMessage = false;
|
|
178
|
+
|
|
179
|
+
for (const [batchIndex, batch] of batches.entries()) {
|
|
180
|
+
log(chalk.blue(`\nProcessing batch ${batchIndex + 1}/${batches.length}...`));
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const { jobs } = await retryWithBackoff(() =>
|
|
184
|
+
createTranslationJob({
|
|
185
|
+
sourceFiles: batch,
|
|
186
|
+
targetLocales: config.outputLocales,
|
|
187
|
+
projectId: config.projectId
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
const jobStatuses = new Map();
|
|
191
|
+
let allCompleted = false;
|
|
192
|
+
|
|
193
|
+
while (!allCompleted) {
|
|
194
|
+
allCompleted = true;
|
|
195
|
+
let totalProgress = 0;
|
|
196
|
+
|
|
197
|
+
for (const job of jobs) {
|
|
198
|
+
const status = await retryWithBackoff(() => checkJobStatus(job.id, true));
|
|
199
|
+
jobStatuses.set(job.id, status);
|
|
200
|
+
|
|
201
|
+
if (status.status === 'processing') {
|
|
202
|
+
allCompleted = false;
|
|
203
|
+
}
|
|
204
|
+
totalProgress += status.progress.percentage;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const averageProgress = Math.round(totalProgress / jobs.length);
|
|
208
|
+
const bar = '='.repeat(averageProgress / 2) + ' '.repeat(50 - averageProgress / 2);
|
|
209
|
+
|
|
210
|
+
process.stdout.write(`\r⧗ Translating... [${bar}] ${averageProgress}%`);
|
|
211
|
+
|
|
212
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const job of jobs) {
|
|
216
|
+
const status = jobStatuses.get(job.id);
|
|
217
|
+
if (status.status === 'completed' && status.translations) {
|
|
218
|
+
if (!hasShownUpdateMessage) {
|
|
219
|
+
console.log(chalk.blue('\nℹ️ Updating translation files...'));
|
|
220
|
+
hasShownUpdateMessage = true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const translations = status.translations.translations;
|
|
224
|
+
const targetLocale = status.language.code;
|
|
225
|
+
const uniqueKey = `${targetLocale}:${Object.keys(translations).sort().join(',')}`;
|
|
226
|
+
|
|
227
|
+
if (processedKeys.has(uniqueKey)) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
processedKeys.add(uniqueKey);
|
|
231
|
+
|
|
232
|
+
const targetFile = targetFiles.find(f => f.locale === targetLocale)?.path ||
|
|
233
|
+
path.join(config.translationPath, `${targetLocale}.yml`);
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const updatedKeys = await updateTranslationFile(
|
|
237
|
+
targetFile,
|
|
238
|
+
translations,
|
|
239
|
+
targetLocale
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
updatedFiles.add(targetFile);
|
|
243
|
+
totalKeysProcessed += updatedKeys.length;
|
|
244
|
+
|
|
245
|
+
if (verbose) {
|
|
246
|
+
console.log(chalk.blue(` Updated ${targetFile}`));
|
|
247
|
+
updatedKeys.forEach(key => console.log(chalk.gray(` - Added: ${key}`)));
|
|
248
|
+
} else {
|
|
249
|
+
console.log(chalk.blue(` Updated ${path.basename(targetFile)}`));
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
console.error(chalk.yellow(`⚠️ Failed to update ${targetFile}: ${error.message}`));
|
|
253
|
+
totalErrors++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
totalErrors++;
|
|
259
|
+
console.error(chalk.red(`❌ Batch ${batchIndex + 1} failed: ${error.message}`));
|
|
260
|
+
console.log(error);
|
|
261
|
+
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (totalErrors > 0) {
|
|
266
|
+
console.error(chalk.red(`\n❌ Translation completed with ${totalErrors} failed batches`));
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log(chalk.green('\n✓ Translations complete!') + ` Updated ${totalKeysProcessed} keys in ${config.outputLocales.length} languages`);
|
|
271
|
+
|
|
272
|
+
if (commit || process.env.GITHUB_ACTIONS === 'true') {
|
|
273
|
+
const translationPaths = Array.from(updatedFiles).join(' ');
|
|
274
|
+
if (translationPaths) {
|
|
275
|
+
autoCommitChanges(translationPaths);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error(chalk.red(`❌ ${error.message}`));
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { configService } from './config.js';
|
|
2
|
+
|
|
3
|
+
export async function getApiKey() {
|
|
4
|
+
const envKey = process.env.LOCALHERO_API_KEY;
|
|
5
|
+
if (envKey) {
|
|
6
|
+
return envKey;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const config = await configService.getAuthConfig();
|
|
10
|
+
return config?.api_key;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function checkAuth() {
|
|
14
|
+
try {
|
|
15
|
+
const apiKey = await getApiKey();
|
|
16
|
+
const isValidFormat = typeof apiKey === 'string' &&
|
|
17
|
+
/^tk_[a-f0-9]+$/.test(apiKey);
|
|
18
|
+
|
|
19
|
+
return isValidFormat;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const AUTH_CONFIG_FILE = '.localhero_key';
|
|
5
|
+
const PROJECT_CONFIG_FILE = 'localhero.json';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PROJECT_CONFIG = {
|
|
8
|
+
schemaVersion: '1.0',
|
|
9
|
+
projectId: '',
|
|
10
|
+
sourceLocale: 'en',
|
|
11
|
+
outputLocales: [],
|
|
12
|
+
translationFiles: {
|
|
13
|
+
paths: [],
|
|
14
|
+
ignore: []
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const configService = {
|
|
19
|
+
async getAuthConfig(basePath = process.cwd()) {
|
|
20
|
+
try {
|
|
21
|
+
const configPath = path.join(basePath, AUTH_CONFIG_FILE);
|
|
22
|
+
const content = await fs.readFile(configPath, 'utf8');
|
|
23
|
+
return JSON.parse(content);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async saveAuthConfig(config, basePath = process.cwd()) {
|
|
30
|
+
const configPath = path.join(basePath, AUTH_CONFIG_FILE);
|
|
31
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), {
|
|
32
|
+
mode: 0o600 // User-only readable
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async getProjectConfig(basePath = process.cwd()) {
|
|
37
|
+
try {
|
|
38
|
+
const configPath = path.join(basePath, PROJECT_CONFIG_FILE);
|
|
39
|
+
const content = await fs.readFile(configPath, 'utf8');
|
|
40
|
+
const config = JSON.parse(content);
|
|
41
|
+
|
|
42
|
+
if (config.schemaVersion !== DEFAULT_PROJECT_CONFIG.schemaVersion) {
|
|
43
|
+
throw new Error(`Unsupported config schema version: ${config.schemaVersion}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return config;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error.code === 'ENOENT') {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async saveProjectConfig(config, basePath = process.cwd()) {
|
|
56
|
+
const configPath = path.join(basePath, PROJECT_CONFIG_FILE);
|
|
57
|
+
const configWithSchema = {
|
|
58
|
+
...DEFAULT_PROJECT_CONFIG,
|
|
59
|
+
...config,
|
|
60
|
+
schemaVersion: DEFAULT_PROJECT_CONFIG.schemaVersion
|
|
61
|
+
};
|
|
62
|
+
await fs.writeFile(configPath, JSON.stringify(configWithSchema, null, 2));
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async validateProjectConfig(config) {
|
|
66
|
+
const required = ['projectId', 'sourceLocale', 'outputLocales', 'translationFiles'];
|
|
67
|
+
const missing = required.filter(key => !config[key]);
|
|
68
|
+
|
|
69
|
+
if (missing.length) {
|
|
70
|
+
throw new Error(`Missing required config: ${missing.join(', ')}. Run 'npx localhero init' to set up your project.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!Array.isArray(config.outputLocales) || config.outputLocales.length === 0) {
|
|
74
|
+
throw new Error('outputLocales must be an array with at least one locale');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (config.outputLocales.length > 10) {
|
|
78
|
+
throw new Error('Maximum 10 target languages allowed per request');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!config.translationFiles.paths || !Array.isArray(config.translationFiles.paths)) {
|
|
82
|
+
throw new Error('translationFiles.paths must be an array of paths');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async getValidProjectConfig(basePath = process.cwd()) {
|
|
89
|
+
const config = await this.getProjectConfig(basePath);
|
|
90
|
+
if (!config) {
|
|
91
|
+
throw new Error('No localhero.json found. Run `npx localhero init` first');
|
|
92
|
+
}
|
|
93
|
+
await this.validateProjectConfig(config);
|
|
94
|
+
return config;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { glob } from 'glob';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import yaml from 'yaml';
|
|
5
|
+
|
|
6
|
+
function parseFile(content, format) {
|
|
7
|
+
try {
|
|
8
|
+
if (format === 'json') {
|
|
9
|
+
return JSON.parse(content);
|
|
10
|
+
}
|
|
11
|
+
return yaml.parse(content);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
throw new Error(`Failed to parse ${format} file: ${error.message}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extractKeysWithContext(obj, parentKeys = [], result = {}) {
|
|
18
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
19
|
+
const currentPath = [...parentKeys, key];
|
|
20
|
+
const fullKey = currentPath.join('.');
|
|
21
|
+
|
|
22
|
+
if (typeof value === 'object' && value !== null) {
|
|
23
|
+
extractKeysWithContext(value, currentPath, result);
|
|
24
|
+
} else {
|
|
25
|
+
const siblings = {};
|
|
26
|
+
const parentObj = parentKeys.length ?
|
|
27
|
+
parentKeys.reduce((acc, key) => acc[key], obj) :
|
|
28
|
+
obj;
|
|
29
|
+
|
|
30
|
+
Object.entries(parentObj)
|
|
31
|
+
.filter(([k, v]) => k !== key && typeof v !== 'object')
|
|
32
|
+
.forEach(([k, v]) => {
|
|
33
|
+
siblings[`${parentKeys.join('.')}.${k}`] = v;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
result[fullKey] = {
|
|
37
|
+
value: value,
|
|
38
|
+
context: {
|
|
39
|
+
parent_keys: parentKeys,
|
|
40
|
+
sibling_keys: siblings
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractLocaleFromPath(filePath, localeRegex) {
|
|
49
|
+
const match = path.basename(filePath).match(new RegExp(localeRegex));
|
|
50
|
+
if (!match || !match[1]) {
|
|
51
|
+
throw new Error(`Could not extract locale from filename: ${filePath}`);
|
|
52
|
+
}
|
|
53
|
+
return match[1].toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function flattenTranslations(obj, parentKey = '') {
|
|
57
|
+
const result = {};
|
|
58
|
+
|
|
59
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
60
|
+
const newKey = parentKey ? `${parentKey}.${key}` : key;
|
|
61
|
+
|
|
62
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
63
|
+
Object.assign(result, flattenTranslations(value, newKey));
|
|
64
|
+
} else {
|
|
65
|
+
result[newKey] = value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function findTranslationFiles(translationPath, localeRegex) {
|
|
73
|
+
const pattern = path.join(translationPath, '**/*.{yml,yaml,json}');
|
|
74
|
+
const files = await glob(pattern, { absolute: true });
|
|
75
|
+
|
|
76
|
+
return Promise.all(files.map(async (filePath) => {
|
|
77
|
+
try {
|
|
78
|
+
const locale = extractLocaleFromPath(filePath, localeRegex);
|
|
79
|
+
const content = await readFile(filePath, 'utf8');
|
|
80
|
+
const format = path.extname(filePath).slice(1);
|
|
81
|
+
const parsedContent = parseFile(content, format);
|
|
82
|
+
|
|
83
|
+
// Extract translations, handling both nested and flat structures
|
|
84
|
+
let translations;
|
|
85
|
+
if (parsedContent[locale]) {
|
|
86
|
+
// Nested under locale key (common in Rails/YAML)
|
|
87
|
+
translations = flattenTranslations(parsedContent[locale]);
|
|
88
|
+
} else {
|
|
89
|
+
// Flat structure (common in JSON)
|
|
90
|
+
translations = flattenTranslations(parsedContent);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Extract keys with context
|
|
94
|
+
const keys = {};
|
|
95
|
+
for (const [key, value] of Object.entries(translations)) {
|
|
96
|
+
const parts = key.split('.');
|
|
97
|
+
const parentKeys = parts.slice(0, -1);
|
|
98
|
+
|
|
99
|
+
// Get sibling translations
|
|
100
|
+
const siblings = {};
|
|
101
|
+
Object.entries(translations)
|
|
102
|
+
.filter(([k, v]) => {
|
|
103
|
+
const kParts = k.split('.');
|
|
104
|
+
return k !== key &&
|
|
105
|
+
kParts.length === parts.length &&
|
|
106
|
+
kParts.slice(0, -1).join('.') === parentKeys.join('.');
|
|
107
|
+
})
|
|
108
|
+
.forEach(([k, v]) => {
|
|
109
|
+
siblings[k] = v;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
keys[key] = {
|
|
113
|
+
value,
|
|
114
|
+
context: {
|
|
115
|
+
parent_keys: parentKeys,
|
|
116
|
+
sibling_keys: siblings
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const formattedContent = {
|
|
122
|
+
keys,
|
|
123
|
+
metadata: {
|
|
124
|
+
source_language: locale
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
path: filePath,
|
|
130
|
+
locale,
|
|
131
|
+
format,
|
|
132
|
+
content: Buffer.from(JSON.stringify(formattedContent)).toString('base64')
|
|
133
|
+
};
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error(chalk.yellow(`⚠️ Skipping ${filePath}: ${error.message}`));
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
})).then(results => results.filter(Boolean));
|
|
139
|
+
}
|
package/src/utils/git.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export async function updateGitignore(basePath) {
|
|
5
|
+
const gitignorePath = path.join(basePath, '.gitignore');
|
|
6
|
+
try {
|
|
7
|
+
const content = await fs.readFile(gitignorePath, 'utf8').catch(() => '');
|
|
8
|
+
if (!content.includes('.localhero_key')) {
|
|
9
|
+
await fs.appendFile(gitignorePath, '\n.localhero_key\n');
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
export function isGitHubAction() {
|
|
6
|
+
return process.env.GITHUB_ACTIONS === 'true';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function createGitHubActionFile(basePath, translationPaths) {
|
|
10
|
+
const workflowDir = path.join(basePath, '.github', 'workflows');
|
|
11
|
+
const workflowFile = path.join(workflowDir, 'localhero-translate.yml');
|
|
12
|
+
|
|
13
|
+
// Create directories if they don't exist
|
|
14
|
+
await fs.mkdir(workflowDir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
const actionContent = `name: Localhero.ai - I18n translation
|
|
17
|
+
|
|
18
|
+
on:
|
|
19
|
+
pull_request:
|
|
20
|
+
paths:
|
|
21
|
+
${translationPaths.map(p => `- "${p}"`).join('\n ')}
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
translate:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
|
|
27
|
+
steps:
|
|
28
|
+
- name: Checkout code
|
|
29
|
+
uses: actions/checkout@v4
|
|
30
|
+
|
|
31
|
+
- name: Set up Node.js
|
|
32
|
+
uses: actions/setup-node@v4
|
|
33
|
+
with:
|
|
34
|
+
node-version: 18
|
|
35
|
+
|
|
36
|
+
- name: Run LocalHero CLI
|
|
37
|
+
env:
|
|
38
|
+
LOCALHERO_API_KEY: \${{ secrets.LOCALHERO_API_KEY }}
|
|
39
|
+
run: npx @localheroai/cli translate`;
|
|
40
|
+
|
|
41
|
+
await fs.writeFile(workflowFile, actionContent);
|
|
42
|
+
return workflowFile;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function autoCommitChanges(filesPath) {
|
|
46
|
+
if (!isGitHubAction()) return;
|
|
47
|
+
|
|
48
|
+
console.log("Running in GitHub Actions. Committing changes...");
|
|
49
|
+
try {
|
|
50
|
+
execSync(`git config --global user.name "LocalHero Bot"`, { stdio: "inherit" });
|
|
51
|
+
execSync(`git config --global user.email "bot@localhero.ai"`, { stdio: "inherit" });
|
|
52
|
+
execSync(`git add ${filesPath}`, { stdio: "inherit" });
|
|
53
|
+
execSync(`git commit -m "Update translations"`, { stdio: "inherit" });
|
|
54
|
+
const branchName = process.env.GITHUB_HEAD_REF;
|
|
55
|
+
execSync(`git push origin ${branchName}`, { stdio: "inherit" });
|
|
56
|
+
console.log("Changes committed and pushed.");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error.message.includes("nothing to commit")) {
|
|
59
|
+
console.log("No changes to commit.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
console.error("Auto-commit failed:", error.message);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|