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.
- package/README.md +198 -3
- package/dist/cjs/cli.js +50 -1
- package/dist/cjs/extractor/core/extractor.js +27 -18
- package/dist/cjs/extractor/core/translation-manager.js +10 -3
- package/dist/cjs/extractor/parsers/call-expression-handler.js +9 -1
- package/dist/cjs/extractor/parsers/jsx-handler.js +10 -1
- package/dist/cjs/index.js +5 -0
- package/dist/cjs/init.js +68 -12
- package/dist/cjs/instrumenter/core/instrumenter.js +1633 -0
- package/dist/cjs/instrumenter/core/key-generator.js +71 -0
- package/dist/cjs/instrumenter/core/string-detector.js +290 -0
- package/dist/cjs/instrumenter/core/transformer.js +339 -0
- package/dist/cjs/linter.js +8 -9
- package/dist/cjs/utils/jsx-attributes.js +131 -0
- package/dist/esm/cli.js +50 -1
- package/dist/esm/extractor/core/extractor.js +27 -18
- package/dist/esm/extractor/core/translation-manager.js +10 -3
- package/dist/esm/extractor/parsers/call-expression-handler.js +9 -1
- package/dist/esm/extractor/parsers/jsx-handler.js +10 -1
- package/dist/esm/index.js +3 -0
- package/dist/esm/init.js +68 -12
- package/dist/esm/instrumenter/core/instrumenter.js +1630 -0
- package/dist/esm/instrumenter/core/key-generator.js +68 -0
- package/dist/esm/instrumenter/core/string-detector.js +288 -0
- package/dist/esm/instrumenter/core/transformer.js +336 -0
- package/dist/esm/linter.js +8 -9
- package/dist/esm/utils/jsx-attributes.js +121 -0
- package/package.json +2 -1
- package/types/cli.d.ts.map +1 -1
- package/types/extractor/core/extractor.d.ts.map +1 -1
- package/types/extractor/core/translation-manager.d.ts.map +1 -1
- package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
- package/types/extractor/parsers/jsx-handler.d.ts.map +1 -1
- package/types/index.d.ts +2 -1
- package/types/index.d.ts.map +1 -1
- package/types/init.d.ts.map +1 -1
- package/types/instrumenter/core/instrumenter.d.ts +16 -0
- package/types/instrumenter/core/instrumenter.d.ts.map +1 -0
- package/types/instrumenter/core/key-generator.d.ts +30 -0
- package/types/instrumenter/core/key-generator.d.ts.map +1 -0
- package/types/instrumenter/core/string-detector.d.ts +27 -0
- package/types/instrumenter/core/string-detector.d.ts.map +1 -0
- package/types/instrumenter/core/transformer.d.ts +31 -0
- package/types/instrumenter/core/transformer.d.ts.map +1 -0
- package/types/instrumenter/index.d.ts +6 -0
- package/types/instrumenter/index.d.ts.map +1 -0
- package/types/linter.d.ts.map +1 -1
- package/types/types.d.ts +285 -1
- package/types/types.d.ts.map +1 -1
- package/types/utils/jsx-attributes.d.ts +68 -0
- 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;
|
package/dist/cjs/linter.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
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.
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|