- 🪶 Lightweight.
- 🌏 Internationalized routing.
- đź”’ Type-safety and autocompletion.
- 🔧 Support for plurals, context, interpolations and formatters.
- 🚀 Built for Astro and nothing else.
- đź’» Astro island compatible.
Most internationalization libraries rely on raw untyped strings to access their translations. This is equally possible with astro-i18n however it can generate project specific types to get type-safety and autocompletion for your translations.
# npm
npm install astro-i18n
# yarn
yarn add astro-i18n
# pnpm
pnpm add astro-i18n
Run the quick install command :
./node_modules/.bin/astro-i18n install
# serverless (no filesystem/node.js setup)
./node_modules/.bin/astro-i18n install --serverless
Verify that your middleware file at src/middleware.ts
or src/middleware/index.ts
uses the astro-i18n
middleware. If the file was generated by the quick install command it should be fine.
// src/middleware.ts
import { sequence } from "astro/middleware"
import { useAstroI18n } from "astro-i18n"
const astroI18n = useAstroI18n(/* astro-i18n config, custom formatters */)
export const onRequest = sequence(astroI18n)
If you are using serverless all your initial data needs to be passed to the middleware, you should import your root astro-i18n config and pass it to the middleware.
When using node (SSR or SSG) you can either go for the same workflow as serverless or use the filesystem to store your config and translations.
If you used the quick install (with node) a config file for astro-i18n
should have been generated, you will find it in your root directory (astro-i18n.config.(ts|js|cjs|mjs|json)
). This is the same as passing it in the middleware parameters.
Configure it as needed, you can set your primaryLocale
and secondaryLocales
there.
You can put your translations in four locations :
- Inside the middleware config.
- Inside the config file at the root.
- In your
src/pages
folder (local page translations), see below. - In the
src/i18n
directory, see below.
astro-i18n
works on the server-side and on the client-side so that it can work with client-side frameworks. Because of this translations are aggregated into groups:
- The
"common"
group which is accesible on every page. - The current page group named after its route, such as
"/about"
or"/posts/[id]"
, as its name implies it's only accessible on the current page. - Any other custom group named by yourself, for example
"admin"
. It's up to you to decide on which pages your custom group is accessible.
By default any translation accessible on the current page is visible on the client-side (you may change that in the config).
As shown in the screenshot above, local page translations can be either in src/pages
or src/i18n/pages
, nest them just like you do with astro file-based routing.
All the following translation file formats are valid :
const regular = `${locale}.json` // fr.json
const private = `_${locale}.json` // _fr.json
const regularWithTranslation = `${locale}.${segmentTranslation}.json` // fr.translated-page-name.json
const privateWithTranslation = `_${locale}.${segmentTranslation}.json` // _fr.translated-page-name.json
These files can either be directly in the page directory or grouped in a special directory called i18n
by default.
Concerning custom groups, create a directory containing the translation files named after your group in src/i18n
. To make them accessible in a page check the loadingRules
.
To get type-safety and generate your translated routes run the following command npm run i18n:sync
.
Once you have some translations you can use the main translation function t
:
// src/pages/about/_i18n/en.json
{
"my_nested": {
"translation_key": "hello"
}
}
// src/pages/about/_i18n/fr.a-propos.json
{
"my_nested": {
"translation_key": "bonjour"
}
}
// src/pages/about/index.astro
import { astroI18n, t } from "astro-i18n"
astroI18n.locale // "en"
t("my_nested.translation_key") // hello
// src/pages/fr/a-propos/index.astro
import { astroI18n, t } from "astro-i18n"
astroI18n.locale // "fr"
t("my_nested.translation_key") // bonjour
If there's duplicate keys t
will return the more specific one (custom group => local page => common group).
To translate a link use the l
function, by default all route segment translations are visible on the client-side :
// src/pages/fr/a-propos/index.astro
import { astroI18n, l } from "astro-i18n"
astroI18n.locale // "en"
l("/about") // "about"
// src/pages/fr/a-propos/index.astro
import { astroI18n, l } from "astro-i18n"
astroI18n.locale // "fr"
l("/about") // "a-propos"
That's it for the basics, for more keep reading !
The default locale for your app.
All the other locales supported by your app.
If left undefined this will be equal to your primaryLocale
, to disable it set it to an empty string. The locale to search a translation in when it's missing in another one.
Boolean deciding whether to show the default locale in the url or not.
Possible values are "always"
or "never"
(default). When set to "always"
, generated routes will have a trailing slash.
Possible values are "server"
or "client+server"
(default). When set to "client+server"
the available translations for the current page and all route translations will be serialized and sent to the client so that astro-i18n can work with client-side frameworks. You can disable this behaviour by setting run
to "server"
.
Your app translations in the following format :
{
"common": {
"en": {
"hello": "Hello",
"form": {
// accessed with the "form.first_name" key :
"first_name": "First name"
}
},
"fr": {
"hello": "Bonjour",
"form": {
"first_name": "Prénom"
}
}
},
"admin": {
"en": {
"how_are_you": "How are you ?"
},
"fr": {
"how_are_you": "Comment allez vous ?"
}
},
// routes are also a valid group, they will automatically load in the corresponding page :
"/posts/[id]": {
"en": {
"bye": "Bye"
},
"fr": {
"bye": "Au revoir"
}
}
}
An array of rules specifying what group to load on which page :
[
{
"routes": ["^/admin.*"], // regex for all routes beginning with "/admin"
"groups": ["^admin$"] // regex to load the "admin" group
}
]
Which name to use for the directories in src
and in src/pages
, by default it's "i18n"
(/src/i18n
and src/pages/i18n
/src/pages/about/i18n
).
{
"i18n": "i18n",
"pages": "i18n"
}
Your route segment translations. This is where you can translate your routes, for example "/about"
to "/a-propos"
.
{
// only specify secondary locales
"fr": {
// "primary_locale_segment": "translated_segment"
"about": "a-propos"
}
}
Variants are the way that plurals and context are handled. You can set multiple variables, numbers will match the closest value however other types will only match exact matches. They are set in the translation key, here's some examples :
{
"car": "There is a car.", // default value for "car" key
"car{{ cars: 2 }}": "There are two cars.",
"car{{ cars: 3 }}": "There are many cars.",
"foo{{ multiple: [true, 'bar'] }}": "baz.",
"context{{ weather: 'rain' }}": "It's rainy.",
"context{{ weather: 'sun' }}": "It's sunny."
}
import { astroI18n, t } from "astro-i18n"
t("car") // "There is a car."
t("car", { cars: 0 }) // "There are two cars."
t("car", { cars: 1 }) // "There are two cars."
t("car", { cars: 2 }) // "There are two cars."
t("car", { cars: 3 }) // "There are many cars."
t("car", { cars: 18 }) // "There are many cars."
t("foo", { multiple: true }) // "baz."
t("foo", { multiple: "bar" }) // "baz."
t("context", { weather: "rain" }) // "It's rainy."
t("context", { weather: "sun" }) // "It's sunny."
Interpolations can be used to display dynamic data in your translation values, here's some examples :
{
"interpolation_1": "{# variable #}",
"interpolation_2": "{# variable(alias) #}",
"interpolation_3": "{# variable(alias)>uppercase #}",
"interpolation_4": "{# 15>intl_format_number({ style: 'currency', currency: currency }) #}",
"interpolation_5": "{# { foo: 'bar'}(alias)>json(false)>uppercase #}"
}
import { astroI18n, t } from "astro-i18n"
astroI18n.locale // "en"
t("interpolation_1", { variable: "foo" }) // "foo"
t("interpolation_2", { alias: "bar" }) // "bar"
t("interpolation_3", { alias: "baz" }) // "BAZ"
t("interpolation_4", { currency: "EUR" }) // "€15.00"
t("interpolation_5") // '{"FOO":"BAR"}'
t("interpolation_5", { alias: { bar: "baz" } }) // '{"BAR":"BAZ"}'
As you can see there's multiple parts to an interpolation :
- The interpolation value which is what the formatters apply on.
- The interpolation alias which can either change the name of the value if it's a variable or make the value a default value replaceable by the alias variable.
- The interpolation formatters which can take arguments, you can chain them and create your own. When creating a formatter, if you want it to work on the client side it must not use anything outside the function scope.
You can apply this to any value, even if it's nested or inside the formatters arguments.
An interpolation formatter is a function that takes the interpolation value and transforms it, if there's multiple of them they will be chained. These are the default interpolation formatters :
upper()
:value.toUpperCase()
.uppercase()
: alias forupper
.lower()
:value.toLowerCase()
.lowercase()
: alias forlower
.capitalize()
: Capitalizes the first character ofvalue
and lowers the others.json(format = true)
:JSON.stringify(value)
, if format is true it will format the JSON.default_falsy(defaultValue)
: When!value
,defaultValue
will be used instead.default(defaultValue)
: alias fordefault_falsy
.default_nullish(defaultValue)
: Whenvalue == null
,defaultValue
will be used instead.default_non_string(defaultValue)
: Whentypeof value !== "string"
,defaultValue
will be used instead.intl_format_number(options, locale = astroI18n.locale)
: Formatsvalue
withIntl.NumberFormat
.intl_format_date(options, locale = astroI18n.locale)
: Formatsvalue
withIntl.DateTimeFormat
.
If the formatter has no arguments you can write it without parenthesises, for example value>upper
.
You can add your own custom formatters in the second argument of the middleware. If you want them to work on the client-side they must not use anything outside the function scope. Here's an example of a custom formatter :
export function xXify(value: unknown, repeats: unknown = 1) {
if (typeof repeats !== "number") {
throw new Error("repeats must be a number")
}
if (repeats <= 0) return value
return `${"xX".repeat(repeats)}${value}${"Xx".repeat(repeats)}`
}
// example that wouldn't work client-side :
const error = new Error("repeats must be a number")
function xXify(value: unknown, repeats: unknown = 1) {
if (typeof repeats !== "number") throw error // cannot use anything outside of scope
// ...
}
function defineAstroI18nConfig(
config: Partial<AstroI18nConfig>,
): Partial<AstroI18nConfig>
Utility function to get type safety when defining the config.
function useAstroI18n(
config?: Partial<AstroI18nConfig> | string, // string if path to config file
formatters?: {
[name: string]: (value: unknown, ...args: unknown[]) => unknown
},
): AstroMiddleware
The astro-i18n middleware, mandatory to make the library work.
function createGetStaticPaths(
callback: (
props: GetStaticPathsProps,
) => GetStaticPathsItem[] | Promise<GetStaticPathsItem[]>,
): GetStaticPathsItem[] | Promise<GetStaticPathsItem[]>
You should use this function if you plan to use any translation features inside a getStaticPaths. This is to fix some Astro behaviour. Using the t
function is not recommended inside getStaticPaths.
Alias for astroI18n.t
.
Alias for astroI18n.l
.
environment: "node" | "none" | "browser"
The current detected environment.
route: string
The current route (without the locale, for example /fr/about
will return /about
).
pages: string[]
An array of all the available pages. At the moment it only detects pages for which you have a translation (even if it's empty).
page: string
The corresponding page for the current route, for example /posts/my-cool-slug
will be /posts/[slug]
. Only gets detected if you have a translation for that page (even if it's empty).
locale: string
The locale for the current page.
locales: string[]
All the supported locales.
primaryLocale: string
The configured primary locale.
secondaryLocales: string
The configured secondary locales.
fallbackLocale: string
The configured fallback locale.
isInitialized: boolean
True once the config has been loaded and the state initialized.
function t(
key: string,
properties?: Record<string, unknown>,
options?: {
route?: string
locale?: string
fallbackLocale?: string
},
): string
The main translation function (t
is an alias).
function l(
route: string,
parameters?: Record<string, unknown>,
options?: {
targetLocale?: string
routeLocale?: string
showPrimaryLocale?: boolean
query?: Record<string, unknown>
},
): string
The main routing function (l
is an alias).
function addTranslations(translations: {
[group: string]: {
[locale: string]: DeepStringRecord
}
}): AstroI18n
Adds new translations at runtime.
function addFormatters(formatters: {
[name: string]: (value: unknown, ...args: unknown[]) => unknown
}): AstroI18n
Adds new formatters at runtime.
function addTranslationLoadingRules(
translationLoadingRules: {
groups: string[]
routes: string[]
}[],
): AstroI18n
Adds new translation loading rules at runtime.
function addRoutes(routes: {
[secondaryLocale: string]: {
[segment: string]: string
}
}): AstroI18n
Adds new route segment translations at runtime.
function extractRouteLocale(route: string): string
Utility function to parse one of the configured locales out of the given route.
function redirect(destination: string | URL, status = 301): void
Use this instead of Astro.redirect
to redirect users.
You can use these premade components in your project :
SEO component, see hreflang.
---
import { astroI18n } from "astro-i18n"
const params: Record<string, string> = {}
for (const [key, value] of Object.entries(Astro.params)) {
if (value === undefined) continue
params[key] = String(value)
}
const hrefLangs = astroI18n.locales.map((locale) => ({
href:
Astro.url.origin +
astroI18n.l(Astro.url.pathname, params, {
targetLocale: locale,
}),
hreflang: locale,
}))
---
{
hrefLangs.map(({ href, hreflang }) => (
<link rel="alternate" href={href} hreflang={hreflang} />
))
}
Simple component to switch locales.
---
import { astroI18n, l } from "astro-i18n"
interface Props {
showCurrent: boolean
labels: {
[locale: string]: string
}
}
const { showCurrent = true, labels = {} } = Astro.props
const params: Record<string, string> = {}
for (const [key, value] of Object.entries(Astro.params)) {
if (value === undefined) continue
params[key] = String(value)
}
let links = astroI18n.locales.map((locale) => ({
locale,
href: l(Astro.url.pathname, params, {
targetLocale: locale,
}),
label: labels[locale] || locale.toUpperCase(),
}))
if (!showCurrent) {
links = links.filter((link) => link.locale !== astroI18n.locale)
}
---
<nav>
<ul>
{
links.map(({ href, label }) => (
<li>
<a href={href}>{label}</a>
</li>
))
}
</ul>
</nav>
Generates default files and add i18n commands to package.json
.
Generates the pages corresponding to your current config and src/pages
folder. You can use the --purge
argument to clear old pages.
Generates types in src/env.d.ts
to give you type safety according to your config & translations.
Extract all the translation keys used in src/pages
(as long as they are strings and not variables), and adds them to your i18n folder for every locale.
This is useful if you use your keys as values.