i18next-cli 1.39.4 → 1.39.6

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/dist/cjs/cli.js CHANGED
@@ -28,7 +28,7 @@ const program = new commander.Command();
28
28
  program
29
29
  .name('i18next-cli')
30
30
  .description('A unified, high-performance i18next CLI.')
31
- .version('1.39.4'); // This string is replaced with the actual version at build time by rollup
31
+ .version('1.39.6'); // This string is replaced with the actual version at build time by rollup
32
32
  // new: global config override option
33
33
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
34
34
  program
@@ -2,9 +2,9 @@
2
2
 
3
3
  var glob = require('glob');
4
4
  var promises = require('node:fs/promises');
5
+ var node_path = require('node:path');
5
6
  var logger = require('./utils/logger.js');
6
7
  var fileUtils = require('./utils/file-utils.js');
7
- var node_path = require('node:path');
8
8
  var nestedObject = require('./utils/nested-object.js');
9
9
  var funnelMsgTracker = require('./utils/funnel-msg-tracker.js');
10
10
  var chalk = require('chalk');
@@ -64,9 +64,13 @@ async function runRenameKey(config, oldKey, newKey, options = {}, logger$1 = new
64
64
  error: 'Target key already exists in translation files'
65
65
  };
66
66
  }
67
+ // Build a quick map of which namespaces contain which keys (union across locales).
68
+ // This allows us to decide, per-call, whether an explicit `{ ns: 'x' }` refers to
69
+ // the namespace we're renaming, and whether that namespace actually contains the key.
70
+ const namespaceKeyMap = await buildNamespaceKeyMap(config);
67
71
  logger$1.info(`🔍 Scanning for usages of "${oldKey}"...`);
68
72
  // Find and update source files
69
- const sourceResults = await updateSourceFiles(oldParts, newParts, config, dryRun, logger$1);
73
+ const sourceResults = await updateSourceFiles(oldParts, newParts, config, dryRun, logger$1, namespaceKeyMap);
70
74
  // Update translation files
71
75
  const translationResults = await updateTranslationFiles(oldParts, newParts, config, dryRun, logger$1);
72
76
  const totalChanges = sourceResults.reduce((sum, r) => sum + r.changes, 0);
@@ -107,13 +111,15 @@ function parseKeyWithNamespace(key, config) {
107
111
  return {
108
112
  namespace: ns,
109
113
  key: rest.join(nsSeparator),
110
- fullKey: key
114
+ fullKey: key,
115
+ explicitNamespace: true
111
116
  };
112
117
  }
113
118
  return {
114
119
  namespace: config.extract.defaultNS || 'translation',
115
120
  key,
116
- fullKey: key
121
+ fullKey: key,
122
+ explicitNamespace: false
117
123
  };
118
124
  }
119
125
  function validateKeys(oldKey, newKey, config) {
@@ -149,7 +155,54 @@ async function checkConflicts(newParts, config) {
149
155
  }
150
156
  return conflicts;
151
157
  }
152
- async function updateSourceFiles(oldParts, newParts, config, dryRun, logger) {
158
+ async function buildNamespaceKeyMap(config) {
159
+ // Map namespace -> set of flattened keys present in that namespace (union across locales)
160
+ const map = new Map();
161
+ // config.extract.output may be either a string template or a function(language, namespace) => string.
162
+ // Produce a string we can turn into a glob pattern. For functions, call with wildcard values.
163
+ const rawOutput = config.extract.output;
164
+ const outputTemplate = typeof rawOutput === 'function'
165
+ ? rawOutput('*', '*') // produce a path with wildcards we can glob
166
+ : String(rawOutput);
167
+ // make a glob pattern by replacing placeholders with *
168
+ const pat = outputTemplate
169
+ .replace(/\{\{language\}\}/g, '*')
170
+ .replace(/\{\{namespace\}\}/g, '*');
171
+ // glob expects unix-style
172
+ const files = await glob.glob([pat.replace(/\\/g, '/')], { nodir: true });
173
+ const keySeparator = config.extract.keySeparator ?? '.';
174
+ for (const f of files) {
175
+ try {
176
+ const translations = await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), f));
177
+ if (!translations)
178
+ continue;
179
+ // derive namespace name from filename: basename without extension (platform-safe)
180
+ const base = node_path.basename(f);
181
+ const ns = base.replace(/\.[^.]+$/, ''); // remove extension
182
+ const set = map.get(ns) ?? new Set();
183
+ // flatten keys recursively
184
+ const collect = (obj, prefix = '') => {
185
+ if (typeof obj !== 'object' || obj === null) {
186
+ // only add non-empty prefix (avoid adding '')
187
+ if (prefix)
188
+ set.add(prefix);
189
+ return;
190
+ }
191
+ for (const k of Object.keys(obj)) {
192
+ const next = prefix ? `${prefix}${keySeparator}${k}` : k;
193
+ collect(obj[k], next);
194
+ }
195
+ };
196
+ collect(translations, '');
197
+ map.set(ns, set);
198
+ }
199
+ catch {
200
+ // ignore unreadable files
201
+ }
202
+ }
203
+ return map;
204
+ }
205
+ async function updateSourceFiles(oldParts, newParts, config, dryRun, logger, namespaceKeyMap) {
153
206
  const defaultIgnore = ['node_modules/**'];
154
207
  const userIgnore = Array.isArray(config.extract.ignore)
155
208
  ? config.extract.ignore
@@ -167,7 +220,7 @@ async function updateSourceFiles(oldParts, newParts, config, dryRun, logger) {
167
220
  const results = [];
168
221
  for (const file of sourceFiles) {
169
222
  const code = await promises.readFile(file, 'utf-8');
170
- const { newCode, changes } = await replaceKeyInSource(code, oldParts, newParts, config);
223
+ const { newCode, changes } = await replaceKeyInSource(code, oldParts, newParts, config, namespaceKeyMap);
171
224
  if (changes > 0) {
172
225
  if (!dryRun) {
173
226
  await promises.writeFile(file, newCode, 'utf-8');
@@ -181,11 +234,11 @@ async function updateSourceFiles(oldParts, newParts, config, dryRun, logger) {
181
234
  }
182
235
  return results;
183
236
  }
184
- async function replaceKeyInSource(code, oldParts, newParts, config) {
237
+ async function replaceKeyInSource(code, oldParts, newParts, config, namespaceKeyMap) {
185
238
  // Simpler and robust regex-based replacement that covers tests' patterns
186
- return replaceKeyWithRegex(code, oldParts, newParts, config);
239
+ return replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap);
187
240
  }
188
- function replaceKeyWithRegex(code, oldParts, newParts, config) {
241
+ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap) {
189
242
  let changes = 0;
190
243
  let newCode = code;
191
244
  const nsSeparator = config.extract.nsSeparator ?? ':';
@@ -200,87 +253,54 @@ function replaceKeyWithRegex(code, oldParts, newParts, config) {
200
253
  // exact function name (may include dot like 'i18n.t' or 'translate')
201
254
  return `\\b${escapeRegex(fnPattern)}`;
202
255
  };
256
+ // Helper: check whether the old key exists in a given namespace (from the prebuilt map)
257
+ const hasKeyInNamespace = (ns) => {
258
+ if (!ns)
259
+ return false;
260
+ const set = namespaceKeyMap.get(ns);
261
+ return !!(set && set.has(oldParts.key));
262
+ };
203
263
  // Replace exact string-key usages inside function calls: fn('key') or fn(`key`) or fn("key")
204
264
  for (const fnPattern of configuredFunctions) {
205
265
  const prefix = fnPrefixToRegex(fnPattern);
206
- // Match fullKey first (namespace-prefixed in source)
207
- if (oldParts.fullKey) {
208
- const regexFull = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g');
209
- newCode = newCode.replace(regexFull, (match, q) => {
210
- changes++;
211
- const replacementKey = (oldParts.fullKey.includes(nsSeparator || ':') ? newParts.fullKey : newParts.key);
212
- // preserve surrounding characters up to the opening quote
213
- return match.replace(oldParts.fullKey, replacementKey);
214
- });
215
- }
216
- // Then match bare key (no namespace in source)
217
- const regexKey = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1`, 'g');
218
- newCode = newCode.replace(regexKey, (match) => {
219
- changes++;
220
- const replacementKey = newParts.key;
221
- return match.replace(new RegExp(escapeRegex(oldParts.key)), replacementKey);
222
- });
223
- // Then match bare key (no namespace in source)
224
- // If moving from defaultNS to another, and call is t('key') with no options, add ns option
225
- const regexKeyWithParen = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`, 'g');
226
- newCode = newCode.replace(regexKeyWithParen, (match, quote) => {
227
- if (oldParts.namespace && newParts.namespace &&
228
- oldParts.namespace !== newParts.namespace &&
229
- config.extract.defaultNS === oldParts.namespace) {
230
- changes++;
231
- return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`), `${quote}${newParts.key}${quote}, { ns: '${newParts.namespace}' })`);
232
- }
233
- else if (oldParts.namespace && newParts.namespace &&
234
- oldParts.namespace !== newParts.namespace &&
235
- config.extract.defaultNS === newParts.namespace) {
236
- // If moving from a namespaced key to defaultNS, update t('key', { ns: 'oldNs' }) to t('key')
237
- // Remove the ns option if it matches the defaultNS
238
- // This is handled below in the ns option replacement
239
- // But also handle t('key', { ns: 'defaultNS' }) -> t('key')
240
- // See below for explicit ns option removal
241
- // No action here, handled below
242
- return match;
243
- }
244
- else {
245
- changes++;
246
- const replacementKey = newParts.key;
247
- return match.replace(new RegExp(escapeRegex(oldParts.key)), replacementKey);
248
- }
249
- });
250
- // Remove ns option if moving to defaultNS
266
+ // 1) If moving TO the defaultNS, remove the explicit ns option and update key in one go:
267
+ // t('key', { ns: 'oldNs', ... }) -> t('newKey') (or t('newKey', { otherProps }) if other props exist)
268
+ // Only do this if the old key actually exists in the old namespace
251
269
  if (oldParts.namespace && newParts.namespace &&
252
270
  oldParts.namespace !== newParts.namespace &&
253
- config.extract.defaultNS === newParts.namespace) {
271
+ config.extract.defaultNS === newParts.namespace &&
272
+ hasKeyInNamespace(oldParts.namespace)) {
254
273
  // t('key', { ns: 'oldNs' }) -> t('key')
255
274
  const nsRegexToDefault = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
256
275
  newCode = newCode.replace(nsRegexToDefault, (match, keyQ, beforeNs, nsQ, afterNs) => {
257
276
  changes++;
258
277
  // Build remaining object props (everything except the ns property)
259
278
  const obj = (beforeNs + afterNs).replace(/,?\s*$/, '').replace(/^\s*,?/, '').trim();
260
- // Start by replacing the key string itself, preserving the original quote style
279
+ // Replace the key string itself, preserving the original quote style
261
280
  let updated = match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `${keyQ}${newParts.key}${keyQ}`);
262
281
  if (obj) {
263
- // If other properties remain, replace the whole object content with the cleaned props
282
+ // If other properties remain, keep them
264
283
  updated = updated.replace(/\{\s*([^}]*)\s*\}/, `{${obj}}`);
265
284
  }
266
285
  else {
267
- // No other props — remove the whole options object (", { ... }") leaving "fn('key')"
286
+ // No other props — remove the options object entirely
268
287
  updated = updated.replace(/\s*,\s*\{[^}]*\}\s*\)/, ')');
269
288
  }
270
289
  return updated;
271
290
  });
272
291
  }
273
- // Handle ns option in options object: fn('key', { ns: 'oldNs', ... })
274
- if (oldParts.namespace && newParts.namespace && oldParts.namespace !== newParts.namespace) {
275
- // We want to change only when key matches and ns value equals oldParts.namespace
292
+ // 2) Update ns option value when moving across namespaces (when options are present)
293
+ // Only attempt to update the ns option if the old namespace actually contains the key.
294
+ if (oldParts.namespace && newParts.namespace && oldParts.namespace !== newParts.namespace && hasKeyInNamespace(oldParts.namespace)) {
295
+ // case where key is bare (e.g. t('key', { ns: 'oldNs', ... }))
276
296
  const nsRegexFullKey = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
277
- newCode = newCode.replace(nsRegexFullKey, (match, keyQ, beforeNs, nsQ, afterNs) => {
297
+ newCode = newCode.replace(nsRegexFullKey, (match) => {
278
298
  changes++;
279
299
  // replace ns value
280
300
  return match.replace(new RegExp(`(\\bns\\s*:\\s*['"\`])${escapeRegex(oldParts.namespace ?? '')}(['"\`])`), `$1${newParts.namespace ?? ''}$2`);
281
301
  });
282
- // same but if the call used fullKey (ns included inside key string), still update ns option if present
283
- if (oldParts.fullKey) {
302
+ // case where fullKey was used inside the string (e.g. t('ns:key', { ns: 'oldNs' }))
303
+ if (oldParts.fullKey && oldParts.explicitNamespace) {
284
304
  const nsRegexFull = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.fullKey)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
285
305
  newCode = newCode.replace(nsRegexFull, (match) => {
286
306
  changes++;
@@ -288,46 +308,73 @@ function replaceKeyWithRegex(code, oldParts, newParts, config) {
288
308
  });
289
309
  }
290
310
  }
291
- }
292
- // Selector API: dot-notation: fn(($) => $.old.key)
293
- for (const fnPattern of configuredFunctions) {
294
- const prefix = fnPrefixToRegex(fnPattern);
295
- // match forms like: prefix( $ => $.old.key )
296
- // capture the arrow param name and the rest
297
- // We'll attempt to match the param and the dotted property chain equal to oldParts.key (exact)
298
- const dotRegex = new RegExp(`${prefix}\\s*\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*\\1\\.${escapeRegex(oldParts.key)}\\s*\\)`, 'g');
299
- newCode = newCode.replace(dotRegex, (match, param) => {
300
- changes++;
301
- // Determine replacement (if source used namespace in key it would be fullKey, but in selector dot-notation it's always key form)
302
- const replacementKey = newParts.key;
303
- return match.replace(`.${oldParts.key}`, `.${replacementKey}`);
304
- });
305
- // Bracket notation: fn(($) => $["Old Key"]) etc.
306
- const bracketRegex = new RegExp(`${prefix}\\s*\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*\\1\\s*\\[\\s*(['"\`])${escapeRegex(oldParts.key)}\\2\\s*\\]\\s*\\)`, 'g');
307
- newCode = newCode.replace(bracketRegex, (match, param, quote) => {
308
- changes++;
309
- const replacementKey = newParts.key;
310
- // If replacementKey is a valid identifier, convert to dot-notation, otherwise keep bracket form with preserved quote style
311
- if (/^[A-Za-z_$][\w$]*$/.test(replacementKey)) {
312
- return match.replace(new RegExp(`\\[\\s*['"\`]${escapeRegex(oldParts.key)}['"\`]\\s*\\]`), `.${replacementKey}`);
313
- }
314
- else {
315
- return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `$1${replacementKey}$1`);
316
- }
317
- });
318
- }
319
- // JSX i18nKey attribute (handles all quote types)
320
- const jsxPatterns = [
321
- { orig: oldParts.fullKey, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g') },
322
- { orig: oldParts.key, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.key)}\\1`, 'g') }
323
- ];
324
- for (const p of jsxPatterns) {
325
- newCode = newCode.replace(p.regex, (match, q) => {
326
- changes++;
327
- const nsSepStr = nsSeparator === false ? ':' : nsSeparator;
328
- const replacement = (p.orig === oldParts.fullKey && oldParts.fullKey.includes(nsSepStr)) ? newParts.fullKey : newParts.key;
329
- return `i18nKey=${q}${replacement}${q}`;
330
- });
311
+ // 3) Replace occurrences where the call uses the fullKey inside the string (e.g. t('ns:key'))
312
+ if (oldParts.fullKey && oldParts.explicitNamespace) {
313
+ const regexFull = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g');
314
+ newCode = newCode.replace(regexFull, (match) => {
315
+ changes++;
316
+ const replacementKey = (oldParts.fullKey.includes(nsSeparator || ':') ? newParts.fullKey : newParts.key);
317
+ return match.replace(oldParts.fullKey, replacementKey);
318
+ });
319
+ }
320
+ // 4) Handle selector / arrow and bracket forms (these are always "key form" so safe to replace)
321
+ // Selector API: dot-notation: fn(($) => $.old.key)
322
+ {
323
+ const dotRegex = new RegExp(`${prefix}\\s*\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*\\1\\.${escapeRegex(oldParts.key)}\\s*\\)`, 'g');
324
+ newCode = newCode.replace(dotRegex, (match) => {
325
+ changes++;
326
+ const replacementKey = newParts.key;
327
+ return match.replace(`.${oldParts.key}`, `.${replacementKey}`);
328
+ });
329
+ const bracketRegex = new RegExp(`${prefix}\\s*\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*\\1\\s*\\[\\s*(['"\`])${escapeRegex(oldParts.key)}\\2\\s*\\]\\s*\\)`, 'g');
330
+ newCode = newCode.replace(bracketRegex, (match) => {
331
+ changes++;
332
+ const replacementKey = newParts.key;
333
+ if (/^[A-Za-z_$][\w$]*$/.test(replacementKey)) {
334
+ return match.replace(new RegExp(`\\[\\s*['"\`]${escapeRegex(oldParts.key)}['"\`]\\s*\\]`), `.${replacementKey}`);
335
+ }
336
+ else {
337
+ return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `$1${replacementKey}$1`);
338
+ }
339
+ });
340
+ }
341
+ // 5) Replace bare calls WITHOUT an options object: fn('key') -> fn('newKey')
342
+ // We purposely only match when the string is directly followed by the closing paren (no comma/options).
343
+ {
344
+ const regexKeyNoOptions = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`, 'g');
345
+ newCode = newCode.replace(regexKeyNoOptions, (match, q) => {
346
+ changes++;
347
+ const replacementKey = newParts.key;
348
+ return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `${q}${replacementKey}${q}`);
349
+ });
350
+ }
351
+ // 6) Handle the case where we have fn('key', /*no ns*/ { otherProps }) and we are moving
352
+ // from defaultNS to another namespace: add ns when appropriate.
353
+ // This block is only relevant when moving FROM defaultNS (add ns option). Only perform it
354
+ // if the old key exists in the old namespace (if we tracked one).
355
+ if (oldParts.namespace && newParts.namespace &&
356
+ oldParts.namespace !== newParts.namespace &&
357
+ config.extract.defaultNS === oldParts.namespace &&
358
+ hasKeyInNamespace(oldParts.namespace)) {
359
+ const regexKeyWithParen = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`, 'g');
360
+ newCode = newCode.replace(regexKeyWithParen, (match, quote) => {
361
+ changes++;
362
+ return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`), `${quote}${newParts.key}${quote}, { ns: '${newParts.namespace}' })`);
363
+ });
364
+ }
365
+ // 7) JSX i18nKey attribute (handles both fullKey and key)
366
+ const jsxPatterns = [
367
+ { orig: oldParts.fullKey, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g') },
368
+ { orig: oldParts.key, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.key)}\\1`, 'g') }
369
+ ];
370
+ for (const p of jsxPatterns) {
371
+ newCode = newCode.replace(p.regex, (match, q) => {
372
+ changes++;
373
+ const nsSepStr = nsSeparator === false ? ':' : nsSeparator;
374
+ const replacement = (p.orig === oldParts.fullKey && oldParts.fullKey.includes(nsSepStr)) ? newParts.fullKey : newParts.key;
375
+ return `i18nKey=${q}${replacement}${q}`;
376
+ });
377
+ }
331
378
  }
332
379
  return { newCode, changes };
333
380
  }
package/dist/esm/cli.js CHANGED
@@ -26,7 +26,7 @@ const program = new Command();
26
26
  program
27
27
  .name('i18next-cli')
28
28
  .description('A unified, high-performance i18next CLI.')
29
- .version('1.39.4'); // This string is replaced with the actual version at build time by rollup
29
+ .version('1.39.6'); // This string is replaced with the actual version at build time by rollup
30
30
  // new: global config override option
31
31
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
32
32
  program
@@ -1,8 +1,8 @@
1
1
  import { glob } from 'glob';
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
+ import { resolve, basename } from 'node:path';
3
4
  import { ConsoleLogger } from './utils/logger.js';
4
5
  import { getOutputPath, loadTranslationFile, serializeTranslationFile } from './utils/file-utils.js';
5
- import { resolve } from 'node:path';
6
6
  import { getNestedValue, setNestedValue } from './utils/nested-object.js';
7
7
  import { shouldShowFunnel, recordFunnelShown } from './utils/funnel-msg-tracker.js';
8
8
  import chalk from 'chalk';
@@ -62,9 +62,13 @@ async function runRenameKey(config, oldKey, newKey, options = {}, logger = new C
62
62
  error: 'Target key already exists in translation files'
63
63
  };
64
64
  }
65
+ // Build a quick map of which namespaces contain which keys (union across locales).
66
+ // This allows us to decide, per-call, whether an explicit `{ ns: 'x' }` refers to
67
+ // the namespace we're renaming, and whether that namespace actually contains the key.
68
+ const namespaceKeyMap = await buildNamespaceKeyMap(config);
65
69
  logger.info(`🔍 Scanning for usages of "${oldKey}"...`);
66
70
  // Find and update source files
67
- const sourceResults = await updateSourceFiles(oldParts, newParts, config, dryRun, logger);
71
+ const sourceResults = await updateSourceFiles(oldParts, newParts, config, dryRun, logger, namespaceKeyMap);
68
72
  // Update translation files
69
73
  const translationResults = await updateTranslationFiles(oldParts, newParts, config, dryRun, logger);
70
74
  const totalChanges = sourceResults.reduce((sum, r) => sum + r.changes, 0);
@@ -105,13 +109,15 @@ function parseKeyWithNamespace(key, config) {
105
109
  return {
106
110
  namespace: ns,
107
111
  key: rest.join(nsSeparator),
108
- fullKey: key
112
+ fullKey: key,
113
+ explicitNamespace: true
109
114
  };
110
115
  }
111
116
  return {
112
117
  namespace: config.extract.defaultNS || 'translation',
113
118
  key,
114
- fullKey: key
119
+ fullKey: key,
120
+ explicitNamespace: false
115
121
  };
116
122
  }
117
123
  function validateKeys(oldKey, newKey, config) {
@@ -147,7 +153,54 @@ async function checkConflicts(newParts, config) {
147
153
  }
148
154
  return conflicts;
149
155
  }
150
- async function updateSourceFiles(oldParts, newParts, config, dryRun, logger) {
156
+ async function buildNamespaceKeyMap(config) {
157
+ // Map namespace -> set of flattened keys present in that namespace (union across locales)
158
+ const map = new Map();
159
+ // config.extract.output may be either a string template or a function(language, namespace) => string.
160
+ // Produce a string we can turn into a glob pattern. For functions, call with wildcard values.
161
+ const rawOutput = config.extract.output;
162
+ const outputTemplate = typeof rawOutput === 'function'
163
+ ? rawOutput('*', '*') // produce a path with wildcards we can glob
164
+ : String(rawOutput);
165
+ // make a glob pattern by replacing placeholders with *
166
+ const pat = outputTemplate
167
+ .replace(/\{\{language\}\}/g, '*')
168
+ .replace(/\{\{namespace\}\}/g, '*');
169
+ // glob expects unix-style
170
+ const files = await glob([pat.replace(/\\/g, '/')], { nodir: true });
171
+ const keySeparator = config.extract.keySeparator ?? '.';
172
+ for (const f of files) {
173
+ try {
174
+ const translations = await loadTranslationFile(resolve(process.cwd(), f));
175
+ if (!translations)
176
+ continue;
177
+ // derive namespace name from filename: basename without extension (platform-safe)
178
+ const base = basename(f);
179
+ const ns = base.replace(/\.[^.]+$/, ''); // remove extension
180
+ const set = map.get(ns) ?? new Set();
181
+ // flatten keys recursively
182
+ const collect = (obj, prefix = '') => {
183
+ if (typeof obj !== 'object' || obj === null) {
184
+ // only add non-empty prefix (avoid adding '')
185
+ if (prefix)
186
+ set.add(prefix);
187
+ return;
188
+ }
189
+ for (const k of Object.keys(obj)) {
190
+ const next = prefix ? `${prefix}${keySeparator}${k}` : k;
191
+ collect(obj[k], next);
192
+ }
193
+ };
194
+ collect(translations, '');
195
+ map.set(ns, set);
196
+ }
197
+ catch {
198
+ // ignore unreadable files
199
+ }
200
+ }
201
+ return map;
202
+ }
203
+ async function updateSourceFiles(oldParts, newParts, config, dryRun, logger, namespaceKeyMap) {
151
204
  const defaultIgnore = ['node_modules/**'];
152
205
  const userIgnore = Array.isArray(config.extract.ignore)
153
206
  ? config.extract.ignore
@@ -165,7 +218,7 @@ async function updateSourceFiles(oldParts, newParts, config, dryRun, logger) {
165
218
  const results = [];
166
219
  for (const file of sourceFiles) {
167
220
  const code = await readFile(file, 'utf-8');
168
- const { newCode, changes } = await replaceKeyInSource(code, oldParts, newParts, config);
221
+ const { newCode, changes } = await replaceKeyInSource(code, oldParts, newParts, config, namespaceKeyMap);
169
222
  if (changes > 0) {
170
223
  if (!dryRun) {
171
224
  await writeFile(file, newCode, 'utf-8');
@@ -179,11 +232,11 @@ async function updateSourceFiles(oldParts, newParts, config, dryRun, logger) {
179
232
  }
180
233
  return results;
181
234
  }
182
- async function replaceKeyInSource(code, oldParts, newParts, config) {
235
+ async function replaceKeyInSource(code, oldParts, newParts, config, namespaceKeyMap) {
183
236
  // Simpler and robust regex-based replacement that covers tests' patterns
184
- return replaceKeyWithRegex(code, oldParts, newParts, config);
237
+ return replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap);
185
238
  }
186
- function replaceKeyWithRegex(code, oldParts, newParts, config) {
239
+ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap) {
187
240
  let changes = 0;
188
241
  let newCode = code;
189
242
  const nsSeparator = config.extract.nsSeparator ?? ':';
@@ -198,87 +251,54 @@ function replaceKeyWithRegex(code, oldParts, newParts, config) {
198
251
  // exact function name (may include dot like 'i18n.t' or 'translate')
199
252
  return `\\b${escapeRegex(fnPattern)}`;
200
253
  };
254
+ // Helper: check whether the old key exists in a given namespace (from the prebuilt map)
255
+ const hasKeyInNamespace = (ns) => {
256
+ if (!ns)
257
+ return false;
258
+ const set = namespaceKeyMap.get(ns);
259
+ return !!(set && set.has(oldParts.key));
260
+ };
201
261
  // Replace exact string-key usages inside function calls: fn('key') or fn(`key`) or fn("key")
202
262
  for (const fnPattern of configuredFunctions) {
203
263
  const prefix = fnPrefixToRegex(fnPattern);
204
- // Match fullKey first (namespace-prefixed in source)
205
- if (oldParts.fullKey) {
206
- const regexFull = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g');
207
- newCode = newCode.replace(regexFull, (match, q) => {
208
- changes++;
209
- const replacementKey = (oldParts.fullKey.includes(nsSeparator || ':') ? newParts.fullKey : newParts.key);
210
- // preserve surrounding characters up to the opening quote
211
- return match.replace(oldParts.fullKey, replacementKey);
212
- });
213
- }
214
- // Then match bare key (no namespace in source)
215
- const regexKey = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1`, 'g');
216
- newCode = newCode.replace(regexKey, (match) => {
217
- changes++;
218
- const replacementKey = newParts.key;
219
- return match.replace(new RegExp(escapeRegex(oldParts.key)), replacementKey);
220
- });
221
- // Then match bare key (no namespace in source)
222
- // If moving from defaultNS to another, and call is t('key') with no options, add ns option
223
- const regexKeyWithParen = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`, 'g');
224
- newCode = newCode.replace(regexKeyWithParen, (match, quote) => {
225
- if (oldParts.namespace && newParts.namespace &&
226
- oldParts.namespace !== newParts.namespace &&
227
- config.extract.defaultNS === oldParts.namespace) {
228
- changes++;
229
- return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`), `${quote}${newParts.key}${quote}, { ns: '${newParts.namespace}' })`);
230
- }
231
- else if (oldParts.namespace && newParts.namespace &&
232
- oldParts.namespace !== newParts.namespace &&
233
- config.extract.defaultNS === newParts.namespace) {
234
- // If moving from a namespaced key to defaultNS, update t('key', { ns: 'oldNs' }) to t('key')
235
- // Remove the ns option if it matches the defaultNS
236
- // This is handled below in the ns option replacement
237
- // But also handle t('key', { ns: 'defaultNS' }) -> t('key')
238
- // See below for explicit ns option removal
239
- // No action here, handled below
240
- return match;
241
- }
242
- else {
243
- changes++;
244
- const replacementKey = newParts.key;
245
- return match.replace(new RegExp(escapeRegex(oldParts.key)), replacementKey);
246
- }
247
- });
248
- // Remove ns option if moving to defaultNS
264
+ // 1) If moving TO the defaultNS, remove the explicit ns option and update key in one go:
265
+ // t('key', { ns: 'oldNs', ... }) -> t('newKey') (or t('newKey', { otherProps }) if other props exist)
266
+ // Only do this if the old key actually exists in the old namespace
249
267
  if (oldParts.namespace && newParts.namespace &&
250
268
  oldParts.namespace !== newParts.namespace &&
251
- config.extract.defaultNS === newParts.namespace) {
269
+ config.extract.defaultNS === newParts.namespace &&
270
+ hasKeyInNamespace(oldParts.namespace)) {
252
271
  // t('key', { ns: 'oldNs' }) -> t('key')
253
272
  const nsRegexToDefault = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
254
273
  newCode = newCode.replace(nsRegexToDefault, (match, keyQ, beforeNs, nsQ, afterNs) => {
255
274
  changes++;
256
275
  // Build remaining object props (everything except the ns property)
257
276
  const obj = (beforeNs + afterNs).replace(/,?\s*$/, '').replace(/^\s*,?/, '').trim();
258
- // Start by replacing the key string itself, preserving the original quote style
277
+ // Replace the key string itself, preserving the original quote style
259
278
  let updated = match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `${keyQ}${newParts.key}${keyQ}`);
260
279
  if (obj) {
261
- // If other properties remain, replace the whole object content with the cleaned props
280
+ // If other properties remain, keep them
262
281
  updated = updated.replace(/\{\s*([^}]*)\s*\}/, `{${obj}}`);
263
282
  }
264
283
  else {
265
- // No other props — remove the whole options object (", { ... }") leaving "fn('key')"
284
+ // No other props — remove the options object entirely
266
285
  updated = updated.replace(/\s*,\s*\{[^}]*\}\s*\)/, ')');
267
286
  }
268
287
  return updated;
269
288
  });
270
289
  }
271
- // Handle ns option in options object: fn('key', { ns: 'oldNs', ... })
272
- if (oldParts.namespace && newParts.namespace && oldParts.namespace !== newParts.namespace) {
273
- // We want to change only when key matches and ns value equals oldParts.namespace
290
+ // 2) Update ns option value when moving across namespaces (when options are present)
291
+ // Only attempt to update the ns option if the old namespace actually contains the key.
292
+ if (oldParts.namespace && newParts.namespace && oldParts.namespace !== newParts.namespace && hasKeyInNamespace(oldParts.namespace)) {
293
+ // case where key is bare (e.g. t('key', { ns: 'oldNs', ... }))
274
294
  const nsRegexFullKey = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
275
- newCode = newCode.replace(nsRegexFullKey, (match, keyQ, beforeNs, nsQ, afterNs) => {
295
+ newCode = newCode.replace(nsRegexFullKey, (match) => {
276
296
  changes++;
277
297
  // replace ns value
278
298
  return match.replace(new RegExp(`(\\bns\\s*:\\s*['"\`])${escapeRegex(oldParts.namespace ?? '')}(['"\`])`), `$1${newParts.namespace ?? ''}$2`);
279
299
  });
280
- // same but if the call used fullKey (ns included inside key string), still update ns option if present
281
- if (oldParts.fullKey) {
300
+ // case where fullKey was used inside the string (e.g. t('ns:key', { ns: 'oldNs' }))
301
+ if (oldParts.fullKey && oldParts.explicitNamespace) {
282
302
  const nsRegexFull = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.fullKey)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
283
303
  newCode = newCode.replace(nsRegexFull, (match) => {
284
304
  changes++;
@@ -286,46 +306,73 @@ function replaceKeyWithRegex(code, oldParts, newParts, config) {
286
306
  });
287
307
  }
288
308
  }
289
- }
290
- // Selector API: dot-notation: fn(($) => $.old.key)
291
- for (const fnPattern of configuredFunctions) {
292
- const prefix = fnPrefixToRegex(fnPattern);
293
- // match forms like: prefix( $ => $.old.key )
294
- // capture the arrow param name and the rest
295
- // We'll attempt to match the param and the dotted property chain equal to oldParts.key (exact)
296
- const dotRegex = new RegExp(`${prefix}\\s*\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*\\1\\.${escapeRegex(oldParts.key)}\\s*\\)`, 'g');
297
- newCode = newCode.replace(dotRegex, (match, param) => {
298
- changes++;
299
- // Determine replacement (if source used namespace in key it would be fullKey, but in selector dot-notation it's always key form)
300
- const replacementKey = newParts.key;
301
- return match.replace(`.${oldParts.key}`, `.${replacementKey}`);
302
- });
303
- // Bracket notation: fn(($) => $["Old Key"]) etc.
304
- const bracketRegex = new RegExp(`${prefix}\\s*\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*\\1\\s*\\[\\s*(['"\`])${escapeRegex(oldParts.key)}\\2\\s*\\]\\s*\\)`, 'g');
305
- newCode = newCode.replace(bracketRegex, (match, param, quote) => {
306
- changes++;
307
- const replacementKey = newParts.key;
308
- // If replacementKey is a valid identifier, convert to dot-notation, otherwise keep bracket form with preserved quote style
309
- if (/^[A-Za-z_$][\w$]*$/.test(replacementKey)) {
310
- return match.replace(new RegExp(`\\[\\s*['"\`]${escapeRegex(oldParts.key)}['"\`]\\s*\\]`), `.${replacementKey}`);
311
- }
312
- else {
313
- return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `$1${replacementKey}$1`);
314
- }
315
- });
316
- }
317
- // JSX i18nKey attribute (handles all quote types)
318
- const jsxPatterns = [
319
- { orig: oldParts.fullKey, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g') },
320
- { orig: oldParts.key, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.key)}\\1`, 'g') }
321
- ];
322
- for (const p of jsxPatterns) {
323
- newCode = newCode.replace(p.regex, (match, q) => {
324
- changes++;
325
- const nsSepStr = nsSeparator === false ? ':' : nsSeparator;
326
- const replacement = (p.orig === oldParts.fullKey && oldParts.fullKey.includes(nsSepStr)) ? newParts.fullKey : newParts.key;
327
- return `i18nKey=${q}${replacement}${q}`;
328
- });
309
+ // 3) Replace occurrences where the call uses the fullKey inside the string (e.g. t('ns:key'))
310
+ if (oldParts.fullKey && oldParts.explicitNamespace) {
311
+ const regexFull = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g');
312
+ newCode = newCode.replace(regexFull, (match) => {
313
+ changes++;
314
+ const replacementKey = (oldParts.fullKey.includes(nsSeparator || ':') ? newParts.fullKey : newParts.key);
315
+ return match.replace(oldParts.fullKey, replacementKey);
316
+ });
317
+ }
318
+ // 4) Handle selector / arrow and bracket forms (these are always "key form" so safe to replace)
319
+ // Selector API: dot-notation: fn(($) => $.old.key)
320
+ {
321
+ const dotRegex = new RegExp(`${prefix}\\s*\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*\\1\\.${escapeRegex(oldParts.key)}\\s*\\)`, 'g');
322
+ newCode = newCode.replace(dotRegex, (match) => {
323
+ changes++;
324
+ const replacementKey = newParts.key;
325
+ return match.replace(`.${oldParts.key}`, `.${replacementKey}`);
326
+ });
327
+ const bracketRegex = new RegExp(`${prefix}\\s*\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*\\1\\s*\\[\\s*(['"\`])${escapeRegex(oldParts.key)}\\2\\s*\\]\\s*\\)`, 'g');
328
+ newCode = newCode.replace(bracketRegex, (match) => {
329
+ changes++;
330
+ const replacementKey = newParts.key;
331
+ if (/^[A-Za-z_$][\w$]*$/.test(replacementKey)) {
332
+ return match.replace(new RegExp(`\\[\\s*['"\`]${escapeRegex(oldParts.key)}['"\`]\\s*\\]`), `.${replacementKey}`);
333
+ }
334
+ else {
335
+ return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `$1${replacementKey}$1`);
336
+ }
337
+ });
338
+ }
339
+ // 5) Replace bare calls WITHOUT an options object: fn('key') -> fn('newKey')
340
+ // We purposely only match when the string is directly followed by the closing paren (no comma/options).
341
+ {
342
+ const regexKeyNoOptions = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`, 'g');
343
+ newCode = newCode.replace(regexKeyNoOptions, (match, q) => {
344
+ changes++;
345
+ const replacementKey = newParts.key;
346
+ return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `${q}${replacementKey}${q}`);
347
+ });
348
+ }
349
+ // 6) Handle the case where we have fn('key', /*no ns*/ { otherProps }) and we are moving
350
+ // from defaultNS to another namespace: add ns when appropriate.
351
+ // This block is only relevant when moving FROM defaultNS (add ns option). Only perform it
352
+ // if the old key exists in the old namespace (if we tracked one).
353
+ if (oldParts.namespace && newParts.namespace &&
354
+ oldParts.namespace !== newParts.namespace &&
355
+ config.extract.defaultNS === oldParts.namespace &&
356
+ hasKeyInNamespace(oldParts.namespace)) {
357
+ const regexKeyWithParen = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`, 'g');
358
+ newCode = newCode.replace(regexKeyWithParen, (match, quote) => {
359
+ changes++;
360
+ return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`), `${quote}${newParts.key}${quote}, { ns: '${newParts.namespace}' })`);
361
+ });
362
+ }
363
+ // 7) JSX i18nKey attribute (handles both fullKey and key)
364
+ const jsxPatterns = [
365
+ { orig: oldParts.fullKey, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g') },
366
+ { orig: oldParts.key, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.key)}\\1`, 'g') }
367
+ ];
368
+ for (const p of jsxPatterns) {
369
+ newCode = newCode.replace(p.regex, (match, q) => {
370
+ changes++;
371
+ const nsSepStr = nsSeparator === false ? ':' : nsSeparator;
372
+ const replacement = (p.orig === oldParts.fullKey && oldParts.fullKey.includes(nsSepStr)) ? newParts.fullKey : newParts.key;
373
+ return `i18nKey=${q}${replacement}${q}`;
374
+ });
375
+ }
329
376
  }
330
377
  return { newCode, changes };
331
378
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.39.4",
3
+ "version": "1.39.6",
4
4
  "description": "A unified, high-performance i18next CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- {"version":3,"file":"rename-key.d.ts","sourceRoot":"","sources":["../src/rename-key.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAQ5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,YAAY,CAChC,MAAM,EAAE,oBAAoB,EAC5B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IACP,MAAM,CAAC,EAAE,OAAO,CAAA;CACZ,EACN,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,eAAe,CAAC,CAwD1B"}
1
+ {"version":3,"file":"rename-key.d.ts","sourceRoot":"","sources":["../src/rename-key.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAO5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,YAAY,CAChC,MAAM,EAAE,oBAAoB,EAC5B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IACP,MAAM,CAAC,EAAE,OAAO,CAAA;CACZ,EACN,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,eAAe,CAAC,CA6D1B"}