Translating a Theme

2 views Markdown

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

locale/{lang}.php The theme's common scope: chrome, shared strings and the labels of its own settings, as a flat map of key to string. A name and a description key here override the manifest's. The panel's theme list shows them translated.
locale/{lang}/{scope}.php One file per page. Only the scope of the view in use is loaded, so a page pays for its own strings and nothing else.
content/{lang}/{scope}.php The operator's overrides, written by the panel. Generated, out of version control, and holding only the values that differ from the default. Removing the last one deletes the file.
The platform's own strings Wording every theme shares (accessibility labels, form errors, period names) stays in the platform's language files. It is reached by its path-style key. Do not copy it into a theme.

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.

ViewScopeLocale file
views/home.tplhomelocale/en/home.php
views/account/settings.tplaccount/settingslocale/en/account/settings.php
views/page/about.tplaboutlocale/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

  1. 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.
  2. Prefix by region when the key belongs to the chrome (hdr_, ftr_). The common file is the only place a prefix helps.
  3. Print it in the partial and open the page in both languages.

Add a Page's Own Strings

  1. Create the scope file, one per language, named after the page's public identity.
  2. Write short keys: the file is already the scope, so hero_cta, not home_hero_cta. Two pages reusing a short key never collide, because only one view scope is loaded per request.
  3. Open the page. The scope is loaded before the view runs, and the page appears in the content editor.

Print It With Values

  1. Call the language function with the key.
  2. Pass a value by adding an attribute of the same name: count replaces {count} in the string. Any number of them, any names you like.
  3. 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

  1. Copy the English tree, common file and every scope file, into the new language code.
  2. Translate the values and leave the keys untouched. A renamed key is a missing key in that language only.
  3. 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.

StepSourceWhy it is there
1Operator override, active languagePanel edits win over everything, even a theme update
2Theme locale, active language (common + loaded scope)The developer's default wording
3Theme locale, EnglishAn untranslated language still works
4The platform's own language filesPath-style keys like website/index/logo-alt keep working from a theme
5The key itselfA missing string shows as its key, found in review rather than production

The Translation API

reading
// $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;
the content editor's side
// 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

key The only required attribute. A theme key is a bare name; a platform key is its path form. An empty key returns an empty string.
Any other attribute Becomes a replacement: count=$n replaces {count} in the value. The braces belong to the string, not to the call.
Template syntax inside a value A returned string containing a template call (a link, a setting, a formatted amount) goes through the same sandbox. One that will not compile appears as-is.
Escaping Website output is escaped by default. A value that carries markup goes out with the unfiltered modifier, decided per call, not per file.

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.

templates/website/Acme/locale/en/services.php
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',
];
templates/website/Acme/locale/tr/services.php
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',
];
templates/website/Acme/views/services.tpl
<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

A scope file under the page folder is never loaded

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.

A page cannot read another page's 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.

The override tree is generated, not source

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.

Do not copy platform wording into a theme

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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.