@mariokreitz/langsync 0.1.1 → 0.4.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 +25 -5
- package/dist/cli.js +414 -3
- package/dist/index.js +6 -1
- package/package.json +10 -5
package/README.md
CHANGED
|
@@ -18,10 +18,16 @@ chaos of hand-edited JSON or fragile Excel hand-offs.
|
|
|
18
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
21
|
+
<!-- embedme ../../docs/shared/install.sh -->
|
|
22
|
+
|
|
21
23
|
```bash
|
|
22
24
|
pnpm add -D @mariokreitz/langsync
|
|
23
|
-
# or
|
|
24
|
-
|
|
25
|
+
# or
|
|
26
|
+
npm install -D @mariokreitz/langsync
|
|
27
|
+
# or
|
|
28
|
+
yarn add -D @mariokreitz/langsync
|
|
29
|
+
|
|
30
|
+
|
|
25
31
|
```
|
|
26
32
|
|
|
27
33
|
> Requires **Node.js 22+** and an ESM-compatible project.
|
|
@@ -38,10 +44,13 @@ npx langsync validate
|
|
|
38
44
|
# 3. Add missing keys (empty placeholders) to non-reference locales
|
|
39
45
|
npx langsync sync
|
|
40
46
|
|
|
41
|
-
# 4.
|
|
47
|
+
# 4. Optionally fill the gaps with AI
|
|
48
|
+
npx langsync translate
|
|
49
|
+
|
|
50
|
+
# 5. Hand off to translators via Excel
|
|
42
51
|
npx langsync export excel
|
|
43
52
|
|
|
44
|
-
#
|
|
53
|
+
# 6. Import their work back
|
|
45
54
|
npx langsync import excel
|
|
46
55
|
```
|
|
47
56
|
|
|
@@ -53,6 +62,8 @@ npx langsync import excel
|
|
|
53
62
|
| `langsync validate` | Report missing, extra, and empty keys; exits non-zero on errors. |
|
|
54
63
|
| `langsync find-missing` | Report missing keys per locale; exits non-zero on errors. |
|
|
55
64
|
| `langsync sync` | Synchronize keys from the reference locale into every other locale. |
|
|
65
|
+
| `langsync translate` | Fill empty values in non-reference locales using an AI provider. |
|
|
66
|
+
| `langsync watch` | Watch locale files and run incremental sync + validation on change. |
|
|
56
67
|
| `langsync export excel` | Export all locales into a single `.xlsx` workbook. |
|
|
57
68
|
| `langsync import excel` | Import translations from a workbook back into JSON files. |
|
|
58
69
|
|
|
@@ -61,8 +72,9 @@ All read commands support `--reporter json`. All write commands support
|
|
|
61
72
|
|
|
62
73
|
## Configuration
|
|
63
74
|
|
|
75
|
+
<!-- embedme ../../docs/shared/config.ts -->
|
|
76
|
+
|
|
64
77
|
```ts
|
|
65
|
-
// langsync.config.ts
|
|
66
78
|
import { defineConfig } from '@mariokreitz/langsync';
|
|
67
79
|
|
|
68
80
|
export default defineConfig({
|
|
@@ -71,6 +83,14 @@ export default defineConfig({
|
|
|
71
83
|
locales: ['en', 'de', 'fr'],
|
|
72
84
|
defaultLocale: 'en',
|
|
73
85
|
framework: 'i18next',
|
|
86
|
+
excel: {
|
|
87
|
+
file: 'translations.xlsx',
|
|
88
|
+
sheetName: 'Translations',
|
|
89
|
+
},
|
|
90
|
+
ai: {
|
|
91
|
+
provider: 'openai',
|
|
92
|
+
model: 'gpt-5-mini',
|
|
93
|
+
},
|
|
74
94
|
});
|
|
75
95
|
```
|
|
76
96
|
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { cosmiconfig } from 'cosmiconfig';
|
|
|
9
9
|
import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import ExcelJS from 'exceljs';
|
|
12
|
+
import chokidar from 'chokidar';
|
|
12
13
|
|
|
13
14
|
var PREFIX = chalk.bold.cyan("langsync");
|
|
14
15
|
var logger = {
|
|
@@ -44,7 +45,7 @@ function assertNodeVersion(minMajor) {
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
var FRAMEWORK_SIGNATURES = [
|
|
47
|
-
{ framework: "i18next", packages: ["i18next", "react-i18next", "vue-i18next"] },
|
|
48
|
+
{ framework: "i18next", packages: ["i18next", "react-i18next", "i18next-vue", "vue-i18next"] },
|
|
48
49
|
{ framework: "ngx-translate", packages: ["@ngx-translate/core", "@ngx-translate/http-loader"] },
|
|
49
50
|
{ framework: "react-intl", packages: ["react-intl", "@formatjs/intl"] }
|
|
50
51
|
];
|
|
@@ -253,7 +254,12 @@ var LangSyncConfigSchema = z.object({
|
|
|
253
254
|
excel: z.object({
|
|
254
255
|
file: z.string().default("translations.xlsx"),
|
|
255
256
|
sheetName: z.string().default("Translations")
|
|
256
|
-
}).optional()
|
|
257
|
+
}).optional(),
|
|
258
|
+
ai: z.object({
|
|
259
|
+
provider: z.enum(["openai", "deepl", "anthropic", "gemini"]).default("openai").describe("AI translation provider."),
|
|
260
|
+
apiKey: z.string().optional().describe("API key. Falls back to the provider-specific env var."),
|
|
261
|
+
model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
|
|
262
|
+
}).optional().describe("AI translation settings.")
|
|
257
263
|
});
|
|
258
264
|
async function loadConfig(cwd = process.cwd()) {
|
|
259
265
|
const explorer = cosmiconfig("langsync", {
|
|
@@ -669,8 +675,411 @@ function registerImportCommand(program) {
|
|
|
669
675
|
});
|
|
670
676
|
}
|
|
671
677
|
|
|
678
|
+
// ../ai-engine/dist/index.js
|
|
679
|
+
var DEFAULT_MODEL = "gpt-5-mini";
|
|
680
|
+
var ENDPOINT = "https://api.openai.com/v1/chat/completions";
|
|
681
|
+
var OpenAIAdapter = class {
|
|
682
|
+
provider = "openai";
|
|
683
|
+
apiKey;
|
|
684
|
+
model;
|
|
685
|
+
fetchImpl;
|
|
686
|
+
constructor(options = {}) {
|
|
687
|
+
const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
|
|
688
|
+
if (!apiKey) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
"OpenAI API key missing. Set `ai.apiKey` in your config or the OPENAI_API_KEY env var."
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
this.apiKey = apiKey;
|
|
694
|
+
this.model = options.model ?? DEFAULT_MODEL;
|
|
695
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
696
|
+
}
|
|
697
|
+
async translate(request) {
|
|
698
|
+
const response = await this.fetchImpl(ENDPOINT, {
|
|
699
|
+
method: "POST",
|
|
700
|
+
headers: {
|
|
701
|
+
"content-type": "application/json",
|
|
702
|
+
authorization: `Bearer ${this.apiKey}`
|
|
703
|
+
},
|
|
704
|
+
body: JSON.stringify({
|
|
705
|
+
model: this.model,
|
|
706
|
+
temperature: 0,
|
|
707
|
+
messages: [
|
|
708
|
+
{
|
|
709
|
+
role: "system",
|
|
710
|
+
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.`
|
|
711
|
+
},
|
|
712
|
+
{ role: "user", content: request.text }
|
|
713
|
+
]
|
|
714
|
+
})
|
|
715
|
+
});
|
|
716
|
+
if (!response.ok) {
|
|
717
|
+
throw new Error(`OpenAI request failed: ${response.status} ${response.statusText}`);
|
|
718
|
+
}
|
|
719
|
+
const data = await response.json();
|
|
720
|
+
const content = data.choices?.[0]?.message?.content?.trim();
|
|
721
|
+
if (!content) {
|
|
722
|
+
throw new Error("OpenAI returned an empty translation.");
|
|
723
|
+
}
|
|
724
|
+
return content;
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
var FREE_ENDPOINT = "https://api-free.deepl.com/v2/translate";
|
|
728
|
+
var PRO_ENDPOINT = "https://api.deepl.com/v2/translate";
|
|
729
|
+
var FREE_KEY_SUFFIX = ":fx";
|
|
730
|
+
function toDeepLLang(locale) {
|
|
731
|
+
return locale.split("-")[0].toUpperCase();
|
|
732
|
+
}
|
|
733
|
+
var DeepLAdapter = class {
|
|
734
|
+
provider = "deepl";
|
|
735
|
+
apiKey;
|
|
736
|
+
endpoint;
|
|
737
|
+
fetchImpl;
|
|
738
|
+
constructor(options = {}) {
|
|
739
|
+
const apiKey = options.apiKey ?? process.env.DEEPL_API_KEY;
|
|
740
|
+
if (!apiKey) {
|
|
741
|
+
throw new Error(
|
|
742
|
+
"DeepL API key missing. Set `ai.apiKey` in your config or the DEEPL_API_KEY env var."
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
this.apiKey = apiKey;
|
|
746
|
+
const useFreeTier = options.useFreeTier ?? apiKey.endsWith(FREE_KEY_SUFFIX);
|
|
747
|
+
this.endpoint = useFreeTier ? FREE_ENDPOINT : PRO_ENDPOINT;
|
|
748
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
749
|
+
}
|
|
750
|
+
async translate(request) {
|
|
751
|
+
const response = await this.fetchImpl(this.endpoint, {
|
|
752
|
+
method: "POST",
|
|
753
|
+
headers: {
|
|
754
|
+
"content-type": "application/json",
|
|
755
|
+
authorization: `DeepL-Auth-Key ${this.apiKey}`
|
|
756
|
+
},
|
|
757
|
+
body: JSON.stringify({
|
|
758
|
+
text: [request.text],
|
|
759
|
+
source_lang: toDeepLLang(request.sourceLocale),
|
|
760
|
+
target_lang: toDeepLLang(request.targetLocale)
|
|
761
|
+
})
|
|
762
|
+
});
|
|
763
|
+
if (!response.ok) {
|
|
764
|
+
throw new Error(`DeepL request failed: ${response.status} ${response.statusText}`);
|
|
765
|
+
}
|
|
766
|
+
const data = await response.json();
|
|
767
|
+
const content = data.translations?.[0]?.text?.trim();
|
|
768
|
+
if (!content) {
|
|
769
|
+
throw new Error("DeepL returned an empty translation.");
|
|
770
|
+
}
|
|
771
|
+
return content;
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
var DEFAULT_MODEL2 = "claude-haiku-4-5";
|
|
775
|
+
var ENDPOINT2 = "https://api.anthropic.com/v1/messages";
|
|
776
|
+
var ANTHROPIC_VERSION = "2023-06-01";
|
|
777
|
+
var MAX_TOKENS = 1024;
|
|
778
|
+
var AnthropicAdapter = class {
|
|
779
|
+
provider = "anthropic";
|
|
780
|
+
apiKey;
|
|
781
|
+
model;
|
|
782
|
+
fetchImpl;
|
|
783
|
+
constructor(options = {}) {
|
|
784
|
+
const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
|
785
|
+
if (!apiKey) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
"Anthropic API key missing. Set `ai.apiKey` in your config or the ANTHROPIC_API_KEY env var."
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
this.apiKey = apiKey;
|
|
791
|
+
this.model = options.model ?? DEFAULT_MODEL2;
|
|
792
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
793
|
+
}
|
|
794
|
+
async translate(request) {
|
|
795
|
+
const response = await this.fetchImpl(ENDPOINT2, {
|
|
796
|
+
method: "POST",
|
|
797
|
+
headers: {
|
|
798
|
+
"content-type": "application/json",
|
|
799
|
+
"x-api-key": this.apiKey,
|
|
800
|
+
"anthropic-version": ANTHROPIC_VERSION
|
|
801
|
+
},
|
|
802
|
+
body: JSON.stringify({
|
|
803
|
+
model: this.model,
|
|
804
|
+
max_tokens: MAX_TOKENS,
|
|
805
|
+
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.`,
|
|
806
|
+
messages: [{ role: "user", content: request.text }]
|
|
807
|
+
})
|
|
808
|
+
});
|
|
809
|
+
if (!response.ok) {
|
|
810
|
+
throw new Error(`Anthropic request failed: ${response.status} ${response.statusText}`);
|
|
811
|
+
}
|
|
812
|
+
const data = await response.json();
|
|
813
|
+
const content = data.content?.find((block) => block.type === "text" || block.text)?.text?.trim();
|
|
814
|
+
if (!content) {
|
|
815
|
+
throw new Error("Anthropic returned an empty translation.");
|
|
816
|
+
}
|
|
817
|
+
return content;
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
var DEFAULT_MODEL3 = "gemini-3-flash";
|
|
821
|
+
var BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
|
|
822
|
+
var GeminiAdapter = class {
|
|
823
|
+
provider = "gemini";
|
|
824
|
+
apiKey;
|
|
825
|
+
model;
|
|
826
|
+
fetchImpl;
|
|
827
|
+
constructor(options = {}) {
|
|
828
|
+
const apiKey = options.apiKey ?? process.env.GEMINI_API_KEY;
|
|
829
|
+
if (!apiKey) {
|
|
830
|
+
throw new Error(
|
|
831
|
+
"Gemini API key missing. Set `ai.apiKey` in your config or the GEMINI_API_KEY env var."
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
this.apiKey = apiKey;
|
|
835
|
+
this.model = options.model ?? DEFAULT_MODEL3;
|
|
836
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
837
|
+
}
|
|
838
|
+
async translate(request) {
|
|
839
|
+
const url = `${BASE_URL}/${this.model}:generateContent?key=${this.apiKey}`;
|
|
840
|
+
const response = await this.fetchImpl(url, {
|
|
841
|
+
method: "POST",
|
|
842
|
+
headers: { "content-type": "application/json" },
|
|
843
|
+
body: JSON.stringify({
|
|
844
|
+
systemInstruction: {
|
|
845
|
+
parts: [
|
|
846
|
+
{
|
|
847
|
+
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.`
|
|
848
|
+
}
|
|
849
|
+
]
|
|
850
|
+
},
|
|
851
|
+
contents: [{ parts: [{ text: request.text }] }],
|
|
852
|
+
generationConfig: { temperature: 0 }
|
|
853
|
+
})
|
|
854
|
+
});
|
|
855
|
+
if (!response.ok) {
|
|
856
|
+
throw new Error(`Gemini request failed: ${response.status} ${response.statusText}`);
|
|
857
|
+
}
|
|
858
|
+
const data = await response.json();
|
|
859
|
+
const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
|
|
860
|
+
if (!content) {
|
|
861
|
+
throw new Error("Gemini returned an empty translation.");
|
|
862
|
+
}
|
|
863
|
+
return content;
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
var RELEASED_PROVIDERS = ["openai"];
|
|
867
|
+
var ALL_PROVIDERS = ["openai", "deepl", "anthropic", "gemini"];
|
|
868
|
+
function experimentalEnabled() {
|
|
869
|
+
return process.env.LANGSYNC_AI_EXPERIMENTAL === "1";
|
|
870
|
+
}
|
|
871
|
+
function availableProviders() {
|
|
872
|
+
return experimentalEnabled() ? [...ALL_PROVIDERS] : [...RELEASED_PROVIDERS];
|
|
873
|
+
}
|
|
874
|
+
function createAdapter(options) {
|
|
875
|
+
if (!availableProviders().includes(options.provider)) {
|
|
876
|
+
if (ALL_PROVIDERS.includes(options.provider)) {
|
|
877
|
+
throw new Error(
|
|
878
|
+
`AI provider "${options.provider}" is not yet available. Currently supported: ${availableProviders().join(", ")}.`
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
throw new Error(`Unknown AI provider "${options.provider}".`);
|
|
882
|
+
}
|
|
883
|
+
const { provider, ...rest } = options;
|
|
884
|
+
switch (provider) {
|
|
885
|
+
case "openai":
|
|
886
|
+
return new OpenAIAdapter(rest);
|
|
887
|
+
case "deepl":
|
|
888
|
+
return new DeepLAdapter(rest);
|
|
889
|
+
case "anthropic":
|
|
890
|
+
return new AnthropicAdapter(rest);
|
|
891
|
+
case "gemini":
|
|
892
|
+
return new GeminiAdapter(rest);
|
|
893
|
+
default: {
|
|
894
|
+
const exhaustive = provider;
|
|
895
|
+
throw new Error(`AI provider "${String(exhaustive)}" has no adapter implementation yet.`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function isEmpty(value) {
|
|
900
|
+
return value === void 0 || value.trim() === "";
|
|
901
|
+
}
|
|
902
|
+
async function fillEmptyTranslations(options) {
|
|
903
|
+
const referenceFlat = flatten(options.reference);
|
|
904
|
+
const targetFlat = flatten(options.target);
|
|
905
|
+
const translatedKeys = [];
|
|
906
|
+
for (const [key, referenceValue] of Object.entries(referenceFlat)) {
|
|
907
|
+
if (isEmpty(referenceValue)) continue;
|
|
908
|
+
if (!isEmpty(targetFlat[key])) continue;
|
|
909
|
+
targetFlat[key] = await options.adapter.translate({
|
|
910
|
+
text: referenceValue,
|
|
911
|
+
sourceLocale: options.sourceLocale,
|
|
912
|
+
targetLocale: options.targetLocale
|
|
913
|
+
});
|
|
914
|
+
translatedKeys.push(key);
|
|
915
|
+
}
|
|
916
|
+
return { tree: unflatten(targetFlat), translatedKeys };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// src/commands/translate/run.ts
|
|
920
|
+
async function runTranslate(options) {
|
|
921
|
+
const loaded = await loadConfig(options.cwd);
|
|
922
|
+
if (!loaded) {
|
|
923
|
+
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
924
|
+
}
|
|
925
|
+
const { config } = loaded;
|
|
926
|
+
const referenceLocale = config.defaultLocale ?? config.locales[0];
|
|
927
|
+
const provider = options.provider ?? config.ai?.provider ?? "openai";
|
|
928
|
+
const adapter = createAdapter({
|
|
929
|
+
provider,
|
|
930
|
+
apiKey: config.ai?.apiKey,
|
|
931
|
+
model: options.model ?? config.ai?.model
|
|
932
|
+
});
|
|
933
|
+
const files = await loadLocaleFiles({
|
|
934
|
+
cwd: options.cwd,
|
|
935
|
+
inputDir: config.input,
|
|
936
|
+
locales: config.locales
|
|
937
|
+
});
|
|
938
|
+
const reference = files.find((f) => f.locale === referenceLocale);
|
|
939
|
+
if (!reference) {
|
|
940
|
+
throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
|
|
941
|
+
}
|
|
942
|
+
const targets = files.filter((f) => f.locale !== referenceLocale);
|
|
943
|
+
const written = [];
|
|
944
|
+
const planned = [];
|
|
945
|
+
const translatedByLocale = {};
|
|
946
|
+
for (const target of targets) {
|
|
947
|
+
const { tree, translatedKeys } = await fillEmptyTranslations({
|
|
948
|
+
reference: reference.translations,
|
|
949
|
+
target: target.translations,
|
|
950
|
+
sourceLocale: referenceLocale,
|
|
951
|
+
targetLocale: target.locale,
|
|
952
|
+
adapter
|
|
953
|
+
});
|
|
954
|
+
translatedByLocale[target.locale] = translatedKeys;
|
|
955
|
+
if (translatedKeys.length === 0) continue;
|
|
956
|
+
planned.push(target.path);
|
|
957
|
+
if (!options.dryRun) {
|
|
958
|
+
await writeJson(target.path, tree);
|
|
959
|
+
written.push(target.path);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return { provider, referenceLocale, written, planned, translatedByLocale };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// src/commands/translate.ts
|
|
966
|
+
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) => {
|
|
968
|
+
try {
|
|
969
|
+
const cwd = process.cwd();
|
|
970
|
+
const result = await runTranslate({
|
|
971
|
+
cwd,
|
|
972
|
+
dryRun: flags.dryRun,
|
|
973
|
+
provider: flags.provider,
|
|
974
|
+
model: flags.model
|
|
975
|
+
});
|
|
976
|
+
const totals = Object.values(result.translatedByLocale).reduce(
|
|
977
|
+
(sum, keys) => sum + keys.length,
|
|
978
|
+
0
|
|
979
|
+
);
|
|
980
|
+
if (totals === 0) {
|
|
981
|
+
logger.info(`Nothing to translate with ${chalk.cyan(result.provider)}.`);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const verb = flags.dryRun ? "Would translate" : "Translated";
|
|
985
|
+
for (const [locale, keys] of Object.entries(result.translatedByLocale)) {
|
|
986
|
+
if (keys.length === 0) continue;
|
|
987
|
+
logger.success(`${verb} ${chalk.bold(String(keys.length))} key(s) for ${locale}`);
|
|
988
|
+
}
|
|
989
|
+
if (!flags.dryRun) {
|
|
990
|
+
for (const path of result.written) {
|
|
991
|
+
logger.info(`Wrote ${chalk.bold(relative(cwd, path))}`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
} catch (error) {
|
|
995
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
996
|
+
logger.error(message);
|
|
997
|
+
process.exitCode = 1;
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
async function resolveWatchDir(cwd) {
|
|
1002
|
+
const loaded = await loadConfig(cwd);
|
|
1003
|
+
if (!loaded) {
|
|
1004
|
+
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
1005
|
+
}
|
|
1006
|
+
return resolve(cwd, loaded.config.input);
|
|
1007
|
+
}
|
|
1008
|
+
async function runWatchPass(options) {
|
|
1009
|
+
const { referenceLocale, written } = await runSync({
|
|
1010
|
+
cwd: options.cwd,
|
|
1011
|
+
dryRun: options.dryRun
|
|
1012
|
+
});
|
|
1013
|
+
const loaded = await loadConfig(options.cwd);
|
|
1014
|
+
if (!loaded) {
|
|
1015
|
+
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
1016
|
+
}
|
|
1017
|
+
const files = await loadLocaleFiles({
|
|
1018
|
+
cwd: options.cwd,
|
|
1019
|
+
inputDir: loaded.config.input,
|
|
1020
|
+
locales: loaded.config.locales
|
|
1021
|
+
});
|
|
1022
|
+
const issues = validateLocales(files, referenceLocale);
|
|
1023
|
+
return { referenceLocale, written, issues };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/commands/watch.ts
|
|
1027
|
+
function reportPass(cwd, written, issueCount) {
|
|
1028
|
+
if (written.length === 0) {
|
|
1029
|
+
logger.info("No locale changes to sync.");
|
|
1030
|
+
} else {
|
|
1031
|
+
for (const path of written) {
|
|
1032
|
+
logger.success(`Synced ${chalk.bold(relative(cwd, path))}`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (issueCount > 0) {
|
|
1036
|
+
logger.warn(`${chalk.yellow(String(issueCount))} validation issue(s) remaining.`);
|
|
1037
|
+
} else {
|
|
1038
|
+
logger.success("All locales are consistent.");
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
function registerWatchCommand(program) {
|
|
1042
|
+
program.command("watch").description("Watch locale files and run incremental sync + validation on change.").option("--dry-run", "Report planned changes without writing files.", false).option("--debounce <ms>", "Debounce window for rapid file changes.", "200").action(async (flags) => {
|
|
1043
|
+
try {
|
|
1044
|
+
const cwd = process.cwd();
|
|
1045
|
+
const watchDir = await resolveWatchDir(cwd);
|
|
1046
|
+
const debounceMs = Number.parseInt(flags.debounce, 10) || 200;
|
|
1047
|
+
const initial = await runWatchPass({ cwd, dryRun: flags.dryRun });
|
|
1048
|
+
reportPass(cwd, initial.written, initial.issues.length);
|
|
1049
|
+
logger.info(`Watching ${chalk.cyan(relative(cwd, watchDir) || ".")} for changes...`);
|
|
1050
|
+
const watcher = chokidar.watch(join(watchDir, "*.json"), {
|
|
1051
|
+
ignoreInitial: true
|
|
1052
|
+
});
|
|
1053
|
+
let timer;
|
|
1054
|
+
let running = false;
|
|
1055
|
+
const trigger = () => {
|
|
1056
|
+
if (timer) clearTimeout(timer);
|
|
1057
|
+
timer = setTimeout(() => {
|
|
1058
|
+
void (async () => {
|
|
1059
|
+
if (running) return;
|
|
1060
|
+
running = true;
|
|
1061
|
+
try {
|
|
1062
|
+
const pass = await runWatchPass({ cwd, dryRun: flags.dryRun });
|
|
1063
|
+
reportPass(cwd, pass.written, pass.issues.length);
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
logger.error(error instanceof Error ? error.message : String(error));
|
|
1066
|
+
} finally {
|
|
1067
|
+
running = false;
|
|
1068
|
+
}
|
|
1069
|
+
})();
|
|
1070
|
+
}, debounceMs);
|
|
1071
|
+
};
|
|
1072
|
+
watcher.on("change", trigger).on("add", trigger).on("unlink", trigger);
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1075
|
+
logger.error(message);
|
|
1076
|
+
process.exitCode = 1;
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
672
1081
|
// src/cli.ts
|
|
673
|
-
var VERSION = "0.
|
|
1082
|
+
var VERSION = "0.4.0" ;
|
|
674
1083
|
async function main() {
|
|
675
1084
|
assertNodeVersion(22);
|
|
676
1085
|
const program = new Command();
|
|
@@ -681,6 +1090,8 @@ async function main() {
|
|
|
681
1090
|
registerSyncCommand(program);
|
|
682
1091
|
registerValidateCommand(program);
|
|
683
1092
|
registerFindMissingCommand(program);
|
|
1093
|
+
registerTranslateCommand(program);
|
|
1094
|
+
registerWatchCommand(program);
|
|
684
1095
|
registerExportCommand(program);
|
|
685
1096
|
registerImportCommand(program);
|
|
686
1097
|
await program.parseAsync(process.argv);
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,12 @@ z.object({
|
|
|
12
12
|
excel: z.object({
|
|
13
13
|
file: z.string().default("translations.xlsx"),
|
|
14
14
|
sheetName: z.string().default("Translations")
|
|
15
|
-
}).optional()
|
|
15
|
+
}).optional(),
|
|
16
|
+
ai: z.object({
|
|
17
|
+
provider: z.enum(["openai", "deepl", "anthropic", "gemini"]).default("openai").describe("AI translation provider."),
|
|
18
|
+
apiKey: z.string().optional().describe("API key. Falls back to the provider-specific env var."),
|
|
19
|
+
model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
|
|
20
|
+
}).optional().describe("AI translation settings.")
|
|
16
21
|
});
|
|
17
22
|
function defineConfig(config) {
|
|
18
23
|
return config;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mariokreitz/langsync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Modern localization workflow tooling for TypeScript applications.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
"cli",
|
|
11
11
|
"i18next",
|
|
12
12
|
"ngx-translate",
|
|
13
|
-
"react-intl"
|
|
13
|
+
"react-intl",
|
|
14
|
+
"ai-translation",
|
|
15
|
+
"openai",
|
|
16
|
+
"watch-mode"
|
|
14
17
|
],
|
|
15
18
|
"license": "MIT",
|
|
16
19
|
"author": "Mario Kreitz",
|
|
@@ -19,7 +22,7 @@
|
|
|
19
22
|
"url": "git+https://github.com/mariokreitz/langsync.git",
|
|
20
23
|
"directory": "packages/cli"
|
|
21
24
|
},
|
|
22
|
-
"homepage": "https://
|
|
25
|
+
"homepage": "https://docs.langsync.kreitz-webdev.de",
|
|
23
26
|
"bugs": {
|
|
24
27
|
"url": "https://github.com/mariokreitz/langsync/issues"
|
|
25
28
|
},
|
|
@@ -49,6 +52,7 @@
|
|
|
49
52
|
},
|
|
50
53
|
"dependencies": {
|
|
51
54
|
"chalk": "^5.3.0",
|
|
55
|
+
"chokidar": "^4.0.3",
|
|
52
56
|
"commander": "^12.1.0",
|
|
53
57
|
"cosmiconfig": "^9.0.0",
|
|
54
58
|
"cosmiconfig-typescript-loader": "^6.1.0",
|
|
@@ -60,10 +64,11 @@
|
|
|
60
64
|
},
|
|
61
65
|
"devDependencies": {
|
|
62
66
|
"@types/prompts": "^2.4.9",
|
|
63
|
-
"memfs": "^4.
|
|
67
|
+
"memfs": "^4.57.3",
|
|
68
|
+
"@langsync/shared": "0.1.0",
|
|
64
69
|
"@langsync/core": "0.1.0",
|
|
65
70
|
"@langsync/excel-engine": "0.1.0",
|
|
66
|
-
"@langsync/
|
|
71
|
+
"@langsync/ai-engine": "0.1.0"
|
|
67
72
|
},
|
|
68
73
|
"scripts": {
|
|
69
74
|
"build": "tsup",
|