# Vanilla JavaScript

Use @comvi/core directly in any JavaScript project: create the i18n instance, await init(), translate with t(), switch the locale and re-render from events.

Canonical: https://comvi.io/docs/i18n/vanilla

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](https://stackblitz.com/github/comvi-io/comvi-js/tree/main/examples/vanilla) to see the switcher, plurals, a link inside a translated string and lazy catalogs, with no framework.

## Install
**npm**

```bash
npm install @comvi/core
```

**pnpm**

```bash
pnpm add @comvi/core
```

**yarn**

```bash
yarn add @comvi/core
```

**bun**

```bash
bun 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.

## Setup

`src/i18n.ts`

```ts

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](/docs/i18n/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

`src/switch.ts`

```ts

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

`src/i18n.ts`

```ts

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](/docs/i18n/connect-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](/docs/platform/cdn#url-format).
If your keys use ICU (`{count, plural, …}`), add `compiler: icuCompiler` as on
[Pluralize with ICU](/docs/i18n/pluralization). The platform and the compiler are independent:
either works without the other.
## 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

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](/docs/i18n/ssr).

## Update the DOM

`index.html`

```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`

```ts

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](/docs/i18n/api/core#events).

> **Never assign a translation to innerHTML**
>
> Use `textContent` and DOM methods such as `replaceChildren()`. For markup inside a translation, parse it
> with `prepareTranslation` from `@comvi/core/rich-text` and build real nodes, as on [Tag interpolation](/docs/i18n/tag-interpolation).
> Importing `@comvi/core/tags` once makes `<tag>` syntax available to plain `t()` process-wide.

## Instance lifetime

`src/teardown.ts`

```ts

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

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

| 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](/docs/i18n/troubleshooting).

## Read next

- [Connect to Comvi](/docs/i18n/connect-comvi): the CDN URL, and a wording change that ships without a redeploy.
- [Load translations on demand](/docs/i18n/loading-translations): `.use(loader(map))`, namespaces and caching.
- [Core API reference](/docs/i18n/api/core): every member of the instance, the events and the capability subpaths.
