@mariokreitz/langsync 0.5.0 → 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,6 +305,11 @@ 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
315
  output: z.string().default("./translations").describe(
@@ -255,6 +320,9 @@ var LangSyncConfigSchema = z.object({
255
320
  "Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
256
321
  ),
257
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
+ ),
258
326
  excel: z.object({
259
327
  file: z.string().default("translations.xlsx"),
260
328
  sheetName: z.string().default("Translations")
@@ -301,27 +369,206 @@ async function writeJson(filePath, data, { indent = 2 } = {}) {
301
369
  await mkdir(dirname(absolute), { recursive: true });
302
370
  await writeFile(absolute, JSON.stringify(data, null, indent) + "\n", "utf-8");
303
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
+ }
304
494
  async function loadLocaleFiles(options) {
305
495
  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 = {};
310
- try {
311
- 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;
312
507
  try {
313
- translations = JSON.parse(content);
508
+ translations = await readTranslationFile(path, `${locale}.json`);
509
+ exists = true;
314
510
  } catch (error) {
315
- const message = error instanceof Error ? error.message : String(error);
316
- throw new Error(`Failed to parse ${locale}.json: ${message}`, { cause: error });
511
+ const errno = error.code;
512
+ if (errno !== "ENOENT") throw error;
317
513
  }
318
- } catch (error) {
319
- const errno = error.code;
320
- if (errno !== "ENOENT") throw error;
514
+ out.push({ locale, namespace: null, path, translations, exists });
321
515
  }
322
- out.push({ locale, path, translations });
516
+ return out;
323
517
  }
324
- 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
+ };
325
572
  }
326
573
 
327
574
  // ../core/dist/index.js
@@ -409,6 +656,13 @@ function hasChanges(diff) {
409
656
  return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
410
657
  }
411
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
+ }
665
+
412
666
  // src/commands/sync/run.ts
413
667
  async function runSync(options) {
414
668
  const loaded = await loadConfig(options.cwd);
@@ -420,29 +674,43 @@ async function runSync(options) {
420
674
  const files = await loadLocaleFiles({
421
675
  cwd: options.cwd,
422
676
  inputDir: config.input,
423
- locales: config.locales
677
+ locales: config.locales,
678
+ namespaces: config.namespaces
424
679
  });
425
- const reference = files.find((f) => f.locale === referenceLocale);
426
- 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) {
427
688
  throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
428
689
  }
429
- const targets = files.filter((f) => f.locale !== referenceLocale);
430
690
  const planned = [];
431
691
  const written = [];
432
692
  const unchanged = [];
433
693
  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);
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
+ }
446
714
  }
447
715
  }
448
716
  return { referenceLocale, written, planned, unchanged, diffsByPath };
@@ -485,9 +753,30 @@ async function runValidate(options) {
485
753
  const files = await loadLocaleFiles({
486
754
  cwd: options.cwd,
487
755
  inputDir: config.input,
488
- 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);
489
779
  });
490
- const issues = validateLocales(files, referenceLocale);
491
780
  const hasErrors = issues.some((i) => i.type === "missing" || i.type === "extra");
492
781
  return {
493
782
  referenceLocale,
@@ -497,6 +786,9 @@ async function runValidate(options) {
497
786
  }
498
787
 
499
788
  // src/commands/validate.ts
789
+ function formatIssueKey(issue) {
790
+ return issue.namespace === null ? issue.key : `${issue.namespace}:${issue.key}`;
791
+ }
500
792
  function registerValidateCommand(program) {
501
793
  program.command("validate").description("Validate locale consistency, structure and missing keys.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
502
794
  try {
@@ -511,7 +803,7 @@ function registerValidateCommand(program) {
511
803
  for (const issue of issues) {
512
804
  byType[issue.type]++;
513
805
  const colored = issue.type === "empty" ? chalk.yellow(issue.type) : chalk.red(issue.type);
514
- logger.info(`${colored} ${chalk.cyan(issue.locale)} ${issue.key}`);
806
+ logger.info(`${colored} ${chalk.cyan(issue.locale)} ${formatIssueKey(issue)}`);
515
807
  }
516
808
  console.log();
517
809
  logger.info(
@@ -534,16 +826,25 @@ async function runFindMissing(options) {
534
826
  const missingByLocale = {};
535
827
  for (const issue of issues) {
536
828
  if (issue.type !== "missing") continue;
537
- (missingByLocale[issue.locale] ??= []).push(issue.key);
829
+ (missingByLocale[issue.locale] ??= []).push({
830
+ namespace: issue.namespace,
831
+ key: issue.key,
832
+ path: issue.path
833
+ });
538
834
  }
539
- for (const locale of Object.keys(missingByLocale)) {
540
- 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
+ );
541
839
  }
542
840
  const exitCode = Object.keys(missingByLocale).length === 0 ? 0 : 1;
543
841
  return { referenceLocale, missingByLocale, exitCode };
544
842
  }
545
843
 
546
844
  // src/commands/find-missing.ts
845
+ function formatMissingEntry(entry) {
846
+ return entry.namespace === null ? entry.key : `${entry.namespace}:${entry.key}`;
847
+ }
547
848
  function registerFindMissingCommand(program) {
548
849
  program.command("find-missing").description("Find missing translation keys across locales.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
549
850
  try {
@@ -557,7 +858,7 @@ function registerFindMissingCommand(program) {
557
858
  } else {
558
859
  for (const [locale, keys] of Object.entries(missingByLocale)) {
559
860
  logger.warn(`${chalk.cyan(locale)} is missing ${keys.length} key(s):`);
560
- for (const key of keys) console.log(` - ${key}`);
861
+ for (const entry of keys) console.log(` - ${formatMissingEntry(entry)}`);
561
862
  }
562
863
  }
563
864
  process.exitCode = exitCode;
@@ -568,49 +869,159 @@ function registerFindMissingCommand(program) {
568
869
  }
569
870
  });
570
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
+ }
571
932
  async function exportToExcel(options) {
572
933
  const workbook = new ExcelJS.Workbook();
573
934
  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);
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.");
580
939
  }
581
- for (const key of [...allKeys].sort()) {
582
- sheet.addRow([key, ...flatPerLocale.map((flat) => flat[key] ?? "")]);
940
+ if (allSingleFile) {
941
+ exportSingleFile(sheet, options.files);
942
+ } else {
943
+ exportNamespaced(sheet, options.files);
583
944
  }
584
- sheet.getRow(1).font = { bold: true };
585
945
  await workbook.xlsx.writeFile(options.file);
586
946
  }
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");
947
+ function importSingleFile(sheet, header) {
948
+ const locales = header.slice(2).filter((value) => typeof value === "string");
594
949
  const flatPerLocale = Object.fromEntries(
595
- locales.map((l) => [l, {}])
950
+ locales.map((locale) => [locale, {}])
596
951
  );
597
952
  sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
598
953
  if (rowNumber === 1) return;
599
954
  const values = row.values;
600
- const key = values[1];
955
+ const key = cellText(values[1]);
601
956
  if (!key) return;
602
957
  locales.forEach((locale, idx) => {
603
- const value = values[idx + 2];
604
- flatPerLocale[locale][key] = typeof value === "string" ? value : "";
958
+ flatPerLocale[locale][key] = cellText(values[idx + 2]);
605
959
  });
606
960
  });
607
961
  return {
962
+ format: "single-file",
608
963
  locales: locales.map((locale) => ({
609
964
  locale,
965
+ namespace: null,
610
966
  translations: unflatten(flatPerLocale[locale])
611
967
  }))
612
968
  };
613
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
+ }
614
1025
 
615
1026
  // src/commands/export/run.ts
616
1027
  var DEFAULT_FILE = "translations.xlsx";
@@ -621,19 +1032,29 @@ async function runExportExcel(options) {
621
1032
  throw new Error("No LangSync config found. Run `langsync init` first.");
622
1033
  }
623
1034
  const { config } = loaded;
1035
+ const referenceLocale = config.defaultLocale ?? config.locales[0];
624
1036
  const file = resolve(options.cwd, options.file ?? config.excel?.file ?? DEFAULT_FILE);
625
1037
  const sheetName = options.sheetName ?? config.excel?.sheetName ?? DEFAULT_SHEET;
626
1038
  const files = await loadLocaleFiles({
627
1039
  cwd: options.cwd,
628
1040
  inputDir: config.input,
629
- locales: config.locales
1041
+ locales: config.locales,
1042
+ namespaces: config.namespaces
630
1043
  });
1044
+ const index = indexLocaleFiles(files);
1045
+ if (config.namespaces !== void 0 && index.namespaces.length === 0) {
1046
+ throw noNamespacesError(referenceLocale, config.input);
1047
+ }
631
1048
  await exportToExcel({
632
1049
  file,
633
1050
  sheetName,
634
- 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
+ }))
635
1056
  });
636
- return { file, sheetName, locales: files.map((f) => f.locale) };
1057
+ return { file, sheetName, locales: config.locales, namespaces: index.namespaces };
637
1058
  }
638
1059
 
639
1060
  // src/commands/export.ts
@@ -642,13 +1063,14 @@ function registerExportCommand(program) {
642
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) => {
643
1064
  try {
644
1065
  const cwd = process.cwd();
645
- const { file, locales } = await runExportExcel({
1066
+ const { file, locales, namespaces } = await runExportExcel({
646
1067
  cwd,
647
1068
  file: flags.file,
648
1069
  sheetName: flags.sheet
649
1070
  });
1071
+ const namespaceSummary = namespaces.length > 0 ? ` across ${chalk.cyan(String(namespaces.length))} namespace(s)` : "";
650
1072
  logger.success(
651
- `Exported ${chalk.cyan(String(locales.length))} locale(s) to ${chalk.bold(
1073
+ `Exported ${chalk.cyan(String(locales.length))} locale(s)${namespaceSummary} to ${chalk.bold(
652
1074
  relative(cwd, file)
653
1075
  )}`
654
1076
  );
@@ -669,9 +1091,18 @@ async function runImportExcel(options) {
669
1091
  const { config } = loaded;
670
1092
  const file = resolve(options.cwd, options.file ?? config.excel?.file ?? DEFAULT_FILE2);
671
1093
  const sheetName = options.sheetName ?? config.excel?.sheetName ?? DEFAULT_SHEET2;
672
- const inputAbs = resolve(options.cwd, config.input);
673
1094
  const configuredLocales = new Set(config.locales);
674
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
+ }
675
1106
  const planned = [];
676
1107
  const written = [];
677
1108
  const skipped = [];
@@ -680,7 +1111,13 @@ async function runImportExcel(options) {
680
1111
  skipped.push(entry.locale);
681
1112
  continue;
682
1113
  }
683
- 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
+ });
684
1121
  planned.push(path);
685
1122
  if (!options.dryRun) {
686
1123
  await writeJson(path, entry.translations);
@@ -973,6 +1410,12 @@ async function fillEmptyTranslations(options) {
973
1410
  }
974
1411
 
975
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
+ }
976
1419
  async function runTranslate(options) {
977
1420
  const loaded = await loadConfig(options.cwd);
978
1421
  if (!loaded) {
@@ -981,62 +1424,101 @@ async function runTranslate(options) {
981
1424
  const { config } = loaded;
982
1425
  const referenceLocale = config.defaultLocale ?? config.locales[0];
983
1426
  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
1427
  const files = await loadLocaleFiles({
990
1428
  cwd: options.cwd,
991
1429
  inputDir: config.input,
992
- locales: config.locales
1430
+ locales: config.locales,
1431
+ namespaces: config.namespaces
993
1432
  });
994
- const reference = files.find((f) => f.locale === referenceLocale);
995
- 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) {
996
1441
  throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
997
1442
  }
998
- const targets = files.filter((f) => f.locale !== referenceLocale);
999
- const refFlat = flatten(reference.translations);
1443
+ const adapter = createAdapter({
1444
+ provider,
1445
+ apiKey: config.ai?.apiKey,
1446
+ model: options.model ?? config.ai?.model
1447
+ });
1000
1448
  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 });
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
+ }
1007
1470
  }
1008
1471
  }
1009
1472
  const totalTranslatableKeys = allCandidates.length;
1010
1473
  const limited = options.maxKeys ? allCandidates.slice(0, options.maxKeys) : allCandidates;
1011
- const perLocaleMaxKeys = {};
1474
+ const limitedByPath = /* @__PURE__ */ new Map();
1475
+ const skippedCandidates = options.maxKeys ? allCandidates.slice(options.maxKeys) : [];
1012
1476
  for (const candidate of limited) {
1013
- perLocaleMaxKeys[candidate.file.locale] = (perLocaleMaxKeys[candidate.file.locale] ?? 0) + 1;
1477
+ const entries = limitedByPath.get(candidate.path) ?? [];
1478
+ entries.push(candidate);
1479
+ limitedByPath.set(candidate.path, entries);
1014
1480
  }
1015
1481
  const written = [];
1016
1482
  const planned = [];
1017
1483
  const translatedByLocale = {};
1018
1484
  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);
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
+ }
1040
1522
  }
1041
1523
  }
1042
1524
  return {
@@ -1134,21 +1616,32 @@ async function resolveWatchDir(cwd) {
1134
1616
  if (!loaded) {
1135
1617
  throw new Error("No LangSync config found. Run `langsync init` first.");
1136
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
+ }
1137
1624
  return resolve(cwd, loaded.config.input);
1138
1625
  }
1139
1626
  async function runWatchPass(options) {
1140
- const { referenceLocale, written, unchanged, diffsByPath } = await runSync({
1141
- cwd: options.cwd,
1142
- dryRun: options.dryRun
1143
- });
1144
1627
  const loaded = await loadConfig(options.cwd);
1145
1628
  if (!loaded) {
1146
1629
  throw new Error("No LangSync config found. Run `langsync init` first.");
1147
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
+ });
1148
1640
  const files = await loadLocaleFiles({
1149
1641
  cwd: options.cwd,
1150
1642
  inputDir: loaded.config.input,
1151
- locales: loaded.config.locales
1643
+ locales: loaded.config.locales,
1644
+ namespaces: loaded.config.namespaces
1152
1645
  });
1153
1646
  const issues = validateLocales(files, referenceLocale);
1154
1647
  return { referenceLocale, written, unchanged, diffsByPath, issues };
@@ -1231,7 +1724,7 @@ function registerWatchCommand(program) {
1231
1724
  }
1232
1725
 
1233
1726
  // src/cli.ts
1234
- var VERSION = "0.5.0" ;
1727
+ var VERSION = "0.6.0" ;
1235
1728
  async function main() {
1236
1729
  assertNodeVersion(22);
1237
1730
  const program = new Command();
package/dist/index.js CHANGED
@@ -3,6 +3,11 @@ 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
13
  output: z.string().default("./translations").describe(
@@ -13,6 +18,9 @@ z.object({
13
18
  "Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
14
19
  ),
15
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
+ ),
16
24
  excel: z.object({
17
25
  file: z.string().default("translations.xlsx"),
18
26
  sheetName: z.string().default("Translations")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mariokreitz/langsync",
3
- "version": "0.5.0",
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.2.0",
69
- "@langsync/excel-engine": "0.1.1",
70
- "@langsync/core": "0.1.1",
71
- "@langsync/ai-engine": "0.2.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",