pyssg is pre-1.0 and under active development - APIs, config, and themes may change.
pyssg.plugins.i18n

3 min read

pyssg.plugins.i18n

Internationalisation (i18n) plugin: directory-based, route-driven locales.

The locale of a document is its top-level content directory: content/en/... and content/vi/... are the English and Vietnamese variants of the same site. The default locale is served at the site root (its directory segment is stripped from the URL); every other locale keeps its segment as a URL prefix::

content/en/guide/intro.md  ->  /guide/intro/      (en = default)
content/vi/guide/intro.md  ->  /vi/guide/intro/

When i18n is enabled the only content the engine emits is content that lives under a recognised locale directory. A file or folder whose first path segment is not a configured locale (content/about.md, content/blog/...) produces no page: the route tap returns "" and the permalink generator skips it. There is deliberately no way to override a document's locale from frontmatter -- the directory is the single source of truth, which keeps the feature free of edge cases.

Pure: the locale, the translation key and the routed URL are all functions of the source path plus the static config, never of a clock or randomness, so two builds of the same tree are byte-identical. The locale config is folded into cache_version so changing locales / default_locale busts the cache.

This is a built-in plugin and uses the standard library only.

locale_of(source_path: str, locales: frozenset[str]) -> str | None

Locale of a document, or None if it is not under a locale directory.

The locale is the first path segment when that segment is a configured locale; otherwise the document lives outside the i18n tree and is dropped.

translation_key(source_path: str, locales: frozenset[str]) -> str

Path identifying a document across locales (the part after the locale dir).

en/guide/intro.md and vi/guide/intro.md share the key guide/intro.md. A path outside any locale directory is returned verbatim.

route_url(url: str, source_path: str, locales: frozenset[str], default: str) -> str

Apply the locale routing rule to a computed URL.

Returns "" when the document is not under a locale directory (the page is then suppressed). For the default locale the leading /{default}/ segment is stripped (so it is served at the root); other locales keep their segment.

build_i18n_data(build: Build, default_locale: str, locales: frozenset[str]) -> None

Group pages by translation key and stash the result on site_data.

Runs at evaluate_collections (once every page has its final routed URL). Pages sharing a translation key (same path under different locale dirs) are cross-linked. The shape written is::

site_data["i18n"] = {
    "default_locale": "en",
    "languages": ["en", "vi"],            # all configured, sorted
    "by_url": {
        "/guide/intro/": {                # the English (default) page
            "lang": "en",
            "translations": [{"lang": "vi", "url": "/vi/guide/intro/", "title": ...}],
        },
        ...
    },
}

translations lists only the other locales that actually have the page, so a language switcher links to real translations and never to a 404 (untranslated pages are simply absent).

discover_locales(*dirs: Path | None) -> frozenset[str]

Locale codes for which a <lang>.toml table exists in any of dirs.

UI-string tables are discovered from disk rather than tied to the i18n routing plugin's configured locales, so a single-language site (no i18n plugin) still gets its theme's strings. The result is sorted-deterministic via the set; callers that need order should sort.

load_strings(theme_i18n_dir: Path | None, site_i18n_dir: Path | None, locales: frozenset[str]) -> dict[str, dict[str, str]]

Load and merge UI-string translation tables for the given locales.

For each locale <lang> two optional files are read and merged, the site's table overriding the theme's per key::

<theme>/i18n/<lang>.toml   (theme default strings)
<site>/i18n/<lang>.toml    (site override)

Tables are flattened to dotted keys. Locales with no table on disk are simply absent from the result. Pure given its path inputs: reading the same files twice yields the same mapping, so it preserves the build-twice invariant; the render cache folds a digest of these strings so editing a table busts it.

class I18nPlugin

Routes documents by their top-level locale directory.

I18nPlugin.__init__(self, default_locale: str, locales: Sequence[str]) -> None

I18nPlugin.apply(self, builder: Builder) -> None

i18n(default_locale: str, locales: Sequence[str]) -> I18nPlugin

Factory used in pyssg.config.py.