pyssg is pre-1.0 and under active development - APIs, config, and themes may change.
Add internationalization (i18n)

2 min read

Add internationalization (i18n)

Goal: serve the same site in several languages, with a language switcher and correct hreflang tags, without ever linking to a missing translation.

1. Add the plugin

The i18n plugin is built in. Append it to a preset with extra_plugins:

from __future__ import annotations

from pyssg.presets import docs
from pyssg.plugins import i18n

config = docs(
    site={"title": "My Docs"},
    base_url="https://example.com",
    extra_plugins=[i18n(default_locale="en", locales=["en", "vi"])],
)

2. Lay content out one directory per locale

The locale is the top-level directory under content/ - there is no frontmatter override:

content/
  en/index.md          ->  /            (default locale, served at the root)
  en/guide/intro.md    ->  /guide/intro/
  vi/index.md          ->  /vi/
  vi/guide/intro.md    ->  /vi/guide/intro/

The rules are deliberately simple:

  • The default locale is served at the site root (its prefix is stripped); every other locale keeps its /<locale>/ prefix.
  • Content outside any locale directory produces no page.
  • A page is emitted only for locales that actually have the file - there is no content fallback, so a language switcher never links to a missing translation.

3. Use the template variables

The plugin gives every page three extra template variables:

  • lang - the current page's locale.
  • translations - the same page in other locales, each {lang, url, title}.
  • languages - all configured locales.

The built-in docs and blog themes already use them to render <html lang>, hreflang alternates, and a header switcher, so with the layout above you get a working bilingual site out of the box.

4. Translate the UI strings

Steps 1-3 localise your content. The labels baked into the theme - "Tags", "Previous", "On this page" - are translated separately, through string tables.

Where the tables live

Two optional files per locale are merged, the site's overriding the theme's per key:

<layout>/i18n/en.toml    # theme defaults (the built-in themes ship these)
<layout>/i18n/vi.toml
i18n/en.toml             # site overrides, relative to the site root
i18n/vi.toml

The tables are loaded independently of the routing plugin, so even a single-language site can use them.

Write a table

Group keys with TOML tables and address them with dots (nav.home). Values may contain {placeholders}:

# i18n/vi.toml
[nav]
home = "Trang chủ"
tags = "Thẻ"

[post]
reading_time = "{minutes} phút đọc"

Call t() in a template

The render step injects a t(key, **vars) function into every template:

<a href="/">{{ t("nav.home") }}</a>
<span>{{ t("post.reading_time", minutes=reading_time) }}</span>

t() resolves the key in the current page's locale, falls back to the default locale, and finally returns the key itself - so an untranslated label shows its key (easy to spot) instead of breaking the page. The built-in blog and docs themes already route every label through t() and ship en/vi tables, so translating them is just a matter of adding or overriding a .toml file.

See the i18n reference for the exact lookup and merge rules.

5. Build and check

pyssg --site my-site build

English pages appear at the root and Vietnamese pages under /vi/. See examples/docs/ in the repository for a complete bilingual sample.