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 (
detectLocaleininternal/processor/processor_impl.go): matches the first path segment aftercontent/againstI18n.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 inbuild.goafterProcess(); groups articles byTranslationKeyand populatesTranslationson 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.Localesis 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.