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 interface strings for 32 locales. English, Simplified Chinese (zh and zh-cn) and Traditional Chinese (zh-tw) are reviewed; the rest keep the translations inherited from Docsy, with English fallbacks for the labels OINK added. To change one, 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

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.

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 site turns that constraint into a CI check with a script that compares rendered HTML rather than source:

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

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
    languageDirection: 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. Build, and confirm both languages’ output and indexes exist:

    hugo --printPathWarnings --panicOnWarning
    ls public/index.html public/zh/index.html
    ls public/offline-search-index.*
  2. Check hreflang: each page’s <head> should carry one rel="alternate" per language plus a rel="canonical" pointing at itself.

    grep -o 'rel="alternate" hreflang="[^"]*"' public/zh/docs/index.html
  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. Wire the heading alignment check into CI on a bilingual site, using the script above.