Skip to main content
The cover image for "Eleventy (11ty) string-based translation with i18next"

Eleventy (11ty) string-based translation with i18next

Sidebar

This article shows how you can make an Eleventy (11ty) website translatable on a string-by-string basis, supporting translation platforms such as Weblate. A full working example is available at .

This article shows how you can make an Eleventy (11ty) website translatable on a string-by-string basis, supporting translation platforms such as Weblate.

A full working example is available at https://gitlab.com/rubenwardy/eleventy-i18n-example.

You can also take a look at the Luanti website for an example of this in production.

Why string-based rather than entire pages #

Most Eleventy tutorials describe how you can translate entire pages at a time. You might create a blog post in English in an en folder and then translate into French in a fr folder, for example. This is great if you’re already multilingual or have an in-house translation department, but doesn’t work when you want to work with crowd translation services.

First of all, a page is way too large and overwelming to translate at once. Working on a string-by-string basis is much more manageable for translators.

Secondly, if you update the source page, you need to manually go through and update all the translated pages. By using our source language as the source of truth, we can invalidate and notify translators when the source changes automatically. Translates will only need to update strings that have changed.

Why data-driven #

Translation platforms like Weblate allow non-technical users to easily contribute translations to a website. It also allows multiple translators to work on the same language at the same time without worrying about Git conflicts.

By making our translations data-driven rather than requiring editing random files, we allow the use of Weblate. In my solution, translations are stored as i18next JSON files.

Eleventy (11ty) and internationalization (i18n) libraries #

Eleventy’s built-in i18n plugin allows you to manage translated pages. You can specify the language of a page and use filters to find alternative languages for a page. It does not help you with actually translating the content, that’s where a third-party library comes in.

I went with i18next as a third-party internationalization library. By exposing it as a filter, we can translate text in templates:

<h1>{{ "Hello world!" | i18n }}</h1>

When the site renders, the i18n filter can use page.lang and the input text to look up a translation:

<h1>Bonjour le monde!</h1>

Here’s where the magic happens. If we put all of our page content into a layout, we can translate the page into multiple languages by using it as a layout and just changing the lang:

---
layout: pages/about.html
lang: fr
---

If we move lang to the data-cascade, we can just copy the content/fr folder when adding support for new languages and only need to edit the cascade file.

Ideally, we’d be able to generate these pages using pagination by reading a list of enabled languages. Unfortunately, this does not work in practice due to how pagination is implemented.

Explaining the code #

I18nPlugin #

We initialise the Eleventy i18n plugin:

import { I18nPlugin } from "@11ty/eleventy";

// in the eleventy function

eleventyConfig.addPlugin(I18nPlugin, {
	defaultLanguage: "en",
});

i18next #

First, we import some dependencies and work out the dirname:

import i18next from 'i18next';
import Backend from 'i18next-fs-backend';
import { join } from 'path';
import { readdirSync, lstatSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import iso639 from "iso-639-1";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

In your Eleventy config function, we initialise i18next:

i18next.use(Backend).init({
	initAsync: false,
	lng: "en",
	saveMissing: true,
	nsSeparator: false,
	keySeparator: false,
	fallbackLng: "en",

	backend: {
		loadPath: join(__dirname, 'locales/{{lng}}/{{ns}}.json'),
		addPath: join(__dirname, 'locales/{{lng}}/{{ns}}.json'),
	},

	preload: readdirSync(join(__dirname, 'locales')).filter((fileName) => {
		const joinedPath = join(join(__dirname, 'locales'), fileName)
		const isDirectory = lstatSync(joinedPath).isDirectory()
		return isDirectory;
	}),
});

We enable saveMissing so that we can save missing translations into the English translation.json when building the site. This allows us to update the template without needing an extraction tool.

Filters #

Here’s the i18n filter. I’ll explain how to use it in the next section.

eleventyConfig.addFilter("i18n", function(msg, ...args) {
	const t = i18next.getFixedT(this.page.lang ?? "en");

	if (args.length % 2 !== 0) {
		throw new Error("i18n: expected even number of arguments");
	}
	const params = {};
	for (let i = 0; i < args.length; i++) {
		const key = args[i];
		const value = args[i + 1];
		params[key] = value;
	}

	return t(msg.replaceAll("[[", "{{").replaceAll("]]", "}}"), params);
});

eleventyConfig.addFilter("langName", function(langCode) {
	return iso639.getNativeName(langCode.split("-")[0]);
});

Making pages translatable #

Start by creating content/en with an content/en/en.json with the following content:

{
	"lang": "en"
}

You can now add pages, for example, content/en/index.html:

---
layout: pages/index.html
---

_includes/pages/index.html should contain the page content:


---
title: Homepage
layout: base
---

<h1>{{ title | i18n }}</h1>

<p>
	{{ "Hello [[name]]" | i18n: "name", "Bob" }}
</p>

Notice how we use [[name]] rather than {{name}}. This is because curly braces are already used by Eleventy templates. The i18n filter will replace double square braces with double curly braces.

Conclusion #

Again, a full working example is available at https://gitlab.com/rubenwardy/eleventy-i18n-example.

rubenwardy's profile picture, the letter R

Andrew Ward

Hi, I'm Andrew Ward. I'm a software developer, an open source maintainer, and a graduate from the University of Bristol. I’m a core developer for Luanti, an open source voxel game engine.

Comments

Leave comment

Shown publicly next to your comment. Leave blank to show as "Anonymous".
Optional, to notify you if rubenwardy replies. Not shown publicly.
Max 1800 characters. You may use plain text, HTML, or Markdown.