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 */ 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 . */ 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 */ 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; } }