localingos 0.1.40 → 0.1.41
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/package.json +1 -1
- package/src/api.js +14 -25
- package/src/commands/extract.js +2 -1
- package/src/commands/mcp-serve.js +44 -4
- package/src/commands/sync.js +54 -41
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -6,36 +6,25 @@ export class LocalingosApi {
|
|
|
6
6
|
this.apiKey = apiKey;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
async sync(projectId, translatables, {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
9
|
+
async sync(projectId, translatables, { knownHashes = {}, onProgress } = {}) {
|
|
10
|
+
const result = await this._post('/sync', {
|
|
11
|
+
projectId,
|
|
12
|
+
translatables,
|
|
13
|
+
knownHashes
|
|
14
|
+
});
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// Paginate pull if server returned nextPullToken
|
|
17
|
+
while (result.nextPullToken) {
|
|
18
|
+
if (onProgress) onProgress('pull');
|
|
19
|
+
const page = await this._post('/sync/pull', {
|
|
18
20
|
projectId,
|
|
19
|
-
|
|
21
|
+
nextPullToken: result.nextPullToken
|
|
20
22
|
});
|
|
21
|
-
if (
|
|
23
|
+
if (page.translations) result.translations.push(...page.translations);
|
|
24
|
+
result.nextPullToken = page.nextPullToken;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
const allTranslations = [];
|
|
26
|
-
const ids = translatables.map(t => t.id);
|
|
27
|
-
const pullBatchSize = 50;
|
|
28
|
-
for (let i = 0; i < ids.length; i += pullBatchSize) {
|
|
29
|
-
const batch = ids.slice(i, i + pullBatchSize);
|
|
30
|
-
const batchTranslations = await this._post(`/translation/${projectId}/batch`, { foreignIds: batch });
|
|
31
|
-
if (Array.isArray(batchTranslations)) allTranslations.push(...batchTranslations);
|
|
32
|
-
if (onProgress) onProgress(Math.min(i + pullBatchSize, ids.length), ids.length, 'pull');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const availableIds = new Set(allTranslations.map(t => t.foreignId || t.translatableId));
|
|
36
|
-
const pending = translatables.filter(t => !availableIds.has(t.id)).map(t => t.id);
|
|
37
|
-
|
|
38
|
-
return { translations: allTranslations, pending };
|
|
27
|
+
return result;
|
|
39
28
|
}
|
|
40
29
|
|
|
41
30
|
async getTranslatables(projectId) {
|
package/src/commands/extract.js
CHANGED
|
@@ -402,8 +402,9 @@ How it works:
|
|
|
402
402
|
4. If a key is never referenced anywhere (neither by its original export name nor any alias), it's an orphan
|
|
403
403
|
|
|
404
404
|
Behavior:
|
|
405
|
+
- **Always**: orphan ids are excluded from the JSON output files — they are never written to the source or descriptions JSON, regardless of flags. This prevents them from being synced/translated.
|
|
405
406
|
- **Default run** (\`npm run i18n:extract\`): report orphans with file, export name, record key, and id — grouped by file for readability. Print a message like: \`🧹 N orphan message(s) found — defined but never referenced in code\`
|
|
406
|
-
- **\`--prune\`**: also
|
|
407
|
+
- **\`--prune\`**: also remove stale keys (keys in JSON but no longer in any \`*${msgExt}\` file) from the JSON output files
|
|
407
408
|
- **\`--check\`**: exit with error code if orphans exist (prevents wasting translation budget in CI)
|
|
408
409
|
|
|
409
410
|
This prevents paying to translate strings that aren't even used in the app.
|
|
@@ -21,6 +21,21 @@ function loadConfig(cwd) {
|
|
|
21
21
|
return config;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function loadKnownHashes(cwd) {
|
|
25
|
+
try {
|
|
26
|
+
const data = JSON.parse(fs.readFileSync(path.join(cwd, '.localingos.json'), 'utf-8'));
|
|
27
|
+
return data.knownHashes || {};
|
|
28
|
+
} catch { return {}; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function saveKnownHashes(cwd, hashes) {
|
|
32
|
+
const localPath = path.join(cwd, '.localingos.json');
|
|
33
|
+
let data = {};
|
|
34
|
+
try { data = JSON.parse(fs.readFileSync(localPath, 'utf-8')); } catch {}
|
|
35
|
+
data.knownHashes = hashes;
|
|
36
|
+
fs.writeFileSync(localPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
async function api(method, urlPath, apiUrl, apiKey, body) {
|
|
25
40
|
const url = `${apiUrl.replace(/\/+$/, '')}${urlPath}`;
|
|
26
41
|
const opts = { method, headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' } };
|
|
@@ -89,11 +104,36 @@ export async function mcpServeCommand() {
|
|
|
89
104
|
}
|
|
90
105
|
const entries = readSourceEntries(config, cwd);
|
|
91
106
|
if (!entries.length) return { content: [{ type: 'text', text: 'No source strings found.' }] };
|
|
92
|
-
const
|
|
93
|
-
const
|
|
107
|
+
const knownHashes = loadKnownHashes(cwd);
|
|
108
|
+
const result = await api('POST', '/sync', apiUrl, config.apiKey, { projectId: config.projectId, translatables: entries, knownHashes });
|
|
109
|
+
const translations = result.translations || [];
|
|
110
|
+
// Paginate pull
|
|
111
|
+
while (result.nextPullToken) {
|
|
112
|
+
const page = await api('POST', '/sync/pull', apiUrl, config.apiKey, { projectId: config.projectId, nextPullToken: result.nextPullToken });
|
|
113
|
+
if (page.translations) translations.push(...page.translations);
|
|
114
|
+
result.nextPullToken = page.nextPullToken;
|
|
115
|
+
}
|
|
116
|
+
if (translations.length > 0) {
|
|
117
|
+
const changedLocales = new Set(translations.filter(t => t.locale !== config.sourceLocale).map(t => t.locale));
|
|
118
|
+
const outputDir = path.resolve(cwd, config.outputDir);
|
|
119
|
+
for (const locale of changedLocales) {
|
|
120
|
+
const existing = {};
|
|
121
|
+
const fileName = (config.outputPattern || '{locale}.json').replace('{locale}', locale);
|
|
122
|
+
const filePath = path.join(outputDir, fileName);
|
|
123
|
+
if (fs.existsSync(filePath)) {
|
|
124
|
+
try { for (const e of flatten(JSON.parse(fs.readFileSync(filePath, 'utf-8')))) existing[e.id] = { foreignId: e.id, text: e.text, locale }; } catch {}
|
|
125
|
+
}
|
|
126
|
+
for (const t of translations.filter(t => t.locale === locale)) existing[t.foreignId] = t;
|
|
127
|
+
writeTranslations(Object.values(existing), config, cwd);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (result.hashes) saveKnownHashes(cwd, result.hashes);
|
|
131
|
+
const push = result.push || {};
|
|
94
132
|
const pending = result.pending || [];
|
|
95
|
-
const lines = [
|
|
96
|
-
|
|
133
|
+
const lines = [
|
|
134
|
+
`Push: ${push.created || 0} created, ${push.updated || 0} updated, ${push.deleted || 0} deleted, ${push.unchanged || 0} unchanged`,
|
|
135
|
+
`${translations.length} new translations received`,
|
|
136
|
+
pending.length ? `${pending.length} keys pending` : null,
|
|
97
137
|
extractOut ? `\n${extractOut}` : null].filter(Boolean);
|
|
98
138
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
99
139
|
}
|
package/src/commands/sync.js
CHANGED
|
@@ -6,6 +6,23 @@ import { loadConfig, resolveApiUrl } from '../config.js';
|
|
|
6
6
|
import { LocalingosApi } from '../api.js';
|
|
7
7
|
import { getFormatter } from '../formats/index.js';
|
|
8
8
|
|
|
9
|
+
const LOCAL_CONFIG_FILE = '.localingos.json';
|
|
10
|
+
|
|
11
|
+
function loadKnownHashes() {
|
|
12
|
+
try {
|
|
13
|
+
const data = JSON.parse(fs.readFileSync(path.resolve(LOCAL_CONFIG_FILE), 'utf-8'));
|
|
14
|
+
return data.knownHashes || {};
|
|
15
|
+
} catch { return {}; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function saveKnownHashes(hashes) {
|
|
19
|
+
const localPath = path.resolve(LOCAL_CONFIG_FILE);
|
|
20
|
+
let data = {};
|
|
21
|
+
try { data = JSON.parse(fs.readFileSync(localPath, 'utf-8')); } catch {}
|
|
22
|
+
data.knownHashes = hashes;
|
|
23
|
+
fs.writeFileSync(localPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
export async function syncCommand(options) {
|
|
10
27
|
const config = loadConfig(options.config);
|
|
11
28
|
const apiUrl = resolveApiUrl(options.env);
|
|
@@ -72,68 +89,64 @@ export async function syncCommand(options) {
|
|
|
72
89
|
// 2. Call /sync endpoint (push + pull in one call)
|
|
73
90
|
console.log(chalk.blue(`\n🔄 Syncing with Localingos (project: ${config.projectId})...`));
|
|
74
91
|
const api = new LocalingosApi(apiUrl, config.apiKey);
|
|
92
|
+
const knownHashes = loadKnownHashes();
|
|
75
93
|
|
|
76
94
|
let result;
|
|
77
95
|
try {
|
|
78
|
-
result = await api.sync(config.projectId, translatables, {
|
|
79
|
-
onProgress: (done, total, phase) => {
|
|
80
|
-
const label = phase === 'push' ? 'Pushed' : 'Pulled';
|
|
81
|
-
process.stdout.write(`\r ${label} ${done}/${total} strings...`);
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
process.stdout.write('\n');
|
|
96
|
+
result = await api.sync(config.projectId, translatables, { knownHashes });
|
|
85
97
|
} catch (e) {
|
|
86
98
|
console.error(chalk.red(`\nSync failed: ${e.message}`));
|
|
87
99
|
process.exit(1);
|
|
88
100
|
}
|
|
89
101
|
|
|
90
|
-
const translations =
|
|
91
|
-
|
|
92
|
-
|
|
102
|
+
const { push, translations = [], hashes = {}, pending = [] } = result;
|
|
103
|
+
if (push) {
|
|
104
|
+
console.log(chalk.gray(` Push: ${push.created} created, ${push.updated} updated, ${push.deleted} deleted, ${push.unchanged} unchanged`));
|
|
105
|
+
}
|
|
106
|
+
console.log(chalk.green(` ✅ ${translations.length} translations received`));
|
|
93
107
|
if (pending.length > 0) {
|
|
94
108
|
console.log(chalk.yellow(` ⏳ ${pending.length} keys pending translation: ${pending.slice(0, 5).join(', ')}${pending.length > 5 ? '...' : ''}`));
|
|
95
109
|
}
|
|
96
110
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
console.log(chalk.blue('\n🔍 Checking for stale translatables in the remote project...'));
|
|
100
|
-
try {
|
|
101
|
-
const remoteTranslatables = await api.getTranslatables(config.projectId);
|
|
102
|
-
const localIds = new Set(translatables.map(t => t.id));
|
|
103
|
-
const staleIds = remoteTranslatables
|
|
104
|
-
.filter(t => !localIds.has(t.foreignId || t.id))
|
|
105
|
-
.map(t => t.foreignId || t.id);
|
|
106
|
-
|
|
107
|
-
if (staleIds.length > 0) {
|
|
108
|
-
console.log(chalk.yellow(` Found ${staleIds.length} stale translatable(s) not in source file:`));
|
|
109
|
-
for (const id of staleIds.slice(0, 10)) {
|
|
110
|
-
console.log(chalk.gray(` - ${id}`));
|
|
111
|
-
}
|
|
112
|
-
if (staleIds.length > 10) console.log(chalk.gray(` ... and ${staleIds.length - 10} more`));
|
|
113
|
-
|
|
114
|
-
const deleteResult = await api.deleteTranslatables(config.projectId, staleIds);
|
|
115
|
-
console.log(chalk.green(` 🗑️ Deleted ${deleteResult.deleted} stale translatable(s) from remote`));
|
|
116
|
-
} else {
|
|
117
|
-
console.log(chalk.green(' ✅ No stale translatables found'));
|
|
118
|
-
}
|
|
119
|
-
} catch (e) {
|
|
120
|
-
console.error(chalk.yellow(` ⚠️ Could not prune remote translatables: ${e.message}`));
|
|
121
|
-
}
|
|
122
|
-
}
|
|
111
|
+
// Save updated hashes
|
|
112
|
+
saveKnownHashes(hashes);
|
|
123
113
|
|
|
124
|
-
// 3. Write translation files
|
|
114
|
+
// 3. Write translation files — merge new translations into existing files
|
|
125
115
|
const targetTranslations = translations.filter(t => t.locale !== config.sourceLocale);
|
|
126
116
|
|
|
127
117
|
if (targetTranslations.length > 0) {
|
|
128
118
|
const outputDir = path.resolve(config.outputDir);
|
|
129
119
|
console.log(chalk.blue(`\n📝 Writing translation files to ${outputDir}`));
|
|
130
120
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
121
|
+
// Only rewrite locale files that have new/updated translations
|
|
122
|
+
const changedLocales = new Set(targetTranslations.map(t => t.locale));
|
|
123
|
+
|
|
124
|
+
for (const locale of changedLocales) {
|
|
125
|
+
const pattern = (config.outputPattern || '{locale}.json');
|
|
126
|
+
const fileName = pattern.replace('{locale}', locale);
|
|
127
|
+
const filePath = path.join(outputDir, fileName);
|
|
128
|
+
|
|
129
|
+
// Read existing translations for this locale from disk
|
|
130
|
+
const existing = {};
|
|
131
|
+
if (fs.existsSync(filePath)) {
|
|
132
|
+
try {
|
|
133
|
+
for (const e of formatter.extract(filePath)) {
|
|
134
|
+
existing[e.id] = { foreignId: e.id, text: e.text, locale };
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Merge: new translations override existing
|
|
140
|
+
for (const t of targetTranslations.filter(t => t.locale === locale)) {
|
|
141
|
+
existing[t.foreignId] = t;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const merged = Object.values(existing);
|
|
145
|
+
formatter.write(merged, outputDir, config.outputPattern);
|
|
146
|
+
console.log(chalk.green(` ✅ ${filePath}`));
|
|
134
147
|
}
|
|
135
148
|
} else {
|
|
136
|
-
console.log(chalk.yellow('\n No translations
|
|
149
|
+
console.log(chalk.yellow('\n No new translations. Run "localingos sync" again later.'));
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
console.log(chalk.blue('\n✨ Sync complete!\n'));
|