i18next-cli 1.34.0 → 1.34.1

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.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/cli.js +271 -1
  3. package/dist/cjs/config.js +211 -1
  4. package/dist/cjs/extractor/core/ast-visitors.js +364 -1
  5. package/dist/cjs/extractor/core/extractor.js +245 -1
  6. package/dist/cjs/extractor/core/key-finder.js +132 -1
  7. package/dist/cjs/extractor/core/translation-manager.js +745 -1
  8. package/dist/cjs/extractor/parsers/ast-utils.js +85 -1
  9. package/dist/cjs/extractor/parsers/call-expression-handler.js +941 -1
  10. package/dist/cjs/extractor/parsers/comment-parser.js +375 -1
  11. package/dist/cjs/extractor/parsers/expression-resolver.js +362 -1
  12. package/dist/cjs/extractor/parsers/jsx-handler.js +492 -1
  13. package/dist/cjs/extractor/parsers/jsx-parser.js +355 -1
  14. package/dist/cjs/extractor/parsers/scope-manager.js +408 -1
  15. package/dist/cjs/extractor/plugin-manager.js +106 -1
  16. package/dist/cjs/heuristic-config.js +99 -1
  17. package/dist/cjs/index.js +28 -1
  18. package/dist/cjs/init.js +174 -1
  19. package/dist/cjs/linter.js +431 -1
  20. package/dist/cjs/locize.js +269 -1
  21. package/dist/cjs/migrator.js +196 -1
  22. package/dist/cjs/rename-key.js +354 -1
  23. package/dist/cjs/status.js +336 -1
  24. package/dist/cjs/syncer.js +120 -1
  25. package/dist/cjs/types-generator.js +165 -1
  26. package/dist/cjs/utils/default-value.js +43 -1
  27. package/dist/cjs/utils/file-utils.js +136 -1
  28. package/dist/cjs/utils/funnel-msg-tracker.js +75 -1
  29. package/dist/cjs/utils/logger.js +36 -1
  30. package/dist/cjs/utils/nested-object.js +124 -1
  31. package/dist/cjs/utils/validation.js +71 -1
  32. package/dist/esm/cli.js +269 -1
  33. package/dist/esm/config.js +206 -1
  34. package/dist/esm/extractor/core/ast-visitors.js +362 -1
  35. package/dist/esm/extractor/core/extractor.js +241 -1
  36. package/dist/esm/extractor/core/key-finder.js +130 -1
  37. package/dist/esm/extractor/core/translation-manager.js +743 -1
  38. package/dist/esm/extractor/parsers/ast-utils.js +80 -1
  39. package/dist/esm/extractor/parsers/call-expression-handler.js +939 -1
  40. package/dist/esm/extractor/parsers/comment-parser.js +373 -1
  41. package/dist/esm/extractor/parsers/expression-resolver.js +360 -1
  42. package/dist/esm/extractor/parsers/jsx-handler.js +490 -1
  43. package/dist/esm/extractor/parsers/jsx-parser.js +334 -1
  44. package/dist/esm/extractor/parsers/scope-manager.js +406 -1
  45. package/dist/esm/extractor/plugin-manager.js +103 -1
  46. package/dist/esm/heuristic-config.js +97 -1
  47. package/dist/esm/index.js +11 -1
  48. package/dist/esm/init.js +172 -1
  49. package/dist/esm/linter.js +425 -1
  50. package/dist/esm/locize.js +265 -1
  51. package/dist/esm/migrator.js +194 -1
  52. package/dist/esm/rename-key.js +352 -1
  53. package/dist/esm/status.js +334 -1
  54. package/dist/esm/syncer.js +118 -1
  55. package/dist/esm/types-generator.js +163 -1
  56. package/dist/esm/utils/default-value.js +41 -1
  57. package/dist/esm/utils/file-utils.js +131 -1
  58. package/dist/esm/utils/funnel-msg-tracker.js +72 -1
  59. package/dist/esm/utils/logger.js +34 -1
  60. package/dist/esm/utils/nested-object.js +120 -1
  61. package/dist/esm/utils/validation.js +68 -1
  62. package/package.json +2 -2
  63. package/types/locize.d.ts.map +1 -1
@@ -1 +1,352 @@
1
- import{glob as e}from"glob";import{readFile as t,writeFile as n}from"node:fs/promises";import{ConsoleLogger as r}from"./utils/logger.js";import{getOutputPath as o,loadTranslationFile as s,serializeTranslationFile as a}from"./utils/file-utils.js";import{resolve as i}from"node:path";import{getNestedValue as c,setNestedValue as l}from"./utils/nested-object.js";import{shouldShowFunnel as f,recordFunnelShown as u}from"./utils/funnel-msg-tracker.js";import p from"chalk";async function g(g,d,m,h={},x=new r){const{dryRun:k=!1}=h,b=function(e,t){if(!e||!e.trim())return{valid:!1,error:"Old key cannot be empty"};if(!t||!t.trim())return{valid:!1,error:"New key cannot be empty"};if(e===t)return{valid:!1,error:"Old and new keys are identical"};return{valid:!0}}(d,m);if(!b.valid)return{success:!1,sourceFiles:[],translationFiles:[],error:b.error};const K=y(d,g),v=y(m,g),S=await async function(e,t){const n=[];for(const r of t.locales){const a=o(t.extract.output,r,e.namespace),l=i(process.cwd(),a);try{const o=await s(l);if(o){const s=t.extract.keySeparator??".";void 0!==c(o,e.key,s)&&n.push(`${r}:${e.fullKey}`)}}catch{}}return n}(v,g);if(S.length>0)return{success:!1,sourceFiles:[],translationFiles:[],conflicts:S,error:"Target key already exists in translation files"};x.info(`🔍 Scanning for usages of "${d}"...`);const R=await async function(r,o,s,a,i){const c=["node_modules/**"],l=Array.isArray(s.extract.ignore)?s.extract.ignore:s.extract.ignore?[s.extract.ignore]:[],f=Array.isArray(s.extract.input)?s.extract.input:[s.extract.input],u=f.map(e=>e.replace(/\\/g,"/")),p=await e(u,{ignore:[...c,...l],cwd:process.cwd()}),g=[];for(const e of p){const c=await t(e,"utf-8"),{newCode:l,changes:f}=await $(c,r,o,s);f>0&&(a||await n(e,l,"utf-8"),g.push({path:e,changes:f}),i.info(` ${a?"(dry-run) ":""}✓ ${e} (${f} ${1===f?"change":"changes"})`))}g.length>0&&i.info(`\n📝 Source file changes: ${g.length} file${1===g.length?"":"s"}`);return g}(K,v,g,k,x),E=await async function(e,t,r,f,u){const p=[],g=r.extract.keySeparator??".";for(const y of r.locales){const $=o(r.extract.output,y,e.namespace),d=i(process.cwd(),$);try{const o=await s(d);if(!o)continue;const i=c(o,e.key,g);if(void 0===i)continue;if(w(o,e.key,g),l(o,t.key,i,g),!f){const e=a(o,r.extract.outputFormat,r.extract.indentation);await n(d,e,"utf-8")}p.push({path:d,updated:!0}),u.info(` ${f?"(dry-run) ":""}✓ ${d}`)}catch(e){}}p.length>0&&u.info(`\n📦 Translation file updates: ${p.length} file${1===p.length?"":"s"}`);return p}(K,v,g,k,x),j=R.reduce((e,t)=>e+t.changes,0);return!k&&j>0?(x.info("\n✨ Successfully renamed key!"),x.info(` Old: "${d}"`),x.info(` New: "${m}"`),await async function(){if(!await f("rename-key"))return;return console.log(p.yellow.bold("\n💡 Tip: Managing translations across multiple projects?")),console.log(" With locize, you can rename, move, and copy translation keys directly"),console.log(" in the web interface—no CLI needed. Perfect for collaboration with"),console.log(" translators and managing complex refactoring across namespaces."),console.log(` Learn more: ${p.cyan("https://www.locize.com/docs/how-can-a-segment-key-be-copied-moved-or-renamed")}`),u("rename-key")}()):0===j&&x.info(`\n⚠️ No usages found for "${d}"`),{success:!0,sourceFiles:R,translationFiles:E}}function y(e,t){const n=t.extract.nsSeparator??":";if(n&&e.includes(n)){const[t,...r]=e.split(n);return{namespace:t,key:r.join(n),fullKey:e}}return{namespace:t.extract.defaultNS||"translation",key:e,fullKey:e}}async function $(e,t,n,r){return function(e,t,n,r){let o=0,s=e;const a=r.extract.nsSeparator??":",i=e=>a&&e.includes(String(a))?n.fullKey:n.key,c=r.extract.functions||["t","*.t"],l=[];for(const e of c)if(e.startsWith("*.")){const n=d(e.substring(1));l.push({pattern:new RegExp(`\\w+${n}\\((['"\`])${d(t.fullKey)}\\1`,"g"),original:t.fullKey}),l.push({pattern:new RegExp(`\\w+${n}\\((['"\`])${d(t.key)}\\1`,"g"),original:t.key})}else{const n=d(e);l.push({pattern:new RegExp(`\\b${n}\\((['"\`])${d(t.fullKey)}\\1`,"g"),original:t.fullKey}),l.push({pattern:new RegExp(`\\b${n}\\((['"\`])${d(t.key)}\\1`,"g"),original:t.key})}for(const{pattern:e,original:t}of l)if(e.test(s)){const n=i(t);s=s.replace(e,(e,t)=>{o++;const r=e.match(/^(\w+(?:\.\w+)*)\(/);return r?`${r[1]}(${t}${n}${t}`:e})}for(const e of c){let n;if(e.startsWith("*.")){n=`\\w+${d(e.substring(1))}`}else n=d(e);for(const e of[t.fullKey,t.key]){const t=new RegExp(`(\\b${n}\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*)\\2\\.${d(e)}(\\s*\\))`,"g");if(t.test(s)){const n=i(e);s=s.replace(t,(e,t,r,s)=>(o++,`${t}${r}.${n}${s}`))}const r=new RegExp(`(\\b${n}\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*)\\2\\[\\s*(['"\`])${d(e)}\\3\\s*\\](\\s*\\))`,"g");if(r.test(s)){const t=i(e),n=e=>/^[A-Za-z_$][\w$]*$/.test(e);s=s.replace(r,(e,r,s,a,i)=>(o++,n(t)?`${r}${s}.${t}${i}`:`${r}${s}[${a}${t}${a}]${i}`))}}}const f=[{pattern:new RegExp(`i18nKey=(['"\`])${d(t.fullKey)}\\1`,"g"),original:t.fullKey},{pattern:new RegExp(`i18nKey=(['"\`])${d(t.key)}\\1`,"g"),original:t.key}];for(const{pattern:e,original:t}of f)if(e.test(s)){const n=i(t);s=s.replace(e,(e,t)=>(o++,`i18nKey=${t}${n}${t}`))}return{newCode:s,changes:o}}(e,t,n,r)}function d(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function w(e,t,n){if(!1===n)return void delete e[t];const r=t.split(String(n));let o=e;for(let e=0;e<r.length-1;e++){if(!o[r[e]])return;o=o[r[e]]}delete o[r[r.length-1]]}export{g as runRenameKey};
1
+ import { glob } from 'glob';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { ConsoleLogger } from './utils/logger.js';
4
+ import { getOutputPath, loadTranslationFile, serializeTranslationFile } from './utils/file-utils.js';
5
+ import { resolve } from 'node:path';
6
+ import { getNestedValue, setNestedValue } from './utils/nested-object.js';
7
+ import { shouldShowFunnel, recordFunnelShown } from './utils/funnel-msg-tracker.js';
8
+ import chalk from 'chalk';
9
+
10
+ /**
11
+ * Renames a translation key across all source files and translation files.
12
+ *
13
+ * This function performs a comprehensive key rename operation:
14
+ * 1. Validates the old and new key names
15
+ * 2. Checks for conflicts in translation files
16
+ * 3. Updates all occurrences in source code (AST-based)
17
+ * 4. Updates all translation files for all locales
18
+ * 5. Preserves the original translation values
19
+ *
20
+ * @param config - The i18next toolkit configuration
21
+ * @param oldKey - The current key to rename (may include namespace prefix)
22
+ * @param newKey - The new key name (may include namespace prefix)
23
+ * @param options - Rename options (dry-run mode, etc.)
24
+ * @param logger - Logger instance for output
25
+ * @returns Result object with update status and file lists
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * // Basic rename
30
+ * const result = await runRenameKey(config, 'old.key', 'new.key')
31
+ *
32
+ * // With namespace
33
+ * const result = await runRenameKey(config, 'common:button.submit', 'common:button.save')
34
+ *
35
+ * // Dry run to preview changes
36
+ * const result = await runRenameKey(config, 'old.key', 'new.key', { dryRun: true })
37
+ * ```
38
+ */
39
+ async function runRenameKey(config, oldKey, newKey, options = {}, logger = new ConsoleLogger()) {
40
+ const { dryRun = false } = options;
41
+ // Validate keys
42
+ const validation = validateKeys(oldKey, newKey);
43
+ if (!validation.valid) {
44
+ return {
45
+ success: false,
46
+ sourceFiles: [],
47
+ translationFiles: [],
48
+ error: validation.error
49
+ };
50
+ }
51
+ // Parse namespace from keys
52
+ const oldParts = parseKeyWithNamespace(oldKey, config);
53
+ const newParts = parseKeyWithNamespace(newKey, config);
54
+ // Check for conflicts in translation files
55
+ const conflicts = await checkConflicts(newParts, config);
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
+ };
64
+ }
65
+ logger.info(`🔍 Scanning for usages of "${oldKey}"...`);
66
+ // Find and update source files
67
+ const sourceResults = await updateSourceFiles(oldParts, newParts, config, dryRun, logger);
68
+ // Update translation files
69
+ const translationResults = await updateTranslationFiles(oldParts, newParts, config, dryRun, logger);
70
+ const totalChanges = sourceResults.reduce((sum, r) => sum + r.changes, 0);
71
+ if (!dryRun && totalChanges > 0) {
72
+ logger.info('\n✨ Successfully renamed key!');
73
+ logger.info(` Old: "${oldKey}"`);
74
+ logger.info(` New: "${newKey}"`);
75
+ // Show locize funnel after successful rename
76
+ await printLocizeFunnel();
77
+ }
78
+ else if (totalChanges === 0) {
79
+ logger.info(`\n⚠️ No usages found for "${oldKey}"`);
80
+ }
81
+ return {
82
+ success: true,
83
+ sourceFiles: sourceResults,
84
+ translationFiles: translationResults
85
+ };
86
+ }
87
+ /**
88
+ * Prints a promotional message for the locize rename/move workflow.
89
+ * This message is shown after a successful key rename operation.
90
+ */
91
+ async function printLocizeFunnel() {
92
+ if (!(await shouldShowFunnel('rename-key')))
93
+ return;
94
+ console.log(chalk.yellow.bold('\n💡 Tip: Managing translations across multiple projects?'));
95
+ console.log(' With locize, you can rename, move, and copy translation keys directly');
96
+ console.log(' in the web interface—no CLI needed. Perfect for collaboration with');
97
+ console.log(' translators and managing complex refactoring across namespaces.');
98
+ console.log(` Learn more: ${chalk.cyan('https://www.locize.com/docs/how-can-a-segment-key-be-copied-moved-or-renamed')}`);
99
+ return recordFunnelShown('rename-key');
100
+ }
101
+ function parseKeyWithNamespace(key, config) {
102
+ const nsSeparator = config.extract.nsSeparator ?? ':';
103
+ if (nsSeparator && key.includes(nsSeparator)) {
104
+ const [ns, ...rest] = key.split(nsSeparator);
105
+ return {
106
+ namespace: ns,
107
+ key: rest.join(nsSeparator),
108
+ fullKey: key
109
+ };
110
+ }
111
+ return {
112
+ namespace: config.extract.defaultNS || 'translation',
113
+ key,
114
+ fullKey: key
115
+ };
116
+ }
117
+ function validateKeys(oldKey, newKey, config) {
118
+ if (!oldKey || !oldKey.trim()) {
119
+ return { valid: false, error: 'Old key cannot be empty' };
120
+ }
121
+ if (!newKey || !newKey.trim()) {
122
+ return { valid: false, error: 'New key cannot be empty' };
123
+ }
124
+ if (oldKey === newKey) {
125
+ return { valid: false, error: 'Old and new keys are identical' };
126
+ }
127
+ return { valid: true };
128
+ }
129
+ async function checkConflicts(newParts, config) {
130
+ const conflicts = [];
131
+ for (const locale of config.locales) {
132
+ const outputPath = getOutputPath(config.extract.output, locale, newParts.namespace);
133
+ const fullPath = resolve(process.cwd(), outputPath);
134
+ try {
135
+ const existingTranslations = await loadTranslationFile(fullPath);
136
+ if (existingTranslations) {
137
+ const keySeparator = config.extract.keySeparator ?? '.';
138
+ const value = getNestedValue(existingTranslations, newParts.key, keySeparator);
139
+ if (value !== undefined) {
140
+ conflicts.push(`${locale}:${newParts.fullKey}`);
141
+ }
142
+ }
143
+ }
144
+ catch {
145
+ // File doesn't exist, no conflict
146
+ }
147
+ }
148
+ return conflicts;
149
+ }
150
+ async function updateSourceFiles(oldParts, newParts, config, dryRun, logger) {
151
+ const defaultIgnore = ['node_modules/**'];
152
+ const userIgnore = Array.isArray(config.extract.ignore)
153
+ ? config.extract.ignore
154
+ : config.extract.ignore ? [config.extract.ignore] : [];
155
+ // Normalize input patterns for cross-platform compatibility
156
+ const inputPatterns = Array.isArray(config.extract.input)
157
+ ? config.extract.input
158
+ : [config.extract.input];
159
+ const normalizedPatterns = inputPatterns.map(pattern => pattern.replace(/\\/g, '/'));
160
+ const sourceFiles = await glob(normalizedPatterns, {
161
+ ignore: [...defaultIgnore, ...userIgnore],
162
+ cwd: process.cwd()
163
+ });
164
+ const results = [];
165
+ for (const file of sourceFiles) {
166
+ const code = await readFile(file, 'utf-8');
167
+ const { newCode, changes } = await replaceKeyInSource(code, oldParts, newParts, config);
168
+ if (changes > 0) {
169
+ if (!dryRun) {
170
+ await writeFile(file, newCode, 'utf-8');
171
+ }
172
+ results.push({ path: file, changes });
173
+ logger.info(` ${dryRun ? '(dry-run) ' : ''}✓ ${file} (${changes} ${changes === 1 ? 'change' : 'changes'})`);
174
+ }
175
+ }
176
+ if (results.length > 0) {
177
+ logger.info(`\n📝 Source file changes: ${results.length} file${results.length === 1 ? '' : 's'}`);
178
+ }
179
+ return results;
180
+ }
181
+ async function replaceKeyInSource(code, oldParts, newParts, config) {
182
+ // Use regex-based replacement which is more reliable than AST manipulation
183
+ return replaceKeyWithRegex(code, oldParts, newParts, config);
184
+ }
185
+ function replaceKeyWithRegex(code, oldParts, newParts, config) {
186
+ let changes = 0;
187
+ let newCode = code;
188
+ const nsSeparator = config.extract.nsSeparator ?? ':';
189
+ // Helper to determine which key form to use in replacement
190
+ const getReplacementKey = (originalKey) => {
191
+ const hasNamespace = nsSeparator && originalKey.includes(String(nsSeparator));
192
+ return hasNamespace ? newParts.fullKey : newParts.key;
193
+ };
194
+ // Pattern 1: Function calls - respect configured functions
195
+ const configuredFunctions = config.extract.functions || ['t', '*.t'];
196
+ const functionPatterns = [];
197
+ for (const fnPattern of configuredFunctions) {
198
+ if (fnPattern.startsWith('*.')) {
199
+ // Wildcard pattern like '*.t' - match any prefix
200
+ const suffix = fnPattern.substring(1); // '.t'
201
+ const escapedSuffix = escapeRegex(suffix);
202
+ // Match: anyIdentifier.t('key')
203
+ functionPatterns.push({
204
+ pattern: new RegExp(`\\w+${escapedSuffix}\\((['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g'),
205
+ original: oldParts.fullKey
206
+ });
207
+ functionPatterns.push({
208
+ pattern: new RegExp(`\\w+${escapedSuffix}\\((['"\`])${escapeRegex(oldParts.key)}\\1`, 'g'),
209
+ original: oldParts.key
210
+ });
211
+ }
212
+ else {
213
+ // Exact function name
214
+ const escapedFn = escapeRegex(fnPattern);
215
+ functionPatterns.push({
216
+ pattern: new RegExp(`\\b${escapedFn}\\((['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g'),
217
+ original: oldParts.fullKey
218
+ });
219
+ functionPatterns.push({
220
+ pattern: new RegExp(`\\b${escapedFn}\\((['"\`])${escapeRegex(oldParts.key)}\\1`, 'g'),
221
+ original: oldParts.key
222
+ });
223
+ }
224
+ }
225
+ for (const { pattern, original } of functionPatterns) {
226
+ if (pattern.test(newCode)) {
227
+ const replacement = getReplacementKey(original);
228
+ newCode = newCode.replace(pattern, (match, quote) => {
229
+ changes++;
230
+ // Preserve the function name part, only replace the key
231
+ const functionNameMatch = match.match(/^(\w+(?:\.\w+)*)\(/);
232
+ if (functionNameMatch) {
233
+ return `${functionNameMatch[1]}(${quote}${replacement}${quote}`;
234
+ }
235
+ return match;
236
+ });
237
+ }
238
+ }
239
+ // Pattern 2: Selector API arrow functions (e.g. t(($) => $.old.key) or i18n.t($ => $.old.key))
240
+ // Respect configured function names (including wildcard patterns)
241
+ for (const fnPattern of configuredFunctions) {
242
+ // Build a regex prefix for the function invocation (handles wildcard '*.t' -> '\w+\.t')
243
+ let patternPrefix;
244
+ if (fnPattern.startsWith('*.')) {
245
+ const suffix = fnPattern.substring(1); // '.t'
246
+ patternPrefix = `\\w+${escapeRegex(suffix)}`;
247
+ }
248
+ else {
249
+ patternPrefix = escapeRegex(fnPattern);
250
+ }
251
+ // Try matching both the plain key and the ns-prefixed fullKey used in selector access
252
+ for (const original of [oldParts.fullKey, oldParts.key]) {
253
+ // Match dot-notation selector forms like:
254
+ // t(($) => $.old.key)
255
+ // i18n.t($ => $.old.key.nested)
256
+ const selectorDotRegex = new RegExp(`(\\b${patternPrefix}\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*)\\2\\.${escapeRegex(original)}(\\s*\\))`, 'g');
257
+ if (selectorDotRegex.test(newCode)) {
258
+ const replacementKey = getReplacementKey(original);
259
+ newCode = newCode.replace(selectorDotRegex, (match, prefix, param, suffix) => {
260
+ changes++;
261
+ // Replace property chain with dot-notation replacement
262
+ return `${prefix}${param}.${replacementKey}${suffix}`;
263
+ });
264
+ }
265
+ // Match bracket-notation selector forms like:
266
+ // t(($) => $["Old Key"])
267
+ const selectorBracketRegex = new RegExp(`(\\b${patternPrefix}\\(\\s*\\(?\\s*([a-zA-Z_$][\\w$]*)\\s*\\)?\\s*=>\\s*)\\2\\[\\s*(['"\`])${escapeRegex(original)}\\3\\s*\\](\\s*\\))`, 'g');
268
+ if (selectorBracketRegex.test(newCode)) {
269
+ const replacementKey = getReplacementKey(original);
270
+ const isIdentifier = (s) => /^[A-Za-z_$][\w$]*$/.test(s);
271
+ newCode = newCode.replace(selectorBracketRegex, (match, prefix, param, quote, suffix) => {
272
+ changes++;
273
+ // If the replacement is a valid identifier, convert to dot-notation, otherwise keep bracket-notation
274
+ if (isIdentifier(replacementKey)) {
275
+ return `${prefix}${param}.${replacementKey}${suffix}`;
276
+ }
277
+ return `${prefix}${param}[${quote}${replacementKey}${quote}]${suffix}`;
278
+ });
279
+ }
280
+ }
281
+ }
282
+ // Pattern 3: JSX i18nKey attribute - respect configured transComponents
283
+ // const transComponents = config.extract.transComponents || ['Trans']
284
+ // Create a pattern that matches i18nKey on any of the configured components
285
+ // This is a simplified approach - for more complex cases, consider AST-based replacement
286
+ const i18nKeyPatterns = [
287
+ { pattern: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.fullKey)}\\1`, 'g'), original: oldParts.fullKey },
288
+ { pattern: new RegExp(`i18nKey=(['"\`])${escapeRegex(oldParts.key)}\\1`, 'g'), original: oldParts.key }
289
+ ];
290
+ for (const { pattern, original } of i18nKeyPatterns) {
291
+ if (pattern.test(newCode)) {
292
+ const replacement = getReplacementKey(original);
293
+ newCode = newCode.replace(pattern, (match, quote) => {
294
+ changes++;
295
+ return `i18nKey=${quote}${replacement}${quote}`;
296
+ });
297
+ }
298
+ }
299
+ return { newCode, changes };
300
+ }
301
+ function escapeRegex(str) {
302
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
303
+ }
304
+ async function updateTranslationFiles(oldParts, newParts, config, dryRun, logger) {
305
+ const results = [];
306
+ const keySeparator = config.extract.keySeparator ?? '.';
307
+ for (const locale of config.locales) {
308
+ const outputPath = getOutputPath(config.extract.output, locale, oldParts.namespace);
309
+ const fullPath = resolve(process.cwd(), outputPath);
310
+ try {
311
+ const translations = await loadTranslationFile(fullPath);
312
+ if (!translations)
313
+ continue;
314
+ const oldValue = getNestedValue(translations, oldParts.key, keySeparator);
315
+ if (oldValue === undefined)
316
+ continue;
317
+ // Remove old key
318
+ deleteNestedValue(translations, oldParts.key, keySeparator);
319
+ // Add new key with same value
320
+ setNestedValue(translations, newParts.key, oldValue, keySeparator);
321
+ if (!dryRun) {
322
+ const content = serializeTranslationFile(translations, config.extract.outputFormat, config.extract.indentation);
323
+ await writeFile(fullPath, content, 'utf-8');
324
+ }
325
+ results.push({ path: fullPath, updated: true });
326
+ logger.info(` ${dryRun ? '(dry-run) ' : ''}✓ ${fullPath}`);
327
+ }
328
+ catch (error) {
329
+ // File doesn't exist or couldn't be processed
330
+ }
331
+ }
332
+ if (results.length > 0) {
333
+ logger.info(`\n📦 Translation file updates: ${results.length} file${results.length === 1 ? '' : 's'}`);
334
+ }
335
+ return results;
336
+ }
337
+ function deleteNestedValue(obj, path, separator) {
338
+ if (separator === false) {
339
+ delete obj[path];
340
+ return;
341
+ }
342
+ const keys = path.split(String(separator));
343
+ let current = obj;
344
+ for (let i = 0; i < keys.length - 1; i++) {
345
+ if (!current[keys[i]])
346
+ return;
347
+ current = current[keys[i]];
348
+ }
349
+ delete current[keys[keys.length - 1]];
350
+ }
351
+
352
+ export { runRenameKey };