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