i18next-cli 1.55.0 → 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 +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/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 +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/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/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/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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared parser for i18next-style nested translation references of the form
|
|
5
|
+
* `$t(key, { options })`.
|
|
6
|
+
*
|
|
7
|
+
* Used in two places:
|
|
8
|
+
* 1. The extractor's AST pass scans source-code keys and default values for
|
|
9
|
+
* nested references and registers the referenced keys (so they show up in
|
|
10
|
+
* output translation files).
|
|
11
|
+
* 2. The translation-manager uses it during `removeUnusedKeys` cleanup so
|
|
12
|
+
* keys that are only referenced from inside a translation value (and thus
|
|
13
|
+
* invisible to the AST pass) are preserved instead of being deleted.
|
|
14
|
+
*/
|
|
15
|
+
const naturalLanguageChars = /[ ,?!;]/;
|
|
16
|
+
const looksLikeNaturalLanguage = (s) => naturalLanguageChars.test(s);
|
|
17
|
+
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
18
|
+
/**
|
|
19
|
+
* Scans a string for `$t(...)` references and returns metadata about each one.
|
|
20
|
+
* The implementation mirrors the behaviour of i18next's Interpolator so that
|
|
21
|
+
* the extractor and translation manager agree on what counts as a reference.
|
|
22
|
+
*/
|
|
23
|
+
function parseNestedReferences(text, config) {
|
|
24
|
+
if (!text || typeof text !== 'string')
|
|
25
|
+
return [];
|
|
26
|
+
const prefix = config.nestingPrefix ?? '$t(';
|
|
27
|
+
const suffix = config.nestingSuffix ?? ')';
|
|
28
|
+
const separator = config.nestingOptionsSeparator ?? ',';
|
|
29
|
+
const nsSeparator = config.nsSeparator ?? ':';
|
|
30
|
+
const escapedPrefix = escapeRegex(prefix);
|
|
31
|
+
const escapedSuffix = escapeRegex(suffix);
|
|
32
|
+
// Regex adapted from i18next Interpolator.js — matches `$t(key)` or
|
|
33
|
+
// `$t(key, { options })` with (limited) support for balanced parens and
|
|
34
|
+
// quoted strings.
|
|
35
|
+
const nestingRegexp = new RegExp(`${escapedPrefix}((?:[^()"']+|"[^"]*"|'[^']*'|\\((?:[^()]|"[^"]*"|'[^']*')*\\))*?)${escapedSuffix}`, 'g');
|
|
36
|
+
const results = [];
|
|
37
|
+
let match;
|
|
38
|
+
while ((match = nestingRegexp.exec(text)) !== null) {
|
|
39
|
+
const content = match[1];
|
|
40
|
+
if (!content)
|
|
41
|
+
continue;
|
|
42
|
+
let key = content;
|
|
43
|
+
let optionsString = '';
|
|
44
|
+
if (content.indexOf(separator) < 0) {
|
|
45
|
+
key = content.trim();
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// i18next does: const c = key.split(new RegExp(`${sep}[ ]*{`));
|
|
49
|
+
// This assumes options start with `{`.
|
|
50
|
+
const sepRegex = new RegExp(`${escapeRegex(separator)}[ ]*{`);
|
|
51
|
+
const parts = content.split(sepRegex);
|
|
52
|
+
if (parts.length > 1) {
|
|
53
|
+
key = parts[0].trim();
|
|
54
|
+
optionsString = `{${parts.slice(1).join(separator + ' {')}`;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const sepIdx = content.indexOf(separator);
|
|
58
|
+
key = content.substring(0, sepIdx).trim();
|
|
59
|
+
optionsString = content.substring(sepIdx + 1).trim();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith('"') && key.endsWith('"'))) {
|
|
63
|
+
key = key.slice(1, -1);
|
|
64
|
+
}
|
|
65
|
+
if (!key)
|
|
66
|
+
continue;
|
|
67
|
+
let ns;
|
|
68
|
+
if (nsSeparator && typeof nsSeparator === 'string' && key.includes(nsSeparator)) {
|
|
69
|
+
const parts = key.split(nsSeparator);
|
|
70
|
+
const candidateNs = parts[0];
|
|
71
|
+
if (!looksLikeNaturalLanguage(candidateNs)) {
|
|
72
|
+
ns = parts.shift();
|
|
73
|
+
key = parts.join(nsSeparator);
|
|
74
|
+
if (!key || key.trim() === '')
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
ns = config.defaultNS;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
ns = config.defaultNS;
|
|
83
|
+
}
|
|
84
|
+
let hasCount = false;
|
|
85
|
+
let context;
|
|
86
|
+
if (optionsString) {
|
|
87
|
+
if (/['"]?count['"]?\s*:/.test(optionsString)) {
|
|
88
|
+
hasCount = true;
|
|
89
|
+
}
|
|
90
|
+
const contextMatch = /['"]?context['"]?\s*:\s*(['"])(.*?)\1/.exec(optionsString);
|
|
91
|
+
if (contextMatch) {
|
|
92
|
+
context = contextMatch[2];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
results.push({ key, ns, hasCount, context });
|
|
96
|
+
}
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
exports.parseNestedReferences = parseNestedReferences;
|
package/dist/esm/cli.js
CHANGED
|
@@ -30,7 +30,7 @@ const program = new Command();
|
|
|
30
30
|
program
|
|
31
31
|
.name('i18next-cli')
|
|
32
32
|
.description('A unified, high-performance i18next CLI.')
|
|
33
|
-
.version('1.
|
|
33
|
+
.version('1.56.0'); // This string is replaced with the actual version at build time by rollup
|
|
34
34
|
// new: global config override option
|
|
35
35
|
program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
|
|
36
36
|
program
|
|
@@ -5,6 +5,8 @@ import { getOutputPath, loadTranslationFile } from '../../utils/file-utils.js';
|
|
|
5
5
|
import { resolveDefaultValue } from '../../utils/default-value.js';
|
|
6
6
|
import { ConsoleLogger } from '../../utils/logger.js';
|
|
7
7
|
import { safePluralRules } from '../../utils/plural-rules.js';
|
|
8
|
+
import { parseNestedReferences } from '../../utils/nesting.js';
|
|
9
|
+
import { isContextVariantOfAcceptingKey } from '../../utils/context-variants.js';
|
|
8
10
|
|
|
9
11
|
// used for natural language check
|
|
10
12
|
const chars = [' ', ',', '?', '!', ';'];
|
|
@@ -19,58 +21,6 @@ function globToRegex(glob) {
|
|
|
19
21
|
const regexString = `^${escaped.replace(/\*/g, '.*')}$`;
|
|
20
22
|
return new RegExp(regexString);
|
|
21
23
|
}
|
|
22
|
-
/**
|
|
23
|
-
* Checks if an existing key is a context variant of a base key that accepts context.
|
|
24
|
-
* This function handles complex cases where:
|
|
25
|
-
* - The key might have plural suffixes (_one, _other, etc.)
|
|
26
|
-
* - The context value itself might contain the separator (e.g., mc_laren)
|
|
27
|
-
*
|
|
28
|
-
* @param existingKey - The key from the translation file to check
|
|
29
|
-
* @param keysAcceptingContext - Set of base keys that were used with context in source code
|
|
30
|
-
* @param pluralSeparator - The separator used for plural forms (default: '_')
|
|
31
|
-
* @param contextSeparator - The separator used for context variants (default: '_')
|
|
32
|
-
* @returns true if the existing key is a context variant of a key accepting context
|
|
33
|
-
*/
|
|
34
|
-
function isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator) {
|
|
35
|
-
if (keysAcceptingContext.size === 0) {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
// Try to extract the base key from this existing key by removing context and/or plural suffixes
|
|
39
|
-
let potentialBaseKey = existingKey;
|
|
40
|
-
// First, try removing plural suffixes if present
|
|
41
|
-
for (const form of pluralForms) {
|
|
42
|
-
if (potentialBaseKey.endsWith(`${pluralSeparator}${form}`)) {
|
|
43
|
-
potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + form.length));
|
|
44
|
-
break;
|
|
45
|
-
}
|
|
46
|
-
if (potentialBaseKey.endsWith(`${pluralSeparator}ordinal${pluralSeparator}${form}`)) {
|
|
47
|
-
potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + 'ordinal'.length + pluralSeparator.length + form.length));
|
|
48
|
-
break;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
// Then, try removing the context suffix to get the base key
|
|
52
|
-
// We need to check all possible base keys since the context value itself might contain separators
|
|
53
|
-
// For example: 'formula_one_mc_laren' could be:
|
|
54
|
-
// - base: 'formula_one_mc', context: 'laren'
|
|
55
|
-
// - base: 'formula_one', context: 'mc_laren' ← correct
|
|
56
|
-
// - base: 'formula', context: 'one_mc_laren'
|
|
57
|
-
const parts = potentialBaseKey.split(contextSeparator);
|
|
58
|
-
if (parts.length > 1) {
|
|
59
|
-
// Try removing 1, 2, 3... parts from the end to find a matching base key
|
|
60
|
-
for (let i = 1; i < parts.length; i++) {
|
|
61
|
-
const baseWithoutContext = parts.slice(0, -i).join(contextSeparator);
|
|
62
|
-
if (keysAcceptingContext.has(baseWithoutContext)) {
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
// Also check if the key itself (after removing plural suffix) accepts context
|
|
68
|
-
// This handles cases like 'friend_other' where 'friend' accepts context
|
|
69
|
-
if (keysAcceptingContext.has(potentialBaseKey)) {
|
|
70
|
-
return true;
|
|
71
|
-
}
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
24
|
/**
|
|
75
25
|
* Checks if a key looks like an object path or natural language.
|
|
76
26
|
* (like in i18next)
|
|
@@ -283,6 +233,100 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
|
|
|
283
233
|
return [...union];
|
|
284
234
|
})()
|
|
285
235
|
: null;
|
|
236
|
+
// Discover keys that are only referenced through `$t(...)` nested references
|
|
237
|
+
// inside existing translation values (see issue #241). These keys are
|
|
238
|
+
// invisible to the AST-based extractor, so without this step they would be
|
|
239
|
+
// deleted when `removeUnusedKeys` is true and never expanded into the plural
|
|
240
|
+
// forms a secondary locale needs.
|
|
241
|
+
//
|
|
242
|
+
// We inject synthetic ExtractedKey entries for each discovered reference so
|
|
243
|
+
// the normal filter / plural-expansion pipeline picks them up — for the
|
|
244
|
+
// primary language this preserves the existing variants, and for secondary
|
|
245
|
+
// languages this generates the correct per-locale plural skeleton.
|
|
246
|
+
const syntheticNestedKeys = [];
|
|
247
|
+
const namespaceMatches = (refNs) => {
|
|
248
|
+
if (namespace === undefined)
|
|
249
|
+
return true;
|
|
250
|
+
// Nested references arrive from parseNestedReferences with `ns` either set
|
|
251
|
+
// from an explicit `ns:key` prefix or defaulted to config.extract.defaultNS.
|
|
252
|
+
// Normalise to the same bucket keys used in `keysByNS`.
|
|
253
|
+
const normalizedRef = refNs === undefined || refNs === null
|
|
254
|
+
? config.extract.defaultNS ?? 'translation'
|
|
255
|
+
: refNs;
|
|
256
|
+
return normalizedRef === namespace;
|
|
257
|
+
};
|
|
258
|
+
// All cardinal plural categories we should expand to for a context+count
|
|
259
|
+
// nested reference, covering every configured locale so the per-locale
|
|
260
|
+
// filter can then keep only the relevant ones.
|
|
261
|
+
const nestedContextCountCategories = (() => {
|
|
262
|
+
const union = new Set();
|
|
263
|
+
for (const loc of config.locales) {
|
|
264
|
+
safePluralRules(loc, { type: 'cardinal' }).resolvedOptions().pluralCategories.forEach(c => union.add(c));
|
|
265
|
+
}
|
|
266
|
+
return [...union];
|
|
267
|
+
})();
|
|
268
|
+
const seenNestedValues = new Set();
|
|
269
|
+
const collectFromValue = (value) => {
|
|
270
|
+
if (typeof value === 'string') {
|
|
271
|
+
if (seenNestedValues.has(value))
|
|
272
|
+
return;
|
|
273
|
+
seenNestedValues.add(value);
|
|
274
|
+
const refs = parseNestedReferences(value, {
|
|
275
|
+
nestingPrefix: config.extract.nestingPrefix,
|
|
276
|
+
nestingSuffix: config.extract.nestingSuffix,
|
|
277
|
+
nestingOptionsSeparator: config.extract.nestingOptionsSeparator,
|
|
278
|
+
nsSeparator: config.extract.nsSeparator,
|
|
279
|
+
defaultNS: config.extract.defaultNS
|
|
280
|
+
});
|
|
281
|
+
for (const ref of refs) {
|
|
282
|
+
if (!namespaceMatches(ref.ns))
|
|
283
|
+
continue;
|
|
284
|
+
const effectiveHasCount = ref.hasCount && !config.extract.disablePlurals;
|
|
285
|
+
if (ref.context !== undefined) {
|
|
286
|
+
const ctxKey = `${ref.key}${contextSeparator}${ref.context}`;
|
|
287
|
+
if (effectiveHasCount) {
|
|
288
|
+
// `ctxKey` contains `contextSeparator` (which equals pluralSeparator
|
|
289
|
+
// by default) so we cannot hand it to the base plural expansion
|
|
290
|
+
// pass. Instead, push fully-expanded variants and rely on the
|
|
291
|
+
// per-locale filter to keep the relevant ones.
|
|
292
|
+
for (const category of nestedContextCountCategories) {
|
|
293
|
+
syntheticNestedKeys.push({
|
|
294
|
+
key: `${ctxKey}${pluralSeparator}${category}`,
|
|
295
|
+
hasCount: true,
|
|
296
|
+
isExpandedPlural: true
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
syntheticNestedKeys.push({ key: ref.key });
|
|
302
|
+
syntheticNestedKeys.push({ key: ctxKey });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else if (effectiveHasCount) {
|
|
306
|
+
// Plain plural reference — push the base plural key and let the
|
|
307
|
+
// normal expansion in the main loop emit per-locale variants.
|
|
308
|
+
syntheticNestedKeys.push({ key: ref.key, hasCount: true });
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
syntheticNestedKeys.push({ key: ref.key });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
316
|
+
for (const v of Object.values(value)) {
|
|
317
|
+
collectFromValue(v);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
// Scan both the locale being built and the primary locale so that newly
|
|
322
|
+
// introduced references are propagated to every locale on the first run.
|
|
323
|
+
collectFromValue(existingTranslations);
|
|
324
|
+
if (primaryExistingTranslations && primaryExistingTranslations !== existingTranslations) {
|
|
325
|
+
collectFromValue(primaryExistingTranslations);
|
|
326
|
+
}
|
|
327
|
+
const nsKeysWithNested = syntheticNestedKeys.length > 0
|
|
328
|
+
? [...nsKeys, ...syntheticNestedKeys]
|
|
329
|
+
: nsKeys;
|
|
286
330
|
// Prepare namespace pattern checking helpers
|
|
287
331
|
const rawPreserve = config.extract.preservePatterns || [];
|
|
288
332
|
// Helper to check if a key should be filtered out during extraction
|
|
@@ -338,7 +382,7 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
|
|
|
338
382
|
return false;
|
|
339
383
|
};
|
|
340
384
|
// Filter nsKeys to only include keys relevant to this language
|
|
341
|
-
const filteredKeys =
|
|
385
|
+
const filteredKeys = nsKeysWithNested.filter(({ key, hasCount, isOrdinal, explicitDefault }) => {
|
|
342
386
|
// FIRST: Check if key matches preservePatterns and should be excluded
|
|
343
387
|
if (shouldFilterKey(key)) {
|
|
344
388
|
return false;
|
|
@@ -419,6 +463,35 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
|
|
|
419
463
|
setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.');
|
|
420
464
|
}
|
|
421
465
|
}
|
|
466
|
+
// PROPAGATE CONTEXT VARIANTS FROM PRIMARY TO SECONDARY (issue #242):
|
|
467
|
+
// When `preserveContextVariants` is enabled and the source code uses a
|
|
468
|
+
// dynamic context value (e.g. `t('exportType', { context: type })`), the
|
|
469
|
+
// extractor tags the base key as "accepting context" but the actual context
|
|
470
|
+
// values (e.g. `gas`, `water`) are only known from the primary translation
|
|
471
|
+
// file. Propagate those variants from primary to secondary locales so every
|
|
472
|
+
// locale ends up with the same key skeleton — translators and downstream
|
|
473
|
+
// `sync` can then fill in real values.
|
|
474
|
+
if (preserveContextVariants && locale !== primaryLanguage && primaryExistingTranslations) {
|
|
475
|
+
const primaryKeys = getNestedKeys(primaryExistingTranslations, keySeparator ?? '.');
|
|
476
|
+
for (const primaryKey of primaryKeys) {
|
|
477
|
+
if (shouldFilterKey(primaryKey))
|
|
478
|
+
continue;
|
|
479
|
+
const isContextVariant = isContextVariantOfAcceptingKey(primaryKey, keysAcceptingContext, pluralSeparator, contextSeparator);
|
|
480
|
+
if (!isContextVariant)
|
|
481
|
+
continue;
|
|
482
|
+
const separator = primaryKey.startsWith('<') ? false : (keySeparator ?? '.');
|
|
483
|
+
const alreadySet = getNestedValue(newTranslations, primaryKey, separator);
|
|
484
|
+
if (alreadySet !== undefined)
|
|
485
|
+
continue;
|
|
486
|
+
// Prefer an existing secondary value if present, otherwise fall back to
|
|
487
|
+
// the configured defaultValue (empty string for secondaries by default).
|
|
488
|
+
const existingSecondaryValue = getNestedValue(existingTranslations, primaryKey, separator);
|
|
489
|
+
const valueToSet = existingSecondaryValue !== undefined
|
|
490
|
+
? existingSecondaryValue
|
|
491
|
+
: resolveDefaultValue(emptyDefaultValue, primaryKey, namespace || config?.extract?.defaultNS || 'translation', locale);
|
|
492
|
+
setNestedValue(newTranslations, primaryKey, valueToSet, separator);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
422
495
|
// PRESERVE LOCALE-SPECIFIC PLURAL FORMS: When dealing with plural keys in non-primary locales,
|
|
423
496
|
// preserve any existing plural forms that are NOT being explicitly generated.
|
|
424
497
|
// This ensures that locale-specific forms (like _few, _many) added by translators are preserved.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { safePluralRules } from '../../utils/plural-rules.js';
|
|
2
|
+
import { parseNestedReferences } from '../../utils/nesting.js';
|
|
2
3
|
import { lineColumnFromOffset, isSimpleTemplateLiteral, getObjectPropValue, getObjectPropValueExpression } from './ast-utils.js';
|
|
3
4
|
|
|
4
5
|
// Helper to escape regex characters
|
|
@@ -452,103 +453,26 @@ class CallExpressionHandler {
|
|
|
452
453
|
}
|
|
453
454
|
}
|
|
454
455
|
/**
|
|
455
|
-
* Scans a string for nested translations like $t(key, options) and
|
|
456
|
+
* Scans a string for nested translations like $t(key, options) and registers
|
|
457
|
+
* the referenced keys (plus their plural / context variants) on the current
|
|
458
|
+
* plugin context.
|
|
456
459
|
*/
|
|
457
|
-
extractNestedKeys(text,
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
while ((match = nestingRegexp.exec(text)) !== null) {
|
|
470
|
-
if (match[1]) {
|
|
471
|
-
// Do NOT trust the outer `ns` blindly — compute namespace from the nested key itself
|
|
472
|
-
// inside processNestedContent. Pass `undefined` so processNestedContent resolves ns
|
|
473
|
-
// deterministically (either from key "ns:key" or from defaultNS).
|
|
474
|
-
this.processNestedContent(match[1], undefined);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
processNestedContent(content, ns) {
|
|
479
|
-
let key = content;
|
|
480
|
-
let optionsString = '';
|
|
481
|
-
const separator = this.config.extract.nestingOptionsSeparator ?? ',';
|
|
482
|
-
// Logic adapted from i18next Interpolator.js handleHasOptions
|
|
483
|
-
if (content.indexOf(separator) < 0) {
|
|
484
|
-
key = content.trim();
|
|
485
|
-
}
|
|
486
|
-
else {
|
|
487
|
-
// Split by separator, but be careful about objects
|
|
488
|
-
// i18next does: const c = key.split(new RegExp(`${sep}[ ]*{`));
|
|
489
|
-
// This assumes options start with {
|
|
490
|
-
const sepRegex = new RegExp(`${escapeRegex(separator)}[ ]*{`);
|
|
491
|
-
const parts = content.split(sepRegex);
|
|
492
|
-
if (parts.length > 1) {
|
|
493
|
-
key = parts[0].trim();
|
|
494
|
-
// Reconstruct the options part: add back the '{' that was consumed by split
|
|
495
|
-
optionsString = `{${parts.slice(1).join(separator + ' {')}`;
|
|
460
|
+
extractNestedKeys(text, _ns) {
|
|
461
|
+
const references = parseNestedReferences(text, {
|
|
462
|
+
nestingPrefix: this.config.extract.nestingPrefix,
|
|
463
|
+
nestingSuffix: this.config.extract.nestingSuffix,
|
|
464
|
+
nestingOptionsSeparator: this.config.extract.nestingOptionsSeparator,
|
|
465
|
+
nsSeparator: this.config.extract.nsSeparator,
|
|
466
|
+
defaultNS: this.config.extract.defaultNS
|
|
467
|
+
});
|
|
468
|
+
for (const { key, ns: nestedNs, hasCount, context } of references) {
|
|
469
|
+
const effectiveHasCount = hasCount && !this.config.extract.disablePlurals;
|
|
470
|
+
if (effectiveHasCount || context !== undefined) {
|
|
471
|
+
this.generateNestedPluralKeys(key, nestedNs, effectiveHasCount, context);
|
|
496
472
|
}
|
|
497
473
|
else {
|
|
498
|
-
|
|
499
|
-
const sepIdx = content.indexOf(separator);
|
|
500
|
-
key = content.substring(0, sepIdx).trim();
|
|
501
|
-
optionsString = content.substring(sepIdx + 1).trim();
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
// Remove quotes from key if present
|
|
505
|
-
if ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith('"') && key.endsWith('"'))) {
|
|
506
|
-
key = key.slice(1, -1);
|
|
507
|
-
}
|
|
508
|
-
if (!key)
|
|
509
|
-
return;
|
|
510
|
-
// Resolve namespace for the nested key:
|
|
511
|
-
// If nested key contains nsSeparator (e.g. "ns:key"), extract namespace,
|
|
512
|
-
// otherwise use configured defaultNS.
|
|
513
|
-
let nestedNs;
|
|
514
|
-
const nsSeparator = this.config.extract.nsSeparator ?? ':';
|
|
515
|
-
if (nsSeparator && key.includes(nsSeparator)) {
|
|
516
|
-
const parts = key.split(nsSeparator);
|
|
517
|
-
const candidateNs = parts[0];
|
|
518
|
-
if (!looksLikeNaturalLanguage(candidateNs)) {
|
|
519
|
-
nestedNs = parts.shift();
|
|
520
|
-
key = parts.join(nsSeparator);
|
|
521
|
-
if (!key || key.trim() === '')
|
|
522
|
-
return;
|
|
474
|
+
this.pluginContext.addKey({ key, ns: nestedNs });
|
|
523
475
|
}
|
|
524
|
-
else {
|
|
525
|
-
nestedNs = this.config.extract.defaultNS;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
nestedNs = this.config.extract.defaultNS;
|
|
530
|
-
}
|
|
531
|
-
let hasCount = false;
|
|
532
|
-
let context;
|
|
533
|
-
if (optionsString) {
|
|
534
|
-
// Simple regex check for count and context in the options string
|
|
535
|
-
// This is an approximation since we don't have a full JSON parser here that handles JS objects perfectly
|
|
536
|
-
// but it should cover most static cases.
|
|
537
|
-
// Check for count: ...
|
|
538
|
-
if (/['"]?count['"]?\s*:/.test(optionsString)) {
|
|
539
|
-
hasCount = true;
|
|
540
|
-
}
|
|
541
|
-
// Check for context: ...
|
|
542
|
-
const contextMatch = /['"]?context['"]?\s*:\s*(['"])(.*?)\1/.exec(optionsString);
|
|
543
|
-
if (contextMatch) {
|
|
544
|
-
context = contextMatch[2];
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
if ((hasCount && !this.config.extract.disablePlurals) || context !== undefined) {
|
|
548
|
-
this.generateNestedPluralKeys(key, nestedNs, hasCount && !this.config.extract.disablePlurals, context);
|
|
549
|
-
}
|
|
550
|
-
else {
|
|
551
|
-
this.pluginContext.addKey({ key, ns: nestedNs });
|
|
552
476
|
}
|
|
553
477
|
}
|
|
554
478
|
generateNestedPluralKeys(key, ns, hasCount, context) {
|
package/dist/esm/status.js
CHANGED
|
@@ -5,9 +5,10 @@ import '@swc/core';
|
|
|
5
5
|
import 'node:fs/promises';
|
|
6
6
|
import { findKeys } from './extractor/core/key-finder.js';
|
|
7
7
|
import 'glob';
|
|
8
|
-
import { getNestedValue } from './utils/nested-object.js';
|
|
8
|
+
import { getNestedKeys, getNestedValue } from './utils/nested-object.js';
|
|
9
9
|
import { loadTranslationFile, getOutputPath } from './utils/file-utils.js';
|
|
10
10
|
import { safePluralRules } from './utils/plural-rules.js';
|
|
11
|
+
import { isContextVariantOfAcceptingKey } from './utils/context-variants.js';
|
|
11
12
|
import { shouldShowFunnel, recordFunnelShown } from './utils/funnel-msg-tracker.js';
|
|
12
13
|
import './extractor/parsers/jsx-parser.js';
|
|
13
14
|
|
|
@@ -80,7 +81,8 @@ async function generateStatusReport(config) {
|
|
|
80
81
|
config.extract.primaryLanguage ||= config.locales[0] || 'en';
|
|
81
82
|
config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
|
|
82
83
|
const { allKeys: allExtractedKeys } = await findKeys(config);
|
|
83
|
-
const { secondaryLanguages, keySeparator = '.', defaultNS = 'translation', mergeNamespaces = false, pluralSeparator = '_', fallbackNS } = config.extract;
|
|
84
|
+
const { secondaryLanguages, keySeparator = '.', defaultNS = 'translation', mergeNamespaces = false, pluralSeparator = '_', contextSeparator = '_', fallbackNS } = config.extract;
|
|
85
|
+
const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en';
|
|
84
86
|
const keysByNs = new Map();
|
|
85
87
|
for (const key of allExtractedKeys.values()) {
|
|
86
88
|
const ns = key.ns || defaultNS || 'translation';
|
|
@@ -103,6 +105,40 @@ async function generateStatusReport(config) {
|
|
|
103
105
|
keysByNs,
|
|
104
106
|
locales: new Map(),
|
|
105
107
|
};
|
|
108
|
+
// Discover context variants that live in the primary translation file but
|
|
109
|
+
// are not directly extracted as keys (see issue #243). When source code uses
|
|
110
|
+
// a dynamic context value like `t('exportType', { context: type })`, the
|
|
111
|
+
// extractor can only tag the base key as "accepting context"; the actual
|
|
112
|
+
// context values (`_gas`, `_water`, ...) are only visible in the primary
|
|
113
|
+
// translation file. Without this scan, status never checks those variants
|
|
114
|
+
// for translation gaps in secondary locales.
|
|
115
|
+
const keysAcceptingContext = new Set();
|
|
116
|
+
for (const keys of keysByNs.values()) {
|
|
117
|
+
for (const k of keys) {
|
|
118
|
+
if (k.keyAcceptingContext)
|
|
119
|
+
keysAcceptingContext.add(k.keyAcceptingContext);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const contextVariantsByNs = new Map();
|
|
123
|
+
if (keysAcceptingContext.size > 0) {
|
|
124
|
+
const primaryMerged = mergeNamespaces
|
|
125
|
+
? ((await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, primaryLanguage, (defaultNS === false ? 'translation' : (defaultNS || 'translation')))))) || {})
|
|
126
|
+
: null;
|
|
127
|
+
for (const ns of keysByNs.keys()) {
|
|
128
|
+
const primaryNsTranslations = mergeNamespaces
|
|
129
|
+
? (primaryMerged?.[ns] ?? primaryMerged ?? {})
|
|
130
|
+
: ((await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, primaryLanguage, ns)))) || {});
|
|
131
|
+
const primaryKeys = getNestedKeys(primaryNsTranslations, keySeparator ?? '.');
|
|
132
|
+
const variants = [];
|
|
133
|
+
for (const primaryKey of primaryKeys) {
|
|
134
|
+
if (isContextVariantOfAcceptingKey(primaryKey, keysAcceptingContext, pluralSeparator, contextSeparator)) {
|
|
135
|
+
variants.push(primaryKey);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (variants.length > 0)
|
|
139
|
+
contextVariantsByNs.set(ns, variants);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
106
142
|
for (const locale of secondaryLanguages) {
|
|
107
143
|
let totalTranslatedForLocale = 0;
|
|
108
144
|
let totalEmptyForLocale = 0;
|
|
@@ -170,6 +206,7 @@ async function generateStatusReport(config) {
|
|
|
170
206
|
}
|
|
171
207
|
return primaryState;
|
|
172
208
|
};
|
|
209
|
+
const processedKeys = new Set();
|
|
173
210
|
for (const { key: baseKey, hasCount, isOrdinal, isExpandedPlural } of keysInNs) {
|
|
174
211
|
if (hasCount) {
|
|
175
212
|
if (isExpandedPlural) {
|
|
@@ -183,7 +220,8 @@ async function generateStatusReport(config) {
|
|
|
183
220
|
// Get the plural categories for this locale
|
|
184
221
|
const localePluralCategories = getLocalePluralCategories(locale, isOrdinalVariant);
|
|
185
222
|
// Only count this key if it's a plural form used by this locale
|
|
186
|
-
if (localePluralCategories.includes(category)) {
|
|
223
|
+
if (localePluralCategories.includes(category) && !processedKeys.has(baseKey)) {
|
|
224
|
+
processedKeys.add(baseKey);
|
|
187
225
|
totalInNs++;
|
|
188
226
|
const state = resolveAndClassify(baseKey);
|
|
189
227
|
if (state === 'translated')
|
|
@@ -200,10 +238,13 @@ async function generateStatusReport(config) {
|
|
|
200
238
|
// Expand it according to THIS locale's plural rules
|
|
201
239
|
const localePluralCategories = getLocalePluralCategories(locale, isOrdinal || false);
|
|
202
240
|
for (const category of localePluralCategories) {
|
|
203
|
-
totalInNs++;
|
|
204
241
|
const pluralKey = isOrdinal
|
|
205
242
|
? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
|
|
206
243
|
: `${baseKey}${pluralSeparator}${category}`;
|
|
244
|
+
if (processedKeys.has(pluralKey))
|
|
245
|
+
continue;
|
|
246
|
+
processedKeys.add(pluralKey);
|
|
247
|
+
totalInNs++;
|
|
207
248
|
const state = resolveAndClassify(pluralKey);
|
|
208
249
|
if (state === 'translated')
|
|
209
250
|
translatedInNs++;
|
|
@@ -216,17 +257,37 @@ async function generateStatusReport(config) {
|
|
|
216
257
|
}
|
|
217
258
|
}
|
|
218
259
|
else {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
260
|
+
if (!processedKeys.has(baseKey)) {
|
|
261
|
+
processedKeys.add(baseKey);
|
|
262
|
+
totalInNs++;
|
|
263
|
+
const state = resolveAndClassify(baseKey);
|
|
264
|
+
if (state === 'translated')
|
|
265
|
+
translatedInNs++;
|
|
266
|
+
else if (state === 'empty')
|
|
267
|
+
emptyInNs++;
|
|
268
|
+
else
|
|
269
|
+
absentInNs++;
|
|
270
|
+
keyDetails.push({ key: baseKey, state });
|
|
271
|
+
}
|
|
228
272
|
}
|
|
229
273
|
}
|
|
274
|
+
// Additionally check context variants discovered in the primary file
|
|
275
|
+
// (see issue #243). Skip variants already counted via extracted keys.
|
|
276
|
+
const contextVariants = contextVariantsByNs.get(ns) || [];
|
|
277
|
+
for (const variantKey of contextVariants) {
|
|
278
|
+
if (processedKeys.has(variantKey))
|
|
279
|
+
continue;
|
|
280
|
+
processedKeys.add(variantKey);
|
|
281
|
+
totalInNs++;
|
|
282
|
+
const state = resolveAndClassify(variantKey);
|
|
283
|
+
if (state === 'translated')
|
|
284
|
+
translatedInNs++;
|
|
285
|
+
else if (state === 'empty')
|
|
286
|
+
emptyInNs++;
|
|
287
|
+
else
|
|
288
|
+
absentInNs++;
|
|
289
|
+
keyDetails.push({ key: variantKey, state });
|
|
290
|
+
}
|
|
230
291
|
namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, emptyKeys: emptyInNs, absentKeys: absentInNs, keyDetails });
|
|
231
292
|
totalTranslatedForLocale += translatedInNs;
|
|
232
293
|
totalEmptyForLocale += emptyInNs;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for reasoning about context variants of translation keys.
|
|
3
|
+
*
|
|
4
|
+
* A "key accepting context" is a base key that was called with a `context`
|
|
5
|
+
* option in source code (e.g. `t('friend', { context: gender })`). Its
|
|
6
|
+
* variants in the translation file look like `<base><contextSeparator><ctx>`
|
|
7
|
+
* (optionally suffixed with a CLDR plural form).
|
|
8
|
+
*/
|
|
9
|
+
const pluralForms = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
10
|
+
/**
|
|
11
|
+
* Checks if an existing key is a context variant of a base key that accepts context.
|
|
12
|
+
* Handles:
|
|
13
|
+
* - Keys suffixed with a CLDR plural form (e.g. `friend_male_one`).
|
|
14
|
+
* - Context values that contain the separator (e.g. `mc_laren`).
|
|
15
|
+
*
|
|
16
|
+
* @param existingKey - The key from the translation file to check
|
|
17
|
+
* @param keysAcceptingContext - Set of base keys that were used with context in source code
|
|
18
|
+
* @param pluralSeparator - The separator used for plural forms (default: '_')
|
|
19
|
+
* @param contextSeparator - The separator used for context variants (default: '_')
|
|
20
|
+
* @returns true if the existing key is a context variant of a key accepting context
|
|
21
|
+
*/
|
|
22
|
+
function isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator) {
|
|
23
|
+
if (keysAcceptingContext.size === 0) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
let potentialBaseKey = existingKey;
|
|
27
|
+
// First, try removing plural suffixes if present
|
|
28
|
+
for (const form of pluralForms) {
|
|
29
|
+
if (potentialBaseKey.endsWith(`${pluralSeparator}${form}`)) {
|
|
30
|
+
potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + form.length));
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
if (potentialBaseKey.endsWith(`${pluralSeparator}ordinal${pluralSeparator}${form}`)) {
|
|
34
|
+
potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + 'ordinal'.length + pluralSeparator.length + form.length));
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// The context value itself may contain the separator — try every possible
|
|
39
|
+
// split to find a base that matches an accepting-context key.
|
|
40
|
+
const parts = potentialBaseKey.split(contextSeparator);
|
|
41
|
+
if (parts.length > 1) {
|
|
42
|
+
for (let i = 1; i < parts.length; i++) {
|
|
43
|
+
const baseWithoutContext = parts.slice(0, -i).join(contextSeparator);
|
|
44
|
+
if (keysAcceptingContext.has(baseWithoutContext)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Also accept the plural-stripped key itself as a direct match
|
|
50
|
+
// (e.g. `friend_other` → base `friend`).
|
|
51
|
+
if (keysAcceptingContext.has(potentialBaseKey)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { isContextVariantOfAcceptingKey };
|