astro-i18n
v2.2.4
Published
A TypeScript-first internationalization library for Astro.
Downloads
2,162
Maintainers
Readme
Features
- 🪶 Lightweight.
- 🌏 Internationalized routing.
- 🔒 Type-safety and autocompletion.
- 🔧 Support for plurals, context, interpolations and formatters.
- 🚀 Built for Astro and nothing else.
- 💻 Astro island compatible.
Introduction
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.
Installation
# npm
npm install astro-i18n
# yarn
yarn add astro-i18n
# pnpm
pnpm add astro-i18n
Get started
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 !
Configuration
primaryLocale
The default locale for your app.
secondaryLocales
All the other locales supported by your app.
fallbackLocale
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.
showPrimaryLocale
Boolean deciding whether to show the default locale in the url or not.
trailingSlash
Possible values are "always"
or "never"
(default). When set to "always"
, generated routes will have a trailing slash.
run
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"
.
translations
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"
}
}
}
translationLoadingRules
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
}
]
translationDirectory
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"
}
routes
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
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
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.
Interpolation formatters
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
// ...
}
Reference
defineAstroI18nConfig
function defineAstroI18nConfig(
config: Partial<AstroI18nConfig>,
): Partial<AstroI18nConfig>
Utility function to get type safety when defining the config.
useAstroI18n
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.
createGetStaticPaths
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.
t
Alias for astroI18n.t
.
l
Alias for astroI18n.l
.
astroI18n.environment
environment: "node" | "none" | "browser"
The current detected environment.
astroI18n.route
route: string
The current route (without the locale, for example /fr/about
will return /about
).
astroI18n.pages
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).
astroI18n.page
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).
astroI18n.locale
locale: string
The locale for the current page.
astroI18n.locales
locales: string[]
All the supported locales.
astroI18n.primaryLocale
primaryLocale: string
The configured primary locale.
astroI18n.secondaryLocales
secondaryLocales: string
The configured secondary locales.
astroI18n.fallbackLocale
fallbackLocale: string
The configured fallback locale.
astroI18n.isInitialized
isInitialized: boolean
True once the config has been loaded and the state initialized.
astroI18n.t
function t(
key: string,
properties?: Record<string, unknown>,
options?: {
route?: string
locale?: string
fallbackLocale?: string
},
): string
The main translation function (t
is an alias).
astroI18n.l
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).
astroI18n.addTranslations
function addTranslations(translations: {
[group: string]: {
[locale: string]: DeepStringRecord
}
}): AstroI18n
Adds new translations at runtime.
astroI18n.addFormatters
function addFormatters(formatters: {
[name: string]: (value: unknown, ...args: unknown[]) => unknown
}): AstroI18n
Adds new formatters at runtime.
astroI18n.addTranslationLoadingRules
function addTranslationLoadingRules(
translationLoadingRules: {
groups: string[]
routes: string[]
}[],
): AstroI18n
Adds new translation loading rules at runtime.
astroI18n.addRoutes
function addRoutes(routes: {
[secondaryLocale: string]: {
[segment: string]: string
}
}): AstroI18n
Adds new route segment translations at runtime.
astroI18n.extractRouteLocale
function extractRouteLocale(route: string): string
Utility function to parse one of the configured locales out of the given route.
astroI18n.redirect
function redirect(destination: string | URL, status = 301): void
Use this instead of Astro.redirect
to redirect users.
Components
You can use these premade components in your project :
HrefLangs
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} />
))
}
LocaleSwitcher
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>
CLI
astro-i18n install
Generates default files and add i18n commands to package.json
.
astro-i18n generate:pages
Generates the pages corresponding to your current config and src/pages
folder. You can use the --purge
argument to clear old pages.
astro-i18n generate:types
Generates types in src/env.d.ts
to give you type safety according to your config & translations.
astro-i18n extract:keys
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.