next-intl 4.5.8 → 4.6.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.
Files changed (88) hide show
  1. package/dist/cjs/development/ExtractorCodec-D9Tw618d.cjs +7 -0
  2. package/dist/cjs/development/JSONCodec-L1_VeQBi.cjs +48 -0
  3. package/dist/cjs/development/POCodec-Be_UL6jy.cjs +105 -0
  4. package/dist/cjs/development/plugin-DDtWCyPI.cjs +1373 -0
  5. package/dist/cjs/development/plugin.cjs +8 -379
  6. package/dist/esm/development/extractor/ExtractionCompiler.js +23 -26
  7. package/dist/esm/development/extractor/catalog/CatalogLocales.js +0 -33
  8. package/dist/esm/development/extractor/catalog/CatalogManager.js +171 -110
  9. package/dist/esm/development/extractor/catalog/CatalogPersister.js +31 -13
  10. package/dist/esm/development/extractor/catalog/SaveScheduler.js +1 -1
  11. package/dist/esm/development/extractor/catalogLoader.js +10 -10
  12. package/dist/esm/development/extractor/extractMessages.js +9 -2
  13. package/dist/esm/development/extractor/extractionLoader.js +15 -12
  14. package/dist/esm/development/extractor/extractor/MessageExtractor.js +5 -4
  15. package/dist/esm/development/extractor/format/ExtractorCodec.js +5 -0
  16. package/dist/esm/development/extractor/format/codecs/JSONCodec.js +40 -0
  17. package/dist/esm/development/extractor/format/codecs/POCodec.js +93 -0
  18. package/dist/esm/development/extractor/format/index.js +44 -0
  19. package/dist/esm/development/extractor/source/SourceFileScanner.js +2 -1
  20. package/dist/esm/development/extractor/source/SourceFileWatcher.js +132 -0
  21. package/dist/esm/development/extractor/utils.js +16 -1
  22. package/dist/esm/development/extractor.js +1 -0
  23. package/dist/esm/development/plugin/createNextIntlPlugin.js +3 -1
  24. package/dist/esm/development/plugin/declaration/createMessagesDeclaration.js +2 -11
  25. package/dist/esm/development/plugin/extractor/initExtractionCompiler.js +45 -0
  26. package/dist/esm/development/plugin/getNextConfig.js +7 -4
  27. package/dist/esm/development/plugin/utils.js +16 -1
  28. package/dist/esm/production/extractor/ExtractionCompiler.js +1 -1
  29. package/dist/esm/production/extractor/catalog/CatalogLocales.js +1 -1
  30. package/dist/esm/production/extractor/catalog/CatalogManager.js +1 -1
  31. package/dist/esm/production/extractor/catalog/CatalogPersister.js +1 -1
  32. package/dist/esm/production/extractor/catalog/SaveScheduler.js +1 -1
  33. package/dist/esm/production/extractor/catalogLoader.js +1 -1
  34. package/dist/esm/production/extractor/extractMessages.js +1 -1
  35. package/dist/esm/production/extractor/extractionLoader.js +1 -1
  36. package/dist/esm/production/extractor/extractor/MessageExtractor.js +1 -1
  37. package/dist/esm/production/extractor/format/ExtractorCodec.js +1 -0
  38. package/dist/esm/production/extractor/format/codecs/JSONCodec.js +1 -0
  39. package/dist/esm/production/extractor/format/codecs/POCodec.js +1 -0
  40. package/dist/esm/production/extractor/format/index.js +1 -0
  41. package/dist/esm/production/extractor/source/SourceFileScanner.js +1 -1
  42. package/dist/esm/production/extractor/source/SourceFileWatcher.js +1 -0
  43. package/dist/esm/production/extractor/utils.js +1 -1
  44. package/dist/esm/production/extractor.js +1 -1
  45. package/dist/esm/production/plugin/createNextIntlPlugin.js +1 -1
  46. package/dist/esm/production/plugin/declaration/createMessagesDeclaration.js +1 -1
  47. package/dist/esm/production/plugin/extractor/initExtractionCompiler.js +1 -0
  48. package/dist/esm/production/plugin/getNextConfig.js +1 -1
  49. package/dist/esm/production/plugin/utils.js +1 -1
  50. package/dist/types/extractor/ExtractionCompiler.d.ts +5 -10
  51. package/dist/types/extractor/catalog/CatalogLocales.d.ts +0 -2
  52. package/dist/types/extractor/catalog/CatalogManager.d.ts +26 -15
  53. package/dist/types/extractor/catalog/CatalogPersister.d.ts +15 -6
  54. package/dist/types/extractor/catalog/SaveScheduler.d.ts +2 -2
  55. package/dist/types/extractor/extractor/MessageExtractor.d.ts +6 -6
  56. package/dist/types/extractor/format/ExtractorCodec.d.ts +33 -0
  57. package/dist/types/extractor/format/codecs/JSONCodec.d.ts +2 -0
  58. package/dist/types/extractor/format/codecs/POCodec.d.ts +2 -0
  59. package/dist/types/extractor/format/codecs/fixtures/JSONCodecStructured.d.ts +2 -0
  60. package/dist/types/extractor/format/codecs/fixtures/POCodecSourceMessageKey.d.ts +2 -0
  61. package/dist/types/extractor/format/index.d.ts +15 -0
  62. package/dist/types/extractor/format/types.d.ts +8 -0
  63. package/dist/types/extractor/index.d.ts +1 -0
  64. package/dist/types/extractor/source/SourceFileFilter.d.ts +2 -2
  65. package/dist/types/extractor/source/SourceFileScanner.d.ts +1 -1
  66. package/dist/types/extractor/source/SourceFileWatcher.d.ts +15 -0
  67. package/dist/types/extractor/types.d.ts +2 -2
  68. package/dist/types/extractor/utils.d.ts +3 -0
  69. package/dist/types/plugin/extractor/initExtractionCompiler.d.ts +2 -0
  70. package/dist/types/plugin/types.d.ts +1 -1
  71. package/dist/types/plugin/utils.d.ts +6 -0
  72. package/package.json +6 -5
  73. package/dist/esm/development/extractor/formatters/Formatter.js +0 -3
  74. package/dist/esm/development/extractor/formatters/JSONFormatter.js +0 -42
  75. package/dist/esm/development/extractor/formatters/POFormatter.js +0 -51
  76. package/dist/esm/development/extractor/formatters/index.js +0 -6
  77. package/dist/esm/development/extractor/formatters/utils.js +0 -15
  78. package/dist/esm/production/extractor/formatters/Formatter.js +0 -1
  79. package/dist/esm/production/extractor/formatters/JSONFormatter.js +0 -1
  80. package/dist/esm/production/extractor/formatters/POFormatter.js +0 -1
  81. package/dist/esm/production/extractor/formatters/index.js +0 -1
  82. package/dist/esm/production/extractor/formatters/utils.js +0 -1
  83. package/dist/types/extractor/extractor/ASTScope.d.ts +0 -12
  84. package/dist/types/extractor/formatters/Formatter.d.ts +0 -10
  85. package/dist/types/extractor/formatters/JSONFormatter.d.ts +0 -10
  86. package/dist/types/extractor/formatters/POFormatter.d.ts +0 -10
  87. package/dist/types/extractor/formatters/index.d.ts +0 -5
  88. package/dist/types/extractor/formatters/utils.d.ts +0 -2
@@ -1,19 +1,25 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
- import MessageExtractor from '../extractor/MessageExtractor.js';
4
- import formatters from '../formatters/index.js';
3
+ import { resolveCodec, getFormatExtension } from '../format/index.js';
5
4
  import SourceFileScanner from '../source/SourceFileScanner.js';
6
- import { localeCompare } from '../utils.js';
5
+ import SourceFileWatcher from '../source/SourceFileWatcher.js';
6
+ import { getDefaultProjectRoot, localeCompare } from '../utils.js';
7
7
  import CatalogLocales from './CatalogLocales.js';
8
8
  import CatalogPersister from './CatalogPersister.js';
9
9
  import SaveScheduler from './SaveScheduler.js';
10
10
 
11
11
  class CatalogManager {
12
- /* The source of truth for which messages are used. */
12
+ /**
13
+ * The source of truth for which messages are used.
14
+ * NOTE: Should be mutated in place to keep `messagesById` and `messagesByFile` in sync.
15
+ */
13
16
  messagesByFile = (() => new Map())();
14
17
 
15
- /* Fast lookup for messages by ID across all files,
16
- * contains the same messages as `messagesByFile`. */
18
+ /**
19
+ * Fast lookup for messages by ID across all files,
20
+ * contains the same messages as `messagesByFile`.
21
+ * NOTE: Should be mutated in place to keep `messagesById` and `messagesByFile` in sync.
22
+ */
17
23
  messagesById = (() => new Map())();
18
24
 
19
25
  /**
@@ -26,104 +32,92 @@ class CatalogManager {
26
32
  // Cached instances
27
33
 
28
34
  // Resolves when all catalogs are loaded
29
- // (but doesn't indicate that project scan is done)
30
35
 
31
- constructor(config, opts = {}) {
36
+ // Resolves when the initial project scan and processing is complete
37
+
38
+ constructor(config, opts) {
32
39
  this.config = config;
33
40
  this.saveScheduler = new SaveScheduler(50);
34
- this.projectRoot = opts.projectRoot || process.cwd();
41
+ this.projectRoot = opts.projectRoot ?? getDefaultProjectRoot();
35
42
  this.isDevelopment = opts.isDevelopment ?? false;
36
- this.messageExtractor = new MessageExtractor({
37
- isDevelopment: this.isDevelopment,
38
- projectRoot: this.projectRoot,
39
- sourceMap: opts.sourceMap
40
- });
43
+ this.extractor = opts.extractor;
44
+ if (this.isDevelopment) {
45
+ // We kick this off as early as possible, so we get notified about changes
46
+ // that happen during the initial project scan (while awaiting it to
47
+ // complete though)
48
+ this.sourceWatcher = new SourceFileWatcher(this.getSrcPaths(), this.handleFileEvents.bind(this));
49
+ void this.sourceWatcher.start();
50
+ }
41
51
  }
42
- async getFormatter() {
43
- if (this.formatter) {
44
- return this.formatter;
45
- } else {
46
- const FormatterClass = (await formatters[this.config.messages.format]()).default;
47
- this.formatter = new FormatterClass();
48
- return this.formatter;
52
+ async getCodec() {
53
+ if (!this.codec) {
54
+ this.codec = await resolveCodec(this.config.messages.format, this.projectRoot);
49
55
  }
56
+ return this.codec;
50
57
  }
51
58
  async getPersister() {
52
59
  if (this.persister) {
53
60
  return this.persister;
54
61
  } else {
55
- this.persister = new CatalogPersister(this.config.messages.path, await this.getFormatter());
62
+ this.persister = new CatalogPersister({
63
+ messagesPath: this.config.messages.path,
64
+ codec: await this.getCodec(),
65
+ extension: getFormatExtension(this.config.messages.format)
66
+ });
56
67
  return this.persister;
57
68
  }
58
69
  }
59
- async getCatalogLocales() {
70
+ getCatalogLocales() {
60
71
  if (this.catalogLocales) {
61
72
  return this.catalogLocales;
62
73
  } else {
63
74
  const messagesDir = path.join(this.projectRoot, this.config.messages.path);
64
- const formatter = await this.getFormatter();
65
75
  this.catalogLocales = new CatalogLocales({
66
76
  messagesDir,
67
77
  sourceLocale: this.config.sourceLocale,
68
- extension: formatter.EXTENSION,
78
+ extension: getFormatExtension(this.config.messages.format),
69
79
  locales: this.config.messages.locales
70
80
  });
71
81
  return this.catalogLocales;
72
82
  }
73
83
  }
74
84
  async getTargetLocales() {
75
- const catalogLocales = await this.getCatalogLocales();
76
- return catalogLocales.getTargetLocales();
85
+ return this.getCatalogLocales().getTargetLocales();
77
86
  }
78
87
  getSrcPaths() {
79
88
  return (Array.isArray(this.config.srcPath) ? this.config.srcPath : [this.config.srcPath]).map(srcPath => path.join(this.projectRoot, srcPath));
80
89
  }
81
90
  async loadMessages() {
82
- this.loadCatalogsPromise = Promise.all([this.loadSourceMessages(), this.loadTargetMessages()]);
83
-
84
- // Ensure catalogs are loaded before scanning source files.
85
- // Otherwise, `loadSourceMessages` might overwrite extracted
86
- // messages if it finishes after source file extraction.
91
+ const sourceDiskMessages = await this.loadSourceMessages();
92
+ this.loadCatalogsPromise = this.loadTargetMessages();
87
93
  await this.loadCatalogsPromise;
94
+ this.scanCompletePromise = (async () => {
95
+ const sourceFiles = await SourceFileScanner.getSourceFiles(this.getSrcPaths());
96
+ await Promise.all(Array.from(sourceFiles).map(async filePath => this.processFile(filePath)));
97
+ this.mergeSourceDiskMetadata(sourceDiskMessages);
98
+ })();
99
+ await this.scanCompletePromise;
88
100
  if (this.isDevelopment) {
89
- const catalogLocales = await this.getCatalogLocales();
101
+ const catalogLocales = this.getCatalogLocales();
90
102
  catalogLocales.subscribeLocalesChange(this.onLocalesChange);
91
103
  }
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
104
  }
95
105
  async loadSourceMessages() {
96
- // First hydrate from source locale file to potentially init metadata
97
- const messages = await this.loadLocaleMessages(this.config.sourceLocale);
98
- const messagesById = new Map();
99
- const messagesByFile = new Map();
100
- for (const message of messages) {
101
- messagesById.set(message.id, message);
102
- if (message.references) {
103
- for (const ref of message.references) {
104
- const absoluteFilePath = path.join(this.projectRoot, ref.path);
105
- let fileMessages = messagesByFile.get(absoluteFilePath);
106
- if (!fileMessages) {
107
- fileMessages = new Map();
108
- messagesByFile.set(absoluteFilePath, fileMessages);
109
- }
110
- fileMessages.set(message.id, message);
111
- }
112
- }
106
+ // Load source catalog to hydrate metadata (e.g. flags) later without
107
+ // treating catalog entries as source of truth.
108
+ const diskMessages = await this.loadLocaleMessages(this.config.sourceLocale);
109
+ const byId = new Map();
110
+ for (const diskMessage of diskMessages) {
111
+ byId.set(diskMessage.id, diskMessage);
113
112
  }
114
- this.messagesById = messagesById;
115
- this.messagesByFile = messagesByFile;
113
+ return byId;
116
114
  }
117
115
  async loadLocaleMessages(locale) {
118
116
  const persister = await this.getPersister();
119
- try {
120
- const messages = await persister.read(locale);
121
- const fileTime = await persister.getLastModified(locale);
122
- this.lastWriteByLocale.set(locale, fileTime);
123
- return messages;
124
- } catch {
125
- return [];
126
- }
117
+ const messages = await persister.read(locale);
118
+ const fileTime = await persister.getLastModified(locale);
119
+ this.lastWriteByLocale.set(locale, fileTime);
120
+ return messages;
127
121
  }
128
122
  async loadTargetMessages() {
129
123
  const targetLocales = await this.getTargetLocales();
@@ -136,28 +130,70 @@ class CatalogManager {
136
130
  for (const diskMessage of diskMessages) {
137
131
  const prev = this.messagesById.get(diskMessage.id);
138
132
  if (prev) {
133
+ // Mutate the existing object instead of creating a copy
134
+ // to keep messagesById and messagesByFile in sync.
139
135
  // Unknown properties (like flags): disk wins
140
136
  // Known properties: existing (from extraction) wins
141
- this.messagesById.set(diskMessage.id, {
142
- ...diskMessage,
143
- id: prev.id,
144
- message: prev.message,
145
- description: prev.description,
146
- references: prev.references
147
- });
137
+ for (const key of Object.keys(diskMessage)) {
138
+ if (!['id', 'message', 'description', 'references'].includes(key)) {
139
+ // For unknown properties (like flags), disk wins
140
+ prev[key] = diskMessage[key];
141
+ }
142
+ }
148
143
  }
149
144
  }
150
145
  } else {
151
- // For target: disk wins completely
152
- const translations = new Map();
153
- for (const message of diskMessages) {
154
- translations.set(message.id, message);
146
+ // For target: disk wins completely, BUT preserve existing translations
147
+ // if we read empty (likely a write in progress by an external tool
148
+ // that causes the file to temporarily be empty)
149
+ const existingTranslations = this.translationsByTargetLocale.get(locale);
150
+ const hasExistingTranslations = existingTranslations && existingTranslations.size > 0;
151
+ if (diskMessages.length > 0) {
152
+ // We got content from disk, replace with it
153
+ const translations = new Map();
154
+ for (const message of diskMessages) {
155
+ translations.set(message.id, message);
156
+ }
157
+ this.translationsByTargetLocale.set(locale, translations);
158
+ } else if (hasExistingTranslations) ; else {
159
+ // We read empty and have no existing translations
160
+ const translations = new Map();
161
+ this.translationsByTargetLocale.set(locale, translations);
162
+ }
163
+ }
164
+ }
165
+ mergeSourceDiskMetadata(diskMessages) {
166
+ for (const [id, diskMessage] of diskMessages) {
167
+ const existing = this.messagesById.get(id);
168
+ if (!existing) continue;
169
+
170
+ // Mutate the existing object instead of creating a copy.
171
+ // This keeps `messagesById` and `messagesByFile` in sync since
172
+ // they reference the same object instance.
173
+ for (const key of Object.keys(diskMessage)) {
174
+ if (existing[key] == null) {
175
+ existing[key] = diskMessage[key];
176
+ }
155
177
  }
156
- this.translationsByTargetLocale.set(locale, translations);
157
178
  }
158
179
  }
159
- async extractFileMessages(absoluteFilePath, source) {
160
- const result = await this.messageExtractor.processFileContent(absoluteFilePath, source);
180
+ async processFile(absoluteFilePath) {
181
+ let messages = [];
182
+ try {
183
+ const content = await fs.readFile(absoluteFilePath, 'utf8');
184
+ let extraction;
185
+ try {
186
+ extraction = await this.extractor.extract(absoluteFilePath, content);
187
+ } catch {
188
+ return false;
189
+ }
190
+ messages = extraction.messages;
191
+ } catch (err) {
192
+ if (err.code !== 'ENOENT') {
193
+ throw err;
194
+ }
195
+ // ENOENT -> treat as no messages
196
+ }
161
197
  const prevFileMessages = this.messagesByFile.get(absoluteFilePath);
162
198
 
163
199
  // Init with all previous ones
@@ -165,24 +201,17 @@ class CatalogManager {
165
201
 
166
202
  // Replace existing messages with new ones
167
203
  const fileMessages = new Map();
168
- for (let message of result.messages) {
204
+ for (let message of messages) {
169
205
  const prevMessage = this.messagesById.get(message.id);
170
206
 
171
207
  // Merge with previous message if it exists
172
208
  if (prevMessage) {
173
- // References: The `message` we receive here will always have one
174
- // reference, which is the current file. We need to merge this with
175
- // potentially existing references.
176
- const references = [...(prevMessage.references ?? [])];
177
- message.references.forEach(ref => {
178
- if (!references.some(cur => cur.path === ref.path)) {
179
- references.push(ref);
180
- }
181
- });
182
- references.sort((referenceA, referenceB) => localeCompare(referenceA.path, referenceB.path));
209
+ const validated = prevMessage.references ?? [];
183
210
  message = {
184
211
  ...message,
185
- references
212
+ references: this.mergeReferences(validated, {
213
+ path: path.relative(this.projectRoot, absoluteFilePath)
214
+ })
186
215
  };
187
216
 
188
217
  // Merge other properties like description, or unknown
@@ -200,31 +229,39 @@ class CatalogManager {
200
229
  const index = idsToRemove.indexOf(message.id);
201
230
  if (index !== -1) idsToRemove.splice(index, 1);
202
231
  }
203
-
204
- // Don't delete IDs still used in other files
205
232
  const relativeFilePath = path.relative(this.projectRoot, absoluteFilePath);
206
- const idsToDelete = idsToRemove.filter(id => {
207
- const message = this.messagesById.get(id);
208
- return !message?.references?.some(ref => ref.path !== relativeFilePath);
209
- });
210
233
 
211
234
  // Clean up removed messages from `messagesById`
212
- idsToDelete.forEach(id => {
213
- this.messagesById.delete(id);
235
+ idsToRemove.forEach(id => {
236
+ const message = this.messagesById.get(id);
237
+ if (!message) return;
238
+ const hasOtherReferences = message.references?.some(ref => ref.path !== relativeFilePath);
239
+ if (!hasOtherReferences) {
240
+ // No other references, delete the message entirely
241
+ this.messagesById.delete(id);
242
+ } else {
243
+ // Message is used elsewhere, remove this file from references
244
+ // Mutate the existing object to keep `messagesById` and `messagesByFile` in sync
245
+ message.references = message.references?.filter(ref => ref.path !== relativeFilePath);
246
+ }
214
247
  });
215
248
 
216
249
  // Update the stored messages
217
- const hasMessages = result.messages.length > 0;
218
- if (hasMessages) {
250
+ if (messages.length > 0) {
219
251
  this.messagesByFile.set(absoluteFilePath, fileMessages);
220
252
  } else {
221
253
  this.messagesByFile.delete(absoluteFilePath);
222
254
  }
223
255
  const changed = this.haveMessagesChangedForFile(prevFileMessages, fileMessages);
224
- return {
225
- ...result,
226
- changed
227
- };
256
+ return changed;
257
+ }
258
+ mergeReferences(existing, current) {
259
+ const dedup = new Map();
260
+ for (const ref of existing) {
261
+ dedup.set(ref.path, ref);
262
+ }
263
+ dedup.set(current.path, current);
264
+ return Array.from(dedup.values()).sort((a, b) => localeCompare(a.path, b.path));
228
265
  }
229
266
  haveMessagesChangedForFile(beforeMessages, afterMessages) {
230
267
  // If one exists and the other doesn't, there's a change
@@ -273,18 +310,21 @@ class CatalogManager {
273
310
  if (currentFileTime && lastWriteTime && currentFileTime > lastWriteTime) {
274
311
  await this.reloadLocaleCatalog(locale);
275
312
  }
276
- const prevMessages = isSourceLocale ? this.messagesById : this.translationsByTargetLocale.get(locale);
277
- const localeMessages = messages.map(message => {
278
- const prev = prevMessages?.get(message.id);
313
+ const localeMessages = isSourceLocale ? this.messagesById : this.translationsByTargetLocale.get(locale);
314
+ const messagesToPersist = messages.map(message => {
315
+ const localeMessage = localeMessages?.get(message.id);
279
316
  return {
280
- ...prev,
317
+ ...localeMessage,
281
318
  id: message.id,
282
319
  description: message.description,
283
320
  references: message.references,
284
- message: isSourceLocale ? message.message : prev?.message ?? ''
321
+ message: isSourceLocale ? message.message : localeMessage?.message ?? ''
285
322
  };
286
323
  });
287
- await persister.write(locale, localeMessages);
324
+ await persister.write(messagesToPersist, {
325
+ locale,
326
+ sourceMessagesById: this.messagesById
327
+ });
288
328
 
289
329
  // Update timestamps
290
330
  const newTime = await persister.getLastModified(locale);
@@ -301,8 +341,29 @@ class CatalogManager {
301
341
  this.lastWriteByLocale.delete(locale);
302
342
  }
303
343
  };
304
- destroy() {
305
- this.saveScheduler.destroy();
344
+ async handleFileEvents(events) {
345
+ if (this.loadCatalogsPromise) {
346
+ await this.loadCatalogsPromise;
347
+ }
348
+
349
+ // Wait for initial scan to complete to avoid race conditions
350
+ if (this.scanCompletePromise) {
351
+ await this.scanCompletePromise;
352
+ }
353
+ let changed = false;
354
+ const expandedEvents = await this.sourceWatcher.expandDirectoryDeleteEvents(events, Array.from(this.messagesByFile.keys()));
355
+ for (const event of expandedEvents) {
356
+ const hasChanged = await this.processFile(event.path);
357
+ changed ||= hasChanged;
358
+ }
359
+ if (changed) {
360
+ await this.save();
361
+ }
362
+ }
363
+ [Symbol.dispose]() {
364
+ this.sourceWatcher?.stop();
365
+ this.sourceWatcher = undefined;
366
+ this.saveScheduler[Symbol.dispose]();
306
367
  if (this.catalogLocales && this.isDevelopment) {
307
368
  this.catalogLocales.unsubscribeLocalesChange(this.onLocalesChange);
308
369
  }
@@ -2,25 +2,43 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
 
4
4
  class CatalogPersister {
5
- constructor(messagesPath, formatter) {
6
- this.messagesPath = messagesPath;
7
- this.formatter = formatter;
5
+ constructor(params) {
6
+ this.messagesPath = params.messagesPath;
7
+ this.codec = params.codec;
8
+ this.extension = params.extension;
9
+ }
10
+ getFileName(locale) {
11
+ return locale + this.extension;
8
12
  }
9
13
  getFilePath(locale) {
10
- return path.join(this.messagesPath, locale + this.formatter.EXTENSION);
14
+ return path.join(this.messagesPath, this.getFileName(locale));
11
15
  }
12
16
  async read(locale) {
13
17
  const filePath = this.getFilePath(locale);
14
- const content = await fs.readFile(filePath, 'utf8');
15
- return this.formatter.parse(content, {
16
- locale
17
- });
18
+ let content;
19
+ try {
20
+ content = await fs.readFile(filePath, 'utf8');
21
+ } catch (error) {
22
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
23
+ return [];
24
+ }
25
+ throw new Error(`Error while reading ${this.getFileName(locale)}:\n> ${error}`, {
26
+ cause: error
27
+ });
28
+ }
29
+ try {
30
+ return this.codec.decode(content, {
31
+ locale
32
+ });
33
+ } catch (error) {
34
+ throw new Error(`Error while decoding ${this.getFileName(locale)}:\n> ${error}`, {
35
+ cause: error
36
+ });
37
+ }
18
38
  }
19
- async write(locale, messages) {
20
- const filePath = this.getFilePath(locale);
21
- const content = this.formatter.serialize(messages, {
22
- locale
23
- });
39
+ async write(messages, context) {
40
+ const filePath = this.getFilePath(context.locale);
41
+ const content = this.codec.encode(messages, context);
24
42
  try {
25
43
  const outputDir = path.dirname(filePath);
26
44
  await fs.mkdir(outputDir, {
@@ -71,7 +71,7 @@ class SaveScheduler {
71
71
  }
72
72
  }
73
73
  }
74
- destroy() {
74
+ [Symbol.dispose]() {
75
75
  if (this.saveTimeout) {
76
76
  clearTimeout(this.saveTimeout);
77
77
  this.saveTimeout = undefined;
@@ -1,13 +1,12 @@
1
1
  import path from 'path';
2
- import formatters from './formatters/index.js';
2
+ import { getFormatExtension, resolveCodec } from './format/index.js';
3
3
 
4
- let cachedFormatter = null;
5
- async function getFormatter(options) {
6
- if (!cachedFormatter) {
7
- const FormatterClass = (await formatters[options.messages.format]()).default;
8
- cachedFormatter = new FormatterClass();
4
+ let cachedCodec = null;
5
+ async function getCodec(options, projectRoot) {
6
+ if (!cachedCodec) {
7
+ cachedCodec = await resolveCodec(options.messages.format, projectRoot);
9
8
  }
10
- return cachedFormatter;
9
+ return cachedCodec;
11
10
  }
12
11
 
13
12
  /**
@@ -20,9 +19,10 @@ async function getFormatter(options) {
20
19
  function catalogLoader(source) {
21
20
  const options = this.getOptions();
22
21
  const callback = this.async();
23
- getFormatter(options).then(formatter => {
24
- const locale = path.basename(this.resourcePath, formatter.EXTENSION);
25
- const jsonString = formatter.toJSONString(source, {
22
+ const extension = getFormatExtension(options.messages.format);
23
+ getCodec(options, this.rootContext).then(codec => {
24
+ const locale = path.basename(this.resourcePath, extension);
25
+ const jsonString = codec.toJSONString(source, {
26
26
  locale
27
27
  });
28
28
 
@@ -1,8 +1,15 @@
1
1
  import ExtractionCompiler from './ExtractionCompiler.js';
2
+ import MessageExtractor from './extractor/MessageExtractor.js';
3
+ import { getDefaultProjectRoot } from './utils.js';
2
4
 
3
5
  async function extractMessages(params) {
4
- const compiler = new ExtractionCompiler(params);
5
- await compiler.extract();
6
+ const compiler = new ExtractionCompiler(params, {
7
+ extractor: new MessageExtractor({
8
+ isDevelopment: false,
9
+ projectRoot: getDefaultProjectRoot()
10
+ })
11
+ });
12
+ await compiler.extractAll();
6
13
  }
7
14
 
8
15
  export { extractMessages as default };
@@ -1,21 +1,24 @@
1
- import ExtractionCompiler from './ExtractionCompiler.js';
1
+ import MessageExtractor from './extractor/MessageExtractor.js';
2
2
 
3
- // This instance:
4
- // - Remains available through HMR
5
- // - Is the same across react-client and react-server
6
- // - Is only lost when the dev server restarts (e.g. due to change to Next.js config)
7
- let compiler;
3
+ // Module-level extractor instance for transformation caching.
4
+ // Note: Next.js/Turbopack may create multiple loader instances, but each
5
+ // only handles file transformation. The ExtractionCompiler (which manages
6
+ // catalogs) is initialized separately in createNextIntlPlugin.
7
+ let extractor;
8
8
  function extractionLoader(source) {
9
- const options = this.getOptions();
10
9
  const callback = this.async();
11
- if (!compiler) {
12
- compiler = new ExtractionCompiler(options, {
13
- // Avoid rollup's `replace` plugin to compile this away
14
- isDevelopment: process.env['NODE_ENV'.trim()] === 'development',
10
+ const projectRoot = this.rootContext;
11
+
12
+ // Avoid rollup's `replace` plugin to compile this away
13
+ const isDevelopment = process.env['NODE_ENV'.trim()] === 'development';
14
+ if (!extractor) {
15
+ extractor = new MessageExtractor({
16
+ isDevelopment,
17
+ projectRoot,
15
18
  sourceMap: this.sourceMap
16
19
  });
17
20
  }
18
- compiler.compile(this.resourcePath, source).then(result => {
21
+ extractor.extract(this.resourcePath, source).then(result => {
19
22
  callback(null, result.code, result.map);
20
23
  }).catch(callback);
21
24
  }
@@ -1,18 +1,19 @@
1
1
  import { createRequire } from 'module';
2
2
  import path from 'path';
3
3
  import { transform } from '@swc/core';
4
+ import { getDefaultProjectRoot } from '../utils.js';
4
5
  import LRUCache from './LRUCache.js';
5
6
 
6
7
  const require = createRequire(import.meta.url);
7
8
  class MessageExtractor {
8
9
  compileCache = (() => new LRUCache(750))();
9
10
  constructor(opts) {
10
- this.isDevelopment = opts.isDevelopment;
11
- this.projectRoot = opts.projectRoot;
11
+ this.isDevelopment = opts.isDevelopment ?? false;
12
+ this.projectRoot = opts.projectRoot ?? getDefaultProjectRoot();
12
13
  this.sourceMap = opts.sourceMap ?? false;
13
14
  }
14
- async processFileContent(absoluteFilePath, source) {
15
- const cacheKey = source;
15
+ async extract(absoluteFilePath, source) {
16
+ const cacheKey = [source, absoluteFilePath].join('!');
16
17
  const cached = this.compileCache.get(cacheKey);
17
18
  if (cached) return cached;
18
19
 
@@ -0,0 +1,5 @@
1
+ function defineCodec(factory) {
2
+ return factory;
3
+ }
4
+
5
+ export { defineCodec };
@@ -0,0 +1,40 @@
1
+ import { getSortedMessages, setNestedProperty } from '../../utils.js';
2
+ import { defineCodec } from '../ExtractorCodec.js';
3
+
4
+ var JSONCodec = defineCodec(() => ({
5
+ decode(source) {
6
+ const json = JSON.parse(source);
7
+ const messages = [];
8
+ traverseMessages(json, (message, id) => {
9
+ messages.push({
10
+ id,
11
+ message
12
+ });
13
+ });
14
+ return messages;
15
+ },
16
+ encode(messages) {
17
+ const root = {};
18
+ for (const message of getSortedMessages(messages)) {
19
+ setNestedProperty(root, message.id, message.message);
20
+ }
21
+ return JSON.stringify(root, null, 2) + '\n';
22
+ },
23
+ toJSONString(source) {
24
+ return source;
25
+ }
26
+ }));
27
+ function traverseMessages(obj, callback, path = '') {
28
+ const NAMESPACE_SEPARATOR = '.';
29
+ for (const key of Object.keys(obj)) {
30
+ const newPath = path ? path + NAMESPACE_SEPARATOR + key : key;
31
+ const value = obj[key];
32
+ if (typeof value === 'string') {
33
+ callback(value, newPath);
34
+ } else if (typeof value === 'object') {
35
+ traverseMessages(value, callback, newPath);
36
+ }
37
+ }
38
+ }
39
+
40
+ export { JSONCodec as default };