Custom modules
Custom modules let you extend Messagevisor runtime behavior without changing datafiles or authored message files. Use them when your app needs project-specific formatting, post-processing, diagnostics, or runtime integrations.
When to write a module#
Write a module when behavior belongs at message evaluation time:
- custom placeholder syntax
- pseudo-localization
- markdown or sanitizer transforms
- observability hooks for missing or deprecated translations
- custom diagnostics
- feature flag or experiment resolver integration
- message-specific behavior driven by
meta
If you need to add a CLI workflow, write a plugin. If you need to read another authoring file format, write a parser. If the behavior changes evaluated translation output, a module is usually the right extension point.
Register modules#
Register modules in messagevisor.config.js so CLI evaluation, examples, tests, and catalog output use them:
const { createICUModule } = require("@messagevisor/module-icu");const { createPseudoLocaleModule } = require("./modules/pseudo-locale");module.exports = { modules: [createICUModule(), createPseudoLocaleModule()],};Register the same runtime modules on your SDK instance:
import { createMessagevisor } from "@messagevisor/sdk";import { createICUModule } from "@messagevisor/module-icu";import { createPseudoLocaleModule } from "./modules/pseudo-locale";const m = createMessagevisor({ datafile, modules: [createICUModule(), createPseudoLocaleModule()],});The CLI and the app are separate runtimes. If one has a module and the other does not, messages can pass tests but render differently in production.
Module shape#
import type { MessagevisorFormatPayload, MessagevisorModule, MessagevisorModuleApi, MessagevisorTransformPayload,} from "@messagevisor/sdk";export interface MessagevisorModule { name?: string; setup?: (api: MessagevisorModuleApi) => void; format?: (payload: MessagevisorFormatPayload, api?: MessagevisorModuleApi) => unknown; transform?: (payload: MessagevisorTransformPayload, api?: MessagevisorModuleApi) => unknown; close?: () => void | Promise<void>;}| Hook | When it runs | Typical use |
|---|---|---|
setup | when the module is registered | subscribe to diagnostics, register flag or variation resolvers |
format | while formatting a translation | interpolation, ICU, named syntax, rich-text handling |
transform | after all formatting modules have had a chance | markdown, sanitizer, pseudo-localization, output wrappers |
close | when the SDK instance closes | unsubscribe external listeners, flush buffers, close clients |
format and transform hooks can return any value. Returning undefined keeps the current translation unchanged. Returning any other value passes that value to the next module.
For portable modules that should work across future SDKs, treat string input and string output as the baseline. JavaScript modules may return arrays, framework nodes, or other rich values, but those are JavaScript-enhanced capabilities. A module intended for Python, Java, Swift, PHP, Ruby, Go, and JavaScript should document when it only supports string output.
Execution order#
Modules run in the order you register them:
const m = createMessagevisor({ datafile, modules: [createInterpolationModule(), createICUModule(), createMarkdownModule()],});For each evaluation:
- Messagevisor finds the raw message.
- Every module with
formatruns in registration order. - Every module with
transformruns in registration order. - The final value is returned from
translate()orformatMessage().
This means a module that produces ICU syntax should run before the ICU module, while a module that sanitizes final HTML should usually run after formatting.
Payloads#
format receives:
interface MessagevisorFormatPayload { translation: unknown; values?: MessageValues; locale: string; source: "translation" | "formatMessage"; messageKey?: string; meta?: Record<string, unknown>; formats: FormatPresets; moduleOptions?: Record<string, unknown>;}transform receives:
interface MessagevisorTransformPayload { translation: unknown; locale: string; source: "translation" | "formatMessage"; messageKey?: string; meta?: Record<string, unknown>;}Important payload details:
source: "translation"means the call came fromtranslate()/t().source: "formatMessage"means the call came from formatting a literal string.messageKeyandmetaare only available for keyed translations.formatsis available toformathooks and contains the resolved locale+target+runtime format preset object.moduleOptionsis available toformathooks and contains the per-call object passed totranslate()orformatMessage().
Minimal transform module#
import type { MessagevisorModule } from "@messagevisor/sdk";export function createUppercaseModule(): MessagevisorModule { return { name: "uppercase", transform({ translation }) { if (typeof translation !== "string") { return; } return translation.toUpperCase(); }, };}m.addModule(createUppercaseModule());m.translate("nav.home"); // "HOME"m.removeModule("uppercase");Use name when you want duplicate detection, removeModule(name), or name-keyed moduleOptions.
Names and runtime management#
Named modules can be managed after instance creation:
m.addModule(createUppercaseModule());m.removeModule("uppercase");If another registered module already uses the same name, Messagevisor reports a duplicate_module diagnostic and does not register the duplicate. Anonymous modules are allowed, but they cannot be removed by name and cannot receive name-keyed moduleOptions.
Custom placeholder module#
Use format when you need access to values.
import type { MessagevisorModule } from "@messagevisor/sdk";export function createPercentPlaceholderModule(): MessagevisorModule { return { name: "percent-placeholders", format({ translation, values }) { if (typeof translation !== "string") { return; } return translation.replace(/%\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, key) => { const value = values?.[key]; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return String(value); } return match; }); }, };}m.formatMessage("Hello %{name}", { name: "Ada" });// "Hello Ada"For production use, prefer the built-in Interpolation module unless your project needs custom behavior.
Pseudo-localization module#
Pseudo-localization is useful for UI expansion checks before translations are ready.
import type { MessagevisorModule } from "@messagevisor/sdk";const map: Record<string, string> = { a: "á", e: "é", i: "í", o: "ó", u: "ú", A: "Á", E: "É", I: "Í", O: "Ó", U: "Ú",};export function createConfigurablePseudoLocaleModule(): MessagevisorModule { return { name: "pseudo", transform({ translation, locale }) { if (locale !== "en-XA" || typeof translation !== "string") { return; } const expanded = translation .split("") .map((character) => map[character] || character) .join(""); return `[!! ${expanded} !!]`; }, };}Markdown or sanitizer transform#
Use transform for final-output post-processing.
import type { MessagevisorModule } from "@messagevisor/sdk";export function createMarkdownModule(markdownToHtml: (input: string) => string): MessagevisorModule { return { name: "markdown", transform({ translation, meta }) { if (typeof translation !== "string" || meta?.format !== "markdown") { return; } return markdownToHtml(translation); }, };}The meta check keeps markdown processing opt-in per message.
description: Terms link textmeta: format: markdowntranslations: en: "Read **terms**"Rich output#
Modules are not limited to strings. Rich-text integrations can return arrays, framework nodes, or any other renderable value that the caller expects.
When handling rich output:
- check the current value type before transforming it
- return
undefinedfor values the module does not own - avoid stringifying arrays or framework nodes unless that is the module's purpose
- let framework packages such as React and Vue handle their own renderable output where possible
This is why most string-only modules start with:
if (typeof translation !== "string") { return;}Per-call module options#
Modules can read options by their module name:
import type { MessagevisorModule } from "@messagevisor/sdk";interface PseudoOptions { enabled?: boolean; wrapper?: string;}export function createPseudoLocaleModule(): MessagevisorModule { const name = "pseudo"; return { name, format({ translation, moduleOptions }) { const options = moduleOptions?.[name] as PseudoOptions | undefined; if (!options?.enabled || typeof translation !== "string") { return; } const wrapper = options.wrapper || "!!"; return `[${wrapper} ${translation} ${wrapper}]`; }, };}m.translate("nav.home", undefined, { moduleOptions: { pseudo: { enabled: true, wrapper: "###", }, },});Name-keyed options let one evaluation path change module behavior without changing the global SDK instance.
Diagnostics module#
Modules can subscribe to diagnostics during setup. Module diagnostic subscriptions have their own logLevel, independent of the SDK instance log level.
import type { MessagevisorDiagnostic, MessagevisorModule } from "@messagevisor/sdk";export function createMissingTranslationReporter( report: (diagnostic: MessagevisorDiagnostic) => void,): MessagevisorModule { return { name: "missing-reporter", setup({ onDiagnostic }) { onDiagnostic( (diagnostic) => { if (diagnostic.code === "missing_translation") { report(diagnostic); } }, { logLevel: "error" }, ); }, };}Modules can also emit diagnostics:
export function createEmptyTranslationDiagnosticModule(): MessagevisorModule { return { name: "empty-translation", transform(payload, api) { const { translation, locale, messageKey, source } = payload; if (translation !== "") { return; } api?.reportDiagnostic({ level: "warn", code: "empty_translation", message: "Empty translation", locale, messageKey, source, }); }, };}Diagnostics emitted by a named module include the module name. A module does not receive diagnostics that it reports itself.
Feature and experiment resolver module#
Modules can wire feature flag and experiment resolvers into the SDK:
import type { Context } from "@messagevisor/types";import type { MessagevisorModule } from "@messagevisor/sdk";export function createFeaturevisorModule(featurevisor: { isEnabled(featureKey: string, context?: Context): boolean; getVariation(experimentKey: string, context?: Context): string;}): MessagevisorModule { return { name: "featurevisor", setup({ setFlagResolver, setVariationResolver }) { setFlagResolver((featureKey, context) => featurevisor.isEnabled(featureKey, context)); setVariationResolver((experimentKey, context) => featurevisor.getVariation(experimentKey, context), ); }, };}This lets authored override conditions reference feature flags and experiments while the application owns the actual resolver implementation.
Cleanup#
Use close when a module owns resources:
export function createBufferedReporterModule(reporter: { flush(): Promise<void> }): MessagevisorModule { return { name: "buffered-reporter", async close() { await reporter.flush(); }, };}When m.close() is called, modules close in reverse registration order. Messagevisor waits for async cleanup. If one module fails to close, Messagevisor still tries to close the remaining modules and rejects with a close error.
Testing custom modules#
Test modules directly with the SDK:
import { createMessagevisor } from "@messagevisor/sdk";import { createPseudoLocaleModule } from "./pseudo-locale";it("pseudo-localizes en-XA output", function () { const m = createMessagevisor({ locale: "en-XA", defaultTranslations: { "en-XA": { "nav.home": "Home", }, }, modules: [createPseudoLocaleModule()], }); expect(m.translate("nav.home")).toEqual("[!! Hómé !!]");});For CLI behavior, register the same module in messagevisor.config.js and cover it with message examples or tests.
Checklist#
- give modules stable names
- return
undefinedwhen the module does not handle the current value - preserve non-string rich outputs unless the module intentionally handles them
- use
formatwhen you needvalues,formats, ormoduleOptions - use
transformfor final output post-processing - keep module ordering explicit
- register matching modules in config and SDK runtime
- test both keyed translations and
formatMessage()if your module supports both

