next-intl 4.4.0 → 4.5.1
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/dist/cjs/development/plugin.cjs +129 -8
- package/dist/esm/development/extractor/ExtractionCompiler.js +41 -0
- package/dist/esm/development/extractor/catalog/CatalogLocales.js +117 -0
- package/dist/esm/development/extractor/catalog/CatalogManager.js +286 -0
- package/dist/esm/development/extractor/catalog/CatalogPersister.js +45 -0
- package/dist/esm/development/extractor/catalog/SaveScheduler.js +66 -0
- package/dist/esm/development/extractor/catalogLoader.js +35 -0
- package/dist/esm/development/extractor/extractMessages.js +8 -0
- package/dist/esm/development/extractor/extractionLoader.js +22 -0
- package/dist/esm/development/extractor/extractor/ASTScope.js +18 -0
- package/dist/esm/development/extractor/extractor/KeyGenerator.js +11 -0
- package/dist/esm/development/extractor/extractor/LRUCache.js +30 -0
- package/dist/esm/development/extractor/extractor/MessageExtractor.js +402 -0
- package/dist/esm/development/extractor/formatters/Formatter.js +3 -0
- package/dist/esm/development/extractor/formatters/JSONFormatter.js +42 -0
- package/dist/esm/development/extractor/formatters/POFormatter.js +51 -0
- package/dist/esm/development/extractor/formatters/index.js +6 -0
- package/dist/esm/development/extractor/formatters/utils.js +13 -0
- package/dist/esm/development/extractor/source/SourceFileFilter.js +11 -0
- package/dist/esm/development/extractor/source/SourceFileScanner.js +27 -0
- package/dist/esm/development/extractor/utils/ObjectUtils.js +14 -0
- package/dist/esm/development/extractor/utils/POParser.js +222 -0
- package/dist/esm/development/extractor.js +1 -0
- package/dist/esm/development/index.react-client.js +2 -1
- package/dist/esm/development/index.react-server.js +1 -0
- package/dist/esm/development/middleware/utils.js +6 -2
- package/dist/esm/development/plugin/createNextIntlPlugin.js +1 -1
- package/dist/esm/development/plugin/{createMessagesDeclaration.js → declaration/createMessagesDeclaration.js} +2 -2
- package/dist/esm/development/plugin/getNextConfig.js +117 -8
- package/dist/esm/development/plugin/{hasStableTurboConfig.js → nextFlags.js} +7 -2
- package/dist/esm/development/react-client/index.js +0 -1
- package/dist/esm/development/react-server/useExtracted.js +9 -0
- package/dist/esm/development/server/react-client/index.js +2 -1
- package/dist/esm/development/server/react-server/getExtracted.js +24 -0
- package/dist/esm/development/server/react-server/getServerExtractor.js +38 -0
- package/dist/esm/development/server.react-client.js +1 -1
- package/dist/esm/development/server.react-server.js +1 -0
- package/dist/esm/development/shared/utils.js +2 -1
- package/dist/esm/production/extractor/ExtractionCompiler.js +1 -0
- package/dist/esm/production/extractor/catalog/CatalogLocales.js +1 -0
- package/dist/esm/production/extractor/catalog/CatalogManager.js +1 -0
- package/dist/esm/production/extractor/catalog/CatalogPersister.js +1 -0
- package/dist/esm/production/extractor/catalog/SaveScheduler.js +1 -0
- package/dist/esm/production/extractor/catalogLoader.js +1 -0
- package/dist/esm/production/extractor/extractMessages.js +1 -0
- package/dist/esm/production/extractor/extractionLoader.js +1 -0
- package/dist/esm/production/extractor/extractor/ASTScope.js +1 -0
- package/dist/esm/production/extractor/extractor/KeyGenerator.js +1 -0
- package/dist/esm/production/extractor/extractor/LRUCache.js +1 -0
- package/dist/esm/production/extractor/extractor/MessageExtractor.js +1 -0
- package/dist/esm/production/extractor/formatters/Formatter.js +1 -0
- package/dist/esm/production/extractor/formatters/JSONFormatter.js +1 -0
- package/dist/esm/production/extractor/formatters/POFormatter.js +1 -0
- package/dist/esm/production/extractor/formatters/index.js +1 -0
- package/dist/esm/production/extractor/formatters/utils.js +1 -0
- package/dist/esm/production/extractor/source/SourceFileFilter.js +1 -0
- package/dist/esm/production/extractor/source/SourceFileScanner.js +1 -0
- package/dist/esm/production/extractor/utils/ObjectUtils.js +1 -0
- package/dist/esm/production/extractor/utils/POParser.js +1 -0
- package/dist/esm/production/extractor.js +1 -0
- package/dist/esm/production/index.react-client.js +1 -1
- package/dist/esm/production/index.react-server.js +1 -1
- package/dist/esm/production/middleware/utils.js +1 -1
- package/dist/esm/production/plugin/createNextIntlPlugin.js +1 -1
- package/dist/esm/production/plugin/declaration/createMessagesDeclaration.js +1 -0
- package/dist/esm/production/plugin/getNextConfig.js +1 -1
- package/dist/esm/production/plugin/nextFlags.js +1 -0
- package/dist/esm/production/react-client/index.js +1 -1
- package/dist/esm/production/react-server/useExtracted.js +1 -0
- package/dist/esm/production/server/react-client/index.js +1 -1
- package/dist/esm/production/server/react-server/getExtracted.js +1 -0
- package/dist/esm/production/server/react-server/getServerExtractor.js +1 -0
- package/dist/esm/production/server.react-client.js +1 -1
- package/dist/esm/production/server.react-server.js +1 -1
- package/dist/esm/production/shared/utils.js +1 -1
- package/dist/types/extractor/ExtractionCompiler.d.ts +14 -0
- package/dist/types/extractor/catalog/CatalogLocales.d.ts +31 -0
- package/dist/types/extractor/catalog/CatalogManager.d.ts +46 -0
- package/dist/types/extractor/catalog/CatalogPersister.d.ts +11 -0
- package/dist/types/extractor/catalog/SaveScheduler.d.ts +17 -0
- package/dist/types/extractor/extractMessages.d.ts +2 -0
- package/dist/types/extractor/extractor/ASTScope.d.ts +12 -0
- package/dist/types/extractor/extractor/KeyGenerator.d.ts +3 -0
- package/dist/types/extractor/extractor/LRUCache.d.ts +7 -0
- package/dist/types/extractor/extractor/MessageExtractor.d.ts +20 -0
- package/dist/types/extractor/formatters/Formatter.d.ts +10 -0
- package/dist/types/extractor/formatters/JSONFormatter.d.ts +10 -0
- package/dist/types/extractor/formatters/POFormatter.d.ts +10 -0
- package/dist/types/extractor/formatters/index.d.ts +5 -0
- package/dist/types/extractor/formatters/utils.d.ts +2 -0
- package/dist/types/extractor/index.d.ts +1 -0
- package/dist/types/extractor/source/SourceFileFilter.d.ts +4 -0
- package/dist/types/extractor/source/SourceFileScanner.d.ts +4 -0
- package/dist/types/extractor/types.d.ts +23 -0
- package/dist/types/extractor/utils/ObjectUtils.d.ts +1 -0
- package/dist/types/extractor/utils/POParser.d.ts +24 -0
- package/dist/types/extractor.d.ts +1 -0
- package/dist/types/navigation/react-client/createNavigation.d.ts +9 -9
- package/dist/types/navigation/react-server/createNavigation.d.ts +9 -9
- package/dist/types/navigation/shared/createSharedNavigationFns.d.ts +10 -10
- package/dist/types/plugin/catalog/catalogLoader.d.ts +10 -0
- package/dist/types/plugin/declaration/index.d.ts +1 -0
- package/dist/types/plugin/extractor/extractionLoader.d.ts +3 -0
- package/dist/types/plugin/nextFlags.d.ts +2 -0
- package/dist/types/plugin/types.d.ts +18 -0
- package/dist/types/react-client/index.d.ts +3 -1
- package/dist/types/react-server/index.d.ts +1 -0
- package/dist/types/react-server/useExtracted.d.ts +2 -0
- package/dist/types/server/react-client/index.d.ts +2 -1
- package/dist/types/server/react-server/getExtracted.d.ts +10 -0
- package/dist/types/server/react-server/getServerExtractor.d.ts +5 -0
- package/dist/types/server/react-server/index.d.ts +1 -0
- package/extractor/catalogLoader.d.ts +4 -0
- package/extractor/extractionLoader.d.ts +4 -0
- package/extractor.d.ts +2 -0
- package/package.json +20 -5
- package/dist/esm/production/plugin/createMessagesDeclaration.js +0 -1
- package/dist/esm/production/plugin/hasStableTurboConfig.js +0 -1
- package/dist/types/plugin/hasStableTurboConfig.d.ts +0 -2
- /package/dist/types/plugin/{createMessagesDeclaration.d.ts → declaration/createMessagesDeclaration.d.ts} +0 -0
|
@@ -105,6 +105,14 @@ export default messages;`;
|
|
|
105
105
|
fs.writeFileSync(declarationPath, createDeclaration(content));
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
class SourceFileFilter {
|
|
109
|
+
static EXTENSIONS = ['ts', 'tsx', 'js', 'jsx'];
|
|
110
|
+
static isSourceFile(filePath) {
|
|
111
|
+
const ext = path.extname(filePath);
|
|
112
|
+
return SourceFileFilter.EXTENSIONS.map(cur => '.' + cur).includes(ext);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
108
116
|
function getCurrentVersion() {
|
|
109
117
|
try {
|
|
110
118
|
const require$1 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('plugin.cjs', document.baseURI).href)));
|
|
@@ -127,7 +135,12 @@ function compareVersions(version1, version2) {
|
|
|
127
135
|
}
|
|
128
136
|
return 0;
|
|
129
137
|
}
|
|
130
|
-
|
|
138
|
+
function hasStableTurboConfig() {
|
|
139
|
+
return compareVersions(getCurrentVersion(), '15.3.0') >= 0;
|
|
140
|
+
}
|
|
141
|
+
function isNextJs16OrHigher() {
|
|
142
|
+
return compareVersions(getCurrentVersion(), '16.0.0') >= 0;
|
|
143
|
+
}
|
|
131
144
|
|
|
132
145
|
function withExtensions(localPath) {
|
|
133
146
|
return [`${localPath}.ts`, `${localPath}.tsx`, `${localPath}.js`, `${localPath}.jsx`];
|
|
@@ -165,22 +178,99 @@ const withNextIntl = createNextIntlPlugin(
|
|
|
165
178
|
function getNextConfig(pluginConfig, nextConfig) {
|
|
166
179
|
const useTurbo = process.env.TURBOPACK != null;
|
|
167
180
|
const nextIntlConfig = {};
|
|
168
|
-
|
|
169
|
-
|
|
181
|
+
function getExtractMessagesLoaderConfig() {
|
|
182
|
+
const experimental = pluginConfig.experimental;
|
|
183
|
+
if (!experimental.srcPath || !experimental.messages) {
|
|
184
|
+
throwError('`srcPath` and `messages` are required when using `extractor`.');
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
loader: 'next-intl/extractor/extractionLoader',
|
|
188
|
+
options: {
|
|
189
|
+
srcPath: experimental.srcPath,
|
|
190
|
+
sourceLocale: experimental.extract.sourceLocale,
|
|
191
|
+
messages: experimental.messages
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function getCatalogLoaderConfig() {
|
|
196
|
+
return {
|
|
197
|
+
loader: 'next-intl/extractor/catalogLoader',
|
|
198
|
+
options: {
|
|
199
|
+
messages: pluginConfig.experimental.messages
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function getTurboRules() {
|
|
204
|
+
return nextConfig?.turbopack?.rules ||
|
|
205
|
+
// @ts-expect-error -- For Next.js <16
|
|
206
|
+
nextConfig?.experimental?.turbo?.rules || {};
|
|
207
|
+
}
|
|
208
|
+
function addTurboRule(rules, glob, rule) {
|
|
209
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
210
|
+
if (rules[glob]) {
|
|
211
|
+
if (Array.isArray(rules[glob])) {
|
|
212
|
+
rules[glob].push(rule);
|
|
213
|
+
} else {
|
|
214
|
+
rules[glob] = [rules[glob], rule];
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
rules[glob] = rule;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
170
220
|
if (useTurbo) {
|
|
171
|
-
if (pluginConfig.requestConfig
|
|
221
|
+
if (pluginConfig.requestConfig && path.isAbsolute(pluginConfig.requestConfig)) {
|
|
172
222
|
throwError("Turbopack support for next-intl currently does not support absolute paths, please provide a relative one (e.g. './src/i18n/config.ts').\n\nFound: " + pluginConfig.requestConfig);
|
|
173
223
|
}
|
|
224
|
+
|
|
225
|
+
// Assign alias for `next-intl/config`
|
|
174
226
|
const resolveAlias = {
|
|
175
227
|
// Turbo aliases don't work with absolute
|
|
176
228
|
// paths (see error handling above)
|
|
177
229
|
'next-intl/config': resolveI18nPath(pluginConfig.requestConfig)
|
|
178
230
|
};
|
|
179
|
-
|
|
231
|
+
|
|
232
|
+
// Add loaders
|
|
233
|
+
let rules;
|
|
234
|
+
|
|
235
|
+
// Add loader for extractor
|
|
236
|
+
if (pluginConfig.experimental?.extract) {
|
|
237
|
+
if (!isNextJs16OrHigher()) {
|
|
238
|
+
throwError('Message extraction requires Next.js 16 or higher.');
|
|
239
|
+
}
|
|
240
|
+
rules ??= getTurboRules();
|
|
241
|
+
addTurboRule(rules, `*.{${SourceFileFilter.EXTENSIONS.join(',')}}`, {
|
|
242
|
+
loaders: [getExtractMessagesLoaderConfig()],
|
|
243
|
+
condition: {
|
|
244
|
+
// Note: We don't need `not: 'foreign'`, because this is
|
|
245
|
+
// implied by the filter based on `srcPath`.
|
|
246
|
+
path: (Array.isArray(pluginConfig.experimental.srcPath) ? `{${pluginConfig.experimental.srcPath.join(',')}}` : pluginConfig.experimental.srcPath) + '/**/*',
|
|
247
|
+
content: /(useExtracted|getExtracted)/
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Add loader for catalog
|
|
253
|
+
if (pluginConfig.experimental?.messages) {
|
|
254
|
+
if (!isNextJs16OrHigher()) {
|
|
255
|
+
throwError('Message catalog loading requires Next.js 16 or higher.');
|
|
256
|
+
}
|
|
257
|
+
rules ??= getTurboRules();
|
|
258
|
+
addTurboRule(rules, `*.${pluginConfig.experimental.messages.format}`, {
|
|
259
|
+
loaders: [getCatalogLoaderConfig()],
|
|
260
|
+
condition: {
|
|
261
|
+
path: `${pluginConfig.experimental.messages.path}/**/*`
|
|
262
|
+
},
|
|
263
|
+
as: '*.js'
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (hasStableTurboConfig() &&
|
|
180
267
|
// @ts-expect-error -- For Next.js <16
|
|
181
268
|
!nextConfig?.experimental?.turbo) {
|
|
182
269
|
nextIntlConfig.turbopack = {
|
|
183
270
|
...nextConfig?.turbopack,
|
|
271
|
+
...(rules && {
|
|
272
|
+
rules
|
|
273
|
+
}),
|
|
184
274
|
resolveAlias: {
|
|
185
275
|
...nextConfig?.turbopack?.resolveAlias,
|
|
186
276
|
...resolveAlias
|
|
@@ -193,6 +283,9 @@ function getNextConfig(pluginConfig, nextConfig) {
|
|
|
193
283
|
turbo: {
|
|
194
284
|
// @ts-expect-error -- For Next.js <16
|
|
195
285
|
...nextConfig?.experimental?.turbo,
|
|
286
|
+
...(rules && {
|
|
287
|
+
rules
|
|
288
|
+
}),
|
|
196
289
|
resolveAlias: {
|
|
197
290
|
// @ts-expect-error -- For Next.js <16
|
|
198
291
|
...nextConfig?.experimental?.turbo?.resolveAlias,
|
|
@@ -202,11 +295,39 @@ function getNextConfig(pluginConfig, nextConfig) {
|
|
|
202
295
|
};
|
|
203
296
|
}
|
|
204
297
|
} else {
|
|
205
|
-
nextIntlConfig.webpack = function webpack(
|
|
206
|
-
|
|
298
|
+
nextIntlConfig.webpack = function webpack(config, context) {
|
|
299
|
+
if (!config.resolve) config.resolve = {};
|
|
300
|
+
if (!config.resolve.alias) config.resolve.alias = {};
|
|
301
|
+
|
|
302
|
+
// Assign alias for `next-intl/config`
|
|
303
|
+
// (Webpack requires absolute paths)
|
|
207
304
|
config.resolve.alias['next-intl/config'] = path.resolve(config.context, resolveI18nPath(pluginConfig.requestConfig, config.context));
|
|
305
|
+
|
|
306
|
+
// Add loader for extractor
|
|
307
|
+
if (pluginConfig.experimental?.extract) {
|
|
308
|
+
if (!config.module) config.module = {};
|
|
309
|
+
if (!config.module.rules) config.module.rules = [];
|
|
310
|
+
const srcPath = pluginConfig.experimental.srcPath;
|
|
311
|
+
config.module.rules.push({
|
|
312
|
+
test: new RegExp(`\\.(${SourceFileFilter.EXTENSIONS.join('|')})$`),
|
|
313
|
+
include: Array.isArray(srcPath) ? srcPath.map(cur => path.resolve(config.context, cur)) : path.resolve(config.context, srcPath || ''),
|
|
314
|
+
use: [getExtractMessagesLoaderConfig()]
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Add loader for catalog
|
|
319
|
+
if (pluginConfig.experimental?.messages) {
|
|
320
|
+
if (!config.module) config.module = {};
|
|
321
|
+
if (!config.module.rules) config.module.rules = [];
|
|
322
|
+
config.module.rules.push({
|
|
323
|
+
test: new RegExp(`\\.${pluginConfig.experimental.messages.format}$`),
|
|
324
|
+
include: path.resolve(config.context, pluginConfig.experimental.messages.path),
|
|
325
|
+
use: [getCatalogLoaderConfig()],
|
|
326
|
+
type: 'javascript/auto'
|
|
327
|
+
});
|
|
328
|
+
}
|
|
208
329
|
if (typeof nextConfig?.webpack === 'function') {
|
|
209
|
-
return nextConfig.webpack(config,
|
|
330
|
+
return nextConfig.webpack(config, context);
|
|
210
331
|
}
|
|
211
332
|
return config;
|
|
212
333
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import CatalogManager from './catalog/CatalogManager.js';
|
|
2
|
+
|
|
3
|
+
class ExtractionCompiler {
|
|
4
|
+
isDevelopment = false;
|
|
5
|
+
constructor(config, opts = {}) {
|
|
6
|
+
this.manager = new CatalogManager(config, opts);
|
|
7
|
+
this.isDevelopment = opts.isDevelopment ?? false;
|
|
8
|
+
|
|
9
|
+
// Kick off the initial scan as early as possible,
|
|
10
|
+
// while awaiting it in `compile`. This also ensures
|
|
11
|
+
// we're only scanning once.
|
|
12
|
+
this.initialScanPromise = this.performInitialScan();
|
|
13
|
+
}
|
|
14
|
+
async compile(resourcePath, source) {
|
|
15
|
+
if (this.initialScanPromise) {
|
|
16
|
+
await this.initialScanPromise;
|
|
17
|
+
this.initialScanPromise = undefined;
|
|
18
|
+
}
|
|
19
|
+
const result = await this.manager.extractFileMessages(resourcePath, source);
|
|
20
|
+
if (this.isDevelopment && result.changed) {
|
|
21
|
+
// While we await the AST modification, we
|
|
22
|
+
// don't need to await the persistence
|
|
23
|
+
void this.manager.save();
|
|
24
|
+
}
|
|
25
|
+
return result.source;
|
|
26
|
+
}
|
|
27
|
+
async performInitialScan() {
|
|
28
|
+
// We can't rely on all files being compiled (e.g. due to persistent
|
|
29
|
+
// caching), so loading the messages initially is necessary.
|
|
30
|
+
await this.manager.loadMessages();
|
|
31
|
+
await this.manager.save();
|
|
32
|
+
}
|
|
33
|
+
async extract() {
|
|
34
|
+
await this.initialScanPromise;
|
|
35
|
+
}
|
|
36
|
+
[Symbol.dispose]() {
|
|
37
|
+
this.manager.destroy();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { ExtractionCompiler as default };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs$1 from 'fs';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
class CatalogLocales {
|
|
6
|
+
cleanupHandlers = [];
|
|
7
|
+
onChangeCallbacks = (() => new Set())();
|
|
8
|
+
constructor(params) {
|
|
9
|
+
this.messagesDir = params.messagesDir;
|
|
10
|
+
this.sourceLocale = params.sourceLocale;
|
|
11
|
+
this.extension = params.extension;
|
|
12
|
+
this.locales = params.locales;
|
|
13
|
+
}
|
|
14
|
+
async getTargetLocales() {
|
|
15
|
+
if (this.targetLocales) {
|
|
16
|
+
return this.targetLocales;
|
|
17
|
+
}
|
|
18
|
+
if (this.locales === 'infer') {
|
|
19
|
+
this.targetLocales = await this.readTargetLocales();
|
|
20
|
+
} else {
|
|
21
|
+
this.targetLocales = this.locales.filter(locale => locale !== this.sourceLocale);
|
|
22
|
+
}
|
|
23
|
+
return this.targetLocales;
|
|
24
|
+
}
|
|
25
|
+
async readTargetLocales() {
|
|
26
|
+
try {
|
|
27
|
+
const files = await fs.readdir(this.messagesDir);
|
|
28
|
+
return files.filter(file => file.endsWith(this.extension)).map(file => path.basename(file, this.extension)).filter(locale => locale !== this.sourceLocale);
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
subscribeLocalesChange(callback) {
|
|
34
|
+
this.onChangeCallbacks.add(callback);
|
|
35
|
+
if (this.locales === 'infer' && !this.watcher) {
|
|
36
|
+
void this.startWatcher();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
unsubscribeLocalesChange(callback) {
|
|
40
|
+
this.onChangeCallbacks.delete(callback);
|
|
41
|
+
if (this.onChangeCallbacks.size === 0) {
|
|
42
|
+
this.stopWatcher();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async startWatcher() {
|
|
46
|
+
if (this.watcher) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
await fs.mkdir(this.messagesDir, {
|
|
50
|
+
recursive: true
|
|
51
|
+
});
|
|
52
|
+
this.watcher = fs$1.watch(this.messagesDir, {
|
|
53
|
+
persistent: false,
|
|
54
|
+
recursive: false
|
|
55
|
+
}, (event, filename) => {
|
|
56
|
+
const isCatalogFile = filename != null && filename.endsWith(this.extension) && !filename.includes(path.sep);
|
|
57
|
+
if (isCatalogFile) {
|
|
58
|
+
void this.onChange();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
this.setupCleanupHandlers();
|
|
62
|
+
}
|
|
63
|
+
stopWatcher() {
|
|
64
|
+
if (this.watcher) {
|
|
65
|
+
this.watcher.close();
|
|
66
|
+
this.watcher = undefined;
|
|
67
|
+
}
|
|
68
|
+
for (const handler of this.cleanupHandlers) {
|
|
69
|
+
handler();
|
|
70
|
+
}
|
|
71
|
+
this.cleanupHandlers = [];
|
|
72
|
+
}
|
|
73
|
+
async onChange() {
|
|
74
|
+
const oldLocales = new Set(this.targetLocales || []);
|
|
75
|
+
this.targetLocales = await this.readTargetLocales();
|
|
76
|
+
const newLocalesSet = new Set(this.targetLocales);
|
|
77
|
+
const added = this.targetLocales.filter(locale => !oldLocales.has(locale));
|
|
78
|
+
const removed = Array.from(oldLocales).filter(locale => !newLocalesSet.has(locale));
|
|
79
|
+
if (added.length > 0 || removed.length > 0) {
|
|
80
|
+
for (const callback of this.onChangeCallbacks) {
|
|
81
|
+
callback({
|
|
82
|
+
added,
|
|
83
|
+
removed
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
setupCleanupHandlers() {
|
|
89
|
+
const cleanup = () => {
|
|
90
|
+
if (this.watcher) {
|
|
91
|
+
this.watcher.close();
|
|
92
|
+
this.watcher = undefined;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
function exitHandler() {
|
|
96
|
+
cleanup();
|
|
97
|
+
}
|
|
98
|
+
function sigintHandler() {
|
|
99
|
+
cleanup();
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
function sigtermHandler() {
|
|
103
|
+
cleanup();
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
process.once('exit', exitHandler);
|
|
107
|
+
process.once('SIGINT', sigintHandler);
|
|
108
|
+
process.once('SIGTERM', sigtermHandler);
|
|
109
|
+
this.cleanupHandlers.push(() => {
|
|
110
|
+
process.removeListener('exit', exitHandler);
|
|
111
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
112
|
+
process.removeListener('SIGTERM', sigtermHandler);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { CatalogLocales as default };
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import MessageExtractor from '../extractor/MessageExtractor.js';
|
|
4
|
+
import formatters from '../formatters/index.js';
|
|
5
|
+
import SourceFileScanner from '../source/SourceFileScanner.js';
|
|
6
|
+
import CatalogLocales from './CatalogLocales.js';
|
|
7
|
+
import CatalogPersister from './CatalogPersister.js';
|
|
8
|
+
import SaveScheduler from './SaveScheduler.js';
|
|
9
|
+
|
|
10
|
+
class CatalogManager {
|
|
11
|
+
/* The source of truth for which messages are used. */
|
|
12
|
+
messagesByFile = (() => new Map())();
|
|
13
|
+
|
|
14
|
+
/* Fast lookup for messages by ID across all files,
|
|
15
|
+
* contains the same messages as `messagesByFile`. */
|
|
16
|
+
messagesById = (() => new Map())();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* This potentially also includes outdated ones that were initially available,
|
|
20
|
+
* but are not used anymore. This allows to restore them if they are used again.
|
|
21
|
+
**/
|
|
22
|
+
translationsByTargetLocale = (() => new Map())();
|
|
23
|
+
lastWriteByLocale = (() => new Map())();
|
|
24
|
+
|
|
25
|
+
// Cached instances
|
|
26
|
+
|
|
27
|
+
constructor(config, opts = {}) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.saveScheduler = new SaveScheduler(50);
|
|
30
|
+
this.projectRoot = opts.projectRoot || process.cwd();
|
|
31
|
+
this.isDevelopment = opts.isDevelopment ?? false;
|
|
32
|
+
this.messageExtractor = new MessageExtractor({
|
|
33
|
+
isDevelopment: this.isDevelopment,
|
|
34
|
+
projectRoot: this.projectRoot
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async getFormatter() {
|
|
38
|
+
if (this.formatter) {
|
|
39
|
+
return this.formatter;
|
|
40
|
+
} else {
|
|
41
|
+
const FormatterClass = (await formatters[this.config.messages.format]()).default;
|
|
42
|
+
this.formatter = new FormatterClass();
|
|
43
|
+
return this.formatter;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async getPersister() {
|
|
47
|
+
if (this.persister) {
|
|
48
|
+
return this.persister;
|
|
49
|
+
} else {
|
|
50
|
+
this.persister = new CatalogPersister(this.config.messages.path, await this.getFormatter());
|
|
51
|
+
return this.persister;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async getCatalogLocales() {
|
|
55
|
+
if (this.catalogLocales) {
|
|
56
|
+
return this.catalogLocales;
|
|
57
|
+
} else {
|
|
58
|
+
const messagesDir = path.join(this.projectRoot, this.config.messages.path);
|
|
59
|
+
const formatter = await this.getFormatter();
|
|
60
|
+
this.catalogLocales = new CatalogLocales({
|
|
61
|
+
messagesDir,
|
|
62
|
+
sourceLocale: this.config.sourceLocale,
|
|
63
|
+
extension: formatter.EXTENSION,
|
|
64
|
+
locales: this.config.messages.locales
|
|
65
|
+
});
|
|
66
|
+
return this.catalogLocales;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async getTargetLocales() {
|
|
70
|
+
const catalogLocales = await this.getCatalogLocales();
|
|
71
|
+
return catalogLocales.getTargetLocales();
|
|
72
|
+
}
|
|
73
|
+
getSrcPaths() {
|
|
74
|
+
return (Array.isArray(this.config.srcPath) ? this.config.srcPath : [this.config.srcPath]).map(srcPath => path.join(this.projectRoot, srcPath));
|
|
75
|
+
}
|
|
76
|
+
getFileMessages(absoluteFilePath) {
|
|
77
|
+
return this.messagesByFile.get(absoluteFilePath);
|
|
78
|
+
}
|
|
79
|
+
async loadMessages() {
|
|
80
|
+
await this.loadSourceMessages();
|
|
81
|
+
await this.loadTargetMessages();
|
|
82
|
+
if (this.isDevelopment) {
|
|
83
|
+
const catalogLocales = await this.getCatalogLocales();
|
|
84
|
+
catalogLocales.subscribeLocalesChange(this.onLocalesChange);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async loadSourceMessages() {
|
|
88
|
+
// First hydrate from source locale file to potentially init metadata
|
|
89
|
+
await this.loadLocaleMessages(this.config.sourceLocale);
|
|
90
|
+
|
|
91
|
+
// Then extract from all source files
|
|
92
|
+
const sourceFiles = await SourceFileScanner.getSourceFiles(this.getSrcPaths());
|
|
93
|
+
await Promise.all(sourceFiles.map(async filePath => this.extractFileMessages(filePath, await fs.readFile(filePath, 'utf8'))));
|
|
94
|
+
}
|
|
95
|
+
async loadLocaleMessages(locale) {
|
|
96
|
+
const persister = await this.getPersister();
|
|
97
|
+
try {
|
|
98
|
+
const messages = await persister.read(locale);
|
|
99
|
+
const fileTime = await persister.getLastModified(locale);
|
|
100
|
+
this.lastWriteByLocale.set(locale, fileTime);
|
|
101
|
+
return messages;
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async loadTargetMessages() {
|
|
107
|
+
const targetLocales = await this.getTargetLocales();
|
|
108
|
+
await Promise.all(targetLocales.map(async locale => {
|
|
109
|
+
this.translationsByTargetLocale.set(locale, new Map());
|
|
110
|
+
const messages = await this.loadLocaleMessages(locale);
|
|
111
|
+
for (const message of messages) {
|
|
112
|
+
const translations = this.translationsByTargetLocale.get(locale);
|
|
113
|
+
translations.set(message.id, message.message);
|
|
114
|
+
}
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
async extractFileMessages(absoluteFilePath, source) {
|
|
118
|
+
const result = await this.messageExtractor.processFileContent(absoluteFilePath, source);
|
|
119
|
+
const prevFileMessages = this.messagesByFile.get(absoluteFilePath);
|
|
120
|
+
|
|
121
|
+
// Init with all previous ones
|
|
122
|
+
const idsToRemove = Array.from(prevFileMessages?.keys() ?? []);
|
|
123
|
+
|
|
124
|
+
// Replace existing messages with new ones
|
|
125
|
+
const fileMessages = new Map();
|
|
126
|
+
for (let message of result.messages) {
|
|
127
|
+
const prevMessage = this.messagesById.get(message.id);
|
|
128
|
+
|
|
129
|
+
// Merge with previous message if it exists
|
|
130
|
+
if (prevMessage) {
|
|
131
|
+
// References: The `message` we receive here will always have one
|
|
132
|
+
// reference, which is the current file. We need to merge this with
|
|
133
|
+
// potentially existing references.
|
|
134
|
+
const references = [...(prevMessage.references ?? [])];
|
|
135
|
+
message.references.forEach(ref => {
|
|
136
|
+
if (!references.some(cur => cur.path === ref.path)) {
|
|
137
|
+
references.push(ref);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
message = {
|
|
141
|
+
...message,
|
|
142
|
+
references
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Description: In case we have conflicting descriptions, the new one wins.
|
|
146
|
+
if (prevMessage.description && !message.description) {
|
|
147
|
+
message = {
|
|
148
|
+
...message,
|
|
149
|
+
description: prevMessage.description
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
this.messagesById.set(message.id, message);
|
|
154
|
+
fileMessages.set(message.id, message);
|
|
155
|
+
|
|
156
|
+
// This message continues to exist in this file
|
|
157
|
+
const index = idsToRemove.indexOf(message.id);
|
|
158
|
+
if (index !== -1) idsToRemove.splice(index, 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Don't delete IDs still used in other files
|
|
162
|
+
const relativeFilePath = path.relative(this.projectRoot, absoluteFilePath);
|
|
163
|
+
const idsToDelete = idsToRemove.filter(id => {
|
|
164
|
+
const message = this.messagesById.get(id);
|
|
165
|
+
return !message?.references?.some(ref => ref.path !== relativeFilePath);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Clean up removed messages from `messagesById`
|
|
169
|
+
idsToDelete.forEach(id => {
|
|
170
|
+
this.messagesById.delete(id);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Update the stored messages
|
|
174
|
+
const hasMessages = result.messages.length > 0;
|
|
175
|
+
if (hasMessages) {
|
|
176
|
+
this.messagesByFile.set(absoluteFilePath, fileMessages);
|
|
177
|
+
} else {
|
|
178
|
+
this.messagesByFile.delete(absoluteFilePath);
|
|
179
|
+
}
|
|
180
|
+
const changed = this.haveMessagesChanged(prevFileMessages, fileMessages);
|
|
181
|
+
return {
|
|
182
|
+
...result,
|
|
183
|
+
changed
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
haveMessagesChanged(beforeMessages, afterMessages) {
|
|
187
|
+
// If one exists and the other doesn't, there's a change
|
|
188
|
+
if (!beforeMessages) {
|
|
189
|
+
return afterMessages.size > 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Different sizes means changes
|
|
193
|
+
if (beforeMessages.size !== afterMessages.size) {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check differences in beforeMessages vs afterMessages
|
|
198
|
+
for (const [id, msg1] of beforeMessages) {
|
|
199
|
+
const msg2 = afterMessages.get(id);
|
|
200
|
+
if (!msg2 || !this.areMessagesEqual(msg1, msg2)) {
|
|
201
|
+
return true; // Early exit on first difference
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
areMessagesEqual(msg1, msg2) {
|
|
207
|
+
return msg1.id === msg2.id && msg1.message === msg2.message && msg1.description === msg2.description && this.areReferencesEqual(msg1.references, msg2.references);
|
|
208
|
+
}
|
|
209
|
+
areReferencesEqual(refs1, refs2) {
|
|
210
|
+
// Both undefined or both empty
|
|
211
|
+
if (!refs1 && !refs2) return true;
|
|
212
|
+
if (!refs1 || !refs2) return false;
|
|
213
|
+
if (refs1.length !== refs2.length) return false;
|
|
214
|
+
|
|
215
|
+
// Compare each reference
|
|
216
|
+
for (let i = 0; i < refs1.length; i++) {
|
|
217
|
+
if (refs1[i].path !== refs2[i].path) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
async save() {
|
|
224
|
+
return this.saveScheduler.schedule(() => this.saveImpl());
|
|
225
|
+
}
|
|
226
|
+
async saveImpl() {
|
|
227
|
+
const messages = Array.from(this.messagesById.values());
|
|
228
|
+
const persister = await this.getPersister();
|
|
229
|
+
await persister.write(this.config.sourceLocale, messages);
|
|
230
|
+
for (const locale of await this.getTargetLocales()) {
|
|
231
|
+
await this.saveLocale(locale);
|
|
232
|
+
}
|
|
233
|
+
return messages.length;
|
|
234
|
+
}
|
|
235
|
+
async saveLocale(locale) {
|
|
236
|
+
const messages = Array.from(this.messagesById.values());
|
|
237
|
+
const persister = await this.getPersister();
|
|
238
|
+
|
|
239
|
+
// Check if file was modified externally
|
|
240
|
+
const lastWriteTime = this.lastWriteByLocale.get(locale);
|
|
241
|
+
const currentFileTime = await persister.getLastModified(locale);
|
|
242
|
+
|
|
243
|
+
// If file was modified externally, read and merge
|
|
244
|
+
if (currentFileTime && lastWriteTime && currentFileTime > lastWriteTime) {
|
|
245
|
+
const diskMessages = await persister.read(locale);
|
|
246
|
+
const translations = this.translationsByTargetLocale.get(locale);
|
|
247
|
+
for (const diskMessage of diskMessages) {
|
|
248
|
+
// Disk wins: preserve manual edits
|
|
249
|
+
translations.set(diskMessage.id, diskMessage.message);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const translations = this.translationsByTargetLocale.get(locale);
|
|
253
|
+
const localeMessages = messages.map(message => ({
|
|
254
|
+
...message,
|
|
255
|
+
message: translations.get(message.id) || ''
|
|
256
|
+
}));
|
|
257
|
+
await persister.write(locale, localeMessages);
|
|
258
|
+
|
|
259
|
+
// Update timestamps
|
|
260
|
+
const newTime = await persister.getLastModified(locale);
|
|
261
|
+
this.lastWriteByLocale.set(locale, newTime);
|
|
262
|
+
}
|
|
263
|
+
onLocalesChange = async params => {
|
|
264
|
+
for (const locale of params.added) {
|
|
265
|
+
const translations = new Map();
|
|
266
|
+
this.translationsByTargetLocale.set(locale, translations);
|
|
267
|
+
const messages = await this.loadLocaleMessages(locale);
|
|
268
|
+
for (const message of messages) {
|
|
269
|
+
translations.set(message.id, message.message);
|
|
270
|
+
}
|
|
271
|
+
await this.saveLocale(locale);
|
|
272
|
+
}
|
|
273
|
+
for (const locale of params.removed) {
|
|
274
|
+
this.translationsByTargetLocale.delete(locale);
|
|
275
|
+
this.lastWriteByLocale.delete(locale);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
destroy() {
|
|
279
|
+
this.saveScheduler.destroy();
|
|
280
|
+
if (this.catalogLocales && this.isDevelopment) {
|
|
281
|
+
this.catalogLocales.unsubscribeLocalesChange(this.onLocalesChange);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export { CatalogManager as default };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
class CatalogPersister {
|
|
5
|
+
constructor(messagesPath, formatter) {
|
|
6
|
+
this.messagesPath = messagesPath;
|
|
7
|
+
this.formatter = formatter;
|
|
8
|
+
}
|
|
9
|
+
getFilePath(locale) {
|
|
10
|
+
return path.join(this.messagesPath, locale + this.formatter.EXTENSION);
|
|
11
|
+
}
|
|
12
|
+
async read(locale) {
|
|
13
|
+
const filePath = this.getFilePath(locale);
|
|
14
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
15
|
+
return this.formatter.parse(content, {
|
|
16
|
+
locale
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async write(locale, messages) {
|
|
20
|
+
const filePath = this.getFilePath(locale);
|
|
21
|
+
const content = this.formatter.serialize(messages, {
|
|
22
|
+
locale
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
const outputDir = path.dirname(filePath);
|
|
26
|
+
await fs.mkdir(outputDir, {
|
|
27
|
+
recursive: true
|
|
28
|
+
});
|
|
29
|
+
await fs.writeFile(filePath, content);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(`❌ Failed to write catalog: ${error}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async getLastModified(locale) {
|
|
35
|
+
const filePath = this.getFilePath(locale);
|
|
36
|
+
try {
|
|
37
|
+
const stats = await fs.stat(filePath);
|
|
38
|
+
return stats.mtime;
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { CatalogPersister as default };
|