Write a plugin
With @comvi/core installed and an i18n instance from the 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 has the lifecycle it runs in.
The plugin contract
Section titled “The plugin contract”import type { I18nPlugin, I18nPluginFactory } from '@comvi/core';
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() };import { createI18n } from '@comvi/core';import { definePlugin } from '@comvi/core/plugins';import { Logger } from './plugins/logger';
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; cleanup errors are reported
with source: 'plugin-cleanup' and never stop the remaining cleanups.
import type { I18nPlugin } from '@comvi/core';
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(); }); };}import { createI18n } from '@comvi/core';import { attachLoader } from '@comvi/core/loader';import { definePlugin } from '@comvi/core/plugins';import { ApiLoader } from './plugins/api-loader';
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.
import type { I18nPlugin } from '@comvi/core';
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.- Post-processors run in registration order, each receiving the previous result together with the key, the namespace and the call’s params.
- Every
onMissingKeycallback runs, and the first defined return value is the fallback. Afallbackpassed tot()outranks it, and theonMissingKeyconstructor option runs last. registerLocaleDetector(fn)installs the functioninit()consults after the plugin queue has drained; a different locale is applied throughsetLocale(). Only one detector is held, so a second registration replaces the first.setPluginData(key, data)andgetPluginData<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
Section titled “Write an installer”import type { I18n, I18nLoaderApi, I18nPluginApi } from '@comvi/core';import { attachLoader } from '@comvi/core/loader';import { attachPlugins, ensureInstallable } from '@comvi/core/plugins';import { ApiLoader } from './api-loader';
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; };}import { createI18n } from '@comvi/core';import { apiLoader } from './plugins/api-loader-installer';
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:
ensureInstallable(i18n, name)is the innermost call. It throwsE_INSTALLER_AS_PLUGINwhen the function runs as a plugin, underregisterPlugin(apiLoader(…)), before anything is attached; outside plugin initialization it returns the instance untouched.- Attach what the plugin needs and nothing more. Every
attach*is idempotent. - End in
registerPlugin(…), so registration options, the timeout and LIFO cleanup keep running inside the plugin capability. - Return the widened instance.
.use()is a pipe,i18n.use(f) === f(i18n), and it throwsE_USE_NOT_INSTALLERwhen anything else comes back.
The mirror mistake
Section titled “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
Section titled “Read next”- Overview: the lifecycle and the registration options.
- Handle missing keys and errors: every event and its payload.
- Plugins API: every member the plugin capability adds.