localingos 0.1.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 +134 -0
- package/bin/localingos.js +39 -0
- package/package.json +26 -0
- package/src/api.js +71 -0
- package/src/commands/init.js +114 -0
- package/src/commands/pull.js +37 -0
- package/src/commands/push.js +55 -0
- package/src/commands/sync.js +75 -0
- package/src/config.js +39 -0
- package/src/formats/index.js +20 -0
- package/src/formats/json-flat.js +33 -0
- package/src/formats/json-nested.js +69 -0
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Localingos CLI
|
|
2
|
+
|
|
3
|
+
Sync translations between your codebase and [Localingos](https://localingos.com) with a single command.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install globally
|
|
9
|
+
npm install -g localingos
|
|
10
|
+
|
|
11
|
+
# Or use with npx (no install needed)
|
|
12
|
+
npx localingos init
|
|
13
|
+
npx localingos sync
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
### 1. Create an API Key
|
|
19
|
+
|
|
20
|
+
Go to [localingos.com](https://localingos.com) → **Settings** → **API Keys** → **Create API Key**.
|
|
21
|
+
|
|
22
|
+
### 2. Initialize your project
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
localingos init
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This will interactively create a `.localingos.json` config file:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"apiUrl": "https://api.localingos.com",
|
|
33
|
+
"apiKey": "your-api-key",
|
|
34
|
+
"familyId": "your-family-id",
|
|
35
|
+
"sourceLocale": "en-US",
|
|
36
|
+
"format": "json-nested",
|
|
37
|
+
"sourceFile": "./src/i18n/en-US.json",
|
|
38
|
+
"outputDir": "./src/i18n",
|
|
39
|
+
"outputPattern": "{locale}.json"
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
> ⚠️ Add `.localingos.json` to your `.gitignore` — it contains your API key.
|
|
44
|
+
|
|
45
|
+
### 3. Sync translations
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
localingos sync
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This single command will:
|
|
52
|
+
1. **Extract** all translatable strings from your source file
|
|
53
|
+
2. **Push** them to Localingos (creates new keys, updates changed ones)
|
|
54
|
+
3. **Pull** all available translations
|
|
55
|
+
4. **Write** translation files per locale (e.g., `es-ES.json`, `fr-FR.json`)
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
### `localingos init`
|
|
60
|
+
|
|
61
|
+
Interactive setup wizard. Creates `.localingos.json` in the current directory.
|
|
62
|
+
|
|
63
|
+
### `localingos sync`
|
|
64
|
+
|
|
65
|
+
The main command. Pushes source strings and pulls translations in one call.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
localingos sync # Full sync
|
|
69
|
+
localingos sync --dry-run # Preview what would be synced
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `localingos push`
|
|
73
|
+
|
|
74
|
+
Push source strings only (no pull).
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
localingos push # Push source strings
|
|
78
|
+
localingos push --dry-run # Preview what would be pushed
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `localingos pull`
|
|
82
|
+
|
|
83
|
+
Pull translations only (no push).
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
localingos pull # Pull and write translation files
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Common Options
|
|
90
|
+
|
|
91
|
+
| Option | Description |
|
|
92
|
+
|--------|-------------|
|
|
93
|
+
| `-c, --config <path>` | Path to config file (default: `.localingos.json`) |
|
|
94
|
+
| `--dry-run` | Preview without making API calls (sync/push) |
|
|
95
|
+
| `-V, --version` | Show version |
|
|
96
|
+
| `-h, --help` | Show help |
|
|
97
|
+
|
|
98
|
+
## Supported Formats
|
|
99
|
+
|
|
100
|
+
| Format | Description | Example |
|
|
101
|
+
|--------|-------------|---------|
|
|
102
|
+
| `json-nested` | Nested JSON objects | `{ "welcome": { "title": "Hello" } }` |
|
|
103
|
+
| `json-flat` | Flat key-value JSON | `{ "welcome.title": "Hello" }` |
|
|
104
|
+
|
|
105
|
+
More formats (YAML, properties, XLIFF) coming soon.
|
|
106
|
+
|
|
107
|
+
## CI/CD Integration
|
|
108
|
+
|
|
109
|
+
Add to your build pipeline to keep translations in sync:
|
|
110
|
+
|
|
111
|
+
```yaml
|
|
112
|
+
# GitHub Actions example
|
|
113
|
+
- name: Sync translations
|
|
114
|
+
run: npx localingos sync
|
|
115
|
+
env:
|
|
116
|
+
# Store API key as a secret, override config
|
|
117
|
+
LOCALINGOS_API_KEY: ${{ secrets.LOCALINGOS_API_KEY }}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## How It Works
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Your codebase Localingos
|
|
124
|
+
┌──────────────┐ ┌──────────────┐
|
|
125
|
+
│ en-US.json │ ──── push ──→ │ Process & │
|
|
126
|
+
│ (source) │ │ Translate │
|
|
127
|
+
│ │ ←── pull ──── │ (AI-powered) │
|
|
128
|
+
│ es-ES.json │ │ │
|
|
129
|
+
│ fr-FR.json │ │ │
|
|
130
|
+
│ de-DE.json │ │ │
|
|
131
|
+
└──────────────┘ └──────────────┘
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The `sync` command combines push + pull into a single API call for efficiency. New/changed strings are registered for translation processing. Already-translated strings are returned immediately.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from 'commander';
|
|
4
|
+
import { initCommand } from '../src/commands/init.js';
|
|
5
|
+
import { syncCommand } from '../src/commands/sync.js';
|
|
6
|
+
import { pullCommand } from '../src/commands/pull.js';
|
|
7
|
+
import { pushCommand } from '../src/commands/push.js';
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name('localingos')
|
|
11
|
+
.description('CLI tool to sync translations with Localingos')
|
|
12
|
+
.version('0.1.0');
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.command('init')
|
|
16
|
+
.description('Initialize a new .localingos.json config file interactively')
|
|
17
|
+
.action(initCommand);
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.command('sync')
|
|
21
|
+
.description('Extract source strings, push to Localingos, and pull all available translations')
|
|
22
|
+
.option('-c, --config <path>', 'Path to config file', '.localingos.json')
|
|
23
|
+
.option('--dry-run', 'Show what would be synced without making API calls')
|
|
24
|
+
.action(syncCommand);
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command('pull')
|
|
28
|
+
.description('Pull all available translations from Localingos and write locale files')
|
|
29
|
+
.option('-c, --config <path>', 'Path to config file', '.localingos.json')
|
|
30
|
+
.action(pullCommand);
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.command('push')
|
|
34
|
+
.description('Extract and push source strings to Localingos without pulling translations')
|
|
35
|
+
.option('-c, --config <path>', 'Path to config file', '.localingos.json')
|
|
36
|
+
.option('--dry-run', 'Show what would be pushed without making API calls')
|
|
37
|
+
.action(pushCommand);
|
|
38
|
+
|
|
39
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "localingos",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI tool to sync translations with Localingos",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"localingos": "./bin/localingos.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"i18n",
|
|
15
|
+
"translation",
|
|
16
|
+
"localization",
|
|
17
|
+
"cli"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"commander": "^12.0.0",
|
|
22
|
+
"chalk": "^5.3.0",
|
|
23
|
+
"inquirer": "^9.2.0",
|
|
24
|
+
"yaml": "^2.4.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export class LocalingosApi {
|
|
4
|
+
constructor(apiUrl, apiKey) {
|
|
5
|
+
this.apiUrl = apiUrl.replace(/\/+$/, '');
|
|
6
|
+
this.apiKey = apiKey;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async sync(familyId, translatables) {
|
|
10
|
+
return this._post('/sync', {
|
|
11
|
+
familyId,
|
|
12
|
+
translatables
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async getTranslatables(familyId) {
|
|
17
|
+
return this._get(`/translatable/${familyId}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async getTranslations(familyId) {
|
|
21
|
+
return this._get(`/translation/${familyId}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async postTranslatables(familyId, translatableList) {
|
|
25
|
+
return this._post('/translatable', {
|
|
26
|
+
familyId,
|
|
27
|
+
translatableList
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async _get(path) {
|
|
32
|
+
const url = `${this.apiUrl}${path}`;
|
|
33
|
+
const response = await fetch(url, {
|
|
34
|
+
method: 'GET',
|
|
35
|
+
headers: {
|
|
36
|
+
'X-API-Key': this.apiKey,
|
|
37
|
+
'Accept': 'application/json'
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const body = await response.text().catch(() => '');
|
|
43
|
+
throw new Error(`API GET ${path} failed (${response.status}): ${body}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return response.json();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async _post(path, body) {
|
|
50
|
+
const url = `${this.apiUrl}${path}`;
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'X-API-Key': this.apiKey,
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
'Accept': 'application/json'
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify(body)
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const responseBody = await response.text().catch(() => '');
|
|
63
|
+
throw new Error(`API POST ${path} failed (${response.status}): ${responseBody}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Some endpoints return 200 with no body
|
|
67
|
+
const text = await response.text();
|
|
68
|
+
if (!text) return {};
|
|
69
|
+
return JSON.parse(text);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { saveConfig } from '../config.js';
|
|
6
|
+
import { getSupportedFormats } from '../formats/index.js';
|
|
7
|
+
|
|
8
|
+
// Use createRequire for inquirer since it needs CJS-style dynamic import
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
export async function initCommand() {
|
|
12
|
+
const inquirer = (await import('inquirer')).default;
|
|
13
|
+
const configPath = path.resolve('.localingos.json');
|
|
14
|
+
|
|
15
|
+
if (fs.existsSync(configPath)) {
|
|
16
|
+
const { overwrite } = await inquirer.prompt([{
|
|
17
|
+
type: 'confirm',
|
|
18
|
+
name: 'overwrite',
|
|
19
|
+
message: '.localingos.json already exists. Overwrite?',
|
|
20
|
+
default: false
|
|
21
|
+
}]);
|
|
22
|
+
if (!overwrite) {
|
|
23
|
+
console.log(chalk.yellow('Aborted.'));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.log(chalk.blue('\n🌍 Localingos CLI Setup\n'));
|
|
29
|
+
|
|
30
|
+
const answers = await inquirer.prompt([
|
|
31
|
+
{
|
|
32
|
+
type: 'input',
|
|
33
|
+
name: 'apiUrl',
|
|
34
|
+
message: 'API URL:',
|
|
35
|
+
default: 'https://api.localingos.com'
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: 'input',
|
|
39
|
+
name: 'apiKey',
|
|
40
|
+
message: 'API Key (from localingos.com → Settings → API Keys):',
|
|
41
|
+
validate: (input) => input.trim().length > 0 || 'API key is required'
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: 'input',
|
|
45
|
+
name: 'familyId',
|
|
46
|
+
message: 'Family ID (your project/family in Localingos):',
|
|
47
|
+
validate: (input) => input.trim().length > 0 || 'Family ID is required'
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: 'input',
|
|
51
|
+
name: 'sourceLocale',
|
|
52
|
+
message: 'Source locale code (e.g. en-US, en-GB):',
|
|
53
|
+
default: 'en-US'
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: 'list',
|
|
57
|
+
name: 'format',
|
|
58
|
+
message: 'Source file format:',
|
|
59
|
+
choices: getSupportedFormats()
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: 'input',
|
|
63
|
+
name: 'sourceFile',
|
|
64
|
+
message: 'Path to source locale file:',
|
|
65
|
+
default: './src/i18n/en-US.json'
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: 'input',
|
|
69
|
+
name: 'outputDir',
|
|
70
|
+
message: 'Output directory for translated files:',
|
|
71
|
+
default: './src/i18n'
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
type: 'input',
|
|
75
|
+
name: 'outputPattern',
|
|
76
|
+
message: 'Output file pattern ({locale} will be replaced):',
|
|
77
|
+
default: '{locale}.json'
|
|
78
|
+
}
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const config = {
|
|
82
|
+
apiUrl: answers.apiUrl.trim(),
|
|
83
|
+
apiKey: answers.apiKey.trim(),
|
|
84
|
+
familyId: answers.familyId.trim(),
|
|
85
|
+
sourceLocale: answers.sourceLocale.trim(),
|
|
86
|
+
format: answers.format,
|
|
87
|
+
sourceFile: answers.sourceFile.trim(),
|
|
88
|
+
outputDir: answers.outputDir.trim(),
|
|
89
|
+
outputPattern: answers.outputPattern.trim()
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const savedPath = saveConfig(config);
|
|
93
|
+
console.log(chalk.green(`\n✅ Config saved to ${savedPath}`));
|
|
94
|
+
|
|
95
|
+
// Check if .gitignore exists and suggest adding .localingos.json
|
|
96
|
+
const gitignorePath = path.resolve('.gitignore');
|
|
97
|
+
if (fs.existsSync(gitignorePath)) {
|
|
98
|
+
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8');
|
|
99
|
+
if (!gitignoreContent.includes('.localingos.json')) {
|
|
100
|
+
const { addGitignore } = await inquirer.prompt([{
|
|
101
|
+
type: 'confirm',
|
|
102
|
+
name: 'addGitignore',
|
|
103
|
+
message: 'Add .localingos.json to .gitignore? (recommended — contains your API key)',
|
|
104
|
+
default: true
|
|
105
|
+
}]);
|
|
106
|
+
if (addGitignore) {
|
|
107
|
+
fs.appendFileSync(gitignorePath, '\n# Localingos CLI config (contains API key)\n.localingos.json\n');
|
|
108
|
+
console.log(chalk.green('✅ Added .localingos.json to .gitignore'));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log(chalk.blue('\nRun "localingos sync" to push source strings and pull translations.\n'));
|
|
114
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { loadConfig } from '../config.js';
|
|
4
|
+
import { LocalingosApi } from '../api.js';
|
|
5
|
+
import { getFormatter } from '../formats/index.js';
|
|
6
|
+
|
|
7
|
+
export async function pullCommand(options) {
|
|
8
|
+
const config = loadConfig(options.config);
|
|
9
|
+
const formatter = getFormatter(config.format);
|
|
10
|
+
|
|
11
|
+
console.log(chalk.blue(`⬇️ Pulling translations for family ${config.familyId}...`));
|
|
12
|
+
const api = new LocalingosApi(config.apiUrl, config.apiKey);
|
|
13
|
+
|
|
14
|
+
let translations;
|
|
15
|
+
try {
|
|
16
|
+
translations = await api.getTranslations(config.familyId);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
console.error(chalk.red(`\nPull failed: ${e.message}`));
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
console.log(chalk.green(` ✅ ${translations.length} translations received`));
|
|
23
|
+
|
|
24
|
+
if (translations.length > 0) {
|
|
25
|
+
const outputDir = path.resolve(config.outputDir);
|
|
26
|
+
console.log(chalk.blue(`\n📝 Writing translation files to ${outputDir}`));
|
|
27
|
+
|
|
28
|
+
const writtenFiles = formatter.write(translations, outputDir, config.outputPattern);
|
|
29
|
+
for (const file of writtenFiles) {
|
|
30
|
+
console.log(chalk.green(` ✅ ${file}`));
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
console.log(chalk.yellow('\n No translations available yet.'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(chalk.blue('\n✨ Pull complete!\n'));
|
|
37
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { loadConfig } from '../config.js';
|
|
4
|
+
import { LocalingosApi } from '../api.js';
|
|
5
|
+
import { getFormatter } from '../formats/index.js';
|
|
6
|
+
|
|
7
|
+
export async function pushCommand(options) {
|
|
8
|
+
const config = loadConfig(options.config);
|
|
9
|
+
const formatter = getFormatter(config.format);
|
|
10
|
+
|
|
11
|
+
// 1. Extract source strings
|
|
12
|
+
const sourceFilePath = path.resolve(config.sourceFile);
|
|
13
|
+
console.log(chalk.blue(`📖 Extracting source strings from ${sourceFilePath}`));
|
|
14
|
+
|
|
15
|
+
let entries;
|
|
16
|
+
try {
|
|
17
|
+
entries = formatter.extract(sourceFilePath);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
console.error(chalk.red(`Failed to read source file: ${e.message}`));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log(chalk.gray(` Found ${entries.length} translatable strings`));
|
|
24
|
+
|
|
25
|
+
const translatables = entries.map(entry => ({
|
|
26
|
+
id: entry.id,
|
|
27
|
+
locale: config.sourceLocale,
|
|
28
|
+
text: entry.text,
|
|
29
|
+
description: entry.description || null,
|
|
30
|
+
forceReTranslate: false
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
if (options.dryRun) {
|
|
34
|
+
console.log(chalk.yellow('\n🔍 Dry run — would push these keys:'));
|
|
35
|
+
for (const t of translatables) {
|
|
36
|
+
console.log(chalk.gray(` ${t.id}: "${t.text.substring(0, 60)}${t.text.length > 60 ? '...' : ''}"`));
|
|
37
|
+
}
|
|
38
|
+
console.log(chalk.yellow(`\nTotal: ${translatables.length} keys. No API calls made.`));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Push to API
|
|
43
|
+
console.log(chalk.blue(`\n⬆️ Pushing ${translatables.length} translatables to family ${config.familyId}...`));
|
|
44
|
+
const api = new LocalingosApi(config.apiUrl, config.apiKey);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await api.postTranslatables(config.familyId, translatables);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.error(chalk.red(`\nPush failed: ${e.message}`));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(chalk.green(` ✅ ${translatables.length} source strings pushed`));
|
|
54
|
+
console.log(chalk.blue('\n✨ Push complete! Translations will be processed shortly.\n'));
|
|
55
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { loadConfig } from '../config.js';
|
|
4
|
+
import { LocalingosApi } from '../api.js';
|
|
5
|
+
import { getFormatter } from '../formats/index.js';
|
|
6
|
+
|
|
7
|
+
export async function syncCommand(options) {
|
|
8
|
+
const config = loadConfig(options.config);
|
|
9
|
+
const formatter = getFormatter(config.format);
|
|
10
|
+
|
|
11
|
+
// 1. Extract source strings
|
|
12
|
+
const sourceFilePath = path.resolve(config.sourceFile);
|
|
13
|
+
console.log(chalk.blue(`📖 Extracting source strings from ${sourceFilePath}`));
|
|
14
|
+
|
|
15
|
+
let entries;
|
|
16
|
+
try {
|
|
17
|
+
entries = formatter.extract(sourceFilePath);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
console.error(chalk.red(`Failed to read source file: ${e.message}`));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log(chalk.gray(` Found ${entries.length} translatable strings`));
|
|
24
|
+
|
|
25
|
+
// Build translatables payload
|
|
26
|
+
const translatables = entries.map(entry => ({
|
|
27
|
+
id: entry.id,
|
|
28
|
+
locale: config.sourceLocale,
|
|
29
|
+
text: entry.text,
|
|
30
|
+
description: entry.description || null,
|
|
31
|
+
forceReTranslate: false
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
if (options.dryRun) {
|
|
35
|
+
console.log(chalk.yellow('\n🔍 Dry run — would sync these keys:'));
|
|
36
|
+
for (const t of translatables) {
|
|
37
|
+
console.log(chalk.gray(` ${t.id}: "${t.text.substring(0, 60)}${t.text.length > 60 ? '...' : ''}"`));
|
|
38
|
+
}
|
|
39
|
+
console.log(chalk.yellow(`\nTotal: ${translatables.length} keys. No API calls made.`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. Call /sync endpoint (push + pull in one call)
|
|
44
|
+
console.log(chalk.blue(`\n🔄 Syncing with Localingos (family: ${config.familyId})...`));
|
|
45
|
+
const api = new LocalingosApi(config.apiUrl, config.apiKey);
|
|
46
|
+
|
|
47
|
+
let result;
|
|
48
|
+
try {
|
|
49
|
+
result = await api.sync(config.familyId, translatables);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.error(chalk.red(`\nSync failed: ${e.message}`));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { translations, pending } = result;
|
|
56
|
+
console.log(chalk.green(` ✅ ${translations.length} translations available`));
|
|
57
|
+
if (pending.length > 0) {
|
|
58
|
+
console.log(chalk.yellow(` ⏳ ${pending.length} keys pending translation: ${pending.slice(0, 5).join(', ')}${pending.length > 5 ? '...' : ''}`));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. Write translation files
|
|
62
|
+
if (translations.length > 0) {
|
|
63
|
+
const outputDir = path.resolve(config.outputDir);
|
|
64
|
+
console.log(chalk.blue(`\n📝 Writing translation files to ${outputDir}`));
|
|
65
|
+
|
|
66
|
+
const writtenFiles = formatter.write(translations, outputDir, config.outputPattern);
|
|
67
|
+
for (const file of writtenFiles) {
|
|
68
|
+
console.log(chalk.green(` ✅ ${file}`));
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
console.log(chalk.yellow('\n No translations available yet. Run "localingos sync" again later.'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(chalk.blue('\n✨ Sync complete!\n'));
|
|
75
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG_FILE = '.localingos.json';
|
|
6
|
+
|
|
7
|
+
export function loadConfig(configPath) {
|
|
8
|
+
const resolvedPath = path.resolve(configPath || DEFAULT_CONFIG_FILE);
|
|
9
|
+
|
|
10
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
11
|
+
console.error(chalk.red(`Config file not found: ${resolvedPath}`));
|
|
12
|
+
console.error(chalk.yellow('Run "localingos init" to create one.'));
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const raw = fs.readFileSync(resolvedPath, 'utf-8');
|
|
18
|
+
const config = JSON.parse(raw);
|
|
19
|
+
|
|
20
|
+
// Validate required fields
|
|
21
|
+
const required = ['apiKey', 'familyId', 'apiUrl', 'sourceLocale', 'format', 'sourceFile', 'outputDir'];
|
|
22
|
+
const missing = required.filter(field => !config[field]);
|
|
23
|
+
if (missing.length > 0) {
|
|
24
|
+
console.error(chalk.red(`Missing required config fields: ${missing.join(', ')}`));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return config;
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error(chalk.red(`Failed to parse config file: ${e.message}`));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function saveConfig(config, configPath) {
|
|
36
|
+
const resolvedPath = path.resolve(configPath || DEFAULT_CONFIG_FILE);
|
|
37
|
+
fs.writeFileSync(resolvedPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
38
|
+
return resolvedPath;
|
|
39
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as jsonNested from './json-nested.js';
|
|
2
|
+
import * as jsonFlat from './json-flat.js';
|
|
3
|
+
|
|
4
|
+
const formatters = {
|
|
5
|
+
'json-nested': jsonNested,
|
|
6
|
+
'json-flat': jsonFlat
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function getFormatter(format) {
|
|
10
|
+
const formatter = formatters[format];
|
|
11
|
+
if (!formatter) {
|
|
12
|
+
const supported = Object.keys(formatters).join(', ');
|
|
13
|
+
throw new Error(`Unsupported format "${format}". Supported: ${supported}`);
|
|
14
|
+
}
|
|
15
|
+
return formatter;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSupportedFormats() {
|
|
19
|
+
return Object.keys(formatters);
|
|
20
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export function extract(filePath) {
|
|
5
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
6
|
+
const json = JSON.parse(raw);
|
|
7
|
+
return Object.entries(json).map(([key, value]) => ({
|
|
8
|
+
id: key,
|
|
9
|
+
text: String(value)
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function write(translations, outputDir, outputPattern) {
|
|
14
|
+
const byLocale = {};
|
|
15
|
+
for (const t of translations) {
|
|
16
|
+
if (!byLocale[t.locale]) {
|
|
17
|
+
byLocale[t.locale] = {};
|
|
18
|
+
}
|
|
19
|
+
byLocale[t.locale][t.foreignId] = t.text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const writtenFiles = [];
|
|
23
|
+
for (const [locale, entries] of Object.entries(byLocale)) {
|
|
24
|
+
const fileName = (outputPattern || '{locale}.json').replace('{locale}', locale);
|
|
25
|
+
const filePath = path.join(outputDir, fileName);
|
|
26
|
+
|
|
27
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
28
|
+
fs.writeFileSync(filePath, JSON.stringify(entries, null, 2) + '\n', 'utf-8');
|
|
29
|
+
writtenFiles.push(filePath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return writtenFiles;
|
|
33
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flatten a nested JSON object into dot-separated keys.
|
|
6
|
+
* { "welcome": { "title": "Hello" } } → [{ id: "welcome.title", text: "Hello" }]
|
|
7
|
+
*/
|
|
8
|
+
function flatten(obj, prefix = '') {
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
11
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
12
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
13
|
+
entries.push(...flatten(value, fullKey));
|
|
14
|
+
} else {
|
|
15
|
+
entries.push({ id: fullKey, text: String(value) });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return entries;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Unflatten dot-separated keys back into nested object.
|
|
23
|
+
* [{ foreignId: "welcome.title", text: "Hello" }] → { "welcome": { "title": "Hello" } }
|
|
24
|
+
*/
|
|
25
|
+
function unflatten(translations) {
|
|
26
|
+
const result = {};
|
|
27
|
+
for (const { foreignId, text } of translations) {
|
|
28
|
+
const keys = foreignId.split('.');
|
|
29
|
+
let current = result;
|
|
30
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
31
|
+
if (!current[keys[i]] || typeof current[keys[i]] !== 'object') {
|
|
32
|
+
current[keys[i]] = {};
|
|
33
|
+
}
|
|
34
|
+
current = current[keys[i]];
|
|
35
|
+
}
|
|
36
|
+
current[keys[keys.length - 1]] = text;
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function extract(filePath) {
|
|
42
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
43
|
+
const json = JSON.parse(raw);
|
|
44
|
+
return flatten(json);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function write(translations, outputDir, outputPattern) {
|
|
48
|
+
// Group translations by locale
|
|
49
|
+
const byLocale = {};
|
|
50
|
+
for (const t of translations) {
|
|
51
|
+
if (!byLocale[t.locale]) {
|
|
52
|
+
byLocale[t.locale] = [];
|
|
53
|
+
}
|
|
54
|
+
byLocale[t.locale].push(t);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const writtenFiles = [];
|
|
58
|
+
for (const [locale, localeTranslations] of Object.entries(byLocale)) {
|
|
59
|
+
const nested = unflatten(localeTranslations);
|
|
60
|
+
const fileName = (outputPattern || '{locale}.json').replace('{locale}', locale);
|
|
61
|
+
const filePath = path.join(outputDir, fileName);
|
|
62
|
+
|
|
63
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
64
|
+
fs.writeFileSync(filePath, JSON.stringify(nested, null, 2) + '\n', 'utf-8');
|
|
65
|
+
writtenFiles.push(filePath);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return writtenFiles;
|
|
69
|
+
}
|