@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/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
- return response;
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 locale of answers.locales) {
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
- var LangSyncConfigSchema = z.object({
249
- input: z.string().describe("Path to the source i18n directory."),
250
- output: z.string().default("./translations").describe(
251
- 'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
252
- ),
253
- locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
254
- defaultLocale: z.string().optional().describe(
255
- "Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
256
- ),
257
- framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
258
- excel: z.object({
259
- file: z.string().default("translations.xlsx"),
260
- sheetName: z.string().default("Translations")
261
- }).optional(),
262
- ai: z.object({
263
- provider: z.enum(["openai", "deepl", "anthropic", "gemini"]).default("openai").describe("AI translation provider."),
264
- apiKey: z.string().optional().describe("API key. Falls back to the provider-specific env var."),
265
- model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
266
- }).optional().describe("AI translation settings.")
267
- });
268
- function formatZodError(error) {
269
- const issues = error.issues.map((issue) => {
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
- async function loadConfig(cwd = process.cwd()) {
277
- const explorer = cosmiconfig("langsync", {
278
- searchPlaces: [
279
- "langsync.config.ts",
280
- "langsync.config.js",
281
- "langsync.config.mjs",
282
- "langsync.config.json",
283
- ".langsyncrc",
284
- ".langsyncrc.json",
285
- "package.json"
286
- ],
287
- loaders: {
288
- ".ts": TypeScriptLoader()
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
- async function writeJson(filePath, data, { indent = 2 } = {}) {
300
- const absolute = resolve(filePath);
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
- async function loadLocaleFiles(options) {
305
- const inputAbs = resolve(options.cwd, options.inputDir);
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 content = await readFile(path, "utf-8");
312
- try {
313
- translations = JSON.parse(content);
314
- } catch (error) {
315
- const message = error instanceof Error ? error.message : String(error);
316
- throw new Error(`Failed to parse ${locale}.json: ${message}`, { cause: error });
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 errno = error.code;
320
- if (errno !== "ENOENT") throw error;
387
+ const message = error instanceof Error ? error.message : String(error);
388
+ logger.error(message);
389
+ process.exitCode = 1;
321
390
  }
322
- out.push({ locale, path, translations });
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
- function syncTrees(source, target) {
383
- const sourceFlat = flatten(source);
384
- const targetFlat = flatten(target);
385
- const merged = {};
386
- for (const key of Object.keys(sourceFlat)) {
387
- merged[key] = targetFlat[key] ?? "";
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 unflatten(merged);
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 diffTrees(prev, next) {
392
- const prevFlat = flatten(prev);
393
- const nextFlat = flatten(next);
394
- const prevKeys = new Set(Object.keys(prevFlat));
395
- const nextKeys = new Set(Object.keys(nextFlat));
396
- const added = [];
397
- const removed = [];
398
- const changed = [];
399
- for (const key of nextKeys) {
400
- if (!prevKeys.has(key)) added.push(key);
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 prevKeys) {
404
- if (!nextKeys.has(key)) removed.push(key);
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 hasChanges(diff) {
409
- return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
483
+ function namespacedKey(namespace, locale) {
484
+ return `${namespace}\0${locale}`;
410
485
  }
411
-
412
- // src/commands/sync/run.ts
413
- async function runSync(options) {
414
- const loaded = await loadConfig(options.cwd);
415
- if (!loaded) {
416
- throw new Error("No LangSync config found. Run `langsync init` first.");
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 { config } = loaded;
419
- const referenceLocale = config.defaultLocale ?? config.locales[0];
420
- const files = await loadLocaleFiles({
421
- cwd: options.cwd,
422
- inputDir: config.input,
423
- locales: config.locales
424
- });
425
- const reference = files.find((f) => f.locale === referenceLocale);
426
- if (!reference) {
427
- throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
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
- const targets = files.filter((f) => f.locale !== referenceLocale);
430
- const planned = [];
431
- const written = [];
432
- const unchanged = [];
433
- const diffsByPath = {};
434
- for (const target of targets) {
435
- const merged = syncTrees(reference.translations, target.translations);
436
- const diff = diffTrees(target.translations, merged);
437
- if (!hasChanges(diff)) {
438
- unchanged.push(target.path);
439
- continue;
440
- }
441
- diffsByPath[target.path] = diff;
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
- return { referenceLocale, written, planned, unchanged, diffsByPath };
524
+ await workbook.xlsx.writeFile(options.file);
449
525
  }
450
-
451
- // src/commands/sync.ts
452
- function registerSyncCommand(program) {
453
- program.command("sync").description("Synchronize translation keys across all configured locales.").option("--dry-run", "Report planned changes without writing files.", false).action(async (flags) => {
454
- try {
455
- const cwd = process.cwd();
456
- const { written, planned, referenceLocale } = await runSync({
457
- cwd,
458
- dryRun: flags.dryRun
459
- });
460
- const targets = flags.dryRun ? planned : written;
461
- if (targets.length === 0) {
462
- logger.info(`Nothing to sync against ${chalk.cyan(referenceLocale)}.`);
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
- // src/commands/validate/run.ts
478
- async function runValidate(options) {
479
- const loaded = await loadConfig(options.cwd);
480
- if (!loaded) {
481
- throw new Error("No LangSync config found. Run `langsync init` first.");
482
- }
483
- const { config } = loaded;
484
- const referenceLocale = config.defaultLocale ?? config.locales[0];
485
- const files = await loadLocaleFiles({
486
- cwd: options.cwd,
487
- inputDir: config.input,
488
- locales: config.locales
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
- referenceLocale,
494
- issues,
495
- exitCode: hasErrors ? 1 : 0
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
- // src/commands/validate.ts
500
- function registerValidateCommand(program) {
501
- program.command("validate").description("Validate locale consistency, structure and missing keys.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
502
- try {
503
- const { issues, exitCode, referenceLocale } = await runValidate({ cwd: process.cwd() });
504
- if (flags.reporter === "json") {
505
- console.log(JSON.stringify({ referenceLocale, issues }, null, 2));
506
- } else {
507
- if (issues.length === 0) {
508
- logger.success(`All locales are consistent with ${chalk.cyan(referenceLocale)}.`);
509
- } else {
510
- const byType = { missing: 0, extra: 0, empty: 0 };
511
- for (const issue of issues) {
512
- byType[issue.type]++;
513
- const colored = issue.type === "empty" ? chalk.yellow(issue.type) : chalk.red(issue.type);
514
- logger.info(`${colored} ${chalk.cyan(issue.locale)} ${issue.key}`);
515
- }
516
- console.log();
517
- logger.info(
518
- `Summary: ${byType.missing} missing, ${byType.extra} extra, ${byType.empty} empty`
519
- );
520
- }
521
- }
522
- process.exitCode = exitCode;
523
- } catch (error) {
524
- const message = error instanceof Error ? error.message : String(error);
525
- logger.error(message);
526
- process.exitCode = 1;
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
- // src/commands/find-missing/run.ts
532
- async function runFindMissing(options) {
533
- const { issues, referenceLocale } = await runValidate({ cwd: options.cwd });
534
- const missingByLocale = {};
535
- for (const issue of issues) {
536
- if (issue.type !== "missing") continue;
537
- (missingByLocale[issue.locale] ??= []).push(issue.key);
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
- for (const locale of Object.keys(missingByLocale)) {
540
- missingByLocale[locale].sort();
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
- // src/commands/find-missing.ts
547
- function registerFindMissingCommand(program) {
548
- program.command("find-missing").description("Find missing translation keys across locales.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
549
- try {
550
- const { referenceLocale, missingByLocale, exitCode } = await runFindMissing({
551
- cwd: process.cwd()
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 exportToExcel(options) {
572
- const workbook = new ExcelJS.Workbook();
573
- const sheet = workbook.addWorksheet(options.sheetName ?? "Translations");
574
- const localeKeys = options.locales.map((l) => l.locale);
575
- sheet.addRow(["key", ...localeKeys]);
576
- const allKeys = /* @__PURE__ */ new Set();
577
- const flatPerLocale = options.locales.map((l) => flatten(l.translations));
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
- for (const key of [...allKeys].sort()) {
582
- sheet.addRow([key, ...flatPerLocale.map((flat) => flat[key] ?? "")]);
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
- sheet.getRow(1).font = { bold: true };
585
- await workbook.xlsx.writeFile(options.file);
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 importFromExcel(file, sheetName) {
588
- const workbook = new ExcelJS.Workbook();
589
- await workbook.xlsx.readFile(file);
590
- const sheet = sheetName ? workbook.getWorksheet(sheetName) : workbook.worksheets[0];
591
- if (!sheet) throw new Error(`Worksheet not found: ${sheetName ?? "<first>"}`);
592
- const header = sheet.getRow(1).values;
593
- const locales = header.slice(2).filter((v) => typeof v === "string");
594
- const flatPerLocale = Object.fromEntries(
595
- locales.map((l) => [l, {}])
596
- );
597
- sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
598
- if (rowNumber === 1) return;
599
- const values = row.values;
600
- const key = values[1];
601
- if (!key) return;
602
- locales.forEach((locale, idx) => {
603
- const value = values[idx + 2];
604
- flatPerLocale[locale][key] = typeof value === "string" ? value : "";
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
- locales: locales.map((locale) => ({
609
- locale,
610
- translations: unflatten(flatPerLocale[locale])
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
- locales: files.map((f) => ({ locale: f.locale, translations: f.translations }))
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: files.map((f) => f.locale) };
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 = join(inputAbs, `${entry.locale}.json`);
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.5.0" ;
1209
+ var VERSION = "0.7.0" ;
1235
1210
  async function main() {
1236
1211
  assertNodeVersion(22);
1237
1212
  const program = new Command();