next-intl 4.5.8 → 4.6.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 (75) hide show
  1. package/dist/cjs/development/ExtractorCodec-DZKNn0Zq.cjs +37 -0
  2. package/dist/cjs/development/JSONCodec-Dlcx71xz.cjs +41 -0
  3. package/dist/cjs/development/POCodec-BW-UDNcq.cjs +94 -0
  4. package/dist/cjs/development/plugin.cjs +28 -5
  5. package/dist/esm/development/extractor/ExtractionCompiler.js +22 -25
  6. package/dist/esm/development/extractor/catalog/CatalogLocales.js +0 -33
  7. package/dist/esm/development/extractor/catalog/CatalogManager.js +134 -102
  8. package/dist/esm/development/extractor/catalog/CatalogPersister.js +31 -13
  9. package/dist/esm/development/extractor/catalogLoader.js +10 -10
  10. package/dist/esm/development/extractor/extractMessages.js +9 -2
  11. package/dist/esm/development/extractor/extractionLoader.js +27 -4
  12. package/dist/esm/development/extractor/extractor/MessageExtractor.js +5 -4
  13. package/dist/esm/development/extractor/format/ExtractorCodec.js +5 -0
  14. package/dist/esm/development/extractor/format/codecs/JSONCodec.js +40 -0
  15. package/dist/esm/development/extractor/format/codecs/POCodec.js +93 -0
  16. package/dist/esm/development/extractor/format/index.js +44 -0
  17. package/dist/esm/development/extractor/source/SourceFileScanner.js +2 -1
  18. package/dist/esm/development/extractor/source/SourceFileWatcher.js +38 -0
  19. package/dist/esm/development/extractor/utils.js +16 -1
  20. package/dist/esm/development/extractor.js +1 -0
  21. package/dist/esm/development/plugin/createNextIntlPlugin.js +1 -1
  22. package/dist/esm/development/plugin/getNextConfig.js +7 -4
  23. package/dist/esm/production/extractor/ExtractionCompiler.js +1 -1
  24. package/dist/esm/production/extractor/catalog/CatalogLocales.js +1 -1
  25. package/dist/esm/production/extractor/catalog/CatalogManager.js +1 -1
  26. package/dist/esm/production/extractor/catalog/CatalogPersister.js +1 -1
  27. package/dist/esm/production/extractor/catalogLoader.js +1 -1
  28. package/dist/esm/production/extractor/extractMessages.js +1 -1
  29. package/dist/esm/production/extractor/extractionLoader.js +1 -1
  30. package/dist/esm/production/extractor/extractor/MessageExtractor.js +1 -1
  31. package/dist/esm/production/extractor/format/ExtractorCodec.js +1 -0
  32. package/dist/esm/production/extractor/format/codecs/JSONCodec.js +1 -0
  33. package/dist/esm/production/extractor/format/codecs/POCodec.js +1 -0
  34. package/dist/esm/production/extractor/format/index.js +1 -0
  35. package/dist/esm/production/extractor/source/SourceFileScanner.js +1 -1
  36. package/dist/esm/production/extractor/source/SourceFileWatcher.js +1 -0
  37. package/dist/esm/production/extractor/utils.js +1 -1
  38. package/dist/esm/production/extractor.js +1 -1
  39. package/dist/esm/production/plugin/createNextIntlPlugin.js +1 -1
  40. package/dist/esm/production/plugin/getNextConfig.js +1 -1
  41. package/dist/types/extractor/ExtractionCompiler.d.ts +5 -10
  42. package/dist/types/extractor/catalog/CatalogLocales.d.ts +0 -2
  43. package/dist/types/extractor/catalog/CatalogManager.d.ts +21 -11
  44. package/dist/types/extractor/catalog/CatalogPersister.d.ts +15 -6
  45. package/dist/types/extractor/extractor/MessageExtractor.d.ts +6 -6
  46. package/dist/types/extractor/format/ExtractorCodec.d.ts +33 -0
  47. package/dist/types/extractor/format/codecs/JSONCodec.d.ts +2 -0
  48. package/dist/types/extractor/format/codecs/POCodec.d.ts +2 -0
  49. package/dist/types/extractor/format/codecs/fixtures/JSONCodecStructured.d.ts +2 -0
  50. package/dist/types/extractor/format/codecs/fixtures/POCodecSourceMessageKey.d.ts +2 -0
  51. package/dist/types/extractor/format/index.d.ts +15 -0
  52. package/dist/types/extractor/format/types.d.ts +8 -0
  53. package/dist/types/extractor/index.d.ts +1 -0
  54. package/dist/types/extractor/source/SourceFileFilter.d.ts +1 -1
  55. package/dist/types/extractor/source/SourceFileScanner.d.ts +1 -1
  56. package/dist/types/extractor/source/SourceFileWatcher.d.ts +12 -0
  57. package/dist/types/extractor/types.d.ts +2 -2
  58. package/dist/types/extractor/utils.d.ts +3 -0
  59. package/dist/types/plugin/types.d.ts +1 -1
  60. package/package.json +6 -5
  61. package/dist/esm/development/extractor/formatters/Formatter.js +0 -3
  62. package/dist/esm/development/extractor/formatters/JSONFormatter.js +0 -42
  63. package/dist/esm/development/extractor/formatters/POFormatter.js +0 -51
  64. package/dist/esm/development/extractor/formatters/index.js +0 -6
  65. package/dist/esm/development/extractor/formatters/utils.js +0 -15
  66. package/dist/esm/production/extractor/formatters/Formatter.js +0 -1
  67. package/dist/esm/production/extractor/formatters/JSONFormatter.js +0 -1
  68. package/dist/esm/production/extractor/formatters/POFormatter.js +0 -1
  69. package/dist/esm/production/extractor/formatters/index.js +0 -1
  70. package/dist/esm/production/extractor/formatters/utils.js +0 -1
  71. package/dist/types/extractor/formatters/Formatter.d.ts +0 -10
  72. package/dist/types/extractor/formatters/JSONFormatter.d.ts +0 -10
  73. package/dist/types/extractor/formatters/POFormatter.d.ts +0 -10
  74. package/dist/types/extractor/formatters/index.d.ts +0 -5
  75. 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
  /**
@@ -28,102 +34,83 @@ class CatalogManager {
28
34
  // Resolves when all catalogs are loaded
29
35
  // (but doesn't indicate that project scan is done)
30
36
 
31
- constructor(config, opts = {}) {
37
+ constructor(config, opts) {
32
38
  this.config = config;
33
39
  this.saveScheduler = new SaveScheduler(50);
34
- this.projectRoot = opts.projectRoot || process.cwd();
40
+ this.projectRoot = opts.projectRoot ?? getDefaultProjectRoot();
35
41
  this.isDevelopment = opts.isDevelopment ?? false;
36
- this.messageExtractor = new MessageExtractor({
37
- isDevelopment: this.isDevelopment,
38
- projectRoot: this.projectRoot,
39
- sourceMap: opts.sourceMap
40
- });
42
+ this.extractor = opts.extractor;
43
+ if (this.isDevelopment) {
44
+ this.sourceWatcher = new SourceFileWatcher(this.getSrcPaths(), this.handleFileEvents.bind(this));
45
+ void this.sourceWatcher.start();
46
+ }
41
47
  }
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;
48
+ async getCodec() {
49
+ if (!this.codec) {
50
+ this.codec = await resolveCodec(this.config.messages.format, this.projectRoot);
49
51
  }
52
+ return this.codec;
50
53
  }
51
54
  async getPersister() {
52
55
  if (this.persister) {
53
56
  return this.persister;
54
57
  } else {
55
- this.persister = new CatalogPersister(this.config.messages.path, await this.getFormatter());
58
+ this.persister = new CatalogPersister({
59
+ messagesPath: this.config.messages.path,
60
+ codec: await this.getCodec(),
61
+ extension: getFormatExtension(this.config.messages.format)
62
+ });
56
63
  return this.persister;
57
64
  }
58
65
  }
59
- async getCatalogLocales() {
66
+ getCatalogLocales() {
60
67
  if (this.catalogLocales) {
61
68
  return this.catalogLocales;
62
69
  } else {
63
70
  const messagesDir = path.join(this.projectRoot, this.config.messages.path);
64
- const formatter = await this.getFormatter();
65
71
  this.catalogLocales = new CatalogLocales({
66
72
  messagesDir,
67
73
  sourceLocale: this.config.sourceLocale,
68
- extension: formatter.EXTENSION,
74
+ extension: getFormatExtension(this.config.messages.format),
69
75
  locales: this.config.messages.locales
70
76
  });
71
77
  return this.catalogLocales;
72
78
  }
73
79
  }
74
80
  async getTargetLocales() {
75
- const catalogLocales = await this.getCatalogLocales();
76
- return catalogLocales.getTargetLocales();
81
+ return this.getCatalogLocales().getTargetLocales();
77
82
  }
78
83
  getSrcPaths() {
79
84
  return (Array.isArray(this.config.srcPath) ? this.config.srcPath : [this.config.srcPath]).map(srcPath => path.join(this.projectRoot, srcPath));
80
85
  }
81
86
  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.
87
+ const sourceDiskMessages = await this.loadSourceMessages();
88
+ this.loadCatalogsPromise = this.loadTargetMessages();
87
89
  await this.loadCatalogsPromise;
90
+ const sourceFiles = await SourceFileScanner.getSourceFiles(this.getSrcPaths());
91
+ await Promise.all(Array.from(sourceFiles).map(async filePath => this.processFile(filePath)));
92
+ this.mergeSourceDiskMetadata(sourceDiskMessages);
88
93
  if (this.isDevelopment) {
89
- const catalogLocales = await this.getCatalogLocales();
94
+ const catalogLocales = this.getCatalogLocales();
90
95
  catalogLocales.subscribeLocalesChange(this.onLocalesChange);
91
96
  }
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
97
  }
95
98
  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
- }
99
+ // Load source catalog to hydrate metadata (e.g. flags) later without
100
+ // treating catalog entries as source of truth.
101
+ const diskMessages = await this.loadLocaleMessages(this.config.sourceLocale);
102
+ const byId = new Map();
103
+ for (const diskMessage of diskMessages) {
104
+ byId.set(diskMessage.id, diskMessage);
113
105
  }
114
- this.messagesById = messagesById;
115
- this.messagesByFile = messagesByFile;
106
+ return byId;
116
107
  }
117
108
  async loadLocaleMessages(locale) {
118
109
  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
- }
110
+ const messages = await persister.read(locale);
111
+ const fileTime = await persister.getLastModified(locale);
112
+ this.lastWriteByLocale.set(locale, fileTime);
113
+ return messages;
127
114
  }
128
115
  async loadTargetMessages() {
129
116
  const targetLocales = await this.getTargetLocales();
@@ -136,15 +123,16 @@ class CatalogManager {
136
123
  for (const diskMessage of diskMessages) {
137
124
  const prev = this.messagesById.get(diskMessage.id);
138
125
  if (prev) {
126
+ // Mutate the existing object instead of creating a copy
127
+ // to keep messagesById and messagesByFile in sync.
139
128
  // Unknown properties (like flags): disk wins
140
129
  // 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
- });
130
+ for (const key of Object.keys(diskMessage)) {
131
+ if (!['id', 'message', 'description', 'references'].includes(key)) {
132
+ // For unknown properties (like flags), disk wins
133
+ prev[key] = diskMessage[key];
134
+ }
135
+ }
148
136
  }
149
137
  }
150
138
  } else {
@@ -156,8 +144,33 @@ class CatalogManager {
156
144
  this.translationsByTargetLocale.set(locale, translations);
157
145
  }
158
146
  }
159
- async extractFileMessages(absoluteFilePath, source) {
160
- const result = await this.messageExtractor.processFileContent(absoluteFilePath, source);
147
+ mergeSourceDiskMetadata(diskMessages) {
148
+ for (const [id, diskMessage] of diskMessages) {
149
+ const existing = this.messagesById.get(id);
150
+ if (!existing) continue;
151
+
152
+ // Mutate the existing object instead of creating a copy.
153
+ // This keeps `messagesById` and `messagesByFile` in sync since
154
+ // they reference the same object instance.
155
+ for (const key of Object.keys(diskMessage)) {
156
+ if (existing[key] == null) {
157
+ existing[key] = diskMessage[key];
158
+ }
159
+ }
160
+ }
161
+ }
162
+ async processFile(absoluteFilePath) {
163
+ let messages = [];
164
+ try {
165
+ const content = await fs.readFile(absoluteFilePath, 'utf8');
166
+ const extraction = await this.extractor.extract(absoluteFilePath, content);
167
+ messages = extraction.messages;
168
+ } catch (err) {
169
+ if (err.code !== 'ENOENT') {
170
+ throw err;
171
+ }
172
+ // ENOENT -> treat as no messages
173
+ }
161
174
  const prevFileMessages = this.messagesByFile.get(absoluteFilePath);
162
175
 
163
176
  // Init with all previous ones
@@ -165,24 +178,17 @@ class CatalogManager {
165
178
 
166
179
  // Replace existing messages with new ones
167
180
  const fileMessages = new Map();
168
- for (let message of result.messages) {
181
+ for (let message of messages) {
169
182
  const prevMessage = this.messagesById.get(message.id);
170
183
 
171
184
  // Merge with previous message if it exists
172
185
  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));
186
+ const validated = prevMessage.references ?? [];
183
187
  message = {
184
188
  ...message,
185
- references
189
+ references: this.mergeReferences(validated, {
190
+ path: path.relative(this.projectRoot, absoluteFilePath)
191
+ })
186
192
  };
187
193
 
188
194
  // Merge other properties like description, or unknown
@@ -200,31 +206,39 @@ class CatalogManager {
200
206
  const index = idsToRemove.indexOf(message.id);
201
207
  if (index !== -1) idsToRemove.splice(index, 1);
202
208
  }
203
-
204
- // Don't delete IDs still used in other files
205
209
  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
210
 
211
211
  // Clean up removed messages from `messagesById`
212
- idsToDelete.forEach(id => {
213
- this.messagesById.delete(id);
212
+ idsToRemove.forEach(id => {
213
+ const message = this.messagesById.get(id);
214
+ if (!message) return;
215
+ const hasOtherReferences = message.references?.some(ref => ref.path !== relativeFilePath);
216
+ if (!hasOtherReferences) {
217
+ // No other references, delete the message entirely
218
+ this.messagesById.delete(id);
219
+ } else {
220
+ // Message is used elsewhere, remove this file from references
221
+ // Mutate the existing object to keep `messagesById` and `messagesByFile` in sync
222
+ message.references = message.references?.filter(ref => ref.path !== relativeFilePath);
223
+ }
214
224
  });
215
225
 
216
226
  // Update the stored messages
217
- const hasMessages = result.messages.length > 0;
218
- if (hasMessages) {
227
+ if (messages.length > 0) {
219
228
  this.messagesByFile.set(absoluteFilePath, fileMessages);
220
229
  } else {
221
230
  this.messagesByFile.delete(absoluteFilePath);
222
231
  }
223
232
  const changed = this.haveMessagesChangedForFile(prevFileMessages, fileMessages);
224
- return {
225
- ...result,
226
- changed
227
- };
233
+ return changed;
234
+ }
235
+ mergeReferences(existing, current) {
236
+ const dedup = new Map();
237
+ for (const ref of existing) {
238
+ dedup.set(ref.path, ref);
239
+ }
240
+ dedup.set(current.path, current);
241
+ return Array.from(dedup.values()).sort((a, b) => localeCompare(a.path, b.path));
228
242
  }
229
243
  haveMessagesChangedForFile(beforeMessages, afterMessages) {
230
244
  // If one exists and the other doesn't, there's a change
@@ -273,18 +287,21 @@ class CatalogManager {
273
287
  if (currentFileTime && lastWriteTime && currentFileTime > lastWriteTime) {
274
288
  await this.reloadLocaleCatalog(locale);
275
289
  }
276
- const prevMessages = isSourceLocale ? this.messagesById : this.translationsByTargetLocale.get(locale);
277
- const localeMessages = messages.map(message => {
278
- const prev = prevMessages?.get(message.id);
290
+ const localeMessages = isSourceLocale ? this.messagesById : this.translationsByTargetLocale.get(locale);
291
+ const messagesToPersist = messages.map(message => {
292
+ const localeMessage = localeMessages?.get(message.id);
279
293
  return {
280
- ...prev,
294
+ ...localeMessage,
281
295
  id: message.id,
282
296
  description: message.description,
283
297
  references: message.references,
284
- message: isSourceLocale ? message.message : prev?.message ?? ''
298
+ message: isSourceLocale ? message.message : localeMessage?.message ?? ''
285
299
  };
286
300
  });
287
- await persister.write(locale, localeMessages);
301
+ await persister.write(messagesToPersist, {
302
+ locale,
303
+ sourceMessagesById: this.messagesById
304
+ });
288
305
 
289
306
  // Update timestamps
290
307
  const newTime = await persister.getLastModified(locale);
@@ -301,7 +318,22 @@ class CatalogManager {
301
318
  this.lastWriteByLocale.delete(locale);
302
319
  }
303
320
  };
321
+ async handleFileEvents(events) {
322
+ if (this.loadCatalogsPromise) {
323
+ await this.loadCatalogsPromise;
324
+ }
325
+ let changed = false;
326
+ for (const event of events) {
327
+ const hasChanged = await this.processFile(event.path);
328
+ changed ||= hasChanged;
329
+ }
330
+ if (changed) {
331
+ await this.save();
332
+ }
333
+ }
304
334
  destroy() {
335
+ this.sourceWatcher?.stop();
336
+ this.sourceWatcher = undefined;
305
337
  this.saveScheduler.destroy();
306
338
  if (this.catalogLocales && this.isDevelopment) {
307
339
  this.catalogLocales.unsubscribeLocalesChange(this.onLocalesChange);
@@ -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, {
@@ -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,44 @@
1
1
  import ExtractionCompiler from './ExtractionCompiler.js';
2
+ import MessageExtractor from './extractor/MessageExtractor.js';
2
3
 
3
4
  // This instance:
4
5
  // - Remains available through HMR
5
6
  // - Is the same across react-client and react-server
6
7
  // - Is only lost when the dev server restarts (e.g. due to change to Next.js config)
7
8
  let compiler;
9
+ let extractor;
10
+ let extractAllPromise;
8
11
  function extractionLoader(source) {
9
12
  const options = this.getOptions();
10
13
  const callback = this.async();
14
+ const projectRoot = this.rootContext;
15
+
16
+ // Avoid rollup's `replace` plugin to compile this away
17
+ const isDevelopment = process.env['NODE_ENV'.trim()] === 'development';
18
+ if (!extractor) {
19
+ // This instance is shared with the compiler to enable caching
20
+ // across code transformations and catalog extraction
21
+ extractor = new MessageExtractor({
22
+ isDevelopment,
23
+ projectRoot,
24
+ sourceMap: this.sourceMap
25
+ });
26
+ }
11
27
  if (!compiler) {
12
28
  compiler = new ExtractionCompiler(options, {
13
- // Avoid rollup's `replace` plugin to compile this away
14
- isDevelopment: process.env['NODE_ENV'.trim()] === 'development',
15
- sourceMap: this.sourceMap
29
+ isDevelopment,
30
+ projectRoot,
31
+ sourceMap: this.sourceMap,
32
+ extractor
16
33
  });
17
34
  }
18
- compiler.compile(this.resourcePath, source).then(result => {
35
+ if (!extractAllPromise) {
36
+ extractAllPromise = compiler.extractAll();
37
+ }
38
+ extractor.extract(this.resourcePath, source).then(async result => {
39
+ if (!isDevelopment) {
40
+ await extractAllPromise;
41
+ }
19
42
  callback(null, result.code, result.map);
20
43
  }).catch(callback);
21
44
  }
@@ -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 };