@mariokreitz/langsync 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,10 +5,11 @@ import ora from 'ora';
5
5
  import { relative, join, resolve, dirname, sep, basename } from 'path';
6
6
  import { readFile, mkdir, writeFile, access, readdir } from 'fs/promises';
7
7
  import prompts from 'prompts';
8
+ import { runSync, runValidate, runFindMissing, runTranslate } from '@mariokreitz/langsync-sdk';
9
+ import ExcelJS from 'exceljs';
8
10
  import { cosmiconfig } from 'cosmiconfig';
9
11
  import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
10
12
  import { z } from 'zod';
11
- import ExcelJS from 'exceljs';
12
13
  import chokidar from 'chokidar';
13
14
 
14
15
  var PREFIX = chalk.bold.cyan("langsync");
@@ -25,7 +26,7 @@ var logger = {
25
26
  };
26
27
  function printBanner(version) {
27
28
  const title = chalk.bold.cyan("LangSync");
28
- const tagline = chalk.gray("Modern localization workflow tooling.");
29
+ const tagline = chalk.gray("CLI tooling for localization workflows.");
29
30
  const ver = chalk.dim(`v${version}`);
30
31
  console.log(`
31
32
  ${title} ${ver}
@@ -305,579 +306,157 @@ function registerInitCommand(program) {
305
306
  }
306
307
  });
307
308
  }
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
- });
313
- var LangSyncConfigSchema = z.object({
314
- input: z.string().describe("Path to the source i18n directory."),
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
- ),
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
- ),
326
- excel: z.object({
327
- file: z.string().default("translations.xlsx"),
328
- sheetName: z.string().default("Translations")
329
- }).optional(),
330
- ai: z.object({
331
- provider: z.enum(["openai", "deepl", "anthropic", "gemini"]).default("openai").describe("AI translation provider."),
332
- apiKey: z.string().optional().describe("API key. Falls back to the provider-specific env var."),
333
- model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
334
- }).optional().describe("AI translation settings.")
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}`;
309
+ function registerSyncCommand(program) {
310
+ program.command("sync").description("Synchronize translation keys across all configured locales.").option("--dry-run", "Report planned changes without writing files.", false).action(async (flags) => {
311
+ try {
312
+ const cwd = process.cwd();
313
+ const { written, planned, referenceLocale } = await runSync({
314
+ cwd,
315
+ dryRun: flags.dryRun
316
+ });
317
+ const targets = flags.dryRun ? planned : written;
318
+ if (targets.length === 0) {
319
+ logger.info(`Nothing to sync against ${chalk.cyan(referenceLocale)}.`);
320
+ } else {
321
+ const verb = flags.dryRun ? "Would update" : "Updated";
322
+ for (const path of targets) {
323
+ logger.success(`${verb} ${chalk.bold(relative(cwd, path))}`);
324
+ }
325
+ }
326
+ } catch (error) {
327
+ const message = error instanceof Error ? error.message : String(error);
328
+ logger.error(message);
329
+ process.exitCode = 1;
330
+ }
340
331
  });
341
- return `Invalid LangSync configuration:
342
- ${issues.join("\n")}`;
343
332
  }
344
- async function loadConfig(cwd = process.cwd()) {
345
- const explorer = cosmiconfig("langsync", {
346
- searchPlaces: [
347
- "langsync.config.ts",
348
- "langsync.config.js",
349
- "langsync.config.mjs",
350
- "langsync.config.json",
351
- ".langsyncrc",
352
- ".langsyncrc.json",
353
- "package.json"
354
- ],
355
- loaders: {
356
- ".ts": TypeScriptLoader()
333
+ function formatIssueKey(issue) {
334
+ return issue.namespace === null ? issue.key : `${issue.namespace}:${issue.key}`;
335
+ }
336
+ function registerValidateCommand(program) {
337
+ program.command("validate").description("Validate locale consistency, structure and missing keys.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
338
+ try {
339
+ const { issues, exitCode, referenceLocale } = await runValidate({ cwd: process.cwd() });
340
+ if (flags.reporter === "json") {
341
+ console.log(JSON.stringify({ referenceLocale, issues }, null, 2));
342
+ } else {
343
+ if (issues.length === 0) {
344
+ logger.success(`All locales are consistent with ${chalk.cyan(referenceLocale)}.`);
345
+ } else {
346
+ const byType = { missing: 0, extra: 0, empty: 0 };
347
+ for (const issue of issues) {
348
+ byType[issue.type]++;
349
+ const colored = issue.type === "empty" ? chalk.yellow(issue.type) : chalk.red(issue.type);
350
+ logger.info(`${colored} ${chalk.cyan(issue.locale)} ${formatIssueKey(issue)}`);
351
+ }
352
+ console.log();
353
+ logger.info(
354
+ `Summary: ${byType.missing} missing, ${byType.extra} extra, ${byType.empty} empty`
355
+ );
356
+ }
357
+ }
358
+ process.exitCode = exitCode;
359
+ } catch (error) {
360
+ const message = error instanceof Error ? error.message : String(error);
361
+ logger.error(message);
362
+ process.exitCode = 1;
357
363
  }
358
364
  });
359
- const result = await explorer.search(cwd);
360
- if (!result) return null;
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 };
366
365
  }
367
- async function writeJson(filePath, data, { indent = 2 } = {}) {
368
- const absolute = resolve(filePath);
369
- await mkdir(dirname(absolute), { recursive: true });
370
- await writeFile(absolute, JSON.stringify(data, null, indent) + "\n", "utf-8");
366
+ function formatMissingEntry(entry) {
367
+ return entry.namespace === null ? entry.key : `${entry.namespace}:${entry.key}`;
371
368
  }
372
- function isWithinDirectory(path, directory) {
373
- return path === directory || path.startsWith(directory + sep);
369
+ function registerFindMissingCommand(program) {
370
+ program.command("find-missing").description("Find missing translation keys across locales.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
371
+ try {
372
+ const { referenceLocale, missingByLocale, exitCode } = await runFindMissing({
373
+ cwd: process.cwd()
374
+ });
375
+ if (flags.reporter === "json") {
376
+ console.log(JSON.stringify({ referenceLocale, missingByLocale }, null, 2));
377
+ } else if (Object.keys(missingByLocale).length === 0) {
378
+ logger.success(`No missing keys relative to ${chalk.cyan(referenceLocale)}.`);
379
+ } else {
380
+ for (const [locale, keys] of Object.entries(missingByLocale)) {
381
+ logger.warn(`${chalk.cyan(locale)} is missing ${keys.length} key(s):`);
382
+ for (const entry of keys) console.log(` - ${formatMissingEntry(entry)}`);
383
+ }
384
+ }
385
+ process.exitCode = exitCode;
386
+ } catch (error) {
387
+ const message = error instanceof Error ? error.message : String(error);
388
+ logger.error(message);
389
+ process.exitCode = 1;
390
+ }
391
+ });
374
392
  }
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
- );
393
+
394
+ // ../core/dist/index.js
395
+ function flatten(tree, prefix = "") {
396
+ const out = {};
397
+ for (const [key, value] of Object.entries(tree)) {
398
+ const fullKey = prefix ? `${prefix}.${key}` : key;
399
+ if (typeof value === "string") {
400
+ out[fullKey] = value;
401
+ } else {
402
+ Object.assign(out, flatten(value, fullKey));
403
+ }
389
404
  }
390
- if (namespace.split("/").some((segment) => segment === "." || segment === "..")) {
391
- throw new Error(`Invalid namespace "${namespace}": path traversal segments are not allowed.`);
405
+ return out;
406
+ }
407
+ function unflatten(flat) {
408
+ const result = {};
409
+ for (const [key, value] of Object.entries(flat)) {
410
+ const parts = key.split(".");
411
+ let cursor = result;
412
+ for (let i = 0; i < parts.length - 1; i++) {
413
+ const part = parts[i];
414
+ const next = cursor[part];
415
+ if (typeof next !== "object" || next === null) {
416
+ const fresh = {};
417
+ cursor[part] = fresh;
418
+ cursor = fresh;
419
+ } else {
420
+ cursor = next;
421
+ }
422
+ }
423
+ cursor[parts[parts.length - 1]] = value;
392
424
  }
425
+ return result;
393
426
  }
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
- );
427
+ function validateLocales(files, referenceLocale) {
428
+ const issues = [];
429
+ const reference = files.find((f) => f.locale === referenceLocale);
430
+ if (!reference) return issues;
431
+ const referenceKeys = new Set(Object.keys(flatten(reference.translations)));
432
+ for (const file of files) {
433
+ const flat = flatten(file.translations);
434
+ const fileKeys = new Set(Object.keys(flat));
435
+ if (file.locale !== referenceLocale) {
436
+ for (const key of referenceKeys) {
437
+ if (!fileKeys.has(key)) issues.push({ type: "missing", locale: file.locale, key });
438
+ }
439
+ for (const key of fileKeys) {
440
+ if (!referenceKeys.has(key)) issues.push({ type: "extra", locale: file.locale, key });
441
+ }
401
442
  }
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
- );
443
+ for (const [key, value] of Object.entries(flat)) {
444
+ if (!value || value.trim() === "") issues.push({ type: "empty", locale: file.locale, key });
407
445
  }
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
446
  }
420
- return path;
447
+ return issues;
421
448
  }
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 });
449
+
450
+ // ../excel-engine/dist/index.js
451
+ function distinctLocales(files) {
452
+ const seen = /* @__PURE__ */ new Set();
453
+ const locales = [];
454
+ for (const file of files) {
455
+ if (seen.has(file.locale)) continue;
456
+ seen.add(file.locale);
457
+ locales.push(file.locale);
429
458
  }
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
- }
494
- async function loadLocaleFiles(options) {
495
- const inputAbs = resolve(options.cwd, options.inputDir);
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;
507
- try {
508
- translations = await readTranslationFile(path, `${locale}.json`);
509
- exists = true;
510
- } catch (error) {
511
- const errno = error.code;
512
- if (errno !== "ENOENT") throw error;
513
- }
514
- out.push({ locale, namespace: null, path, translations, exists });
515
- }
516
- return out;
517
- }
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
- };
572
- }
573
-
574
- // ../core/dist/index.js
575
- function flatten(tree, prefix = "") {
576
- const out = {};
577
- for (const [key, value] of Object.entries(tree)) {
578
- const fullKey = prefix ? `${prefix}.${key}` : key;
579
- if (typeof value === "string") {
580
- out[fullKey] = value;
581
- } else {
582
- Object.assign(out, flatten(value, fullKey));
583
- }
584
- }
585
- return out;
586
- }
587
- function unflatten(flat) {
588
- const result = {};
589
- for (const [key, value] of Object.entries(flat)) {
590
- const parts = key.split(".");
591
- let cursor = result;
592
- for (let i = 0; i < parts.length - 1; i++) {
593
- const part = parts[i];
594
- const next = cursor[part];
595
- if (typeof next !== "object" || next === null) {
596
- const fresh = {};
597
- cursor[part] = fresh;
598
- cursor = fresh;
599
- } else {
600
- cursor = next;
601
- }
602
- }
603
- cursor[parts[parts.length - 1]] = value;
604
- }
605
- return result;
606
- }
607
- function validateLocales(files, referenceLocale) {
608
- const issues = [];
609
- const reference = files.find((f) => f.locale === referenceLocale);
610
- if (!reference) return issues;
611
- const referenceKeys = new Set(Object.keys(flatten(reference.translations)));
612
- for (const file of files) {
613
- const flat = flatten(file.translations);
614
- const fileKeys = new Set(Object.keys(flat));
615
- if (file.locale !== referenceLocale) {
616
- for (const key of referenceKeys) {
617
- if (!fileKeys.has(key)) issues.push({ type: "missing", locale: file.locale, key });
618
- }
619
- for (const key of fileKeys) {
620
- if (!referenceKeys.has(key)) issues.push({ type: "extra", locale: file.locale, key });
621
- }
622
- }
623
- for (const [key, value] of Object.entries(flat)) {
624
- if (!value || value.trim() === "") issues.push({ type: "empty", locale: file.locale, key });
625
- }
626
- }
627
- return issues;
628
- }
629
- function syncTrees(source, target) {
630
- const sourceFlat = flatten(source);
631
- const targetFlat = flatten(target);
632
- const merged = {};
633
- for (const key of Object.keys(sourceFlat)) {
634
- merged[key] = targetFlat[key] ?? "";
635
- }
636
- return unflatten(merged);
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
- }
665
-
666
- // src/commands/sync/run.ts
667
- async function runSync(options) {
668
- const loaded = await loadConfig(options.cwd);
669
- if (!loaded) {
670
- throw new Error("No LangSync config found. Run `langsync init` first.");
671
- }
672
- const { config } = loaded;
673
- const referenceLocale = config.defaultLocale ?? config.locales[0];
674
- const files = await loadLocaleFiles({
675
- cwd: options.cwd,
676
- inputDir: config.input,
677
- locales: config.locales,
678
- namespaces: config.namespaces
679
- });
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) {
688
- throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
689
- }
690
- const planned = [];
691
- const written = [];
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
- }
714
- }
715
- }
716
- return { referenceLocale, written, planned, unchanged, diffsByPath };
717
- }
718
-
719
- // src/commands/sync.ts
720
- function registerSyncCommand(program) {
721
- program.command("sync").description("Synchronize translation keys across all configured locales.").option("--dry-run", "Report planned changes without writing files.", false).action(async (flags) => {
722
- try {
723
- const cwd = process.cwd();
724
- const { written, planned, referenceLocale } = await runSync({
725
- cwd,
726
- dryRun: flags.dryRun
727
- });
728
- const targets = flags.dryRun ? planned : written;
729
- if (targets.length === 0) {
730
- logger.info(`Nothing to sync against ${chalk.cyan(referenceLocale)}.`);
731
- } else {
732
- const verb = flags.dryRun ? "Would update" : "Updated";
733
- for (const path of targets) {
734
- logger.success(`${verb} ${chalk.bold(relative(cwd, path))}`);
735
- }
736
- }
737
- } catch (error) {
738
- const message = error instanceof Error ? error.message : String(error);
739
- logger.error(message);
740
- process.exitCode = 1;
741
- }
742
- });
743
- }
744
-
745
- // src/commands/validate/run.ts
746
- async function runValidate(options) {
747
- const loaded = await loadConfig(options.cwd);
748
- if (!loaded) {
749
- throw new Error("No LangSync config found. Run `langsync init` first.");
750
- }
751
- const { config } = loaded;
752
- const referenceLocale = config.defaultLocale ?? config.locales[0];
753
- const files = await loadLocaleFiles({
754
- cwd: options.cwd,
755
- inputDir: config.input,
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);
779
- });
780
- const hasErrors = issues.some((i) => i.type === "missing" || i.type === "extra");
781
- return {
782
- referenceLocale,
783
- issues,
784
- exitCode: hasErrors ? 1 : 0
785
- };
786
- }
787
-
788
- // src/commands/validate.ts
789
- function formatIssueKey(issue) {
790
- return issue.namespace === null ? issue.key : `${issue.namespace}:${issue.key}`;
791
- }
792
- function registerValidateCommand(program) {
793
- program.command("validate").description("Validate locale consistency, structure and missing keys.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
794
- try {
795
- const { issues, exitCode, referenceLocale } = await runValidate({ cwd: process.cwd() });
796
- if (flags.reporter === "json") {
797
- console.log(JSON.stringify({ referenceLocale, issues }, null, 2));
798
- } else {
799
- if (issues.length === 0) {
800
- logger.success(`All locales are consistent with ${chalk.cyan(referenceLocale)}.`);
801
- } else {
802
- const byType = { missing: 0, extra: 0, empty: 0 };
803
- for (const issue of issues) {
804
- byType[issue.type]++;
805
- const colored = issue.type === "empty" ? chalk.yellow(issue.type) : chalk.red(issue.type);
806
- logger.info(`${colored} ${chalk.cyan(issue.locale)} ${formatIssueKey(issue)}`);
807
- }
808
- console.log();
809
- logger.info(
810
- `Summary: ${byType.missing} missing, ${byType.extra} extra, ${byType.empty} empty`
811
- );
812
- }
813
- }
814
- process.exitCode = exitCode;
815
- } catch (error) {
816
- const message = error instanceof Error ? error.message : String(error);
817
- logger.error(message);
818
- process.exitCode = 1;
819
- }
820
- });
821
- }
822
-
823
- // src/commands/find-missing/run.ts
824
- async function runFindMissing(options) {
825
- const { issues, referenceLocale } = await runValidate({ cwd: options.cwd });
826
- const missingByLocale = {};
827
- for (const issue of issues) {
828
- if (issue.type !== "missing") continue;
829
- (missingByLocale[issue.locale] ??= []).push({
830
- namespace: issue.namespace,
831
- key: issue.key,
832
- path: issue.path
833
- });
834
- }
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
- );
839
- }
840
- const exitCode = Object.keys(missingByLocale).length === 0 ? 0 : 1;
841
- return { referenceLocale, missingByLocale, exitCode };
842
- }
843
-
844
- // src/commands/find-missing.ts
845
- function formatMissingEntry(entry) {
846
- return entry.namespace === null ? entry.key : `${entry.namespace}:${entry.key}`;
847
- }
848
- function registerFindMissingCommand(program) {
849
- program.command("find-missing").description("Find missing translation keys across locales.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
850
- try {
851
- const { referenceLocale, missingByLocale, exitCode } = await runFindMissing({
852
- cwd: process.cwd()
853
- });
854
- if (flags.reporter === "json") {
855
- console.log(JSON.stringify({ referenceLocale, missingByLocale }, null, 2));
856
- } else if (Object.keys(missingByLocale).length === 0) {
857
- logger.success(`No missing keys relative to ${chalk.cyan(referenceLocale)}.`);
858
- } else {
859
- for (const [locale, keys] of Object.entries(missingByLocale)) {
860
- logger.warn(`${chalk.cyan(locale)} is missing ${keys.length} key(s):`);
861
- for (const entry of keys) console.log(` - ${formatMissingEntry(entry)}`);
862
- }
863
- }
864
- process.exitCode = exitCode;
865
- } catch (error) {
866
- const message = error instanceof Error ? error.message : String(error);
867
- logger.error(message);
868
- process.exitCode = 1;
869
- }
870
- });
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;
459
+ return locales;
881
460
  }
882
461
  function cellText(value) {
883
462
  return typeof value === "string" ? value : "";
@@ -967,59 +546,331 @@ function importSingleFile(sheet, header) {
967
546
  }))
968
547
  };
969
548
  }
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
- );
549
+ function importNamespaced(sheet, header) {
550
+ const locales = header.slice(3).filter((value) => typeof value === "string");
551
+ const namespaces = /* @__PURE__ */ new Set();
552
+ const flatByLocaleNamespace = /* @__PURE__ */ new Map();
553
+ const seenRows = /* @__PURE__ */ new Set();
554
+ sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
555
+ if (rowNumber === 1) return;
556
+ const values = row.values;
557
+ const namespace = cellText(values[1]).trim();
558
+ const key = cellText(values[2]);
559
+ if (!key) return;
560
+ if (!namespace) {
561
+ throw new Error(
562
+ `Row ${rowNumber}: namespace cell must not be empty in a namespaced workbook.`
563
+ );
564
+ }
565
+ const rowKey = `${namespace}\0${key}`;
566
+ if (seenRows.has(rowKey)) {
567
+ throw new Error(`Duplicate (namespace, key) row: "${namespace}" / "${key}".`);
568
+ }
569
+ seenRows.add(rowKey);
570
+ namespaces.add(namespace);
571
+ locales.forEach((locale, idx) => {
572
+ const mapKey = namespacedKey(namespace, locale);
573
+ let flat = flatByLocaleNamespace.get(mapKey);
574
+ if (!flat) {
575
+ flat = {};
576
+ flatByLocaleNamespace.set(mapKey, flat);
577
+ }
578
+ flat[key] = cellText(values[idx + 3]);
579
+ });
580
+ });
581
+ return {
582
+ format: "namespaced",
583
+ locales: locales.flatMap(
584
+ (locale) => [...namespaces].map((namespace) => ({
585
+ locale,
586
+ namespace,
587
+ translations: unflatten(flatByLocaleNamespace.get(namespacedKey(namespace, locale)) ?? {})
588
+ }))
589
+ )
590
+ };
591
+ }
592
+ async function importFromExcel(file, sheetName) {
593
+ const workbook = new ExcelJS.Workbook();
594
+ await workbook.xlsx.readFile(file);
595
+ const sheet = sheetName ? workbook.getWorksheet(sheetName) : workbook.worksheets[0];
596
+ if (!sheet) throw new Error(`Worksheet not found: ${sheetName ?? "<first>"}`);
597
+ const header = sheet.getRow(1).values;
598
+ if (header[1] === "key") return importSingleFile(sheet, header);
599
+ if (header[1] === "namespace" && header[2] === "key") return importNamespaced(sheet, header);
600
+ throw new Error(
601
+ 'Unrecognized workbook header. Expected "key" (single-file) or "namespace","key" (namespaced).'
602
+ );
603
+ }
604
+ var NamespaceConfigSchema = z.object({
605
+ structure: z.enum(["locale-dir", "locale-prefix"]).describe(
606
+ "Optional namespace layout. `locale-dir` resolves <input>/<locale>/<namespace>.json recursively. `locale-prefix` resolves <input>/<locale>.<namespace>.json."
607
+ )
608
+ });
609
+ var LangSyncConfigSchema = z.object({
610
+ input: z.string().describe("Path to the source i18n directory."),
611
+ output: z.string().default("./translations").describe(
612
+ 'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
613
+ ),
614
+ locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
615
+ defaultLocale: z.string().optional().describe(
616
+ "Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
617
+ ),
618
+ framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
619
+ namespaces: NamespaceConfigSchema.optional().describe(
620
+ "Optional namespace settings. Omit this block to keep the default single-file layout at <input>/<locale>.json."
621
+ ),
622
+ excel: z.object({
623
+ file: z.string().default("translations.xlsx"),
624
+ sheetName: z.string().default("Translations")
625
+ }).optional(),
626
+ ai: z.object({
627
+ provider: z.enum(["openai", "deepl", "anthropic", "gemini"]).default("openai").describe("AI translation provider."),
628
+ apiKey: z.string().optional().describe("API key. Falls back to the provider-specific env var."),
629
+ model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
630
+ }).optional().describe("AI translation settings.")
631
+ });
632
+ function formatZodError(error) {
633
+ const issues = error.issues.map((issue) => {
634
+ const path = issue.path.length > 0 ? ` ${issue.path.join(".")}: ` : " ";
635
+ return `${path}${issue.message}`;
636
+ });
637
+ return `Invalid LangSync configuration:
638
+ ${issues.join("\n")}`;
639
+ }
640
+ async function loadConfig(cwd = process.cwd()) {
641
+ const explorer = cosmiconfig("langsync", {
642
+ searchPlaces: [
643
+ "langsync.config.ts",
644
+ "langsync.config.js",
645
+ "langsync.config.mjs",
646
+ "langsync.config.json",
647
+ ".langsyncrc",
648
+ ".langsyncrc.json",
649
+ "package.json"
650
+ ],
651
+ loaders: {
652
+ ".ts": TypeScriptLoader()
653
+ }
654
+ });
655
+ const result = await explorer.search(cwd);
656
+ if (!result) return null;
657
+ const parsed = LangSyncConfigSchema.safeParse(result.config);
658
+ if (!parsed.success) {
659
+ throw new Error(formatZodError(parsed.error));
660
+ }
661
+ return { config: parsed.data, filepath: result.filepath };
662
+ }
663
+ async function writeJson(filePath, data, { indent = 2 } = {}) {
664
+ const absolute = resolve(filePath);
665
+ await mkdir(dirname(absolute), { recursive: true });
666
+ await writeFile(absolute, JSON.stringify(data, null, indent) + "\n", "utf-8");
667
+ }
668
+ function isWithinDirectory(path, directory) {
669
+ return path === directory || path.startsWith(directory + sep);
670
+ }
671
+ function validateNamespace(namespace, structure) {
672
+ if (namespace.trim() === "") {
673
+ throw new Error('Invalid namespace "": namespace must not be empty.');
674
+ }
675
+ if (namespace.includes("\\")) {
676
+ throw new Error(`Invalid namespace "${namespace}": backslashes are not allowed.`);
677
+ }
678
+ if (namespace.startsWith("/")) {
679
+ throw new Error(`Invalid namespace "${namespace}": absolute namespace paths are not allowed.`);
680
+ }
681
+ if (structure === "locale-prefix" && namespace.includes("/")) {
682
+ throw new Error(
683
+ `Invalid namespace "${namespace}": locale-prefix namespaces must not contain '/'.`
684
+ );
685
+ }
686
+ if (namespace.split("/").some((segment) => segment === "." || segment === "..")) {
687
+ throw new Error(`Invalid namespace "${namespace}": path traversal segments are not allowed.`);
688
+ }
689
+ }
690
+ function resolveLocaleFilePath(args) {
691
+ const inputAbs = resolve(args.cwd, args.inputDir);
692
+ if (!args.namespaces) {
693
+ if (args.namespace !== null) {
694
+ throw new Error(
695
+ `Invalid namespace "${args.namespace}": single-file mode cannot resolve a namespace.`
696
+ );
697
+ }
698
+ const path2 = resolve(inputAbs, `${args.locale}.json`);
699
+ if (!isWithinDirectory(path2, inputAbs)) {
700
+ throw new Error(
701
+ `Resolved locale file path escapes input directory for locale "${args.locale}".`
702
+ );
703
+ }
704
+ return path2;
705
+ }
706
+ if (args.namespace === null) {
707
+ throw new Error("Namespaced mode requires a non-null namespace.");
708
+ }
709
+ validateNamespace(args.namespace, args.namespaces.structure);
710
+ const path = args.namespaces.structure === "locale-dir" ? resolve(inputAbs, args.locale, `${args.namespace}.json`) : resolve(inputAbs, `${args.locale}.${args.namespace}.json`);
711
+ if (!isWithinDirectory(path, inputAbs)) {
712
+ throw new Error(
713
+ `Resolved locale file path escapes input directory for namespace "${args.namespace}".`
714
+ );
715
+ }
716
+ return path;
717
+ }
718
+ async function readTranslationFile(path, logicalPath) {
719
+ const content = await readFile(path, "utf-8");
720
+ try {
721
+ return JSON.parse(content);
722
+ } catch (error) {
723
+ const message = error instanceof Error ? error.message : String(error);
724
+ throw new Error(`Failed to parse ${logicalPath}: ${message}`, { cause: error });
725
+ }
726
+ }
727
+ async function listJsonFilesRecursive(directory) {
728
+ let entries;
729
+ try {
730
+ entries = await readdir(directory, { withFileTypes: true });
731
+ } catch (error) {
732
+ const errno = error.code;
733
+ if (errno === "ENOENT") return [];
734
+ throw error;
735
+ }
736
+ const files = [];
737
+ for (const entry of entries) {
738
+ const path = join(directory, entry.name);
739
+ if (entry.isDirectory()) {
740
+ files.push(...await listJsonFilesRecursive(path));
741
+ } else if (entry.isFile() && entry.name.endsWith(".json")) {
742
+ files.push(path);
743
+ }
744
+ }
745
+ return files.sort();
746
+ }
747
+ async function listDirectJsonFiles(directory) {
748
+ let entries;
749
+ try {
750
+ entries = await readdir(directory, { withFileTypes: true });
751
+ } catch (error) {
752
+ const errno = error.code;
753
+ if (errno === "ENOENT") return [];
754
+ throw error;
755
+ }
756
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => join(directory, entry.name)).sort();
757
+ }
758
+ function orderAndSynthesizeFiles(loaded, options) {
759
+ const namespaces = [
760
+ ...new Set(loaded.map((file) => file.namespace).filter((ns) => ns !== null))
761
+ ].sort();
762
+ if (namespaces.length === 0) return [];
763
+ const byKey = new Map(loaded.map((file) => [`${file.locale}\0${file.namespace}`, file]));
764
+ const ordered = [];
765
+ for (const locale of options.locales) {
766
+ for (const namespace of namespaces) {
767
+ const key = `${locale}\0${namespace}`;
768
+ const existing = byKey.get(key);
769
+ if (existing) {
770
+ ordered.push(existing);
771
+ continue;
772
+ }
773
+ ordered.push({
774
+ locale,
775
+ namespace,
776
+ path: resolveLocaleFilePath({
777
+ cwd: options.cwd,
778
+ inputDir: options.inputDir,
779
+ locale,
780
+ namespace,
781
+ namespaces: options.namespaces
782
+ }),
783
+ translations: {},
784
+ exists: false
785
+ });
985
786
  }
986
- const rowKey = `${namespace}\0${key}`;
987
- if (seenRows.has(rowKey)) {
988
- throw new Error(`Duplicate (namespace, key) row: "${namespace}" / "${key}".`);
787
+ }
788
+ return ordered;
789
+ }
790
+ async function loadLocaleFiles(options) {
791
+ const inputAbs = resolve(options.cwd, options.inputDir);
792
+ if (!options.namespaces) {
793
+ const out = [];
794
+ for (const locale of options.locales) {
795
+ const path = resolveLocaleFilePath({
796
+ cwd: options.cwd,
797
+ inputDir: options.inputDir,
798
+ locale,
799
+ namespace: null
800
+ });
801
+ let translations = {};
802
+ let exists = false;
803
+ try {
804
+ translations = await readTranslationFile(path, `${locale}.json`);
805
+ exists = true;
806
+ } catch (error) {
807
+ const errno = error.code;
808
+ if (errno !== "ENOENT") throw error;
809
+ }
810
+ out.push({ locale, namespace: null, path, translations, exists });
989
811
  }
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);
812
+ return out;
813
+ }
814
+ const loaded = [];
815
+ if (options.namespaces.structure === "locale-dir") {
816
+ for (const locale of options.locales) {
817
+ const localeDir = resolve(inputAbs, locale);
818
+ if (!isWithinDirectory(localeDir, inputAbs)) {
819
+ throw new Error(`Locale "${locale}" resolves outside the input directory.`);
998
820
  }
999
- flat[key] = cellText(values[idx + 3]);
821
+ const paths2 = await listJsonFilesRecursive(localeDir);
822
+ for (const path of paths2) {
823
+ const namespace = relative(localeDir, path).slice(0, -".json".length).split(sep).join("/");
824
+ validateNamespace(namespace, "locale-dir");
825
+ loaded.push({
826
+ locale,
827
+ namespace,
828
+ path,
829
+ translations: await readTranslationFile(path, `${locale}/${namespace}.json`),
830
+ exists: true
831
+ });
832
+ }
833
+ }
834
+ return orderAndSynthesizeFiles(loaded, options);
835
+ }
836
+ const sortedLocales = [...options.locales].sort((a, b) => b.length - a.length);
837
+ const paths = await listDirectJsonFiles(inputAbs);
838
+ for (const path of paths) {
839
+ const fileName = basename(path);
840
+ const locale = sortedLocales.find((candidate) => fileName.startsWith(`${candidate}.`));
841
+ if (!locale) continue;
842
+ const namespace = fileName.slice(locale.length + 1, -".json".length);
843
+ if (namespace.trim() === "") continue;
844
+ validateNamespace(namespace, "locale-prefix");
845
+ loaded.push({
846
+ locale,
847
+ namespace,
848
+ path,
849
+ translations: await readTranslationFile(path, fileName),
850
+ exists: true
1000
851
  });
1001
- });
852
+ }
853
+ return orderAndSynthesizeFiles(loaded, options);
854
+ }
855
+ function indexLocaleFiles(files) {
856
+ const byLocale = {};
857
+ const namespaceSet = /* @__PURE__ */ new Set();
858
+ for (const file of files) {
859
+ byLocale[file.locale] ??= /* @__PURE__ */ new Map();
860
+ byLocale[file.locale].set(file.namespace, file);
861
+ if (file.namespace !== null) namespaceSet.add(file.namespace);
862
+ }
1002
863
  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
- )
864
+ files,
865
+ namespaces: [...namespaceSet].sort(),
866
+ byLocale
1011
867
  };
1012
868
  }
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).'
869
+
870
+ // ../shared/dist/errors/index.js
871
+ function noNamespacesError(referenceLocale, inputDir) {
872
+ return new Error(
873
+ `No namespace files found under "${inputDir}". Run \`langsync init\` or create at least one namespace file for "${referenceLocale}".`
1023
874
  );
1024
875
  }
1025
876
 
@@ -1164,375 +1015,6 @@ var TranslationAdapterError = class extends Error {
1164
1015
  this.name = "TranslationAdapterError";
1165
1016
  }
1166
1017
  };
1167
- var DEFAULT_MODEL = "gpt-5-mini";
1168
- var ENDPOINT = "https://api.openai.com/v1/chat/completions";
1169
- var OpenAIAdapter = class {
1170
- provider = "openai";
1171
- apiKey;
1172
- model;
1173
- fetchImpl;
1174
- constructor(options = {}) {
1175
- const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
1176
- if (!apiKey) {
1177
- throw new Error(
1178
- "OpenAI API key missing. Set `ai.apiKey` in your config or the OPENAI_API_KEY env var."
1179
- );
1180
- }
1181
- this.apiKey = apiKey;
1182
- this.model = options.model ?? DEFAULT_MODEL;
1183
- this.fetchImpl = options.fetchImpl ?? fetch;
1184
- }
1185
- async translate(request) {
1186
- const response = await this.fetchImpl(ENDPOINT, {
1187
- method: "POST",
1188
- headers: {
1189
- "content-type": "application/json",
1190
- authorization: `Bearer ${this.apiKey}`
1191
- },
1192
- body: JSON.stringify({
1193
- model: this.model,
1194
- temperature: 0,
1195
- messages: [
1196
- {
1197
- role: "system",
1198
- content: `You are a professional software localization engine. Translate the user message from ${request.sourceLocale} to ${request.targetLocale}. Preserve placeholders, ICU syntax, and surrounding punctuation. Respond with the translation only, no quotes or commentary.`
1199
- },
1200
- { role: "user", content: request.text }
1201
- ]
1202
- })
1203
- });
1204
- if (!response.ok) {
1205
- throw new Error(`OpenAI request failed: ${response.status} ${response.statusText}`);
1206
- }
1207
- const data = await response.json();
1208
- const content = data.choices?.[0]?.message?.content?.trim();
1209
- if (!content) {
1210
- throw new Error("OpenAI returned an empty translation.");
1211
- }
1212
- return content;
1213
- }
1214
- };
1215
- var FREE_ENDPOINT = "https://api-free.deepl.com/v2/translate";
1216
- var PRO_ENDPOINT = "https://api.deepl.com/v2/translate";
1217
- var FREE_KEY_SUFFIX = ":fx";
1218
- function toDeepLLang(locale) {
1219
- return locale.split("-")[0].toUpperCase();
1220
- }
1221
- var DeepLAdapter = class {
1222
- provider = "deepl";
1223
- apiKey;
1224
- endpoint;
1225
- fetchImpl;
1226
- constructor(options = {}) {
1227
- const apiKey = options.apiKey ?? process.env.DEEPL_API_KEY;
1228
- if (!apiKey) {
1229
- throw new Error(
1230
- "DeepL API key missing. Set `ai.apiKey` in your config or the DEEPL_API_KEY env var."
1231
- );
1232
- }
1233
- this.apiKey = apiKey;
1234
- const useFreeTier = options.useFreeTier ?? apiKey.endsWith(FREE_KEY_SUFFIX);
1235
- this.endpoint = useFreeTier ? FREE_ENDPOINT : PRO_ENDPOINT;
1236
- this.fetchImpl = options.fetchImpl ?? fetch;
1237
- }
1238
- async translate(request) {
1239
- const response = await this.fetchImpl(this.endpoint, {
1240
- method: "POST",
1241
- headers: {
1242
- "content-type": "application/json",
1243
- authorization: `DeepL-Auth-Key ${this.apiKey}`
1244
- },
1245
- body: JSON.stringify({
1246
- text: [request.text],
1247
- source_lang: toDeepLLang(request.sourceLocale),
1248
- target_lang: toDeepLLang(request.targetLocale)
1249
- })
1250
- });
1251
- if (!response.ok) {
1252
- throw new Error(`DeepL request failed: ${response.status} ${response.statusText}`);
1253
- }
1254
- const data = await response.json();
1255
- const content = data.translations?.[0]?.text?.trim();
1256
- if (!content) {
1257
- throw new Error("DeepL returned an empty translation.");
1258
- }
1259
- return content;
1260
- }
1261
- };
1262
- var DEFAULT_MODEL2 = "claude-haiku-4-5";
1263
- var ENDPOINT2 = "https://api.anthropic.com/v1/messages";
1264
- var ANTHROPIC_VERSION = "2023-06-01";
1265
- var MAX_TOKENS = 1024;
1266
- var AnthropicAdapter = class {
1267
- provider = "anthropic";
1268
- apiKey;
1269
- model;
1270
- fetchImpl;
1271
- constructor(options = {}) {
1272
- const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
1273
- if (!apiKey) {
1274
- throw new Error(
1275
- "Anthropic API key missing. Set `ai.apiKey` in your config or the ANTHROPIC_API_KEY env var."
1276
- );
1277
- }
1278
- this.apiKey = apiKey;
1279
- this.model = options.model ?? DEFAULT_MODEL2;
1280
- this.fetchImpl = options.fetchImpl ?? fetch;
1281
- }
1282
- async translate(request) {
1283
- const response = await this.fetchImpl(ENDPOINT2, {
1284
- method: "POST",
1285
- headers: {
1286
- "content-type": "application/json",
1287
- "x-api-key": this.apiKey,
1288
- "anthropic-version": ANTHROPIC_VERSION
1289
- },
1290
- body: JSON.stringify({
1291
- model: this.model,
1292
- max_tokens: MAX_TOKENS,
1293
- system: `You are a professional software localization engine. Translate the user message from ${request.sourceLocale} to ${request.targetLocale}. Preserve placeholders, ICU syntax, and surrounding punctuation. Respond with the translation only, no quotes or commentary.`,
1294
- messages: [{ role: "user", content: request.text }]
1295
- })
1296
- });
1297
- if (!response.ok) {
1298
- throw new Error(`Anthropic request failed: ${response.status} ${response.statusText}`);
1299
- }
1300
- const data = await response.json();
1301
- const content = data.content?.find((block) => block.type === "text" || block.text)?.text?.trim();
1302
- if (!content) {
1303
- throw new Error("Anthropic returned an empty translation.");
1304
- }
1305
- return content;
1306
- }
1307
- };
1308
- var DEFAULT_MODEL3 = "gemini-3-flash";
1309
- var BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
1310
- var GeminiAdapter = class {
1311
- provider = "gemini";
1312
- apiKey;
1313
- model;
1314
- fetchImpl;
1315
- constructor(options = {}) {
1316
- const apiKey = options.apiKey ?? process.env.GEMINI_API_KEY;
1317
- if (!apiKey) {
1318
- throw new Error(
1319
- "Gemini API key missing. Set `ai.apiKey` in your config or the GEMINI_API_KEY env var."
1320
- );
1321
- }
1322
- this.apiKey = apiKey;
1323
- this.model = options.model ?? DEFAULT_MODEL3;
1324
- this.fetchImpl = options.fetchImpl ?? fetch;
1325
- }
1326
- async translate(request) {
1327
- const url = `${BASE_URL}/${this.model}:generateContent?key=${this.apiKey}`;
1328
- const response = await this.fetchImpl(url, {
1329
- method: "POST",
1330
- headers: { "content-type": "application/json" },
1331
- body: JSON.stringify({
1332
- systemInstruction: {
1333
- parts: [
1334
- {
1335
- text: `You are a professional software localization engine. Translate the user message from ${request.sourceLocale} to ${request.targetLocale}. Preserve placeholders, ICU syntax, and surrounding punctuation. Respond with the translation only, no quotes or commentary.`
1336
- }
1337
- ]
1338
- },
1339
- contents: [{ parts: [{ text: request.text }] }],
1340
- generationConfig: { temperature: 0 }
1341
- })
1342
- });
1343
- if (!response.ok) {
1344
- throw new Error(`Gemini request failed: ${response.status} ${response.statusText}`);
1345
- }
1346
- const data = await response.json();
1347
- const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
1348
- if (!content) {
1349
- throw new Error("Gemini returned an empty translation.");
1350
- }
1351
- return content;
1352
- }
1353
- };
1354
- var RELEASED_PROVIDERS = ["openai", "deepl"];
1355
- var ALL_PROVIDERS = ["openai", "deepl", "anthropic", "gemini"];
1356
- function experimentalEnabled() {
1357
- return process.env.LANGSYNC_AI_EXPERIMENTAL === "1";
1358
- }
1359
- function availableProviders() {
1360
- return experimentalEnabled() ? [...ALL_PROVIDERS] : [...RELEASED_PROVIDERS];
1361
- }
1362
- function createAdapter(options) {
1363
- if (!availableProviders().includes(options.provider)) {
1364
- if (ALL_PROVIDERS.includes(options.provider)) {
1365
- throw new Error(
1366
- `AI provider "${options.provider}" is not yet available. Currently supported: ${availableProviders().join(", ")}.`
1367
- );
1368
- }
1369
- throw new Error(`Unknown AI provider "${options.provider}".`);
1370
- }
1371
- const { provider, ...rest } = options;
1372
- switch (provider) {
1373
- case "openai":
1374
- return new OpenAIAdapter(rest);
1375
- case "deepl":
1376
- return new DeepLAdapter(rest);
1377
- case "anthropic":
1378
- return new AnthropicAdapter(rest);
1379
- case "gemini":
1380
- return new GeminiAdapter(rest);
1381
- default: {
1382
- const exhaustive = provider;
1383
- throw new Error(`AI provider "${String(exhaustive)}" has no adapter implementation yet.`);
1384
- }
1385
- }
1386
- }
1387
- function isEmpty(value) {
1388
- return value === void 0 || value.trim() === "";
1389
- }
1390
- async function fillEmptyTranslations(options) {
1391
- const referenceFlat = flatten(options.reference);
1392
- const targetFlat = flatten(options.target);
1393
- const translatedKeys = [];
1394
- const skippedKeys = [];
1395
- for (const [key, referenceValue] of Object.entries(referenceFlat)) {
1396
- if (isEmpty(referenceValue)) continue;
1397
- if (!isEmpty(targetFlat[key])) continue;
1398
- if (options.maxKeys !== void 0 && translatedKeys.length >= options.maxKeys) {
1399
- skippedKeys.push(key);
1400
- continue;
1401
- }
1402
- targetFlat[key] = await options.adapter.translate({
1403
- text: referenceValue,
1404
- sourceLocale: options.sourceLocale,
1405
- targetLocale: options.targetLocale
1406
- });
1407
- translatedKeys.push(key);
1408
- }
1409
- return { tree: unflatten(targetFlat), translatedKeys, skippedKeys };
1410
- }
1411
-
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
- }
1419
- async function runTranslate(options) {
1420
- const loaded = await loadConfig(options.cwd);
1421
- if (!loaded) {
1422
- throw new Error("No LangSync config found. Run `langsync init` first.");
1423
- }
1424
- const { config } = loaded;
1425
- const referenceLocale = config.defaultLocale ?? config.locales[0];
1426
- const provider = options.provider ?? config.ai?.provider ?? "openai";
1427
- const files = await loadLocaleFiles({
1428
- cwd: options.cwd,
1429
- inputDir: config.input,
1430
- locales: config.locales,
1431
- namespaces: config.namespaces
1432
- });
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) {
1441
- throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
1442
- }
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
- }
1481
- const written = [];
1482
- const planned = [];
1483
- const translatedByLocale = {};
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
- }
1522
- }
1523
- }
1524
- return {
1525
- provider,
1526
- referenceLocale,
1527
- written,
1528
- planned,
1529
- translatedByLocale,
1530
- skippedByLocale,
1531
- totalTranslatableKeys
1532
- };
1533
- }
1534
-
1535
- // src/commands/translate.ts
1536
1018
  function registerTranslateCommand(program) {
1537
1019
  program.command("translate").description("Fill empty values in non-reference locales using an AI provider.").option("--provider <provider>", "Override the configured AI provider.").option("--model <model>", "Override the configured provider model.").option(
1538
1020
  "--max-keys <n>",
@@ -1724,11 +1206,11 @@ function registerWatchCommand(program) {
1724
1206
  }
1725
1207
 
1726
1208
  // src/cli.ts
1727
- var VERSION = "0.6.0" ;
1209
+ var VERSION = "0.7.1" ;
1728
1210
  async function main() {
1729
1211
  assertNodeVersion(22);
1730
1212
  const program = new Command();
1731
- program.name("langsync").description("Modern localization workflow tooling for TypeScript applications.").version(VERSION, "-v, --version", "Output the current version.").hook("preAction", () => {
1213
+ program.name("langsync").description("CLI tooling for localization workflows in TypeScript projects.").version(VERSION, "-v, --version", "Output the current version.").hook("preAction", () => {
1732
1214
  printBanner(VERSION);
1733
1215
  });
1734
1216
  registerInitCommand(program);