Skip to content

Latest commit

 

History

History
492 lines (332 loc) · 17.7 KB

File metadata and controls

492 lines (332 loc) · 17.7 KB

Localization in BinktermPHP

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
├── es/
│   ├── common.php
│   └── errors.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 and errors.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_hardcoded_strings.php
php scripts/check_i18n_error_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 and config/i18n/<locale>/errors.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.

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 and errors.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 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 and config/i18n/<locale>/errors.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 and config/i18n/en/errors.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. 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

6. Run the Validation Scripts

php scripts/check_i18n_hardcoded_strings.php
php scripts/check_i18n_error_keys.php

Both must pass before submitting. They do not check translation quality but do catch missing errors.* keys and newly introduced hardcoded English strings.

7. 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 (check_i18n_hardcoded_strings.php, check_i18n_error_keys.php) 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

Two scripts keep the catalogs consistent:

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

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.

Both scripts exit non-zero on failure and are run in CI via .github/workflows/i18n-error-keys.yml.


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'));
});