firsthand

@firsthandjs/i18n

Reference index · 0.38 kB gzip · depends on @firsthandjs/core

Makes a translation function reactive. Guide: Internationalisation.


fromI18next

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.

translator

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.

Translator

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.

What it costs

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.

What it is not

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.