From 63c8c759d2677f0ef3e78cc55d42b95c8ae3d540 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 4 Jun 2026 08:53:58 +0000 Subject: [PATCH 01/27] 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 --- app/Core/I18n.php | 140 +++++++++++++++ app/Core/Locale.php | 162 +++++++++++++++++ docs/adr/002-multilanguage-architecture.md | 123 +++++++++++++ tests/Core/I18nTest.php | 198 +++++++++++++++++++++ tests/Core/LocaleTest.php | 184 +++++++++++++++++++ 5 files changed, 807 insertions(+) create mode 100644 app/Core/I18n.php create mode 100644 app/Core/Locale.php create mode 100644 docs/adr/002-multilanguage-architecture.md create mode 100644 tests/Core/I18nTest.php create mode 100644 tests/Core/LocaleTest.php diff --git a/app/Core/I18n.php b/app/Core/I18n.php new file mode 100644 index 0000000..637ee13 --- /dev/null +++ b/app/Core/I18n.php @@ -0,0 +1,140 @@ + '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> 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 $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 $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'; + } +} diff --git a/app/Core/Locale.php b/app/Core/Locale.php new file mode 100644 index 0000000..8ecb1d9 --- /dev/null +++ b/app/Core/Locale.php @@ -0,0 +1,162 @@ + 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; + } +} diff --git a/docs/adr/002-multilanguage-architecture.md b/docs/adr/002-multilanguage-architecture.md new file mode 100644 index 0000000..11e3b2e --- /dev/null +++ b/docs/adr/002-multilanguage-architecture.md @@ -0,0 +1,123 @@ +# ADR-002: Multi-Language Architecture (i18n) + +**Status:** Accepted +**Date:** 2026-06-04 +**Context:** Issue #71 (Epic: Multi-Language: UK/RU/EN — DE bleibt für Rechtliches) +**Deciders:** Martin (Product Owner), Hermes (Implementation) + +## Context and Problem Statement + +The landing page `landingpage-haus-schleusingen.de` is currently German-only. +It must support 4 languages: **DE** (default, for legal content), **EN** (UK +English), **UK** (Ukrainian), **RU** (Russian). The site is SEO-critical (real +estate listing), has no build step, and runs on stock PHP 8.x on shared hosting. + +The challenge: ship server-side rendering for SEO + no FOUC, without dragging +in a heavy framework or build pipeline. + +## Considered Options + +1. **PHP-Server-Side-Rendering with `app/Locales/*.php` arrays + `t()` helper** + (chosen) +2. JSON translation files + JS-driven i18n (rejected — FOUC, bad SEO) +3. Full Symfony/translation component (rejected — overkill for 1-page site) +4. Static-site per language (`/de/`, `/en/`, `/uk/`, `/ru/` directories) + (rejected — duplicates routes/forms, harder to maintain) + +## Decision + +**Option 1: PHP SSR with `app/Locales/*.php` and a `t()` helper.** + +### Components + +- **`App\Core\Locale`** — locale resolution (priority: query-param `?lang=` + → cookie → `Accept-Language` header → fallback `de`). Immutable, no + globals. Available locales: `['de', 'en', 'uk', 'ru']`. +- **`App\Core\I18n`** — translation loader + `t(string $key, array $params = [])` + function. Loads `app/Locales/{locale}.php` lazily, caches in static array. + Supports `{placeholder}` interpolation. Falls back to `de` if a key is + missing in the current locale, then to the key itself. +- **`App\Controllers\LocaleController`** — `GET /locale/{locale}` sets a + one-year `locale` cookie and 302-redirects to `Referer` (or `/`). +- **`app/Locales/{de,en,uk,ru}.php`** — flat `key => 'text'` arrays. Keys use + dotted notation (`nav.gallery`, `hero.cta`, `form.error.email`). +- **Layout** — `app/views/layouts/main.php` reads current locale from + `Locale::current()` and renders dynamic `` + `og:locale`. +- **Switcher UI** — in `app/views/partials/locale_switcher.php`, embedded in + navbar. Inline SVG flag icons (no external assets). + +### Locale Resolution Order + +1. Query parameter `?lang=xx` (one-shot, sets cookie) +2. Cookie `locale` (1 year, `SameSite=Lax`, no `Secure` flag for HTTP test + hosts, `Secure` flag in prod via env check) +3. `Accept-Language` header — first matching language from + `['en-US', 'en', 'uk', 'ru']` (BCP-47 → ISO 639-1 mapping) +4. Fallback: `de` + +### Translation File Format + +```php +// app/Locales/de.php +return [ + 'nav.gallery' => 'Galerie', + 'hero.cta' => 'Jetzt anfragen', + 'form.label.email' => 'E-Mail', +]; +``` + +`{placeholder}` interpolation, e.g.: + +```php +'greeting' => 'Hallo, {name}!', +echo t('greeting', ['name' => 'Martin']); // "Hallo, Martin!" +``` + +### Out of Scope (this MVP) + +- Right-to-left languages (Arabic, Hebrew) +- Plural forms (`{n,plural,one{...}other{...}}` ICU syntax) — flat strings only +- Admin UI for editing translations (POEditor, Crowdin, etc.) +- Per-page translation overrides +- URL-based locale (`/en/`, `/uk/`) — cookie + query only for MVP + +### Trade-offs Accepted + +- **No URL-based locale** → weaker SEO signal for non-default languages. + Mitigation: `og:locale` + `` + hreflang tags (TODO post-MVP). +- **No ICU plural** → manual `{n} Zimmer` strings. Acceptable: page has + fixed numbers (`227 m²`, `6 Zimmer`). +- **Flat key namespace** (`nav.gallery` vs nested arrays) → slightly more + verbose but trivially diff-able in PRs and avoids PHP array-merging + surprises. + +## Consequences + +### Positive + +- **Zero new dependencies** (no Composer additions, no JS framework) +- **SEO-perfect** — fully server-rendered, no FOUC +- **Trivially testable** — pure PHP, no globals, no I/O at request time + (files loaded once, cached) +- **Diff-friendly** — translation files are flat PHP arrays +- **Fast** — locale detection is in-memory; translation load happens once + per request, per locale + +### Negative + +- Adding a new key requires touching all 4 files (mitigated by `missing +key → fallback to DE → fallback to key string`) +- No URL canonicalization for non-DE locales (mitigated post-MVP with + hreflang) +- Manual translation review (no professional translator for UK/RU in MVP) + +## Implementation Plan + +Issue #71 (Epic) → #72–#77 (6 sub-issues, dependency-ordered). + +## References + +- Issue #71: https://git.home.kies-media.de/greggy/landingpage-haus-schleusingen/issues/71 +- Issue #72–#77: sub-issues, all in Milestone "Multi-Language MVP" +- W3C i18n tutorials: https://www.w3.org/International/tutorials/ +- BCP-47 language tags: https://datatracker.ietf.org/doc/html/rfc5646 diff --git a/tests/Core/I18nTest.php b/tests/Core/I18nTest.php new file mode 100644 index 0000000..aa7c773 --- /dev/null +++ b/tests/Core/I18nTest.php @@ -0,0 +1,198 @@ +tmpDir = sys_get_temp_dir() . '/i18n-test-' . bin2hex(random_bytes(4)); + mkdir($this->tmpDir); + I18n::setLocalesPath($this->tmpDir); + I18n::flushCache(); + } + + protected function tearDown(): void + { + I18n::flushCache(); + I18n::setLocalesPath(dirname(__DIR__, 2) . '/app/Locales'); + if (is_dir($this->tmpDir)) { + foreach (glob($this->tmpDir . '/*') as $f) { + @unlink($f); + } + @rmdir($this->tmpDir); + } + } + + private function writeLocale(string $code, array $data): void + { + $content = 'tmpDir . '/' . $code . '.php', $content); + } + + // ────────────────────────────────────────────── + // t(): basic lookup + // ────────────────────────────────────────────── + + public function testReturnsKeyWhenNoLocalesExist(): void + { + $this->assertSame('missing.key', I18n::t('missing.key')); + } + + public function testReturnsKeyWhenLocaleFileMissing(): void + { + // Only DE file exists + $this->writeLocale('de', ['hello' => 'Hallo']); + $this->assertSame('missing.key', I18n::t('missing.key', [], 'en')); + } + + public function testReturnsKeyWhenKeyMissingInAllLocales(): void + { + $this->writeLocale('de', ['hello' => 'Hallo']); + $this->writeLocale('en', ['other' => 'Other']); + $this->assertSame('greeting', I18n::t('greeting', [], 'en')); + } + + public function testFallsBackToDeWhenKeyMissingInCurrentLocale(): void + { + $this->writeLocale('de', ['nav.home' => 'Start']); + $this->writeLocale('en', ['other.key' => 'Other']); + $this->assertSame('Start', I18n::t('nav.home', [], 'en')); + } + + public function testReturnsValueInCurrentLocale(): void + { + $this->writeLocale('de', ['greeting' => 'Hallo']); + $this->writeLocale('en', ['greeting' => 'Hello']); + $this->assertSame('Hallo', I18n::t('greeting', [], 'de')); + $this->assertSame('Hello', I18n::t('greeting', [], 'en')); + } + + // ────────────────────────────────────────────── + // t(): placeholders + // ────────────────────────────────────────────── + + public function testInterpolatesPlaceholders(): void + { + $this->writeLocale('de', ['welcome' => 'Willkommen, {name}!']); + $this->assertSame( + 'Willkommen, Martin!', + I18n::t('welcome', ['name' => 'Martin'], 'de') + ); + } + + public function testInterpolatesMultiplePlaceholders(): void + { + $this->writeLocale('de', ['mail' => '{greeting}, deine Bestellung #{order} ist da.']); + $this->assertSame( + 'Hi, deine Bestellung #42 ist da.', + I18n::t('mail', ['greeting' => 'Hi', 'order' => '42'], 'de') + ); + } + + public function testLeavesUnreplacedPlaceholdersAlone(): void + { + $this->writeLocale('de', ['x' => 'Hallo {name}']); + $this->assertSame('Hallo {name}', I18n::t('x', [], 'de')); + } + + // ────────────────────────────────────────────── + // t(): default locale behavior + // ────────────────────────────────────────────── + + public function testDefaultsToDeLocaleWhenNoneSpecified(): void + { + $this->writeLocale('de', ['greeting' => 'Hallo']); + $this->assertSame('Hallo', I18n::t('greeting')); + } + + public function testRejectsUnsupportedLocaleAndReturnsKey(): void + { + $this->writeLocale('de', ['greeting' => 'Hallo']); + $this->assertSame('greeting', I18n::t('greeting', [], 'fr')); + } + + // ────────────────────────────────────────────── + // tn(): plural variants (MVP: {n} interpolation) + // ────────────────────────────────────────────── + + public function testTnPicksSingularForOne(): void + { + $this->writeLocale('de', [ + 'room.singular' => '1 Zimmer', + 'room.plural' => '{n} Zimmer', + ]); + $this->assertSame('1 Zimmer', I18n::tn('room.singular', 'room.plural', 1, [], 'de')); + } + + public function testTnPicksPluralForOtherNumbers(): void + { + $this->writeLocale('de', [ + 'room.singular' => '1 Zimmer', + 'room.plural' => '{n} Zimmer', + ]); + $this->assertSame('6 Zimmer', I18n::tn('room.singular', 'room.plural', 6, [], 'de')); + $this->assertSame('0 Zimmer', I18n::tn('room.singular', 'room.plural', 0, [], 'de')); + } + + // ────────────────────────────────────────────── + // has() + // ────────────────────────────────────────────── + + public function testHasReturnsTrueForExistingKey(): void + { + $this->writeLocale('de', ['greeting' => 'Hallo']); + $this->writeLocale('en', ['greeting' => 'Hello']); + $this->assertTrue(I18n::has('greeting', 'en')); + $this->assertTrue(I18n::has('greeting', 'de')); + } + + public function testHasReturnsTrueForFallbackKey(): void + { + $this->writeLocale('de', ['only_de' => 'Nur DE']); + $this->assertTrue(I18n::has('only_de', 'en')); + } + + public function testHasReturnsFalseForMissingKey(): void + { + $this->writeLocale('de', ['x' => 'X']); + $this->assertFalse(I18n::has('nope', 'de')); + } + + // ────────────────────────────────────────────── + // Caching + // ────────────────────────────────────────────── + + public function testCacheSurvivesAcrossCalls(): void + { + $this->writeLocale('de', ['k' => 'v1']); + $this->assertSame('v1', I18n::t('k', [], 'de')); + + // Mutate the file — cached value should still be returned + $this->writeLocale('de', ['k' => 'v2']); + $this->assertSame('v1', I18n::t('k', [], 'de')); + + // Flush — now we see the new value + I18n::flushCache(); + $this->assertSame('v2', I18n::t('k', [], 'de')); + } + + public function testFlushCacheClearsAllLocales(): void + { + $this->writeLocale('de', ['k' => 'de-v']); + $this->writeLocale('en', ['k' => 'en-v']); + I18n::t('k', [], 'en'); + I18n::flushCache(); + $this->writeLocale('en', ['k' => 'en-v2']); + $this->assertSame('en-v2', I18n::t('k', [], 'en')); + } +} diff --git a/tests/Core/LocaleTest.php b/tests/Core/LocaleTest.php new file mode 100644 index 0000000..2af02f2 --- /dev/null +++ b/tests/Core/LocaleTest.php @@ -0,0 +1,184 @@ +assertSame('de', Locale::resolve()); + } + + public function testQueryParamWinsOverCookieAndHeader(): void + { + $this->assertSame('en', Locale::resolve('en', 'ru', 'uk')); + } + + public function testCookieWinsOverHeader(): void + { + $this->assertSame('ru', Locale::resolve(null, 'ru', 'en')); + } + + public function testHeaderUsedWhenNoQueryOrCookie(): void + { + $this->assertSame('en', Locale::resolve(null, null, 'en-US,de;q=0.9')); + } + + public function testFallsBackToDefaultWhenHeaderDoesNotMatch(): void + { + $this->assertSame('de', Locale::resolve(null, null, 'fr-FR,it-IT')); + } + + public function testInvalidQueryParamIsSkippedAndCookieWins(): void + { + // Invalid query (e.g. 'fr' which is not supported) is treated as + // "no signal" from that source — we fall through to the next source. + $this->assertSame('en', Locale::resolve('fr', 'en', 'en')); + } + + public function testEmptyStringsAreTreatedAsNoSignal(): void + { + $this->assertSame('en', Locale::resolve('', '', 'en')); + } + + // ────────────────────────────────────────────── + // isSupported() + // ────────────────────────────────────────────── + + #[DataProvider('provideSupportedLocales')] + public function testIsSupportedReturnsTrueForKnownLocales(string $code): void + { + $this->assertTrue(Locale::isSupported($code)); + } + + #[DataProvider('provideUnsupportedLocales')] + public function testIsSupportedReturnsFalseForUnknownLocales(string $code): void + { + $this->assertFalse(Locale::isSupported($code)); + } + + public static function provideSupportedLocales(): array + { + return [ + 'german' => ['de'], + 'uk-english' => ['en'], + 'ukrainian' => ['uk'], + 'russian' => ['ru'], + ]; + } + + public static function provideUnsupportedLocales(): array + { + return [ + 'french' => ['fr'], + 'empty' => [''], + 'upper' => ['DE'], + 'region' => ['de-DE'], + 'wildcard' => ['*'], + 'garbage' => ['xx'], + ]; + } + + // ────────────────────────────────────────────── + // parseAcceptLanguage() + // ────────────────────────────────────────────── + + public function testParseAcceptLanguageReturnsEmptyForEmptyHeader(): void + { + $this->assertSame([], Locale::parseAcceptLanguage('')); + } + + public function testParseAcceptLanguageHandlesSingleTag(): void + { + $this->assertSame(['en'], Locale::parseAcceptLanguage('en')); + } + + public function testParseAcceptLanguageSortsByQValue(): void + { + $this->assertSame( + ['ru', 'en', 'de'], + Locale::parseAcceptLanguage('de;q=0.5,en;q=0.8,ru;q=0.9') + ); + } + + public function testParseAcceptLanguageDefaultsQTo1(): void + { + $this->assertSame(['de', 'en'], Locale::parseAcceptLanguage('de,en;q=0.5')); + } + + public function testParseAcceptLanguageDropsQ0(): void + { + // 'en;q=0' is explicitly forbidden by the client → drop it. + // 'de;q=1' is the only one left. + $this->assertSame(['de'], Locale::parseAcceptLanguage('en;q=0,de;q=1')); + } + + public function testParseAcceptLanguageStripsBcp47Region(): void + { + $this->assertSame(['en', 'de'], Locale::parseAcceptLanguage('en-US,de-DE')); + } + + public function testParseAcceptLanguageDropsWildcard(): void + { + $this->assertSame([], Locale::parseAcceptLanguage('*')); + // Wildcard plus q=0 → nothing to use + $this->assertSame([], Locale::parseAcceptLanguage('*,en;q=0')); + } + + public function testParseAcceptLanguageIsStableForEqualQValues(): void + { + $this->assertSame( + ['en', 'de', 'uk'], + Locale::parseAcceptLanguage('en;q=0.8,de;q=0.8,uk;q=0.8') + ); + } + + public function testParseAcceptLanguageHandlesWhitespace(): void + { + $this->assertSame(['en', 'de'], Locale::parseAcceptLanguage(' en , de ; q=0.5 ')); + } + + // ────────────────────────────────────────────── + // toOgLocale() + // ────────────────────────────────────────────── + + public function testToOgLocaleMapsToBcp47(): void + { + $this->assertSame('de_DE', Locale::toOgLocale('de')); + $this->assertSame('en_GB', Locale::toOgLocale('en')); + $this->assertSame('uk_UA', Locale::toOgLocale('uk')); + $this->assertSame('ru_RU', Locale::toOgLocale('ru')); + } + + public function testToOgLocaleFallsBackToDeForUnknown(): void + { + $this->assertSame('de_DE', Locale::toOgLocale('fr')); + } + + // ────────────────────────────────────────────── + // hreflangAlternates() + // ────────────────────────────────────────────── + + public function testHreflangAlternatesBuildsFullSet(): void + { + $alts = Locale::hreflangAlternates('/'); + + $this->assertCount(4, $alts); + $locales = array_column($alts, 'locale'); + $this->assertSame(['de', 'en', 'uk', 'ru'], $locales); + + $en = array_values(array_filter($alts, static fn ($a) => $a['locale'] === 'en'))[0]; + $this->assertSame('en-GB', $en['hreflang']); + $this->assertStringContainsString('?lang=en', $en['href']); + } +} From ce2124230898d7fe190469aa121f20b976b5e990 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 4 Jun 2026 08:57:33 +0000 Subject: [PATCH 02/27] feat(i18n): LocaleController switcher with open-redirect protection (closes #73) - App\Controllers\LocaleController: GET /locale?set=xx&return=/path - Sets 1-year cookie (HttpOnly=false for SSR, SameSite=Lax, Secure on HTTPS) - 302 redirect to explicit return URL > Referer > / - Pure buildResponse() helper for unit tests (no headers/exit) - current() helper: resolves locale from $_GET/$_COOKIE/Accept-Language - safeRedirect: rejects absolute URLs, protocol-relative (//evil.com), backslash tricks (\\evil.com), javascript:/data: schemes - 28 PHPUnit tests (LocaleControllerTest), all green - Total project tests now: 92 --- app/Controllers/LocaleController.php | 145 +++++++++++++++++++ public/index.php | 1 + tests/Controllers/LocaleControllerTest.php | 161 +++++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 app/Controllers/LocaleController.php create mode 100644 tests/Controllers/LocaleControllerTest.php diff --git a/app/Controllers/LocaleController.php b/app/Controllers/LocaleController.php new file mode 100644 index 0000000..44a0137 --- /dev/null +++ b/app/Controllers/LocaleController.php @@ -0,0 +1,145 @@ + $resp['cookie_expires'], + 'path' => '/', + 'secure' => $resp['cookie_secure'], + 'httponly' => false, // read by JS-free client (we keep it readable for SSR) + 'samesite' => 'Lax', + ]; + setcookie(self::COOKIE_NAME, $resp['cookie_value'], $params); + } + + header('Location: ' . $resp['redirect'], true, $resp['status']); + exit; + } + + /** + * Pure response builder — testable without headers/exit. + * + * @return array{ + * status: int, + * redirect: string, + * set_cookie: bool, + * cookie_value: string, + * cookie_expires: int, + * cookie_secure: bool + * } + */ + public static function buildResponse( + ?string $locale, + ?string $explicitReturn, + ?string $referer, + bool $isHttps, + ): array { + $valid = is_string($locale) && Locale::isSupported($locale); + + $redirect = self::safeRedirect($explicitReturn, $referer); + + if (!$valid) { + return [ + 'status' => 302, + 'redirect' => $redirect, + 'set_cookie' => false, + 'cookie_value' => '', + 'cookie_expires' => 0, + 'cookie_secure' => $isHttps, + ]; + } + + return [ + 'status' => 302, + 'redirect' => $redirect, + 'set_cookie' => true, + 'cookie_value' => $locale, + 'cookie_expires' => time() + 60 * 60 * 24 * 365, // 1 year + 'cookie_secure' => $isHttps, + ]; + } + + /** + * Compute the current locale from $_GET, $_COOKIE and Accept-Language. + * Convenience for the front controller / View layer. + */ + public static function current(): string + { + return Locale::resolve( + isset($_GET['lang']) && is_string($_GET['lang']) ? $_GET['lang'] : null, + $_COOKIE[self::COOKIE_NAME] ?? null, + $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? null, + ); + } + + /** + * Sanitize the return URL — same-origin path-only. Anything with a + * scheme, host, or `//` prefix is rejected and replaced with `/`. + */ + private static function safeRedirect(?string $explicit, ?string $referer): string + { + $candidate = $explicit ?: $referer; + if (!is_string($candidate) || $candidate === '') { + return '/'; + } + + // Reject absolute URLs and protocol-relative BEFORE backslash fixup, + // so a backslash in the input doesn't smuggle a `//` past us. + if (preg_match('#^(https?:)?//#i', $candidate)) { + return '/'; + } + + // Normalize backslashes (some browsers treat \ as /) + $candidate = str_replace('\\', '/', $candidate); + + // After normalization, re-check for `//` (could be a backslash trick). + if (preg_match('#^//#', $candidate)) { + return '/'; + } + + if ($candidate[0] !== '/') { + return '/'; + } + + return $candidate; + } +} diff --git a/public/index.php b/public/index.php index e343151..bbe6a5f 100755 --- a/public/index.php +++ b/public/index.php @@ -18,6 +18,7 @@ $router = new Router(); $router->addRoute('/', \App\Controllers\HomeController::class, 'index'); $router->addRoute('/impressum', \App\Controllers\ImpressumController::class, 'index'); $router->addRoute('/datenschutz', \App\Controllers\DatenschutzController::class, 'index'); +$router->addRoute('/locale', \App\Controllers\LocaleController::class, 'switch'); // Dispatch $uri = $_SERVER['REQUEST_URI'] ?? '/'; diff --git a/tests/Controllers/LocaleControllerTest.php b/tests/Controllers/LocaleControllerTest.php new file mode 100644 index 0000000..357eb80 --- /dev/null +++ b/tests/Controllers/LocaleControllerTest.php @@ -0,0 +1,161 @@ +assertSame(302, $resp['status']); + $this->assertSame('/foo', $resp['redirect']); + $this->assertTrue($resp['set_cookie']); + $this->assertSame('en', $resp['cookie_value']); + $this->assertGreaterThan(time(), $resp['cookie_expires']); + } + + public function testCookieExpiresInOneYear(): void + { + $before = time(); + $resp = LocaleController::buildResponse('uk', '/', null, false); + $after = time(); + + $expected = 60 * 60 * 24 * 365; + $this->assertGreaterThanOrEqual($before + $expected, $resp['cookie_expires']); + $this->assertLessThanOrEqual($after + $expected, $resp['cookie_expires']); + } + + public function testCookieSecureFlagMatchesHttps(): void + { + $http = LocaleController::buildResponse('en', '/', null, false); + $https = LocaleController::buildResponse('en', '/', null, true); + + $this->assertFalse($http['cookie_secure']); + $this->assertTrue($https['cookie_secure']); + } + + public function testSupportsAllFourLocales(): void + { + foreach (['de', 'en', 'uk', 'ru'] as $code) { + $resp = LocaleController::buildResponse($code, '/', null, false); + $this->assertTrue($resp['set_cookie'], "Locale {$code} should set cookie"); + $this->assertSame($code, $resp['cookie_value']); + } + } + + // ────────────────────────────────────────────── + // buildResponse() — invalid locale + // ────────────────────────────────────────────── + + public function testInvalidLocaleDoesNotSetCookie(): void + { + $resp = LocaleController::buildResponse('fr', '/', null, false); + $this->assertFalse($resp['set_cookie']); + $this->assertSame('', $resp['cookie_value']); + } + + public function testInvalidLocaleStillRedirects(): void + { + $resp = LocaleController::buildResponse('fr', '/safe-path', null, false); + $this->assertSame(302, $resp['status']); + $this->assertSame('/safe-path', $resp['redirect']); + } + + public function testNullLocaleDoesNotSetCookie(): void + { + $resp = LocaleController::buildResponse(null, '/', null, false); + $this->assertFalse($resp['set_cookie']); + } + + public function testEmptyStringLocaleDoesNotSetCookie(): void + { + $resp = LocaleController::buildResponse('', '/', null, false); + $this->assertFalse($resp['set_cookie']); + } + + // ────────────────────────────────────────────── + // safeRedirect() — return URL sanitization + // ────────────────────────────────────────────── + + #[DataProvider('provideOpenRedirectAttempts')] + public function testRejectsOpenRedirects(string $bad, string $expected): void + { + $resp = LocaleController::buildResponse('en', $bad, null, false); + $this->assertSame($expected, $resp['redirect']); + } + + public static function provideOpenRedirectAttempts(): array + { + return [ + 'absolute https' => ['https://evil.com/phish', '/'], + 'absolute http' => ['http://evil.com/phish', '/'], + 'protocol-relative' => ['//evil.com/phish', '/'], + 'scheme-relative upper' => ['//EVIL.COM/phish', '/'], + 'javascript scheme' => ['javascript:alert(1)', '/'], + 'data scheme' => ['data:text/html, @@ -44,7 +85,58 @@ - + + + + +
+ +
+ +
+ + + +
+ + diff --git a/tests/Core/LocaleConsistencyTest.php b/tests/Core/LocaleConsistencyTest.php new file mode 100644 index 0000000..31728a7 --- /dev/null +++ b/tests/Core/LocaleConsistencyTest.php @@ -0,0 +1,90 @@ + */ + public static function localeProvider(): array + { + return [ + 'de' => ['de'], + 'en' => ['en'], + 'uk' => ['uk'], + 'ru' => ['ru'], + ]; + } + + #[Test] + public function allFourLocaleFilesLoadAndAreArrays(): void + { + foreach (self::localeProvider() as [$locale]) { + I18n::flushCache(); + $data = require __DIR__ . '/../../app/Locales/' . $locale . '.php'; + self::assertIsArray($data, "Locale file {$locale}.php must return an array"); + self::assertNotEmpty($data, "Locale file {$locale}.php must not be empty"); + } + } + + #[Test] + public function everyLocaleHasExactlyTheSameKeySet(): void + { + $keysByLocale = []; + foreach (self::localeProvider() as [$locale]) { + $keysByLocale[$locale] = array_keys(require __DIR__ . '/../../app/Locales/' . $locale . '.php'); + sort($keysByLocale[$locale]); + } + + $reference = $keysByLocale['de']; + foreach (['en', 'uk', 'ru'] as $locale) { + $missing = array_diff($reference, $keysByLocale[$locale]); + $extra = array_diff($keysByLocale[$locale], $reference); + self::assertSame( + [], + $missing, + "Locale '{$locale}' is missing keys: " . implode(', ', $missing) + ); + self::assertSame( + [], + $extra, + "Locale '{$locale}' has extra keys not in DE: " . implode(', ', $extra) + ); + } + } + + #[Test] + public function noTranslationValueIsEmpty(): void + { + foreach (self::localeProvider() as [$locale]) { + $data = require __DIR__ . '/../../app/Locales/' . $locale . '.php'; + foreach ($data as $key => $value) { + self::assertIsString($value, "{$locale}.{$key} must be a string"); + self::assertNotSame('', trim($value), "{$locale}.{$key} must not be empty"); + } + } + } + + #[Test] + #[DataProvider('localeProvider')] + public function everyTranslationIsValidUtf8(string $locale): void + { + $data = require __DIR__ . '/../../app/Locales/' . $locale . '.php'; + foreach ($data as $key => $value) { + self::assertTrue( + mb_check_encoding($value, 'UTF-8'), + "{$locale}.{$key} contains invalid UTF-8" + ); + } + } +} From 0186de90ec59f9a84332cf9c1c290022a906fc12 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 4 Jun 2026 09:44:40 +0000 Subject: [PATCH 04/27] feat(i18n): responsive locale-switcher with SVG flags (closes #75) --- app/Controllers/LocaleSwitcher.php | 86 +++++++++++++-- app/Core/I18n.php | 8 -- public/css/haus-schleusingen.css | 119 +++++++++++++++++++++ tests/Controllers/LocaleSwitcherTest.php | 128 +++++++++++++++++++++++ 4 files changed, 322 insertions(+), 19 deletions(-) create mode 100644 tests/Controllers/LocaleSwitcherTest.php diff --git a/app/Controllers/LocaleSwitcher.php b/app/Controllers/LocaleSwitcher.php index 490a211..25a35ff 100644 --- a/app/Controllers/LocaleSwitcher.php +++ b/app/Controllers/LocaleSwitcher.php @@ -11,10 +11,13 @@ use App\Core\Locale; * Renders the language switcher widget. Pure HTML generation — no * side effects, no header writing. * - * Output is semantic HTML (a