@cepharum/vue3-i18n

a simple i18n library for Vue3 applications
12,844
Install
npm install @cepharum/vue3-i18n
Latest Version:3.0.0
Vue:3
License:MIT
Last Updated:Oct 6, 2026
Links: npm
Maintainer: soletan

@cepharum/vue3-i18n

a simple i18n library for Vue3 applications

License

MIT

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:

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.NumberFormat yourself 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, relative or list.
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:

  • locale keeps providing the language, e.g. de, as it always did.
  • tag provides the full canonical tag, e.g. de-AT.
  • script and region provide either part, or null.
  • defaultTag provides 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 iw keeps fetching iw, not he.

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:

  • locale reactively provides current locale's language as string, e.g. en or de, with tag providing the full language tag, e.g. en-US. Both are derived from browser's locale by default.

    • The locale of current document is preferred.
    • If locale of document is missing or if there is no document, the locale of current browser as exposed in navigator.language is used.
    • If locale of browser is missing or there is no browser, a default locale is used as fallback which is en initially.
  • 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>
    
  • defaultLocale reactively exposes the fallback locale used in last step described above. The method setDefaultLocale() 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() and translate().

    <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 locale property of L10n component.

  • 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

@cepharum/vue3-commons

a library of Vue3 components and composables shared across cepharum applications

4,933

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