@mariokreitz/langsync-sdk 0.2.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/LICENSE +22 -0
- package/README.md +68 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +916 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mario Kreitz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @mariokreitz/langsync-sdk
|
|
2
|
+
|
|
3
|
+
> The programmatic SDK for [LangSync](https://github.com/mariokreitz/langsync) —
|
|
4
|
+
> drive validate, sync, find-missing, and translate workflows in-process from
|
|
5
|
+
> Node.js.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@mariokreitz/langsync-sdk)
|
|
8
|
+
[](https://github.com/mariokreitz/langsync/actions/workflows/ci.yml)
|
|
9
|
+
[](https://codecov.io/gh/mariokreitz/langsync)
|
|
10
|
+
[](https://nodejs.org)
|
|
11
|
+
[](https://github.com/mariokreitz/langsync/blob/main/LICENSE)
|
|
12
|
+
|
|
13
|
+
The LangSync SDK exposes the same command runners that power the `langsync` CLI
|
|
14
|
+
and the official VS Code extension. Every runner takes an explicit `cwd`,
|
|
15
|
+
returns a structured result object, and never logs, prompts, or calls
|
|
16
|
+
`process.exit` — so it is safe to embed in long-running hosts such as editors,
|
|
17
|
+
dev servers, and CI pipelines.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @mariokreitz/langsync-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm add @mariokreitz/langsync-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { loadConfig, runValidate, runSync } from '@mariokreitz/langsync-sdk';
|
|
33
|
+
|
|
34
|
+
const loaded = await loadConfig(process.cwd());
|
|
35
|
+
if (!loaded) {
|
|
36
|
+
throw new Error('No LangSync config found. Run `langsync init` first.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const validation = await runValidate({ cwd: process.cwd() });
|
|
40
|
+
if (validation.exitCode !== 0) {
|
|
41
|
+
await runSync({ cwd: process.cwd() });
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
- `loadConfig(cwd?)` — resolve and validate the LangSync config; returns `null`
|
|
48
|
+
when no config is found.
|
|
49
|
+
- `defineConfig(config)` — typed helper for authoring `langsync.config.ts`.
|
|
50
|
+
- `runValidate(options)` — report missing, extra, and empty translation keys.
|
|
51
|
+
- `runSync(options)` — add missing keys to non-reference locales (supports
|
|
52
|
+
`dryRun`).
|
|
53
|
+
- `runFindMissing(options)` — list missing keys per locale.
|
|
54
|
+
- `runTranslate(options)` — fill missing keys via an AI provider (supports
|
|
55
|
+
`dryRun`, `maxKeys`, and provider/model overrides).
|
|
56
|
+
|
|
57
|
+
See the [SDK reference](https://docs.langsync.kreitz-webdev.de/docs/sdk) for the
|
|
58
|
+
full surface, options, and result types.
|
|
59
|
+
|
|
60
|
+
## Testing
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm --filter @mariokreitz/langsync-sdk test
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export { LangSyncConfig, LangSyncConfigInput, LoadedConfig, defineConfig, loadConfig } from './config/index.js';
|
|
2
|
+
|
|
3
|
+
interface ValidationIssue {
|
|
4
|
+
type: 'missing' | 'extra' | 'empty';
|
|
5
|
+
locale: string;
|
|
6
|
+
key: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface TreeDiff {
|
|
10
|
+
/** Keys present in `next` but not in `prev`. */
|
|
11
|
+
added: string[];
|
|
12
|
+
/** Keys present in `prev` but not in `next`. */
|
|
13
|
+
removed: string[];
|
|
14
|
+
/** Keys present in both whose value changed. */
|
|
15
|
+
changed: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface RunValidateOptions {
|
|
19
|
+
cwd: string;
|
|
20
|
+
}
|
|
21
|
+
type NamespacedValidationIssue = ValidationIssue & {
|
|
22
|
+
namespace: string | null;
|
|
23
|
+
path: string;
|
|
24
|
+
};
|
|
25
|
+
interface RunValidateResult {
|
|
26
|
+
referenceLocale: string;
|
|
27
|
+
issues: NamespacedValidationIssue[];
|
|
28
|
+
exitCode: 0 | 1;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validate the locale files configured in the LangSync config at `cwd`.
|
|
32
|
+
*
|
|
33
|
+
* - `missing` and `extra` issues are treated as errors (exit code 1).
|
|
34
|
+
* - `empty` issues are treated as warnings only (exit code 0).
|
|
35
|
+
*/
|
|
36
|
+
declare function runValidate(options: RunValidateOptions): Promise<RunValidateResult>;
|
|
37
|
+
|
|
38
|
+
interface RunSyncOptions {
|
|
39
|
+
cwd: string;
|
|
40
|
+
dryRun?: boolean;
|
|
41
|
+
}
|
|
42
|
+
interface RunSyncResult {
|
|
43
|
+
referenceLocale: string;
|
|
44
|
+
/** Paths that were written to disk. Empty in dry-run. */
|
|
45
|
+
written: string[];
|
|
46
|
+
/** Paths that were planned to be written (subset of targets with changes). */
|
|
47
|
+
planned: string[];
|
|
48
|
+
/** Paths skipped because the locale was already in sync with the reference. */
|
|
49
|
+
unchanged: string[];
|
|
50
|
+
/**
|
|
51
|
+
* Per-path diff keyed by absolute file path.
|
|
52
|
+
* Only contains entries for files that had changes (i.e. entries in `planned`).
|
|
53
|
+
*/
|
|
54
|
+
diffsByPath: Record<string, TreeDiff>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Synchronize keys from the reference locale into every other locale,
|
|
58
|
+
* preserving existing translations and adding empty placeholders for new keys.
|
|
59
|
+
* Files whose content would not change after sync are skipped — no unnecessary
|
|
60
|
+
* disk writes are performed.
|
|
61
|
+
*/
|
|
62
|
+
declare function runSync(options: RunSyncOptions): Promise<RunSyncResult>;
|
|
63
|
+
|
|
64
|
+
interface RunFindMissingOptions {
|
|
65
|
+
cwd: string;
|
|
66
|
+
}
|
|
67
|
+
interface MissingEntry {
|
|
68
|
+
namespace: string | null;
|
|
69
|
+
key: string;
|
|
70
|
+
path: string;
|
|
71
|
+
}
|
|
72
|
+
interface RunFindMissingResult {
|
|
73
|
+
referenceLocale: string;
|
|
74
|
+
missingByLocale: Record<string, MissingEntry[]>;
|
|
75
|
+
exitCode: 0 | 1;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Report missing translation keys per locale, relative to the reference locale.
|
|
79
|
+
*/
|
|
80
|
+
declare function runFindMissing(options: RunFindMissingOptions): Promise<RunFindMissingResult>;
|
|
81
|
+
|
|
82
|
+
type AIProvider = 'openai' | 'deepl' | 'anthropic' | 'gemini';
|
|
83
|
+
|
|
84
|
+
interface RunTranslateOptions {
|
|
85
|
+
cwd: string;
|
|
86
|
+
dryRun?: boolean;
|
|
87
|
+
/** Override the provider configured in `langsync.config.ts`. */
|
|
88
|
+
provider?: AIProvider;
|
|
89
|
+
/** Override the model configured in `langsync.config.ts`. */
|
|
90
|
+
model?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Maximum total number of keys to translate across all target locales.
|
|
93
|
+
* Keys are selected deterministically (same order on every run) by
|
|
94
|
+
* iterating target locales in config order, then namespaces in order, then
|
|
95
|
+
* keys in reference order. Remaining untranslated keys are reported.
|
|
96
|
+
*/
|
|
97
|
+
maxKeys?: number;
|
|
98
|
+
}
|
|
99
|
+
interface TranslationEntry {
|
|
100
|
+
namespace: string | null;
|
|
101
|
+
key: string;
|
|
102
|
+
path: string;
|
|
103
|
+
}
|
|
104
|
+
interface RunTranslateResult {
|
|
105
|
+
provider: AIProvider;
|
|
106
|
+
referenceLocale: string;
|
|
107
|
+
/** Files written to disk. */
|
|
108
|
+
written: string[];
|
|
109
|
+
/** Files that have at least one translated key (the change candidates). */
|
|
110
|
+
planned: string[];
|
|
111
|
+
/** Translated dot-notated keys per locale. */
|
|
112
|
+
translatedByLocale: Record<string, TranslationEntry[]>;
|
|
113
|
+
/** Keys that were skipped due to the `maxKeys` cap, grouped by locale. */
|
|
114
|
+
skippedByLocale: Record<string, TranslationEntry[]>;
|
|
115
|
+
/** Total number of keys that could be translated before any cap is applied. */
|
|
116
|
+
totalTranslatableKeys: number;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Fill empty values in every non-reference locale using the configured AI
|
|
120
|
+
* provider, preserving existing translations.
|
|
121
|
+
*/
|
|
122
|
+
declare function runTranslate(options: RunTranslateOptions): Promise<RunTranslateResult>;
|
|
123
|
+
|
|
124
|
+
export { type AIProvider, type MissingEntry, type NamespacedValidationIssue, type RunFindMissingOptions, type RunFindMissingResult, type RunSyncOptions, type RunSyncResult, type RunTranslateOptions, type RunTranslateResult, type RunValidateOptions, type RunValidateResult, type TranslationEntry, type TreeDiff, type ValidationIssue, runFindMissing, runSync, runTranslate, runValidate };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { cosmiconfig } from 'cosmiconfig';
|
|
3
|
+
import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { mkdir, writeFile, readFile, readdir } from 'fs/promises';
|
|
6
|
+
import { resolve, relative, sep, basename, dirname, join } from 'path';
|
|
7
|
+
|
|
8
|
+
// ../shared/dist/index.js
|
|
9
|
+
chalk.bold.cyan("langsync");
|
|
10
|
+
var NamespaceConfigSchema = z.object({
|
|
11
|
+
structure: z.enum(["locale-dir", "locale-prefix"]).describe(
|
|
12
|
+
"Optional namespace layout. `locale-dir` resolves <input>/<locale>/<namespace>.json recursively. `locale-prefix` resolves <input>/<locale>.<namespace>.json."
|
|
13
|
+
)
|
|
14
|
+
});
|
|
15
|
+
var LangSyncConfigSchema = z.object({
|
|
16
|
+
input: z.string().describe("Path to the source i18n directory."),
|
|
17
|
+
output: z.string().default("./translations").describe(
|
|
18
|
+
'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
|
|
19
|
+
),
|
|
20
|
+
locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
|
|
21
|
+
defaultLocale: z.string().optional().describe(
|
|
22
|
+
"Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
|
|
23
|
+
),
|
|
24
|
+
framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
|
|
25
|
+
namespaces: NamespaceConfigSchema.optional().describe(
|
|
26
|
+
"Optional namespace settings. Omit this block to keep the default single-file layout at <input>/<locale>.json."
|
|
27
|
+
),
|
|
28
|
+
excel: z.object({
|
|
29
|
+
file: z.string().default("translations.xlsx"),
|
|
30
|
+
sheetName: z.string().default("Translations")
|
|
31
|
+
}).optional(),
|
|
32
|
+
ai: z.object({
|
|
33
|
+
provider: z.enum(["openai", "deepl", "anthropic", "gemini"]).default("openai").describe("AI translation provider."),
|
|
34
|
+
apiKey: z.string().optional().describe("API key. Falls back to the provider-specific env var."),
|
|
35
|
+
model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
|
|
36
|
+
}).optional().describe("AI translation settings.")
|
|
37
|
+
});
|
|
38
|
+
function formatZodError(error) {
|
|
39
|
+
const issues = error.issues.map((issue) => {
|
|
40
|
+
const path = issue.path.length > 0 ? ` ${issue.path.join(".")}: ` : " ";
|
|
41
|
+
return `${path}${issue.message}`;
|
|
42
|
+
});
|
|
43
|
+
return `Invalid LangSync configuration:
|
|
44
|
+
${issues.join("\n")}`;
|
|
45
|
+
}
|
|
46
|
+
function defineConfig(config) {
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
async function loadConfig(cwd = process.cwd()) {
|
|
50
|
+
const explorer = cosmiconfig("langsync", {
|
|
51
|
+
searchPlaces: [
|
|
52
|
+
"langsync.config.ts",
|
|
53
|
+
"langsync.config.js",
|
|
54
|
+
"langsync.config.mjs",
|
|
55
|
+
"langsync.config.json",
|
|
56
|
+
".langsyncrc",
|
|
57
|
+
".langsyncrc.json",
|
|
58
|
+
"package.json"
|
|
59
|
+
],
|
|
60
|
+
loaders: {
|
|
61
|
+
".ts": TypeScriptLoader()
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const result = await explorer.search(cwd);
|
|
65
|
+
if (!result) return null;
|
|
66
|
+
const parsed = LangSyncConfigSchema.safeParse(result.config);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
throw new Error(formatZodError(parsed.error));
|
|
69
|
+
}
|
|
70
|
+
return { config: parsed.data, filepath: result.filepath };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ../core/dist/index.js
|
|
74
|
+
function flatten(tree, prefix = "") {
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
77
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
78
|
+
if (typeof value === "string") {
|
|
79
|
+
out[fullKey] = value;
|
|
80
|
+
} else {
|
|
81
|
+
Object.assign(out, flatten(value, fullKey));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function unflatten(flat) {
|
|
87
|
+
const result = {};
|
|
88
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
89
|
+
const parts = key.split(".");
|
|
90
|
+
let cursor = result;
|
|
91
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
92
|
+
const part = parts[i];
|
|
93
|
+
const next = cursor[part];
|
|
94
|
+
if (typeof next !== "object" || next === null) {
|
|
95
|
+
const fresh = {};
|
|
96
|
+
cursor[part] = fresh;
|
|
97
|
+
cursor = fresh;
|
|
98
|
+
} else {
|
|
99
|
+
cursor = next;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
cursor[parts[parts.length - 1]] = value;
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
function validateLocales(files, referenceLocale) {
|
|
107
|
+
const issues = [];
|
|
108
|
+
const reference = files.find((f) => f.locale === referenceLocale);
|
|
109
|
+
if (!reference) return issues;
|
|
110
|
+
const referenceKeys = new Set(Object.keys(flatten(reference.translations)));
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
const flat = flatten(file.translations);
|
|
113
|
+
const fileKeys = new Set(Object.keys(flat));
|
|
114
|
+
if (file.locale !== referenceLocale) {
|
|
115
|
+
for (const key of referenceKeys) {
|
|
116
|
+
if (!fileKeys.has(key)) issues.push({ type: "missing", locale: file.locale, key });
|
|
117
|
+
}
|
|
118
|
+
for (const key of fileKeys) {
|
|
119
|
+
if (!referenceKeys.has(key)) issues.push({ type: "extra", locale: file.locale, key });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
123
|
+
if (!value || value.trim() === "") issues.push({ type: "empty", locale: file.locale, key });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return issues;
|
|
127
|
+
}
|
|
128
|
+
function syncTrees(source, target) {
|
|
129
|
+
const sourceFlat = flatten(source);
|
|
130
|
+
const targetFlat = flatten(target);
|
|
131
|
+
const merged = {};
|
|
132
|
+
for (const key of Object.keys(sourceFlat)) {
|
|
133
|
+
merged[key] = targetFlat[key] ?? "";
|
|
134
|
+
}
|
|
135
|
+
return unflatten(merged);
|
|
136
|
+
}
|
|
137
|
+
function diffTrees(prev, next) {
|
|
138
|
+
const prevFlat = flatten(prev);
|
|
139
|
+
const nextFlat = flatten(next);
|
|
140
|
+
const prevKeys = new Set(Object.keys(prevFlat));
|
|
141
|
+
const nextKeys = new Set(Object.keys(nextFlat));
|
|
142
|
+
const added = [];
|
|
143
|
+
const removed = [];
|
|
144
|
+
const changed = [];
|
|
145
|
+
for (const key of nextKeys) {
|
|
146
|
+
if (!prevKeys.has(key)) added.push(key);
|
|
147
|
+
else if (prevFlat[key] !== nextFlat[key]) changed.push(key);
|
|
148
|
+
}
|
|
149
|
+
for (const key of prevKeys) {
|
|
150
|
+
if (!nextKeys.has(key)) removed.push(key);
|
|
151
|
+
}
|
|
152
|
+
return { added, removed, changed };
|
|
153
|
+
}
|
|
154
|
+
function hasChanges(diff) {
|
|
155
|
+
return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
|
|
156
|
+
}
|
|
157
|
+
var NamespaceConfigSchema2 = z.object({
|
|
158
|
+
structure: z.enum(["locale-dir", "locale-prefix"]).describe(
|
|
159
|
+
"Optional namespace layout. `locale-dir` resolves <input>/<locale>/<namespace>.json recursively. `locale-prefix` resolves <input>/<locale>.<namespace>.json."
|
|
160
|
+
)
|
|
161
|
+
});
|
|
162
|
+
var LangSyncConfigSchema2 = z.object({
|
|
163
|
+
input: z.string().describe("Path to the source i18n directory."),
|
|
164
|
+
output: z.string().default("./translations").describe(
|
|
165
|
+
'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
|
|
166
|
+
),
|
|
167
|
+
locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
|
|
168
|
+
defaultLocale: z.string().optional().describe(
|
|
169
|
+
"Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
|
|
170
|
+
),
|
|
171
|
+
framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
|
|
172
|
+
namespaces: NamespaceConfigSchema2.optional().describe(
|
|
173
|
+
"Optional namespace settings. Omit this block to keep the default single-file layout at <input>/<locale>.json."
|
|
174
|
+
),
|
|
175
|
+
excel: z.object({
|
|
176
|
+
file: z.string().default("translations.xlsx"),
|
|
177
|
+
sheetName: z.string().default("Translations")
|
|
178
|
+
}).optional(),
|
|
179
|
+
ai: z.object({
|
|
180
|
+
provider: z.enum(["openai", "deepl", "anthropic", "gemini"]).default("openai").describe("AI translation provider."),
|
|
181
|
+
apiKey: z.string().optional().describe("API key. Falls back to the provider-specific env var."),
|
|
182
|
+
model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
|
|
183
|
+
}).optional().describe("AI translation settings.")
|
|
184
|
+
});
|
|
185
|
+
function formatZodError2(error) {
|
|
186
|
+
const issues = error.issues.map((issue) => {
|
|
187
|
+
const path = issue.path.length > 0 ? ` ${issue.path.join(".")}: ` : " ";
|
|
188
|
+
return `${path}${issue.message}`;
|
|
189
|
+
});
|
|
190
|
+
return `Invalid LangSync configuration:
|
|
191
|
+
${issues.join("\n")}`;
|
|
192
|
+
}
|
|
193
|
+
async function loadConfig2(cwd = process.cwd()) {
|
|
194
|
+
const explorer = cosmiconfig("langsync", {
|
|
195
|
+
searchPlaces: [
|
|
196
|
+
"langsync.config.ts",
|
|
197
|
+
"langsync.config.js",
|
|
198
|
+
"langsync.config.mjs",
|
|
199
|
+
"langsync.config.json",
|
|
200
|
+
".langsyncrc",
|
|
201
|
+
".langsyncrc.json",
|
|
202
|
+
"package.json"
|
|
203
|
+
],
|
|
204
|
+
loaders: {
|
|
205
|
+
".ts": TypeScriptLoader()
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
const result = await explorer.search(cwd);
|
|
209
|
+
if (!result) return null;
|
|
210
|
+
const parsed = LangSyncConfigSchema2.safeParse(result.config);
|
|
211
|
+
if (!parsed.success) {
|
|
212
|
+
throw new Error(formatZodError2(parsed.error));
|
|
213
|
+
}
|
|
214
|
+
return { config: parsed.data, filepath: result.filepath };
|
|
215
|
+
}
|
|
216
|
+
async function writeJson(filePath, data, { indent = 2 } = {}) {
|
|
217
|
+
const absolute = resolve(filePath);
|
|
218
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
219
|
+
await writeFile(absolute, JSON.stringify(data, null, indent) + "\n", "utf-8");
|
|
220
|
+
}
|
|
221
|
+
function isWithinDirectory(path, directory) {
|
|
222
|
+
return path === directory || path.startsWith(directory + sep);
|
|
223
|
+
}
|
|
224
|
+
function validateNamespace(namespace, structure) {
|
|
225
|
+
if (namespace.trim() === "") {
|
|
226
|
+
throw new Error('Invalid namespace "": namespace must not be empty.');
|
|
227
|
+
}
|
|
228
|
+
if (namespace.includes("\\")) {
|
|
229
|
+
throw new Error(`Invalid namespace "${namespace}": backslashes are not allowed.`);
|
|
230
|
+
}
|
|
231
|
+
if (namespace.startsWith("/")) {
|
|
232
|
+
throw new Error(`Invalid namespace "${namespace}": absolute namespace paths are not allowed.`);
|
|
233
|
+
}
|
|
234
|
+
if (structure === "locale-prefix" && namespace.includes("/")) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Invalid namespace "${namespace}": locale-prefix namespaces must not contain '/'.`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (namespace.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
240
|
+
throw new Error(`Invalid namespace "${namespace}": path traversal segments are not allowed.`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function resolveLocaleFilePath(args) {
|
|
244
|
+
const inputAbs = resolve(args.cwd, args.inputDir);
|
|
245
|
+
if (!args.namespaces) {
|
|
246
|
+
if (args.namespace !== null) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Invalid namespace "${args.namespace}": single-file mode cannot resolve a namespace.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const path2 = resolve(inputAbs, `${args.locale}.json`);
|
|
252
|
+
if (!isWithinDirectory(path2, inputAbs)) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`Resolved locale file path escapes input directory for locale "${args.locale}".`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return path2;
|
|
258
|
+
}
|
|
259
|
+
if (args.namespace === null) {
|
|
260
|
+
throw new Error("Namespaced mode requires a non-null namespace.");
|
|
261
|
+
}
|
|
262
|
+
validateNamespace(args.namespace, args.namespaces.structure);
|
|
263
|
+
const path = args.namespaces.structure === "locale-dir" ? resolve(inputAbs, args.locale, `${args.namespace}.json`) : resolve(inputAbs, `${args.locale}.${args.namespace}.json`);
|
|
264
|
+
if (!isWithinDirectory(path, inputAbs)) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`Resolved locale file path escapes input directory for namespace "${args.namespace}".`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return path;
|
|
270
|
+
}
|
|
271
|
+
async function readTranslationFile(path, logicalPath) {
|
|
272
|
+
const content = await readFile(path, "utf-8");
|
|
273
|
+
try {
|
|
274
|
+
return JSON.parse(content);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
+
throw new Error(`Failed to parse ${logicalPath}: ${message}`, { cause: error });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function listJsonFilesRecursive(directory) {
|
|
281
|
+
let entries;
|
|
282
|
+
try {
|
|
283
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const errno = error.code;
|
|
286
|
+
if (errno === "ENOENT") return [];
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
const files = [];
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
const path = join(directory, entry.name);
|
|
292
|
+
if (entry.isDirectory()) {
|
|
293
|
+
files.push(...await listJsonFilesRecursive(path));
|
|
294
|
+
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
295
|
+
files.push(path);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return files.sort();
|
|
299
|
+
}
|
|
300
|
+
async function listDirectJsonFiles(directory) {
|
|
301
|
+
let entries;
|
|
302
|
+
try {
|
|
303
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
304
|
+
} catch (error) {
|
|
305
|
+
const errno = error.code;
|
|
306
|
+
if (errno === "ENOENT") return [];
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => join(directory, entry.name)).sort();
|
|
310
|
+
}
|
|
311
|
+
function orderAndSynthesizeFiles(loaded, options) {
|
|
312
|
+
const namespaces = [
|
|
313
|
+
...new Set(loaded.map((file) => file.namespace).filter((ns) => ns !== null))
|
|
314
|
+
].sort();
|
|
315
|
+
if (namespaces.length === 0) return [];
|
|
316
|
+
const byKey = new Map(loaded.map((file) => [`${file.locale}\0${file.namespace}`, file]));
|
|
317
|
+
const ordered = [];
|
|
318
|
+
for (const locale of options.locales) {
|
|
319
|
+
for (const namespace of namespaces) {
|
|
320
|
+
const key = `${locale}\0${namespace}`;
|
|
321
|
+
const existing = byKey.get(key);
|
|
322
|
+
if (existing) {
|
|
323
|
+
ordered.push(existing);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
ordered.push({
|
|
327
|
+
locale,
|
|
328
|
+
namespace,
|
|
329
|
+
path: resolveLocaleFilePath({
|
|
330
|
+
cwd: options.cwd,
|
|
331
|
+
inputDir: options.inputDir,
|
|
332
|
+
locale,
|
|
333
|
+
namespace,
|
|
334
|
+
namespaces: options.namespaces
|
|
335
|
+
}),
|
|
336
|
+
translations: {},
|
|
337
|
+
exists: false
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return ordered;
|
|
342
|
+
}
|
|
343
|
+
async function loadLocaleFiles(options) {
|
|
344
|
+
const inputAbs = resolve(options.cwd, options.inputDir);
|
|
345
|
+
if (!options.namespaces) {
|
|
346
|
+
const out = [];
|
|
347
|
+
for (const locale of options.locales) {
|
|
348
|
+
const path = resolveLocaleFilePath({
|
|
349
|
+
cwd: options.cwd,
|
|
350
|
+
inputDir: options.inputDir,
|
|
351
|
+
locale,
|
|
352
|
+
namespace: null
|
|
353
|
+
});
|
|
354
|
+
let translations = {};
|
|
355
|
+
let exists = false;
|
|
356
|
+
try {
|
|
357
|
+
translations = await readTranslationFile(path, `${locale}.json`);
|
|
358
|
+
exists = true;
|
|
359
|
+
} catch (error) {
|
|
360
|
+
const errno = error.code;
|
|
361
|
+
if (errno !== "ENOENT") throw error;
|
|
362
|
+
}
|
|
363
|
+
out.push({ locale, namespace: null, path, translations, exists });
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
const loaded = [];
|
|
368
|
+
if (options.namespaces.structure === "locale-dir") {
|
|
369
|
+
for (const locale of options.locales) {
|
|
370
|
+
const localeDir = resolve(inputAbs, locale);
|
|
371
|
+
if (!isWithinDirectory(localeDir, inputAbs)) {
|
|
372
|
+
throw new Error(`Locale "${locale}" resolves outside the input directory.`);
|
|
373
|
+
}
|
|
374
|
+
const paths2 = await listJsonFilesRecursive(localeDir);
|
|
375
|
+
for (const path of paths2) {
|
|
376
|
+
const namespace = relative(localeDir, path).slice(0, -".json".length).split(sep).join("/");
|
|
377
|
+
validateNamespace(namespace, "locale-dir");
|
|
378
|
+
loaded.push({
|
|
379
|
+
locale,
|
|
380
|
+
namespace,
|
|
381
|
+
path,
|
|
382
|
+
translations: await readTranslationFile(path, `${locale}/${namespace}.json`),
|
|
383
|
+
exists: true
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return orderAndSynthesizeFiles(loaded, options);
|
|
388
|
+
}
|
|
389
|
+
const sortedLocales = [...options.locales].sort((a, b) => b.length - a.length);
|
|
390
|
+
const paths = await listDirectJsonFiles(inputAbs);
|
|
391
|
+
for (const path of paths) {
|
|
392
|
+
const fileName = basename(path);
|
|
393
|
+
const locale = sortedLocales.find((candidate) => fileName.startsWith(`${candidate}.`));
|
|
394
|
+
if (!locale) continue;
|
|
395
|
+
const namespace = fileName.slice(locale.length + 1, -".json".length);
|
|
396
|
+
if (namespace.trim() === "") continue;
|
|
397
|
+
validateNamespace(namespace, "locale-prefix");
|
|
398
|
+
loaded.push({
|
|
399
|
+
locale,
|
|
400
|
+
namespace,
|
|
401
|
+
path,
|
|
402
|
+
translations: await readTranslationFile(path, fileName),
|
|
403
|
+
exists: true
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return orderAndSynthesizeFiles(loaded, options);
|
|
407
|
+
}
|
|
408
|
+
function indexLocaleFiles(files) {
|
|
409
|
+
const byLocale = {};
|
|
410
|
+
const namespaceSet = /* @__PURE__ */ new Set();
|
|
411
|
+
for (const file of files) {
|
|
412
|
+
byLocale[file.locale] ??= /* @__PURE__ */ new Map();
|
|
413
|
+
byLocale[file.locale].set(file.namespace, file);
|
|
414
|
+
if (file.namespace !== null) namespaceSet.add(file.namespace);
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
files,
|
|
418
|
+
namespaces: [...namespaceSet].sort(),
|
|
419
|
+
byLocale
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ../shared/dist/errors/index.js
|
|
424
|
+
function noNamespacesError(referenceLocale, inputDir) {
|
|
425
|
+
return new Error(
|
|
426
|
+
`No namespace files found under "${inputDir}". Run \`langsync init\` or create at least one namespace file for "${referenceLocale}".`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// src/validate.ts
|
|
431
|
+
async function runValidate(options) {
|
|
432
|
+
const loaded = await loadConfig2(options.cwd);
|
|
433
|
+
if (!loaded) {
|
|
434
|
+
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
435
|
+
}
|
|
436
|
+
const { config } = loaded;
|
|
437
|
+
const referenceLocale = config.defaultLocale ?? config.locales[0];
|
|
438
|
+
const files = await loadLocaleFiles({
|
|
439
|
+
cwd: options.cwd,
|
|
440
|
+
inputDir: config.input,
|
|
441
|
+
locales: config.locales,
|
|
442
|
+
namespaces: config.namespaces
|
|
443
|
+
});
|
|
444
|
+
const index = indexLocaleFiles(files);
|
|
445
|
+
const namespaced = config.namespaces !== void 0;
|
|
446
|
+
if (namespaced && index.namespaces.length === 0) {
|
|
447
|
+
throw noNamespacesError(referenceLocale, config.input);
|
|
448
|
+
}
|
|
449
|
+
const nsKeys = namespaced ? index.namespaces : [null];
|
|
450
|
+
const issues = [];
|
|
451
|
+
for (const nsKey of nsKeys) {
|
|
452
|
+
const namespaceFiles = config.locales.map((locale) => index.byLocale[locale]?.get(nsKey)).filter((file) => file !== void 0);
|
|
453
|
+
const namespaceIssues = validateLocales(namespaceFiles, referenceLocale);
|
|
454
|
+
for (const issue of namespaceIssues) {
|
|
455
|
+
const file = index.byLocale[issue.locale]?.get(nsKey);
|
|
456
|
+
if (!file) continue;
|
|
457
|
+
issues.push({ ...issue, namespace: nsKey, path: file.path });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
issues.sort((a, b) => {
|
|
461
|
+
const namespaceA = a.namespace ?? "";
|
|
462
|
+
const namespaceB = b.namespace ?? "";
|
|
463
|
+
return a.locale.localeCompare(b.locale) || namespaceA.localeCompare(namespaceB) || a.key.localeCompare(b.key) || a.type.localeCompare(b.type);
|
|
464
|
+
});
|
|
465
|
+
const hasErrors = issues.some((i) => i.type === "missing" || i.type === "extra");
|
|
466
|
+
return {
|
|
467
|
+
referenceLocale,
|
|
468
|
+
issues,
|
|
469
|
+
exitCode: hasErrors ? 1 : 0
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/sync.ts
|
|
474
|
+
async function runSync(options) {
|
|
475
|
+
const loaded = await loadConfig2(options.cwd);
|
|
476
|
+
if (!loaded) {
|
|
477
|
+
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
478
|
+
}
|
|
479
|
+
const { config } = loaded;
|
|
480
|
+
const referenceLocale = config.defaultLocale ?? config.locales[0];
|
|
481
|
+
const files = await loadLocaleFiles({
|
|
482
|
+
cwd: options.cwd,
|
|
483
|
+
inputDir: config.input,
|
|
484
|
+
locales: config.locales,
|
|
485
|
+
namespaces: config.namespaces
|
|
486
|
+
});
|
|
487
|
+
const index = indexLocaleFiles(files);
|
|
488
|
+
const namespaced = config.namespaces !== void 0;
|
|
489
|
+
if (namespaced && index.namespaces.length === 0) {
|
|
490
|
+
throw noNamespacesError(referenceLocale, config.input);
|
|
491
|
+
}
|
|
492
|
+
const nsKeys = namespaced ? index.namespaces : [null];
|
|
493
|
+
const referenceBucket = index.byLocale[referenceLocale];
|
|
494
|
+
if (!referenceBucket) {
|
|
495
|
+
throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
|
|
496
|
+
}
|
|
497
|
+
const planned = [];
|
|
498
|
+
const written = [];
|
|
499
|
+
const unchanged = [];
|
|
500
|
+
const diffsByPath = {};
|
|
501
|
+
for (const targetLocale of config.locales) {
|
|
502
|
+
if (targetLocale === referenceLocale) continue;
|
|
503
|
+
const targetBucket = index.byLocale[targetLocale];
|
|
504
|
+
if (!targetBucket) continue;
|
|
505
|
+
for (const nsKey of nsKeys) {
|
|
506
|
+
const source = referenceBucket.get(nsKey);
|
|
507
|
+
const target = targetBucket.get(nsKey);
|
|
508
|
+
if (!source || !target) continue;
|
|
509
|
+
const merged = syncTrees(source.translations, target.translations);
|
|
510
|
+
const diff = diffTrees(target.translations, merged);
|
|
511
|
+
if (!hasChanges(diff)) {
|
|
512
|
+
unchanged.push(target.path);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
diffsByPath[target.path] = diff;
|
|
516
|
+
planned.push(target.path);
|
|
517
|
+
if (!options.dryRun) {
|
|
518
|
+
await writeJson(target.path, merged);
|
|
519
|
+
written.push(target.path);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return { referenceLocale, written, planned, unchanged, diffsByPath };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/find-missing.ts
|
|
527
|
+
async function runFindMissing(options) {
|
|
528
|
+
const { issues, referenceLocale } = await runValidate({ cwd: options.cwd });
|
|
529
|
+
const missingByLocale = {};
|
|
530
|
+
for (const issue of issues) {
|
|
531
|
+
if (issue.type !== "missing") continue;
|
|
532
|
+
(missingByLocale[issue.locale] ??= []).push({
|
|
533
|
+
namespace: issue.namespace,
|
|
534
|
+
key: issue.key,
|
|
535
|
+
path: issue.path
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
for (const entries of Object.values(missingByLocale)) {
|
|
539
|
+
entries.sort(
|
|
540
|
+
(a, b) => (a.namespace ?? "").localeCompare(b.namespace ?? "") || a.key.localeCompare(b.key)
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
const exitCode = Object.keys(missingByLocale).length === 0 ? 0 : 1;
|
|
544
|
+
return { referenceLocale, missingByLocale, exitCode };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ../ai-engine/dist/index.js
|
|
548
|
+
var DEFAULT_MODEL = "gpt-5-mini";
|
|
549
|
+
var ENDPOINT = "https://api.openai.com/v1/chat/completions";
|
|
550
|
+
var OpenAIAdapter = class {
|
|
551
|
+
provider = "openai";
|
|
552
|
+
apiKey;
|
|
553
|
+
model;
|
|
554
|
+
fetchImpl;
|
|
555
|
+
constructor(options = {}) {
|
|
556
|
+
const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
|
|
557
|
+
if (!apiKey) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
"OpenAI API key missing. Set `ai.apiKey` in your config or the OPENAI_API_KEY env var."
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
this.apiKey = apiKey;
|
|
563
|
+
this.model = options.model ?? DEFAULT_MODEL;
|
|
564
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
565
|
+
}
|
|
566
|
+
async translate(request) {
|
|
567
|
+
const response = await this.fetchImpl(ENDPOINT, {
|
|
568
|
+
method: "POST",
|
|
569
|
+
headers: {
|
|
570
|
+
"content-type": "application/json",
|
|
571
|
+
authorization: `Bearer ${this.apiKey}`
|
|
572
|
+
},
|
|
573
|
+
body: JSON.stringify({
|
|
574
|
+
model: this.model,
|
|
575
|
+
temperature: 0,
|
|
576
|
+
messages: [
|
|
577
|
+
{
|
|
578
|
+
role: "system",
|
|
579
|
+
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.`
|
|
580
|
+
},
|
|
581
|
+
{ role: "user", content: request.text }
|
|
582
|
+
]
|
|
583
|
+
})
|
|
584
|
+
});
|
|
585
|
+
if (!response.ok) {
|
|
586
|
+
throw new Error(`OpenAI request failed: ${response.status} ${response.statusText}`);
|
|
587
|
+
}
|
|
588
|
+
const data = await response.json();
|
|
589
|
+
const content = data.choices?.[0]?.message?.content?.trim();
|
|
590
|
+
if (!content) {
|
|
591
|
+
throw new Error("OpenAI returned an empty translation.");
|
|
592
|
+
}
|
|
593
|
+
return content;
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
var FREE_ENDPOINT = "https://api-free.deepl.com/v2/translate";
|
|
597
|
+
var PRO_ENDPOINT = "https://api.deepl.com/v2/translate";
|
|
598
|
+
var FREE_KEY_SUFFIX = ":fx";
|
|
599
|
+
function toDeepLLang(locale) {
|
|
600
|
+
return locale.split("-")[0].toUpperCase();
|
|
601
|
+
}
|
|
602
|
+
var DeepLAdapter = class {
|
|
603
|
+
provider = "deepl";
|
|
604
|
+
apiKey;
|
|
605
|
+
endpoint;
|
|
606
|
+
fetchImpl;
|
|
607
|
+
constructor(options = {}) {
|
|
608
|
+
const apiKey = options.apiKey ?? process.env.DEEPL_API_KEY;
|
|
609
|
+
if (!apiKey) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
"DeepL API key missing. Set `ai.apiKey` in your config or the DEEPL_API_KEY env var."
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
this.apiKey = apiKey;
|
|
615
|
+
const useFreeTier = options.useFreeTier ?? apiKey.endsWith(FREE_KEY_SUFFIX);
|
|
616
|
+
this.endpoint = useFreeTier ? FREE_ENDPOINT : PRO_ENDPOINT;
|
|
617
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
618
|
+
}
|
|
619
|
+
async translate(request) {
|
|
620
|
+
const response = await this.fetchImpl(this.endpoint, {
|
|
621
|
+
method: "POST",
|
|
622
|
+
headers: {
|
|
623
|
+
"content-type": "application/json",
|
|
624
|
+
authorization: `DeepL-Auth-Key ${this.apiKey}`
|
|
625
|
+
},
|
|
626
|
+
body: JSON.stringify({
|
|
627
|
+
text: [request.text],
|
|
628
|
+
source_lang: toDeepLLang(request.sourceLocale),
|
|
629
|
+
target_lang: toDeepLLang(request.targetLocale)
|
|
630
|
+
})
|
|
631
|
+
});
|
|
632
|
+
if (!response.ok) {
|
|
633
|
+
throw new Error(`DeepL request failed: ${response.status} ${response.statusText}`);
|
|
634
|
+
}
|
|
635
|
+
const data = await response.json();
|
|
636
|
+
const content = data.translations?.[0]?.text?.trim();
|
|
637
|
+
if (!content) {
|
|
638
|
+
throw new Error("DeepL returned an empty translation.");
|
|
639
|
+
}
|
|
640
|
+
return content;
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
var DEFAULT_MODEL2 = "claude-haiku-4-5";
|
|
644
|
+
var ENDPOINT2 = "https://api.anthropic.com/v1/messages";
|
|
645
|
+
var ANTHROPIC_VERSION = "2023-06-01";
|
|
646
|
+
var MAX_TOKENS = 1024;
|
|
647
|
+
var AnthropicAdapter = class {
|
|
648
|
+
provider = "anthropic";
|
|
649
|
+
apiKey;
|
|
650
|
+
model;
|
|
651
|
+
fetchImpl;
|
|
652
|
+
constructor(options = {}) {
|
|
653
|
+
const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
|
654
|
+
if (!apiKey) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
"Anthropic API key missing. Set `ai.apiKey` in your config or the ANTHROPIC_API_KEY env var."
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
this.apiKey = apiKey;
|
|
660
|
+
this.model = options.model ?? DEFAULT_MODEL2;
|
|
661
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
662
|
+
}
|
|
663
|
+
async translate(request) {
|
|
664
|
+
const response = await this.fetchImpl(ENDPOINT2, {
|
|
665
|
+
method: "POST",
|
|
666
|
+
headers: {
|
|
667
|
+
"content-type": "application/json",
|
|
668
|
+
"x-api-key": this.apiKey,
|
|
669
|
+
"anthropic-version": ANTHROPIC_VERSION
|
|
670
|
+
},
|
|
671
|
+
body: JSON.stringify({
|
|
672
|
+
model: this.model,
|
|
673
|
+
max_tokens: MAX_TOKENS,
|
|
674
|
+
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.`,
|
|
675
|
+
messages: [{ role: "user", content: request.text }]
|
|
676
|
+
})
|
|
677
|
+
});
|
|
678
|
+
if (!response.ok) {
|
|
679
|
+
throw new Error(`Anthropic request failed: ${response.status} ${response.statusText}`);
|
|
680
|
+
}
|
|
681
|
+
const data = await response.json();
|
|
682
|
+
const content = data.content?.find((block) => block.type === "text" || block.text)?.text?.trim();
|
|
683
|
+
if (!content) {
|
|
684
|
+
throw new Error("Anthropic returned an empty translation.");
|
|
685
|
+
}
|
|
686
|
+
return content;
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
var DEFAULT_MODEL3 = "gemini-3-flash";
|
|
690
|
+
var BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
|
|
691
|
+
var GeminiAdapter = class {
|
|
692
|
+
provider = "gemini";
|
|
693
|
+
apiKey;
|
|
694
|
+
model;
|
|
695
|
+
fetchImpl;
|
|
696
|
+
constructor(options = {}) {
|
|
697
|
+
const apiKey = options.apiKey ?? process.env.GEMINI_API_KEY;
|
|
698
|
+
if (!apiKey) {
|
|
699
|
+
throw new Error(
|
|
700
|
+
"Gemini API key missing. Set `ai.apiKey` in your config or the GEMINI_API_KEY env var."
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
this.apiKey = apiKey;
|
|
704
|
+
this.model = options.model ?? DEFAULT_MODEL3;
|
|
705
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
706
|
+
}
|
|
707
|
+
async translate(request) {
|
|
708
|
+
const url = `${BASE_URL}/${this.model}:generateContent?key=${this.apiKey}`;
|
|
709
|
+
const response = await this.fetchImpl(url, {
|
|
710
|
+
method: "POST",
|
|
711
|
+
headers: { "content-type": "application/json" },
|
|
712
|
+
body: JSON.stringify({
|
|
713
|
+
systemInstruction: {
|
|
714
|
+
parts: [
|
|
715
|
+
{
|
|
716
|
+
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.`
|
|
717
|
+
}
|
|
718
|
+
]
|
|
719
|
+
},
|
|
720
|
+
contents: [{ parts: [{ text: request.text }] }],
|
|
721
|
+
generationConfig: { temperature: 0 }
|
|
722
|
+
})
|
|
723
|
+
});
|
|
724
|
+
if (!response.ok) {
|
|
725
|
+
throw new Error(`Gemini request failed: ${response.status} ${response.statusText}`);
|
|
726
|
+
}
|
|
727
|
+
const data = await response.json();
|
|
728
|
+
const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
|
|
729
|
+
if (!content) {
|
|
730
|
+
throw new Error("Gemini returned an empty translation.");
|
|
731
|
+
}
|
|
732
|
+
return content;
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
var RELEASED_PROVIDERS = ["openai", "deepl"];
|
|
736
|
+
var ALL_PROVIDERS = ["openai", "deepl", "anthropic", "gemini"];
|
|
737
|
+
function experimentalEnabled() {
|
|
738
|
+
return process.env.LANGSYNC_AI_EXPERIMENTAL === "1";
|
|
739
|
+
}
|
|
740
|
+
function availableProviders() {
|
|
741
|
+
return experimentalEnabled() ? [...ALL_PROVIDERS] : [...RELEASED_PROVIDERS];
|
|
742
|
+
}
|
|
743
|
+
function createAdapter(options) {
|
|
744
|
+
if (!availableProviders().includes(options.provider)) {
|
|
745
|
+
if (ALL_PROVIDERS.includes(options.provider)) {
|
|
746
|
+
throw new Error(
|
|
747
|
+
`AI provider "${options.provider}" is not yet available. Currently supported: ${availableProviders().join(", ")}.`
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
throw new Error(`Unknown AI provider "${options.provider}".`);
|
|
751
|
+
}
|
|
752
|
+
const { provider, ...rest } = options;
|
|
753
|
+
switch (provider) {
|
|
754
|
+
case "openai":
|
|
755
|
+
return new OpenAIAdapter(rest);
|
|
756
|
+
case "deepl":
|
|
757
|
+
return new DeepLAdapter(rest);
|
|
758
|
+
case "anthropic":
|
|
759
|
+
return new AnthropicAdapter(rest);
|
|
760
|
+
case "gemini":
|
|
761
|
+
return new GeminiAdapter(rest);
|
|
762
|
+
default: {
|
|
763
|
+
const exhaustive = provider;
|
|
764
|
+
throw new Error(`AI provider "${String(exhaustive)}" has no adapter implementation yet.`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
function isEmpty(value) {
|
|
769
|
+
return value === void 0 || value.trim() === "";
|
|
770
|
+
}
|
|
771
|
+
async function fillEmptyTranslations(options) {
|
|
772
|
+
const referenceFlat = flatten(options.reference);
|
|
773
|
+
const targetFlat = flatten(options.target);
|
|
774
|
+
const translatedKeys = [];
|
|
775
|
+
const skippedKeys = [];
|
|
776
|
+
for (const [key, referenceValue] of Object.entries(referenceFlat)) {
|
|
777
|
+
if (isEmpty(referenceValue)) continue;
|
|
778
|
+
if (!isEmpty(targetFlat[key])) continue;
|
|
779
|
+
if (options.maxKeys !== void 0 && translatedKeys.length >= options.maxKeys) {
|
|
780
|
+
skippedKeys.push(key);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
targetFlat[key] = await options.adapter.translate({
|
|
784
|
+
text: referenceValue,
|
|
785
|
+
sourceLocale: options.sourceLocale,
|
|
786
|
+
targetLocale: options.targetLocale
|
|
787
|
+
});
|
|
788
|
+
translatedKeys.push(key);
|
|
789
|
+
}
|
|
790
|
+
return { tree: unflatten(targetFlat), translatedKeys, skippedKeys };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/translate.ts
|
|
794
|
+
function isEmpty2(value) {
|
|
795
|
+
return value === void 0 || value.trim() === "";
|
|
796
|
+
}
|
|
797
|
+
function candidateEntry(candidate) {
|
|
798
|
+
return { namespace: candidate.namespace, key: candidate.key, path: candidate.path };
|
|
799
|
+
}
|
|
800
|
+
async function runTranslate(options) {
|
|
801
|
+
const loaded = await loadConfig2(options.cwd);
|
|
802
|
+
if (!loaded) {
|
|
803
|
+
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
804
|
+
}
|
|
805
|
+
const { config } = loaded;
|
|
806
|
+
const referenceLocale = config.defaultLocale ?? config.locales[0];
|
|
807
|
+
const provider = options.provider ?? config.ai?.provider ?? "openai";
|
|
808
|
+
const files = await loadLocaleFiles({
|
|
809
|
+
cwd: options.cwd,
|
|
810
|
+
inputDir: config.input,
|
|
811
|
+
locales: config.locales,
|
|
812
|
+
namespaces: config.namespaces
|
|
813
|
+
});
|
|
814
|
+
const index = indexLocaleFiles(files);
|
|
815
|
+
const namespaced = config.namespaces !== void 0;
|
|
816
|
+
if (namespaced && index.namespaces.length === 0) {
|
|
817
|
+
throw noNamespacesError(referenceLocale, config.input);
|
|
818
|
+
}
|
|
819
|
+
const nsKeys = namespaced ? index.namespaces : [null];
|
|
820
|
+
const referenceBucket = index.byLocale[referenceLocale];
|
|
821
|
+
if (!referenceBucket) {
|
|
822
|
+
throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
|
|
823
|
+
}
|
|
824
|
+
const adapter = createAdapter({
|
|
825
|
+
provider,
|
|
826
|
+
apiKey: config.ai?.apiKey,
|
|
827
|
+
model: options.model ?? config.ai?.model
|
|
828
|
+
});
|
|
829
|
+
const allCandidates = [];
|
|
830
|
+
for (const targetLocale of config.locales) {
|
|
831
|
+
if (targetLocale === referenceLocale) continue;
|
|
832
|
+
const targetBucket = index.byLocale[targetLocale];
|
|
833
|
+
if (!targetBucket) continue;
|
|
834
|
+
for (const nsKey of nsKeys) {
|
|
835
|
+
const reference = referenceBucket.get(nsKey);
|
|
836
|
+
const target = targetBucket.get(nsKey);
|
|
837
|
+
if (!reference || !target) continue;
|
|
838
|
+
const refFlat = flatten(reference.translations);
|
|
839
|
+
const targetFlat = flatten(target.translations);
|
|
840
|
+
for (const [key, value] of Object.entries(refFlat)) {
|
|
841
|
+
if (isEmpty2(value)) continue;
|
|
842
|
+
if (!isEmpty2(targetFlat[key])) continue;
|
|
843
|
+
allCandidates.push({
|
|
844
|
+
locale: targetLocale,
|
|
845
|
+
namespace: nsKey,
|
|
846
|
+
path: target.path,
|
|
847
|
+
key,
|
|
848
|
+
sourceValue: value
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const totalTranslatableKeys = allCandidates.length;
|
|
854
|
+
const limited = options.maxKeys ? allCandidates.slice(0, options.maxKeys) : allCandidates;
|
|
855
|
+
const limitedByPath = /* @__PURE__ */ new Map();
|
|
856
|
+
const skippedCandidates = options.maxKeys ? allCandidates.slice(options.maxKeys) : [];
|
|
857
|
+
for (const candidate of limited) {
|
|
858
|
+
const entries = limitedByPath.get(candidate.path) ?? [];
|
|
859
|
+
entries.push(candidate);
|
|
860
|
+
limitedByPath.set(candidate.path, entries);
|
|
861
|
+
}
|
|
862
|
+
const written = [];
|
|
863
|
+
const planned = [];
|
|
864
|
+
const translatedByLocale = {};
|
|
865
|
+
const skippedByLocale = {};
|
|
866
|
+
for (const candidate of skippedCandidates) {
|
|
867
|
+
if (limitedByPath.has(candidate.path)) continue;
|
|
868
|
+
(skippedByLocale[candidate.locale] ??= []).push(candidateEntry(candidate));
|
|
869
|
+
}
|
|
870
|
+
for (const targetLocale of config.locales) {
|
|
871
|
+
if (targetLocale === referenceLocale) continue;
|
|
872
|
+
const targetBucket = index.byLocale[targetLocale];
|
|
873
|
+
if (!targetBucket) continue;
|
|
874
|
+
for (const nsKey of nsKeys) {
|
|
875
|
+
const reference = referenceBucket.get(nsKey);
|
|
876
|
+
const target = targetBucket.get(nsKey);
|
|
877
|
+
if (!reference || !target) continue;
|
|
878
|
+
const budget = limitedByPath.get(target.path)?.length;
|
|
879
|
+
if (budget === void 0) continue;
|
|
880
|
+
const { tree, translatedKeys, skippedKeys } = await fillEmptyTranslations({
|
|
881
|
+
reference: reference.translations,
|
|
882
|
+
target: target.translations,
|
|
883
|
+
sourceLocale: referenceLocale,
|
|
884
|
+
targetLocale,
|
|
885
|
+
adapter,
|
|
886
|
+
maxKeys: budget
|
|
887
|
+
});
|
|
888
|
+
if (translatedKeys.length > 0) {
|
|
889
|
+
(translatedByLocale[targetLocale] ??= []).push(
|
|
890
|
+
...translatedKeys.map((key) => ({ namespace: nsKey, key, path: target.path }))
|
|
891
|
+
);
|
|
892
|
+
planned.push(target.path);
|
|
893
|
+
if (!options.dryRun) {
|
|
894
|
+
await writeJson(target.path, tree);
|
|
895
|
+
written.push(target.path);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (skippedKeys.length > 0) {
|
|
899
|
+
(skippedByLocale[targetLocale] ??= []).push(
|
|
900
|
+
...skippedKeys.map((key) => ({ namespace: nsKey, key, path: target.path }))
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return {
|
|
906
|
+
provider,
|
|
907
|
+
referenceLocale,
|
|
908
|
+
written,
|
|
909
|
+
planned,
|
|
910
|
+
translatedByLocale,
|
|
911
|
+
skippedByLocale,
|
|
912
|
+
totalTranslatableKeys
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
export { defineConfig, loadConfig, runFindMissing, runSync, runTranslate, runValidate };
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mariokreitz/langsync-sdk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Programmatic SDK for LangSync — run validate, sync, find-missing, and translate localization workflows in-process from Node.js.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"langsync",
|
|
7
|
+
"sdk",
|
|
8
|
+
"i18n",
|
|
9
|
+
"l10n",
|
|
10
|
+
"localization",
|
|
11
|
+
"internationalization",
|
|
12
|
+
"translation",
|
|
13
|
+
"locales"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Mario Kreitz",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/mariokreitz/langsync.git",
|
|
20
|
+
"directory": "packages/sdk"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://docs.langsync.kreitz-webdev.de/docs/sdk",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/mariokreitz/langsync/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"chalk": "^5.6.2",
|
|
46
|
+
"cosmiconfig": "^9.0.1",
|
|
47
|
+
"cosmiconfig-typescript-loader": "^6.1.0",
|
|
48
|
+
"zod": "^3.25.76"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@langsync/ai-engine": "0.2.2",
|
|
52
|
+
"@langsync/core": "0.1.3",
|
|
53
|
+
"@langsync/shared": "0.2.2"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsup",
|
|
60
|
+
"dev": "tsup --watch",
|
|
61
|
+
"lint": "eslint src",
|
|
62
|
+
"typecheck": "tsc --noEmit",
|
|
63
|
+
"test": "vitest run --passWithNoTests",
|
|
64
|
+
"clean": "rm -rf dist .turbo"
|
|
65
|
+
}
|
|
66
|
+
}
|