@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.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/create-i18n.d.ts +116 -0
- package/dist/create-i18n.js +132 -0
- package/dist/define-translation.d.ts +71 -0
- package/dist/define-translation.js +8 -0
- package/dist/dictionary.d.ts +203 -0
- package/dist/dictionary.js +8 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/negotiate-locale.d.ts +25 -0
- package/dist/negotiate-locale.js +46 -0
- package/dist/translator.d.ts +36 -0
- package/dist/translator.js +265 -0
- package/documentation.md +501 -0
- package/package.json +36 -0
|
@@ -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
|
+
};
|