Translating a Theme
A theme keeps its own wording in its own locale files, one file per page. The operator can reword any of it from the panel, without editing a file.
Overview
No visible string is written into a template. Every label, button and accessible name comes from a locale key. The theme resolves it against its own files first, the platform's second.
The developer writes the defaults, one file per page. The operator rewrites them from the content editor. Those edits land in a generated tree a theme update never overwrites.
Structure
Where Text Lives
name and a description key here override the manifest's. The panel's theme list shows them translated.
What a Scope Is
A scope is the page's public identity, not its file path. For an ordinary view the two match; for a file-based marketing page they do not.
| View | Scope | Locale file |
|---|---|---|
views/home.tpl | home | locale/en/home.php |
views/account/settings.tpl | account/settings | locale/en/account/settings.php |
views/page/about.tpl | about | locale/en/about.php |
The last row matters. A file-based page is addressed at its bare slug, so the page/ segment is stripped before the scope resolves.
Step by Step
Add a Shared String
- Put the key in the common file of every installed language, not only yours. A key missing from a language falls through to English, a silent half-translation.
- Prefix by region when the key belongs to the chrome (
hdr_,ftr_). The common file is the only place a prefix helps. - Print it in the partial and open the page in both languages.
Add a Page's Own Strings
- Create the scope file, one per language, named after the page's public identity.
- Write short keys: the file is already the scope, so
hero_cta, nothome_hero_cta. Two pages reusing a short key never collide, because only one view scope is loaded per request. - Open the page. The scope is loaded before the view runs, and the page appears in the content editor.
Print It With Values
- Call the language function with the key.
- Pass a value by adding an attribute of the same name:
countreplaces{count}in the string. Any number of them, any names you like. - Keep the placeholder in the value and the sentence order in the translation. Never build a sentence by concatenating two keys around a value: word order differs per language.
Add a Language
- Copy the English tree, common file and every scope file, into the new language code.
- Translate the values and leave the keys untouched. A renamed key is a missing key in that language only.
- Check the pages that carry counts and dates. Their placeholders move, and that is where a mechanical translation breaks first.
Reference
Resolution Order
Every lookup walks this list and stops at the first hit. The last step makes an unresolved key visible instead of blank.
| Step | Source | Why it is there |
|---|---|---|
| 1 | Operator override, active language | Panel edits win over everything, even a theme update |
| 2 | Theme locale, active language (common + loaded scope) | The developer's default wording |
| 3 | Theme locale, English | An untranslated language still works |
| 4 | The platform's own language files | Path-style keys like website/index/logo-alt keep working from a theme |
| 5 | The key itself | A missing string shows as its key, found in review rather than production |
The Translation API
// $vars is a replacement map with the braces INCLUDED: ['{count}' => 3].
// This is what the template function calls; use it from PHP for the same result.
public function lang(string $key, array $vars = []): string;
// Load a view's scope before rendering it. Called for you on the request path and by the
// context-free render; call it yourself only when you render a view by hand.
public function loadViewContent(string $view): void;
// Another theme's locale, without booting it as the active theme (the panel's theme list
// reads a theme's own name and description this way). Falls back to English.
public static function localeFor(string $themeName, string $lang = ''): array;
// '' when the name is not a usable scope. A scope is [a-zA-Z0-9/_-] and may not climb out.
public static function cleanScope(string $scope): string;
// Every editable scope of a theme: 'common' first, then the page scopes, sorted.
public static function contentScopes(string $themeName): array;
// The developer's defaults of one scope in one language (string values only).
public static function scopeDefaults(string $themeName, string $lang, string $scope): array;
// The operator's overrides of the same scope. Empty when nothing was changed.
public static function scopeOverrides(string $themeName, string $lang, string $scope): array;
// Persist them. An EMPTY set deletes the file, which is how "reset to default" is stored:
// an override is an exception, never a copy of the default.
public static function writeScopeOverrides(string $themeName, string $lang, string $scope, array $values): bool;
The Template Function
count=$n replaces {count} in the value. The braces belong to the string, not to the call.
Example
One page's strings in two languages, and the view that prints them. The placeholder lives in the value; sentence order differs between the files.
return [
// Short keys: the FILE is the scope, so a page prefix would only repeat it.
'title' => 'Services',
'lede' => 'Everything running on your account, with its next renewal date.',
'count' => '{n} services',
'empty_title' => 'No services yet',
'empty_text' => 'Your first order will show up here.',
'renew_cta' => 'Renew',
'aria_list' => 'Service list',
];
return [
// Same keys, translated values. A renamed key here is a missing key in Turkish only.
'title' => 'Hizmetler',
'lede' => 'Hesabınızda çalışan her şey ve bir sonraki yenileme tarihi.',
'count' => '{n} hizmet',
'empty_title' => 'Henüz hizmet yok',
'empty_text' => 'İlk siparişiniz burada görünecek.',
'renew_cta' => 'Yenile',
'aria_list' => 'Hizmet listesi',
];
<h1>{lang key='title'}</h1>
<p>{lang key='lede'}</p>
{if $services}
{* The attribute name is the placeholder name; the sentence order is the translator's. *}
<p>{lang key='count' n=$services|@count}</p>
<ul aria-label="{lang key='aria_list'}">
{foreach $services as $service}
<li>
{$service.name}
<a href="{link route='service-detail' p1=$service.id}">{lang key='renew_cta'}</a>
</li>
{/foreach}
</ul>
{else}
{* Accessible names are strings too: nothing visible OR announced is hardcoded. *}
<h2>{lang key='empty_title'}</h2>
<p>{lang key='empty_text'}</p>
{/if}
{* A platform key, in its path form: the same wording every theme shares. *}
<a href="{link route='home'}" aria-label="{lang key='website/index/logo-alt'}">{$company_name}</a>
Pitfalls
Put the file at the slug's own path, not under the view's folder. Left where the view is, it exists, it lints, and all its strings appear as keys.
Only the common scope and the current view's scope are loaded per request. A key borrowed from another page may resolve in development and print as its key in production. Shared wording belongs in the common file.
Written by the panel, kept out of version control, holding only the differences. Editing it by hand puts a value where the next panel save overwrites it. Shipping it in a theme package hands your test wording to every installation.
Strings every theme shares already exist and are already translated. A copy inside a theme drifts, and it stops the operator's platform-wide wording change from reaching your theme.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.