Multi-Language MVP: 4 Locales (DE/EN/UK/RU) mit SSR + UI + Tests #79

Merged
greggy merged 27 commits from feature/multilanguage-mvp into main 2026-06-05 23:49:39 +02:00
7 changed files with 82 additions and 62 deletions
Showing only changes of commit 391985cd42 - Show all commits

View File

@@ -47,7 +47,7 @@ final class LocaleSwitcher
'UTF-8', 'UTF-8',
); );
$currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8'); $currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8');
$currentFlag = self::flagSvg($this->currentLocale); $currentFlag = self::flagImg($this->currentLocale);
$html = '<details class="locale-switcher">'; $html = '<details class="locale-switcher">';
$html .= '<summary class="locale-switcher__trigger"' $html .= '<summary class="locale-switcher__trigger"'
@@ -68,7 +68,7 @@ final class LocaleSwitcher
'UTF-8', 'UTF-8',
); );
$codeAttr = htmlspecialchars($code, ENT_QUOTES, 'UTF-8'); $codeAttr = htmlspecialchars($code, ENT_QUOTES, 'UTF-8');
$flag = self::flagSvg($code); $flag = self::flagImg($code);
$html .= '<li>'; $html .= '<li>';
if ($isCurrent) { if ($isCurrent) {
@@ -99,46 +99,40 @@ final class LocaleSwitcher
} }
/** /**
* Inline 24×16 SVG for the four supported locales. * Country flag for the given locale. Renders a 24×18 <img>
* pointing at the official flag-icons SVG asset shipped under
* public/img/flags/. 4:3 aspect (de/gb/ua/ru), crisp at any DPI,
* no external CDN dependency.
* *
* - DE: black/red/gold horizontal stripes (Germany) * Decorative: `alt=""` (the visible locale-switcher label and
* - EN: simplified Union Jack (en-GB per ADR-002) * the <a>'s `hreflang`/`lang` carry the accessible name).
* - UK: blue/yellow horizontal stripes (Ukraine)
* - RU: white/blue/red horizontal stripes (Russia)
*
* Decorative: marked `aria-hidden="true"` + `focusable="false"`.
* The full accessible name is conveyed by the visible label and
* the <a>'s hreflang/lang.
*/ */
public static function flagSvg(string $locale): string public static function flagImg(string $locale): string
{ {
$svg = match ($locale) { $src = self::flagSource($locale);
'de' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">' return '<img class="flag" src="' . $src . '" alt="" width="24" height="18" loading="lazy" decoding="async">';
. '<rect width="24" height="5.33" fill="#000"/>' }
. '<rect y="5.33" width="24" height="5.34" fill="#DD0000"/>'
. '<rect y="10.67" width="24" height="5.33" fill="#FFCC00"/>' /**
. '</svg>', * Map our locale codes to flag-icons file names. Locale "en"
'en' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">' * is en-GB per ADR-002, so the asset is "gb.svg". Anything we
. '<rect width="24" height="16" fill="#012169"/>' * do not know falls back to a transparent 1×1 gif so the layout
. '<path d="M0,0 L24,16 M24,0 L0,16" stroke="#fff" stroke-width="2.4"/>' * stays intact and the alt text (from the surrounding <a>) is
. '<path d="M0,0 L24,16 M24,0 L0,16" stroke="#C8102E" stroke-width="1.2"/>' * the only signal.
. '<path d="M12,0 V16 M0,8 H24" stroke="#fff" stroke-width="3.2"/>' */
. '<path d="M12,0 V16 M0,8 H24" stroke="#C8102E" stroke-width="1.6"/>' private static function flagSource(string $locale): string
. '</svg>', {
'uk' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">' $file = match ($locale) {
. '<rect width="24" height="8" fill="#005BBB"/>' 'de' => 'de',
. '<rect y="8" width="24" height="8" fill="#FFD500"/>' 'en' => 'gb',
. '</svg>', 'uk' => 'ua',
'ru' => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">' 'ru' => 'ru',
. '<rect width="24" height="5.33" fill="#fff"/>' default => null,
. '<rect y="5.33" width="24" height="5.34" fill="#0039A6"/>'
. '<rect y="10.67" width="24" height="5.33" fill="#D52B1E"/>'
. '</svg>',
default => '<svg class="flag" viewBox="0 0 24 16" aria-hidden="true" focusable="false">'
. '<rect width="24" height="16" fill="#888"/>'
. '</svg>',
}; };
return $svg; if ($file === null) {
return 'data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAACAkQBADs=';
}
return '/img/flags/' . $file . '.svg';
} }
/** /**

View File

@@ -1568,20 +1568,22 @@ footer {
.locale-switcher .flag { .locale-switcher .flag {
width: 24px; width: 24px;
height: 16px; height: 18px;
flex: 0 0 24px; flex: 0 0 24px;
border-radius: 2px; border-radius: 2px;
box-shadow: 0 0 0 1px rgb(0 0 0 / 15%); box-shadow: 0 0 0 1px rgb(0 0 0 / 15%);
display: block; display: block;
object-fit: cover;
} }
.locale-switcher__option .flag { .locale-switcher__option .flag {
width: 24px; width: 24px;
height: 16px; height: 18px;
flex: 0 0 24px; flex: 0 0 24px;
border-radius: 2px; border-radius: 2px;
box-shadow: 0 0 0 1px rgb(0 0 0 / 15%); box-shadow: 0 0 0 1px rgb(0 0 0 / 15%);
display: block; display: block;
object-fit: cover;
} }
.locale-switcher__label { .locale-switcher__label {

5
public/img/flags/de.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-de" viewBox="0 0 640 480">
<path fill="#fc0" d="M0 320h640v160H0z"/>
<path fill="#000001" d="M0 0h640v160H0z"/>
<path fill="red" d="M0 160h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 221 B

7
public/img/flags/en.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-gb" viewBox="0 0 640 480">
<path fill="#012169" d="M0 0h640v480H0z"/>
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0z"/>
<path fill="#C8102E" d="m424 281 216 159v40L369 281zm-184 20 6 35L54 480H0zM640 0v3L391 191l2-44L590 0zM0 0l239 176h-60L0 42z"/>
<path fill="#FFF" d="M241 0v480h160V0zM0 160v160h640V160z"/>
<path fill="#C8102E" d="M0 193v96h640v-96zM273 0v480h96V0z"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

5
public/img/flags/ru.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ru" viewBox="0 0 640 480">
<path fill="#fff" d="M0 0h640v160H0z"/>
<path fill="#0039a6" d="M0 160h640v160H0z"/>
<path fill="#d52b1e" d="M0 320h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

6
public/img/flags/uk.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ua" viewBox="0 0 640 480">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="gold" d="M0 0h640v480H0z"/>
<path fill="#0057b8" d="M0 0h640v240H0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 232 B

View File

@@ -97,34 +97,35 @@ final class LocaleSwitcherTest extends TestCase
public static function flagDataProvider(): array public static function flagDataProvider(): array
{ {
return [ return [
'DE Germany' => ['de', '#FFCC00'], 'DE Germany' => ['de', 'de.svg'],
'EN UnionJack' => ['en', '#C8102E'], 'EN en-GB' => ['en', 'gb.svg'],
'UK Ukraine' => ['uk', '#FFD500'], 'UK Ukraine' => ['uk', 'ua.svg'],
'RU Russia' => ['ru', '#D52B1E'], 'RU Russia' => ['ru', 'ru.svg'],
]; ];
} }
#[Test] #[Test]
public function flagSvgReturnsValidSvgForEverySupportedLocale(): void public function flagImgReturnsValidImgForEverySupportedLocale(): void
{ {
foreach (Locale::SUPPORTED as $code) { foreach (Locale::SUPPORTED as $code) {
$svg = LocaleSwitcher::flagSvg($code); $img = LocaleSwitcher::flagImg($code);
self::assertStringStartsWith('<svg', $svg); self::assertStringStartsWith('<img', $img);
self::assertStringContainsString('viewBox="0 0 24 16"', $svg); self::assertStringContainsString('class="flag"', $img);
self::assertStringContainsString('aria-hidden="true"', $svg); self::assertStringContainsString('width="24" height="18"', $img);
self::assertStringContainsString('focusable="false"', $svg); self::assertStringContainsString('alt=""', $img);
self::assertStringContainsString('class="flag"', $svg); self::assertStringEndsWith('>', $img);
self::assertStringEndsWith('</svg>', $svg);
} }
} }
#[Test] #[Test]
public function flagSvgHasFallbackForUnknownLocale(): void public function flagImgHasFallbackForUnknownLocale(): void
{ {
$svg = LocaleSwitcher::flagSvg('xx'); $img = LocaleSwitcher::flagImg('xx');
self::assertStringStartsWith('<svg', $svg); self::assertStringStartsWith('<img', $img);
self::assertStringContainsString('class="flag"', $svg); self::assertStringContainsString('class="flag"', $img);
self::assertStringEndsWith('</svg>', $svg); // 1×1 transparent gif keeps the layout stable even when the
// locale code is not one of our four.
self::assertStringContainsString('data:image/gif', $img);
} }
#[Test] #[Test]
@@ -142,11 +143,11 @@ final class LocaleSwitcherTest extends TestCase
{ {
// The closed dropdown shows the current locale's flag in the trigger // The closed dropdown shows the current locale's flag in the trigger
$html = (new LocaleSwitcher('de', '/'))->render(); $html = (new LocaleSwitcher('de', '/'))->render();
// The first <svg class="flag"> in the document is the trigger // The first <img class="flag"> in the document is the trigger and it
self::assertStringContainsString('<svg class="flag" viewBox="0 0 24 16"', $html); // must point at the German flag asset under /img/flags/.
// The first flag SVG must contain the German colours $deFlag = LocaleSwitcher::flagImg('de');
$deFlag = LocaleSwitcher::flagSvg('de');
$pos = strpos($html, $deFlag); $pos = strpos($html, $deFlag);
self::assertNotFalse($pos, 'expected German flag SVG in the trigger (first <svg> in document)'); self::assertNotFalse($pos, 'expected German flag <img> in the trigger (first <img class="flag"> in document)');
self::assertStringContainsString('src="/img/flags/de.svg"', $deFlag);
} }
} }