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-Language request header
  • Public site routes per language with per-node-type URL prefixes (/recipes in English, /recepty in 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: true in 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 the language_code column on every translatable row.
  • Name — display name shown in the admin and in the public language switcher.
  • Directionltr / rtl; the public layout reads this and emits the right dir attribute.
  • 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:

  1. Open the source node in the admin.
  2. Click Add translation in the Translations sidebar panel.
  3. Pick the target language.
  4. 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.

REST
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.

DESIGN HINT
Site-wide things you'd never localise (the chosen email provider, the active theme slug, the security key) stay non-translatable. Brand-shaped things you almost always localise (site name, tagline, social handles, head/foot custom code) are translatable. The kernel's built-in schema is conservative — only fields that obviously need translation are flagged. Extension authors should follow the same rule when registering their own settings groups.

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.