Skip to content
Comvi

Write a plugin

View .md

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.

src/plugins/logger.ts
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()
};
src/i18n.ts
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.

src/plugins/api-loader.ts
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();
});
};
}
src/i18n.ts
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.

src/plugins/hooks.ts
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 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.
src/plugins/api-loader-installer.ts
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;
};
}
src/main.ts
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:

  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.

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(…).