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:
162
app/Core/Locale.php
Normal file
162
app/Core/Locale.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user