- App\Core\Locale: query-param > cookie > Accept-Language > 'de' fallback
- BCP-47 region stripping (en-US -> en, uk-UA -> uk)
- q-value sorting with stable order
- og:locale mapping (de_DE, en_GB, uk_UA, ru_RU)
- hreflang alternates helper
- App\Core\I18n: t() with {placeholder} interpolation, lookup chain
current-locale -> de -> key, in-memory cache
- ADR-002: documents the architecture decision
- 46 PHPUnit tests (LocaleTest, I18nTest), all green
141 lines
4.3 KiB
PHP
141 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Core;
|
|
|
|
/**
|
|
* Translation loader + t() helper.
|
|
*
|
|
* Loads flat `key => 'text'` arrays from `app/Locales/{locale}.php` once per
|
|
* request per locale, caches in static memory. Supports {placeholder}
|
|
* interpolation.
|
|
*
|
|
* Fallback chain: current locale → 'de' → key string itself (with optional
|
|
* missing-key indicator in dev).
|
|
*
|
|
* Stateless on the instance — `t()` is a static method so views can call
|
|
* it without a container.
|
|
*/
|
|
final class I18n
|
|
{
|
|
/** @var array<string, array<string,string>> locale => [key => text] */
|
|
private static array $cache = [];
|
|
|
|
/** @var string|null Path to the Locales directory (overridable for tests) */
|
|
private static ?string $localesPath = null;
|
|
|
|
/**
|
|
* Translate a key in the current locale, with {placeholder} interpolation.
|
|
*
|
|
* @param string $key Dotted key, e.g. 'nav.gallery'
|
|
* @param array<string,string> $params Placeholders: ['name' => 'Martin']
|
|
* @param string|null $locale Override locale (defaults to current)
|
|
*/
|
|
public static function t(string $key, array $params = [], ?string $locale = null): string
|
|
{
|
|
$locale ??= Locale::DEFAULT;
|
|
|
|
// Unsupported locale = likely a developer bug; surface the key
|
|
// rather than silently falling back to DE.
|
|
if (!Locale::isSupported($locale)) {
|
|
return self::interpolate($key, $params);
|
|
}
|
|
|
|
$text = self::lookup($key, $locale);
|
|
if ($text === null && $locale !== Locale::DEFAULT) {
|
|
$text = self::lookup($key, Locale::DEFAULT);
|
|
}
|
|
$text ??= $key;
|
|
return self::interpolate($text, $params);
|
|
|
|
if ($params !== []) {
|
|
$search = array_map(static fn (string $k): string => '{' . $k . '}', array_keys($params));
|
|
$replace = array_values($params);
|
|
$text = str_replace($search, $replace, $text);
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* Plural-aware translation. MVP: no ICU — we just append `{n}` to the
|
|
* key so the caller provides singular and plural variants.
|
|
*
|
|
* @param array<string,string> $params
|
|
*/
|
|
public static function tn(string $keySingular, string $keyPlural, int $n, array $params = [], ?string $locale = null): string
|
|
{
|
|
$key = $n === 1 ? $keySingular : $keyPlural;
|
|
$params = array_merge($params, ['n' => (string) $n]);
|
|
return self::t($key, $params, $locale);
|
|
}
|
|
|
|
/**
|
|
* Check whether a key exists in the given locale (or the default).
|
|
* Useful for tests and conditional UI logic.
|
|
*/
|
|
public static function has(string $key, ?string $locale = null): bool
|
|
{
|
|
$locale ??= Locale::DEFAULT;
|
|
return self::lookup($key, $locale) !== null
|
|
|| ($locale !== Locale::DEFAULT && self::lookup($key, Locale::DEFAULT) !== null);
|
|
}
|
|
|
|
/**
|
|
* Reset the in-memory cache. Test-only utility.
|
|
*/
|
|
public static function flushCache(): void
|
|
{
|
|
self::$cache = [];
|
|
}
|
|
|
|
/**
|
|
* Override the Locales directory. Test-only utility.
|
|
*/
|
|
public static function setLocalesPath(string $path): void
|
|
{
|
|
self::$localesPath = $path;
|
|
}
|
|
|
|
/**
|
|
* Look up a key in a specific locale's array.
|
|
*/
|
|
private static function interpolate(string $text, array $params): string
|
|
{
|
|
if ($params === []) {
|
|
return $text;
|
|
}
|
|
$search = array_map(static fn (string $k): string => '{' . $k . '}', array_keys($params));
|
|
$replace = array_values($params);
|
|
return str_replace($search, $replace, $text);
|
|
}
|
|
|
|
private static function lookup(string $key, string $locale): ?string
|
|
{
|
|
if (!Locale::isSupported($locale)) {
|
|
return null;
|
|
}
|
|
|
|
if (!isset(self::$cache[$locale])) {
|
|
$file = self::localesPath() . '/' . $locale . '.php';
|
|
if (!is_file($file)) {
|
|
self::$cache[$locale] = [];
|
|
return null;
|
|
}
|
|
$data = require $file;
|
|
self::$cache[$locale] = is_array($data) ? $data : [];
|
|
}
|
|
|
|
return self::$cache[$locale][$key] ?? null;
|
|
}
|
|
|
|
private static function localesPath(): string
|
|
{
|
|
if (self::$localesPath !== null) {
|
|
return self::$localesPath;
|
|
}
|
|
return dirname(__DIR__) . '/Locales';
|
|
}
|
|
}
|