Belén Albeza

Building a static multi-language site with Metalsmith (part II)

This is the second part of the Building a static multi-language site with Metalsmith series. If you are already familiar with Metalsmith or need more context, please read part I.

You can also fetch the source code from the Github repository.

Creating content with multiple languages

Now we will start with multi-language content. We will be usign the metalsmith-multi-language plugin which does a bunch of useful stuff for us:

├── es
│   └── index.html
├── fr
│   └── index.html
└── index.html

In my own case, I just needed two locales: English (en) and Spanish (es). Let's replicate that. Rename index.md to index_en.md and add a index_es.md as well:

---
title: Inicio  
---

Mi colección de **recetas**.  

Use the plugin before doing any other processing:

var multiLanguage = require('metalsmith-multi-language');

const DEFAULT_LOCALE = 'en';  
const LOCALES = ['en', 'es'];

metalsmith(__dirname)  
    // ...
    .destination('dist')
    .use(multiLanguage({
        default: DEFAULT_LOCALE,
        locales: LOCALES
    }))
    // ...

This is how the built site looks like:

dist  
├── css
│   └── styles.css
├── es
│   └── index.html
└── index.html

And if we open es/index.html, we can see the content in Spanish:

Spanish screenshot

Let's put a link in each file to their translation counterpart. We can use the lang method that is added to every file. Given a locale, it will match that version. Unfortunately, the plugin we are using doesn't inject the list of available locales for every file, so we need to calculate that ourselves.

How? The plugin does add the locales and defaultLocale fields that we specified when using the plugin. With that, we just need to filter out the current locale from the locales array and the locales with no translation yet. We can get that by querying lang: if it returns undefined, that file isn't available in that locale.

Since we'll want this in every page of our site, we will add this to the default.jade layout:

main: article  
    - var availableLocales = locales.filter(x => x !== locale && !!lang(x));
    if availableLocales
        p Also available in#{' '}
            each lc, index in availableLocales
                a(href=`/${lang(lc).path}`)= lc
                if index < availableLocales.length - 1
                    |,#{' '}
            |.

If you build the site, you will see the link in action. Note that since the link URL is absolute, you will need to run a local server:

Translation link

In addition to the link to the translation, it would be good to include a link to the index of each locale in the main heading. We just need to take into account that the main locale has a different pattern than the others.

Let's add this link to default.jade:

header  
    - var url = locale === defaultLocale ? '/' : `/${locale}/`;
    h1: a(href=url)= __('Cookbook')

Adding the locale to the URL

We want to replicate what the metalsmith-multi-language plugin does with the index files with the rest of our content (why isn't this feature included in the plugin itself?).

In our case, we will put every translation of a file to their own locale subdirectory –including the main locale as well. So for our cookbook example, we would have es/recipes/paella.html and en/recipes/paella.html.

We can achieve this with yet another plugin: metalsmith-permalinks. It will also allow us to have nice URL's, so we could access /en/recipes/paella/ instead of /en/recipes/paella.html.

Create a content/recipes/ directory and add two files to it.

This is the English version, content/recipes/tortilla_en.md:

---
title: Spanish omelette  
---

Ingredients:

- 2 potatoes
- 3 eggs
- 1 onion
- Olive oil
- Salt

And this is its Spanish counterpart, content/recipes/tortilla_es.md:

---
title: Tortilla de patata  
---

Ingredientes:

- 2 patatas
- 3 huevos
- 1 cebolla
- Aceite de oliva
- Sal

When using the metalsmith-permalinks plugin, we will disable the relative setting. By default, the plugin allows relative URL's by duplicating the files all over. This is generally useful, but since we could potentially have multiple locales (and thus, multiple siblings), it seems a waste of space.

We will also be using the file's title to create the URL. In that way, we have more SEO-friendly URL's:

var permalinks = require('metalsmith-permalinks');

metalsmith(__dirname  
    // ...
    .use(markdown())
    .use(permalinks({
        relative: false,
        pattern: ':locale/:title/'
    }))
    // ...

After building the site, you will see the following file tree:

dist  
├── css
│   └── styles.css
├── en
│   ├── home
│   │   └── index.html
│   └── spanish-omelette
│       └── index.html
└── es
    ├── inicio
    │   └── index.html
    └── tortilla-de-patatas
        └── index.html

Everything looks kind of right, but we don't want our index files to be handled by permalinks. In order to disable it for these files, add a permalink: false to their metadata. For instance:

---
title: Home  
permalink: false  
---

Note that you only need to add that piece of metadata to the index of the main locale (in this case, index_en.md). This is because the multi-language plugin merges the metadata from the main locale to the other ones. Once you have edited that file, you will have the structure we want:

dist  
├── css
│   └── styles.css
├── en
│   └── spanish-omelette
│       └── index.html
├── es
│   ├── index.html
│   └── tortilla-de-patatas
│       └── index.html
└── index.html

Translating the layout

You already noticed that although our Markdown content is translated, the site itself isn't. There is a plugin that brings us the i18n library, metalsmith-i18n. With this library, we will mark strings to be translated. We will have a JSON file per locale, ready for us to translate, and, at the same time, those strings marked will be translated if its translation is available.

npm install --save metalsmith-i18n  

As with the multi-language plugin, we need to specify the default locale and the available ones. We also need to indicate where do we want the translation JSON files to be generated.

var i18n = require('metalsmith-i18n');

metalsmith(__dirname)  
    // ...
    .use(i18n({
        default: DEFAULT_LOCALE,
        locales: LOCALES,
        directory: 'locales'
    }))
    .use(markdown()
    // ...

Running it will change nothing in our site… but it will add a locales directory to the root of the project. If you inspect the JSON files in it, they both will contain an empty object.

Let's mark some strings for translation in layouts/default.jade. We will do this by calling the __() function.

doctype html  
head  
    title #{__('Cookbook')} - #{title}
    meta(charset='utf-8')
    link(rel='stylesheet', href='/css/styles.css')
body  
    header
        h1= __('Cookbook')
    main: article
        - var availableLocales = locales.filter(x => x !== locale && !!lang(x));
        if availableLocales
            p #{__('Also available in') + ' '}
                each lc, index in availableLocales
                    a(href=`/${lang(lc).path}`)= __(lc)
                    if index < availableLocales.length - 1
                        |,#{' '}
                |.
        h1= title
        != contents

Build the site again and you will see that the JSON files contain keys to translate. Let's do it. For English we just need to edit the language locale string for Spanish. Edit locales/en.json:

{
    "Cookbook": "Cookbook",
    "Also available in": "Also available in",
    "es": "castellano"
}

For locales/es.json we need to do a bit of translation…

{
    "Cookbook": "Recetario",
    "Also available in": "Disponible también en",
    "en": "English"
}

Re-build the site and, here it is!

Layout translated

Translating URL's

We are almost done. Something that would be nice is create more elaborated URL's. So instead having everything hanging from root, we could have the recipes inside their own translated prefix. For instance, /en/recipes/<title>/ and /es/recetas/<title>/.

To do that we need to classify the recipes by locale and then having the permalinks plugin to run a different URL for each locale. We can do that by using the metalsmith-collections. This plugin let us organise our files into collections matching a particular pattern. Later we can use these collections to create permalinks or to output a list of files, for instance.

npm install --save metalsmith-collections  

We will group the recipes by their locale:

var collections = require('metalsmith-collections');

metalsmith(__dirname)  
    // ...
    .destination('dist')
    .use(collections({
        'recipes_en': 'recipes/*_en.md',
        'recipes_es': 'recipes/*_es.md'
    }))
    // ...
    .use(permalinks({
        relative: false,
        pattern: ':locale/:title/',
        linksets: [{
            match: { collection: 'recipes_en' },
            pattern: ':locale/recipes/:title/'
        }, {
            match: { collection: 'recipes_es' },
            pattern: ':locale/recetas/:title/'
        }]
    }))

Run it and see it in action:

dist  
├── css
│   └── styles.css
├── en
│   └── recipes
│       └── spanish-omelette
│           └── index.html
├── es
│   ├── index.html
│   └── recetas
│       └── tortilla-de-patatas
│           └── index.html
└── index.html

Getting files of a single locale

Finally, we need to create some sort of index for each locale that will output a list with links of its locale. This is useful for blogs –and for cookbooks too.

Collections are made available to templates inside a collections object, so the recipes in Spanish would be available in collections['recipes_es'].

What we will do is to create a new layout –that extends the default one– to be used by the index files, where this list of recipes will be available.

Edit layouts/default.jade to add a new block which we can fill later via inheritance:

body  
    // ...
    != contents
    block extra

Now create layouts/home.jade:

extends ./default.jade

block extra  
    h2= __('Recipes')

    ul
        each recipe in collections[`recipes_${locale}`]
            li: a(href=`/${recipe.path}`)= recipe.title

And that's it! Here's how the new index looks like:

Index with ToC

I hope you found this useful. And don't forget that you can take a look at my actual cookbook as well!