@adrienlcp/i18n 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The locale to open on, out of what the caller asked for. Callers hand out
3
+ * BCP-47 tags carrying a region the app may have no dictionary for, so each tag
4
+ * is tried whole, then with one subtag dropped at a time — and at every one of
5
+ * those steps, a supported locale that *extends* the candidate will do.
6
+ *
7
+ * Both directions are needed and neither is enough alone. Walking up only, a
8
+ * plain `fr` finds nothing in an app shipping `fr-FR` and `fr-CA`, and
9
+ * answering English to someone asking for French is worse than answering the
10
+ * wrong French. Walking down only, `fr-CA` misses a plain `fr`.
11
+ *
12
+ * Order across tags matters more than presence: a device listing
13
+ * `de-DE, fr-FR, en` wants French, not the first supported language that
14
+ * happens to appear anywhere in the list — so one tag is exhausted in both
15
+ * directions before the next is looked at.
16
+ *
17
+ * It reads nothing on its own — not `navigator.languages`, not
18
+ * `Accept-Language`, not a cookie. Where the preferences come from is the
19
+ * caller's business, which is what lets the same function serve a browser
20
+ * booting and a server rendering a mail for a stored account setting.
21
+ */
22
+ export declare const negotiateLocale: <Locale extends string>(preferred: readonly string[], { fallback, supported }: {
23
+ fallback: Locale;
24
+ supported: readonly Locale[];
25
+ }) => Locale;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The locale to open on, out of what the caller asked for. Callers hand out
3
+ * BCP-47 tags carrying a region the app may have no dictionary for, so each tag
4
+ * is tried whole, then with one subtag dropped at a time — and at every one of
5
+ * those steps, a supported locale that *extends* the candidate will do.
6
+ *
7
+ * Both directions are needed and neither is enough alone. Walking up only, a
8
+ * plain `fr` finds nothing in an app shipping `fr-FR` and `fr-CA`, and
9
+ * answering English to someone asking for French is worse than answering the
10
+ * wrong French. Walking down only, `fr-CA` misses a plain `fr`.
11
+ *
12
+ * Order across tags matters more than presence: a device listing
13
+ * `de-DE, fr-FR, en` wants French, not the first supported language that
14
+ * happens to appear anywhere in the list — so one tag is exhausted in both
15
+ * directions before the next is looked at.
16
+ *
17
+ * It reads nothing on its own — not `navigator.languages`, not
18
+ * `Accept-Language`, not a cookie. Where the preferences come from is the
19
+ * caller's business, which is what lets the same function serve a browser
20
+ * booting and a server rendering a mail for a stored account setting.
21
+ */
22
+ export const negotiateLocale = (preferred, { fallback, supported }) => {
23
+ const byTag = new Map(supported.map((locale) => [locale.toLowerCase(), locale]));
24
+ for (const tag of preferred) {
25
+ for (const candidate of tagAndParentTags(tag.toLowerCase())) {
26
+ const exact = byTag.get(candidate);
27
+ if (exact !== undefined) {
28
+ return exact;
29
+ }
30
+ const firstDeclaredRegion = supported.find((locale) => locale.toLowerCase().startsWith(`${candidate}-`));
31
+ if (firstDeclaredRegion !== undefined) {
32
+ return firstDeclaredRegion;
33
+ }
34
+ }
35
+ }
36
+ return fallback;
37
+ };
38
+ const tagAndParentTags = (tag) => {
39
+ const tags = [];
40
+ let current = tag;
41
+ while (current !== '') {
42
+ tags.push(current);
43
+ current = current.replace(/-?[^-]+$/, '');
44
+ }
45
+ return tags;
46
+ };
@@ -0,0 +1,36 @@
1
+ import type { Dictionary, DictionaryFor, DotPath, LeafAt, ParameterizedKey, PlainKey, RichValuesFor, ValuesFor } from './dictionary.ts';
2
+ export type Translator<Reference> = {
3
+ <Key extends PlainKey<Reference>>(key: Key): string;
4
+ <Key extends ParameterizedKey<Reference>, Values extends ValuesFor<LeafAt<Reference, Key>>>(key: Key, values: Values): string;
5
+ /**
6
+ * The same message, cut at the spans it marks, each one handed to the
7
+ * function named after it. What comes back is the pieces in order — strings
8
+ * for the plain parts, whatever the functions returned for the marked ones —
9
+ * which a UI framework renders as a list and a string consumer joins.
10
+ *
11
+ * This is how a sentence keeps one key while still carrying a link or a bold
12
+ * word: splitting it into three keys instead would break the moment a
13
+ * language puts the words in another order.
14
+ */
15
+ rich: <Key extends DotPath<Reference> & string, Node>(key: Key, values: RichValuesFor<LeafAt<Reference, Key>, Node>) => (string | Node)[];
16
+ };
17
+ type TranslatorOptions<Reference> = {
18
+ dictionary: Dictionary & DictionaryFor<Reference>;
19
+ locale: string;
20
+ };
21
+ /**
22
+ * The reference dictionary is the contract and every locale is an
23
+ * implementation of it, so the keys and their values are typed from the
24
+ * reference even when the strings being read are another language's.
25
+ *
26
+ * One translator holds one locale's dictionary and nothing else — no registry
27
+ * of every language, no cascade from one to the next. Choosing the locale is
28
+ * `negotiateLocale`'s job, and loading only the chosen dictionary is the
29
+ * caller's, which is what lets an app `import()` it rather than bundle every
30
+ * language it ships. There is no key-level fallback to another locale because
31
+ * `DictionaryFor` makes a missing key fail to compile; a dictionary that fails
32
+ * to *load* has no keys at all, and the caller answers that by keeping the
33
+ * translator it already had.
34
+ */
35
+ export declare const createTranslator: <Reference>({ dictionary, locale }: TranslatorOptions<Reference>) => Translator<Reference>;
36
+ export {};
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The reference dictionary is the contract and every locale is an
3
+ * implementation of it, so the keys and their values are typed from the
4
+ * reference even when the strings being read are another language's.
5
+ *
6
+ * One translator holds one locale's dictionary and nothing else — no registry
7
+ * of every language, no cascade from one to the next. Choosing the locale is
8
+ * `negotiateLocale`'s job, and loading only the chosen dictionary is the
9
+ * caller's, which is what lets an app `import()` it rather than bundle every
10
+ * language it ships. There is no key-level fallback to another locale because
11
+ * `DictionaryFor` makes a missing key fail to compile; a dictionary that fails
12
+ * to *load* has no keys at all, and the caller answers that by keeping the
13
+ * translator it already had.
14
+ */
15
+ export const createTranslator = ({ dictionary, locale }) => {
16
+ const formatters = createTranslatorScopedFormatters(locale);
17
+ function translate(key, values) {
18
+ const translation = findLeaf(dictionary, key);
19
+ if (translation === undefined) {
20
+ return key;
21
+ }
22
+ if (typeof translation === 'string') {
23
+ return substitute({
24
+ formatters,
25
+ message: translation,
26
+ options: {},
27
+ values: values ?? {}
28
+ });
29
+ }
30
+ const [message, options] = translation;
31
+ return substitute({ formatters, message, options, values: values ?? {} });
32
+ }
33
+ const rich = (key, values) => {
34
+ const translation = findLeaf(dictionary, key);
35
+ if (translation === undefined) {
36
+ return [key];
37
+ }
38
+ const [message, options] = typeof translation === 'string'
39
+ ? [translation, {}]
40
+ : translation;
41
+ const spansCutBeforeSubstitution = splitSpansWithoutNesting(message);
42
+ return spansCutBeforeSubstitution.map((span) => {
43
+ const text = substitute({
44
+ formatters,
45
+ message: span.text,
46
+ options,
47
+ values
48
+ });
49
+ if (span.tag === undefined) {
50
+ return text;
51
+ }
52
+ const render = values[span.tag];
53
+ return typeof render === 'function' ? render(text) : text;
54
+ });
55
+ };
56
+ return Object.assign(translate, { rich });
57
+ };
58
+ const SPAN = /<(\w+)>([\s\S]*?)<\/\1>/g;
59
+ /**
60
+ * A span cannot hold another span: the inner one would have to be rendered
61
+ * before the outer function could be given a string, and a string is all a
62
+ * function receives. Nesting is the price of that simplicity, and no sentence
63
+ * has needed it yet.
64
+ */
65
+ const splitSpansWithoutNesting = (message) => {
66
+ const spans = [];
67
+ let cursor = 0;
68
+ for (const match of message.matchAll(SPAN)) {
69
+ const [whole, tag, children] = match;
70
+ if (tag === undefined || children === undefined) {
71
+ continue;
72
+ }
73
+ const before = message.slice(cursor, match.index);
74
+ if (before !== '') {
75
+ spans.push({ tag: undefined, text: before });
76
+ }
77
+ spans.push({ tag, text: children });
78
+ cursor = match.index + whole.length;
79
+ }
80
+ const tail = message.slice(cursor);
81
+ if (tail !== '' || spans.length === 0) {
82
+ spans.push({ tag: undefined, text: tail });
83
+ }
84
+ return spans;
85
+ };
86
+ const isLeaf = (branch) => typeof branch === 'string' || Array.isArray(branch);
87
+ const findLeaf = (dictionary, key) => {
88
+ let branch = dictionary;
89
+ for (const segment of key.split('.')) {
90
+ if (branch === undefined || isLeaf(branch)) {
91
+ return undefined;
92
+ }
93
+ branch = branch[segment];
94
+ }
95
+ return branch !== undefined && isLeaf(branch) ? branch : undefined;
96
+ };
97
+ const PLACEHOLDER = /\{(\w+)(?::(\w+))?\}/g;
98
+ const FORMATTED_COUNT = '{?}';
99
+ /**
100
+ * One pass over the message, so a value that itself reads `{like this}` is
101
+ * written out and never looked at again. Substituting argument by argument
102
+ * would feed each result back to the next argument's turn.
103
+ *
104
+ * The one thing read twice is the alternative a placeholder resolved to — a
105
+ * plural form, an enum member — which `expand` runs through a pass of its own.
106
+ * That is dictionary text rather than a caller's value, so the rule above is
107
+ * untouched.
108
+ *
109
+ * A value of the wrong type, or one the message never asked for, leaves its
110
+ * placeholder standing rather than throwing: one bad value costs one word, not
111
+ * the whole sentence.
112
+ */
113
+ const substitute = ({ expanding = [], formatters, message, options, values }) => message.replace(PLACEHOLDER, (placeholder, name, type) => {
114
+ const value = values[name];
115
+ if (value === undefined) {
116
+ return placeholder;
117
+ }
118
+ switch (type) {
119
+ case 'date':
120
+ return value instanceof Date
121
+ ? formatters.date(options.date?.[name]).format(value)
122
+ : placeholder;
123
+ case 'displayname':
124
+ return typeof value === 'string'
125
+ ? (displayName({
126
+ formatters,
127
+ of: value,
128
+ options: options.displayname?.[name]
129
+ }) ?? placeholder)
130
+ : placeholder;
131
+ case 'enum': {
132
+ const member = typeof value === 'string' ? options.enum?.[name]?.[value] : undefined;
133
+ return member === undefined
134
+ ? placeholder
135
+ : expand({
136
+ expanding,
137
+ formatters,
138
+ message: member,
139
+ name,
140
+ options,
141
+ placeholder,
142
+ values
143
+ });
144
+ }
145
+ case 'list':
146
+ return Array.isArray(value)
147
+ ? formatters.list(options.list?.[name]).format(value)
148
+ : placeholder;
149
+ case 'number':
150
+ return typeof value === 'number'
151
+ ? formatters.number(options.number?.[name]).format(value)
152
+ : placeholder;
153
+ case 'plural':
154
+ return typeof value === 'number'
155
+ ? expand({
156
+ expanding,
157
+ formatters,
158
+ message: pluralize({
159
+ count: value,
160
+ formatters,
161
+ forms: options.plural?.[name]
162
+ }),
163
+ name,
164
+ options,
165
+ placeholder,
166
+ values
167
+ })
168
+ : placeholder;
169
+ case 'relative':
170
+ return typeof value === 'number'
171
+ ? (relativeTime({
172
+ count: value,
173
+ formatters,
174
+ unit: options.relative?.[name]
175
+ }) ?? placeholder)
176
+ : placeholder;
177
+ default:
178
+ return String(value);
179
+ }
180
+ });
181
+ /**
182
+ * A plural form and an enum member are dictionary text, so a placeholder one
183
+ * carries is substituted like the rest of the sentence — `{?} messages from
184
+ * {sender}` prints the sender rather than the word. `rich` already substitutes
185
+ * inside the spans it cuts, and the two paths would otherwise disagree.
186
+ *
187
+ * Only the alternative the dictionary chose is read again, never a caller's
188
+ * value: feeding a value back through is what prints a number of seconds inside
189
+ * a player who named themselves `{seconds}`.
190
+ *
191
+ * `expanding` is the floor the recursion has none of otherwise. A form naming
192
+ * the placeholder it was selected for — `other: '{count:plural} left'` under
193
+ * `count` — would expand forever, so a name already being expanded leaves its
194
+ * placeholder standing, which is what a value of the wrong type does too.
195
+ */
196
+ const expand = ({ expanding, formatters, message, name, options, placeholder, values }) => expanding.includes(name)
197
+ ? placeholder
198
+ : substitute({
199
+ expanding: [...expanding, name],
200
+ formatters,
201
+ message,
202
+ options,
203
+ values
204
+ });
205
+ /**
206
+ * Neither English nor French has a CLDR `zero` category, so a `zero` form would
207
+ * never be selected on its own — yet "nobody has answered" at 0 is the sentence
208
+ * a screen actually wants. Declaring one opts into it.
209
+ */
210
+ const pluralize = ({ count, formatters, forms }) => {
211
+ if (forms === undefined) {
212
+ return String(count);
213
+ }
214
+ const optsIntoZeroForm = count === 0 && forms.zero !== undefined;
215
+ const category = optsIntoZeroForm
216
+ ? 'zero'
217
+ : formatters.plural({ type: forms.type }).select(count);
218
+ return (forms[category] ?? forms.other).replaceAll(FORMATTED_COUNT, formatters.number(forms.formatter).format(count));
219
+ };
220
+ /**
221
+ * A message declaring `{x:displayname}` must declare the kind of name it wants,
222
+ * so the options are never absent — but the runtime shape stays loose, and
223
+ * `Intl.DisplayNames` throws without a `type`. Nothing rather than a crash.
224
+ */
225
+ const displayName = ({ formatters, of, options }) => options === undefined ? undefined : formatters.displayname(options).of(of);
226
+ /** Same reasoning: the unit is declared with the message, or there is none. */
227
+ const relativeTime = ({ count, formatters, unit }) => unit === undefined
228
+ ? undefined
229
+ : formatters.relative(unit).format(count, unit.unit);
230
+ /**
231
+ * Building an `Intl` formatter costs far more than using one — it resolves the
232
+ * locale data — and a message asks for the same one on every render. Held per
233
+ * translator rather than in a module-level map, so the memory a locale takes
234
+ * goes away with the translator that used it, and a test starts from nothing.
235
+ *
236
+ * Keyed on the options as written: two equivalent option objects whose keys are
237
+ * in a different order get an entry each. They come from dictionary literals,
238
+ * so there are as many entries as the dictionary has distinct formats.
239
+ */
240
+ const createTranslatorScopedFormatters = (locale) => {
241
+ const dates = new Map();
242
+ const displayNames = new Map();
243
+ const lists = new Map();
244
+ const numbers = new Map();
245
+ const plurals = new Map();
246
+ const relatives = new Map();
247
+ return {
248
+ date: (options) => remembered(dates, options, () => new Intl.DateTimeFormat(locale, options)),
249
+ displayname: (options) => remembered(displayNames, options, () => new Intl.DisplayNames(locale, options)),
250
+ list: (options) => remembered(lists, options, () => new Intl.ListFormat(locale, options)),
251
+ number: (options) => remembered(numbers, options, () => new Intl.NumberFormat(locale, options)),
252
+ plural: (options) => remembered(plurals, options, () => new Intl.PluralRules(locale, options)),
253
+ relative: (options) => remembered(relatives, options, () => new Intl.RelativeTimeFormat(locale, options))
254
+ };
255
+ };
256
+ const remembered = (cache, options, build) => {
257
+ const key = JSON.stringify(options ?? null);
258
+ const built = cache.get(key);
259
+ if (built !== undefined) {
260
+ return built;
261
+ }
262
+ const created = build();
263
+ cache.set(key, created);
264
+ return created;
265
+ };