@mariokreitz/langsync 0.5.0 → 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 +608 -115
- package/dist/index.js +8 -0
- 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,6 +305,11 @@ 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
315
|
output: z.string().default("./translations").describe(
|
|
@@ -255,6 +320,9 @@ var LangSyncConfigSchema = z.object({
|
|
|
255
320
|
"Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
|
|
256
321
|
),
|
|
257
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
|
+
),
|
|
258
326
|
excel: z.object({
|
|
259
327
|
file: z.string().default("translations.xlsx"),
|
|
260
328
|
sheetName: z.string().default("Translations")
|
|
@@ -301,27 +369,206 @@ async function writeJson(filePath, data, { indent = 2 } = {}) {
|
|
|
301
369
|
await mkdir(dirname(absolute), { recursive: true });
|
|
302
370
|
await writeFile(absolute, JSON.stringify(data, null, indent) + "\n", "utf-8");
|
|
303
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
|
+
}
|
|
304
494
|
async function loadLocaleFiles(options) {
|
|
305
495
|
const inputAbs = resolve(options.cwd, options.inputDir);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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;
|
|
312
507
|
try {
|
|
313
|
-
translations =
|
|
508
|
+
translations = await readTranslationFile(path, `${locale}.json`);
|
|
509
|
+
exists = true;
|
|
314
510
|
} catch (error) {
|
|
315
|
-
const
|
|
316
|
-
|
|
511
|
+
const errno = error.code;
|
|
512
|
+
if (errno !== "ENOENT") throw error;
|
|
317
513
|
}
|
|
318
|
-
|
|
319
|
-
const errno = error.code;
|
|
320
|
-
if (errno !== "ENOENT") throw error;
|
|
514
|
+
out.push({ locale, namespace: null, path, translations, exists });
|
|
321
515
|
}
|
|
322
|
-
out
|
|
516
|
+
return out;
|
|
323
517
|
}
|
|
324
|
-
|
|
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
|
+
};
|
|
325
572
|
}
|
|
326
573
|
|
|
327
574
|
// ../core/dist/index.js
|
|
@@ -409,6 +656,13 @@ function hasChanges(diff) {
|
|
|
409
656
|
return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
|
|
410
657
|
}
|
|
411
658
|
|
|
659
|
+
// src/commands/shared/namespace-error.ts
|
|
660
|
+
function noNamespacesError(referenceLocale, inputDir) {
|
|
661
|
+
return new Error(
|
|
662
|
+
`No namespace files found under "${inputDir}". Run \`langsync init\` or create at least one namespace file for "${referenceLocale}".`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
412
666
|
// src/commands/sync/run.ts
|
|
413
667
|
async function runSync(options) {
|
|
414
668
|
const loaded = await loadConfig(options.cwd);
|
|
@@ -420,29 +674,43 @@ async function runSync(options) {
|
|
|
420
674
|
const files = await loadLocaleFiles({
|
|
421
675
|
cwd: options.cwd,
|
|
422
676
|
inputDir: config.input,
|
|
423
|
-
locales: config.locales
|
|
677
|
+
locales: config.locales,
|
|
678
|
+
namespaces: config.namespaces
|
|
424
679
|
});
|
|
425
|
-
const
|
|
426
|
-
|
|
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) {
|
|
427
688
|
throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
|
|
428
689
|
}
|
|
429
|
-
const targets = files.filter((f) => f.locale !== referenceLocale);
|
|
430
690
|
const planned = [];
|
|
431
691
|
const written = [];
|
|
432
692
|
const unchanged = [];
|
|
433
693
|
const diffsByPath = {};
|
|
434
|
-
for (const
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
if (!
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
+
}
|
|
446
714
|
}
|
|
447
715
|
}
|
|
448
716
|
return { referenceLocale, written, planned, unchanged, diffsByPath };
|
|
@@ -485,9 +753,30 @@ async function runValidate(options) {
|
|
|
485
753
|
const files = await loadLocaleFiles({
|
|
486
754
|
cwd: options.cwd,
|
|
487
755
|
inputDir: config.input,
|
|
488
|
-
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);
|
|
489
779
|
});
|
|
490
|
-
const issues = validateLocales(files, referenceLocale);
|
|
491
780
|
const hasErrors = issues.some((i) => i.type === "missing" || i.type === "extra");
|
|
492
781
|
return {
|
|
493
782
|
referenceLocale,
|
|
@@ -497,6 +786,9 @@ async function runValidate(options) {
|
|
|
497
786
|
}
|
|
498
787
|
|
|
499
788
|
// src/commands/validate.ts
|
|
789
|
+
function formatIssueKey(issue) {
|
|
790
|
+
return issue.namespace === null ? issue.key : `${issue.namespace}:${issue.key}`;
|
|
791
|
+
}
|
|
500
792
|
function registerValidateCommand(program) {
|
|
501
793
|
program.command("validate").description("Validate locale consistency, structure and missing keys.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
|
|
502
794
|
try {
|
|
@@ -511,7 +803,7 @@ function registerValidateCommand(program) {
|
|
|
511
803
|
for (const issue of issues) {
|
|
512
804
|
byType[issue.type]++;
|
|
513
805
|
const colored = issue.type === "empty" ? chalk.yellow(issue.type) : chalk.red(issue.type);
|
|
514
|
-
logger.info(`${colored} ${chalk.cyan(issue.locale)} ${issue
|
|
806
|
+
logger.info(`${colored} ${chalk.cyan(issue.locale)} ${formatIssueKey(issue)}`);
|
|
515
807
|
}
|
|
516
808
|
console.log();
|
|
517
809
|
logger.info(
|
|
@@ -534,16 +826,25 @@ async function runFindMissing(options) {
|
|
|
534
826
|
const missingByLocale = {};
|
|
535
827
|
for (const issue of issues) {
|
|
536
828
|
if (issue.type !== "missing") continue;
|
|
537
|
-
(missingByLocale[issue.locale] ??= []).push(
|
|
829
|
+
(missingByLocale[issue.locale] ??= []).push({
|
|
830
|
+
namespace: issue.namespace,
|
|
831
|
+
key: issue.key,
|
|
832
|
+
path: issue.path
|
|
833
|
+
});
|
|
538
834
|
}
|
|
539
|
-
for (const
|
|
540
|
-
|
|
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
|
+
);
|
|
541
839
|
}
|
|
542
840
|
const exitCode = Object.keys(missingByLocale).length === 0 ? 0 : 1;
|
|
543
841
|
return { referenceLocale, missingByLocale, exitCode };
|
|
544
842
|
}
|
|
545
843
|
|
|
546
844
|
// src/commands/find-missing.ts
|
|
845
|
+
function formatMissingEntry(entry) {
|
|
846
|
+
return entry.namespace === null ? entry.key : `${entry.namespace}:${entry.key}`;
|
|
847
|
+
}
|
|
547
848
|
function registerFindMissingCommand(program) {
|
|
548
849
|
program.command("find-missing").description("Find missing translation keys across locales.").option("--reporter <kind>", "Output format: pretty | json.", "pretty").action(async (flags) => {
|
|
549
850
|
try {
|
|
@@ -557,7 +858,7 @@ function registerFindMissingCommand(program) {
|
|
|
557
858
|
} else {
|
|
558
859
|
for (const [locale, keys] of Object.entries(missingByLocale)) {
|
|
559
860
|
logger.warn(`${chalk.cyan(locale)} is missing ${keys.length} key(s):`);
|
|
560
|
-
for (const
|
|
861
|
+
for (const entry of keys) console.log(` - ${formatMissingEntry(entry)}`);
|
|
561
862
|
}
|
|
562
863
|
}
|
|
563
864
|
process.exitCode = exitCode;
|
|
@@ -568,49 +869,159 @@ function registerFindMissingCommand(program) {
|
|
|
568
869
|
}
|
|
569
870
|
});
|
|
570
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
|
+
}
|
|
571
932
|
async function exportToExcel(options) {
|
|
572
933
|
const workbook = new ExcelJS.Workbook();
|
|
573
934
|
const sheet = workbook.addWorksheet(options.sheetName ?? "Translations");
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
for (const flat of flatPerLocale) {
|
|
579
|
-
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.");
|
|
580
939
|
}
|
|
581
|
-
|
|
582
|
-
sheet
|
|
940
|
+
if (allSingleFile) {
|
|
941
|
+
exportSingleFile(sheet, options.files);
|
|
942
|
+
} else {
|
|
943
|
+
exportNamespaced(sheet, options.files);
|
|
583
944
|
}
|
|
584
|
-
sheet.getRow(1).font = { bold: true };
|
|
585
945
|
await workbook.xlsx.writeFile(options.file);
|
|
586
946
|
}
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
await workbook.xlsx.readFile(file);
|
|
590
|
-
const sheet = sheetName ? workbook.getWorksheet(sheetName) : workbook.worksheets[0];
|
|
591
|
-
if (!sheet) throw new Error(`Worksheet not found: ${sheetName ?? "<first>"}`);
|
|
592
|
-
const header = sheet.getRow(1).values;
|
|
593
|
-
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");
|
|
594
949
|
const flatPerLocale = Object.fromEntries(
|
|
595
|
-
locales.map((
|
|
950
|
+
locales.map((locale) => [locale, {}])
|
|
596
951
|
);
|
|
597
952
|
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
598
953
|
if (rowNumber === 1) return;
|
|
599
954
|
const values = row.values;
|
|
600
|
-
const key = values[1];
|
|
955
|
+
const key = cellText(values[1]);
|
|
601
956
|
if (!key) return;
|
|
602
957
|
locales.forEach((locale, idx) => {
|
|
603
|
-
|
|
604
|
-
flatPerLocale[locale][key] = typeof value === "string" ? value : "";
|
|
958
|
+
flatPerLocale[locale][key] = cellText(values[idx + 2]);
|
|
605
959
|
});
|
|
606
960
|
});
|
|
607
961
|
return {
|
|
962
|
+
format: "single-file",
|
|
608
963
|
locales: locales.map((locale) => ({
|
|
609
964
|
locale,
|
|
965
|
+
namespace: null,
|
|
610
966
|
translations: unflatten(flatPerLocale[locale])
|
|
611
967
|
}))
|
|
612
968
|
};
|
|
613
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
|
+
}
|
|
614
1025
|
|
|
615
1026
|
// src/commands/export/run.ts
|
|
616
1027
|
var DEFAULT_FILE = "translations.xlsx";
|
|
@@ -621,19 +1032,29 @@ async function runExportExcel(options) {
|
|
|
621
1032
|
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
622
1033
|
}
|
|
623
1034
|
const { config } = loaded;
|
|
1035
|
+
const referenceLocale = config.defaultLocale ?? config.locales[0];
|
|
624
1036
|
const file = resolve(options.cwd, options.file ?? config.excel?.file ?? DEFAULT_FILE);
|
|
625
1037
|
const sheetName = options.sheetName ?? config.excel?.sheetName ?? DEFAULT_SHEET;
|
|
626
1038
|
const files = await loadLocaleFiles({
|
|
627
1039
|
cwd: options.cwd,
|
|
628
1040
|
inputDir: config.input,
|
|
629
|
-
locales: config.locales
|
|
1041
|
+
locales: config.locales,
|
|
1042
|
+
namespaces: config.namespaces
|
|
630
1043
|
});
|
|
1044
|
+
const index = indexLocaleFiles(files);
|
|
1045
|
+
if (config.namespaces !== void 0 && index.namespaces.length === 0) {
|
|
1046
|
+
throw noNamespacesError(referenceLocale, config.input);
|
|
1047
|
+
}
|
|
631
1048
|
await exportToExcel({
|
|
632
1049
|
file,
|
|
633
1050
|
sheetName,
|
|
634
|
-
|
|
1051
|
+
files: files.map((f) => ({
|
|
1052
|
+
locale: f.locale,
|
|
1053
|
+
namespace: f.namespace,
|
|
1054
|
+
translations: f.translations
|
|
1055
|
+
}))
|
|
635
1056
|
});
|
|
636
|
-
return { file, sheetName, locales:
|
|
1057
|
+
return { file, sheetName, locales: config.locales, namespaces: index.namespaces };
|
|
637
1058
|
}
|
|
638
1059
|
|
|
639
1060
|
// src/commands/export.ts
|
|
@@ -642,13 +1063,14 @@ function registerExportCommand(program) {
|
|
|
642
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) => {
|
|
643
1064
|
try {
|
|
644
1065
|
const cwd = process.cwd();
|
|
645
|
-
const { file, locales } = await runExportExcel({
|
|
1066
|
+
const { file, locales, namespaces } = await runExportExcel({
|
|
646
1067
|
cwd,
|
|
647
1068
|
file: flags.file,
|
|
648
1069
|
sheetName: flags.sheet
|
|
649
1070
|
});
|
|
1071
|
+
const namespaceSummary = namespaces.length > 0 ? ` across ${chalk.cyan(String(namespaces.length))} namespace(s)` : "";
|
|
650
1072
|
logger.success(
|
|
651
|
-
`Exported ${chalk.cyan(String(locales.length))} locale(s) to ${chalk.bold(
|
|
1073
|
+
`Exported ${chalk.cyan(String(locales.length))} locale(s)${namespaceSummary} to ${chalk.bold(
|
|
652
1074
|
relative(cwd, file)
|
|
653
1075
|
)}`
|
|
654
1076
|
);
|
|
@@ -669,9 +1091,18 @@ async function runImportExcel(options) {
|
|
|
669
1091
|
const { config } = loaded;
|
|
670
1092
|
const file = resolve(options.cwd, options.file ?? config.excel?.file ?? DEFAULT_FILE2);
|
|
671
1093
|
const sheetName = options.sheetName ?? config.excel?.sheetName ?? DEFAULT_SHEET2;
|
|
672
|
-
const inputAbs = resolve(options.cwd, config.input);
|
|
673
1094
|
const configuredLocales = new Set(config.locales);
|
|
674
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
|
+
}
|
|
675
1106
|
const planned = [];
|
|
676
1107
|
const written = [];
|
|
677
1108
|
const skipped = [];
|
|
@@ -680,7 +1111,13 @@ async function runImportExcel(options) {
|
|
|
680
1111
|
skipped.push(entry.locale);
|
|
681
1112
|
continue;
|
|
682
1113
|
}
|
|
683
|
-
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
|
+
});
|
|
684
1121
|
planned.push(path);
|
|
685
1122
|
if (!options.dryRun) {
|
|
686
1123
|
await writeJson(path, entry.translations);
|
|
@@ -973,6 +1410,12 @@ async function fillEmptyTranslations(options) {
|
|
|
973
1410
|
}
|
|
974
1411
|
|
|
975
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
|
+
}
|
|
976
1419
|
async function runTranslate(options) {
|
|
977
1420
|
const loaded = await loadConfig(options.cwd);
|
|
978
1421
|
if (!loaded) {
|
|
@@ -981,62 +1424,101 @@ async function runTranslate(options) {
|
|
|
981
1424
|
const { config } = loaded;
|
|
982
1425
|
const referenceLocale = config.defaultLocale ?? config.locales[0];
|
|
983
1426
|
const provider = options.provider ?? config.ai?.provider ?? "openai";
|
|
984
|
-
const adapter = createAdapter({
|
|
985
|
-
provider,
|
|
986
|
-
apiKey: config.ai?.apiKey,
|
|
987
|
-
model: options.model ?? config.ai?.model
|
|
988
|
-
});
|
|
989
1427
|
const files = await loadLocaleFiles({
|
|
990
1428
|
cwd: options.cwd,
|
|
991
1429
|
inputDir: config.input,
|
|
992
|
-
locales: config.locales
|
|
1430
|
+
locales: config.locales,
|
|
1431
|
+
namespaces: config.namespaces
|
|
993
1432
|
});
|
|
994
|
-
const
|
|
995
|
-
|
|
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) {
|
|
996
1441
|
throw new Error(`Could not find reference locale file for "${referenceLocale}".`);
|
|
997
1442
|
}
|
|
998
|
-
const
|
|
999
|
-
|
|
1443
|
+
const adapter = createAdapter({
|
|
1444
|
+
provider,
|
|
1445
|
+
apiKey: config.ai?.apiKey,
|
|
1446
|
+
model: options.model ?? config.ai?.model
|
|
1447
|
+
});
|
|
1000
1448
|
const allCandidates = [];
|
|
1001
|
-
for (const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
+
}
|
|
1007
1470
|
}
|
|
1008
1471
|
}
|
|
1009
1472
|
const totalTranslatableKeys = allCandidates.length;
|
|
1010
1473
|
const limited = options.maxKeys ? allCandidates.slice(0, options.maxKeys) : allCandidates;
|
|
1011
|
-
const
|
|
1474
|
+
const limitedByPath = /* @__PURE__ */ new Map();
|
|
1475
|
+
const skippedCandidates = options.maxKeys ? allCandidates.slice(options.maxKeys) : [];
|
|
1012
1476
|
for (const candidate of limited) {
|
|
1013
|
-
|
|
1477
|
+
const entries = limitedByPath.get(candidate.path) ?? [];
|
|
1478
|
+
entries.push(candidate);
|
|
1479
|
+
limitedByPath.set(candidate.path, entries);
|
|
1014
1480
|
}
|
|
1015
1481
|
const written = [];
|
|
1016
1482
|
const planned = [];
|
|
1017
1483
|
const translatedByLocale = {};
|
|
1018
1484
|
const skippedByLocale = {};
|
|
1019
|
-
for (const
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
const
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
+
}
|
|
1040
1522
|
}
|
|
1041
1523
|
}
|
|
1042
1524
|
return {
|
|
@@ -1134,21 +1616,32 @@ async function resolveWatchDir(cwd) {
|
|
|
1134
1616
|
if (!loaded) {
|
|
1135
1617
|
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
1136
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
|
+
}
|
|
1137
1624
|
return resolve(cwd, loaded.config.input);
|
|
1138
1625
|
}
|
|
1139
1626
|
async function runWatchPass(options) {
|
|
1140
|
-
const { referenceLocale, written, unchanged, diffsByPath } = await runSync({
|
|
1141
|
-
cwd: options.cwd,
|
|
1142
|
-
dryRun: options.dryRun
|
|
1143
|
-
});
|
|
1144
1627
|
const loaded = await loadConfig(options.cwd);
|
|
1145
1628
|
if (!loaded) {
|
|
1146
1629
|
throw new Error("No LangSync config found. Run `langsync init` first.");
|
|
1147
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
|
+
});
|
|
1148
1640
|
const files = await loadLocaleFiles({
|
|
1149
1641
|
cwd: options.cwd,
|
|
1150
1642
|
inputDir: loaded.config.input,
|
|
1151
|
-
locales: loaded.config.locales
|
|
1643
|
+
locales: loaded.config.locales,
|
|
1644
|
+
namespaces: loaded.config.namespaces
|
|
1152
1645
|
});
|
|
1153
1646
|
const issues = validateLocales(files, referenceLocale);
|
|
1154
1647
|
return { referenceLocale, written, unchanged, diffsByPath, issues };
|
|
@@ -1231,7 +1724,7 @@ function registerWatchCommand(program) {
|
|
|
1231
1724
|
}
|
|
1232
1725
|
|
|
1233
1726
|
// src/cli.ts
|
|
1234
|
-
var VERSION = "0.
|
|
1727
|
+
var VERSION = "0.6.0" ;
|
|
1235
1728
|
async function main() {
|
|
1236
1729
|
assertNodeVersion(22);
|
|
1237
1730
|
const program = new Command();
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,11 @@ 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
13
|
output: z.string().default("./translations").describe(
|
|
@@ -13,6 +18,9 @@ z.object({
|
|
|
13
18
|
"Reference locale. Keys from this locale are synced into all other locales. Defaults to the first entry in `locales`."
|
|
14
19
|
),
|
|
15
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
|
+
),
|
|
16
24
|
excel: z.object({
|
|
17
25
|
file: z.string().default("translations.xlsx"),
|
|
18
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.2.
|
|
69
|
-
"@langsync/
|
|
70
|
-
"@langsync/
|
|
71
|
-
"@langsync/
|
|
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",
|