@mariokreitz/langsync 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -8
- package/dist/cli.js +764 -119
- package/dist/index.d.ts +1 -1
- package/dist/index.js +15 -3
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ chaos of hand-edited JSON or fragile Excel hand-offs.
|
|
|
14
14
|
- **Bidirectional Excel I/O** for non-technical translators.
|
|
15
15
|
- **Strict validation** with CI-friendly exit codes and JSON output.
|
|
16
16
|
- **Auto-detected integrations** for `i18next`, `ngx-translate`, `react-intl`.
|
|
17
|
-
- **Interactive setup** that scaffolds your config and locale files.
|
|
17
|
+
- **Interactive setup** that scaffolds your config and single-file or namespaced locale files.
|
|
18
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
@@ -59,16 +59,16 @@ npx langsync import excel
|
|
|
59
59
|
| Command | Description |
|
|
60
60
|
| ----------------------- | ------------------------------------------------------------------- |
|
|
61
61
|
| `langsync init` | Initialize a typed `langsync.config.ts` and scaffold locale files. |
|
|
62
|
-
| `langsync validate` | Report missing, extra, and empty keys
|
|
62
|
+
| `langsync validate` | Report missing, extra, and empty keys across locales/namespaces. |
|
|
63
63
|
| `langsync find-missing` | Report missing keys per locale; exits non-zero on errors. |
|
|
64
|
-
| `langsync sync` | Synchronize keys
|
|
64
|
+
| `langsync sync` | Synchronize reference keys into each target locale or namespace. |
|
|
65
65
|
| `langsync translate` | Fill empty values in non-reference locales using an AI provider. |
|
|
66
66
|
| `langsync watch` | Watch locale files and run incremental sync + validation on change. |
|
|
67
|
-
| `langsync export excel` | Export
|
|
68
|
-
| `langsync import excel` | Import translations
|
|
67
|
+
| `langsync export excel` | Export locales/namespaces into a single `.xlsx` workbook. |
|
|
68
|
+
| `langsync import excel` | Import workbook translations back into configured JSON files. |
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
`--dry-run`.
|
|
70
|
+
The `validate` and `find-missing` commands support `--reporter json`. The
|
|
71
|
+
`sync`, `translate`, and `import excel` commands support `--dry-run`.
|
|
72
72
|
|
|
73
73
|
## Configuration
|
|
74
74
|
|
|
@@ -83,6 +83,9 @@ export default defineConfig({
|
|
|
83
83
|
locales: ['en', 'de', 'fr'],
|
|
84
84
|
defaultLocale: 'en',
|
|
85
85
|
framework: 'i18next',
|
|
86
|
+
// Opt in to namespaced files when your project outgrows one file per locale.
|
|
87
|
+
// namespaces: { structure: 'locale-dir' }, // ./src/i18n/en/common.json
|
|
88
|
+
// namespaces: { structure: 'locale-prefix' }, // ./src/i18n/en.common.json
|
|
86
89
|
excel: {
|
|
87
90
|
file: 'translations.xlsx',
|
|
88
91
|
sheetName: 'Translations',
|
|
@@ -95,7 +98,9 @@ export default defineConfig({
|
|
|
95
98
|
```
|
|
96
99
|
|
|
97
100
|
JSON, JS, and MJS configs are also supported via cosmiconfig. Omit `framework`
|
|
98
|
-
or set `framework: 'none'` for custom setups.
|
|
101
|
+
or set `framework: 'none'` for custom setups. Omit `namespaces` for the default
|
|
102
|
+
`<input>/<locale>.json` layout, or set `namespaces.structure` to `locale-dir`
|
|
103
|
+
or `locale-prefix` for per-namespace files.
|
|
99
104
|
|
|
100
105
|
## Documentation
|
|
101
106
|
|
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import ora from 'ora';
|
|
5
|
-
import { relative, join, resolve, dirname } from 'path';
|
|
6
|
-
import { readFile, mkdir, writeFile, access } from 'fs/promises';
|
|
5
|
+
import { relative, join, resolve, dirname, sep, basename } from 'path';
|
|
6
|
+
import { readFile, mkdir, writeFile, access, readdir } from 'fs/promises';
|
|
7
7
|
import prompts from 'prompts';
|
|
8
8
|
import { cosmiconfig } from 'cosmiconfig';
|
|
9
9
|
import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
|
|
@@ -73,12 +73,30 @@ var FRAMEWORK_CHOICES = [
|
|
|
73
73
|
{ title: "react-intl", value: "react-intl" },
|
|
74
74
|
{ title: "None / Custom", value: "none" }
|
|
75
75
|
];
|
|
76
|
+
var LOCALE_LAYOUT_CHOICES = [
|
|
77
|
+
{
|
|
78
|
+
title: "Single file per locale (<input>/<locale>.json)",
|
|
79
|
+
value: "single-file"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
title: "Namespace folders per locale (<input>/<locale>/<namespace>.json)",
|
|
83
|
+
value: "locale-dir"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
title: "Flat namespace-prefixed files (<input>/<locale>.<namespace>.json)",
|
|
87
|
+
value: "locale-prefix"
|
|
88
|
+
}
|
|
89
|
+
];
|
|
76
90
|
var DEFAULTS = {
|
|
77
91
|
input: "./src/i18n",
|
|
78
92
|
output: "./translations",
|
|
79
93
|
locales: "en,de",
|
|
80
94
|
defaultLocale: "en"
|
|
81
95
|
};
|
|
96
|
+
function parseNamespaces(value) {
|
|
97
|
+
const namespaces = value.split(",").map((namespace) => namespace.trim()).filter(Boolean);
|
|
98
|
+
return namespaces.length > 0 ? namespaces : ["common"];
|
|
99
|
+
}
|
|
82
100
|
async function runInitPrompts(options) {
|
|
83
101
|
const framework = options.detectedFramework ?? "none";
|
|
84
102
|
if (options.yes) {
|
|
@@ -98,6 +116,20 @@ async function runInitPrompts(options) {
|
|
|
98
116
|
message: "Where are your source i18n files?",
|
|
99
117
|
initial: DEFAULTS.input
|
|
100
118
|
},
|
|
119
|
+
{
|
|
120
|
+
type: "select",
|
|
121
|
+
name: "localeLayout",
|
|
122
|
+
message: "How should locale files be organized?",
|
|
123
|
+
choices: LOCALE_LAYOUT_CHOICES,
|
|
124
|
+
initial: 0
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
type: (prev) => prev === "single-file" ? null : "text",
|
|
128
|
+
name: "initialNamespaces",
|
|
129
|
+
message: "Initial namespaces? (comma-separated)",
|
|
130
|
+
initial: "common",
|
|
131
|
+
format: parseNamespaces
|
|
132
|
+
},
|
|
101
133
|
{
|
|
102
134
|
type: "text",
|
|
103
135
|
name: "output",
|
|
@@ -135,7 +167,18 @@ async function runInitPrompts(options) {
|
|
|
135
167
|
}
|
|
136
168
|
}
|
|
137
169
|
);
|
|
138
|
-
|
|
170
|
+
const answers = {
|
|
171
|
+
input: response.input,
|
|
172
|
+
output: response.output,
|
|
173
|
+
locales: response.locales,
|
|
174
|
+
defaultLocale: response.defaultLocale,
|
|
175
|
+
framework: response.framework
|
|
176
|
+
};
|
|
177
|
+
if (response.localeLayout !== "single-file") {
|
|
178
|
+
answers.namespaces = { structure: response.localeLayout };
|
|
179
|
+
answers.initialNamespaces = response.initialNamespaces?.length ? response.initialNamespaces : ["common"];
|
|
180
|
+
}
|
|
181
|
+
return answers;
|
|
139
182
|
}
|
|
140
183
|
var CANDIDATE_CONFIG_FILES = [
|
|
141
184
|
"langsync.config.ts",
|
|
@@ -161,6 +204,8 @@ async function findExistingConfig(cwd) {
|
|
|
161
204
|
function renderTsConfig(answers) {
|
|
162
205
|
const frameworkLine = answers.framework === "none" ? "" : ` framework: '${answers.framework}',
|
|
163
206
|
`;
|
|
207
|
+
const namespacesLine = answers.namespaces ? ` namespaces: { structure: '${answers.namespaces.structure}' },
|
|
208
|
+
` : "";
|
|
164
209
|
const localesArr = answers.locales.map((l) => `'${l}'`).join(", ");
|
|
165
210
|
return `import { defineConfig } from '@mariokreitz/langsync';
|
|
166
211
|
|
|
@@ -169,7 +214,7 @@ export default defineConfig({
|
|
|
169
214
|
output: '${answers.output}',
|
|
170
215
|
locales: [${localesArr}],
|
|
171
216
|
defaultLocale: '${answers.defaultLocale}',
|
|
172
|
-
${frameworkLine}});
|
|
217
|
+
${frameworkLine}${namespacesLine}});
|
|
173
218
|
`;
|
|
174
219
|
}
|
|
175
220
|
function renderJsonConfig(answers) {
|
|
@@ -180,8 +225,23 @@ function renderJsonConfig(answers) {
|
|
|
180
225
|
defaultLocale: answers.defaultLocale
|
|
181
226
|
};
|
|
182
227
|
if (answers.framework !== "none") obj.framework = answers.framework;
|
|
228
|
+
if (answers.namespaces) obj.namespaces = answers.namespaces;
|
|
183
229
|
return JSON.stringify(obj, null, 2) + "\n";
|
|
184
230
|
}
|
|
231
|
+
function getLocaleStubPaths(inputDir, answers) {
|
|
232
|
+
if (!answers.namespaces) {
|
|
233
|
+
return answers.locales.map((locale) => join(inputDir, `${locale}.json`));
|
|
234
|
+
}
|
|
235
|
+
const initialNamespaces = answers.initialNamespaces?.length ? answers.initialNamespaces : ["common"];
|
|
236
|
+
return answers.locales.flatMap(
|
|
237
|
+
(locale) => initialNamespaces.map((namespace) => {
|
|
238
|
+
if (answers.namespaces?.structure === "locale-dir") {
|
|
239
|
+
return join(inputDir, locale, `${namespace}.json`);
|
|
240
|
+
}
|
|
241
|
+
return join(inputDir, `${locale}.${namespace}.json`);
|
|
242
|
+
})
|
|
243
|
+
);
|
|
244
|
+
}
|
|
185
245
|
async function writeConfig(options) {
|
|
186
246
|
const { cwd, answers, format, force } = options;
|
|
187
247
|
const existing = await findExistingConfig(cwd);
|
|
@@ -198,9 +258,9 @@ async function writeConfig(options) {
|
|
|
198
258
|
const inputDir = resolve(cwd, answers.input);
|
|
199
259
|
await mkdir(inputDir, { recursive: true });
|
|
200
260
|
const createdLocaleFiles = [];
|
|
201
|
-
for (const
|
|
202
|
-
const localePath = join(inputDir, `${locale}.json`);
|
|
261
|
+
for (const localePath of getLocaleStubPaths(inputDir, answers)) {
|
|
203
262
|
if (!await pathExists(localePath)) {
|
|
263
|
+
await mkdir(dirname(localePath), { recursive: true });
|
|
204
264
|
await writeFile(localePath, "{}\n", "utf-8");
|
|
205
265
|
createdLocaleFiles.push(localePath);
|
|
206
266
|
}
|
|
@@ -245,12 +305,24 @@ function registerInitCommand(program) {
|
|
|
245
305
|
}
|
|
246
306
|
});
|
|
247
307
|
}
|
|
308
|
+
var NamespaceConfigSchema = z.object({
|
|
309
|
+
structure: z.enum(["locale-dir", "locale-prefix"]).describe(
|
|
310
|
+
"Optional namespace layout. `locale-dir` resolves <input>/<locale>/<namespace>.json recursively. `locale-prefix` resolves <input>/<locale>.<namespace>.json."
|
|
311
|
+
)
|
|
312
|
+
});
|
|
248
313
|
var LangSyncConfigSchema = z.object({
|
|
249
314
|
input: z.string().describe("Path to the source i18n directory."),
|
|
250
|
-
output: z.string().
|
|
251
|
-
|
|
252
|
-
|
|
315
|
+
output: z.string().default("./translations").describe(
|
|
316
|
+
'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
|
|
317
|
+
),
|
|
318
|
+
locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
|
|
319
|
+
defaultLocale: z.string().optional().describe(
|
|
320
|
+
"Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
|
|
321
|
+
),
|
|
253
322
|
framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
|
|
323
|
+
namespaces: NamespaceConfigSchema.optional().describe(
|
|
324
|
+
"Optional namespace settings. Omit this block to keep the default single-file layout at <input>/<locale>.json."
|
|
325
|
+
),
|
|
254
326
|
excel: z.object({
|
|
255
327
|
file: z.string().default("translations.xlsx"),
|
|
256
328
|
sheetName: z.string().default("Translations")
|
|
@@ -261,6 +333,14 @@ var LangSyncConfigSchema = z.object({
|
|
|
261
333
|
model: z.string().optional().describe("Provider model id (e.g. gpt-5-mini).")
|
|
262
334
|
}).optional().describe("AI translation settings.")
|
|
263
335
|
});
|
|
336
|
+
function formatZodError(error) {
|
|
337
|
+
const issues = error.issues.map((issue) => {
|
|
338
|
+
const path = issue.path.length > 0 ? ` ${issue.path.join(".")}: ` : " ";
|
|
339
|
+
return `${path}${issue.message}`;
|
|
340
|
+
});
|
|
341
|
+
return `Invalid LangSync configuration:
|
|
342
|
+
${issues.join("\n")}`;
|
|
343
|
+
}
|
|
264
344
|
async function loadConfig(cwd = process.cwd()) {
|
|
265
345
|
const explorer = cosmiconfig("langsync", {
|
|
266
346
|
searchPlaces: [
|
|
@@ -278,35 +358,217 @@ async function loadConfig(cwd = process.cwd()) {
|
|
|
278
358
|
});
|
|
279
359
|
const result = await explorer.search(cwd);
|
|
280
360
|
if (!result) return null;
|
|
281
|
-
const parsed = LangSyncConfigSchema.
|
|
282
|
-
|
|
361
|
+
const parsed = LangSyncConfigSchema.safeParse(result.config);
|
|
362
|
+
if (!parsed.success) {
|
|
363
|
+
throw new Error(formatZodError(parsed.error));
|
|
364
|
+
}
|
|
365
|
+
return { config: parsed.data, filepath: result.filepath };
|
|
283
366
|
}
|
|
284
367
|
async function writeJson(filePath, data, { indent = 2 } = {}) {
|
|
285
368
|
const absolute = resolve(filePath);
|
|
286
369
|
await mkdir(dirname(absolute), { recursive: true });
|
|
287
370
|
await writeFile(absolute, JSON.stringify(data, null, indent) + "\n", "utf-8");
|
|
288
371
|
}
|
|
372
|
+
function isWithinDirectory(path, directory) {
|
|
373
|
+
return path === directory || path.startsWith(directory + sep);
|
|
374
|
+
}
|
|
375
|
+
function validateNamespace(namespace, structure) {
|
|
376
|
+
if (namespace.trim() === "") {
|
|
377
|
+
throw new Error('Invalid namespace "": namespace must not be empty.');
|
|
378
|
+
}
|
|
379
|
+
if (namespace.includes("\\")) {
|
|
380
|
+
throw new Error(`Invalid namespace "${namespace}": backslashes are not allowed.`);
|
|
381
|
+
}
|
|
382
|
+
if (namespace.startsWith("/")) {
|
|
383
|
+
throw new Error(`Invalid namespace "${namespace}": absolute namespace paths are not allowed.`);
|
|
384
|
+
}
|
|
385
|
+
if (structure === "locale-prefix" && namespace.includes("/")) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Invalid namespace "${namespace}": locale-prefix namespaces must not contain '/'.`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (namespace.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
391
|
+
throw new Error(`Invalid namespace "${namespace}": path traversal segments are not allowed.`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function resolveLocaleFilePath(args) {
|
|
395
|
+
const inputAbs = resolve(args.cwd, args.inputDir);
|
|
396
|
+
if (!args.namespaces) {
|
|
397
|
+
if (args.namespace !== null) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Invalid namespace "${args.namespace}": single-file mode cannot resolve a namespace.`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
const path2 = resolve(inputAbs, `${args.locale}.json`);
|
|
403
|
+
if (!isWithinDirectory(path2, inputAbs)) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Resolved locale file path escapes input directory for locale "${args.locale}".`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
return path2;
|
|
409
|
+
}
|
|
410
|
+
if (args.namespace === null) {
|
|
411
|
+
throw new Error("Namespaced mode requires a non-null namespace.");
|
|
412
|
+
}
|
|
413
|
+
validateNamespace(args.namespace, args.namespaces.structure);
|
|
414
|
+
const path = args.namespaces.structure === "locale-dir" ? resolve(inputAbs, args.locale, `${args.namespace}.json`) : resolve(inputAbs, `${args.locale}.${args.namespace}.json`);
|
|
415
|
+
if (!isWithinDirectory(path, inputAbs)) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`Resolved locale file path escapes input directory for namespace "${args.namespace}".`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return path;
|
|
421
|
+
}
|
|
422
|
+
async function readTranslationFile(path, logicalPath) {
|
|
423
|
+
const content = await readFile(path, "utf-8");
|
|
424
|
+
try {
|
|
425
|
+
return JSON.parse(content);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
428
|
+
throw new Error(`Failed to parse ${logicalPath}: ${message}`, { cause: error });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async function listJsonFilesRecursive(directory) {
|
|
432
|
+
let entries;
|
|
433
|
+
try {
|
|
434
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
435
|
+
} catch (error) {
|
|
436
|
+
const errno = error.code;
|
|
437
|
+
if (errno === "ENOENT") return [];
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
const files = [];
|
|
441
|
+
for (const entry of entries) {
|
|
442
|
+
const path = join(directory, entry.name);
|
|
443
|
+
if (entry.isDirectory()) {
|
|
444
|
+
files.push(...await listJsonFilesRecursive(path));
|
|
445
|
+
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
446
|
+
files.push(path);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return files.sort();
|
|
450
|
+
}
|
|
451
|
+
async function listDirectJsonFiles(directory) {
|
|
452
|
+
let entries;
|
|
453
|
+
try {
|
|
454
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
455
|
+
} catch (error) {
|
|
456
|
+
const errno = error.code;
|
|
457
|
+
if (errno === "ENOENT") return [];
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => join(directory, entry.name)).sort();
|
|
461
|
+
}
|
|
462
|
+
function orderAndSynthesizeFiles(loaded, options) {
|
|
463
|
+
const namespaces = [
|
|
464
|
+
...new Set(loaded.map((file) => file.namespace).filter((ns) => ns !== null))
|
|
465
|
+
].sort();
|
|
466
|
+
if (namespaces.length === 0) return [];
|
|
467
|
+
const byKey = new Map(loaded.map((file) => [`${file.locale}\0${file.namespace}`, file]));
|
|
468
|
+
const ordered = [];
|
|
469
|
+
for (const locale of options.locales) {
|
|
470
|
+
for (const namespace of namespaces) {
|
|
471
|
+
const key = `${locale}\0${namespace}`;
|
|
472
|
+
const existing = byKey.get(key);
|
|
473
|
+
if (existing) {
|
|
474
|
+
ordered.push(existing);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
ordered.push({
|
|
478
|
+
locale,
|
|
479
|
+
namespace,
|
|
480
|
+
path: resolveLocaleFilePath({
|
|
481
|
+
cwd: options.cwd,
|
|
482
|
+
inputDir: options.inputDir,
|
|
483
|
+
locale,
|
|
484
|
+
namespace,
|
|
485
|
+
namespaces: options.namespaces
|
|
486
|
+
}),
|
|
487
|
+
translations: {},
|
|
488
|
+
exists: false
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return ordered;
|
|
493
|
+
}
|
|
289
494
|
async function loadLocaleFiles(options) {
|
|
290
495
|
const inputAbs = resolve(options.cwd, options.inputDir);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
496
|
+
if (!options.namespaces) {
|
|
497
|
+
const out = [];
|
|
498
|
+
for (const locale of options.locales) {
|
|
499
|
+
const path = resolveLocaleFilePath({
|
|
500
|
+
cwd: options.cwd,
|
|
501
|
+
inputDir: options.inputDir,
|
|
502
|
+
locale,
|
|
503
|
+
namespace: null
|
|
504
|
+
});
|
|
505
|
+
let translations = {};
|
|
506
|
+
let exists = false;
|
|
297
507
|
try {
|
|
298
|
-
translations =
|
|
508
|
+
translations = await readTranslationFile(path, `${locale}.json`);
|
|
509
|
+
exists = true;
|
|
299
510
|
} catch (error) {
|
|
300
|
-
const
|
|
301
|
-
|
|
511
|
+
const errno = error.code;
|
|
512
|
+
if (errno !== "ENOENT") throw error;
|
|
302
513
|
}
|
|
303
|
-
|
|
304
|
-
const errno = error.code;
|
|
305
|
-
if (errno !== "ENOENT") throw error;
|
|
514
|
+
out.push({ locale, namespace: null, path, translations, exists });
|
|
306
515
|
}
|
|
307
|
-
out
|
|
516
|
+
return out;
|
|
308
517
|
}
|
|
309
|
-
|
|
518
|
+
const loaded = [];
|
|
519
|
+
if (options.namespaces.structure === "locale-dir") {
|
|
520
|
+
for (const locale of options.locales) {
|
|
521
|
+
const localeDir = resolve(inputAbs, locale);
|
|
522
|
+
if (!isWithinDirectory(localeDir, inputAbs)) {
|
|
523
|
+
throw new Error(`Locale "${locale}" resolves outside the input directory.`);
|
|
524
|
+
}
|
|
525
|
+
const paths2 = await listJsonFilesRecursive(localeDir);
|
|
526
|
+
for (const path of paths2) {
|
|
527
|
+
const namespace = relative(localeDir, path).slice(0, -".json".length).split(sep).join("/");
|
|
528
|
+
validateNamespace(namespace, "locale-dir");
|
|
529
|
+
loaded.push({
|
|
530
|
+
locale,
|
|
531
|
+
namespace,
|
|
532
|
+
path,
|
|
533
|
+
translations: await readTranslationFile(path, `${locale}/${namespace}.json`),
|
|
534
|
+
exists: true
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return orderAndSynthesizeFiles(loaded, options);
|
|
539
|
+
}
|
|
540
|
+
const sortedLocales = [...options.locales].sort((a, b) => b.length - a.length);
|
|
541
|
+
const paths = await listDirectJsonFiles(inputAbs);
|
|
542
|
+
for (const path of paths) {
|
|
543
|
+
const fileName = basename(path);
|
|
544
|
+
const locale = sortedLocales.find((candidate) => fileName.startsWith(`${candidate}.`));
|
|
545
|
+
if (!locale) continue;
|
|
546
|
+
const namespace = fileName.slice(locale.length + 1, -".json".length);
|
|
547
|
+
if (namespace.trim() === "") continue;
|
|
548
|
+
validateNamespace(namespace, "locale-prefix");
|
|
549
|
+
loaded.push({
|
|
550
|
+
locale,
|
|
551
|
+
namespace,
|
|
552
|
+
path,
|
|
553
|
+
translations: await readTranslationFile(path, fileName),
|
|
554
|
+
exists: true
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return orderAndSynthesizeFiles(loaded, options);
|
|
558
|
+
}
|
|
559
|
+
function indexLocaleFiles(files) {
|
|
560
|
+
const byLocale = {};
|
|
561
|
+
const namespaceSet = /* @__PURE__ */ new Set();
|
|
562
|
+
for (const file of files) {
|
|
563
|
+
byLocale[file.locale] ??= /* @__PURE__ */ new Map();
|
|
564
|
+
byLocale[file.locale].set(file.namespace, file);
|
|
565
|
+
if (file.namespace !== null) namespaceSet.add(file.namespace);
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
files,
|
|
569
|
+
namespaces: [...namespaceSet].sort(),
|
|
570
|
+
byLocale
|
|
571
|
+
};
|
|
310
572
|
}
|
|
311
573
|
|
|
312
574
|
// ../core/dist/index.js
|
|
@@ -373,6 +635,33 @@ function syncTrees(source, target) {
|
|
|
373
635
|
}
|
|
374
636
|
return unflatten(merged);
|
|
375
637
|
}
|
|
638
|
+
function diffTrees(prev, next) {
|
|
639
|
+
const prevFlat = flatten(prev);
|
|
640
|
+
const nextFlat = flatten(next);
|
|
641
|
+
const prevKeys = new Set(Object.keys(prevFlat));
|
|
642
|
+
const nextKeys = new Set(Object.keys(nextFlat));
|
|
643
|
+
const added = [];
|
|
644
|
+
const removed = [];
|
|
645
|
+
const changed = [];
|
|
646
|
+
for (const key of nextKeys) {
|
|
647
|
+
if (!prevKeys.has(key)) added.push(key);
|
|
648
|
+
else if (prevFlat[key] !== nextFlat[key]) changed.push(key);
|
|
649
|
+
}
|
|
650
|
+
for (const key of prevKeys) {
|
|
651
|
+
if (!nextKeys.has(key)) removed.push(key);
|
|
652
|
+
}
|
|
653
|
+
return { added, removed, changed };
|
|
654
|
+
}
|
|
655
|
+
function hasChanges(diff) {
|
|
656
|
+
return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/commands/shared/namespace-error.ts
|
|
660
|
+
function noNamespacesError(referenceLocale, inputDir) {
|
|
661
|
+
return new Error(
|
|
662
|
+
`No namespace files found under "${inputDir}". Run \`langsync init\` or create at least one namespace file for "${referenceLocale}".`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
376
665
|
|
|
377
666
|
// src/commands/sync/run.ts
|
|
378
667
|
async function runSync(options) {
|
|
@@ -385,24 +674,46 @@ async function runSync(options) {
|
|
|
385
674
|
const files = await loadLocaleFiles({
|
|
386
675
|
cwd: options.cwd,
|
|
387
676
|
inputDir: config.input,
|
|
388
|
-
locales: config.locales
|
|
677
|
+
locales: config.locales,
|
|
678
|
+
namespaces: config.namespaces
|
|
389
679
|
});
|
|
390
|
-
const
|
|
391
|
-
|
|
680
|
+
const index = indexLocaleFiles(files);
|
|
681
|
+
const namespaced = config.namespaces !== void 0;
|
|
682
|
+
if (namespaced && index.namespaces.length === 0) {
|
|
683
|
+
throw noNamespacesError(referenceLocale, config.input);
|
|
684
|
+
}
|
|
685
|
+
const nsKeys = namespaced ? index.namespaces : [null];
|
|
686
|
+
const referenceBucket = index.byLocale[referenceLocale];
|
|
687
|
+
if (!referenceBucket) {
|
|
392
688
|
throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
|
|
393
689
|
}
|
|
394
|
-
const targets = files.filter((f) => f.locale !== referenceLocale);
|
|
395
690
|
const planned = [];
|
|
396
691
|
const written = [];
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (
|
|
401
|
-
|
|
402
|
-
|
|
692
|
+
const unchanged = [];
|
|
693
|
+
const diffsByPath = {};
|
|
694
|
+
for (const targetLocale of config.locales) {
|
|
695
|
+
if (targetLocale === referenceLocale) continue;
|
|
696
|
+
const targetBucket = index.byLocale[targetLocale];
|
|
697
|
+
if (!targetBucket) continue;
|
|
698
|
+
for (const nsKey of nsKeys) {
|
|
699
|
+
const source = referenceBucket.get(nsKey);
|
|
700
|
+
const target = targetBucket.get(nsKey);
|
|
701
|
+
if (!source || !target) continue;
|
|
702
|
+
const merged = syncTrees(source.translations, target.translations);
|
|
703
|
+
const diff = diffTrees(target.translations, merged);
|
|
704
|
+
if (!hasChanges(diff)) {
|
|
705
|
+
unchanged.push(target.path);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
diffsByPath[target.path] = diff;
|
|
709
|
+
planned.push(target.path);
|
|
710
|
+
if (!options.dryRun) {
|
|
711
|
+
await writeJson(target.path, merged);
|
|
712
|
+
written.push(target.path);
|
|
713
|
+
}
|
|
403
714
|
}
|
|
404
715
|
}
|
|
405
|
-
return { referenceLocale, written, planned };
|
|
716
|
+
return { referenceLocale, written, planned, unchanged, diffsByPath };
|
|
406
717
|
}
|
|
407
718
|
|
|
408
719
|
// src/commands/sync.ts
|
|
@@ -442,9 +753,30 @@ async function runValidate(options) {
|
|
|
442
753
|
const files = await loadLocaleFiles({
|
|
443
754
|
cwd: options.cwd,
|
|
444
755
|
inputDir: config.input,
|
|
445
|
-
locales: config.locales
|
|
756
|
+
locales: config.locales,
|
|
757
|
+
namespaces: config.namespaces
|
|
758
|
+
});
|
|
759
|
+
const index = indexLocaleFiles(files);
|
|
760
|
+
const namespaced = config.namespaces !== void 0;
|
|
761
|
+
if (namespaced && index.namespaces.length === 0) {
|
|
762
|
+
throw noNamespacesError(referenceLocale, config.input);
|
|
763
|
+
}
|
|
764
|
+
const nsKeys = namespaced ? index.namespaces : [null];
|
|
765
|
+
const issues = [];
|
|
766
|
+
for (const nsKey of nsKeys) {
|
|
767
|
+
const namespaceFiles = config.locales.map((locale) => index.byLocale[locale]?.get(nsKey)).filter((file) => file !== void 0);
|
|
768
|
+
const namespaceIssues = validateLocales(namespaceFiles, referenceLocale);
|
|
769
|
+
for (const issue of namespaceIssues) {
|
|
770
|
+
const file = index.byLocale[issue.locale]?.get(nsKey);
|
|
771
|
+
if (!file) continue;
|
|
772
|
+
issues.push({ ...issue, namespace: nsKey, path: file.path });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
issues.sort((a, b) => {
|
|
776
|
+
const namespaceA = a.namespace ?? "";
|
|
777
|
+
const namespaceB = b.namespace ?? "";
|
|
778
|
+
return a.locale.localeCompare(b.locale) || namespaceA.localeCompare(namespaceB) || a.key.localeCompare(b.key) || a.type.localeCompare(b.type);
|
|
446
779
|
});
|
|
447
|
-
const issues = validateLocales(files, referenceLocale);
|
|
448
780
|
const hasErrors = issues.some((i) => i.type === "missing" || i.type === "extra");
|
|
449
781
|
return {
|
|
450
782
|
referenceLocale,
|
|
@@ -454,6 +786,9 @@ async function runValidate(options) {
|
|
|
454
786
|
}
|
|
455
787
|
|
|
456
788
|
// src/commands/validate.ts
|
|
789
|
+
function formatIssueKey(issue) {
|
|
790
|
+
return issue.namespace === null ? issue.key : `${issue.namespace}:${issue.key}`;
|
|
791
|
+
}
|
|
457
792
|
function registerValidateCommand(program) {
|
|
458
793
|
program.command("validate").description("Validate locale consistency, structure and missing keys.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
|
|
459
794
|
try {
|
|
@@ -468,7 +803,7 @@ function registerValidateCommand(program) {
|
|
|
468
803
|
for (const issue of issues) {
|
|
469
804
|
byType[issue.type]++;
|
|
470
805
|
const colored = issue.type === "empty" ? chalk.yellow(issue.type) : chalk.red(issue.type);
|
|
471
|
-
logger.info(`${colored} ${chalk.cyan(issue.locale)} ${issue
|
|
806
|
+
logger.info(`${colored} ${chalk.cyan(issue.locale)} ${formatIssueKey(issue)}`);
|
|
472
807
|
}
|
|
473
808
|
console.log();
|
|
474
809
|
logger.info(
|
|
@@ -491,16 +826,25 @@ async function runFindMissing(options) {
|
|
|
491
826
|
const missingByLocale = {};
|
|
492
827
|
for (const issue of issues) {
|
|
493
828
|
if (issue.type !== "missing") continue;
|
|
494
|
-
(missingByLocale[issue.locale] ??= []).push(
|
|
829
|
+
(missingByLocale[issue.locale] ??= []).push({
|
|
830
|
+
namespace: issue.namespace,
|
|
831
|
+
key: issue.key,
|
|
832
|
+
path: issue.path
|
|
833
|
+
});
|
|
495
834
|
}
|
|
496
|
-
for (const
|
|
497
|
-
|
|
835
|
+
for (const entries of Object.values(missingByLocale)) {
|
|
836
|
+
entries.sort(
|
|
837
|
+
(a, b) => (a.namespace ?? "").localeCompare(b.namespace ?? "") || a.key.localeCompare(b.key)
|
|
838
|
+
);
|
|
498
839
|
}
|
|
499
840
|
const exitCode = Object.keys(missingByLocale).length === 0 ? 0 : 1;
|
|
500
841
|
return { referenceLocale, missingByLocale, exitCode };
|
|
501
842
|
}
|
|
502
843
|
|
|
503
844
|
// src/commands/find-missing.ts
|
|
845
|
+
function formatMissingEntry(entry) {
|
|
846
|
+
return entry.namespace === null ? entry.key : `${entry.namespace}:${entry.key}`;
|
|
847
|
+
}
|
|
504
848
|
function registerFindMissingCommand(program) {
|
|
505
849
|
program.command("find-missing").description("Find missing translation keys across locales.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
|
|
506
850
|
try {
|
|
@@ -514,7 +858,7 @@ function registerFindMissingCommand(program) {
|
|
|
514
858
|
} else {
|
|
515
859
|
for (const [locale, keys] of Object.entries(missingByLocale)) {
|
|
516
860
|
logger.warn(`${chalk.cyan(locale)} is missing ${keys.length} key(s):`);
|
|
517
|
-
for (const
|
|
861
|
+
for (const entry of keys) console.log(` - ${formatMissingEntry(entry)}`);
|
|
518
862
|
}
|
|
519
863
|
}
|
|
520
864
|
process.exitCode = exitCode;
|
|
@@ -525,49 +869,159 @@ function registerFindMissingCommand(program) {
|
|
|
525
869
|
}
|
|
526
870
|
});
|
|
527
871
|
}
|
|
872
|
+
function distinctLocales(files) {
|
|
873
|
+
const seen = /* @__PURE__ */ new Set();
|
|
874
|
+
const locales = [];
|
|
875
|
+
for (const file of files) {
|
|
876
|
+
if (seen.has(file.locale)) continue;
|
|
877
|
+
seen.add(file.locale);
|
|
878
|
+
locales.push(file.locale);
|
|
879
|
+
}
|
|
880
|
+
return locales;
|
|
881
|
+
}
|
|
882
|
+
function cellText(value) {
|
|
883
|
+
return typeof value === "string" ? value : "";
|
|
884
|
+
}
|
|
885
|
+
function addHeader(sheet, header) {
|
|
886
|
+
sheet.addRow(header);
|
|
887
|
+
sheet.getRow(1).font = { bold: true };
|
|
888
|
+
}
|
|
889
|
+
function exportSingleFile(sheet, files) {
|
|
890
|
+
const locales = distinctLocales(files);
|
|
891
|
+
addHeader(sheet, ["key", ...locales]);
|
|
892
|
+
const flatByLocale = /* @__PURE__ */ new Map();
|
|
893
|
+
for (const file of files) {
|
|
894
|
+
flatByLocale.set(file.locale, flatten(file.translations));
|
|
895
|
+
}
|
|
896
|
+
const allKeys = /* @__PURE__ */ new Set();
|
|
897
|
+
for (const flat of flatByLocale.values()) {
|
|
898
|
+
for (const key of Object.keys(flat)) allKeys.add(key);
|
|
899
|
+
}
|
|
900
|
+
for (const key of [...allKeys].sort()) {
|
|
901
|
+
sheet.addRow([key, ...locales.map((locale) => flatByLocale.get(locale)?.[key] ?? "")]);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function namespacedKey(namespace, locale) {
|
|
905
|
+
return `${namespace}\0${locale}`;
|
|
906
|
+
}
|
|
907
|
+
function exportNamespaced(sheet, files) {
|
|
908
|
+
const locales = distinctLocales(files);
|
|
909
|
+
addHeader(sheet, ["namespace", "key", ...locales]);
|
|
910
|
+
const flatByNamespaceLocale = /* @__PURE__ */ new Map();
|
|
911
|
+
const rowKeys = /* @__PURE__ */ new Set();
|
|
912
|
+
for (const file of files) {
|
|
913
|
+
if (file.namespace === null) continue;
|
|
914
|
+
const flat = flatten(file.translations);
|
|
915
|
+
flatByNamespaceLocale.set(namespacedKey(file.namespace, file.locale), flat);
|
|
916
|
+
for (const key of Object.keys(flat)) rowKeys.add(`${file.namespace}\0${key}`);
|
|
917
|
+
}
|
|
918
|
+
const sortedRows = [...rowKeys].map((rowKey) => {
|
|
919
|
+
const [namespace, key] = rowKey.split("\0");
|
|
920
|
+
return { namespace, key };
|
|
921
|
+
}).sort((a, b) => a.namespace.localeCompare(b.namespace) || a.key.localeCompare(b.key));
|
|
922
|
+
for (const { namespace, key } of sortedRows) {
|
|
923
|
+
sheet.addRow([
|
|
924
|
+
namespace,
|
|
925
|
+
key,
|
|
926
|
+
...locales.map(
|
|
927
|
+
(locale) => flatByNamespaceLocale.get(namespacedKey(namespace, locale))?.[key] ?? ""
|
|
928
|
+
)
|
|
929
|
+
]);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
528
932
|
async function exportToExcel(options) {
|
|
529
933
|
const workbook = new ExcelJS.Workbook();
|
|
530
934
|
const sheet = workbook.addWorksheet(options.sheetName ?? "Translations");
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
for (const flat of flatPerLocale) {
|
|
536
|
-
for (const k of Object.keys(flat)) allKeys.add(k);
|
|
935
|
+
const allSingleFile = options.files.every((file) => file.namespace === null);
|
|
936
|
+
const allNamespaced = options.files.every((file) => file.namespace !== null);
|
|
937
|
+
if (!allSingleFile && !allNamespaced) {
|
|
938
|
+
throw new Error("exportToExcel: files must be uniformly single-file or namespaced.");
|
|
537
939
|
}
|
|
538
|
-
|
|
539
|
-
sheet
|
|
940
|
+
if (allSingleFile) {
|
|
941
|
+
exportSingleFile(sheet, options.files);
|
|
942
|
+
} else {
|
|
943
|
+
exportNamespaced(sheet, options.files);
|
|
540
944
|
}
|
|
541
|
-
sheet.getRow(1).font = { bold: true };
|
|
542
945
|
await workbook.xlsx.writeFile(options.file);
|
|
543
946
|
}
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
await workbook.xlsx.readFile(file);
|
|
547
|
-
const sheet = sheetName ? workbook.getWorksheet(sheetName) : workbook.worksheets[0];
|
|
548
|
-
if (!sheet) throw new Error(`Worksheet not found: ${sheetName ?? "<first>"}`);
|
|
549
|
-
const header = sheet.getRow(1).values;
|
|
550
|
-
const locales = header.slice(2).filter((v) => typeof v === "string");
|
|
947
|
+
function importSingleFile(sheet, header) {
|
|
948
|
+
const locales = header.slice(2).filter((value) => typeof value === "string");
|
|
551
949
|
const flatPerLocale = Object.fromEntries(
|
|
552
|
-
locales.map((
|
|
950
|
+
locales.map((locale) => [locale, {}])
|
|
553
951
|
);
|
|
554
952
|
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
555
953
|
if (rowNumber === 1) return;
|
|
556
954
|
const values = row.values;
|
|
557
|
-
const key = values[1];
|
|
955
|
+
const key = cellText(values[1]);
|
|
558
956
|
if (!key) return;
|
|
559
957
|
locales.forEach((locale, idx) => {
|
|
560
|
-
|
|
561
|
-
flatPerLocale[locale][key] = typeof value === "string" ? value : "";
|
|
958
|
+
flatPerLocale[locale][key] = cellText(values[idx + 2]);
|
|
562
959
|
});
|
|
563
960
|
});
|
|
564
961
|
return {
|
|
962
|
+
format: "single-file",
|
|
565
963
|
locales: locales.map((locale) => ({
|
|
566
964
|
locale,
|
|
965
|
+
namespace: null,
|
|
567
966
|
translations: unflatten(flatPerLocale[locale])
|
|
568
967
|
}))
|
|
569
968
|
};
|
|
570
969
|
}
|
|
970
|
+
function importNamespaced(sheet, header) {
|
|
971
|
+
const locales = header.slice(3).filter((value) => typeof value === "string");
|
|
972
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
973
|
+
const flatByLocaleNamespace = /* @__PURE__ */ new Map();
|
|
974
|
+
const seenRows = /* @__PURE__ */ new Set();
|
|
975
|
+
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
976
|
+
if (rowNumber === 1) return;
|
|
977
|
+
const values = row.values;
|
|
978
|
+
const namespace = cellText(values[1]).trim();
|
|
979
|
+
const key = cellText(values[2]);
|
|
980
|
+
if (!key) return;
|
|
981
|
+
if (!namespace) {
|
|
982
|
+
throw new Error(
|
|
983
|
+
`Row ${rowNumber}: namespace cell must not be empty in a namespaced workbook.`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
const rowKey = `${namespace}\0${key}`;
|
|
987
|
+
if (seenRows.has(rowKey)) {
|
|
988
|
+
throw new Error(`Duplicate (namespace, key) row: "${namespace}" / "${key}".`);
|
|
989
|
+
}
|
|
990
|
+
seenRows.add(rowKey);
|
|
991
|
+
namespaces.add(namespace);
|
|
992
|
+
locales.forEach((locale, idx) => {
|
|
993
|
+
const mapKey = namespacedKey(namespace, locale);
|
|
994
|
+
let flat = flatByLocaleNamespace.get(mapKey);
|
|
995
|
+
if (!flat) {
|
|
996
|
+
flat = {};
|
|
997
|
+
flatByLocaleNamespace.set(mapKey, flat);
|
|
998
|
+
}
|
|
999
|
+
flat[key] = cellText(values[idx + 3]);
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
return {
|
|
1003
|
+
format: "namespaced",
|
|
1004
|
+
locales: locales.flatMap(
|
|
1005
|
+
(locale) => [...namespaces].map((namespace) => ({
|
|
1006
|
+
locale,
|
|
1007
|
+
namespace,
|
|
1008
|
+
translations: unflatten(flatByLocaleNamespace.get(namespacedKey(namespace, locale)) ?? {})
|
|
1009
|
+
}))
|
|
1010
|
+
)
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
async function importFromExcel(file, sheetName) {
|
|
1014
|
+
const workbook = new ExcelJS.Workbook();
|
|
1015
|
+
await workbook.xlsx.readFile(file);
|
|
1016
|
+
const sheet = sheetName ? workbook.getWorksheet(sheetName) : workbook.worksheets[0];
|
|
1017
|
+
if (!sheet) throw new Error(`Worksheet not found: ${sheetName ?? "<first>"}`);
|
|
1018
|
+
const header = sheet.getRow(1).values;
|
|
1019
|
+
if (header[1] === "key") return importSingleFile(sheet, header);
|
|
1020
|
+
if (header[1] === "namespace" && header[2] === "key") return importNamespaced(sheet, header);
|
|
1021
|
+
throw new Error(
|
|
1022
|
+
'Unrecognized workbook header. Expected "key" (single-file) or "namespace","key" (namespaced).'
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
571
1025
|
|
|
572
1026
|
// src/commands/export/run.ts
|
|
573
1027
|
var DEFAULT_FILE = "translations.xlsx";
|
|
@@ -578,19 +1032,29 @@ async function runExportExcel(options) {
|
|
|
578
1032
|
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
579
1033
|
}
|
|
580
1034
|
const { config } = loaded;
|
|
1035
|
+
const referenceLocale = config.defaultLocale ?? config.locales[0];
|
|
581
1036
|
const file = resolve(options.cwd, options.file ?? config.excel?.file ?? DEFAULT_FILE);
|
|
582
1037
|
const sheetName = options.sheetName ?? config.excel?.sheetName ?? DEFAULT_SHEET;
|
|
583
1038
|
const files = await loadLocaleFiles({
|
|
584
1039
|
cwd: options.cwd,
|
|
585
1040
|
inputDir: config.input,
|
|
586
|
-
locales: config.locales
|
|
1041
|
+
locales: config.locales,
|
|
1042
|
+
namespaces: config.namespaces
|
|
587
1043
|
});
|
|
1044
|
+
const index = indexLocaleFiles(files);
|
|
1045
|
+
if (config.namespaces !== void 0 && index.namespaces.length === 0) {
|
|
1046
|
+
throw noNamespacesError(referenceLocale, config.input);
|
|
1047
|
+
}
|
|
588
1048
|
await exportToExcel({
|
|
589
1049
|
file,
|
|
590
1050
|
sheetName,
|
|
591
|
-
|
|
1051
|
+
files: files.map((f) => ({
|
|
1052
|
+
locale: f.locale,
|
|
1053
|
+
namespace: f.namespace,
|
|
1054
|
+
translations: f.translations
|
|
1055
|
+
}))
|
|
592
1056
|
});
|
|
593
|
-
return { file, sheetName, locales:
|
|
1057
|
+
return { file, sheetName, locales: config.locales, namespaces: index.namespaces };
|
|
594
1058
|
}
|
|
595
1059
|
|
|
596
1060
|
// src/commands/export.ts
|
|
@@ -599,13 +1063,14 @@ function registerExportCommand(program) {
|
|
|
599
1063
|
cmd.command("excel").description("Export translations to an Excel workbook.").option("--file <path>", "Output Excel file (overrides config).").option("--sheet <name>", "Worksheet name (overrides config).").action(async (flags) => {
|
|
600
1064
|
try {
|
|
601
1065
|
const cwd = process.cwd();
|
|
602
|
-
const { file, locales } = await runExportExcel({
|
|
1066
|
+
const { file, locales, namespaces } = await runExportExcel({
|
|
603
1067
|
cwd,
|
|
604
1068
|
file: flags.file,
|
|
605
1069
|
sheetName: flags.sheet
|
|
606
1070
|
});
|
|
1071
|
+
const namespaceSummary = namespaces.length > 0 ? ` across ${chalk.cyan(String(namespaces.length))} namespace(s)` : "";
|
|
607
1072
|
logger.success(
|
|
608
|
-
`Exported ${chalk.cyan(String(locales.length))} locale(s) to ${chalk.bold(
|
|
1073
|
+
`Exported ${chalk.cyan(String(locales.length))} locale(s)${namespaceSummary} to ${chalk.bold(
|
|
609
1074
|
relative(cwd, file)
|
|
610
1075
|
)}`
|
|
611
1076
|
);
|
|
@@ -626,9 +1091,18 @@ async function runImportExcel(options) {
|
|
|
626
1091
|
const { config } = loaded;
|
|
627
1092
|
const file = resolve(options.cwd, options.file ?? config.excel?.file ?? DEFAULT_FILE2);
|
|
628
1093
|
const sheetName = options.sheetName ?? config.excel?.sheetName ?? DEFAULT_SHEET2;
|
|
629
|
-
const inputAbs = resolve(options.cwd, config.input);
|
|
630
1094
|
const configuredLocales = new Set(config.locales);
|
|
631
1095
|
const result = await importFromExcel(file, sheetName);
|
|
1096
|
+
if (!config.namespaces && result.format === "namespaced") {
|
|
1097
|
+
throw new Error(
|
|
1098
|
+
"Cannot import a namespaced workbook into a single-file project. Configure a `namespaces` block first."
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
if (config.namespaces && result.format === "single-file") {
|
|
1102
|
+
throw new Error(
|
|
1103
|
+
"Cannot import a single-file workbook into a namespaced project. Export a namespaced workbook or remove the `namespaces` block."
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
632
1106
|
const planned = [];
|
|
633
1107
|
const written = [];
|
|
634
1108
|
const skipped = [];
|
|
@@ -637,7 +1111,13 @@ async function runImportExcel(options) {
|
|
|
637
1111
|
skipped.push(entry.locale);
|
|
638
1112
|
continue;
|
|
639
1113
|
}
|
|
640
|
-
const path =
|
|
1114
|
+
const path = resolveLocaleFilePath({
|
|
1115
|
+
cwd: options.cwd,
|
|
1116
|
+
inputDir: config.input,
|
|
1117
|
+
locale: entry.locale,
|
|
1118
|
+
namespace: entry.namespace,
|
|
1119
|
+
namespaces: config.namespaces
|
|
1120
|
+
});
|
|
641
1121
|
planned.push(path);
|
|
642
1122
|
if (!options.dryRun) {
|
|
643
1123
|
await writeJson(path, entry.translations);
|
|
@@ -676,6 +1156,14 @@ function registerImportCommand(program) {
|
|
|
676
1156
|
}
|
|
677
1157
|
|
|
678
1158
|
// ../ai-engine/dist/index.js
|
|
1159
|
+
var TranslationAdapterError = class extends Error {
|
|
1160
|
+
constructor(message, provider, statusCode, options) {
|
|
1161
|
+
super(message, options);
|
|
1162
|
+
this.provider = provider;
|
|
1163
|
+
this.statusCode = statusCode;
|
|
1164
|
+
this.name = "TranslationAdapterError";
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
679
1167
|
var DEFAULT_MODEL = "gpt-5-mini";
|
|
680
1168
|
var ENDPOINT = "https://api.openai.com/v1/chat/completions";
|
|
681
1169
|
var OpenAIAdapter = class {
|
|
@@ -863,7 +1351,7 @@ var GeminiAdapter = class {
|
|
|
863
1351
|
return content;
|
|
864
1352
|
}
|
|
865
1353
|
};
|
|
866
|
-
var RELEASED_PROVIDERS = ["openai"];
|
|
1354
|
+
var RELEASED_PROVIDERS = ["openai", "deepl"];
|
|
867
1355
|
var ALL_PROVIDERS = ["openai", "deepl", "anthropic", "gemini"];
|
|
868
1356
|
function experimentalEnabled() {
|
|
869
1357
|
return process.env.LANGSYNC_AI_EXPERIMENTAL === "1";
|
|
@@ -903,9 +1391,14 @@ async function fillEmptyTranslations(options) {
|
|
|
903
1391
|
const referenceFlat = flatten(options.reference);
|
|
904
1392
|
const targetFlat = flatten(options.target);
|
|
905
1393
|
const translatedKeys = [];
|
|
1394
|
+
const skippedKeys = [];
|
|
906
1395
|
for (const [key, referenceValue] of Object.entries(referenceFlat)) {
|
|
907
1396
|
if (isEmpty(referenceValue)) continue;
|
|
908
1397
|
if (!isEmpty(targetFlat[key])) continue;
|
|
1398
|
+
if (options.maxKeys !== void 0 && translatedKeys.length >= options.maxKeys) {
|
|
1399
|
+
skippedKeys.push(key);
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
909
1402
|
targetFlat[key] = await options.adapter.translate({
|
|
910
1403
|
text: referenceValue,
|
|
911
1404
|
sourceLocale: options.sourceLocale,
|
|
@@ -913,10 +1406,16 @@ async function fillEmptyTranslations(options) {
|
|
|
913
1406
|
});
|
|
914
1407
|
translatedKeys.push(key);
|
|
915
1408
|
}
|
|
916
|
-
return { tree: unflatten(targetFlat), translatedKeys };
|
|
1409
|
+
return { tree: unflatten(targetFlat), translatedKeys, skippedKeys };
|
|
917
1410
|
}
|
|
918
1411
|
|
|
919
1412
|
// src/commands/translate/run.ts
|
|
1413
|
+
function isEmpty2(value) {
|
|
1414
|
+
return value === void 0 || value.trim() === "";
|
|
1415
|
+
}
|
|
1416
|
+
function candidateEntry(candidate) {
|
|
1417
|
+
return { namespace: candidate.namespace, key: candidate.key, path: candidate.path };
|
|
1418
|
+
}
|
|
920
1419
|
async function runTranslate(options) {
|
|
921
1420
|
const loaded = await loadConfig(options.cwd);
|
|
922
1421
|
if (!loaded) {
|
|
@@ -925,75 +1424,189 @@ async function runTranslate(options) {
|
|
|
925
1424
|
const { config } = loaded;
|
|
926
1425
|
const referenceLocale = config.defaultLocale ?? config.locales[0];
|
|
927
1426
|
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
1427
|
const files = await loadLocaleFiles({
|
|
934
1428
|
cwd: options.cwd,
|
|
935
1429
|
inputDir: config.input,
|
|
936
|
-
locales: config.locales
|
|
1430
|
+
locales: config.locales,
|
|
1431
|
+
namespaces: config.namespaces
|
|
937
1432
|
});
|
|
938
|
-
const
|
|
939
|
-
|
|
1433
|
+
const index = indexLocaleFiles(files);
|
|
1434
|
+
const namespaced = config.namespaces !== void 0;
|
|
1435
|
+
if (namespaced && index.namespaces.length === 0) {
|
|
1436
|
+
throw noNamespacesError(referenceLocale, config.input);
|
|
1437
|
+
}
|
|
1438
|
+
const nsKeys = namespaced ? index.namespaces : [null];
|
|
1439
|
+
const referenceBucket = index.byLocale[referenceLocale];
|
|
1440
|
+
if (!referenceBucket) {
|
|
940
1441
|
throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
|
|
941
1442
|
}
|
|
942
|
-
const
|
|
1443
|
+
const adapter = createAdapter({
|
|
1444
|
+
provider,
|
|
1445
|
+
apiKey: config.ai?.apiKey,
|
|
1446
|
+
model: options.model ?? config.ai?.model
|
|
1447
|
+
});
|
|
1448
|
+
const allCandidates = [];
|
|
1449
|
+
for (const targetLocale of config.locales) {
|
|
1450
|
+
if (targetLocale === referenceLocale) continue;
|
|
1451
|
+
const targetBucket = index.byLocale[targetLocale];
|
|
1452
|
+
if (!targetBucket) continue;
|
|
1453
|
+
for (const nsKey of nsKeys) {
|
|
1454
|
+
const reference = referenceBucket.get(nsKey);
|
|
1455
|
+
const target = targetBucket.get(nsKey);
|
|
1456
|
+
if (!reference || !target) continue;
|
|
1457
|
+
const refFlat = flatten(reference.translations);
|
|
1458
|
+
const targetFlat = flatten(target.translations);
|
|
1459
|
+
for (const [key, value] of Object.entries(refFlat)) {
|
|
1460
|
+
if (isEmpty2(value)) continue;
|
|
1461
|
+
if (!isEmpty2(targetFlat[key])) continue;
|
|
1462
|
+
allCandidates.push({
|
|
1463
|
+
locale: targetLocale,
|
|
1464
|
+
namespace: nsKey,
|
|
1465
|
+
path: target.path,
|
|
1466
|
+
key,
|
|
1467
|
+
sourceValue: value
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
const totalTranslatableKeys = allCandidates.length;
|
|
1473
|
+
const limited = options.maxKeys ? allCandidates.slice(0, options.maxKeys) : allCandidates;
|
|
1474
|
+
const limitedByPath = /* @__PURE__ */ new Map();
|
|
1475
|
+
const skippedCandidates = options.maxKeys ? allCandidates.slice(options.maxKeys) : [];
|
|
1476
|
+
for (const candidate of limited) {
|
|
1477
|
+
const entries = limitedByPath.get(candidate.path) ?? [];
|
|
1478
|
+
entries.push(candidate);
|
|
1479
|
+
limitedByPath.set(candidate.path, entries);
|
|
1480
|
+
}
|
|
943
1481
|
const written = [];
|
|
944
1482
|
const planned = [];
|
|
945
1483
|
const translatedByLocale = {};
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1484
|
+
const skippedByLocale = {};
|
|
1485
|
+
for (const candidate of skippedCandidates) {
|
|
1486
|
+
if (limitedByPath.has(candidate.path)) continue;
|
|
1487
|
+
(skippedByLocale[candidate.locale] ??= []).push(candidateEntry(candidate));
|
|
1488
|
+
}
|
|
1489
|
+
for (const targetLocale of config.locales) {
|
|
1490
|
+
if (targetLocale === referenceLocale) continue;
|
|
1491
|
+
const targetBucket = index.byLocale[targetLocale];
|
|
1492
|
+
if (!targetBucket) continue;
|
|
1493
|
+
for (const nsKey of nsKeys) {
|
|
1494
|
+
const reference = referenceBucket.get(nsKey);
|
|
1495
|
+
const target = targetBucket.get(nsKey);
|
|
1496
|
+
if (!reference || !target) continue;
|
|
1497
|
+
const budget = limitedByPath.get(target.path)?.length;
|
|
1498
|
+
if (budget === void 0) continue;
|
|
1499
|
+
const { tree, translatedKeys, skippedKeys } = await fillEmptyTranslations({
|
|
1500
|
+
reference: reference.translations,
|
|
1501
|
+
target: target.translations,
|
|
1502
|
+
sourceLocale: referenceLocale,
|
|
1503
|
+
targetLocale,
|
|
1504
|
+
adapter,
|
|
1505
|
+
maxKeys: budget
|
|
1506
|
+
});
|
|
1507
|
+
if (translatedKeys.length > 0) {
|
|
1508
|
+
(translatedByLocale[targetLocale] ??= []).push(
|
|
1509
|
+
...translatedKeys.map((key) => ({ namespace: nsKey, key, path: target.path }))
|
|
1510
|
+
);
|
|
1511
|
+
planned.push(target.path);
|
|
1512
|
+
if (!options.dryRun) {
|
|
1513
|
+
await writeJson(target.path, tree);
|
|
1514
|
+
written.push(target.path);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
if (skippedKeys.length > 0) {
|
|
1518
|
+
(skippedByLocale[targetLocale] ??= []).push(
|
|
1519
|
+
...skippedKeys.map((key) => ({ namespace: nsKey, key, path: target.path }))
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
960
1522
|
}
|
|
961
1523
|
}
|
|
962
|
-
return {
|
|
1524
|
+
return {
|
|
1525
|
+
provider,
|
|
1526
|
+
referenceLocale,
|
|
1527
|
+
written,
|
|
1528
|
+
planned,
|
|
1529
|
+
translatedByLocale,
|
|
1530
|
+
skippedByLocale,
|
|
1531
|
+
totalTranslatableKeys
|
|
1532
|
+
};
|
|
963
1533
|
}
|
|
964
1534
|
|
|
965
1535
|
// src/commands/translate.ts
|
|
966
1536
|
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(
|
|
1537
|
+
program.command("translate").description("Fill empty values in non-reference locales using an AI provider.").option("--provider <provider>", "Override the configured AI provider.").option("--model <model>", "Override the configured provider model.").option(
|
|
1538
|
+
"--max-keys <n>",
|
|
1539
|
+
"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."
|
|
1540
|
+
).option("--dry-run", "Report what would be translated without writing files.", false).action(async (flags) => {
|
|
968
1541
|
try {
|
|
969
1542
|
const cwd = process.cwd();
|
|
1543
|
+
const maxKeys = flags.maxKeys ? Number.parseInt(flags.maxKeys, 10) : void 0;
|
|
1544
|
+
if (maxKeys !== void 0 && (Number.isNaN(maxKeys) || maxKeys <= 0)) {
|
|
1545
|
+
logger.error("--max-keys must be a positive integer.");
|
|
1546
|
+
process.exitCode = 1;
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
970
1549
|
const result = await runTranslate({
|
|
971
1550
|
cwd,
|
|
972
1551
|
dryRun: flags.dryRun,
|
|
973
1552
|
provider: flags.provider,
|
|
974
|
-
model: flags.model
|
|
1553
|
+
model: flags.model,
|
|
1554
|
+
maxKeys
|
|
975
1555
|
});
|
|
976
1556
|
const totals = Object.values(result.translatedByLocale).reduce(
|
|
977
1557
|
(sum, keys) => sum + keys.length,
|
|
978
1558
|
0
|
|
979
1559
|
);
|
|
1560
|
+
if (flags.dryRun) {
|
|
1561
|
+
if (result.totalTranslatableKeys === 0) {
|
|
1562
|
+
logger.info(`Nothing to translate with ${chalk.cyan(result.provider)}.`);
|
|
1563
|
+
} else {
|
|
1564
|
+
const capped = maxKeys !== void 0 && maxKeys < result.totalTranslatableKeys;
|
|
1565
|
+
logger.info(
|
|
1566
|
+
`[dry-run] Would translate ${chalk.bold(String(totals))} key(s) across ${Object.keys(result.translatedByLocale).length} locale(s) using ${chalk.cyan(result.provider)}.`
|
|
1567
|
+
);
|
|
1568
|
+
if (capped) {
|
|
1569
|
+
logger.info(
|
|
1570
|
+
`[dry-run] ${result.totalTranslatableKeys - totals} key(s) skipped due to --max-keys ${String(maxKeys)}.`
|
|
1571
|
+
);
|
|
1572
|
+
}
|
|
1573
|
+
for (const [locale, keys] of Object.entries(result.translatedByLocale)) {
|
|
1574
|
+
if (keys.length === 0) continue;
|
|
1575
|
+
logger.info(`[dry-run] ${locale}: ${String(keys.length)} key(s)`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
980
1580
|
if (totals === 0) {
|
|
981
1581
|
logger.info(`Nothing to translate with ${chalk.cyan(result.provider)}.`);
|
|
982
1582
|
return;
|
|
983
1583
|
}
|
|
984
|
-
const verb = flags.dryRun ? "Would translate" : "Translated";
|
|
985
1584
|
for (const [locale, keys] of Object.entries(result.translatedByLocale)) {
|
|
986
1585
|
if (keys.length === 0) continue;
|
|
987
|
-
logger.success(
|
|
1586
|
+
logger.success(`Translated ${chalk.bold(String(keys.length))} key(s) for ${locale}`);
|
|
988
1587
|
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1588
|
+
for (const path of result.written) {
|
|
1589
|
+
logger.info(`Wrote ${chalk.bold(relative(cwd, path))}`);
|
|
1590
|
+
}
|
|
1591
|
+
const totalSkipped = Object.values(result.skippedByLocale).reduce(
|
|
1592
|
+
(sum, keys) => sum + keys.length,
|
|
1593
|
+
0
|
|
1594
|
+
);
|
|
1595
|
+
if (totalSkipped > 0) {
|
|
1596
|
+
logger.warn(
|
|
1597
|
+
`${chalk.yellow(String(totalSkipped))} key(s) skipped due to --max-keys. Run again to translate remaining keys.`
|
|
1598
|
+
);
|
|
993
1599
|
}
|
|
994
1600
|
} catch (error) {
|
|
995
|
-
|
|
996
|
-
|
|
1601
|
+
if (error instanceof TranslationAdapterError) {
|
|
1602
|
+
const statusInfo = error.statusCode ? ` (${String(error.statusCode)})` : "";
|
|
1603
|
+
logger.error(`${error.provider} translation failed${statusInfo}: ${error.message}`);
|
|
1604
|
+
if (error.statusCode === 429) {
|
|
1605
|
+
logger.info("Rate limited. Retry in a moment, or use --max-keys to reduce requests.");
|
|
1606
|
+
}
|
|
1607
|
+
} else {
|
|
1608
|
+
logger.error(error instanceof Error ? error.message : String(error));
|
|
1609
|
+
}
|
|
997
1610
|
process.exitCode = 1;
|
|
998
1611
|
}
|
|
999
1612
|
});
|
|
@@ -1003,33 +1616,59 @@ async function resolveWatchDir(cwd) {
|
|
|
1003
1616
|
if (!loaded) {
|
|
1004
1617
|
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
1005
1618
|
}
|
|
1619
|
+
if (loaded.config.namespaces) {
|
|
1620
|
+
throw new Error(
|
|
1621
|
+
"Namespace support for this command is coming in a follow-up release. Remove the `namespaces` block from your config to use single-file mode."
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1006
1624
|
return resolve(cwd, loaded.config.input);
|
|
1007
1625
|
}
|
|
1008
1626
|
async function runWatchPass(options) {
|
|
1009
|
-
const { referenceLocale, written } = await runSync({
|
|
1010
|
-
cwd: options.cwd,
|
|
1011
|
-
dryRun: options.dryRun
|
|
1012
|
-
});
|
|
1013
1627
|
const loaded = await loadConfig(options.cwd);
|
|
1014
1628
|
if (!loaded) {
|
|
1015
1629
|
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
1016
1630
|
}
|
|
1631
|
+
if (loaded.config.namespaces) {
|
|
1632
|
+
throw new Error(
|
|
1633
|
+
"Namespace support for this command is coming in a follow-up release. Remove the `namespaces` block from your config to use single-file mode."
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
const { referenceLocale, written, unchanged, diffsByPath } = await runSync({
|
|
1637
|
+
cwd: options.cwd,
|
|
1638
|
+
dryRun: options.dryRun
|
|
1639
|
+
});
|
|
1017
1640
|
const files = await loadLocaleFiles({
|
|
1018
1641
|
cwd: options.cwd,
|
|
1019
1642
|
inputDir: loaded.config.input,
|
|
1020
|
-
locales: loaded.config.locales
|
|
1643
|
+
locales: loaded.config.locales,
|
|
1644
|
+
namespaces: loaded.config.namespaces
|
|
1021
1645
|
});
|
|
1022
1646
|
const issues = validateLocales(files, referenceLocale);
|
|
1023
|
-
return { referenceLocale, written, issues };
|
|
1647
|
+
return { referenceLocale, written, unchanged, diffsByPath, issues };
|
|
1024
1648
|
}
|
|
1025
1649
|
|
|
1026
1650
|
// src/commands/watch.ts
|
|
1027
|
-
function
|
|
1028
|
-
|
|
1029
|
-
|
|
1651
|
+
function formatDiff(diff) {
|
|
1652
|
+
const parts = [];
|
|
1653
|
+
if (diff.added.length > 0) parts.push(chalk.green(`+${diff.added.length}`));
|
|
1654
|
+
if (diff.removed.length > 0) parts.push(chalk.red(`-${diff.removed.length}`));
|
|
1655
|
+
if (diff.changed.length > 0) parts.push(chalk.yellow(`~${diff.changed.length}`));
|
|
1656
|
+
return parts.join(", ");
|
|
1657
|
+
}
|
|
1658
|
+
function reportPass(cwd, written, unchanged, diffsByPath, issueCount) {
|
|
1659
|
+
if (written.length === 0 && unchanged.length === 0) {
|
|
1660
|
+
logger.info("No locale files found to sync.");
|
|
1661
|
+
} else if (written.length === 0) {
|
|
1662
|
+
logger.info("All locales are already in sync.");
|
|
1030
1663
|
} else {
|
|
1031
1664
|
for (const path of written) {
|
|
1032
|
-
|
|
1665
|
+
const rel = relative(cwd, path);
|
|
1666
|
+
const diff = diffsByPath[path];
|
|
1667
|
+
const summary = diff ? ` (${formatDiff(diff)})` : "";
|
|
1668
|
+
logger.success(`Synced ${chalk.bold(rel)}${summary}`);
|
|
1669
|
+
}
|
|
1670
|
+
for (const path of unchanged) {
|
|
1671
|
+
logger.info(`No changes: ${chalk.dim(relative(cwd, path))}`);
|
|
1033
1672
|
}
|
|
1034
1673
|
}
|
|
1035
1674
|
if (issueCount > 0) {
|
|
@@ -1045,7 +1684,13 @@ function registerWatchCommand(program) {
|
|
|
1045
1684
|
const watchDir = await resolveWatchDir(cwd);
|
|
1046
1685
|
const debounceMs = Number.parseInt(flags.debounce, 10) || 200;
|
|
1047
1686
|
const initial = await runWatchPass({ cwd, dryRun: flags.dryRun });
|
|
1048
|
-
reportPass(
|
|
1687
|
+
reportPass(
|
|
1688
|
+
cwd,
|
|
1689
|
+
initial.written,
|
|
1690
|
+
initial.unchanged,
|
|
1691
|
+
initial.diffsByPath,
|
|
1692
|
+
initial.issues.length
|
|
1693
|
+
);
|
|
1049
1694
|
logger.info(`Watching ${chalk.cyan(relative(cwd, watchDir) || ".")} for changes...`);
|
|
1050
1695
|
const watcher = chokidar.watch(join(watchDir, "*.json"), {
|
|
1051
1696
|
ignoreInitial: true
|
|
@@ -1060,7 +1705,7 @@ function registerWatchCommand(program) {
|
|
|
1060
1705
|
running = true;
|
|
1061
1706
|
try {
|
|
1062
1707
|
const pass = await runWatchPass({ cwd, dryRun: flags.dryRun });
|
|
1063
|
-
reportPass(cwd, pass.written, pass.issues.length);
|
|
1708
|
+
reportPass(cwd, pass.written, pass.unchanged, pass.diffsByPath, pass.issues.length);
|
|
1064
1709
|
} catch (error) {
|
|
1065
1710
|
logger.error(error instanceof Error ? error.message : String(error));
|
|
1066
1711
|
} finally {
|
|
@@ -1079,7 +1724,7 @@ function registerWatchCommand(program) {
|
|
|
1079
1724
|
}
|
|
1080
1725
|
|
|
1081
1726
|
// src/cli.ts
|
|
1082
|
-
var VERSION = "0.
|
|
1727
|
+
var VERSION = "0.6.0" ;
|
|
1083
1728
|
async function main() {
|
|
1084
1729
|
assertNodeVersion(22);
|
|
1085
1730
|
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
|
@@ -3,12 +3,24 @@ import 'cosmiconfig';
|
|
|
3
3
|
import 'cosmiconfig-typescript-loader';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
|
|
6
|
+
var NamespaceConfigSchema = z.object({
|
|
7
|
+
structure: z.enum(["locale-dir", "locale-prefix"]).describe(
|
|
8
|
+
"Optional namespace layout. `locale-dir` resolves <input>/<locale>/<namespace>.json recursively. `locale-prefix` resolves <input>/<locale>.<namespace>.json."
|
|
9
|
+
)
|
|
10
|
+
});
|
|
6
11
|
z.object({
|
|
7
12
|
input: z.string().describe("Path to the source i18n directory."),
|
|
8
|
-
output: z.string().
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
output: z.string().default("./translations").describe(
|
|
14
|
+
'Base directory for translated output. Defaults to "./translations". Reserved for report and export output in future releases.'
|
|
15
|
+
),
|
|
16
|
+
locales: z.array(z.string()).min(1).describe('List of supported locales (e.g. ["en", "de", "fr"]).'),
|
|
17
|
+
defaultLocale: z.string().optional().describe(
|
|
18
|
+
"Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
|
|
19
|
+
),
|
|
11
20
|
framework: z.enum(["i18next", "ngx-translate", "react-intl", "none"]).optional().describe("i18n framework integration. Use `none` to opt out explicitly."),
|
|
21
|
+
namespaces: NamespaceConfigSchema.optional().describe(
|
|
22
|
+
"Optional namespace settings. Omit this block to keep the default single-file layout at <input>/<locale>.json."
|
|
23
|
+
),
|
|
12
24
|
excel: z.object({
|
|
13
25
|
file: z.string().default("translations.xlsx"),
|
|
14
26
|
sheetName: z.string().default("Translations")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mariokreitz/langsync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Modern localization workflow tooling for TypeScript applications.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -65,10 +65,10 @@
|
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@types/prompts": "^2.4.9",
|
|
67
67
|
"memfs": "^4.57.3",
|
|
68
|
-
"@langsync/shared": "0.1
|
|
69
|
-
"@langsync/
|
|
70
|
-
"@langsync/
|
|
71
|
-
"@langsync/excel-engine": "0.
|
|
68
|
+
"@langsync/shared": "0.2.1",
|
|
69
|
+
"@langsync/core": "0.1.2",
|
|
70
|
+
"@langsync/ai-engine": "0.2.1",
|
|
71
|
+
"@langsync/excel-engine": "0.2.0"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsup",
|