From e9669927b1c433f18e45dc66dae8a21274e84c7f Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Mon, 31 Aug 2026 16:32:40 +0200 Subject: [PATCH] playground: allow setting the locale with a ?lang= URL parameter A shared link can now carry the language alongside the command, e.g. /playground?lang=fr-FR&cmd=cut+-f+1,4-2,9-12+fruits.txt to show a localized error message. The value accepts both the short (fr) and full (fr-FR) form, is validated against the locales the build ships, and is applied before the ?cmd= commands run. The share-link button appends it when the locale is not the default, and the Language dropdown stays in sync via a new uutils:locale-changed event. --- content/playground-how-it-works.md | 5 ++- content/playground.md | 9 +++++ static/js/playground.js | 26 +++++++++++-- static/js/wasm-terminal.js | 60 ++++++++++++++++++++++++++++-- 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/content/playground-how-it-works.md b/content/playground-how-it-works.md index 0b8533b89..284186599 100644 --- a/content/playground-how-it-works.md +++ b/content/playground-how-it-works.md @@ -119,7 +119,10 @@ flowchart TB G --> I["uutils.wasm
(compileStreaming)"] H --> J["Ready"] I --> J - J --> K{"?cmd= parameter?"} + J --> K0{"?lang= parameter?"} + K0 -->|Yes| K1["Set locale (LANG=…)"] + K1 --> K{"?cmd= parameter?"} + K0 -->|No| K K -->|Yes| L["Auto-run commands"] K -->|No| M["Show prompt"] diff --git a/content/playground.md b/content/playground.md index b0e21f200..8e14c3ce6 100644 --- a/content/playground.md +++ b/content/playground.md @@ -112,6 +112,15 @@ Multiple commands can be run in sequence, separated either by `;` on one line or - [`?cmd=echo hello%0Aecho world`](/playground?cmd=echo%20hello%0Aecho%20world) - run two commands in sequence - [`?cmd=updatedb; locate names`](/playground?cmd=updatedb%3B%20locate%20names) - build the locate database, then search it +Add `?lang=` to run the command in another language - it sets the locale the +same way the **Language** dropdown does, before the command runs. Both the full +form (`fr-FR`) and the short form (`fr`) work, and an unknown language is +ignored. The share-link button includes it automatically when the locale isn't +the default: + +- [`?lang=fr-FR&cmd=cut -f 1,4-2,9-12 fruits.txt`](/playground?lang=fr-FR&cmd=cut%20-f%201%2C4-2%2C9-12%20fruits.txt) - a French error message +- [`?lang=de&cmd=ls /nope`](/playground?lang=de&cmd=ls%20%2Fnope) - a German error message + ## Available commands The following commands run as **real Rust coreutils compiled to WebAssembly**: diff --git a/static/js/playground.js b/static/js/playground.js index 57ebcaa73..daa0d1177 100644 --- a/static/js/playground.js +++ b/static/js/playground.js @@ -74,6 +74,9 @@ document.addEventListener("DOMContentLoaded", function() { var url = new URL(window.location.href); url.search = ""; url.hash = ""; + // Only carry the locale when it isn't the default, to keep links short. + var locale = window.getLocale ? window.getLocale() : ""; + if (locale && locale !== "en-US") url.searchParams.set("lang", locale); url.searchParams.set("cmd", cmd); return url.toString(); }; @@ -103,17 +106,34 @@ document.addEventListener("DOMContentLoaded", function() { } // Populate the locale dropdown from the build-generated list - if (typeof WASM_LOCALES !== "undefined") { - var sel = document.getElementById("locale-select"); + var localeSelect = document.getElementById("locale-select"); + if (typeof WASM_LOCALES !== "undefined" && localeSelect) { WASM_LOCALES.forEach(function(loc) { if (loc === "en-US") return; // already the default option var opt = document.createElement("option"); opt.value = loc; opt.textContent = loc; - sel.appendChild(opt); + localeSelect.appendChild(opt); }); } + // Keep the dropdown in sync when the locale is set elsewhere: the `locale` + // builtin, or the ?lang= URL parameter on load. + document.addEventListener("uutils:locale-changed", function(e) { + if (!localeSelect || !e.detail) return; + var loc = e.detail.locale; + var known = Array.prototype.some.call(localeSelect.options, function(o) { + return o.value === loc; + }); + if (!known) { + var opt = document.createElement("option"); + opt.value = loc; + opt.textContent = loc; + localeSelect.appendChild(opt); + } + localeSelect.value = loc; + }); + // Populate the "Available commands" list from the build-generated list if (typeof WASM_COMMANDS !== "undefined" && Array.isArray(WASM_COMMANDS)) { var listEl = document.getElementById("wasm-commands-list"); diff --git a/static/js/wasm-terminal.js b/static/js/wasm-terminal.js index 995301313..ac9d9e24e 100644 --- a/static/js/wasm-terminal.js +++ b/static/js/wasm-terminal.js @@ -563,6 +563,37 @@ function sanitizeUrlCommand(raw) { return cmd; } +/** + * Normalize a locale name: "fr" -> "fr-FR", "en" -> "en-US", full forms as-is. + */ +function normalizeLocale(raw) { + const arg = (raw || "").trim(); + if (!arg) return ""; + return arg.includes("-") ? arg : LOCALE_SHORTCUTS[arg.toLowerCase()] || arg; +} + +/** + * Sanitize a locale coming from the ?lang= URL parameter. + * + * Accepts either a shortcut ("fr") or a full locale ("fr-FR"), and only + * returns a value the build actually ships (WASM_LOCALES, when available) so + * a bogus ?lang= cannot push an arbitrary string into the LANG environment + * variable handed to the WASM runtime. Returns "" when unusable. + */ +function sanitizeUrlLocale(raw) { + const locale = normalizeLocale(raw); + if (!locale || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})?$/.test(locale)) return ""; + const available = + (typeof WASM_LOCALES !== "undefined" && Array.isArray(WASM_LOCALES) && WASM_LOCALES.length > 0) + ? WASM_LOCALES + : null; + if (available) { + const match = available.find(l => l.toLowerCase() === locale.toLowerCase()); + return match || ""; + } + return locale; +} + /** * Read a file from the virtual filesystem. Returns its content as a string, * or null if not found. @@ -698,9 +729,8 @@ async function executeSingleCommandLine(line) { if (!arg) { return `LANG=${currentLocale}.UTF-8\n`; } - // Normalize: "fr" -> "fr-FR", "en" -> "en-US", or accept full form - const normalized = arg.includes("-") ? arg : LOCALE_SHORTCUTS[arg.toLowerCase()] || arg; - currentLocale = normalized; + currentLocale = normalizeLocale(arg); + notifyLocaleChanged(); return `Locale set to ${currentLocale}\n`; } @@ -1112,8 +1142,18 @@ async function initPlayground(containerId) { terminal.writeln("Try reloading the page."); } + // Apply the locale from the URL ?lang= parameter before running ?cmd=, so a + // shared link like ?lang=fr-FR&cmd=... shows the localized output. + const params = new URLSearchParams(window.location.search); + const urlLocale = sanitizeUrlLocale(params.get("lang")); + if (urlLocale) { + currentLocale = urlLocale; + notifyLocaleChanged(); + terminal.writeln(`Locale set to ${currentLocale}`); + } + // Run command(s) from URL ?cmd= parameter if present - const urlCmd = sanitizeUrlCommand(new URLSearchParams(window.location.search).get("cmd")); + const urlCmd = sanitizeUrlCommand(params.get("cmd")); if (urlCmd) { for (const cmd of urlCmd.split("\n")) { if (cmd.trim()) await runInTerminal(cmd.trim()); @@ -1137,11 +1177,22 @@ async function runInTerminal(cmd) { prompt(); } +/** + * Let the page chrome (locale dropdown, share link) know the locale changed, + * whichever way it was set: dropdown, `locale` builtin or ?lang= URL param. + */ +function notifyLocaleChanged() { + document.dispatchEvent(new CustomEvent("uutils:locale-changed", { + detail: { locale: currentLocale }, + })); +} + /** * Set the locale and optionally update the terminal. */ function setLocale(locale) { currentLocale = locale; + notifyLocaleChanged(); if (terminal) { terminal.writeln(`\r\nLocale set to ${currentLocale}`); prompt(); @@ -1154,6 +1205,7 @@ window.uutilsExecute = executeCommandLine; window.runInTerminal = runInTerminal; window.setLocale = setLocale; window.getLastCommand = () => lastCommand; +window.getLocale = () => currentLocale; // On-demand loading of the optional standalone modules, used by the "Load" // buttons on the playground page. Buttons operate on groups (see