Skip to content
Comvi

Insert values and markup

View .md

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.

src/Welcome.tsx
import { useI18n } from '@comvi/react';
export function Welcome({ name }: { name: string }) {
const { t } = useI18n();
return <h1>{t('welcome', { name })}</h1>;
}

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

src/i18n.ts
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}!' } },
});
src/greet.ts
import { i18n } from './i18n';
await i18n.init();
i18n.t('greet'); // "Hello, {name}!" plus one development warning
i18n.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.

src/Help.tsx
import { T } from '@comvi/react';
// "help": "Click <link>here</link> for help"
export function Help() {
return <T i18nKey="help" components={{ link: <a href="/help" /> }} />;
}

<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:

src/tags-on.ts
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.

src/Handlers.tsx
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" /> }} />
</>
);
}

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.

src/i18n.ts
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.
src/locales/en.json
{
"code": "Use the \\<div> element",
"legal": "a &lt;b&gt; &amp; c"
}

A backslash before <, or the entities &lt;, &gt; and &amp;, 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 &lt;b&gt; &amp; c as written. ICU apostrophe quoting is a separate rule that works on every instance, on the ICU reference.

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.