Overriding Text and Templates

3 views Markdown

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.

StorePathRead with
Root packages: needs, date, errors, actions, constants, blocks, routescoremio/locale/{lang}/{name}.phpLanguage::g()
Controller packages, one per surfacecoremio/locale/{lang}/cm/{admin|website|system}/{name}.phpLanguage::gc()
Module stringscoremio/modules/{Type}/{Name}/lang/{lang}.php$this->lang['key']
Theme strings and page copytemplates/website/{Theme}/locale/{lang}.phpLanguage::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

coremio/classes/Language.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

coremio/locale/en/cm/admin/services.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

coremio/classes/Theme.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;
OrderSourceNote
1The operator's overrideWins over all below
2The theme's own textlocale/{lang}.php plus locale/{lang}/{scope}.php
3The same key in EnglishThe only language fallback
4The core packages, controller first then rootLets a theme reuse a platform string
5The key itselfNever 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.

coremio/hooks/acme-wording.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.

the variables seam
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.

reading the language list instead of assuming it
// 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.

templates/website/{Theme}/content/en/home.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.',
];
the same override, written from code
$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.

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.