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 +14 -0
- package/package.json +12 -6
- package/src/index.js +91 -185
- package/src/loader.js +22 -0
- package/src/transform.js +360 -0
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.
|
|
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
|
-
|
|
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": "^
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
};
|
package/src/transform.js
ADDED
|
@@ -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
|
+
};
|