diff --git a/assets/js/language-redirect.js b/assets/js/language-redirect.js
new file mode 100644
index 0000000000..0391a65ff0
--- /dev/null
+++ b/assets/js/language-redirect.js
@@ -0,0 +1,319 @@
+(function () {
+ var config = window.BlowfishLanguageRedirectConfig;
+
+ if (!config || config.enabled !== true) {
+ return;
+ }
+
+ function getStorage() {
+ if (!config.storageKey) {
+ return null;
+ }
+
+ try {
+ return window.localStorage;
+ } catch (error) {
+ return null;
+ }
+ }
+
+ var storage = getStorage();
+
+ function normalizeLanguageTag(value) {
+ if (typeof value !== "string") {
+ return "";
+ }
+
+ return value.trim().toLowerCase().replace(/_/g, "-");
+ }
+
+ function getBaseLanguage(value) {
+ var languageTag = normalizeLanguageTag(value);
+
+ if (!languageTag) {
+ return "";
+ }
+
+ return languageTag.split("-")[0];
+ }
+
+ function getTranslationTags(translation) {
+ if (!translation) {
+ return [];
+ }
+
+ var tags = [];
+ var values = [translation.lang, translation.languageCode];
+
+ for (var i = 0; i < values.length; i += 1) {
+ var languageTag = normalizeLanguageTag(values[i]);
+
+ if (languageTag && tags.indexOf(languageTag) === -1) {
+ tags.push(languageTag);
+ }
+ }
+
+ return tags;
+ }
+
+ function getCanonicalTranslationTag(translation) {
+ var tags = getTranslationTags(translation);
+
+ if (!tags.length) {
+ return "";
+ }
+
+ return tags.reduce(function (bestTag, languageTag) {
+ if (languageTag.split("-").length > bestTag.split("-").length) {
+ return languageTag;
+ }
+
+ return bestTag;
+ }, tags[0]);
+ }
+
+ function translationHasTag(translation, languageTag) {
+ var normalizedTag = normalizeLanguageTag(languageTag);
+
+ if (!normalizedTag) {
+ return false;
+ }
+
+ return getTranslationTags(translation).indexOf(normalizedTag) !== -1;
+ }
+
+ function getAvailableTranslations() {
+ if (!Array.isArray(config.translations)) {
+ return [];
+ }
+
+ return config.translations.filter(function (translation) {
+ return translation && translation.url && getTranslationTags(translation).length > 0;
+ });
+ }
+
+ var translations = getAvailableTranslations();
+
+ function findExactTranslation(languageTag) {
+ var normalizedTag = normalizeLanguageTag(languageTag);
+
+ if (!normalizedTag) {
+ return null;
+ }
+
+ for (var i = 0; i < translations.length; i += 1) {
+ if (translationHasTag(translations[i], normalizedTag)) {
+ return translations[i];
+ }
+ }
+
+ return null;
+ }
+
+ function findBaseTranslation(languageTag) {
+ var baseLanguage = getBaseLanguage(languageTag);
+
+ if (!baseLanguage) {
+ return null;
+ }
+
+ for (var i = 0; i < translations.length; i += 1) {
+ var tags = getTranslationTags(translations[i]);
+
+ for (var j = 0; j < tags.length; j += 1) {
+ if (getBaseLanguage(tags[j]) === baseLanguage) {
+ return translations[i];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ function findBestTranslation(languageTag) {
+ var normalizedTag = normalizeLanguageTag(languageTag);
+
+ if (!normalizedTag) {
+ return null;
+ }
+
+ while (normalizedTag) {
+ var translation = findExactTranslation(normalizedTag);
+
+ if (translation) {
+ return translation;
+ }
+
+ var lastSubtagIndex = normalizedTag.lastIndexOf("-");
+
+ if (lastSubtagIndex === -1) {
+ break;
+ }
+
+ normalizedTag = normalizedTag.slice(0, lastSubtagIndex);
+ }
+
+ return findBaseTranslation(languageTag);
+ }
+
+ function getStoredLanguage() {
+ if (!storage || config.storedLanguageRedirect !== true) {
+ return null;
+ }
+
+ try {
+ return storage.getItem(config.storageKey);
+ } catch (error) {
+ return null;
+ }
+ }
+
+ function setStoredLanguage(language) {
+ if (!storage) {
+ return;
+ }
+
+ var languageTag = normalizeLanguageTag(language);
+
+ if (!languageTag) {
+ return;
+ }
+
+ try {
+ storage.setItem(config.storageKey, languageTag);
+ } catch (error) {
+ // Ignore storage errors so the language link keeps its normal behavior.
+ }
+ }
+
+ function getBrowserTranslation() {
+ var languages = [];
+
+ if (Array.isArray(navigator.languages)) {
+ languages = languages.concat(navigator.languages);
+ }
+
+ if (navigator.language) {
+ languages.push(navigator.language);
+ }
+
+ for (var i = 0; i < languages.length; i += 1) {
+ var translation = findBestTranslation(languages[i]);
+
+ if (translation) {
+ return translation;
+ }
+ }
+
+ if (config.fallbackLanguage) {
+ return findBestTranslation(config.fallbackLanguage);
+ }
+
+ return null;
+ }
+
+ function normalizePath(pathname) {
+ if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === "/") {
+ return pathname.slice(0, -1);
+ }
+
+ return pathname;
+ }
+
+ function getUrl(value) {
+ try {
+ return new URL(value, window.location.origin);
+ } catch (error) {
+ return null;
+ }
+ }
+
+ function redirectToTranslation(translation) {
+ if (!translation || !translation.url) {
+ return false;
+ }
+
+ var currentLanguage = getCanonicalTranslationTag({
+ lang: config.currentLanguage,
+ languageCode: config.currentLanguageCode
+ });
+ var targetLanguage = getCanonicalTranslationTag(translation);
+
+ if (currentLanguage && targetLanguage && currentLanguage === targetLanguage) {
+ return false;
+ }
+
+ var targetUrl = getUrl(translation.url);
+
+ if (!targetUrl || normalizePath(targetUrl.pathname) === normalizePath(window.location.pathname)) {
+ return false;
+ }
+
+ window.location.replace(targetUrl.href);
+ return true;
+ }
+
+ function redirectToLanguage(language) {
+ return redirectToTranslation(findBestTranslation(language));
+ }
+
+ function findTranslationByUrl(url) {
+ var linkUrl = getUrl(url);
+
+ if (!linkUrl) {
+ return null;
+ }
+
+ for (var i = 0; i < translations.length; i += 1) {
+ var translationUrl = getUrl(translations[i].url);
+
+ if (translationUrl && normalizePath(translationUrl.pathname) === normalizePath(linkUrl.pathname)) {
+ return translations[i];
+ }
+ }
+
+ return null;
+ }
+
+ function bindLanguageSwitcher() {
+ document.addEventListener("click", function (event) {
+ if (!event.target || !event.target.closest) {
+ return;
+ }
+
+ var link = event.target.closest(".translation a[href]");
+
+ if (!link) {
+ return;
+ }
+
+ var translation = findTranslationByUrl(link.href);
+
+ if (translation) {
+ setStoredLanguage(getCanonicalTranslationTag(translation));
+ }
+ });
+ }
+
+ function maybeRedirect() {
+ var storedLanguage = getStoredLanguage();
+
+ if (storedLanguage) {
+ redirectToLanguage(storedLanguage);
+ return;
+ }
+
+ if (config.browserRedirectHomeOnly === true && config.isHome !== true) {
+ return;
+ }
+
+ var browserTranslation = getBrowserTranslation();
+
+ if (browserTranslation) {
+ redirectToTranslation(browserTranslation);
+ }
+ }
+
+ bindLanguageSwitcher();
+ maybeRedirect();
+})();
diff --git a/config/_default/params.toml b/config/_default/params.toml
index edb4c2f852..44be887a71 100644
--- a/config/_default/params.toml
+++ b/config/_default/params.toml
@@ -42,6 +42,13 @@ fingerprintAlgorithm = "sha512" # Valid values are "sha512" (default), "sha384",
giteaDefaultServer = "https://git.fsfe.org"
forgejoDefaultServer = "https://v11.next.forgejo.org"
+[languageRedirect]
+ enabled = false # Enable client-side language redirects for multilingual sites.
+ storageKey = "blowfish_preferred_language" # localStorage key for manual language selections.
+ # fallbackLanguage = "" # Language to use when no browser language matches. Defaults to Hugo's default content language.
+ browserRedirectHomeOnly = true # Restrict browser-language redirects to home pages.
+ storedLanguageRedirect = true # Redirect to the stored language when a matching translation exists.
+
[seo]
# metaDescriptionOrder = ["summary", "description", "site"] # Controls the fallback order for the HTML meta description. Valid values are "summary", "description", and "site".
diff --git a/exampleSite/content/docs/configuration/index.md b/exampleSite/content/docs/configuration/index.md
index 691d9be0e0..ff68287146 100644
--- a/exampleSite/content/docs/configuration/index.md
+++ b/exampleSite/content/docs/configuration/index.md
@@ -151,6 +151,21 @@ The default file can be used as a template to create additional languages, or re
| `params.author.links` | _Not set_ | The links to display alongside the author's details. The config file contains example links which can simply be uncommented to enable. The order that the links are displayed is determined by the order they appear in the array. Custom links can be added by providing corresponding SVG icon assets in `assets/icons/`. |
+### Client-side language redirect
+
+Blowfish can optionally redirect visitors to a matching translated page entirely in the browser. The feature is disabled by default, requires no server-side component, and uses `localStorage` instead of cookies to remember language choices made through the existing language dropdown.
+
+When enabled, the browser-language redirect runs only on home pages by default. If none of the visitor's browser languages match an available translation, Blowfish can redirect to `fallbackLanguage` when that translation exists. If `fallbackLanguage` is not set, Blowfish uses Hugo's default content language. A language chosen manually from the dropdown is stored and preferred on later visits when the current page has a matching translated URL.
+
+```toml
+[languageRedirect]
+ enabled = false
+ storageKey = "blowfish_preferred_language"
+ # fallbackLanguage = "en"
+ browserRedirectHomeOnly = true
+ storedLanguageRedirect = true
+```
+
### Menus
Blowfish also supports language-specific menu configurations. Menu config files follow the same naming format as the languages file. Simply provide the language code in the file name to tell Hugo which language the file relates to.
@@ -201,6 +216,11 @@ Many of the article defaults here can be overridden on a per article basis by sp
| `smartTOC` | _Not set_ | Activate smart Table of Contents, items in view will be highlighted. |
| `smartTOCHideUnfocusedChildren` | _Not set_ | When smart Table of Contents is turned on, this will hide deeper levels of the table when they are not in focus. |
| `fingerprintAlgorithm` | `"sha512"` | Hash algorithm for CSS/JS file fingerprinting to prevent browser caching issues. Valid values are `sha512` (default), `sha384`, `sha256`. |
+| `languageRedirect.enabled` | `false` | Enables client-side language redirects for multilingual sites. Disabled by default for backwards compatibility. |
+| `languageRedirect.storageKey` | `"blowfish_preferred_language"` | The `localStorage` key used to persist manual language dropdown selections. |
+| `languageRedirect.fallbackLanguage` | Hugo's default content language | Language to redirect to when no browser language matches and that language has a translation for the current page. |
+| `languageRedirect.browserRedirectHomeOnly` | `true` | Restricts browser-language redirects to home pages to avoid surprising visitors who open deep links. |
+| `languageRedirect.storedLanguageRedirect` | `true` | Allows a stored manual language selection to redirect translated pages when a matching translation exists. |
### Header
diff --git a/layouts/partials/head.html b/layouts/partials/head.html
index 47705511b5..18d6971921 100644
--- a/layouts/partials/head.html
+++ b/layouts/partials/head.html
@@ -139,6 +139,7 @@
type="text/javascript"
src="{{ $jsAppearance.RelPermalink }}"
integrity="{{ $jsAppearance.Data.Integrity }}">
+ {{ partial "language-redirect.html" . }}
{{ $enableA11y := .Site.Params.enableA11y | default false }}
{{ if $enableA11y }}
{{ $jsA11y := resources.Get "js/a11y.js" | resources.Minify | resources.Fingerprint $alg }}
diff --git a/layouts/partials/language-redirect.html b/layouts/partials/language-redirect.html
new file mode 100644
index 0000000000..e034b531a4
--- /dev/null
+++ b/layouts/partials/language-redirect.html
@@ -0,0 +1,35 @@
+{{ $params := site.Params.languageRedirect | default dict }}
+{{ if and ($params.enabled | default false) hugo.IsMultilingual }}
+ {{ $translations := slice }}
+ {{ range .AllTranslations }}
+ {{ $translations = $translations | append (dict "lang" .Language.Lang "languageCode" (.Language.LanguageCode | default "") "url" .RelPermalink) }}
+ {{ end }}
+
+ {{ $browserRedirectHomeOnly := true }}
+ {{ if isset $params "browserredirecthomeonly" }}
+ {{ $browserRedirectHomeOnly = $params.browserRedirectHomeOnly }}
+ {{ end }}
+
+ {{ $storedLanguageRedirect := true }}
+ {{ if isset $params "storedlanguageredirect" }}
+ {{ $storedLanguageRedirect = $params.storedLanguageRedirect }}
+ {{ end }}
+
+ {{ $fallbackLanguage := hugo.Sites.Default.Language.Lang }}
+ {{ with $params.fallbackLanguage }}
+ {{ $fallbackLanguage = . }}
+ {{ end }}
+
+ {{ $config := dict "enabled" true "storageKey" ($params.storageKey | default "blowfish_preferred_language") "fallbackLanguage" $fallbackLanguage "browserRedirectHomeOnly" $browserRedirectHomeOnly "storedLanguageRedirect" $storedLanguageRedirect "currentLanguage" .Language.Lang "currentLanguageCode" (.Language.LanguageCode | default "") "isHome" .IsHome "translations" $translations }}
+
+ {{ $alg := site.Params.fingerprintAlgorithm | default "sha512" }}
+ {{ with resources.Get "js/language-redirect.js" }}
+ {{ $jsLanguageRedirect := . | resources.Minify | resources.Fingerprint $alg }}
+
+ {{ end }}
+{{ end }}