Ovellum v0.12.0
English

Edited

Multiple languages (i18n)#

Ovellum can publish the same site in several languages. It's opt-in: a single-language site needs no locale folders and behaves exactly as before. Turn it on by declaring your languages, and content moves into one subtree per language.

Enable it#

Add site.locales (and optionally site.defaultLocale) to your config:

export default {
  site: {
    defaultLocale: 'en-US',
    locales: [
      { code: 'en-US', label: 'English' },
      { code: 'ja', label: '日本語' },
      { code: 'zh-Hans', label: '简体中文' },
    ],
  },
} satisfies OvellumUserConfig;
  • code is a BCP 47 language tag — en-US, ja, zh-Hans / zh-Hant, de, pt-BR. This is also the content folder name and the <html lang>.
  • label is what the picker shows — use the language's autonym (its own name: 日本語, 简体中文, English), since readers scan for their language in its native script.
  • defaultLocale is served at the site root; it defaults to the first entry of locales.

Use the right codes. UK English is en-GB (not en-uk), Japanese is ja (not jp), and Chinese uses a script subtag — zh-Hans (Simplified) or zh-Hant (Traditional) — not a country code.

Organize content per locale#

With locales on, content lives in one subtree per language, named by its code:

content/
  public/              ← shared across all locales (copied to the root)
  en-US/               ← the default locale
    _landing.md
    docs/
      getting-started.md
      guides/install.md
  ja/                  ← Japanese
    docs/
      getting-started.md

Pages line up across languages by identical relative path: en-US/docs/guides/install.md is the translation of ja/docs/guides/install.md. That mapping is what the language picker follows. Each locale has its own sidebar, _meta.json ordering, and frontmatter.

The reserved publicDir (public/) stays shared — it is not a locale and is copied to the output root once.

Migrating an existing site#

Adding i18n to a single-language site is a one-time move: put your existing content into content/<defaultLocale>/ and add the locales config. Files that were at the content root (a CNAME, say) move into public/ so they still reach the output root. Nothing else changes.

URLs#

The default locale is served at the root; every other locale is served under its code:

Pageen-US (default)ja
docs/guides/install.md/docs/guides/install//ja/docs/guides/install/
home / landing//ja/

So existing URLs don't change when the default locale is the language you already had.

The language picker#

A globe dropdown appears in the topbar (after your nav links, before the icon cluster), listing every locale by its label. Switching takes the reader to the same page in that language. If a page isn't translated yet, the picker falls back to that locale's home — so you can ship a language with only a few pages translated and grow it over time.

Each page also gets <html lang> and, when site.baseUrl is set, hreflang alternate links (plus x-default for the default locale) so search engines serve the right language.

What's localized — and what isn't yet#

Localized: all page content and frontmatter, the sidebar/nav, page URLs, <html lang>, hreflang, the sitemap, and the template's own UI chrome — "On this page", "Edited" and its dates, "min read", the appearance-panel labels, prev/next, the 404 page, and the rest. The chrome ships translated for a set of built-in languages (English and Japanese today); any other locale falls back to English per string, and you can fill the gaps yourself (below). Right-to-left languages also get <html dir="rtl"> automatically.

Config-driven text — the landing hero/CTA/feature copy and the topbarNav / footerNav link labels you write in ovellum.config.* — is localizable too: any such field accepts either a plain string or a per-locale map (below).

Not yet: per-locale RSS feeds.

Localizing config text#

Anywhere the config takes a user-facing label or copy string, you can give a per-locale map instead of a plain string — keyed by locale code, falling back to the default locale:

topbarNav: [{ label: { 'en-US': 'Docs', ja: 'ドキュメント' }, href: '/docs/' }],
landing: {
  hero: {
    title: { 'en-US': "Docs that don't drift.", ja: 'ドリフトしないドキュメント。' },
    ctas: [{ label: { 'en-US': 'Get started', ja: 'はじめる' }, href: '/docs/' }],
  },
},

A plain string still works and shows in every locale — so you only mapify the strings you actually translate. This applies to topbarNav/footerNav labels and the landing hero title/subtitle, CTA labels, feature titles/descriptions, install titles, and trust-strip text.

Overriding or adding chrome strings#

If a locale isn't built in, or you want different wording, set strings on the locale — it's merged over the built-in table (English fills anything you omit):

site: {
  locales: [
    { code: 'en-US', label: 'English' },
    {
      code: 'fr',
      label: 'Français',
      strings: { tocTitle: 'Sur cette page', editedLabel: 'Modifié', backToTop: 'Haut de page' },
    },
  ],
}

The keys are the UI-string names (tocTitle, editedLabel, minRead, previous, next, backToTop, the appearance-panel labels, and so on).

Translations are yours to write or generate#

Ovellum renders whatever Markdown is in each locale folder. Author translations by hand, or pre-translate them however you like and drop the files in — the tool doesn't translate for you, and it doesn't get in the way.

Keeping translations in sync#

The hard part of a hand-maintained translation isn't writing it — it's noticing when the source page changes and the translation silently falls behind. ovellum check watches for that drift.

Each translated page can carry a sourceHash in its frontmatter — a fingerprint of the default-locale page it mirrors (matched by identical path across the locale folders, e.g. ja/docs/install.mden-US/docs/install.md). check recomputes the source's fingerprint and compares:

  • source unchanged → nothing to report;
  • source changed since the translation was stamped → flagged [i18n] as stale (exit code 1, so CI catches it);
  • no sourceHash yet → flagged, so new translations get stamped;
  • no matching source page → flagged as an orphan translation.

You don't write the hash by hand. After you've brought a translation back in line with its source, stamp it:

ovellum check --update-translations

That writes the current sourceHash into every translated page (touching only that one frontmatter line) and exits. The fingerprint covers the page body, not its frontmatter, and normalizes line endings — so reformatting or a frontmatter tweak won't trip a false "stale". A typical loop: edit an English page → ovellum check flags the Japanese mirror → translate the change → ovellum check --update-translations to re-stamp.

Edit this page