# Write a plugin

Write your own Comvi plugin: the contract, registering it with definePlugin, the loader, event, post-processor and detector hooks, and a .use() installer.

Canonical: https://comvi.io/docs/i18n/plugins/custom-plugins

With `@comvi/core` installed and an i18n instance from the [Quick start](/docs/i18n/quick-start),
write a plugin when you need a custom loader, an event listener or a post-processor. A plugin
is a function of the i18n instance that may return a cleanup function, and the
[plugin overview](/docs/i18n/plugins) has the lifecycle it runs in.

## The plugin contract

`src/plugins/logger.ts`

```ts

export const Logger: I18nPluginFactory<{ prefix?: string }> = (options = {}): I18nPlugin =>
function LoggerPlugin(i18n) {
    const prefix = options.prefix ?? '[locale]';
    const unsubscribe = i18n.on('localeChanged', ({ from, to }) => {
      console.log(`${prefix} ${from} → ${to}`);
    });

    return unsubscribe; // runs on i18n.destroy()
};
```

`src/i18n.ts`

```ts

export const i18n = createI18n({ locale: 'en' }).use(
definePlugin(Logger({ prefix: '[app]' })),
);

await i18n.init();
```

The factory takes options and runs at the call site; the plugin function it returns runs
during `init()`, with the i18n instance as its one argument. Validate options in the factory
and throw there, before any registration option applies. Name the plugin function: the error
pipeline reports `pluginName` from it, and an anonymous arrow shows up as `"anonymous"`.
`definePlugin(fn, options?)` composes the plugin capability if the instance lacks it, queues
`fn` and returns the instance; on an instance that already composed `.use(plugins())`,
`i18n.registerPlugin(fn, options?)` does the same. Errors thrown by the plugin function are
governed by `required`, `timeout` and `onError` on
[Registration options](/docs/i18n/plugins#registration-options); cleanup errors are reported
with `source: 'plugin-cleanup'` and never stop the remaining cleanups.

> **Return nothing or a cleanup function**
>
> Any other return value throws `E_PLUGIN_INIT_RETURN` at `init()` and registers nothing.
> The usual cause is an expression-bodied arrow that returns its own assignment:
> 
> ```ts title="src/plugins/wrong.ts" check=expect-error
> 
> let ready = false;
> 
> // Returns `true`. Write a statement body instead.
> export const ReadyFlag: I18nPlugin = (i18n) => (ready = true);
> ```

## Hooks

`src/plugins/api-loader.ts`

```ts

export function ApiLoader(options: { apiUrl: string }): I18nPlugin {
if (!options?.apiUrl) throw new Error('ApiLoader requires apiUrl');

return function ApiLoaderPlugin(i18n) {
    i18n.registerLoader(async (locale, namespace) => {
      const response = await fetch(`${options.apiUrl}/${locale}/${namespace}.json`);
      if (!response.ok) throw new Error(`Failed to load ${locale}:${namespace}`);
      return response.json();
    });
};
}
```

`src/i18n.ts`

```ts

export const i18n = createI18n({ locale: 'en' })
.use(attachLoader)
.use(definePlugin(ApiLoader({ apiUrl: 'https://example.com/i18n' })));

await i18n.init();
```

`registerLoader` takes a `(locale, namespace) => Promise<catalog>` function and belongs to
the loader capability, so the instance composes `.use(attachLoader)` as well. Handing it an
object throws `E_REGISTER_LOADER_ARG`; for a static import map compose `.use(loader(map))`
instead, or wrap it with `createImportMapLoader(map, () => i18n.getDefaultNamespace())`
from `@comvi/core/loader`.

`src/plugins/hooks.ts`

```ts

declare const analytics: { track(event: string, payload: Record<string, string>): void };
declare function readAcceptLanguage(): string;

export function Analytics(): I18nPlugin {
return function AnalyticsPlugin(i18n) {
    return i18n.on('missingKey', ({ key, locale, namespace }) => {
      analytics.track('i18n_missing_key', { key, locale, namespace });
    });
};
}

export function Uppercase(): I18nPlugin {
return function UppercasePlugin(i18n) {
    i18n.registerPostProcessor((result) => {
      if (typeof result !== 'string') return result;
      return result.toUpperCase();
    });
};
}

export function LoudMissingKeys(): I18nPlugin {
return function LoudMissingKeysPlugin(i18n) {
    return i18n.onMissingKey((key, locale, namespace) => `⟨${namespace}:${key}@${locale}⟩`);
};
}

export function HeaderLocale(): I18nPlugin {
return function HeaderLocalePlugin(i18n) {
    i18n.registerLocaleDetector(() => readAcceptLanguage());
};
}
```

- `i18n.on(event, handler)` returns its own unsubscribe function, which is a valid cleanup.
The events and their payloads are on [Handle missing keys and errors](/docs/i18n/error-handling#events).
- Post-processors run in registration order, each receiving the previous result together
with the key, the namespace and the call's params.
- Every `onMissingKey` callback runs, and the first defined return value is the fallback. A
`fallback` passed to `t()` outranks it, and the `onMissingKey` constructor option runs
last.
- `registerLocaleDetector(fn)` installs the function `init()` consults after the plugin
queue has drained; a different locale is applied through `setLocale()`. Only one
detector is held, so a second registration replaces the first.
- `setPluginData(key, data)` and `getPluginData<T>(key)` are a string-keyed store that lives
as long as the instance, for publishing resolved configuration to a helper outside the
plugin. All of these live on the plugin capability.

## Write an installer

`src/plugins/api-loader-installer.ts`

```ts

export function apiLoader(options: { apiUrl: string }) {
return <T extends I18n<any>>(i18n: T): T & I18nLoaderApi & I18nPluginApi => {
    const instance = attachLoader(attachPlugins(ensureInstallable(i18n, 'apiLoader'))); // plugins first, as the shipped installers do
    instance.registerPlugin(ApiLoader(options));
    return instance;
};
}
```

`src/main.ts`

```ts

export const app = createI18n({ locale: 'en' }).use(
apiLoader({ apiUrl: 'https://example.com/i18n' }),
);

await app.init();
```

An installer composes what its plugin needs and then registers it, so a caller gets one
`.use()` step instead of three. It receives the i18n instance, runs at `.use()` time, and
returns the same instance with the capability types added. Four rules keep it correct:

1. `ensureInstallable(i18n, name)` is the innermost call. It throws `E_INSTALLER_AS_PLUGIN`
   when the function runs as a plugin, under `registerPlugin(apiLoader(…))`, before anything
   is attached; outside plugin initialization it returns the instance untouched.
2. Attach what the plugin needs and nothing more. Every `attach*` is idempotent.
3. End in `registerPlugin(…)`, so registration options, the timeout and LIFO cleanup keep
   running inside the plugin capability.
4. Return the widened instance. `.use()` is a pipe, `i18n.use(f) === f(i18n)`, and it throws
   `E_USE_NOT_INSTALLER` when anything else comes back.

### The mirror mistake

A raw plugin handed to `.use()` is refused with `E_USE_NOT_INSTALLER`, because it never
returns the instance. The refusal comes after the call, though. The plugin body has already
run outside the `init()` drain loop, so `required`, `timeout` and `onError` never applied, its
return value was discarded rather than kept for `destroy()`, and an `async` plugin's promise
was never awaited. A plugin such as `Logger` only calls `i18n.on`, which every instance has, so
it subscribes and then leaks. Wrap your own plugin in `definePlugin(…)`, and hand a package's
`Plugin`-suffixed factory only to `registerPlugin(…)`.

## Read next

- [Overview](/docs/i18n/plugins): the lifecycle and the registration options.
- [Handle missing keys and errors](/docs/i18n/error-handling): every event and its payload.
- [Plugins API](/docs/i18n/api/core-plugins): every member the plugin capability adds.
