@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 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;
@@ -0,0 +1,5 @@
1
+ export * from './create-i18n.ts';
2
+ export * from './define-translation.ts';
3
+ export * from './dictionary.ts';
4
+ export * from './negotiate-locale.ts';
5
+ export * from './translator.ts';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './create-i18n.js';
2
+ export * from './define-translation.js';
3
+ export * from './dictionary.js';
4
+ export * from './negotiate-locale.js';
5
+ export * from './translator.js';