From bf1deac58be37dc4f61df2e39a8968c259ec47ec Mon Sep 17 00:00:00 2001 From: Daniel Nylander Date: Mon, 28 Sep 2026 09:55:12 +0200 Subject: [PATCH 1/3] Add Swedish interface localization --- README.md | 8 ++++ frontend/index.html | 91 +++++++++++++++++------------------- frontend/javascript/i18n.js | 56 ++++++++++++++++++++++ frontend/javascript/index.js | 21 +++++---- frontend/locales/en.json | 91 ++++++++++++++++++++++++++++++++++++ frontend/locales/sv.json | 91 ++++++++++++++++++++++++++++++++++++ index-classic.html | 59 +++++++++++------------ index-modern.html | 87 +++++++++++++++++----------------- stability.html | 69 +++++++++++++-------------- 9 files changed, 408 insertions(+), 165 deletions(-) create mode 100644 frontend/javascript/i18n.js create mode 100644 frontend/locales/en.json create mode 100644 frontend/locales/sv.json diff --git a/README.md b/README.md index 31c17bf5e..4e45e4d33 100755 --- a/README.md +++ b/README.md @@ -64,6 +64,14 @@ A command line client is available [here](https://github.com/librespeed/speedtes A .NET client library is available in the [`LibreSpeed.NET`](https://github.com/Memphizzz/LibreSpeed.NET) repo ([NuGet](https://www.nuget.org/packages/LibreSpeed.NET)), maintained by [MemphiZ](https://github.com/Memphizzz). +## Localization + +The user interfaces load translations from `frontend/locales/`. English is the +fallback; a supported browser language is selected automatically, or pass +`?lang=sv` to select Swedish explicitly. Add a complete JSON catalog using the +keys in `frontend/locales/en.json`; keep dynamic UI strings behind +`LibreSpeedI18n.t()`. + ## Development If you want to contribute or develop with LibreSpeed, see [DEVELOPMENT.md](DEVELOPMENT.md) for information about using npm for development tasks, linting, and formatting. diff --git a/frontend/index.html b/frontend/index.html index 8a804d7a2..38243c200 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,13 +8,14 @@ /> - + + - LibreSpeed - Free and Open Source Speedtest + LibreSpeed - Free and Open Source Speedtest @@ -22,30 +23,30 @@ LibreSpeed
-

Free and Open Source Speedtest.

-

No Flash, No Java, No Websockets, No Bullsh*t

+

Free and Open Source Speedtest.

+

No Flash, No Java, No Websockets, No Bullsh*t

select...
-

current server

-

searching nearest server...

+

current server

+

searching nearest server...

    @@ -53,100 +54,96 @@

    searching nearest server...

    00 Mbps

    -

    Download

    +

    Download

    00 Mbps

    -

    Upload

    +

    Upload

    - Close + Close
    - Test results in graphical form - + Test results in graphical form +
    - Close + Close
    -

    Privacy Policy

    +

    Privacy Policy

    - This HTML5 speed test server is configured with telemetry enabled. + This HTML5 speed test server is configured with telemetry enabled.

    -

    What data we collect

    +

    What data we collect

    - At the end of the test, the following data is collected and stored: + At the end of the test, the following data is collected and stored:

      -
    • Test ID
    • -
    • Time of testing
    • -
    • Test results (download and upload speed, ping and jitter)
    • -
    • IP address
    • -
    • ISP information
    • -
    • Approximate location (inferred from IP address, not GPS)
    • -
    • User agent and browser locale
    • -
    • Test log (contains no personal information)
    • +
    • Test ID
    • +
    • Time of testing
    • +
    • Test results (download and upload speed, ping and jitter)
    • +
    • IP address
    • +
    • ISP information
    • +
    • Approximate location (inferred from IP address, not GPS)
    • +
    • User agent and browser locale
    • +
    • Test log (contains no personal information)
    -

    How we use the data

    -

    Data collected through this service is used to:

    +

    How we use the data

    +

    Data collected through this service is used to:

    • - Allow sharing of test results (sharable image for forums, etc.) + Allow sharing of test results (sharable image for forums, etc.)
    • - To improve the service offered to you (for instance, to detect - problems on our side) + To improve the service offered to you (for instance, to detect problems on our side)
    -

    No personal information is disclosed to third parties.

    +

    No personal information is disclosed to third parties.

    -

    Your consent

    +

    Your consent

    - By starting the test, you consent to the terms of this privacy policy. + By starting the test, you consent to the terms of this privacy policy.

    -

    Data removal

    +

    Data removal

    - If you want to have your information deleted, you need to provide - either the ID of the test or your IP address. This is the only way to - identify your data, without this information we won't be able to - comply with your request. + If you want to have your information deleted, you need to provide either the ID of the test or your IP address. This is the only way to identify your data, without this information we won't be able to comply with your request.

    - Contact this email address for all deletion requests: + Contact this email address for all deletion requests: TO BE FILLED BY DEVELOPER.

    - +
    diff --git a/frontend/javascript/i18n.js b/frontend/javascript/i18n.js new file mode 100644 index 000000000..9fe699d9f --- /dev/null +++ b/frontend/javascript/i18n.js @@ -0,0 +1,56 @@ +(() => { + const DEFAULT_LANGUAGE = "en"; + const SUPPORTED_LANGUAGES = new Set(["en", "sv"]); + const script = document.currentScript; + const localesURL = new URL("../locales/", script.src); + let messages = {}; + + function languageFromRequest() { + const requested = new URLSearchParams(window.location.search).get("lang"); + if (requested) return requested.toLowerCase().split("-")[0]; + return (navigator.languages || [navigator.language || DEFAULT_LANGUAGE]) + .map((language) => language.toLowerCase().split("-")[0]) + .find((language) => SUPPORTED_LANGUAGES.has(language)) || DEFAULT_LANGUAGE; + } + + function interpolate(value, parameters) { + return value.replace(/\{(\w+)\}/g, (match, key) => + Object.prototype.hasOwnProperty.call(parameters, key) ? parameters[key] : match + ); + } + + function translate(key, fallback = key, parameters = {}) { + return interpolate(messages[key] || fallback, parameters); + } + + function translateDocument() { + document.querySelectorAll("[data-i18n]").forEach((element) => { + const key = element.dataset.i18n; + element.textContent = translate(key, element.textContent.trim()); + }); + document.querySelectorAll("[data-i18n-attr]").forEach((element) => { + element.dataset.i18nAttr.split(",").forEach((binding) => { + const [attribute, key] = binding.split(":"); + if (attribute && key) element.setAttribute(attribute, translate(key, element.getAttribute(attribute) || key)); + }); + }); + } + + const language = languageFromRequest(); + const ready = fetch(new URL(`${language}.json`, localesURL)) + .then((response) => { + if (!response.ok) throw new Error(`Unable to load ${language} translations`); + return response.json(); + }) + .then((catalog) => { + messages = catalog; + document.documentElement.lang = language; + translateDocument(); + }) + .catch((error) => { + console.warn("LibreSpeed localization unavailable:", error); + document.documentElement.lang = DEFAULT_LANGUAGE; + }); + + window.LibreSpeedI18n = { language, ready, t: translate }; +})(); diff --git a/frontend/javascript/index.js b/frontend/javascript/index.js index f1fe7d801..87f986d52 100644 --- a/frontend/javascript/index.js +++ b/frontend/javascript/index.js @@ -26,6 +26,7 @@ const testState = { // Bootstrap the application when the DOM is ready window.addEventListener("DOMContentLoaded", async () => { + await window.LibreSpeedI18n.ready; createSpeedtest(); hookUpButtons(); startRenderingLoop(); @@ -127,10 +128,10 @@ async function copyLinkButtonClickHandler() { await navigator.clipboard.writeText(link); const button = document.querySelector("#copy-link"); button.classList.add("active"); - button.textContent = "Copied!"; + button.textContent = window.LibreSpeedI18n.t("share.copied", "Copied!"); setTimeout(() => { button.classList.remove("active"); - button.textContent = "Copy link"; + button.textContent = window.LibreSpeedI18n.t("share.copy", "Copy link"); }, 3000); } @@ -212,7 +213,7 @@ async function applyServerListJSON() { selectServer(bestServer); } else { alert( - "Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network." + window.LibreSpeedI18n.t("error.no-servers", "Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network.") ); } }); @@ -334,10 +335,10 @@ function startRenderingLoop() { const resultsImage = document.querySelector("#results"); const buttonTexts = { - [INITIALIZING]: "Loading...", - [READY]: "Let's start", - [RUNNING]: "Abort", - [FINISHED]: "Restart", + [INITIALIZING]: window.LibreSpeedI18n.t("button.loading", "Loading..."), + [READY]: window.LibreSpeedI18n.t("button.start", "Let's start"), + [RUNNING]: window.LibreSpeedI18n.t("button.abort", "Abort"), + [FINISHED]: window.LibreSpeedI18n.t("button.restart", "Restart"), }; // Show copy link button only if navigator.clipboard is available @@ -358,9 +359,9 @@ function startRenderingLoop() { selectedServer.textContent = server.name; if (server.sponsorName) { if (server.sponsorURL) { - sponsor.innerHTML = `Sponsor: ${server.sponsorName}`; + sponsor.innerHTML = `${window.LibreSpeedI18n.t("server.sponsor", "Sponsor: {name}", { name: "" })}${server.sponsorName}`; } else { - sponsor.textContent = `Sponsor: ${server.sponsorName}`; + sponsor.textContent = window.LibreSpeedI18n.t("server.sponsor", "Sponsor: {name}", { name: server.sponsorName }); } } else { sponsor.innerHTML = " "; @@ -442,7 +443,7 @@ function startRenderingLoop() { privacyWarning.innerHTML = ''; const connectedThrough = document.createElement('span'); - connectedThrough.textContent = 'You are connected through:'; + connectedThrough.textContent = window.LibreSpeedI18n.t("connection.through", "You are connected through:"); const ipAddress = document.createTextNode(testState.testData.clientIp); diff --git a/frontend/locales/en.json b/frontend/locales/en.json new file mode 100644 index 000000000..8a28a7c4b --- /dev/null +++ b/frontend/locales/en.json @@ -0,0 +1,91 @@ +{ + "page.title": "LibreSpeed - Free and Open Source Speedtest", + "page.description": "Free and Open Source Speedtest. Run it right now in your browser, or self-host on a PHP, Golang, Rust or Node server. License: LGPL.", + "heading": "Free and Open Source Speedtest.", + "tagline": "No Flash, No Java, No Websockets, No Bullsh*t", + "server.current": "current server", + "server.searching": "searching nearest server...", + "privacy.warning": "by clicking the start button you agree to our privacy policy", + "privacy.choose": "or choose your privacy options", + "metric.download": "Download", + "metric.upload": "Upload", + "metric.ping": "Ping", + "metric.jitter": "Jitter", + "share.results": "Share results", + "footer.stability": "stability test", + "footer.source": "source code", + "dialog.close": "Close", + "dialog.results-alt": "Test results in graphical form", + "share.copy": "Copy link", + "share.copied": "Copied!", + "privacy.title": "Privacy Policy", + "privacy.telemetry": "This HTML5 speed test server is configured with telemetry enabled.", + "privacy.collect-title": "What data we collect", + "privacy.collect-intro": "At the end of the test, the following data is collected and stored:", + "privacy.item.test-id": "Test ID", + "privacy.item.time": "Time of testing", + "privacy.item.results": "Test results (download and upload speed, ping and jitter)", + "privacy.item.ip": "IP address", + "privacy.item.isp": "ISP information", + "privacy.item.location": "Approximate location (inferred from IP address, not GPS)", + "privacy.item.locale": "User agent and browser locale", + "privacy.item.log": "Test log (contains no personal information)", + "privacy.use-title": "How we use the data", + "privacy.use-intro": "Data collected through this service is used to:", + "privacy.use.share": "Allow sharing of test results (sharable image for forums, etc.)", + "privacy.use.improve": "To improve the service offered to you (for instance, to detect problems on our side)", + "privacy.no-disclosure": "No personal information is disclosed to third parties.", + "privacy.consent-title": "Your consent", + "privacy.consent": "By starting the test, you consent to the terms of this privacy policy.", + "privacy.removal-title": "Data removal", + "privacy.removal": "If you want to have your information deleted, you need to provide either the ID of the test or your IP address. This is the only way to identify your data, without this information we won't be able to comply with your request.", + "privacy.contact": "Contact this email address for all deletion requests:", + "button.loading": "Loading...", + "button.start": "Let's start", + "button.abort": "Abort", + "button.restart": "Restart", + "server.sponsor": "Sponsor: {name}", + "connection.through": "You are connected through:", + "error.no-servers": "Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network.", + "classic.selecting": "Selecting a server...", + "classic.no-servers": "No servers available", + "classic.privacy": "Privacy", + "classic.server": "Server:", + "classic.share": "Share results", + "classic.test-id": "Test ID:", + "classic.modern": "Try the modern design", + "classic.stability": "Stability Test", + "classic.source": "Source code", + "classic.link-copied": "Link copied", + "classic.start": "Start", + "classic.abort": "Abort", + "stability.title": "LibreSpeed - Stability Test", + "stability.back": "Speed Test", + "stability.seconds": "{count} Seconds", + "stability.minutes": "{count} Minutes", + "stability.local": "Local Server", + "stability.finding": "Finding...", + "stability.reset": "Reset", + "stability.server": "Server:", + "stability.current": "Current", + "stability.average": "Average", + "stability.min": "Min", + "stability.max": "Max", + "stability.failed": "Failed Requests", + "stability.elapsed": "Elapsed", + "stability.alert": "Alert threshold:", + "stability.off": "Off", + "stability.download-csv": "Download CSV", + "stability.source": "Source code", + "stability.60-seconds": "60 Seconds", + "stability.90-seconds": "90 Seconds", + "stability.2-minutes": "2 Minutes", + "stability.3-minutes": "3 Minutes", + "stability.5-minutes": "5 Minutes", + "stability.finding-server": "Finding best server...", + "stability.no-server": "No reachable local server found", + "stability.great": "Great", + "stability.good": "Good", + "stability.poor": "Poor", + "stability.bad": "Bad" +} diff --git a/frontend/locales/sv.json b/frontend/locales/sv.json new file mode 100644 index 000000000..cdd880d10 --- /dev/null +++ b/frontend/locales/sv.json @@ -0,0 +1,91 @@ +{ + "page.title": "LibreSpeed – fritt hastighetstest med öppen källkod", + "page.description": "Fritt hastighetstest med öppen källkod. Kör det direkt i webbläsaren eller driftssätt det själv på en PHP-, Go-, Rust- eller Node-server. Licens: LGPL.", + "heading": "Fritt hastighetstest med öppen källkod.", + "tagline": "Ingen Flash, ingen Java, inga WebSockets, inget struntprat", + "server.current": "aktuell server", + "server.searching": "söker efter närmaste server...", + "privacy.warning": "genom att klicka på startknappen godkänner du vår integritetspolicy", + "privacy.choose": "eller välj dina integritetsalternativ", + "metric.download": "Hämtning", + "metric.upload": "Sändning", + "metric.ping": "Ping", + "metric.jitter": "Jitter", + "share.results": "Dela resultat", + "footer.stability": "stabilitetstest", + "footer.source": "källkod", + "dialog.close": "Stäng", + "dialog.results-alt": "Testresultat i grafisk form", + "share.copy": "Kopiera länk", + "share.copied": "Kopierad!", + "privacy.title": "Integritetspolicy", + "privacy.telemetry": "Den här HTML5-hastighetstestservern är konfigurerad med telemetri aktiverad.", + "privacy.collect-title": "Vilka data vi samlar in", + "privacy.collect-intro": "När testet är klart samlas följande data in och lagras:", + "privacy.item.test-id": "Test-id", + "privacy.item.time": "Tidpunkt för testet", + "privacy.item.results": "Testresultat (hämtnings- och sändningshastighet, ping och jitter)", + "privacy.item.ip": "IP-adress", + "privacy.item.isp": "Information om internetleverantör", + "privacy.item.location": "Ungefärlig plats (härledd från IP-adress, inte GPS)", + "privacy.item.locale": "Webbläsaragent och språkinställning", + "privacy.item.log": "Testlogg (innehåller inga personuppgifter)", + "privacy.use-title": "Så använder vi data", + "privacy.use-intro": "Data som samlas in via tjänsten används för att:", + "privacy.use.share": "möjliggöra delning av testresultat (delbar bild för forum med mera)", + "privacy.use.improve": "förbättra tjänsten vi erbjuder dig (till exempel för att upptäcka problem hos oss)", + "privacy.no-disclosure": "Inga personuppgifter lämnas ut till tredje part.", + "privacy.consent-title": "Ditt samtycke", + "privacy.consent": "Genom att starta testet samtycker du till villkoren i den här integritetspolicyn.", + "privacy.removal-title": "Radering av data", + "privacy.removal": "Om du vill få dina uppgifter raderade behöver du ange antingen testets id eller din IP-adress. Det är det enda sättet att identifiera dina uppgifter; utan den informationen kan vi inte uppfylla din begäran.", + "privacy.contact": "Kontakta den här e-postadressen för alla begäranden om radering:", + "button.loading": "Läser in...", + "button.start": "Starta testet", + "button.abort": "Avbryt", + "button.restart": "Starta om", + "server.sponsor": "Sponsor: {name}", + "connection.through": "Du är ansluten via:", + "error.no-servers": "Det går inte att nå någon av hastighetstestservrarna. Du är ändå på den här sidan, så något verkar vara fel med nätverket.", + "classic.selecting": "Väljer server...", + "classic.no-servers": "Inga servrar är tillgängliga", + "classic.privacy": "Integritet", + "classic.server": "Server:", + "classic.share": "Dela resultat", + "classic.test-id": "Test-id:", + "classic.modern": "Prova den moderna designen", + "classic.stability": "Stabilitetstest", + "classic.source": "Källkod", + "classic.link-copied": "Länken har kopierats", + "classic.start": "Starta", + "classic.abort": "Avbryt", + "stability.title": "LibreSpeed – stabilitetstest", + "stability.back": "Hastighetstest", + "stability.seconds": "{count} sekunder", + "stability.minutes": "{count} minuter", + "stability.local": "Lokal server", + "stability.finding": "Söker...", + "stability.reset": "Återställ", + "stability.server": "Server:", + "stability.current": "Aktuell", + "stability.average": "Genomsnitt", + "stability.min": "Min", + "stability.max": "Max", + "stability.failed": "Misslyckade begäranden", + "stability.elapsed": "Förfluten tid", + "stability.alert": "Larmgräns:", + "stability.off": "Av", + "stability.download-csv": "Hämta CSV", + "stability.source": "Källkod", + "stability.60-seconds": "60 sekunder", + "stability.90-seconds": "90 sekunder", + "stability.2-minutes": "2 minuter", + "stability.3-minutes": "3 minuter", + "stability.5-minutes": "5 minuter", + "stability.finding-server": "Söker bästa servern...", + "stability.no-server": "Ingen lokal server som kan nås hittades", + "stability.great": "Utmärkt", + "stability.good": "Bra", + "stability.poor": "Svag", + "stability.bad": "Dålig" +} diff --git a/index-classic.html b/index-classic.html index 67353e59c..23edc8ac0 100644 --- a/index-classic.html +++ b/index-classic.html @@ -47,7 +47,7 @@ initUI(); } else { //multiple servers var noServersAvailable = function () { - I("message").innerHTML = "No servers available"; + I("message").textContent = window.LibreSpeedI18n.t("classic.no-servers", "No servers available"); } var runServerSelect = function () { s.selectServer(function (server) { @@ -309,11 +309,11 @@ } #startStopBtn:before { - content: "Start"; + content: attr(data-label-start); } #startStopBtn.running:before { - content: "Abort"; + content: attr(data-label-abort); } #serverArea { @@ -520,39 +520,39 @@ LibreSpeed - +

    LibreSpeed

    -

    Selecting a server...

    +

    Selecting a server...

    + diff --git a/index-modern.html b/index-modern.html index a5285c827..50b3181ba 100644 --- a/index-modern.html +++ b/index-modern.html @@ -5,7 +5,7 @@ + content="Free and Open Source Speedtest. Run it right now in your browser, or self-host on a PHP, Golang, Rust or Node server. License: LGPL." data-i18n-attr="content:page.description" /> @@ -15,9 +15,10 @@ // Set this to a different URL to load the server list from another location. var SPEEDTEST_SERVERS = "server-list.json"; - + + - LibreSpeed - Free and Open Source Speedtest + LibreSpeed - Free and Open Source Speedtest @@ -25,30 +26,30 @@ LibreSpeed
    -

    Free and Open Source Speedtest.

    -

    No Flash, No Java, No Websockets, No Bullsh*t

    +

    Free and Open Source Speedtest.

    +

    No Flash, No Java, No Websockets, No Bullsh*t

    select...
    -

    current server

    -

    searching nearest server...

    +

    current server

    +

    searching nearest server...

      @@ -56,18 +57,18 @@

      searching nearest server...

      00 Mbps

      -

      Download

      +

      Download

      00 Mbps

      -

      Upload

      +

      Upload

      @@ -78,77 +79,73 @@

      Upload

      - Close + Close
      - Test results in graphical form - + Test results in graphical form +
      - Close + Close
      -

      Privacy Policy

      +

      Privacy Policy

      - This HTML5 speed test server is configured with telemetry enabled. + This HTML5 speed test server is configured with telemetry enabled.

      -

      What data we collect

      +

      What data we collect

      - At the end of the test, the following data is collected and stored: + At the end of the test, the following data is collected and stored:

        -
      • Test ID
      • -
      • Time of testing
      • -
      • Test results (download and upload speed, ping and jitter)
      • -
      • IP address
      • -
      • ISP information
      • -
      • Approximate location (inferred from IP address, not GPS)
      • -
      • User agent and browser locale
      • -
      • Test log (contains no personal information)
      • +
      • Test ID
      • +
      • Time of testing
      • +
      • Test results (download and upload speed, ping and jitter)
      • +
      • IP address
      • +
      • ISP information
      • +
      • Approximate location (inferred from IP address, not GPS)
      • +
      • User agent and browser locale
      • +
      • Test log (contains no personal information)
      -

      How we use the data

      -

      Data collected through this service is used to:

      +

      How we use the data

      +

      Data collected through this service is used to:

      • - Allow sharing of test results (sharable image for forums, etc.) + Allow sharing of test results (sharable image for forums, etc.)
      • - To improve the service offered to you (for instance, to detect - problems on our side) + To improve the service offered to you (for instance, to detect problems on our side)
      -

      No personal information is disclosed to third parties.

      +

      No personal information is disclosed to third parties.

      -

      Your consent

      +

      Your consent

      - By starting the test, you consent to the terms of this privacy policy. + By starting the test, you consent to the terms of this privacy policy.

      -

      Data removal

      +

      Data removal

      - If you want to have your information deleted, you need to provide - either the ID of the test or your IP address. This is the only way to - identify your data, without this information we won't be able to - comply with your request. + If you want to have your information deleted, you need to provide either the ID of the test or your IP address. This is the only way to identify your data, without this information we won't be able to comply with your request.

      - Contact this email address for all deletion requests: + Contact this email address for all deletion requests: TO BE FILLED BY DEVELOPER.

      - +
      diff --git a/stability.html b/stability.html index 7f93bffab..0c4a48a20 100644 --- a/stability.html +++ b/stability.html @@ -4,7 +4,7 @@ - LibreSpeed - Stability Test + LibreSpeed - Stability Test -

      LibreSpeed - Stability Test

      - +

      LibreSpeed - Stability Test

      +
      -
      -
      Reset
      +
      +
      Reset
      @@ -307,39 +307,39 @@

      LibreSpeed - Stability Test

      -
      Current
      +
      Current
      ms
      -
      Average
      +
      Average
      ms
      -
      Jitter
      +
      Jitter
      ms
      -
      Min
      +
      Min
      ms
      -
      Max
      +
      Max
      ms
      -
      Failed Requests
      +
      Failed Requests
      %
      -
      Elapsed
      +
      Elapsed
      00:00
      @@ -352,7 +352,7 @@

      LibreSpeed - Stability Test

      - +
      - + + From 6844bf4151a14a5eaef72478c7dca97e92bebf88 Mon Sep 17 00:00:00 2001 From: Daniel Nylander Date: Mon, 28 Sep 2026 09:56:26 +0200 Subject: [PATCH 2/3] Localize classic privacy content --- index-classic.html | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/index-classic.html b/index-classic.html index 23edc8ac0..c0a6c57b3 100644 --- a/index-classic.html +++ b/index-classic.html @@ -563,7 +563,7 @@

      LibreSpeed

      From 18f4cc8c491f824a08465d6f86c09a88b9cf43a1 Mon Sep 17 00:00:00 2001 From: Daniel Nylander <1206564+yeager@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:47:26 +0200 Subject: [PATCH 3/3] Harden localization loading and preserve UI state --- frontend/javascript/i18n.js | 30 ++++++++++++++++++++++++++---- frontend/javascript/index.js | 21 ++++++++++++++++++++- index-modern.html | 2 +- stability.html | 17 +++++++++++------ 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/frontend/javascript/i18n.js b/frontend/javascript/i18n.js index 9fe699d9f..221d467d5 100644 --- a/frontend/javascript/i18n.js +++ b/frontend/javascript/i18n.js @@ -1,18 +1,36 @@ (() => { const DEFAULT_LANGUAGE = "en"; const SUPPORTED_LANGUAGES = new Set(["en", "sv"]); + const CATALOG_TIMEOUT_MS = 3000; const script = document.currentScript; const localesURL = new URL("../locales/", script.src); let messages = {}; + const requested = new URLSearchParams(window.location.search).get("lang"); + const explicitLanguage = requested === null ? null : requested.toLowerCase().split("-")[0]; + function languageFromRequest() { - const requested = new URLSearchParams(window.location.search).get("lang"); - if (requested) return requested.toLowerCase().split("-")[0]; + if (explicitLanguage !== null) + return SUPPORTED_LANGUAGES.has(explicitLanguage) ? explicitLanguage : DEFAULT_LANGUAGE; return (navigator.languages || [navigator.language || DEFAULT_LANGUAGE]) .map((language) => language.toLowerCase().split("-")[0]) .find((language) => SUPPORTED_LANGUAGES.has(language)) || DEFAULT_LANGUAGE; } + function preserveLanguageInLinks(language) { + if (explicitLanguage === null) return; + document.querySelectorAll("a[href]").forEach((link) => { + const href = link.getAttribute("href"); + if (href.startsWith("#")) return; + const url = new URL(href, window.location.href); + if (url.origin !== window.location.origin || + !(/\/(?:index(?:-classic|-modern)?|stability)\.html$/.test(url.pathname) || + url.pathname.endsWith("/"))) return; + url.searchParams.set("lang", language); + link.href = url.href; + }); + } + function interpolate(value, parameters) { return value.replace(/\{(\w+)\}/g, (match, key) => Object.prototype.hasOwnProperty.call(parameters, key) ? parameters[key] : match @@ -37,7 +55,10 @@ } const language = languageFromRequest(); - const ready = fetch(new URL(`${language}.json`, localesURL)) + preserveLanguageInLinks(language); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CATALOG_TIMEOUT_MS); + const ready = fetch(new URL(`${language}.json`, localesURL), { signal: controller.signal }) .then((response) => { if (!response.ok) throw new Error(`Unable to load ${language} translations`); return response.json(); @@ -50,7 +71,8 @@ .catch((error) => { console.warn("LibreSpeed localization unavailable:", error); document.documentElement.lang = DEFAULT_LANGUAGE; - }); + }) + .finally(() => clearTimeout(timeout)); window.LibreSpeedI18n = { language, ready, t: translate }; })(); diff --git a/frontend/javascript/index.js b/frontend/javascript/index.js index 87f986d52..88e9f6d30 100644 --- a/frontend/javascript/index.js +++ b/frontend/javascript/index.js @@ -359,7 +359,26 @@ function startRenderingLoop() { selectedServer.textContent = server.name; if (server.sponsorName) { if (server.sponsorURL) { - sponsor.innerHTML = `${window.LibreSpeedI18n.t("server.sponsor", "Sponsor: {name}", { name: "" })}${server.sponsorName}`; + let sponsorURL; + try { + sponsorURL = new URL(server.sponsorURL, window.location.href); + } catch (error) { + // Display the sponsor as plain text if its URL is invalid. + } + if (sponsorURL && ["http:", "https:"].includes(sponsorURL.protocol)) { + const label = window.LibreSpeedI18n.t("server.sponsor", "Sponsor: {name}"); + const placeholder = label.indexOf("{name}"); + const link = document.createElement("a"); + link.href = sponsorURL.href; + link.textContent = server.sponsorName; + sponsor.replaceChildren( + document.createTextNode(placeholder < 0 ? `${label} ` : label.slice(0, placeholder)), + link, + document.createTextNode(placeholder < 0 ? "" : label.slice(placeholder + 6)) + ); + } else { + sponsor.textContent = window.LibreSpeedI18n.t("server.sponsor", "Sponsor: {name}", { name: server.sponsorName }); + } } else { sponsor.textContent = window.LibreSpeedI18n.t("server.sponsor", "Sponsor: {name}", { name: server.sponsorName }); } diff --git a/index-modern.html b/index-modern.html index 50b3181ba..45037680f 100644 --- a/index-modern.html +++ b/index-modern.html @@ -74,7 +74,7 @@

      Upload