@mariokreitz/langsync 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -247,9 +247,13 @@ function registerInitCommand(program) {
247
247
  }
248
248
  var LangSyncConfigSchema = z.object({
249
249
  input: z.string().describe("Path to the source i18n directory."),
250
- output: z.string().describe("Path to the output/translations directory."),
251
- locales: z.array(z.string()).min(1).describe("List of supported locales."),
252
- defaultLocale: z.string().optional(),
250
+ output: z.string().default("./translations").describe(
251
+ 'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
252
+ ),
253
+ locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
254
+ defaultLocale: z.string().optional().describe(
255
+ "Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
256
+ ),
253
257
  framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
254
258
  excel: z.object({
255
259
  file: z.string().default("translations.xlsx"),
@@ -261,6 +265,14 @@ var LangSyncConfigSchema = z.object({
261
265
  model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
262
266
  }).optional().describe("AI translation settings.")
263
267
  });
268
+ function formatZodError(error) {
269
+ const issues = error.issues.map((issue) => {
270
+ const path = issue.path.length > 0 ? ` ${issue.path.join(".")}: ` : " ";
271
+ return `${path}${issue.message}`;
272
+ });
273
+ return `Invalid LangSync configuration:
274
+ ${issues.join("\n")}`;
275
+ }
264
276
  async function loadConfig(cwd = process.cwd()) {
265
277
  const explorer = cosmiconfig("langsync", {
266
278
  searchPlaces: [
@@ -278,8 +290,11 @@ async function loadConfig(cwd = process.cwd()) {
278
290
  });
279
291
  const result = await explorer.search(cwd);
280
292
  if (!result) return null;
281
- const parsed = LangSyncConfigSchema.parse(result.config);
282
- return { config: parsed, filepath: result.filepath };
293
+ const parsed = LangSyncConfigSchema.safeParse(result.config);
294
+ if (!parsed.success) {
295
+ throw new Error(formatZodError(parsed.error));
296
+ }
297
+ return { config: parsed.data, filepath: result.filepath };
283
298
  }
284
299
  async function writeJson(filePath, data, { indent = 2 } = {}) {
285
300
  const absolute = resolve(filePath);
@@ -373,6 +388,26 @@ function syncTrees(source, target) {
373
388
  }
374
389
  return unflatten(merged);
375
390
  }
391
+ function diffTrees(prev, next) {
392
+ const prevFlat = flatten(prev);
393
+ const nextFlat = flatten(next);
394
+ const prevKeys = new Set(Object.keys(prevFlat));
395
+ const nextKeys = new Set(Object.keys(nextFlat));
396
+ const added = [];
397
+ const removed = [];
398
+ const changed = [];
399
+ for (const key of nextKeys) {
400
+ if (!prevKeys.has(key)) added.push(key);
401
+ else if (prevFlat[key] !== nextFlat[key]) changed.push(key);
402
+ }
403
+ for (const key of prevKeys) {
404
+ if (!nextKeys.has(key)) removed.push(key);
405
+ }
406
+ return { added, removed, changed };
407
+ }
408
+ function hasChanges(diff) {
409
+ return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
410
+ }
376
411
 
377
412
  // src/commands/sync/run.ts
378
413
  async function runSync(options) {
@@ -394,15 +429,23 @@ async function runSync(options) {
394
429
  const targets = files.filter((f) => f.locale !== referenceLocale);
395
430
  const planned = [];
396
431
  const written = [];
432
+ const unchanged = [];
433
+ const diffsByPath = {};
397
434
  for (const target of targets) {
398
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;
399
442
  planned.push(target.path);
400
443
  if (!options.dryRun) {
401
444
  await writeJson(target.path, merged);
402
445
  written.push(target.path);
403
446
  }
404
447
  }
405
- return { referenceLocale, written, planned };
448
+ return { referenceLocale, written, planned, unchanged, diffsByPath };
406
449
  }
407
450
 
408
451
  // src/commands/sync.ts
@@ -676,6 +719,14 @@ function registerImportCommand(program) {
676
719
  }
677
720
 
678
721
  // ../ai-engine/dist/index.js
722
+ var TranslationAdapterError = class extends Error {
723
+ constructor(message, provider, statusCode, options) {
724
+ super(message, options);
725
+ this.provider = provider;
726
+ this.statusCode = statusCode;
727
+ this.name = "TranslationAdapterError";
728
+ }
729
+ };
679
730
  var DEFAULT_MODEL = "gpt-5-mini";
680
731
  var ENDPOINT = "https://api.openai.com/v1/chat/completions";
681
732
  var OpenAIAdapter = class {
@@ -863,7 +914,7 @@ var GeminiAdapter = class {
863
914
  return content;
864
915
  }
865
916
  };
866
- var RELEASED_PROVIDERS = ["openai"];
917
+ var RELEASED_PROVIDERS = ["openai", "deepl"];
867
918
  var ALL_PROVIDERS = ["openai", "deepl", "anthropic", "gemini"];
868
919
  function experimentalEnabled() {
869
920
  return process.env.LANGSYNC_AI_EXPERIMENTAL === "1";
@@ -903,9 +954,14 @@ async function fillEmptyTranslations(options) {
903
954
  const referenceFlat = flatten(options.reference);
904
955
  const targetFlat = flatten(options.target);
905
956
  const translatedKeys = [];
957
+ const skippedKeys = [];
906
958
  for (const [key, referenceValue] of Object.entries(referenceFlat)) {
907
959
  if (isEmpty(referenceValue)) continue;
908
960
  if (!isEmpty(targetFlat[key])) continue;
961
+ if (options.maxKeys !== void 0 && translatedKeys.length >= options.maxKeys) {
962
+ skippedKeys.push(key);
963
+ continue;
964
+ }
909
965
  targetFlat[key] = await options.adapter.translate({
910
966
  text: referenceValue,
911
967
  sourceLocale: options.sourceLocale,
@@ -913,7 +969,7 @@ async function fillEmptyTranslations(options) {
913
969
  });
914
970
  translatedKeys.push(key);
915
971
  }
916
- return { tree: unflatten(targetFlat), translatedKeys };
972
+ return { tree: unflatten(targetFlat), translatedKeys, skippedKeys };
917
973
  }
918
974
 
919
975
  // src/commands/translate/run.ts
@@ -940,18 +996,42 @@ async function runTranslate(options) {
940
996
  throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
941
997
  }
942
998
  const targets = files.filter((f) => f.locale !== referenceLocale);
999
+ const refFlat = flatten(reference.translations);
1000
+ const allCandidates = [];
1001
+ for (const target of targets) {
1002
+ const targetFlat = flatten(target.translations);
1003
+ for (const [key, value] of Object.entries(refFlat)) {
1004
+ if (!value || value.trim() === "") continue;
1005
+ if (targetFlat[key] && targetFlat[key].trim() !== "") continue;
1006
+ allCandidates.push({ file: target, key, sourceValue: value });
1007
+ }
1008
+ }
1009
+ const totalTranslatableKeys = allCandidates.length;
1010
+ const limited = options.maxKeys ? allCandidates.slice(0, options.maxKeys) : allCandidates;
1011
+ const perLocaleMaxKeys = {};
1012
+ for (const candidate of limited) {
1013
+ perLocaleMaxKeys[candidate.file.locale] = (perLocaleMaxKeys[candidate.file.locale] ?? 0) + 1;
1014
+ }
943
1015
  const written = [];
944
1016
  const planned = [];
945
1017
  const translatedByLocale = {};
1018
+ const skippedByLocale = {};
946
1019
  for (const target of targets) {
947
- const { tree, translatedKeys } = await fillEmptyTranslations({
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({
948
1026
  reference: reference.translations,
949
1027
  target: target.translations,
950
1028
  sourceLocale: referenceLocale,
951
1029
  targetLocale: target.locale,
952
- adapter
1030
+ adapter,
1031
+ maxKeys: localeMax
953
1032
  });
954
1033
  translatedByLocale[target.locale] = translatedKeys;
1034
+ if (skippedKeys.length > 0) skippedByLocale[target.locale] = skippedKeys;
955
1035
  if (translatedKeys.length === 0) continue;
956
1036
  planned.push(target.path);
957
1037
  if (!options.dryRun) {
@@ -959,41 +1039,92 @@ async function runTranslate(options) {
959
1039
  written.push(target.path);
960
1040
  }
961
1041
  }
962
- return { provider, referenceLocale, written, planned, translatedByLocale };
1042
+ return {
1043
+ provider,
1044
+ referenceLocale,
1045
+ written,
1046
+ planned,
1047
+ translatedByLocale,
1048
+ skippedByLocale,
1049
+ totalTranslatableKeys
1050
+ };
963
1051
  }
964
1052
 
965
1053
  // src/commands/translate.ts
966
1054
  function registerTranslateCommand(program) {
967
- program.command("translate").description("Fill empty values in non-reference locales using an AI provider.").option("--provider <provider>", "Override the configured AI provider.").option("--model <model>", "Override the configured provider model.").option("--dry-run", "Report what would be translated without writing files.", false).action(async (flags) => {
1055
+ program.command("translate").description("Fill empty values in non-reference locales using an AI provider.").option("--provider <provider>", "Override the configured AI provider.").option("--model <model>", "Override the configured provider model.").option(
1056
+ "--max-keys <n>",
1057
+ "Limit the total number of keys translated per run. Keys are selected deterministically: target locales in config order, then keys in reference order. Useful for controlling API spend."
1058
+ ).option("--dry-run", "Report what would be translated without writing files.", false).action(async (flags) => {
968
1059
  try {
969
1060
  const cwd = process.cwd();
1061
+ const maxKeys = flags.maxKeys ? Number.parseInt(flags.maxKeys, 10) : void 0;
1062
+ if (maxKeys !== void 0 && (Number.isNaN(maxKeys) || maxKeys <= 0)) {
1063
+ logger.error("--max-keys must be a positive integer.");
1064
+ process.exitCode = 1;
1065
+ return;
1066
+ }
970
1067
  const result = await runTranslate({
971
1068
  cwd,
972
1069
  dryRun: flags.dryRun,
973
1070
  provider: flags.provider,
974
- model: flags.model
1071
+ model: flags.model,
1072
+ maxKeys
975
1073
  });
976
1074
  const totals = Object.values(result.translatedByLocale).reduce(
977
1075
  (sum, keys) => sum + keys.length,
978
1076
  0
979
1077
  );
1078
+ if (flags.dryRun) {
1079
+ if (result.totalTranslatableKeys === 0) {
1080
+ logger.info(`Nothing to translate with ${chalk.cyan(result.provider)}.`);
1081
+ } else {
1082
+ const capped = maxKeys !== void 0 && maxKeys < result.totalTranslatableKeys;
1083
+ logger.info(
1084
+ `[dry-run] Would translate ${chalk.bold(String(totals))} key(s) across ${Object.keys(result.translatedByLocale).length} locale(s) using ${chalk.cyan(result.provider)}.`
1085
+ );
1086
+ if (capped) {
1087
+ logger.info(
1088
+ `[dry-run] ${result.totalTranslatableKeys - totals} key(s) skipped due to --max-keys ${String(maxKeys)}.`
1089
+ );
1090
+ }
1091
+ for (const [locale, keys] of Object.entries(result.translatedByLocale)) {
1092
+ if (keys.length === 0) continue;
1093
+ logger.info(`[dry-run] ${locale}: ${String(keys.length)} key(s)`);
1094
+ }
1095
+ }
1096
+ return;
1097
+ }
980
1098
  if (totals === 0) {
981
1099
  logger.info(`Nothing to translate with ${chalk.cyan(result.provider)}.`);
982
1100
  return;
983
1101
  }
984
- const verb = flags.dryRun ? "Would translate" : "Translated";
985
1102
  for (const [locale, keys] of Object.entries(result.translatedByLocale)) {
986
1103
  if (keys.length === 0) continue;
987
- logger.success(`${verb} ${chalk.bold(String(keys.length))} key(s) for ${locale}`);
1104
+ logger.success(`Translated ${chalk.bold(String(keys.length))} key(s) for ${locale}`);
988
1105
  }
989
- if (!flags.dryRun) {
990
- for (const path of result.written) {
991
- logger.info(`Wrote ${chalk.bold(relative(cwd, path))}`);
992
- }
1106
+ for (const path of result.written) {
1107
+ logger.info(`Wrote ${chalk.bold(relative(cwd, path))}`);
1108
+ }
1109
+ const totalSkipped = Object.values(result.skippedByLocale).reduce(
1110
+ (sum, keys) => sum + keys.length,
1111
+ 0
1112
+ );
1113
+ if (totalSkipped > 0) {
1114
+ logger.warn(
1115
+ `${chalk.yellow(String(totalSkipped))} key(s) skipped due to --max-keys. Run again to translate remaining keys.`
1116
+ );
993
1117
  }
994
1118
  } catch (error) {
995
- const message = error instanceof Error ? error.message : String(error);
996
- logger.error(message);
1119
+ if (error instanceof TranslationAdapterError) {
1120
+ const statusInfo = error.statusCode ? ` (${String(error.statusCode)})` : "";
1121
+ logger.error(`${error.provider} translation failed${statusInfo}: ${error.message}`);
1122
+ if (error.statusCode === 429) {
1123
+ logger.info("Rate limited. Retry in a moment, or use --max-keys to reduce requests.");
1124
+ }
1125
+ } else {
1126
+ logger.error(error instanceof Error ? error.message : String(error));
1127
+ }
997
1128
  process.exitCode = 1;
998
1129
  }
999
1130
  });
@@ -1006,7 +1137,7 @@ async function resolveWatchDir(cwd) {
1006
1137
  return resolve(cwd, loaded.config.input);
1007
1138
  }
1008
1139
  async function runWatchPass(options) {
1009
- const { referenceLocale, written } = await runSync({
1140
+ const { referenceLocale, written, unchanged, diffsByPath } = await runSync({
1010
1141
  cwd: options.cwd,
1011
1142
  dryRun: options.dryRun
1012
1143
  });
@@ -1020,16 +1151,31 @@ async function runWatchPass(options) {
1020
1151
  locales: loaded.config.locales
1021
1152
  });
1022
1153
  const issues = validateLocales(files, referenceLocale);
1023
- return { referenceLocale, written, issues };
1154
+ return { referenceLocale, written, unchanged, diffsByPath, issues };
1024
1155
  }
1025
1156
 
1026
1157
  // src/commands/watch.ts
1027
- function reportPass(cwd, written, issueCount) {
1028
- if (written.length === 0) {
1029
- logger.info("No locale changes to sync.");
1158
+ function formatDiff(diff) {
1159
+ const parts = [];
1160
+ if (diff.added.length > 0) parts.push(chalk.green(`+${diff.added.length}`));
1161
+ if (diff.removed.length > 0) parts.push(chalk.red(`-${diff.removed.length}`));
1162
+ if (diff.changed.length > 0) parts.push(chalk.yellow(`~${diff.changed.length}`));
1163
+ return parts.join(", ");
1164
+ }
1165
+ function reportPass(cwd, written, unchanged, diffsByPath, issueCount) {
1166
+ if (written.length === 0 && unchanged.length === 0) {
1167
+ logger.info("No locale files found to sync.");
1168
+ } else if (written.length === 0) {
1169
+ logger.info("All locales are already in sync.");
1030
1170
  } else {
1031
1171
  for (const path of written) {
1032
- logger.success(`Synced ${chalk.bold(relative(cwd, path))}`);
1172
+ const rel = relative(cwd, path);
1173
+ const diff = diffsByPath[path];
1174
+ const summary = diff ? ` (${formatDiff(diff)})` : "";
1175
+ logger.success(`Synced ${chalk.bold(rel)}${summary}`);
1176
+ }
1177
+ for (const path of unchanged) {
1178
+ logger.info(`No changes: ${chalk.dim(relative(cwd, path))}`);
1033
1179
  }
1034
1180
  }
1035
1181
  if (issueCount > 0) {
@@ -1045,7 +1191,13 @@ function registerWatchCommand(program) {
1045
1191
  const watchDir = await resolveWatchDir(cwd);
1046
1192
  const debounceMs = Number.parseInt(flags.debounce, 10) || 200;
1047
1193
  const initial = await runWatchPass({ cwd, dryRun: flags.dryRun });
1048
- reportPass(cwd, initial.written, initial.issues.length);
1194
+ reportPass(
1195
+ cwd,
1196
+ initial.written,
1197
+ initial.unchanged,
1198
+ initial.diffsByPath,
1199
+ initial.issues.length
1200
+ );
1049
1201
  logger.info(`Watching ${chalk.cyan(relative(cwd, watchDir) || ".")} for changes...`);
1050
1202
  const watcher = chokidar.watch(join(watchDir, "*.json"), {
1051
1203
  ignoreInitial: true
@@ -1060,7 +1212,7 @@ function registerWatchCommand(program) {
1060
1212
  running = true;
1061
1213
  try {
1062
1214
  const pass = await runWatchPass({ cwd, dryRun: flags.dryRun });
1063
- reportPass(cwd, pass.written, pass.issues.length);
1215
+ reportPass(cwd, pass.written, pass.unchanged, pass.diffsByPath, pass.issues.length);
1064
1216
  } catch (error) {
1065
1217
  logger.error(error instanceof Error ? error.message : String(error));
1066
1218
  } finally {
@@ -1079,7 +1231,7 @@ function registerWatchCommand(program) {
1079
1231
  }
1080
1232
 
1081
1233
  // src/cli.ts
1082
- var VERSION = "0.4.1" ;
1234
+ var VERSION = "0.5.0" ;
1083
1235
  async function main() {
1084
1236
  assertNodeVersion(22);
1085
1237
  const program = new Command();
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { LangSyncConfig, defineConfig } from '@langsync/shared/config';
1
+ export { LangSyncConfig, LangSyncConfigInput, defineConfig } from '@langsync/shared/config';
package/dist/index.js CHANGED
@@ -5,9 +5,13 @@ import { z } from 'zod';
5
5
 
6
6
  z.object({
7
7
  input: z.string().describe("Path to the source i18n directory."),
8
- output: z.string().describe("Path to the output/translations directory."),
9
- locales: z.array(z.string()).min(1).describe("List of supported locales."),
10
- defaultLocale: z.string().optional(),
8
+ output: z.string().default("./translations").describe(
9
+ 'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
10
+ ),
11
+ locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
12
+ defaultLocale: z.string().optional().describe(
13
+ "Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
14
+ ),
11
15
  framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
12
16
  excel: z.object({
13
17
  file: z.string().default("translations.xlsx"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mariokreitz/langsync",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Modern localization workflow tooling for TypeScript applications.",
5
5
  "keywords": [
6
6
  "i18n",
@@ -65,10 +65,10 @@
65
65
  "devDependencies": {
66
66
  "@types/prompts": "^2.4.9",
67
67
  "memfs": "^4.57.3",
68
- "@langsync/shared": "0.1.0",
69
- "@langsync/ai-engine": "0.1.0",
70
- "@langsync/core": "0.1.0",
71
- "@langsync/excel-engine": "0.1.0"
68
+ "@langsync/shared": "0.2.0",
69
+ "@langsync/excel-engine": "0.1.1",
70
+ "@langsync/core": "0.1.1",
71
+ "@langsync/ai-engine": "0.2.0"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "tsup",