@cepharum/vue3-i18n
@cepharum/vue3-i18n
a simple i18n library for Vue3 applications
License
Security
Report suspected vulnerabilities to security@cepharum.de rather than in a public issue. See SECURITY.md for the policy, the supported versions and notes on the library's attack surface.
For LLMs and AI Agents
This repository includes comprehensive documentation designed for AI assistants:
- AGENT.md - Detailed technical documentation covering the full API, architecture, and usage patterns
- QUICK_START.md - Quick reference guide with tables and concise examples
These files are specifically structured to help LLMs understand how this library works and how to use it in consuming applications.
About
This library implements a component and helper functions for reactively mapping internationalized keys into localized strings based on hierarchical trees of translations per locale, each implemented as regular Javascript objects e.g. read from JSON files.
It supports data interpolation and some limited Markdown syntax for styling translations.
Due to its reactive approach, all translations get updated automatically e.g. on switching the locale and fetching its set of translations. The locale to use is read from current document or browser automatically. A custom locale can be selected explicitly to override locale read from document or browser.
Requirements
Node 18.13+ or an evergreen browser. The library builds on the runtime's Intl
API — PluralRules including selectRange, NumberFormat, DateTimeFormat,
RelativeTimeFormat, ListFormat and Locale — and ships no runtime
dependencies of its own. Vue 3 is a peer dependency.
Install
In a consuming Vue3 project, this library is installed using npm like this:
npm install @cepharum/vue3-i18n
Usage
A loader for your translations must be provided. It should be set up as early as possible, e.g. on mounting your project's root view in src/App.vue component.
<script setup>
import { useL10n } from "@cepharum/vue3-i18n";
useL10n().setLoader( locale => import(`./l10n/${locale}.json`) );
</script>
Create a folder src/l10n in your project and put a file name en.json there. It is going to provide all the translations like this:
{
"COMMON": {
"confirm": "Okay",
"cancel": "Cancel"
},
"FEATURE": {
"title": "Info",
"text": "This is the feature!"
}
}
Use this library's component <L10n> to render text:
<script setup>
import { L10n } from "@cepharum/vue3-i18n";
</script>
<template>
<h1><L10n i18n="FEATURE.title" /></h1>
<p><L10n i18n="FEATURE.text" /></p>
<button><L10n i18n="COMMON.confirm" /></button>
</template>
VitePress
In VitePress, the default theme must be extended or a custom theme must be set up for registering vue3-i18n. In file .vitepress/theme/index.js, the method enhanceApp must be extended:
import { L10n, useL10n } from "@cepharum/vue3-i18n";
enhanceApp( ctx ) {
const l10n = useL10n();
// set up loader for fetching translations per locale
l10n.setLoader( locale => import( ( `./l10n/${locale}.json` ) ) );
// globally register L10n component for use in content pages
ctx.app.component( "L10n", L10n );
}
Translation files per locale are expected to reside in folder .vitepress/theme/l10n here.
In versions prior to v0.5.0, an observer for the document's locale had to be set up explicitly. Starting with v0.5.0, the observer is set up automatically.
For More Information
For detailed documentation on how this library works, see:
- AGENT.md - Complete technical documentation
- QUICK_START.md - Quick reference guide
Plugins
Starting with v0.4.1, separate loaders can be registered to provide translations for dedicated namespaces. This enables internationalized plugins for a Vue3-based application providing their own subset of translations.
// in a plugin's setup code:
import { L10n, useL10n } from "@cepharum/vue3-i18n";
const l10n = useL10n();
// set up loader for fetching plugin's translations per locale
l10n.setNamespaceLoader( "@my-plugin", locale => import( ( `./l10n/${locale}.json` ) ) );
In this, a namespace is a non-empty string of non-whitespace characters prefixed with @. Keys of all translations fetched by a namespace's loader are automatically prefixed with that namespace. For the example given above, a translation file could look like this:
{
"FOO": {
"bar": "translated"
}
}
For looking it up, the namespace must be prepended in plugin's code:
const translation = useL10n().lookup( "@my-plugin.FOO.bar" );
The regular loader selected via setLoader() isn't affected by this automatic prefixing. Thus, it is capable of providing custom translations for either namespace, too. For that, the regular loader is always processed last on compiling a locale's set of translations.
Interpolation
You can use additional markup in your translations to be replaced with values extracted from an additionally provided data object. Translations can look like this:
{
"COMMON": {
"confirm": "Okay",
"cancel": "Cancel"
},
"FEATURE": {
"title": "About {mode.label}",
"text": "This is the feature!"
}
}
<L10n> component supports another property data accepting some object with properties matching names wrapped in curly braces above:
<script setup>
import { ref } from "vue";
import { L10n } from "@cepharum/vue3-i18n";
const data = ref({
mode: {
label: "Interpolation",
},
});
</script>
<template>
<h1><L10n i18n="FEATURE.title" :data="data" /></h1>
<p><L10n i18n="FEATURE.text" /></p>
<button><L10n i18n="COMMON.confirm" /></button>
</template>
data of <L10n> accepts a function, too, which gets invoked on every update to fetch actual data:
<script setup>
import { L10n } from "@cepharum/vue3-i18n";
const data = () => ({
mode: {
label: "Interpolation",
},
});
</script>
<template>
<h1><L10n i18n="FEATURE.title" :data="data" /></h1>
<p><L10n i18n="FEATURE.text" /></p>
<button><L10n i18n="COMMON.confirm" /></button>
</template>
The component renders the resulting translation without any HTML element unless markdown support has been enabled. By setting optional property tag to the name of an HTML element, the translation is always wrapped in an element with that name. The following example results in same output as the previous one:
<script setup>
import { L10n } from "@cepharum/vue3-i18n";
const data = () => ({
mode: {
label: "Interpolation",
},
});
</script>
<template>
<L10n i18n="FEATURE.title" :data="data" tag="h1" />
<L10n i18n="FEATURE.text" tag="p"/>
<L10n i18n="COMMON.confirm" tag="button" />
</template>
Selections: plural, gender and beyond
A translation does not have to be a single string. It may be a selecting node picking one out of several branches depending on the interpolated data. This is how translations stay correct in languages whose grammar does not follow the germanic pattern.
Plural
Write the branches, and the matching one is picked according to the CLDR rules of the current locale:
{
"FILES": {
"removed": {
"one": "{count} file removed",
"other": "{count} files removed"
}
}
}
<L10n i18n="FILES.removed" :data="{ count: numFiles }" />
No control key is required: a node is taken for a plural selection if all its
keys are CLDR plural categories (zero, one, two, few, many, other)
or the default branch *, and at least one of them is beyond one. The
selecting value is property count of the interpolated data by default.
The set of branches belongs to the target language, not to the source language. The very same key and the very same call may be two branches in English, four in Polish and a plain string in Japanese:
{
"FILES": {
"removed": {
"one": "Usunięto {count} plik",
"few": "Usunięto {count} pliki",
"many": "Usunięto {count} plików",
"other": "Usunięto {count} pliku"
}
}
}
{ "FILES": { "removed": "{count}件のファイルを削除しました" } }
Use $plural for selecting by another property, and $ordinal for ordinal
numbers, which many languages inflect by rules of their own:
{
"CART": { "$plural": "amount", "one": "{amount} item", "other": "{amount} items" },
"RANK": { "$ordinal": "rank", "one": "{rank}st", "two": "{rank}nd", "few": "{rank}rd", "other": "{rank}th" }
}
$range selects by the category of a range of numbers, e.g. for "1–5 days":
{ "DAYS": { "$range": [ "from", "to" ], "one": "{from}–{to} day", "other": "{from}–{to} days" } }
Gender and any other distinction
$select picks a branch by an arbitrary value, such as the gender of a person,
the grammatical gender of an inserted noun, its animacy or its noun class:
{
"INVITE": {
"$select": "actor.gender",
"f": "{actor.name} invited you to her project.",
"m": "{actor.name} invited you to his project.",
"*": "{actor.name} invited you to their project."
}
}
Branch * is used whenever no other branch matches, including the case of a
missing or unknown value, and should therefore carry a formulation that works
without the distinction. A language that does not make the distinction at all
just writes a plain string instead.
Nesting
Branches may be selecting nodes themselves. This covers e.g. slavic languages, in which animacy governs which plural forms apply:
{
"SELECTED": {
"$select": "item.animacy",
"personal": { "one": "…", "few": "…", "many": "…", "other": "…" },
"*": { "one": "…", "few": "…", "many": "…", "other": "…" }
}
}
Fallbacks and opting out
If no branch matches, other is tried, then * (the other way round for
$select), and eventually the key itself is returned just like for a missing
translation.
Keys starting with $ are reserved. A regular thread of keys that would be
mistaken for a selection — one actually named one, few, other, … — is
marked with $literal:
{ "ROUND": { "$literal": true, "one": "First round", "other": "Any other round" } }
Note: Numbers are interpolated as they are. Wrap them in
Intl.NumberFormatyourself if you need grouping separators, or pass the formatted string as data.
Terms: inserting inflected words
Selecting a branch is not enough where the inserted word itself has to change its form — german needs four cases, polish seven, finnish fifteen. Since this library never inflects a word on its own, a translation file provides the forms, and the placeholder requests one.
A term is an entry providing forms, plus any grammatical features you want to
branch on. By convention terms live under TERMS:
{
"TERMS": {
"file": {
"gender": "f",
"forms": { "nom": "Datei", "gen": "Datei", "dat": "Datei", "acc": "Datei", "nom.pl": "Dateien" }
},
"folder": {
"gender": "m",
"forms": { "nom": "Ordner", "gen": "Ordners", "dat": "Ordner", "acc": "Ordner" }
}
},
"MOVE": "{file:acc} nach {folder:dat} verschieben"
}
import { term, useL10n } from "@cepharum/vue3-i18n";
useL10n().translate( "MOVE", { file: term( "file" ), folder: term( "folder" ) } );
// "Datei nach Ordner verschieben"
The form names are yours: a finnish file provides nom, gen, part,
iness, … and a polish one nom, gen, dat, acc, instr, loc, voc
plus their plurals. {file} without a form inserts the term's default form,
which is nom unless the term names another one in default.
Because a term carries its features, selections can branch on them — this is how article and participle agree with whatever noun is inserted:
{
"DELETED": {
"$select": "obj.gender",
"m": "Der {obj:nom} wurde gelöscht.",
"f": "Die {obj:nom} wurde gelöscht.",
"n": "Das {obj:nom} wurde gelöscht."
}
}
Turkish writes a single branch here, spanish two, and a bantu language as many as its noun classes require — the calling code stays the same in every case.
Terms may also be passed inline, without any lexicon, which suits records your application already holds:
translate( "DELETED", { obj: { gender: "n", forms: { nom: "Bild" } } } );
Notes:
- A plain string keeps working as interpolation data. A form requested of it is simply ignored.
- An unresolvable term is inserted as its key, just like a missing translation.
configure( { terms: "WORDS" } )moves the lexicon to another key.- Formatter names take precedence over form names, so avoid naming a form
number,date,time,datetime,relativeorlist.
The slavic reference case
Case, number and animacy at once — and again with unchanged calling code:
{
"TERMS": {
"plik": {
"gender": "m", "animacy": "inanimate",
"forms": { "nom": "plik", "acc": "plik", "nom.pl": "pliki", "gen.pl": "plików" }
}
},
"REMOVED": {
"one": "Usunięto {count} {item:acc}",
"few": "Usunięto {count} {item:nom.pl}",
"many": "Usunięto {count} {item:gen.pl}",
"other": "Usunięto {count} {item:gen.pl}"
}
}
translate( "REMOVED", { count, item: term( "plik" ) } );
// 1 → "Usunięto 1 plik" 2 → "Usunięto 2 pliki"
// 5 → "Usunięto 5 plików" 22 → "Usunięto 22 pliki"
Formatting values
Numbers, dates and lists follow conventions of their own in every locale, so placeholders may name a formatter:
{
"CART": {
"total": "{count:number} items, {sum:number(currency=EUR)}",
"due": "due {when:date(long)}, updated {ago:relative(hour)}",
"who": "shared with {names:list}"
}
}
translate( "CART.total", { count: 12345, sum: 79.9 } );
// en: "12,345 items, €79.90"
// de: "12.345 items, 79,90 €"
Formatters are backed by Intl and resolve by the full language tag, so
de-CH groups thousands differently from de-DE.
| Formatter | Arguments | Backed by |
|---|---|---|
number |
currency=EUR, unit=kilometer, percent, compact, min=, max= |
Intl.NumberFormat |
date, time, datetime |
full, long, medium, short |
Intl.DateTimeFormat |
relative |
unit (day, hour, …), always |
Intl.RelativeTimeFormat |
list |
or, unit, long, short, narrow |
Intl.ListFormat |
A value that does not suit its formatter — or a formatter that does not exist — is inserted unformatted, so a translation never breaks over it.
Register formatters of your own with registerFormatter(), and use the name in
placeholders afterwards:
useL10n().registerFormatter( "file", ( value, { locale } ) => prettyBytes( value, locale ) );
When rendering Markdown, a formatter's output is escaped like any
other interpolated value. Return html() to
provide markup instead.
Options instead of positional arguments
translate() accepts a set of options as its second argument:
translate( "CART.total", { data, markdown: true, locale: "de-AT", bidi: "auto" } );
The compact form translate( key, data, markdown, locale ) remains supported
and is the more convenient one for the common case. An object is taken for
options only if all of its properties are option names, so
translate( key, { data: x, name: y } ) still passes interpolation data.
Isolating interpolated values
Inserting a latin name into an arabic sentence — or the other way round — rearranges the punctuation around it unless the value is isolated. Enable isolation per context or per translation:
useL10n().configure( { bidi: "auto" } );
Values whose direction conflicts with the direction of the locale are then
wrapped in U+2068/U+2069. Values matching the direction are left alone, so
nothing changes for latin text. Default is "off" to keep existing output
untouched.
Regional locales
Locales are handled as full BCP-47 language tags, and translations are looked up along a chain from the bare language to the most specific variant:
de-AT → de
zh-Hant-TW → zh-Hant → zh
The loader is invoked for every link of that chain, starting with the bare language, and each result overlays the previous one. A regional file therefore holds the deviations, only:
// l10n/de.json
{ "COMMON": { "ok": "Okay", "cancel": "Abbrechen" } }
// l10n/de-AT.json
{ "COMMON": { "cancel": "Zurück" } }
useL10n().setLoader( locale => import( `./l10n/${locale}.json` ) );
useL10n().setLocale( "de-AT" );
Providing those files is optional. A loader failing on any link that is more
specific than the bare language is ignored silently — the example above works
just as well in an application shipping de.json, only. Only a loader failing
on the bare language is an error, just like before.
A locale without region or script does not cause additional lookups at all:
selecting de invokes the loader once, exactly as in previous versions.
The distinction matters beyond wording, for pt and pt-PT differ in their
plural rules, and zh-Hans and zh-Hant are different scripts. Selections are
therefore resolved by the full tag.
Related properties of the context:
localekeeps providing the language, e.g.de, as it always did.tagprovides the full canonical tag, e.g.de-AT.scriptandregionprovide either part, ornull.defaultTagprovides the full tag of the default locale.
Set configure( { regional: false } ) to disable the extra lookups and address
the bare language, only:
useL10n().configure( { regional: false } );
Note: Deprecated language codes are preserved rather than replaced, so selecting
iwkeeps fetchingiw, nothe.
Validating translations
A russian file missing its few branch does not fail — it silently falls back
to other and reads wrong for 2, 3 and 4. validateTranslations() finds that:
import { validateTranslations } from "@cepharum/vue3-i18n";
validateTranslations( await import( "./l10n/ru.json" ), "ru", {
reference: await import( "./l10n/en.json" ),
} );
// [ { path: "FILES.removed", severity: "error", code: "missing-category",
// message: "selection at FILES.removed lacks few, many required for locale ru" } ]
Checked per locale:
| Code | Severity | Meaning |
|---|---|---|
missing-category |
error | a plural selection lacks a category the locale uses |
unused-category |
warning | a plural selection provides a category the locale does not use |
missing-default-branch |
error | a $select node lacks its * branch |
missing-form |
warning | a placeholder requests a form the term does not provide |
empty-term / missing-default-form |
error | a term provides no forms, or not the one it names as default |
missing-translation |
warning | a key of the reference tree is missing (only with reference) |
The required categories come from
Intl.PluralRules( locale ).resolvedOptions().pluralCategories, so no rules are
maintained anywhere — adding a locale needs no change to this library.
Use it as a test, so an incomplete translation fails the build:
import { describe, it } from "vitest";
import { validateTranslations } from "@cepharum/vue3-i18n";
describe( "translations", () => {
for ( const locale of [ "de", "ru", "pl" ] ) {
it( `are complete for ${locale}`, async() => {
const findings = validateTranslations(
( await import( `./l10n/${locale}.json` ) ).default, locale,
{ reference: ( await import( "./l10n/en.json" ) ).default } );
findings.filter( finding => finding.severity === "error" ).should.be.empty();
} );
}
} );
In development mode, translations are validated as they are loaded and findings
are reported on the console. Switch that off with
configure( { validate: false } ). The context also exposes
validate( locale?, options? ) for the translations it has loaded.
Markdown
There is support for some basic Markdown in translations.
{
"COMMON": {
"confirm": "Okay",
"cancel": "Cancel"
},
"FEATURE": {
"title": "About",
"text": "This is **the** feature!"
}
}
<L10n> ignores any Markdown by default. By setting markdown property, supported Markdown is converted to HTML, though. But any HTML additionally found in translations is escaped.
Supported syntax is *italic* or _italic_, **bold** or __bold__, two spaces before a line break for <br>, an empty line for starting another paragraph and [label](href) for links. A link is rendered as its label, only, unless its target is a relative URL or an absolute one with scheme http, https, mailto or tel.
<script setup>
import { L10n } from "@cepharum/vue3-i18n";
</script>
<template>
<h1><L10n i18n="FEATURE.title" markdown /></h1>
<p><L10n i18n="FEATURE.text" /></p>
<button><L10n i18n="COMMON.confirm" /></button>
</template>
HTML in interpolated values
Values interpolated into a translation rendered as Markdown are escaped, the same way as HTML found in the translation itself. This applies to formatted values, to forms of terms and to values inserted into the target of a link, too. Markdown found in an interpolated value is not processed either.
Mark a value with html() to have it inserted as-is, e.g. for including an icon:
import { html, useL10n } from "@cepharum/vue3-i18n";
// translation: "Status: {icon} **{label}**"
useL10n().translate( "JOB.status", {
icon: html( '<i class="fa-solid fa-check"></i>' ),
label: job.label,
}, true );
A formatter may return html(), too:
import { escapeHtml, html, useL10n } from "@cepharum/vue3-i18n";
useL10n().registerFormatter( "icon", name => html( `<i class="fa-solid fa-${escapeHtml( name )}"></i>` ) );
[!WARNING] Never pass user-provided content to
html(). The marker exists for markup your application has assembled itself. It can't be provided as part of data e.g. parsed from JSON, because it is an instance of a class rather than a plain object.
Without markdown, the result of a translation is text which <L10n> renders as such. Interpolated values aren't escaped then, since Vue is escaping the whole text. Thus, html() doesn't make a difference in that case: its code is inserted and shown as literal text. If you pass a result of translate() to v-html yourself, always enable markdown for it.
Additional API features
The context fetched with useL10n() exposes additional properties and actions:
-
localereactively provides current locale's language as string, e.g.enorde, withtagproviding the full language tag, e.g.en-US. Both are derived from browser's locale by default.- The locale of current
documentis preferred. - If locale of
documentis missing or if there is nodocument, the locale of current browser as exposed innavigator.languageis used. - If locale of browser is missing or there is no browser, a default locale is used as fallback which is
eninitially.
- The locale of current
-
Method
setLocale( locale )selects a custom locale to prefer as current locale over any locale read from other sources as described above. It causes that locale's translations to be fetched and all currently used<L10n>components to be updated.The method returns a promise which is settled as soon as those translations have been fetched. Awaiting it is optional, but saves you from selecting a locale and awaiting
loadTranslations()separately:await useL10n().setLocale( "de" );Providing an invalid locale still throws synchronously rather than rejecting that promise.
<script setup> import { useL10n } from "@cepharum/vue3-i18n"; useL10n().setLocale( "fr" ); </script>The method
setLocale()can be used to drop any previously selected custom locale and to reinstate the discovery of current locale as described above.<script setup> import { useL10n } from "@cepharum/vue3-i18n"; useL10n().setLocale(); </script> -
defaultLocalereactively exposes the fallback locale used in last step described above. The methodsetDefaultLocale()can be used to adjust it.<script setup> import { useL10n } from "@cepharum/vue3-i18n"; useL10n().setDefaultLocale( "de" ); </script> -
In addition to serving as a fallback on discovering a current locale, this default locale is considered on looking up a translation for an i18n key when there is no translation provided for the current locale.
-
You can fetch reactive translations using method
lookup().<script setup> import { useL10n } from "@cepharum/vue3-i18n"; const translation = useL10n().lookup( "SOME.KEY.FOR.firstField" ); </script>The resulting translation isn't interpolated, but extracted from fetched translations as-is. Provided key may address a whole thread of translations, too.
-
Moreover, you can fetch multiple reactive translations using method
translate()with optional interpolation and Markdown support.<script setup> import { ref } from "@vue/reactivity"; import { useL10n } from "@cepharum/vue3-i18n"; const { translate } = useL10n(); const data = ref( { count: 23, foo: "test" } ); // translates and interpolates single string supporting Markdown, too const single = translate( "SOME.KEY.FOR.firstField", data, true ); // translates multiple strings at once and returns object with // same properties each providing related key's translation const multi = translate( { first: "SOME.KEY.FOR.firstField", second: "SOME.KEY.FOR.secondField", }, data ); </script> -
By default, translations are looked up for current locale selected as described above. However, a custom locale can be chosen explicitly to look up translations for that particular locale with
lookup()andtranslate().<script setup> import { ref } from "@vue/reactivity"; import { useL10n } from "@cepharum/vue3-i18n"; const { translate, lookup } = useL10n(); const data = ref( { count: 23, foo: "test" } ); // translates single string to French explicitly const translation = lookup( "SOME.KEY.FOR.firstField", null, "fr" ); // translates multiple strings at once to French const multi = translate( { first: "SOME.KEY.FOR.firstField", second: "SOME.KEY.FOR.secondField", }, data, false, "fr" ); </script>Same works with
localeproperty ofL10ncomponent. -
Considering a document's language can be disabled. (Prior to v0.5.0, this has been the default and discovery of current local has been working in a simpler way.)
import { useL10n } from "@cepharum/vue3-i18n"; useL10n().unobserveDocument(); </script>Considering document's language can be re-enabled later:
import { useL10n } from "@cepharum/vue3-i18n"; useL10n().observeDocument(); </script>Additional documents can be observed, too. However, it is controlling custom locale usually selected with
import { useL10n } from "@cepharum/vue3-i18n"; useL10n().observeDocument( customDocument ); </script>However, this isn't replacing the monitoring of current document's locale, but selects given document's locale as custom locale just like invoking
setLocale( locale )method every time that given document's locale is changing. Thus, observing multiple additional documents is possible, but does not make much sense.Stopping observation of an additional document works accordingly:
import { useL10n } from "@cepharum/vue3-i18n"; useL10n().unobserveDocument( customDocument ); </script>This will reinstate the process of discovering current locale as described in first item above. Any selected custom locale gets dropped.
Related Packages
a library of Vue3 components and composables shared across cepharum applications
Version History
| Version | Released | License |
|---|---|---|
| 3.0.0 | MIT | |
| 2.0.0 | MIT | |
| 1.0.0 | MIT | |
| 0.5.4 | MIT | |
| 0.5.3 | MIT | |
| 0.5.2 | MIT | |
| 0.5.1 | MIT | |
| 0.5.0 | MIT | |
| 0.4.3 | MIT | |
| 0.4.2 | MIT | |
| 0.4.1 | MIT | |
| 0.4.0 | MIT | |
| 0.3.2 | MIT | |
| 0.3.1 | MIT | |
| 0.3.0 | MIT | |
| 0.2.1 | MIT | |
| 0.2.0 | MIT | |
| 0.1.2 | MIT | |
| 0.1.1 | MIT | |
| 0.1.0 | MIT |