i18next-cli 1.34.0 → 1.35.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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/cli.js +271 -1
  3. package/dist/cjs/config.js +211 -1
  4. package/dist/cjs/extractor/core/ast-visitors.js +372 -1
  5. package/dist/cjs/extractor/core/extractor.js +245 -1
  6. package/dist/cjs/extractor/core/key-finder.js +132 -1
  7. package/dist/cjs/extractor/core/translation-manager.js +745 -1
  8. package/dist/cjs/extractor/parsers/ast-utils.js +85 -1
  9. package/dist/cjs/extractor/parsers/call-expression-handler.js +942 -1
  10. package/dist/cjs/extractor/parsers/comment-parser.js +375 -1
  11. package/dist/cjs/extractor/parsers/expression-resolver.js +362 -1
  12. package/dist/cjs/extractor/parsers/jsx-handler.js +492 -1
  13. package/dist/cjs/extractor/parsers/jsx-parser.js +355 -1
  14. package/dist/cjs/extractor/parsers/scope-manager.js +408 -1
  15. package/dist/cjs/extractor/plugin-manager.js +106 -1
  16. package/dist/cjs/heuristic-config.js +99 -1
  17. package/dist/cjs/index.js +28 -1
  18. package/dist/cjs/init.js +174 -1
  19. package/dist/cjs/linter.js +431 -1
  20. package/dist/cjs/locize.js +269 -1
  21. package/dist/cjs/migrator.js +196 -1
  22. package/dist/cjs/rename-key.js +354 -1
  23. package/dist/cjs/status.js +336 -1
  24. package/dist/cjs/syncer.js +120 -1
  25. package/dist/cjs/types-generator.js +165 -1
  26. package/dist/cjs/utils/default-value.js +43 -1
  27. package/dist/cjs/utils/file-utils.js +136 -1
  28. package/dist/cjs/utils/funnel-msg-tracker.js +75 -1
  29. package/dist/cjs/utils/logger.js +36 -1
  30. package/dist/cjs/utils/nested-object.js +124 -1
  31. package/dist/cjs/utils/validation.js +71 -1
  32. package/dist/esm/cli.js +269 -1
  33. package/dist/esm/config.js +206 -1
  34. package/dist/esm/extractor/core/ast-visitors.js +370 -1
  35. package/dist/esm/extractor/core/extractor.js +241 -1
  36. package/dist/esm/extractor/core/key-finder.js +130 -1
  37. package/dist/esm/extractor/core/translation-manager.js +743 -1
  38. package/dist/esm/extractor/parsers/ast-utils.js +80 -1
  39. package/dist/esm/extractor/parsers/call-expression-handler.js +940 -1
  40. package/dist/esm/extractor/parsers/comment-parser.js +373 -1
  41. package/dist/esm/extractor/parsers/expression-resolver.js +360 -1
  42. package/dist/esm/extractor/parsers/jsx-handler.js +490 -1
  43. package/dist/esm/extractor/parsers/jsx-parser.js +334 -1
  44. package/dist/esm/extractor/parsers/scope-manager.js +406 -1
  45. package/dist/esm/extractor/plugin-manager.js +103 -1
  46. package/dist/esm/heuristic-config.js +97 -1
  47. package/dist/esm/index.js +11 -1
  48. package/dist/esm/init.js +172 -1
  49. package/dist/esm/linter.js +425 -1
  50. package/dist/esm/locize.js +265 -1
  51. package/dist/esm/migrator.js +194 -1
  52. package/dist/esm/rename-key.js +352 -1
  53. package/dist/esm/status.js +334 -1
  54. package/dist/esm/syncer.js +118 -1
  55. package/dist/esm/types-generator.js +163 -1
  56. package/dist/esm/utils/default-value.js +41 -1
  57. package/dist/esm/utils/file-utils.js +131 -1
  58. package/dist/esm/utils/funnel-msg-tracker.js +72 -1
  59. package/dist/esm/utils/logger.js +34 -1
  60. package/dist/esm/utils/nested-object.js +120 -1
  61. package/dist/esm/utils/validation.js +68 -1
  62. package/package.json +2 -2
  63. package/types/extractor/core/ast-visitors.d.ts.map +1 -1
  64. package/types/extractor/parsers/call-expression-handler.d.ts +3 -2
  65. package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
  66. package/types/locize.d.ts.map +1 -1
@@ -1 +1,745 @@
1
- "use strict";var e=require("node:path"),t=require("glob"),n=require("../../utils/nested-object.js"),r=require("../../utils/file-utils.js"),s=require("../../utils/default-value.js");const a=[" ",",","?","!",";"];function o(e){const t=`^${e.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*")}$`;return new RegExp(t)}function i(e,t,n,r){if(0===t.size)return!1;let s=e;const a=["zero","one","two","few","many","other"];for(const e of a){if(s.endsWith(`${n}${e}`)){s=s.slice(0,-(n.length+e.length));break}if(s.endsWith(`${n}ordinal${n}${e}`)){s=s.slice(0,-(n.length+7+n.length+e.length));break}}const o=s.split(r);if(o.length>1)for(let e=1;e<o.length;e++){const n=o.slice(0,-e).join(r);if(t.has(n))return!0}return!!t.has(s)}function l(e,t,n){if(!n)return!0;let r=!n.test(e);if(!r){const s=e.indexOf(t);s>0&&!n.test(e.substring(0,s))&&(r=!0)}return r}function c(e,t,n){if("object"!=typeof e||null===e||Array.isArray(e))return e;const r={},s=t?.extract?.pluralSeparator??"_",a=["zero","one","two","few","many","other"],o=a.map(e=>`ordinal${s}${e}`),i=Object.keys(e).sort((e,t)=>{const r=e=>{for(const t of o)if(e.endsWith(`${s}${t}`)){return{base:e.slice(0,-(s.length+t.length)),form:t,isOrdinal:!0,isPlural:!0,fullKey:e}}for(const t of a)if(e.endsWith(`${s}${t}`)){return{base:e.slice(0,-(s.length+t.length)),form:t,isOrdinal:!1,isPlural:!0,fullKey:e}}return{base:e,form:"",isOrdinal:!1,isPlural:!1,fullKey:e}},i=r(e),l=r(t);if(i.isPlural&&l.isPlural){const e=n?n(i.base,l.base):i.base.localeCompare(l.base,void 0,{sensitivity:"base"});if(0!==e)return e;if(i.isOrdinal!==l.isOrdinal)return i.isOrdinal?1:-1;const t=i.isOrdinal?o:a,r=t.indexOf(i.form),s=t.indexOf(l.form);return-1!==r&&-1!==s?r-s:i.form.localeCompare(l.form)}if(n)return n(e,t);const c=e.localeCompare(t,void 0,{sensitivity:"base"});return 0===c?e.localeCompare(t,void 0,{sensitivity:"case"}):c});for(const s of i)r[s]=c(e[s],t,n);return r}function u(e,t,r,u,f,d=[],p=new Set,g=!1,h=!1){const{keySeparator:y=".",sort:x=!0,removeUnusedKeys:$=!0,primaryLanguage:S,defaultValue:m="",pluralSeparator:O="_",contextSeparator:v="_",preserveContextVariants:N=!1}=r.extract,w="string"==typeof r.extract.nsSeparator?r.extract.nsSeparator:":",b=r.extract.defaultValue,k=(e,t,n)=>{if(n)return!1;if(null==t)return!0;const r=String(t);if(r===e)return!0;if(w&&f&&r===`${f}${w}${e}`)return!0;if("string"==typeof y&&y.length>0&&e.endsWith(`${y}${r}`))return!0;if(r&&e!==r){if(e.startsWith(r+O))return!0;if(e.startsWith(r+v))return!0}return!1},j=a.filter(e=>w.indexOf(e)<0&&("string"!=typeof y||y.indexOf(e)<0)),V=j.length>0?new RegExp(`(${j.map(e=>"?"===e?"\\?":e).join("|")})`):null,P=new Set;if(N)for(const{keyAcceptingContext:t}of e)t&&P.add(t);const W=new Set;let C=[],_=[];try{const e=new Intl.PluralRules(u,{type:"cardinal"}),t=new Intl.PluralRules(u,{type:"ordinal"});C=e.resolvedOptions().pluralCategories,_=t.resolvedOptions().pluralCategories,C.forEach(e=>W.add(e)),t.resolvedOptions().pluralCategories.forEach(e=>W.add(`ordinal_${e}`))}catch(e){const t=S||"en",n=new Intl.PluralRules(t,{type:"cardinal"}),r=new Intl.PluralRules(t,{type:"ordinal"});C=n.resolvedOptions().pluralCategories,_=r.resolvedOptions().pluralCategories,C.forEach(e=>W.add(e)),r.resolvedOptions().pluralCategories.forEach(e=>W.add(`ordinal_${e}`))}const E=r.extract.preservePatterns||[],A=e=>{if(d.some(t=>t.test(e)))return!0;for(const t of E)if("string"==typeof t){if(t.endsWith(`${w}*`)){const e=t.slice(0,-(w.length+1));if("*"===e||f&&e===f)return!0}if(t.includes(w)&&f){const[n,r]=t.split(w);if(n===f){if(o(r).test(e))return!0}}}return!1},D=e.filter(({key:e,hasCount:t,isOrdinal:n})=>{if((e=>{if(d.some(t=>t.test(e)))return!0;for(const e of E)if("string"==typeof e&&e.endsWith(`${w}*`)){const t=e.slice(0,-(w.length+1));if("*"===t||f&&t===f)return!0}return!1})(e))return!1;if(!t)return!0;const r=e.split(O);if(t&&1===r.length)return!0;if(1===C.length&&"other"===C[0]&&1===r.length)return!0;if(n&&r.includes("ordinal")){const e=r[r.length-1];return W.has(`ordinal_${e}`)}if(t){const e=r[r.length-1];return W.has(e)}return!0}),R=new Set;for(const e of D)if(e.isExpandedPlural){const t=String(e.key).split(O);t.length>=3&&"ordinal"===t[t.length-2]?R.add(t.slice(0,-2).join(O)):R.add(t.slice(0,-1).join(O))}let T=$?{}:JSON.parse(JSON.stringify(t));const I=n.getNestedKeys(t,y??".");for(const e of I){const r=A(e),s=!r&&i(e,P,O,v);if(r||N&&s){const r=n.getNestedValue(t,e,y??".");n.setNestedValue(T,e,r,y??".")}}if($){const e=n.getNestedKeys(t,y??".");for(const r of e){const e=r.split(O);if("zero"===e[e.length-1]){const s=e.slice(0,-1).join(O);if(D.some(({key:e})=>e.split(O).slice(0,-1).join(O)===s)){const e=n.getNestedValue(t,r,y??".");n.setNestedValue(T,r,e,y??".")}}}}for(const{key:e,defaultValue:a,explicitDefault:o,hasCount:i,isExpandedPlural:c,isOrdinal:d}of D){if(i&&!c){const t=String(e).split(O);let n=e;if(t.length>=3&&"ordinal"===t[t.length-2]?n=t.slice(0,-2).join(O):t.length>=2&&(n=t.slice(0,-1).join(O)),R.has(n))continue}if(i&&!c){if(1===String(e).split(O).length&&u!==S){const o=e;if(R.has(o));else{const e=d?_:C;for(const i of e){const e=d?`${o}${O}ordinal${O}${i}`:`${o}${O}${i}`,l=!e.startsWith("<")&&(y??"."),c=n.getNestedValue(t,e,l);if(void 0===c){let t;t="string"==typeof a?a:s.resolveDefaultValue(m,String(o),f||r?.extract?.defaultNS||"translation",u,a),n.setNestedValue(T,e,t,l)}else n.setNestedValue(T,e,c,l)}}continue}}let x=!e.startsWith("<")&&(y??".");x&&"string"==typeof x&&(l(e,x,V)||(x=!1));const $=n.getNestedValue(t,e,x),N=!1===y||!D.some(t=>t.key!==e&&t.key.startsWith(`${e}${y}`)),j="object"==typeof $&&null!==$&&(p.has(e)||!a||a===e),P="object"==typeof $&&null!==$&&N&&!p.has(e)&&!j;if(j){n.setNestedValue(T,e,$,x);continue}let W;if(void 0===$||P)if(u===S)if(g){const t=k(e,a,o);W=a&&!t?a:s.resolveDefaultValue(m,e,f||r?.extract?.defaultNS||"translation",u,a)}else{W=k(e,a,o)&&void 0!==b?s.resolveDefaultValue(b,e,f||r?.extract?.defaultNS||"translation",u,a):a||e}else W=s.resolveDefaultValue(m,e,f||r?.extract?.defaultNS||"translation",u,a);else if(u===S&&g){const t=a&&(a===e||w&&f&&a===`${f}${w}${e}`||e!==a&&(e.startsWith(a+O)||e.startsWith(a+v)));W=(e.includes(O)||e.includes(v))&&!o?$:a&&!t?s.resolveDefaultValue(a,e,f||r?.extract?.defaultNS||"translation",u,a):$}else W=h&&u!==S&&o?s.resolveDefaultValue(m,e,f||r?.extract?.defaultNS||"translation",u,a):$;n.setNestedValue(T,e,W,x)}if(!0===x)return c(T,r);if("function"==typeof x){const t={},n=Object.keys(T),s=new Map;for(const t of e)if(s.set(String(t.key),t),y){const e=String(t.key).split(y)[0];s.has(e)||s.set(e,t)}const a=(e,t)=>{const n=s.get(e),r=s.get(t);if(n&&r)return x(n,r);return x({key:e},{key:t})};n.sort(a);for(const e of n)t[e]=c(T[e],r,a);T=t}return T}exports.getTranslations=async function(n,s,i,{syncPrimaryWithDefaults:l=!1,syncAll:c=!1}={}){i.extract.primaryLanguage||=i.locales[0]||"en",i.extract.secondaryLanguages||=i.locales.filter(e=>e!==i?.extract?.primaryLanguage);const f=[...i.extract.preservePatterns||[]],d=i.extract.indentation??2;for(const e of s)f.push(`${e}.*`);const p=f.map(o),g="__no_namespace__",h=new Map,y="string"==typeof i.extract.nsSeparator?i.extract.nsSeparator:":",x=new RegExp(`(${a.map(e=>"?"===e?"\\?":e).join("|")})`);for(const e of n.values()){let t=e.ns,n=e.key;t&&x.test(t)&&(n=`${t}${y}${n}`,t=void 0);const r=e.nsIsImplicit&&!1===i.extract.defaultNS?g:String(t??i.extract.defaultNS??"translation");h.has(r)||h.set(r,[]),t!==e.ns||n!==e.key?h.get(r).push({...e,ns:t,key:n}):h.get(r).push(e)}const $=[],S=Array.isArray(i.extract.ignore)?i.extract.ignore:i.extract.ignore?[i.extract.ignore]:[];for(const n of i.locales){if(i.extract.mergeNamespaces||"string"==typeof i.extract.output&&!i.extract.output.includes("{{namespace}}")){const t={},a=r.getOutputPath(i.extract.output,n),o=e.resolve(process.cwd(),a),c=await r.loadTranslationFile(o)||{},f=Object.keys(c),y=!1!==i.extract.defaultNS&&f.some(e=>{const t=c[e];return"object"==typeof t&&null!==t&&!Array.isArray(t)})?new Set([...h.keys(),...f]):new Set([...h.keys(),g]);for(const e of y){const r=h.get(e)||[];if(e===g){const e=u(r,c,i,n,void 0,p,s,l);Object.assign(t,e)}else{const a=c[e]||{};t[e]=u(r,a,i,n,e,p,s,l)}}const x=JSON.stringify(c,null,d),S=JSON.stringify(t,null,d);$.push({path:o,updated:S!==x,newTranslations:t,existingTranslations:c})}else{const a=new Set(h.keys()),o=r.getOutputPath(i.extract.output,n,"*").replace(/\\/g,"/"),f=await t.glob(o,{ignore:S});for(const t of f)a.add(e.basename(t,e.extname(t)));for(const t of a){const a=h.get(t)||[],o=r.getOutputPath(i.extract.output,n,t),f=e.resolve(process.cwd(),o),g=await r.loadTranslationFile(f)||{},y=u(a,g,i,n,t,p,s,l,c),x=JSON.stringify(g,null,d),S=JSON.stringify(y,null,d);$.push({path:f,updated:S!==x,newTranslations:y,existingTranslations:g})}}}return $};
1
+ 'use strict';
2
+
3
+ var node_path = require('node:path');
4
+ var glob = require('glob');
5
+ var nestedObject = require('../../utils/nested-object.js');
6
+ var fileUtils = require('../../utils/file-utils.js');
7
+ var defaultValue = require('../../utils/default-value.js');
8
+
9
+ // used for natural language check
10
+ const chars = [' ', ',', '?', '!', ';'];
11
+ /**
12
+ * Converts a glob pattern to a regular expression for matching keys
13
+ * @param glob - The glob pattern to convert
14
+ * @returns A RegExp object that matches the glob pattern
15
+ */
16
+ function globToRegex(glob) {
17
+ const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
18
+ const regexString = `^${escaped.replace(/\*/g, '.*')}$`;
19
+ return new RegExp(regexString);
20
+ }
21
+ /**
22
+ * Checks if an existing key is a context variant of a base key that accepts context.
23
+ * This function handles complex cases where:
24
+ * - The key might have plural suffixes (_one, _other, etc.)
25
+ * - The context value itself might contain the separator (e.g., mc_laren)
26
+ *
27
+ * @param existingKey - The key from the translation file to check
28
+ * @param keysAcceptingContext - Set of base keys that were used with context in source code
29
+ * @param pluralSeparator - The separator used for plural forms (default: '_')
30
+ * @param contextSeparator - The separator used for context variants (default: '_')
31
+ * @returns true if the existing key is a context variant of a key accepting context
32
+ */
33
+ function isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator) {
34
+ if (keysAcceptingContext.size === 0) {
35
+ return false;
36
+ }
37
+ // Try to extract the base key from this existing key by removing context and/or plural suffixes
38
+ let potentialBaseKey = existingKey;
39
+ // First, try removing plural suffixes if present
40
+ const pluralForms = ['zero', 'one', 'two', 'few', 'many', 'other'];
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
+ /**
75
+ * Checks if a key looks like an object path or natural language.
76
+ * (like in i18next)
77
+ */
78
+ function looksLikeObjectPath(key, separator, regex) {
79
+ if (!regex)
80
+ return true;
81
+ let matched = !regex.test(key);
82
+ if (!matched) {
83
+ const ki = key.indexOf(separator);
84
+ if (ki > 0 && !regex.test(key.substring(0, ki))) {
85
+ matched = true;
86
+ }
87
+ }
88
+ return matched;
89
+ }
90
+ /**
91
+ * Recursively sorts the keys of an object.
92
+ */
93
+ function sortObject(obj, config, customSort) {
94
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
95
+ return obj;
96
+ }
97
+ const sortedObj = {};
98
+ const pluralSeparator = config?.extract?.pluralSeparator ?? '_';
99
+ // Define the canonical order for plural forms
100
+ const pluralOrder = ['zero', 'one', 'two', 'few', 'many', 'other'];
101
+ const ordinalPluralOrder = pluralOrder.map(form => `ordinal${pluralSeparator}${form}`);
102
+ const keys = Object.keys(obj).sort((a, b) => {
103
+ // Helper function to extract base key and form info
104
+ const getKeyInfo = (key) => {
105
+ // Handle ordinal plurals: key_ordinal_form or key_context_ordinal_form
106
+ for (const form of ordinalPluralOrder) {
107
+ if (key.endsWith(`${pluralSeparator}${form}`)) {
108
+ const base = key.slice(0, -(pluralSeparator.length + form.length));
109
+ return { base, form, isOrdinal: true, isPlural: true, fullKey: key };
110
+ }
111
+ }
112
+ // Handle cardinal plurals: key_form or key_context_form
113
+ for (const form of pluralOrder) {
114
+ if (key.endsWith(`${pluralSeparator}${form}`)) {
115
+ const base = key.slice(0, -(pluralSeparator.length + form.length));
116
+ return { base, form, isOrdinal: false, isPlural: true, fullKey: key };
117
+ }
118
+ }
119
+ return { base: key, form: '', isOrdinal: false, isPlural: false, fullKey: key };
120
+ };
121
+ const aInfo = getKeyInfo(a);
122
+ const bInfo = getKeyInfo(b);
123
+ // If both are plural forms
124
+ if (aInfo.isPlural && bInfo.isPlural) {
125
+ // First compare by base key
126
+ const baseComparison = customSort
127
+ ? customSort(aInfo.base, bInfo.base)
128
+ : aInfo.base.localeCompare(bInfo.base, undefined, { sensitivity: 'base' });
129
+ if (baseComparison !== 0) {
130
+ return baseComparison;
131
+ }
132
+ // Same base key - now sort by plural form order
133
+ // Ordinal forms come after cardinal forms
134
+ if (aInfo.isOrdinal !== bInfo.isOrdinal) {
135
+ return aInfo.isOrdinal ? 1 : -1;
136
+ }
137
+ // Both same type (cardinal or ordinal), sort by canonical order
138
+ const orderArray = aInfo.isOrdinal ? ordinalPluralOrder : pluralOrder;
139
+ const aIndex = orderArray.indexOf(aInfo.form);
140
+ const bIndex = orderArray.indexOf(bInfo.form);
141
+ if (aIndex !== -1 && bIndex !== -1) {
142
+ return aIndex - bIndex;
143
+ }
144
+ // Fallback to alphabetical if forms not found in order array
145
+ return aInfo.form.localeCompare(bInfo.form);
146
+ }
147
+ // Use custom sort if provided, otherwise default sorting
148
+ if (customSort) {
149
+ return customSort(a, b);
150
+ }
151
+ // Default: case-insensitive, then by case
152
+ const caseInsensitiveComparison = a.localeCompare(b, undefined, { sensitivity: 'base' });
153
+ if (caseInsensitiveComparison === 0) {
154
+ return a.localeCompare(b, undefined, { sensitivity: 'case' });
155
+ }
156
+ return caseInsensitiveComparison;
157
+ });
158
+ for (const key of keys) {
159
+ sortedObj[key] = sortObject(obj[key], config, customSort);
160
+ }
161
+ return sortedObj;
162
+ }
163
+ /**
164
+ * A helper function to build a new translation object for a single namespace.
165
+ * This centralizes the core logic of merging keys.
166
+ */
167
+ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, namespace, preservePatterns = [], objectKeys = new Set(), syncPrimaryWithDefaults = false, syncAll = false) {
168
+ const { keySeparator = '.', sort = true, removeUnusedKeys = true, primaryLanguage, defaultValue: emptyDefaultValue = '', pluralSeparator = '_', contextSeparator = '_', preserveContextVariants = false, } = config.extract;
169
+ const nsSep = typeof config.extract.nsSeparator === 'string' ? config.extract.nsSeparator : ':';
170
+ // Keep the raw configured defaultValue so we can distinguish:
171
+ // - "not provided" (undefined) vs
172
+ // - "provided as empty string" ('')
173
+ const configuredDefaultValue = config.extract.defaultValue;
174
+ // Treat "defaultValue that equals the key (or a suffix of it)" as a derived fallback.
175
+ // This happens for:
176
+ // - plain calls without a code default: t('hello')
177
+ // - keyPrefix calls where the stored key is prefixed but defaultValue is the unprefixed part:
178
+ // key="nested.another.key", defaultValue="another.key"
179
+ const isDerivedFromKey = (key, defaultValue, explicitDefault) => {
180
+ if (explicitDefault)
181
+ return false;
182
+ if (defaultValue === undefined || defaultValue === null)
183
+ return true;
184
+ const dv = String(defaultValue);
185
+ // Exact fallback
186
+ if (dv === key)
187
+ return true;
188
+ // Namespace:key fallback
189
+ if (nsSep && namespace && dv === `${namespace}${nsSep}${key}`)
190
+ return true;
191
+ // keyPrefix-style fallback: defaultValue is a suffix of the full key
192
+ // Example: key="nested.key", dv="key" OR key="nested.another.key", dv="another.key"
193
+ if (typeof keySeparator === 'string' && keySeparator.length > 0) {
194
+ if (key.endsWith(`${keySeparator}${dv}`))
195
+ return true;
196
+ }
197
+ // Plural/context variants sometimes store base as default; keep existing logic parity
198
+ if (dv && key !== dv) {
199
+ if (key.startsWith(dv + pluralSeparator))
200
+ return true;
201
+ if (key.startsWith(dv + contextSeparator))
202
+ return true;
203
+ }
204
+ return false;
205
+ };
206
+ // Prepare regex for natural language detection
207
+ const possibleChars = chars.filter((c) => nsSep.indexOf(c) < 0 && (typeof keySeparator === 'string' ? keySeparator.indexOf(c) < 0 : true));
208
+ const naturalLanguageRegex = possibleChars.length > 0
209
+ ? new RegExp(`(${possibleChars.map((c) => (c === '?' ? '\\?' : c)).join('|')})`)
210
+ : null;
211
+ // Build a set of base keys that accept context (only if preserveContextVariants is enabled)
212
+ // These are keys that were called with a context parameter in the source code
213
+ const keysAcceptingContext = new Set();
214
+ if (preserveContextVariants) {
215
+ for (const { keyAcceptingContext } of nsKeys) {
216
+ if (keyAcceptingContext) {
217
+ keysAcceptingContext.add(keyAcceptingContext);
218
+ }
219
+ }
220
+ }
221
+ // Get the plural categories for the target language
222
+ const targetLanguagePluralCategories = new Set();
223
+ // Track cardinal plural categories separately so we can special-case single-"other" languages
224
+ let cardinalCategories = [];
225
+ let ordinalCategories = [];
226
+ try {
227
+ const cardinalRules = new Intl.PluralRules(locale, { type: 'cardinal' });
228
+ const ordinalRules = new Intl.PluralRules(locale, { type: 'ordinal' });
229
+ cardinalCategories = cardinalRules.resolvedOptions().pluralCategories;
230
+ ordinalCategories = ordinalRules.resolvedOptions().pluralCategories;
231
+ cardinalCategories.forEach(cat => targetLanguagePluralCategories.add(cat));
232
+ ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`));
233
+ }
234
+ catch (e) {
235
+ // Fallback to primaryLanguage (or English) if locale is invalid
236
+ const fallbackLang = primaryLanguage || 'en';
237
+ const cardinalRules = new Intl.PluralRules(fallbackLang, { type: 'cardinal' });
238
+ const ordinalRules = new Intl.PluralRules(fallbackLang, { type: 'ordinal' });
239
+ cardinalCategories = cardinalRules.resolvedOptions().pluralCategories;
240
+ ordinalCategories = ordinalRules.resolvedOptions().pluralCategories;
241
+ cardinalCategories.forEach(cat => targetLanguagePluralCategories.add(cat));
242
+ ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`));
243
+ }
244
+ // Prepare namespace pattern checking helpers
245
+ const rawPreserve = config.extract.preservePatterns || [];
246
+ // Helper to check if a key should be filtered out during extraction
247
+ const shouldFilterKey = (key) => {
248
+ // 1) regex based patterns (existing behavior)
249
+ if (preservePatterns.some(re => re.test(key))) {
250
+ return true;
251
+ }
252
+ // 2) namespace:* style patterns (respect nsSeparator)
253
+ for (const rp of rawPreserve) {
254
+ if (typeof rp !== 'string')
255
+ continue;
256
+ if (rp.endsWith(`${nsSep}*`)) {
257
+ const nsPrefix = rp.slice(0, -(nsSep.length + 1));
258
+ // If namespace is provided to this builder, and pattern targets this namespace, skip keys from this ns
259
+ // Support wildcard namespace '*' to match any namespace
260
+ if (nsPrefix === '*' || (namespace && nsPrefix === namespace)) {
261
+ return true;
262
+ }
263
+ }
264
+ }
265
+ return false;
266
+ };
267
+ // Helper to check if an existing key should be preserved
268
+ const shouldPreserveExistingKey = (key) => {
269
+ // 1) regex-style patterns
270
+ if (preservePatterns.some(re => re.test(key))) {
271
+ return true;
272
+ }
273
+ // 2) namespace:key patterns - check if pattern matches this namespace:key combination
274
+ for (const rp of rawPreserve) {
275
+ if (typeof rp !== 'string')
276
+ continue;
277
+ // Handle namespace:* patterns
278
+ if (rp.endsWith(`${nsSep}*`)) {
279
+ const nsPrefix = rp.slice(0, -(nsSep.length + 1));
280
+ if (nsPrefix === '*' || (namespace && nsPrefix === namespace)) {
281
+ return true;
282
+ }
283
+ }
284
+ // Handle namespace:specificKey patterns (e.g., 'other:okey', 'other:second*')
285
+ if (rp.includes(nsSep) && namespace) {
286
+ const [patternNs, patternKey] = rp.split(nsSep);
287
+ if (patternNs === namespace) {
288
+ // Convert the key part to regex (handle wildcards)
289
+ const keyRegex = globToRegex(patternKey);
290
+ if (keyRegex.test(key)) {
291
+ return true;
292
+ }
293
+ }
294
+ }
295
+ }
296
+ return false;
297
+ };
298
+ // Filter nsKeys to only include keys relevant to this language
299
+ const filteredKeys = nsKeys.filter(({ key, hasCount, isOrdinal }) => {
300
+ // FIRST: Check if key matches preservePatterns and should be excluded
301
+ if (shouldFilterKey(key)) {
302
+ return false;
303
+ }
304
+ if (!hasCount) {
305
+ // Non-plural keys are always included
306
+ return true;
307
+ }
308
+ // For plural keys, check if this specific plural form is needed for the target language
309
+ const keyParts = key.split(pluralSeparator);
310
+ // If this is a base plural key (no plural suffix), keep it so that the
311
+ // builder can expand it to the target locale's plural forms.
312
+ if (hasCount && keyParts.length === 1) {
313
+ return true;
314
+ }
315
+ // Special-case single-cardinal-"other" languages (ja/zh/ko etc.):
316
+ // when the target language's cardinal categories are exactly ['other'],
317
+ // the extractor may have emitted the base key (no "_other" suffix).
318
+ // Accept the base key in that situation, while still accepting explicit *_other variants.
319
+ if (cardinalCategories.length === 1 && cardinalCategories[0] === 'other') {
320
+ // If this is a plain/base key (no plural suffix), include it.
321
+ if (keyParts.length === 1)
322
+ return true;
323
+ // Otherwise fall through and check the explicit suffix as before.
324
+ }
325
+ if (isOrdinal && keyParts.includes('ordinal')) {
326
+ // For ordinal plurals: key_context_ordinal_category or key_ordinal_category
327
+ const lastPart = keyParts[keyParts.length - 1];
328
+ return targetLanguagePluralCategories.has(`ordinal_${lastPart}`);
329
+ }
330
+ else if (hasCount) {
331
+ // For cardinal plurals: key_context_category or key_category
332
+ const lastPart = keyParts[keyParts.length - 1];
333
+ return targetLanguagePluralCategories.has(lastPart);
334
+ }
335
+ return true;
336
+ });
337
+ // NEW: detect bases that already have expanded plural variants extracted.
338
+ // If a base has explicit expanded variants (e.g. key_one, key_other or key_ordinal_one),
339
+ // we should avoid generating/expanding the base plural key for that base to prevent
340
+ // double-generation / duplicate counting.
341
+ const expandedBases = new Set();
342
+ for (const ek of filteredKeys) {
343
+ if (ek.isExpandedPlural) {
344
+ const parts = String(ek.key).split(pluralSeparator);
345
+ // If ordinal form like "key_ordinal_one" -> base should strip "_ordinal_<cat>"
346
+ if (parts.length >= 3 && parts[parts.length - 2] === 'ordinal') {
347
+ expandedBases.add(parts.slice(0, -2).join(pluralSeparator));
348
+ }
349
+ else {
350
+ // strip single trailing category
351
+ expandedBases.add(parts.slice(0, -1).join(pluralSeparator));
352
+ }
353
+ }
354
+ }
355
+ // If `removeUnusedKeys` is true, start with an empty object. Otherwise, start with a clone of the existing translations.
356
+ let newTranslations = removeUnusedKeys
357
+ ? {}
358
+ : JSON.parse(JSON.stringify(existingTranslations));
359
+ // Preserve keys that match the configured patterns OR are context variants of keys accepting context
360
+ const existingKeys = nestedObject.getNestedKeys(existingTranslations, keySeparator ?? '.');
361
+ for (const existingKey of existingKeys) {
362
+ const shouldPreserve = shouldPreserveExistingKey(existingKey);
363
+ const isContextVariant = !shouldPreserve && isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator);
364
+ if (shouldPreserve || (preserveContextVariants && isContextVariant)) {
365
+ const value = nestedObject.getNestedValue(existingTranslations, existingKey, keySeparator ?? '.');
366
+ nestedObject.setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.');
367
+ }
368
+ }
369
+ // SPECIAL HANDLING: Preserve existing _zero forms even if not in extracted keys
370
+ // This ensures that optional _zero forms are not removed when they exist
371
+ if (removeUnusedKeys) {
372
+ const existingKeys = nestedObject.getNestedKeys(existingTranslations, keySeparator ?? '.');
373
+ for (const existingKey of existingKeys) {
374
+ // Check if this is a _zero form that should be preserved
375
+ const keyParts = existingKey.split(pluralSeparator);
376
+ const lastPart = keyParts[keyParts.length - 1];
377
+ if (lastPart === 'zero') {
378
+ // Check if the base plural key exists in our extracted keys
379
+ const baseKey = keyParts.slice(0, -1).join(pluralSeparator);
380
+ const hasBaseInExtracted = filteredKeys.some(({ key }) => {
381
+ const extractedParts = key.split(pluralSeparator);
382
+ const extractedBase = extractedParts.slice(0, -1).join(pluralSeparator);
383
+ return extractedBase === baseKey;
384
+ });
385
+ if (hasBaseInExtracted) {
386
+ // Preserve the existing _zero form
387
+ const value = nestedObject.getNestedValue(existingTranslations, existingKey, keySeparator ?? '.');
388
+ nestedObject.setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.');
389
+ }
390
+ }
391
+ }
392
+ }
393
+ // 1. Build the object first, without any sorting.
394
+ for (const { key, defaultValue: defaultValue$1, explicitDefault, hasCount, isExpandedPlural, isOrdinal } of filteredKeys) {
395
+ // If this is a base plural key (hasCount true but not an already-expanded variant)
396
+ // and we detected explicit expanded variants for this base, skip expanding the base.
397
+ if (hasCount && !isExpandedPlural) {
398
+ const parts = String(key).split(pluralSeparator);
399
+ let base = key;
400
+ if (parts.length >= 3 && parts[parts.length - 2] === 'ordinal') {
401
+ base = parts.slice(0, -2).join(pluralSeparator);
402
+ }
403
+ else if (parts.length >= 2) {
404
+ base = parts.slice(0, -1).join(pluralSeparator);
405
+ }
406
+ if (expandedBases.has(base)) {
407
+ // Skip generating/expanding this base key because explicit expanded forms exist.
408
+ continue;
409
+ }
410
+ }
411
+ // If this is a base plural key (no explicit suffix) and the locale is NOT the primary,
412
+ // expand it into locale-specific plural variants (e.g. key_one, key_other).
413
+ // Use the extracted defaultValue (fallback to base) for variant values.
414
+ if (hasCount && !isExpandedPlural) {
415
+ const parts = String(key).split(pluralSeparator);
416
+ const isBaseKey = parts.length === 1;
417
+ if (isBaseKey && locale !== primaryLanguage) {
418
+ // If explicit expanded variants exist, do not expand the base.
419
+ const base = key;
420
+ if (expandedBases.has(base)) ;
421
+ else {
422
+ // choose categories based on ordinal flag
423
+ const categories = isOrdinal ? ordinalCategories : cardinalCategories;
424
+ for (const category of categories) {
425
+ const finalKey = isOrdinal
426
+ ? `${base}${pluralSeparator}ordinal${pluralSeparator}${category}`
427
+ : `${base}${pluralSeparator}${category}`;
428
+ // If the key looks like a serialized Trans component (starts with <), treat it as a flat key
429
+ // to prevent splitting on dots that appear within the content.
430
+ const separator = finalKey.startsWith('<') ? false : (keySeparator ?? '.');
431
+ // Preserve existing translation if present; otherwise set a sensible default
432
+ const existingVariantValue = nestedObject.getNestedValue(existingTranslations, finalKey, separator);
433
+ if (existingVariantValue === undefined) {
434
+ // Prefer explicit defaultValue extracted for this key; fall back to configured defaultValue
435
+ // (resolved via resolveDefaultValue which handles functions or strings and accepts the full parameter set).
436
+ let resolvedValue;
437
+ if (typeof defaultValue$1 === 'string') {
438
+ resolvedValue = defaultValue$1;
439
+ }
440
+ else {
441
+ // Use resolveDefaultValue to compute a sensible default, providing namespace and locale context.
442
+ resolvedValue = defaultValue.resolveDefaultValue(emptyDefaultValue, String(base), namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue$1);
443
+ }
444
+ nestedObject.setNestedValue(newTranslations, finalKey, resolvedValue, separator);
445
+ }
446
+ else {
447
+ // Keep existing translation
448
+ nestedObject.setNestedValue(newTranslations, finalKey, existingVariantValue, separator);
449
+ }
450
+ }
451
+ }
452
+ // We've expanded variants for this base key; skip the normal single-key handling.
453
+ continue;
454
+ }
455
+ }
456
+ // If the key looks like a serialized Trans component (starts with <), treat it as a flat key
457
+ let separator = key.startsWith('<') ? false : (keySeparator ?? '.');
458
+ if (separator && typeof separator === 'string') {
459
+ if (!looksLikeObjectPath(key, separator, naturalLanguageRegex)) {
460
+ separator = false;
461
+ }
462
+ }
463
+ const existingValue = nestedObject.getNestedValue(existingTranslations, key, separator);
464
+ // When keySeparator === false we are working with flat keys (no nesting).
465
+ // Avoid concatenating false into strings (``${key}${false}`` => "keyfalse") which breaks the startsWith check.
466
+ // For flat keys there cannot be nested children, so treat them as leaves.
467
+ const isLeafInNewKeys = keySeparator === false
468
+ ? true
469
+ : !filteredKeys.some(otherKey => otherKey.key !== key && otherKey.key.startsWith(`${key}${keySeparator}`));
470
+ // Determine if we should preserve an existing object
471
+ const shouldPreserveObject = typeof existingValue === 'object' && existingValue !== null && (objectKeys.has(key) || // Explicit returnObjects
472
+ !defaultValue$1 || defaultValue$1 === key // No explicit default or default equals key
473
+ );
474
+ const isStaleObject = typeof existingValue === 'object' && existingValue !== null && isLeafInNewKeys && !objectKeys.has(key) && !shouldPreserveObject;
475
+ // Special handling for existing objects that should be preserved
476
+ if (shouldPreserveObject) {
477
+ nestedObject.setNestedValue(newTranslations, key, existingValue, separator);
478
+ continue;
479
+ }
480
+ let valueToSet;
481
+ if (existingValue === undefined || isStaleObject) {
482
+ if (locale === primaryLanguage) {
483
+ if (syncPrimaryWithDefaults) {
484
+ // use the unified "derived" detector (includes keyPrefix suffixes).
485
+ const isDerivedDefault = isDerivedFromKey(key, defaultValue$1, explicitDefault);
486
+ valueToSet =
487
+ (defaultValue$1 && !isDerivedDefault)
488
+ ? defaultValue$1
489
+ : defaultValue.resolveDefaultValue(emptyDefaultValue, key, namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue$1);
490
+ }
491
+ else {
492
+ // If there's no real code-provided default (defaultValue is derived fallback),
493
+ // use the configured extract.defaultValue for PRIMARY language too.
494
+ const derived = isDerivedFromKey(key, defaultValue$1, explicitDefault);
495
+ if (derived && configuredDefaultValue !== undefined) {
496
+ valueToSet = defaultValue.resolveDefaultValue(configuredDefaultValue, key, namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue$1);
497
+ }
498
+ else {
499
+ valueToSet = defaultValue$1 || key;
500
+ }
501
+ }
502
+ }
503
+ else {
504
+ // For secondary languages, always use empty string
505
+ valueToSet = defaultValue.resolveDefaultValue(emptyDefaultValue, key, namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue$1);
506
+ }
507
+ }
508
+ else {
509
+ // Existing value exists - decide whether to preserve, sync primary, or clear other locales when requested
510
+ if (locale === primaryLanguage && syncPrimaryWithDefaults) {
511
+ // Only update when we have a meaningful defaultValue that's not derived from the key pattern.
512
+ const isDerivedDefault = defaultValue$1 && (defaultValue$1 === key || // Exact match with the key itself
513
+ // Check if defaultValue matches the namespaced key format (namespace:key)
514
+ (nsSep && namespace && defaultValue$1 === `${namespace}${nsSep}${key}`) ||
515
+ // For variant keys (plural/context), check if defaultValue is the base
516
+ (key !== defaultValue$1 &&
517
+ (key.startsWith(defaultValue$1 + pluralSeparator) ||
518
+ key.startsWith(defaultValue$1 + contextSeparator))));
519
+ // If this key looks like a plural/context variant and the default
520
+ // wasn't explicitly provided in source code, preserve the existing value.
521
+ const isVariantKey = key.includes(pluralSeparator) || key.includes(contextSeparator);
522
+ if (isVariantKey && !explicitDefault) {
523
+ valueToSet = existingValue;
524
+ }
525
+ else if (defaultValue$1 && !isDerivedDefault) {
526
+ valueToSet = defaultValue.resolveDefaultValue(defaultValue$1, key, namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue$1);
527
+ }
528
+ else {
529
+ valueToSet = existingValue;
530
+ }
531
+ }
532
+ else {
533
+ // Non-primary locale behavior
534
+ if (syncAll && locale !== primaryLanguage && explicitDefault) {
535
+ // When syncAll is requested, clear (reset) any existing translations for keys
536
+ // that had explicit defaults in code so the primary default can be propagated
537
+ // while secondary locales get a blank/placeholder value.
538
+ valueToSet = defaultValue.resolveDefaultValue(emptyDefaultValue, key, namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue$1);
539
+ }
540
+ else {
541
+ // Preserve existing translation by default
542
+ valueToSet = existingValue;
543
+ }
544
+ }
545
+ }
546
+ nestedObject.setNestedValue(newTranslations, key, valueToSet, separator);
547
+ }
548
+ // 2. If sorting is enabled, recursively sort the entire object.
549
+ // This correctly handles both top-level and nested keys.
550
+ if (sort === true) {
551
+ return sortObject(newTranslations, config);
552
+ }
553
+ // Custom sort function logic remains as a future enhancement if needed,
554
+ // but for now, this robustly handles the most common `sort: true` case.
555
+ if (typeof sort === 'function') {
556
+ const sortedObject = {};
557
+ const topLevelKeys = Object.keys(newTranslations);
558
+ // Create a map from key string to ExtractedKey for lookup
559
+ const keyMap = new Map();
560
+ for (const extractedKey of nsKeys) {
561
+ // Store the full key path
562
+ keyMap.set(String(extractedKey.key), extractedKey);
563
+ // For nested keys, also store the top-level part
564
+ if (keySeparator) {
565
+ const topLevelKey = String(extractedKey.key).split(keySeparator)[0];
566
+ if (!keyMap.has(topLevelKey)) {
567
+ keyMap.set(topLevelKey, extractedKey);
568
+ }
569
+ }
570
+ }
571
+ // Create a string comparator that applies the same logic as the custom sort function
572
+ // by extracting the actual comparison behavior
573
+ const stringSort = (a, b) => {
574
+ // Try to find ExtractedKey objects to use the custom comparator
575
+ const keyA = keyMap.get(a);
576
+ const keyB = keyMap.get(b);
577
+ if (keyA && keyB) {
578
+ return sort(keyA, keyB);
579
+ }
580
+ // If we don't have ExtractedKey objects, we need to apply the same sorting logic
581
+ // Create mock ExtractedKey objects with just the key property
582
+ const mockKeyA = { key: a };
583
+ const mockKeyB = { key: b };
584
+ return sort(mockKeyA, mockKeyB);
585
+ };
586
+ // Sort top-level keys
587
+ topLevelKeys.sort(stringSort);
588
+ // Pass the same string comparator to sortObject for nested keys
589
+ for (const key of topLevelKeys) {
590
+ sortedObject[key] = sortObject(newTranslations[key], config, stringSort);
591
+ }
592
+ newTranslations = sortedObject;
593
+ }
594
+ return newTranslations;
595
+ }
596
+ /**
597
+ * Processes extracted translation keys and generates translation files for all configured locales.
598
+ *
599
+ * This function:
600
+ * 1. Groups keys by namespace
601
+ * 2. For each locale and namespace combination:
602
+ * - Reads existing translation files
603
+ * - Preserves keys matching `preservePatterns` and those from `objectKeys`
604
+ * - Merges in newly extracted keys
605
+ * - Uses primary language defaults or empty strings for secondary languages
606
+ * - Maintains key sorting based on configuration
607
+ * 3. Determines if files need updating by comparing content
608
+ *
609
+ * @param keys - Map of extracted translation keys with metadata.
610
+ * @param objectKeys - A set of base keys that were called with the `returnObjects: true` option.
611
+ * @param config - The i18next toolkit configuration object.
612
+ * @returns Promise resolving to array of translation results with update status.
613
+ *
614
+ * @example
615
+ * ```typescript
616
+ * const keys = new Map([
617
+ * ['translation:welcome', { key: 'welcome', defaultValue: 'Welcome!', ns: 'translation' }],
618
+ * ]);
619
+ * const objectKeys = new Set(['countries']);
620
+ *
621
+ * const results = await getTranslations(keys, objectKeys, config);
622
+ * // Results contain update status and new/existing translations for each locale.
623
+ * ```
624
+ */
625
+ async function getTranslations(keys, objectKeys, config, { syncPrimaryWithDefaults = false, syncAll = false } = {}) {
626
+ config.extract.primaryLanguage ||= config.locales[0] || 'en';
627
+ config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
628
+ const patternsToPreserve = [...(config.extract.preservePatterns || [])];
629
+ const indentation = config.extract.indentation ?? 2;
630
+ for (const key of objectKeys) {
631
+ // Convert the object key to a glob pattern to preserve all its children
632
+ patternsToPreserve.push(`${key}.*`);
633
+ }
634
+ const preservePatterns = patternsToPreserve.map(globToRegex);
635
+ // Group keys by namespace. If the plugin recorded the namespace as implicit
636
+ // (nsIsImplicit) AND the user set defaultNS === false we treat those keys
637
+ // as "no namespace" (will be merged at top-level). Otherwise use the stored
638
+ // namespace (internally we keep implicit keys as 'translation').
639
+ const NO_NS_TOKEN = '__no_namespace__';
640
+ const keysByNS = new Map();
641
+ const nsSep = typeof config.extract.nsSeparator === 'string' ? config.extract.nsSeparator : ':';
642
+ const nsNaturalLanguageRegex = new RegExp(`(${chars.map((c) => (c === '?' ? '\\?' : c)).join('|')})`);
643
+ for (const k of keys.values()) {
644
+ let ns = k.ns;
645
+ let key = k.key;
646
+ // Fix for incorrect splitting of natural language keys containing nsSeparator
647
+ // If the namespace contains spaces or looks like natural language, assume it was split incorrectly
648
+ // and rejoin it with the key.
649
+ if (ns && nsNaturalLanguageRegex.test(ns)) {
650
+ key = `${ns}${nsSep}${key}`;
651
+ ns = undefined;
652
+ }
653
+ const nsKey = (k.nsIsImplicit && config.extract.defaultNS === false)
654
+ ? NO_NS_TOKEN
655
+ : String(ns ?? (config.extract.defaultNS ?? 'translation'));
656
+ if (!keysByNS.has(nsKey))
657
+ keysByNS.set(nsKey, []);
658
+ if (ns !== k.ns || key !== k.key) {
659
+ keysByNS.get(nsKey).push({ ...k, ns, key });
660
+ }
661
+ else {
662
+ keysByNS.get(nsKey).push(k);
663
+ }
664
+ }
665
+ const results = [];
666
+ const userIgnore = Array.isArray(config.extract.ignore)
667
+ ? config.extract.ignore
668
+ : config.extract.ignore ? [config.extract.ignore] : [];
669
+ // Process each locale one by one
670
+ for (const locale of config.locales) {
671
+ // If output is a string we can detect the presence of the namespace placeholder.
672
+ // If it's a function we cannot reliably detect that here — default to not merged
673
+ // unless mergeNamespaces is explicitly true.
674
+ const shouldMerge = config.extract.mergeNamespaces || (typeof config.extract.output === 'string' ? !config.extract.output.includes('{{namespace}}') : false);
675
+ // LOGIC PATH 1: Merged Namespaces
676
+ if (shouldMerge) {
677
+ const newMergedTranslations = {};
678
+ const outputPath = fileUtils.getOutputPath(config.extract.output, locale);
679
+ const fullPath = node_path.resolve(process.cwd(), outputPath);
680
+ const existingMergedFile = await fileUtils.loadTranslationFile(fullPath) || {};
681
+ // Determine whether the existing merged file already uses namespace objects
682
+ // or is a flat mapping of translation keys -> values.
683
+ // If it's flat (values are primitives), we must NOT treat each translation key as a namespace.
684
+ const existingKeys = Object.keys(existingMergedFile);
685
+ // Treat the file as namespaced only when the user is using namespaces.
686
+ // If defaultNS === false the project stores translations at the top-level
687
+ // (possibly as nested objects when keySeparator is '.'), which should NOT
688
+ // be interpreted as "namespaced files". This avoids splitting a single
689
+ // merged translations file into artificial namespace buckets on re-extract.
690
+ const existingIsNamespaced = (config.extract.defaultNS !== false) && existingKeys.some(k => {
691
+ const v = existingMergedFile[k];
692
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
693
+ });
694
+ // The namespaces to process:
695
+ // - If existing file is namespaced, combine keysByNS with existingMergedFile namespaces.
696
+ // - If existing file is flat (top-level translations), ensure NO_NS_TOKEN is processed.
697
+ const namespacesToProcess = existingIsNamespaced
698
+ ? new Set([...keysByNS.keys(), ...existingKeys])
699
+ : new Set([...keysByNS.keys(), NO_NS_TOKEN]);
700
+ for (const nsKey of namespacesToProcess) {
701
+ const nsKeys = keysByNS.get(nsKey) || [];
702
+ if (nsKey === NO_NS_TOKEN) {
703
+ // keys without namespace -> merged into top-level of the merged file
704
+ const built = buildNewTranslationsForNs(nsKeys, existingMergedFile, config, locale, undefined, preservePatterns, objectKeys, syncPrimaryWithDefaults);
705
+ Object.assign(newMergedTranslations, built);
706
+ }
707
+ else {
708
+ const existingTranslations = existingMergedFile[nsKey] || {};
709
+ newMergedTranslations[nsKey] = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, nsKey, preservePatterns, objectKeys, syncPrimaryWithDefaults);
710
+ }
711
+ }
712
+ const oldContent = JSON.stringify(existingMergedFile, null, indentation);
713
+ const newContent = JSON.stringify(newMergedTranslations, null, indentation);
714
+ // Push a single result for the merged file
715
+ results.push({ path: fullPath, updated: newContent !== oldContent, newTranslations: newMergedTranslations, existingTranslations: existingMergedFile });
716
+ // LOGIC PATH 2: Separate Namespace Files
717
+ }
718
+ else {
719
+ // Find all namespaces that exist on disk for this locale
720
+ const namespacesToProcess = new Set(keysByNS.keys());
721
+ const existingNsPattern = fileUtils.getOutputPath(config.extract.output, locale, '*');
722
+ // Ensure glob receives POSIX-style separators so pattern matching works cross-platform (Windows -> backslashes)
723
+ const existingNsGlobPattern = existingNsPattern.replace(/\\/g, '/');
724
+ const existingNsFiles = await glob.glob(existingNsGlobPattern, { ignore: userIgnore });
725
+ for (const file of existingNsFiles) {
726
+ namespacesToProcess.add(node_path.basename(file, node_path.extname(file)));
727
+ }
728
+ // Process each namespace individually and create a result for each one
729
+ for (const ns of namespacesToProcess) {
730
+ const nsKeys = keysByNS.get(ns) || [];
731
+ const outputPath = fileUtils.getOutputPath(config.extract.output, locale, ns);
732
+ const fullPath = node_path.resolve(process.cwd(), outputPath);
733
+ const existingTranslations = await fileUtils.loadTranslationFile(fullPath) || {};
734
+ const newTranslations = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, ns, preservePatterns, objectKeys, syncPrimaryWithDefaults, syncAll);
735
+ const oldContent = JSON.stringify(existingTranslations, null, indentation);
736
+ const newContent = JSON.stringify(newTranslations, null, indentation);
737
+ // Push one result per namespace file
738
+ results.push({ path: fullPath, updated: newContent !== oldContent, newTranslations, existingTranslations });
739
+ }
740
+ }
741
+ }
742
+ return results;
743
+ }
744
+
745
+ exports.getTranslations = getTranslations;