Insert values and markup
Fill {param} placeholders with t() and render tags such as <link> inside a translation
with <T>. Parameters work on any instance; <T> needs one of the framework bindings on top
of the Quick start.
Parameters
Section titled “Parameters”import { useI18n } from '@comvi/react';
export function Welcome({ name }: { name: string }) { const { t } = useI18n(); return <h1>{t('welcome', { name })}</h1>;}<script setup lang="ts">import { useI18n } from '@comvi/vue';
defineProps<{ name: string }>();const { t } = useI18n();</script>
<template> <h1>{{ t('welcome', { name }) }}</h1></template>import { defineNuxtConfig } from 'nuxt/config';
export default defineNuxtConfig({ modules: ['@comvi/nuxt'], comvi: { defaultLocale: 'en', locales: [{ code: 'en', name: 'English' }] },});<script setup lang="ts">defineProps<{ name: string }>();const { t } = useI18n();</script>
<template> <h1>{{ t('welcome', { name }) }}</h1></template><script lang="ts"> import { useI18n } from '@comvi/svelte';
const { name }: { name: string } = $props(); const { t } = useI18n();</script>
<h1>{$t('welcome', { name })}</h1>import { useI18n } from '@comvi/solid';
export function Welcome(props: { name: string }) { const { t } = useI18n(); return <h1>{t('welcome', { name: props.name })}</h1>;}import { createI18n } from '@comvi/core';
const i18n = createI18n({ locale: 'en', messages: { en: { welcome: 'Welcome, {name}!' } } });await i18n.init();
i18n.t('welcome', { name: 'Alice' }); // "Welcome, Alice!"Every {name} in the message is replaced by the value under that key. Strings, numbers and
booleans are pasted in, an array item by item. A value renders as an element only when it is a
node made by the factories in @comvi/core/rich-text, or a framework element passed to <T>;
anything else goes through String().
Missing parameters
Section titled “Missing parameters”import { createI18n } from '@comvi/core';
export const i18n = createI18n({ locale: 'en', missingParam: 'literal', // default; 'drop' renders an empty string instead messages: { en: { greet: 'Hello, {name}!' } },});import { i18n } from './i18n';
await i18n.init();
i18n.t('greet'); // "Hello, {name}!" plus one development warningi18n.t('greet', { name: null }); // "Hello, !"i18n.t('greet', { name: 'Alice' }); // "Hello, Alice!"A parameter that is absent or undefined renders as its own placeholder, and development
warns once per template and parameter. missingParam: 'drop' renders an empty string instead.
An explicit null always renders as an empty string, in both modes.
<T> versus plain t()
Section titled “<T> versus plain t()”import { T } from '@comvi/react';
// "help": "Click <link>here</link> for help"export function Help() { return <T i18nKey="help" components={{ link: <a href="/help" /> }} />;}<script setup lang="ts">import { T } from '@comvi/vue';</script>
<!-- "help": "Click <link>here</link> for help" --><template> <T i18nKey="help"> <template #link="{ children }"> <a href="/help">{{ children }}</a> </template> </T></template><!-- "help": "Click <link>here</link> for help" --><template> <T i18nKey="help"> <template #link="{ children }"> <NuxtLinkLocale to="/help">{{ children }}</NuxtLinkLocale> </template> </T></template><script lang="ts"> import { T } from '@comvi/svelte';</script>
<!-- "help": "Click <link>here</link> for help" --><T i18nKey="help" components={{ link: { tag: 'a', props: { href: '/help' } } }} />import { T } from '@comvi/solid';
// "help": "Click <link>here</link> for help"export function Help() { return <T i18nKey="help" components={{ link: (props) => <a href="/help">{props.children}</a> }} />;}<T> parses the tags on any instance and hands each one to the handler under its name. The
translator moves the tags; your code decides what they render, and no attribute or component
name ever comes from the catalog. Without a component layer there is no <T>:
prepareTranslation from @comvi/core/rich-text returns the parsed parts to build DOM nodes from.
Plain t() leaves tags in the string until the bundle registers the tag grammar:
import '@comvi/core/tags';With that import, t('help') returns Click here for help instead of the string with angle
brackets in it. Without it, the string renders literally with one development warning per
template. createNextI18n() imports the entry itself; every other binding and bare
@comvi/core leave it to you.
The snippet imports from @comvi/core. An app that installed only a binding adds
@comvi/core to its own dependencies first: it arrives as a transitive dependency, and
pnpm does not hoist one into the app’s node_modules, so the import does not resolve there.
Handlers
Section titled “Handlers”import type { ReactNode } from 'react';import { T } from '@comvi/react';
// "demo": "Click <link>here</link> for <bold>help</bold> and <highlight>tips</highlight>"// "msg": "Click <a><b>here</b></a> to continue"// "address": "Line 1<br/>Line 2"// "promo": "Get <discount>{percent}% off</discount> today!"const Bold = ({ children }: { children?: ReactNode }) => <b>{children}</b>; // a handler is a component: define it outside render
export function Handlers() { return ( <> <T i18nKey="demo" components={{ link: <a href="/help" />, // element: rendered with createElement, children injected bold: Bold, // function: full control highlight: 'mark', // string: renders that HTML element }} /> <T i18nKey="msg" components={{ a: <a href="/next" />, b: <strong /> }} /> <T i18nKey="address" components={{ br: <br /> }} /> <T i18nKey="promo" params={{ percent: 20 }} components={{ discount: <span className="text-red-500" /> }} /> </> );}<script setup lang="ts">import { T } from '@comvi/vue';</script>
<!-- "demo": "Click <link>here</link> for <bold>help</bold> and <highlight>tips</highlight>" --><!-- "msg": "Click <a><b>here</b></a> to continue" --><!-- "address": "Line 1<br/>Line 2" --><!-- "promo": "Get <discount>{percent}% off</discount> today!" --><template> <T i18nKey="demo" :components="{ highlight: 'mark' }"> <template #link="{ children }"><a href="/help">{{ children }}</a></template> <template #bold="{ children }"><b>{{ children }}</b></template> </T> <T i18nKey="msg" :components="{ a: { component: 'a', props: { href: '/next' } }, b: 'strong' }" /> <T i18nKey="address" :components="{ br: 'br' }" /> <T i18nKey="promo" :params="{ percent: 20 }" :components="{ discount: { component: 'span', props: { class: 'text-red-500' } } }" /></template><!-- "demo": "Click <link>here</link> for <bold>help</bold> and <highlight>tips</highlight>" --><!-- "msg": "Click <a><b>here</b></a> to continue" --><!-- "address": "Line 1<br/>Line 2" --><!-- "promo": "Get <discount>{percent}% off</discount> today!" --><template> <T i18nKey="demo" :components="{ highlight: 'mark' }"> <template #link="{ children }"><NuxtLinkLocale to="/help">{{ children }}</NuxtLinkLocale></template> <template #bold="{ children }"><b>{{ children }}</b></template> </T> <T i18nKey="msg" :components="{ a: { component: 'a', props: { href: '/next' } }, b: 'strong' }" /> <T i18nKey="address" :components="{ br: 'br' }" /> <T i18nKey="promo" :params="{ percent: 20 }" :components="{ discount: { component: 'span', props: { class: 'text-red-500' } } }" /></template><script lang="ts"> import { T } from '@comvi/svelte';</script>
<!-- "demo": "Click <link>here</link> for <bold>help</bold> and <highlight>tips</highlight>" --><!-- "msg": "Click <a><b>here</b></a> to continue" --><!-- "address": "Line 1<br/>Line 2" --><!-- "promo": "Get <discount>{percent}% off</discount> today!" --><T i18nKey="demo" components={{ link: { tag: 'a', props: { href: '/help' } }, // config: element plus attributes bold: 'b', // string: renders that HTML element; a Svelte component works too highlight: 'mark', }}/><T i18nKey="msg" components={{ a: { tag: 'a', props: { href: '/next' } }, b: 'strong' }} /><T i18nKey="address" components={{ br: 'br' }} /><T i18nKey="promo" params={{ percent: 20 }} components={{ discount: { tag: 'span', props: { class: 'text-red-500' } } }} />import { T } from '@comvi/solid';
// "demo": "Click <link>here</link> for <bold>help</bold> and <highlight>tips</highlight>"// "msg": "Click <a><b>here</b></a> to continue"// "address": "Line 1<br/>Line 2"// "promo": "Get <discount>{percent}% off</discount> today!"export function Handlers() { return ( <> <T i18nKey="demo" components={{ link: { tag: 'a', props: { href: '/help' } }, // config: element plus attributes bold: (props) => <b>{props.children}</b>, // function: full control highlight: 'mark', // string: renders that HTML element }} /> <T i18nKey="msg" components={{ a: { tag: 'a', props: { href: '/next' } }, b: 'strong' }} /> <T i18nKey="address" components={{ br: 'br' }} /> <T i18nKey="promo" params={{ percent: 20 }} components={{ discount: { tag: 'span', props: { class: 'text-red-500' } } }} /> </> );}A handler is an HTML tag name, a component of the binding’s own kind, or a config with props;
the binding’s API reference lists the exact shapes. Tags nest, each with its own handler, and
<tag/> is a self-closing tag with no children. params fills {percent} inside a tag. A
plural inside a tag needs compiler: icuCompiler on the instance, from
Pluralize with ICU.
Whitelist and strict mode
Section titled “Whitelist and strict mode”import { createI18n } from '@comvi/core';
declare const Sentry: { captureMessage(message: string, level: string): void };
export const i18n = createI18n({ locale: 'en', tagInterpolation: { basicHtmlTags: ['strong', 'em', 'br', 'b', 'i', 'p', 'span'], strict: 'warn', // false (default) | 'warn' | true onTagWarning: (tagName) => { Sentry.captureMessage(`i18n: missing handler for <${tagName}>`, 'warning'); }, },});Tags in basicHtmlTags render as HTML elements with no handler, so <T i18nKey="note" />
renders This is <strong>important</strong> as bold text. A handler for the same tag name
wins over the whitelist.
strict decides what a tag with no handler does:
| Mode | Behavior |
|---|---|
false |
Renders the inner text silently. |
'warn' |
Calls onTagWarning(tagName), then renders the inner text. Without your callback, the constructor routes the warning to onError. |
true |
Throws E_MISSING_TAG_HANDLER, in production too. |
Escaping
Section titled “Escaping”{ "code": "Use the \\<div> element", "legal": "a <b> & c"}A backslash before <, or the entities <, > and &, put a literal angle
bracket or ampersand into a message that goes through the tag pipeline: <T i18nKey="code" />
renders Use the <div> element. Without a tag extension in the bundle these are ordinary
characters, so plain t('legal') on a bare instance returns a <b> & c as written.
ICU apostrophe quoting is a separate rule that works on every instance, on the
ICU reference.
How it works
Section titled “How it works”Every message goes through one parser. A template is scanned once for {, <, & and ';
a message with none of them is static and is returned as written. Anything else is parsed into
tokens, text, parameters and tags, and cached by the template text plus the grammar that parsed
it, so the first call pays for the parse and every later call with different values pays for a
lookup and a concatenation. In development the same parse also runs at ingestion, where a
broken message fails before anything renders.
Tags are a grammar the parser does not know by default. import '@comvi/core/tags' registers
the tag syntax as a process-wide extension, and the registration is retroactive: every
instance in the bundle starts stripping tags, because the cache is keyed by the active grammar
and a parse made before the import is never reused after it. The same registration is registerTagSyntax() from
@comvi/core/rich-text, which returns an unregister function for tests and multi-tenant
servers. <T> does not depend on the import: it passes the tag grammar into the call it makes,
so a <T> in a bundle with no tag import still renders markup while a plain t() next to it
returns the angle brackets. That difference is what the SSR mismatch is made of: a server
bundle without the import and a client bundle with it parse the same message two ways.
When <T> meets a tag, it looks for a handler in this order: an entry under that tag’s name
in components, then the basicHtmlTags whitelist, then strict. A tag’s children are rendered
first, parameters and nested tags included, and the finished children are what the handler
receives; it returns the element or component the binding knows how to mount. That is why
{percent} inside <discount> fills like any other parameter, and why a plural inside a tag
still needs the ICU compiler: the compiler decides what a braced argument means, the tag
extension only what the angle brackets mean. Unclosed or mismatched tags never throw:
development logs a warning and the raw text is returned. strict governs the tag pipeline only,
so plain t() on an instance without the tag import is unaffected by it.
Read next
Section titled “Read next”- Translate text with t(): call options, defaults and missing keys.
- Pluralize with ICU:
pluralandselectinside and outside tags. @comvi/core/rich-text:prepareTranslationandregisterTagSyntax.