PHP Classes

File: docs/Localization.md

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   docs/Localization.md   Download  
File: docs/Localization.md
Role: Auxiliary data
Content type: text/markdown
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 25,718 bytes
 

Contents

Class file image Download

Localization in BinktermPHP

Sysop Quick Guide

Localization is BinktermPHP's language system for the web UI, API messages, and terminal/BBS strings. It decides which language a visitor sees, loads the matching catalogs from config/i18n/, and falls back safely to the default locale when a requested language or translation key is unavailable.

How Locale Detection Works

For normal web requests, BinktermPHP resolves the active locale in this order:

  1. `?locale=` in the current request URL
  2. The logged-in user's saved language preference (`user_settings.locale`)
  3. The `binktermphp_locale` cookie from an earlier visit
  4. The browser's `Accept-Language` header
  5. `I18N_DEFAULT_LOCALE` from `.env`

Important behavior:

  • Locale codes are normalized, so `fr_ca`, `fr-CA`, and `FR-ca` all resolve consistently.
  • Regional variants fall back to the base language when possible. For example, if the browser sends `es-MX` and BinktermPHP supports `es` but not `es-MX`, it will use `es`.
  • After a locale is resolved, BinktermPHP writes it back to the `binktermphp_locale` cookie for one year so future anonymous visits stay in the same language.
  • If a locale is not supported at all, BinktermPHP falls back to `I18N_DEFAULT_LOCALE`.

.env Configuration

The main sysop controls are in .env:

I18N_DEFAULT_LOCALE=en
I18N_SUPPORTED_LOCALES=en,es,fr,de,it
I18N_LOG_MISSING_KEYS=false
I18N_MISSING_KEYS_LOG_FILE=

  • `I18N_DEFAULT_LOCALE`: The final fallback language. Use a locale code such as `en` or `fr`.
  • `I18N_SUPPORTED_LOCALES`: Optional comma-separated allowlist of languages users may select. If unset, BinktermPHP auto-discovers locales by scanning `config/i18n/<locale>/`.
  • `I18N_LOG_MISSING_KEYS`: When `true`, logs missing translation keys so you can spot incomplete catalogs.
  • `I18N_MISSING_KEYS_LOG_FILE`: Optional explicit log file for missing-key entries. If blank, missing-key warnings go to the normal server logger.

Practical examples:

  • To run an English-only system, set `I18N_DEFAULT_LOCALE=en` and `I18N_SUPPORTED_LOCALES=en`.
  • To offer only bundled English and Spanish, set `I18N_SUPPORTED_LOCALES=en,es`.
  • To add a new language without pinning the list, create `config/i18n/<locale>/` and leave `I18N_SUPPORTED_LOCALES` unset so auto-discovery can find it.

What Sysops Usually Need To Do

  • Set `I18N_DEFAULT_LOCALE` to the language you want as the system fallback.
  • Decide whether to pin `I18N_SUPPORTED_LOCALES` or let BinktermPHP auto-discover locale directories.
  • Let users choose their own language in their account settings if you want per-user persistence.
  • Use Admin ? BBS Settings ? Language Overrides when you want to customize individual phrases without editing the shipped catalogs.

BinktermPHP uses a key-based localization system covering Twig templates, PHP route/controller code, and client-side JavaScript. Translation catalogs live under config/i18n/ and are loaded on demand by locale.

Directory Structure

config/i18n/
??? en/
?   ??? common.php           # UI strings (templates, JS)
?   ??? errors.php           # API error messages
?   ??? terminalserver.php   # Terminal/BBS server strings
??? es/
?   ??? common.php
?   ??? errors.php
?   ??? terminalserver.php
??? overrides/          # Sysop phrase overrides (JSON, applied on top of base catalogs)
?   ??? <locale>/
?       ??? <namespace>.json
??? hardcoded_allowlist.php   # Known-OK English strings exempt from the linter

Each locale directory name becomes a supported locale identifier (e.g. en, es). New locales are added by creating a new directory with common.php, errors.php, and terminalserver.php files.

> Note on the bundled Spanish (es) translation: The Spanish catalog was generated by AI and has not been independently reviewed for accuracy. It may contain errors, awkward phrasing, or incorrect terminology. Community corrections are welcome ? see the Translation Contributor Workflow section below.

Core Classes

Translator (src/I18n/Translator.php)

Loads and caches catalog files, performs key lookup with fallback, and interpolates {param} placeholders.

  • Reads `I18N_DEFAULT_LOCALE` from `.env` (default: `en`).
  • Supported locales are read from `I18N_SUPPORTED_LOCALES` (comma-separated), or auto-discovered from the `config/i18n/` directory structure.
  • On a missing key, falls back to the default locale, then returns the key itself as a last resort.
  • Missing keys can be logged by setting `I18N_LOG_MISSING_KEYS=true` and optionally `I18N_MISSING_KEYS_LOG_FILE`.
  • After loading a base `.php` catalog, automatically merges any sysop overrides from `config/i18n/overrides/<locale>/<namespace>.json` (see Language Phrase Overrides).

LocaleResolver (src/I18n/LocaleResolver.php)

Determines the active locale for a request using this priority order:

  1. Explicit locale argument (e.g. from a `?locale=` query param)
  2. Authenticated user's saved locale preference (`users.locale` column)
  3. `binktermphp_locale` cookie
  4. `Accept-Language` request header (highest `q` value wins)
  5. Default locale from `.env`

persistLocale() writes the resolved locale to the cookie for one year.

Server-Side Translation

Twig Templates

The t() function is registered globally in Template.php and is available in every template.

{# Basic usage #}
{{ t('ui.login.title', {}, locale, ['common']) }}

{# With parameters #}
{{ t('ui.polls.create.submit', {'cost': poll_cost}, locale, ['common']) }}

{# Errors namespace #}
{{ t('errors.auth.invalid_credentials', {}, locale, ['errors']) }}

Arguments: t(key, params, locale, namespaces)

  • `key` ? dot-separated translation key
  • `params` ? object of `{placeholder}` substitutions
  • `locale` ? the `locale` Twig global (set per-request by `Template.php`)
  • `namespaces` ? array of catalogs to search; defaults to `['common']`

The locale and supported_locales globals are automatically available in every template.

PHP (Routes / Controllers)

Use apiLocalizedText() for strings returned in API responses:

apiLocalizedText('errors.auth.invalid_credentials', 'Invalid credentials');

This resolves the current user's locale automatically. An optional $user array, $params, and $namespace can be passed.

API Error Responses

All API errors must use the apiError() helper so the frontend can resolve the display text:

apiError('errors.some.key', apiLocalizedText('errors.some.key', 'English fallback'), 400);

The response payload shape is:

{
  "success": false,
  "error_code": "errors.some.key",
  "error": "Translated error message"
}

The error_code field is the translation key. The error field is the server-side translated string. The frontend can re-translate using the client-side catalog if needed (see below).

Success responses that carry a human-readable message use message_code / message the same way:

{
  "success": true,
  "message_code": "ui.some.success_key",
  "message": "Translated success message"
}

Client-Side Translation

Catalog Loading

Template.php injects window.appLocale, window.appDefaultLocale, and window.appI18nNamespaces into every page via base.twig. On DOMContentLoaded, app.js fetches the catalog for all namespaces (always ['common', 'errors']) from:

GET /api/i18n/catalog?ns=common,errors&locale=<locale>

The response merges the default locale catalog with the active locale catalog so only translated keys need to be provided for non-default locales.

window.t(key, params, fallback)

The primary translation function available everywhere:

window.t('ui.polls.create.submit', { cost: 25 }, 'Create Poll (25 credits)')

  • Looks up `key` in loaded catalogs.
  • Interpolates `{param}` placeholders from `params`.
  • Returns `fallback` (or the key itself) when the key is not found.

uiT(key, fallback, params) (template-local wrapper)

Many templates define a local wrapper to handle the case where window.t is not yet available:

function uiT(key, fallback, params = {}) {
    if (window.t) {
        return window.t(key, params, fallback);
    }
    return fallback;
}

getApiErrorMessage(payload, fallback)

Resolves the display text for an API error payload:

.catch(error => {
    showAlert('danger', getApiErrorMessage(error, 'Operation failed'));
});

Checks payload.error_code first (looks it up in the client catalog), then payload.error, then fallback.

getApiMessage(payload, fallback)

Same pattern for success messages using message_code / message.

Lazy Namespace Loading

Additional namespaces can be loaded on demand:

loadI18nNamespaces(['common', 'errors']).then(function() {
    // catalog is now available
});

Adding a New Translation Key

  1. Add to config/i18n/en/common.php (or `errors.php` for API errors):
'ui.my_feature.some_label' => 'My Label',

  1. Add the same key to config/i18n/es/common.php:
'ui.my_feature.some_label' => 'Mi etiqueta',

  1. Use it in Twig:
{{ t('ui.my_feature.some_label', {}, locale, ['common']) }}

  1. Use it in JavaScript:
window.t('ui.my_feature.some_label', {}, 'My Label')

  1. Run the validation scripts before committing:
php scripts/check_i18n_syntax.php
php scripts/check_i18n_hardcoded_strings.php
php scripts/check_i18n_error_keys.php
php scripts/check_i18n_missing_keys.php
php scripts/check_i18n_extra_keys.php

Key Naming Conventions

| Prefix | Purpose | |---|---| | ui.<page>.* | Template / UI strings for a specific page | | ui.base.* | Strings in the shared base layout | | ui.common.* | Strings shared across many pages | | ui.admin.<page>.* | Admin panel page strings | | errors.<area>.* | API error messages |

Examples: - ui.login.title - ui.compose.echomail_guideline_identity - ui.admin.bbs_settings.features.enable_webdoors - errors.auth.invalid_credentials - errors.polls.not_found

Adding a New Locale

  1. Create `config/i18n/<locale>/common.php`, `config/i18n/<locale>/errors.php`, and `config/i18n/<locale>/terminalserver.php` returning arrays of translated keys.
  2. The locale is auto-discovered from the directory name ? no code changes required.
  3. Optionally, pin the supported locale list explicitly via `I18N_SUPPORTED_LOCALES=en,es,fr` in `.env`.

Localizing Documentation Files

In addition to UI string catalogs, documentation files served through the web interface support locale-specific variants using a file-naming convention. This covers:

  • The User Guide (`/user-guide`) ? source file at `docs/userguide/index.md`
  • The Admin documentation browser (`/admin/docs`) ? files in `docs/`
  • The special root-level docs surfaced in the admin browser: `FAQ.md`, `README.md`, `REGISTER.md`

File Naming Convention

Place a translated file alongside the original, inserting the locale code before the .md extension:

docs/userguide/index.md        ? English source (always required)
docs/userguide/index.de.md     ? German translation
docs/userguide/index.fr.md     ? French translation
docs/userguide/index.it.md     ? Italian translation

docs/FAQ.md                    ? English source
docs/FAQ.de.md                 ? German translation (placed in repo root)

Resolution Order

When a page is requested, DocsController::resolveLocalizedPath() (src/Web/DocsController.php) picks the first file that exists from this list:

  1. `FILENAME.<locale>.md` ? the user's active locale (e.g. `index.de.md` for German)
  2. `FILENAME.md` ? the generic file with no locale suffix (the English source)
  3. `FILENAME.en.md` ? an explicit English file, as an alternative to the unsuffixed form

The active locale is resolved the same way as for UI strings: user preference ? locale cookie ? Accept-Language header ? default locale.

Adding a Translated Doc

  1. Copy the English source and translate the content.
  2. Save it as `FILENAME.<locale>.md` next to the original.
  3. No code changes are required ? the resolution logic picks it up automatically.

Heading Anchors and Accented Characters

The Markdown renderer generates heading anchors using a GitHub-style slugifier that only retains ASCII word characters ([a-zA-Z0-9_]). Accented characters in heading text (e.g. ü, é, ñ) are stripped from the anchor ID.

When writing a translated document, make sure the Table of Contents anchor links match the slugified form of each heading. For example, the heading ## Échomail : Forums Mondiaux produces the anchor #chomail--forums-mondiaux ? or, more reliably, avoid accented characters in headings and use them only in body text.

Automated Catalog Generation

The script scripts/create_translation_catalog.php translates the English catalogs into a new locale automatically using an AI API (OpenAI or Anthropic Claude). It is the fastest way to bootstrap a new locale and produces a complete common.php, errors.php, and terminalserver.php ready for human review.

Requirements

  • OpenAI: set `OPENAI_API_KEY` in `.env` (optionally `OPENAI_API_BASE` for a custom endpoint)
  • Claude: set `ANTHROPIC_API_KEY` in `.env` (optionally `ANTHROPIC_API_BASE`)

Basic Usage

# Translate into French using whichever API key is configured
php scripts/create_translation_catalog.php --locale=fr --language="French"

# Force a specific provider
php scripts/create_translation_catalog.php --locale=fr --language="French" --provider=claude
php scripts/create_translation_catalog.php --locale=de --language="German" --provider=openai

# Specific model
php scripts/create_translation_catalog.php --locale=ja --language="Japanese" --model=claude-opus-4-6

# Overwrite an existing locale
php scripts/create_translation_catalog.php --locale=es --language="Spanish" --overwrite

# Dry run ? translate but do not write files
php scripts/create_translation_catalog.php --locale=fr --language="French" --dry-run

Provider Auto-Detection

The script selects the provider automatically when --provider is not given:

  • If only `ANTHROPIC_API_KEY` is set ? Claude
  • Otherwise ? OpenAI

Options

| Option | Default | Description | |--------|---------|-------------| | --locale | (required) | Target locale code, e.g. fr, de, pt-BR | | --language | (required) | Full language name passed to the model, e.g. French | | --provider | auto | openai or claude | | --model | gpt-4o-mini / claude-sonnet-4-6 | Model to use (default depends on provider) | | --namespaces | common,errors,terminalserver | Which catalogs to translate | | --batch-size | 150 | Translation keys per API request | | --timeout | 120 | HTTP timeout in seconds per request | | --retries | 3 | Retries on batch failure | | --pause-ms | 0 | Milliseconds between batch requests | | --overwrite | off | Overwrite existing locale files | | --dry-run | off | Translate but do not write files |

Output

Writes config/i18n/<locale>/common.php, config/i18n/<locale>/errors.php, and config/i18n/<locale>/terminalserver.php. Any keys where placeholder tokens ({name}, %s, etc.) did not survive translation are kept in English and logged to config/i18n/<locale>/translation_warnings.log.

After Running

  1. Review the output files for quality ? AI translations are a starting point, not a finished product.
  2. Fix any entries in `translation_warnings.log`.
  3. Test by setting `?locale=<code>` in the browser and browsing key pages.
  4. Commit the new locale directory.

Translation Contributor Workflow

This section describes how to contribute a translation for a new language from start to finish.

1. Find What Needs Translating

Enable missing-key logging against your target locale so the application tells you what's untranslated as you browse:

# .env
I18N_LOG_MISSING_KEYS=true
I18N_MISSING_KEYS_LOG_FILE=/path/to/binkterm/data/logs/i18n-missing.log
I18N_DEFAULT_LOCALE=en
I18N_SUPPORTED_LOCALES=en,fr   # add your target locale

Then browse the site with your browser's Accept-Language set to the target locale (or append ?locale=fr to any URL). Missing keys accumulate in the log file.

Alternatively, use the English catalog as your full source of truth ? every key in config/i18n/en/common.php, config/i18n/en/errors.php, and config/i18n/en/terminalserver.php needs a counterpart in your locale.

2. Create the Locale Directory

mkdir config/i18n/fr

3. Create common.php

Copy the English catalog and translate the values. Keys must stay identical ? only values change.

<?php
// config/i18n/fr/common.php
return [
    'ui.login.title'    => 'Connexion',
    'ui.login.username' => 'Nom d\'utilisateur',
    'ui.login.password' => 'Mot de passe',
    // ... all other keys
];

Tips: - Preserve {placeholder} tokens exactly ? they are substituted at runtime and must not be translated or renamed. - Keep the same key ordering as the English file to make diff reviews easier. - You do not need to include keys whose English value is acceptable as-is; the system falls back to the default locale for any missing key.

4. Create errors.php

Same process for API error messages:

<?php
// config/i18n/fr/errors.php
return [
    'errors.generic'                    => 'Une erreur inattendue s\'est produite',
    'errors.auth.invalid_credentials'   => 'Identifiants invalides',
    // ... all other keys
];

5. Create terminalserver.php

Same process for terminal/BBS server strings used by the telnet and SSH interfaces:

<?php
// config/i18n/fr/terminalserver.php
return [
    'ui.terminalserver.server.login.username_prompt' => 'Nom d\'utilisateur : ',
    // ... all other keys
];

6. Test Your Translation

Set your browser's preferred language to the target locale (or use the language selector in user settings) and navigate through the interface. Key areas to check:

  • Login, registration, and password reset pages
  • Compose (netmail and echomail) ? including posting identity guidelines
  • Admin panel settings pages
  • Error messages (try submitting invalid forms)
  • API responses shown in alerts/toasts

7. Run the Validation Scripts

php scripts/check_i18n_syntax.php --locale=<your-locale>
php scripts/check_i18n_hardcoded_strings.php
php scripts/check_i18n_error_keys.php
php scripts/check_i18n_missing_keys.php --locale=<your-locale>
php scripts/check_i18n_extra_keys.php --locale=<your-locale>

All four must pass before submitting. They do not check translation quality but do catch missing errors.* keys, newly introduced hardcoded English strings, keys missing from your locale, and orphaned keys that no longer exist in English.

8. Submit

Open a pull request against the main branch with only the new locale files (config/i18n/<locale>/). Include a brief note in the PR description about which areas were translated and any strings intentionally left in English.

Notes for Translators

  • Placeholders like `{cost}`, `{system_name}`, `{count}` must appear verbatim in translated strings ? the system replaces them at runtime.
  • HTML is not used inside catalog strings. Do not add markup.
  • Gendered / plural forms are not currently supported ? choose a neutral phrasing where the language requires it.
  • Missing keys fall back to English automatically, so a partial translation ships gracefully without breaking the interface.

Language Phrase Overrides

Sysops can customize individual phrases for any locale without editing the base translation files. Overrides are layered on top of the base catalog at runtime ? only the keys you define in an override file are affected; everything else falls through to the base catalog as normal.

Admin UI

Navigate to Admin ? BBS Settings ? Language Overrides. Select a locale and catalog, then click Load. Each row shows the translation key, the current base value, and an input field for your override. Leave a field empty to use the base value. Click Save Overrides when done.

File Format

Override files are plain JSON stored at config/i18n/overrides/<locale>/<namespace>.json:

{
    "ui.terminalserver.server.banner.title": "My BBS Telnet Service",
    "ui.nav.home": "Home Base"
}

Only include the keys you want to override. Keys not present in the file are unaffected. Saving an empty set of overrides removes the file entirely.

How It Works

When Translator loads a catalog it checks for a corresponding override file after loading the base .php catalog and merges any matching keys on top. The override is transparent to all callers ? t(), window.t(), and API responses all see the overridden values automatically without any code changes.

Notes

  • Override files are written through the admin daemon ? the web process never writes them directly.
  • Keys in override files that do not exist in the base catalog are silently ignored at runtime but are still saved in the file.
  • Override files are not tracked by the i18n validation scripts and do not need to be committed to version control for a production installation.

Environment Variables

| Variable | Default | Description | |---|---|---| | I18N_DEFAULT_LOCALE | en | Fallback locale when no user preference or browser locale matches | | I18N_SUPPORTED_LOCALES | (auto) | Comma-separated list of supported locales; auto-discovered if unset | | I18N_LOG_MISSING_KEYS | false | Log a warning when a translation key is not found | | I18N_MISSING_KEYS_LOG_FILE | (php error log) | Path to write missing-key log entries |

Validation Scripts

Five scripts keep the catalogs consistent. All exit non-zero on failure.

scripts/check_i18n_syntax.php

Runs php -l on every catalog file under config/i18n/ and reports parse errors. A broken catalog causes a fatal HTTP 500 whenever that locale is active, so this should be the first check run ? the key-comparison scripts silently skip files they cannot load.

php scripts/check_i18n_syntax.php               # all locales
php scripts/check_i18n_syntax.php --locale=it   # one locale

scripts/check_i18n_hardcoded_strings.php

Scans templates and JavaScript files for user-visible English strings that should be translation keys. Strings in config/i18n/hardcoded_allowlist.php are exempt (e.g. API fallback strings, internal values). Run in CI via .github/workflows/i18n-error-keys.yml.

scripts/check_i18n_error_keys.php

Verifies that every error_code used in apiError() calls throughout routes and controllers exists in config/i18n/en/errors.php. Run in CI via .github/workflows/i18n-error-keys.yml.

scripts/check_i18n_missing_keys.php

Compares every non-English locale catalog against the English baseline and reports keys that are present in en but absent from the locale. Use this after adding new keys to en to find which locales need updating.

php scripts/check_i18n_missing_keys.php               # all locales, all namespaces
php scripts/check_i18n_missing_keys.php --locale=fr   # one locale
php scripts/check_i18n_missing_keys.php --ns=errors   # one namespace

scripts/check_i18n_extra_keys.php

Compares every non-English locale catalog against the English baseline and reports keys that exist in the locale but not in en (orphaned keys). These arise when a key is renamed or removed from English and the corresponding locale entries are not cleaned up. Run this after removing or renaming keys in en.

php scripts/check_i18n_extra_keys.php               # all locales, all namespaces
php scripts/check_i18n_extra_keys.php --locale=es   # one locale
php scripts/check_i18n_extra_keys.php --ns=common   # one namespace

Important: Timing of Client-Side Translations

The i18n catalog is fetched asynchronously on page load. The load sequence is:

  1. Twig renders the page server-side with the correct locale (always correct)
  2. `DOMContentLoaded` fires
  3. `loadUserSettings()` fetches `/api/user/settings` (async)
  4. On completion, `loadI18nNamespaces()` fetches `/api/i18n/catalog` (async)
  5. Only after step 4 does `window.t()` return translated strings

There is no mechanism that queues or defers window.t() calls made before step 4 completes. Any JavaScript that runs during steps 2?4 and tries to set visible text via window.t() or uiT() will receive the English fallback string regardless of user locale.

The primary protection is server-side rendering. Twig handles the initial render correctly, so JavaScript should not re-render already-translated text during initialization.

Rule: When a UI element is already rendered by Twig server-side, do not overwrite it from JavaScript during page initialization. Only use window.t() / uiT() to update text in response to user interactions (dropdown changes, button clicks, etc.) ? by which point the catalog is reliably loaded.

If you must translate a string from JS during init (no server-rendered equivalent), defer it until the catalog is ready:

loadI18nNamespaces(['common']).then(function() {
    $('#myElement').text(window.t('ui.my_feature.label', {}, 'My Label'));
});