Skip to content

Languages

Add a language, keep translations side by side, configure menus and interface strings per language, and align heading anchors across languages.

OINK uses Hugo’s multilingual model and adds no directory conventions of its own: configure a languages block, and keep a translation beside its original in the same directory, distinguished by a filename suffix. What follows covers what a single-language site has to change to become bilingual, plus the two things bilingual sites get wrong: resource ownership and heading anchors.

Enabling a second language

hugo.yml
defaultContentLanguage: en

languages:
  en:
    label: English
    locale: en-US
    weight: 1
    title: OINK
    params:
      description: A Hugo theme for engineering docs
  zh:
    label: 简体中文
    locale: zh-CN
    weight: 2
    title: OINK
    params:
      description: 为工程而设计的 Hugo 文档主题
      time_format_default: 2006年1月2日
      time_format_blog: 2006年1月2日

That is this site’s configuration. What the four fields do:

  • label is the name shown in the language picker, written in that language’s own script: 简体中文, not Chinese.
  • locale is the standard language tag, and reaches <html lang>, the hreflang alternate links and the Open Graph metadata.
  • weight decides both language order and the picker’s cycle order, lowest first.
  • params is a per-language override: a key not written here inherits the global value of the same name. Date formats usually need one per language.

The default language carries no path prefix (English at /docs/…), and each other language takes one (Chinese at /zh/docs/…). To give the default language a prefix too, add defaultContentLanguageInSubdir: true. That changes every URL on the site, so a live site needs redirects arranged at the same time.

File naming and resources

A translation sits beside its original, distinguished by suffix, and Hugo treats the shared base filename as making them two language versions of one page:

  • content/docs/
    • install.mdEnglish
    • install.zh.mdChinese
    • _index.md
    • _index.zh.md

Page bundles work the same way: index.md and index.zh.md in one directory.

Resources in a page bundle follow one rule: a resource whose filename has no language suffix is shared by every language, and one with a suffix belongs only to that language.

  • content/docs/install/
    • index.mdEnglish page
    • index.zh.mdChinese page
    • topology.webpavailable to both languages
    • screenshot.zh.webpavailable to the Chinese page only

When the body references a suffixed resource, write the name without the suffix: ![Screenshot](screenshot.webp), and Hugo resolves it for the current language.

That rule has a corollary: in a page bundle holding only index.zh.md with no English counterpart, unsuffixed resources are not handed to the Chinese page — they belong to the default language, which has no page in that bundle. Every resource then needs the .zh. suffix, which is how the Chinese page bundles under this site’s docs/ are arranged.

What needs translating:

  • Translate: title, description, summaries, menu labels, tag names, image alt text, callout bodies, and reader-facing shortcode parameters.
  • Keep identical: dates, weight, aliases, and any metadata affecting routing. A mismatch makes the sidebar order differ between languages.
  • Do not translate: commands, configuration keys, filenames, URLs, version numbers, product names, shortcode names.

Per-language configuration

Three things live outside content/ and need one copy per language.

Menus are written under their own language:

hugo.yml
languages:
  zh:
    menus:
      main:
        - identifier: docs
          name: 文档
          pageRef: /docs
          weight: 20

The identifier must match across languages: the command palette’s quick links and the grouping order of search results both match on it. Configuring menus fully is in Navigation and menus.

Home page data is chosen by language: data/home/en.yaml, data/home/zh.yaml. Without a file for the current language it falls back to en.yaml; a single-language site needs only one data/home.yaml. See Home and landing pages.

Interface strings: the theme ships 32 complete interface catalogs: the 31 locale filenames supported by Docsy, plus generic zh. Every catalog contains all 194 OINK messages in its native language; none relies on generated English fallback blocks. zh and zh-cn use Simplified Chinese, while zh-tw uses Traditional Chinese. The exact locale and placeholder contract is recorded in Architecture. To change a string, create a file of the same name under the site’s own i18n/ and write only the keys you override:

i18n/en.yaml
ui_search: Search the docs

Keep the concrete locale: zh-CN shown for the non-default language when supporting Hugo 0.160.x with regional Chinese catalogs present. Bare locale: zh is safe in the same configuration from Hugo 0.161 onward.

Untranslated fallback and the language picker

The language picker’s icon is itself a link: clicking it moves to the next language by weight (wrapping at the end), while hovering or focusing it expands a menu of every language. On a touch screen the menu does not expand and a tap switches directly. A bilingual site therefore toggles back and forth in one click.

The menu always lists every configured language, whether or not the current page is translated:

  • The target language has a translation → jump to that page;
  • The target language has none → jump to that language’s home page.

Falling back to the home page beats dropping the reader into a 404. The cost is that the reader may not notice being sent there, so a bilingual site should check “every page has a counterpart” as a constraint rather than relying on the fallback.

This fallback belongs to the language picker. The 1.2.0 implementation separates it from SEO: hreflang lists only the current page and its actual translations, each blog pagination page has its own canonical, and later pages omit language alternates. These corrections are not in the published v1.1.0 tag; see SEO version behavior before applying those expectations to a pinned release.

A missing translation is never filled in with the original

When a Chinese page does not exist, the Chinese site does not have that page at all: it is absent from the sidebar, the search index and the paging order.

Search indexes are also per language: searching from a Chinese page matches Chinese content only. CJK queries use substring matching, detailed in Search.

Heading anchors must align

Hugo derives an ID from the heading text, so a Chinese heading yields a Chinese ID: /docs/install/#prerequisites and /zh/docs/install/#前置条件 point at the same place through two anchors that do not connect, and cross-language deep links, contents and in-page jumps all break.

The remedy is to write the original’s ID explicitly on the translated heading:

install.zh.md
## 前置条件 {#prerequisites}

Two disciplines:

  1. Take the ID from the HTML the English page renders, not from the heading text. When a heading contains inline code, a badge or a shortcode, the generated ID does not match the heading text.
  2. Corresponding pages must have the same number of headings, in the same order, with the same IDs. Where a translation genuinely needs an extra section, give it an independent, stable ID that does not collide with the English side.

This documentation site’s translation checker compares source structure and rendered heading IDs. The following command runs in the oink.pgsty.com checkout; the script is not part of a normal consumer site:

node scripts/check-doc-translations.mjs --public public

Before adapting it to your CI, change its fixed content scopes and EN/ZH file conventions. It locates source files relative to the script itself; --public only chooses the rendered output directory. For a manual check, compare the heading IDs in a representative translated pair’s generated HTML.

Writing explicit English {#id} anchors from the moment a page is created costs less than retrofitting them.

Right-to-left languages

Declare the writing direction under the language:

hugo.yml
languages:
  ar:
    label: العربية
    locale: ar
    direction: rtl
    weight: 3

<html dir> changes with it, and the theme additionally loads Bootstrap’s RTL stylesheet. The theme’s own CSS uses logical properties throughout (margin-inline-start rather than margin-left), so mirroring happens by itself. A site’s own CSS needs logical properties too, or it will be misplaced under RTL.

Verify

  1. From your site’s root, build and confirm both languages’ output exists. These paths assume English at / and Chinese at /zh/; adjust them for your language settings. Check the indexes only if local search is enabled:

    hugo --printPathWarnings --panicOnWarning
    ls public/index.html public/zh/index.html
    ls public/offline-search-index.*
  2. Inspect hreflang and canonical URLs against your pinned version’s SEO behavior. For the 1.2.0 implementation, expect only actual translations, a canonical for each blog pagination page, and no language alternates after page 1. The v1.1.0 tag retains the earlier behavior; these differences alone do not indicate a configuration error.

    # Replace with an actual generated page in your site.
    PAGE=public/zh/docs/getting-started/index.html
    test -f "$PAGE" && grep -o '<link[^>]*hreflang[^>]*>' "$PAGE"
  3. On a translated page, expand the language picker and choose the other language; confirm you stay on the same document. Repeat on an untranslated page and confirm you land on that language’s home page rather than a 404.

  4. Search the same concept once in each language and confirm both return results.

  5. Compare heading IDs in a translated pair. When adding this check to CI, adapt the documentation-site script as described above rather than running it unchanged against a different content tree.