next-intl 4.3.12 → 4.5.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 (116) hide show
  1. package/dist/cjs/development/plugin.cjs +134 -8
  2. package/dist/esm/development/extractor/ExtractionCompiler.js +41 -0
  3. package/dist/esm/development/extractor/catalog/CatalogLocales.js +117 -0
  4. package/dist/esm/development/extractor/catalog/CatalogManager.js +286 -0
  5. package/dist/esm/development/extractor/catalog/CatalogPersister.js +45 -0
  6. package/dist/esm/development/extractor/catalog/SaveScheduler.js +66 -0
  7. package/dist/esm/development/extractor/catalogLoader.js +35 -0
  8. package/dist/esm/development/extractor/extractMessages.js +8 -0
  9. package/dist/esm/development/extractor/extractionLoader.js +22 -0
  10. package/dist/esm/development/extractor/extractor/ASTScope.js +18 -0
  11. package/dist/esm/development/extractor/extractor/KeyGenerator.js +11 -0
  12. package/dist/esm/development/extractor/extractor/LRUCache.js +30 -0
  13. package/dist/esm/development/extractor/extractor/MessageExtractor.js +402 -0
  14. package/dist/esm/development/extractor/formatters/Formatter.js +3 -0
  15. package/dist/esm/development/extractor/formatters/JSONFormatter.js +42 -0
  16. package/dist/esm/development/extractor/formatters/POFormatter.js +51 -0
  17. package/dist/esm/development/extractor/formatters/index.js +6 -0
  18. package/dist/esm/development/extractor/formatters/utils.js +13 -0
  19. package/dist/esm/development/extractor/source/SourceFileFilter.js +11 -0
  20. package/dist/esm/development/extractor/source/SourceFileScanner.js +27 -0
  21. package/dist/esm/development/extractor/utils/ObjectUtils.js +14 -0
  22. package/dist/esm/development/extractor/utils/POParser.js +222 -0
  23. package/dist/esm/development/extractor.js +1 -0
  24. package/dist/esm/development/index.react-client.js +2 -1
  25. package/dist/esm/development/index.react-server.js +1 -0
  26. package/dist/esm/development/plugin/createNextIntlPlugin.js +1 -1
  27. package/dist/esm/development/plugin/{createMessagesDeclaration.js → declaration/createMessagesDeclaration.js} +2 -2
  28. package/dist/esm/development/plugin/getNextConfig.js +122 -8
  29. package/dist/esm/development/plugin/{hasStableTurboConfig.js → nextFlags.js} +7 -2
  30. package/dist/esm/development/react-client/index.js +0 -1
  31. package/dist/esm/development/react-server/useExtracted.js +9 -0
  32. package/dist/esm/development/server/react-client/index.js +2 -1
  33. package/dist/esm/development/server/react-server/getExtracted.js +24 -0
  34. package/dist/esm/development/server/react-server/getServerExtractor.js +38 -0
  35. package/dist/esm/development/server.react-client.js +1 -1
  36. package/dist/esm/development/server.react-server.js +1 -0
  37. package/dist/esm/production/extractor/ExtractionCompiler.js +1 -0
  38. package/dist/esm/production/extractor/catalog/CatalogLocales.js +1 -0
  39. package/dist/esm/production/extractor/catalog/CatalogManager.js +1 -0
  40. package/dist/esm/production/extractor/catalog/CatalogPersister.js +1 -0
  41. package/dist/esm/production/extractor/catalog/SaveScheduler.js +1 -0
  42. package/dist/esm/production/extractor/catalogLoader.js +1 -0
  43. package/dist/esm/production/extractor/extractMessages.js +1 -0
  44. package/dist/esm/production/extractor/extractionLoader.js +1 -0
  45. package/dist/esm/production/extractor/extractor/ASTScope.js +1 -0
  46. package/dist/esm/production/extractor/extractor/KeyGenerator.js +1 -0
  47. package/dist/esm/production/extractor/extractor/LRUCache.js +1 -0
  48. package/dist/esm/production/extractor/extractor/MessageExtractor.js +1 -0
  49. package/dist/esm/production/extractor/formatters/Formatter.js +1 -0
  50. package/dist/esm/production/extractor/formatters/JSONFormatter.js +1 -0
  51. package/dist/esm/production/extractor/formatters/POFormatter.js +1 -0
  52. package/dist/esm/production/extractor/formatters/index.js +1 -0
  53. package/dist/esm/production/extractor/formatters/utils.js +1 -0
  54. package/dist/esm/production/extractor/source/SourceFileFilter.js +1 -0
  55. package/dist/esm/production/extractor/source/SourceFileScanner.js +1 -0
  56. package/dist/esm/production/extractor/utils/ObjectUtils.js +1 -0
  57. package/dist/esm/production/extractor/utils/POParser.js +1 -0
  58. package/dist/esm/production/extractor.js +1 -0
  59. package/dist/esm/production/index.react-client.js +1 -1
  60. package/dist/esm/production/index.react-server.js +1 -1
  61. package/dist/esm/production/plugin/createNextIntlPlugin.js +1 -1
  62. package/dist/esm/production/plugin/declaration/createMessagesDeclaration.js +1 -0
  63. package/dist/esm/production/plugin/getNextConfig.js +1 -1
  64. package/dist/esm/production/plugin/nextFlags.js +1 -0
  65. package/dist/esm/production/react-client/index.js +1 -1
  66. package/dist/esm/production/react-server/useExtracted.js +1 -0
  67. package/dist/esm/production/server/react-client/index.js +1 -1
  68. package/dist/esm/production/server/react-server/getExtracted.js +1 -0
  69. package/dist/esm/production/server/react-server/getServerExtractor.js +1 -0
  70. package/dist/esm/production/server.react-client.js +1 -1
  71. package/dist/esm/production/server.react-server.js +1 -1
  72. package/dist/types/extractor/ExtractionCompiler.d.ts +14 -0
  73. package/dist/types/extractor/catalog/CatalogLocales.d.ts +31 -0
  74. package/dist/types/extractor/catalog/CatalogManager.d.ts +46 -0
  75. package/dist/types/extractor/catalog/CatalogPersister.d.ts +11 -0
  76. package/dist/types/extractor/catalog/SaveScheduler.d.ts +17 -0
  77. package/dist/types/extractor/extractMessages.d.ts +2 -0
  78. package/dist/types/extractor/extractor/ASTScope.d.ts +12 -0
  79. package/dist/types/extractor/extractor/KeyGenerator.d.ts +3 -0
  80. package/dist/types/extractor/extractor/LRUCache.d.ts +7 -0
  81. package/dist/types/extractor/extractor/MessageExtractor.d.ts +20 -0
  82. package/dist/types/extractor/formatters/Formatter.d.ts +10 -0
  83. package/dist/types/extractor/formatters/JSONFormatter.d.ts +10 -0
  84. package/dist/types/extractor/formatters/POFormatter.d.ts +10 -0
  85. package/dist/types/extractor/formatters/index.d.ts +5 -0
  86. package/dist/types/extractor/formatters/utils.d.ts +2 -0
  87. package/dist/types/extractor/index.d.ts +1 -0
  88. package/dist/types/extractor/source/SourceFileFilter.d.ts +4 -0
  89. package/dist/types/extractor/source/SourceFileScanner.d.ts +4 -0
  90. package/dist/types/extractor/types.d.ts +23 -0
  91. package/dist/types/extractor/utils/ObjectUtils.d.ts +1 -0
  92. package/dist/types/extractor/utils/POParser.d.ts +24 -0
  93. package/dist/types/extractor.d.ts +1 -0
  94. package/dist/types/navigation/react-client/createNavigation.d.ts +9 -9
  95. package/dist/types/navigation/react-server/createNavigation.d.ts +9 -9
  96. package/dist/types/navigation/shared/createSharedNavigationFns.d.ts +10 -10
  97. package/dist/types/plugin/catalog/catalogLoader.d.ts +10 -0
  98. package/dist/types/plugin/declaration/index.d.ts +1 -0
  99. package/dist/types/plugin/extractor/extractionLoader.d.ts +3 -0
  100. package/dist/types/plugin/nextFlags.d.ts +2 -0
  101. package/dist/types/plugin/types.d.ts +18 -0
  102. package/dist/types/react-client/index.d.ts +3 -1
  103. package/dist/types/react-server/index.d.ts +1 -0
  104. package/dist/types/react-server/useExtracted.d.ts +2 -0
  105. package/dist/types/server/react-client/index.d.ts +2 -1
  106. package/dist/types/server/react-server/getExtracted.d.ts +10 -0
  107. package/dist/types/server/react-server/getServerExtractor.d.ts +5 -0
  108. package/dist/types/server/react-server/index.d.ts +1 -0
  109. package/extractor/catalogLoader.d.ts +4 -0
  110. package/extractor/extractionLoader.d.ts +4 -0
  111. package/extractor.d.ts +2 -0
  112. package/package.json +21 -6
  113. package/dist/esm/production/plugin/createMessagesDeclaration.js +0 -1
  114. package/dist/esm/production/plugin/hasStableTurboConfig.js +0 -1
  115. package/dist/types/plugin/hasStableTurboConfig.d.ts +0 -2
  116. /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
- const hasStableTurboConfig = compareVersions(getCurrentVersion(), '15.3.0') >= 0;
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,20 +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
- // Assign alias for `next-intl/config`
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?.startsWith('/')) {
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
- if (hasStableTurboConfig && !nextConfig?.experimental?.turbo) {
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() &&
267
+ // @ts-expect-error -- For Next.js <16
268
+ !nextConfig?.experimental?.turbo) {
180
269
  nextIntlConfig.turbopack = {
181
270
  ...nextConfig?.turbopack,
271
+ ...(rules && {
272
+ rules
273
+ }),
182
274
  resolveAlias: {
183
275
  ...nextConfig?.turbopack?.resolveAlias,
184
276
  ...resolveAlias
@@ -187,9 +279,15 @@ function getNextConfig(pluginConfig, nextConfig) {
187
279
  } else {
188
280
  nextIntlConfig.experimental = {
189
281
  ...nextConfig?.experimental,
282
+ // @ts-expect-error -- For Next.js <16
190
283
  turbo: {
284
+ // @ts-expect-error -- For Next.js <16
191
285
  ...nextConfig?.experimental?.turbo,
286
+ ...(rules && {
287
+ rules
288
+ }),
192
289
  resolveAlias: {
290
+ // @ts-expect-error -- For Next.js <16
193
291
  ...nextConfig?.experimental?.turbo?.resolveAlias,
194
292
  ...resolveAlias
195
293
  }
@@ -197,11 +295,39 @@ function getNextConfig(pluginConfig, nextConfig) {
197
295
  };
198
296
  }
199
297
  } else {
200
- nextIntlConfig.webpack = function webpack(...[config, options]) {
201
- // Webpack requires absolute paths
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)
202
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
+ }
203
329
  if (typeof nextConfig?.webpack === 'function') {
204
- return nextConfig.webpack(config, options);
330
+ return nextConfig.webpack(config, context);
205
331
  }
206
332
  return config;
207
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 };