Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
Changelog
=========

1.0.5 (Unreleased)
------------------
- Fix: England no longer displays as "United Kingdom" with the Union Jack — England, Scotland and Wales now use their own flags (Unicode subdivision tag sequences) with their proper names; Kosovo (KVX) resolves to its localized name and flag; Northern Ireland falls back to a neutral initials badge (it has no emoji flag).

1.0.4 (June 3,2026)
-------------------
- Chore: bump minVersion to 1.18
Expand Down
2 changes: 1 addition & 1 deletion module.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"world cup",
"leaderboard"
],
"version": "1.0.4",
"version": "1.0.5",
"humhub": {
"minVersion": "1.18"
},
Expand Down
90 changes: 84 additions & 6 deletions services/TeamNameLocalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,33 @@
* country code simply fall back to the stored name.
*
* Adapters may store the country code as ISO-3166-1 alpha-2 (mock), alpha-3
* (some football-data fields), or 3-letter international codes (`GER`, `SUI`,
* `KSA`). `normalizeToIso2()` collapses all three onto a canonical alpha-2.
* (some football-data fields), or 3-letter FIFA codes (`GER`, `SUI`, `KSA`).
* `normalizeToIso2()` collapses all three onto a canonical alpha-2.
*
* FIFA members without an ISO 3166-1 identity get special treatment: the
* British home nations (England, Scotland, Wales) are ISO 3166-2
* subdivisions of GB whose flags are Unicode tag sequences (see
* `flagCodepoints()`), and Kosovo maps to the user-assigned-but-universal
* `XK`. Their names fall back to the stored team name where ICU can't help
* (PHP's intl exposes territory names only, not subdivision names).
*/
final class TeamNameLocalizer
{
/**
* 3-letter (international / ISO-3) → ISO-2 alias map for nations the
* module is likely to see. Codes already in alpha-2 are not listed — they
* pass through unchanged.
* 3-letter (FIFA / ISO-3) → ISO-2 alias map for nations the module is
* likely to see. Codes already in alpha-2 are not listed — they pass
* through unchanged. Where FIFA and ISO-3 disagree (GER/DEU, SUI/CHE, …)
* both spellings are present.
*
* Deliberately NOT mapped: ENG/SCO/WAL (subdivision flags, see
* SUBDIVISION_FLAGS) and NIR (no ISO code and no emoji flag — Unicode
* declined the politically contested Ulster Banner — so Northern Ireland
* intentionally falls through to the neutral initials badge).
*/
private const ISO3_TO_ISO2 = [
'ARG' => 'AR', 'FRA' => 'FR', 'BRA' => 'BR', 'ESP' => 'ES',
'ENG' => 'GB', 'GBR' => 'GB',
'GBR' => 'GB',
'KVX' => 'XK', // Kosovo: FIFA KVX → user-assigned ISO-2, resolved by ICU/CLDR
'POR' => 'PT', 'PRT' => 'PT',
'NED' => 'NL', 'NLD' => 'NL',
'BEL' => 'BE',
Expand Down Expand Up @@ -83,6 +97,19 @@ final class TeamNameLocalizer
'CUW' => 'CW',
];

/**
* FIFA codes of teams that are ISO 3166-2 *subdivisions* (the British
* home nations), keyed to the lowercase tag-letter part of their Unicode
* tag-sequence flag (🏴 + tag letters + cancel tag). They have no ISO
* 3166-1 code, so `normalizeToIso2()` keeps returning null for them —
* names fall back to the stored team name, only the flag is resolvable.
*/
private const SUBDIVISION_FLAGS = [
'ENG' => 'gbeng',
'SCO' => 'gbsct',
'WAL' => 'gbwls',
];

/**
* Returns the canonical ISO-3166-1 alpha-2 code for whatever variant the
* adapter wrote in, or null if the input doesn't look like a country code
Expand All @@ -103,6 +130,57 @@ public static function normalizeToIso2(?string $code): ?string
return self::ISO3_TO_ISO2[$code] ?? null;
}

/**
* Returns the Unicode codepoint sequence of the team's flag emoji, or
* null when no flag exists for the code. Two shapes:
* - countries: a regional-indicator pair (e.g. DE → U+1F1E9 U+1F1EA);
* - home nations (ENG/SCO/WAL): a tag sequence — black flag, one tag
* character per subdivision letter, cancel tag.
*
* The sequence doubles as the Twemoji asset filename (codepoints in
* lowercase hex, joined by "-"), so emoji rendering and SVG lookup can't
* drift apart.
*
* @return int[]|null
*/
public static function flagCodepoints(?string $code): ?array
{
$raw = strtoupper(trim((string) $code));
if (isset(self::SUBDIVISION_FLAGS[$raw])) {
$codepoints = [0x1F3F4]; // 🏴 waving black flag
foreach (str_split(self::SUBDIVISION_FLAGS[$raw]) as $letter) {
$codepoints[] = 0xE0000 + ord($letter); // tag character
}
$codepoints[] = 0xE007F; // cancel tag
return $codepoints;
}

$iso2 = self::normalizeToIso2($code);
if ($iso2 === null) {
return null;
}
return [
0x1F1E6 + ord($iso2[0]) - 65,
0x1F1E6 + ord($iso2[1]) - 65,
];
}

/**
* Returns the team's flag as an emoji string, or null when no flag
* exists for the code (clubs, unknown codes, Northern Ireland).
*/
public static function flagEmoji(?string $code): ?string
{
$codepoints = self::flagCodepoints($code);
if ($codepoints === null) {
return null;
}
return implode('', array_map(
static fn(int $codepoint): string => mb_chr($codepoint, 'UTF-8'),
$codepoints,
));
}

/**
* Returns a localized name for the team (e.g. "Deutschland" / "Germany"
* / "Brésil") via Intl. Falls back to `$fallbackName` whenever:
Expand Down
64 changes: 64 additions & 0 deletions tests/codeception/unit/TeamNameLocalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,56 @@ public function testNormalizeToIso2ReturnsNullForUnknownInputs(): void
$this->assertNull(TeamNameLocalizer::normalizeToIso2('TOOLONG'));
}

public function testNormalizeToIso2HandlesNonIsoFifaMembers(): void
{
$this->assertSame('XK', TeamNameLocalizer::normalizeToIso2('KVX'), 'Kosovo → user-assigned XK');
// The home nations are ISO 3166-2 subdivisions, not countries — they
// must NOT resolve to GB (England is not the United Kingdom).
$this->assertNull(TeamNameLocalizer::normalizeToIso2('ENG'));
$this->assertNull(TeamNameLocalizer::normalizeToIso2('SCO'));
$this->assertNull(TeamNameLocalizer::normalizeToIso2('WAL'));
$this->assertNull(TeamNameLocalizer::normalizeToIso2('NIR'));
// The UK itself keeps resolving (e.g. an Olympic "Team GB").
$this->assertSame('GB', TeamNameLocalizer::normalizeToIso2('GBR'));
}

public function testFlagCodepointsForCountries(): void
{
$this->assertSame([0x1F1E9, 0x1F1EA], TeamNameLocalizer::flagCodepoints('DE'));
$this->assertSame([0x1F1E9, 0x1F1EA], TeamNameLocalizer::flagCodepoints('GER'), 'FIFA code resolves too');
$this->assertSame([0x1F1FD, 0x1F1F0], TeamNameLocalizer::flagCodepoints('KVX'), 'Kosovo 🇽🇰');
$this->assertNull(TeamNameLocalizer::flagCodepoints(null));
$this->assertNull(TeamNameLocalizer::flagCodepoints('XYZ'));
}

public function testFlagCodepointsForHomeNations(): void
{
$this->assertSame(
[0x1F3F4, 0xE0067, 0xE0062, 0xE0065, 0xE006E, 0xE0067, 0xE007F],
TeamNameLocalizer::flagCodepoints('ENG'),
'England tag sequence',
);
$this->assertSame(
[0x1F3F4, 0xE0067, 0xE0062, 0xE0073, 0xE0063, 0xE0074, 0xE007F],
TeamNameLocalizer::flagCodepoints('SCO'),
'Scotland tag sequence',
);
$this->assertSame(
[0x1F3F4, 0xE0067, 0xE0062, 0xE0077, 0xE006C, 0xE0073, 0xE007F],
TeamNameLocalizer::flagCodepoints('WAL'),
'Wales tag sequence',
);
$this->assertNull(TeamNameLocalizer::flagCodepoints('NIR'), 'Northern Ireland has no emoji flag');
}

public function testFlagEmoji(): void
{
$this->assertSame('🇫🇷', TeamNameLocalizer::flagEmoji('FRA'));
$this->assertSame('🏴󠁧󠁢󠁥󠁮󠁧󠁿', TeamNameLocalizer::flagEmoji('ENG'));
$this->assertNull(TeamNameLocalizer::flagEmoji('NIR'));
$this->assertNull(TeamNameLocalizer::flagEmoji(null));
}

public function testLocalizeTranslatesViaIntl(): void
{
if (!class_exists(Locale::class)) {
Expand All @@ -58,4 +108,18 @@ public function testLocalizeFallsBackToStoredNameWhenCodeUnknown(): void
$this->assertSame('FC Bayern', TeamNameLocalizer::localize('', 'FC Bayern', 'de'));
$this->assertSame('Mystery Club', TeamNameLocalizer::localize('XYZ', 'Mystery Club', 'de'));
}

public function testLocalizeHomeNationsAndKosovo(): void
{
if (!class_exists(Locale::class)) {
$this->markTestSkipped('Intl extension not available');
}
// England must NOT become "Vereinigtes Königreich" — no ISO country,
// so the stored name passes through on every UI language.
$this->assertSame('England', TeamNameLocalizer::localize('ENG', 'England', 'de'));
$this->assertSame('Scotland', TeamNameLocalizer::localize('SCO', 'Scotland', 'fr'));
$this->assertSame('Northern Ireland', TeamNameLocalizer::localize('NIR', 'Northern Ireland', 'de'));
// Kosovo resolves via the user-assigned XK, which CLDR localizes.
$this->assertSame('Kosovo', TeamNameLocalizer::localize('KVX', 'Kosovo', 'de'));
}
}
6 changes: 1 addition & 5 deletions views/competition/_team_badge.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@
$flag = null;

if (!$logo && $team) {
$iso2 = \humhub\modules\kickoff\services\TeamNameLocalizer::normalizeToIso2($team->country_code);
if ($iso2 !== null) {
$flag = mb_chr(0x1F1E6 + ord($iso2[0]) - 65, 'UTF-8')
. mb_chr(0x1F1E6 + ord($iso2[1]) - 65, 'UTF-8');
}
$flag = \humhub\modules\kickoff\services\TeamNameLocalizer::flagEmoji($team->country_code);
}

if ($short !== null) {
Expand Down
Loading