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:
Hermes
2026-06-04 08:53:58 +00:00
parent f9295a2d07
commit 63c8c759d2
5 changed files with 807 additions and 0 deletions

140
app/Core/I18n.php Normal file
View 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';
}
}

162
app/Core/Locale.php Normal file
View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Locale resolution: query-param → cookie → Accept-Language → fallback 'de'.
*
* Immutable. No globals on the instance — all input is passed explicitly so
* the class is trivial to unit-test.
*
* Supported locales: 'de' (default), 'en', 'uk', 'ru'.
*
* The class is a thin value object; resolution is a static method so it
* can be called from anywhere (controllers, views, tests) without wiring.
*/
final class Locale
{
public const DEFAULT = 'de';
/** @var list<string> ISO 639-1 codes, de is the source of truth */
public const SUPPORTED = ['de', 'en', 'uk', 'ru'];
/**
* Resolve a locale from request signals.
*
* Priority: explicit query/cookie > Accept-Language header > default.
*
* @param string|null $queryParam Value of ?lang= (raw, unvalidated)
* @param string|null $cookieValue Value of the 'locale' cookie (raw)
* @param string|null $acceptLanguage Raw Accept-Language header
*/
public static function resolve(
?string $queryParam = null,
?string $cookieValue = null,
?string $acceptLanguage = null,
): string {
// 1. Query param wins (one-shot, used by LocaleController to set cookie)
if (is_string($queryParam) && self::isSupported($queryParam)) {
return $queryParam;
}
// 2. Cookie next
if (is_string($cookieValue) && self::isSupported($cookieValue)) {
return $cookieValue;
}
// 3. Accept-Language header
if (is_string($acceptLanguage) && $acceptLanguage !== '') {
$parsed = self::parseAcceptLanguage($acceptLanguage);
foreach ($parsed as $tag) {
if (self::isSupported($tag)) {
return $tag;
}
}
}
// 4. Fallback
return self::DEFAULT;
}
/**
* Normalize an Accept-Language header into a list of ISO 639-1 codes
* sorted by q-value (highest first), with q=0 entries dropped.
*
* Handles wildcards ("*") and BCP-47 subtags ("en-US" → "en",
* "uk-UA" → "uk"). Entries with the same q-value keep header order
* (stable).
*
* @return list<string>
*/
public static function parseAcceptLanguage(string $header): array
{
$header = trim($header);
if ($header === '') {
return [];
}
$entries = [];
foreach (explode(',', $header) as $i => $part) {
$parts = explode(';', trim($part));
$tag = trim($parts[0]);
$q = 1.0;
for ($j = 1; $j < count($parts); $j++) {
if (preg_match('/^q\s*=\s*([0-9.]+)$/i', trim($parts[$j]), $m)) {
$q = (float) $m[1];
}
}
if ($q <= 0.0) {
continue;
}
// Strip BCP-47 region: "en-US" → "en", "uk-UA" → "uk"
$primary = strtolower(explode('-', $tag)[0]);
if ($primary === '*' || $primary === '') {
continue;
}
// Sort key: -q (descending) and original position (ascending)
$entries[] = [
'tag' => $primary,
'q' => $q,
'pos' => $i,
];
}
usort($entries, static function (array $a, array $b): int {
if ($a['q'] !== $b['q']) {
return $b['q'] <=> $a['q'];
}
return $a['pos'] <=> $b['pos'];
});
return array_values(array_map(static fn (array $e): string => $e['tag'], $entries));
}
/**
* Check whether a code is in {@see self::SUPPORTED}.
*/
public static function isSupported(string $code): bool
{
return in_array($code, self::SUPPORTED, true);
}
/**
* Map ISO 639-1 → BCP-47 og:locale format.
* Used by View layout for <meta property="og:locale">.
*/
public static function toOgLocale(string $code): string
{
return match ($code) {
'de' => 'de_DE',
'en' => 'en_GB', // UK English by user requirement
'uk' => 'uk_UA',
'ru' => 'ru_RU',
default => 'de_DE',
};
}
/**
* Build the full hreflang alternate list for the current page, given its
* canonical path. Returns an array of ['locale' => 'hreflang', 'href' => url].
*
* @return list<array{locale:string, hreflang:string, href:string}>
*/
public static function hreflangAlternates(string $canonicalPath, string $baseUrl = 'https://haus-schleusingen.de'): array
{
$out = [];
foreach (self::SUPPORTED as $code) {
$hreflang = $code === 'en' ? 'en-GB' : ($code === 'uk' ? 'uk' : $code);
$out[] = [
'locale' => $code,
'hreflang' => $hreflang,
'href' => $baseUrl . $canonicalPath . '?lang=' . $code,
];
}
return $out;
}
}