From 3a86a7656ef476490bf70ca49b22a6fab9cca3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pl=C3=B6ger?= Date: Sun, 10 May 2026 10:47:49 -0300 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20opt-in=20client-side=20la?= =?UTF-8?q?nguage=20redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/js/language-redirect.js | 242 ++++++++++++++++++ config/_default/params.toml | 7 + .../content/docs/configuration/index.md | 20 ++ layouts/partials/head.html | 1 + layouts/partials/language-redirect.html | 35 +++ 5 files changed, 305 insertions(+) create mode 100644 assets/js/language-redirect.js create mode 100644 layouts/partials/language-redirect.html diff --git a/assets/js/language-redirect.js b/assets/js/language-redirect.js new file mode 100644 index 000000000..767ce8fe2 --- /dev/null +++ b/assets/js/language-redirect.js @@ -0,0 +1,242 @@ +(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(); + } + + function normalizeLanguage(value) { + return normalizeLanguageTag(value).split("-")[0]; + } + + function getTranslationLanguage(translation) { + if (!translation) { + return ""; + } + + return normalizeLanguageTag(translation.lang || translation.languageCode); + } + + function getAvailableTranslations() { + if (!Array.isArray(config.translations)) { + return []; + } + + return config.translations.filter(function (translation) { + return translation && translation.url && normalizeLanguage(translation.lang || translation.languageCode); + }); + } + + var translations = getAvailableTranslations(); + + function findTranslation(language) { + var languageTag = normalizeLanguageTag(language); + var baseLanguage = normalizeLanguage(language); + + if (!baseLanguage) { + return null; + } + + for (var i = 0; i < translations.length; i += 1) { + var translation = translations[i]; + + if ( + normalizeLanguageTag(translation.lang) === languageTag || + normalizeLanguageTag(translation.languageCode) === languageTag + ) { + return translation; + } + } + + for (var j = 0; j < translations.length; j += 1) { + var baseTranslation = translations[j]; + + if ( + normalizeLanguage(baseTranslation.lang) === baseLanguage || + normalizeLanguage(baseTranslation.languageCode) === baseLanguage + ) { + return baseTranslation; + } + } + + return null; + } + + 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 getBrowserLanguage() { + 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 baseLanguage = normalizeLanguage(languages[i]); + + if (findTranslation(baseLanguage)) { + return baseLanguage; + } + } + + if (config.fallbackLanguage && findTranslation(config.fallbackLanguage)) { + return 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 redirectToLanguage(language) { + var translation = findTranslation(language); + + if (!translation || !translation.url) { + return false; + } + + var currentLanguage = normalizeLanguageTag(config.currentLanguage || config.currentLanguageCode); + var targetLanguage = getTranslationLanguage(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 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(getTranslationLanguage(translation)); + } + }); + } + + function maybeRedirect() { + var storedLanguage = getStoredLanguage(); + + if (storedLanguage) { + redirectToLanguage(storedLanguage); + return; + } + + if (config.browserRedirectHomeOnly === true && config.isHome !== true) { + return; + } + + var browserLanguage = getBrowserLanguage(); + + if (browserLanguage) { + redirectToLanguage(browserLanguage); + } + } + + bindLanguageSwitcher(); + maybeRedirect(); +})(); diff --git a/config/_default/params.toml b/config/_default/params.toml index 0ec9dcaad..52877d6a7 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. + [header] layout = "basic" # valid options: basic, fixed, fixed-fill, fixed-gradient, fixed-fill-blur diff --git a/exampleSite/content/docs/configuration/index.md b/exampleSite/content/docs/configuration/index.md index 5e6f65668..9e32318b5 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. @@ -200,6 +215,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 e41972d99..b18876eff 100644 --- a/layouts/partials/head.html +++ b/layouts/partials/head.html @@ -129,6 +129,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 000000000..e034b531a --- /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 }} From 598905a36b94bbdeb436bfd87a7357c9a87b562b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pl=C3=B6ger?= Date: Sun, 10 May 2026 11:14:37 -0300 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8C=90=20Preserve=20locale=20subtags?= =?UTF-8?q?=20in=20language=20redirects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/js/language-redirect.js | 153 +++++++++++++++++++++++++-------- 1 file changed, 115 insertions(+), 38 deletions(-) diff --git a/assets/js/language-redirect.js b/assets/js/language-redirect.js index 767ce8fe2..0391a65ff 100644 --- a/assets/js/language-redirect.js +++ b/assets/js/language-redirect.js @@ -24,19 +24,62 @@ return ""; } - return value.trim().toLowerCase(); + return value.trim().toLowerCase().replace(/_/g, "-"); } - function normalizeLanguage(value) { - return normalizeLanguageTag(value).split("-")[0]; + function getBaseLanguage(value) { + var languageTag = normalizeLanguageTag(value); + + if (!languageTag) { + return ""; + } + + return languageTag.split("-")[0]; } - function getTranslationLanguage(translation) { + 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 normalizeLanguageTag(translation.lang || translation.languageCode); + 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() { @@ -45,43 +88,72 @@ } return config.translations.filter(function (translation) { - return translation && translation.url && normalizeLanguage(translation.lang || translation.languageCode); + return translation && translation.url && getTranslationTags(translation).length > 0; }); } var translations = getAvailableTranslations(); - function findTranslation(language) { - var languageTag = normalizeLanguageTag(language); - var baseLanguage = normalizeLanguage(language); + 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 translation = translations[i]; + var tags = getTranslationTags(translations[i]); - if ( - normalizeLanguageTag(translation.lang) === languageTag || - normalizeLanguageTag(translation.languageCode) === languageTag - ) { - return translation; + for (var j = 0; j < tags.length; j += 1) { + if (getBaseLanguage(tags[j]) === baseLanguage) { + return translations[i]; + } } } - for (var j = 0; j < translations.length; j += 1) { - var baseTranslation = translations[j]; + return null; + } + + function findBestTranslation(languageTag) { + var normalizedTag = normalizeLanguageTag(languageTag); + + if (!normalizedTag) { + return null; + } + + while (normalizedTag) { + var translation = findExactTranslation(normalizedTag); - if ( - normalizeLanguage(baseTranslation.lang) === baseLanguage || - normalizeLanguage(baseTranslation.languageCode) === baseLanguage - ) { - return baseTranslation; + if (translation) { + return translation; + } + + var lastSubtagIndex = normalizedTag.lastIndexOf("-"); + + if (lastSubtagIndex === -1) { + break; } + + normalizedTag = normalizedTag.slice(0, lastSubtagIndex); } - return null; + return findBaseTranslation(languageTag); } function getStoredLanguage() { @@ -114,7 +186,7 @@ } } - function getBrowserLanguage() { + function getBrowserTranslation() { var languages = []; if (Array.isArray(navigator.languages)) { @@ -126,15 +198,15 @@ } for (var i = 0; i < languages.length; i += 1) { - var baseLanguage = normalizeLanguage(languages[i]); + var translation = findBestTranslation(languages[i]); - if (findTranslation(baseLanguage)) { - return baseLanguage; + if (translation) { + return translation; } } - if (config.fallbackLanguage && findTranslation(config.fallbackLanguage)) { - return config.fallbackLanguage; + if (config.fallbackLanguage) { + return findBestTranslation(config.fallbackLanguage); } return null; @@ -156,15 +228,16 @@ } } - function redirectToLanguage(language) { - var translation = findTranslation(language); - + function redirectToTranslation(translation) { if (!translation || !translation.url) { return false; } - var currentLanguage = normalizeLanguageTag(config.currentLanguage || config.currentLanguageCode); - var targetLanguage = getTranslationLanguage(translation); + var currentLanguage = getCanonicalTranslationTag({ + lang: config.currentLanguage, + languageCode: config.currentLanguageCode + }); + var targetLanguage = getCanonicalTranslationTag(translation); if (currentLanguage && targetLanguage && currentLanguage === targetLanguage) { return false; @@ -180,6 +253,10 @@ return true; } + function redirectToLanguage(language) { + return redirectToTranslation(findBestTranslation(language)); + } + function findTranslationByUrl(url) { var linkUrl = getUrl(url); @@ -213,7 +290,7 @@ var translation = findTranslationByUrl(link.href); if (translation) { - setStoredLanguage(getTranslationLanguage(translation)); + setStoredLanguage(getCanonicalTranslationTag(translation)); } }); } @@ -230,10 +307,10 @@ return; } - var browserLanguage = getBrowserLanguage(); + var browserTranslation = getBrowserTranslation(); - if (browserLanguage) { - redirectToLanguage(browserLanguage); + if (browserTranslation) { + redirectToTranslation(browserTranslation); } }