@uniflowed/i18n 0.0.0-alpha.18
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/catalogue.js +501 -0
- package/format.js +824 -0
- package/index.js +271 -0
- package/negotiate.js +162 -0
- package/package.json +28 -0
- package/syntax.js +717 -0
package/catalogue.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/i18n/catalogue`: the messages an application has, and the types
|
|
4
|
+
// that make calling one checkable.
|
|
5
|
+
//
|
|
6
|
+
// # The problem this module exists to solve
|
|
7
|
+
//
|
|
8
|
+
// Every i18n library types the key and gives up on the arguments. `t("unread",
|
|
9
|
+
// { count })` and `t("unread", {})` are the same call to the checker, so a
|
|
10
|
+
// message that gained a placeholder in the source locale keeps compiling
|
|
11
|
+
// everywhere it is used and renders `{$count}` to a user. That is the bug this
|
|
12
|
+
// module is about, and it is not a small one: the placeholder was added by
|
|
13
|
+
// whoever wrote the English, and the call sites are wherever the message is
|
|
14
|
+
// shown.
|
|
15
|
+
//
|
|
16
|
+
// The reason libraries stop there is real. Reading `{name: string, count:
|
|
17
|
+
// number}` off the *string* `"Hello {$name}, you have {$count}"` needs the
|
|
18
|
+
// message parsed at the type level, which in TypeScript means template-literal
|
|
19
|
+
// types and in Flow is not possible at all: Flow has no template-literal
|
|
20
|
+
// types, so the placeholders inside a string literal are not part of its type
|
|
21
|
+
// in any form a conditional type can reach.
|
|
22
|
+
//
|
|
23
|
+
// # So the parameters are values
|
|
24
|
+
//
|
|
25
|
+
// A message declares its parameters beside its source, as values:
|
|
26
|
+
//
|
|
27
|
+
// const messages = {
|
|
28
|
+
// greeting: message("Hello, {$name}!", { name: string }),
|
|
29
|
+
// unread: message(UNREAD_SOURCE, { count: number }),
|
|
30
|
+
// cartEmpty: message("Your cart is empty.", {}),
|
|
31
|
+
// };
|
|
32
|
+
//
|
|
33
|
+
// `ParamArgs` maps that object to `{ name: string }` and `{ count: number }`,
|
|
34
|
+
// `ArgsOf` reads it back out of the message, and `t` is generic in the key —
|
|
35
|
+
// so `t("greeting", { name })` checks, `t("greeting", {})` does not, and
|
|
36
|
+
// `t("unread", { count: "3" })` does not either. Nobody wrote a type down.
|
|
37
|
+
//
|
|
38
|
+
// Declaring the parameters as values rather than as a type argument is what
|
|
39
|
+
// closes the gap the type system leaves open, and this is the whole reason for
|
|
40
|
+
// the shape. `message` is handed both the source *and* the parameters at run
|
|
41
|
+
// time, so it can compare them: a message that reads `$nom` when the
|
|
42
|
+
// parameters say `name` throws where it is written, naming the key. A type
|
|
43
|
+
// argument — `message<{ name: string }>("Hello, {$nom}!")` — would have been
|
|
44
|
+
// less to write and would have checked nothing, because a type argument is
|
|
45
|
+
// erased before anything could look at it.
|
|
46
|
+
//
|
|
47
|
+
// The same comparison catches the annotation: `{ count: number }` against a
|
|
48
|
+
// message that says `{$count :date}` is refused. That is not a type error
|
|
49
|
+
// anywhere, in any checker; it is two facts about one name that only exist in
|
|
50
|
+
// the same place at this moment.
|
|
51
|
+
//
|
|
52
|
+
// # What is checked, and where
|
|
53
|
+
//
|
|
54
|
+
// | | |
|
|
55
|
+
// | --- | --- |
|
|
56
|
+
// | a key that does not exist | Flow, at the call |
|
|
57
|
+
// | a missing or misspelled argument | Flow, at the call |
|
|
58
|
+
// | an argument of the wrong type | Flow, at the call |
|
|
59
|
+
// | a message reading a parameter nobody declared | `message`, where it is written |
|
|
60
|
+
// | a parameter the message never reads | `message`, where it is written |
|
|
61
|
+
// | a parameter annotated as the wrong kind | `message`, where it is written |
|
|
62
|
+
// | a translation that lost or invented a placeholder | `translate`, at start-up |
|
|
63
|
+
// | a translation key the source locale does not have | `translate`, at start-up |
|
|
64
|
+
// | a message never translated | reported as `untranslated`, never a throw |
|
|
65
|
+
//
|
|
66
|
+
// The one thing nothing here checks is that a *translation* says the same
|
|
67
|
+
// thing as its source. That is what a translator is for.
|
|
68
|
+
//
|
|
69
|
+
// # Why `t("cartEmpty", {})` and not `t("cartEmpty")`
|
|
70
|
+
//
|
|
71
|
+
// The empty object is not an oversight. Flow cannot make one parameter
|
|
72
|
+
// optional as a function of another parameter's type, so the choice is between
|
|
73
|
+
// an argument that is always required and an argument that is always optional
|
|
74
|
+
// — and the second gives up the whole point of the module, because
|
|
75
|
+
// `t("greeting")` would then check too. Two extra characters on the messages
|
|
76
|
+
// that take nothing is the cheaper half of that trade.
|
|
77
|
+
|
|
78
|
+
import type { FormatContext } from "./format.js";
|
|
79
|
+
import { MessageFormatError, formatMessage, isKnownFunction } from "./format.js";
|
|
80
|
+
import type { MessageNode, MessageUsage } from "./syntax.js";
|
|
81
|
+
import { messageUsage, parseMessage } from "./syntax.js";
|
|
82
|
+
|
|
83
|
+
/** What a message says it needs. */
|
|
84
|
+
export type ParamKind = "string" | "number" | "boolean" | "date";
|
|
85
|
+
|
|
86
|
+
type ParamCarrier<out TValue> = {
|
|
87
|
+
readonly kind: ParamKind,
|
|
88
|
+
/**
|
|
89
|
+
* A phantom.
|
|
90
|
+
*
|
|
91
|
+
* `TValue` is never produced or consumed at run time — a parameter
|
|
92
|
+
* declaration is four bytes of metadata — and it has to appear somewhere in
|
|
93
|
+
* the type to be a parameter at all. Return position keeps it covariant,
|
|
94
|
+
* which is what lets `Param<string>` be used where `Param<mixed>` is wanted
|
|
95
|
+
* and so lets `ParamMap` have an indexer.
|
|
96
|
+
*/
|
|
97
|
+
readonly value: () => TValue,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** A parameter's kind, carrying the type of the value it stands for. */
|
|
101
|
+
export opaque type Param<out TValue>: ParamCarrier<TValue> = ParamCarrier<TValue>;
|
|
102
|
+
|
|
103
|
+
/** The parameters of a message: what `message` takes beside its source. */
|
|
104
|
+
export type ParamMap = { readonly [string]: Param<mixed>, ... };
|
|
105
|
+
|
|
106
|
+
/** The value type behind one parameter. */
|
|
107
|
+
export type ParamValue<TParam> = TParam extends Param<infer TValue> ? TValue : empty;
|
|
108
|
+
|
|
109
|
+
/** The argument object a set of parameters describes. */
|
|
110
|
+
export type ParamArgs<TParams extends ParamMap> = {
|
|
111
|
+
[Key in keyof TParams]: ParamValue<TParams[Key]>,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Never called, and unreachable rather than absent.
|
|
116
|
+
*
|
|
117
|
+
* A phantom needs a value of a function type, and `undefined` cast into one
|
|
118
|
+
* would be a lie the first time somebody called it by accident. This throws
|
|
119
|
+
* something that says what happened.
|
|
120
|
+
*/
|
|
121
|
+
function phantom(): empty {
|
|
122
|
+
throw new Error("@uniflowed/i18n: a parameter declaration is not a value and cannot be called");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A parameter formatted as text: `{$name}`, or `{$name :string}`. */
|
|
126
|
+
export const string: Param<string> = { kind: "string", value: phantom };
|
|
127
|
+
|
|
128
|
+
/** A parameter formatted as a number: `{$count :number}`, `{$n :integer}`. */
|
|
129
|
+
export const number: Param<number> = { kind: "number", value: phantom };
|
|
130
|
+
|
|
131
|
+
/** A parameter that selects but rarely prints: `.match $isAdmin`. */
|
|
132
|
+
export const boolean: Param<boolean> = { kind: "boolean", value: phantom };
|
|
133
|
+
|
|
134
|
+
/** A parameter formatted as an instant: `{$at :date}`, `:time`, `:datetime`. */
|
|
135
|
+
export const date: Param<Date> = { kind: "date", value: phantom };
|
|
136
|
+
|
|
137
|
+
/** Which MF2 functions may be applied to a parameter of each kind. */
|
|
138
|
+
const KINDS_TO_FUNCTIONS: { readonly [ParamKind]: $ReadOnlyArray<string> } = {
|
|
139
|
+
string: ["string"],
|
|
140
|
+
number: ["number", "integer"],
|
|
141
|
+
boolean: ["string"],
|
|
142
|
+
date: ["date", "time", "datetime"],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
type MessageCarrier<out TArgs> = {
|
|
146
|
+
readonly source: string,
|
|
147
|
+
readonly node: MessageNode,
|
|
148
|
+
readonly usage: MessageUsage,
|
|
149
|
+
readonly params: ParamMap,
|
|
150
|
+
/** A phantom, for the same reason `ParamCarrier`'s is. */
|
|
151
|
+
readonly args: () => TArgs,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One message: its MF2 source, parsed, and the arguments formatting it needs.
|
|
156
|
+
*
|
|
157
|
+
* Opaque so that the only way to get one is [`message`], which is the only
|
|
158
|
+
* thing that checks the source against the parameters. A hand-written object
|
|
159
|
+
* of the same shape would be a message whose type says one thing and whose
|
|
160
|
+
* text says another, which is the single failure this module exists to
|
|
161
|
+
* prevent.
|
|
162
|
+
*/
|
|
163
|
+
export opaque type Message<out TArgs>: MessageCarrier<TArgs> = MessageCarrier<TArgs>;
|
|
164
|
+
|
|
165
|
+
/** The arguments one message needs. */
|
|
166
|
+
export type ArgsOf<TMessage> = TMessage extends Message<infer TArgs> ? TArgs : empty;
|
|
167
|
+
|
|
168
|
+
/** A catalogue's messages: the object `defineCatalogue` is given. */
|
|
169
|
+
export type MessageMap = { readonly [string]: Message<mixed>, ... };
|
|
170
|
+
|
|
171
|
+
/** One locale's text for every key, as plain strings a translator can edit. */
|
|
172
|
+
export type Translations<TMessages extends MessageMap> = {
|
|
173
|
+
[Key in keyof TMessages]: string,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A message that does not agree with the parameters declared beside it.
|
|
178
|
+
*
|
|
179
|
+
* Separate from `MessageSyntaxError` because the message parses fine: this is
|
|
180
|
+
* the check no type system performs, and an error that says "unexpected token"
|
|
181
|
+
* would send the reader looking for a typo in the syntax.
|
|
182
|
+
*/
|
|
183
|
+
export class MessageContractError extends Error {
|
|
184
|
+
key: string;
|
|
185
|
+
|
|
186
|
+
constructor(key: string, detail: string) {
|
|
187
|
+
super(`@uniflowed/i18n: the message ${JSON.stringify(key)} ${detail}`);
|
|
188
|
+
this.name = "MessageContractError";
|
|
189
|
+
this.key = key;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function listed(names: $ReadOnlyArray<string>): string {
|
|
194
|
+
return names.length === 0 ? "nothing" : names.map((name) => `$${name}`).join(", ");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Hold a parsed message against the parameters declared beside it.
|
|
199
|
+
*
|
|
200
|
+
* `key` is only for the error text — a message is checked long before it is
|
|
201
|
+
* put in a catalogue, and `message` passes the source itself so that the
|
|
202
|
+
* failure reads sensibly for a message that has no key yet.
|
|
203
|
+
*/
|
|
204
|
+
function checkAgainstParams(key: string, usage: MessageUsage, params: ParamMap): void {
|
|
205
|
+
for (const name of usage.functions) {
|
|
206
|
+
if (!isKnownFunction(name)) {
|
|
207
|
+
throw new MessageContractError(
|
|
208
|
+
key,
|
|
209
|
+
`names the function :${name}, which uf does not implement; the six it does are ` +
|
|
210
|
+
":string, :number, :integer, :date, :time and :datetime",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const declared = Object.keys(params).sort();
|
|
216
|
+
const missing = usage.variables.filter((name) => !declared.includes(name));
|
|
217
|
+
if (missing.length > 0) {
|
|
218
|
+
throw new MessageContractError(
|
|
219
|
+
key,
|
|
220
|
+
`reads ${listed(missing)}, which ${missing.length === 1 ? "is" : "are"} not declared in ` +
|
|
221
|
+
`its parameters (${listed(declared)})`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const unused = declared.filter((name) => !usage.variables.includes(name));
|
|
226
|
+
if (unused.length > 0) {
|
|
227
|
+
// Refused rather than tolerated. An unused parameter is nearly always the
|
|
228
|
+
// other half of a rename that only landed in one of the two places, and a
|
|
229
|
+
// caller is being made to pass a value that reaches nothing.
|
|
230
|
+
throw new MessageContractError(
|
|
231
|
+
key,
|
|
232
|
+
`declares ${listed(unused)}, which the message never reads`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const [name, functionName] of usage.annotated) {
|
|
237
|
+
const param = params[name];
|
|
238
|
+
if (param == null) continue;
|
|
239
|
+
const allowed = KINDS_TO_FUNCTIONS[param.kind];
|
|
240
|
+
if (!allowed.includes(functionName)) {
|
|
241
|
+
throw new MessageContractError(
|
|
242
|
+
key,
|
|
243
|
+
`applies :${functionName} to $${name}, which is declared ${param.kind}; ` +
|
|
244
|
+
`${param.kind} takes ${allowed.map((each) => `:${each}`).join(" or ")}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Declare a message and the parameters formatting it needs.
|
|
252
|
+
*
|
|
253
|
+
* Throws where the message is written if the two disagree — see the module
|
|
254
|
+
* header for why that is the point rather than a nicety.
|
|
255
|
+
*/
|
|
256
|
+
export function message<TParams extends ParamMap>(
|
|
257
|
+
source: string,
|
|
258
|
+
params: TParams,
|
|
259
|
+
): Message<ParamArgs<TParams>> {
|
|
260
|
+
const node = parseMessage(source);
|
|
261
|
+
const usage = messageUsage(node);
|
|
262
|
+
checkAgainstParams(source, usage, params);
|
|
263
|
+
return { source, node, usage, params, args: phantom };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* An application's messages in one locale, and the typed way to format one.
|
|
268
|
+
*
|
|
269
|
+
* A plain object rather than an opaque type: everything on it is worth
|
|
270
|
+
* reading, `translate` builds a second one from the first, and nothing breaks
|
|
271
|
+
* if an application builds one itself — the guarantee lives in [`Message`],
|
|
272
|
+
* which a catalogue can only hold and never mint.
|
|
273
|
+
*/
|
|
274
|
+
export type Catalogue<TMessages extends MessageMap> = {
|
|
275
|
+
readonly locale: string,
|
|
276
|
+
/** Keys this locale did not translate, so they fall back to the source. */
|
|
277
|
+
readonly untranslated: $ReadOnlyArray<string>,
|
|
278
|
+
readonly t: <TKey extends $Keys<TMessages>>(key: TKey, args: ArgsOf<TMessages[TKey]>) => string,
|
|
279
|
+
/** The MF2 source behind a key, for a dev overlay or a test. */
|
|
280
|
+
readonly sourceOf: (key: string) => string,
|
|
281
|
+
/** What `translate` needs and nothing else should read. */
|
|
282
|
+
readonly messages: TMessages,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
export type CatalogueOptions = {
|
|
286
|
+
/**
|
|
287
|
+
* What to do about a value that reached formatting and should not have.
|
|
288
|
+
*
|
|
289
|
+
* Throws by default. Every ordinary mistake is caught before this — by Flow
|
|
290
|
+
* at the call, or by `message` where the message is written — so reaching
|
|
291
|
+
* here means a value came from outside the type system and is not what the
|
|
292
|
+
* message needs. That is worth finding rather than papering over, and an
|
|
293
|
+
* application that would rather show the MF2 fallback than fail a render
|
|
294
|
+
* passes something that records instead.
|
|
295
|
+
*/
|
|
296
|
+
readonly onError?: (error: MessageFormatError) => void,
|
|
297
|
+
/** See `FormatContext` in `format.js`; off by default, and why. */
|
|
298
|
+
readonly bidiIsolation?: boolean,
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
function raise(error: MessageFormatError): void {
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function contextFor(locale: string, options: CatalogueOptions): FormatContext {
|
|
306
|
+
return {
|
|
307
|
+
locale,
|
|
308
|
+
bidiIsolation: options.bidiIsolation === true,
|
|
309
|
+
onError: options.onError ?? raise,
|
|
310
|
+
numberFormats: new Map(),
|
|
311
|
+
dateFormats: new Map(),
|
|
312
|
+
pluralRules: new Map(),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build a catalogue over a set of messages.
|
|
318
|
+
*
|
|
319
|
+
* The messages are already parsed — [`message`] did that — so this allocates
|
|
320
|
+
* one lookup table and three empty caches. Defining a catalogue at module
|
|
321
|
+
* scope is cheap on purpose: the parse happened when the message was
|
|
322
|
+
* declared, and a page that never formats anything pays for nothing beyond it.
|
|
323
|
+
*/
|
|
324
|
+
export function defineCatalogue<TMessages extends MessageMap>(
|
|
325
|
+
locale: string,
|
|
326
|
+
messages: TMessages,
|
|
327
|
+
options?: CatalogueOptions,
|
|
328
|
+
): Catalogue<TMessages> {
|
|
329
|
+
return build(locale, messages, messages, [], options ?? {});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The one place a catalogue is actually constructed.
|
|
334
|
+
*
|
|
335
|
+
* `messages` is what the types describe and `formatting` is what is formatted;
|
|
336
|
+
* they are the same object for a source catalogue and differ for a translated
|
|
337
|
+
* one. Keeping the two apart is what lets `translate` return
|
|
338
|
+
* `Catalogue<TMessages>` — the same keys and the same argument types — over
|
|
339
|
+
* different text, with no cast anywhere.
|
|
340
|
+
*/
|
|
341
|
+
function build<TMessages extends MessageMap>(
|
|
342
|
+
locale: string,
|
|
343
|
+
messages: TMessages,
|
|
344
|
+
formatting: { readonly [string]: Message<mixed> },
|
|
345
|
+
untranslated: $ReadOnlyArray<string>,
|
|
346
|
+
options: CatalogueOptions,
|
|
347
|
+
): Catalogue<TMessages> {
|
|
348
|
+
const context = contextFor(locale, options);
|
|
349
|
+
const compiled: Map<string, Message<mixed>> = new Map();
|
|
350
|
+
for (const key of Object.keys(formatting)) {
|
|
351
|
+
compiled.set(key, formatting[key]);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const find = (key: string): Message<mixed> => {
|
|
355
|
+
const found = compiled.get(key);
|
|
356
|
+
if (found == null) {
|
|
357
|
+
// Unreachable through the types, and worth a real error anyway: a key
|
|
358
|
+
// read out of a database or a URL arrives as a string and Flow never saw
|
|
359
|
+
// it.
|
|
360
|
+
throw new MessageContractError(key, "is not in this catalogue");
|
|
361
|
+
}
|
|
362
|
+
return found;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// `mixed` rather than a dictionary, and not because the type is unknown to
|
|
366
|
+
// the caller — it is `ArgsOf<TMessages[TKey]>`, and Flow checks the call
|
|
367
|
+
// against it. It is unknown *here*: inside a generic body there is nothing to
|
|
368
|
+
// resolve that conditional against, so a dictionary parameter would make the
|
|
369
|
+
// one function this package needs to be polymorphic the one it cannot write.
|
|
370
|
+
// `format.js` reads the arguments through a `Map` for the same reason and one
|
|
371
|
+
// better one.
|
|
372
|
+
const format = (key: string, args: mixed): string => formatMessage(find(key).node, args, context);
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
locale,
|
|
376
|
+
untranslated,
|
|
377
|
+
t: format,
|
|
378
|
+
sourceOf: (key: string) => find(key).source,
|
|
379
|
+
messages,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A second locale over the same keys and the same argument types.
|
|
385
|
+
*
|
|
386
|
+
* A translation is plain strings, which is what a translator can be handed and
|
|
387
|
+
* what a `.json` export can hold. It does not redeclare the parameters,
|
|
388
|
+
* because they are a property of the message rather than of the language, and
|
|
389
|
+
* a locale file that redeclared them would be a second place for them to
|
|
390
|
+
* disagree.
|
|
391
|
+
*
|
|
392
|
+
* Partial on purpose: a locale that has translated nine keys of twenty is the
|
|
393
|
+
* ordinary state of a growing application, and refusing to build a catalogue
|
|
394
|
+
* for it would mean an untranslated string could never ship. The keys that
|
|
395
|
+
* fell back are on the catalogue as `untranslated`, so a test can require the
|
|
396
|
+
* list to be empty for the locales an application claims to support — which is
|
|
397
|
+
* the same fact, asserted by whoever wants to assert it rather than by this
|
|
398
|
+
* package.
|
|
399
|
+
*/
|
|
400
|
+
export function translate<TMessages extends MessageMap>(
|
|
401
|
+
base: Catalogue<TMessages>,
|
|
402
|
+
locale: string,
|
|
403
|
+
translations: Partial<Translations<TMessages>>,
|
|
404
|
+
options?: CatalogueOptions,
|
|
405
|
+
): Catalogue<TMessages> {
|
|
406
|
+
const sources: { readonly [string]: mixed } = translations;
|
|
407
|
+
const formatting: { [string]: Message<mixed> } = {};
|
|
408
|
+
const untranslated: Array<string> = [];
|
|
409
|
+
|
|
410
|
+
for (const key of Object.keys(sources)) {
|
|
411
|
+
if (base.messages[key] == null) {
|
|
412
|
+
throw new MessageContractError(
|
|
413
|
+
key,
|
|
414
|
+
`is translated into ${locale} and is not in the source catalogue`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const key of Object.keys(base.messages)) {
|
|
420
|
+
const original = base.messages[key];
|
|
421
|
+
const replacement = sources[key];
|
|
422
|
+
if (typeof replacement !== "string") {
|
|
423
|
+
untranslated.push(key);
|
|
424
|
+
formatting[key] = original;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const node = parseMessage(replacement);
|
|
429
|
+
const usage = messageUsage(node);
|
|
430
|
+
// The parameters are the source message's, not a new set: a translation
|
|
431
|
+
// may reorder placeholders, drop one that a language does not need, and
|
|
432
|
+
// add none. Checking it against the source's parameters is what turns "the
|
|
433
|
+
// German broke on a page nobody opened" into a start-up error naming the
|
|
434
|
+
// key.
|
|
435
|
+
checkAgainstParams(key, usage, original.params);
|
|
436
|
+
formatting[key] = { source: replacement, node, usage, params: original.params, args: phantom };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return build(locale, base.messages, formatting, untranslated, options ?? {});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** How a locale's translations arrive, usually `() => import("./ja.js")`. */
|
|
443
|
+
export type LocaleLoader<TMessages extends MessageMap> = () => Promise<
|
|
444
|
+
Partial<Translations<TMessages>>,
|
|
445
|
+
>;
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Every locale an application has, with only the source one loaded.
|
|
449
|
+
*
|
|
450
|
+
* `load` is asynchronous and `available` is not, which is the split a server
|
|
451
|
+
* needs: negotiation happens against the list, before anything is fetched, so
|
|
452
|
+
* a request that resolves to a locale the page already has costs no round
|
|
453
|
+
* trip.
|
|
454
|
+
*/
|
|
455
|
+
export type Locales<TMessages extends MessageMap> = {
|
|
456
|
+
readonly source: Catalogue<TMessages>,
|
|
457
|
+
readonly available: $ReadOnlyArray<string>,
|
|
458
|
+
readonly load: (locale: string) => Promise<Catalogue<TMessages>>,
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Bind a set of lazily loaded translations to a source catalogue.
|
|
463
|
+
*
|
|
464
|
+
* The loaders are thunks rather than modules so that a bundler splits them:
|
|
465
|
+
* `() => import("./ja.js")` is a chunk a page fetches only if negotiation
|
|
466
|
+
* lands on Japanese, which is what "a page ships one locale" means in
|
|
467
|
+
* practice. Passing the modules directly would put every locale in the entry
|
|
468
|
+
* bundle and make this a table with extra steps.
|
|
469
|
+
*
|
|
470
|
+
* A locale is loaded at most once. The promise is cached rather than the
|
|
471
|
+
* catalogue, so two concurrent requests for the same locale share one fetch
|
|
472
|
+
* instead of racing to build two catalogues over one download.
|
|
473
|
+
*/
|
|
474
|
+
export function defineLocales<TMessages extends MessageMap>(
|
|
475
|
+
source: Catalogue<TMessages>,
|
|
476
|
+
loaders: { readonly [string]: LocaleLoader<TMessages> },
|
|
477
|
+
options?: CatalogueOptions,
|
|
478
|
+
): Locales<TMessages> {
|
|
479
|
+
const pending: Map<string, Promise<Catalogue<TMessages>>> = new Map();
|
|
480
|
+
const available = [source.locale, ...Object.keys(loaders).filter((tag) => tag !== source.locale)];
|
|
481
|
+
|
|
482
|
+
const load = (locale: string): Promise<Catalogue<TMessages>> => {
|
|
483
|
+
if (locale === source.locale) return Promise.resolve(source);
|
|
484
|
+
const started = pending.get(locale);
|
|
485
|
+
if (started != null) return started;
|
|
486
|
+
const loader = loaders[locale];
|
|
487
|
+
if (loader == null) {
|
|
488
|
+
return Promise.reject(
|
|
489
|
+
new MessageContractError(
|
|
490
|
+
locale,
|
|
491
|
+
`is not a locale this application has; it has ${available.join(", ")}`,
|
|
492
|
+
),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const promise = loader().then((strings) => translate(source, locale, strings, options));
|
|
496
|
+
pending.set(locale, promise);
|
|
497
|
+
return promise;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
return { source, available, load };
|
|
501
|
+
}
|