Multi-language
Per-language content, terms, and settings with linked translations.
The MVP we ship
Squilla's i18n is intentionally pragmatic. The model is closer to a competent v1 than to a feature-complete WPML clone, but the foundation — linked translations on nodes and terms, per-language URL routing, per-language settings with default-language fallback, language-aware menus and admin lists — is solid enough to scale into. The schema makes the multilingual case the default; you have to opt out, not opt in.
What's in the box
- Add languages, mark a default, mark inactive ones to remove from the public site without deleting the rows
- Edit any node, term, or setting in any language, with a top-bar switcher that propagates through the admin via the
X-Admin-Languagerequest header - Public site routes per language with per-node-type URL prefixes (
/recipesin English,/receptyin Slovak) - Public language switcher follows linked translations — click "Slovak" on an English post and you land on its Slovak translation, not the homepage or a 404
- Settings inherit from the default language when not localised — only fields marked
Translatable: truein their schema are eligible for per-locale overrides
What's not (yet)
- No bulk translation workflow, queues, or assignee fields
- No translation memory or external service connectors (DeepL, Google Translate)
- No per-string translation tables for theme-hardcoded copy — use translatable settings instead, or seed translated nodes
Configuring languages
Manage at /admin/settings/languages. Each language row has:
- Code — ISO 639-1 (e.g.
en,sk,de). Stored verbatim and used as thelanguage_codecolumn on every translatable row. - Name — display name shown in the admin and in the public language switcher.
- Direction —
ltr/rtl; the public layout reads this and emits the rightdirattribute. - Default — exactly one language is marked default. Default-language rows are the fallback target for every read that doesn't find a locale-specific row.
- Active — inactive languages stay in the database but disappear from the public site, the admin top-bar switcher, and the language picker on node/term editors. Use this to retire a language without losing translations.
Default-language fallback
Reads scope to the requesting language. If no row exists for that language, the kernel falls back to the default-language row. Roll out new languages incrementally: untranslated content shows in the default language rather than 404'ing, and editors can fill in localised content as they go.
Editing in a specific language
The admin top bar carries a language switcher. Picking a language sets the X-Admin-Language header on every admin request from that browser tab. From that point:
- Lists scope to that language by default — you only see that language's posts unless you toggle the "all languages" filter.
- Save writes go to that language's row.
- Settings reads pick the localised row first, default-language second.
- Menu reads pick the localised menu first, default-language second.
The chosen language survives across navigation but resets when you log out and back in. The user profile carries a language_preference that pre-selects the switcher when this user logs in — useful when an editor only ever works in one locale.
Linked translations on nodes
Every node has language_code and translation_group_id columns. Two nodes share a translation_group_id when they're translations of the same logical content. The pairing is created lazily — if a node has no translations yet, its translation_group_id may be null until the first translation is added.
To translate a node:
- Open the source node in the admin.
- Click Add translation in the Translations sidebar panel.
- Pick the target language.
- The kernel clones the node into that language with a fresh slug, joins both rows under one
translation_group_id, and opens the new node for editing.
From then on, the public site's language switcher follows the group: clicking Slovak on the English page navigates to the Slovak version. The translations panel always shows every member of the group with quick-edit links so you can hop between localisations without losing context.
Linked translations on terms
Identical model. Slug uniqueness is scoped to (node_type, taxonomy, slug, language_code) — you can have cuisine/italian in English and cuisine/talianska in Slovak, both in the same translation group. The admin's term editor exposes the same Translations tab for term rows.
POST /admin/api/terms/<id>/translations
Content-Type: application/json
{ "language_code": "sk" }
The source row gets a fresh translation_group_id if it didn't already have one; the clone joins the same group with the new language. The admin UI exposes this through the term editor's translations tab — you don't normally call the route directly. Migration 0040 backfilled every existing term row with a real language_code; there is no longer a shared sentinel for "all languages".
Per-language settings
The site_settings table carries a language_code on every row. Reads scope to the caller's locale (admin uses X-Admin-Language; public uses request locale) and fall back to the default-language row when no per-locale value exists. Migration 0040 removed the legacy shared sentinel (language_code = ''); every row now has a real language.
Translatability is per-field, declared in the settings schema via the Translatable: true attribute (see internal/settings/builtin.go). Only translatable fields can be overridden per language; non-translatable fields share one value across every locale. The settings handler rejects writes to non-translatable fields with a locale and forces locale-bearing writes on translatable fields. The admin shows or hides the per-language switcher next to each field based on this flag, so the editor knows which knobs they're allowed to localise.
URL prefixes per language
Each node type has a url_prefixes map. The same node type can use different prefixes per language: { en: "recipes", sk: "recepty" } means a recipe at slug scallops renders at /recipes/scallops in English and /recepty/scallops in Slovak. Note that the slug itself is per-language too — each translated row has its own slug, and the language router resolves the right one based on the request locale.
The public language switcher
Every theme can render the kernel-supplied language switcher with one helper call. The output uses the user-friendly Name and follows the linked translation when available, falling back to the language's homepage when not. If the current page has no translation in a given language, the switcher entry can either be omitted, link to the homepage in that language, or render disabled — the choice is theme-controlled via a template parameter.