next-intl 4.11.2 → 4.12.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 (38) hide show
  1. package/dist/cjs/development/{JSONCodec-B-lAnRTg.cjs → JSONCodec-CzA8ubPy.cjs} +4 -2
  2. package/dist/cjs/development/{POCodec-0XdsL-1F.cjs → POCodec-CWGHK-Gp.cjs} +16 -10
  3. package/dist/cjs/development/{plugin-0S9vVrVM.cjs → plugin-DlFYUFWh.cjs} +281 -150
  4. package/dist/cjs/development/plugin.cjs +1 -1
  5. package/dist/esm/development/extractor/catalog/CatalogManager.js +146 -95
  6. package/dist/esm/development/extractor/extractMessages.js +9 -2
  7. package/dist/esm/development/extractor/format/codecs/JSONCodec.js +3 -1
  8. package/dist/esm/development/extractor/format/codecs/POCodec.js +15 -9
  9. package/dist/esm/development/extractor/normalizeExtractorConfig.js +70 -0
  10. package/dist/esm/development/extractor/source/SourceFileWatcher.js +1 -1
  11. package/dist/esm/development/extractor/utils.js +29 -5
  12. package/dist/esm/development/plugin/createNextIntlPlugin.js +13 -2
  13. package/dist/esm/development/plugin/extractor/initExtractionCompiler.js +3 -8
  14. package/dist/esm/development/plugin/getNextConfig.js +21 -34
  15. package/dist/esm/development/server/react-server/getServerExtractor.js +3 -3
  16. package/dist/esm/production/extractor/catalog/CatalogManager.js +1 -1
  17. package/dist/esm/production/extractor/extractMessages.js +1 -1
  18. package/dist/esm/production/extractor/format/codecs/JSONCodec.js +1 -1
  19. package/dist/esm/production/extractor/format/codecs/POCodec.js +1 -1
  20. package/dist/esm/production/extractor/normalizeExtractorConfig.js +1 -0
  21. package/dist/esm/production/extractor/source/SourceFileWatcher.js +1 -1
  22. package/dist/esm/production/extractor/utils.js +1 -1
  23. package/dist/esm/production/plugin/createNextIntlPlugin.js +1 -1
  24. package/dist/esm/production/plugin/extractor/initExtractionCompiler.js +1 -1
  25. package/dist/esm/production/plugin/getNextConfig.js +1 -1
  26. package/dist/esm/production/server/react-server/getServerExtractor.js +1 -1
  27. package/dist/types/extractor/ExtractionCompiler.d.ts +2 -1
  28. package/dist/types/extractor/catalog/CatalogLocales.d.ts +2 -2
  29. package/dist/types/extractor/catalog/CatalogManager.d.ts +27 -10
  30. package/dist/types/extractor/extractMessages.d.ts +2 -2
  31. package/dist/types/extractor/extractor/MessageExtractor.d.ts +2 -6
  32. package/dist/types/extractor/normalizeExtractorConfig.d.ts +5 -0
  33. package/dist/types/extractor/types.d.ts +62 -11
  34. package/dist/types/extractor/utils.d.ts +2 -1
  35. package/dist/types/plugin/extractor/initExtractionCompiler.d.ts +2 -2
  36. package/dist/types/plugin/getNextConfig.d.ts +2 -1
  37. package/dist/types/plugin/types.d.ts +10 -14
  38. package/package.json +5 -5
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var plugin = require('./plugin-0S9vVrVM.cjs');
3
+ var plugin = require('./plugin-DlFYUFWh.cjs');
4
4
  require('fs/promises');
5
5
  require('path');
6
6
  require('@parcel/watcher');
@@ -3,22 +3,34 @@ import path from 'path';
3
3
  import { resolveCodec, getFormatExtension } from '../format/index.js';
4
4
  import SourceFileScanner from '../source/SourceFileScanner.js';
5
5
  import SourceFileWatcher from '../source/SourceFileWatcher.js';
6
- import { getDefaultProjectRoot, normalizePathToPosix, compareReferences } from '../utils.js';
6
+ import { getDefaultProjectRoot, localeCompare, compareReferences } 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
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.
13
+ * Extraction-derived fields aggregated into `ExtractorMessage`.
14
+ * Source code is the source of truth for these fields, only ancillary
15
+ * codec fields may merge from disk (e.g. flags).
15
16
  */
16
- messagesByFile = new Map();
17
+ static extractorOwnedAggregatorKeys = new Set(['description', 'id', 'message', 'references']);
18
+ /**
19
+ * Source of truth for statically extracted source messages,
20
+ * grouped by file and message ID.
21
+ */
22
+ sourceMessagesByFile = new Map();
23
+
24
+ /**
25
+ * Reverse index for rebuilding aggregated messages without scanning all files.
26
+ * Contains the same `SourceMessage` arrays as `sourceMessagesByFile` and is
27
+ * kept in sync with it.
28
+ */
29
+ sourceMessagesById = new Map();
17
30
 
18
31
  /**
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.
32
+ * Fast lookup for messages by ID, aggregated across all files. This combines
33
+ * metadata from `sourceMessagesById`, e.g. references and descriptions.
22
34
  */
23
35
  messagesById = new Map();
24
36
 
@@ -37,7 +49,7 @@ class CatalogManager {
37
49
 
38
50
  constructor(config, opts) {
39
51
  this.config = config;
40
- this.saveScheduler = new SaveScheduler(50);
52
+ this.saveScheduler = new SaveScheduler(opts.saveDebounceMs ?? 50);
41
53
  this.projectRoot = opts.projectRoot ?? getDefaultProjectRoot();
42
54
  this.isDevelopment = opts.isDevelopment ?? false;
43
55
  this.extractor = opts.extractor;
@@ -60,7 +72,7 @@ class CatalogManager {
60
72
  return this.persister;
61
73
  } else {
62
74
  this.persister = new CatalogPersister({
63
- messagesPath: this.config.messages.path,
75
+ messagesPath: this.config.extract.path,
64
76
  codec: await this.getCodec(),
65
77
  extension: getFormatExtension(this.config.messages.format)
66
78
  });
@@ -71,12 +83,12 @@ class CatalogManager {
71
83
  if (this.catalogLocales) {
72
84
  return this.catalogLocales;
73
85
  } else {
74
- const messagesDir = path.join(this.projectRoot, this.config.messages.path);
86
+ const messagesDir = path.join(this.projectRoot, this.config.extract.path);
75
87
  this.catalogLocales = new CatalogLocales({
76
88
  messagesDir,
77
- sourceLocale: this.config.sourceLocale,
78
89
  extension: getFormatExtension(this.config.messages.format),
79
- locales: this.config.messages.locales
90
+ locales: this.config.extract.locales,
91
+ sourceLocale: this.config.extract.sourceLocale
80
92
  });
81
93
  return this.catalogLocales;
82
94
  }
@@ -85,15 +97,28 @@ class CatalogManager {
85
97
  return this.getCatalogLocales().getTargetLocales();
86
98
  }
87
99
  getSrcPaths() {
88
- return (Array.isArray(this.config.srcPath) ? this.config.srcPath : [this.config.srcPath]).map(srcPath => path.join(this.projectRoot, srcPath));
100
+ return (Array.isArray(this.config.extract.srcPath) ? this.config.extract.srcPath : [this.config.extract.srcPath]).map(srcPath => path.join(this.projectRoot, srcPath));
89
101
  }
90
102
  async loadMessages() {
91
103
  const sourceDiskMessages = await this.loadSourceMessages();
92
104
  this.loadCatalogsPromise = this.loadTargetMessages();
93
105
  await this.loadCatalogsPromise;
94
106
  this.scanCompletePromise = (async () => {
95
- const sourceFiles = await SourceFileScanner.getSourceFiles(this.getSrcPaths());
96
- await Promise.all(Array.from(sourceFiles).map(async filePath => this.processFile(filePath)));
107
+ const sourceFiles = Array.from(await SourceFileScanner.getSourceFiles(this.getSrcPaths()))
108
+ // Stable file order keeps catalog ties independent of processing timing
109
+ .toSorted(localeCompare);
110
+ const extractedFiles = await Promise.all(sourceFiles.map(async filePath => ({
111
+ filePath,
112
+ messages: await this.extractFile(filePath)
113
+ })));
114
+ for (const {
115
+ filePath,
116
+ messages
117
+ } of extractedFiles) {
118
+ if (messages) {
119
+ this.applyFileMessages(filePath, messages);
120
+ }
121
+ }
97
122
  this.mergeSourceDiskMetadata(sourceDiskMessages);
98
123
  })();
99
124
  await this.scanCompletePromise;
@@ -105,7 +130,7 @@ class CatalogManager {
105
130
  async loadSourceMessages() {
106
131
  // Load source catalog to hydrate metadata (e.g. flags) later without
107
132
  // treating catalog entries as source of truth.
108
- const diskMessages = await this.loadLocaleMessages(this.config.sourceLocale);
133
+ const diskMessages = await this.loadLocaleMessages(this.config.extract.sourceLocale);
109
134
  const byId = new Map();
110
135
  for (const diskMessage of diskMessages) {
111
136
  byId.set(diskMessage.id, diskMessage);
@@ -125,17 +150,13 @@ class CatalogManager {
125
150
  }
126
151
  async reloadLocaleCatalog(locale) {
127
152
  const diskMessages = await this.loadLocaleMessages(locale);
128
- if (locale === this.config.sourceLocale) {
153
+ if (locale === this.config.extract.sourceLocale) {
129
154
  // For source: Merge additional properties like flags
130
155
  for (const diskMessage of diskMessages) {
131
156
  const prev = this.messagesById.get(diskMessage.id);
132
157
  if (prev) {
133
- // Mutate the existing object instead of creating a copy
134
- // to keep messagesById and messagesByFile in sync.
135
- // Unknown properties (like flags): disk wins
136
- // Known properties: existing (from extraction) wins
137
158
  for (const key of Object.keys(diskMessage)) {
138
- if (!['id', 'message', 'description', 'references'].includes(key)) {
159
+ if (!CatalogManager.extractorOwnedAggregatorKeys.has(key)) {
139
160
  // For unknown properties (like flags), disk wins
140
161
  prev[key] = diskMessage[key];
141
162
  }
@@ -167,17 +188,23 @@ class CatalogManager {
167
188
  const existing = this.messagesById.get(id);
168
189
  if (!existing) continue;
169
190
 
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.
191
+ // Fill unknown metadata from disk without replacing extraction-owned fields.
173
192
  for (const key of Object.keys(diskMessage)) {
174
- if (existing[key] == null) {
193
+ if (!CatalogManager.extractorOwnedAggregatorKeys.has(key) && existing[key] == null) {
175
194
  existing[key] = diskMessage[key];
176
195
  }
177
196
  }
178
197
  }
179
198
  }
180
199
  async processFile(absoluteFilePath) {
200
+ const messages = await this.extractFile(absoluteFilePath);
201
+
202
+ // `undefined` only when `extractFile()` throws. An empty array is success
203
+ // and must still run `applyFileMessages` to clear stale ids for this file.
204
+ if (!messages) return false;
205
+ return this.applyFileMessages(absoluteFilePath, messages);
206
+ }
207
+ async extractFile(absoluteFilePath) {
181
208
  let messages = [];
182
209
  try {
183
210
  const content = await fs.readFile(absoluteFilePath, 'utf8');
@@ -185,7 +212,7 @@ class CatalogManager {
185
212
  try {
186
213
  extraction = await this.extractor.extract(absoluteFilePath, content);
187
214
  } catch {
188
- return false;
215
+ return undefined;
189
216
  }
190
217
  messages = extraction.messages;
191
218
  } catch (err) {
@@ -194,71 +221,91 @@ class CatalogManager {
194
221
  }
195
222
  // ENOENT -> treat as no messages
196
223
  }
197
- const prevFileMessages = this.messagesByFile.get(absoluteFilePath);
198
- const relativeFilePath = normalizePathToPosix(path.relative(this.projectRoot, absoluteFilePath));
199
-
200
- // Init with all previous ones
201
- const idsToRemove = Array.from(prevFileMessages?.keys() ?? []);
202
-
203
- // Replace existing messages with new ones
204
- const fileMessages = new Map();
205
- for (let message of messages) {
206
- const prevMessage = this.messagesById.get(message.id);
224
+ return messages;
225
+ }
226
+ applyFileMessages(absoluteFilePath, messages) {
227
+ const prevFileMessages = this.sourceMessagesByFile.get(absoluteFilePath);
228
+ const nextFileMessages = this.groupSourceMessagesById(messages);
229
+ const affectedIds = new Set([...(prevFileMessages?.keys() ?? []), ...nextFileMessages.keys()]);
230
+ if (nextFileMessages.size > 0) {
231
+ this.sourceMessagesByFile.set(absoluteFilePath, nextFileMessages);
232
+ } else {
233
+ this.sourceMessagesByFile.delete(absoluteFilePath);
234
+ }
207
235
 
208
- // Merge with previous message if it exists
209
- if (prevMessage) {
210
- message = {
211
- ...message
212
- };
213
- if (message.references) {
214
- message.references = this.mergeReferences(prevMessage.references ?? [], relativeFilePath, message.references);
236
+ // Clear this file's contribution from the reverse index, then re-insert
237
+ // fresh rows and rebuild aggregates (messagesById) per touched id.
238
+ for (const id of affectedIds) {
239
+ const sourceMessagesForId = this.sourceMessagesById.get(id);
240
+ if (sourceMessagesForId) {
241
+ sourceMessagesForId.delete(absoluteFilePath);
242
+ // No files left for this id: drop the reverse-index entry.
243
+ if (sourceMessagesForId.size === 0) {
244
+ this.sourceMessagesById.delete(id);
215
245
  }
216
-
217
- // Merge other properties like description, or unknown
218
- // attributes like flags that are opaque to us
219
- for (const key of Object.keys(prevMessage)) {
220
- if (message[key] == null) {
221
- message[key] = prevMessage[key];
222
- }
246
+ }
247
+ const nextSourceMessagesForId = nextFileMessages.get(id);
248
+ if (nextSourceMessagesForId) {
249
+ let sourceMessagesByFile = this.sourceMessagesById.get(id);
250
+ if (!sourceMessagesByFile) {
251
+ sourceMessagesByFile = new Map();
252
+ this.sourceMessagesById.set(id, sourceMessagesByFile);
223
253
  }
254
+ sourceMessagesByFile.set(absoluteFilePath, nextSourceMessagesForId);
224
255
  }
225
- this.messagesById.set(message.id, message);
226
- fileMessages.set(message.id, message);
227
-
228
- // This message continues to exist in this file
229
- const index = idsToRemove.indexOf(message.id);
230
- if (index !== -1) idsToRemove.splice(index, 1);
256
+ this.rebuildMessageById(id);
231
257
  }
232
-
233
- // Clean up removed messages from `messagesById`
234
- idsToRemove.forEach(id => {
235
- const message = this.messagesById.get(id);
236
- if (!message) return;
237
- const hasOtherReferences = message.references?.some(ref => ref.path !== relativeFilePath);
238
- if (!hasOtherReferences) {
239
- // No other references, delete the message entirely
240
- this.messagesById.delete(id);
258
+ const changed = this.haveMessagesChangedForFile(prevFileMessages, nextFileMessages);
259
+ return changed;
260
+ }
261
+ groupSourceMessagesById(messages) {
262
+ const result = new Map();
263
+ for (const message of messages) {
264
+ const messagesById = result.get(message.id);
265
+ if (messagesById) {
266
+ messagesById.push(message);
241
267
  } else {
242
- // Message is used elsewhere, remove this file from references
243
- // Mutate the existing object to keep `messagesById` and `messagesByFile` in sync
244
- message.references = message.references?.filter(ref => ref.path !== relativeFilePath);
268
+ result.set(message.id, [message]);
245
269
  }
246
- });
247
-
248
- // Update the stored messages
249
- if (messages.length > 0) {
250
- this.messagesByFile.set(absoluteFilePath, fileMessages);
251
- } else {
252
- this.messagesByFile.delete(absoluteFilePath);
253
270
  }
254
- const changed = this.haveMessagesChangedForFile(prevFileMessages, fileMessages);
255
- return changed;
271
+ return result;
256
272
  }
257
- mergeReferences(existing, currentFilePath, currentFileRefs) {
258
- // Keep refs from other files, replace all refs from the current file
259
- const otherFileRefs = existing.filter(ref => ref.path !== currentFilePath);
260
- const merged = [...otherFileRefs, ...currentFileRefs];
261
- return merged.sort(compareReferences);
273
+ rebuildMessageById(id) {
274
+ const sourceMessages = Array.from(this.sourceMessagesById.get(id)?.values() ?? []).flat();
275
+ if (sourceMessages.length === 0) {
276
+ this.messagesById.delete(id);
277
+ return;
278
+ }
279
+ const previousMessage = this.messagesById.get(id);
280
+ const aggregate = {
281
+ description: this.mergeDescriptions(sourceMessages),
282
+ id,
283
+ message: sourceMessages[0].message,
284
+ references: sourceMessages.map(message => message.reference).sort(compareReferences)
285
+ };
286
+ if (previousMessage) {
287
+ for (const key of Object.keys(previousMessage)) {
288
+ // Preserve extra fields (e.g. from disk/codec) across rebuilds; the
289
+ // four core fields above are always recomputed from source messages.
290
+ if (!CatalogManager.extractorOwnedAggregatorKeys.has(key) && aggregate[key] == null) {
291
+ aggregate[key] = previousMessage[key];
292
+ }
293
+ }
294
+ }
295
+ this.messagesById.set(id, aggregate);
296
+ }
297
+ mergeDescriptions(messages) {
298
+ const sortedByReference = messages.toSorted((a, b) => compareReferences(a.reference, b.reference));
299
+ const merged = [];
300
+ for (const message of sortedByReference) {
301
+ const {
302
+ description
303
+ } = message;
304
+ if (description != null && !merged.includes(description)) {
305
+ merged.push(description);
306
+ }
307
+ }
308
+ return merged;
262
309
  }
263
310
  haveMessagesChangedForFile(beforeMessages, afterMessages) {
264
311
  // If one exists and the other doesn't, there's a change
@@ -272,25 +319,28 @@ class CatalogManager {
272
319
  }
273
320
 
274
321
  // Check differences in beforeMessages vs afterMessages
275
- for (const [id, msg1] of beforeMessages) {
276
- const msg2 = afterMessages.get(id);
277
- if (!msg2 || !this.areMessagesEqual(msg1, msg2)) {
322
+ for (const [id, prevSourceMessages] of beforeMessages) {
323
+ const nextSourceMessages = afterMessages.get(id);
324
+ if (!nextSourceMessages) {
325
+ return true;
326
+ }
327
+ if (!this.areSourceMessageArraysEqual(prevSourceMessages, nextSourceMessages)) {
278
328
  return true; // Early exit on first difference
279
329
  }
280
330
  }
281
331
  return false;
282
332
  }
283
- areMessagesEqual(msg1, msg2) {
284
- // Note: We intentionally don't compare references here.
285
- // References are aggregated metadata from multiple files and comparing
286
- // them would cause false positives due to parallel extraction order.
287
- return msg1.id === msg2.id && msg1.message === msg2.message && msg1.description === msg2.description;
333
+ areSourceMessageArraysEqual(messages1, messages2) {
334
+ return messages1.length === messages2.length && messages1.every((message, index) => this.areSourceMessagesEqual(message, messages2[index]));
335
+ }
336
+ areSourceMessagesEqual(msg1, msg2) {
337
+ return msg1.id === msg2.id && msg1.message === msg2.message && msg1.description === msg2.description && msg1.reference.path === msg2.reference.path && msg1.reference.line === msg2.reference.line;
288
338
  }
289
339
  async save() {
290
340
  return this.saveScheduler.schedule(() => this.saveImpl());
291
341
  }
292
342
  async saveImpl() {
293
- await this.saveLocale(this.config.sourceLocale);
343
+ await this.saveLocale(this.config.extract.sourceLocale);
294
344
  const targetLocales = await this.getTargetLocales();
295
345
  await Promise.all(targetLocales.map(locale => this.saveLocale(locale)));
296
346
  }
@@ -298,7 +348,7 @@ class CatalogManager {
298
348
  await this.loadCatalogsPromise;
299
349
  const messages = Array.from(this.messagesById.values());
300
350
  const persister = await this.getPersister();
301
- const isSourceLocale = locale === this.config.sourceLocale;
351
+ const isSourceLocale = locale === this.config.extract.sourceLocale;
302
352
 
303
353
  // Check if file was modified externally (poll-at-save is cheaper than
304
354
  // watchers here since stat() is fast and avoids continuous overhead)
@@ -348,8 +398,9 @@ class CatalogManager {
348
398
  await this.scanCompletePromise;
349
399
  }
350
400
  let changed = false;
351
- const expandedEvents = await this.sourceWatcher.expandDirectoryDeleteEvents(events, Array.from(this.messagesByFile.keys()));
352
- for (const event of expandedEvents) {
401
+ const expandedEvents = await this.sourceWatcher.expandDirectoryDeleteEvents(events, Array.from(this.sourceMessagesByFile.keys()));
402
+ // Stable file order keeps catalog ties independent of event timing.
403
+ for (const event of expandedEvents.toSorted((a, b) => localeCompare(a.path, b.path))) {
353
404
  const hasChanged = await this.processFile(event.path);
354
405
  changed ||= hasChanged;
355
406
  }
@@ -1,9 +1,16 @@
1
+ import { warn } from '../plugin/utils.js';
1
2
  import ExtractionCompiler from './ExtractionCompiler.js';
2
3
  import MessageExtractor from './extractor/MessageExtractor.js';
3
- import { getDefaultProjectRoot } from './utils.js';
4
+ import normalizeExtractorConfig from './normalizeExtractorConfig.js';
5
+ import { hasLocalesToExtract, getDefaultProjectRoot } from './utils.js';
4
6
 
5
7
  async function extractMessages(params) {
6
- const compiler = new ExtractionCompiler(params, {
8
+ const config = normalizeExtractorConfig(params);
9
+ if (!hasLocalesToExtract(config)) {
10
+ warn('`messages.locales` is empty, so no messages were updated.');
11
+ return;
12
+ }
13
+ const compiler = new ExtractionCompiler(config, {
7
14
  extractor: new MessageExtractor({
8
15
  isDevelopment: false,
9
16
  projectRoot: getDefaultProjectRoot()
@@ -8,7 +8,9 @@ var JSONCodec = defineCodec(() => ({
8
8
  traverseMessages(json, (message, id) => {
9
9
  messages.push({
10
10
  id,
11
- message
11
+ message,
12
+ references: [],
13
+ description: []
12
14
  });
13
15
  });
14
16
  return messages;
@@ -32,41 +32,47 @@ var POCodec = defineCodec(() => {
32
32
  msgctxt,
33
33
  msgid,
34
34
  msgstr,
35
+ references,
35
36
  ...rest
36
37
  } = msg;
37
- if (extractedComments && extractedComments.length > 1) {
38
- throw new Error(`Multiple extracted comments are not supported. Found ${extractedComments.length} comments for msgid "${msgid}".`);
39
- }
40
38
  return {
41
39
  ...rest,
42
40
  id: msgctxt ? [msgctxt, msgid].join(NAMESPACE_SEPARATOR) : msgid,
43
41
  message: msgstr,
44
- ...(extractedComments && extractedComments.length > 0 && {
45
- description: extractedComments[0]
46
- })
42
+ description: extractedComments ?? [],
43
+ references: references ?? []
47
44
  };
48
45
  });
49
46
  },
50
47
  encode(messages, context) {
51
48
  const encodedMessages = getSortedMessages(messages).map(msg => {
52
49
  const {
53
- description,
50
+ description = [],
54
51
  id,
55
52
  message,
53
+ references,
56
54
  ...rest
57
55
  } = msg;
58
56
  const lastDotIndex = id.lastIndexOf(NAMESPACE_SEPARATOR);
59
57
  const hasNamespace = id.includes(NAMESPACE_SEPARATOR);
60
58
  const msgid = hasNamespace ? id.slice(lastDotIndex + NAMESPACE_SEPARATOR.length) : id;
59
+
60
+ // Path-only refs (no `:line`), unique paths
61
+ const pathOnlyRefs = [...new Set(references.map(ref => ref.path))].map(path => ({
62
+ path
63
+ }));
61
64
  return {
62
65
  msgid,
63
66
  msgstr: message,
64
- ...(description && {
65
- extractedComments: [description]
67
+ ...(description.length > 0 && {
68
+ extractedComments: description
66
69
  }),
67
70
  ...(hasNamespace && {
68
71
  msgctxt: id.slice(0, lastDotIndex)
69
72
  }),
73
+ ...(pathOnlyRefs.length > 0 && {
74
+ references: pathOnlyRefs
75
+ }),
70
76
  ...rest
71
77
  };
72
78
  });
@@ -0,0 +1,70 @@
1
+ import { throwError, warn } from '../plugin/utils.js';
2
+
3
+ function stripTrailingSlash(dirPath) {
4
+ if (dirPath.endsWith('/')) {
5
+ return dirPath.slice(0, -1);
6
+ } else {
7
+ return dirPath;
8
+ }
9
+ }
10
+ function normalizeMessagesCatalogPaths(messagesPath) {
11
+ const rawPaths = Array.isArray(messagesPath) ? messagesPath : [messagesPath];
12
+ return rawPaths.map(dirPath => stripTrailingSlash(String(dirPath).trim())).filter(dirPath => dirPath.length > 0);
13
+ }
14
+ function normalizeExtractorConfig(input) {
15
+ if (input.messages == null) {
16
+ throwError('`messages` is required when extracting messages.');
17
+ }
18
+ const extract = input.extract;
19
+ let extractPath;
20
+ let sourceLocale;
21
+ if (extract !== undefined && extract !== true) {
22
+ if (extract.sourceLocale) {
23
+ warn('`extract.sourceLocale` is deprecated in favor of `messages.sourceLocale`.');
24
+ sourceLocale = extract.sourceLocale;
25
+ }
26
+ if (extract.path) {
27
+ extractPath = stripTrailingSlash(extract.path);
28
+ }
29
+ }
30
+ const locales = input.messages.locales;
31
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
32
+ if (!locales) {
33
+ throwError('`messages.locales` is required when extracting messages.');
34
+ }
35
+ if (input.messages.sourceLocale) {
36
+ sourceLocale = input.messages.sourceLocale;
37
+ }
38
+ if (!sourceLocale) {
39
+ throwError('`messages.sourceLocale` is required when extracting messages.');
40
+ }
41
+ const srcPath = input.srcPath;
42
+ if (srcPath == null) {
43
+ throwError('`srcPath` is required when extracting messages.');
44
+ }
45
+ const pathIsArray = Array.isArray(input.messages.path);
46
+ const messagesPath = normalizeMessagesCatalogPaths(input.messages.path);
47
+ if (messagesPath.length === 0) {
48
+ throwError('`messages.path` must not be empty.');
49
+ }
50
+ if (extractPath == null) {
51
+ if (pathIsArray) {
52
+ throwError('When `messages.path` is an array, `extract.path` is required to select the writable catalog directory.');
53
+ }
54
+ extractPath = messagesPath[0];
55
+ }
56
+ return {
57
+ extract: {
58
+ locales,
59
+ path: extractPath,
60
+ sourceLocale,
61
+ srcPath
62
+ },
63
+ messages: {
64
+ format: input.messages.format,
65
+ path: messagesPath
66
+ }
67
+ };
68
+ }
69
+
70
+ export { normalizeExtractorConfig as default, normalizeMessagesCatalogPaths };
@@ -23,7 +23,7 @@ class SourceFileWatcher {
23
23
  }
24
24
  const filtered = await this.normalizeEvents(events);
25
25
  if (filtered.length > 0) {
26
- void this.onChange(filtered);
26
+ await this.onChange(filtered);
27
27
  }
28
28
  }, {
29
29
  ignore
@@ -1,4 +1,5 @@
1
1
  import path from 'path';
2
+ import { warn } from '../plugin/utils.js';
2
3
 
3
4
  function normalizePathToPosix(filePath) {
4
5
  // `path.relative` uses OS-specific separators. For stable `.po` references we
@@ -9,6 +10,12 @@ const FORBIDDEN_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
9
10
  function isForbiddenObjectKey(key) {
10
11
  return FORBIDDEN_OBJECT_KEYS.has(key);
11
12
  }
13
+ function hasLocalesToExtract(config) {
14
+ const {
15
+ locales
16
+ } = config.extract;
17
+ return locales === 'infer' || locales.length > 0;
18
+ }
12
19
 
13
20
  // Essentialls lodash/set, but we avoid this dependency
14
21
  function setNestedProperty(obj, keyPath, value) {
@@ -29,17 +36,34 @@ function setNestedProperty(obj, keyPath, value) {
29
36
  current[keys[keys.length - 1]] = value;
30
37
  }
31
38
  function getSortedMessages(messages) {
39
+ const warnedMissingReferenceIds = new Set();
32
40
  return messages.toSorted((messageA, messageB) => {
33
- const refA = messageA.references?.[0];
34
- const refB = messageB.references?.[0];
41
+ const refA = messageA.references[0];
42
+ const refB = messageB.references[0];
43
+
44
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
45
+ if (refA == null) {
46
+ warnAboutMissingReference(messageA.id, warnedMissingReferenceIds);
47
+ }
48
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
49
+ if (refB == null) {
50
+ warnAboutMissingReference(messageB.id, warnedMissingReferenceIds);
51
+ }
35
52
 
36
- // No references: preserve original (extraction) order
37
- if (!refA || !refB) return 0;
53
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
54
+ if (refA == null || refB == null) {
55
+ return 0;
56
+ }
38
57
 
39
58
  // Sort by path, then line. Same path+line: preserve original order
40
59
  return compareReferences(refA, refB);
41
60
  });
42
61
  }
62
+ function warnAboutMissingReference(id, warnedMissingReferenceIds) {
63
+ if (warnedMissingReferenceIds.has(id)) return;
64
+ warnedMissingReferenceIds.add(id);
65
+ warn(`Missing file reference for extracted message: ${id}`);
66
+ }
43
67
  function localeCompare(a, b) {
44
68
  return a.localeCompare(b, 'en');
45
69
  }
@@ -52,4 +76,4 @@ function getDefaultProjectRoot() {
52
76
  return process.cwd();
53
77
  }
54
78
 
55
- export { compareReferences, getDefaultProjectRoot, getSortedMessages, isForbiddenObjectKey, localeCompare, normalizePathToPosix, setNestedProperty };
79
+ export { compareReferences, getDefaultProjectRoot, getSortedMessages, hasLocalesToExtract, isForbiddenObjectKey, localeCompare, normalizePathToPosix, setNestedProperty };
@@ -1,3 +1,4 @@
1
+ import normalizeExtractorConfig from '../extractor/normalizeExtractorConfig.js';
1
2
  import initExtractionCompiler from './extractor/initExtractionCompiler.js';
2
3
  import getNextConfig from './getNextConfig.js';
3
4
  import { warn } from './utils.js';
@@ -12,10 +13,20 @@ function initPlugin(pluginConfig, nextConfig) {
12
13
  if (messagesPathOrPaths && !skipWatchers) {
13
14
  createMessagesDeclaration(typeof messagesPathOrPaths === 'string' ? [messagesPathOrPaths] : messagesPathOrPaths);
14
15
  }
16
+ let extractorConfig;
17
+ const experimental = pluginConfig.experimental;
18
+ const extract = experimental?.extract;
19
+ if (extract) {
20
+ extractorConfig = normalizeExtractorConfig({
21
+ extract,
22
+ messages: experimental.messages,
23
+ srcPath: experimental.srcPath
24
+ });
25
+ }
15
26
  if (!skipWatchers) {
16
- initExtractionCompiler(pluginConfig);
27
+ initExtractionCompiler(extractorConfig);
17
28
  }
18
- return getNextConfig(pluginConfig, nextConfig);
29
+ return getNextConfig(pluginConfig, nextConfig, extractorConfig);
19
30
  }
20
31
  function createNextIntlPlugin(i18nPathOrConfig = {}) {
21
32
  const config = typeof i18nPathOrConfig === 'string' ? {