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
319 changes: 319 additions & 0 deletions assets/js/language-redirect.js
Original file line number Diff line number Diff line change
@@ -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();
})();
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.

[seo]
# metaDescriptionOrder = ["summary", "description", "site"] # Controls the fallback order for the HTML meta description. Valid values are "summary", "description", and "site".

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 @@ -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

Expand Down
1 change: 1 addition & 0 deletions layouts/partials/head.html
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,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
Loading