Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
242 changes: 242 additions & 0 deletions assets/js/language-redirect.js
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
aplgr marked this conversation as resolved.
Outdated
}
}

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();
})();
7 changes: 7 additions & 0 deletions config/_default/params.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions exampleSite/content/docs/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`. |
<!-- prettier-ignore-end -->

### 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.
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions layouts/partials/head.html
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
type="text/javascript"
src="{{ $jsAppearance.RelPermalink }}"
integrity="{{ $jsAppearance.Data.Integrity }}"></script>
{{ partial "language-redirect.html" . }}
{{ $enableA11y := .Site.Params.enableA11y | default false }}
{{ if $enableA11y }}
{{ $jsA11y := resources.Get "js/a11y.js" | resources.Minify | resources.Fingerprint $alg }}
Expand Down
35 changes: 35 additions & 0 deletions layouts/partials/language-redirect.html
Original file line number Diff line number Diff line change
@@ -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 }}
<script>
window.BlowfishLanguageRedirectConfig = {{ $config | jsonify | safeJS }};
</script>
{{ $alg := site.Params.fingerprintAlgorithm | default "sha512" }}
{{ with resources.Get "js/language-redirect.js" }}
{{ $jsLanguageRedirect := . | resources.Minify | resources.Fingerprint $alg }}
<script
type="text/javascript"
src="{{ $jsLanguageRedirect.RelPermalink }}"
integrity="{{ $jsLanguageRedirect.Data.Integrity }}"></script>
{{ end }}
{{ end }}