@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/index.js ADDED
@@ -0,0 +1,271 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/i18n`: a message's arguments have a type.
4
+ //
5
+ // ```js
6
+ // import { defineCatalogue, message, number, string } from "@uniflowed/i18n";
7
+ //
8
+ // const messages = {
9
+ // greeting: message("Hello, {$name}!", { name: string }),
10
+ // unread: message(
11
+ // `.input {$count :number}
12
+ // .match $count
13
+ // one {{You have {$count} unread message.}}
14
+ // * {{You have {$count} unread messages.}}`,
15
+ // { count: number },
16
+ // ),
17
+ // };
18
+ //
19
+ // const en = defineCatalogue("en-US", messages);
20
+ //
21
+ // en.t("greeting", { name: "Ada" }); // "Hello, Ada!"
22
+ // en.t("unread", { count: 1 }); // "You have 1 unread message."
23
+ // en.t("unread", { count: 1200 }); // "You have 1,200 unread messages."
24
+ //
25
+ // en.t("unread", {}); // Flow: property count is missing
26
+ // en.t("unread", { count: "12" }); // Flow: string is incompatible with number
27
+ // en.t("unreadd", { count: 1 }); // Flow: not a key of the catalogue
28
+ // ```
29
+ //
30
+ // The last three lines are the package. Every i18n library types the key;
31
+ // almost none types the arguments, and the one that does not is where the bugs
32
+ // are — a message that gains a `{$count}` in the source locale keeps compiling
33
+ // at every call site and renders the placeholder to a user.
34
+ //
35
+ // Ordinary Flow-typed JavaScript with no native binding and no dependencies,
36
+ // so it behaves identically on Node.js, Deno, Bun and in a browser.
37
+ //
38
+ // # Why MessageFormat 2 rather than a format of uf's own
39
+ //
40
+ // Because the hard parts of this are not syntax. Which of "1 message", "2
41
+ // messages" and "22 wiadomości" to use is CLDR's plural rules; where the
42
+ // number goes in a Japanese sentence is the translator's; whether `1200` is
43
+ // `1,200` or `1.200` is the locale's. A format invented here would have to
44
+ // answer all three eventually, and would answer them differently from every
45
+ // tool a translator already uses.
46
+ //
47
+ // MF2 is the Unicode standard for exactly this, it is final rather than
48
+ // proposed, and it is what translation tooling is moving to. Building on it
49
+ // means a message in this catalogue is a message a translation vendor can
50
+ // round-trip, and it means the pluralisation rules are CLDR's rather than
51
+ // somebody's guess.
52
+ //
53
+ // It also lands with typed placeholders, which is the part that makes it worth
54
+ // building *on* rather than around: `{$count :number}` says what `count` is,
55
+ // and that is the fact this package turns into a type error at the call.
56
+ //
57
+ // # The subset uf implements
58
+ //
59
+ // A partial implementation stated plainly, because the alternative is a
60
+ // complete one nobody can trust. Everything below is implemented and tested;
61
+ // everything under "not implemented" is refused at the point a message is
62
+ // declared, with an error naming itself, rather than accepted and quietly
63
+ // ignored.
64
+ //
65
+ // **Implemented.**
66
+ //
67
+ // - Simple messages, quoted patterns (`{{…}}`), and the four escapes
68
+ // (`\\`, `\{`, `\}`, `\|`).
69
+ // - Variable placeholders `{$name}` and literal placeholders `{42}`, `{|two
70
+ // words|}`.
71
+ // - The whole MF2 default function registry, and only it: `:string`,
72
+ // `:number`, `:integer`, `:date`, `:time` and `:datetime`, with their
73
+ // options, and with option values that may themselves be variables
74
+ // (`{$n :number minimumFractionDigits=$digits}`).
75
+ // - `.input` and `.local` declarations, including an annotation put on a name
76
+ // by a declaration and inherited by every later use of it.
77
+ // - `.match` with any number of selectors, literal and `*` variant keys, exact
78
+ // numeric keys preferred over plural categories, and MF2's variant sort — so
79
+ // a two-selector matcher resolves ties the way the specification says rather
80
+ // than the way a single scan would.
81
+ //
82
+ // **Not implemented, deliberately.**
83
+ //
84
+ // - **Markup** — `{#bold}…{/bold}`. `t` returns a string, and markup only
85
+ // means something to a caller that can turn a list of parts into React
86
+ // elements. The two ways to fake it are both worse than refusing: dropping
87
+ // the tags silently loses emphasis a translator put in, and inlining HTML
88
+ // puts unescaped translator input into a page. A `parts` API returning
89
+ // `$ReadOnlyArray<MessagePart>` is where this belongs, and it is a different
90
+ // return type rather than a bigger parser.
91
+ // - **Attributes** — `{$x @unit}`. The specification says they do not affect
92
+ // formatting, so accepting and ignoring them would be conforming. They are
93
+ // refused because the only thing an attribute is for is a tool that reads
94
+ // it, uf has no such tool, and a message carrying one would mean its author
95
+ // believes something untrue.
96
+ // - **The draft function registry** — `:currency`, `:unit`, `:math`. The line
97
+ // is drawn at "the whole required registry, none of the draft one" because
98
+ // it is a line a reader can hold in their head: a function uf accepts is one
99
+ // every conforming implementation must also accept. `:currency` is the one
100
+ // that will be missed, and it needs a currency code in the message, which is
101
+ // a decision about where currency codes live rather than a parser change.
102
+ // - **Reserved and private-use annotations** — `{$x !foo}`, `{$x ^bar}`.
103
+ // Refusing them is what keeps a message that parses here from meaning
104
+ // something else under a conforming implementation later.
105
+ // - **Bidi isolation is off by default**, which the specification allows
106
+ // (`bidiIsolation: none`) but does not default to. `format.js` says why: the
107
+ // output goes into React children where the DOM already isolates, and two
108
+ // invisible code points per placeholder would make every string assertion in
109
+ // every application a puzzle. `defineCatalogue(…, { bidiIsolation: true })`
110
+ // turns it on.
111
+ //
112
+ // # Why uf formats MF2 itself, over `Intl`
113
+ //
114
+ // `Intl.MessageFormat` is a TC39 proposal with no implementation in any
115
+ // shipping runtime, so building on it means building on nothing. Feature-
116
+ // detecting it and falling back would be worse than not using it: two code
117
+ // paths deciding what a user reads, one of which has never run, and a
118
+ // catalogue that renders differently in Safari and in Node.
119
+ //
120
+ // So the algorithm is uf's and the data is `Intl`'s. Plural categories come
121
+ // from `Intl.PluralRules`, numerals from `Intl.NumberFormat`, dates from
122
+ // `Intl.DateTimeFormat`. The selection algorithm is a few hundred lines; CLDR's
123
+ // plural rules and number formats for the locales a browser already ships are
124
+ // megabytes, and a second, staler copy of them is exactly what a bundle does
125
+ // not need. When `Intl.MessageFormat` ships, the seam is one function in
126
+ // `format.js` rather than anything an application wrote.
127
+ //
128
+ // # Where the type system stops, and why
129
+ //
130
+ // Worth being exact about, because the promise above is a strong one and the
131
+ // limit is real.
132
+ //
133
+ // Flow has **no template-literal types**. The placeholders inside the string
134
+ // `"Hello, {$name}!"` are not part of that string's type, in Flow or in any
135
+ // checker without the TypeScript machinery — so `{ name: string }` cannot be
136
+ // *derived* from the message. It has to be written beside it.
137
+ //
138
+ // That is why parameters are declared as values rather than as a type
139
+ // argument, and it is not a workaround: because they are values, `message` can
140
+ // compare them with the message at run time, where it is written. A message
141
+ // that reads `$nom` when the parameters say `name`, a parameter the message
142
+ // never reads, an annotation that does not fit the declared kind — all three
143
+ // throw at the declaration, naming the key. A type argument would have been
144
+ // less to write and would have checked none of them, because a type argument
145
+ // is erased before anything could look at it.
146
+ //
147
+ // What is left unchecked is one thing, and it is worth saying rather than
148
+ // glossing: the *pair* is what is verified, so a message and its parameters
149
+ // that agree with each other and disagree with the sentence the product wanted
150
+ // are nobody's error. `t("unread", { count: 3 })` cannot render "3 messages"
151
+ // if the message says `{$count}` — but no tool here knows whether the product
152
+ // meant "unread" or "unarchived".
153
+ //
154
+ // A `uf lint` rule reading the type and the string literal together is what
155
+ // would close the remaining seam between a declaration and its call, and uf
156
+ // already parses Flow in Rust, so it is a rule rather than a research problem.
157
+ // It is not written.
158
+ //
159
+ // # How the package is laid out
160
+ //
161
+ // Four modules beside this one, each reachable through a subpath. Nothing is
162
+ // under an `internal/`: each is a reasonable thing to import on purpose, and a
163
+ // tool that wants only the parser should not carry the catalogue.
164
+ //
165
+ // - `syntax.js` — MF2 source into a tree, and the refusals that define the
166
+ // subset. Knows nothing about locales or `Intl`. Read this first.
167
+ // - `format.js` — a tree, a locale and some arguments into a string:
168
+ // declarations, the variant selection algorithm, and the `Intl` objects it
169
+ // is all built on.
170
+ // - `catalogue.js` — the types that make a call checkable, the run-time check
171
+ // that the message agrees with them, translations over the same keys, and
172
+ // lazily loaded locales.
173
+ // - `negotiate.js` — `Accept-Language` into a list of tags, and RFC 4647
174
+ // Lookup against the locales an application actually has.
175
+ //
176
+ // # A page ships one locale
177
+ //
178
+ // ```js
179
+ // const locales = defineLocales(en, {
180
+ // ja: () => import("./ja.js").then((module) => module.default),
181
+ // fr: () => import("./fr.js").then((module) => module.default),
182
+ // });
183
+ //
184
+ // const wanted = negotiate(
185
+ // parseAcceptLanguage(request.headers.get("accept-language") ?? ""),
186
+ // locales.available,
187
+ // "en-US",
188
+ // );
189
+ // const t = (await locales.load(wanted)).t;
190
+ // ```
191
+ //
192
+ // The loaders are thunks so a bundler splits them: only the locale negotiation
193
+ // chose is fetched. A translation file is plain strings over the same keys —
194
+ // it does not redeclare the parameters, because those belong to the message
195
+ // rather than to the language — and `translate` holds each one against the
196
+ // source message's parameters at start-up, so a translator who drops a
197
+ // `{$count}` fails the build rather than the page.
198
+ //
199
+ // # Readiness
200
+ //
201
+ // **Implemented and tested.** Everything under "Implemented" above, the
202
+ // definition-time contract checks, translations with partial coverage and an
203
+ // `untranslated` list, lazily loaded locales with one load per locale, and
204
+ // negotiation over `Accept-Language` including quality values and `*`.
205
+ // `tests/library/i18n.test.js` covers each.
206
+ //
207
+ // **Not implemented, and a gap.** The catalogue is not extracted at build
208
+ // time. `uf build` does not walk a project for `message(…)` calls, so there is
209
+ // no `messages.json` for a translation vendor to import and no build-time
210
+ // report of a key that no locale translates. The half that belongs in this
211
+ // package — a parse complete enough to generate from, and `messageUsage` to
212
+ // read it — is here; the half that walks a repository's sources is Rust's, for
213
+ // the same reason the formatter and the checker are.
214
+ //
215
+ // **Not implemented, and declined.** A React hook and a provider. A catalogue
216
+ // is a value, `t` is a function on it, and a `useTranslation()` that read one
217
+ // out of context would put a re-render between a component and a string that
218
+ // does not change. An application that wants the locale in context already has
219
+ // `React.createContext`, and it should hold the catalogue rather than a hook
220
+ // this package invented.
221
+
222
+ export type {
223
+ MessageAnnotation,
224
+ MessageBody,
225
+ MessageDeclaration,
226
+ MessageExpression,
227
+ MessageLiteral,
228
+ MessageNode,
229
+ MessageOperand,
230
+ MessageOption,
231
+ MessagePart,
232
+ MessagePattern,
233
+ MessageText,
234
+ MessageUsage,
235
+ MessageVariable,
236
+ MessageVariant,
237
+ MessageVariantKey,
238
+ } from "./syntax.js";
239
+ export { MessageSyntaxError, messageUsage, parseMessage } from "./syntax.js";
240
+
241
+ export type { FormatContext } from "./format.js";
242
+ export { MessageFormatError, formatMessage } from "./format.js";
243
+
244
+ export type {
245
+ ArgsOf,
246
+ Catalogue,
247
+ CatalogueOptions,
248
+ LocaleLoader,
249
+ Locales,
250
+ Message,
251
+ MessageMap,
252
+ Param,
253
+ ParamArgs,
254
+ ParamKind,
255
+ ParamMap,
256
+ ParamValue,
257
+ Translations,
258
+ } from "./catalogue.js";
259
+ export {
260
+ MessageContractError,
261
+ boolean,
262
+ date,
263
+ defineCatalogue,
264
+ defineLocales,
265
+ message,
266
+ number,
267
+ string,
268
+ translate,
269
+ } from "./catalogue.js";
270
+
271
+ export { negotiate, parseAcceptLanguage } from "./negotiate.js";
package/negotiate.js ADDED
@@ -0,0 +1,162 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/i18n/negotiate`: which of the locales we have does this reader
4
+ // want.
5
+ //
6
+ // Two functions and no state. Nothing here loads anything, and that is the
7
+ // point of it being separate: a server decides the locale before it decides
8
+ // what to render, from a header and a list of tags, and both of those are
9
+ // known long before a catalogue exists. `defineLocales` in `catalogue.js` is
10
+ // what turns the answer into messages.
11
+ //
12
+ // # Lookup, not filtering
13
+ //
14
+ // RFC 4647 defines two matching schemes and they answer different questions.
15
+ // *Filtering* returns every tag that matches, which is what a search does.
16
+ // *Lookup* returns the single best one, which is what a page does, because a
17
+ // page renders in one language. So this is Lookup: the requested tag is
18
+ // truncated one subtag at a time — `en-Latn-GB-oed`, `en-Latn-GB`, `en-Latn`,
19
+ // `en` — and the first truncation that names something we have wins.
20
+ //
21
+ // The single-character subtag is skipped rather than tried, because RFC 4647
22
+ // says so and the reason is worth knowing: `zh-x-private` truncated to `zh-x`
23
+ // is not a language tag at all, it is the start of a private-use sequence with
24
+ // nothing after it.
25
+ //
26
+ // # The one deliberate departure
27
+ //
28
+ // Lookup only ever shortens the *request*, so a reader asking for `en` does
29
+ // not match a catalogue that has `en-US`. That is correct for the internet RFC
30
+ // 4647 was written for, where a server could generate a document for any tag
31
+ // it was asked about. It is wrong for an application with a fixed set of
32
+ // translation files, where the alternative to American English for a reader
33
+ // who asked for English is not British English — it is the default locale,
34
+ // which may be Japanese.
35
+ //
36
+ // So there is a final pass, after every exact and truncated match has failed
37
+ // for every requested tag: match on the primary language subtag alone, taking
38
+ // the first available tag that shares it. It runs last, so it can never take
39
+ // priority over a real match, and it is the difference between "we have your
40
+ // language" and "we have your language and refused to use it".
41
+ //
42
+ // # Why `Intl.LocaleMatcher` is not used
43
+ //
44
+ // Because it does not exist. `Intl.supportedValuesOf` and
45
+ // `Intl.getCanonicalLocales` are not a matcher, and the locale negotiation
46
+ // inside `Intl.NumberFormat` is not reachable from outside it — passing a
47
+ // list and reading `resolvedOptions().locale` back answers which locale *ICU*
48
+ // has data for, which is nearly every locale, and says nothing about which
49
+ // ones this application has translations for.
50
+
51
+ /** One entry of an `Accept-Language` header. */
52
+ type Weighted = { readonly tag: string, readonly quality: number, readonly at: number };
53
+
54
+ /**
55
+ * `Accept-Language` as a list of tags, best first.
56
+ *
57
+ * Entries with `q=0` are dropped: RFC 9110 gives that the specific meaning
58
+ * "not acceptable", so treating it as merely last would pick a language the
59
+ * reader explicitly refused.
60
+ *
61
+ * `*` is kept, as the tag `*`. It means "anything", and the only sensible
62
+ * answer to it is the fallback, which is what [`negotiate`] already returns
63
+ * when nothing matches — so it needs no special case there, only here, where
64
+ * dropping it would be wrong for a header that is nothing but `*`.
65
+ */
66
+ export function parseAcceptLanguage(header: string): $ReadOnlyArray<string> {
67
+ const entries: Array<Weighted> = [];
68
+
69
+ header.split(",").forEach((part, index) => {
70
+ const pieces = part.split(";");
71
+ const tag = pieces[0].trim();
72
+ if (tag === "") return;
73
+
74
+ let quality = 1;
75
+ for (const parameter of pieces.slice(1)) {
76
+ const [name, value] = parameter.split("=");
77
+ if (name.trim().toLowerCase() !== "q") continue;
78
+ const parsed = Number(value);
79
+ quality = Number.isFinite(parsed) ? parsed : 1;
80
+ }
81
+ if (quality <= 0) return;
82
+ entries.push({ tag, quality, at: index });
83
+ });
84
+
85
+ // Sorted by quality, and by position within one quality. A header listing
86
+ // `en, fr` without weights means the reader prefers English, and a sort that
87
+ // only looked at the number would be free to return them the other way
88
+ // round.
89
+ entries.sort((left, right) =>
90
+ left.quality === right.quality ? left.at - right.at : right.quality - left.quality,
91
+ );
92
+ return entries.map((entry) => entry.tag);
93
+ }
94
+
95
+ /** Language tags are case-insensitive, and nothing else about them matters here. */
96
+ function fold(tag: string): string {
97
+ return tag.toLowerCase();
98
+ }
99
+
100
+ /** The truncations of a tag, longest first, per RFC 4647's Lookup. */
101
+ function truncations(tag: string): $ReadOnlyArray<string> {
102
+ const out: Array<string> = [];
103
+ let current = fold(tag);
104
+ while (current !== "") {
105
+ out.push(current);
106
+ const cut = current.lastIndexOf("-");
107
+ if (cut < 0) break;
108
+ current = current.slice(0, cut);
109
+ // `en-x` and `zh-a` are not tags: a single-character subtag introduces an
110
+ // extension or a private-use sequence, so truncating to it leaves a prefix
111
+ // with nothing to match.
112
+ const last = current.lastIndexOf("-");
113
+ if (current.length - last === 2) {
114
+ current = current.slice(0, last);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function primary(tag: string): string {
121
+ const cut = fold(tag).indexOf("-");
122
+ return cut < 0 ? fold(tag) : fold(tag).slice(0, cut);
123
+ }
124
+
125
+ /**
126
+ * The best of `available` for a reader who asked for `requested`.
127
+ *
128
+ * `requested` is in preference order — what [`parseAcceptLanguage`] returns, or
129
+ * a single tag from a cookie or a URL segment. `available` is what the
130
+ * application has, and `fallback` is what it does when it has none of them.
131
+ *
132
+ * The returned tag is one of `available` verbatim, case and all, rather than
133
+ * the folded form matching used — a caller is going to hand it to
134
+ * `defineLocales`, which keys on the string the application wrote.
135
+ */
136
+ export function negotiate(
137
+ requested: $ReadOnlyArray<string> | string,
138
+ available: $ReadOnlyArray<string>,
139
+ fallback: string,
140
+ ): string {
141
+ const wanted = typeof requested === "string" ? [requested] : requested;
142
+ const folded = available.map(fold);
143
+
144
+ for (const tag of wanted) {
145
+ if (tag === "*") break;
146
+ for (const candidate of truncations(tag)) {
147
+ const found = folded.indexOf(candidate);
148
+ if (found >= 0) return available[found];
149
+ }
150
+ }
151
+
152
+ // The departure from RFC 4647, and last so that it cannot outrank a real
153
+ // match. See the module header.
154
+ for (const tag of wanted) {
155
+ if (tag === "*") break;
156
+ const language = primary(tag);
157
+ const found = folded.findIndex((candidate) => primary(candidate) === language);
158
+ if (found >= 0) return available[found];
159
+ }
160
+
161
+ return fallback;
162
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@uniflowed/i18n",
3
+ "version": "0.0.0-alpha.18",
4
+ "description": "Type-safe internationalisation on MessageFormat 2: a message's arguments are checked at the call, part of the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/i18n"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./catalogue": "./catalogue.js",
16
+ "./format": "./format.js",
17
+ "./negotiate": "./negotiate.js",
18
+ "./syntax": "./syntax.js"
19
+ },
20
+ "files": [
21
+ "catalogue.js",
22
+ "format.js",
23
+ "index.js",
24
+ "negotiate.js",
25
+ "syntax.js",
26
+ "!*.test.js"
27
+ ]
28
+ }