Skip to content
Comvi

Vanilla JavaScript

View .md

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.

Terminal window
npm install @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.

src/i18n.ts
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.

src/switch.ts
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.

src/i18n.ts
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.

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.

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.

index.html
<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>
src/main.ts
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.

src/teardown.ts
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.

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.

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.