@mariokreitz/langsync 0.4.1 → 0.6.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 CHANGED
@@ -14,7 +14,7 @@ chaos of hand-edited JSON or fragile Excel hand-offs.
14
14
  - **Bidirectional Excel I/O** for non-technical translators.
15
15
  - **Strict validation** with CI-friendly exit codes and JSON output.
16
16
  - **Auto-detected integrations** for `i18next`, `ngx-translate`, `react-intl`.
17
- - **Interactive setup** that scaffolds your config and locale files.
17
+ - **Interactive setup** that scaffolds your config and single-file or namespaced locale files.
18
18
 
19
19
  ## Install
20
20
 
@@ -59,16 +59,16 @@ npx langsync import excel
59
59
  | Command | Description |
60
60
  | ----------------------- | ------------------------------------------------------------------- |
61
61
  | `langsync init` | Initialize a typed `langsync.config.ts` and scaffold locale files. |
62
- | `langsync validate` | Report missing, extra, and empty keys; exits non-zero on errors. |
62
+ | `langsync validate` | Report missing, extra, and empty keys across locales/namespaces. |
63
63
  | `langsync find-missing` | Report missing keys per locale; exits non-zero on errors. |
64
- | `langsync sync` | Synchronize keys from the reference locale into every other locale. |
64
+ | `langsync sync` | Synchronize reference keys into each target locale or namespace. |
65
65
  | `langsync translate` | Fill empty values in non-reference locales using an AI provider. |
66
66
  | `langsync watch` | Watch locale files and run incremental sync + validation on change. |
67
- | `langsync export excel` | Export all locales into a single `.xlsx` workbook. |
68
- | `langsync import excel` | Import translations from a workbook back into JSON files. |
67
+ | `langsync export excel` | Export locales/namespaces into a single `.xlsx` workbook. |
68
+ | `langsync import excel` | Import workbook translations back into configured JSON files. |
69
69
 
70
- All read commands support `--reporter json`. All write commands support
71
- `--dry-run`.
70
+ The `validate` and `find-missing` commands support `--reporter json`. The
71
+ `sync`, `translate`, and `import excel` commands support `--dry-run`.
72
72
 
73
73
  ## Configuration
74
74
 
@@ -83,6 +83,9 @@ export default defineConfig({
83
83
  locales: ['en', 'de', 'fr'],
84
84
  defaultLocale: 'en',
85
85
  framework: 'i18next',
86
+ // Opt in to namespaced files when your project outgrows one file per locale.
87
+ // namespaces: { structure: 'locale-dir' }, // ./src/i18n/en/common.json
88
+ // namespaces: { structure: 'locale-prefix' }, // ./src/i18n/en.common.json
86
89
  excel: {
87
90
  file: 'translations.xlsx',
88
91
  sheetName: 'Translations',
@@ -95,7 +98,9 @@ export default defineConfig({
95
98
  ```
96
99
 
97
100
  JSON, JS, and MJS configs are also supported via cosmiconfig. Omit `framework`
98
- or set `framework: 'none'` for custom setups.
101
+ or set `framework: 'none'` for custom setups. Omit `namespaces` for the default
102
+ `<input>/<locale>.json` layout, or set `namespaces.structure` to `locale-dir`
103
+ or `locale-prefix` for per-namespace files.
99
104
 
100
105
  ## Documentation
101
106
 
package/dist/cli.js CHANGED
@@ -2,8 +2,8 @@
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
8
  import { cosmiconfig } from 'cosmiconfig';
9
9
  import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
@@ -73,12 +73,30 @@ var FRAMEWORK_CHOICES = [
73
73
  { title: "react-intl", value: "react-intl" },
74
74
  { title: "None / Custom", value: "none" }
75
75
  ];
76
+ var LOCALE_LAYOUT_CHOICES = [
77
+ {
78
+ title: "Single file per locale (<input>/<locale>.json)",
79
+ value: "single-file"
80
+ },
81
+ {
82
+ title: "Namespace folders per locale (<input>/<locale>/<namespace>.json)",
83
+ value: "locale-dir"
84
+ },
85
+ {
86
+ title: "Flat namespace-prefixed files (<input>/<locale>.<namespace>.json)",
87
+ value: "locale-prefix"
88
+ }
89
+ ];
76
90
  var DEFAULTS = {
77
91
  input: "./src/i18n",
78
92
  output: "./translations",
79
93
  locales: "en,de",
80
94
  defaultLocale: "en"
81
95
  };
96
+ function parseNamespaces(value) {
97
+ const namespaces = value.split(",").map((namespace) => namespace.trim()).filter(Boolean);
98
+ return namespaces.length > 0 ? namespaces : ["common"];
99
+ }
82
100
  async function runInitPrompts(options) {
83
101
  const framework = options.detectedFramework ?? "none";
84
102
  if (options.yes) {
@@ -98,6 +116,20 @@ async function runInitPrompts(options) {
98
116
  message: "Where are your source i18n files?",
99
117
  initial: DEFAULTS.input
100
118
  },
119
+ {
120
+ type: "select",
121
+ name: "localeLayout",
122
+ message: "How should locale files be organized?",
123
+ choices: LOCALE_LAYOUT_CHOICES,
124
+ initial: 0
125
+ },
126
+ {
127
+ type: (prev) => prev === "single-file" ? null : "text",
128
+ name: "initialNamespaces",
129
+ message: "Initial namespaces? (comma-separated)",
130
+ initial: "common",
131
+ format: parseNamespaces
132
+ },
101
133
  {
102
134
  type: "text",
103
135
  name: "output",
@@ -135,7 +167,18 @@ async function runInitPrompts(options) {
135
167
  }
136
168
  }
137
169
  );
138
- return response;
170
+ const answers = {
171
+ input: response.input,
172
+ output: response.output,
173
+ locales: response.locales,
174
+ defaultLocale: response.defaultLocale,
175
+ framework: response.framework
176
+ };
177
+ if (response.localeLayout !== "single-file") {
178
+ answers.namespaces = { structure: response.localeLayout };
179
+ answers.initialNamespaces = response.initialNamespaces?.length ? response.initialNamespaces : ["common"];
180
+ }
181
+ return answers;
139
182
  }
140
183
  var CANDIDATE_CONFIG_FILES = [
141
184
  "langsync.config.ts",
@@ -161,6 +204,8 @@ async function findExistingConfig(cwd) {
161
204
  function renderTsConfig(answers) {
162
205
  const frameworkLine = answers.framework === "none" ? "" : ` framework: '${answers.framework}',
163
206
  `;
207
+ const namespacesLine = answers.namespaces ? ` namespaces: { structure: '${answers.namespaces.structure}' },
208
+ ` : "";
164
209
  const localesArr = answers.locales.map((l) => `'${l}'`).join(", ");
165
210
  return `import { defineConfig } from '@mariokreitz/langsync';
166
211
 
@@ -169,7 +214,7 @@ export default defineConfig({
169
214
  output: '${answers.output}',
170
215
  locales: [${localesArr}],
171
216
  defaultLocale: '${answers.defaultLocale}',
172
- ${frameworkLine}});
217
+ ${frameworkLine}${namespacesLine}});
173
218
  `;
174
219
  }
175
220
  function renderJsonConfig(answers) {
@@ -180,8 +225,23 @@ function renderJsonConfig(answers) {
180
225
  defaultLocale: answers.defaultLocale
181
226
  };
182
227
  if (answers.framework !== "none") obj.framework = answers.framework;
228
+ if (answers.namespaces) obj.namespaces = answers.namespaces;
183
229
  return JSON.stringify(obj, null, 2) + "\n";
184
230
  }
231
+ function getLocaleStubPaths(inputDir, answers) {
232
+ if (!answers.namespaces) {
233
+ return answers.locales.map((locale) => join(inputDir, `${locale}.json`));
234
+ }
235
+ const initialNamespaces = answers.initialNamespaces?.length ? answers.initialNamespaces : ["common"];
236
+ return answers.locales.flatMap(
237
+ (locale) => initialNamespaces.map((namespace) => {
238
+ if (answers.namespaces?.structure === "locale-dir") {
239
+ return join(inputDir, locale, `${namespace}.json`);
240
+ }
241
+ return join(inputDir, `${locale}.${namespace}.json`);
242
+ })
243
+ );
244
+ }
185
245
  async function writeConfig(options) {
186
246
  const { cwd, answers, format, force } = options;
187
247
  const existing = await findExistingConfig(cwd);
@@ -198,9 +258,9 @@ async function writeConfig(options) {
198
258
  const inputDir = resolve(cwd, answers.input);
199
259
  await mkdir(inputDir, { recursive: true });
200
260
  const createdLocaleFiles = [];
201
- for (const locale of answers.locales) {
202
- const localePath = join(inputDir, `${locale}.json`);
261
+ for (const localePath of getLocaleStubPaths(inputDir, answers)) {
203
262
  if (!await pathExists(localePath)) {
263
+ await mkdir(dirname(localePath), { recursive: true });
204
264
  await writeFile(localePath, "{}\n", "utf-8");
205
265
  createdLocaleFiles.push(localePath);
206
266
  }
@@ -245,12 +305,24 @@ function registerInitCommand(program) {
245
305
  }
246
306
  });
247
307
  }
308
+ var NamespaceConfigSchema = z.object({
309
+ structure: z.enum(["locale-dir", "locale-prefix"]).describe(
310
+ "Optional namespace layout. `locale-dir` resolves <input>/<locale>/<namespace>.json recursively. `locale-prefix` resolves <input>/<locale>.<namespace>.json."
311
+ )
312
+ });
248
313
  var LangSyncConfigSchema = z.object({
249
314
  input: z.string().describe("Path to the source i18n directory."),
250
- output: z.string().describe("Path to the output/translations directory."),
251
- locales: z.array(z.string()).min(1).describe("List of supported locales."),
252
- defaultLocale: z.string().optional(),
315
+ output: z.string().default("./translations").describe(
316
+ 'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
317
+ ),
318
+ locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
319
+ defaultLocale: z.string().optional().describe(
320
+ "Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
321
+ ),
253
322
  framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
323
+ namespaces: NamespaceConfigSchema.optional().describe(
324
+ "Optional namespace settings. Omit this block to keep the default single-file layout at <input>/<locale>.json."
325
+ ),
254
326
  excel: z.object({
255
327
  file: z.string().default("translations.xlsx"),
256
328
  sheetName: z.string().default("Translations")
@@ -261,6 +333,14 @@ var LangSyncConfigSchema = z.object({
261
333
  model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
262
334
  }).optional().describe("AI translation settings.")
263
335
  });
336
+ function formatZodError(error) {
337
+ const issues = error.issues.map((issue) => {
338
+ const path = issue.path.length > 0 ? ` ${issue.path.join(".")}: ` : " ";
339
+ return `${path}${issue.message}`;
340
+ });
341
+ return `Invalid LangSync configuration:
342
+ ${issues.join("\n")}`;
343
+ }
264
344
  async function loadConfig(cwd = process.cwd()) {
265
345
  const explorer = cosmiconfig("langsync", {
266
346
  searchPlaces: [
@@ -278,35 +358,217 @@ async function loadConfig(cwd = process.cwd()) {
278
358
  });
279
359
  const result = await explorer.search(cwd);
280
360
  if (!result) return null;
281
- const parsed = LangSyncConfigSchema.parse(result.config);
282
- return { config: parsed, filepath: result.filepath };
361
+ const parsed = LangSyncConfigSchema.safeParse(result.config);
362
+ if (!parsed.success) {
363
+ throw new Error(formatZodError(parsed.error));
364
+ }
365
+ return { config: parsed.data, filepath: result.filepath };
283
366
  }
284
367
  async function writeJson(filePath, data, { indent = 2 } = {}) {
285
368
  const absolute = resolve(filePath);
286
369
  await mkdir(dirname(absolute), { recursive: true });
287
370
  await writeFile(absolute, JSON.stringify(data, null, indent) + "\n", "utf-8");
288
371
  }
372
+ function isWithinDirectory(path, directory) {
373
+ return path === directory || path.startsWith(directory + sep);
374
+ }
375
+ function validateNamespace(namespace, structure) {
376
+ if (namespace.trim() === "") {
377
+ throw new Error('Invalid namespace "": namespace must not be empty.');
378
+ }
379
+ if (namespace.includes("\\")) {
380
+ throw new Error(`Invalid namespace "${namespace}": backslashes are not allowed.`);
381
+ }
382
+ if (namespace.startsWith("/")) {
383
+ throw new Error(`Invalid namespace "${namespace}": absolute namespace paths are not allowed.`);
384
+ }
385
+ if (structure === "locale-prefix" && namespace.includes("/")) {
386
+ throw new Error(
387
+ `Invalid namespace "${namespace}": locale-prefix namespaces must not contain '/'.`
388
+ );
389
+ }
390
+ if (namespace.split("/").some((segment) => segment === "." || segment === "..")) {
391
+ throw new Error(`Invalid namespace "${namespace}": path traversal segments are not allowed.`);
392
+ }
393
+ }
394
+ function resolveLocaleFilePath(args) {
395
+ const inputAbs = resolve(args.cwd, args.inputDir);
396
+ if (!args.namespaces) {
397
+ if (args.namespace !== null) {
398
+ throw new Error(
399
+ `Invalid namespace "${args.namespace}": single-file mode cannot resolve a namespace.`
400
+ );
401
+ }
402
+ const path2 = resolve(inputAbs, `${args.locale}.json`);
403
+ if (!isWithinDirectory(path2, inputAbs)) {
404
+ throw new Error(
405
+ `Resolved locale file path escapes input directory for locale "${args.locale}".`
406
+ );
407
+ }
408
+ return path2;
409
+ }
410
+ if (args.namespace === null) {
411
+ throw new Error("Namespaced mode requires a non-null namespace.");
412
+ }
413
+ validateNamespace(args.namespace, args.namespaces.structure);
414
+ const path = args.namespaces.structure === "locale-dir" ? resolve(inputAbs, args.locale, `${args.namespace}.json`) : resolve(inputAbs, `${args.locale}.${args.namespace}.json`);
415
+ if (!isWithinDirectory(path, inputAbs)) {
416
+ throw new Error(
417
+ `Resolved locale file path escapes input directory for namespace "${args.namespace}".`
418
+ );
419
+ }
420
+ return path;
421
+ }
422
+ async function readTranslationFile(path, logicalPath) {
423
+ const content = await readFile(path, "utf-8");
424
+ try {
425
+ return JSON.parse(content);
426
+ } catch (error) {
427
+ const message = error instanceof Error ? error.message : String(error);
428
+ throw new Error(`Failed to parse ${logicalPath}: ${message}`, { cause: error });
429
+ }
430
+ }
431
+ async function listJsonFilesRecursive(directory) {
432
+ let entries;
433
+ try {
434
+ entries = await readdir(directory, { withFileTypes: true });
435
+ } catch (error) {
436
+ const errno = error.code;
437
+ if (errno === "ENOENT") return [];
438
+ throw error;
439
+ }
440
+ const files = [];
441
+ for (const entry of entries) {
442
+ const path = join(directory, entry.name);
443
+ if (entry.isDirectory()) {
444
+ files.push(...await listJsonFilesRecursive(path));
445
+ } else if (entry.isFile() && entry.name.endsWith(".json")) {
446
+ files.push(path);
447
+ }
448
+ }
449
+ return files.sort();
450
+ }
451
+ async function listDirectJsonFiles(directory) {
452
+ let entries;
453
+ try {
454
+ entries = await readdir(directory, { withFileTypes: true });
455
+ } catch (error) {
456
+ const errno = error.code;
457
+ if (errno === "ENOENT") return [];
458
+ throw error;
459
+ }
460
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => join(directory, entry.name)).sort();
461
+ }
462
+ function orderAndSynthesizeFiles(loaded, options) {
463
+ const namespaces = [
464
+ ...new Set(loaded.map((file) => file.namespace).filter((ns) => ns !== null))
465
+ ].sort();
466
+ if (namespaces.length === 0) return [];
467
+ const byKey = new Map(loaded.map((file) => [`${file.locale}\0${file.namespace}`, file]));
468
+ const ordered = [];
469
+ for (const locale of options.locales) {
470
+ for (const namespace of namespaces) {
471
+ const key = `${locale}\0${namespace}`;
472
+ const existing = byKey.get(key);
473
+ if (existing) {
474
+ ordered.push(existing);
475
+ continue;
476
+ }
477
+ ordered.push({
478
+ locale,
479
+ namespace,
480
+ path: resolveLocaleFilePath({
481
+ cwd: options.cwd,
482
+ inputDir: options.inputDir,
483
+ locale,
484
+ namespace,
485
+ namespaces: options.namespaces
486
+ }),
487
+ translations: {},
488
+ exists: false
489
+ });
490
+ }
491
+ }
492
+ return ordered;
493
+ }
289
494
  async function loadLocaleFiles(options) {
290
495
  const inputAbs = resolve(options.cwd, options.inputDir);
291
- const out = [];
292
- for (const locale of options.locales) {
293
- const path = join(inputAbs, `${locale}.json`);
294
- let translations = {};
295
- try {
296
- const content = await readFile(path, "utf-8");
496
+ if (!options.namespaces) {
497
+ const out = [];
498
+ for (const locale of options.locales) {
499
+ const path = resolveLocaleFilePath({
500
+ cwd: options.cwd,
501
+ inputDir: options.inputDir,
502
+ locale,
503
+ namespace: null
504
+ });
505
+ let translations = {};
506
+ let exists = false;
297
507
  try {
298
- translations = JSON.parse(content);
508
+ translations = await readTranslationFile(path, `${locale}.json`);
509
+ exists = true;
299
510
  } catch (error) {
300
- const message = error instanceof Error ? error.message : String(error);
301
- throw new Error(`Failed to parse ${locale}.json: ${message}`, { cause: error });
511
+ const errno = error.code;
512
+ if (errno !== "ENOENT") throw error;
302
513
  }
303
- } catch (error) {
304
- const errno = error.code;
305
- if (errno !== "ENOENT") throw error;
514
+ out.push({ locale, namespace: null, path, translations, exists });
306
515
  }
307
- out.push({ locale, path, translations });
516
+ return out;
308
517
  }
309
- return out;
518
+ const loaded = [];
519
+ if (options.namespaces.structure === "locale-dir") {
520
+ for (const locale of options.locales) {
521
+ const localeDir = resolve(inputAbs, locale);
522
+ if (!isWithinDirectory(localeDir, inputAbs)) {
523
+ throw new Error(`Locale "${locale}" resolves outside the input directory.`);
524
+ }
525
+ const paths2 = await listJsonFilesRecursive(localeDir);
526
+ for (const path of paths2) {
527
+ const namespace = relative(localeDir, path).slice(0, -".json".length).split(sep).join("/");
528
+ validateNamespace(namespace, "locale-dir");
529
+ loaded.push({
530
+ locale,
531
+ namespace,
532
+ path,
533
+ translations: await readTranslationFile(path, `${locale}/${namespace}.json`),
534
+ exists: true
535
+ });
536
+ }
537
+ }
538
+ return orderAndSynthesizeFiles(loaded, options);
539
+ }
540
+ const sortedLocales = [...options.locales].sort((a, b) => b.length - a.length);
541
+ const paths = await listDirectJsonFiles(inputAbs);
542
+ for (const path of paths) {
543
+ const fileName = basename(path);
544
+ const locale = sortedLocales.find((candidate) => fileName.startsWith(`${candidate}.`));
545
+ if (!locale) continue;
546
+ const namespace = fileName.slice(locale.length + 1, -".json".length);
547
+ if (namespace.trim() === "") continue;
548
+ validateNamespace(namespace, "locale-prefix");
549
+ loaded.push({
550
+ locale,
551
+ namespace,
552
+ path,
553
+ translations: await readTranslationFile(path, fileName),
554
+ exists: true
555
+ });
556
+ }
557
+ return orderAndSynthesizeFiles(loaded, options);
558
+ }
559
+ function indexLocaleFiles(files) {
560
+ const byLocale = {};
561
+ const namespaceSet = /* @__PURE__ */ new Set();
562
+ for (const file of files) {
563
+ byLocale[file.locale] ??= /* @__PURE__ */ new Map();
564
+ byLocale[file.locale].set(file.namespace, file);
565
+ if (file.namespace !== null) namespaceSet.add(file.namespace);
566
+ }
567
+ return {
568
+ files,
569
+ namespaces: [...namespaceSet].sort(),
570
+ byLocale
571
+ };
310
572
  }
311
573
 
312
574
  // ../core/dist/index.js
@@ -373,6 +635,33 @@ function syncTrees(source, target) {
373
635
  }
374
636
  return unflatten(merged);
375
637
  }
638
+ function diffTrees(prev, next) {
639
+ const prevFlat = flatten(prev);
640
+ const nextFlat = flatten(next);
641
+ const prevKeys = new Set(Object.keys(prevFlat));
642
+ const nextKeys = new Set(Object.keys(nextFlat));
643
+ const added = [];
644
+ const removed = [];
645
+ const changed = [];
646
+ for (const key of nextKeys) {
647
+ if (!prevKeys.has(key)) added.push(key);
648
+ else if (prevFlat[key] !== nextFlat[key]) changed.push(key);
649
+ }
650
+ for (const key of prevKeys) {
651
+ if (!nextKeys.has(key)) removed.push(key);
652
+ }
653
+ return { added, removed, changed };
654
+ }
655
+ function hasChanges(diff) {
656
+ return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
657
+ }
658
+
659
+ // src/commands/shared/namespace-error.ts
660
+ function noNamespacesError(referenceLocale, inputDir) {
661
+ return new Error(
662
+ `No namespace files found under "${inputDir}". Run \`langsync init\` or create at least one namespace file for "${referenceLocale}".`
663
+ );
664
+ }
376
665
 
377
666
  // src/commands/sync/run.ts
378
667
  async function runSync(options) {
@@ -385,24 +674,46 @@ async function runSync(options) {
385
674
  const files = await loadLocaleFiles({
386
675
  cwd: options.cwd,
387
676
  inputDir: config.input,
388
- locales: config.locales
677
+ locales: config.locales,
678
+ namespaces: config.namespaces
389
679
  });
390
- const reference = files.find((f) => f.locale === referenceLocale);
391
- if (!reference) {
680
+ const index = indexLocaleFiles(files);
681
+ const namespaced = config.namespaces !== void 0;
682
+ if (namespaced && index.namespaces.length === 0) {
683
+ throw noNamespacesError(referenceLocale, config.input);
684
+ }
685
+ const nsKeys = namespaced ? index.namespaces : [null];
686
+ const referenceBucket = index.byLocale[referenceLocale];
687
+ if (!referenceBucket) {
392
688
  throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
393
689
  }
394
- const targets = files.filter((f) => f.locale !== referenceLocale);
395
690
  const planned = [];
396
691
  const written = [];
397
- for (const target of targets) {
398
- const merged = syncTrees(reference.translations, target.translations);
399
- planned.push(target.path);
400
- if (!options.dryRun) {
401
- await writeJson(target.path, merged);
402
- written.push(target.path);
692
+ const unchanged = [];
693
+ const diffsByPath = {};
694
+ for (const targetLocale of config.locales) {
695
+ if (targetLocale === referenceLocale) continue;
696
+ const targetBucket = index.byLocale[targetLocale];
697
+ if (!targetBucket) continue;
698
+ for (const nsKey of nsKeys) {
699
+ const source = referenceBucket.get(nsKey);
700
+ const target = targetBucket.get(nsKey);
701
+ if (!source || !target) continue;
702
+ const merged = syncTrees(source.translations, target.translations);
703
+ const diff = diffTrees(target.translations, merged);
704
+ if (!hasChanges(diff)) {
705
+ unchanged.push(target.path);
706
+ continue;
707
+ }
708
+ diffsByPath[target.path] = diff;
709
+ planned.push(target.path);
710
+ if (!options.dryRun) {
711
+ await writeJson(target.path, merged);
712
+ written.push(target.path);
713
+ }
403
714
  }
404
715
  }
405
- return { referenceLocale, written, planned };
716
+ return { referenceLocale, written, planned, unchanged, diffsByPath };
406
717
  }
407
718
 
408
719
  // src/commands/sync.ts
@@ -442,9 +753,30 @@ async function runValidate(options) {
442
753
  const files = await loadLocaleFiles({
443
754
  cwd: options.cwd,
444
755
  inputDir: config.input,
445
- locales: config.locales
756
+ locales: config.locales,
757
+ namespaces: config.namespaces
758
+ });
759
+ const index = indexLocaleFiles(files);
760
+ const namespaced = config.namespaces !== void 0;
761
+ if (namespaced && index.namespaces.length === 0) {
762
+ throw noNamespacesError(referenceLocale, config.input);
763
+ }
764
+ const nsKeys = namespaced ? index.namespaces : [null];
765
+ const issues = [];
766
+ for (const nsKey of nsKeys) {
767
+ const namespaceFiles = config.locales.map((locale) => index.byLocale[locale]?.get(nsKey)).filter((file) => file !== void 0);
768
+ const namespaceIssues = validateLocales(namespaceFiles, referenceLocale);
769
+ for (const issue of namespaceIssues) {
770
+ const file = index.byLocale[issue.locale]?.get(nsKey);
771
+ if (!file) continue;
772
+ issues.push({ ...issue, namespace: nsKey, path: file.path });
773
+ }
774
+ }
775
+ issues.sort((a, b) => {
776
+ const namespaceA = a.namespace ?? "";
777
+ const namespaceB = b.namespace ?? "";
778
+ return a.locale.localeCompare(b.locale) || namespaceA.localeCompare(namespaceB) || a.key.localeCompare(b.key) || a.type.localeCompare(b.type);
446
779
  });
447
- const issues = validateLocales(files, referenceLocale);
448
780
  const hasErrors = issues.some((i) => i.type === "missing" || i.type === "extra");
449
781
  return {
450
782
  referenceLocale,
@@ -454,6 +786,9 @@ async function runValidate(options) {
454
786
  }
455
787
 
456
788
  // src/commands/validate.ts
789
+ function formatIssueKey(issue) {
790
+ return issue.namespace === null ? issue.key : `${issue.namespace}:${issue.key}`;
791
+ }
457
792
  function registerValidateCommand(program) {
458
793
  program.command("validate").description("Validate locale consistency, structure and missing keys.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
459
794
  try {
@@ -468,7 +803,7 @@ function registerValidateCommand(program) {
468
803
  for (const issue of issues) {
469
804
  byType[issue.type]++;
470
805
  const colored = issue.type === "empty" ? chalk.yellow(issue.type) : chalk.red(issue.type);
471
- logger.info(`${colored} ${chalk.cyan(issue.locale)} ${issue.key}`);
806
+ logger.info(`${colored} ${chalk.cyan(issue.locale)} ${formatIssueKey(issue)}`);
472
807
  }
473
808
  console.log();
474
809
  logger.info(
@@ -491,16 +826,25 @@ async function runFindMissing(options) {
491
826
  const missingByLocale = {};
492
827
  for (const issue of issues) {
493
828
  if (issue.type !== "missing") continue;
494
- (missingByLocale[issue.locale] ??= []).push(issue.key);
829
+ (missingByLocale[issue.locale] ??= []).push({
830
+ namespace: issue.namespace,
831
+ key: issue.key,
832
+ path: issue.path
833
+ });
495
834
  }
496
- for (const locale of Object.keys(missingByLocale)) {
497
- missingByLocale[locale].sort();
835
+ for (const entries of Object.values(missingByLocale)) {
836
+ entries.sort(
837
+ (a, b) => (a.namespace ?? "").localeCompare(b.namespace ?? "") || a.key.localeCompare(b.key)
838
+ );
498
839
  }
499
840
  const exitCode = Object.keys(missingByLocale).length === 0 ? 0 : 1;
500
841
  return { referenceLocale, missingByLocale, exitCode };
501
842
  }
502
843
 
503
844
  // src/commands/find-missing.ts
845
+ function formatMissingEntry(entry) {
846
+ return entry.namespace === null ? entry.key : `${entry.namespace}:${entry.key}`;
847
+ }
504
848
  function registerFindMissingCommand(program) {
505
849
  program.command("find-missing").description("Find missing translation keys across locales.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
506
850
  try {
@@ -514,7 +858,7 @@ function registerFindMissingCommand(program) {
514
858
  } else {
515
859
  for (const [locale, keys] of Object.entries(missingByLocale)) {
516
860
  logger.warn(`${chalk.cyan(locale)} is missing ${keys.length} key(s):`);
517
- for (const key of keys) console.log(` - ${key}`);
861
+ for (const entry of keys) console.log(` - ${formatMissingEntry(entry)}`);
518
862
  }
519
863
  }
520
864
  process.exitCode = exitCode;
@@ -525,49 +869,159 @@ function registerFindMissingCommand(program) {
525
869
  }
526
870
  });
527
871
  }
872
+ function distinctLocales(files) {
873
+ const seen = /* @__PURE__ */ new Set();
874
+ const locales = [];
875
+ for (const file of files) {
876
+ if (seen.has(file.locale)) continue;
877
+ seen.add(file.locale);
878
+ locales.push(file.locale);
879
+ }
880
+ return locales;
881
+ }
882
+ function cellText(value) {
883
+ return typeof value === "string" ? value : "";
884
+ }
885
+ function addHeader(sheet, header) {
886
+ sheet.addRow(header);
887
+ sheet.getRow(1).font = { bold: true };
888
+ }
889
+ function exportSingleFile(sheet, files) {
890
+ const locales = distinctLocales(files);
891
+ addHeader(sheet, ["key", ...locales]);
892
+ const flatByLocale = /* @__PURE__ */ new Map();
893
+ for (const file of files) {
894
+ flatByLocale.set(file.locale, flatten(file.translations));
895
+ }
896
+ const allKeys = /* @__PURE__ */ new Set();
897
+ for (const flat of flatByLocale.values()) {
898
+ for (const key of Object.keys(flat)) allKeys.add(key);
899
+ }
900
+ for (const key of [...allKeys].sort()) {
901
+ sheet.addRow([key, ...locales.map((locale) => flatByLocale.get(locale)?.[key] ?? "")]);
902
+ }
903
+ }
904
+ function namespacedKey(namespace, locale) {
905
+ return `${namespace}\0${locale}`;
906
+ }
907
+ function exportNamespaced(sheet, files) {
908
+ const locales = distinctLocales(files);
909
+ addHeader(sheet, ["namespace", "key", ...locales]);
910
+ const flatByNamespaceLocale = /* @__PURE__ */ new Map();
911
+ const rowKeys = /* @__PURE__ */ new Set();
912
+ for (const file of files) {
913
+ if (file.namespace === null) continue;
914
+ const flat = flatten(file.translations);
915
+ flatByNamespaceLocale.set(namespacedKey(file.namespace, file.locale), flat);
916
+ for (const key of Object.keys(flat)) rowKeys.add(`${file.namespace}\0${key}`);
917
+ }
918
+ const sortedRows = [...rowKeys].map((rowKey) => {
919
+ const [namespace, key] = rowKey.split("\0");
920
+ return { namespace, key };
921
+ }).sort((a, b) => a.namespace.localeCompare(b.namespace) || a.key.localeCompare(b.key));
922
+ for (const { namespace, key } of sortedRows) {
923
+ sheet.addRow([
924
+ namespace,
925
+ key,
926
+ ...locales.map(
927
+ (locale) => flatByNamespaceLocale.get(namespacedKey(namespace, locale))?.[key] ?? ""
928
+ )
929
+ ]);
930
+ }
931
+ }
528
932
  async function exportToExcel(options) {
529
933
  const workbook = new ExcelJS.Workbook();
530
934
  const sheet = workbook.addWorksheet(options.sheetName ?? "Translations");
531
- const localeKeys = options.locales.map((l) => l.locale);
532
- sheet.addRow(["key", ...localeKeys]);
533
- const allKeys = /* @__PURE__ */ new Set();
534
- const flatPerLocale = options.locales.map((l) => flatten(l.translations));
535
- for (const flat of flatPerLocale) {
536
- for (const k of Object.keys(flat)) allKeys.add(k);
935
+ const allSingleFile = options.files.every((file) => file.namespace === null);
936
+ const allNamespaced = options.files.every((file) => file.namespace !== null);
937
+ if (!allSingleFile && !allNamespaced) {
938
+ throw new Error("exportToExcel: files must be uniformly single-file or namespaced.");
537
939
  }
538
- for (const key of [...allKeys].sort()) {
539
- sheet.addRow([key, ...flatPerLocale.map((flat) => flat[key] ?? "")]);
940
+ if (allSingleFile) {
941
+ exportSingleFile(sheet, options.files);
942
+ } else {
943
+ exportNamespaced(sheet, options.files);
540
944
  }
541
- sheet.getRow(1).font = { bold: true };
542
945
  await workbook.xlsx.writeFile(options.file);
543
946
  }
544
- async function importFromExcel(file, sheetName) {
545
- const workbook = new ExcelJS.Workbook();
546
- await workbook.xlsx.readFile(file);
547
- const sheet = sheetName ? workbook.getWorksheet(sheetName) : workbook.worksheets[0];
548
- if (!sheet) throw new Error(`Worksheet not found: ${sheetName ?? "<first>"}`);
549
- const header = sheet.getRow(1).values;
550
- const locales = header.slice(2).filter((v) => typeof v === "string");
947
+ function importSingleFile(sheet, header) {
948
+ const locales = header.slice(2).filter((value) => typeof value === "string");
551
949
  const flatPerLocale = Object.fromEntries(
552
- locales.map((l) => [l, {}])
950
+ locales.map((locale) => [locale, {}])
553
951
  );
554
952
  sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
555
953
  if (rowNumber === 1) return;
556
954
  const values = row.values;
557
- const key = values[1];
955
+ const key = cellText(values[1]);
558
956
  if (!key) return;
559
957
  locales.forEach((locale, idx) => {
560
- const value = values[idx + 2];
561
- flatPerLocale[locale][key] = typeof value === "string" ? value : "";
958
+ flatPerLocale[locale][key] = cellText(values[idx + 2]);
562
959
  });
563
960
  });
564
961
  return {
962
+ format: "single-file",
565
963
  locales: locales.map((locale) => ({
566
964
  locale,
965
+ namespace: null,
567
966
  translations: unflatten(flatPerLocale[locale])
568
967
  }))
569
968
  };
570
969
  }
970
+ function importNamespaced(sheet, header) {
971
+ const locales = header.slice(3).filter((value) => typeof value === "string");
972
+ const namespaces = /* @__PURE__ */ new Set();
973
+ const flatByLocaleNamespace = /* @__PURE__ */ new Map();
974
+ const seenRows = /* @__PURE__ */ new Set();
975
+ sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
976
+ if (rowNumber === 1) return;
977
+ const values = row.values;
978
+ const namespace = cellText(values[1]).trim();
979
+ const key = cellText(values[2]);
980
+ if (!key) return;
981
+ if (!namespace) {
982
+ throw new Error(
983
+ `Row ${rowNumber}: namespace cell must not be empty in a namespaced workbook.`
984
+ );
985
+ }
986
+ const rowKey = `${namespace}\0${key}`;
987
+ if (seenRows.has(rowKey)) {
988
+ throw new Error(`Duplicate (namespace, key) row: "${namespace}" / "${key}".`);
989
+ }
990
+ seenRows.add(rowKey);
991
+ namespaces.add(namespace);
992
+ locales.forEach((locale, idx) => {
993
+ const mapKey = namespacedKey(namespace, locale);
994
+ let flat = flatByLocaleNamespace.get(mapKey);
995
+ if (!flat) {
996
+ flat = {};
997
+ flatByLocaleNamespace.set(mapKey, flat);
998
+ }
999
+ flat[key] = cellText(values[idx + 3]);
1000
+ });
1001
+ });
1002
+ return {
1003
+ format: "namespaced",
1004
+ locales: locales.flatMap(
1005
+ (locale) => [...namespaces].map((namespace) => ({
1006
+ locale,
1007
+ namespace,
1008
+ translations: unflatten(flatByLocaleNamespace.get(namespacedKey(namespace, locale)) ?? {})
1009
+ }))
1010
+ )
1011
+ };
1012
+ }
1013
+ async function importFromExcel(file, sheetName) {
1014
+ const workbook = new ExcelJS.Workbook();
1015
+ await workbook.xlsx.readFile(file);
1016
+ const sheet = sheetName ? workbook.getWorksheet(sheetName) : workbook.worksheets[0];
1017
+ if (!sheet) throw new Error(`Worksheet not found: ${sheetName ?? "<first>"}`);
1018
+ const header = sheet.getRow(1).values;
1019
+ if (header[1] === "key") return importSingleFile(sheet, header);
1020
+ if (header[1] === "namespace" && header[2] === "key") return importNamespaced(sheet, header);
1021
+ throw new Error(
1022
+ 'Unrecognized workbook header. Expected "key" (single-file) or "namespace","key" (namespaced).'
1023
+ );
1024
+ }
571
1025
 
572
1026
  // src/commands/export/run.ts
573
1027
  var DEFAULT_FILE = "translations.xlsx";
@@ -578,19 +1032,29 @@ async function runExportExcel(options) {
578
1032
  throw new Error("No LangSync config found. Run `langsync init` first.");
579
1033
  }
580
1034
  const { config } = loaded;
1035
+ const referenceLocale = config.defaultLocale ?? config.locales[0];
581
1036
  const file = resolve(options.cwd, options.file ?? config.excel?.file ?? DEFAULT_FILE);
582
1037
  const sheetName = options.sheetName ?? config.excel?.sheetName ?? DEFAULT_SHEET;
583
1038
  const files = await loadLocaleFiles({
584
1039
  cwd: options.cwd,
585
1040
  inputDir: config.input,
586
- locales: config.locales
1041
+ locales: config.locales,
1042
+ namespaces: config.namespaces
587
1043
  });
1044
+ const index = indexLocaleFiles(files);
1045
+ if (config.namespaces !== void 0 && index.namespaces.length === 0) {
1046
+ throw noNamespacesError(referenceLocale, config.input);
1047
+ }
588
1048
  await exportToExcel({
589
1049
  file,
590
1050
  sheetName,
591
- locales: files.map((f) => ({ locale: f.locale, translations: f.translations }))
1051
+ files: files.map((f) => ({
1052
+ locale: f.locale,
1053
+ namespace: f.namespace,
1054
+ translations: f.translations
1055
+ }))
592
1056
  });
593
- return { file, sheetName, locales: files.map((f) => f.locale) };
1057
+ return { file, sheetName, locales: config.locales, namespaces: index.namespaces };
594
1058
  }
595
1059
 
596
1060
  // src/commands/export.ts
@@ -599,13 +1063,14 @@ function registerExportCommand(program) {
599
1063
  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) => {
600
1064
  try {
601
1065
  const cwd = process.cwd();
602
- const { file, locales } = await runExportExcel({
1066
+ const { file, locales, namespaces } = await runExportExcel({
603
1067
  cwd,
604
1068
  file: flags.file,
605
1069
  sheetName: flags.sheet
606
1070
  });
1071
+ const namespaceSummary = namespaces.length > 0 ? ` across ${chalk.cyan(String(namespaces.length))} namespace(s)` : "";
607
1072
  logger.success(
608
- `Exported ${chalk.cyan(String(locales.length))} locale(s) to ${chalk.bold(
1073
+ `Exported ${chalk.cyan(String(locales.length))} locale(s)${namespaceSummary} to ${chalk.bold(
609
1074
  relative(cwd, file)
610
1075
  )}`
611
1076
  );
@@ -626,9 +1091,18 @@ async function runImportExcel(options) {
626
1091
  const { config } = loaded;
627
1092
  const file = resolve(options.cwd, options.file ?? config.excel?.file ?? DEFAULT_FILE2);
628
1093
  const sheetName = options.sheetName ?? config.excel?.sheetName ?? DEFAULT_SHEET2;
629
- const inputAbs = resolve(options.cwd, config.input);
630
1094
  const configuredLocales = new Set(config.locales);
631
1095
  const result = await importFromExcel(file, sheetName);
1096
+ if (!config.namespaces && result.format === "namespaced") {
1097
+ throw new Error(
1098
+ "Cannot import a namespaced workbook into a single-file project. Configure a `namespaces` block first."
1099
+ );
1100
+ }
1101
+ if (config.namespaces && result.format === "single-file") {
1102
+ throw new Error(
1103
+ "Cannot import a single-file workbook into a namespaced project. Export a namespaced workbook or remove the `namespaces` block."
1104
+ );
1105
+ }
632
1106
  const planned = [];
633
1107
  const written = [];
634
1108
  const skipped = [];
@@ -637,7 +1111,13 @@ async function runImportExcel(options) {
637
1111
  skipped.push(entry.locale);
638
1112
  continue;
639
1113
  }
640
- const path = join(inputAbs, `${entry.locale}.json`);
1114
+ const path = resolveLocaleFilePath({
1115
+ cwd: options.cwd,
1116
+ inputDir: config.input,
1117
+ locale: entry.locale,
1118
+ namespace: entry.namespace,
1119
+ namespaces: config.namespaces
1120
+ });
641
1121
  planned.push(path);
642
1122
  if (!options.dryRun) {
643
1123
  await writeJson(path, entry.translations);
@@ -676,6 +1156,14 @@ function registerImportCommand(program) {
676
1156
  }
677
1157
 
678
1158
  // ../ai-engine/dist/index.js
1159
+ var TranslationAdapterError = class extends Error {
1160
+ constructor(message, provider, statusCode, options) {
1161
+ super(message, options);
1162
+ this.provider = provider;
1163
+ this.statusCode = statusCode;
1164
+ this.name = "TranslationAdapterError";
1165
+ }
1166
+ };
679
1167
  var DEFAULT_MODEL = "gpt-5-mini";
680
1168
  var ENDPOINT = "https://api.openai.com/v1/chat/completions";
681
1169
  var OpenAIAdapter = class {
@@ -863,7 +1351,7 @@ var GeminiAdapter = class {
863
1351
  return content;
864
1352
  }
865
1353
  };
866
- var RELEASED_PROVIDERS = ["openai"];
1354
+ var RELEASED_PROVIDERS = ["openai", "deepl"];
867
1355
  var ALL_PROVIDERS = ["openai", "deepl", "anthropic", "gemini"];
868
1356
  function experimentalEnabled() {
869
1357
  return process.env.LANGSYNC_AI_EXPERIMENTAL === "1";
@@ -903,9 +1391,14 @@ async function fillEmptyTranslations(options) {
903
1391
  const referenceFlat = flatten(options.reference);
904
1392
  const targetFlat = flatten(options.target);
905
1393
  const translatedKeys = [];
1394
+ const skippedKeys = [];
906
1395
  for (const [key, referenceValue] of Object.entries(referenceFlat)) {
907
1396
  if (isEmpty(referenceValue)) continue;
908
1397
  if (!isEmpty(targetFlat[key])) continue;
1398
+ if (options.maxKeys !== void 0 && translatedKeys.length >= options.maxKeys) {
1399
+ skippedKeys.push(key);
1400
+ continue;
1401
+ }
909
1402
  targetFlat[key] = await options.adapter.translate({
910
1403
  text: referenceValue,
911
1404
  sourceLocale: options.sourceLocale,
@@ -913,10 +1406,16 @@ async function fillEmptyTranslations(options) {
913
1406
  });
914
1407
  translatedKeys.push(key);
915
1408
  }
916
- return { tree: unflatten(targetFlat), translatedKeys };
1409
+ return { tree: unflatten(targetFlat), translatedKeys, skippedKeys };
917
1410
  }
918
1411
 
919
1412
  // src/commands/translate/run.ts
1413
+ function isEmpty2(value) {
1414
+ return value === void 0 || value.trim() === "";
1415
+ }
1416
+ function candidateEntry(candidate) {
1417
+ return { namespace: candidate.namespace, key: candidate.key, path: candidate.path };
1418
+ }
920
1419
  async function runTranslate(options) {
921
1420
  const loaded = await loadConfig(options.cwd);
922
1421
  if (!loaded) {
@@ -925,75 +1424,189 @@ async function runTranslate(options) {
925
1424
  const { config } = loaded;
926
1425
  const referenceLocale = config.defaultLocale ?? config.locales[0];
927
1426
  const provider = options.provider ?? config.ai?.provider ?? "openai";
928
- const adapter = createAdapter({
929
- provider,
930
- apiKey: config.ai?.apiKey,
931
- model: options.model ?? config.ai?.model
932
- });
933
1427
  const files = await loadLocaleFiles({
934
1428
  cwd: options.cwd,
935
1429
  inputDir: config.input,
936
- locales: config.locales
1430
+ locales: config.locales,
1431
+ namespaces: config.namespaces
937
1432
  });
938
- const reference = files.find((f) => f.locale === referenceLocale);
939
- if (!reference) {
1433
+ const index = indexLocaleFiles(files);
1434
+ const namespaced = config.namespaces !== void 0;
1435
+ if (namespaced && index.namespaces.length === 0) {
1436
+ throw noNamespacesError(referenceLocale, config.input);
1437
+ }
1438
+ const nsKeys = namespaced ? index.namespaces : [null];
1439
+ const referenceBucket = index.byLocale[referenceLocale];
1440
+ if (!referenceBucket) {
940
1441
  throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
941
1442
  }
942
- const targets = files.filter((f) => f.locale !== referenceLocale);
1443
+ const adapter = createAdapter({
1444
+ provider,
1445
+ apiKey: config.ai?.apiKey,
1446
+ model: options.model ?? config.ai?.model
1447
+ });
1448
+ const allCandidates = [];
1449
+ for (const targetLocale of config.locales) {
1450
+ if (targetLocale === referenceLocale) continue;
1451
+ const targetBucket = index.byLocale[targetLocale];
1452
+ if (!targetBucket) continue;
1453
+ for (const nsKey of nsKeys) {
1454
+ const reference = referenceBucket.get(nsKey);
1455
+ const target = targetBucket.get(nsKey);
1456
+ if (!reference || !target) continue;
1457
+ const refFlat = flatten(reference.translations);
1458
+ const targetFlat = flatten(target.translations);
1459
+ for (const [key, value] of Object.entries(refFlat)) {
1460
+ if (isEmpty2(value)) continue;
1461
+ if (!isEmpty2(targetFlat[key])) continue;
1462
+ allCandidates.push({
1463
+ locale: targetLocale,
1464
+ namespace: nsKey,
1465
+ path: target.path,
1466
+ key,
1467
+ sourceValue: value
1468
+ });
1469
+ }
1470
+ }
1471
+ }
1472
+ const totalTranslatableKeys = allCandidates.length;
1473
+ const limited = options.maxKeys ? allCandidates.slice(0, options.maxKeys) : allCandidates;
1474
+ const limitedByPath = /* @__PURE__ */ new Map();
1475
+ const skippedCandidates = options.maxKeys ? allCandidates.slice(options.maxKeys) : [];
1476
+ for (const candidate of limited) {
1477
+ const entries = limitedByPath.get(candidate.path) ?? [];
1478
+ entries.push(candidate);
1479
+ limitedByPath.set(candidate.path, entries);
1480
+ }
943
1481
  const written = [];
944
1482
  const planned = [];
945
1483
  const translatedByLocale = {};
946
- for (const target of targets) {
947
- const { tree, translatedKeys } = await fillEmptyTranslations({
948
- reference: reference.translations,
949
- target: target.translations,
950
- sourceLocale: referenceLocale,
951
- targetLocale: target.locale,
952
- adapter
953
- });
954
- translatedByLocale[target.locale] = translatedKeys;
955
- if (translatedKeys.length === 0) continue;
956
- planned.push(target.path);
957
- if (!options.dryRun) {
958
- await writeJson(target.path, tree);
959
- written.push(target.path);
1484
+ const skippedByLocale = {};
1485
+ for (const candidate of skippedCandidates) {
1486
+ if (limitedByPath.has(candidate.path)) continue;
1487
+ (skippedByLocale[candidate.locale] ??= []).push(candidateEntry(candidate));
1488
+ }
1489
+ for (const targetLocale of config.locales) {
1490
+ if (targetLocale === referenceLocale) continue;
1491
+ const targetBucket = index.byLocale[targetLocale];
1492
+ if (!targetBucket) continue;
1493
+ for (const nsKey of nsKeys) {
1494
+ const reference = referenceBucket.get(nsKey);
1495
+ const target = targetBucket.get(nsKey);
1496
+ if (!reference || !target) continue;
1497
+ const budget = limitedByPath.get(target.path)?.length;
1498
+ if (budget === void 0) continue;
1499
+ const { tree, translatedKeys, skippedKeys } = await fillEmptyTranslations({
1500
+ reference: reference.translations,
1501
+ target: target.translations,
1502
+ sourceLocale: referenceLocale,
1503
+ targetLocale,
1504
+ adapter,
1505
+ maxKeys: budget
1506
+ });
1507
+ if (translatedKeys.length > 0) {
1508
+ (translatedByLocale[targetLocale] ??= []).push(
1509
+ ...translatedKeys.map((key) => ({ namespace: nsKey, key, path: target.path }))
1510
+ );
1511
+ planned.push(target.path);
1512
+ if (!options.dryRun) {
1513
+ await writeJson(target.path, tree);
1514
+ written.push(target.path);
1515
+ }
1516
+ }
1517
+ if (skippedKeys.length > 0) {
1518
+ (skippedByLocale[targetLocale] ??= []).push(
1519
+ ...skippedKeys.map((key) => ({ namespace: nsKey, key, path: target.path }))
1520
+ );
1521
+ }
960
1522
  }
961
1523
  }
962
- return { provider, referenceLocale, written, planned, translatedByLocale };
1524
+ return {
1525
+ provider,
1526
+ referenceLocale,
1527
+ written,
1528
+ planned,
1529
+ translatedByLocale,
1530
+ skippedByLocale,
1531
+ totalTranslatableKeys
1532
+ };
963
1533
  }
964
1534
 
965
1535
  // src/commands/translate.ts
966
1536
  function registerTranslateCommand(program) {
967
- 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("--dry-run", "Report what would be translated without writing files.", false).action(async (flags) => {
1537
+ 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(
1538
+ "--max-keys <n>",
1539
+ "Limit the total number of keys translated per run. Keys are selected deterministically: target locales in config order, then keys in reference order. Useful for controlling API spend."
1540
+ ).option("--dry-run", "Report what would be translated without writing files.", false).action(async (flags) => {
968
1541
  try {
969
1542
  const cwd = process.cwd();
1543
+ const maxKeys = flags.maxKeys ? Number.parseInt(flags.maxKeys, 10) : void 0;
1544
+ if (maxKeys !== void 0 && (Number.isNaN(maxKeys) || maxKeys <= 0)) {
1545
+ logger.error("--max-keys must be a positive integer.");
1546
+ process.exitCode = 1;
1547
+ return;
1548
+ }
970
1549
  const result = await runTranslate({
971
1550
  cwd,
972
1551
  dryRun: flags.dryRun,
973
1552
  provider: flags.provider,
974
- model: flags.model
1553
+ model: flags.model,
1554
+ maxKeys
975
1555
  });
976
1556
  const totals = Object.values(result.translatedByLocale).reduce(
977
1557
  (sum, keys) => sum + keys.length,
978
1558
  0
979
1559
  );
1560
+ if (flags.dryRun) {
1561
+ if (result.totalTranslatableKeys === 0) {
1562
+ logger.info(`Nothing to translate with ${chalk.cyan(result.provider)}.`);
1563
+ } else {
1564
+ const capped = maxKeys !== void 0 && maxKeys < result.totalTranslatableKeys;
1565
+ logger.info(
1566
+ `[dry-run] Would translate ${chalk.bold(String(totals))} key(s) across ${Object.keys(result.translatedByLocale).length} locale(s) using ${chalk.cyan(result.provider)}.`
1567
+ );
1568
+ if (capped) {
1569
+ logger.info(
1570
+ `[dry-run] ${result.totalTranslatableKeys - totals} key(s) skipped due to --max-keys ${String(maxKeys)}.`
1571
+ );
1572
+ }
1573
+ for (const [locale, keys] of Object.entries(result.translatedByLocale)) {
1574
+ if (keys.length === 0) continue;
1575
+ logger.info(`[dry-run] ${locale}: ${String(keys.length)} key(s)`);
1576
+ }
1577
+ }
1578
+ return;
1579
+ }
980
1580
  if (totals === 0) {
981
1581
  logger.info(`Nothing to translate with ${chalk.cyan(result.provider)}.`);
982
1582
  return;
983
1583
  }
984
- const verb = flags.dryRun ? "Would translate" : "Translated";
985
1584
  for (const [locale, keys] of Object.entries(result.translatedByLocale)) {
986
1585
  if (keys.length === 0) continue;
987
- logger.success(`${verb} ${chalk.bold(String(keys.length))} key(s) for ${locale}`);
1586
+ logger.success(`Translated ${chalk.bold(String(keys.length))} key(s) for ${locale}`);
988
1587
  }
989
- if (!flags.dryRun) {
990
- for (const path of result.written) {
991
- logger.info(`Wrote ${chalk.bold(relative(cwd, path))}`);
992
- }
1588
+ for (const path of result.written) {
1589
+ logger.info(`Wrote ${chalk.bold(relative(cwd, path))}`);
1590
+ }
1591
+ const totalSkipped = Object.values(result.skippedByLocale).reduce(
1592
+ (sum, keys) => sum + keys.length,
1593
+ 0
1594
+ );
1595
+ if (totalSkipped > 0) {
1596
+ logger.warn(
1597
+ `${chalk.yellow(String(totalSkipped))} key(s) skipped due to --max-keys. Run again to translate remaining keys.`
1598
+ );
993
1599
  }
994
1600
  } catch (error) {
995
- const message = error instanceof Error ? error.message : String(error);
996
- logger.error(message);
1601
+ if (error instanceof TranslationAdapterError) {
1602
+ const statusInfo = error.statusCode ? ` (${String(error.statusCode)})` : "";
1603
+ logger.error(`${error.provider} translation failed${statusInfo}: ${error.message}`);
1604
+ if (error.statusCode === 429) {
1605
+ logger.info("Rate limited. Retry in a moment, or use --max-keys to reduce requests.");
1606
+ }
1607
+ } else {
1608
+ logger.error(error instanceof Error ? error.message : String(error));
1609
+ }
997
1610
  process.exitCode = 1;
998
1611
  }
999
1612
  });
@@ -1003,33 +1616,59 @@ async function resolveWatchDir(cwd) {
1003
1616
  if (!loaded) {
1004
1617
  throw new Error("No LangSync config found. Run `langsync init` first.");
1005
1618
  }
1619
+ if (loaded.config.namespaces) {
1620
+ throw new Error(
1621
+ "Namespace support for this command is coming in a follow-up release. Remove the `namespaces` block from your config to use single-file mode."
1622
+ );
1623
+ }
1006
1624
  return resolve(cwd, loaded.config.input);
1007
1625
  }
1008
1626
  async function runWatchPass(options) {
1009
- const { referenceLocale, written } = await runSync({
1010
- cwd: options.cwd,
1011
- dryRun: options.dryRun
1012
- });
1013
1627
  const loaded = await loadConfig(options.cwd);
1014
1628
  if (!loaded) {
1015
1629
  throw new Error("No LangSync config found. Run `langsync init` first.");
1016
1630
  }
1631
+ if (loaded.config.namespaces) {
1632
+ throw new Error(
1633
+ "Namespace support for this command is coming in a follow-up release. Remove the `namespaces` block from your config to use single-file mode."
1634
+ );
1635
+ }
1636
+ const { referenceLocale, written, unchanged, diffsByPath } = await runSync({
1637
+ cwd: options.cwd,
1638
+ dryRun: options.dryRun
1639
+ });
1017
1640
  const files = await loadLocaleFiles({
1018
1641
  cwd: options.cwd,
1019
1642
  inputDir: loaded.config.input,
1020
- locales: loaded.config.locales
1643
+ locales: loaded.config.locales,
1644
+ namespaces: loaded.config.namespaces
1021
1645
  });
1022
1646
  const issues = validateLocales(files, referenceLocale);
1023
- return { referenceLocale, written, issues };
1647
+ return { referenceLocale, written, unchanged, diffsByPath, issues };
1024
1648
  }
1025
1649
 
1026
1650
  // src/commands/watch.ts
1027
- function reportPass(cwd, written, issueCount) {
1028
- if (written.length === 0) {
1029
- logger.info("No locale changes to sync.");
1651
+ function formatDiff(diff) {
1652
+ const parts = [];
1653
+ if (diff.added.length > 0) parts.push(chalk.green(`+${diff.added.length}`));
1654
+ if (diff.removed.length > 0) parts.push(chalk.red(`-${diff.removed.length}`));
1655
+ if (diff.changed.length > 0) parts.push(chalk.yellow(`~${diff.changed.length}`));
1656
+ return parts.join(", ");
1657
+ }
1658
+ function reportPass(cwd, written, unchanged, diffsByPath, issueCount) {
1659
+ if (written.length === 0 && unchanged.length === 0) {
1660
+ logger.info("No locale files found to sync.");
1661
+ } else if (written.length === 0) {
1662
+ logger.info("All locales are already in sync.");
1030
1663
  } else {
1031
1664
  for (const path of written) {
1032
- logger.success(`Synced ${chalk.bold(relative(cwd, path))}`);
1665
+ const rel = relative(cwd, path);
1666
+ const diff = diffsByPath[path];
1667
+ const summary = diff ? ` (${formatDiff(diff)})` : "";
1668
+ logger.success(`Synced ${chalk.bold(rel)}${summary}`);
1669
+ }
1670
+ for (const path of unchanged) {
1671
+ logger.info(`No changes: ${chalk.dim(relative(cwd, path))}`);
1033
1672
  }
1034
1673
  }
1035
1674
  if (issueCount > 0) {
@@ -1045,7 +1684,13 @@ function registerWatchCommand(program) {
1045
1684
  const watchDir = await resolveWatchDir(cwd);
1046
1685
  const debounceMs = Number.parseInt(flags.debounce, 10) || 200;
1047
1686
  const initial = await runWatchPass({ cwd, dryRun: flags.dryRun });
1048
- reportPass(cwd, initial.written, initial.issues.length);
1687
+ reportPass(
1688
+ cwd,
1689
+ initial.written,
1690
+ initial.unchanged,
1691
+ initial.diffsByPath,
1692
+ initial.issues.length
1693
+ );
1049
1694
  logger.info(`Watching ${chalk.cyan(relative(cwd, watchDir) || ".")} for changes...`);
1050
1695
  const watcher = chokidar.watch(join(watchDir, "*.json"), {
1051
1696
  ignoreInitial: true
@@ -1060,7 +1705,7 @@ function registerWatchCommand(program) {
1060
1705
  running = true;
1061
1706
  try {
1062
1707
  const pass = await runWatchPass({ cwd, dryRun: flags.dryRun });
1063
- reportPass(cwd, pass.written, pass.issues.length);
1708
+ reportPass(cwd, pass.written, pass.unchanged, pass.diffsByPath, pass.issues.length);
1064
1709
  } catch (error) {
1065
1710
  logger.error(error instanceof Error ? error.message : String(error));
1066
1711
  } finally {
@@ -1079,7 +1724,7 @@ function registerWatchCommand(program) {
1079
1724
  }
1080
1725
 
1081
1726
  // src/cli.ts
1082
- var VERSION = "0.4.1" ;
1727
+ var VERSION = "0.6.0" ;
1083
1728
  async function main() {
1084
1729
  assertNodeVersion(22);
1085
1730
  const program = new Command();
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { LangSyncConfig, defineConfig } from '@langsync/shared/config';
1
+ export { LangSyncConfig, LangSyncConfigInput, defineConfig } from '@langsync/shared/config';
package/dist/index.js CHANGED
@@ -3,12 +3,24 @@ import 'cosmiconfig';
3
3
  import 'cosmiconfig-typescript-loader';
4
4
  import { z } from 'zod';
5
5
 
6
+ var NamespaceConfigSchema = z.object({
7
+ structure: z.enum(["locale-dir", "locale-prefix"]).describe(
8
+ "Optional namespace layout. `locale-dir` resolves <input>/<locale>/<namespace>.json recursively. `locale-prefix` resolves <input>/<locale>.<namespace>.json."
9
+ )
10
+ });
6
11
  z.object({
7
12
  input: z.string().describe("Path to the source i18n directory."),
8
- output: z.string().describe("Path to the output/translations directory."),
9
- locales: z.array(z.string()).min(1).describe("List of supported locales."),
10
- defaultLocale: z.string().optional(),
13
+ output: z.string().default("./translations").describe(
14
+ 'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
15
+ ),
16
+ locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
17
+ defaultLocale: z.string().optional().describe(
18
+ "Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
19
+ ),
11
20
  framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
21
+ namespaces: NamespaceConfigSchema.optional().describe(
22
+ "Optional namespace settings. Omit this block to keep the default single-file layout at <input>/<locale>.json."
23
+ ),
12
24
  excel: z.object({
13
25
  file: z.string().default("translations.xlsx"),
14
26
  sheetName: z.string().default("Translations")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mariokreitz/langsync",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Modern localization workflow tooling for TypeScript applications.",
5
5
  "keywords": [
6
6
  "i18n",
@@ -65,10 +65,10 @@
65
65
  "devDependencies": {
66
66
  "@types/prompts": "^2.4.9",
67
67
  "memfs": "^4.57.3",
68
- "@langsync/shared": "0.1.0",
69
- "@langsync/ai-engine": "0.1.0",
70
- "@langsync/core": "0.1.0",
71
- "@langsync/excel-engine": "0.1.0"
68
+ "@langsync/shared": "0.2.1",
69
+ "@langsync/core": "0.1.2",
70
+ "@langsync/ai-engine": "0.2.1",
71
+ "@langsync/excel-engine": "0.2.0"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "tsup",