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
@@ -39,6 +39,73 @@ function once(namespace) {
39
39
  };
40
40
  }
41
41
 
42
+ function stripTrailingSlash(dirPath) {
43
+ if (dirPath.endsWith('/')) {
44
+ return dirPath.slice(0, -1);
45
+ } else {
46
+ return dirPath;
47
+ }
48
+ }
49
+ function normalizeMessagesCatalogPaths(messagesPath) {
50
+ const rawPaths = Array.isArray(messagesPath) ? messagesPath : [messagesPath];
51
+ return rawPaths.map(dirPath => stripTrailingSlash(String(dirPath).trim())).filter(dirPath => dirPath.length > 0);
52
+ }
53
+ function normalizeExtractorConfig(input) {
54
+ if (input.messages == null) {
55
+ throwError('`messages` is required when extracting messages.');
56
+ }
57
+ const extract = input.extract;
58
+ let extractPath;
59
+ let sourceLocale;
60
+ if (extract !== undefined && extract !== true) {
61
+ if (extract.sourceLocale) {
62
+ warn('`extract.sourceLocale` is deprecated in favor of `messages.sourceLocale`.');
63
+ sourceLocale = extract.sourceLocale;
64
+ }
65
+ if (extract.path) {
66
+ extractPath = stripTrailingSlash(extract.path);
67
+ }
68
+ }
69
+ const locales = input.messages.locales;
70
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
71
+ if (!locales) {
72
+ throwError('`messages.locales` is required when extracting messages.');
73
+ }
74
+ if (input.messages.sourceLocale) {
75
+ sourceLocale = input.messages.sourceLocale;
76
+ }
77
+ if (!sourceLocale) {
78
+ throwError('`messages.sourceLocale` is required when extracting messages.');
79
+ }
80
+ const srcPath = input.srcPath;
81
+ if (srcPath == null) {
82
+ throwError('`srcPath` is required when extracting messages.');
83
+ }
84
+ const pathIsArray = Array.isArray(input.messages.path);
85
+ const messagesPath = normalizeMessagesCatalogPaths(input.messages.path);
86
+ if (messagesPath.length === 0) {
87
+ throwError('`messages.path` must not be empty.');
88
+ }
89
+ if (extractPath == null) {
90
+ if (pathIsArray) {
91
+ throwError('When `messages.path` is an array, `extract.path` is required to select the writable catalog directory.');
92
+ }
93
+ extractPath = messagesPath[0];
94
+ }
95
+ return {
96
+ extract: {
97
+ locales,
98
+ path: extractPath,
99
+ sourceLocale,
100
+ srcPath
101
+ },
102
+ messages: {
103
+ format: input.messages.format,
104
+ path: messagesPath
105
+ }
106
+ };
107
+ }
108
+
42
109
  /**
43
110
  * Wrapper around `fs.watch` that provides a workaround
44
111
  * for https://github.com/nodejs/node/issues/5039.
@@ -122,11 +189,11 @@ export default messages;`;
122
189
 
123
190
  const formats = {
124
191
  json: {
125
- codec: () => Promise.resolve().then(function () { return require('./JSONCodec-B-lAnRTg.cjs'); }),
192
+ codec: () => Promise.resolve().then(function () { return require('./JSONCodec-CzA8ubPy.cjs'); }),
126
193
  extension: '.json'
127
194
  },
128
195
  po: {
129
- codec: () => Promise.resolve().then(function () { return require('./POCodec-0XdsL-1F.cjs'); }),
196
+ codec: () => Promise.resolve().then(function () { return require('./POCodec-CWGHK-Gp.cjs'); }),
130
197
  extension: '.po'
131
198
  }
132
199
  };
@@ -231,7 +298,7 @@ class SourceFileWatcher {
231
298
  }
232
299
  const filtered = await this.normalizeEvents(events);
233
300
  if (filtered.length > 0) {
234
- void this.onChange(filtered);
301
+ await this.onChange(filtered);
235
302
  }
236
303
  }, {
237
304
  ignore
@@ -346,6 +413,12 @@ const FORBIDDEN_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
346
413
  function isForbiddenObjectKey(key) {
347
414
  return FORBIDDEN_OBJECT_KEYS.has(key);
348
415
  }
416
+ function hasLocalesToExtract(config) {
417
+ const {
418
+ locales
419
+ } = config.extract;
420
+ return locales === 'infer' || locales.length > 0;
421
+ }
349
422
 
350
423
  // Essentialls lodash/set, but we avoid this dependency
351
424
  function setNestedProperty(obj, keyPath, value) {
@@ -366,17 +439,34 @@ function setNestedProperty(obj, keyPath, value) {
366
439
  current[keys[keys.length - 1]] = value;
367
440
  }
368
441
  function getSortedMessages(messages) {
442
+ const warnedMissingReferenceIds = new Set();
369
443
  return messages.toSorted((messageA, messageB) => {
370
- const refA = messageA.references?.[0];
371
- const refB = messageB.references?.[0];
444
+ const refA = messageA.references[0];
445
+ const refB = messageB.references[0];
372
446
 
373
- // No references: preserve original (extraction) order
374
- if (!refA || !refB) return 0;
447
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
448
+ if (refA == null) {
449
+ warnAboutMissingReference(messageA.id, warnedMissingReferenceIds);
450
+ }
451
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
452
+ if (refB == null) {
453
+ warnAboutMissingReference(messageB.id, warnedMissingReferenceIds);
454
+ }
455
+
456
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
457
+ if (refA == null || refB == null) {
458
+ return 0;
459
+ }
375
460
 
376
461
  // Sort by path, then line. Same path+line: preserve original order
377
462
  return compareReferences(refA, refB);
378
463
  });
379
464
  }
465
+ function warnAboutMissingReference(id, warnedMissingReferenceIds) {
466
+ if (warnedMissingReferenceIds.has(id)) return;
467
+ warnedMissingReferenceIds.add(id);
468
+ warn(`Missing file reference for extracted message: ${id}`);
469
+ }
380
470
  function localeCompare(a, b) {
381
471
  return a.localeCompare(b, 'en');
382
472
  }
@@ -613,15 +703,27 @@ class SaveScheduler {
613
703
 
614
704
  class CatalogManager {
615
705
  /**
616
- * The source of truth for which messages are used.
617
- * NOTE: Should be mutated in place to keep `messagesById` and `messagesByFile` in sync.
706
+ * Extraction-derived fields aggregated into `ExtractorMessage`.
707
+ * Source code is the source of truth for these fields, only ancillary
708
+ * codec fields may merge from disk (e.g. flags).
709
+ */
710
+ static extractorOwnedAggregatorKeys = new Set(['description', 'id', 'message', 'references']);
711
+ /**
712
+ * Source of truth for statically extracted source messages,
713
+ * grouped by file and message ID.
618
714
  */
619
- messagesByFile = new Map();
715
+ sourceMessagesByFile = new Map();
620
716
 
621
717
  /**
622
- * Fast lookup for messages by ID across all files,
623
- * contains the same messages as `messagesByFile`.
624
- * NOTE: Should be mutated in place to keep `messagesById` and `messagesByFile` in sync.
718
+ * Reverse index for rebuilding aggregated messages without scanning all files.
719
+ * Contains the same `SourceMessage` arrays as `sourceMessagesByFile` and is
720
+ * kept in sync with it.
721
+ */
722
+ sourceMessagesById = new Map();
723
+
724
+ /**
725
+ * Fast lookup for messages by ID, aggregated across all files. This combines
726
+ * metadata from `sourceMessagesById`, e.g. references and descriptions.
625
727
  */
626
728
  messagesById = new Map();
627
729
 
@@ -640,7 +742,7 @@ class CatalogManager {
640
742
 
641
743
  constructor(config, opts) {
642
744
  this.config = config;
643
- this.saveScheduler = new SaveScheduler(50);
745
+ this.saveScheduler = new SaveScheduler(opts.saveDebounceMs ?? 50);
644
746
  this.projectRoot = opts.projectRoot ?? getDefaultProjectRoot();
645
747
  this.isDevelopment = opts.isDevelopment ?? false;
646
748
  this.extractor = opts.extractor;
@@ -663,7 +765,7 @@ class CatalogManager {
663
765
  return this.persister;
664
766
  } else {
665
767
  this.persister = new CatalogPersister({
666
- messagesPath: this.config.messages.path,
768
+ messagesPath: this.config.extract.path,
667
769
  codec: await this.getCodec(),
668
770
  extension: getFormatExtension(this.config.messages.format)
669
771
  });
@@ -674,12 +776,12 @@ class CatalogManager {
674
776
  if (this.catalogLocales) {
675
777
  return this.catalogLocales;
676
778
  } else {
677
- const messagesDir = path__default.default.join(this.projectRoot, this.config.messages.path);
779
+ const messagesDir = path__default.default.join(this.projectRoot, this.config.extract.path);
678
780
  this.catalogLocales = new CatalogLocales({
679
781
  messagesDir,
680
- sourceLocale: this.config.sourceLocale,
681
782
  extension: getFormatExtension(this.config.messages.format),
682
- locales: this.config.messages.locales
783
+ locales: this.config.extract.locales,
784
+ sourceLocale: this.config.extract.sourceLocale
683
785
  });
684
786
  return this.catalogLocales;
685
787
  }
@@ -688,15 +790,28 @@ class CatalogManager {
688
790
  return this.getCatalogLocales().getTargetLocales();
689
791
  }
690
792
  getSrcPaths() {
691
- return (Array.isArray(this.config.srcPath) ? this.config.srcPath : [this.config.srcPath]).map(srcPath => path__default.default.join(this.projectRoot, srcPath));
793
+ return (Array.isArray(this.config.extract.srcPath) ? this.config.extract.srcPath : [this.config.extract.srcPath]).map(srcPath => path__default.default.join(this.projectRoot, srcPath));
692
794
  }
693
795
  async loadMessages() {
694
796
  const sourceDiskMessages = await this.loadSourceMessages();
695
797
  this.loadCatalogsPromise = this.loadTargetMessages();
696
798
  await this.loadCatalogsPromise;
697
799
  this.scanCompletePromise = (async () => {
698
- const sourceFiles = await SourceFileScanner.getSourceFiles(this.getSrcPaths());
699
- await Promise.all(Array.from(sourceFiles).map(async filePath => this.processFile(filePath)));
800
+ const sourceFiles = Array.from(await SourceFileScanner.getSourceFiles(this.getSrcPaths()))
801
+ // Stable file order keeps catalog ties independent of processing timing
802
+ .toSorted(localeCompare);
803
+ const extractedFiles = await Promise.all(sourceFiles.map(async filePath => ({
804
+ filePath,
805
+ messages: await this.extractFile(filePath)
806
+ })));
807
+ for (const {
808
+ filePath,
809
+ messages
810
+ } of extractedFiles) {
811
+ if (messages) {
812
+ this.applyFileMessages(filePath, messages);
813
+ }
814
+ }
700
815
  this.mergeSourceDiskMetadata(sourceDiskMessages);
701
816
  })();
702
817
  await this.scanCompletePromise;
@@ -708,7 +823,7 @@ class CatalogManager {
708
823
  async loadSourceMessages() {
709
824
  // Load source catalog to hydrate metadata (e.g. flags) later without
710
825
  // treating catalog entries as source of truth.
711
- const diskMessages = await this.loadLocaleMessages(this.config.sourceLocale);
826
+ const diskMessages = await this.loadLocaleMessages(this.config.extract.sourceLocale);
712
827
  const byId = new Map();
713
828
  for (const diskMessage of diskMessages) {
714
829
  byId.set(diskMessage.id, diskMessage);
@@ -728,17 +843,13 @@ class CatalogManager {
728
843
  }
729
844
  async reloadLocaleCatalog(locale) {
730
845
  const diskMessages = await this.loadLocaleMessages(locale);
731
- if (locale === this.config.sourceLocale) {
846
+ if (locale === this.config.extract.sourceLocale) {
732
847
  // For source: Merge additional properties like flags
733
848
  for (const diskMessage of diskMessages) {
734
849
  const prev = this.messagesById.get(diskMessage.id);
735
850
  if (prev) {
736
- // Mutate the existing object instead of creating a copy
737
- // to keep messagesById and messagesByFile in sync.
738
- // Unknown properties (like flags): disk wins
739
- // Known properties: existing (from extraction) wins
740
851
  for (const key of Object.keys(diskMessage)) {
741
- if (!['id', 'message', 'description', 'references'].includes(key)) {
852
+ if (!CatalogManager.extractorOwnedAggregatorKeys.has(key)) {
742
853
  // For unknown properties (like flags), disk wins
743
854
  prev[key] = diskMessage[key];
744
855
  }
@@ -770,17 +881,23 @@ class CatalogManager {
770
881
  const existing = this.messagesById.get(id);
771
882
  if (!existing) continue;
772
883
 
773
- // Mutate the existing object instead of creating a copy.
774
- // This keeps `messagesById` and `messagesByFile` in sync since
775
- // they reference the same object instance.
884
+ // Fill unknown metadata from disk without replacing extraction-owned fields.
776
885
  for (const key of Object.keys(diskMessage)) {
777
- if (existing[key] == null) {
886
+ if (!CatalogManager.extractorOwnedAggregatorKeys.has(key) && existing[key] == null) {
778
887
  existing[key] = diskMessage[key];
779
888
  }
780
889
  }
781
890
  }
782
891
  }
783
892
  async processFile(absoluteFilePath) {
893
+ const messages = await this.extractFile(absoluteFilePath);
894
+
895
+ // `undefined` only when `extractFile()` throws. An empty array is success
896
+ // and must still run `applyFileMessages` to clear stale ids for this file.
897
+ if (!messages) return false;
898
+ return this.applyFileMessages(absoluteFilePath, messages);
899
+ }
900
+ async extractFile(absoluteFilePath) {
784
901
  let messages = [];
785
902
  try {
786
903
  const content = await fs__default$1.default.readFile(absoluteFilePath, 'utf8');
@@ -788,7 +905,7 @@ class CatalogManager {
788
905
  try {
789
906
  extraction = await this.extractor.extract(absoluteFilePath, content);
790
907
  } catch {
791
- return false;
908
+ return undefined;
792
909
  }
793
910
  messages = extraction.messages;
794
911
  } catch (err) {
@@ -797,71 +914,91 @@ class CatalogManager {
797
914
  }
798
915
  // ENOENT -> treat as no messages
799
916
  }
800
- const prevFileMessages = this.messagesByFile.get(absoluteFilePath);
801
- const relativeFilePath = normalizePathToPosix(path__default.default.relative(this.projectRoot, absoluteFilePath));
802
-
803
- // Init with all previous ones
804
- const idsToRemove = Array.from(prevFileMessages?.keys() ?? []);
805
-
806
- // Replace existing messages with new ones
807
- const fileMessages = new Map();
808
- for (let message of messages) {
809
- const prevMessage = this.messagesById.get(message.id);
810
-
811
- // Merge with previous message if it exists
812
- if (prevMessage) {
813
- message = {
814
- ...message
815
- };
816
- if (message.references) {
817
- message.references = this.mergeReferences(prevMessage.references ?? [], relativeFilePath, message.references);
917
+ return messages;
918
+ }
919
+ applyFileMessages(absoluteFilePath, messages) {
920
+ const prevFileMessages = this.sourceMessagesByFile.get(absoluteFilePath);
921
+ const nextFileMessages = this.groupSourceMessagesById(messages);
922
+ const affectedIds = new Set([...(prevFileMessages?.keys() ?? []), ...nextFileMessages.keys()]);
923
+ if (nextFileMessages.size > 0) {
924
+ this.sourceMessagesByFile.set(absoluteFilePath, nextFileMessages);
925
+ } else {
926
+ this.sourceMessagesByFile.delete(absoluteFilePath);
927
+ }
928
+
929
+ // Clear this file's contribution from the reverse index, then re-insert
930
+ // fresh rows and rebuild aggregates (messagesById) per touched id.
931
+ for (const id of affectedIds) {
932
+ const sourceMessagesForId = this.sourceMessagesById.get(id);
933
+ if (sourceMessagesForId) {
934
+ sourceMessagesForId.delete(absoluteFilePath);
935
+ // No files left for this id: drop the reverse-index entry.
936
+ if (sourceMessagesForId.size === 0) {
937
+ this.sourceMessagesById.delete(id);
818
938
  }
819
-
820
- // Merge other properties like description, or unknown
821
- // attributes like flags that are opaque to us
822
- for (const key of Object.keys(prevMessage)) {
823
- if (message[key] == null) {
824
- message[key] = prevMessage[key];
825
- }
939
+ }
940
+ const nextSourceMessagesForId = nextFileMessages.get(id);
941
+ if (nextSourceMessagesForId) {
942
+ let sourceMessagesByFile = this.sourceMessagesById.get(id);
943
+ if (!sourceMessagesByFile) {
944
+ sourceMessagesByFile = new Map();
945
+ this.sourceMessagesById.set(id, sourceMessagesByFile);
826
946
  }
947
+ sourceMessagesByFile.set(absoluteFilePath, nextSourceMessagesForId);
827
948
  }
828
- this.messagesById.set(message.id, message);
829
- fileMessages.set(message.id, message);
830
-
831
- // This message continues to exist in this file
832
- const index = idsToRemove.indexOf(message.id);
833
- if (index !== -1) idsToRemove.splice(index, 1);
834
- }
835
-
836
- // Clean up removed messages from `messagesById`
837
- idsToRemove.forEach(id => {
838
- const message = this.messagesById.get(id);
839
- if (!message) return;
840
- const hasOtherReferences = message.references?.some(ref => ref.path !== relativeFilePath);
841
- if (!hasOtherReferences) {
842
- // No other references, delete the message entirely
843
- this.messagesById.delete(id);
949
+ this.rebuildMessageById(id);
950
+ }
951
+ const changed = this.haveMessagesChangedForFile(prevFileMessages, nextFileMessages);
952
+ return changed;
953
+ }
954
+ groupSourceMessagesById(messages) {
955
+ const result = new Map();
956
+ for (const message of messages) {
957
+ const messagesById = result.get(message.id);
958
+ if (messagesById) {
959
+ messagesById.push(message);
844
960
  } else {
845
- // Message is used elsewhere, remove this file from references
846
- // Mutate the existing object to keep `messagesById` and `messagesByFile` in sync
847
- message.references = message.references?.filter(ref => ref.path !== relativeFilePath);
961
+ result.set(message.id, [message]);
848
962
  }
849
- });
850
-
851
- // Update the stored messages
852
- if (messages.length > 0) {
853
- this.messagesByFile.set(absoluteFilePath, fileMessages);
854
- } else {
855
- this.messagesByFile.delete(absoluteFilePath);
856
963
  }
857
- const changed = this.haveMessagesChangedForFile(prevFileMessages, fileMessages);
858
- return changed;
964
+ return result;
859
965
  }
860
- mergeReferences(existing, currentFilePath, currentFileRefs) {
861
- // Keep refs from other files, replace all refs from the current file
862
- const otherFileRefs = existing.filter(ref => ref.path !== currentFilePath);
863
- const merged = [...otherFileRefs, ...currentFileRefs];
864
- return merged.sort(compareReferences);
966
+ rebuildMessageById(id) {
967
+ const sourceMessages = Array.from(this.sourceMessagesById.get(id)?.values() ?? []).flat();
968
+ if (sourceMessages.length === 0) {
969
+ this.messagesById.delete(id);
970
+ return;
971
+ }
972
+ const previousMessage = this.messagesById.get(id);
973
+ const aggregate = {
974
+ description: this.mergeDescriptions(sourceMessages),
975
+ id,
976
+ message: sourceMessages[0].message,
977
+ references: sourceMessages.map(message => message.reference).sort(compareReferences)
978
+ };
979
+ if (previousMessage) {
980
+ for (const key of Object.keys(previousMessage)) {
981
+ // Preserve extra fields (e.g. from disk/codec) across rebuilds; the
982
+ // four core fields above are always recomputed from source messages.
983
+ if (!CatalogManager.extractorOwnedAggregatorKeys.has(key) && aggregate[key] == null) {
984
+ aggregate[key] = previousMessage[key];
985
+ }
986
+ }
987
+ }
988
+ this.messagesById.set(id, aggregate);
989
+ }
990
+ mergeDescriptions(messages) {
991
+ const sortedByReference = messages.toSorted((a, b) => compareReferences(a.reference, b.reference));
992
+ const merged = [];
993
+ for (const message of sortedByReference) {
994
+ const {
995
+ description
996
+ } = message;
997
+ if (description != null && !merged.includes(description)) {
998
+ merged.push(description);
999
+ }
1000
+ }
1001
+ return merged;
865
1002
  }
866
1003
  haveMessagesChangedForFile(beforeMessages, afterMessages) {
867
1004
  // If one exists and the other doesn't, there's a change
@@ -875,25 +1012,28 @@ class CatalogManager {
875
1012
  }
876
1013
 
877
1014
  // Check differences in beforeMessages vs afterMessages
878
- for (const [id, msg1] of beforeMessages) {
879
- const msg2 = afterMessages.get(id);
880
- if (!msg2 || !this.areMessagesEqual(msg1, msg2)) {
1015
+ for (const [id, prevSourceMessages] of beforeMessages) {
1016
+ const nextSourceMessages = afterMessages.get(id);
1017
+ if (!nextSourceMessages) {
1018
+ return true;
1019
+ }
1020
+ if (!this.areSourceMessageArraysEqual(prevSourceMessages, nextSourceMessages)) {
881
1021
  return true; // Early exit on first difference
882
1022
  }
883
1023
  }
884
1024
  return false;
885
1025
  }
886
- areMessagesEqual(msg1, msg2) {
887
- // Note: We intentionally don't compare references here.
888
- // References are aggregated metadata from multiple files and comparing
889
- // them would cause false positives due to parallel extraction order.
890
- return msg1.id === msg2.id && msg1.message === msg2.message && msg1.description === msg2.description;
1026
+ areSourceMessageArraysEqual(messages1, messages2) {
1027
+ return messages1.length === messages2.length && messages1.every((message, index) => this.areSourceMessagesEqual(message, messages2[index]));
1028
+ }
1029
+ areSourceMessagesEqual(msg1, msg2) {
1030
+ 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;
891
1031
  }
892
1032
  async save() {
893
1033
  return this.saveScheduler.schedule(() => this.saveImpl());
894
1034
  }
895
1035
  async saveImpl() {
896
- await this.saveLocale(this.config.sourceLocale);
1036
+ await this.saveLocale(this.config.extract.sourceLocale);
897
1037
  const targetLocales = await this.getTargetLocales();
898
1038
  await Promise.all(targetLocales.map(locale => this.saveLocale(locale)));
899
1039
  }
@@ -901,7 +1041,7 @@ class CatalogManager {
901
1041
  await this.loadCatalogsPromise;
902
1042
  const messages = Array.from(this.messagesById.values());
903
1043
  const persister = await this.getPersister();
904
- const isSourceLocale = locale === this.config.sourceLocale;
1044
+ const isSourceLocale = locale === this.config.extract.sourceLocale;
905
1045
 
906
1046
  // Check if file was modified externally (poll-at-save is cheaper than
907
1047
  // watchers here since stat() is fast and avoids continuous overhead)
@@ -951,8 +1091,9 @@ class CatalogManager {
951
1091
  await this.scanCompletePromise;
952
1092
  }
953
1093
  let changed = false;
954
- const expandedEvents = await this.sourceWatcher.expandDirectoryDeleteEvents(events, Array.from(this.messagesByFile.keys()));
955
- for (const event of expandedEvents) {
1094
+ const expandedEvents = await this.sourceWatcher.expandDirectoryDeleteEvents(events, Array.from(this.sourceMessagesByFile.keys()));
1095
+ // Stable file order keeps catalog ties independent of event timing.
1096
+ for (const event of expandedEvents.toSorted((a, b) => localeCompare(a.path, b.path))) {
956
1097
  const hasChanged = await this.processFile(event.path);
957
1098
  changed ||= hasChanged;
958
1099
  }
@@ -999,7 +1140,7 @@ class LRUCache {
999
1140
  }
1000
1141
  }
1001
1142
 
1002
- const require$2 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('plugin-0S9vVrVM.cjs', document.baseURI).href)));
1143
+ const require$2 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('plugin-DlFYUFWh.cjs', document.baseURI).href)));
1003
1144
  class MessageExtractor {
1004
1145
  compileCache = new LRUCache(750);
1005
1146
  constructor(opts) {
@@ -1104,9 +1245,8 @@ const isDevelopmentOrNextBuild = isDevelopment || isNextBuild;
1104
1245
  // Single compiler instance, initialized once per process
1105
1246
  let compiler;
1106
1247
  const runOnce = once('_NEXT_INTL_EXTRACT');
1107
- function initExtractionCompiler(pluginConfig) {
1108
- const experimental = pluginConfig.experimental;
1109
- if (!experimental?.extract) {
1248
+ function initExtractionCompiler(extractorConfig) {
1249
+ if (!extractorConfig || !hasLocalesToExtract(extractorConfig)) {
1110
1250
  return;
1111
1251
  }
1112
1252
 
@@ -1125,11 +1265,6 @@ function initExtractionCompiler(pluginConfig) {
1125
1265
  const shouldRun = isDevelopment || isNextBuild;
1126
1266
  if (!shouldRun) return;
1127
1267
  runOnce(() => {
1128
- const extractorConfig = {
1129
- srcPath: experimental.srcPath,
1130
- sourceLocale: experimental.extract.sourceLocale,
1131
- messages: experimental.messages
1132
- };
1133
1268
  compiler = new ExtractionCompiler(extractorConfig, {
1134
1269
  isDevelopment,
1135
1270
  projectRoot: process.cwd()
@@ -1157,7 +1292,7 @@ function initExtractionCompiler(pluginConfig) {
1157
1292
 
1158
1293
  function getCurrentVersion() {
1159
1294
  try {
1160
- 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-0S9vVrVM.cjs', document.baseURI).href)));
1295
+ 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-DlFYUFWh.cjs', document.baseURI).href)));
1161
1296
  const pkg = require$1('next/package.json');
1162
1297
  return pkg.version;
1163
1298
  } catch (error) {
@@ -1184,7 +1319,7 @@ function isNextJs16OrHigher() {
1184
1319
  return compareVersions(getCurrentVersion(), '16.0.0') >= 0;
1185
1320
  }
1186
1321
 
1187
- 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-0S9vVrVM.cjs', document.baseURI).href)));
1322
+ 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-DlFYUFWh.cjs', document.baseURI).href)));
1188
1323
  function withExtensions(localPath) {
1189
1324
  return [`${localPath}.ts`, `${localPath}.tsx`, `${localPath}.js`, `${localPath}.jsx`];
1190
1325
  }
@@ -1229,7 +1364,7 @@ function resolveI18nPath(providedPath, cwd) {
1229
1364
  }
1230
1365
  }
1231
1366
  }
1232
- function getNextConfig(pluginConfig, nextConfig) {
1367
+ function getNextConfig(pluginConfig, nextConfig, extractorConfig) {
1233
1368
  const useTurbo = process.env.TURBOPACK != null;
1234
1369
 
1235
1370
  // `experimental-analyze` doesn’t set the TURBOPACK env param. Since Next.js
@@ -1237,25 +1372,27 @@ function getNextConfig(pluginConfig, nextConfig) {
1237
1372
  // always configure Turbopack just in case.
1238
1373
  const shouldConfigureTurbo = useTurbo || isNextJs16OrHigher();
1239
1374
  const nextIntlConfig = {};
1240
- function getExtractMessagesLoaderConfig() {
1241
- const experimental = pluginConfig.experimental;
1242
- if (!experimental.srcPath || !pluginConfig.experimental?.messages) {
1243
- throwError('`srcPath` and `messages` are required when using `extractor`.');
1244
- }
1375
+ let messageLoadPaths = [];
1376
+ if (pluginConfig.experimental?.messages) {
1377
+ messageLoadPaths = normalizeMessagesCatalogPaths(pluginConfig.experimental.messages.path);
1378
+ }
1379
+ function getExtractMessagesLoaderConfig(config) {
1245
1380
  return {
1246
1381
  loader: 'next-intl/extractor/extractionLoader',
1247
- options: {
1248
- srcPath: experimental.srcPath,
1249
- sourceLocale: experimental.extract.sourceLocale,
1250
- messages: pluginConfig.experimental.messages
1251
- }
1382
+ options: config
1252
1383
  };
1253
1384
  }
1254
1385
  function getCatalogLoaderConfig() {
1386
+ const messages = pluginConfig.experimental.messages;
1255
1387
  return {
1256
1388
  loader: 'next-intl/extractor/catalogLoader',
1257
1389
  options: {
1258
- messages: pluginConfig.experimental.messages
1390
+ messages: {
1391
+ format: messages.format,
1392
+ ...(messages.precompile !== undefined && {
1393
+ precompile: messages.precompile
1394
+ })
1395
+ }
1259
1396
  }
1260
1397
  };
1261
1398
  }
@@ -1276,18 +1413,6 @@ function getNextConfig(pluginConfig, nextConfig) {
1276
1413
  rules[glob] = rule;
1277
1414
  }
1278
1415
  }
1279
-
1280
- // Validate messages config
1281
- if (pluginConfig.experimental?.messages) {
1282
- const messages = pluginConfig.experimental.messages;
1283
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- For non-TS consumers
1284
- if (!messages.format) {
1285
- throwError('`format` is required when using `messages`.');
1286
- }
1287
- if (!messages.path) {
1288
- throwError('`path` is required when using `messages`.');
1289
- }
1290
- }
1291
1416
  if (shouldConfigureTurbo) {
1292
1417
  if (pluginConfig.requestConfig && path__default.default.isAbsolute(pluginConfig.requestConfig)) {
1293
1418
  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);
@@ -1324,13 +1449,11 @@ function getNextConfig(pluginConfig, nextConfig) {
1324
1449
  throwError('Message extraction requires Next.js 16 or higher.');
1325
1450
  }
1326
1451
  rules ??= getTurboRules();
1327
- const srcPaths = (Array.isArray(pluginConfig.experimental.srcPath) ? pluginConfig.experimental.srcPath : [pluginConfig.experimental.srcPath]).map(srcPath => srcPath.endsWith('/') ? srcPath.slice(0, -1) : srcPath);
1328
1452
  addTurboRule(rules, `*.{${SourceFileFilter.EXTENSIONS.join(',')}}`, {
1329
- loaders: [getExtractMessagesLoaderConfig()],
1453
+ loaders: [getExtractMessagesLoaderConfig(extractorConfig)],
1330
1454
  condition: {
1331
- // Note: We don't need `not: 'foreign'`, because this is
1332
- // implied by the filter based on `srcPath`.
1333
- path: `{${srcPaths.join(',')}}` + '/**/*',
1455
+ // We don't filter for `path` here to allow transformation
1456
+ // of `useExtracted` calls in external packages (e.g. monorepos)
1334
1457
  content: /(useExtracted|getExtracted)/
1335
1458
  }
1336
1459
  });
@@ -1346,7 +1469,7 @@ function getNextConfig(pluginConfig, nextConfig) {
1346
1469
  addTurboRule(rules, `*${extension}`, {
1347
1470
  loaders: [getCatalogLoaderConfig()],
1348
1471
  condition: {
1349
- path: `${pluginConfig.experimental.messages.path}/**/*`
1472
+ path: `{${messageLoadPaths.join(',')}}/**/*`
1350
1473
  },
1351
1474
  as: '*.js'
1352
1475
  });
@@ -1404,11 +1527,9 @@ function getNextConfig(pluginConfig, nextConfig) {
1404
1527
  if (pluginConfig.experimental?.extract) {
1405
1528
  if (!config.module) config.module = {};
1406
1529
  if (!config.module.rules) config.module.rules = [];
1407
- const srcPath = pluginConfig.experimental.srcPath;
1408
1530
  config.module.rules.push({
1409
1531
  test: new RegExp(`\\.(${SourceFileFilter.EXTENSIONS.join('|')})$`),
1410
- include: Array.isArray(srcPath) ? srcPath.map(cur => path__default.default.resolve(config.context, cur)) : path__default.default.resolve(config.context, srcPath || ''),
1411
- use: [getExtractMessagesLoaderConfig()]
1532
+ use: [getExtractMessagesLoaderConfig(extractorConfig)]
1412
1533
  });
1413
1534
  }
1414
1535
 
@@ -1419,7 +1540,7 @@ function getNextConfig(pluginConfig, nextConfig) {
1419
1540
  const extension = getFormatExtension(pluginConfig.experimental.messages.format);
1420
1541
  config.module.rules.push({
1421
1542
  test: new RegExp(`${extension.replace(/\./g, '\\.')}$`),
1422
- include: path__default.default.resolve(config.context, pluginConfig.experimental.messages.path),
1543
+ include: messageLoadPaths.map(dirPath => path__default.default.resolve(config.context, dirPath)),
1423
1544
  use: [getCatalogLoaderConfig()],
1424
1545
  type: 'javascript/auto'
1425
1546
  });
@@ -1450,10 +1571,20 @@ function initPlugin(pluginConfig, nextConfig) {
1450
1571
  if (messagesPathOrPaths && !skipWatchers) {
1451
1572
  createMessagesDeclaration(typeof messagesPathOrPaths === 'string' ? [messagesPathOrPaths] : messagesPathOrPaths);
1452
1573
  }
1574
+ let extractorConfig;
1575
+ const experimental = pluginConfig.experimental;
1576
+ const extract = experimental?.extract;
1577
+ if (extract) {
1578
+ extractorConfig = normalizeExtractorConfig({
1579
+ extract,
1580
+ messages: experimental.messages,
1581
+ srcPath: experimental.srcPath
1582
+ });
1583
+ }
1453
1584
  if (!skipWatchers) {
1454
- initExtractionCompiler(pluginConfig);
1585
+ initExtractionCompiler(extractorConfig);
1455
1586
  }
1456
- return getNextConfig(pluginConfig, nextConfig);
1587
+ return getNextConfig(pluginConfig, nextConfig, extractorConfig);
1457
1588
  }
1458
1589
  function createNextIntlPlugin(i18nPathOrConfig = {}) {
1459
1590
  const config = typeof i18nPathOrConfig === 'string' ? {