i18next-cli 1.39.6 → 1.39.8

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.6'); // This string is replaced with the actual version at build time by rollup
31
+ .version('1.39.8'); // 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
@@ -56,13 +56,21 @@ async function runRenameKey(config, oldKey, newKey, options = {}, logger$1 = new
56
56
  // Check for conflicts in translation files
57
57
  const conflicts = await checkConflicts(newParts, config);
58
58
  if (conflicts.length > 0) {
59
- return {
60
- success: false,
61
- sourceFiles: [],
62
- translationFiles: [],
63
- conflicts,
64
- error: 'Target key already exists in translation files'
65
- };
59
+ // If the old key doesn't exist in any translation file, treat this as a
60
+ // no-op (allow the command to succeed). This mirrors previous behavior
61
+ // where renaming a missing key doesn't fail just because the target
62
+ // already exists (it avoids blocking repeated/no-op renames).
63
+ const oldExists = await checkOldKeyExists(parseKeyWithNamespace(oldKey, config), config);
64
+ if (oldExists) {
65
+ return {
66
+ success: false,
67
+ sourceFiles: [],
68
+ translationFiles: [],
69
+ conflicts,
70
+ error: 'Target key already exists in translation files'
71
+ };
72
+ }
73
+ // otherwise: old key not present -> continue (no-op)
66
74
  }
67
75
  // Build a quick map of which namespaces contain which keys (union across locales).
68
76
  // This allows us to decide, per-call, whether an explicit `{ ns: 'x' }` refers to
@@ -155,6 +163,25 @@ async function checkConflicts(newParts, config) {
155
163
  }
156
164
  return conflicts;
157
165
  }
166
+ async function checkOldKeyExists(oldParts, config) {
167
+ const keySeparator = config.extract.keySeparator ?? '.';
168
+ for (const locale of config.locales) {
169
+ const outputPath = fileUtils.getOutputPath(config.extract.output, locale, oldParts.namespace);
170
+ const fullPath = node_path.resolve(process.cwd(), outputPath);
171
+ try {
172
+ const translations = await fileUtils.loadTranslationFile(fullPath);
173
+ if (translations) {
174
+ const val = nestedObject.getNestedValue(translations, oldParts.key, keySeparator);
175
+ if (val !== undefined)
176
+ return true;
177
+ }
178
+ }
179
+ catch {
180
+ // file missing — continue to next locale
181
+ }
182
+ }
183
+ return false;
184
+ }
158
185
  async function buildNamespaceKeyMap(config) {
159
186
  // Map namespace -> set of flattened keys present in that namespace (union across locales)
160
187
  const map = new Map();
@@ -246,11 +273,9 @@ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap)
246
273
  // Helper to create function-prefix regex fragment
247
274
  const fnPrefixToRegex = (fnPattern) => {
248
275
  if (fnPattern.startsWith('*.')) {
249
- // '*.t' -> match anyIdentifier.t
250
276
  const suffix = fnPattern.slice(2);
251
- return `\\b[\\w$]+\\.${escapeRegex(suffix)}`; // e.g. \b[\w$]+\.t
277
+ return `\\b[\\w$]+\\.${escapeRegex(suffix)}`;
252
278
  }
253
- // exact function name (may include dot like 'i18n.t' or 'translate')
254
279
  return `\\b${escapeRegex(fnPattern)}`;
255
280
  };
256
281
  // Helper: check whether the old key exists in a given namespace (from the prebuilt map)
@@ -260,55 +285,73 @@ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap)
260
285
  const set = namespaceKeyMap.get(ns);
261
286
  return !!(set && set.has(oldParts.key));
262
287
  };
263
- // Replace exact string-key usages inside function calls: fn('key') or fn(`key`) or fn("key")
264
288
  for (const fnPattern of configuredFunctions) {
265
289
  const prefix = fnPrefixToRegex(fnPattern);
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
290
+ //
291
+ // 1) If moving TO the defaultNS, remove the explicit ns option and update key in one go.
292
+ // Only if the old key exists in the old namespace.
293
+ //
269
294
  if (oldParts.namespace && newParts.namespace &&
270
295
  oldParts.namespace !== newParts.namespace &&
271
296
  config.extract.defaultNS === newParts.namespace &&
272
297
  hasKeyInNamespace(oldParts.namespace)) {
273
- // t('key', { ns: 'oldNs' }) -> t('key')
274
298
  const nsRegexToDefault = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
275
299
  newCode = newCode.replace(nsRegexToDefault, (match, keyQ, beforeNs, nsQ, afterNs) => {
276
300
  changes++;
277
- // Build remaining object props (everything except the ns property)
278
301
  const obj = (beforeNs + afterNs).replace(/,?\s*$/, '').replace(/^\s*,?/, '').trim();
279
- // Replace the key string itself, preserving the original quote style
280
302
  let updated = match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `${keyQ}${newParts.key}${keyQ}`);
281
303
  if (obj) {
282
- // If other properties remain, keep them
283
304
  updated = updated.replace(/\{\s*([^}]*)\s*\}/, `{${obj}}`);
284
305
  }
285
306
  else {
286
- // No other props — remove the options object entirely
287
307
  updated = updated.replace(/\s*,\s*\{[^}]*\}\s*\)/, ')');
288
308
  }
289
309
  return updated;
290
310
  });
291
311
  }
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', ... }))
312
+ //
313
+ // 2) Handle calls that include an options object with ns: 'oldNs'.
314
+ // This covers both:
315
+ // - renames *inside the same namespace* (ns stays the same, key changes),
316
+ // - renames *across namespaces* (ns changes to new namespace OR removed if new default).
317
+ // Only run if old namespace actually contains the key (to avoid touching unrelated ns calls).
318
+ //
319
+ if (oldParts.namespace && newParts.namespace && hasKeyInNamespace(oldParts.namespace)) {
296
320
  const nsRegexFullKey = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
297
- newCode = newCode.replace(nsRegexFullKey, (match) => {
321
+ newCode = newCode.replace(nsRegexFullKey, (match, keyQ, beforeNs, nsQ, afterNs) => {
298
322
  changes++;
299
- // replace ns value
300
- return match.replace(new RegExp(`(\\bns\\s*:\\s*['"\`])${escapeRegex(oldParts.namespace ?? '')}(['"\`])`), `$1${newParts.namespace ?? ''}$2`);
323
+ // remaining props except ns
324
+ const obj = (beforeNs + afterNs).replace(/,?\s*$/, '').replace(/^\s*,?/, '').trim();
325
+ // start by replacing the key (preserve original quote style)
326
+ let updated = match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `${keyQ}${newParts.key}${keyQ}`);
327
+ if (oldParts.namespace === newParts.namespace) {
328
+ // same namespace: keep ns value untouched, but keep other props
329
+ if (obj) {
330
+ updated = updated.replace(/\{\s*([^}]*)\s*\}/, `{${obj}}`);
331
+ }
332
+ }
333
+ else {
334
+ // moving across namespaces
335
+ if (config.extract.defaultNS === newParts.namespace) {
336
+ // moving INTO the default namespace -> remove the ns property
337
+ if (obj) {
338
+ updated = updated.replace(/\{\s*([^}]*)\s*\}/, `{${obj}}`);
339
+ }
340
+ else {
341
+ updated = updated.replace(/\s*,\s*\{[^}]*\}\s*\)/, ')');
342
+ }
343
+ }
344
+ else {
345
+ // replace ns value to new namespace
346
+ updated = updated.replace(new RegExp(`(\\bns\\s*:\\s*['"\`])${escapeRegex(oldParts.namespace ?? '')}(['"\`])`), `$1${newParts.namespace ?? ''}$2`);
347
+ }
348
+ }
349
+ return updated;
301
350
  });
302
- // case where fullKey was used inside the string (e.g. t('ns:key', { ns: 'oldNs' }))
303
- if (oldParts.fullKey && oldParts.explicitNamespace) {
304
- const nsRegexFull = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.fullKey)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
305
- newCode = newCode.replace(nsRegexFull, (match) => {
306
- changes++;
307
- return match.replace(new RegExp(`(\\bns\\s*:\\s*['"\`])${escapeRegex(oldParts.namespace ?? '')}(['"\`])`), `$1${newParts.namespace ?? ''}$2`);
308
- });
309
- }
310
351
  }
311
- // 3) Replace occurrences where the call uses the fullKey inside the string (e.g. t('ns:key'))
352
+ //
353
+ // 3) fullKey (explicitly namespaced string in call): only when user supplied a namespaced target
354
+ //
312
355
  if (oldParts.fullKey && oldParts.explicitNamespace) {
313
356
  const regexFull = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g');
314
357
  newCode = newCode.replace(regexFull, (match) => {
@@ -317,14 +360,14 @@ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap)
317
360
  return match.replace(oldParts.fullKey, replacementKey);
318
361
  });
319
362
  }
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)
363
+ //
364
+ // 4) Selector / bracket forms
365
+ //
322
366
  {
323
367
  const dotRegex = new RegExp(`${prefix}\\s*\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*\\1\\.${escapeRegex(oldParts.key)}\\s*\\)`, 'g');
324
368
  newCode = newCode.replace(dotRegex, (match) => {
325
369
  changes++;
326
- const replacementKey = newParts.key;
327
- return match.replace(`.${oldParts.key}`, `.${replacementKey}`);
370
+ return match.replace(`.${oldParts.key}`, `.${newParts.key}`);
328
371
  });
329
372
  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
373
  newCode = newCode.replace(bracketRegex, (match) => {
@@ -338,20 +381,11 @@ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap)
338
381
  }
339
382
  });
340
383
  }
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).
384
+ //
385
+ // 5) Special-case: moving FROM defaultNS to another namespace for bare calls.
386
+ // Add ns option for bare calls. This must happen *before* the plain bare-call replacement
387
+ // so the final call includes the ns option.
388
+ //
355
389
  if (oldParts.namespace && newParts.namespace &&
356
390
  oldParts.namespace !== newParts.namespace &&
357
391
  config.extract.defaultNS === oldParts.namespace &&
@@ -362,18 +396,41 @@ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap)
362
396
  return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`), `${quote}${newParts.key}${quote}, { ns: '${newParts.namespace}' })`);
363
397
  });
364
398
  }
399
+ //
400
+ // 6) Bare calls without options: fn('key') -> fn('newKey')
401
+ // Apply this replacement only when the old key's namespace is the
402
+ // *effective* default namespace (config.extract.defaultNS ?? 'translation').
403
+ // This preserves previous behaviour: default-namespace bare-calls are
404
+ // considered "key form" and should be rewritten even when the translation
405
+ // file exists but the specific key isn't present.
406
+ //
407
+ {
408
+ const effectiveDefaultNS = config.extract.defaultNS ?? 'translation';
409
+ if (oldParts.namespace === effectiveDefaultNS) {
410
+ const regexKeyNoOptions = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`, 'g');
411
+ newCode = newCode.replace(regexKeyNoOptions, (match, q) => {
412
+ changes++;
413
+ const replacementKey = newParts.key;
414
+ return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `${q}${replacementKey}${q}`);
415
+ });
416
+ }
417
+ }
418
+ //
365
419
  // 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
- });
420
+ //
421
+ {
422
+ const jsxPatterns = [
423
+ { orig: oldParts.fullKey, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g') },
424
+ { orig: oldParts.key, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.key)}\\1`, 'g') }
425
+ ];
426
+ for (const p of jsxPatterns) {
427
+ newCode = newCode.replace(p.regex, (match, q) => {
428
+ changes++;
429
+ const nsSepStr = nsSeparator === false ? ':' : nsSeparator;
430
+ const replacement = (p.orig === oldParts.fullKey && oldParts.fullKey.includes(nsSepStr)) ? newParts.fullKey : newParts.key;
431
+ return `i18nKey=${q}${replacement}${q}`;
432
+ });
433
+ }
377
434
  }
378
435
  }
379
436
  return { newCode, changes };
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.6'); // This string is replaced with the actual version at build time by rollup
29
+ .version('1.39.8'); // 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
@@ -54,13 +54,21 @@ async function runRenameKey(config, oldKey, newKey, options = {}, logger = new C
54
54
  // Check for conflicts in translation files
55
55
  const conflicts = await checkConflicts(newParts, config);
56
56
  if (conflicts.length > 0) {
57
- return {
58
- success: false,
59
- sourceFiles: [],
60
- translationFiles: [],
61
- conflicts,
62
- error: 'Target key already exists in translation files'
63
- };
57
+ // If the old key doesn't exist in any translation file, treat this as a
58
+ // no-op (allow the command to succeed). This mirrors previous behavior
59
+ // where renaming a missing key doesn't fail just because the target
60
+ // already exists (it avoids blocking repeated/no-op renames).
61
+ const oldExists = await checkOldKeyExists(parseKeyWithNamespace(oldKey, config), config);
62
+ if (oldExists) {
63
+ return {
64
+ success: false,
65
+ sourceFiles: [],
66
+ translationFiles: [],
67
+ conflicts,
68
+ error: 'Target key already exists in translation files'
69
+ };
70
+ }
71
+ // otherwise: old key not present -> continue (no-op)
64
72
  }
65
73
  // Build a quick map of which namespaces contain which keys (union across locales).
66
74
  // This allows us to decide, per-call, whether an explicit `{ ns: 'x' }` refers to
@@ -153,6 +161,25 @@ async function checkConflicts(newParts, config) {
153
161
  }
154
162
  return conflicts;
155
163
  }
164
+ async function checkOldKeyExists(oldParts, config) {
165
+ const keySeparator = config.extract.keySeparator ?? '.';
166
+ for (const locale of config.locales) {
167
+ const outputPath = getOutputPath(config.extract.output, locale, oldParts.namespace);
168
+ const fullPath = resolve(process.cwd(), outputPath);
169
+ try {
170
+ const translations = await loadTranslationFile(fullPath);
171
+ if (translations) {
172
+ const val = getNestedValue(translations, oldParts.key, keySeparator);
173
+ if (val !== undefined)
174
+ return true;
175
+ }
176
+ }
177
+ catch {
178
+ // file missing — continue to next locale
179
+ }
180
+ }
181
+ return false;
182
+ }
156
183
  async function buildNamespaceKeyMap(config) {
157
184
  // Map namespace -> set of flattened keys present in that namespace (union across locales)
158
185
  const map = new Map();
@@ -244,11 +271,9 @@ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap)
244
271
  // Helper to create function-prefix regex fragment
245
272
  const fnPrefixToRegex = (fnPattern) => {
246
273
  if (fnPattern.startsWith('*.')) {
247
- // '*.t' -> match anyIdentifier.t
248
274
  const suffix = fnPattern.slice(2);
249
- return `\\b[\\w$]+\\.${escapeRegex(suffix)}`; // e.g. \b[\w$]+\.t
275
+ return `\\b[\\w$]+\\.${escapeRegex(suffix)}`;
250
276
  }
251
- // exact function name (may include dot like 'i18n.t' or 'translate')
252
277
  return `\\b${escapeRegex(fnPattern)}`;
253
278
  };
254
279
  // Helper: check whether the old key exists in a given namespace (from the prebuilt map)
@@ -258,55 +283,73 @@ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap)
258
283
  const set = namespaceKeyMap.get(ns);
259
284
  return !!(set && set.has(oldParts.key));
260
285
  };
261
- // Replace exact string-key usages inside function calls: fn('key') or fn(`key`) or fn("key")
262
286
  for (const fnPattern of configuredFunctions) {
263
287
  const prefix = fnPrefixToRegex(fnPattern);
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
288
+ //
289
+ // 1) If moving TO the defaultNS, remove the explicit ns option and update key in one go.
290
+ // Only if the old key exists in the old namespace.
291
+ //
267
292
  if (oldParts.namespace && newParts.namespace &&
268
293
  oldParts.namespace !== newParts.namespace &&
269
294
  config.extract.defaultNS === newParts.namespace &&
270
295
  hasKeyInNamespace(oldParts.namespace)) {
271
- // t('key', { ns: 'oldNs' }) -> t('key')
272
296
  const nsRegexToDefault = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
273
297
  newCode = newCode.replace(nsRegexToDefault, (match, keyQ, beforeNs, nsQ, afterNs) => {
274
298
  changes++;
275
- // Build remaining object props (everything except the ns property)
276
299
  const obj = (beforeNs + afterNs).replace(/,?\s*$/, '').replace(/^\s*,?/, '').trim();
277
- // Replace the key string itself, preserving the original quote style
278
300
  let updated = match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `${keyQ}${newParts.key}${keyQ}`);
279
301
  if (obj) {
280
- // If other properties remain, keep them
281
302
  updated = updated.replace(/\{\s*([^}]*)\s*\}/, `{${obj}}`);
282
303
  }
283
304
  else {
284
- // No other props — remove the options object entirely
285
305
  updated = updated.replace(/\s*,\s*\{[^}]*\}\s*\)/, ')');
286
306
  }
287
307
  return updated;
288
308
  });
289
309
  }
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', ... }))
310
+ //
311
+ // 2) Handle calls that include an options object with ns: 'oldNs'.
312
+ // This covers both:
313
+ // - renames *inside the same namespace* (ns stays the same, key changes),
314
+ // - renames *across namespaces* (ns changes to new namespace OR removed if new default).
315
+ // Only run if old namespace actually contains the key (to avoid touching unrelated ns calls).
316
+ //
317
+ if (oldParts.namespace && newParts.namespace && hasKeyInNamespace(oldParts.namespace)) {
294
318
  const nsRegexFullKey = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
295
- newCode = newCode.replace(nsRegexFullKey, (match) => {
319
+ newCode = newCode.replace(nsRegexFullKey, (match, keyQ, beforeNs, nsQ, afterNs) => {
296
320
  changes++;
297
- // replace ns value
298
- return match.replace(new RegExp(`(\\bns\\s*:\\s*['"\`])${escapeRegex(oldParts.namespace ?? '')}(['"\`])`), `$1${newParts.namespace ?? ''}$2`);
321
+ // remaining props except ns
322
+ const obj = (beforeNs + afterNs).replace(/,?\s*$/, '').replace(/^\s*,?/, '').trim();
323
+ // start by replacing the key (preserve original quote style)
324
+ let updated = match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `${keyQ}${newParts.key}${keyQ}`);
325
+ if (oldParts.namespace === newParts.namespace) {
326
+ // same namespace: keep ns value untouched, but keep other props
327
+ if (obj) {
328
+ updated = updated.replace(/\{\s*([^}]*)\s*\}/, `{${obj}}`);
329
+ }
330
+ }
331
+ else {
332
+ // moving across namespaces
333
+ if (config.extract.defaultNS === newParts.namespace) {
334
+ // moving INTO the default namespace -> remove the ns property
335
+ if (obj) {
336
+ updated = updated.replace(/\{\s*([^}]*)\s*\}/, `{${obj}}`);
337
+ }
338
+ else {
339
+ updated = updated.replace(/\s*,\s*\{[^}]*\}\s*\)/, ')');
340
+ }
341
+ }
342
+ else {
343
+ // replace ns value to new namespace
344
+ updated = updated.replace(new RegExp(`(\\bns\\s*:\\s*['"\`])${escapeRegex(oldParts.namespace ?? '')}(['"\`])`), `$1${newParts.namespace ?? ''}$2`);
345
+ }
346
+ }
347
+ return updated;
299
348
  });
300
- // case where fullKey was used inside the string (e.g. t('ns:key', { ns: 'oldNs' }))
301
- if (oldParts.fullKey && oldParts.explicitNamespace) {
302
- const nsRegexFull = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.fullKey)}\\1\\s*,\\s*\\{([^}]*)\\bns\\s*:\\s*(['"\`])${escapeRegex(oldParts.namespace)}\\3([^}]*)\\}\\s*\\)`, 'g');
303
- newCode = newCode.replace(nsRegexFull, (match) => {
304
- changes++;
305
- return match.replace(new RegExp(`(\\bns\\s*:\\s*['"\`])${escapeRegex(oldParts.namespace ?? '')}(['"\`])`), `$1${newParts.namespace ?? ''}$2`);
306
- });
307
- }
308
349
  }
309
- // 3) Replace occurrences where the call uses the fullKey inside the string (e.g. t('ns:key'))
350
+ //
351
+ // 3) fullKey (explicitly namespaced string in call): only when user supplied a namespaced target
352
+ //
310
353
  if (oldParts.fullKey && oldParts.explicitNamespace) {
311
354
  const regexFull = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g');
312
355
  newCode = newCode.replace(regexFull, (match) => {
@@ -315,14 +358,14 @@ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap)
315
358
  return match.replace(oldParts.fullKey, replacementKey);
316
359
  });
317
360
  }
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)
361
+ //
362
+ // 4) Selector / bracket forms
363
+ //
320
364
  {
321
365
  const dotRegex = new RegExp(`${prefix}\\s*\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*\\1\\.${escapeRegex(oldParts.key)}\\s*\\)`, 'g');
322
366
  newCode = newCode.replace(dotRegex, (match) => {
323
367
  changes++;
324
- const replacementKey = newParts.key;
325
- return match.replace(`.${oldParts.key}`, `.${replacementKey}`);
368
+ return match.replace(`.${oldParts.key}`, `.${newParts.key}`);
326
369
  });
327
370
  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
371
  newCode = newCode.replace(bracketRegex, (match) => {
@@ -336,20 +379,11 @@ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap)
336
379
  }
337
380
  });
338
381
  }
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).
382
+ //
383
+ // 5) Special-case: moving FROM defaultNS to another namespace for bare calls.
384
+ // Add ns option for bare calls. This must happen *before* the plain bare-call replacement
385
+ // so the final call includes the ns option.
386
+ //
353
387
  if (oldParts.namespace && newParts.namespace &&
354
388
  oldParts.namespace !== newParts.namespace &&
355
389
  config.extract.defaultNS === oldParts.namespace &&
@@ -360,18 +394,41 @@ function replaceKeyWithRegex(code, oldParts, newParts, config, namespaceKeyMap)
360
394
  return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`), `${quote}${newParts.key}${quote}, { ns: '${newParts.namespace}' })`);
361
395
  });
362
396
  }
397
+ //
398
+ // 6) Bare calls without options: fn('key') -> fn('newKey')
399
+ // Apply this replacement only when the old key's namespace is the
400
+ // *effective* default namespace (config.extract.defaultNS ?? 'translation').
401
+ // This preserves previous behaviour: default-namespace bare-calls are
402
+ // considered "key form" and should be rewritten even when the translation
403
+ // file exists but the specific key isn't present.
404
+ //
405
+ {
406
+ const effectiveDefaultNS = config.extract.defaultNS ?? 'translation';
407
+ if (oldParts.namespace === effectiveDefaultNS) {
408
+ const regexKeyNoOptions = new RegExp(`${prefix}\\s*\\(\\s*(['"\`])${escapeRegex(oldParts.key)}\\1\\s*\\)`, 'g');
409
+ newCode = newCode.replace(regexKeyNoOptions, (match, q) => {
410
+ changes++;
411
+ const replacementKey = newParts.key;
412
+ return match.replace(new RegExp(`(['"\`])${escapeRegex(oldParts.key)}\\1`), `${q}${replacementKey}${q}`);
413
+ });
414
+ }
415
+ }
416
+ //
363
417
  // 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
- });
418
+ //
419
+ {
420
+ const jsxPatterns = [
421
+ { orig: oldParts.fullKey, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g') },
422
+ { orig: oldParts.key, regex: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.key)}\\1`, 'g') }
423
+ ];
424
+ for (const p of jsxPatterns) {
425
+ newCode = newCode.replace(p.regex, (match, q) => {
426
+ changes++;
427
+ const nsSepStr = nsSeparator === false ? ':' : nsSeparator;
428
+ const replacement = (p.orig === oldParts.fullKey && oldParts.fullKey.includes(nsSepStr)) ? newParts.fullKey : newParts.key;
429
+ return `i18nKey=${q}${replacement}${q}`;
430
+ });
431
+ }
375
432
  }
376
433
  }
377
434
  return { newCode, changes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.39.6",
3
+ "version": "1.39.8",
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":"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"}
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,CAqE1B"}