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