localize-sync 1.0.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/bin/cli.js +53 -0
- package/package.json +21 -0
- package/readme +120 -0
- package/src/commands/check.js +57 -0
- package/src/commands/report.js +20 -0
- package/src/commands/translate.js +39 -0
- package/src/utils/flattenKeys.js +12 -0
- package/src/utils/loadconfig.js +44 -0
- package/src/utils/translateText.js +28 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from 'commander';
|
|
4
|
+
import { loadConfig } from '../src/utils/loadconfig.js';
|
|
5
|
+
import { check } from '../src/commands/check.js';
|
|
6
|
+
import { report } from '../src/commands/report.js';
|
|
7
|
+
import { translate } from '../src/commands/translate.js';
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name('localize-sync')
|
|
11
|
+
.description('Auto detect and translate missing i18n JSON keys')
|
|
12
|
+
.version('1.0.0');
|
|
13
|
+
|
|
14
|
+
const sharedOptions = (cmd) => {
|
|
15
|
+
return cmd
|
|
16
|
+
.option('-d, --dir <path>', 'Path to locales directory')
|
|
17
|
+
.option('-s, --source <lang>', 'Source language file (without .json)');
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
sharedOptions(
|
|
21
|
+
program.command('check').description('Check missing and extra keys across all locale files')
|
|
22
|
+
).action(async (options) => {
|
|
23
|
+
const config = await loadConfig(options);
|
|
24
|
+
await check(config);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
sharedOptions(
|
|
28
|
+
program.command('report').description('Generate a JSON report of translation coverage')
|
|
29
|
+
).action(async (options) => {
|
|
30
|
+
const config = await loadConfig(options);
|
|
31
|
+
const results = await check(config);
|
|
32
|
+
await report(results, config);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
sharedOptions(
|
|
36
|
+
program.command('translate').description('Auto translate missing keys using MyMemory API')
|
|
37
|
+
).action(async (options) => {
|
|
38
|
+
const config = await loadConfig(options);
|
|
39
|
+
const results = await check(config);
|
|
40
|
+
await translate(results, config);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
sharedOptions(
|
|
44
|
+
program.command('all').description('Run check, translate and report together')
|
|
45
|
+
).action(async (options) => {
|
|
46
|
+
const config = await loadConfig(options);
|
|
47
|
+
const results = await check(config);
|
|
48
|
+
await translate(results, config);
|
|
49
|
+
const updatedResults = await check(config);
|
|
50
|
+
await report(updatedResults, config);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "localize-sync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Auto translate i18n JSON files using MyMemory API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"localize-sync": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"i18n",
|
|
14
|
+
"translate",
|
|
15
|
+
"cli",
|
|
16
|
+
"localization",
|
|
17
|
+
"json"
|
|
18
|
+
],
|
|
19
|
+
"author": "Amir Hossein Jamshidi",
|
|
20
|
+
"license": "MIT"
|
|
21
|
+
}
|
package/readme
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# localize-sync
|
|
2
|
+
|
|
3
|
+
Auto-detect and translate missing i18n JSON keys using the MyMemory API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g localize-sync
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx localize-sync <command>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
`localize-sync` treats one JSON file as the **source of truth** (default: `fa.json`) and compares all other JSON files in the same directory against it. Missing keys are automatically translated via the [MyMemory API](https://mymemory.translated.net/).
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
| Command | Description |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `check` | Show missing and extra keys across all locale files |
|
|
30
|
+
| `translate` | Auto-translate missing keys |
|
|
31
|
+
| `report` | Generate a `i18n-report.json` coverage report |
|
|
32
|
+
| `all` | Run check ā translate ā report in sequence |
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Options
|
|
37
|
+
|
|
38
|
+
| Flag | Alias | Default | Description |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| `--dir <path>` | `-d` | `./locales` | Path to your locales directory |
|
|
41
|
+
| `--source <lang>` | `-s` | `fa` | Source language filename (without `.json`) |
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Check missing keys (source: fa.json, dir: ./locales)
|
|
49
|
+
localize-sync check
|
|
50
|
+
|
|
51
|
+
# Translate missing keys in a custom directory
|
|
52
|
+
localize-sync translate --dir ./src/translations
|
|
53
|
+
|
|
54
|
+
# Use a different source language
|
|
55
|
+
localize-sync all --source en
|
|
56
|
+
|
|
57
|
+
# Combine both options
|
|
58
|
+
localize-sync all --dir ./src/i18n --source en
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Config file (optional)
|
|
64
|
+
|
|
65
|
+
Instead of passing flags every time, create an `i18n.config.js` in your project root:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
// i18n.config.js
|
|
69
|
+
export default {
|
|
70
|
+
dir: './src/locales',
|
|
71
|
+
source: 'fa',
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Priority order: `CLI flags` > `i18n.config.js` > `defaults`
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Example
|
|
80
|
+
|
|
81
|
+
Given this structure:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
locales/
|
|
85
|
+
fa.json ā source of truth
|
|
86
|
+
en.json ā partially translated
|
|
87
|
+
de.json ā empty
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Running `localize-sync all` will:
|
|
91
|
+
|
|
92
|
+
1. Detect missing keys in `en.json` and `de.json`
|
|
93
|
+
2. Translate them automatically from Persian
|
|
94
|
+
3. Save a coverage report to `i18n-report.json`
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Report output
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"en": {
|
|
103
|
+
"total": 9,
|
|
104
|
+
"translated": 9,
|
|
105
|
+
"coverage": "100%",
|
|
106
|
+
"missingKeys": [],
|
|
107
|
+
"extraKeys": []
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Notes
|
|
115
|
+
|
|
116
|
+
- Translation is powered by [MyMemory](https://mymemory.translated.net/) ā free, no API key required
|
|
117
|
+
- A 300ms delay is applied between requests to avoid rate limiting
|
|
118
|
+
- If a translation fails, a `ā ļø MISSING: <original>` placeholder is inserted
|
|
119
|
+
|
|
120
|
+
---
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { flattenKeys } from '../utils/flattenKeys.js';
|
|
4
|
+
|
|
5
|
+
export async function check(config) {
|
|
6
|
+
const files = fs.readdirSync(config.dir).filter((f) => f.endsWith('.json'));
|
|
7
|
+
|
|
8
|
+
if (files.length === 0) {
|
|
9
|
+
console.error('ā No JSON files found in:', config.dir);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const sourceFile = `${config.source}.json`;
|
|
14
|
+
|
|
15
|
+
if (!files.includes(sourceFile)) {
|
|
16
|
+
console.error(`ā Source file "${sourceFile}" not found in ${config.dir}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sourceData = JSON.parse(fs.readFileSync(path.join(config.dir, sourceFile), 'utf-8'));
|
|
21
|
+
const sourceKeys = flattenKeys(sourceData);
|
|
22
|
+
|
|
23
|
+
console.log(`\nš Source: ${sourceFile} ā ${Object.keys(sourceKeys).length} keys\n`);
|
|
24
|
+
|
|
25
|
+
const results = [];
|
|
26
|
+
|
|
27
|
+
files
|
|
28
|
+
.filter((f) => f !== sourceFile)
|
|
29
|
+
.forEach((file) => {
|
|
30
|
+
const lang = file.replace('.json', '');
|
|
31
|
+
const targetData = JSON.parse(fs.readFileSync(path.join(config.dir, file)));
|
|
32
|
+
const targetKeys = flattenKeys(targetData);
|
|
33
|
+
|
|
34
|
+
const missingKeys = Object.keys(sourceKeys).filter((k) => !(k in targetKeys));
|
|
35
|
+
const extraKeys = Object.keys(targetKeys).filter((k) => !(k in sourceKeys));
|
|
36
|
+
|
|
37
|
+
console.log(`š ${file}`);
|
|
38
|
+
console.log(
|
|
39
|
+
` ā
Translated: ${Object.keys(targetKeys).length} / ${Object.keys(sourceKeys).length}`
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (missingKeys.length > 0) {
|
|
43
|
+
console.log(` ā Missing keys (${missingKeys.length}):`);
|
|
44
|
+
} else {
|
|
45
|
+
console.log(` š All keys are translated!`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (extraKeys.length > 0) {
|
|
49
|
+
console.log(` ā ļø Extra keys (${extraKeys.length}):`);
|
|
50
|
+
extraKeys.forEach((k) => console.log(` - ${k}`));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
results.push({ lang, sourceKeys, targetKeys, missingKeys, extraKeys });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export async function report(results, config) {
|
|
5
|
+
const output = {};
|
|
6
|
+
|
|
7
|
+
results.forEach(({ lang, sourceKeys, targetKeys, missingKeys, extraKeys }) => {
|
|
8
|
+
output[lang] = {
|
|
9
|
+
total: Object.keys(sourceKeys).length,
|
|
10
|
+
translated: Object.keys(targetKeys).length,
|
|
11
|
+
coverage: `${Math.round((Object.keys(targetKeys).length / Object.keys(sourceKeys).length) * 100)}%`,
|
|
12
|
+
missingKeys,
|
|
13
|
+
extraKeys
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const reportPath = path.resolve(process.cwd(), 'i18n-report.json');
|
|
18
|
+
fs.writeFileSync(reportPath, JSON.stringify(output, null, 2), 'utf-8');
|
|
19
|
+
console.log(`\nš Report saved: ${reportPath}\n`);
|
|
20
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { translateText } from '../utils/translateText.js';
|
|
4
|
+
|
|
5
|
+
export async function translate(results, config) {
|
|
6
|
+
for (const result of results) {
|
|
7
|
+
const { lang, missingKeys, sourceKeys } = result;
|
|
8
|
+
const filePath = path.join(config.dir, `${lang}.json`);
|
|
9
|
+
const targetData = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
10
|
+
if (missingKeys.length === 0) {
|
|
11
|
+
console.log(`ā
${lang}.json - Nothing to translate`);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log(`\nš ${lang}.json - Translating ${missingKeys.length} keys...\n`);
|
|
16
|
+
|
|
17
|
+
for (const flatKey of missingKeys) {
|
|
18
|
+
const originalText = sourceKeys[flatKey];
|
|
19
|
+
process.stdout.write(` š ${flatKey}: "${originalText}" ā `);
|
|
20
|
+
const translated = await translateText(originalText, config.source, lang);
|
|
21
|
+
|
|
22
|
+
const parts = flatKey.split('.');
|
|
23
|
+
let current = targetData;
|
|
24
|
+
parts.forEach((part, index) => {
|
|
25
|
+
if (index === parts.length - 1) {
|
|
26
|
+
current[part] = translated ?? `ā ļø MISSING: ${originalText}`;
|
|
27
|
+
} else {
|
|
28
|
+
if (!current[part]) current[part] = {};
|
|
29
|
+
current = current[part];
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
console.log(translated ? `"${translated}"` : `ā ļø Translation failed, placeholder inserted`);
|
|
33
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fs.writeFileSync(filePath, JSON.stringify(targetData, null, 2), 'utf-8');
|
|
37
|
+
console.log(`\nš¾ ${lang}.json saved`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function flattenKeys(obj, prefix = '') {
|
|
2
|
+
return Object.keys(obj).reduce((acc, key) => {
|
|
3
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
4
|
+
const value = obj[key];
|
|
5
|
+
if (typeof value === 'object' && value !== null) {
|
|
6
|
+
Object.assign(acc, flattenKeys(value, fullKey));
|
|
7
|
+
} else {
|
|
8
|
+
acc[fullKey] = String(value);
|
|
9
|
+
}
|
|
10
|
+
return acc;
|
|
11
|
+
}, {});
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
dir: './locales',
|
|
6
|
+
source: 'fa'
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function loadConfig(flags = {}) {
|
|
10
|
+
let fileConfig = {};
|
|
11
|
+
const configPath = path.resolve(process.cwd(), 'i18n.config.js');
|
|
12
|
+
|
|
13
|
+
if (fs.existsSync(configPath)) {
|
|
14
|
+
try {
|
|
15
|
+
const imported = await import(configPath);
|
|
16
|
+
fileConfig = imported.default || {};
|
|
17
|
+
console.log('āļø Config loaded from i18n.config.js');
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.warn('ā ļø Failed to load i18n.config.js, using defaults');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const config = {
|
|
24
|
+
dir: flags.dir || fileConfig.dir || DEFAULTS.dir,
|
|
25
|
+
source: flags.source || fileConfig.source || DEFAULTS.source
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const resolvedDir = path.isAbsolute(config.dir)
|
|
29
|
+
? config.dir
|
|
30
|
+
: path.resolve(process.cwd(), config.dir);
|
|
31
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
32
|
+
console.log(`ā Directory not found: ${resolvedDir}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const stat = fs.statSync(resolvedDir);
|
|
37
|
+
if (!stat.isDirectory()) {
|
|
38
|
+
console.error(`ā Path is not a directory: ${resolvedDir}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
config.dir = resolvedDir;
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
|
|
3
|
+
const LANG_MAP = {
|
|
4
|
+
en: 'en-US',
|
|
5
|
+
de: 'de-DE',
|
|
6
|
+
fr: 'fr-FR',
|
|
7
|
+
ar: 'ar-SA',
|
|
8
|
+
es: 'es-ES',
|
|
9
|
+
it: 'it-IT',
|
|
10
|
+
tr: 'tr-TR'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function translateText(text, fromLang, toLang) {
|
|
14
|
+
const langPair = `${LANG_MAP[fromLang] || fromLang}|${LANG_MAP[toLang] || toLang}`;
|
|
15
|
+
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${langPair}`;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(url);
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
if (data.responseStatus === 200) {
|
|
21
|
+
return data.responseData.translatedText;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error(`ā Error translating "${text}":`, err.message);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|