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.
- package/dist/cjs/development/{JSONCodec-B-lAnRTg.cjs → JSONCodec-CzA8ubPy.cjs} +4 -2
- package/dist/cjs/development/{POCodec-0XdsL-1F.cjs → POCodec-CWGHK-Gp.cjs} +16 -10
- package/dist/cjs/development/{plugin-0S9vVrVM.cjs → plugin-DlFYUFWh.cjs} +281 -150
- package/dist/cjs/development/plugin.cjs +1 -1
- package/dist/esm/development/extractor/catalog/CatalogManager.js +146 -95
- package/dist/esm/development/extractor/extractMessages.js +9 -2
- package/dist/esm/development/extractor/format/codecs/JSONCodec.js +3 -1
- package/dist/esm/development/extractor/format/codecs/POCodec.js +15 -9
- package/dist/esm/development/extractor/normalizeExtractorConfig.js +70 -0
- package/dist/esm/development/extractor/source/SourceFileWatcher.js +1 -1
- package/dist/esm/development/extractor/utils.js +29 -5
- package/dist/esm/development/plugin/createNextIntlPlugin.js +13 -2
- package/dist/esm/development/plugin/extractor/initExtractionCompiler.js +3 -8
- package/dist/esm/development/plugin/getNextConfig.js +21 -34
- package/dist/esm/development/server/react-server/getServerExtractor.js +3 -3
- package/dist/esm/production/extractor/catalog/CatalogManager.js +1 -1
- package/dist/esm/production/extractor/extractMessages.js +1 -1
- package/dist/esm/production/extractor/format/codecs/JSONCodec.js +1 -1
- package/dist/esm/production/extractor/format/codecs/POCodec.js +1 -1
- package/dist/esm/production/extractor/normalizeExtractorConfig.js +1 -0
- package/dist/esm/production/extractor/source/SourceFileWatcher.js +1 -1
- package/dist/esm/production/extractor/utils.js +1 -1
- package/dist/esm/production/plugin/createNextIntlPlugin.js +1 -1
- package/dist/esm/production/plugin/extractor/initExtractionCompiler.js +1 -1
- package/dist/esm/production/plugin/getNextConfig.js +1 -1
- package/dist/esm/production/server/react-server/getServerExtractor.js +1 -1
- package/dist/types/extractor/ExtractionCompiler.d.ts +2 -1
- package/dist/types/extractor/catalog/CatalogLocales.d.ts +2 -2
- package/dist/types/extractor/catalog/CatalogManager.d.ts +27 -10
- package/dist/types/extractor/extractMessages.d.ts +2 -2
- package/dist/types/extractor/extractor/MessageExtractor.d.ts +2 -6
- package/dist/types/extractor/normalizeExtractorConfig.d.ts +5 -0
- package/dist/types/extractor/types.d.ts +62 -11
- package/dist/types/extractor/utils.d.ts +2 -1
- package/dist/types/plugin/extractor/initExtractionCompiler.d.ts +2 -2
- package/dist/types/plugin/getNextConfig.d.ts +2 -1
- package/dist/types/plugin/types.d.ts +10 -14
- package/package.json +5 -5
|
@@ -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,
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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.
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
return changed;
|
|
271
|
+
return result;
|
|
256
272
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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,
|
|
276
|
-
const
|
|
277
|
-
if (!
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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.
|
|
352
|
-
|
|
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
|
|
4
|
+
import normalizeExtractorConfig from './normalizeExtractorConfig.js';
|
|
5
|
+
import { hasLocalesToExtract, getDefaultProjectRoot } from './utils.js';
|
|
4
6
|
|
|
5
7
|
async function extractMessages(params) {
|
|
6
|
-
const
|
|
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()
|
|
@@ -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
|
-
|
|
45
|
-
|
|
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:
|
|
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 };
|
|
@@ -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
|
|
34
|
-
const refB = messageB.references
|
|
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
|
-
//
|
|
37
|
-
if (
|
|
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(
|
|
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' ? {
|