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;
codeis 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>.labelis what the picker shows — use the language's autonym (its own name:日本語,简体中文,English), since readers scan for their language in its native script.defaultLocaleis served at the site root; it defaults to the first entry oflocales.
Use the right codes. UK English is
en-GB(noten-uk), Japanese isja(notjp), and Chinese uses a script subtag —zh-Hans(Simplified) orzh-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:
| Page | en-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.md ↔ en-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 code1, so CI catches it); - no
sourceHashyet → 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.