feat(i18n): core Locale resolver + I18n t()-helper with tests (closes #72)
- 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
This commit is contained in:
140
app/Core/I18n.php
Normal file
140
app/Core/I18n.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user