Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
91 changes: 44 additions & 47 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,145 +8,142 @@
/>
<meta
name="description"
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."
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"
/>
<link rel="shortcut icon" href="images/favicon.svg" />
<script type="text/javascript" src="speedtest.js"></script>
<script type="text/javascript" src="javascript/index.js"></script>
<script defer type="text/javascript" src="javascript/i18n.js"></script>
<script defer type="text/javascript" src="javascript/index.js"></script>
<link rel="stylesheet" type="text/css" href="styling/index.css" />
<title>LibreSpeed - Free and Open Source Speedtest</title>
<title data-i18n="page.title">LibreSpeed - Free and Open Source Speedtest</title>
</head>

<body>
<header>
<img src="images/logo.svg" alt="LibreSpeed" />
</header>
<main>
<h1>Free and Open Source Speedtest.</h1>
<p class="tagline">No Flash, No Java, No Websockets, No Bullsh*t</p>
<h1 data-i18n="heading">Free and Open Source Speedtest.</h1>
<p class="tagline" data-i18n="tagline">No Flash, No Java, No Websockets, No Bullsh*t</p>

<div class="server-selector">
<div class="chosen">
<div class="chevron">
<img src="images/chevron.svg" alt="select..." />
</div>
<p>current server</p>
<h2 id="selected-server">searching nearest server...</h2>
<p data-i18n="server.current">current server</p>
<h2 id="selected-server" data-i18n="server.searching">searching nearest server...</h2>
</div>
<ul class="servers"></ul>
<p class="sponsor" id="sponsor">&nbsp;</p>
</div>

<p id="privacy-warning" class="hidden">
by clicking the start button you agree to our privacy policy<br />
<a href="#" id="choose-privacy">or choose your privacy options</a>
<span data-i18n="privacy.warning">by clicking the start button you agree to our privacy policy</span><br />
<a href="#" id="choose-privacy"><span data-i18n="privacy.choose">or choose your privacy options</span></a>
</p>
<button class="disabled" id="start-button"></button>

<div class="gauge-layout">
<div class="ping hidden">
<span class="label">Ping</span>:&nbsp;
<span class="label" data-i18n="metric.ping">Ping</span>:&nbsp;
<span class="value" id="ping">00</span>ms
</div>

<div class="gauge download" id="download-gauge">
<div class="progress"></div>
<div class="speed"></div>
<h1><span id="download-speed">00</span> Mbps</h1>
<h2>Download</h2>
<h2 data-i18n="metric.download">Download</h2>
</div>

<div class="gauge upload" id="upload-gauge">
<div class="progress"></div>
<div class="speed"></div>
<h1><span id="upload-speed">00</span> Mbps</h1>
<h2>Upload</h2>
<h2 data-i18n="metric.upload">Upload</h2>
</div>

<div class="jitter hidden">
<span class="label">Jitter</span>:&nbsp;
<span class="label" data-i18n="metric.jitter">Jitter</span>:&nbsp;
<span class="value" id="jitter">00</span>ms
</div>
</div>

<button class="small inverted hidden" id="share-results">
Share results
<span data-i18n="share.results">Share results</span>
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
</button>
</main>
<footer>
<p class="source">
<a href="stability.html">stability test</a> |
<a href="https://github.com/librespeed/speedtest">source code</a>
<a href="stability.html" data-i18n="footer.stability">stability test</a> |
<a href="https://github.com/librespeed/speedtest"> <span data-i18n="footer.source">source code</span></a>
</p>
</footer>

<dialog id="share">
<div class="close-dialog">
<img src="images/close-button.svg" alt="Close" />
<img src="images/close-button.svg" alt="Close" data-i18n-attr="alt:dialog.close" />
</div>
<img id="results" src="" alt="Test results in graphical form" />
<button id="copy-link">Copy link</button>
<img id="results" src="" alt="Test results in graphical form" data-i18n-attr="alt:dialog.results-alt" />
<button id="copy-link"><span data-i18n="share.copy">Copy link</span></button>
</dialog>

<dialog id="privacy">
<div class="close-dialog">
<img src="images/close-button.svg" alt="Close" />
<img src="images/close-button.svg" alt="Close" data-i18n-attr="alt:dialog.close" />
</div>
<section>
<h1>Privacy Policy</h1>
<h1 data-i18n="privacy.title">Privacy Policy</h1>
<p>
This HTML5 speed test server is configured with telemetry enabled.
<span data-i18n="privacy.telemetry">This HTML5 speed test server is configured with telemetry enabled.</span>
</p>

<h2>What data we collect</h2>
<h2 data-i18n="privacy.collect-title">What data we collect</h2>
<p>
At the end of the test, the following data is collected and stored:
<span data-i18n="privacy.collect-intro">At the end of the test, the following data is collected and stored:</span>
</p>

<ul>
<li>Test ID</li>
<li>Time of testing</li>
<li>Test results (download and upload speed, ping and jitter)</li>
<li>IP address</li>
<li>ISP information</li>
<li>Approximate location (inferred from IP address, not GPS)</li>
<li>User agent and browser locale</li>
<li>Test log (contains no personal information)</li>
<li data-i18n="privacy.item.test-id">Test ID</li>
<li data-i18n="privacy.item.time">Time of testing</li>
<li data-i18n="privacy.item.results">Test results (download and upload speed, ping and jitter)</li>
<li data-i18n="privacy.item.ip">IP address</li>
<li data-i18n="privacy.item.isp">ISP information</li>
<li data-i18n="privacy.item.location">Approximate location (inferred from IP address, not GPS)</li>
<li data-i18n="privacy.item.locale">User agent and browser locale</li>
<li data-i18n="privacy.item.log">Test log (contains no personal information)</li>
</ul>

<h2>How we use the data</h2>
<p>Data collected through this service is used to:</p>
<h2 data-i18n="privacy.use-title">How we use the data</h2>
<p data-i18n="privacy.use-intro">Data collected through this service is used to:</p>

<ul>
<li>
Allow sharing of test results (sharable image for forums, etc.)
<span data-i18n="privacy.use.share">Allow sharing of test results (sharable image for forums, etc.)</span>
</li>
<li>
To improve the service offered to you (for instance, to detect
problems on our side)
<span data-i18n="privacy.use.improve">To improve the service offered to you (for instance, to detect problems on our side)</span>
</li>
</ul>

<p>No personal information is disclosed to third parties.</p>
<p data-i18n="privacy.no-disclosure">No personal information is disclosed to third parties.</p>

<h2>Your consent</h2>
<h2 data-i18n="privacy.consent-title">Your consent</h2>
<p>
By starting the test, you consent to the terms of this privacy policy.
<span data-i18n="privacy.consent">By starting the test, you consent to the terms of this privacy policy.</span>
</p>

<h2>Data removal</h2>
<h2 data-i18n="privacy.removal-title">Data removal</h2>
<p>
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.
<span data-i18n="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.</span>
</p>
<p>
Contact this email address for all deletion requests:
<span data-i18n="privacy.contact">Contact this email address for all deletion requests:</span>
<a href="mailto:PUT@YOUR_EMAIL.HERE">TO BE FILLED BY DEVELOPER</a>.
</p>
</section>
<button id="close-privacy">Close</button>
<button id="close-privacy"><span data-i18n="dialog.close">Close</span></button>
</dialog>
</body>
</html>
78 changes: 78 additions & 0 deletions frontend/javascript/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
(() => {
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() {
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
);
}

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();
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();
})
.then((catalog) => {
messages = catalog;
document.documentElement.lang = language;
translateDocument();
})
.catch((error) => {
console.warn("LibreSpeed localization unavailable:", error);
document.documentElement.lang = DEFAULT_LANGUAGE;
})
.finally(() => clearTimeout(timeout));

window.LibreSpeedI18n = { language, ready, t: translate };
})();
40 changes: 30 additions & 10 deletions frontend/javascript/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const testState = {

// Bootstrap the application when the DOM is ready
window.addEventListener("DOMContentLoaded", async () => {
await window.LibreSpeedI18n.ready;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
createSpeedtest();
hookUpButtons();
startRenderingLoop();
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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.")
);
}
});
Expand Down Expand Up @@ -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
Expand All @@ -358,9 +359,28 @@ function startRenderingLoop() {
selectedServer.textContent = server.name;
if (server.sponsorName) {
if (server.sponsorURL) {
sponsor.innerHTML = `Sponsor: <a href="${server.sponsorURL}">${server.sponsorName}</a>`;
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 = `Sponsor: ${server.sponsorName}`;
sponsor.textContent = window.LibreSpeedI18n.t("server.sponsor", "Sponsor: {name}", { name: server.sponsorName });
}
} else {
sponsor.innerHTML = "&nbsp;";
Expand Down Expand Up @@ -442,7 +462,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);

Expand Down
Loading