i18n-tools-mcp 1.0.3 → 1.0.5

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 CHANGED
@@ -33,7 +33,7 @@ Arguments:
33
33
 
34
34
  ## Directory structures
35
35
 
36
- Both flat and namespaced layouts are auto-detected.
36
+ Both flat and namespaced layouts are auto-detected. JSON files directly in the directory make it flat; other folders next to them (such as Laravel's `resources/lang/vendor`) are then ignored.
37
37
 
38
38
  **Flat** — one JSON file per locale:
39
39
 
@@ -57,6 +57,14 @@ messages/
57
57
 
58
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.
59
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
+
60
68
  ## Tools
61
69
 
62
70
  ### `query`
@@ -8,32 +8,37 @@ function sanitize(value) {
8
8
  .replace(/[\u201C\u201D\u201E\u201F\u2033\u2036]/g, '"')
9
9
  .replace(/[\u2018\u2019\u201A\u201B\u2032\u2035]/g, "'");
10
10
  }
11
+ /**
12
+ * JSON files directly in the directory are the locales (`en.json`); only without them do
13
+ * subdirectories hold namespaced locales (`en/common.json`). Flat files win so that an
14
+ * unrelated folder next to them, such as Laravel's `resources/lang/vendor`, is ignored.
15
+ */
11
16
  function discoverLocaleFiles(dir) {
12
17
  const resolvedDir = resolve(dir);
13
18
  const entries = readdirSync(resolvedDir, { withFileTypes: true });
14
- const subdirs = entries.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
15
- if (subdirs.length > 0) {
16
- const files = [];
17
- for (const subdir of subdirs) {
18
- const localePath = join(resolvedDir, subdir.name);
19
- const jsonFiles = readdirSync(localePath).filter((f) => f.endsWith('.json')).sort();
20
- for (const f of jsonFiles) {
21
- files.push({
22
- locale: subdir.name,
23
- namespace: basename(f, '.json'),
24
- path: join(localePath, f),
25
- });
26
- }
27
- }
28
- return files;
29
- }
30
19
  const jsonFiles = entries
31
20
  .filter((e) => e.isFile() && e.name.endsWith('.json'))
32
21
  .sort((a, b) => a.name.localeCompare(b.name));
33
- return jsonFiles.map((f) => ({
34
- locale: basename(f.name, '.json'),
35
- path: join(resolvedDir, f.name),
36
- }));
22
+ if (jsonFiles.length > 0) {
23
+ return jsonFiles.map((f) => ({
24
+ locale: basename(f.name, '.json'),
25
+ path: join(resolvedDir, f.name),
26
+ }));
27
+ }
28
+ const subdirs = entries.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
29
+ const files = [];
30
+ for (const subdir of subdirs) {
31
+ const localePath = join(resolvedDir, subdir.name);
32
+ const namespaceFiles = readdirSync(localePath).filter((f) => f.endsWith('.json')).sort();
33
+ for (const f of namespaceFiles) {
34
+ files.push({
35
+ locale: subdir.name,
36
+ namespace: basename(f, '.json'),
37
+ path: join(localePath, f),
38
+ });
39
+ }
40
+ }
41
+ return files;
37
42
  }
38
43
  function parseKey(key) {
39
44
  const colonIndex = key.indexOf(NS_SEPARATOR);
@@ -62,58 +67,170 @@ function writeJson(filePath, data) {
62
67
  export function setIndent(spaces) {
63
68
  jsonIndent = spaces;
64
69
  }
65
- function getNestedValue(obj, key) {
66
- const parts = key.split('.');
67
- let current = obj;
68
- for (const part of parts) {
69
- if (current == null || typeof current !== 'object') {
70
- return undefined;
70
+ function isObject(value) {
71
+ return value != null && typeof value === 'object' && !Array.isArray(value);
72
+ }
73
+ function hasOwn(obj, key) {
74
+ return Object.prototype.hasOwnProperty.call(obj, key);
75
+ }
76
+ /**
77
+ * Resolve a dot-notation key to the real chain of object keys. A single object key may itself
78
+ * contain dots (Laravel-style flat files such as `{"auth.login.title": "..."}`), so at every
79
+ * level the longest literal key wins, backtracking when a branch does not lead to the full key.
80
+ */
81
+ function resolvePath(obj, key) {
82
+ return resolveParts(obj, key.split('.'));
83
+ }
84
+ function resolveParts(obj, parts) {
85
+ for (let i = parts.length; i > 0; i--) {
86
+ const segment = parts.slice(0, i).join('.');
87
+ if (!hasOwn(obj, segment)) {
88
+ continue;
89
+ }
90
+ if (i === parts.length) {
91
+ return [segment];
92
+ }
93
+ const child = obj[segment];
94
+ if (isObject(child)) {
95
+ const rest = resolveParts(child, parts.slice(i));
96
+ if (rest) {
97
+ return [segment, ...rest];
98
+ }
71
99
  }
72
- current = current[part];
73
100
  }
74
- return current;
101
+ return undefined;
75
102
  }
76
- function deleteNestedValue(obj, key) {
77
- const parts = key.split('.');
103
+ function parentOf(obj, path) {
78
104
  let current = obj;
79
- for (let i = 0; i < parts.length - 1; i++) {
80
- const part = parts[i];
81
- if (current[part] == null || typeof current[part] !== 'object') {
82
- return false;
105
+ for (const segment of path.slice(0, -1)) {
106
+ current = current[segment];
107
+ }
108
+ return current;
109
+ }
110
+ function collectEntries(obj, prefix = '', path = []) {
111
+ const entries = [];
112
+ for (const [k, v] of Object.entries(obj)) {
113
+ const key = prefix ? `${prefix}.${k}` : k;
114
+ if (isObject(v)) {
115
+ entries.push(...collectEntries(v, key, [...path, k]));
116
+ }
117
+ else {
118
+ entries.push({ key, path: [...path, k], value: v });
83
119
  }
84
- current = current[part];
85
120
  }
86
- const lastPart = parts[parts.length - 1];
87
- if (!(lastPart in current)) {
88
- return false;
121
+ return entries;
122
+ }
123
+ function collectKeys(obj) {
124
+ return collectEntries(obj).map((entry) => entry.key);
125
+ }
126
+ /**
127
+ * Leaf entries whose full key equals `key` or lives under it. Unlike resolvePath this also
128
+ * finds groups that only exist as a shared prefix of flat keys (`datatable` for
129
+ * `"datatable.columns"` and `"datatable.rows"`).
130
+ */
131
+ function matchingEntries(obj, key) {
132
+ return collectEntries(obj).filter((entry) => entry.key === key || entry.key.startsWith(`${key}.`));
133
+ }
134
+ function getValue(obj, key) {
135
+ const path = resolvePath(obj, key);
136
+ if (path) {
137
+ return parentOf(obj, path)[path[path.length - 1]];
89
138
  }
90
- delete current[lastPart];
91
- return true;
139
+ const entries = matchingEntries(obj, key);
140
+ if (entries.length === 0) {
141
+ return undefined;
142
+ }
143
+ return Object.fromEntries(entries.map((entry) => [entry.key.slice(key.length + 1), entry.value]));
144
+ }
145
+ function deleteValue(obj, key) {
146
+ let deleted = false;
147
+ const path = resolvePath(obj, key);
148
+ if (path) {
149
+ delete parentOf(obj, path)[path[path.length - 1]];
150
+ deleted = true;
151
+ }
152
+ for (const entry of matchingEntries(obj, key)) {
153
+ delete parentOf(obj, entry.path)[entry.path[entry.path.length - 1]];
154
+ deleted = true;
155
+ }
156
+ return deleted;
92
157
  }
93
- function setNestedValue(obj, key, value) {
94
- const parts = key.split('.');
158
+ /**
159
+ * Set a value, overwriting an existing key wherever it lives. A new key descends into existing
160
+ * nested objects as far as possible; the remainder is written as one literal dotted key when
161
+ * that object already uses dotted keys (flat style), and as nested objects otherwise.
162
+ */
163
+ function setValue(obj, key, value) {
164
+ const path = resolvePath(obj, key);
165
+ if (path) {
166
+ parentOf(obj, path)[path[path.length - 1]] = value;
167
+ return;
168
+ }
95
169
  let current = obj;
96
- for (let i = 0; i < parts.length - 1; i++) {
97
- const part = parts[i];
98
- if (current[part] == null || typeof current[part] !== 'object') {
170
+ let parts = key.split('.');
171
+ descend: while (parts.length > 1) {
172
+ for (let i = parts.length - 1; i > 0; i--) {
173
+ const segment = parts.slice(0, i).join('.');
174
+ const child = current[segment];
175
+ if (hasOwn(current, segment) && isObject(child)) {
176
+ current = child;
177
+ parts = parts.slice(i);
178
+ continue descend;
179
+ }
180
+ }
181
+ break;
182
+ }
183
+ if (Object.keys(current).some((k) => k.includes('.'))) {
184
+ current[parts.join('.')] = value;
185
+ return;
186
+ }
187
+ for (const part of parts.slice(0, -1)) {
188
+ if (!isObject(current[part])) {
99
189
  current[part] = {};
100
190
  }
101
191
  current = current[part];
102
192
  }
103
193
  current[parts[parts.length - 1]] = value;
104
194
  }
105
- function collectKeys(obj, prefix = '') {
106
- const keys = [];
107
- for (const [k, v] of Object.entries(obj)) {
108
- const fk = prefix ? `${prefix}.${k}` : k;
109
- if (v != null && typeof v === 'object' && !Array.isArray(v)) {
110
- keys.push(...collectKeys(v, fk));
195
+ function valueAt(obj, path) {
196
+ let current = obj;
197
+ for (const segment of path) {
198
+ if (!isObject(current) || !hasOwn(current, segment)) {
199
+ return undefined;
111
200
  }
112
- else {
113
- keys.push(fk);
201
+ current = current[segment];
202
+ }
203
+ return current;
204
+ }
205
+ /**
206
+ * Move every leaf under `oldKey` to `newKey`. New keys are written before the old ones are
207
+ * removed, so setValue still sees the file's key style (a flat file emptied first would look
208
+ * nested). Afterwards the old node is dropped if it is an object that is now empty.
209
+ */
210
+ function renameValue(obj, oldKey, newKey) {
211
+ const entries = matchingEntries(obj, oldKey);
212
+ if (entries.length === 0) {
213
+ return false;
214
+ }
215
+ const oldPath = resolvePath(obj, oldKey);
216
+ const written = new Set();
217
+ for (const entry of entries) {
218
+ const target = newKey + entry.key.slice(oldKey.length);
219
+ setValue(obj, target, entry.value);
220
+ written.add(JSON.stringify(resolvePath(obj, target)));
221
+ }
222
+ for (const entry of entries) {
223
+ if (!written.has(JSON.stringify(entry.path)) && valueAt(obj, entry.path) !== undefined && !isObject(valueAt(obj, entry.path))) {
224
+ delete parentOf(obj, entry.path)[entry.path[entry.path.length - 1]];
114
225
  }
115
226
  }
116
- return keys;
227
+ if (oldPath) {
228
+ const node = valueAt(obj, oldPath);
229
+ if (isObject(node) && Object.keys(node).length === 0) {
230
+ delete parentOf(obj, oldPath)[oldPath[oldPath.length - 1]];
231
+ }
232
+ }
233
+ return true;
117
234
  }
118
235
  function filterFiles(files, namespace) {
119
236
  if (!namespace) {
@@ -135,7 +252,7 @@ export function query(key, dir) {
135
252
  const results = [];
136
253
  for (const file of filesToSearch) {
137
254
  const data = readJson(file.path);
138
- const value = getNestedValue(data, dotKey);
255
+ const value = getValue(data, dotKey);
139
256
  if (value !== undefined) {
140
257
  const label = !namespace && file.namespace
141
258
  ? `${file.locale} (${file.namespace})`
@@ -166,7 +283,7 @@ export function set(locale, key, value, dir) {
166
283
  throw new Error(`Locale "${target}" not found in ${resolvedDir}`);
167
284
  }
168
285
  const data = readJson(localeFile.path);
169
- setNestedValue(data, dotKey, sanitize(value));
286
+ setValue(data, dotKey, sanitize(value));
170
287
  writeJson(localeFile.path, data);
171
288
  return `Set ${locale}.${key} = "${value}"`;
172
289
  }
@@ -178,7 +295,7 @@ export function remove(key, dir) {
178
295
  const deleted = [];
179
296
  for (const file of filesToSearch) {
180
297
  const data = readJson(file.path);
181
- if (deleteNestedValue(data, dotKey)) {
298
+ if (deleteValue(data, dotKey)) {
182
299
  writeJson(file.path, data);
183
300
  const label = !namespace && file.namespace
184
301
  ? `${file.locale} (${file.namespace})`
@@ -208,7 +325,7 @@ export function add(key, translations, dir) {
208
325
  continue;
209
326
  }
210
327
  const data = readJson(localeFile.path);
211
- setNestedValue(data, dotKey, sanitize(value));
328
+ setValue(data, dotKey, sanitize(value));
212
329
  writeJson(localeFile.path, data);
213
330
  updated.push(locale);
214
331
  }
@@ -231,21 +348,9 @@ export function rename(oldKey, newKey, dir) {
231
348
  const renamed = [];
232
349
  for (const file of filesToSearch) {
233
350
  const data = readJson(file.path);
234
- const value = getNestedValue(data, oldDotKey);
235
- if (value === undefined) {
351
+ if (!renameValue(data, oldDotKey, newDotKey)) {
236
352
  continue;
237
353
  }
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
354
  writeJson(file.path, data);
250
355
  const label = !oldNs && file.namespace
251
356
  ? `${file.locale} (${file.namespace})`
@@ -317,9 +422,7 @@ export function search(value, page = 1, pageSize = 100, dir) {
317
422
  const lowerValue = value.toLowerCase();
318
423
  for (const file of localeFiles) {
319
424
  const data = readJson(file.path);
320
- const keys = collectKeys(data);
321
- for (const key of keys) {
322
- const v = getNestedValue(data, key);
425
+ for (const { key, value: v } of collectEntries(data)) {
323
426
  if (typeof v === 'string' && v.toLowerCase().includes(lowerValue)) {
324
427
  const fk = fullKey(file, key);
325
428
  results.push(`${file.locale}.${fk} = "${v}"`);
@@ -367,7 +470,7 @@ export function batch(ops, dir) {
367
470
  const target = namespace ? `${op.locale}/${namespace}` : op.locale;
368
471
  throw new Error(`Locale "${target}" not found in ${resolvedDir}`);
369
472
  }
370
- setNestedValue(load(file), dotKey, sanitize(op.value));
473
+ setValue(load(file), dotKey, sanitize(op.value));
371
474
  dirty.add(file.path);
372
475
  summary.push(`set ${op.locale}.${op.key}`);
373
476
  break;
@@ -385,7 +488,7 @@ export function batch(ops, dir) {
385
488
  skipped.push(locale);
386
489
  continue;
387
490
  }
388
- setNestedValue(load(file), dotKey, sanitize(value));
491
+ setValue(load(file), dotKey, sanitize(value));
389
492
  dirty.add(file.path);
390
493
  updated.push(locale);
391
494
  }
@@ -402,7 +505,7 @@ export function batch(ops, dir) {
402
505
  const deleted = [];
403
506
  for (const file of filesToSearch) {
404
507
  const data = load(file);
405
- if (deleteNestedValue(data, dotKey)) {
508
+ if (deleteValue(data, dotKey)) {
406
509
  dirty.add(file.path);
407
510
  deleted.push(file.locale);
408
511
  }
@@ -419,21 +522,8 @@ export function batch(ops, dir) {
419
522
  const filesToSearch = filterFiles(localeFiles, oldNs);
420
523
  const renamed = [];
421
524
  for (const file of filesToSearch) {
422
- const data = load(file);
423
- const value = getNestedValue(data, oldDotKey);
424
- if (value === undefined)
525
+ if (!renameValue(load(file), oldDotKey, newDotKey))
425
526
  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
527
  dirty.add(file.path);
438
528
  renamed.push(file.locale);
439
529
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-tools-mcp",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "MCP server for managing JSON translation files",
5
5
  "type": "module",
6
6
  "bin": {