webpack-easyi18n 0.5.2 → 0.6.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,20 @@ module.exports = Object.keys(Locales).map(function(locale) {
48
48
  ])
49
49
  };
50
50
  });
51
+
52
+ ### React / JSX support
53
+
54
+ This plugin transforms nuggets at **source level** (via an injected webpack loader), so nuggets can span JSX text, `{expressions}`, and even nested JSX elements.
55
+
56
+ Example:
57
+
58
+ ```jsx
59
+ <p>
60
+ [[[Just {changeStatusBtn} to change your status.]]]
61
+ </p>
62
+ ```
63
+
64
+ Internally this becomes a translated JSX children sequence rather than a single string, so placeholders like `%0` can be reordered per-locale without breaking React.
51
65
  ```
52
66
 
53
67
  ### Options
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webpack-easyi18n",
3
- "version": "0.5.2",
3
+ "version": "0.6.0-rc.0",
4
4
  "description": "Go from gettext catalog (.po files) to embeded localization in your Webpack bundles",
5
5
  "engines": {
6
6
  "node": ">=4.3.0 <5.0.0 || >=5.10"
@@ -10,13 +10,10 @@
10
10
  ],
11
11
  "main": "src/index.js",
12
12
  "scripts": {
13
- "test": "echo \"Error: no test specified\" && exit 1",
14
- "example:clean": "rimraf example\\dist example\\locale\\webpack-easyi18n-temp",
15
- "example:build": "npm run example:clean && webpack -c example\\webpack.config.js",
16
- "example:generatetranslations": "dotnet tool restore && dotnet tool run generatepot --app-settings-paths=\"./example/appsettings.json\""
13
+ "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js"
17
14
  },
18
15
  "dependencies": {
19
- "i18next-conv": "^4.0.3"
16
+ "i18next-conv": "^16.0.0"
20
17
  },
21
18
  "repository": {
22
19
  "type": "git",
@@ -33,7 +30,16 @@
33
30
  },
34
31
  "homepage": "https://github.com/SimplyDating/webpack-easyi18n#readme",
35
32
  "devDependencies": {
33
+ "@babel/core": "^7.26.3",
34
+ "@babel/generator": "^7.26.3",
35
+ "@babel/parser": "^7.26.3",
36
+ "@babel/preset-env": "^7.26.3",
37
+ "@babel/preset-react": "^7.26.3",
38
+ "@babel/traverse": "^7.26.3",
39
+ "@babel/types": "^7.26.3",
36
40
  "html-webpack-plugin": "^5.3.1",
41
+ "jest": "^29.7.0",
42
+ "react": "^18.3.1",
37
43
  "rimraf": "^6.0.1",
38
44
  "webpack": "^5.99.9",
39
45
  "webpack-cli": "^4.6.0"
package/src/index.js CHANGED
@@ -1,11 +1,25 @@
1
- const { SourceMapSource } = require("webpack").sources;
2
1
  const path = require("path");
3
2
  const {
4
3
  readFileSync,
5
4
  writeFileSync,
6
5
  mkdirSync
7
6
  } = require("fs");
8
- const gettextToI18Next = require("i18next-conv").gettextToI18next;
7
+
8
+ async function loadGettextToI18next() {
9
+ // i18next-conv@16+ is ESM-only (and Node>=20). Requiring it from CommonJS breaks Jest with
10
+ // "Unexpected token 'export'". We support both:
11
+ // - CJS: require('i18next-conv').gettextToI18next
12
+ // - ESM: (await import('i18next-conv')).gettextToI18next
13
+ try {
14
+ // eslint-disable-next-line global-require
15
+ const mod = require('i18next-conv');
16
+ return mod.gettextToI18next || mod.gettextToI18Next || mod.default;
17
+ } catch (err) {
18
+ // If it's ESM-only, fall back to dynamic import.
19
+ const mod = await import('i18next-conv');
20
+ return mod.gettextToI18next || mod.gettextToI18Next || mod.default;
21
+ }
22
+ }
9
23
 
10
24
  class EasyI18nPlugin {
11
25
  static defaultOptions = {
@@ -28,6 +42,8 @@ class EasyI18nPlugin {
28
42
  ...EasyI18nPlugin.defaultOptions,
29
43
  ...options
30
44
  };
45
+
46
+ this.translationLookup = null;
31
47
  }
32
48
 
33
49
  apply(compiler) {
@@ -39,189 +55,79 @@ class EasyI18nPlugin {
39
55
  }
40
56
  };
41
57
 
42
- /**
43
- * Decode (a small subset of) JavaScript-style escape sequences from *bundle text* into
44
- * their real runtime characters.
45
- *
46
- * Why this exists:
47
- * - When targeting older environments, Babel/minifiers sometimes emit non-ASCII
48
- * characters using unicode escapes, e.g. "don\u2019t" instead of "don’t".
49
- * - If we then call escapeNuggets(), it will escape backslashes, turning "\u2019" into
50
- * "\\u2019".
51
- * - In the final JS bundle, "\\u2019" is *not* a unicode escape anymore; it becomes a
52
- * literal backslash-u sequence and the browser renders "don\u2019t".
53
- *
54
- * Example:
55
- * - Bundle text contains: "don\u2019t"
56
- * - Without decode: escapeNuggets => "don\\u2019t" (renders as don\u2019t)
57
- * - With decode first: unescapeJsLike => "don’t"; then escapeNuggets keeps it as don’t
58
- *
59
- * Notes:
60
- * - This intentionally decodes only "\uXXXX" and "\xXX" sequences.
61
- * - It avoids decoding when the backslash itself is escaped (e.g. "\\u2019"), because
62
- * that usually means the author intended a literal "\u2019" to be displayed.
63
- */
64
- const unescapeJsLike = (value) => {
65
- if (typeof value !== 'string') return value;
66
-
67
- const isHex = (c) => (c >= '0' && c <= '9')
68
- || (c >= 'a' && c <= 'f')
69
- || (c >= 'A' && c <= 'F');
70
-
71
- let out = '';
72
- for (let i = 0; i < value.length; i++) {
73
- const ch = value[i];
74
- if (ch !== '\\') {
75
- out += ch;
76
- continue;
77
- }
78
-
79
- // If we have an escaped backslash ("\\u...." in text), do not decode.
80
- if (i > 0 && value[i - 1] === '\\') {
81
- out += ch;
82
- continue;
83
- }
84
-
85
- const next = value[i + 1];
86
- if (next === 'u') {
87
- const a = value[i + 2], b = value[i + 3], c = value[i + 4], d = value[i + 5];
88
- if (isHex(a) && isHex(b) && isHex(c) && isHex(d)) {
89
- out += String.fromCharCode(parseInt(`${a}${b}${c}${d}`, 16));
90
- i += 5;
91
- continue;
92
- }
93
- } else if (next === 'x') {
94
- const a = value[i + 2], b = value[i + 3];
95
- if (isHex(a) && isHex(b)) {
96
- out += String.fromCharCode(parseInt(`${a}${b}`, 16));
97
- i += 3;
98
- continue;
99
- }
100
- }
101
-
102
- // Not a recognized escape; keep the backslash.
103
- out += ch;
104
- }
105
-
106
- return out;
107
- };
108
-
109
- compiler.hooks.thisCompilation.tap('EasyI18nPlugin', (compilation) => {
110
- compilation.hooks.processAssets.tapPromise(
111
- {
112
- name: 'EasyI18nPlugin',
113
- stage: compilation.PROCESS_ASSETS_STAGE_DERIVED,
114
- },
115
- async () => {
116
- const localeKey = this.locale[0];
117
- const localePoPath = this.locale[1];
118
-
119
- if (localePoPath !== null) {
120
- var poPath = path.join(this.options.localesPath, localePoPath);
121
-
122
- mkdir(path.resolve(path.join(this.options.localesPath, "/webpack-easyi18n-temp/")));
123
-
124
- console.log(`Reading translations from ${poPath}`)
125
- var lookupData = await gettextToI18Next(localeKey, readFileSync(poPath), {});
126
- var translationLookupPath = path.join(this.options.localesPath, `/webpack-easyi18n-temp/${localeKey}.json`);
127
- writeFileSync(translationLookupPath, lookupData);
128
- console.log(`${localeKey} translation lookup file created ${translationLookupPath}`);
129
- }
130
-
131
- let translationLookup = null;
132
- if (localePoPath !== null) {
133
- translationLookup = require(path.join(this.options.localesPath, `/webpack-easyi18n-temp/${localeKey}.json`));
134
- }
135
-
136
- compilation.getAssets().forEach((asset) => {
137
- const filename = asset.name;
138
- const originalSourceObj = compilation.assets[filename];
139
- const originalSource = originalSourceObj.source();
140
-
141
- // skip any files that have been excluded
142
- const modifyFile = typeof originalSource === 'string'
143
- && (this.options.excludeUrls == null || !this.options.excludeUrls.some(excludedUrl => filename.includes(excludedUrl)))
144
- && (this.options.includeUrls == null || this.options.includeUrls.some(includedUrl => filename.includes(includedUrl)));
145
- if (!modifyFile) return;
146
-
147
- // Unfortunately the regex below doesn't work as js flavoured regex makes only the last capture included
148
- // in a capture group available (unlike .NET which lets you iterate over all captures in a group).
149
- // This means formatable nuggets with multiple formatable items will fail.
150
- //
151
- // Take the following nugget for example:
152
- // - [[[%0 %1|||1|||2]]]
153
- //
154
- // The regex below will only include "2" in the second capture group, rather than all captures "1|||2".
155
- // We need to do multiple rounds of parsing in order to work around this
156
- //const regex = /\[\[\[(.+?)(?:\|\|\|(.+?))*(?:\/\/\/(.+?))?\]\]\]/sg;
157
- const regex = /\[\[\[(.+?)(?:\|\|\|.+?)*(?:\/\/\/(.+?))?\]\]\]/sg;
158
-
159
- let source = originalSource.replace(regex, (originalText, nuggetSyntaxRemoved) => {
160
- let replacement = null;
161
-
162
- if (localePoPath === null) {
163
- if (this.options.alwaysRemoveBrackets) {
164
- replacement = nuggetSyntaxRemoved;
165
- } else {
166
- return originalText; // leave this nugget alone
167
- }
168
- } else {
169
- // .po files use \n notation for line breaks
170
- const translationKeyRaw = nuggetSyntaxRemoved.replace(/\r\n/g, '\n');
171
- const translationKey = unescapeJsLike(translationKeyRaw);
172
-
173
- // find this nugget in the locale's array of translations
174
- replacement = translationLookup[translationKey];
175
- if (typeof (replacement) === "undefined") {
176
- replacement = translationLookup[translationKeyRaw];
177
- }
178
- if (typeof (replacement) === "undefined" || replacement === "") {
179
- if (this.options.warnOnMissingTranslations) {
180
- compilation.warnings.push(
181
- new Error(`Missing translation in ${filename}.\n '${nuggetSyntaxRemoved}' : ${localeKey}`));
182
- }
183
-
184
- if (this.options.alwaysRemoveBrackets) {
185
- replacement = nuggetSyntaxRemoved;
186
- } else {
187
- return originalText; // leave this nugget alone
188
- }
189
- }
190
- }
191
-
192
- // Escape the translated text BEFORE formatting/splicing
193
- replacement = EasyI18nPlugin.escapeNuggets(unescapeJsLike(replacement));
194
-
195
- // format nuggets
196
- var formatItemsMatch = originalText.match(/\|\|\|(.+?)(?:\/\/\/.+?)?\]\]\]/s)
197
- if (formatItemsMatch) {
198
- const formatItems = formatItemsMatch[1]
199
- .split('|||');
200
-
201
- replacement = replacement.replace(/(%\d+)/g, (value) => {
202
- var identifier = parseInt(value.slice(1));
203
- if (!isNaN(identifier) && formatItems.length > identifier) {
204
- return formatItems[identifier];
205
- } else {
206
- return value;
207
- }
208
- });
209
- }
210
-
211
- return replacement;
212
- });
213
-
214
- compilation.updateAsset(filename, new SourceMapSource(
215
- source,
216
- filename,
217
- originalSourceObj.map(),
218
- originalSource,
219
- null,
220
- true));
221
- });
222
- }
223
- );
224
- });
58
+ const localeKey = this.locale[0];
59
+ const localePoPath = this.locale[1];
60
+
61
+ const prepareTranslations = async () => {
62
+ if (localePoPath === null) {
63
+ this.translationLookup = null;
64
+ return;
65
+ }
66
+
67
+ const poPath = path.join(this.options.localesPath, localePoPath);
68
+ mkdir(path.resolve(path.join(this.options.localesPath, "/webpack-easyi18n-temp/")));
69
+
70
+ const gettextToI18next = await loadGettextToI18next();
71
+ if (typeof gettextToI18next !== 'function') {
72
+ throw new Error('Failed to load i18next-conv gettextToI18next()');
73
+ }
74
+
75
+ // Keep the temp json file behavior for backward-compatibility/debugging.
76
+ const lookupData = await gettextToI18next(localeKey, readFileSync(poPath), {});
77
+ const translationLookupPath = path.join(this.options.localesPath, `/webpack-easyi18n-temp/${localeKey}.json`);
78
+ writeFileSync(translationLookupPath, lookupData);
79
+
80
+ try {
81
+ this.translationLookup = typeof lookupData === 'string' ? JSON.parse(lookupData) : lookupData;
82
+ } catch {
83
+ // Fallback to requiring the generated file.
84
+ // eslint-disable-next-line global-require, import/no-dynamic-require
85
+ this.translationLookup = require(translationLookupPath);
86
+ }
87
+ };
88
+
89
+ compiler.hooks.beforeCompile.tapPromise('EasyI18nPlugin', prepareTranslations);
90
+ compiler.hooks.watchRun.tapPromise('EasyI18nPlugin', prepareTranslations);
91
+
92
+ // Inject our loader so nuggets are transformed at source-level (before Babel turns JSX into string concatenations).
93
+ compiler.hooks.normalModuleFactory.tap('EasyI18nPlugin', (normalModuleFactory) => {
94
+ normalModuleFactory.hooks.afterResolve.tap('EasyI18nPlugin', (resolveData) => {
95
+ if (!resolveData) return;
96
+
97
+ // webpack 5 can expose resource on different fields depending on stage.
98
+ const resourcePath = resolveData.resource
99
+ || (resolveData.resourceResolveData && resolveData.resourceResolveData.path)
100
+ || (resolveData.createData && resolveData.createData.resource);
101
+ if (!resourcePath) return;
102
+
103
+ // Skip dependencies
104
+ if (resourcePath.includes(`${path.sep}node_modules${path.sep}`)) return;
105
+
106
+ // Only transform JS/TS sources.
107
+ if (!/\.[cm]?[jt]sx?$/.test(resourcePath)) return;
108
+
109
+ // Apply include/exclude patterns against the full resource path.
110
+ if (this.options.excludeUrls != null && this.options.excludeUrls.some(excludedUrl => resourcePath.includes(excludedUrl))) return;
111
+ if (this.options.includeUrls != null && !this.options.includeUrls.some(includedUrl => resourcePath.includes(includedUrl))) return;
112
+
113
+ // In webpack 5, mutate createData.loaders to affect the final NormalModule.
114
+ if (!resolveData.createData) resolveData.createData = {};
115
+ if (!Array.isArray(resolveData.createData.loaders)) {
116
+ resolveData.createData.loaders = Array.isArray(resolveData.loaders) ? resolveData.loaders : [];
117
+ }
118
+
119
+ resolveData.createData.loaders.push({
120
+ loader: path.resolve(__dirname, 'loader.js'),
121
+ options: {
122
+ localeKey,
123
+ localePoPath,
124
+ alwaysRemoveBrackets: this.options.alwaysRemoveBrackets,
125
+ warnOnMissingTranslations: this.options.warnOnMissingTranslations,
126
+ translationLookup: this.translationLookup,
127
+ },
128
+ });
129
+ });
130
+ });
225
131
  }
226
132
  }
227
133
 
package/src/loader.js ADDED
@@ -0,0 +1,22 @@
1
+ const { transformSource } = require('./transform');
2
+
3
+ module.exports = function easyI18nLoader(source) {
4
+ const callback = this.async();
5
+ const options = this.getOptions ? this.getOptions() : {};
6
+
7
+ try {
8
+ const code = transformSource(source.toString(), {
9
+ localeKey: options.localeKey,
10
+ localePoPath: options.localePoPath,
11
+ alwaysRemoveBrackets: options.alwaysRemoveBrackets,
12
+ warnOnMissingTranslations: options.warnOnMissingTranslations,
13
+ translationLookup: options.translationLookup,
14
+ fileNameForWarnings: this.resourcePath,
15
+ emitWarning: (err) => this.emitWarning(err),
16
+ });
17
+
18
+ callback(null, code);
19
+ } catch (err) {
20
+ callback(err);
21
+ }
22
+ };
@@ -0,0 +1,360 @@
1
+ const parser = require('@babel/parser');
2
+ const traverse = require('@babel/traverse').default;
3
+ const t = require('@babel/types');
4
+ const generate = require('@babel/generator').default;
5
+
6
+ const NUGGET_START = '[[[';
7
+ const NUGGET_END = ']]]';
8
+
9
+ function normalizeKey(value) {
10
+ if (typeof value !== 'string') return value;
11
+ // .po files use \n; source may have CRLF
12
+ return value.replace(/\r\n/g, '\n');
13
+ }
14
+
15
+ function getTranslation({ localePoPath, alwaysRemoveBrackets, translationLookup, key, rawKey }) {
16
+ if (localePoPath == null) {
17
+ if (alwaysRemoveBrackets) return rawKey;
18
+ return null;
19
+ }
20
+
21
+ if (!translationLookup) return null;
22
+ const normalized = normalizeKey(key);
23
+ const normalizedRaw = normalizeKey(rawKey);
24
+ let value = translationLookup[normalized];
25
+ if (typeof value === 'undefined') value = translationLookup[normalizedRaw];
26
+ if (typeof value === 'undefined' || value === '') return null;
27
+ return value;
28
+ }
29
+
30
+ function splitByPlaceholder(text) {
31
+ // Returns array of {type:'text', value} or {type:'ph', index}
32
+ const parts = [];
33
+ const re = /(%\d+)/g;
34
+ let last = 0;
35
+ let match;
36
+ while ((match = re.exec(text)) != null) {
37
+ if (match.index > last) {
38
+ parts.push({ type: 'text', value: text.slice(last, match.index) });
39
+ }
40
+ const index = Number(match[1].slice(1));
41
+ parts.push({ type: 'ph', index });
42
+ last = match.index + match[1].length;
43
+ }
44
+ if (last < text.length) parts.push({ type: 'text', value: text.slice(last) });
45
+ return parts;
46
+ }
47
+
48
+ function normalizeJsxTextForKey(text) {
49
+ // Make translation keys resilient to formatting/indentation inside JSX.
50
+ // React itself collapses most whitespace in JSXText; we mirror that for keys.
51
+ return text.replace(/\s+/g, ' ');
52
+ }
53
+
54
+ function replaceNuggetsInPlainString(input, { localePoPath, alwaysRemoveBrackets, warnOnMissingTranslations, translationLookup, onMissing }) {
55
+ if (typeof input !== 'string' || input.indexOf(NUGGET_START) === -1) return input;
56
+
57
+ // Note: this regex intentionally does NOT attempt to parse format items across multiple groups.
58
+ const regex = /\[\[\[(.+?)(?:\|\|\|.+?)*(?:\/\/\/(.+?))?\]\]\]/gs;
59
+
60
+ return input.replace(regex, (originalText, nuggetSyntaxRemoved) => {
61
+ const rawKey = nuggetSyntaxRemoved;
62
+ const translated = getTranslation({
63
+ localePoPath,
64
+ alwaysRemoveBrackets,
65
+ translationLookup,
66
+ key: nuggetSyntaxRemoved,
67
+ rawKey,
68
+ });
69
+
70
+ if (translated == null) {
71
+ if (localePoPath != null && warnOnMissingTranslations && typeof onMissing === 'function') {
72
+ onMissing(rawKey);
73
+ }
74
+ if (localePoPath == null && !alwaysRemoveBrackets) return originalText;
75
+ return rawKey;
76
+ }
77
+
78
+ let replacement = translated;
79
+
80
+ // Format nuggets: [[[Hello %0|||A|||B]]]
81
+ const formatItemsMatch = originalText.match(/\|\|\|(.+?)(?:\/\/\/.+?)?\]\]\]/s);
82
+ if (formatItemsMatch) {
83
+ const formatItems = formatItemsMatch[1].split('|||');
84
+ replacement = replacement.replace(/(%\d+)/g, (value) => {
85
+ const identifier = Number(value.slice(1));
86
+ if (!Number.isNaN(identifier) && formatItems.length > identifier) return formatItems[identifier];
87
+ return value;
88
+ });
89
+ }
90
+
91
+ return replacement;
92
+ });
93
+ }
94
+
95
+ function transformJsxNuggets(children, state, fileNameForWarnings) {
96
+ const out = [];
97
+ let i = 0;
98
+
99
+ const emitMissing = (key) => {
100
+ if (!state.warnOnMissingTranslations) return;
101
+ if (typeof state.emitWarning === 'function') {
102
+ state.emitWarning(new Error(`Missing translation${fileNameForWarnings ? ` in ${fileNameForWarnings}` : ''}.\n '${key}' : ${state.localeKey}`));
103
+ }
104
+ };
105
+
106
+ while (i < children.length) {
107
+ const child = children[i];
108
+
109
+ // We only start nuggets from JSXText containing [[[.
110
+ if (!t.isJSXText(child) || child.value.indexOf(NUGGET_START) === -1) {
111
+ out.push(child);
112
+ i++;
113
+ continue;
114
+ }
115
+
116
+ const startIndex = child.value.indexOf(NUGGET_START);
117
+ const before = child.value.slice(0, startIndex);
118
+ const afterStart = child.value.slice(startIndex + NUGGET_START.length);
119
+
120
+ if (before) out.push(t.jsxText(before));
121
+
122
+ // Begin capturing nugget content across subsequent children.
123
+ let message = '';
124
+ const values = [];
125
+ let done = false;
126
+
127
+ // Seed with remaining text after [[[ in the starting node.
128
+ let seed = afterStart;
129
+ let localIndex = i;
130
+
131
+ const consumeText = (text) => {
132
+ if (!text) return;
133
+ const endIdx = text.indexOf(NUGGET_END);
134
+ if (endIdx === -1) {
135
+ message += normalizeJsxTextForKey(text);
136
+ return { done: false };
137
+ }
138
+ message += normalizeJsxTextForKey(text.slice(0, endIdx));
139
+ const remainder = text.slice(endIdx + NUGGET_END.length);
140
+ return { done: true, remainder };
141
+ };
142
+
143
+ // Consume seed (which may contain ]]]).
144
+ {
145
+ const res = consumeText(seed);
146
+ if (res.done) {
147
+ // Entire nugget is within the first JSXText.
148
+ const keyRaw = message;
149
+ const key = keyRaw;
150
+ const translated = getTranslation({
151
+ localePoPath: state.localePoPath,
152
+ alwaysRemoveBrackets: state.alwaysRemoveBrackets,
153
+ translationLookup: state.translationLookup,
154
+ key,
155
+ rawKey: keyRaw,
156
+ });
157
+
158
+ if (translated == null) {
159
+ if (state.localePoPath != null && state.warnOnMissingTranslations) emitMissing(keyRaw);
160
+ if (state.localePoPath == null && !state.alwaysRemoveBrackets) {
161
+ // Leave original unmodified
162
+ out.push(child);
163
+ i++;
164
+ continue;
165
+ }
166
+ out.push(t.jsxText(keyRaw + res.remainder));
167
+ i++;
168
+ continue;
169
+ }
170
+
171
+ out.push(...buildJsxChildrenFromTranslation(translated, values));
172
+ if (res.remainder) out.push(t.jsxText(res.remainder));
173
+ i++;
174
+ continue;
175
+ }
176
+ }
177
+
178
+ localIndex = i + 1;
179
+
180
+ while (localIndex < children.length) {
181
+ const current = children[localIndex];
182
+
183
+ if (t.isJSXText(current)) {
184
+ const res = consumeText(current.value);
185
+ if (res.done) {
186
+ done = true;
187
+ // Finish: translate and emit remainder
188
+ const keyRaw = message;
189
+ const key = keyRaw;
190
+ const translated = getTranslation({
191
+ localePoPath: state.localePoPath,
192
+ alwaysRemoveBrackets: state.alwaysRemoveBrackets,
193
+ translationLookup: state.translationLookup,
194
+ key,
195
+ rawKey: keyRaw,
196
+ });
197
+
198
+ if (translated == null) {
199
+ if (state.localePoPath != null && state.warnOnMissingTranslations) emitMissing(keyRaw);
200
+ if (state.localePoPath == null && !state.alwaysRemoveBrackets) {
201
+ // Leave original sequence unmodified
202
+ out.push(child);
203
+ for (let k = i + 1; k <= localIndex; k++) out.push(children[k]);
204
+ i = localIndex + 1;
205
+ continue;
206
+ }
207
+
208
+ out.push(...buildJsxChildrenFromTranslation(keyRaw, values));
209
+ } else {
210
+ out.push(...buildJsxChildrenFromTranslation(translated, values));
211
+ }
212
+
213
+ if (res.remainder) out.push(t.jsxText(res.remainder));
214
+ i = localIndex + 1;
215
+ break;
216
+ }
217
+
218
+ localIndex++;
219
+ continue;
220
+ }
221
+
222
+ if (t.isJSXExpressionContainer(current)) {
223
+ // Ignore empty expressions (comments)
224
+ if (t.isJSXEmptyExpression(current.expression)) {
225
+ localIndex++;
226
+ continue;
227
+ }
228
+ const index = values.length;
229
+ values.push(current.expression);
230
+ message += `%${index}`;
231
+ localIndex++;
232
+ continue;
233
+ }
234
+
235
+ if (t.isJSXElement(current) || t.isJSXFragment(current)) {
236
+ const index = values.length;
237
+ values.push(current);
238
+ message += `%${index}`;
239
+ localIndex++;
240
+ continue;
241
+ }
242
+
243
+ // Other child types: keep them as placeholders to avoid losing content.
244
+ const index = values.length;
245
+ values.push(current);
246
+ message += `%${index}`;
247
+ localIndex++;
248
+ }
249
+
250
+ if (!done) {
251
+ // Unclosed nugget: leave original child
252
+ out.push(child);
253
+ i++;
254
+ }
255
+ }
256
+
257
+ return out;
258
+ }
259
+
260
+ function buildJsxChildrenFromTranslation(translated, values) {
261
+ const parts = splitByPlaceholder(translated);
262
+ const out = [];
263
+ for (const part of parts) {
264
+ if (part.type === 'text') {
265
+ if (part.value) out.push(t.jsxText(part.value));
266
+ continue;
267
+ }
268
+
269
+ const value = values[part.index];
270
+ if (typeof value === 'undefined') {
271
+ out.push(t.jsxText(`%${part.index}`));
272
+ continue;
273
+ }
274
+
275
+ if (t.isJSXElement(value) || t.isJSXFragment(value)) {
276
+ out.push(value);
277
+ continue;
278
+ }
279
+
280
+ // Expressions must be wrapped
281
+ out.push(t.jsxExpressionContainer(value));
282
+ }
283
+
284
+ return out;
285
+ }
286
+
287
+ function transformSource(source, options = {}) {
288
+ const state = {
289
+ localeKey: options.localeKey || '',
290
+ localePoPath: options.localePoPath ?? null,
291
+ alwaysRemoveBrackets: Boolean(options.alwaysRemoveBrackets),
292
+ warnOnMissingTranslations: options.warnOnMissingTranslations !== false,
293
+ translationLookup: options.translationLookup || null,
294
+ emitWarning: options.emitWarning,
295
+ };
296
+
297
+ const ast = parser.parse(source, {
298
+ sourceType: 'unambiguous',
299
+ plugins: [
300
+ 'jsx',
301
+ 'typescript',
302
+ 'classProperties',
303
+ 'objectRestSpread',
304
+ 'optionalChaining',
305
+ 'nullishCoalescingOperator',
306
+ 'dynamicImport',
307
+ 'topLevelAwait',
308
+ 'importMeta',
309
+ ],
310
+ });
311
+
312
+ traverse(ast, {
313
+ JSXElement(path) {
314
+ path.node.children = transformJsxNuggets(path.node.children, state, options.fileNameForWarnings);
315
+ },
316
+ JSXFragment(path) {
317
+ path.node.children = transformJsxNuggets(path.node.children, state, options.fileNameForWarnings);
318
+ },
319
+ StringLiteral(path) {
320
+ const before = path.node.value;
321
+ const after = replaceNuggetsInPlainString(before, {
322
+ ...state,
323
+ onMissing: (key) => {
324
+ if (state.emitWarning) state.emitWarning(new Error(`Missing translation${options.fileNameForWarnings ? ` in ${options.fileNameForWarnings}` : ''}.\n '${key}' : ${state.localeKey}`));
325
+ },
326
+ });
327
+ if (after !== before) path.node.value = after;
328
+ },
329
+ TemplateLiteral(path) {
330
+ for (const quasi of path.node.quasis) {
331
+ const before = quasi.value.cooked;
332
+ if (typeof before !== 'string') continue;
333
+ const after = replaceNuggetsInPlainString(before, {
334
+ ...state,
335
+ onMissing: (key) => {
336
+ if (state.emitWarning) state.emitWarning(new Error(`Missing translation${options.fileNameForWarnings ? ` in ${options.fileNameForWarnings}` : ''}.\n '${key}' : ${state.localeKey}`));
337
+ },
338
+ });
339
+ if (after !== before) {
340
+ quasi.value.cooked = after;
341
+ quasi.value.raw = after;
342
+ }
343
+ }
344
+ },
345
+ });
346
+
347
+ const output = generate(ast, {
348
+ jsescOption: { minimal: true },
349
+ retainLines: true,
350
+ }, source);
351
+
352
+ return output.code;
353
+ }
354
+
355
+ module.exports = {
356
+ transformSource,
357
+ replaceNuggetsInPlainString,
358
+ transformJsxNuggets,
359
+ buildJsxChildrenFromTranslation,
360
+ };