Vanilla JavaScript
With a plain JavaScript or TypeScript app and no framework, @comvi/core is the whole library:
one i18n instance, await init(), t() for every string on screen, and events to tell you when to
re-render.
Open the Vanilla example in StackBlitz to see the switcher, plurals, a link inside a translated string and lazy catalogs, with no framework.
Install
Section titled “Install”npm install @comvi/corepnpm add @comvi/coreyarn add @comvi/corebun add @comvi/core@comvi/core peer-depends on nothing and is the package every binding brings. It is ESM; a
<script src> global build exists too, as ComviCore.I18n, with every capability composed.
import { createI18n } from '@comvi/core';
export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en', messages: { en: { greeting: 'Hello, {name}!' }, uk: { greeting: 'Привіт, {name}!' }, },});
await i18n.init();
i18n.t('greeting', { name: 'Alice' }); // "Hello, Alice!"createI18n() returns the i18n instance with the inline catalog already in its cache. init()
runs the plugins, loads the default namespace, sets isInitialized and resolves to the instance.
t() always returns a string, with {name} filled from its second argument; tRaw() returns the
structured result when a translation carries tags. The catalog options are on the
Quick start.
Top-level await needs a build target that has it. On Vite 5 and 6 set
build.target: 'es2022' in vite.config.ts, or wrap the calls in an async function.
Switch the locale
Section titled “Switch the locale”import { getTextDirection } from '@comvi/core';import { i18n } from './i18n';
await i18n.setLocale('uk');console.log(i18n.t('greeting', { name: 'Аліса' })); // "Привіт, Аліса!"
i18n.locale = 'en';document.documentElement.dir = getTextDirection(i18n.locale);The instance has a locale getter and setter and setLocale(), which returns a promise. The
setter is fire-and-forget and reports a failure through the loadError event. With a loader
composed, setLocale() loads the active namespaces before the locale
applies, and the latest of several rapid calls wins. Text direction is the free function
getTextDirection(), not a property. formatNumber, formatDate, formatCurrency and
formatRelativeTime are free functions too and take the instance as their first argument.
Load from Comvi
Section titled “Load from Comvi”import { createI18n } from '@comvi/core';import { cdnLoader } from '@comvi/plugin-fetch-loader';
export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en',}).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));
await i18n.init();
i18n.t('items', { count: 5 }); // "5 items"Install @comvi/plugin-fetch-loader and compose .use(cdnLoader()) before init(). The
installer attaches the loader capability at once, and the CDN plugin runs inside init(), which
fetches <cdnUrl>/en.json for the default namespace. The rest of the walkthrough is on
Connect to Comvi; icuCompiler comes from @comvi/core/icu.
The CDN endpoint URL is under Settings → Content Deploy in your Comvi project, once Content
Deploy is enabled and saved; pass it as cdnUrl. The URL format is on
CDN Deployment.
If your keys use ICU ({count, plural, …}), add compiler: icuCompiler as on
Pluralize with ICU. The platform and the compiler are independent:
either works without the other.
First paint
Section titled “First paint”The first fetch happens inside await init(), so there is no gate to write: nothing calls t()
before the catalog is in place. A t() that runs before init() resolves finds an empty cache and
returns the key.
Server-side rendering
Section titled “Server-side rendering”On a server, build a fresh instance per request, because a module-scope instance has a mutable locale shared by every concurrent render. See Render on the server.
Update the DOM
Section titled “Update the DOM”<h1 data-i18n="hello.world"></h1><p data-i18n="welcome" data-i18n-params='{"name":"Alice"}'></p>
<select id="language-switcher"> <option value="en">English</option> <option value="de">Deutsch</option></select>import { createI18n, getTextDirection } from '@comvi/core';
const i18n = createI18n({ locale: 'en', fallbackLocale: 'en', messages: { en: { 'hello.world': 'Hello, World!', welcome: 'Welcome, {name}!' }, de: { 'hello.world': 'Hallo, Welt!', welcome: 'Willkommen, {name}!' }, },});
function translateDOM() { for (const el of document.querySelectorAll('[data-i18n]')) { const key = el.getAttribute('data-i18n')!; const paramsAttr = el.getAttribute('data-i18n-params'); el.textContent = i18n.t(key, paramsAttr ? JSON.parse(paramsAttr) : undefined); }
document.documentElement.lang = i18n.locale; document.documentElement.dir = getTextDirection(i18n.locale);}
i18n.on('localeChanged', translateDOM);
document.getElementById('language-switcher')!.addEventListener('change', (event) => { void i18n.setLocale((event.target as HTMLSelectElement).value);});
await i18n.init();translateDOM();Without a framework’s reactivity, events are how you learn that state changed, and you re-render in
the handler. localeChanged fires after the new catalog is loaded and the locale applied, so
translateDOM() reads complete text. Every on() call returns an unsubscribe function. The events
are initialized, destroyed, localeChanged, defaultNamespaceChanged, messagesCleared,
loadingStateChanged, namespaceLoaded, missingKey, loadError and configChanged; their
payloads are on the Core API reference.
Instance lifetime
Section titled “Instance lifetime”import { createI18n } from '@comvi/core';
const i18n = createI18n({ locale: 'en' });await i18n.init();
await i18n.destroy();destroy() tears the instance down: capability cleanup, the destroyed event, then every
subscription and the cache. Call it between test cases or when a micro-frontend unmounts; a
destroyed instance cannot be re-initialized. The same events API drives a Web Component: render in
connectedCallback, subscribe to localeChanged, unsubscribe in disconnectedCallback.
How it works
Section titled “How it works”The instance is a translation cache keyed locale:namespace, a current locale, a fallback chain,
a set of active namespaces and an event emitter. createI18n() builds the base instance and puts
the messages option into the cache at once. Everything else is a capability composed with
.use(): loader(map) or cdnLoader() for loading, plugins() for a plugin queue. A base
instance has no loader, so ns: ['admin'] on one loads nothing, and development warns about it at
init().
init() raises isInitializing and emits loadingStateChanged, runs the queued plugins (a locale
detector may change the locale here, before anything loads), then loads the namespaces named in
ns, or the default namespace, through the loader for the current locale and for each locale in
fallbackLocale, skipping any already in the cache. It then sets isInitialized, emits
initialized and resolves to the instance. A load that fails for every requested namespace of the
current locale rejects init() and reports through onError; a fallback catalog that fails is
reported only.
t() looks the key up in the current locale, then in each fallback locale in order, and parses
each message once, keeping the parsed form. When nothing matches it emits missingKey and returns the key
itself, so a raw key on screen means a missing translation. params.fallback replaces the key for
one call, a plugin’s onMissingKey can supply a value, and strict: 'dev' adds a console warning
in development.
setLocale() with a loader composed loads every active namespace for the new locale before it
assigns the locale and emits localeChanged, so the handler in translateDOM() never reads a
half-loaded catalog. Overlapping calls are tracked by a change id: the latest wins, and a superseded
call neither applies its locale nor surfaces its error. The locale setter is the same call with
the rejection routed to the loadError event instead.
The loader runs one request per locale:namespace at a time; concurrent callers for the same pair
share it. A finished load writes the catalog to the cache and emits namespaceLoaded; a failed one
emits loadError and rejects. A load whose scope was cleared or destroyed while in flight writes
nothing and reports nothing. cdnLoader fetches <cdnUrl>/<locale>.json for the default namespace
and <cdnUrl>/<namespace>/<locale>.json for any other, under a timeout, with no retry: the fallback
chain already sits in front of it.
Every error reaches reportError(), which calls the onError option when you passed one and
otherwise warns to the console in development. Each error object is reported once per instance, so
a rejection you also catch and log is not printed twice.
Errors
Section titled “Errors”| what happened | message |
|---|---|
init() on an instance after destroy() |
[@comvi/core] Cannot call init() after destroy(). Create a new i18n instance. |
reloadMessages() on an instance with no loader registered |
[@comvi/core] No loader registered. Cannot reload messages. |
More messages and their fixes are on Troubleshooting.
Read next
Section titled “Read next”- Connect to Comvi: the CDN URL, and a wording change that ships without a redeploy.
- Load translations on demand:
.use(loader(map)), namespaces and caching. - Core API reference: every member of the instance, the events and the capability subpaths.