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