Multiple languages

On this page

A Kilim site can be in several languages. Each page is a file, and its translations are files next to it with the language in their name. Declare the languages in the config:

import { defineConfig } from "@kilim-dev/core";

export default defineConfig({
  url: "https://example.com",
  i18n: { defaultLocale: "en", locales: ["en", "tr"] },
});

locales lists every language as a BCP 47 tag, such as en, tr or pt-BR, and includes defaultLocale.

Files and URLs

A file without a language suffix is in the default language and keeps its usual URL. .<locale> before .md makes it a translation, served under /<locale>/:

FileLanguageURL
content/index.mden/
content/index.tr.mdtr/tr/
content/posts/hello.mden/posts/hello/
content/posts/hello.tr.mdtr/tr/posts/hello/
content/404.tr.mdtr/tr/404.html

The prefix also applies to permalink patterns, slug and trailingSlash. A frontmatter permalink is used as written, without a prefix. kilim preview and kilim dev answer a missing URL with the nearest 404.html, so /tr/nothing/ gets the Turkish one.

A page does not need every language: a post written only in Turkish is fine.

Pairing translations

hello.md and hello.tr.md are versions of one page because their paths match without the suffix. When the names differ, such as about.md and hakkinda.tr.md, give the files the same translationKey. Its value is any string; the path of the default language file is a natural choice:

---
title: Hakkında
translationKey: about
---

Like other frontmatter, translationKey has to be in the collection schema: translationKey: z.string().optional(). Two files of one language with the same key are an error.

What pages and themes get

  • page.locale is the page's language, and page.translations its other versions with their locale, url and title.
  • site.i18n has the default language and every language with its code, its name in itself ("Türkçe") and the url of its home page.
  • site.collections holds the entries in the page's language only.
  • Pages link their versions with hreflang alternates and an x-default for the default language, and sitemap.xml lists them as xhtml:link alternates. Both need config url.
  • List pages and feeds are written per language: /blog/ and /blog/feed.xml for the default language, /tr/blog/ and /tr/blog/feed.xml once there are Turkish posts.
  • kilim check does not count translations sharing a title as duplicates.

The default theme sets <html lang>, shows a language switcher in the header (linking to the page's translation, or to that language's home page when there is none), and writes its interface text and dates in the page's language. It has English and Turkish text; other languages get English until you set labels.

Settings per language

List page titles take one title per language:

collections: {
  posts: { list: { title: { en: "Blog", tr: "Yazılar" } } },
},

The theme's own lists, such as posts, already have English and Turkish titles.

Theme settings in settings/<theme>.yaml apply to every language. settings/<theme>.<locale>.yaml changes them for one language: each top-level setting it has replaces the default one, and the rest stay. A Turkish menu for the default theme:

# settings/default.tr.yaml
menu:
  - label: Yazılar
    url: /tr/posts/
  - label: Hakkında
    url: /tr/hakkinda/

The default language has no file of its own: its settings are settings/<theme>.yaml. In kilim dev, the admin edits each language's settings, showing what it takes from the default.

Text the core writes

The core writes a little text into pages itself: the title of later list pages and the messages of the search box. It has English and Turkish; other languages fall back to English. Change them per language with i18n.messages:

i18n: {
  defaultLocale: "en",
  locales: ["en", "de"],
  messages: {
    de: {
      page: "{title} (Seite {page})",
      searchUnavailable: "Die Suche ist gerade nicht verfügbar.",
      searchNoResults: "Keine Ergebnisse für „{query}“.",
    },
  },
},
MessageEnglishPlaceholders
page{title} (page {page}){title}, {page}
searchUnavailableSearch is not available right now.
searchNoResultsNo results for “{query}”.{query}

Plugins and commands

  • search() writes one index per language (/_kilim/search.tr.json), and each page's search box looks only in its own language.
  • llmsTxt() writes /<locale>/llms.txt for the other languages.
  • kilim new posts "Merhaba" --locale tr creates content/posts/merhaba.tr.md, with a translationKey comment to fill in when the schema has one. --of hello --locale tr creates content/posts/hello.tr.md instead, the translation of hello.md, with its translationKey when it has one.
  • In the admin, the versions of a page share a row, and a missing language can be added as a draft copy to translate.