@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adrien Lacourpaille
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @adrienlcp/i18n
|
|
2
|
+
|
|
3
|
+
A translation and formatting library for TypeScript, in five files, with **no
|
|
4
|
+
dependencies** and no tie to any framework. Everything it does at runtime it
|
|
5
|
+
does through the platform's `Intl`.
|
|
6
|
+
|
|
7
|
+
What makes it worth having a package of its own is one property: **the arguments a
|
|
8
|
+
message takes are read off the message itself, at compile time**, with no code
|
|
9
|
+
generation, no extraction step and no build plugin.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const EN = defineDictionary({ greeting: 'Hello {name}' })
|
|
13
|
+
|
|
14
|
+
translate('greeting', { name: 'Ada' }) // fine
|
|
15
|
+
translate('greeting') // ✗ Expected 2 arguments, but got 1
|
|
16
|
+
translate('greeting', { nom: 'Ada' }) // ✗ 'nom' does not exist in type '{ name: string }'
|
|
17
|
+
translate('greting', { name: 'Ada' }) // ✗ not assignable to '"greeting"'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
A second language is typed against the first, down to the placeholders inside
|
|
21
|
+
each message: a missing key, an invented key, a wrong plural table, a `{nom}`
|
|
22
|
+
written where the reference says `{name}` — each is a compile error rather than a
|
|
23
|
+
screen showing a raw placeholder to a user.
|
|
24
|
+
|
|
25
|
+
## Using it
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm add @adrienlcp/i18n
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createI18n, defineDictionary } from '@adrienlcp/i18n'
|
|
33
|
+
|
|
34
|
+
const EN = defineDictionary({ greeting: 'Hello {name}' })
|
|
35
|
+
|
|
36
|
+
export const i18n = createI18n({
|
|
37
|
+
defaultLocale: 'en',
|
|
38
|
+
dictionaries: { en: EN, fr: () => import('./dictionary-fr') }
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const translate = await i18n.load('fr')
|
|
42
|
+
translate('greeting', { name: 'Ada' }) // 'Bonjour Ada'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
[`documentation.md`](documentation.md) is the documentation — syntax, type
|
|
46
|
+
guarantees, adding a locale, rich text, and the known limitations. Read that
|
|
47
|
+
one.
|
|
48
|
+
|
|
49
|
+
## Working on it
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pnpm install # at the repository root
|
|
53
|
+
pnpm validate # typecheck + biome ci + vitest + build
|
|
54
|
+
pnpm --filter @adrienlcp/i18n test:watch # this package alone
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The suite runs in well under a second: it is pure TypeScript, with no browser and
|
|
58
|
+
no network — the one module a test imports late is a dictionary, so that a lazily
|
|
59
|
+
loaded locale is proved across a real module boundary.
|
|
60
|
+
`src/translator.types.test.ts` is worth knowing about — every rule this library
|
|
61
|
+
enforces is a *compile* error at a call site, and a compile error cannot be
|
|
62
|
+
caught by a test that has to compile, so each one is written there as the type it
|
|
63
|
+
resolves to and checked by `tsc`.
|
|
64
|
+
|
|
65
|
+
## Where it came from
|
|
66
|
+
|
|
67
|
+
It started as Web Dev Simplified's
|
|
68
|
+
[`intl-crash-course`](https://github.com/WebDevSimplified/intl-crash-course)
|
|
69
|
+
([video](https://www.youtube.com/watch?v=VbZVx13b2oY)): the `{name:type}`
|
|
70
|
+
placeholder syntax, `defineTranslation` and typed dot-path keys come from there.
|
|
71
|
+
This package adds relative-time and display-name placeholders, cross-locale
|
|
72
|
+
dictionary parity checks, rich text, locale negotiation and lazy loading, and
|
|
73
|
+
drops the React layer and the locale fallback cascade.
|
|
74
|
+
|
|
75
|
+
It grew apart in two projects, then was reunited in a repository of its own.
|
|
76
|
+
What changed along the way:
|
|
77
|
+
a plural `zero` form that actually fires, one-pass substitution (a repeated
|
|
78
|
+
placeholder used to be filled once, and a substituted value could be read back
|
|
79
|
+
as a placeholder), cross-locale key parity, a plural or enum written without its
|
|
80
|
+
alternatives refusing to compile, a locale registry that binds each language to
|
|
81
|
+
its dictionary once, negotiation that walks both up and down the tag,
|
|
82
|
+
`Intl` formatters built once instead of on every substitution, dictionaries a
|
|
83
|
+
locale can fetch rather than ship, rich text, and tests.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Dictionary, DictionaryFor, MatchingDictionary } from './dictionary.ts';
|
|
2
|
+
import { type Translator } from './translator.ts';
|
|
3
|
+
/**
|
|
4
|
+
* A dictionary the bundler is told to split out — `() => import('./fr')`. The
|
|
5
|
+
* module `export default`s it, so TypeScript reads its type at compile time
|
|
6
|
+
* even though its text arrives at run time: a locale that is fetched late is
|
|
7
|
+
* held to the reference exactly like one that is imported.
|
|
8
|
+
*/
|
|
9
|
+
export type DictionaryLoader<Reference> = () => Promise<{
|
|
10
|
+
default: Localized<Reference>;
|
|
11
|
+
}>;
|
|
12
|
+
/**
|
|
13
|
+
* A dictionary for a second locale, seen from both sides at once: as the tree of
|
|
14
|
+
* strings the reference demands, and as a dictionary the lookup can walk. It
|
|
15
|
+
* names `Dictionary` for a second reason — a mapped type over a type parameter
|
|
16
|
+
* is opaque enough that TypeScript will not rule out its being callable, and a
|
|
17
|
+
* concrete member is what lets `typeof entry === 'function'` tell a dictionary
|
|
18
|
+
* from a loader at all.
|
|
19
|
+
*
|
|
20
|
+
* The index signature that comes with it switches off TypeScript's own excess
|
|
21
|
+
* property check, which is why `MatchingDictionary` demands `never` at a key the
|
|
22
|
+
* reference does not have rather than leaving that to the literal.
|
|
23
|
+
*/
|
|
24
|
+
type Localized<Reference> = Dictionary & DictionaryFor<Reference>;
|
|
25
|
+
type AnyLoader = () => Promise<{
|
|
26
|
+
default: Dictionary;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* One locale's entry held to the reference, whichever way it arrives. A loader
|
|
30
|
+
* is compared through the dictionary its module `export default`s, so a locale
|
|
31
|
+
* that is fetched late is checked exactly like one that is imported — and
|
|
32
|
+
* checked before it is ever called.
|
|
33
|
+
*/
|
|
34
|
+
type Matching<Reference, Entry> = Entry extends () => Promise<{
|
|
35
|
+
default: infer Loaded;
|
|
36
|
+
}> ? () => Promise<{
|
|
37
|
+
default: MatchingDictionary<Reference, Loaded>;
|
|
38
|
+
}> : Localized<Reference> & MatchingDictionary<Reference, Entry>;
|
|
39
|
+
export type I18n<Reference, Locale extends string, DefaultLocale extends Locale = Locale> = {
|
|
40
|
+
/**
|
|
41
|
+
* A comparator for `Array.sort`, so that a list of names reads the way the
|
|
42
|
+
* locale orders them — `Émile` between `Adrien` and `Zoé`, where sorting by
|
|
43
|
+
* code point puts it after both.
|
|
44
|
+
*/
|
|
45
|
+
compare: (locale: Locale, options?: Intl.CollatorOptions) => (first: string, second: string) => number;
|
|
46
|
+
/** The locale a preference list falls back to, and the reference dictionary's own. */
|
|
47
|
+
defaultLocale: DefaultLocale;
|
|
48
|
+
/**
|
|
49
|
+
* Fetches what `locale` registered a loader for, and resolves with the
|
|
50
|
+
* translator that reads it — from then on the one `translator(locale)` hands
|
|
51
|
+
* out. A locale whose dictionary is already in hand resolves immediately, and
|
|
52
|
+
* two calls made while one fetch is in flight share it rather than fetching
|
|
53
|
+
* twice.
|
|
54
|
+
*
|
|
55
|
+
* It rejects when the fetch does, and forgets the attempt so that calling it
|
|
56
|
+
* again retries. Ignoring that rejection is safe: `translator(locale)` goes on
|
|
57
|
+
* answering with the default locale's translator, which is what the reader
|
|
58
|
+
* was already seeing.
|
|
59
|
+
*/
|
|
60
|
+
load: (locale: Locale) => Promise<Translator<Reference>>;
|
|
61
|
+
/** Every locale the registry knows, whether its dictionary is loaded or not. */
|
|
62
|
+
locales: readonly Locale[];
|
|
63
|
+
/** Which of `locales` a list of BCP-47 tags asks for. */
|
|
64
|
+
negotiate: (preferred: readonly string[]) => Locale;
|
|
65
|
+
/**
|
|
66
|
+
* The translator for one locale, synchronously and always: the locale's own
|
|
67
|
+
* once its dictionary is in hand, the default locale's until then. So a page
|
|
68
|
+
* renders on the first frame in a language the reader can read, and swaps to
|
|
69
|
+
* theirs when `load` resolves — no blank screen, no spinner.
|
|
70
|
+
*
|
|
71
|
+
* The same function every time it is asked for, and a different one on either
|
|
72
|
+
* side of a load. That is what a consumer memoises on.
|
|
73
|
+
*/
|
|
74
|
+
translator: (locale: Locale) => Translator<Reference>;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Binds every locale to its dictionary once, so that afterwards a locale is all
|
|
78
|
+
* anyone passes. It is the door callers want: `createTranslator` takes a locale
|
|
79
|
+
* *and* a dictionary, and nothing there can check that the two go together — a
|
|
80
|
+
* translator built with the French dictionary and the tag `'en'` reads French
|
|
81
|
+
* and counts in English.
|
|
82
|
+
*
|
|
83
|
+
* The reference dictionary is `defaultLocale`'s, so that is where the keys and
|
|
84
|
+
* the values are typed from, and every other locale is checked against it.
|
|
85
|
+
*
|
|
86
|
+
* A locale registers either its dictionary or a function fetching it, and the
|
|
87
|
+
* guarantee holds for both — the type of `import('./fr')` is known before it is
|
|
88
|
+
* ever called. Registering loaders is what keeps a bundler from shipping five
|
|
89
|
+
* languages to a reader who reads one:
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* export const i18n = createI18n({
|
|
93
|
+
* defaultLocale: 'en',
|
|
94
|
+
* dictionaries: {
|
|
95
|
+
* en: EN_DICTIONARY,
|
|
96
|
+
* fr: () => import('./dictionary-fr'),
|
|
97
|
+
* de: () => import('./dictionary-de')
|
|
98
|
+
* }
|
|
99
|
+
* })
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* The default locale's entry is a dictionary and never a loader, which the type
|
|
103
|
+
* forces twice over: it is the reference every key and value is read from, so a
|
|
104
|
+
* late arrival would leave TypeScript knowing nothing at the moment
|
|
105
|
+
* `translate('…')` is written — and it is what a reader sees during the moment
|
|
106
|
+
* their own language is in flight.
|
|
107
|
+
*
|
|
108
|
+
* A registry built without a single loader behaves exactly as one built before
|
|
109
|
+
* they existed: everything is in hand, `translator` never falls back, and
|
|
110
|
+
* `load` resolves on the spot.
|
|
111
|
+
*/
|
|
112
|
+
export declare const createI18n: <const Entries extends Record<string, Dictionary | AnyLoader>, const DefaultLocale extends keyof Entries & string>({ defaultLocale, dictionaries }: {
|
|
113
|
+
defaultLocale: DefaultLocale;
|
|
114
|
+
dictionaries: Entries & { [Locale in keyof Entries]: Matching<Entries[DefaultLocale], Entries[Locale]>; } & Record<DefaultLocale, Localized<Entries[DefaultLocale]>>;
|
|
115
|
+
}) => I18n<Entries[DefaultLocale], keyof Entries & string, DefaultLocale>;
|
|
116
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { negotiateLocale } from './negotiate-locale.js';
|
|
2
|
+
import { createTranslator } from './translator.js';
|
|
3
|
+
const isLoader = (registered) => typeof registered === 'function';
|
|
4
|
+
/**
|
|
5
|
+
* Binds every locale to its dictionary once, so that afterwards a locale is all
|
|
6
|
+
* anyone passes. It is the door callers want: `createTranslator` takes a locale
|
|
7
|
+
* *and* a dictionary, and nothing there can check that the two go together — a
|
|
8
|
+
* translator built with the French dictionary and the tag `'en'` reads French
|
|
9
|
+
* and counts in English.
|
|
10
|
+
*
|
|
11
|
+
* The reference dictionary is `defaultLocale`'s, so that is where the keys and
|
|
12
|
+
* the values are typed from, and every other locale is checked against it.
|
|
13
|
+
*
|
|
14
|
+
* A locale registers either its dictionary or a function fetching it, and the
|
|
15
|
+
* guarantee holds for both — the type of `import('./fr')` is known before it is
|
|
16
|
+
* ever called. Registering loaders is what keeps a bundler from shipping five
|
|
17
|
+
* languages to a reader who reads one:
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* export const i18n = createI18n({
|
|
21
|
+
* defaultLocale: 'en',
|
|
22
|
+
* dictionaries: {
|
|
23
|
+
* en: EN_DICTIONARY,
|
|
24
|
+
* fr: () => import('./dictionary-fr'),
|
|
25
|
+
* de: () => import('./dictionary-de')
|
|
26
|
+
* }
|
|
27
|
+
* })
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* The default locale's entry is a dictionary and never a loader, which the type
|
|
31
|
+
* forces twice over: it is the reference every key and value is read from, so a
|
|
32
|
+
* late arrival would leave TypeScript knowing nothing at the moment
|
|
33
|
+
* `translate('…')` is written — and it is what a reader sees during the moment
|
|
34
|
+
* their own language is in flight.
|
|
35
|
+
*
|
|
36
|
+
* A registry built without a single loader behaves exactly as one built before
|
|
37
|
+
* they existed: everything is in hand, `translator` never falls back, and
|
|
38
|
+
* `load` resolves on the spot.
|
|
39
|
+
*/
|
|
40
|
+
export const createI18n = ({ defaultLocale, dictionaries }) => {
|
|
41
|
+
const locales = localesOf(dictionaries);
|
|
42
|
+
const registryWithoutIntersection = new Map(locales.map((locale) => [locale, dictionaries[locale]]));
|
|
43
|
+
const collators = new Map();
|
|
44
|
+
const loaded = new Map();
|
|
45
|
+
const loading = new Map();
|
|
46
|
+
const translators = new Map();
|
|
47
|
+
for (const [locale, registered] of registryWithoutIntersection) {
|
|
48
|
+
if (!isLoader(registered)) {
|
|
49
|
+
loaded.set(locale, registered);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const defaultDictionary = dictionaries[defaultLocale];
|
|
53
|
+
const compare = (locale, options) => {
|
|
54
|
+
const key = `${locale} ${JSON.stringify(options ?? null)}`;
|
|
55
|
+
const built = collators.get(key);
|
|
56
|
+
if (built !== undefined) {
|
|
57
|
+
return built.compare;
|
|
58
|
+
}
|
|
59
|
+
const created = new Intl.Collator(locale, options);
|
|
60
|
+
collators.set(key, created);
|
|
61
|
+
return created.compare;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Built once per locale and kept. That is not a speed optimisation: a
|
|
65
|
+
* consumer memoising on the translator must see a new identity when the
|
|
66
|
+
* language it reads changes and the same one when it does not, or every
|
|
67
|
+
* `useMemo` downstream either recomputes forever or serves the old language.
|
|
68
|
+
*/
|
|
69
|
+
const stableTranslatorFor = (locale, dictionary) => {
|
|
70
|
+
const built = translators.get(locale);
|
|
71
|
+
if (built !== undefined) {
|
|
72
|
+
return built;
|
|
73
|
+
}
|
|
74
|
+
const created = createTranslator({ dictionary, locale });
|
|
75
|
+
translators.set(locale, created);
|
|
76
|
+
return created;
|
|
77
|
+
};
|
|
78
|
+
const translator = (locale) => {
|
|
79
|
+
const dictionary = loaded.get(locale);
|
|
80
|
+
return dictionary === undefined
|
|
81
|
+
? stableTranslatorFor(defaultLocale, defaultDictionary)
|
|
82
|
+
: stableTranslatorFor(locale, dictionary);
|
|
83
|
+
};
|
|
84
|
+
const load = (locale) => {
|
|
85
|
+
const dictionary = loaded.get(locale);
|
|
86
|
+
if (dictionary !== undefined) {
|
|
87
|
+
return Promise.resolve(stableTranslatorFor(locale, dictionary));
|
|
88
|
+
}
|
|
89
|
+
const inFlight = loading.get(locale);
|
|
90
|
+
if (inFlight !== undefined) {
|
|
91
|
+
return inFlight;
|
|
92
|
+
}
|
|
93
|
+
const loader = registryWithoutIntersection.get(locale);
|
|
94
|
+
if (!isLoader(loader)) {
|
|
95
|
+
return Promise.resolve(translator(locale));
|
|
96
|
+
}
|
|
97
|
+
const started = fetchDictionary(loader)
|
|
98
|
+
.then((dictionary) => {
|
|
99
|
+
loaded.set(locale, dictionary);
|
|
100
|
+
return stableTranslatorFor(locale, dictionary);
|
|
101
|
+
})
|
|
102
|
+
.finally(() => {
|
|
103
|
+
loading.delete(locale);
|
|
104
|
+
});
|
|
105
|
+
loading.set(locale, started);
|
|
106
|
+
return started;
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
compare,
|
|
110
|
+
defaultLocale,
|
|
111
|
+
load,
|
|
112
|
+
locales,
|
|
113
|
+
negotiate: (preferred) => negotiateLocale(preferred, {
|
|
114
|
+
fallback: defaultLocale,
|
|
115
|
+
supported: locales
|
|
116
|
+
}),
|
|
117
|
+
translator
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Calling the loader through a parameter of its own type, rather than as the
|
|
122
|
+
* intersection the narrowing leaves behind: an intersection of two call
|
|
123
|
+
* signatures resolves to the first, and the first is the one the constraint
|
|
124
|
+
* wrote, which knows only that a dictionary comes back.
|
|
125
|
+
*/
|
|
126
|
+
const fetchDictionary = (loader) => loader().then((module) => module.default);
|
|
127
|
+
/**
|
|
128
|
+
* `Object.keys` widens to `string[]`, which would make the negotiated locale a
|
|
129
|
+
* `string` and let any tag reach `translator`. The predicate re-establishes what
|
|
130
|
+
* the record already knows — its own keys — without a cast.
|
|
131
|
+
*/
|
|
132
|
+
const localesOf = (dictionaries) => Object.keys(dictionaries).filter((key) => key in dictionaries);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A placeholder written `{name}` is text and takes a string. One written
|
|
3
|
+
* `{name:type}` is a value the locale has to format, and the type decides both
|
|
4
|
+
* what the caller must pass and what this file demands alongside the message.
|
|
5
|
+
*
|
|
6
|
+
* `plural` and `enum` carry their alternatives here rather than in the sentence,
|
|
7
|
+
* because those alternatives are what differs between locales.
|
|
8
|
+
*/
|
|
9
|
+
export type PluralForms = Partial<Record<Exclude<Intl.LDMLPluralRule, 'other'>, string>> & {
|
|
10
|
+
formatter?: Intl.NumberFormatOptions;
|
|
11
|
+
other: string;
|
|
12
|
+
type?: Intl.PluralRuleType;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* `Intl.RelativeTimeFormat` formats a number *of something*, and the message
|
|
16
|
+
* cannot say which — so the unit is declared here, per placeholder, and the
|
|
17
|
+
* caller passes the count alone. Negative is the past, positive the future,
|
|
18
|
+
* which is `Intl`'s own convention.
|
|
19
|
+
*/
|
|
20
|
+
export type RelativeTime = Intl.RelativeTimeFormatOptions & {
|
|
21
|
+
unit: Intl.RelativeTimeFormatUnit;
|
|
22
|
+
};
|
|
23
|
+
export type TranslationOptions = {
|
|
24
|
+
date?: Record<string, Intl.DateTimeFormatOptions>;
|
|
25
|
+
displayname?: Record<string, Intl.DisplayNamesOptions>;
|
|
26
|
+
enum?: Record<string, Record<string, string>>;
|
|
27
|
+
list?: Record<string, Intl.ListFormatOptions>;
|
|
28
|
+
number?: Record<string, Intl.NumberFormatOptions>;
|
|
29
|
+
plural?: Record<string, PluralForms>;
|
|
30
|
+
relative?: Record<string, RelativeTime>;
|
|
31
|
+
};
|
|
32
|
+
type OptionsForParam<Type extends string, Name extends string> = Type extends 'date' ? {
|
|
33
|
+
date?: {
|
|
34
|
+
[K in Name]?: Intl.DateTimeFormatOptions;
|
|
35
|
+
};
|
|
36
|
+
} : Type extends 'displayname' ? {
|
|
37
|
+
displayname: {
|
|
38
|
+
[K in Name]: Intl.DisplayNamesOptions;
|
|
39
|
+
};
|
|
40
|
+
} : Type extends 'enum' ? {
|
|
41
|
+
enum: {
|
|
42
|
+
[K in Name]: Record<string, string>;
|
|
43
|
+
};
|
|
44
|
+
} : Type extends 'list' ? {
|
|
45
|
+
list?: {
|
|
46
|
+
[K in Name]?: Intl.ListFormatOptions;
|
|
47
|
+
};
|
|
48
|
+
} : Type extends 'number' ? {
|
|
49
|
+
number?: {
|
|
50
|
+
[K in Name]?: Intl.NumberFormatOptions;
|
|
51
|
+
};
|
|
52
|
+
} : Type extends 'plural' ? {
|
|
53
|
+
plural: {
|
|
54
|
+
[K in Name]: PluralForms;
|
|
55
|
+
};
|
|
56
|
+
} : Type extends 'relative' ? {
|
|
57
|
+
relative: {
|
|
58
|
+
[K in Name]: RelativeTime;
|
|
59
|
+
};
|
|
60
|
+
} : never;
|
|
61
|
+
export type OptionsFor<Message extends string> = Message extends `${string}{${infer Param}}${infer Rest}` ? Param extends `${infer Name}:${infer Type}` ? OptionsForParam<Type, Name> & OptionsFor<Rest> : OptionsFor<Rest> : unknown;
|
|
62
|
+
export type DefinedTranslation = readonly [string, TranslationOptions];
|
|
63
|
+
/**
|
|
64
|
+
* Pairs a message with what its placeholders need beyond the value itself.
|
|
65
|
+
* `plural` and `enum` need the alternatives to choose between; `relative` needs
|
|
66
|
+
* its unit and `displayname` the kind of name to look up. The other three only
|
|
67
|
+
* configure a formatter, so a message wanting the locale's defaults is written
|
|
68
|
+
* as a bare string.
|
|
69
|
+
*/
|
|
70
|
+
export declare const defineTranslation: <Message extends string, const Options extends OptionsFor<Message>>(message: Message, options: Options) => readonly [Message, Options];
|
|
71
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairs a message with what its placeholders need beyond the value itself.
|
|
3
|
+
* `plural` and `enum` need the alternatives to choose between; `relative` needs
|
|
4
|
+
* its unit and `displayname` the kind of name to look up. The other three only
|
|
5
|
+
* configure a formatter, so a message wanting the locale's defaults is written
|
|
6
|
+
* as a bare string.
|
|
7
|
+
*/
|
|
8
|
+
export const defineTranslation = (message, options) => [message, options];
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { DefinedTranslation, OptionsFor, PluralForms } from './define-translation.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A tree of messages for one locale: every leaf is a sentence, every branch a
|
|
4
|
+
* namespace. A branch is never also a leaf — `host.answerWindow.label`, not
|
|
5
|
+
* `host.answerWindow` doubling as both.
|
|
6
|
+
*/
|
|
7
|
+
export type Dictionary = {
|
|
8
|
+
[segment: string]: string | DefinedTranslation | Dictionary;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* A bare string is enough until a placeholder needs alternatives to choose
|
|
12
|
+
* between — and `{n:plural}` or `{n:enum}` written as one resolves to `never`
|
|
13
|
+
* here rather than putting the raw key on screen at runtime.
|
|
14
|
+
*
|
|
15
|
+
* The last branch has to name `Dictionary` rather than map over anything: a
|
|
16
|
+
* mapped type over a primitive returns that primitive, so a leaf holding a
|
|
17
|
+
* number, a `Date` or a function would otherwise pass through unexamined.
|
|
18
|
+
*/
|
|
19
|
+
export type WellFormed<Message> = Message extends string ? Record<string, never> extends OptionsFor<Message> ? Message : never : Message extends DefinedTranslation ? Message : Message extends Dictionary ? {
|
|
20
|
+
[Segment in keyof Message]: WellFormed<Message[Segment]>;
|
|
21
|
+
} : never;
|
|
22
|
+
/**
|
|
23
|
+
* The reference dictionary — the one every key and every value is typed from.
|
|
24
|
+
*
|
|
25
|
+
* A function rather than a type to `satisfies`, because a type describing a
|
|
26
|
+
* dictionary has to reach its members through an index signature, and their
|
|
27
|
+
* type then cannot depend on the message written at each key.
|
|
28
|
+
*/
|
|
29
|
+
export declare const defineDictionary: <const T extends { [Segment in keyof T]: WellFormed<T[Segment]>; }>(dictionary: T) => T;
|
|
30
|
+
/**
|
|
31
|
+
* What a second locale owes the reference one: the same tree down to the same
|
|
32
|
+
* leaves, and for a leaf whose placeholders carry alternatives, its own
|
|
33
|
+
* alternatives in the same shape. The plural categories may differ — French
|
|
34
|
+
* answers `one` where English answers `other` — so only `other` is required of
|
|
35
|
+
* either.
|
|
36
|
+
*
|
|
37
|
+
* Written as a type to annotate with rather than a function to call, so that
|
|
38
|
+
* TypeScript's excess property check does the other half of the work: a key the
|
|
39
|
+
* reference does not have is rejected at the literal, at every depth.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* export const FR_DICTIONARY: DictionaryFor<typeof EN_DICTIONARY> = { … }
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export type DictionaryFor<Reference> = {
|
|
46
|
+
[Segment in keyof Reference]: Reference[Segment] extends readonly [
|
|
47
|
+
string,
|
|
48
|
+
infer Options
|
|
49
|
+
] ? readonly [string, LocalizedOptions<Options>] : Reference[Segment] extends string ? string : Reference[Segment] extends Dictionary ? DictionaryFor<Reference[Segment]> : never;
|
|
50
|
+
};
|
|
51
|
+
type LocalizedOptions<Options> = {
|
|
52
|
+
[K in keyof Options]: K extends 'plural' ? {
|
|
53
|
+
[Name in keyof Options[K]]: PluralForms;
|
|
54
|
+
} : K extends 'enum' ? {
|
|
55
|
+
[Name in keyof Options[K]]: Record<keyof Options[K][Name] & string, string>;
|
|
56
|
+
} : Options[K];
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* What a second locale owes the reference beyond its shape: the same
|
|
60
|
+
* placeholders, in the same message. `DictionaryFor` types every message as a
|
|
61
|
+
* bare `string`, so `'Willkommen {nom}'` sits opposite `'Welcome {name}'` and
|
|
62
|
+
* compiles — the keys are compared across locales, the placeholders inside them
|
|
63
|
+
* are not. At two languages that is theoretical; at five it is a matter of time.
|
|
64
|
+
*
|
|
65
|
+
* The comparison runs both ways, because a locale declaring *fewer*
|
|
66
|
+
* placeholders than the reference is as wrong as one declaring more, and a
|
|
67
|
+
* `{count}` written where the reference formats `{count:number}` asks its caller
|
|
68
|
+
* for a different value. A leaf that disagrees resolves to `never`, which no
|
|
69
|
+
* message is assignable to, so the error lands on the key that disagrees.
|
|
70
|
+
*
|
|
71
|
+
* There is only something to compare while the messages are still literal
|
|
72
|
+
* types, which is why every dictionary is written through `defineDictionary`.
|
|
73
|
+
* An annotation widens each message to `string` and takes its placeholders with
|
|
74
|
+
* it — and so does `satisfies`, whose contextual type is that same `string`.
|
|
75
|
+
*/
|
|
76
|
+
export type MatchingDictionary<Reference, Candidate> = {
|
|
77
|
+
[Segment in keyof Reference]: MatchingLeaf<Reference[Segment], Segment extends keyof Candidate ? Candidate[Segment] : never>;
|
|
78
|
+
} & KeysTheReferenceLacksRefused<Reference, Candidate>;
|
|
79
|
+
/**
|
|
80
|
+
* A key the reference does not have, demanded as `never` so that whatever was
|
|
81
|
+
* written under it is refused. TypeScript's own excess property check cannot do
|
|
82
|
+
* this here: it fires on a literal with a type of its own, and a dictionary
|
|
83
|
+
* reaches the registry as a value, from another module as often as not.
|
|
84
|
+
*/
|
|
85
|
+
type KeysTheReferenceLacksRefused<Reference, Candidate> = {
|
|
86
|
+
[Segment in Exclude<keyof Candidate, keyof Reference>]: never;
|
|
87
|
+
};
|
|
88
|
+
type MatchingLeaf<Reference, Candidate> = Reference extends readonly [
|
|
89
|
+
string,
|
|
90
|
+
infer Options
|
|
91
|
+
] ? Matches<Reference, Candidate> extends true ? readonly [string, LocalizedOptions<Options>] : never : Reference extends string ? Matches<Reference, Candidate> extends true ? string : never : Reference extends Dictionary ? MatchingDictionary<Reference, Candidate> : never;
|
|
92
|
+
/**
|
|
93
|
+
* Everything the message asks of the outside: the values a caller passes, and
|
|
94
|
+
* the spans a rich rendering marks up. A `<link>` the reference opens and a
|
|
95
|
+
* locale drops takes the link off the screen as quietly as a renamed
|
|
96
|
+
* placeholder puts the wrong word on it, so both are compared at once.
|
|
97
|
+
*/
|
|
98
|
+
type Matches<Reference, Candidate> = Same<RichValuesFor<Reference, unknown>, RichValuesFor<Candidate, unknown>>;
|
|
99
|
+
/** Assignable both ways, so that a difference in either is a difference. */
|
|
100
|
+
type Same<Left, Right> = [Left] extends [Right] ? [Right] extends [Left] ? true : false : false;
|
|
101
|
+
type Join<Segment, Rest> = Segment extends string ? Rest extends string ? `${Segment}.${Rest}` : never : never;
|
|
102
|
+
/**
|
|
103
|
+
* Recursion over a dictionary whose type is still a type parameter has no floor
|
|
104
|
+
* of its own — TypeScript keeps descending into `T[Segment]` and gives up with
|
|
105
|
+
* TS2589. A registered dictionary would be concrete and would need none of
|
|
106
|
+
* this; a generic one has to count the levels down. Past the last one a path
|
|
107
|
+
* resolves to `never`, so nesting deeper than this fails to compile rather than
|
|
108
|
+
* losing a key quietly — with a misleading error, since the whole path union
|
|
109
|
+
* collapses at once.
|
|
110
|
+
*/
|
|
111
|
+
type MaxPathDepth = 10;
|
|
112
|
+
type NextLevel = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
113
|
+
type Level = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
|
114
|
+
/** Every leaf, as the dotted path that reaches it. */
|
|
115
|
+
export type DotPath<T, Remaining extends Level = MaxPathDepth> = {
|
|
116
|
+
[Segment in keyof T]: T[Segment] extends string | DefinedTranslation ? Segment : NextLevel[Remaining] extends Level ? Join<Segment, DotPath<T[Segment], NextLevel[Remaining]>> : never;
|
|
117
|
+
}[keyof T];
|
|
118
|
+
export type LeafAt<T, Path> = Path extends `${infer Segment}.${infer Rest}` ? Segment extends keyof T ? LeafAt<T[Segment], Rest> : never : Path extends keyof T ? T[Path] : never;
|
|
119
|
+
type MessageOf<Translation> = Translation extends readonly [
|
|
120
|
+
infer Message extends string,
|
|
121
|
+
unknown
|
|
122
|
+
] ? Message : Translation extends string ? Translation : never;
|
|
123
|
+
type OptionsOf<Translation> = Translation extends readonly [
|
|
124
|
+
string,
|
|
125
|
+
infer Options
|
|
126
|
+
] ? Options : unknown;
|
|
127
|
+
type EnumsOf<Options> = Options extends {
|
|
128
|
+
enum: infer Enums;
|
|
129
|
+
} ? Enums : Record<string, Record<string, string>>;
|
|
130
|
+
type ValueForParam<Type extends string, Name extends string, Enums> = Type extends 'date' ? Date : Type extends 'displayname' ? string : Type extends 'enum' ? Name extends keyof Enums ? keyof Enums[Name] : never : Type extends 'list' ? readonly string[] : Type extends 'number' | 'plural' | 'relative' ? number : never;
|
|
131
|
+
type FormattedCountMarker = '?';
|
|
132
|
+
/**
|
|
133
|
+
* Every placeholder a message writes, left as written — `name`, `at:date`. What
|
|
134
|
+
* a caller owes is built from that union in one mapped type rather than folded
|
|
135
|
+
* together message by message, which is what lets the alternatives a `:plural`
|
|
136
|
+
* or an `:enum` chooses between be read with the same grammar as the sentence
|
|
137
|
+
* and land in the same object.
|
|
138
|
+
*
|
|
139
|
+
* `{?}` is the plural count's own marker and not a value anyone passes:
|
|
140
|
+
* `pluralize` fills it from the number it was already handed.
|
|
141
|
+
*/
|
|
142
|
+
type ParamsIn<Message extends string> = Message extends `${string}{${infer Param}}${infer Rest}` ? (Param extends FormattedCountMarker ? never : Param) | ParamsIn<Rest> : never;
|
|
143
|
+
/**
|
|
144
|
+
* The text a translation carries besides its sentence: every plural form, every
|
|
145
|
+
* enum member. The runtime substitutes inside whichever one it selects, so a
|
|
146
|
+
* placeholder written there asks its caller for a value exactly as one in the
|
|
147
|
+
* sentence does — and the cross-locale comparison holds a locale to it too.
|
|
148
|
+
*
|
|
149
|
+
* All of them are read, not the one a count will select: which category answers
|
|
150
|
+
* is the locale's business, and unknowable from here.
|
|
151
|
+
*/
|
|
152
|
+
type AlternativesIn<Options> = (Options extends {
|
|
153
|
+
enum: infer Enums;
|
|
154
|
+
} ? TextsIn<Enums> : never) | (Options extends {
|
|
155
|
+
plural: infer Plurals;
|
|
156
|
+
} ? TextsIn<Plurals> : never);
|
|
157
|
+
/**
|
|
158
|
+
* Two levels down is every alternative and nothing else: `formatter` is an
|
|
159
|
+
* object and drops out at `& string`, and `type` is a bare word with no
|
|
160
|
+
* placeholder in it.
|
|
161
|
+
*/
|
|
162
|
+
type TextsIn<Groups> = {
|
|
163
|
+
[Name in keyof Groups]: Groups[Name][keyof Groups[Name]];
|
|
164
|
+
}[keyof Groups] & string;
|
|
165
|
+
/**
|
|
166
|
+
* An untyped `{name}` is text, so it takes a string. A number never arrives
|
|
167
|
+
* unformatted: it declares `:number` or `:plural` and the locale prints it.
|
|
168
|
+
*
|
|
169
|
+
* A message asking for nothing resolves to `unknown`, the neutral element of an
|
|
170
|
+
* intersection, where `{}` would survive one: `RichValuesFor` intersects this
|
|
171
|
+
* with a function per span, and a sentence carrying a `<link>` and no
|
|
172
|
+
* placeholder has to come out as exactly that function.
|
|
173
|
+
*/
|
|
174
|
+
type ValuesForParams<Params extends string, Enums> = [Params] extends [never] ? unknown : {
|
|
175
|
+
[Param in Params as Param extends `${infer Name}:${string}` ? Name : Param]: Param extends `${infer Name}:${infer Type}` ? ValueForParam<Type, Name, Enums> : string;
|
|
176
|
+
};
|
|
177
|
+
export type ValuesFor<Translation> = ValuesForParams<ParamsIn<MessageOf<Translation>> | ParamsIn<AlternativesIn<OptionsOf<Translation>>>, EnumsOf<OptionsOf<Translation>>>;
|
|
178
|
+
/**
|
|
179
|
+
* The names a message marks a span with — `Read the <link>terms</link>` names
|
|
180
|
+
* `link`. Closing tags are skipped rather than collected: they carry the same
|
|
181
|
+
* name, and a name is wanted once.
|
|
182
|
+
*/
|
|
183
|
+
type TagsIn<Message extends string> = Message extends `${string}<${infer Tag}>${infer Rest}` ? Tag extends `/${string}` ? TagsIn<Rest> : Tag | TagsIn<Rest> : never;
|
|
184
|
+
/**
|
|
185
|
+
* What a rich rendering asks for: the message's values, plus one function per
|
|
186
|
+
* marked span. The function decides what a span becomes — a React element, an
|
|
187
|
+
* HTML string, a terminal escape — which is why nothing here names a framework.
|
|
188
|
+
*/
|
|
189
|
+
export type RichValuesFor<Translation, Node> = ValuesFor<Translation> & {
|
|
190
|
+
[Tag in TagsIn<MessageOf<Translation>>]: (children: string) => Node;
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* A key whose message carries no placeholder, so it renders from nothing but
|
|
194
|
+
* itself. It is what a lookup table or a piece of state may hold: a key needing
|
|
195
|
+
* values cannot be translated by whoever ends up reading it back.
|
|
196
|
+
*/
|
|
197
|
+
export type PlainKey<T> = {
|
|
198
|
+
[Path in DotPath<T>]: keyof ValuesFor<LeafAt<T, Path>> extends never ? Path : never;
|
|
199
|
+
}[DotPath<T>];
|
|
200
|
+
export type ParameterizedKey<T> = {
|
|
201
|
+
[Path in DotPath<T>]: keyof ValuesFor<LeafAt<T, Path>> extends never ? never : Path;
|
|
202
|
+
}[DotPath<T>];
|
|
203
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference dictionary — the one every key and every value is typed from.
|
|
3
|
+
*
|
|
4
|
+
* A function rather than a type to `satisfies`, because a type describing a
|
|
5
|
+
* dictionary has to reach its members through an index signature, and their
|
|
6
|
+
* type then cannot depend on the message written at each key.
|
|
7
|
+
*/
|
|
8
|
+
export const defineDictionary = (dictionary) => dictionary;
|
package/dist/index.d.ts
ADDED