@mariokreitz/langsync 0.5.0 → 0.7.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.
- package/README.md +33 -11
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +587 -612
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -29
- package/package.json +7 -6
package/dist/cli.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import ora from 'ora';
|
|
5
|
-
import { relative, join, resolve, dirname } from 'path';
|
|
6
|
-
import { readFile, mkdir, writeFile, access } from 'fs/promises';
|
|
5
|
+
import { relative, join, resolve, dirname, sep, basename } from 'path';
|
|
6
|
+
import { readFile, mkdir, writeFile, access, readdir } from 'fs/promises';
|
|
7
7
|
import prompts from 'prompts';
|
|
8
|
+
import { runSync, runValidate, runFindMissing, runTranslate } from '@mariokreitz/langsync-sdk';
|
|
9
|
+
import ExcelJS from 'exceljs';
|
|
8
10
|
import { cosmiconfig } from 'cosmiconfig';
|
|
9
11
|
import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
|
|
10
12
|
import { z } from 'zod';
|
|
11
|
-
import ExcelJS from 'exceljs';
|
|
12
13
|
import chokidar from 'chokidar';
|
|
13
14
|
|
|
14
15
|
var PREFIX = chalk.bold.cyan("langsync");
|
|
@@ -73,12 +74,30 @@ var FRAMEWORK_CHOICES = [
|
|
|
73
74
|
{ title: "react-intl", value: "react-intl" },
|
|
74
75
|
{ title: "None / Custom", value: "none" }
|
|
75
76
|
];
|
|
77
|
+
var LOCALE_LAYOUT_CHOICES = [
|
|
78
|
+
{
|
|
79
|
+
title: "Single file per locale (<input>/<locale>.json)",
|
|
80
|
+
value: "single-file"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
title: "Namespace folders per locale (<input>/<locale>/<namespace>.json)",
|
|
84
|
+
value: "locale-dir"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
title: "Flat namespace-prefixed files (<input>/<locale>.<namespace>.json)",
|
|
88
|
+
value: "locale-prefix"
|
|
89
|
+
}
|
|
90
|
+
];
|
|
76
91
|
var DEFAULTS = {
|
|
77
92
|
input: "./src/i18n",
|
|
78
93
|
output: "./translations",
|
|
79
94
|
locales: "en,de",
|
|
80
95
|
defaultLocale: "en"
|
|
81
96
|
};
|
|
97
|
+
function parseNamespaces(value) {
|
|
98
|
+
const namespaces = value.split(",").map((namespace) => namespace.trim()).filter(Boolean);
|
|
99
|
+
return namespaces.length > 0 ? namespaces : ["common"];
|
|
100
|
+
}
|
|
82
101
|
async function runInitPrompts(options) {
|
|
83
102
|
const framework = options.detectedFramework ?? "none";
|
|
84
103
|
if (options.yes) {
|
|
@@ -98,6 +117,20 @@ async function runInitPrompts(options) {
|
|
|
98
117
|
message: "Where are your source i18n files?",
|
|
99
118
|
initial: DEFAULTS.input
|
|
100
119
|
},
|
|
120
|
+
{
|
|
121
|
+
type: "select",
|
|
122
|
+
name: "localeLayout",
|
|
123
|
+
message: "How should locale files be organized?",
|
|
124
|
+
choices: LOCALE_LAYOUT_CHOICES,
|
|
125
|
+
initial: 0
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
type: (prev) => prev === "single-file" ? null : "text",
|
|
129
|
+
name: "initialNamespaces",
|
|
130
|
+
message: "Initial namespaces? (comma-separated)",
|
|
131
|
+
initial: "common",
|
|
132
|
+
format: parseNamespaces
|
|
133
|
+
},
|
|
101
134
|
{
|
|
102
135
|
type: "text",
|
|
103
136
|
name: "output",
|
|
@@ -135,7 +168,18 @@ async function runInitPrompts(options) {
|
|
|
135
168
|
}
|
|
136
169
|
}
|
|
137
170
|
);
|
|
138
|
-
|
|
171
|
+
const answers = {
|
|
172
|
+
input: response.input,
|
|
173
|
+
output: response.output,
|
|
174
|
+
locales: response.locales,
|
|
175
|
+
defaultLocale: response.defaultLocale,
|
|
176
|
+
framework: response.framework
|
|
177
|
+
};
|
|
178
|
+
if (response.localeLayout !== "single-file") {
|
|
179
|
+
answers.namespaces = { structure: response.localeLayout };
|
|
180
|
+
answers.initialNamespaces = response.initialNamespaces?.length ? response.initialNamespaces : ["common"];
|
|
181
|
+
}
|
|
182
|
+
return answers;
|
|
139
183
|
}
|
|
140
184
|
var CANDIDATE_CONFIG_FILES = [
|
|
141
185
|
"langsync.config.ts",
|
|
@@ -161,6 +205,8 @@ async function findExistingConfig(cwd) {
|
|
|
161
205
|
function renderTsConfig(answers) {
|
|
162
206
|
const frameworkLine = answers.framework === "none" ? "" : ` framework: '${answers.framework}',
|
|
163
207
|
`;
|
|
208
|
+
const namespacesLine = answers.namespaces ? ` namespaces: { structure: '${answers.namespaces.structure}' },
|
|
209
|
+
` : "";
|
|
164
210
|
const localesArr = answers.locales.map((l) => `'${l}'`).join(", ");
|
|
165
211
|
return `import { defineConfig } from '@mariokreitz/langsync';
|
|
166
212
|
|
|
@@ -169,7 +215,7 @@ export default defineConfig({
|
|
|
169
215
|
output: '${answers.output}',
|
|
170
216
|
locales: [${localesArr}],
|
|
171
217
|
defaultLocale: '${answers.defaultLocale}',
|
|
172
|
-
${frameworkLine}});
|
|
218
|
+
${frameworkLine}${namespacesLine}});
|
|
173
219
|
`;
|
|
174
220
|
}
|
|
175
221
|
function renderJsonConfig(answers) {
|
|
@@ -180,8 +226,23 @@ function renderJsonConfig(answers) {
|
|
|
180
226
|
defaultLocale: answers.defaultLocale
|
|
181
227
|
};
|
|
182
228
|
if (answers.framework !== "none") obj.framework = answers.framework;
|
|
229
|
+
if (answers.namespaces) obj.namespaces = answers.namespaces;
|
|
183
230
|
return JSON.stringify(obj, null, 2) + "\n";
|
|
184
231
|
}
|
|
232
|
+
function getLocaleStubPaths(inputDir, answers) {
|
|
233
|
+
if (!answers.namespaces) {
|
|
234
|
+
return answers.locales.map((locale) => join(inputDir, `${locale}.json`));
|
|
235
|
+
}
|
|
236
|
+
const initialNamespaces = answers.initialNamespaces?.length ? answers.initialNamespaces : ["common"];
|
|
237
|
+
return answers.locales.flatMap(
|
|
238
|
+
(locale) => initialNamespaces.map((namespace) => {
|
|
239
|
+
if (answers.namespaces?.structure === "locale-dir") {
|
|
240
|
+
return join(inputDir, locale, `${namespace}.json`);
|
|
241
|
+
}
|
|
242
|
+
return join(inputDir, `${locale}.${namespace}.json`);
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
}
|
|
185
246
|
async function writeConfig(options) {
|
|
186
247
|
const { cwd, answers, format, force } = options;
|
|
187
248
|
const existing = await findExistingConfig(cwd);
|
|
@@ -198,9 +259,9 @@ async function writeConfig(options) {
|
|
|
198
259
|
const inputDir = resolve(cwd, answers.input);
|
|
199
260
|
await mkdir(inputDir, { recursive: true });
|
|
200
261
|
const createdLocaleFiles = [];
|
|
201
|
-
for (const
|
|
202
|
-
const localePath = join(inputDir, `${locale}.json`);
|
|
262
|
+
for (const localePath of getLocaleStubPaths(inputDir, answers)) {
|
|
203
263
|
if (!await pathExists(localePath)) {
|
|
264
|
+
await mkdir(dirname(localePath), { recursive: true });
|
|
204
265
|
await writeFile(localePath, "{}\n", "utf-8");
|
|
205
266
|
createdLocaleFiles.push(localePath);
|
|
206
267
|
}
|
|
@@ -245,83 +306,89 @@ function registerInitCommand(program) {
|
|
|
245
306
|
}
|
|
246
307
|
});
|
|
247
308
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const path = issue.path.length > 0 ? ` ${issue.path.join(".")}: ` : " ";
|
|
271
|
-
return `${path}${issue.message}`;
|
|
309
|
+
function registerSyncCommand(program) {
|
|
310
|
+
program.command("sync").description("Synchronize translation keys across all configured locales.").option("--dry-run", "Report planned changes without writing files.", false).action(async (flags) => {
|
|
311
|
+
try {
|
|
312
|
+
const cwd = process.cwd();
|
|
313
|
+
const { written, planned, referenceLocale } = await runSync({
|
|
314
|
+
cwd,
|
|
315
|
+
dryRun: flags.dryRun
|
|
316
|
+
});
|
|
317
|
+
const targets = flags.dryRun ? planned : written;
|
|
318
|
+
if (targets.length === 0) {
|
|
319
|
+
logger.info(`Nothing to sync against ${chalk.cyan(referenceLocale)}.`);
|
|
320
|
+
} else {
|
|
321
|
+
const verb = flags.dryRun ? "Would update" : "Updated";
|
|
322
|
+
for (const path of targets) {
|
|
323
|
+
logger.success(`${verb} ${chalk.bold(relative(cwd, path))}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} catch (error) {
|
|
327
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
328
|
+
logger.error(message);
|
|
329
|
+
process.exitCode = 1;
|
|
330
|
+
}
|
|
272
331
|
});
|
|
273
|
-
return `Invalid LangSync configuration:
|
|
274
|
-
${issues.join("\n")}`;
|
|
275
332
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
333
|
+
function formatIssueKey(issue) {
|
|
334
|
+
return issue.namespace === null ? issue.key : `${issue.namespace}:${issue.key}`;
|
|
335
|
+
}
|
|
336
|
+
function registerValidateCommand(program) {
|
|
337
|
+
program.command("validate").description("Validate locale consistency, structure and missing keys.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
|
|
338
|
+
try {
|
|
339
|
+
const { issues, exitCode, referenceLocale } = await runValidate({ cwd: process.cwd() });
|
|
340
|
+
if (flags.reporter === "json") {
|
|
341
|
+
console.log(JSON.stringify({ referenceLocale, issues }, null, 2));
|
|
342
|
+
} else {
|
|
343
|
+
if (issues.length === 0) {
|
|
344
|
+
logger.success(`All locales are consistent with ${chalk.cyan(referenceLocale)}.`);
|
|
345
|
+
} else {
|
|
346
|
+
const byType = { missing: 0, extra: 0, empty: 0 };
|
|
347
|
+
for (const issue of issues) {
|
|
348
|
+
byType[issue.type]++;
|
|
349
|
+
const colored = issue.type === "empty" ? chalk.yellow(issue.type) : chalk.red(issue.type);
|
|
350
|
+
logger.info(`${colored} ${chalk.cyan(issue.locale)} ${formatIssueKey(issue)}`);
|
|
351
|
+
}
|
|
352
|
+
console.log();
|
|
353
|
+
logger.info(
|
|
354
|
+
`Summary: ${byType.missing} missing, ${byType.extra} extra, ${byType.empty} empty`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
process.exitCode = exitCode;
|
|
359
|
+
} catch (error) {
|
|
360
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
361
|
+
logger.error(message);
|
|
362
|
+
process.exitCode = 1;
|
|
289
363
|
}
|
|
290
364
|
});
|
|
291
|
-
const result = await explorer.search(cwd);
|
|
292
|
-
if (!result) return null;
|
|
293
|
-
const parsed = LangSyncConfigSchema.safeParse(result.config);
|
|
294
|
-
if (!parsed.success) {
|
|
295
|
-
throw new Error(formatZodError(parsed.error));
|
|
296
|
-
}
|
|
297
|
-
return { config: parsed.data, filepath: result.filepath };
|
|
298
365
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
await mkdir(dirname(absolute), { recursive: true });
|
|
302
|
-
await writeFile(absolute, JSON.stringify(data, null, indent) + "\n", "utf-8");
|
|
366
|
+
function formatMissingEntry(entry) {
|
|
367
|
+
return entry.namespace === null ? entry.key : `${entry.namespace}:${entry.key}`;
|
|
303
368
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const out = [];
|
|
307
|
-
for (const locale of options.locales) {
|
|
308
|
-
const path = join(inputAbs, `${locale}.json`);
|
|
309
|
-
let translations = {};
|
|
369
|
+
function registerFindMissingCommand(program) {
|
|
370
|
+
program.command("find-missing").description("Find missing translation keys across locales.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
|
|
310
371
|
try {
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
372
|
+
const { referenceLocale, missingByLocale, exitCode } = await runFindMissing({
|
|
373
|
+
cwd: process.cwd()
|
|
374
|
+
});
|
|
375
|
+
if (flags.reporter === "json") {
|
|
376
|
+
console.log(JSON.stringify({ referenceLocale, missingByLocale }, null, 2));
|
|
377
|
+
} else if (Object.keys(missingByLocale).length === 0) {
|
|
378
|
+
logger.success(`No missing keys relative to ${chalk.cyan(referenceLocale)}.`);
|
|
379
|
+
} else {
|
|
380
|
+
for (const [locale, keys] of Object.entries(missingByLocale)) {
|
|
381
|
+
logger.warn(`${chalk.cyan(locale)} is missing ${keys.length} key(s):`);
|
|
382
|
+
for (const entry of keys) console.log(` - ${formatMissingEntry(entry)}`);
|
|
383
|
+
}
|
|
317
384
|
}
|
|
385
|
+
process.exitCode = exitCode;
|
|
318
386
|
} catch (error) {
|
|
319
|
-
const
|
|
320
|
-
|
|
387
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
388
|
+
logger.error(message);
|
|
389
|
+
process.exitCode = 1;
|
|
321
390
|
}
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
return out;
|
|
391
|
+
});
|
|
325
392
|
}
|
|
326
393
|
|
|
327
394
|
// ../core/dist/index.js
|
|
@@ -379,239 +446,434 @@ function validateLocales(files, referenceLocale) {
|
|
|
379
446
|
}
|
|
380
447
|
return issues;
|
|
381
448
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
449
|
+
|
|
450
|
+
// ../excel-engine/dist/index.js
|
|
451
|
+
function distinctLocales(files) {
|
|
452
|
+
const seen = /* @__PURE__ */ new Set();
|
|
453
|
+
const locales = [];
|
|
454
|
+
for (const file of files) {
|
|
455
|
+
if (seen.has(file.locale)) continue;
|
|
456
|
+
seen.add(file.locale);
|
|
457
|
+
locales.push(file.locale);
|
|
388
458
|
}
|
|
389
|
-
return
|
|
459
|
+
return locales;
|
|
460
|
+
}
|
|
461
|
+
function cellText(value) {
|
|
462
|
+
return typeof value === "string" ? value : "";
|
|
463
|
+
}
|
|
464
|
+
function addHeader(sheet, header) {
|
|
465
|
+
sheet.addRow(header);
|
|
466
|
+
sheet.getRow(1).font = { bold: true };
|
|
390
467
|
}
|
|
391
|
-
function
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const
|
|
399
|
-
for (const
|
|
400
|
-
|
|
401
|
-
else if (prevFlat[key] !== nextFlat[key]) changed.push(key);
|
|
468
|
+
function exportSingleFile(sheet, files) {
|
|
469
|
+
const locales = distinctLocales(files);
|
|
470
|
+
addHeader(sheet, ["key", ...locales]);
|
|
471
|
+
const flatByLocale = /* @__PURE__ */ new Map();
|
|
472
|
+
for (const file of files) {
|
|
473
|
+
flatByLocale.set(file.locale, flatten(file.translations));
|
|
474
|
+
}
|
|
475
|
+
const allKeys = /* @__PURE__ */ new Set();
|
|
476
|
+
for (const flat of flatByLocale.values()) {
|
|
477
|
+
for (const key of Object.keys(flat)) allKeys.add(key);
|
|
402
478
|
}
|
|
403
|
-
for (const key of
|
|
404
|
-
|
|
479
|
+
for (const key of [...allKeys].sort()) {
|
|
480
|
+
sheet.addRow([key, ...locales.map((locale) => flatByLocale.get(locale)?.[key] ?? "")]);
|
|
405
481
|
}
|
|
406
|
-
return { added, removed, changed };
|
|
407
482
|
}
|
|
408
|
-
function
|
|
409
|
-
return
|
|
483
|
+
function namespacedKey(namespace, locale) {
|
|
484
|
+
return `${namespace}\0${locale}`;
|
|
410
485
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
486
|
+
function exportNamespaced(sheet, files) {
|
|
487
|
+
const locales = distinctLocales(files);
|
|
488
|
+
addHeader(sheet, ["namespace", "key", ...locales]);
|
|
489
|
+
const flatByNamespaceLocale = /* @__PURE__ */ new Map();
|
|
490
|
+
const rowKeys = /* @__PURE__ */ new Set();
|
|
491
|
+
for (const file of files) {
|
|
492
|
+
if (file.namespace === null) continue;
|
|
493
|
+
const flat = flatten(file.translations);
|
|
494
|
+
flatByNamespaceLocale.set(namespacedKey(file.namespace, file.locale), flat);
|
|
495
|
+
for (const key of Object.keys(flat)) rowKeys.add(`${file.namespace}\0${key}`);
|
|
417
496
|
}
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
497
|
+
const sortedRows = [...rowKeys].map((rowKey) => {
|
|
498
|
+
const [namespace, key] = rowKey.split("\0");
|
|
499
|
+
return { namespace, key };
|
|
500
|
+
}).sort((a, b) => a.namespace.localeCompare(b.namespace) || a.key.localeCompare(b.key));
|
|
501
|
+
for (const { namespace, key } of sortedRows) {
|
|
502
|
+
sheet.addRow([
|
|
503
|
+
namespace,
|
|
504
|
+
key,
|
|
505
|
+
...locales.map(
|
|
506
|
+
(locale) => flatByNamespaceLocale.get(namespacedKey(namespace, locale))?.[key] ?? ""
|
|
507
|
+
)
|
|
508
|
+
]);
|
|
428
509
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
planned.push(target.path);
|
|
443
|
-
if (!options.dryRun) {
|
|
444
|
-
await writeJson(target.path, merged);
|
|
445
|
-
written.push(target.path);
|
|
446
|
-
}
|
|
510
|
+
}
|
|
511
|
+
async function exportToExcel(options) {
|
|
512
|
+
const workbook = new ExcelJS.Workbook();
|
|
513
|
+
const sheet = workbook.addWorksheet(options.sheetName ?? "Translations");
|
|
514
|
+
const allSingleFile = options.files.every((file) => file.namespace === null);
|
|
515
|
+
const allNamespaced = options.files.every((file) => file.namespace !== null);
|
|
516
|
+
if (!allSingleFile && !allNamespaced) {
|
|
517
|
+
throw new Error("exportToExcel: files must be uniformly single-file or namespaced.");
|
|
518
|
+
}
|
|
519
|
+
if (allSingleFile) {
|
|
520
|
+
exportSingleFile(sheet, options.files);
|
|
521
|
+
} else {
|
|
522
|
+
exportNamespaced(sheet, options.files);
|
|
447
523
|
}
|
|
448
|
-
|
|
524
|
+
await workbook.xlsx.writeFile(options.file);
|
|
449
525
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
} else {
|
|
464
|
-
const verb = flags.dryRun ? "Would update" : "Updated";
|
|
465
|
-
for (const path of targets) {
|
|
466
|
-
logger.success(`${verb} ${chalk.bold(relative(cwd, path))}`);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
} catch (error) {
|
|
470
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
471
|
-
logger.error(message);
|
|
472
|
-
process.exitCode = 1;
|
|
473
|
-
}
|
|
526
|
+
function importSingleFile(sheet, header) {
|
|
527
|
+
const locales = header.slice(2).filter((value) => typeof value === "string");
|
|
528
|
+
const flatPerLocale = Object.fromEntries(
|
|
529
|
+
locales.map((locale) => [locale, {}])
|
|
530
|
+
);
|
|
531
|
+
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
532
|
+
if (rowNumber === 1) return;
|
|
533
|
+
const values = row.values;
|
|
534
|
+
const key = cellText(values[1]);
|
|
535
|
+
if (!key) return;
|
|
536
|
+
locales.forEach((locale, idx) => {
|
|
537
|
+
flatPerLocale[locale][key] = cellText(values[idx + 2]);
|
|
538
|
+
});
|
|
474
539
|
});
|
|
540
|
+
return {
|
|
541
|
+
format: "single-file",
|
|
542
|
+
locales: locales.map((locale) => ({
|
|
543
|
+
locale,
|
|
544
|
+
namespace: null,
|
|
545
|
+
translations: unflatten(flatPerLocale[locale])
|
|
546
|
+
}))
|
|
547
|
+
};
|
|
475
548
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
549
|
+
function importNamespaced(sheet, header) {
|
|
550
|
+
const locales = header.slice(3).filter((value) => typeof value === "string");
|
|
551
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
552
|
+
const flatByLocaleNamespace = /* @__PURE__ */ new Map();
|
|
553
|
+
const seenRows = /* @__PURE__ */ new Set();
|
|
554
|
+
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
555
|
+
if (rowNumber === 1) return;
|
|
556
|
+
const values = row.values;
|
|
557
|
+
const namespace = cellText(values[1]).trim();
|
|
558
|
+
const key = cellText(values[2]);
|
|
559
|
+
if (!key) return;
|
|
560
|
+
if (!namespace) {
|
|
561
|
+
throw new Error(
|
|
562
|
+
`Row ${rowNumber}: namespace cell must not be empty in a namespaced workbook.`
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
const rowKey = `${namespace}\0${key}`;
|
|
566
|
+
if (seenRows.has(rowKey)) {
|
|
567
|
+
throw new Error(`Duplicate (namespace, key) row: "${namespace}" / "${key}".`);
|
|
568
|
+
}
|
|
569
|
+
seenRows.add(rowKey);
|
|
570
|
+
namespaces.add(namespace);
|
|
571
|
+
locales.forEach((locale, idx) => {
|
|
572
|
+
const mapKey = namespacedKey(namespace, locale);
|
|
573
|
+
let flat = flatByLocaleNamespace.get(mapKey);
|
|
574
|
+
if (!flat) {
|
|
575
|
+
flat = {};
|
|
576
|
+
flatByLocaleNamespace.set(mapKey, flat);
|
|
577
|
+
}
|
|
578
|
+
flat[key] = cellText(values[idx + 3]);
|
|
579
|
+
});
|
|
489
580
|
});
|
|
490
|
-
const issues = validateLocales(files, referenceLocale);
|
|
491
|
-
const hasErrors = issues.some((i) => i.type === "missing" || i.type === "extra");
|
|
492
581
|
return {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
582
|
+
format: "namespaced",
|
|
583
|
+
locales: locales.flatMap(
|
|
584
|
+
(locale) => [...namespaces].map((namespace) => ({
|
|
585
|
+
locale,
|
|
586
|
+
namespace,
|
|
587
|
+
translations: unflatten(flatByLocaleNamespace.get(namespacedKey(namespace, locale)) ?? {})
|
|
588
|
+
}))
|
|
589
|
+
)
|
|
496
590
|
};
|
|
497
591
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
592
|
+
async function importFromExcel(file, sheetName) {
|
|
593
|
+
const workbook = new ExcelJS.Workbook();
|
|
594
|
+
await workbook.xlsx.readFile(file);
|
|
595
|
+
const sheet = sheetName ? workbook.getWorksheet(sheetName) : workbook.worksheets[0];
|
|
596
|
+
if (!sheet) throw new Error(`Worksheet not found: ${sheetName ?? "<first>"}`);
|
|
597
|
+
const header = sheet.getRow(1).values;
|
|
598
|
+
if (header[1] === "key") return importSingleFile(sheet, header);
|
|
599
|
+
if (header[1] === "namespace" && header[2] === "key") return importNamespaced(sheet, header);
|
|
600
|
+
throw new Error(
|
|
601
|
+
'Unrecognized workbook header. Expected "key" (single-file) or "namespace","key" (namespaced).'
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
var NamespaceConfigSchema = z.object({
|
|
605
|
+
structure: z.enum(["locale-dir", "locale-prefix"]).describe(
|
|
606
|
+
"Optional namespace layout. `locale-dir` resolves <input>/<locale>/<namespace>.json recursively. `locale-prefix` resolves <input>/<locale>.<namespace>.json."
|
|
607
|
+
)
|
|
608
|
+
});
|
|
609
|
+
var LangSyncConfigSchema = z.object({
|
|
610
|
+
input: z.string().describe("Path to the source i18n directory."),
|
|
611
|
+
output: z.string().default("./translations").describe(
|
|
612
|
+
'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
|
|
613
|
+
),
|
|
614
|
+
locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
|
|
615
|
+
defaultLocale: z.string().optional().describe(
|
|
616
|
+
"Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
|
|
617
|
+
),
|
|
618
|
+
framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
|
|
619
|
+
namespaces: NamespaceConfigSchema.optional().describe(
|
|
620
|
+
"Optional namespace settings. Omit this block to keep the default single-file layout at <input>/<locale>.json."
|
|
621
|
+
),
|
|
622
|
+
excel: z.object({
|
|
623
|
+
file: z.string().default("translations.xlsx"),
|
|
624
|
+
sheetName: z.string().default("Translations")
|
|
625
|
+
}).optional(),
|
|
626
|
+
ai: z.object({
|
|
627
|
+
provider: z.enum(["openai", "deepl", "anthropic", "gemini"]).default("openai").describe("AI translation provider."),
|
|
628
|
+
apiKey: z.string().optional().describe("API key. Falls back to the provider-specific env var."),
|
|
629
|
+
model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
|
|
630
|
+
}).optional().describe("AI translation settings.")
|
|
631
|
+
});
|
|
632
|
+
function formatZodError(error) {
|
|
633
|
+
const issues = error.issues.map((issue) => {
|
|
634
|
+
const path = issue.path.length > 0 ? ` ${issue.path.join(".")}: ` : " ";
|
|
635
|
+
return `${path}${issue.message}`;
|
|
636
|
+
});
|
|
637
|
+
return `Invalid LangSync configuration:
|
|
638
|
+
${issues.join("\n")}`;
|
|
639
|
+
}
|
|
640
|
+
async function loadConfig(cwd = process.cwd()) {
|
|
641
|
+
const explorer = cosmiconfig("langsync", {
|
|
642
|
+
searchPlaces: [
|
|
643
|
+
"langsync.config.ts",
|
|
644
|
+
"langsync.config.js",
|
|
645
|
+
"langsync.config.mjs",
|
|
646
|
+
"langsync.config.json",
|
|
647
|
+
".langsyncrc",
|
|
648
|
+
".langsyncrc.json",
|
|
649
|
+
"package.json"
|
|
650
|
+
],
|
|
651
|
+
loaders: {
|
|
652
|
+
".ts": TypeScriptLoader()
|
|
527
653
|
}
|
|
528
654
|
});
|
|
655
|
+
const result = await explorer.search(cwd);
|
|
656
|
+
if (!result) return null;
|
|
657
|
+
const parsed = LangSyncConfigSchema.safeParse(result.config);
|
|
658
|
+
if (!parsed.success) {
|
|
659
|
+
throw new Error(formatZodError(parsed.error));
|
|
660
|
+
}
|
|
661
|
+
return { config: parsed.data, filepath: result.filepath };
|
|
529
662
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
663
|
+
async function writeJson(filePath, data, { indent = 2 } = {}) {
|
|
664
|
+
const absolute = resolve(filePath);
|
|
665
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
666
|
+
await writeFile(absolute, JSON.stringify(data, null, indent) + "\n", "utf-8");
|
|
667
|
+
}
|
|
668
|
+
function isWithinDirectory(path, directory) {
|
|
669
|
+
return path === directory || path.startsWith(directory + sep);
|
|
670
|
+
}
|
|
671
|
+
function validateNamespace(namespace, structure) {
|
|
672
|
+
if (namespace.trim() === "") {
|
|
673
|
+
throw new Error('Invalid namespace "": namespace must not be empty.');
|
|
674
|
+
}
|
|
675
|
+
if (namespace.includes("\\")) {
|
|
676
|
+
throw new Error(`Invalid namespace "${namespace}": backslashes are not allowed.`);
|
|
538
677
|
}
|
|
539
|
-
|
|
540
|
-
|
|
678
|
+
if (namespace.startsWith("/")) {
|
|
679
|
+
throw new Error(`Invalid namespace "${namespace}": absolute namespace paths are not allowed.`);
|
|
680
|
+
}
|
|
681
|
+
if (structure === "locale-prefix" && namespace.includes("/")) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
`Invalid namespace "${namespace}": locale-prefix namespaces must not contain '/'.`
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
if (namespace.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
687
|
+
throw new Error(`Invalid namespace "${namespace}": path traversal segments are not allowed.`);
|
|
541
688
|
}
|
|
542
|
-
const exitCode = Object.keys(missingByLocale).length === 0 ? 0 : 1;
|
|
543
|
-
return { referenceLocale, missingByLocale, exitCode };
|
|
544
689
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
});
|
|
553
|
-
if (flags.reporter === "json") {
|
|
554
|
-
console.log(JSON.stringify({ referenceLocale, missingByLocale }, null, 2));
|
|
555
|
-
} else if (Object.keys(missingByLocale).length === 0) {
|
|
556
|
-
logger.success(`No missing keys relative to ${chalk.cyan(referenceLocale)}.`);
|
|
557
|
-
} else {
|
|
558
|
-
for (const [locale, keys] of Object.entries(missingByLocale)) {
|
|
559
|
-
logger.warn(`${chalk.cyan(locale)} is missing ${keys.length} key(s):`);
|
|
560
|
-
for (const key of keys) console.log(` - ${key}`);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
process.exitCode = exitCode;
|
|
564
|
-
} catch (error) {
|
|
565
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
566
|
-
logger.error(message);
|
|
567
|
-
process.exitCode = 1;
|
|
690
|
+
function resolveLocaleFilePath(args) {
|
|
691
|
+
const inputAbs = resolve(args.cwd, args.inputDir);
|
|
692
|
+
if (!args.namespaces) {
|
|
693
|
+
if (args.namespace !== null) {
|
|
694
|
+
throw new Error(
|
|
695
|
+
`Invalid namespace "${args.namespace}": single-file mode cannot resolve a namespace.`
|
|
696
|
+
);
|
|
568
697
|
}
|
|
569
|
-
|
|
698
|
+
const path2 = resolve(inputAbs, `${args.locale}.json`);
|
|
699
|
+
if (!isWithinDirectory(path2, inputAbs)) {
|
|
700
|
+
throw new Error(
|
|
701
|
+
`Resolved locale file path escapes input directory for locale "${args.locale}".`
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
return path2;
|
|
705
|
+
}
|
|
706
|
+
if (args.namespace === null) {
|
|
707
|
+
throw new Error("Namespaced mode requires a non-null namespace.");
|
|
708
|
+
}
|
|
709
|
+
validateNamespace(args.namespace, args.namespaces.structure);
|
|
710
|
+
const path = args.namespaces.structure === "locale-dir" ? resolve(inputAbs, args.locale, `${args.namespace}.json`) : resolve(inputAbs, `${args.locale}.${args.namespace}.json`);
|
|
711
|
+
if (!isWithinDirectory(path, inputAbs)) {
|
|
712
|
+
throw new Error(
|
|
713
|
+
`Resolved locale file path escapes input directory for namespace "${args.namespace}".`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
return path;
|
|
570
717
|
}
|
|
571
|
-
async function
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
for (const flat of flatPerLocale) {
|
|
579
|
-
for (const k of Object.keys(flat)) allKeys.add(k);
|
|
718
|
+
async function readTranslationFile(path, logicalPath) {
|
|
719
|
+
const content = await readFile(path, "utf-8");
|
|
720
|
+
try {
|
|
721
|
+
return JSON.parse(content);
|
|
722
|
+
} catch (error) {
|
|
723
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
724
|
+
throw new Error(`Failed to parse ${logicalPath}: ${message}`, { cause: error });
|
|
580
725
|
}
|
|
581
|
-
|
|
582
|
-
|
|
726
|
+
}
|
|
727
|
+
async function listJsonFilesRecursive(directory) {
|
|
728
|
+
let entries;
|
|
729
|
+
try {
|
|
730
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
731
|
+
} catch (error) {
|
|
732
|
+
const errno = error.code;
|
|
733
|
+
if (errno === "ENOENT") return [];
|
|
734
|
+
throw error;
|
|
583
735
|
}
|
|
584
|
-
|
|
585
|
-
|
|
736
|
+
const files = [];
|
|
737
|
+
for (const entry of entries) {
|
|
738
|
+
const path = join(directory, entry.name);
|
|
739
|
+
if (entry.isDirectory()) {
|
|
740
|
+
files.push(...await listJsonFilesRecursive(path));
|
|
741
|
+
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
742
|
+
files.push(path);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return files.sort();
|
|
586
746
|
}
|
|
587
|
-
async function
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
);
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
747
|
+
async function listDirectJsonFiles(directory) {
|
|
748
|
+
let entries;
|
|
749
|
+
try {
|
|
750
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
751
|
+
} catch (error) {
|
|
752
|
+
const errno = error.code;
|
|
753
|
+
if (errno === "ENOENT") return [];
|
|
754
|
+
throw error;
|
|
755
|
+
}
|
|
756
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => join(directory, entry.name)).sort();
|
|
757
|
+
}
|
|
758
|
+
function orderAndSynthesizeFiles(loaded, options) {
|
|
759
|
+
const namespaces = [
|
|
760
|
+
...new Set(loaded.map((file) => file.namespace).filter((ns) => ns !== null))
|
|
761
|
+
].sort();
|
|
762
|
+
if (namespaces.length === 0) return [];
|
|
763
|
+
const byKey = new Map(loaded.map((file) => [`${file.locale}\0${file.namespace}`, file]));
|
|
764
|
+
const ordered = [];
|
|
765
|
+
for (const locale of options.locales) {
|
|
766
|
+
for (const namespace of namespaces) {
|
|
767
|
+
const key = `${locale}\0${namespace}`;
|
|
768
|
+
const existing = byKey.get(key);
|
|
769
|
+
if (existing) {
|
|
770
|
+
ordered.push(existing);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
ordered.push({
|
|
774
|
+
locale,
|
|
775
|
+
namespace,
|
|
776
|
+
path: resolveLocaleFilePath({
|
|
777
|
+
cwd: options.cwd,
|
|
778
|
+
inputDir: options.inputDir,
|
|
779
|
+
locale,
|
|
780
|
+
namespace,
|
|
781
|
+
namespaces: options.namespaces
|
|
782
|
+
}),
|
|
783
|
+
translations: {},
|
|
784
|
+
exists: false
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return ordered;
|
|
789
|
+
}
|
|
790
|
+
async function loadLocaleFiles(options) {
|
|
791
|
+
const inputAbs = resolve(options.cwd, options.inputDir);
|
|
792
|
+
if (!options.namespaces) {
|
|
793
|
+
const out = [];
|
|
794
|
+
for (const locale of options.locales) {
|
|
795
|
+
const path = resolveLocaleFilePath({
|
|
796
|
+
cwd: options.cwd,
|
|
797
|
+
inputDir: options.inputDir,
|
|
798
|
+
locale,
|
|
799
|
+
namespace: null
|
|
800
|
+
});
|
|
801
|
+
let translations = {};
|
|
802
|
+
let exists = false;
|
|
803
|
+
try {
|
|
804
|
+
translations = await readTranslationFile(path, `${locale}.json`);
|
|
805
|
+
exists = true;
|
|
806
|
+
} catch (error) {
|
|
807
|
+
const errno = error.code;
|
|
808
|
+
if (errno !== "ENOENT") throw error;
|
|
809
|
+
}
|
|
810
|
+
out.push({ locale, namespace: null, path, translations, exists });
|
|
811
|
+
}
|
|
812
|
+
return out;
|
|
813
|
+
}
|
|
814
|
+
const loaded = [];
|
|
815
|
+
if (options.namespaces.structure === "locale-dir") {
|
|
816
|
+
for (const locale of options.locales) {
|
|
817
|
+
const localeDir = resolve(inputAbs, locale);
|
|
818
|
+
if (!isWithinDirectory(localeDir, inputAbs)) {
|
|
819
|
+
throw new Error(`Locale "${locale}" resolves outside the input directory.`);
|
|
820
|
+
}
|
|
821
|
+
const paths2 = await listJsonFilesRecursive(localeDir);
|
|
822
|
+
for (const path of paths2) {
|
|
823
|
+
const namespace = relative(localeDir, path).slice(0, -".json".length).split(sep).join("/");
|
|
824
|
+
validateNamespace(namespace, "locale-dir");
|
|
825
|
+
loaded.push({
|
|
826
|
+
locale,
|
|
827
|
+
namespace,
|
|
828
|
+
path,
|
|
829
|
+
translations: await readTranslationFile(path, `${locale}/${namespace}.json`),
|
|
830
|
+
exists: true
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return orderAndSynthesizeFiles(loaded, options);
|
|
835
|
+
}
|
|
836
|
+
const sortedLocales = [...options.locales].sort((a, b) => b.length - a.length);
|
|
837
|
+
const paths = await listDirectJsonFiles(inputAbs);
|
|
838
|
+
for (const path of paths) {
|
|
839
|
+
const fileName = basename(path);
|
|
840
|
+
const locale = sortedLocales.find((candidate) => fileName.startsWith(`${candidate}.`));
|
|
841
|
+
if (!locale) continue;
|
|
842
|
+
const namespace = fileName.slice(locale.length + 1, -".json".length);
|
|
843
|
+
if (namespace.trim() === "") continue;
|
|
844
|
+
validateNamespace(namespace, "locale-prefix");
|
|
845
|
+
loaded.push({
|
|
846
|
+
locale,
|
|
847
|
+
namespace,
|
|
848
|
+
path,
|
|
849
|
+
translations: await readTranslationFile(path, fileName),
|
|
850
|
+
exists: true
|
|
605
851
|
});
|
|
606
|
-
}
|
|
852
|
+
}
|
|
853
|
+
return orderAndSynthesizeFiles(loaded, options);
|
|
854
|
+
}
|
|
855
|
+
function indexLocaleFiles(files) {
|
|
856
|
+
const byLocale = {};
|
|
857
|
+
const namespaceSet = /* @__PURE__ */ new Set();
|
|
858
|
+
for (const file of files) {
|
|
859
|
+
byLocale[file.locale] ??= /* @__PURE__ */ new Map();
|
|
860
|
+
byLocale[file.locale].set(file.namespace, file);
|
|
861
|
+
if (file.namespace !== null) namespaceSet.add(file.namespace);
|
|
862
|
+
}
|
|
607
863
|
return {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}))
|
|
864
|
+
files,
|
|
865
|
+
namespaces: [...namespaceSet].sort(),
|
|
866
|
+
byLocale
|
|
612
867
|
};
|
|
613
868
|
}
|
|
614
869
|
|
|
870
|
+
// ../shared/dist/errors/index.js
|
|
871
|
+
function noNamespacesError(referenceLocale, inputDir) {
|
|
872
|
+
return new Error(
|
|
873
|
+
`No namespace files found under "${inputDir}". Run \`langsync init\` or create at least one namespace file for "${referenceLocale}".`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
615
877
|
// src/commands/export/run.ts
|
|
616
878
|
var DEFAULT_FILE = "translations.xlsx";
|
|
617
879
|
var DEFAULT_SHEET = "Translations";
|
|
@@ -621,19 +883,29 @@ async function runExportExcel(options) {
|
|
|
621
883
|
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
622
884
|
}
|
|
623
885
|
const { config } = loaded;
|
|
886
|
+
const referenceLocale = config.defaultLocale ?? config.locales[0];
|
|
624
887
|
const file = resolve(options.cwd, options.file ?? config.excel?.file ?? DEFAULT_FILE);
|
|
625
888
|
const sheetName = options.sheetName ?? config.excel?.sheetName ?? DEFAULT_SHEET;
|
|
626
889
|
const files = await loadLocaleFiles({
|
|
627
890
|
cwd: options.cwd,
|
|
628
891
|
inputDir: config.input,
|
|
629
|
-
locales: config.locales
|
|
892
|
+
locales: config.locales,
|
|
893
|
+
namespaces: config.namespaces
|
|
630
894
|
});
|
|
895
|
+
const index = indexLocaleFiles(files);
|
|
896
|
+
if (config.namespaces !== void 0 && index.namespaces.length === 0) {
|
|
897
|
+
throw noNamespacesError(referenceLocale, config.input);
|
|
898
|
+
}
|
|
631
899
|
await exportToExcel({
|
|
632
900
|
file,
|
|
633
901
|
sheetName,
|
|
634
|
-
|
|
902
|
+
files: files.map((f) => ({
|
|
903
|
+
locale: f.locale,
|
|
904
|
+
namespace: f.namespace,
|
|
905
|
+
translations: f.translations
|
|
906
|
+
}))
|
|
635
907
|
});
|
|
636
|
-
return { file, sheetName, locales:
|
|
908
|
+
return { file, sheetName, locales: config.locales, namespaces: index.namespaces };
|
|
637
909
|
}
|
|
638
910
|
|
|
639
911
|
// src/commands/export.ts
|
|
@@ -642,13 +914,14 @@ function registerExportCommand(program) {
|
|
|
642
914
|
cmd.command("excel").description("Export translations to an Excel workbook.").option("--file <path>", "Output Excel file (overrides config).").option("--sheet <name>", "Worksheet name (overrides config).").action(async (flags) => {
|
|
643
915
|
try {
|
|
644
916
|
const cwd = process.cwd();
|
|
645
|
-
const { file, locales } = await runExportExcel({
|
|
917
|
+
const { file, locales, namespaces } = await runExportExcel({
|
|
646
918
|
cwd,
|
|
647
919
|
file: flags.file,
|
|
648
920
|
sheetName: flags.sheet
|
|
649
921
|
});
|
|
922
|
+
const namespaceSummary = namespaces.length > 0 ? ` across ${chalk.cyan(String(namespaces.length))} namespace(s)` : "";
|
|
650
923
|
logger.success(
|
|
651
|
-
`Exported ${chalk.cyan(String(locales.length))} locale(s) to ${chalk.bold(
|
|
924
|
+
`Exported ${chalk.cyan(String(locales.length))} locale(s)${namespaceSummary} to ${chalk.bold(
|
|
652
925
|
relative(cwd, file)
|
|
653
926
|
)}`
|
|
654
927
|
);
|
|
@@ -669,9 +942,18 @@ async function runImportExcel(options) {
|
|
|
669
942
|
const { config } = loaded;
|
|
670
943
|
const file = resolve(options.cwd, options.file ?? config.excel?.file ?? DEFAULT_FILE2);
|
|
671
944
|
const sheetName = options.sheetName ?? config.excel?.sheetName ?? DEFAULT_SHEET2;
|
|
672
|
-
const inputAbs = resolve(options.cwd, config.input);
|
|
673
945
|
const configuredLocales = new Set(config.locales);
|
|
674
946
|
const result = await importFromExcel(file, sheetName);
|
|
947
|
+
if (!config.namespaces && result.format === "namespaced") {
|
|
948
|
+
throw new Error(
|
|
949
|
+
"Cannot import a namespaced workbook into a single-file project. Configure a `namespaces` block first."
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
if (config.namespaces && result.format === "single-file") {
|
|
953
|
+
throw new Error(
|
|
954
|
+
"Cannot import a single-file workbook into a namespaced project. Export a namespaced workbook or remove the `namespaces` block."
|
|
955
|
+
);
|
|
956
|
+
}
|
|
675
957
|
const planned = [];
|
|
676
958
|
const written = [];
|
|
677
959
|
const skipped = [];
|
|
@@ -680,7 +962,13 @@ async function runImportExcel(options) {
|
|
|
680
962
|
skipped.push(entry.locale);
|
|
681
963
|
continue;
|
|
682
964
|
}
|
|
683
|
-
const path =
|
|
965
|
+
const path = resolveLocaleFilePath({
|
|
966
|
+
cwd: options.cwd,
|
|
967
|
+
inputDir: config.input,
|
|
968
|
+
locale: entry.locale,
|
|
969
|
+
namespace: entry.namespace,
|
|
970
|
+
namespaces: config.namespaces
|
|
971
|
+
});
|
|
684
972
|
planned.push(path);
|
|
685
973
|
if (!options.dryRun) {
|
|
686
974
|
await writeJson(path, entry.translations);
|
|
@@ -727,330 +1015,6 @@ var TranslationAdapterError = class extends Error {
|
|
|
727
1015
|
this.name = "TranslationAdapterError";
|
|
728
1016
|
}
|
|
729
1017
|
};
|
|
730
|
-
var DEFAULT_MODEL = "gpt-5-mini";
|
|
731
|
-
var ENDPOINT = "https://api.openai.com/v1/chat/completions";
|
|
732
|
-
var OpenAIAdapter = class {
|
|
733
|
-
provider = "openai";
|
|
734
|
-
apiKey;
|
|
735
|
-
model;
|
|
736
|
-
fetchImpl;
|
|
737
|
-
constructor(options = {}) {
|
|
738
|
-
const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
|
|
739
|
-
if (!apiKey) {
|
|
740
|
-
throw new Error(
|
|
741
|
-
"OpenAI API key missing. Set `ai.apiKey` in your config or the OPENAI_API_KEY env var."
|
|
742
|
-
);
|
|
743
|
-
}
|
|
744
|
-
this.apiKey = apiKey;
|
|
745
|
-
this.model = options.model ?? DEFAULT_MODEL;
|
|
746
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
747
|
-
}
|
|
748
|
-
async translate(request) {
|
|
749
|
-
const response = await this.fetchImpl(ENDPOINT, {
|
|
750
|
-
method: "POST",
|
|
751
|
-
headers: {
|
|
752
|
-
"content-type": "application/json",
|
|
753
|
-
authorization: `Bearer ${this.apiKey}`
|
|
754
|
-
},
|
|
755
|
-
body: JSON.stringify({
|
|
756
|
-
model: this.model,
|
|
757
|
-
temperature: 0,
|
|
758
|
-
messages: [
|
|
759
|
-
{
|
|
760
|
-
role: "system",
|
|
761
|
-
content: `You are a professional software localization engine. Translate the user message from ${request.sourceLocale} to ${request.targetLocale}. Preserve placeholders, ICU syntax, and surrounding punctuation. Respond with the translation only, no quotes or commentary.`
|
|
762
|
-
},
|
|
763
|
-
{ role: "user", content: request.text }
|
|
764
|
-
]
|
|
765
|
-
})
|
|
766
|
-
});
|
|
767
|
-
if (!response.ok) {
|
|
768
|
-
throw new Error(`OpenAI request failed: ${response.status} ${response.statusText}`);
|
|
769
|
-
}
|
|
770
|
-
const data = await response.json();
|
|
771
|
-
const content = data.choices?.[0]?.message?.content?.trim();
|
|
772
|
-
if (!content) {
|
|
773
|
-
throw new Error("OpenAI returned an empty translation.");
|
|
774
|
-
}
|
|
775
|
-
return content;
|
|
776
|
-
}
|
|
777
|
-
};
|
|
778
|
-
var FREE_ENDPOINT = "https://api-free.deepl.com/v2/translate";
|
|
779
|
-
var PRO_ENDPOINT = "https://api.deepl.com/v2/translate";
|
|
780
|
-
var FREE_KEY_SUFFIX = ":fx";
|
|
781
|
-
function toDeepLLang(locale) {
|
|
782
|
-
return locale.split("-")[0].toUpperCase();
|
|
783
|
-
}
|
|
784
|
-
var DeepLAdapter = class {
|
|
785
|
-
provider = "deepl";
|
|
786
|
-
apiKey;
|
|
787
|
-
endpoint;
|
|
788
|
-
fetchImpl;
|
|
789
|
-
constructor(options = {}) {
|
|
790
|
-
const apiKey = options.apiKey ?? process.env.DEEPL_API_KEY;
|
|
791
|
-
if (!apiKey) {
|
|
792
|
-
throw new Error(
|
|
793
|
-
"DeepL API key missing. Set `ai.apiKey` in your config or the DEEPL_API_KEY env var."
|
|
794
|
-
);
|
|
795
|
-
}
|
|
796
|
-
this.apiKey = apiKey;
|
|
797
|
-
const useFreeTier = options.useFreeTier ?? apiKey.endsWith(FREE_KEY_SUFFIX);
|
|
798
|
-
this.endpoint = useFreeTier ? FREE_ENDPOINT : PRO_ENDPOINT;
|
|
799
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
800
|
-
}
|
|
801
|
-
async translate(request) {
|
|
802
|
-
const response = await this.fetchImpl(this.endpoint, {
|
|
803
|
-
method: "POST",
|
|
804
|
-
headers: {
|
|
805
|
-
"content-type": "application/json",
|
|
806
|
-
authorization: `DeepL-Auth-Key ${this.apiKey}`
|
|
807
|
-
},
|
|
808
|
-
body: JSON.stringify({
|
|
809
|
-
text: [request.text],
|
|
810
|
-
source_lang: toDeepLLang(request.sourceLocale),
|
|
811
|
-
target_lang: toDeepLLang(request.targetLocale)
|
|
812
|
-
})
|
|
813
|
-
});
|
|
814
|
-
if (!response.ok) {
|
|
815
|
-
throw new Error(`DeepL request failed: ${response.status} ${response.statusText}`);
|
|
816
|
-
}
|
|
817
|
-
const data = await response.json();
|
|
818
|
-
const content = data.translations?.[0]?.text?.trim();
|
|
819
|
-
if (!content) {
|
|
820
|
-
throw new Error("DeepL returned an empty translation.");
|
|
821
|
-
}
|
|
822
|
-
return content;
|
|
823
|
-
}
|
|
824
|
-
};
|
|
825
|
-
var DEFAULT_MODEL2 = "claude-haiku-4-5";
|
|
826
|
-
var ENDPOINT2 = "https://api.anthropic.com/v1/messages";
|
|
827
|
-
var ANTHROPIC_VERSION = "2023-06-01";
|
|
828
|
-
var MAX_TOKENS = 1024;
|
|
829
|
-
var AnthropicAdapter = class {
|
|
830
|
-
provider = "anthropic";
|
|
831
|
-
apiKey;
|
|
832
|
-
model;
|
|
833
|
-
fetchImpl;
|
|
834
|
-
constructor(options = {}) {
|
|
835
|
-
const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
|
836
|
-
if (!apiKey) {
|
|
837
|
-
throw new Error(
|
|
838
|
-
"Anthropic API key missing. Set `ai.apiKey` in your config or the ANTHROPIC_API_KEY env var."
|
|
839
|
-
);
|
|
840
|
-
}
|
|
841
|
-
this.apiKey = apiKey;
|
|
842
|
-
this.model = options.model ?? DEFAULT_MODEL2;
|
|
843
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
844
|
-
}
|
|
845
|
-
async translate(request) {
|
|
846
|
-
const response = await this.fetchImpl(ENDPOINT2, {
|
|
847
|
-
method: "POST",
|
|
848
|
-
headers: {
|
|
849
|
-
"content-type": "application/json",
|
|
850
|
-
"x-api-key": this.apiKey,
|
|
851
|
-
"anthropic-version": ANTHROPIC_VERSION
|
|
852
|
-
},
|
|
853
|
-
body: JSON.stringify({
|
|
854
|
-
model: this.model,
|
|
855
|
-
max_tokens: MAX_TOKENS,
|
|
856
|
-
system: `You are a professional software localization engine. Translate the user message from ${request.sourceLocale} to ${request.targetLocale}. Preserve placeholders, ICU syntax, and surrounding punctuation. Respond with the translation only, no quotes or commentary.`,
|
|
857
|
-
messages: [{ role: "user", content: request.text }]
|
|
858
|
-
})
|
|
859
|
-
});
|
|
860
|
-
if (!response.ok) {
|
|
861
|
-
throw new Error(`Anthropic request failed: ${response.status} ${response.statusText}`);
|
|
862
|
-
}
|
|
863
|
-
const data = await response.json();
|
|
864
|
-
const content = data.content?.find((block) => block.type === "text" || block.text)?.text?.trim();
|
|
865
|
-
if (!content) {
|
|
866
|
-
throw new Error("Anthropic returned an empty translation.");
|
|
867
|
-
}
|
|
868
|
-
return content;
|
|
869
|
-
}
|
|
870
|
-
};
|
|
871
|
-
var DEFAULT_MODEL3 = "gemini-3-flash";
|
|
872
|
-
var BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
|
|
873
|
-
var GeminiAdapter = class {
|
|
874
|
-
provider = "gemini";
|
|
875
|
-
apiKey;
|
|
876
|
-
model;
|
|
877
|
-
fetchImpl;
|
|
878
|
-
constructor(options = {}) {
|
|
879
|
-
const apiKey = options.apiKey ?? process.env.GEMINI_API_KEY;
|
|
880
|
-
if (!apiKey) {
|
|
881
|
-
throw new Error(
|
|
882
|
-
"Gemini API key missing. Set `ai.apiKey` in your config or the GEMINI_API_KEY env var."
|
|
883
|
-
);
|
|
884
|
-
}
|
|
885
|
-
this.apiKey = apiKey;
|
|
886
|
-
this.model = options.model ?? DEFAULT_MODEL3;
|
|
887
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
888
|
-
}
|
|
889
|
-
async translate(request) {
|
|
890
|
-
const url = `${BASE_URL}/${this.model}:generateContent?key=${this.apiKey}`;
|
|
891
|
-
const response = await this.fetchImpl(url, {
|
|
892
|
-
method: "POST",
|
|
893
|
-
headers: { "content-type": "application/json" },
|
|
894
|
-
body: JSON.stringify({
|
|
895
|
-
systemInstruction: {
|
|
896
|
-
parts: [
|
|
897
|
-
{
|
|
898
|
-
text: `You are a professional software localization engine. Translate the user message from ${request.sourceLocale} to ${request.targetLocale}. Preserve placeholders, ICU syntax, and surrounding punctuation. Respond with the translation only, no quotes or commentary.`
|
|
899
|
-
}
|
|
900
|
-
]
|
|
901
|
-
},
|
|
902
|
-
contents: [{ parts: [{ text: request.text }] }],
|
|
903
|
-
generationConfig: { temperature: 0 }
|
|
904
|
-
})
|
|
905
|
-
});
|
|
906
|
-
if (!response.ok) {
|
|
907
|
-
throw new Error(`Gemini request failed: ${response.status} ${response.statusText}`);
|
|
908
|
-
}
|
|
909
|
-
const data = await response.json();
|
|
910
|
-
const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
|
|
911
|
-
if (!content) {
|
|
912
|
-
throw new Error("Gemini returned an empty translation.");
|
|
913
|
-
}
|
|
914
|
-
return content;
|
|
915
|
-
}
|
|
916
|
-
};
|
|
917
|
-
var RELEASED_PROVIDERS = ["openai", "deepl"];
|
|
918
|
-
var ALL_PROVIDERS = ["openai", "deepl", "anthropic", "gemini"];
|
|
919
|
-
function experimentalEnabled() {
|
|
920
|
-
return process.env.LANGSYNC_AI_EXPERIMENTAL === "1";
|
|
921
|
-
}
|
|
922
|
-
function availableProviders() {
|
|
923
|
-
return experimentalEnabled() ? [...ALL_PROVIDERS] : [...RELEASED_PROVIDERS];
|
|
924
|
-
}
|
|
925
|
-
function createAdapter(options) {
|
|
926
|
-
if (!availableProviders().includes(options.provider)) {
|
|
927
|
-
if (ALL_PROVIDERS.includes(options.provider)) {
|
|
928
|
-
throw new Error(
|
|
929
|
-
`AI provider "${options.provider}" is not yet available. Currently supported: ${availableProviders().join(", ")}.`
|
|
930
|
-
);
|
|
931
|
-
}
|
|
932
|
-
throw new Error(`Unknown AI provider "${options.provider}".`);
|
|
933
|
-
}
|
|
934
|
-
const { provider, ...rest } = options;
|
|
935
|
-
switch (provider) {
|
|
936
|
-
case "openai":
|
|
937
|
-
return new OpenAIAdapter(rest);
|
|
938
|
-
case "deepl":
|
|
939
|
-
return new DeepLAdapter(rest);
|
|
940
|
-
case "anthropic":
|
|
941
|
-
return new AnthropicAdapter(rest);
|
|
942
|
-
case "gemini":
|
|
943
|
-
return new GeminiAdapter(rest);
|
|
944
|
-
default: {
|
|
945
|
-
const exhaustive = provider;
|
|
946
|
-
throw new Error(`AI provider "${String(exhaustive)}" has no adapter implementation yet.`);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
function isEmpty(value) {
|
|
951
|
-
return value === void 0 || value.trim() === "";
|
|
952
|
-
}
|
|
953
|
-
async function fillEmptyTranslations(options) {
|
|
954
|
-
const referenceFlat = flatten(options.reference);
|
|
955
|
-
const targetFlat = flatten(options.target);
|
|
956
|
-
const translatedKeys = [];
|
|
957
|
-
const skippedKeys = [];
|
|
958
|
-
for (const [key, referenceValue] of Object.entries(referenceFlat)) {
|
|
959
|
-
if (isEmpty(referenceValue)) continue;
|
|
960
|
-
if (!isEmpty(targetFlat[key])) continue;
|
|
961
|
-
if (options.maxKeys !== void 0 && translatedKeys.length >= options.maxKeys) {
|
|
962
|
-
skippedKeys.push(key);
|
|
963
|
-
continue;
|
|
964
|
-
}
|
|
965
|
-
targetFlat[key] = await options.adapter.translate({
|
|
966
|
-
text: referenceValue,
|
|
967
|
-
sourceLocale: options.sourceLocale,
|
|
968
|
-
targetLocale: options.targetLocale
|
|
969
|
-
});
|
|
970
|
-
translatedKeys.push(key);
|
|
971
|
-
}
|
|
972
|
-
return { tree: unflatten(targetFlat), translatedKeys, skippedKeys };
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
// src/commands/translate/run.ts
|
|
976
|
-
async function runTranslate(options) {
|
|
977
|
-
const loaded = await loadConfig(options.cwd);
|
|
978
|
-
if (!loaded) {
|
|
979
|
-
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
980
|
-
}
|
|
981
|
-
const { config } = loaded;
|
|
982
|
-
const referenceLocale = config.defaultLocale ?? config.locales[0];
|
|
983
|
-
const provider = options.provider ?? config.ai?.provider ?? "openai";
|
|
984
|
-
const adapter = createAdapter({
|
|
985
|
-
provider,
|
|
986
|
-
apiKey: config.ai?.apiKey,
|
|
987
|
-
model: options.model ?? config.ai?.model
|
|
988
|
-
});
|
|
989
|
-
const files = await loadLocaleFiles({
|
|
990
|
-
cwd: options.cwd,
|
|
991
|
-
inputDir: config.input,
|
|
992
|
-
locales: config.locales
|
|
993
|
-
});
|
|
994
|
-
const reference = files.find((f) => f.locale === referenceLocale);
|
|
995
|
-
if (!reference) {
|
|
996
|
-
throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
|
|
997
|
-
}
|
|
998
|
-
const targets = files.filter((f) => f.locale !== referenceLocale);
|
|
999
|
-
const refFlat = flatten(reference.translations);
|
|
1000
|
-
const allCandidates = [];
|
|
1001
|
-
for (const target of targets) {
|
|
1002
|
-
const targetFlat = flatten(target.translations);
|
|
1003
|
-
for (const [key, value] of Object.entries(refFlat)) {
|
|
1004
|
-
if (!value || value.trim() === "") continue;
|
|
1005
|
-
if (targetFlat[key] && targetFlat[key].trim() !== "") continue;
|
|
1006
|
-
allCandidates.push({ file: target, key, sourceValue: value });
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
const totalTranslatableKeys = allCandidates.length;
|
|
1010
|
-
const limited = options.maxKeys ? allCandidates.slice(0, options.maxKeys) : allCandidates;
|
|
1011
|
-
const perLocaleMaxKeys = {};
|
|
1012
|
-
for (const candidate of limited) {
|
|
1013
|
-
perLocaleMaxKeys[candidate.file.locale] = (perLocaleMaxKeys[candidate.file.locale] ?? 0) + 1;
|
|
1014
|
-
}
|
|
1015
|
-
const written = [];
|
|
1016
|
-
const planned = [];
|
|
1017
|
-
const translatedByLocale = {};
|
|
1018
|
-
const skippedByLocale = {};
|
|
1019
|
-
for (const target of targets) {
|
|
1020
|
-
const localeMax = perLocaleMaxKeys[target.locale];
|
|
1021
|
-
if (options.maxKeys !== void 0 && !localeMax) {
|
|
1022
|
-
skippedByLocale[target.locale] = allCandidates.filter((c) => c.file.locale === target.locale).map((c) => c.key);
|
|
1023
|
-
continue;
|
|
1024
|
-
}
|
|
1025
|
-
const { tree, translatedKeys, skippedKeys } = await fillEmptyTranslations({
|
|
1026
|
-
reference: reference.translations,
|
|
1027
|
-
target: target.translations,
|
|
1028
|
-
sourceLocale: referenceLocale,
|
|
1029
|
-
targetLocale: target.locale,
|
|
1030
|
-
adapter,
|
|
1031
|
-
maxKeys: localeMax
|
|
1032
|
-
});
|
|
1033
|
-
translatedByLocale[target.locale] = translatedKeys;
|
|
1034
|
-
if (skippedKeys.length > 0) skippedByLocale[target.locale] = skippedKeys;
|
|
1035
|
-
if (translatedKeys.length === 0) continue;
|
|
1036
|
-
planned.push(target.path);
|
|
1037
|
-
if (!options.dryRun) {
|
|
1038
|
-
await writeJson(target.path, tree);
|
|
1039
|
-
written.push(target.path);
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
return {
|
|
1043
|
-
provider,
|
|
1044
|
-
referenceLocale,
|
|
1045
|
-
written,
|
|
1046
|
-
planned,
|
|
1047
|
-
translatedByLocale,
|
|
1048
|
-
skippedByLocale,
|
|
1049
|
-
totalTranslatableKeys
|
|
1050
|
-
};
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
// src/commands/translate.ts
|
|
1054
1018
|
function registerTranslateCommand(program) {
|
|
1055
1019
|
program.command("translate").description("Fill empty values in non-reference locales using an AI provider.").option("--provider <provider>", "Override the configured AI provider.").option("--model <model>", "Override the configured provider model.").option(
|
|
1056
1020
|
"--max-keys <n>",
|
|
@@ -1134,21 +1098,32 @@ async function resolveWatchDir(cwd) {
|
|
|
1134
1098
|
if (!loaded) {
|
|
1135
1099
|
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
1136
1100
|
}
|
|
1101
|
+
if (loaded.config.namespaces) {
|
|
1102
|
+
throw new Error(
|
|
1103
|
+
"Namespace support for this command is coming in a follow-up release. Remove the `namespaces` block from your config to use single-file mode."
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1137
1106
|
return resolve(cwd, loaded.config.input);
|
|
1138
1107
|
}
|
|
1139
1108
|
async function runWatchPass(options) {
|
|
1140
|
-
const { referenceLocale, written, unchanged, diffsByPath } = await runSync({
|
|
1141
|
-
cwd: options.cwd,
|
|
1142
|
-
dryRun: options.dryRun
|
|
1143
|
-
});
|
|
1144
1109
|
const loaded = await loadConfig(options.cwd);
|
|
1145
1110
|
if (!loaded) {
|
|
1146
1111
|
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
1147
1112
|
}
|
|
1113
|
+
if (loaded.config.namespaces) {
|
|
1114
|
+
throw new Error(
|
|
1115
|
+
"Namespace support for this command is coming in a follow-up release. Remove the `namespaces` block from your config to use single-file mode."
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
const { referenceLocale, written, unchanged, diffsByPath } = await runSync({
|
|
1119
|
+
cwd: options.cwd,
|
|
1120
|
+
dryRun: options.dryRun
|
|
1121
|
+
});
|
|
1148
1122
|
const files = await loadLocaleFiles({
|
|
1149
1123
|
cwd: options.cwd,
|
|
1150
1124
|
inputDir: loaded.config.input,
|
|
1151
|
-
locales: loaded.config.locales
|
|
1125
|
+
locales: loaded.config.locales,
|
|
1126
|
+
namespaces: loaded.config.namespaces
|
|
1152
1127
|
});
|
|
1153
1128
|
const issues = validateLocales(files, referenceLocale);
|
|
1154
1129
|
return { referenceLocale, written, unchanged, diffsByPath, issues };
|
|
@@ -1231,7 +1206,7 @@ function registerWatchCommand(program) {
|
|
|
1231
1206
|
}
|
|
1232
1207
|
|
|
1233
1208
|
// src/cli.ts
|
|
1234
|
-
var VERSION = "0.
|
|
1209
|
+
var VERSION = "0.7.0" ;
|
|
1235
1210
|
async function main() {
|
|
1236
1211
|
assertNodeVersion(22);
|
|
1237
1212
|
const program = new Command();
|