@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 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
+ }