Table of Contents

multilang

INTERNAL_KEY: '_multilang'


What it does

The multilang module wires Eleventy's built-in I18nPlugin (the locale-aware URL plugin), normalises your language config, attaches flat per-page language fields (lang, locale, translationKey, isDefaultLang), builds the translation map, and registers three translation filters for cross-language lookups in templates.

Relationships are also published to runtime stores, so consumers outside the cascade can read them: the head module builds hreflang alternates from them once the cascade closes.

A translation key is the identifier you set on a page (translationKey: 'about' in front matter) so Baseline knows the English /about/ and the French /fr/a-propos/ are the same page in different languages. Hreflang is the HTML link relation that tells search engines about those alternates.

Translating UI strings is a separate job with a separate home. This module answers "which pages are versions of each other"; the `t` filter answers "what is the label for this key in this language", reads _data/translations/, and is registered whether or not this module is on.


Active when

All three of these must be set, otherwise the module exits early without registering anything:

  • options.multilingual: true
  • settings.defaultLocale, or settings.defaultLanguage as its alias (a non-empty string)
  • settings.languages (a non-empty object or array)

defaultLocale is the BCP 47 tag ('en-US') and the preferred key. defaultLanguage is the short code ('en'); set it when you do not need a region and the locale is derived for you through Intl.Locale. Either one satisfies activation, and the module resolves both from whichever you supplied.

There is no inference on the other half. Setting the language keys without the explicit multilingual: true will not activate the module.


Lifecycle

  • Build-time. Adds I18nPlugin, registers the translation filters, registers the computed per-page language fields, normalises the language config through normalizeLanguageMap.
  • Cascade-time. The translationsMap collection walks every page with a translationKey and writes the per-key map to a runtime store. Separately, page.translations groups the content graph, and the head module reads that same grouping to emit hreflang.

How it works

  1. Normalise the language config. normalizeLanguageMap accepts an object map or an array of strings, lowercases the keys, drops invalid entries (logged when verbose: true), and returns the normalised object.
  2. Activate I18nPlugin. Adds Eleventy's built-in plugin with the resolved defaultLanguage and errorMode: 'allow-fallback'.
  3. Compute the per-page language fields. Four independent eleventyComputed.page.* registrations: lang (the short code), locale (the BCP 47 string), translationKey, and isDefaultLang.
  4. Build the map. One walk per build writes translationsMap to a runtime store. Pages without a translationKey are skipped silently. Pages whose lang is outside the allowed set are logged.
  5. Register filters. translationsFor, translationIn, defaultTranslation.
  6. Expose page.translations. Grouped from the content graph rather than a collection, so a page can list its siblings without one.

Defaults

  • errorMode for I18nPlugin: 'allow-fallback', so an untranslated page serves the default-language version rather than a 404.
  • Language resolution per page, in order: data.lang, data.language, the language derived from data.locale, then settings.defaultLanguage.
  • Allowed-languages set, built from settings.languages keys; a page whose lang is not in it is logged and skipped during collection building.

Neither default key falls back to 'en' automatically. With both absent, the module stays inactive (see Active when).


Settings

The multilang module reads three parts of the settings argument. Full shape on Site settings.

Key Type Used for
settings.defaultLocale string BCP 47 default locale ('en-US'). The preferred key, and the fallback page.locale for languages with no locale of their own.
settings.defaultLanguage string Short default-language code ('en'), an alias for the above. Also the key the sitemap module and the breadcrumb builder read, both of which assume the convention that this language sits at unprefixed URLs and the others under /<lang>/. Writing those permalinks is still yours.
settings.languages object | string[] Map of language codes to per-language overrides, or a flat array of codes. Arrays are normalised to objects with empty entries.

Options

Option Type Default Meaning
multilingual boolean false Activate the module. Required, alongside settings.

Per-page language fields

Every page receives four flat language fields on page, resolved during the cascade:

export default {
	page: {
		lang: 'en', // short code, resolved and lowercased
		locale: 'en-US', // BCP 47 string, from `data.locale` or the language's `locale`
		translationKey: 'about', // from `data.translationKey`, or undefined
		isDefaultLang: true // lang === defaultLanguage
	}
};

page.locale is the BCP 47 string itself. The head module reads these fields for hreflang. The sitemap partitions on the front-matter lang rather than the computed page.lang, so a page that declares only locale gets a resolved page.lang but still files under the default language's sitemap.


page.translations

The current page's siblings in other languages, current page excluded, sorted by language code. Each entry is { url, lang, label, title, description, isDefaultLang }.

{% for entry in page.translations %}
	<a href="{{ entry.url }}" hreflang="{{ entry.lang }}">{{ entry.label }}</a>
{% endfor %}

It is grouped from the content graph rather than from a collection, which is what lets a page see its siblings during its own cascade at all. One consequence worth knowing: the graph's membership rules are the translation set's membership rules. A page marked _internal, excluded with baselineExcludeFromGraph, or not rendering to .html has no graph node, so it is absent here even when it has a translationKey. It still appears in collections.translationsMap and in the filters below, which walk the collection instead. The list is also empty during the pre-pass, the build that produces the graph in the first place.

label names the language ("Nederlands"), while title and description name the sibling page, so a switcher can show either without a second lookup.

A language switcher wants the current language shown too, so add it back from page.lang.


Collection

One collection, keyed by translationKey:

  • translationsMap is a nested map: translationsMap[translationKey][lang]. Each leaf carries { url, lang, label, title, description, isDefaultLang }.

If you want the pages themselves rather than these records, group collections.all by data.lang or data.translationKey. That is a two-line collection of your own and it stays yours to shape.

The map is also written to a runtime store, so transform-time consumers can read it without going through collections. That is how wikilinks resolve [[slug:lang]].


Filters

Three filters for cross-language lookups in templates. Full reference on Filters.

  • page | translationsFor: every language variant of that page, itself included.
  • page | translationIn(lang): the specific-language variant, or null.
  • page | defaultTranslation: the default-language variant, or null.

They read the translation map directly, so there is no collection to pass. For the page being rendered, page.translations is the shorter route and excludes the current language.


Tips

  • Every localised page needs both translationKey and lang in its front matter. Without translationKey, the page does not appear in translation collections; without lang (or a default), the resolver falls back and may misclassify the page.
  • Keep the default-language page present for every translation key. It powers the x-default alternate and the head module's fallback resolution.
  • With multilang active and more than one language in the map, the sitemap module automatically emits per-language sitemaps plus an index. A single-language map keeps the one flat /sitemap.xml. See sitemap.
  • The "Unknown lang ..." log line is your signal that a page declared a language not in settings.languages. Add it to the languages map or fix the page's front matter.

The head module emits hreflang automatically when this module is active and the page has a translationKey. If you want to render alternates yourself (or override the default markup), translationsMap is the source:

{% set t = collections.translationsMap[translationKey] %}
{% if t %}
	{% for lang, entry in t %}
		<link rel="alternate" hreflang="{{ entry.lang }}" href="{{ entry.url }}">
		{% if entry.isDefaultLang %}
			<link rel="alternate" hreflang="x-default" href="{{ entry.url }}">
		{% endif %}
	{% endfor %}
{% endif %}

Peer deps

None. Uses Eleventy's built-in I18nPlugin.


See also