Reference index · 0.38 kB gzip · depends on
@firsthandjs/core
Makes a translation function reactive. Guide: Internationalisation.
function fromI18next<T extends Translate>(instance: I18nextLike<T>): Translator<T>;
import i18next from 'i18next';
import { fromI18next } from '@firsthandjs/i18n';
await i18next.init({ lng: 'en', resources });
export const { t, language } = fromI18next(i18next);
Connects an i18next instance. The returned t has the same type as the one
you passed, including the key and interpolation types a typed resource table
gives it: it is called through, not wrapped in a new signature.
It listens for four things, because four things change the answer:
| Event | Where it is announced | What it is |
|---|---|---|
languageChanged |
The instance | The obvious one |
loaded |
The instance | A backend finished fetching a namespace |
added |
The resource store | addResource at runtime |
removed |
The resource store | A resource taken away again |
Listening only to the instance is the mistake that makes addResource look as
if it did nothing, and that leaves a key rendered as itself forever after a
namespace arrives late. The store is optional — an instance without one, such
as a mock in a test, is handled.
The parameter is declared structurally, so this package does not depend on
i18next, has no opinion about its version, and takes anything that looks like
it: createInstance(), a fork, or a test double.
function translator<T extends Translate>(source: Source<T>): Translator<T>;
interface Source<T extends Translate> {
translate: T;
language: () => string;
subscribe: (changed: () => void) => Dispose;
}
import { translator } from '@firsthandjs/i18n';
import { IntlMessageFormat } from 'intl-messageformat';
const { t, language } = translator({
translate: (key: string, values?: Record<string, string>) =>
new IntlMessageFormat(messages[current][key], current).format(values),
language: () => current,
subscribe: (changed) => {
emitter.on('locale', changed);
return () => emitter.off('locale', changed);
},
});
The same thing for any other library. subscribe is called once and returns
the way to stop; changed may be called as often as the library likes.
“Could answer differently” is wider than “the language changed.” A backend that loads a namespace late changes the answer too, and so does a resource added at runtime. Report all of them: a wrong extra update costs one re-read of one part, and a missing one leaves the wrong text on the screen.
interface Translator<T extends Translate> {
t: T;
language: ReadonlyCell<string>;
dispose: Dispose;
}
t is the library’s own function with its own types, which now subscribes
whatever reads it.
language is a cell, so a part that shows the current locale beside a
translated title updates with it — and both update once, because the two
cells are written in one batch.
dispose stops listening. It is registered with the enclosing scope
automatically when there is one, so a translator created inside a component is
cleaned up with that component. Setting translations up at module level is the
normal case and nothing owns that, so it lives as long as the page; dispose
is there for the cases that need it.
A version signal, a language signal and a function that reads one before calling through. A part that renders a translation has read the signal, so a language change re-runs exactly the parts that translated something — the same mechanism as any other signal, with no provider tower and no context lookup.
There is no dictionary here, no plural rules, no number or date formatting and no message parser. Those libraries exist and are good; this is the connection between them and the graph.