Add language switcher and language detection

This commit is contained in:
2026-08-11 12:25:14 +02:00
parent 3e7c831dac
commit df489c6c79
11 changed files with 526 additions and 384 deletions
@@ -130,10 +130,10 @@
<div class="mt-6 pt-8 border-t border-gray-600">
<div class="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<p class="text-gray-600 text-sm">
© {{ now.Format "2006" }} {{ .Site.Title }}. All rights reserved.
© {{ now.Format "2006" }} {{ with .Site.Title }}{{ . }}. {{ end }}{{ i18n "allRightsReserved" }}
</p>
<a href="https://www.champs-libres.coop/" class="text-sm text-gray-600 hover:text-primary-600" target="_blank" rel="noopener noreferrer">
Designed by Champs-Libres
{{ i18n "designedBy" }} Champs-Libres
</a>
</div>
</div>
@@ -89,21 +89,7 @@
{{ end }}
<!-- Mobile Language Switcher -->
{{ if hugo.IsMultilingual }}
<div class="py-4 border-t border-gray-200 mt-4">
<div class="text-lg text-gray-600 font-semibold mb-2">{{ i18n "language" }}</div>
{{ range .Site.Languages }}
{{ if eq $.Site.Language.Lang .Lang }}
<span class="block py-2 text-primary-600 font-bold">{{ .LanguageName }}</span>
{{ else }}
{{ $langURL := printf "/%s/" .Lang }}
<a href="{{ $langURL }}" class="language-switch-link block py-2 text-gray-700 hover:text-primary-600" data-lang="{{ .Lang }}">
{{ .LanguageName }}
</a>
{{ end }}
{{ end }}
</div>
{{ end }}
{{ partial "language-switcher-mobile" . }}
{{ if not $headerConfig.hideButtons }}
<div class="pt-4 space-y-4">
@@ -0,0 +1,106 @@
{{/*
Language preference + browser language detection.
Rendered inline in <head> on purpose: it must run before the page paints so
a visitor is never shown a flash of the wrong language, and inlining avoids
an extra HTTP request for ~1 KB of script.
Hugo hands the script everything it needs, so the script never has to guess
URLs: the home page of every language and the translations of *this* page.
That keeps it correct even though French is served from the site root ("/")
while the other languages sit under a prefix ("/en/", "/nl/").
*/}}
{{ if hugo.IsMultilingual }}
{{ $homes := dict }}
{{ range hugo.Sites }}
{{ $homes = merge $homes (dict .Language.Lang .Home.RelPermalink) }}
{{ end }}
{{ $translations := dict }}
{{ range .AllTranslations }}
{{ $translations = merge $translations (dict .Lang .RelPermalink) }}
{{ end }}
<script>
(function () {
'use strict';
var STORAGE_KEY = 'hugo_preferred_language';
var SESSION_KEY = 'language_redirected';
var CURRENT = {{ .Site.Language.Lang }};
var HOMES = {{ $homes | jsonify | safeJS }};
var TRANSLATIONS = {{ $translations | jsonify | safeJS }};
var AVAILABLE = Object.keys(HOMES);
function read(store, key) {
try { return window[store].getItem(key); } catch (e) { return null; }
}
function write(store, key, value) {
try { window[store].setItem(key, value); } catch (e) { /* private mode */ }
}
/* Match a browser language tag ("nl-BE", "en") against the languages this
site actually publishes, comparing on the primary subtag. */
function matchLanguage(tag) {
var primary = String(tag).toLowerCase().split('-')[0];
for (var i = 0; i < AVAILABLE.length; i++) {
if (AVAILABLE[i].toLowerCase().split('-')[0] === primary) {
return AVAILABLE[i];
}
}
return null;
}
function detectFromBrowser() {
var tags = navigator.languages || (navigator.language ? [navigator.language] : []);
for (var i = 0; i < tags.length; i++) {
var match = matchLanguage(tags[i]);
if (match) { return match; }
}
return null;
}
function targetFor(lang) {
/* Prefer the translation of the page being viewed, so a deep link keeps
the visitor on the same page in their language. */
return TRANSLATIONS[lang] || HOMES[lang] || null;
}
function resolveAndRedirect() {
/* Auto-redirect at most once per browsing session: after that the
visitor is free to navigate to any language by hand. */
if (read('sessionStorage', SESSION_KEY)) { return; }
write('sessionStorage', SESSION_KEY, '1');
var stored = read('localStorage', STORAGE_KEY);
var wanted = (stored && HOMES[stored]) ? stored : detectFromBrowser();
if (!wanted || wanted === CURRENT) { return; }
var target = targetFor(wanted);
if (target) {
window.location.replace(target + window.location.search + window.location.hash);
}
}
/* Remember an explicit choice made through the language switcher. */
function rememberChoices() {
var links = document.querySelectorAll('.language-switch-link');
for (var i = 0; i < links.length; i++) {
links[i].addEventListener('click', function () {
var lang = this.getAttribute('data-lang');
if (lang) { write('localStorage', STORAGE_KEY, lang); }
});
}
}
resolveAndRedirect();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', rememberChoices);
} else {
rememberChoices();
}
})();
</script>
{{ end }}
@@ -0,0 +1,24 @@
{{/*
Mobile language switcher, used inside the collapsed navigation menu.
Same translation-aware linking as the desktop switcher.
*/}}
{{ if hugo.IsMultilingual }}
{{ $page := . }}
<div class="py-4 border-t border-gray-200 mt-4">
<div class="text-lg text-gray-600 font-semibold mb-2">{{ i18n "language" }}</div>
{{ range hugo.Sites }}
{{ $lang := .Language.Lang }}
{{ if eq $.Site.Language.Lang $lang }}
<span class="block py-2 text-primary-600 font-bold">{{ .Language.LanguageName }}</span>
{{ else }}
{{ $target := .Home }}
{{ range $page.AllTranslations }}
{{ if eq .Lang $lang }}{{ $target = . }}{{ end }}
{{ end }}
<a href="{{ $target.RelPermalink }}" hreflang="{{ $lang }}" lang="{{ $lang }}" class="language-switch-link block py-2 text-gray-700 hover:text-primary-600" data-lang="{{ $lang }}">
{{ .Language.LanguageName }}
</a>
{{ end }}
{{ end }}
</div>
{{ end }}
@@ -1,8 +1,16 @@
{{/*
Desktop language switcher.
Links point at the translation of the *current* page when one exists, so
switching language keeps the visitor where they are. When the current page
has no translation we fall back to the home page of the target language.
*/}}
{{ if hugo.IsMultilingual }}
{{ $page := . }}
{{ $headerConfig := .Site.Params.header }}
{{ $dropdownConfig := $headerConfig.menu.dropdown }}
<div class="relative group">
<button class="flex items-center {{ with $headerConfig.menu.linkClass }}{{ . }}{{ else }}text-base text-gray-900 hover:text-primary-600 font-bold transition duration-200{{ end }}">
<button type="button" aria-label="{{ i18n "switchLanguage" }}" class="flex items-center {{ with $headerConfig.menu.linkClass }}{{ . }}{{ else }}text-base text-gray-900 hover:text-primary-600 font-bold transition duration-200{{ end }}">
{{ .Site.Language.LanguageName }}
<svg class="ml-2 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
@@ -10,15 +18,19 @@
</button>
<div class="absolute right-0 mt-2 {{ $dropdownConfig.width | default "w-72" }} opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 ease-in-out">
<div class="{{ $dropdownConfig.container_padding | default "py-6" }} {{ $dropdownConfig.background | default "bg-white" }} {{ $dropdownConfig.border | default "border border-gray-100" }} {{ $dropdownConfig.shadow | default "shadow-xl" }} {{ $dropdownConfig.radius | default "rounded-lg" }}">
{{ range .Site.Languages }}
{{ if ne $.Site.Language.Lang .Lang }}
{{ $langURL := printf "/%s/" .Lang }}
<a href="{{ $langURL }}" class="language-switch-link block {{ $dropdownConfig.item_padding | default "px-8 py-3" }} {{ $dropdownConfig.text_size | default "text-sm" }} {{ $dropdownConfig.text_color | default "text-gray-700" }} {{ $dropdownConfig.hover_background | default "hover:bg-gray-50" }}" data-lang="{{ .Lang }}">
{{ .LanguageName }}
{{ range hugo.Sites }}
{{ $lang := .Language.Lang }}
{{ if ne $.Site.Language.Lang $lang }}
{{ $target := .Home }}
{{ range $page.AllTranslations }}
{{ if eq .Lang $lang }}{{ $target = . }}{{ end }}
{{ end }}
<a href="{{ $target.RelPermalink }}" hreflang="{{ $lang }}" lang="{{ $lang }}" class="language-switch-link block {{ $dropdownConfig.item_padding | default "px-8 py-3" }} {{ $dropdownConfig.text_size | default "text-sm" }} {{ $dropdownConfig.text_color | default "text-gray-700" }} {{ $dropdownConfig.hover_background | default "hover:bg-gray-50" }}" data-lang="{{ $lang }}">
{{ .Language.LanguageName }}
</a>
{{ end }}
{{ end }}
{{ end }}
</div>
</div>
</div>
{{ end }}
{{ end }}
@@ -14,8 +14,8 @@ Author: Jeroen van der Schee
{{ end }}
</div>
{{ if gt (len .images) 1 }}
<button class="carousel-prev" aria-label="Précédent">&#10094;</button>
<button class="carousel-next" aria-label="Suivant">&#10095;</button>
<button class="carousel-prev" aria-label="{{ i18n "previous" }}">&#10094;</button>
<button class="carousel-next" aria-label="{{ i18n "next" }}">&#10095;</button>
<div class="carousel-dots">
{{ range $i, $_ := .images }}