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 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
+ }