
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.
Comments