Files
landingpage-haus-schleusingen/app/Core/I18n.php
Hermes 63c8c759d2 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
2026-06-04 08:53:58 +00:00

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