@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
package/documentation.md
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# Typed i18n
|
|
2
|
+
|
|
3
|
+
A translation and formatting library in five files, with no dependency on any
|
|
4
|
+
project and none on a framework.
|
|
5
|
+
|
|
6
|
+
| File | What it holds |
|
|
7
|
+
| --- | --- |
|
|
8
|
+
| `define-translation.ts` | `defineTranslation`, and the options a placeholder demands |
|
|
9
|
+
| `dictionary.ts` | `defineDictionary`, what one locale owes another, and every type read off a message |
|
|
10
|
+
| `translator.ts` | `createTranslator` — the lookup and the substitution |
|
|
11
|
+
| `negotiate-locale.ts` | `negotiateLocale` — which locale a preference list asks for |
|
|
12
|
+
| `create-i18n.ts` | `createI18n` — the registry binding each locale to its dictionary |
|
|
13
|
+
|
|
14
|
+
## What it does, and what it does not
|
|
15
|
+
|
|
16
|
+
It looks a message up by a dotted key and substitutes values into it, formatting
|
|
17
|
+
numbers, dates, lists and plurals through the platform's `Intl`. Everything it
|
|
18
|
+
knows about a message — which values it takes, of which type, whether it takes
|
|
19
|
+
any at all — it knows at compile time.
|
|
20
|
+
|
|
21
|
+
It reads nothing on its own: no `Accept-Language`, no `navigator.languages`, no
|
|
22
|
+
cookie, no query string. It persists nothing, stores nothing, and fetches
|
|
23
|
+
nothing. Where the preferences come from and where the dictionaries live is the
|
|
24
|
+
caller's business — which is what lets the same code serve a browser booting, a
|
|
25
|
+
server rendering a mail, and a test.
|
|
26
|
+
|
|
27
|
+
### One door
|
|
28
|
+
|
|
29
|
+
**`createI18n` is what a project calls.** It takes a table of locales and hands
|
|
30
|
+
back an object where a locale is all anyone passes afterwards. A locale's entry
|
|
31
|
+
is either its dictionary or a function fetching it, and the guarantees are the
|
|
32
|
+
same either way — which is why how a language ships is not a second decision to
|
|
33
|
+
make.
|
|
34
|
+
|
|
35
|
+
`createTranslator` is underneath, and takes a locale *and* a dictionary. Nothing
|
|
36
|
+
there can check that the two go together: a translator built with the French
|
|
37
|
+
dictionary and the tag `'en'` reads French and counts in English. It is for a
|
|
38
|
+
dictionary that comes from somewhere TypeScript cannot see — a server, a CMS —
|
|
39
|
+
and a normal project never calls it.
|
|
40
|
+
|
|
41
|
+
## Getting started
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { createI18n } from './create-i18n'
|
|
45
|
+
import { defineDictionary } from './dictionary'
|
|
46
|
+
|
|
47
|
+
const EN = defineDictionary({
|
|
48
|
+
greeting: 'Hello {name}',
|
|
49
|
+
room: { empty: 'Nobody here yet' },
|
|
50
|
+
title: 'Dashboard'
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const FR = defineDictionary({
|
|
54
|
+
greeting: 'Bonjour {name}',
|
|
55
|
+
room: { empty: 'Personne pour l’instant' },
|
|
56
|
+
title: 'Tableau de bord'
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
export const i18n = createI18n({
|
|
60
|
+
defaultLocale: 'en',
|
|
61
|
+
dictionaries: { en: EN, fr: FR }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const translate = i18n.translator('fr')
|
|
65
|
+
|
|
66
|
+
translate('greeting', { name: 'Ada' }) // 'Bonjour Ada'
|
|
67
|
+
translate('room.empty') // 'Personne pour l’instant'
|
|
68
|
+
translate('title') // 'Tableau de bord'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`defaultLocale`'s dictionary is the **reference**: the contract every other
|
|
72
|
+
locale implements, and the one every key and value is typed from — so the keys
|
|
73
|
+
above are English even when the strings read are French.
|
|
74
|
+
|
|
75
|
+
The registry exposes six things:
|
|
76
|
+
|
|
77
|
+
| | |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `i18n.translator(locale)` | the translator for that locale, synchronously |
|
|
80
|
+
| `i18n.load(locale)` | fetches a dictionary registered as a loader, and resolves with the translator that reads it |
|
|
81
|
+
| `i18n.negotiate(preferred)` | which registered locale a list of BCP-47 tags asks for |
|
|
82
|
+
| `i18n.compare(locale, options?)` | a comparator for `Array.sort`, so `Émile` lands between `Adrien` and `Zoé` rather than after both |
|
|
83
|
+
| `i18n.locales` | every registered locale, loaded or not |
|
|
84
|
+
| `i18n.defaultLocale` | the one `negotiate` falls back to |
|
|
85
|
+
|
|
86
|
+
A translator is built once per locale and kept, so `translator('fr')` returns
|
|
87
|
+
the *same* function every time. That is not a speed optimisation: a consumer
|
|
88
|
+
memoising on the translator has to see a new identity when the locale changes
|
|
89
|
+
and the same one when it does not, or every `useMemo` downstream either
|
|
90
|
+
recomputes on every render or keeps serving the old language.
|
|
91
|
+
|
|
92
|
+
### Choosing which locale to open on
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
i18n.negotiate(navigator.languages)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Each tag is tried whole, then with one subtag dropped at a time, and at every
|
|
99
|
+
one of those steps a registered locale that *extends* the candidate will do. So
|
|
100
|
+
`fr-CA` reads a registered `fr`, a registered `pt-BR` still beats a bare `pt`,
|
|
101
|
+
and — the case a lookup that only walks up gets wrong — a bare `fr` reads
|
|
102
|
+
`fr-FR` in an app that ships `fr-FR` and `fr-CA` and no plain `fr`. Answering
|
|
103
|
+
English there would be worse than answering the wrong French. Between two
|
|
104
|
+
regions of one language, declaration order decides.
|
|
105
|
+
|
|
106
|
+
Order across tags wins over presence: a device listing `de-DE, fr-FR, en` gets
|
|
107
|
+
French, so one tag is exhausted in both directions before the next is looked at.
|
|
108
|
+
`negotiateLocale` is the same rule as a free function, for a caller with no
|
|
109
|
+
registry — a server reading `Accept-Language`, for instance.
|
|
110
|
+
|
|
111
|
+
## Message syntax
|
|
112
|
+
|
|
113
|
+
A placeholder is `{name}`, optionally typed as `{name:type}`. Five types exist.
|
|
114
|
+
A typed placeholder whose formatter needs options, or whose alternatives the
|
|
115
|
+
dictionary must supply, is written with `defineTranslation(message, options)`
|
|
116
|
+
instead of a bare string.
|
|
117
|
+
|
|
118
|
+
| Placeholder | Value | Dictionary options | Behind it |
|
|
119
|
+
| --- | --- | --- | --- |
|
|
120
|
+
| `{x}` | `string` | none | `String(value)` |
|
|
121
|
+
| `{x:number}` | `number` | `number.x`, optional | `Intl.NumberFormat` |
|
|
122
|
+
| `{x:plural}` | `number` | `plural.x`, **required** | `Intl.PluralRules`, then `Intl.NumberFormat` |
|
|
123
|
+
| `{x:date}` | `Date` | `date.x`, optional | `Intl.DateTimeFormat` |
|
|
124
|
+
| `{x:list}` | `readonly string[]` | `list.x`, optional | `Intl.ListFormat` |
|
|
125
|
+
| `{x:relative}` | `number` | `relative.x`, **required** | `Intl.RelativeTimeFormat` |
|
|
126
|
+
| `{x:displayname}` | `string` | `displayname.x`, **required** | `Intl.DisplayNames` |
|
|
127
|
+
| `{x:enum}` | a key of `enum.x` | `enum.x`, **required** | none |
|
|
128
|
+
|
|
129
|
+
`:number`, `:date` and `:list` only configure a formatter, so a bare string
|
|
130
|
+
declaring one is already complete — the options are how you override the
|
|
131
|
+
formatter's defaults. The other three demand theirs: `:plural` and `:enum` carry
|
|
132
|
+
translatable text, `:relative` needs the unit the count is counting and
|
|
133
|
+
`:displayname` the kind of name to look up. A message declaring one of those
|
|
134
|
+
without its options does not compile.
|
|
135
|
+
|
|
136
|
+
`{x:relative}` takes the count alone and reads its sign the way `Intl` does —
|
|
137
|
+
negative is the past. With `numeric: 'auto'` the locale answers *yesterday*
|
|
138
|
+
rather than *1 day ago*.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const EN = defineDictionary({
|
|
142
|
+
fileSize: defineTranslation('{bytes:number} bytes written', {
|
|
143
|
+
number: { bytes: { maximumFractionDigits: 0 } }
|
|
144
|
+
}),
|
|
145
|
+
greeting: 'Hello {name}',
|
|
146
|
+
playedAt: defineTranslation('Played on {day:date}', {
|
|
147
|
+
date: { day: { dateStyle: 'long' } }
|
|
148
|
+
}),
|
|
149
|
+
players: '{count:number} players',
|
|
150
|
+
rounds: defineTranslation('{count:plural} played', {
|
|
151
|
+
plural: {
|
|
152
|
+
count: { one: '{?} round', other: '{?} rounds', zero: 'No round' }
|
|
153
|
+
}
|
|
154
|
+
}),
|
|
155
|
+
sharedWith: defineTranslation('Shared with {people:list}', {
|
|
156
|
+
list: { people: { type: 'conjunction' } }
|
|
157
|
+
}),
|
|
158
|
+
spokenIn: defineTranslation('Spoken in {language:displayname}', {
|
|
159
|
+
displayname: { language: { type: 'language' } }
|
|
160
|
+
}),
|
|
161
|
+
status: defineTranslation('Status: {value:enum}', {
|
|
162
|
+
enum: { value: { draft: 'Draft', published: 'Published' } }
|
|
163
|
+
}),
|
|
164
|
+
updated: defineTranslation('Updated {when:relative}', {
|
|
165
|
+
relative: { when: { numeric: 'auto', unit: 'day' } }
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
translate('greeting', { name: 'Ada' }) // 'Hello Ada'
|
|
170
|
+
translate('players', { count: 3 }) // '3 players'
|
|
171
|
+
translate('fileSize', { bytes: 2048 }) // '2,048 bytes written'
|
|
172
|
+
translate('rounds', { count: 0 }) // 'No round played'
|
|
173
|
+
translate('playedAt', { day: new Date() }) // 'Played on September 3, 2026'
|
|
174
|
+
translate('sharedWith', { people: ['Ada', 'Grace', 'Alan'] }) // 'Shared with Ada, Grace, and Alan'
|
|
175
|
+
translate('status', { value: 'draft' }) // 'Status: Draft'
|
|
176
|
+
translate('updated', { when: -1 }) // 'Updated yesterday'
|
|
177
|
+
translate('spokenIn', { language: 'fr' }) // 'Spoken in French'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Three things about `:plural` that nothing in the syntax hints at:
|
|
181
|
+
|
|
182
|
+
- **The count is written `{?}` inside a branch**, not `#` and not `{count}`.
|
|
183
|
+
`{?}` is replaced by the count run through `Intl.NumberFormat`, configurable
|
|
184
|
+
per message with `plural.count.formatter`. A branch may leave it out.
|
|
185
|
+
- **`zero` is not a CLDR category here.** `Intl.PluralRules` selects `other` for
|
|
186
|
+
0 in English and French, so a `zero` branch would never fire on its own; the
|
|
187
|
+
library checks for a count of exactly 0 first and uses `zero` when the
|
|
188
|
+
dictionary declares one. `other` is the only branch a plural map must have — a
|
|
189
|
+
category the rules select but the map omits falls back to it.
|
|
190
|
+
`plural.count.type` chooses between cardinal and ordinal rules.
|
|
191
|
+
- **A branch may carry placeholders of its own**, and so may an enum member:
|
|
192
|
+
`other: '{?} messages from {sender}'` prints the sender, and the caller is
|
|
193
|
+
asked for one exactly as the sentence would ask. A branch is read once, like
|
|
194
|
+
the sentence — a value substituted into it is never read back as a
|
|
195
|
+
placeholder, and a branch naming the placeholder it was selected for leaves
|
|
196
|
+
that placeholder standing rather than expanding forever.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
const EN = defineDictionary({
|
|
200
|
+
inbox: defineTranslation('{count:plural}', {
|
|
201
|
+
plural: {
|
|
202
|
+
count: {
|
|
203
|
+
one: '{?} message from {sender}',
|
|
204
|
+
other: '{?} messages from {sender}',
|
|
205
|
+
zero: 'Nothing from {sender}'
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
translate('inbox', { count: 4, sender: 'Ada' }) // '4 messages from Ada'
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Type guarantees
|
|
215
|
+
|
|
216
|
+
This is what the library is for; the runtime is the small half.
|
|
217
|
+
|
|
218
|
+
**A key that does not exist does not compile.**
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
// Does not compile: Argument of type '"titel"' is not assignable to
|
|
222
|
+
// parameter of type '"greeting" | "room.empty" | "title"'.
|
|
223
|
+
translate('titel')
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**A branch is not a key.** Only the message at the end of a path is; `room` on
|
|
227
|
+
its own is not a key, `room.empty` is.
|
|
228
|
+
|
|
229
|
+
**Values are required or forbidden per message**, through two call signatures
|
|
230
|
+
rather than one: a message with no placeholder refuses a second argument, and a
|
|
231
|
+
message with placeholders refuses to be called without them.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
// Does not compile: Expected 2 arguments, but got 1.
|
|
235
|
+
translate('greeting')
|
|
236
|
+
|
|
237
|
+
// Does not compile: Expected 1 arguments, but got 2.
|
|
238
|
+
translate('title', { name: 'Ada' })
|
|
239
|
+
|
|
240
|
+
// Does not compile: Type '"archived"' is not assignable to type
|
|
241
|
+
// '"draft" | "published"'.
|
|
242
|
+
translate('status', { value: 'archived' })
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`PlainKey<Reference>` names the first set on its own. It is the type a lookup
|
|
246
|
+
table or a piece of state should hold: a key needing values cannot be translated
|
|
247
|
+
by whoever reads it back, and a key stored as an already-rendered string freezes
|
|
248
|
+
in the language it was rendered in.
|
|
249
|
+
|
|
250
|
+
**A `:plural` or `:enum` written without its map does not compile.** Without the
|
|
251
|
+
map there is no branch to select, and the message would degrade to the bare
|
|
252
|
+
count — so `defineDictionary` makes it unwriteable instead.
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
// Does not compile: Type 'string' is not assignable to type 'never'.
|
|
256
|
+
defineDictionary({ rounds: '{count:plural} played' })
|
|
257
|
+
defineDictionary({ status: 'Status: {value:enum}' })
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**A leaf that is not a message does not compile either** — a number, a `Date`, a
|
|
261
|
+
function. The check has to name `Dictionary` explicitly to catch them, because a
|
|
262
|
+
mapped type over a primitive returns that primitive unexamined.
|
|
263
|
+
|
|
264
|
+
**A second locale that does not say the same thing does not compile.** Not only
|
|
265
|
+
the same keys, at every depth: the same placeholders inside each message, the
|
|
266
|
+
same marked spans, the same value type behind each name. Whichever way the
|
|
267
|
+
dictionary reaches the registry — written into the call, imported, or fetched by
|
|
268
|
+
a loader long after the build — it is held to the reference before it can be
|
|
269
|
+
registered. What that asks of the way a dictionary is written is the next
|
|
270
|
+
section.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
// Does not compile: Type 'string' is not assignable to type 'never'.
|
|
274
|
+
createI18n({
|
|
275
|
+
defaultLocale: 'en',
|
|
276
|
+
dictionaries: {
|
|
277
|
+
en: defineDictionary({ greeting: 'Hello {name}' }),
|
|
278
|
+
fr: defineDictionary({ greeting: 'Bonjour {nom}' })
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**A locale the registry does not hold does not compile**, and neither does a
|
|
284
|
+
`defaultLocale` absent from the dictionaries.
|
|
285
|
+
|
|
286
|
+
## Adding a locale
|
|
287
|
+
|
|
288
|
+
Write it with `defineDictionary`, like the reference one, then add it to the
|
|
289
|
+
registry. The keys `translate` accepts do not change: they are the reference's,
|
|
290
|
+
always.
|
|
291
|
+
|
|
292
|
+
The registry is what holds the two together, and it compares more than the keys:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
const EN = defineDictionary({ greeting: 'Hello {name}' })
|
|
296
|
+
const FR = defineDictionary({ greeting: 'Bonjour {nom}' })
|
|
297
|
+
|
|
298
|
+
createI18n({ defaultLocale: 'en', dictionaries: { en: EN, fr: FR } })
|
|
299
|
+
// ✗ 'string' is not assignable to 'never'
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
A missing key, an invented one at any depth, a placeholder translated along with
|
|
303
|
+
the sentence, one the locale drops or invents, a `{count}` written where the
|
|
304
|
+
reference formats `{count:number}`, a `<link>` span left unopened: each fails to
|
|
305
|
+
compile. The placeholders a branch or an enum member carries are compared along
|
|
306
|
+
with the rest, as one set across the branches: a language naming the sender in
|
|
307
|
+
its plural alone still says the same thing, one dropping it from every branch
|
|
308
|
+
does not. Plural categories may differ, since the languages do — French answers
|
|
309
|
+
`one` where English answers `other`, so only `other` is required of either — and
|
|
310
|
+
an enum keeps the same members, since those are what the calling code passes.
|
|
311
|
+
Word order is free: what is compared is what a message asks of the outside, never
|
|
312
|
+
where it asks for it.
|
|
313
|
+
|
|
314
|
+
**`defineDictionary` is not decoration here — it is what makes that comparison
|
|
315
|
+
possible.** The placeholders are read out of the message itself, so the message
|
|
316
|
+
has to still be a literal type when the registry sees it, and an annotation
|
|
317
|
+
widens every one of them to `string`:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
const FR: DictionaryFor<typeof EN> = { … } // ✗ every message is now `string`
|
|
321
|
+
const FR = { … } satisfies DictionaryFor<typeof EN> // ✗ so is this one
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`satisfies` is no help: its contextual type is that same `string`. A dictionary
|
|
325
|
+
that arrives widened is refused rather than waved through — accepting it would
|
|
326
|
+
let exactly the mistake this catches pass unexamined. `DictionaryFor` stays, for
|
|
327
|
+
typing a variable that holds a dictionary; it is no longer how one is written.
|
|
328
|
+
|
|
329
|
+
A dictionary written straight into the `createI18n` call needs no wrapping, since
|
|
330
|
+
the call preserves its literals itself. One living in a module of its own — which
|
|
331
|
+
is how a lazily loaded locale ships — does.
|
|
332
|
+
|
|
333
|
+
There is **no key-level fallback to another locale**: a missing key fails to
|
|
334
|
+
compile, which is a better place to find out than a screen showing English inside
|
|
335
|
+
French.
|
|
336
|
+
|
|
337
|
+
Whether the registry then holds that dictionary or fetches it is the next
|
|
338
|
+
section, and changes nothing above.
|
|
339
|
+
|
|
340
|
+
### Loading only the dictionary in use
|
|
341
|
+
|
|
342
|
+
A dictionary written into the registry is a dictionary the bundler ships. At two
|
|
343
|
+
languages that is nothing; at five it is every reader downloading four languages
|
|
344
|
+
they cannot read. So a locale may register a *loader* instead, and none of the
|
|
345
|
+
checking changes — the type of `import('./dictionary-de')` is known long before
|
|
346
|
+
it is called:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
export const i18n = createI18n({
|
|
350
|
+
defaultLocale: 'en',
|
|
351
|
+
dictionaries: {
|
|
352
|
+
en: EN, // the reference: in the bundle
|
|
353
|
+
de: () => import('./dictionary-de'), // fetched when it is asked for
|
|
354
|
+
es: () => import('./dictionary-es'),
|
|
355
|
+
fr: () => import('./dictionary-fr')
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
A loaded module `export default`s its dictionary, written with
|
|
361
|
+
`defineDictionary` like any other:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
// dictionary-de.ts — its own chunk, fetched only by a reader who needs it
|
|
365
|
+
export default defineDictionary({
|
|
366
|
+
greeting: 'Hallo {name}',
|
|
367
|
+
room: { empty: 'Noch niemand hier' },
|
|
368
|
+
title: 'Übersicht'
|
|
369
|
+
})
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Nothing about the checking changes: the registry compares that module's type
|
|
373
|
+
against the reference before the loader is ever called, so a renamed placeholder
|
|
374
|
+
in a language nobody on the team reads still fails the build.
|
|
375
|
+
|
|
376
|
+
The default locale's entry is a dictionary and never a loader: it is the
|
|
377
|
+
reference every key and value is typed from, so a late arrival would leave
|
|
378
|
+
TypeScript knowing nothing at the moment `translate('…')` is written.
|
|
379
|
+
|
|
380
|
+
**`translator(locale)` stays synchronous and never fails.** It answers with the
|
|
381
|
+
locale's own translator once that dictionary is in hand, and the default locale's
|
|
382
|
+
until then — so the first frame renders in a language the reader can read rather
|
|
383
|
+
than behind a spinner, and swaps when the dictionary lands:
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
const translate = i18n.translator(locale) // English on a cold load
|
|
387
|
+
await i18n.load(locale) // fetches once, however many callers ask
|
|
388
|
+
i18n.translator(locale) // German, and a new identity to re-render on
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
A failed `load` rejects and forgets the attempt, so asking again retries.
|
|
392
|
+
Ignoring that rejection is safe: the reader stays on the default locale, which is
|
|
393
|
+
what they were already reading.
|
|
394
|
+
|
|
395
|
+
## A link or a bold word inside a sentence
|
|
396
|
+
|
|
397
|
+
Splitting `Read the <link>terms</link> first` into three keys breaks the moment a
|
|
398
|
+
language puts the words in another order. So a message may mark spans, and
|
|
399
|
+
`translate.rich` hands each one to the function named after it:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
const EN = defineDictionary({
|
|
403
|
+
terms: 'Read the <link>terms</link> before playing'
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
translate.rich('terms', { link: (children) => `<a href="/terms">${children}</a>` })
|
|
407
|
+
// [ 'Read the ', '<a href="/terms">terms</a>', ' before playing' ]
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
It returns the pieces in order: strings for the plain parts, whatever the
|
|
411
|
+
functions returned for the marked ones. **Nothing here is a framework.** A
|
|
412
|
+
function may return an element, an HTML string, a terminal escape sequence — the
|
|
413
|
+
return type is inferred from what you give it. A UI framework renders the array
|
|
414
|
+
as a list of children; a string consumer joins it.
|
|
415
|
+
|
|
416
|
+
The values a message needs and the functions its spans need are one object, and
|
|
417
|
+
both are required by the types: a message that marks `<link>` cannot be rendered
|
|
418
|
+
without a `link` function, and one that marks nothing takes none.
|
|
419
|
+
|
|
420
|
+
Spans are cut **before** anything is substituted, so a value that itself reads
|
|
421
|
+
`<b>` is written out as text rather than becoming a span — the same rule the
|
|
422
|
+
placeholders follow, for the same reason.
|
|
423
|
+
|
|
424
|
+
## Where the library ends
|
|
425
|
+
|
|
426
|
+
Everything in this library is generic. Nothing that names your product belongs
|
|
427
|
+
in it — not the locales you ship, not the dictionaries, not the storage key you
|
|
428
|
+
remember a choice under, not a React provider.
|
|
429
|
+
|
|
430
|
+
The shape that keeps the seam clean is one module in the app that calls
|
|
431
|
+
`createI18n` and is imported everywhere else:
|
|
432
|
+
|
|
433
|
+
```
|
|
434
|
+
i18n.ts ← the registry: your locales, your dictionaries
|
|
435
|
+
dictionary-en.ts ← the reference
|
|
436
|
+
dictionary-fr.ts ← defineDictionary, export default if it loads late
|
|
437
|
+
i18n-provider.tsx ← if the app is React: context, switching, persistence
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
The provider is deliberately outside: a translator is a plain function, and
|
|
441
|
+
which framework hands it down — or whether one does at all, as on a server —
|
|
442
|
+
is not this library's business.
|
|
443
|
+
|
|
444
|
+
## Known limitations
|
|
445
|
+
|
|
446
|
+
**Keys stop at ten segments.** `DotPath` counts levels down from `10` to
|
|
447
|
+
terminate its own recursion — a dictionary type that is still a type parameter
|
|
448
|
+
offers nothing else to stop the walk on, and TypeScript gives up with "type
|
|
449
|
+
instantiation is excessively deep". `a.b.c.d.e.f.g.h.i.j` is fine; add an
|
|
450
|
+
eleventh segment and the key stops compiling. The error is misleading, because
|
|
451
|
+
the whole path union collapses to `never` at once — recognise it by the depth
|
|
452
|
+
rather than by the message.
|
|
453
|
+
|
|
454
|
+
**A value that does not arrive leaves its placeholder on screen.** A missing
|
|
455
|
+
value, or one of the wrong runtime type, is written out as `{name}` rather than
|
|
456
|
+
throwing: one bad value costs one word instead of the sentence. It is silent —
|
|
457
|
+
no log, no throw — and hard to reach through the types, but a dictionary built
|
|
458
|
+
dynamically can get there.
|
|
459
|
+
|
|
460
|
+
**A key the dictionary does not hold comes back as itself.** Same silence, same
|
|
461
|
+
reasoning, and unreachable for a dictionary written in the repo.
|
|
462
|
+
|
|
463
|
+
**`createTranslator` cannot check that its dictionary matches its locale.**
|
|
464
|
+
`createI18n` exists to make that pairing impossible to get wrong, whether the
|
|
465
|
+
dictionary is in the bundle or fetched; reaching past it for a dictionary
|
|
466
|
+
TypeScript cannot see takes the guarantee back off the table.
|
|
467
|
+
|
|
468
|
+
**Intl formatters are cached per translator, keyed on the options as
|
|
469
|
+
written.** Building one resolves locale data and costs far more than using one,
|
|
470
|
+
so each is built once and kept for the translator's lifetime. Two equivalent
|
|
471
|
+
option objects whose keys are in a different order each get an entry; they come
|
|
472
|
+
from dictionary literals, so the count is bounded by the dictionary.
|
|
473
|
+
|
|
474
|
+
**A message that marks spans is still readable with `translate`**, markers and
|
|
475
|
+
all. Nothing prevents it, because a plain-text context — a log line, a `title`
|
|
476
|
+
attribute, a test — legitimately wants the raw sentence; on a screen it is a
|
|
477
|
+
visible mistake, and the only guard against it is knowing which keys carry
|
|
478
|
+
spans.
|
|
479
|
+
|
|
480
|
+
**A span cannot hold another span.** The inner one would have to be rendered
|
|
481
|
+
before the outer function could be handed a string, and a string is all a
|
|
482
|
+
function receives. `<b>very <i>very</i> bold</b>` does not work; two sibling
|
|
483
|
+
spans do.
|
|
484
|
+
|
|
485
|
+
**A placeholder inside a branch gets no options of its own.** What a typed
|
|
486
|
+
placeholder demands — the unit `:relative` needs, the kind of name
|
|
487
|
+
`:displayname` looks up, the map behind an `:enum` — is read off the message and
|
|
488
|
+
never off the branches. So `other: '{?} messages, last {when:relative}'`
|
|
489
|
+
compiles with no `relative.when` anywhere, and leaves `{when:relative}` standing
|
|
490
|
+
on screen. Keep the typed placeholders in the sentence and let a branch carry
|
|
491
|
+
the plain ones.
|
|
492
|
+
|
|
493
|
+
**A span inside a branch is not cut.** `rich` splits the sentence before
|
|
494
|
+
anything is substituted, so a `<b>` written into a plural form or an enum member
|
|
495
|
+
reaches the screen as text. The spans a message marks have to be in the message.
|
|
496
|
+
|
|
497
|
+
**Type-level rules are tested as types, not as behaviour.** Everything above
|
|
498
|
+
that says "does not compile" is asserted in `translator.types.test.ts` as the
|
|
499
|
+
type it resolves to, since a compile error cannot be caught by a test that has
|
|
500
|
+
to compile. Those assertions are checked by `tsc --noEmit`, so a regression
|
|
501
|
+
fails the build rather than the test run.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": "Adrien Lacourpaille",
|
|
3
|
+
"bugs": "https://github.com/AdrienLcp/packages/issues",
|
|
4
|
+
"description": "A typed i18n library in five files, with no dependencies, built on Intl",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./dist/index.js",
|
|
7
|
+
"./package.json": "./package.json"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"documentation.md"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/AdrienLcp/packages/tree/main/packages/i18n#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"i18n",
|
|
16
|
+
"intl",
|
|
17
|
+
"translation",
|
|
18
|
+
"typescript"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"name": "@adrienlcp/i18n",
|
|
22
|
+
"repository": {
|
|
23
|
+
"directory": "packages/i18n",
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/AdrienLcp/packages.git"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.build.json",
|
|
29
|
+
"prepublishOnly": "pnpm build",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest --watch"
|
|
32
|
+
},
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"type": "module",
|
|
35
|
+
"version": "0.1.0"
|
|
36
|
+
}
|