i18next-cli 1.46.4 → 1.47.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 (51) hide show
  1. package/README.md +198 -3
  2. package/dist/cjs/cli.js +50 -1
  3. package/dist/cjs/extractor/core/extractor.js +27 -18
  4. package/dist/cjs/extractor/core/translation-manager.js +10 -3
  5. package/dist/cjs/extractor/parsers/call-expression-handler.js +9 -1
  6. package/dist/cjs/extractor/parsers/jsx-handler.js +10 -1
  7. package/dist/cjs/index.js +5 -0
  8. package/dist/cjs/init.js +68 -12
  9. package/dist/cjs/instrumenter/core/instrumenter.js +1633 -0
  10. package/dist/cjs/instrumenter/core/key-generator.js +71 -0
  11. package/dist/cjs/instrumenter/core/string-detector.js +290 -0
  12. package/dist/cjs/instrumenter/core/transformer.js +339 -0
  13. package/dist/cjs/linter.js +8 -9
  14. package/dist/cjs/utils/jsx-attributes.js +131 -0
  15. package/dist/esm/cli.js +50 -1
  16. package/dist/esm/extractor/core/extractor.js +27 -18
  17. package/dist/esm/extractor/core/translation-manager.js +10 -3
  18. package/dist/esm/extractor/parsers/call-expression-handler.js +9 -1
  19. package/dist/esm/extractor/parsers/jsx-handler.js +10 -1
  20. package/dist/esm/index.js +3 -0
  21. package/dist/esm/init.js +68 -12
  22. package/dist/esm/instrumenter/core/instrumenter.js +1630 -0
  23. package/dist/esm/instrumenter/core/key-generator.js +68 -0
  24. package/dist/esm/instrumenter/core/string-detector.js +288 -0
  25. package/dist/esm/instrumenter/core/transformer.js +336 -0
  26. package/dist/esm/linter.js +8 -9
  27. package/dist/esm/utils/jsx-attributes.js +121 -0
  28. package/package.json +2 -1
  29. package/types/cli.d.ts.map +1 -1
  30. package/types/extractor/core/extractor.d.ts.map +1 -1
  31. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  32. package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
  33. package/types/extractor/parsers/jsx-handler.d.ts.map +1 -1
  34. package/types/index.d.ts +2 -1
  35. package/types/index.d.ts.map +1 -1
  36. package/types/init.d.ts.map +1 -1
  37. package/types/instrumenter/core/instrumenter.d.ts +16 -0
  38. package/types/instrumenter/core/instrumenter.d.ts.map +1 -0
  39. package/types/instrumenter/core/key-generator.d.ts +30 -0
  40. package/types/instrumenter/core/key-generator.d.ts.map +1 -0
  41. package/types/instrumenter/core/string-detector.d.ts +27 -0
  42. package/types/instrumenter/core/string-detector.d.ts.map +1 -0
  43. package/types/instrumenter/core/transformer.d.ts +31 -0
  44. package/types/instrumenter/core/transformer.d.ts.map +1 -0
  45. package/types/instrumenter/index.d.ts +6 -0
  46. package/types/instrumenter/index.d.ts.map +1 -0
  47. package/types/linter.d.ts.map +1 -1
  48. package/types/types.d.ts +285 -1
  49. package/types/types.d.ts.map +1 -1
  50. package/types/utils/jsx-attributes.d.ts +68 -0
  51. package/types/utils/jsx-attributes.d.ts.map +1 -0
@@ -0,0 +1,339 @@
1
+ 'use strict';
2
+
3
+ var MagicString = require('magic-string');
4
+ var keyGenerator = require('./key-generator.js');
5
+
6
+ /**
7
+ * Transforms a source file, replacing candidate strings with instrumented code.
8
+ * Also injects useTranslation() hooks into React function components that
9
+ * contain transformed strings.
10
+ *
11
+ * @param content - Original source code
12
+ * @param file - File path
13
+ * @param candidates - Candidate strings to transform
14
+ * @param options - Transformation options
15
+ * @returns TransformResult with modified content and diff
16
+ */
17
+ function transformFile(content, file, candidates, options) {
18
+ const s = new MagicString(content);
19
+ const errors = [];
20
+ const warnings = [];
21
+ let transformCount = 0;
22
+ const injections = {
23
+ importAdded: false,
24
+ hookInjected: false
25
+ };
26
+ // Filter high-confidence candidates
27
+ const highConfidenceCandidates = candidates.filter(c => c.confidence >= 0.7);
28
+ // Track which components have transformed candidates
29
+ const transformedComponents = new Set();
30
+ let hasComponentCandidates = false;
31
+ let hasNonComponentCandidates = false;
32
+ // ── Language-change site injections ────────────────────────────────────
33
+ const languageChangeSites = options.languageChangeSites || [];
34
+ // Track components that need `i18n` from useTranslation()
35
+ const componentsNeedingI18n = new Set();
36
+ let languageChangeCount = 0;
37
+ // Apply language-change injections in reverse order (to preserve offsets)
38
+ const sortedSites = [...languageChangeSites].sort((a, b) => b.callStart - a.callStart);
39
+ for (const site of sortedSites) {
40
+ try {
41
+ const changeCall = site.insideComponent
42
+ ? `i18n.changeLanguage(${site.languageExpression})`
43
+ : `i18next.changeLanguage(${site.languageExpression})`;
44
+ // Check if the call is the expression body of an arrow function (no braces):
45
+ // () => updateSettings({ language: code })
46
+ // We need to wrap it:
47
+ // () => { i18n.changeLanguage(code); updateSettings({ language: code }); }
48
+ const beforeCall = content.slice(0, site.callStart);
49
+ const arrowMatch = beforeCall.match(/=>\s*$/);
50
+ if (arrowMatch) {
51
+ // Arrow function expression body → wrap in block
52
+ const originalCall = content.slice(site.callStart, site.callEnd);
53
+ s.overwrite(site.callStart, site.callEnd, `{ ${changeCall}; ${originalCall}; }`);
54
+ }
55
+ else {
56
+ // Already in a block → prepend as a statement
57
+ s.appendLeft(site.callStart, `${changeCall}; `);
58
+ }
59
+ languageChangeCount++;
60
+ if (site.insideComponent) {
61
+ componentsNeedingI18n.add(site.insideComponent);
62
+ transformedComponents.add(site.insideComponent);
63
+ hasComponentCandidates = true;
64
+ }
65
+ else {
66
+ hasNonComponentCandidates = true;
67
+ }
68
+ }
69
+ catch (err) {
70
+ errors.push(`Failed to inject changeLanguage at offset ${site.callStart}: ${err}`);
71
+ }
72
+ }
73
+ // Apply transformations in reverse order (to maintain correct offsets)
74
+ for (let i = highConfidenceCandidates.length - 1; i >= 0; i--) {
75
+ const candidate = highConfidenceCandidates[i];
76
+ try {
77
+ const key = candidate.key || keyGenerator.generateKeyFromContent(candidate.content);
78
+ const useHookStyle = !!candidate.insideComponent;
79
+ const defaultNS = options.config.extract?.defaultNS ?? 'translation';
80
+ const nsForReplacement = (options.namespace && options.namespace !== defaultNS) ? options.namespace : undefined;
81
+ const replacement = buildReplacement(candidate, key, useHookStyle, nsForReplacement);
82
+ if (replacement) {
83
+ s.overwrite(candidate.offset, candidate.endOffset, replacement);
84
+ transformCount++;
85
+ if (candidate.insideComponent) {
86
+ transformedComponents.add(candidate.insideComponent);
87
+ hasComponentCandidates = true;
88
+ }
89
+ else {
90
+ hasNonComponentCandidates = true;
91
+ }
92
+ }
93
+ }
94
+ catch (err) {
95
+ errors.push(`Failed to transform candidate at offset ${candidate.offset}: ${err}`);
96
+ }
97
+ }
98
+ // Add necessary imports and hooks if any transformations were made
99
+ if (transformCount > 0 || languageChangeCount > 0) {
100
+ const components = options.components || [];
101
+ // Inject useTranslation() hooks into affected React components
102
+ if (options.hasReact && transformedComponents.size > 0) {
103
+ // Process components in reverse order of bodyStart to avoid offset shifts
104
+ const affectedComponents = components
105
+ .filter(c => transformedComponents.has(c.name) && !c.hasUseTranslation)
106
+ .sort((a, b) => b.bodyStart - a.bodyStart);
107
+ for (const comp of affectedComponents) {
108
+ const indent = detectIndent(content, comp.bodyStart);
109
+ const defaultNS = options.config.extract?.defaultNS ?? 'translation';
110
+ const nsArg = (options.namespace && options.namespace !== defaultNS) ? `'${options.namespace}'` : '';
111
+ // Build destructuring: include `t` if the component has string candidates,
112
+ // include `i18n` if the component has language-change sites.
113
+ const needsT = highConfidenceCandidates.some(c => c.insideComponent === comp.name);
114
+ const needsI18n = componentsNeedingI18n.has(comp.name);
115
+ const parts = [];
116
+ if (needsT)
117
+ parts.push('t');
118
+ if (needsI18n)
119
+ parts.push('i18n');
120
+ if (parts.length === 0)
121
+ parts.push('t'); // fallback
122
+ const destructured = `{ ${parts.join(', ')} }`;
123
+ s.appendRight(comp.bodyStart + 1, `\n${indent}const ${destructured} = useTranslation(${nsArg})`);
124
+ injections.hookInjected = true;
125
+ }
126
+ // For components that already have useTranslation but need i18n added,
127
+ // try to upgrade `const { t } = useTranslation(...)` → `const { t, i18n } = useTranslation(...)`
128
+ if (componentsNeedingI18n.size > 0) {
129
+ const compsWithExistingHook = components.filter(c => componentsNeedingI18n.has(c.name) && c.hasUseTranslation);
130
+ for (const comp of compsWithExistingHook) {
131
+ // Search for the destructuring pattern within the component body
132
+ const bodyText = content.slice(comp.bodyStart, comp.bodyEnd);
133
+ // Skip if i18n is already destructured
134
+ if (/const\s*\{[^}]*\bi18n\b[^}]*\}\s*=\s*useTranslation/.test(bodyText))
135
+ continue;
136
+ // Match `{ t }` or `{ t, ... }` in `const { t } = useTranslation`
137
+ const hookRe = /const\s*\{\s*t\s*\}\s*=\s*useTranslation/;
138
+ const hookMatch = hookRe.exec(bodyText);
139
+ if (hookMatch) {
140
+ const absStart = comp.bodyStart + hookMatch.index;
141
+ const absEnd = absStart + hookMatch[0].length;
142
+ const upgraded = hookMatch[0].replace(/\{\s*t\s*\}/, '{ t, i18n }');
143
+ s.overwrite(absStart, absEnd, upgraded);
144
+ }
145
+ }
146
+ }
147
+ }
148
+ // Add import statements
149
+ addImportStatements(s, content, {
150
+ needsUseTranslation: hasComponentCandidates && options.hasReact,
151
+ needsI18next: hasNonComponentCandidates || !options.hasReact
152
+ });
153
+ injections.importAdded = true;
154
+ }
155
+ // Warn when i18next.t() is used in React projects (translations may not be loaded yet)
156
+ if (options.hasReact && hasNonComponentCandidates && transformCount > 0) {
157
+ warnings.push(`${file}: i18next.t() was added outside of a React component. ` +
158
+ 'If translation resources are loaded asynchronously, i18next.t() may return the key instead of the translation. ' +
159
+ 'Consider moving this text into a component and using the useTranslation() hook, or ensure i18next is fully initialized before this code runs. ' +
160
+ 'See: https://www.locize.com/blog/how-to-use-i18next-t-outside-react-components/');
161
+ }
162
+ const newContent = s.toString();
163
+ const diff = generateDiff(content, newContent, file);
164
+ const totalChanges = transformCount + languageChangeCount;
165
+ return {
166
+ modified: totalChanges > 0,
167
+ newContent: !options.isDryRun ? newContent : undefined,
168
+ diff,
169
+ errors,
170
+ warnings,
171
+ transformCount,
172
+ languageChangeCount,
173
+ injections
174
+ };
175
+ }
176
+ /**
177
+ * Builds the replacement code for a candidate string.
178
+ *
179
+ * @param candidate - The candidate to replace
180
+ * @param key - The i18n key to use
181
+ * @param useHookStyle - If true, uses t() (from useTranslation hook); otherwise uses i18next.t()
182
+ * @param namespace - Optional namespace (only set when different from defaultNS)
183
+ */
184
+ function buildReplacement(candidate, key, useHookStyle, namespace) {
185
+ const tFunc = useHookStyle ? 't' : 'i18next.t';
186
+ const escapedContent = escapeString(candidate.content);
187
+ // ── Plural form: t('key', { defaultValue_zero: '…', …, count: expr }) ──
188
+ if (candidate.pluralForms) {
189
+ const pf = candidate.pluralForms;
190
+ const optionEntries = [];
191
+ if (pf.zero !== undefined) {
192
+ optionEntries.push(`defaultValue_zero: '${escapeString(pf.zero)}'`);
193
+ }
194
+ if (pf.one !== undefined) {
195
+ optionEntries.push(`defaultValue_one: '${escapeString(pf.one)}'`);
196
+ }
197
+ optionEntries.push(`defaultValue_other: '${escapeString(pf.other)}'`);
198
+ optionEntries.push(`count: ${pf.countExpression}`);
199
+ if (!useHookStyle && namespace) {
200
+ optionEntries.push(`ns: '${namespace}'`);
201
+ }
202
+ const tCall = `${tFunc}('${key}', { ${optionEntries.join(', ')} })`;
203
+ switch (candidate.type) {
204
+ case 'jsx-text':
205
+ case 'jsx-attribute':
206
+ return `{${tCall}}`;
207
+ default:
208
+ return tCall;
209
+ }
210
+ }
211
+ // Build the optional third argument: { interpolationVars..., ns? }
212
+ const optionEntries = [];
213
+ if (candidate.interpolations?.length) {
214
+ for (const interp of candidate.interpolations) {
215
+ optionEntries.push(interp.name === interp.expression ? interp.name : `${interp.name}: ${interp.expression}`);
216
+ }
217
+ }
218
+ if (!useHookStyle && namespace) {
219
+ optionEntries.push(`ns: '${namespace}'`);
220
+ }
221
+ const optionsArg = optionEntries.length > 0 ? `, { ${optionEntries.join(', ')} }` : '';
222
+ const tCall = `${tFunc}('${key}', '${escapedContent}'${optionsArg})`;
223
+ switch (candidate.type) {
224
+ case 'jsx-text':
225
+ case 'jsx-attribute':
226
+ return `{${tCall}}`;
227
+ case 'jsx-mixed':
228
+ if (useHookStyle) {
229
+ return `<Trans i18nKey="${key}">${candidate.content}</Trans>`;
230
+ }
231
+ return candidate.content;
232
+ case 'template-literal':
233
+ case 'string-literal':
234
+ default:
235
+ return tCall;
236
+ }
237
+ }
238
+ /**
239
+ * Escapes a string for use in generated code.
240
+ */
241
+ function escapeString(str) {
242
+ return str
243
+ .replace(/\\/g, '\\\\') // Escape backslashes first
244
+ .replace(/'/g, "\\'") // Escape single quotes
245
+ .replace(/\n/g, '\\n') // Escape newlines
246
+ .replace(/\r/g, '\\r') // Escape carriage returns
247
+ .replace(/\t/g, '\\t'); // Escape tabs
248
+ }
249
+ /**
250
+ * Checks if an import statement for a module already exists.
251
+ */
252
+ function hasImport(content, moduleName) {
253
+ const importRegex = new RegExp(`import\\s+.*from\\s+['"]${moduleName}['"]`, 'g');
254
+ const requireRegex = new RegExp(`require\\(['"]${moduleName}['"]\\)`, 'g');
255
+ return importRegex.test(content) || requireRegex.test(content);
256
+ }
257
+ /**
258
+ * Detects the indentation level used after an opening brace.
259
+ * Skips blank lines and reads the whitespace of the first line that has actual content.
260
+ */
261
+ function detectIndent(content, braceOffset) {
262
+ let searchFrom = braceOffset;
263
+ while (searchFrom < content.length) {
264
+ const newlinePos = content.indexOf('\n', searchFrom);
265
+ if (newlinePos === -1)
266
+ return ' ';
267
+ let indent = '';
268
+ let i = newlinePos + 1;
269
+ while (i < content.length && (content[i] === ' ' || content[i] === '\t')) {
270
+ indent += content[i];
271
+ i++;
272
+ }
273
+ // If this line has actual content (not just empty / blank), use its indentation
274
+ if (i < content.length && content[i] !== '\n' && content[i] !== '\r') {
275
+ return indent || ' ';
276
+ }
277
+ // Empty line — continue looking at the next line
278
+ searchFrom = i;
279
+ }
280
+ return ' ';
281
+ }
282
+ /**
283
+ * Adds necessary import statements (useTranslation and/or i18next).
284
+ */
285
+ function addImportStatements(s, content, needs) {
286
+ let importStatement = '';
287
+ if (needs.needsUseTranslation && !hasImport(content, 'react-i18next')) {
288
+ importStatement += "import { useTranslation } from 'react-i18next'\n";
289
+ }
290
+ if (needs.needsI18next && !hasImport(content, 'i18next')) {
291
+ importStatement += "import i18next from 'i18next'\n";
292
+ }
293
+ if (!importStatement)
294
+ return;
295
+ // Insert after the last existing import statement, or at the top of the file
296
+ let insertPos = 0;
297
+ // Skip shebang if present
298
+ if (content.startsWith('#!')) {
299
+ insertPos = content.indexOf('\n') + 1;
300
+ }
301
+ // Find the end of the last import statement
302
+ const importRegex = /^import\s.+$/gm;
303
+ let match;
304
+ while ((match = importRegex.exec(content)) !== null) {
305
+ const endOfImport = match.index + match[0].length;
306
+ if (endOfImport > insertPos) {
307
+ const nextNewline = content.indexOf('\n', endOfImport);
308
+ insertPos = nextNewline !== -1 ? nextNewline + 1 : endOfImport;
309
+ }
310
+ }
311
+ s.appendRight(insertPos, importStatement);
312
+ }
313
+ /**
314
+ * Generates a unified diff showing what changed.
315
+ */
316
+ function generateDiff(original, modified, filePath) {
317
+ if (original === modified) {
318
+ return '';
319
+ }
320
+ const originalLines = original.split('\n');
321
+ const modifiedLines = modified.split('\n');
322
+ let diff = `--- a/${filePath}\n`;
323
+ diff += `+++ b/${filePath}\n`;
324
+ // Simple line-by-line diff
325
+ for (let i = 0; i < Math.max(originalLines.length, modifiedLines.length); i++) {
326
+ if (originalLines[i] !== modifiedLines[i]) {
327
+ if (originalLines[i] !== undefined) {
328
+ diff += `-${originalLines[i]}\n`;
329
+ }
330
+ if (modifiedLines[i] !== undefined) {
331
+ diff += `+${modifiedLines[i]}\n`;
332
+ }
333
+ }
334
+ }
335
+ return diff;
336
+ }
337
+
338
+ exports.generateDiff = generateDiff;
339
+ exports.transformFile = transformFile;
@@ -8,6 +8,7 @@ var node_events = require('node:events');
8
8
  var node_util = require('node:util');
9
9
  var logger = require('./utils/logger.js');
10
10
  var wrapOra = require('./utils/wrap-ora.js');
11
+ var jsxAttributes = require('./utils/jsx-attributes.js');
11
12
 
12
13
  /**
13
14
  * Loads all translation values from the primary locale's JSON files and returns
@@ -74,8 +75,8 @@ async function loadPrimaryTranslationValues(config) {
74
75
  function extractInterpolationKeys(str, config) {
75
76
  const prefix = config.extract.interpolationPrefix ?? '{{';
76
77
  const suffix = config.extract.interpolationSuffix ?? '}}';
77
- // Regex to match {{key}}
78
- const regex = new RegExp(`${prefix.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\\s*([\\w.-]+)\\s*${suffix.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}`, 'g');
78
+ // Regex to match {{key}} or {{key, format}} (i18next formatting syntax)
79
+ const regex = new RegExp(`${prefix.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\\s*([\\w.-]+)\\s*(?:,[^}]*)?${suffix.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}`, 'g');
79
80
  const keys = [];
80
81
  let match;
81
82
  while ((match = regex.exec(str))) {
@@ -246,12 +247,10 @@ function lintInterpolationParams(ast, code, config, translationValues) {
246
247
  }
247
248
  return issues;
248
249
  }
249
- const recommendedAcceptedTags = [
250
- 'a', 'abbr', 'address', 'article', 'aside', 'bdi', 'bdo', 'blockquote', 'button', 'caption', 'cite', 'code', 'data', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dt', 'em', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'img', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'mark', 'nav', 'option', 'output', 'p', 'pre', 'q', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary', 'sup', 'td', 'textarea', 'th', 'time', 'title', 'var'
251
- ].map(s => s.toLowerCase());
252
- const recommendedAcceptedAttributes = ['abbr', 'accesskey', 'alt', 'aria-description', 'aria-label', 'aria-placeholder', 'aria-roledescription', 'aria-valuetext', 'content', 'label', 'placeholder', 'summary', 'title'].map(s => s.toLowerCase());
253
- const defaultIgnoredAttributes = ['className', 'key', 'id', 'style', 'href', 'i18nKey', 'defaults', 'type', 'target'].map(s => s.toLowerCase());
254
- const defaultIgnoredTags = ['script', 'style', 'code'];
250
+ const recommendedAcceptedTags = jsxAttributes.acceptedTags.map(s => s.toLowerCase());
251
+ const recommendedAcceptedAttributes = jsxAttributes.translatableAttributes.map(s => s.toLowerCase());
252
+ const defaultIgnoredAttributes = jsxAttributes.ignoredAttributeLowerSet;
253
+ const defaultIgnoredTags = jsxAttributes.ignoredTags;
255
254
  class Linter extends node_events.EventEmitter {
256
255
  config;
257
256
  logger;
@@ -566,7 +565,7 @@ function findHardcodedStrings(ast, code, config) {
566
565
  };
567
566
  const transComponents = (config.extract.transComponents || ['Trans']).map((s) => s.toLowerCase());
568
567
  const customIgnoredTags = (config?.lint?.ignoredTags || config.extract.ignoredTags || []).map((s) => s.toLowerCase());
569
- const allIgnoredTags = new Set([...transComponents, ...defaultIgnoredTags.map(s => s.toLowerCase()), ...customIgnoredTags]);
568
+ const allIgnoredTags = new Set([...transComponents, ...defaultIgnoredTags.map((s) => s.toLowerCase()), ...customIgnoredTags]);
570
569
  const customIgnoredAttributes = (config?.lint?.ignoredAttributes || config.extract.ignoredAttributes || []).map((s) => s.toLowerCase());
571
570
  const ignoredAttributes = new Set([...defaultIgnoredAttributes, ...customIgnoredAttributes]);
572
571
  const lintAcceptedTags = config?.lint?.acceptedTags ? config.lint.acceptedTags : null;
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared constants for JSX / HTML attribute classification.
5
+ *
6
+ * Used by both the **linter** and the **instrumenter** to consistently decide
7
+ * which JSX attribute values are user-facing (translatable) and which are
8
+ * technical / non-translatable.
9
+ *
10
+ * Having a single source of truth avoids drift between the linter's
11
+ * `defaultIgnoredAttributes` / `recommendedAcceptedAttributes` and the
12
+ * instrumenter's `SKIP_JSX_ATTRIBUTES` / `TRANSLATABLE_ATTRIBUTES`.
13
+ */
14
+ // ────────────────────────────────────────────────────────────────────────
15
+ // Translatable attributes
16
+ // ────────────────────────────────────────────────────────────────────────
17
+ /**
18
+ * JSX/HTML attribute names whose values are typically user-visible and
19
+ * should be translated.
20
+ *
21
+ * This is the recommended accepted-list for the linter **and** the set used
22
+ * by the instrumenter's string-detector to allow attribute values through.
23
+ *
24
+ * Exported from the public API as `recommendedAcceptedAttributes`.
25
+ */
26
+ const translatableAttributes = [
27
+ 'abbr', 'accesskey', 'alt',
28
+ 'aria-description', 'aria-label', 'aria-placeholder',
29
+ 'aria-roledescription', 'aria-valuetext',
30
+ 'content', 'description',
31
+ 'label', 'placeholder',
32
+ 'summary', 'caption', 'title'
33
+ ];
34
+ /**
35
+ * Pre-built Set (lower-cased) for fast membership checks in hot loops.
36
+ */
37
+ const translatableAttributeSet = new Set(translatableAttributes.map(s => s.toLowerCase()));
38
+ // ────────────────────────────────────────────────────────────────────────
39
+ // Non-translatable (ignored) attributes
40
+ // ────────────────────────────────────────────────────────────────────────
41
+ /**
42
+ * JSX attribute names whose values are **never** user-facing.
43
+ *
44
+ * The linter uses these as `defaultIgnoredAttributes`, and the instrumenter
45
+ * skips recursing into them entirely so that e.g. `className={...}` is
46
+ * never wrapped in `t()`.
47
+ *
48
+ * Event-handler attributes (on*) are handled separately via a prefix check
49
+ * rather than being enumerated here, but a representative set is included
50
+ * for the instrumenter's early-exit guard which does a Set lookup.
51
+ */
52
+ const ignoredAttributes = [
53
+ // CSS / styling
54
+ 'className', 'class', 'style',
55
+ // Identity / keys
56
+ 'key', 'id', 'htmlFor', 'for', 'name',
57
+ // Links & resources
58
+ 'href', 'src', 'srcSet', 'action',
59
+ // HTML form / behaviour
60
+ 'type', 'target', 'rel', 'role', 'method', 'encType',
61
+ 'autoComplete', 'autoFocus', 'tabIndex',
62
+ // Testing
63
+ 'data-testid', 'data-cy',
64
+ // i18next-specific (already instrumented / extractor config)
65
+ 'i18nKey', 'defaults', 'ns', 'defaultValue',
66
+ // Event handlers (representative set — the instrumenter also does a
67
+ // prefix check for `on[A-Z]`)
68
+ 'onChange', 'onClick', 'onSubmit', 'onFocus', 'onBlur',
69
+ 'onKeyDown', 'onKeyUp', 'onMouseEnter', 'onMouseLeave',
70
+ // React internals
71
+ 'ref', 'dangerouslySetInnerHTML', 'suppressHydrationWarning'
72
+ ];
73
+ /**
74
+ * Pre-built Set for fast membership checks.
75
+ * Values are stored in their original casing — the instrumenter checks the
76
+ * raw SWC attribute name. The linter lower-cases before lookup.
77
+ */
78
+ const ignoredAttributeSet = new Set(ignoredAttributes);
79
+ /**
80
+ * Same set, lower-cased — used by the linter which normalises attr names.
81
+ */
82
+ const ignoredAttributeLowerSet = new Set(ignoredAttributes.map(s => s.toLowerCase()));
83
+ // ────────────────────────────────────────────────────────────────────────
84
+ // Translatable object properties (instrumenter-specific)
85
+ // ────────────────────────────────────────────────────────────────────────
86
+ /**
87
+ * Object / JSON property names whose values are typically user-visible and
88
+ * should be translated. Used by the instrumenter's string-detector to give
89
+ * a confidence boost.
90
+ */
91
+ const translatableProperties = [
92
+ 'label', 'title', 'description', 'text', 'message', 'placeholder',
93
+ 'caption', 'summary', 'heading', 'subheading', 'subtitle', 'tooltip',
94
+ 'hint', 'helpText', 'errorMessage', 'successMessage', 'name'
95
+ ];
96
+ const translatablePropertySet = new Set(translatableProperties);
97
+ // ────────────────────────────────────────────────────────────────────────
98
+ // Ignored tags (linter + potential future instrumenter use)
99
+ // ────────────────────────────────────────────────────────────────────────
100
+ /**
101
+ * HTML/JSX tags whose content should be ignored when linting for hardcoded
102
+ * strings (e.g. `<script>`, `<style>`, `<code>`).
103
+ */
104
+ const ignoredTags = ['script', 'style', 'code'];
105
+ /**
106
+ * Recommended accepted tags — the set of tags the linter considers as
107
+ * potentially containing translatable content.
108
+ *
109
+ * Exported from the public API as `recommendedAcceptedTags`.
110
+ */
111
+ const acceptedTags = [
112
+ 'a', 'abbr', 'address', 'article', 'aside', 'bdi', 'bdo', 'blockquote',
113
+ 'button', 'caption', 'cite', 'code', 'data', 'dd', 'del', 'details',
114
+ 'dfn', 'dialog', 'div', 'dt', 'em', 'figcaption', 'footer',
115
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
116
+ 'img', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'mark', 'nav',
117
+ 'option', 'output', 'p', 'pre', 'q', 's', 'samp', 'section', 'small',
118
+ 'span', 'strong', 'sub', 'summary', 'sup', 'td', 'textarea', 'th',
119
+ 'time', 'title', 'var'
120
+ ];
121
+ new Set(acceptedTags.map(s => s.toLowerCase()));
122
+
123
+ exports.acceptedTags = acceptedTags;
124
+ exports.ignoredAttributeLowerSet = ignoredAttributeLowerSet;
125
+ exports.ignoredAttributeSet = ignoredAttributeSet;
126
+ exports.ignoredAttributes = ignoredAttributes;
127
+ exports.ignoredTags = ignoredTags;
128
+ exports.translatableAttributeSet = translatableAttributeSet;
129
+ exports.translatableAttributes = translatableAttributes;
130
+ exports.translatableProperties = translatableProperties;
131
+ exports.translatablePropertySet = translatablePropertySet;
package/dist/esm/cli.js CHANGED
@@ -21,12 +21,15 @@ import { runLinterCli } from './linter.js';
21
21
  import { runStatus } from './status.js';
22
22
  import { runLocizeSync, runLocizeDownload, runLocizeMigrate } from './locize.js';
23
23
  import { runRenameKey } from './rename-key.js';
24
+ import { runInstrumenter } from './instrumenter/core/instrumenter.js';
25
+ import './utils/jsx-attributes.js';
26
+ import 'magic-string';
24
27
 
25
28
  const program = new Command();
26
29
  program
27
30
  .name('i18next-cli')
28
31
  .description('A unified, high-performance i18next CLI.')
29
- .version('1.46.4'); // This string is replaced with the actual version at build time by rollup
32
+ .version('1.47.1'); // This string is replaced with the actual version at build time by rollup
30
33
  // new: global config override option
31
34
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
32
35
  program
@@ -202,6 +205,52 @@ program
202
205
  }
203
206
  }
204
207
  });
208
+ program
209
+ .command('instrument')
210
+ .description('Scan for hardcoded strings and instrument your code with i18next calls.')
211
+ .option('--dry-run', 'Preview changes without writing files to disk.')
212
+ .option('--interactive', 'Prompt for approval of each candidate string.')
213
+ .option('--namespace <ns>', 'Target a specific namespace for extracted keys.')
214
+ .option('-q, --quiet', 'Suppress spinner and output')
215
+ .action(async (options) => {
216
+ try {
217
+ const cfgPath = program.opts().config;
218
+ const config = await ensureConfig(cfgPath);
219
+ const results = await runInstrumenter(config, {
220
+ isDryRun: !!options.dryRun,
221
+ isInteractive: !!options.interactive,
222
+ namespace: options.namespace,
223
+ quiet: !!options.quiet
224
+ });
225
+ // Display results
226
+ if (!options.quiet) {
227
+ console.log(styleText('bold', '\nInstrumentation Summary:'));
228
+ console.log(` Total candidates: ${results.totalCandidates}`);
229
+ console.log(` Approved: ${results.totalTransformed}`);
230
+ console.log(` Skipped: ${results.totalSkipped}`);
231
+ if (results.totalLanguageChanges > 0) {
232
+ console.log(` Language-change sites: ${results.totalLanguageChanges}`);
233
+ }
234
+ if (options.dryRun) {
235
+ console.log(styleText('blue', '\n📋 Dry-run mode enabled. No files were modified.'));
236
+ console.log('Run again without --dry-run to apply changes.');
237
+ }
238
+ if (results.files.length > 0) {
239
+ console.log(styleText('green', `\n✅ ${results.files.length} file(s) ready for instrumentation`));
240
+ }
241
+ else {
242
+ console.log(styleText('yellow', '\n⚠️ No files required instrumentation'));
243
+ }
244
+ if (results.totalTransformed > 0 && !options.dryRun) {
245
+ console.log(styleText('cyan', '\n💡 Next step: run `i18next-cli extract` to extract the translation keys into your locale files.'));
246
+ }
247
+ }
248
+ }
249
+ catch (error) {
250
+ console.error(styleText('red', 'Error running instrument command:'), error);
251
+ process.exit(1);
252
+ }
253
+ });
205
254
  program
206
255
  .command('locize-sync')
207
256
  .description('Synchronize local translations with your Locize project.')
@@ -59,9 +59,13 @@ async function runExtractor(config, options = {}) {
59
59
  logger: options.logger
60
60
  });
61
61
  let anyFileUpdated = false;
62
+ let anyNewFile = false;
62
63
  for (const result of results) {
63
64
  if (result.updated) {
64
65
  anyFileUpdated = true;
66
+ if (Object.keys(result.existingTranslations || {}).length === 0) {
67
+ anyNewFile = true;
68
+ }
65
69
  if (!options.isDryRun) {
66
70
  // prefer explicit outputFormat; otherwise infer from file extension per-file
67
71
  const effectiveFormat = config.extract.outputFormat ?? inferFormatFromPath(result.path);
@@ -87,8 +91,10 @@ async function runExtractor(config, options = {}) {
87
91
  : styleText('bold', 'Extraction complete!');
88
92
  spinner.succeed(completionMessage);
89
93
  // Show the funnel message only if files were actually changed.
90
- if (anyFileUpdated)
91
- await printLocizeFunnel(options.logger);
94
+ // When new translation files are created (new namespace or first extraction),
95
+ // always show the funnel regardless of cooldown.
96
+ if (anyFileUpdated && !options.isDryRun)
97
+ await printLocizeFunnel(options.logger, anyNewFile);
92
98
  return { anyFileUpdated, hasErrors: fileErrors.length > 0 };
93
99
  }
94
100
  catch (error) {
@@ -249,24 +255,27 @@ async function extract(config, { syncPrimaryWithDefaults = false } = {}) {
249
255
  * Prints a promotional message for the locize saveMissing workflow.
250
256
  * This message is shown after a successful extraction that resulted in changes.
251
257
  */
252
- async function printLocizeFunnel(logger) {
253
- if (!(await shouldShowFunnel('extract')))
258
+ async function printLocizeFunnel(logger, force) {
259
+ if (!force && !(await shouldShowFunnel('extract')))
254
260
  return;
255
261
  const internalLogger = logger ?? new ConsoleLogger();
256
- if (typeof internalLogger.info === 'function') {
257
- internalLogger.info(styleText(['yellow', 'bold'], '\n💡 Tip: Tired of running the extractor manually?'));
258
- internalLogger.info(' Discover a real-time "push" workflow with `saveMissing` and Locize AI,');
259
- internalLogger.info(' where keys are created and translated automatically as you code.');
260
- internalLogger.info(` Learn more: ${styleText('cyan', 'https://www.locize.com/blog/i18next-savemissing-ai-automation')}`);
261
- internalLogger.info(` Watch the video: ${styleText('cyan', 'https://youtu.be/joPsZghT3wM')}`);
262
- }
263
- else {
264
- console.log(styleText(['yellow', 'bold'], '\n💡 Tip: Tired of running the extractor manually?'));
265
- console.log(' Discover a real-time "push" workflow with `saveMissing` and Locize AI,');
266
- console.log(' where keys are created and translated automatically as you code.');
267
- console.log(` Learn more: ${styleText('cyan', 'https://www.locize.com/blog/i18next-savemissing-ai-automation')}`);
268
- console.log(` Watch the video: ${styleText('cyan', 'https://youtu.be/joPsZghT3wM')}`);
269
- }
262
+ const lines = [
263
+ styleText(['yellow', 'bold'], '\n💡 Tip: Tired of running the extractor manually?'),
264
+ ' Discover a real-time "push" workflow with `saveMissing` and Locize AI/MT,',
265
+ ' where keys are created and translated automatically as you code.',
266
+ ` Learn more: ${styleText('cyan', 'https://www.locize.com/blog/i18next-savemissing-ai-automation')}`,
267
+ ` Watch the video: ${styleText('cyan', 'https://youtu.be/joPsZghT3wM')}`,
268
+ '',
269
+ ' You can also sync your extracted translations to Locize:',
270
+ ` ${styleText('cyan', 'npx i18next-cli locize-sync')} – upload/sync translations to Locize`,
271
+ ` ${styleText('cyan', 'npx i18next-cli locize-migrate')} – migrate local translations to Locize`,
272
+ ' Or import them manually via the Locize UI, API, or locize-cli.',
273
+ ];
274
+ const log = typeof internalLogger.info === 'function'
275
+ ? (msg) => internalLogger.info(msg)
276
+ : (msg) => console.log(msg);
277
+ for (const line of lines)
278
+ log(line);
270
279
  return recordFunnelShown('extract');
271
280
  }
272
281