@echo-lingua/i18n
v0.3.0
Published
A tiny JS/TS runtime for Echo Lingua translations: the _() marker with placeholders, plurals, and rich text. Extraction and compilation live in @echo-lingua/i18n-cli.
Maintainers
Readme
@echo-lingua/i18n
The JS/TS runtime for Echo Lingua translations. You wrap UI text in _(); at run time _() renders
the active locale's translation, with placeholders, plurals, and rich text. Zero heavy dependencies,
browser-safe, and tree-shakeable.
This package is the runtime only, and it is optional. It is designed to plug into and sit alongside
established translation runtimes rather than replace them, so outside JS/TS you keep your own
(gettext(), Laravel __(), and the like). The other pieces of the toolchain:
@echo-lingua/i18n-cli: the project-agnostic extractor and compiler (theecho-lingua-i18ncommand). It scans source (JS/TS and PHP/.phtml), hydrates gettext POs, and compiles them to the output your project consumes: this JS catalog, a binary.mofor native gettext, or Laravel JSON.@echo-lingua/i18n-core: the shared, dependency-free primitives (flag vocabulary, CLDR plural tables, catalog key + types) both halves agree on.- Translation itself is the Echo Lingua API, run between extract and compile.
So the pipeline is echo-lingua-i18n extract → Echo Lingua API → echo-lingua-i18n compile, which
writes the catalog this runtime loads. For extraction, config, the PO format, and the .mo/Laravel
targets, see @echo-lingua/i18n-cli.
One config, owned by the CLI. There is a single i18n.config.* file for the whole toolchain, and
@echo-lingua/i18n-cli is the only thing that reads it. This runtime takes no configuration: it loads
the catalog the CLI generated plus whatever you pass to initI18n. So installing both packages still
means one config file. The only setting with a runtime counterpart is split: when it is on, load with
initLazyI18n instead of initI18n (see Runtime setup).
The _() marker
import { _, EL } from '@echo-lingua/i18n';
_('Save changes'); // "Save changes"
_('Hello {name}', { name: 'Tom' }); // "Hello Tom"
_('Click {link}', { link: <a href="/x">here</a> }); // ["Click ", <a/>] (rich text)
// Plurals. The count key selects the form (CLDR, via Intl.PluralRules).
_('There is a file', 'There are {count} files', { count: 5 });
// Options tail: { flags, context, comment }. Use {} for values when there are none.
_('Save', {}, { flags: [EL.CONTEXT_UI_LABEL] });
_('Order', {}, { context: 'verb' }); // real gettext msgctxt, for disambiguation
_('There is a file', 'There are {count} files', { count }, {
flags: [EL.QUALITY_PRO, EL.CONTEXT_LEGAL],
context: 'file list',
comment: 'Shown under the section title', // #. guidance for the model
});_() calls nest. A placeholder value can itself be a _() call, so you can translate the
label inside a link without splitting the sentence:
_('Click {link} to continue', { link: <a href="/docs">{_('here')}</a> });The extractor finds both calls, so Click {link} to continue and here each become their
own entry. At run time the inner _('here') resolves first and its text renders inside the
<a>, while the outer call keeps {link} as a placeholder.
Notes:
- The return type is
string, or(string | node)[]when a placeholder value is a node. - The message must be a string literal (single or double quotes, or a backtick with no
interpolation). A non-literal argument (a variable, a concatenation, or a
${...}template) cannot be extracted, so the extractor skips it with a warning instead of failing the build. Pass the dynamic part as a{placeholder}, or provide the literal elsewhere if you look it up at run time. - The
valueskeys are type-checked against the{tokens}in the message. ELexposes only applied flags (context, quality, cache, lock, ignore, translate-quotes, translate-attrs). Two flags from the same group fail the build. The flag vocabulary lives in@echo-lingua/i18n-coreand is re-exported here, so you importELfrom@echo-lingua/i18n.
What the runtime loads (the compiled catalog)
echo-lingua-i18n compile (its default js target) writes the catalog keyed by msgid, with a
message for a singular entry and plurals (in CLDR form order) for a plural one.
Single file (the CLI's default), one module for every locale:
// src/locales/generated.ts
import type { Catalog } from '@echo-lingua/i18n';
export const translations: Catalog = {
"fr": {
"Hello {name}": { "message": "Bonjour {name}" },
"Save changes": { "message": "Enregistrer les modifications" },
"There is a file": { "plurals": ["Il y a un fichier", "Il y a {count} fichiers"] }
}
};Per-locale (when the CLI is configured with split: true), a loaders index plus one code-split chunk per locale:
// src/locales/generated.ts (index)
import type { LocaleCatalog } from '@echo-lingua/i18n';
export const locales = ["fr"] as const;
export const loaders: Record<string, () => Promise<{ default: LocaleCatalog }>> = {
"fr": () => import("./fr.js"),
};Runtime setup
Single file (default). One module bundles every locale. Simplest, and fine for small to medium catalogs:
import { initI18n, detectLocale } from '@echo-lingua/i18n';
import { translations } from './locales/generated';
initI18n(translations, { locale: detectLocale(Object.keys(translations)) ?? 'en' });Per-locale with lazy loading (from the CLI's split: true output). The index gives you loaders and
locales, and only the active locale's chunk is fetched. Use this when catalogs grow large or you have
many locales:
import { initLazyI18n, loadLocale, detectLocale } from '@echo-lingua/i18n';
import { loaders, locales } from './locales/generated';
initLazyI18n({ loaders, locale: 'en', sourceLocale: 'en' });
await loadLocale(detectLocale(locales) ?? 'en'); // dynamically imports one chunkAn untranslated or not-yet-loaded string falls back to the source text, so a missing entry renders the English rather than a blank or a raw key.
Reactivity
setLocale(locale) switches language and notifies subscribers; subscribe(listener) lets a
framework re-render on a switch (and on the inline editor's live preview). getLocale /
getSourceLocale read the current state. These keep _() a plain function with no per-call context.
