i18n-tools-mcp 1.0.2 → 1.0.4
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 +10 -0
- package/dist/translations.js +156 -71
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# i18n-tools-mcp
|
|
2
4
|
|
|
3
5
|
MCP server for querying and managing JSON translation files. Designed for AI agents (Claude Code, Cursor, etc.) to read and write i18n, i18next, react-i18next, and next-i18next translations directly through the Model Context Protocol.
|
|
@@ -55,6 +57,14 @@ messages/
|
|
|
55
57
|
|
|
56
58
|
In namespace mode, prefix keys with the namespace: `common:Users.name`. Read operations (`query`, `list`, `search`, `missing`) work across all namespaces when no prefix is given. Write operations (`set`, `add`) require a namespace prefix.
|
|
57
59
|
|
|
60
|
+
## Keys containing dots
|
|
61
|
+
|
|
62
|
+
Keys may be nested objects, literal keys containing dots (Laravel-style `{"auth.login.title": "..."}`), or a mix of both. A dot-notation key is resolved against what is actually in the file, with the longest literal key winning at each level, so `auth.login.title` finds both `{"auth": {"login": {"title": ...}}}` and `{"auth.login.title": ...}`.
|
|
63
|
+
|
|
64
|
+
- **Existing keys** are updated in place, in whichever form they already have.
|
|
65
|
+
- **New keys** descend into existing nested objects as far as possible. The remainder is written as one literal dotted key when that object already contains dotted keys, and as nested objects otherwise.
|
|
66
|
+
- **Prefixes** of flat keys act as groups: `query`, `delete`, `rename` and `move` on `datatable` cover `datatable.columns` and `datatable.rows`.
|
|
67
|
+
|
|
58
68
|
## Tools
|
|
59
69
|
|
|
60
70
|
### `query`
|
package/dist/translations.js
CHANGED
|
@@ -62,58 +62,170 @@ function writeJson(filePath, data) {
|
|
|
62
62
|
export function setIndent(spaces) {
|
|
63
63
|
jsonIndent = spaces;
|
|
64
64
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
function isObject(value) {
|
|
66
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
67
|
+
}
|
|
68
|
+
function hasOwn(obj, key) {
|
|
69
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a dot-notation key to the real chain of object keys. A single object key may itself
|
|
73
|
+
* contain dots (Laravel-style flat files such as `{"auth.login.title": "..."}`), so at every
|
|
74
|
+
* level the longest literal key wins, backtracking when a branch does not lead to the full key.
|
|
75
|
+
*/
|
|
76
|
+
function resolvePath(obj, key) {
|
|
77
|
+
return resolveParts(obj, key.split('.'));
|
|
78
|
+
}
|
|
79
|
+
function resolveParts(obj, parts) {
|
|
80
|
+
for (let i = parts.length; i > 0; i--) {
|
|
81
|
+
const segment = parts.slice(0, i).join('.');
|
|
82
|
+
if (!hasOwn(obj, segment)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (i === parts.length) {
|
|
86
|
+
return [segment];
|
|
87
|
+
}
|
|
88
|
+
const child = obj[segment];
|
|
89
|
+
if (isObject(child)) {
|
|
90
|
+
const rest = resolveParts(child, parts.slice(i));
|
|
91
|
+
if (rest) {
|
|
92
|
+
return [segment, ...rest];
|
|
93
|
+
}
|
|
71
94
|
}
|
|
72
|
-
current = current[part];
|
|
73
95
|
}
|
|
74
|
-
return
|
|
96
|
+
return undefined;
|
|
75
97
|
}
|
|
76
|
-
function
|
|
77
|
-
const parts = key.split('.');
|
|
98
|
+
function parentOf(obj, path) {
|
|
78
99
|
let current = obj;
|
|
79
|
-
for (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
100
|
+
for (const segment of path.slice(0, -1)) {
|
|
101
|
+
current = current[segment];
|
|
102
|
+
}
|
|
103
|
+
return current;
|
|
104
|
+
}
|
|
105
|
+
function collectEntries(obj, prefix = '', path = []) {
|
|
106
|
+
const entries = [];
|
|
107
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
108
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
109
|
+
if (isObject(v)) {
|
|
110
|
+
entries.push(...collectEntries(v, key, [...path, k]));
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
entries.push({ key, path: [...path, k], value: v });
|
|
83
114
|
}
|
|
84
|
-
current = current[part];
|
|
85
115
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
function collectKeys(obj) {
|
|
119
|
+
return collectEntries(obj).map((entry) => entry.key);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Leaf entries whose full key equals `key` or lives under it. Unlike resolvePath this also
|
|
123
|
+
* finds groups that only exist as a shared prefix of flat keys (`datatable` for
|
|
124
|
+
* `"datatable.columns"` and `"datatable.rows"`).
|
|
125
|
+
*/
|
|
126
|
+
function matchingEntries(obj, key) {
|
|
127
|
+
return collectEntries(obj).filter((entry) => entry.key === key || entry.key.startsWith(`${key}.`));
|
|
128
|
+
}
|
|
129
|
+
function getValue(obj, key) {
|
|
130
|
+
const path = resolvePath(obj, key);
|
|
131
|
+
if (path) {
|
|
132
|
+
return parentOf(obj, path)[path[path.length - 1]];
|
|
89
133
|
}
|
|
90
|
-
|
|
91
|
-
|
|
134
|
+
const entries = matchingEntries(obj, key);
|
|
135
|
+
if (entries.length === 0) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
return Object.fromEntries(entries.map((entry) => [entry.key.slice(key.length + 1), entry.value]));
|
|
139
|
+
}
|
|
140
|
+
function deleteValue(obj, key) {
|
|
141
|
+
let deleted = false;
|
|
142
|
+
const path = resolvePath(obj, key);
|
|
143
|
+
if (path) {
|
|
144
|
+
delete parentOf(obj, path)[path[path.length - 1]];
|
|
145
|
+
deleted = true;
|
|
146
|
+
}
|
|
147
|
+
for (const entry of matchingEntries(obj, key)) {
|
|
148
|
+
delete parentOf(obj, entry.path)[entry.path[entry.path.length - 1]];
|
|
149
|
+
deleted = true;
|
|
150
|
+
}
|
|
151
|
+
return deleted;
|
|
92
152
|
}
|
|
93
|
-
|
|
94
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Set a value, overwriting an existing key wherever it lives. A new key descends into existing
|
|
155
|
+
* nested objects as far as possible; the remainder is written as one literal dotted key when
|
|
156
|
+
* that object already uses dotted keys (flat style), and as nested objects otherwise.
|
|
157
|
+
*/
|
|
158
|
+
function setValue(obj, key, value) {
|
|
159
|
+
const path = resolvePath(obj, key);
|
|
160
|
+
if (path) {
|
|
161
|
+
parentOf(obj, path)[path[path.length - 1]] = value;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
95
164
|
let current = obj;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
165
|
+
let parts = key.split('.');
|
|
166
|
+
descend: while (parts.length > 1) {
|
|
167
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
168
|
+
const segment = parts.slice(0, i).join('.');
|
|
169
|
+
const child = current[segment];
|
|
170
|
+
if (hasOwn(current, segment) && isObject(child)) {
|
|
171
|
+
current = child;
|
|
172
|
+
parts = parts.slice(i);
|
|
173
|
+
continue descend;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
if (Object.keys(current).some((k) => k.includes('.'))) {
|
|
179
|
+
current[parts.join('.')] = value;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
for (const part of parts.slice(0, -1)) {
|
|
183
|
+
if (!isObject(current[part])) {
|
|
99
184
|
current[part] = {};
|
|
100
185
|
}
|
|
101
186
|
current = current[part];
|
|
102
187
|
}
|
|
103
188
|
current[parts[parts.length - 1]] = value;
|
|
104
189
|
}
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
for (const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
keys.push(...collectKeys(v, fk));
|
|
190
|
+
function valueAt(obj, path) {
|
|
191
|
+
let current = obj;
|
|
192
|
+
for (const segment of path) {
|
|
193
|
+
if (!isObject(current) || !hasOwn(current, segment)) {
|
|
194
|
+
return undefined;
|
|
111
195
|
}
|
|
112
|
-
|
|
113
|
-
|
|
196
|
+
current = current[segment];
|
|
197
|
+
}
|
|
198
|
+
return current;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Move every leaf under `oldKey` to `newKey`. New keys are written before the old ones are
|
|
202
|
+
* removed, so setValue still sees the file's key style (a flat file emptied first would look
|
|
203
|
+
* nested). Afterwards the old node is dropped if it is an object that is now empty.
|
|
204
|
+
*/
|
|
205
|
+
function renameValue(obj, oldKey, newKey) {
|
|
206
|
+
const entries = matchingEntries(obj, oldKey);
|
|
207
|
+
if (entries.length === 0) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const oldPath = resolvePath(obj, oldKey);
|
|
211
|
+
const written = new Set();
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const target = newKey + entry.key.slice(oldKey.length);
|
|
214
|
+
setValue(obj, target, entry.value);
|
|
215
|
+
written.add(JSON.stringify(resolvePath(obj, target)));
|
|
216
|
+
}
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
if (!written.has(JSON.stringify(entry.path)) && valueAt(obj, entry.path) !== undefined && !isObject(valueAt(obj, entry.path))) {
|
|
219
|
+
delete parentOf(obj, entry.path)[entry.path[entry.path.length - 1]];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (oldPath) {
|
|
223
|
+
const node = valueAt(obj, oldPath);
|
|
224
|
+
if (isObject(node) && Object.keys(node).length === 0) {
|
|
225
|
+
delete parentOf(obj, oldPath)[oldPath[oldPath.length - 1]];
|
|
114
226
|
}
|
|
115
227
|
}
|
|
116
|
-
return
|
|
228
|
+
return true;
|
|
117
229
|
}
|
|
118
230
|
function filterFiles(files, namespace) {
|
|
119
231
|
if (!namespace) {
|
|
@@ -135,7 +247,7 @@ export function query(key, dir) {
|
|
|
135
247
|
const results = [];
|
|
136
248
|
for (const file of filesToSearch) {
|
|
137
249
|
const data = readJson(file.path);
|
|
138
|
-
const value =
|
|
250
|
+
const value = getValue(data, dotKey);
|
|
139
251
|
if (value !== undefined) {
|
|
140
252
|
const label = !namespace && file.namespace
|
|
141
253
|
? `${file.locale} (${file.namespace})`
|
|
@@ -166,7 +278,7 @@ export function set(locale, key, value, dir) {
|
|
|
166
278
|
throw new Error(`Locale "${target}" not found in ${resolvedDir}`);
|
|
167
279
|
}
|
|
168
280
|
const data = readJson(localeFile.path);
|
|
169
|
-
|
|
281
|
+
setValue(data, dotKey, sanitize(value));
|
|
170
282
|
writeJson(localeFile.path, data);
|
|
171
283
|
return `Set ${locale}.${key} = "${value}"`;
|
|
172
284
|
}
|
|
@@ -178,7 +290,7 @@ export function remove(key, dir) {
|
|
|
178
290
|
const deleted = [];
|
|
179
291
|
for (const file of filesToSearch) {
|
|
180
292
|
const data = readJson(file.path);
|
|
181
|
-
if (
|
|
293
|
+
if (deleteValue(data, dotKey)) {
|
|
182
294
|
writeJson(file.path, data);
|
|
183
295
|
const label = !namespace && file.namespace
|
|
184
296
|
? `${file.locale} (${file.namespace})`
|
|
@@ -208,7 +320,7 @@ export function add(key, translations, dir) {
|
|
|
208
320
|
continue;
|
|
209
321
|
}
|
|
210
322
|
const data = readJson(localeFile.path);
|
|
211
|
-
|
|
323
|
+
setValue(data, dotKey, sanitize(value));
|
|
212
324
|
writeJson(localeFile.path, data);
|
|
213
325
|
updated.push(locale);
|
|
214
326
|
}
|
|
@@ -231,21 +343,9 @@ export function rename(oldKey, newKey, dir) {
|
|
|
231
343
|
const renamed = [];
|
|
232
344
|
for (const file of filesToSearch) {
|
|
233
345
|
const data = readJson(file.path);
|
|
234
|
-
|
|
235
|
-
if (value === undefined) {
|
|
346
|
+
if (!renameValue(data, oldDotKey, newDotKey)) {
|
|
236
347
|
continue;
|
|
237
348
|
}
|
|
238
|
-
deleteNestedValue(data, oldDotKey);
|
|
239
|
-
if (typeof value === 'object' && value !== null) {
|
|
240
|
-
const subKeys = collectKeys(value);
|
|
241
|
-
for (const subKey of subKeys) {
|
|
242
|
-
const subValue = getNestedValue(value, subKey);
|
|
243
|
-
setNestedValue(data, `${newDotKey}.${subKey}`, subValue);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
else {
|
|
247
|
-
setNestedValue(data, newDotKey, value);
|
|
248
|
-
}
|
|
249
349
|
writeJson(file.path, data);
|
|
250
350
|
const label = !oldNs && file.namespace
|
|
251
351
|
? `${file.locale} (${file.namespace})`
|
|
@@ -317,9 +417,7 @@ export function search(value, page = 1, pageSize = 100, dir) {
|
|
|
317
417
|
const lowerValue = value.toLowerCase();
|
|
318
418
|
for (const file of localeFiles) {
|
|
319
419
|
const data = readJson(file.path);
|
|
320
|
-
const
|
|
321
|
-
for (const key of keys) {
|
|
322
|
-
const v = getNestedValue(data, key);
|
|
420
|
+
for (const { key, value: v } of collectEntries(data)) {
|
|
323
421
|
if (typeof v === 'string' && v.toLowerCase().includes(lowerValue)) {
|
|
324
422
|
const fk = fullKey(file, key);
|
|
325
423
|
results.push(`${file.locale}.${fk} = "${v}"`);
|
|
@@ -367,7 +465,7 @@ export function batch(ops, dir) {
|
|
|
367
465
|
const target = namespace ? `${op.locale}/${namespace}` : op.locale;
|
|
368
466
|
throw new Error(`Locale "${target}" not found in ${resolvedDir}`);
|
|
369
467
|
}
|
|
370
|
-
|
|
468
|
+
setValue(load(file), dotKey, sanitize(op.value));
|
|
371
469
|
dirty.add(file.path);
|
|
372
470
|
summary.push(`set ${op.locale}.${op.key}`);
|
|
373
471
|
break;
|
|
@@ -385,7 +483,7 @@ export function batch(ops, dir) {
|
|
|
385
483
|
skipped.push(locale);
|
|
386
484
|
continue;
|
|
387
485
|
}
|
|
388
|
-
|
|
486
|
+
setValue(load(file), dotKey, sanitize(value));
|
|
389
487
|
dirty.add(file.path);
|
|
390
488
|
updated.push(locale);
|
|
391
489
|
}
|
|
@@ -402,7 +500,7 @@ export function batch(ops, dir) {
|
|
|
402
500
|
const deleted = [];
|
|
403
501
|
for (const file of filesToSearch) {
|
|
404
502
|
const data = load(file);
|
|
405
|
-
if (
|
|
503
|
+
if (deleteValue(data, dotKey)) {
|
|
406
504
|
dirty.add(file.path);
|
|
407
505
|
deleted.push(file.locale);
|
|
408
506
|
}
|
|
@@ -419,21 +517,8 @@ export function batch(ops, dir) {
|
|
|
419
517
|
const filesToSearch = filterFiles(localeFiles, oldNs);
|
|
420
518
|
const renamed = [];
|
|
421
519
|
for (const file of filesToSearch) {
|
|
422
|
-
|
|
423
|
-
const value = getNestedValue(data, oldDotKey);
|
|
424
|
-
if (value === undefined)
|
|
520
|
+
if (!renameValue(load(file), oldDotKey, newDotKey))
|
|
425
521
|
continue;
|
|
426
|
-
deleteNestedValue(data, oldDotKey);
|
|
427
|
-
if (typeof value === 'object' && value !== null) {
|
|
428
|
-
const subKeys = collectKeys(value);
|
|
429
|
-
for (const subKey of subKeys) {
|
|
430
|
-
const subValue = getNestedValue(value, subKey);
|
|
431
|
-
setNestedValue(data, `${newDotKey}.${subKey}`, subValue);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
else {
|
|
435
|
-
setNestedValue(data, newDotKey, value);
|
|
436
|
-
}
|
|
437
522
|
dirty.add(file.path);
|
|
438
523
|
renamed.push(file.locale);
|
|
439
524
|
}
|