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 +9 -1
- package/dist/translations.js +181 -91
- package/package.json +1 -1
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`
|
package/dist/translations.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
101
|
+
return undefined;
|
|
75
102
|
}
|
|
76
|
-
function
|
|
77
|
-
const parts = key.split('.');
|
|
103
|
+
function parentOf(obj, path) {
|
|
78
104
|
let current = obj;
|
|
79
|
-
for (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
for (const
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|