i18next-cli 1.8.0 → 1.10.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.
@@ -1,4 +1,4 @@
1
- import type { Module, Node, CallExpression, VariableDeclarator, JSXElement, ArrowFunctionExpression, ObjectExpression, Expression, TemplateLiteral } from '@swc/core'
1
+ import type { Module, Node, CallExpression, VariableDeclarator, JSXElement, ArrowFunctionExpression, ObjectExpression, Expression, TemplateLiteral, TsType, TsTemplateLiteralType } from '@swc/core'
2
2
  import type { PluginContext, I18nextToolkitConfig, Logger, ExtractedKey, ScopeInfo } from '../../types'
3
3
  import { extractFromTransComponent } from './jsx-parser'
4
4
  import { getObjectProperty, getObjectPropValue } from './ast-utils'
@@ -547,15 +547,44 @@ export class ASTVisitors {
547
547
  const parts = key.split(nsSeparator)
548
548
  ns = parts.shift()
549
549
  key = parts.join(nsSeparator)
550
+
551
+ if (!key || key.trim() === '') {
552
+ this.logger.warn(`Skipping key that became empty after namespace removal: '${ns}${nsSeparator}'`)
553
+ continue
554
+ }
550
555
  }
551
556
 
552
557
  if (!ns && scopeInfo?.defaultNs) ns = scopeInfo.defaultNs
553
558
  if (!ns) ns = this.config.extract.defaultNS
554
559
 
555
560
  let finalKey = key
561
+
562
+ // Apply keyPrefix AFTER namespace extraction
556
563
  if (scopeInfo?.keyPrefix) {
557
564
  const keySeparator = this.config.extract.keySeparator ?? '.'
558
- finalKey = `${scopeInfo.keyPrefix}${keySeparator}${key}`
565
+
566
+ // Apply keyPrefix - handle case where keyPrefix already ends with separator
567
+ if (keySeparator !== false) {
568
+ if (scopeInfo.keyPrefix.endsWith(keySeparator)) {
569
+ finalKey = `${scopeInfo.keyPrefix}${key}`
570
+ } else {
571
+ finalKey = `${scopeInfo.keyPrefix}${keySeparator}${key}`
572
+ }
573
+ } else {
574
+ finalKey = `${scopeInfo.keyPrefix}${key}`
575
+ }
576
+
577
+ // Validate keyPrefix combinations that create problematic keys
578
+ if (keySeparator !== false) {
579
+ // Check for patterns that would create empty segments in the nested key structure
580
+ const segments = finalKey.split(keySeparator)
581
+ const hasEmptySegment = segments.some(segment => segment.trim() === '')
582
+
583
+ if (hasEmptySegment) {
584
+ this.logger.warn(`Skipping key with empty segments: '${finalKey}' (keyPrefix: '${scopeInfo.keyPrefix}', key: '${key}')`)
585
+ continue
586
+ }
587
+ }
559
588
  }
560
589
 
561
590
  const isLastKey = i === keysToProcess.length - 1
@@ -594,14 +623,19 @@ export class ASTVisitors {
594
623
  const hasCount = getObjectPropValue(options, 'count') !== undefined
595
624
  const isOrdinalByOption = getObjectPropValue(options, 'ordinal') === true
596
625
  if (hasCount || isOrdinalByKey) {
597
- // If we have keys with context pluralize them
598
- if (keysWithContext.length > 0) {
599
- for (const { key, ns } of keysWithContext) {
600
- // Pass the combined ordinal flag and the default value to the handler
601
- this.handlePluralKeys(key, ns, options, isOrdinalByOption || isOrdinalByKey, finalDefaultValue)
626
+ // Check if plurals are disabled
627
+ if (this.config.extract.disablePlurals) {
628
+ // When plurals are disabled, treat count as a regular option (for interpolation only)
629
+ // Still handle context normally
630
+ if (keysWithContext.length > 0) {
631
+ keysWithContext.forEach(this.pluginContext.addKey)
632
+ } else {
633
+ // No context, just add the base key (no plurals even if count is present)
634
+ this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv })
602
635
  }
603
636
  } else {
604
- // Otherwise pluralize the base key
637
+ // Original plural handling logic when plurals are enabled
638
+ // Always pass the base key to handlePluralKeys - it will handle context internally
605
639
  this.handlePluralKeys(finalKey, ns, options, isOrdinalByOption || isOrdinalByKey, finalDefaultValue)
606
640
  }
607
641
 
@@ -687,7 +721,23 @@ export class ASTVisitors {
687
721
  try {
688
722
  const type = isOrdinal ? 'ordinal' : 'cardinal'
689
723
 
690
- const pluralCategories = new Intl.PluralRules(this.config.extract?.primaryLanguage, { type }).resolvedOptions().pluralCategories
724
+ // Generate plural forms for ALL target languages to ensure we have all necessary keys
725
+ const allPluralCategories = new Set<string>()
726
+
727
+ for (const locale of this.config.locales) {
728
+ try {
729
+ const pluralRules = new Intl.PluralRules(locale, { type })
730
+ const categories = pluralRules.resolvedOptions().pluralCategories
731
+ categories.forEach(cat => allPluralCategories.add(cat))
732
+ } catch (e) {
733
+ // If a locale is invalid, fall back to English rules
734
+ const englishRules = new Intl.PluralRules('en', { type })
735
+ const categories = englishRules.resolvedOptions().pluralCategories
736
+ categories.forEach(cat => allPluralCategories.add(cat))
737
+ }
738
+ }
739
+
740
+ const pluralCategories = Array.from(allPluralCategories).sort()
691
741
  const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
692
742
 
693
743
  // Get all possible default values once at the start
@@ -709,49 +759,82 @@ export class ASTVisitors {
709
759
  }
710
760
  }
711
761
 
712
- for (const category of pluralCategories) {
713
- // 1. Look for the most specific default value (e.g., defaultValue_ordinal_one)
714
- const specificDefaultKey = isOrdinal ? `defaultValue${pluralSeparator}ordinal${pluralSeparator}${category}` : `defaultValue${pluralSeparator}${category}`
715
- const specificDefault = getObjectPropValue(options, specificDefaultKey)
762
+ // Handle context - both static and dynamic
763
+ const contextProp = getObjectProperty(options, 'context')
764
+ const keysToGenerate: Array<{ key: string, context?: string }> = []
716
765
 
717
- // 2. Determine the final default value using a clear fallback chain
718
- let finalDefaultValue: string | undefined
719
- if (typeof specificDefault === 'string') {
720
- // 1. HIGHEST PRIORITY: Use the most specific default if it exists (e.g., defaultValue_few)
721
- finalDefaultValue = specificDefault
722
- } else if (category === 'one' && typeof defaultValue === 'string') {
723
- // 2. SPECIAL CASE: The 'one' category falls back to the main 'defaultValue' prop
724
- finalDefaultValue = defaultValue
725
- } else if (isOrdinal && typeof ordinalOtherDefault === 'string') {
726
- // 3a. Other ordinal categories fall back to 'defaultValue_ordinal_other'
727
- finalDefaultValue = ordinalOtherDefault
728
- } else if (!isOrdinal && typeof otherDefault === 'string') {
729
- // 3b. Other cardinal categories fall back to 'defaultValue_other'
730
- finalDefaultValue = otherDefault
731
- } else if (typeof defaultValue === 'string') {
732
- // 4. If no '_other' is found, all categories can fall back to the main 'defaultValue'
733
- finalDefaultValue = defaultValue
734
- } else if (defaultValueFromCall && targetCategory === category) {
735
- // 5. LOWER PRIORITY: If we have a default value from the t() call and this category matches the count,
736
- // use the default value from the call (only if no other defaults exist)
737
- finalDefaultValue = defaultValueFromCall
766
+ if (contextProp?.value) {
767
+ // Handle dynamic context by resolving all possible values
768
+ const contextValues = this.resolvePossibleContextStringValues(contextProp.value)
769
+
770
+ if (contextValues.length > 0) {
771
+ // Generate keys for each context value
772
+ for (const contextValue of contextValues) {
773
+ if (contextValue.length > 0) { // Skip empty contexts
774
+ keysToGenerate.push({ key, context: contextValue })
775
+ }
776
+ }
777
+
778
+ // For dynamic context, also generate base plural forms if generateBasePluralForms is not disabled
779
+ const shouldGenerateBaseForms = this.config.extract?.generateBasePluralForms !== false
780
+ if (shouldGenerateBaseForms) {
781
+ keysToGenerate.push({ key })
782
+ }
738
783
  } else {
739
- // 6. Final fallback to the base key itself
740
- finalDefaultValue = key
784
+ // Couldn't resolve context, fall back to base key only
785
+ keysToGenerate.push({ key })
741
786
  }
787
+ } else {
788
+ // No context, always generate base plural forms
789
+ keysToGenerate.push({ key })
790
+ }
742
791
 
743
- // 3. Construct the final plural key
744
- const finalKey = isOrdinal
745
- ? `${key}${pluralSeparator}ordinal${pluralSeparator}${category}`
746
- : `${key}${pluralSeparator}${category}`
792
+ // Generate plural forms for each key variant
793
+ for (const { key: baseKey, context } of keysToGenerate) {
794
+ for (const category of pluralCategories) {
795
+ // 1. Look for the most specific default value
796
+ const specificDefaultKey = isOrdinal ? `defaultValue${pluralSeparator}ordinal${pluralSeparator}${category}` : `defaultValue${pluralSeparator}${category}`
797
+ const specificDefault = getObjectPropValue(options, specificDefaultKey)
798
+
799
+ // 2. Determine the final default value using a clear fallback chain
800
+ let finalDefaultValue: string | undefined
801
+ if (typeof specificDefault === 'string') {
802
+ finalDefaultValue = specificDefault
803
+ } else if (category === 'one' && typeof defaultValue === 'string') {
804
+ finalDefaultValue = defaultValue
805
+ } else if (isOrdinal && typeof ordinalOtherDefault === 'string') {
806
+ finalDefaultValue = ordinalOtherDefault
807
+ } else if (!isOrdinal && typeof otherDefault === 'string') {
808
+ finalDefaultValue = otherDefault
809
+ } else if (typeof defaultValue === 'string') {
810
+ finalDefaultValue = defaultValue
811
+ } else if (defaultValueFromCall && targetCategory === category) {
812
+ finalDefaultValue = defaultValueFromCall
813
+ } else {
814
+ finalDefaultValue = baseKey
815
+ }
747
816
 
748
- this.pluginContext.addKey({
749
- key: finalKey,
750
- ns,
751
- defaultValue: finalDefaultValue,
752
- hasCount: true,
753
- isOrdinal
754
- })
817
+ // 3. Construct the final plural key
818
+ let finalKey: string
819
+ if (context) {
820
+ const contextSeparator = this.config.extract.contextSeparator ?? '_'
821
+ finalKey = isOrdinal
822
+ ? `${baseKey}${contextSeparator}${context}${pluralSeparator}ordinal${pluralSeparator}${category}`
823
+ : `${baseKey}${contextSeparator}${context}${pluralSeparator}${category}`
824
+ } else {
825
+ finalKey = isOrdinal
826
+ ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
827
+ : `${baseKey}${pluralSeparator}${category}`
828
+ }
829
+
830
+ this.pluginContext.addKey({
831
+ key: finalKey,
832
+ ns,
833
+ defaultValue: finalDefaultValue,
834
+ hasCount: true,
835
+ isOrdinal
836
+ })
837
+ }
755
838
  }
756
839
  } catch (e) {
757
840
  this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}". Falling back to simple key extraction.`)
@@ -862,39 +945,85 @@ export class ASTVisitors {
862
945
 
863
946
  // Handle the combination of context and count
864
947
  if (contextExpression && hasCount) {
865
- // Find isOrdinal prop on the <Trans> component
866
- const ordinalAttr = node.opening.attributes?.find(
867
- (attr) =>
868
- attr.type === 'JSXAttribute' &&
869
- attr.name.type === 'Identifier' &&
870
- attr.name.value === 'ordinal'
871
- )
872
- const isOrdinal = !!ordinalAttr
873
-
874
- const contextValues = this.resolvePossibleContextStringValues(contextExpression)
875
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
876
-
877
- // Generate all combinations of context and plural forms
878
- if (contextValues.length > 0) {
879
- // Generate base plural forms (no context)
880
- extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
881
-
882
- // Generate context + plural combinations
883
- for (const context of contextValues) {
884
- for (const extractedKey of extractedKeys) {
885
- const contextKey = `${extractedKey.key}${contextSeparator}${context}`
886
- this.generatePluralKeysForTrans(contextKey, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode)
948
+ // Check if plurals are disabled
949
+ if (this.config.extract.disablePlurals) {
950
+ // When plurals are disabled, treat count as a regular option
951
+ // Still handle context normally
952
+ const contextValues = this.resolvePossibleContextStringValues(contextExpression)
953
+ const contextSeparator = this.config.extract.contextSeparator ?? '_'
954
+
955
+ if (contextValues.length > 0) {
956
+ // For static context (string literal), only add context variants
957
+ if (contextExpression.type === 'StringLiteral') {
958
+ for (const context of contextValues) {
959
+ for (const extractedKey of extractedKeys) {
960
+ const contextKey = `${extractedKey.key}${contextSeparator}${context}`
961
+ this.pluginContext.addKey({ key: contextKey, ns: extractedKey.ns, defaultValue: extractedKey.defaultValue })
962
+ }
963
+ }
964
+ } else {
965
+ // For dynamic context, add both base and context variants
966
+ extractedKeys.forEach(extractedKey => {
967
+ this.pluginContext.addKey({
968
+ key: extractedKey.key,
969
+ ns: extractedKey.ns,
970
+ defaultValue: extractedKey.defaultValue
971
+ })
972
+ })
973
+ for (const context of contextValues) {
974
+ for (const extractedKey of extractedKeys) {
975
+ const contextKey = `${extractedKey.key}${contextSeparator}${context}`
976
+ this.pluginContext.addKey({ key: contextKey, ns: extractedKey.ns, defaultValue: extractedKey.defaultValue })
977
+ }
978
+ }
887
979
  }
980
+ } else {
981
+ // Fallback to just base keys if context resolution fails
982
+ extractedKeys.forEach(extractedKey => {
983
+ this.pluginContext.addKey({
984
+ key: extractedKey.key,
985
+ ns: extractedKey.ns,
986
+ defaultValue: extractedKey.defaultValue
987
+ })
988
+ })
888
989
  }
889
990
  } else {
890
- // Fallback to just plural forms if context resolution fails
891
- extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
991
+ // Original plural handling logic when plurals are enabled
992
+ // Find isOrdinal prop on the <Trans> component
993
+ const ordinalAttr = node.opening.attributes?.find(
994
+ (attr) =>
995
+ attr.type === 'JSXAttribute' &&
996
+ attr.name.type === 'Identifier' &&
997
+ attr.name.value === 'ordinal'
998
+ )
999
+ const isOrdinal = !!ordinalAttr
1000
+
1001
+ const contextValues = this.resolvePossibleContextStringValues(contextExpression)
1002
+ const contextSeparator = this.config.extract.contextSeparator ?? '_'
1003
+
1004
+ // Generate all combinations of context and plural forms
1005
+ if (contextValues.length > 0) {
1006
+ // Generate base plural forms (no context)
1007
+ extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
1008
+
1009
+ // Generate context + plural combinations
1010
+ for (const context of contextValues) {
1011
+ for (const extractedKey of extractedKeys) {
1012
+ const contextKey = `${extractedKey.key}${contextSeparator}${context}`
1013
+ this.generatePluralKeysForTrans(contextKey, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode)
1014
+ }
1015
+ }
1016
+ } else {
1017
+ // Fallback to just plural forms if context resolution fails
1018
+ extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
1019
+ }
892
1020
  }
893
1021
  } else if (contextExpression) {
894
1022
  const contextValues = this.resolvePossibleContextStringValues(contextExpression)
895
1023
  const contextSeparator = this.config.extract.contextSeparator ?? '_'
896
1024
 
897
1025
  if (contextValues.length > 0) {
1026
+ // Add context variants
898
1027
  for (const context of contextValues) {
899
1028
  for (const { key, ns, defaultValue } of extractedKeys) {
900
1029
  this.pluginContext.addKey({ key: `${key}${contextSeparator}${context}`, ns, defaultValue })
@@ -902,22 +1031,57 @@ export class ASTVisitors {
902
1031
  }
903
1032
  // Only add the base key as a fallback if the context is dynamic (i.e., not a simple string).
904
1033
  if (contextExpression.type !== 'StringLiteral') {
905
- extractedKeys.forEach(this.pluginContext.addKey)
1034
+ extractedKeys.forEach(extractedKey => {
1035
+ this.pluginContext.addKey({
1036
+ key: extractedKey.key,
1037
+ ns: extractedKey.ns,
1038
+ defaultValue: extractedKey.defaultValue
1039
+ })
1040
+ })
906
1041
  }
1042
+ } else {
1043
+ // If no context values were resolved, just add base keys
1044
+ extractedKeys.forEach(extractedKey => {
1045
+ this.pluginContext.addKey({
1046
+ key: extractedKey.key,
1047
+ ns: extractedKey.ns,
1048
+ defaultValue: extractedKey.defaultValue
1049
+ })
1050
+ })
907
1051
  }
908
1052
  } else if (hasCount) {
909
- // Find isOrdinal prop on the <Trans> component
910
- const ordinalAttr = node.opening.attributes?.find(
911
- (attr) =>
912
- attr.type === 'JSXAttribute' &&
913
- attr.name.type === 'Identifier' &&
914
- attr.name.value === 'ordinal'
915
- )
916
- const isOrdinal = !!ordinalAttr
1053
+ // Check if plurals are disabled
1054
+ if (this.config.extract.disablePlurals) {
1055
+ // When plurals are disabled, just add the base keys (no plural forms)
1056
+ extractedKeys.forEach(extractedKey => {
1057
+ this.pluginContext.addKey({
1058
+ key: extractedKey.key,
1059
+ ns: extractedKey.ns,
1060
+ defaultValue: extractedKey.defaultValue
1061
+ })
1062
+ })
1063
+ } else {
1064
+ // Original plural handling logic when plurals are enabled
1065
+ // Find isOrdinal prop on the <Trans> component
1066
+ const ordinalAttr = node.opening.attributes?.find(
1067
+ (attr) =>
1068
+ attr.type === 'JSXAttribute' &&
1069
+ attr.name.type === 'Identifier' &&
1070
+ attr.name.value === 'ordinal'
1071
+ )
1072
+ const isOrdinal = !!ordinalAttr
917
1073
 
918
- extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
1074
+ extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
1075
+ }
919
1076
  } else {
920
- extractedKeys.forEach(this.pluginContext.addKey)
1077
+ // No count or context - just add the base keys
1078
+ extractedKeys.forEach(extractedKey => {
1079
+ this.pluginContext.addKey({
1080
+ key: extractedKey.key,
1081
+ ns: extractedKey.ns,
1082
+ defaultValue: extractedKey.defaultValue
1083
+ })
1084
+ })
921
1085
  }
922
1086
  }
923
1087
  }
@@ -1158,17 +1322,49 @@ export class ASTVisitors {
1158
1322
  return [`${expression.value}`] // Handle literals like 5 or true
1159
1323
  }
1160
1324
 
1325
+ // Support building translation keys for
1326
+ // `variable satisfies 'coaching' | 'therapy'`
1327
+ if (expression.type === 'TsSatisfiesExpression' || expression.type === 'TsAsExpression') {
1328
+ const annotation = expression.typeAnnotation
1329
+
1330
+ return this.resolvePossibleStringValuesFromType(annotation, returnEmptyStrings)
1331
+ }
1332
+
1333
+ // We can't statically determine the value of other expressions (e.g., variables, function calls)
1334
+ return []
1335
+ }
1336
+
1337
+ private resolvePossibleStringValuesFromType (type: TsType, returnEmptyStrings = false): string[] {
1338
+ if (type.type === 'TsUnionType') {
1339
+ return type.types.flatMap((t) => this.resolvePossibleStringValuesFromType(t, returnEmptyStrings))
1340
+ }
1341
+
1342
+ if (type.type === 'TsLiteralType') {
1343
+ if (type.literal.type === 'StringLiteral') {
1344
+ // Filter out empty strings as they should be treated as "no context" like i18next does
1345
+ return type.literal.value || returnEmptyStrings ? [type.literal.value] : []
1346
+ }
1347
+
1348
+ if (type.literal.type === 'TemplateLiteral') {
1349
+ return this.resolvePossibleStringValuesFromTemplateLiteralType(type.literal)
1350
+ }
1351
+
1352
+ if (type.literal.type === 'NumericLiteral' || type.literal.type === 'BooleanLiteral') {
1353
+ return [`${type.literal.value}`] // Handle literals like 5 or true
1354
+ }
1355
+ }
1356
+
1161
1357
  // We can't statically determine the value of other expressions (e.g., variables, function calls)
1162
1358
  return []
1163
1359
  }
1164
1360
 
1165
1361
  /**
1166
- * Resolves a template literal to one or more possible string values that can be
1362
+ * Resolves a template literal string to one or more possible strings that can be
1167
1363
  * determined statically from the AST.
1168
1364
  *
1169
1365
  * @private
1170
- * @param templateString - The SWC AST template literal node to resolve
1171
- * @returns An array of possible string values that the template literal may produce.
1366
+ * @param templateString - The SWC AST template literal string to resolve
1367
+ * @returns An array of possible string values that the template may produce.
1172
1368
  */
1173
1369
  private resolvePossibleStringValuesFromTemplateString (templateString: TemplateLiteral): string[] {
1174
1370
  // If there are no expressions, we can just return the cooked value
@@ -1177,7 +1373,7 @@ export class ASTVisitors {
1177
1373
  return [templateString.quasis[0].cooked || '']
1178
1374
  }
1179
1375
 
1180
- // Ex. `translation.key.with.expression.${x ? 'title' : 'description}`
1376
+ // Ex. `translation.key.with.expression.${x ? 'title' : 'description'}`
1181
1377
  const [firstQuasis, ...tails] = templateString.quasis
1182
1378
 
1183
1379
  const stringValues = templateString.expressions.reduce(
@@ -1195,6 +1391,39 @@ export class ASTVisitors {
1195
1391
  return stringValues
1196
1392
  }
1197
1393
 
1394
+ /**
1395
+ * Resolves a template literal type to one or more possible strings that can be
1396
+ * determined statically from the AST.
1397
+ *
1398
+ * @private
1399
+ * @param templateLiteralType - The SWC AST template literal type to resolve
1400
+ * @returns An array of possible string values that the template may produce.
1401
+ */
1402
+ private resolvePossibleStringValuesFromTemplateLiteralType (templateLiteralType: TsTemplateLiteralType): string[] {
1403
+ // If there are no types, we can just return the cooked value
1404
+ if (templateLiteralType.quasis.length === 1 && templateLiteralType.types.length === 0) {
1405
+ // Ex. `translation.key.no.substitution`
1406
+ return [templateLiteralType.quasis[0].cooked || '']
1407
+ }
1408
+
1409
+ // Ex. `translation.key.with.expression.${'title' | 'description'}`
1410
+ const [firstQuasis, ...tails] = templateLiteralType.quasis
1411
+
1412
+ const stringValues = templateLiteralType.types.reduce(
1413
+ (heads, type, i) => {
1414
+ return heads.flatMap((head) => {
1415
+ const tail = tails[i]?.cooked ?? ''
1416
+ return this.resolvePossibleStringValuesFromType(type, true).map(
1417
+ (expressionValue) => `${head}${expressionValue}${tail}`
1418
+ )
1419
+ })
1420
+ },
1421
+ [firstQuasis.cooked ?? '']
1422
+ )
1423
+
1424
+ return stringValues
1425
+ }
1426
+
1198
1427
  /**
1199
1428
  * Finds the configuration for a given useTranslation function name.
1200
1429
  * Applies default argument positions if none are specified.
@@ -39,6 +39,12 @@ export function extractKeysFromComments (
39
39
  let match: RegExpExecArray | null
40
40
  while ((match = keyRegex.exec(text)) !== null) {
41
41
  let key = match[2]
42
+
43
+ // Validate that the key is not empty or whitespace-only
44
+ if (!key || key.trim() === '') {
45
+ continue // Skip empty keys
46
+ }
47
+
42
48
  let ns: string | undefined
43
49
  const remainder = text.slice(match.index + match[0].length)
44
50
 
@@ -54,6 +60,11 @@ export function extractKeysFromComments (
54
60
  isOrdinalByKey = true
55
61
  // Normalize the key by stripping the suffix
56
62
  key = key.slice(0, -(pluralSeparator.length + 7)) // Remove "_ordinal"
63
+
64
+ // Validate that the key is still not empty after normalization
65
+ if (!key || key.trim() === '') {
66
+ continue // Skip keys that become empty after normalization
67
+ }
57
68
  }
58
69
 
59
70
  const isOrdinal = ordinal === true || isOrdinalByKey
@@ -81,21 +92,38 @@ export function extractKeysFromComments (
81
92
  // 4. Final fallback to configured default namespace
82
93
  if (!ns) ns = config.extract.defaultNS
83
94
 
84
- // 5. Handle context and count combinations
85
- if (context && count) {
86
- // Generate all combinations: base plural + context+plural
87
- generatePluralKeys(key, defaultValue ?? key, ns, pluginContext, config, isOrdinal)
88
- generateContextPluralKeys(key, defaultValue ?? key, ns, context, pluginContext, config, isOrdinal)
89
- } else if (context) {
90
- // Just context variants
91
- pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key })
92
- pluginContext.addKey({ key: `${key}_${context}`, ns, defaultValue: defaultValue ?? key })
93
- } else if (count) {
94
- // Just plural variants
95
- generatePluralKeys(key, defaultValue ?? key, ns, pluginContext, config, isOrdinal)
95
+ // 5. Handle context and count combinations based on disablePlurals setting
96
+ if (config.extract.disablePlurals) {
97
+ // When plurals are disabled, ignore count for key generation
98
+ if (context) {
99
+ // Only generate context variants (no base key when context is static)
100
+ pluginContext.addKey({ key: `${key}_${context}`, ns, defaultValue: defaultValue ?? key })
101
+ } else {
102
+ // Simple key (ignore count)
103
+ pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key })
104
+ }
96
105
  } else {
97
- // Simple key
98
- pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key })
106
+ // Original plural handling logic when plurals are enabled
107
+ if (context && count) {
108
+ // Generate context+plural combinations
109
+ generateContextPluralKeys(key, defaultValue ?? key, ns, context, pluginContext, config, isOrdinal)
110
+
111
+ // Only generate base plural forms if generateBasePluralForms is not disabled
112
+ const shouldGenerateBaseForms = config.extract?.generateBasePluralForms !== false
113
+ if (shouldGenerateBaseForms) {
114
+ generatePluralKeys(key, defaultValue ?? key, ns, pluginContext, config, isOrdinal)
115
+ }
116
+ } else if (context) {
117
+ // Just context variants
118
+ pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key })
119
+ pluginContext.addKey({ key: `${key}_${context}`, ns, defaultValue: defaultValue ?? key })
120
+ } else if (count) {
121
+ // Just plural variants
122
+ generatePluralKeys(key, defaultValue ?? key, ns, pluginContext, config, isOrdinal)
123
+ } else {
124
+ // Simple key
125
+ pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key })
126
+ }
99
127
  }
100
128
  }
101
129
  }
@@ -114,8 +142,24 @@ function generatePluralKeys (
114
142
  ): void {
115
143
  try {
116
144
  const type = isOrdinal ? 'ordinal' : 'cardinal'
117
- const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en'
118
- const pluralCategories = new Intl.PluralRules(primaryLanguage, { type }).resolvedOptions().pluralCategories
145
+
146
+ // Generate plural forms for ALL target languages to ensure we have all necessary keys
147
+ const allPluralCategories = new Set<string>()
148
+
149
+ for (const locale of config.locales) {
150
+ try {
151
+ const pluralRules = new Intl.PluralRules(locale, { type })
152
+ const categories = pluralRules.resolvedOptions().pluralCategories
153
+ categories.forEach(cat => allPluralCategories.add(cat))
154
+ } catch (e) {
155
+ // If a locale is invalid, fall back to English rules
156
+ const englishRules = new Intl.PluralRules('en', { type })
157
+ const categories = englishRules.resolvedOptions().pluralCategories
158
+ categories.forEach(cat => allPluralCategories.add(cat))
159
+ }
160
+ }
161
+
162
+ const pluralCategories = Array.from(allPluralCategories).sort()
119
163
  const pluralSeparator = config.extract.pluralSeparator ?? '_'
120
164
 
121
165
  // Generate keys for each plural category
@@ -152,8 +196,24 @@ function generateContextPluralKeys (
152
196
  ): void {
153
197
  try {
154
198
  const type = isOrdinal ? 'ordinal' : 'cardinal'
155
- const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en'
156
- const pluralCategories = new Intl.PluralRules(primaryLanguage, { type }).resolvedOptions().pluralCategories
199
+
200
+ // Generate plural forms for ALL target languages to ensure we have all necessary keys
201
+ const allPluralCategories = new Set<string>()
202
+
203
+ for (const locale of config.locales) {
204
+ try {
205
+ const pluralRules = new Intl.PluralRules(locale, { type })
206
+ const categories = pluralRules.resolvedOptions().pluralCategories
207
+ categories.forEach(cat => allPluralCategories.add(cat))
208
+ } catch (e) {
209
+ // If a locale is invalid, fall back to English rules
210
+ const englishRules = new Intl.PluralRules(config.extract.primaryLanguage || 'en', { type })
211
+ const categories = englishRules.resolvedOptions().pluralCategories
212
+ categories.forEach(cat => allPluralCategories.add(cat))
213
+ }
214
+ }
215
+
216
+ const pluralCategories = Array.from(allPluralCategories).sort()
157
217
  const pluralSeparator = config.extract.pluralSeparator ?? '_'
158
218
 
159
219
  // Generate keys for each context + plural combination