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