i18n

Overview

gohan supports multi-language sites through a directory-based locale structure. Each locale's content lives under a named sub-directory of the content directory (e.g. content/en/, content/ja/). The default locale is served at the root URL; other locales receive a URL prefix.

This feature is entirely backward-compatible — existing single-language sites require no changes.

Configuration

Add an i18n block to config.yaml:

site:
  language: en          # used as the default_locale fallback

i18n:
  locales: [en, ja]     # ordered list of locale codes
  default_locale: en    # served at root URL without prefix; defaults to site.language
Field Type Default Description
locales []string [] (disabled) Locale codes present under the content directory
default_locale string site.language Locale served without a URL prefix

When locales is empty, i18n is disabled and gohan behaves exactly as a single-language site.

Content Structure

content/
  en/
    posts/
      hello-world.md    → /posts/hello-world/
  ja/
    posts/
      hello-world.md    → /ja/posts/hello-world/

Only the first path segment after content/ is treated as a locale code; it must match one of the values in i18n.locales.

Translation Linking

To connect articles that are translations of each other, add translation_key in the front matter. The value must be identical across all variants.

---
title: Hello World
translation_key: hello-world
---

After BuildTranslationMap runs (automatically during gohan build), each ProcessedArticle.Translations slice contains LocaleRef entries for every sibling locale. Templates can use these to render a language switcher.

Data Model

New types

// I18nConfig holds multi-language content configuration.
type I18nConfig struct {
    Locales       []string `yaml:"locales"`
    DefaultLocale string   `yaml:"default_locale"`
}

// LocaleRef holds a locale code and the canonical URL for a translated article.
type LocaleRef struct {
    Locale string
    URL    string
}

Extended types

// FrontMatter — new field
TranslationKey string `yaml:"translation_key"`

// ProcessedArticle — new fields
Locale       string       // detected from content path, e.g. "en" or "ja"
URL          string       // canonical URL path, e.g. "/posts/hello/" or "/ja/posts/hello/"
Translations []LocaleRef  // populated by BuildTranslationMap

// Config — new field
I18n I18nConfig `yaml:"i18n"`

URL Scheme

Content file Locale Output URL
content/en/posts/hello.md en (default) public/posts/hello/index.html /posts/hello/
content/ja/posts/hello.md ja public/ja/posts/hello/index.html /ja/posts/hello/

Index pages follow the same rule:

Locale Output URL
en (default) public/index.html /
ja public/ja/index.html /ja/

Template Usage

Language switcher

For article pages, use .Article.Translations to link to the translated version:

{{if .Article.Translations}}
<nav aria-label="Language">
  <a href="{{.Article.URL}}">{{.Config.Site.Language}}</a>
  {{range .Article.Translations}}
  <a href="{{.URL}}">{{.Locale}}</a>
  {{end}}
</nav>
{{end}}

Language switcher on listing pages

Tag and category listing pages do not have .Article.Translations. Instead, use .CurrentTaxonomy.Translations (a map[locale]URL populated at render time) to look up the counterpart taxonomy page directly:

{{/* tag.html / category.html */}}
{{- $altURL := "/ja/"}}
{{- if eq .CurrentLocale "ja"}}{{$altURL = "/"}}{{end}}
{{- if .CurrentTaxonomy}}
  {{- $targetLocale := "ja"}}
  {{- if eq .CurrentLocale "ja"}}{{$targetLocale = "en"}}{{end}}
  {{- with index .CurrentTaxonomy.Translations $targetLocale}}
    {{- $altURL = .}}
  {{- end}}
{{- end}}
<a href="{{$altURL}}">Switch language</a>

Paginated taxonomy pages always land on page 1 of the opposite locale — page counts may differ per locale, so propagating the current page number would produce broken links.

Archive listing pages use .CurrentArchivePath in the same way:

{{/* archive.html */}}
{{- $altURL := "/ja/"}}
{{- if eq .CurrentLocale "ja"}}{{$altURL = "/"}}{{end}}
{{- if .CurrentArchivePath}}
  {{- if eq .CurrentLocale "ja"}}
    {{- $altURL = slice .CurrentArchivePath 3}}
  {{- else}}
    {{- $altURL = printf "/ja%s" .CurrentArchivePath}}
  {{- end}}
{{- end}}
<a href="{{$altURL}}">Switch language</a>

Both fields are set by gohan for every listing page:

Template Field Example value (EN) Example value (JA)
tag.html, category.html .CurrentTaxonomy.URL /tags/go/ /ja/tags/go/
archive.html (year) .CurrentArchivePath /archives/2024/ /ja/archives/2024/
archive.html (month) .CurrentArchivePath /archives/2024/01/ /ja/archives/2024/01/

Locale-aware conditional content

{{if eq .Article.Locale "ja"}}
<p lang="ja">日本語版の記事です。</p>
{{end}}

Sitemap (hreflang)

When articles have Translations populated, sitemap.xml automatically includes xhtml:link hreflang alternates as recommended by Google:

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>https://example.com/posts/hello-world/</loc>
    <lastmod>2024-01-01</lastmod>
    <xhtml:link rel="alternate" hreflang="en" href="https://example.com/posts/hello-world/"/>
    <xhtml:link rel="alternate" hreflang="ja" href="https://example.com/ja/posts/hello-world/"/>
  </url>
  ...
</urlset>

Implementation Notes

  • Locale detection (detectLocale in internal/processor/processor_impl.go): matches the first path segment after content/ against I18n.Locales.
  • Output path (computeOutputPath): strips the locale segment from the content path; re-prefixes with the locale code for non-default locales.
  • Translation map (BuildTranslationMap): called once in build.go after Process(); groups articles by TranslationKey and populates Translations on each article.
  • Generator (internal/generator/html.go): generates one index page per locale and one article page per article, using the locale-aware path.
  • Backward compatibility: when I18n.Locales is empty, all new helpers return empty values and no behaviour changes.

Locale-aware taxonomy

When i18n is enabled, gohan loads a separate taxonomy registry for each locale. For each locale it looks for a locale-specific file first, then falls back to the global file:

content/
  tags.yaml              # global / fallback
  categories.yaml        # global / fallback
  en/
    tags.yaml            # EN-specific (optional)
    categories.yaml      # EN-specific (optional)
    posts/
  ja/
    tags.yaml            # JA-specific (optional)
    categories.yaml      # JA-specific (optional)
    posts/

Validation (gohan build) is locale-scoped: EN articles are checked against the EN registry, JA articles against the JA registry. If no locale-specific file exists the global file is used as the fallback — so existing single-language sites require no changes.

site.Tags and site.Categories (used in templates) contain the deduplicated union of all locale registries, so all tags and categories are always accessible.

Cross-locale taxonomy linking

To connect the same tag or category across locales (so a language switcher on a tag/category page can jump to the equivalent page in the other locale), add translation_key to each taxonomy entry. Entries sharing the same key are treated as translations of each other.

# content/en/categories.yaml
categories:
  - name: Application
    translation_key: application

# content/ja/categories.yaml
categories:
  - name: アプリケーション
    translation_key: application

At render time, gohan populates .CurrentTaxonomy.Translations — a map[locale]URL for every other locale that shares the same translation_key. On paginated pages the map always points at page 1 of the counterpart, since page counts can differ per locale.

Implicit fallback — shared names. If translation_key is omitted, gohan falls back to matching by the taxonomy Name field (case-insensitive). This means taxonomies whose names are identical across locales — e.g. ASCII identifiers like go, docker, aws — are automatically linked without any configuration. Explicit translation_key always takes precedence.

Case Configuration required Behaviour
Same name in all locales (go, docker) none auto-linked via Name fallback
Different names per locale (Application vs アプリケーション) add matching translation_key in every locale file linked via key
No counterpart in other locale none Translations empty → switcher falls back to locale root

Limitations (current version)

  • Feeds (atom.xml, feed.xml) include articles from all locales.
  • No automatic redirect from / to the user's preferred locale.

Related