# Overriding Text and Templates

https://dev.wisecp.com/es/overriding-text-and-templates

Change a shipped wording or piece of markup, in a way the next upgrade will not undo.

## Overview

Every visible string comes out of a language file and every screen out of a template. Editing those works until the next version overwrites them.

Four seams: a translation filter for one string, the theme content layer for website copy. Then a variables filter for what a template gets, injection points for what it prints.

## Prerequisites

- The exact key; the same word appears in several packages.
- Somewhere for a listener: the hooks directory or your module.
- The installed language list, which is a directory listing.

## Structure

Four stores hold text, each with its own accessor. The wrong one fails silently.

| Store | Path | Read with |
| --- | --- | --- |
| Root packages: needs, date, errors, actions, constants, blocks, routes | `coremio/locale/{lang}/{name}.php` | `Language::g()` |
| Controller packages, one per surface | `coremio/locale/{lang}/cm/{admin\|website\|system}/{name}.php` | `Language::gc()` |
| Module strings | `coremio/modules/{Type}/{Name}/lang/{lang}.php` | `$this->lang['key']` |
| Theme strings and page copy | `templates/website/{Theme}/locale/{lang}.php` | `Language::gc("theme/key")` or the theme's accessor |

Panel edits go to `content/{lang}/{scope}.php` and win. An upgrade replaces `locale/`, never `content/`.

## Walkthrough

### Find the Key

1. Decide the store from the screen.
2. Search the language directory for the visible text.
3. Confirm on the command line.
4. Note the language: one says nothing about the others.

### Override an Existing String

1. For a controller string, listen on the translation filter and rewrite the value in place.
2. Keep the listener cheap: it runs on every controller string.
3. For website copy, use the theme's content layer.
4. The filter does not reach root package strings. Change those at the point of use.

### Add a New String

1. Pick the surface's package and match the neighbours' shape.
2. Add it to **every** installed language; a missing key shows nothing.
3. Nest the array when the key contains a slash.
4. Never hardcode a visible string.

### Override a Template

1. Decide: what the template *receives*, or what it *prints*.
2. To change the data, filter the variables.
3. To change the markup, use the nearest injection point.
4. To replace a public page, copy the view into your theme.

## Reference

### The Accessors

```php
// Root packages. $key is "{file}/{key}/{subkey}", one slash per array level.
// Returns false when any segment is missing. There is NO language fallback.
public static function g($key = '', $replaces = [], $slang = ''): array|string|int|bool;

// Controller packages under cm/. Same shape, one extra leading segment for the area.
public static function gc($name = '', $replaces = [], $slang = ''): array|string|int|bool;

// The active language code, for instance "en".
public static function selected(): string;

// Machine value to human label. $dictionary is the locale prefix the value is
// appended to ("admin/orders/status-"), '@toggle', or '@list:{prefix}'.
// An unknown value falls back to itself rather than to an empty string.
public static function enum_label($value = '', $dictionary = '', $slang = ''): string;

// Writes one package file. See the warning below: this REPLACES, it does not merge.
public static function save($key = '', $data = [], $lk = ''): int|bool;
```

- **Language::g()**: Root packages only; a controller key returns `false`.
- **Language::gc()**: Controller packages only. A root package name can return an **empty array**, which passes a truthiness guard.
- **Argument order**: Both static accessors take replacements second, language third. The instance methods disagree.
- **Language::enum_label()**: Turns a stored status into a label. Audit rows keep the raw value.

### What a Key May Look Like

```php
<?php
return [
    // Plain. Read as Language::gc('admin/services/page-list').
    'page-list' => 'Services',

    // With placeholders. Two notations exist and both are literal search targets:
    // the replacement map's keys are matched as written, braces and colons included.
    'welcome'   => 'Hello {name}, you have {count} messages.',
    'meta-title' => 'Service detail - :name',

    // Declared form. g()/gc() return the 'content' string; 'variables' is metadata
    // for the translation tooling and never reaches the screen.
    'quota-warning' => [
        'content'   => 'Only {left} of {total} remaining.',
        'variables' => '{left},{total}',
    ],

    // A slash in a key is DEPTH, not part of the name. This is the only way to
    // make Language::gc('admin/services/labels/renew') resolve.
    'labels' => [
        'renew'   => 'Renew',
        'suspend' => 'Suspend',
    ],
];
```

- **A missing key**: Resolves to `false`, with no language fallback and nothing in the log.
- **Placeholder substitution**: Plain textual replacement. Pass the keys exactly as they appear, braces or colon included.
- **The site address token**: Controller strings get one free substitution: `{SITE-URL}` becomes the base address.

### The Four Seams

- **filter:i18n.translation**: Fires on every **controller-package** string, by reference. Arguments: value, key, language. Root packages and missing keys never reach it; the return value is unused.
- **filter:template.variables**: Fires for every template, before extraction. Arguments: template path and data array. Returning an array **replaces** the data wholesale, so return all of it.
- **The theme content layer**: Files under `content` override matching keys in `locale`. Written from the panel.
- **ui: injection points**: Several hundred positions print whatever their listeners return, unescaped. This is the panel's stylesheet slot.
- **ui:client.head.css**: The public-site twin of the row above: a stylesheet here restyles a theme. Return the markup; a non-string return is discarded.
- **filter:i18n.translation_save**: Filters what the operator types in the translation editor. Values arrive by reference and the return value is unused.

### How a Theme Resolves a String

```php
// One string, with optional placeholder substitution.
public function lang(string $key, array $vars = []): string;

// The content editor's own surface, for a module that wants to read or write it.
public static function contentScopes(string $themeName): array;
public static function scopeDefaults(string $themeName, string $lang, string $scope): array;
public static function scopeOverrides(string $themeName, string $lang, string $scope): array;
public static function writeScopeOverrides(string $themeName, string $lang, string $scope, array $values): bool;
```

| Order | Source | Note |
| --- | --- | --- |
| 1 | The operator's override | Wins over all below |
| 2 | The theme's own text | `locale/{lang}.php` plus `locale/{lang}/{scope}.php` |
| 3 | The same key in English | The only language fallback |
| 4 | The core packages, controller first then root | Lets a theme reuse a platform string |
| 5 | The key itself | Never blank, unlike the core accessors |

A scope is `common` or a view path, and only the active one is loaded. A view under `page` drops that prefix.

### Writing a Package File

> **The writer replaces the file, it does not merge**
> 
> The array you hand it becomes the whole file. Paired with an accessor that answers with an empty array, a read-modify-write cycle can reduce a package to one key.

## Example

Renaming a term without a language file.

```php
// Key => replacement, per language. A map lookup keeps the listener O(1); a
// str_replace over every string in the panel would not be acceptable here.
const ACME_WORDING = [
    'en' => [
        'admin/services/page-list'  => 'Subscriptions',
        'admin/services/page-title' => 'Subscription detail',
    ],
    'tr' => [
        'admin/services/page-list'  => 'Abonelikler',
        'admin/services/page-title' => 'Abonelik detayı',
    ],
];

Hook::add('filter:i18n.translation', 10, function (&$value, $key, $lang) {
    // Only controller-package strings arrive here, and only when they resolved
    // to a string: a missing key never fires this hook, so a gap cannot be
    // filled from here.
    $value = ACME_WORDING[$lang][$key] ?? $value;
});
```

Changing what a template receives; the path test narrows it.

```php
Hook::add('filter:template.variables', 10, function ($template_path, $data) {
    // Normalise: the path arrives with the platform's directory separator.
    $path = str_replace('\\', '/', (string) $template_path);

    if (!str_ends_with($path, 'admin/services/detail.php')) return null;   // not ours

    // The return REPLACES $data entirely. Building on the incoming array is not
    // a style choice: dropping template_dir or ui_lang breaks the render.
    $data['acme_banner'] = Language::gc('admin/services/acme-banner');

    return $data;
});

// Markup, at a position the platform announces. The return value is printed
// as-is, so anything variable in it has to be escaped here.
Hook::add('ui:admin.head.css', 10, fn (): string
    => '<link rel="stylesheet" href="' . Utility::AppAdress() . '/resources/acme/panel.css">');
```

Adding your own string, in every language.

```php
// The installed languages are a directory listing, never a hardcoded pair.
$langs = array_map('basename', glob(ROOT_DIR . 'coremio' . DS . 'locale' . DS . '*', GLOB_ONLYDIR) ?: []);

foreach ($langs as $lang) {
    // Resolve in that language explicitly: the third argument, not the second.
    $existing = Language::gc('admin/services/acme-banner', [], $lang);

    // false means the key is missing THERE, even if it resolves in English.
    if ($existing === false)
        echo 'missing in ' . $lang . PHP_EOL;
}

// Reading one key in one language, from the command line, is how you settle an
// argument about which accessor a package needs.
var_dump(Language::g('needs/untitled', [], 'tr'));    // string, root package
var_dump(Language::gc('needs/untitled', [], 'tr'));   // false, wrong accessor
```

The theme side: an override is a file, not a listener.

```php
<?php
// Overrides the matching keys of locale/en/home.php for this theme only.
// The panel writes this file; a module may write it through the theme API.
// An upgrade replaces locale/, never content/, so the override survives.
return [
    'hero-title'    => 'Hosting that stays out of your way',
    'hero-subtitle' => 'Deploy in a minute, scale when you need to.',
];
```

```php
$theme = Theme::active()->getName();

// Read the shipped defaults and the operator's current overrides separately:
// they are two layers, and merging them before writing would freeze the defaults.
$defaults  = Theme::scopeDefaults($theme, 'en', 'home');
$overrides = Theme::scopeOverrides($theme, 'en', 'home');

$overrides['hero-title'] = 'Hosting that stays out of your way';

// An empty array removes the override file entirely, restoring the defaults.
Theme::writeScopeOverrides($theme, 'en', 'home', $overrides);
```

## Pitfalls

> **The wrong accessor fails silently, and then destroys data**
> 
> There is no language fallback and no warning. A missing segment returns false or an empty array.

> **Two languages is not the language list**
> 
> Read the installed languages from the directory. A key added to some shows nothing in the rest.

> **The variables filter replaces, it does not merge**
> 
> Returning only your own additions wipes out everything the template needed. Add to the incoming array and return all of it.

> **The translation filter runs hundreds of times per page**
> 
> Every resolved controller string passes through it, so the cost is paid once per string. Keep it to a map lookup.

> **A slash in a key name is a level, not a character**
> 
> Each segment is another array level, so a flat key with a slash is never found.

## Related Articles

- [Translations and Language Files](https://dev.wisecp.com/en/translations-and-language-files)
- [Views and Templates](https://dev.wisecp.com/en/views-and-templates)
- [Translating a Theme](https://dev.wisecp.com/en/translating-a-theme)
- [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener)
- [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core)
- [Surviving a Core Upgrade](https://dev.wisecp.com/en/surviving-a-core-upgrade)
