# Translating a Theme

https://dev.wisecp.com/uk/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

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

| 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

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. Pass values as they are. The function escapes each one, so a name or title a customer typed cannot add markup.
4. 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.

### Print a Count

1. Write the key's value in the plural, with `{n}` where the number goes.
2. Add a sibling key per extra form: `<key>-one`, and in Ukrainian, Russian or Polish also `<key>-few` and `<key>-many`.
3. Print it with `plural=$n`. The form that number needs is picked, and `{n}` is filled with it.
4. If a script changes the number later, also print `forms=1` into a data attribute. Re-pick the word with `window.wcpPlural()` from `templates/admin/js/plural.js`.

### 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.
4. Write the count siblings your language needs. A language without them shows the plural for every number.

## 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

```php
// $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;

// Every form of a count key, all from ONE language. The key's own value is 'other':
// ['one' => '{n} service', 'other' => '{n} services'].
public function lang_forms(string $key): array;

// The form $n reads with, $vars applied. '{n}' is the number unless $vars sets it.
public function lang_plural(string $key, int|float $n, 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;
```

```php
// 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. Each value is escaped on the way in; a character reference already in it is not doubled.
- **raw**: Attribute names, comma-separated, whose values go in unescaped. Only for markup the template built itself, such as links collected in a capture block.
- **plural**: A number. Picks the form it reads with: the key's value is the plural, its `-one`, `-few` and `-many` siblings add the rest. `{n}` holds the number.
- **forms**: With `forms=1` the function returns every form as JSON, safe inside an attribute, for a script that re-labels a count.
- **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. Attribute values are never compiled; they go in after the string is compiled.
- **Escaping**: Variable output is escaped by default; a trusted value with markup goes out with the unfiltered modifier, decided per call. In Smarty the locale value's own markup shows as written. Attribute values arrive escaped, and `raw` is the only way to pass markup through one.
- **Twig**: `lang('key')` takes the key, `plural=n` and `forms=true`. It has no placeholder attributes.

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

```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',
    'count-one'    => '{n} service',
    'empty_title'  => 'No services yet',
    'empty_text'   => 'Your first order will show up here.',
    'renew_cta'    => 'Renew',
    'aria_list'    => 'Service list',
];
```

```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.',
    // Turkish reads the same for every number, so 'count' needs no sibling.
    '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',
];
```

```smarty
<h1>{lang key='title'}</h1>
<p>{lang key='lede'}</p>

{if $services}
    {* plural= picks the form for this number and fills {n}; any other attribute fills its own placeholder. *}
    <p>{lang key='count' plural=$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.

> **Picking the form with an if breaks other languages**
> 
> A check like `{if $n == 1}` knows two forms. Ukrainian and Russian have three for whole numbers, and each language decides where they fall. Use `plural=`.

> **A value under raw reaches the page unescaped**
> 
> Name only markup the template built from escaped variables. A name, title or message a customer typed never goes under `raw`.

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

## Related Articles

- [Translations and Language Files](https://dev.wisecp.com/en/translations-and-language-files)
- [Theme Settings](https://dev.wisecp.com/en/theme-settings)
- [Template Variables](https://dev.wisecp.com/en/template-variables)
- [Page Surfaces](https://dev.wisecp.com/en/page-surfaces)
- [Module Language Files](https://dev.wisecp.com/en/module-language-files)
