From d5eb87661f85b8ed1a6a04fe0b462ad11f30cf92 Mon Sep 17 00:00:00 2001 From: Rivbla <188387007@qq.com> Date: Wed, 29 Jul 2026 11:39:43 +0800 Subject: [PATCH] feat: add English and Simplified Chinese i18n --- .dockerignore | 9 ++ Dockerfile | 12 ++ I18N.md | 73 ++++++++++ assets/css/app.css | 70 ++++++++-- assets/css/darkmode.css | 5 + assets/images/app.svg | 122 ++++++++-------- assets/js/app-2.5.4.js | 37 ++--- assets/js/i18n.js | 298 ++++++++++++++++++++++++++++++++++++++++ index.html | 30 +++- nginx.conf | 31 +++++ package.json | 10 ++ tests/check-i18n.js | 113 +++++++++++++++ tests/i18n.test.js | 95 +++++++++++++ tests/nginx-test.conf | 9 ++ 14 files changed, 822 insertions(+), 92 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 I18N.md create mode 100644 assets/js/i18n.js create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 tests/check-i18n.js create mode 100644 tests/i18n.test.js create mode 100644 tests/nginx-test.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..57c7cd6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +tests +node_modules +npm-debug.log +Dockerfile +I18N.md +README.md +hosted.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..981cf85 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM nginxinc/nginx-unprivileged:stable-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --chown=101:101 index.html downloading upload License.md /usr/share/nginx/html/ +COPY --chown=101:101 assets /usr/share/nginx/html/assets + +RUN nginx -t + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1 diff --git a/I18N.md b/I18N.md new file mode 100644 index 0000000..6bf036e --- /dev/null +++ b/I18N.md @@ -0,0 +1,73 @@ +# Internationalization + +This branch adds a small, dependency-free internationalization layer while +keeping English as the source and fallback language. + +It is a community-maintained implementation based on the upstream +`openspeedtest/Speed-Test` project; it is not an official OpenSpeedTest release. + +## Supported locales + +- `en` — English, the default and fallback +- `zh-CN` — Simplified Chinese + +If the user has not chosen a language, browsers whose preferred language starts +with `zh` automatically use `zh-CN`; all other browsers use English. A manual +choice is stored in `localStorage` under `openspeedtest.locale`. + +## Translation architecture + +All user-facing strings live in `assets/js/i18n.js`. Markup references keys +using: + +- `data-i18n` for text content +- `data-i18n-title` for HTML `title` attributes +- `data-i18n-aria-label` for accessible names +- `data-i18n-content` for metadata content + +The SVG is loaded as an object and then inlined by the upstream application. +`translateSvg()` runs immediately after inlining, so SVG labels and tooltips use +the same dictionary as HTML and runtime status messages. + +Runtime messages call `OpenSpeedTestI18n.t("key")` from the readable +`assets/js/app-2.5.4.js`. The original minified upstream bundle remains in the +repository for provenance but is not loaded. + +## Adding a locale + +1. Add the locale to `SUPPORTED_LOCALES` and `messages` in + `assets/js/i18n.js`. +2. Update `normalizeLocale()` if browser language aliases are needed. +3. Add an option to `#language-select` in `index.html`. +4. Run `npm test`. +5. If Nginx is installed locally, validate the server block with + `nginx -t -p "$PWD/" -c tests/nginx-test.conf`. +6. Test both desktop and portrait layouts in a browser. + +Every locale must contain the same keys as English. The tests enforce this and +also fail if HTML, SVG, or runtime code references an unknown translation key. + +## Container image + +The included Dockerfile is an original, minimal static deployment based +directly on `nginxinc/nginx-unprivileged`. It does not copy files from the +separate OpenSpeedTest Docker-image wrapper. + +Build and run: + +```sh +docker build -t openspeedtest-i18n:local . +docker run --rm -p 8080:8080 openspeedtest-i18n:local +``` + +The custom Nginx configuration consumes the upload test's complete POST body +before converting the static-file `405` response to `200`, serves the download +payload without caching, disables access logs, and caps request bodies at 40 +MiB. `nginx -t` runs during every image build. + +## License + +The upstream Speed-Test source is distributed under the MIT License. The +original `License.md` is preserved unchanged and copied into the container +image. New internationalization, tests, and container configuration in this +branch are contributed under the same MIT terms. diff --git a/assets/css/app.css b/assets/css/app.css index 39fa939..1c04a0a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -41,6 +41,7 @@ body { margin: 0px; padding: 0px; display: block; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; } ::-webkit-scrollbar { @@ -55,7 +56,7 @@ html { color: rgb(125 119 119); text-align: center; font-size:14px; -font-family: Roboto-Medium, Roboto; +font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; } .Credits a { @@ -66,6 +67,51 @@ font-weight: 500; color: #14b0fe; } +.language-switcher { + position: fixed; + z-index: 1000; + top: 12px; + right: 12px; + top: max(12px, env(safe-area-inset-top)); + right: max(12px, env(safe-area-inset-right)); + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + color: #4d4d4d; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(125, 119, 119, 0.25); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + font-size: 12px; +} + +.language-switcher select { + max-width: 9rem; + color: inherit; + background: transparent; + border: 0; + font: inherit; + cursor: pointer; +} + +.language-switcher select:focus-visible { + outline: 2px solid #14b0fe; + outline-offset: 2px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .ConnectError { display: none; @@ -108,13 +154,13 @@ color: #14b0fe; .oDo-Meter { font-size: 16.633283615112305px; fill: gray; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; } .oDoLive-Speed { font-size: 28px; fill: #201e1e; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; text-anchor: middle; } @@ -122,7 +168,7 @@ color: #14b0fe; .oDoLive-Status { font-size: 10px; fill: #d2d1d2; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; text-anchor: middle; } @@ -146,27 +192,27 @@ color: #14b0fe; .rtext { font-size: 12px; fill: #333; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; } .rtextnum { font-size: 23px; fill: #201e1e; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; text-anchor: middle; } .rtextmbms { font-size: 12px; fill: #5f5f5f; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; text-anchor: middle; } .jitter-Mob { font-size: 9px; fill: #5f5f5f; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; text-anchor: middle; } @@ -180,7 +226,7 @@ color: #14b0fe; .buttonTxt { font-size: 40px; fill: #ffffff; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; text-anchor: middle; } @@ -241,7 +287,7 @@ color: #14b0fe; .oDoTop-Speed { font-size: 16.96px; fill: gray; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; text-anchor: end; } @@ -265,7 +311,7 @@ color: #14b0fe; #ipMob { font-size: 15px; fill: #201e1e; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; text-anchor: middle; display: none; @@ -273,7 +319,7 @@ color: #14b0fe; #ipDesk { font-size: 15px; fill: #201e1e; - font-family: Roboto-Medium, Roboto; + font-family: Roboto-Medium, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; font-weight: 500; text-anchor: middle; display: none; diff --git a/assets/css/darkmode.css b/assets/css/darkmode.css index 14fef30..fb5c883 100644 --- a/assets/css/darkmode.css +++ b/assets/css/darkmode.css @@ -1,6 +1,11 @@ body { background-color: #181818; } +.language-switcher { + color: #f5f5f5; + background: rgba(24, 24, 24, 0.92); + border-color: rgba(255, 255, 255, 0.2); +} #ipDesk { fill: aliceblue; } diff --git a/assets/images/app.svg b/assets/images/app.svg index 01441ac..8f4efb0 100644 --- a/assets/images/app.svg +++ b/assets/images/app.svg @@ -138,12 +138,12 @@ - DOWNLOAD + DOWNLOAD - UPLOAD + UPLOAD @@ -155,27 +155,27 @@ - PING + PING - JITTER + JITTER --- - Mbps + Mbps --- - Mbps + Mbps -- - ms + ms -- - ms + ms -- - Jitter + Jitter -- - ms + ms @@ -203,7 +203,7 @@ - Start + Start @@ -223,7 +223,7 @@ l-7.7,253.3C275.7,323.8,268.9,330.4,260.6,330.4z" /> - Network Error + Network Error @@ -278,19 +278,19 @@ - HTML5 Network Performance Estimation Tool. by OpenSpeedTest™ + HTML5 Network Performance Estimation Tool. by OpenSpeedTest™ - The download speed is how fast you can pull data from the server to you in the form of images, videos, text and more. Activities such as listening to music on Spotify, downloading large files or streaming videos on Netflix all require you to download data. + The download speed is how fast you can pull data from the server to you in the form of images, videos, text and more. Activities such as listening to music on Spotify, downloading large files or streaming videos on Netflix all require you to download data. - The upload speed is how fast you send data from you to others in the form of images, videos, text and more. Activities such as uploading to YouTube or Sending email, playing live games like PubG, Voice and Video calling a friend require fast upload speeds for you to send data to someone else’s server. + The upload speed is how fast you send data from you to others in the form of images, videos, text and more. Activities such as uploading to YouTube or Sending email, playing live games like PubG, Voice and Video calling a friend require fast upload speeds for you to send data to someone else’s server. - Ping "Round-trip time" is more or less well defined as the network delay from point A to B and back. This is the sum of all encoding, queueing, processing, decoding, and propagation delays in both directions. Essentially, it's the delay when A may be expecting an answer from B for a request that requires very little processing. A fast ping means a more responsive connection, especially in applications where timing is everything (like video games). Ping is measured in Milliseconds (ms). Jitter is simply the difference in Ping "Round-trip time" . In other words, jitter is measuring time difference in packet inter-arrival time. Jitter helps diagnose internet connection stability. The higher the jitter value, the worse the stability of the connection. + Ping "Round-trip time" is more or less well defined as the network delay from point A to B and back. This is the sum of all encoding, queueing, processing, decoding, and propagation delays in both directions. Essentially, it's the delay when A may be expecting an answer from B for a request that requires very little processing. A fast ping means a more responsive connection, especially in applications where timing is everything (like video games). Ping is measured in Milliseconds (ms). Jitter is simply the difference in Ping "Round-trip time" . In other words, jitter is measuring time difference in packet inter-arrival time. Jitter helps diagnose internet connection stability. The higher the jitter value, the worse the stability of the connection. @@ -299,38 +299,38 @@ - Download speed is measured in megabits per second (Mbps). + Download speed is measured in megabits per second (Mbps). - Upload speed is measured in megabits per second (Mbps). + Upload speed is measured in megabits per second (Mbps). - Ping is measured in Milliseconds (ms). + Ping is measured in Milliseconds (ms). - Jitter is measured in Milliseconds (ms). + Jitter is measured in Milliseconds (ms). - Shows you real-time network traffic graph for Upload! + Shows you real-time network traffic graph for Upload! - Shows you real-time network traffic graph for Download! + Shows you real-time network traffic graph for Download! - Your download speed! + Your download speed! - Your upload speed! + Your upload speed! - Your ping! + Your ping! - Your jitter! + Your jitter! @@ -340,64 +340,68 @@ - Download speed is measured in megabits per second (Mbps). + Download speed is measured in megabits per second (Mbps). - Upload speed is measured in megabits per second (Mbps). + Upload speed is measured in megabits per second (Mbps). - Ping is measured in Milliseconds (ms). + Ping is measured in Milliseconds (ms). - Jitter is measured in Milliseconds (ms). + Jitter is measured in Milliseconds (ms). - Shows you real-time network traffic graph for Upload! + Shows you real-time network traffic graph for Upload! - Shows you real-time network traffic graph for Download! + Shows you real-time network traffic graph for Download! - Your download speed! + Your download speed! - Your upload speed! + Your upload speed! - Your ping! + Your ping! - Your jitter! + Your jitter! + + + Switch to light mode + + + Switch to dark mode - - - Click here to Run a Speed Test! Or Press "Enter" + Click here to Run a Speed Test! Or Press "Enter" - Options + Options - HTML5 Network Performance Estimation Tool. by OpenSpeedTest™ + HTML5 Network Performance Estimation Tool. by OpenSpeedTest™ - The download speed is how fast you can pull data from the server to you in the form of images, videos, text and more. Activities such as listening to music on Spotify, downloading large files or streaming videos on Netflix all require you to download data. + The download speed is how fast you can pull data from the server to you in the form of images, videos, text and more. Activities such as listening to music on Spotify, downloading large files or streaming videos on Netflix all require you to download data. - The upload speed is how fast you send data from you to others in the form of images, videos, text and more. Activities such as uploading to YouTube or Sending email, playing live games like PubG, Voice and Video calling a friend require fast upload speeds for you to send data to someone else’s server. + The upload speed is how fast you send data from you to others in the form of images, videos, text and more. Activities such as uploading to YouTube or Sending email, playing live games like PubG, Voice and Video calling a friend require fast upload speeds for you to send data to someone else’s server. - Ping "Round-trip time" is more or less well defined as the network delay from point A to B and back. This is the sum of all encoding, queueing, processing, decoding, and propagation delays in both directions. Essentially, it's the delay when A may be expecting an answer from B for a request that requires very little processing. A fast ping means a more responsive connection, especially in applications where timing is everything (like video games). Ping is measured in Milliseconds (ms). Jitter is simply the difference in Ping "Round-trip time" . In other words, jitter is measuring time difference in packet inter-arrival time. Jitter helps diagnose internet connection stability. The higher the jitter value, the worse the stability of the connection. + Ping "Round-trip time" is more or less well defined as the network delay from point A to B and back. This is the sum of all encoding, queueing, processing, decoding, and propagation delays in both directions. Essentially, it's the delay when A may be expecting an answer from B for a request that requires very little processing. A fast ping means a more responsive connection, especially in applications where timing is everything (like video games). Ping is measured in Milliseconds (ms). Jitter is simply the difference in Ping "Round-trip time" . In other words, jitter is measuring time difference in packet inter-arrival time. Jitter helps diagnose internet connection stability. The higher the jitter value, the worse the stability of the connection. @@ -406,43 +410,47 @@ - Download speed is measured in megabits per second (Mbps). + Download speed is measured in megabits per second (Mbps). - Upload speed is measured in megabits per second (Mbps). + Upload speed is measured in megabits per second (Mbps). - Ping is measured in Milliseconds (ms). + Ping is measured in Milliseconds (ms). - Shows you real-time network traffic graph for Download! + Shows you real-time network traffic graph for Download! - Upload speed is measured in megabits per second (Mbps). + Upload speed is measured in megabits per second (Mbps). - Your ping! and Jitter + Your ping! and Jitter - Your upload speed! + Your upload speed! - Your download speed! + Your download speed! - - + + Switch to light mode + + + Switch to dark mode + - Click here to run a speed! + Click here to run a speed! - Options + Options @@ -456,4 +464,4 @@ - \ No newline at end of file + diff --git a/assets/js/app-2.5.4.js b/assets/js/app-2.5.4.js index f5b5b6e..6a5b58f 100644 --- a/assets/js/app-2.5.4.js +++ b/assets/js/app-2.5.4.js @@ -8,11 +8,16 @@ */ window.onload = function() { var appSVG = document.getElementById("OpenSpeedTest-UI"); - appSVG.parentNode.replaceChild(appSVG.contentDocument.documentElement, appSVG); + var appRoot = appSVG.contentDocument.documentElement; + appSVG.parentNode.replaceChild(appRoot, appSVG); + if (window.OpenSpeedTestI18n) { + window.OpenSpeedTestI18n.translateSvg(appRoot); + } ostOnload(); OpenSpeedTest.Start(); }; (function(OpenSpeedTest) { + var I18n = window.OpenSpeedTestI18n; var Status; var ProG; var Callback = function(callback) { @@ -809,7 +814,7 @@ window.onload = function() { var requestIP = false; function ShowIP() { if (requestIP) { - Show.YourIP.el.textContent = "Please wait.."; + Show.YourIP.el.textContent = I18n.t("status.pleaseWait"); ServerConnect(7); requestIP = false; } @@ -825,7 +830,7 @@ window.onload = function() { Show.userInterface(); init = false; var AutoTme = Math.ceil(Math.abs(OpenSpeedTestStart)); - Show.showStatus("Automatic Test Starts in ..."); + Show.showStatus(I18n.t("status.automaticStart")); var autoTest = setInterval(countDownF, 1000); } function countDownF() { @@ -843,7 +848,7 @@ window.onload = function() { } if (openSpeedTestServerList === "fetch" && launch === true) { launch = false; - Show.showStatus("Fetching Server Info.."); + Show.showStatus(I18n.t("status.fetchingServer")); ServerConnect(6); } if (launch === true) { @@ -905,10 +910,10 @@ window.onload = function() { } if (Status === "Ping") { Status = "busy"; - Show.showStatus("Milliseconds"); + Show.showStatus(I18n.t("status.milliseconds")); } if (Status === "Download") { - Show.showStatus("Initializing.."); + Show.showStatus(I18n.t("status.initializing")); Get.reset(); reSett(); Show.reset(); @@ -920,7 +925,7 @@ window.onload = function() { Show.Symbol(0); if (Startit == 0) { Startit = 1; - Show.showStatus("Testing download speed.."); + Show.showStatus(I18n.t("status.testingDownload")); var extraTime = (window.performance.now() - downloadTime) / 1000; dReset = extraTime; Show.progress(1, dlDuration + 2.5); @@ -928,7 +933,7 @@ window.onload = function() { } downloadTimeing = (window.performance.now() - downloadTime) / 1000; reportCurrentSpeed("dl"); - Show.showStatus("Mbps download"); + Show.showStatus(I18n.t("status.mbpsDownload")); Show.mainGaugeProgress(currentSpeed); Show.LiveSpeed(currentSpeed); Show.Graph(currentSpeed, 0); @@ -936,7 +941,7 @@ window.onload = function() { if (downloadTimeing >= dlDuration && ProG == "done") { if (SelectTest) { Show.GaugeProgresstoZero(currentSpeed, "SendR"); - Show.showStatus("All done"); + Show.showStatus(I18n.t("status.done")); Show.Symbol(2); } else { Show.GaugeProgresstoZero(currentSpeed, "Upload"); @@ -953,7 +958,7 @@ window.onload = function() { if (stop === 1) { Show.Symbol(1); Status = "initup"; - Show.showStatus("Initializing.."); + Show.showStatus(I18n.t("status.initializing")); Show.LiveSpeed("...", "speedToZero"); SendData = Get.uRandom(ulDataSize, readyToUP); if (SelectTest) { @@ -964,7 +969,7 @@ window.onload = function() { if (Status === "Uploading") { if (Startit == 1) { Startit = 2; - Show.showStatus("Testing upload speed.."); + Show.showStatus(I18n.t("status.testingUpload")); currentSpeed = 0; Get.reset(); Show.reset(); @@ -973,7 +978,7 @@ window.onload = function() { Show.progress(false, ulDuration + 2.5); ulDuration += extraUTime; } - Show.showStatus("Mbps upload"); + Show.showStatus(I18n.t("status.mbpsUpload")); uploadTimeing = (window.performance.now() - uploadTime) / 1000; reportCurrentSpeed("up"); Show.mainGaugeProgress(currentSpeed); @@ -985,27 +990,27 @@ window.onload = function() { Show.uploadResult(uploadSpeed); Show.GaugeProgresstoZero(currentSpeed, "SendR"); SendData = undefined; - Show.showStatus("All done"); + Show.showStatus(I18n.t("status.done")); Show.Symbol(2); Status = "busy"; stop = 0; } } if (Status === "Error") { - Show.showStatus("Check your network connection status."); + Show.showStatus(I18n.t("status.checkConnection")); Show.ConnectionError(); Status = "busy"; clearInterval(Engine); var dummyElement = document.createElement("div"); dummyElement.innerHTML = ''; var htmlAnchorElement = dummyElement.querySelector("a"); - Show.oDoLiveSpeed.el.textContent = "Network Error"; + Show.oDoLiveSpeed.el.textContent = I18n.t("status.networkError"); var circleSVG = document.getElementById("oDoLiveSpeed"); htmlAnchorElement.innerHTML = circleSVG.innerHTML; circleSVG.innerHTML = dummyElement.innerHTML; } if (Status === "SendR") { - Show.showStatus("All done"); + Show.showStatus(I18n.t("status.done")); var dummyElement = document.createElement("div"); dummyElement.innerHTML = ''; var htmlAnchorElement = dummyElement.querySelector("a"); diff --git a/assets/js/i18n.js b/assets/js/i18n.js new file mode 100644 index 0000000..6ef713d --- /dev/null +++ b/assets/js/i18n.js @@ -0,0 +1,298 @@ +(function (window, document) { + "use strict"; + + var STORAGE_KEY = "openspeedtest.locale"; + var DEFAULT_LOCALE = "en"; + var SUPPORTED_LOCALES = { + en: true, + "zh-CN": true + }; + + /* + * Keep every user-facing translation in this dictionary. English remains + * the source locale and is also the fallback for missing translations. + */ + var messages = { + en: { + "page.title": "SpeedTest by OpenSpeedTest™", + "page.description": "Test your network speed now. HTML5 network performance estimation tool. Self-hosted SpeedTest by OpenSpeedTest™.", + "app.ariaLabel": "OpenSpeedTest network speed test", + "app.loading": "Loading OpenSpeedTest", + "app.objectTitle": "OpenSpeedTest interface", + "language.label": "Language", + "language.en": "English", + "language.zhCN": "简体中文", + "credits.is": " is free and ", + "credits.openSource": "open-source HTML5 network speed test", + "credits.software": " software.", + "credits.copyright": "© Copyright 2013–2024 OpenSpeedTest™. All rights reserved.", + + "label.download": "DOWNLOAD", + "label.upload": "UPLOAD", + "label.ping": "PING", + "label.jitter": "JITTER", + "action.start": "Start", + "action.options": "Options", + "action.switchDark": "Switch to dark mode", + "action.switchLight": "Switch to light mode", + "unit.mbps": "Mbps", + "unit.ms": "ms", + + "status.automaticStart": "Automatic test starts in…", + "status.fetchingServer": "Fetching server info…", + "status.milliseconds": "Milliseconds", + "status.initializing": "Initializing…", + "status.testingDownload": "Testing download speed…", + "status.mbpsDownload": "Mbps download", + "status.testingUpload": "Testing upload speed…", + "status.mbpsUpload": "Mbps upload", + "status.done": "All done", + "status.checkConnection": "Check your network connection status.", + "status.networkError": "Network Error", + "status.pleaseWait": "Please wait…", + + "tooltip.app": "HTML5 network performance estimation tool by OpenSpeedTest™.", + "tooltip.downloadDescription": "Download speed is how quickly this device receives data from the server. Streaming video, loading websites, and downloading files all use download bandwidth.", + "tooltip.uploadDescription": "Upload speed is how quickly this device sends data to the server. Video calls, cloud backups, sending files, and live streaming all use upload bandwidth.", + "tooltip.latencyDescription": "Ping is round-trip network delay, measured in milliseconds. Lower is better. Jitter is the variation between ping samples; lower jitter usually means a more stable connection.", + "tooltip.downloadUnit": "Download speed is measured in megabits per second (Mbps).", + "tooltip.uploadUnit": "Upload speed is measured in megabits per second (Mbps).", + "tooltip.pingUnit": "Ping is measured in milliseconds (ms).", + "tooltip.jitterUnit": "Jitter is measured in milliseconds (ms).", + "tooltip.uploadGraph": "Real-time upload traffic graph.", + "tooltip.downloadGraph": "Real-time download traffic graph.", + "tooltip.downloadResult": "Your download speed.", + "tooltip.uploadResult": "Your upload speed.", + "tooltip.pingResult": "Your ping.", + "tooltip.jitterResult": "Your jitter.", + "tooltip.pingJitterResult": "Your ping and jitter.", + "tooltip.start": "Run a speed test. You can also press Enter.", + "tooltip.options": "Show server information." + }, + + "zh-CN": { + "page.title": "OpenSpeedTest™ 网络测速", + "page.description": "立即测试当前设备与服务器之间的网络速度。基于 HTML5 的自托管网络性能测试工具。", + "app.ariaLabel": "OpenSpeedTest 网络测速", + "app.loading": "正在加载 OpenSpeedTest", + "app.objectTitle": "OpenSpeedTest 测速界面", + "language.label": "语言", + "language.en": "English", + "language.zhCN": "简体中文", + "credits.is": " 是一款免费的 ", + "credits.openSource": "开源 HTML5 网络测速", + "credits.software": "软件。", + "credits.copyright": "© 2013–2024 OpenSpeedTest™。保留所有权利。", + + "label.download": "下载", + "label.upload": "上传", + "label.ping": "延迟", + "label.jitter": "抖动", + "action.start": "开始", + "action.options": "选项", + "action.switchDark": "切换到深色模式", + "action.switchLight": "切换到浅色模式", + "unit.mbps": "Mbps", + "unit.ms": "毫秒", + + "status.automaticStart": "即将自动开始测试…", + "status.fetchingServer": "正在获取服务器信息…", + "status.milliseconds": "毫秒", + "status.initializing": "正在初始化…", + "status.testingDownload": "正在测试下载速度…", + "status.mbpsDownload": "Mbps 下载", + "status.testingUpload": "正在测试上传速度…", + "status.mbpsUpload": "Mbps 上传", + "status.done": "测试完成", + "status.checkConnection": "请检查网络连接状态。", + "status.networkError": "网络错误", + "status.pleaseWait": "请稍候…", + + "tooltip.app": "OpenSpeedTest™ 基于 HTML5 的网络性能测试工具。", + "tooltip.downloadDescription": "下载速度表示当前设备从服务器接收数据的速度。观看视频、打开网页和下载文件都会使用下载带宽。", + "tooltip.uploadDescription": "上传速度表示当前设备向服务器发送数据的速度。视频通话、云备份、发送文件和直播都会使用上传带宽。", + "tooltip.latencyDescription": "延迟(Ping)是数据往返服务器所需的时间,以毫秒计,越低越好。抖动是多次延迟测量之间的波动,数值越低通常表示连接越稳定。", + "tooltip.downloadUnit": "下载速度以兆比特每秒(Mbps)为单位。", + "tooltip.uploadUnit": "上传速度以兆比特每秒(Mbps)为单位。", + "tooltip.pingUnit": "延迟以毫秒(ms)为单位。", + "tooltip.jitterUnit": "抖动以毫秒(ms)为单位。", + "tooltip.uploadGraph": "实时上传流量图。", + "tooltip.downloadGraph": "实时下载流量图。", + "tooltip.downloadResult": "你的下载速度。", + "tooltip.uploadResult": "你的上传速度。", + "tooltip.pingResult": "你的网络延迟。", + "tooltip.jitterResult": "你的网络抖动。", + "tooltip.pingJitterResult": "你的网络延迟和抖动。", + "tooltip.start": "开始网络测速,也可以按 Enter 键。", + "tooltip.options": "显示服务器信息。" + } + }; + + function normalizeLocale(locale) { + var normalized = String(locale || "").replace(/_/g, "-").toLowerCase(); + + if (normalized === "zh" || normalized.indexOf("zh-") === 0) { + return "zh-CN"; + } + + return DEFAULT_LOCALE; + } + + function matchBrowserLocale(locale) { + var normalized = String(locale || "").replace(/_/g, "-").toLowerCase(); + + if (normalized === "zh" || normalized.indexOf("zh-") === 0) { + return "zh-CN"; + } + if (normalized === "en" || normalized.indexOf("en-") === 0) { + return "en"; + } + + return ""; + } + + function readStoredLocale() { + try { + var stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY); + if (stored && SUPPORTED_LOCALES[stored]) { + return stored; + } + } catch (error) { + // Storage can be unavailable in strict privacy modes; browser detection + // still provides a deterministic fallback. + } + + return ""; + } + + function detectLocale() { + var stored = readStoredLocale(); + if (stored) { + return stored; + } + + var browserLanguages = window.navigator.languages; + if (!browserLanguages || !browserLanguages.length) { + browserLanguages = [ + window.navigator.language || window.navigator.userLanguage || DEFAULT_LOCALE + ]; + } + + for (var index = 0; index < browserLanguages.length; index += 1) { + var matchedLocale = matchBrowserLocale(browserLanguages[index]); + if (matchedLocale) { + return matchedLocale; + } + } + + return DEFAULT_LOCALE; + } + + var activeLocale = detectLocale(); + + function translate(key) { + var activeMessages = messages[activeLocale] || messages[DEFAULT_LOCALE]; + return activeMessages[key] || messages[DEFAULT_LOCALE][key] || key; + } + + function translateRoot(root) { + if (!root || !root.querySelectorAll) { + return; + } + + var textNodes = root.querySelectorAll("[data-i18n]"); + var index; + for (index = 0; index < textNodes.length; index += 1) { + textNodes[index].textContent = translate(textNodes[index].getAttribute("data-i18n")); + } + + var titleNodes = root.querySelectorAll("[data-i18n-title]"); + for (index = 0; index < titleNodes.length; index += 1) { + titleNodes[index].setAttribute( + "title", + translate(titleNodes[index].getAttribute("data-i18n-title")) + ); + } + + var ariaNodes = root.querySelectorAll("[data-i18n-aria-label]"); + for (index = 0; index < ariaNodes.length; index += 1) { + ariaNodes[index].setAttribute( + "aria-label", + translate(ariaNodes[index].getAttribute("data-i18n-aria-label")) + ); + } + + var contentNodes = root.querySelectorAll("[data-i18n-content]"); + for (index = 0; index < contentNodes.length; index += 1) { + contentNodes[index].setAttribute( + "content", + translate(contentNodes[index].getAttribute("data-i18n-content")) + ); + } + } + + function bindLocaleSelector() { + var selector = document.getElementById("language-select"); + if (!selector) { + return; + } + + selector.value = activeLocale; + selector.addEventListener("change", function () { + setLocale(selector.value); + }); + } + + function translateDocument() { + if (document.documentElement) { + document.documentElement.setAttribute("lang", activeLocale); + } + translateRoot(document); + bindLocaleSelector(); + } + + function translateSvg(svgRoot) { + if (!svgRoot) { + return; + } + + svgRoot.setAttribute("lang", activeLocale); + svgRoot.setAttribute("role", "group"); + svgRoot.setAttribute("aria-label", translate("app.ariaLabel")); + translateRoot(svgRoot); + } + + function setLocale(locale) { + var nextLocale = normalizeLocale(locale); + if (!SUPPORTED_LOCALES[nextLocale]) { + nextLocale = DEFAULT_LOCALE; + } + + try { + if (window.localStorage) { + window.localStorage.setItem(STORAGE_KEY, nextLocale); + } + } catch (error) { + // A reload still applies browser detection if storage is unavailable. + } + + activeLocale = nextLocale; + window.location.reload(); + } + + window.OpenSpeedTestI18n = { + defaultLocale: DEFAULT_LOCALE, + messages: messages, + normalizeLocale: normalizeLocale, + detectLocale: detectLocale, + getLocale: function () { + return activeLocale; + }, + setLocale: setLocale, + t: translate, + translateDocument: translateDocument, + translateSvg: translateSvg + }; + + translateDocument(); +})(window, document); diff --git a/index.html b/index.html index 2772b6b..8bba208 100644 --- a/index.html +++ b/index.html @@ -1,8 +1,8 @@ - SpeedTest by OpenSpeedTest™ - SpeedTest by OpenSpeedTest™ + @@ -148,19 +148,35 @@ please get in touch with support@openspeedtest.com. --> -
+
+ + +
+ +
+ Loading OpenSpeedTest
- +
- SpeedTest by OpenSpeedTest™ is a Free and Open-Source HTML5 Network Speed Test Software. -

© Copyright 2013-2024 OpenSpeedTest™ All Rights Reserved.

+ SpeedTest by OpenSpeedTest™ is free and open-source HTML5 network speed test software. +

© Copyright 2013–2024 OpenSpeedTest™. All rights reserved.

- + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..d3a9a7d --- /dev/null +++ b/nginx.conf @@ -0,0 +1,31 @@ +server { + listen 8080; + listen [::]:8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + access_log off; + client_max_body_size 40m; + client_body_temp_path /tmp/client_temp; + error_page 405 =200 $uri; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location = /upload { + add_header Cache-Control "no-store, no-cache, max-age=0, no-transform" always; + add_header X-Content-Type-Options "nosniff" always; + try_files /upload =404; + } + + location = /downloading { + add_header Cache-Control "no-store, no-cache, max-age=0, no-transform" always; + try_files /downloading =404; + } + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b13a9c --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "openspeedtest-i18n", + "private": true, + "version": "2.5.4-i18n", + "description": "Static checks for the OpenSpeedTest internationalization layer", + "scripts": { + "test": "node tests/i18n.test.js && node tests/check-i18n.js" + }, + "license": "MIT" +} diff --git a/tests/check-i18n.js b/tests/check-i18n.js new file mode 100644 index 0000000..15de933 --- /dev/null +++ b/tests/check-i18n.js @@ -0,0 +1,113 @@ +"use strict"; + +var assert = require("node:assert"); +var fs = require("node:fs"); +var path = require("node:path"); +var vm = require("node:vm"); + +var root = path.join(__dirname, ".."); +var i18nSource = fs.readFileSync(path.join(root, "assets/js/i18n.js"), "utf8"); +var windowMock = { + navigator: { languages: ["en"] }, + localStorage: { getItem: function () { return null; } }, + location: { reload: function () {} } +}; +var documentMock = { + documentElement: { setAttribute: function () {} }, + getElementById: function () { return null; }, + querySelectorAll: function () { return []; } +}; + +vm.runInNewContext(i18nSource, { + window: windowMock, + document: documentMock +}); + +var messages = windowMock.OpenSpeedTestI18n.messages.en; +var files = [ + path.join(root, "index.html"), + path.join(root, "assets/images/app.svg"), + path.join(root, "assets/js/app-2.5.4.js"), + path.join(root, "assets/js/i18n.js") +]; +var usedKeys = {}; +var keyPatterns = [ + /data-i18n(?:-title|-aria-label|-content)?="([^"]+)"/g, + /\.t\("([^"]+)"\)/g, + /translate\("([^"]+)"\)/g +]; + +files.forEach(function (file) { + var contents = fs.readFileSync(file, "utf8"); + keyPatterns.forEach(function (pattern) { + var match; + while ((match = pattern.exec(contents)) !== null) { + usedKeys[match[1]] = true; + } + }); +}); + +Object.keys(usedKeys).forEach(function (key) { + assert.ok(messages[key], "missing English dictionary key: " + key); + assert.ok( + windowMock.OpenSpeedTestI18n.messages["zh-CN"][key], + "missing Simplified Chinese dictionary key: " + key + ); +}); + +var appSource = fs.readFileSync(path.join(root, "assets/js/app-2.5.4.js"), "utf8"); +assert.ok(!/Show\.showStatus\("[A-Za-z]/.test(appSource), "runtime status bypasses i18n"); +assert.ok( + !/oDoLiveSpeed\.el\.textContent = "Network Error"/.test(appSource), + "network error bypasses i18n" +); + +var indexSource = fs.readFileSync(path.join(root, "index.html"), "utf8"); +assert.ok( + indexSource.indexOf("assets/js/app-2.5.4.js") !== -1, + "index.html must load the maintainable, unminified application source" +); +assert.ok( + indexSource.indexOf("app-2.5.4.min.js") === -1, + "index.html must not load the untranslated upstream minified bundle" +); + +var svgSource = fs.readFileSync(path.join(root, "assets/images/app.svg"), "utf8"); +var untranslatedTitles = svgSource.match(/]*data-i18n)[^>]*>[\s\S]*?<\/title>/g) || []; +assert.deepStrictEqual( + untranslatedTitles, + ["OpenSpeedTest™️"], + "only the OpenSpeedTest brand title may bypass translation" +); + +var untranslatedSvgText = (svgSource.match(/]*data-i18n)[^>]*>[\s\S]*?<\/text>/g) || []) + .filter(function (node) { + var textContent = node.replace(/<[^>]+>/g, ""); + return /[A-Za-z]/.test(textContent) && node.indexOf('id="YourIP"') === -1; + }); +assert.deepStrictEqual( + untranslatedSvgText, + [], + "static alphabetic SVG labels must reference the language dictionary" +); + +var nginxSource = fs.readFileSync(path.join(root, "nginx.conf"), "utf8"); +assert.ok( + /error_page\s+405\s+=200\s+\$uri;/.test(nginxSource), + "upload POST must be consumed before the static 405 response becomes 200" +); +assert.ok( + !/location\s*=\s*\/upload[\s\S]*?return\s+200/.test(nginxSource), + "upload location must not return before consuming the request body" +); + +var dockerfileSource = fs.readFileSync(path.join(root, "Dockerfile"), "utf8"); +assert.ok( + /FROM nginxinc\/nginx-unprivileged:stable-alpine/.test(dockerfileSource), + "container must derive directly from the unprivileged Nginx image" +); +assert.ok(/RUN nginx -t/.test(dockerfileSource), "container build must validate Nginx"); + +console.log( + "i18n static checks passed (" + Object.keys(usedKeys).length + " referenced keys)" +); diff --git a/tests/i18n.test.js b/tests/i18n.test.js new file mode 100644 index 0000000..086585f --- /dev/null +++ b/tests/i18n.test.js @@ -0,0 +1,95 @@ +"use strict"; + +var assert = require("node:assert"); +var fs = require("node:fs"); +var path = require("node:path"); +var vm = require("node:vm"); + +var source = fs.readFileSync( + path.join(__dirname, "..", "assets", "js", "i18n.js"), + "utf8" +); + +function loadI18n(options) { + var storage = options.storage || {}; + var reloadCount = 0; + var documentMock = { + documentElement: { + setAttribute: function () {} + }, + getElementById: function () { + return null; + }, + querySelectorAll: function () { + return []; + } + }; + var windowMock = { + navigator: { + languages: options.languages || [], + language: options.language || "" + }, + localStorage: { + getItem: function (key) { + return storage[key] || null; + }, + setItem: function (key, value) { + storage[key] = value; + } + }, + location: { + reload: function () { + reloadCount += 1; + } + } + }; + + vm.runInNewContext(source, { + window: windowMock, + document: documentMock + }); + + return { + api: windowMock.OpenSpeedTestI18n, + storage: storage, + reloadCount: function () { + return reloadCount; + } + }; +} + +var english = loadI18n({ languages: ["fr-FR"] }); +assert.strictEqual(english.api.getLocale(), "en"); +assert.strictEqual(english.api.t("action.start"), "Start"); + +var simplifiedChinese = loadI18n({ languages: ["zh-CN", "en-US"] }); +assert.strictEqual(simplifiedChinese.api.getLocale(), "zh-CN"); +assert.strictEqual(simplifiedChinese.api.t("action.start"), "开始"); + +var genericChinese = loadI18n({ languages: ["zh-Hans"] }); +assert.strictEqual(genericChinese.api.getLocale(), "zh-CN"); + +var browserPreferenceOrder = loadI18n({ languages: ["en-US", "zh-CN"] }); +assert.strictEqual(browserPreferenceOrder.api.getLocale(), "en"); + +var storedEnglish = loadI18n({ + languages: ["zh-CN"], + storage: { "openspeedtest.locale": "en" } +}); +assert.strictEqual(storedEnglish.api.getLocale(), "en"); + +var manualSwitch = loadI18n({ languages: ["en-US"] }); +manualSwitch.api.setLocale("zh-CN"); +assert.strictEqual(manualSwitch.storage["openspeedtest.locale"], "zh-CN"); +assert.strictEqual(manualSwitch.reloadCount(), 1); + +assert.strictEqual( + simplifiedChinese.api.t("missing.translation.key"), + "missing.translation.key" +); + +var englishKeys = Object.keys(english.api.messages.en).sort(); +var chineseKeys = Object.keys(english.api.messages["zh-CN"]).sort(); +assert.deepStrictEqual(chineseKeys, englishKeys); + +console.log("i18n behavior tests passed (" + englishKeys.length + " keys)"); diff --git a/tests/nginx-test.conf b/tests/nginx-test.conf new file mode 100644 index 0000000..363bc4e --- /dev/null +++ b/tests/nginx-test.conf @@ -0,0 +1,9 @@ +pid /tmp/openspeedtest-i18n-nginx-test.pid; +error_log stderr notice; + +events {} + +http { + default_type application/octet-stream; + include nginx.conf; +}