i18next-cli 1.54.2 → 1.56.0
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 +10 -3
- package/dist/cjs/cli.js +66 -2
- package/dist/cjs/extractor/core/ast-visitors.js +11 -1
- package/dist/cjs/extractor/core/extractor.js +1 -1
- package/dist/cjs/extractor/core/translation-manager.js +127 -54
- package/dist/cjs/extractor/parsers/call-expression-handler.js +17 -93
- package/dist/cjs/extractor/parsers/expression-resolver.js +96 -11
- package/dist/cjs/status.js +73 -12
- package/dist/cjs/utils/context-variants.js +59 -0
- package/dist/cjs/utils/nesting.js +100 -0
- package/dist/esm/cli.js +66 -2
- package/dist/esm/extractor/core/ast-visitors.js +11 -1
- package/dist/esm/extractor/core/extractor.js +1 -1
- package/dist/esm/extractor/core/translation-manager.js +126 -53
- package/dist/esm/extractor/parsers/call-expression-handler.js +17 -93
- package/dist/esm/extractor/parsers/expression-resolver.js +96 -11
- package/dist/esm/status.js +74 -13
- package/dist/esm/utils/context-variants.js +57 -0
- package/dist/esm/utils/nesting.js +98 -0
- package/package.json +1 -1
- package/types/cli.d.ts.map +1 -1
- package/types/extractor/core/ast-visitors.d.ts.map +1 -1
- package/types/extractor/core/extractor.d.ts +1 -0
- 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 +3 -2
- package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
- package/types/extractor/parsers/expression-resolver.d.ts +11 -0
- package/types/extractor/parsers/expression-resolver.d.ts.map +1 -1
- package/types/status.d.ts.map +1 -1
- package/types/utils/context-variants.d.ts +22 -0
- package/types/utils/context-variants.d.ts.map +1 -0
- package/types/utils/nesting.d.ts +36 -0
- package/types/utils/nesting.d.ts.map +1 -0
package/README.md
CHANGED
|
@@ -607,9 +607,12 @@ export default defineConfig({
|
|
|
607
607
|
],
|
|
608
608
|
|
|
609
609
|
/**
|
|
610
|
-
* When true, preserves all context variants of keys that use context parameters
|
|
611
|
-
* For example, if 'friend' is used with context
|
|
612
|
-
*
|
|
610
|
+
* When true, preserves all context variants of keys that use context parameters,
|
|
611
|
+
* across every configured locale. For example, if 'friend' is used with a context
|
|
612
|
+
* option in source code, variants like 'friend_male' and 'friend_female' are kept
|
|
613
|
+
* in the primary language even when they're not referenced explicitly, and are
|
|
614
|
+
* propagated to secondary locales with empty placeholders so every language ends
|
|
615
|
+
* up with the same key skeleton.
|
|
613
616
|
* (default: false)
|
|
614
617
|
*/
|
|
615
618
|
preserveContextVariants: false,
|
|
@@ -661,6 +664,10 @@ export default defineConfig({
|
|
|
661
664
|
|
|
662
665
|
// Prefix for nested translations.
|
|
663
666
|
// Controls how nested $t(...) calls inside strings are detected.
|
|
667
|
+
// Nested references are scanned in BOTH source code (keys and defaultValues
|
|
668
|
+
// passed to t()) and in the values of existing translation files, so keys
|
|
669
|
+
// reachable only via `$t(...)` inside a translation value are preserved by
|
|
670
|
+
// `extract` and expanded into the correct per-locale plural skeleton.
|
|
664
671
|
// Example: '$t('
|
|
665
672
|
nestingPrefix: '$t(', // Default: '$t('
|
|
666
673
|
|
package/dist/cjs/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ var heuristicConfig = require('./heuristic-config.js');
|
|
|
11
11
|
var extractor = require('./extractor/core/extractor.js');
|
|
12
12
|
require('./extractor/parsers/jsx-parser.js');
|
|
13
13
|
require('node:path');
|
|
14
|
+
var nestedObject = require('./utils/nested-object.js');
|
|
14
15
|
require('node:fs/promises');
|
|
15
16
|
require('jiti');
|
|
16
17
|
require('@croct/json5-parser');
|
|
@@ -31,7 +32,7 @@ const program = new commander.Command();
|
|
|
31
32
|
program
|
|
32
33
|
.name('i18next-cli')
|
|
33
34
|
.description('A unified, high-performance i18next CLI.')
|
|
34
|
-
.version('1.
|
|
35
|
+
.version('1.56.0'); // This string is replaced with the actual version at build time by rollup
|
|
35
36
|
// new: global config override option
|
|
36
37
|
program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
|
|
37
38
|
program
|
|
@@ -51,7 +52,7 @@ program
|
|
|
51
52
|
const runExtract = async () => {
|
|
52
53
|
// --sync-all implies sync-primary behavior
|
|
53
54
|
const syncPrimary = !!options.syncPrimary || !!options.syncAll;
|
|
54
|
-
const { anyFileUpdated, hasErrors } = await extractor.runExtractor(config$1, {
|
|
55
|
+
const { anyFileUpdated, hasErrors, results } = await extractor.runExtractor(config$1, {
|
|
55
56
|
isWatchMode: !!options.watch,
|
|
56
57
|
isDryRun: !!options.dryRun,
|
|
57
58
|
syncPrimaryWithDefaults: syncPrimary,
|
|
@@ -65,6 +66,7 @@ program
|
|
|
65
66
|
}
|
|
66
67
|
else if (options.ci && anyFileUpdated) {
|
|
67
68
|
console.error('❌ Some files were updated. This should not happen in CI mode.');
|
|
69
|
+
printCiDiff(results, config$1);
|
|
68
70
|
process.exit(1);
|
|
69
71
|
}
|
|
70
72
|
if (hasErrors && !options.watch) {
|
|
@@ -328,5 +330,67 @@ const expandGlobs = async (patterns = []) => {
|
|
|
328
330
|
const sets = await Promise.all(arr.map(p => glob.glob(p || '', { nodir: true })));
|
|
329
331
|
return Array.from(new Set(sets.flat()));
|
|
330
332
|
};
|
|
333
|
+
function printCiDiff(results, config) {
|
|
334
|
+
const rawSep = config.extract.keySeparator;
|
|
335
|
+
const keySeparator = rawSep === false ? false : (rawSep ?? '.');
|
|
336
|
+
for (const result of results) {
|
|
337
|
+
if (!result.updated)
|
|
338
|
+
continue;
|
|
339
|
+
const existing = result.existingTranslations || {};
|
|
340
|
+
const next = result.newTranslations || {};
|
|
341
|
+
const oldKeys = new Set(nestedObject.getNestedKeys(existing, keySeparator));
|
|
342
|
+
const newKeys = new Set(nestedObject.getNestedKeys(next, keySeparator));
|
|
343
|
+
const added = [];
|
|
344
|
+
const removed = [];
|
|
345
|
+
const changed = [];
|
|
346
|
+
for (const k of newKeys) {
|
|
347
|
+
if (!oldKeys.has(k)) {
|
|
348
|
+
added.push(k);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
const oldVal = nestedObject.getNestedValue(existing, k, keySeparator);
|
|
352
|
+
const newVal = nestedObject.getNestedValue(next, k, keySeparator);
|
|
353
|
+
if (oldVal !== newVal)
|
|
354
|
+
changed.push(k);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const k of oldKeys) {
|
|
358
|
+
if (!newKeys.has(k))
|
|
359
|
+
removed.push(k);
|
|
360
|
+
}
|
|
361
|
+
const nsLabel = result.namespace
|
|
362
|
+
? ` [${result.locale}/${result.namespace}]`
|
|
363
|
+
: ` [${result.locale}]`;
|
|
364
|
+
console.error(`\n ${result.path}${nsLabel}`);
|
|
365
|
+
if (added.length === 0 && removed.length === 0 && changed.length === 0) {
|
|
366
|
+
console.error(' (no key differences — only formatting or ordering changes)');
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
added.sort();
|
|
370
|
+
removed.sort();
|
|
371
|
+
changed.sort();
|
|
372
|
+
for (const k of added) {
|
|
373
|
+
const v = nestedObject.getNestedValue(next, k, keySeparator);
|
|
374
|
+
console.error(node_util.styleText('green', ` + ${k}: ${formatCiDiffValue(v)}`));
|
|
375
|
+
}
|
|
376
|
+
for (const k of removed) {
|
|
377
|
+
const v = nestedObject.getNestedValue(existing, k, keySeparator);
|
|
378
|
+
console.error(node_util.styleText('red', ` - ${k}: ${formatCiDiffValue(v)}`));
|
|
379
|
+
}
|
|
380
|
+
for (const k of changed) {
|
|
381
|
+
const oldV = nestedObject.getNestedValue(existing, k, keySeparator);
|
|
382
|
+
const newV = nestedObject.getNestedValue(next, k, keySeparator);
|
|
383
|
+
console.error(node_util.styleText('yellow', ` ~ ${k}: ${formatCiDiffValue(oldV)} → ${formatCiDiffValue(newV)}`));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function formatCiDiffValue(value) {
|
|
388
|
+
try {
|
|
389
|
+
return JSON.stringify(value);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return String(value);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
331
395
|
|
|
332
396
|
exports.program = program;
|
|
@@ -96,6 +96,16 @@ class ASTVisitors {
|
|
|
96
96
|
this.scopeManager.handleVariableDeclarator(node);
|
|
97
97
|
this.expressionResolver.captureVariableDeclarator(node);
|
|
98
98
|
break;
|
|
99
|
+
case 'TSEnumDeclaration':
|
|
100
|
+
case 'TsEnumDeclaration':
|
|
101
|
+
case 'TsEnumDecl':
|
|
102
|
+
// Enums → ExpressionResolver.sharedEnumTable. Needed in pre-scan so
|
|
103
|
+
// that function bodies referencing enum members (e.g. `return
|
|
104
|
+
// OrganizationType.ROUTING`) can be resolved by the body-inference
|
|
105
|
+
// branch of captureFunctionDeclaration when we hit the function later
|
|
106
|
+
// in the same file.
|
|
107
|
+
this.expressionResolver.captureEnumDeclaration(node);
|
|
108
|
+
break;
|
|
99
109
|
case 'TsTypeAliasDeclaration':
|
|
100
110
|
case 'TSTypeAliasDeclaration':
|
|
101
111
|
case 'TsTypeAliasDecl':
|
|
@@ -104,7 +114,7 @@ class ASTVisitors {
|
|
|
104
114
|
break;
|
|
105
115
|
case 'FunctionDeclaration':
|
|
106
116
|
case 'FnDecl':
|
|
107
|
-
// Return-type annotations for t(fn()) patterns
|
|
117
|
+
// Return-type annotations or inferred return values for t(fn()) patterns
|
|
108
118
|
this.expressionResolver.captureFunctionDeclaration(node);
|
|
109
119
|
break;
|
|
110
120
|
}
|
|
@@ -99,7 +99,7 @@ async function runExtractor(config, options = {}) {
|
|
|
99
99
|
// always show the funnel regardless of cooldown.
|
|
100
100
|
if (anyFileUpdated && !options.isDryRun && !options.quiet)
|
|
101
101
|
await printLocizeFunnel(options.logger, anyNewFile);
|
|
102
|
-
return { anyFileUpdated, hasErrors: fileErrors.length > 0 };
|
|
102
|
+
return { anyFileUpdated, hasErrors: fileErrors.length > 0, results };
|
|
103
103
|
}
|
|
104
104
|
catch (error) {
|
|
105
105
|
spinner.fail(node_util.styleText('red', 'Extraction failed.'));
|
|
@@ -7,6 +7,8 @@ var fileUtils = require('../../utils/file-utils.js');
|
|
|
7
7
|
var defaultValue = require('../../utils/default-value.js');
|
|
8
8
|
var logger = require('../../utils/logger.js');
|
|
9
9
|
var pluralRules = require('../../utils/plural-rules.js');
|
|
10
|
+
var nesting = require('../../utils/nesting.js');
|
|
11
|
+
var contextVariants = require('../../utils/context-variants.js');
|
|
10
12
|
|
|
11
13
|
// used for natural language check
|
|
12
14
|
const chars = [' ', ',', '?', '!', ';'];
|
|
@@ -21,58 +23,6 @@ function globToRegex(glob) {
|
|
|
21
23
|
const regexString = `^${escaped.replace(/\*/g, '.*')}$`;
|
|
22
24
|
return new RegExp(regexString);
|
|
23
25
|
}
|
|
24
|
-
/**
|
|
25
|
-
* Checks if an existing key is a context variant of a base key that accepts context.
|
|
26
|
-
* This function handles complex cases where:
|
|
27
|
-
* - The key might have plural suffixes (_one, _other, etc.)
|
|
28
|
-
* - The context value itself might contain the separator (e.g., mc_laren)
|
|
29
|
-
*
|
|
30
|
-
* @param existingKey - The key from the translation file to check
|
|
31
|
-
* @param keysAcceptingContext - Set of base keys that were used with context in source code
|
|
32
|
-
* @param pluralSeparator - The separator used for plural forms (default: '_')
|
|
33
|
-
* @param contextSeparator - The separator used for context variants (default: '_')
|
|
34
|
-
* @returns true if the existing key is a context variant of a key accepting context
|
|
35
|
-
*/
|
|
36
|
-
function isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator) {
|
|
37
|
-
if (keysAcceptingContext.size === 0) {
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
// Try to extract the base key from this existing key by removing context and/or plural suffixes
|
|
41
|
-
let potentialBaseKey = existingKey;
|
|
42
|
-
// First, try removing plural suffixes if present
|
|
43
|
-
for (const form of pluralForms) {
|
|
44
|
-
if (potentialBaseKey.endsWith(`${pluralSeparator}${form}`)) {
|
|
45
|
-
potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + form.length));
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
48
|
-
if (potentialBaseKey.endsWith(`${pluralSeparator}ordinal${pluralSeparator}${form}`)) {
|
|
49
|
-
potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + 'ordinal'.length + pluralSeparator.length + form.length));
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
// Then, try removing the context suffix to get the base key
|
|
54
|
-
// We need to check all possible base keys since the context value itself might contain separators
|
|
55
|
-
// For example: 'formula_one_mc_laren' could be:
|
|
56
|
-
// - base: 'formula_one_mc', context: 'laren'
|
|
57
|
-
// - base: 'formula_one', context: 'mc_laren' ← correct
|
|
58
|
-
// - base: 'formula', context: 'one_mc_laren'
|
|
59
|
-
const parts = potentialBaseKey.split(contextSeparator);
|
|
60
|
-
if (parts.length > 1) {
|
|
61
|
-
// Try removing 1, 2, 3... parts from the end to find a matching base key
|
|
62
|
-
for (let i = 1; i < parts.length; i++) {
|
|
63
|
-
const baseWithoutContext = parts.slice(0, -i).join(contextSeparator);
|
|
64
|
-
if (keysAcceptingContext.has(baseWithoutContext)) {
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
// Also check if the key itself (after removing plural suffix) accepts context
|
|
70
|
-
// This handles cases like 'friend_other' where 'friend' accepts context
|
|
71
|
-
if (keysAcceptingContext.has(potentialBaseKey)) {
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
26
|
/**
|
|
77
27
|
* Checks if a key looks like an object path or natural language.
|
|
78
28
|
* (like in i18next)
|
|
@@ -285,6 +235,100 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
|
|
|
285
235
|
return [...union];
|
|
286
236
|
})()
|
|
287
237
|
: null;
|
|
238
|
+
// Discover keys that are only referenced through `$t(...)` nested references
|
|
239
|
+
// inside existing translation values (see issue #241). These keys are
|
|
240
|
+
// invisible to the AST-based extractor, so without this step they would be
|
|
241
|
+
// deleted when `removeUnusedKeys` is true and never expanded into the plural
|
|
242
|
+
// forms a secondary locale needs.
|
|
243
|
+
//
|
|
244
|
+
// We inject synthetic ExtractedKey entries for each discovered reference so
|
|
245
|
+
// the normal filter / plural-expansion pipeline picks them up — for the
|
|
246
|
+
// primary language this preserves the existing variants, and for secondary
|
|
247
|
+
// languages this generates the correct per-locale plural skeleton.
|
|
248
|
+
const syntheticNestedKeys = [];
|
|
249
|
+
const namespaceMatches = (refNs) => {
|
|
250
|
+
if (namespace === undefined)
|
|
251
|
+
return true;
|
|
252
|
+
// Nested references arrive from parseNestedReferences with `ns` either set
|
|
253
|
+
// from an explicit `ns:key` prefix or defaulted to config.extract.defaultNS.
|
|
254
|
+
// Normalise to the same bucket keys used in `keysByNS`.
|
|
255
|
+
const normalizedRef = refNs === undefined || refNs === null
|
|
256
|
+
? config.extract.defaultNS ?? 'translation'
|
|
257
|
+
: refNs;
|
|
258
|
+
return normalizedRef === namespace;
|
|
259
|
+
};
|
|
260
|
+
// All cardinal plural categories we should expand to for a context+count
|
|
261
|
+
// nested reference, covering every configured locale so the per-locale
|
|
262
|
+
// filter can then keep only the relevant ones.
|
|
263
|
+
const nestedContextCountCategories = (() => {
|
|
264
|
+
const union = new Set();
|
|
265
|
+
for (const loc of config.locales) {
|
|
266
|
+
pluralRules.safePluralRules(loc, { type: 'cardinal' }).resolvedOptions().pluralCategories.forEach(c => union.add(c));
|
|
267
|
+
}
|
|
268
|
+
return [...union];
|
|
269
|
+
})();
|
|
270
|
+
const seenNestedValues = new Set();
|
|
271
|
+
const collectFromValue = (value) => {
|
|
272
|
+
if (typeof value === 'string') {
|
|
273
|
+
if (seenNestedValues.has(value))
|
|
274
|
+
return;
|
|
275
|
+
seenNestedValues.add(value);
|
|
276
|
+
const refs = nesting.parseNestedReferences(value, {
|
|
277
|
+
nestingPrefix: config.extract.nestingPrefix,
|
|
278
|
+
nestingSuffix: config.extract.nestingSuffix,
|
|
279
|
+
nestingOptionsSeparator: config.extract.nestingOptionsSeparator,
|
|
280
|
+
nsSeparator: config.extract.nsSeparator,
|
|
281
|
+
defaultNS: config.extract.defaultNS
|
|
282
|
+
});
|
|
283
|
+
for (const ref of refs) {
|
|
284
|
+
if (!namespaceMatches(ref.ns))
|
|
285
|
+
continue;
|
|
286
|
+
const effectiveHasCount = ref.hasCount && !config.extract.disablePlurals;
|
|
287
|
+
if (ref.context !== undefined) {
|
|
288
|
+
const ctxKey = `${ref.key}${contextSeparator}${ref.context}`;
|
|
289
|
+
if (effectiveHasCount) {
|
|
290
|
+
// `ctxKey` contains `contextSeparator` (which equals pluralSeparator
|
|
291
|
+
// by default) so we cannot hand it to the base plural expansion
|
|
292
|
+
// pass. Instead, push fully-expanded variants and rely on the
|
|
293
|
+
// per-locale filter to keep the relevant ones.
|
|
294
|
+
for (const category of nestedContextCountCategories) {
|
|
295
|
+
syntheticNestedKeys.push({
|
|
296
|
+
key: `${ctxKey}${pluralSeparator}${category}`,
|
|
297
|
+
hasCount: true,
|
|
298
|
+
isExpandedPlural: true
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
syntheticNestedKeys.push({ key: ref.key });
|
|
304
|
+
syntheticNestedKeys.push({ key: ctxKey });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else if (effectiveHasCount) {
|
|
308
|
+
// Plain plural reference — push the base plural key and let the
|
|
309
|
+
// normal expansion in the main loop emit per-locale variants.
|
|
310
|
+
syntheticNestedKeys.push({ key: ref.key, hasCount: true });
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
syntheticNestedKeys.push({ key: ref.key });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
318
|
+
for (const v of Object.values(value)) {
|
|
319
|
+
collectFromValue(v);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
// Scan both the locale being built and the primary locale so that newly
|
|
324
|
+
// introduced references are propagated to every locale on the first run.
|
|
325
|
+
collectFromValue(existingTranslations);
|
|
326
|
+
if (primaryExistingTranslations && primaryExistingTranslations !== existingTranslations) {
|
|
327
|
+
collectFromValue(primaryExistingTranslations);
|
|
328
|
+
}
|
|
329
|
+
const nsKeysWithNested = syntheticNestedKeys.length > 0
|
|
330
|
+
? [...nsKeys, ...syntheticNestedKeys]
|
|
331
|
+
: nsKeys;
|
|
288
332
|
// Prepare namespace pattern checking helpers
|
|
289
333
|
const rawPreserve = config.extract.preservePatterns || [];
|
|
290
334
|
// Helper to check if a key should be filtered out during extraction
|
|
@@ -340,7 +384,7 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
|
|
|
340
384
|
return false;
|
|
341
385
|
};
|
|
342
386
|
// Filter nsKeys to only include keys relevant to this language
|
|
343
|
-
const filteredKeys =
|
|
387
|
+
const filteredKeys = nsKeysWithNested.filter(({ key, hasCount, isOrdinal, explicitDefault }) => {
|
|
344
388
|
// FIRST: Check if key matches preservePatterns and should be excluded
|
|
345
389
|
if (shouldFilterKey(key)) {
|
|
346
390
|
return false;
|
|
@@ -415,12 +459,41 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
|
|
|
415
459
|
const existingKeys = nestedObject.getNestedKeys(existingTranslations, keySeparator ?? '.');
|
|
416
460
|
for (const existingKey of existingKeys) {
|
|
417
461
|
const shouldPreserve = shouldPreserveExistingKey(existingKey);
|
|
418
|
-
const isContextVariant = !shouldPreserve && isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator);
|
|
462
|
+
const isContextVariant = !shouldPreserve && contextVariants.isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator);
|
|
419
463
|
if (shouldPreserve || (preserveContextVariants && isContextVariant)) {
|
|
420
464
|
const value = nestedObject.getNestedValue(existingTranslations, existingKey, keySeparator ?? '.');
|
|
421
465
|
nestedObject.setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.');
|
|
422
466
|
}
|
|
423
467
|
}
|
|
468
|
+
// PROPAGATE CONTEXT VARIANTS FROM PRIMARY TO SECONDARY (issue #242):
|
|
469
|
+
// When `preserveContextVariants` is enabled and the source code uses a
|
|
470
|
+
// dynamic context value (e.g. `t('exportType', { context: type })`), the
|
|
471
|
+
// extractor tags the base key as "accepting context" but the actual context
|
|
472
|
+
// values (e.g. `gas`, `water`) are only known from the primary translation
|
|
473
|
+
// file. Propagate those variants from primary to secondary locales so every
|
|
474
|
+
// locale ends up with the same key skeleton — translators and downstream
|
|
475
|
+
// `sync` can then fill in real values.
|
|
476
|
+
if (preserveContextVariants && locale !== primaryLanguage && primaryExistingTranslations) {
|
|
477
|
+
const primaryKeys = nestedObject.getNestedKeys(primaryExistingTranslations, keySeparator ?? '.');
|
|
478
|
+
for (const primaryKey of primaryKeys) {
|
|
479
|
+
if (shouldFilterKey(primaryKey))
|
|
480
|
+
continue;
|
|
481
|
+
const isContextVariant = contextVariants.isContextVariantOfAcceptingKey(primaryKey, keysAcceptingContext, pluralSeparator, contextSeparator);
|
|
482
|
+
if (!isContextVariant)
|
|
483
|
+
continue;
|
|
484
|
+
const separator = primaryKey.startsWith('<') ? false : (keySeparator ?? '.');
|
|
485
|
+
const alreadySet = nestedObject.getNestedValue(newTranslations, primaryKey, separator);
|
|
486
|
+
if (alreadySet !== undefined)
|
|
487
|
+
continue;
|
|
488
|
+
// Prefer an existing secondary value if present, otherwise fall back to
|
|
489
|
+
// the configured defaultValue (empty string for secondaries by default).
|
|
490
|
+
const existingSecondaryValue = nestedObject.getNestedValue(existingTranslations, primaryKey, separator);
|
|
491
|
+
const valueToSet = existingSecondaryValue !== undefined
|
|
492
|
+
? existingSecondaryValue
|
|
493
|
+
: defaultValue.resolveDefaultValue(emptyDefaultValue, primaryKey, namespace || config?.extract?.defaultNS || 'translation', locale);
|
|
494
|
+
nestedObject.setNestedValue(newTranslations, primaryKey, valueToSet, separator);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
424
497
|
// PRESERVE LOCALE-SPECIFIC PLURAL FORMS: When dealing with plural keys in non-primary locales,
|
|
425
498
|
// preserve any existing plural forms that are NOT being explicitly generated.
|
|
426
499
|
// This ensures that locale-specific forms (like _few, _many) added by translators are preserved.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var pluralRules = require('../../utils/plural-rules.js');
|
|
4
|
+
var nesting = require('../../utils/nesting.js');
|
|
4
5
|
var astUtils = require('./ast-utils.js');
|
|
5
6
|
|
|
6
7
|
// Helper to escape regex characters
|
|
@@ -454,103 +455,26 @@ class CallExpressionHandler {
|
|
|
454
455
|
}
|
|
455
456
|
}
|
|
456
457
|
/**
|
|
457
|
-
* Scans a string for nested translations like $t(key, options) and
|
|
458
|
+
* Scans a string for nested translations like $t(key, options) and registers
|
|
459
|
+
* the referenced keys (plus their plural / context variants) on the current
|
|
460
|
+
* plugin context.
|
|
458
461
|
*/
|
|
459
|
-
extractNestedKeys(text,
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
while ((match = nestingRegexp.exec(text)) !== null) {
|
|
472
|
-
if (match[1]) {
|
|
473
|
-
// Do NOT trust the outer `ns` blindly — compute namespace from the nested key itself
|
|
474
|
-
// inside processNestedContent. Pass `undefined` so processNestedContent resolves ns
|
|
475
|
-
// deterministically (either from key "ns:key" or from defaultNS).
|
|
476
|
-
this.processNestedContent(match[1], undefined);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
processNestedContent(content, ns) {
|
|
481
|
-
let key = content;
|
|
482
|
-
let optionsString = '';
|
|
483
|
-
const separator = this.config.extract.nestingOptionsSeparator ?? ',';
|
|
484
|
-
// Logic adapted from i18next Interpolator.js handleHasOptions
|
|
485
|
-
if (content.indexOf(separator) < 0) {
|
|
486
|
-
key = content.trim();
|
|
487
|
-
}
|
|
488
|
-
else {
|
|
489
|
-
// Split by separator, but be careful about objects
|
|
490
|
-
// i18next does: const c = key.split(new RegExp(`${sep}[ ]*{`));
|
|
491
|
-
// This assumes options start with {
|
|
492
|
-
const sepRegex = new RegExp(`${escapeRegex(separator)}[ ]*{`);
|
|
493
|
-
const parts = content.split(sepRegex);
|
|
494
|
-
if (parts.length > 1) {
|
|
495
|
-
key = parts[0].trim();
|
|
496
|
-
// Reconstruct the options part: add back the '{' that was consumed by split
|
|
497
|
-
optionsString = `{${parts.slice(1).join(separator + ' {')}`;
|
|
462
|
+
extractNestedKeys(text, _ns) {
|
|
463
|
+
const references = nesting.parseNestedReferences(text, {
|
|
464
|
+
nestingPrefix: this.config.extract.nestingPrefix,
|
|
465
|
+
nestingSuffix: this.config.extract.nestingSuffix,
|
|
466
|
+
nestingOptionsSeparator: this.config.extract.nestingOptionsSeparator,
|
|
467
|
+
nsSeparator: this.config.extract.nsSeparator,
|
|
468
|
+
defaultNS: this.config.extract.defaultNS
|
|
469
|
+
});
|
|
470
|
+
for (const { key, ns: nestedNs, hasCount, context } of references) {
|
|
471
|
+
const effectiveHasCount = hasCount && !this.config.extract.disablePlurals;
|
|
472
|
+
if (effectiveHasCount || context !== undefined) {
|
|
473
|
+
this.generateNestedPluralKeys(key, nestedNs, effectiveHasCount, context);
|
|
498
474
|
}
|
|
499
475
|
else {
|
|
500
|
-
|
|
501
|
-
const sepIdx = content.indexOf(separator);
|
|
502
|
-
key = content.substring(0, sepIdx).trim();
|
|
503
|
-
optionsString = content.substring(sepIdx + 1).trim();
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
// Remove quotes from key if present
|
|
507
|
-
if ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith('"') && key.endsWith('"'))) {
|
|
508
|
-
key = key.slice(1, -1);
|
|
509
|
-
}
|
|
510
|
-
if (!key)
|
|
511
|
-
return;
|
|
512
|
-
// Resolve namespace for the nested key:
|
|
513
|
-
// If nested key contains nsSeparator (e.g. "ns:key"), extract namespace,
|
|
514
|
-
// otherwise use configured defaultNS.
|
|
515
|
-
let nestedNs;
|
|
516
|
-
const nsSeparator = this.config.extract.nsSeparator ?? ':';
|
|
517
|
-
if (nsSeparator && key.includes(nsSeparator)) {
|
|
518
|
-
const parts = key.split(nsSeparator);
|
|
519
|
-
const candidateNs = parts[0];
|
|
520
|
-
if (!looksLikeNaturalLanguage(candidateNs)) {
|
|
521
|
-
nestedNs = parts.shift();
|
|
522
|
-
key = parts.join(nsSeparator);
|
|
523
|
-
if (!key || key.trim() === '')
|
|
524
|
-
return;
|
|
476
|
+
this.pluginContext.addKey({ key, ns: nestedNs });
|
|
525
477
|
}
|
|
526
|
-
else {
|
|
527
|
-
nestedNs = this.config.extract.defaultNS;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
else {
|
|
531
|
-
nestedNs = this.config.extract.defaultNS;
|
|
532
|
-
}
|
|
533
|
-
let hasCount = false;
|
|
534
|
-
let context;
|
|
535
|
-
if (optionsString) {
|
|
536
|
-
// Simple regex check for count and context in the options string
|
|
537
|
-
// This is an approximation since we don't have a full JSON parser here that handles JS objects perfectly
|
|
538
|
-
// but it should cover most static cases.
|
|
539
|
-
// Check for count: ...
|
|
540
|
-
if (/['"]?count['"]?\s*:/.test(optionsString)) {
|
|
541
|
-
hasCount = true;
|
|
542
|
-
}
|
|
543
|
-
// Check for context: ...
|
|
544
|
-
const contextMatch = /['"]?context['"]?\s*:\s*(['"])(.*?)\1/.exec(optionsString);
|
|
545
|
-
if (contextMatch) {
|
|
546
|
-
context = contextMatch[2];
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
if ((hasCount && !this.config.extract.disablePlurals) || context !== undefined) {
|
|
550
|
-
this.generateNestedPluralKeys(key, nestedNs, hasCount && !this.config.extract.disablePlurals, context);
|
|
551
|
-
}
|
|
552
|
-
else {
|
|
553
|
-
this.pluginContext.addKey({ key, ns: nestedNs });
|
|
554
478
|
}
|
|
555
479
|
}
|
|
556
480
|
generateNestedPluralKeys(key, ns, hasCount, context) {
|