diff --git a/content/playground.md b/content/playground.md
new file mode 100644
index 000000000..50d3c4678
--- /dev/null
+++ b/content/playground.md
@@ -0,0 +1,90 @@
++++
+title = "Playground"
+template = "page.html"
++++
+
+
Try uutils coreutils directly in your browser! This interactive terminal runs Rust coreutils via WebAssembly — no installation needed.
+
+
+
+
+
+
+
+
+
+
+## Try it out
+
+Click an example to run it in the terminal:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Available commands
+
+The following commands run as **real Rust coreutils compiled to WebAssembly**:
+
+`arch` `b2sum` `base32` `base64` `basenc` `basename` `cat` `cksum`
+`comm` `cp` `csplit` `cut` `date` `dir` `dircolors` `dirname`
+`echo` `expand` `expr` `factor` `false` `fmt` `fold` `head`
+`join` `link` `ln` `ls` `md5sum` `mkdir` `mv` `nl` `nproc` `numfmt`
+`od` `paste` `pathchk` `printenv` `printf` `pr` `ptx` `pwd`
+`readlink` `realpath` `rm` `rmdir`
+`seq` `sort` `split` `tail` `touch` `tr` `tsort`
+`sha1sum` `sha224sum` `sha256sum` `sha384sum` `sha512sum`
+`shred` `shuf` `sleep` `sum` `tee` `true` `truncate`
+`uname` `unexpand` `uniq` `unlink` `vdir` `wc`
+
+The following are **shell builtins** implemented in JavaScript:
+
+- `help` — list available commands and examples
+- `clear` — clear the terminal screen
+
+Some commands are not yet available in the WASM build because they
+depend on platform-specific syscalls not fully supported by
+WebAssembly/WASI.
+`chcon` and `runcon` are excluded as they require
+SELinux, which is not relevant in a browser environment.
+We are actively working on adding more commands as we improve WASI
+compatibility in uutils coreutils.
diff --git a/static/js/wasm-example.js b/static/js/wasm-example.js
new file mode 100644
index 000000000..99861a450
--- /dev/null
+++ b/static/js/wasm-example.js
@@ -0,0 +1,50 @@
+/**
+ * Inline "Run" button handler for uutils code examples.
+ * Lightweight alternative to the full terminal playground.
+ */
+
+(function () {
+ let runtimeLoaded = false;
+
+ async function ensureRuntime() {
+ if (runtimeLoaded) return;
+ // Load the terminal runtime which provides uutilsExecute
+ const script = document.createElement("script");
+ script.src = "/js/wasm-terminal.js";
+ await new Promise((resolve, reject) => {
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ runtimeLoaded = true;
+ }
+
+ async function runExample(button) {
+ const container = button.closest(".wasm-example");
+ if (!container) return;
+
+ const command = container.dataset.command;
+ const outputEl = container.querySelector(".wasm-example-output");
+ const preEl = outputEl.querySelector("pre");
+
+ // Show loading state
+ button.disabled = true;
+ button.textContent = "Running...";
+ outputEl.style.display = "block";
+ preEl.textContent = "Loading...";
+
+ try {
+ await ensureRuntime();
+ const output = await window.uutilsExecute(command);
+ preEl.textContent = output || "(no output)";
+ } catch (e) {
+ preEl.textContent = "Error: " + e.message;
+ }
+
+ button.disabled = false;
+ button.textContent = "Run";
+ }
+
+ // Expose globally
+ window.runWasmExample = runExample;
+})();
diff --git a/static/js/wasm-terminal.js b/static/js/wasm-terminal.js
new file mode 100644
index 000000000..6495bd0d6
--- /dev/null
+++ b/static/js/wasm-terminal.js
@@ -0,0 +1,794 @@
+/**
+ * WASM Terminal Playground for uutils coreutils.
+ *
+ * Lazy-loads xterm.js and @bjorn3/browser_wasi_shim, then provides an
+ * interactive terminal where visitors can run uutils commands in the browser.
+ */
+
+// The WASI shim checks `instanceof SharedArrayBuffer` in random_get.
+// Without cross-origin isolation headers, SharedArrayBuffer is undefined
+// in most browsers. Define a stub so the instanceof check returns false
+// instead of throwing a ReferenceError.
+if (typeof SharedArrayBuffer === "undefined") {
+ globalThis.SharedArrayBuffer = /** @type {any} */ (function () {});
+}
+
+const WASM_URL = "/wasm/uutils.wasm";
+const XTERM_CSS = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css";
+const XTERM_CSS_INTEGRITY = "sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co";
+const XTERM_JS = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js";
+const XTERM_JS_INTEGRITY = "sha384-J4qzUjBl1FxyLsl/kQPQIOeINsmp17OHYXDOMpMxlKX53ZfYsL+aWHpgArvOuof9";
+const XTERM_FIT_JS = "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/lib/addon-fit.min.js";
+const XTERM_FIT_JS_INTEGRITY = "sha384-UwMkGaBqfOcrTjPjXdAPWrGQkhpxTJ21vKtTwLb6wBpBM8HQXKAiUuwVJfgY0Yw6";
+const WASI_SHIM_URL = "https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.4.0/+esm";
+
+// Sample files for the virtual filesystem
+const SAMPLE_FILES = {
+ "names.txt": "Alice\nBob\nCharlie\nDiana\nEve\nFrank\nGrace\nHeidi\n",
+ "numbers.txt": "42\n7\n13\n99\n1\n56\n23\n8\n100\n3\n",
+ "fruits.txt": "banana\napple\ncherry\ndate\napple\nbanana\ncherry\napple\n",
+ "csv.txt": "name,age,city\nAlice,30,Paris\nBob,25,London\nCharlie,35,Berlin\nDiana,28,Tokyo\n",
+ "words.txt": "hello world\nfoo bar baz\nthe quick brown fox\njumps over the lazy dog\n",
+};
+
+// Commands available in the feat_wasm build
+const AVAILABLE_COMMANDS = [
+ "arch", "b2sum", "base32", "base64", "basenc", "basename", "cat", "cksum",
+ "comm", "cp", "csplit", "cut", "date", "dir", "dircolors", "dirname",
+ "echo", "expand", "expr", "factor", "false", "fmt", "fold", "head",
+ "join", "link", "ln", "ls", "md5sum", "mkdir", "mv", "nl", "numfmt",
+ "nproc", "od", "paste", "pathchk", "printenv", "printf", "pr", "ptx", "pwd",
+ "readlink", "realpath", "rm", "rmdir",
+ "seq", "sort", "split", "tail", "touch", "tr", "tsort",
+ "sha1sum", "sha224sum", "sha256sum", "sha384sum", "sha512sum",
+ "shred", "shuf", "sleep", "sum", "tee", "true", "truncate",
+ "uname", "unexpand", "uniq", "unlink", "vdir", "wc",
+];
+
+// Shortcut mappings for locale command (e.g. "fr" -> "fr-FR")
+const LOCALE_SHORTCUTS = {
+ en: "en-US", fr: "fr-FR", de: "de-DE", es: "es-ES", it: "it-IT",
+ pt: "pt-BR", ja: "ja-JP", zh: "zh-CN", ko: "ko-KR", ru: "ru-RU",
+ pl: "pl-PL", nl: "nl-NL", sv: "sv-SE", da: "da-DK", fi: "fi-FI",
+ nb: "nb-NO", uk: "uk-UA", cs: "cs-CZ", ro: "ro-RO", hu: "hu-HU",
+};
+
+let wasmModule = null;
+let wasiShim = null;
+let terminal = null;
+let inputBuffer = "";
+let cursorPos = 0;
+let history = [];
+let historyIndex = -1;
+let wasmReady = false;
+let persistentDir = null;
+let cwd = ""; // virtual current working directory (relative to preopened root)
+let currentLocale = "en-US"; // current locale for l10n
+
+function loadScript(src, integrity) {
+ return new Promise((resolve, reject) => {
+ if (document.querySelector(`script[src="${src}"]`)) {
+ resolve();
+ return;
+ }
+ const s = document.createElement("script");
+ s.src = src;
+ if (integrity) {
+ s.integrity = integrity;
+ s.crossOrigin = "anonymous";
+ }
+ s.onload = resolve;
+ s.onerror = reject;
+ document.head.appendChild(s);
+ });
+}
+
+function loadCSS(href, integrity) {
+ if (document.querySelector(`link[href="${href}"]`)) return;
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ if (integrity) {
+ link.integrity = integrity;
+ link.crossOrigin = "anonymous";
+ }
+ link.href = href;
+ document.head.appendChild(link);
+}
+
+async function loadWasiShim() {
+ if (wasiShim) return wasiShim;
+ wasiShim = await import(WASI_SHIM_URL);
+ return wasiShim;
+}
+
+async function loadWasm() {
+ if (wasmModule) return wasmModule;
+ const response = await fetch(WASM_URL);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch WASM binary: ${response.status}`);
+ }
+ // compileStreaming requires application/wasm content-type; fall back if not set
+ try {
+ if (WebAssembly.compileStreaming) {
+ wasmModule = await WebAssembly.compileStreaming(response);
+ } else {
+ wasmModule = await WebAssembly.compile(await response.arrayBuffer());
+ }
+ } catch (e) {
+ // Some servers don't set proper MIME type, retry with arrayBuffer
+ console.warn("WASM compileStreaming failed, falling back to arrayBuffer:", e.message);
+ const buf = await fetch(WASM_URL).then(r => r.arrayBuffer());
+ wasmModule = await WebAssembly.compile(buf);
+ }
+ return wasmModule;
+}
+
+async function initWasm() {
+ if (wasmReady) return;
+ try {
+ await Promise.all([loadWasiShim(), loadWasm()]);
+ wasmReady = true;
+ } catch (e) {
+ // Will fall back to JS implementations
+ console.warn("WASM init failed:", e);
+ }
+}
+
+/**
+ * Get or create the persistent virtual filesystem directory.
+ * This directory is shared across all command invocations so that
+ * files created by one command (e.g. mkdir, cp) are visible to the next.
+ */
+function getPersistentDir() {
+ if (persistentDir) return persistentDir;
+ const encoder = new TextEncoder();
+ const fileEntries = Object.entries(SAMPLE_FILES).map(
+ ([name, content]) => [name, new wasiShim.File(encoder.encode(content))]
+ );
+ persistentDir = new wasiShim.PreopenDirectory(".", fileEntries);
+ return persistentDir;
+}
+
+/**
+ * Resolve a path relative to cwd. Returns a normalized path relative to the
+ * preopened root directory.
+ */
+function resolvePath(p) {
+ if (!p || p.startsWith("-")) return p;
+ // Absolute paths (starting with /) stay as-is — WASI preopened dir is "."
+ // so absolute paths won't resolve anyway, but don't mangle them.
+ if (p.startsWith("/")) return p;
+ const base = cwd ? cwd.split("/") : [];
+ const parts = [...base, ...p.split("/")];
+ const resolved = [];
+ for (const part of parts) {
+ if (part === "." || part === "") continue;
+ if (part === "..") { resolved.pop(); continue; }
+ resolved.push(part);
+ }
+ return resolved.join("/") || ".";
+}
+
+/**
+ * Look up a directory in the virtual filesystem.
+ * Returns the Directory object if found, or null.
+ */
+function lookupDir(path) {
+ if (!wasiShim) return null;
+ const dir = getPersistentDir();
+ if (!path || path === ".") return dir.dir;
+ const parts = path.split("/").filter(Boolean);
+ let current = dir.dir; // the Directory inside PreopenDirectory
+ for (const part of parts) {
+ if (part === ".") continue;
+ if (part === "..") return null; // can't go above root
+ const entry = current.contents.get(part);
+ if (!entry || !entry.contents) return null; // not a directory
+ current = entry;
+ }
+ return current;
+}
+
+/**
+ * Run a single uutils command via the WASM module using browser_wasi_shim.
+ * Returns { stdout: string, stderr: string, exitCode: number }
+ */
+async function runCommand(argv, stdinData = "") {
+ if (!wasmReady) throw new Error("WASM not loaded");
+
+ const encoder = new TextEncoder();
+
+ // Collect raw bytes for stdout/stderr to avoid mid-character UTF-8 splits.
+ // ConsoleStdout.lineBuffered uses TextDecoder in streaming mode which can
+ // split multibyte UTF-8 characters across fd_write calls. Instead, we
+ // use ConsoleStdout with a raw byte callback and decode once at the end.
+ let stdoutBytes = [];
+ let stderrBytes = [];
+
+ const stdoutFd = new wasiShim.ConsoleStdout((bytes) => {
+ stdoutBytes.push(new Uint8Array(bytes));
+ });
+
+ const stderrFd = new wasiShim.ConsoleStdout((bytes) => {
+ stderrBytes.push(new Uint8Array(bytes));
+ });
+
+ // stdin as an OpenFile wrapping a File
+ const stdinFile = new wasiShim.OpenFile(
+ new wasiShim.File(encoder.encode(stdinData))
+ );
+
+ const preopenDir = getPersistentDir();
+
+ const fds = [
+ stdinFile, // fd 0: stdin
+ stdoutFd, // fd 1: stdout
+ stderrFd, // fd 2: stderr
+ preopenDir, // fd 3: preopened directory
+ ];
+
+ const env = [`LANG=${currentLocale}.UTF-8`, "TERM=xterm-256color"];
+ const wasi = new wasiShim.WASI(argv, env, fds);
+
+ // Workaround for https://github.com/bjorn3/browser_wasi_shim/issues/109
+ // @bjorn3/browser_wasi_shim args_sizes_get and environ_sizes_get use
+ // JavaScript's String.length (UTF-16 code units) to report the buffer
+ // size needed for argv/environ strings. However, WASI expects UTF-8
+ // byte lengths. For ASCII this is identical, but for multibyte characters
+ // (CJK, emoji, Arabic, etc.) the UTF-8 encoding is longer than the
+ // UTF-16 code unit count, causing the WASM runtime to allocate a buffer
+ // that is too small. The result is truncated arguments and garbled output
+ // (e.g. "こんにちは世界" → "こんにち�3").
+ // Fix: override both functions to use TextEncoder for correct byte counts.
+ wasi.wasiImport.args_sizes_get = function(argcPtr, argvBufSizePtr) {
+ const mem = () => new DataView(wasi.inst.exports.memory.buffer);
+ mem().setUint32(argcPtr, argv.length, true);
+ let bufSize = 0;
+ for (const arg of argv) bufSize += encoder.encode(arg).length + 1;
+ mem().setUint32(argvBufSizePtr, bufSize, true);
+ return 0;
+ };
+
+ wasi.wasiImport.environ_sizes_get = function(countPtr, sizePtr) {
+ const mem = () => new DataView(wasi.inst.exports.memory.buffer);
+ mem().setUint32(countPtr, env.length, true);
+ let bufSize = 0;
+ for (const e of env) bufSize += encoder.encode(e).length + 1;
+ mem().setUint32(sizePtr, bufSize, true);
+ return 0;
+ };
+
+ let exitCode = 0;
+ try {
+ const result = await WebAssembly.instantiate(wasmModule, {
+ wasi_snapshot_preview1: wasi.wasiImport,
+ });
+ // instantiate(Module) returns Instance; instantiate(Buffer) returns {instance}
+ const instance = result.instance || result;
+ wasi.start(instance);
+ } catch (e) {
+ if (e instanceof wasiShim.WASIProcExit) {
+ exitCode = e.code;
+ } else {
+ return { stdout: "", stderr: e.message + "\n", exitCode: 1 };
+ }
+ }
+
+ const decoder = new TextDecoder();
+ const concatBytes = (chunks) => {
+ const total = chunks.reduce((s, c) => s + c.length, 0);
+ const buf = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) { buf.set(chunk, offset); offset += chunk.length; }
+ return decoder.decode(buf);
+ };
+
+ return {
+ stdout: concatBytes(stdoutBytes),
+ stderr: concatBytes(stderrBytes),
+ exitCode,
+ };
+}
+
+/**
+ * Parse a command line into a pipeline of commands.
+ * Supports simple pipes: cmd1 | cmd2 | cmd3
+ * Pipes inside quotes are treated as literal characters.
+ */
+function parseCommandLine(line) {
+ const pipeline = [[]];
+ let current = "";
+ let inSingle = false;
+ let inDouble = false;
+ let escape = false;
+
+ for (const ch of line) {
+ if (escape) {
+ current += ch;
+ escape = false;
+ continue;
+ }
+ if (ch === "\\" && !inSingle) {
+ escape = true;
+ continue;
+ }
+ if (ch === "'" && !inDouble) {
+ inSingle = !inSingle;
+ continue;
+ }
+ if (ch === '"' && !inSingle) {
+ inDouble = !inDouble;
+ continue;
+ }
+ if (ch === "|" && !inSingle && !inDouble) {
+ if (current) { pipeline[pipeline.length - 1].push(current); current = ""; }
+ pipeline.push([]);
+ continue;
+ }
+ if (ch === " " && !inSingle && !inDouble) {
+ if (current) { pipeline[pipeline.length - 1].push(current); current = ""; }
+ continue;
+ }
+ current += ch;
+ }
+ if (current) pipeline[pipeline.length - 1].push(current);
+ return pipeline;
+}
+
+/**
+ * Execute a full command line, handling pipes and builtins.
+ */
+async function executeCommandLine(line) {
+ line = line.trim();
+ if (!line) return "";
+
+ // Builtin: help
+ if (line === "help") {
+ return (
+ "Available commands:\n" +
+ AVAILABLE_COMMANDS.join(", ") +
+ "\n\nBuiltins: help, clear, cd, locale\n" +
+ "Sample files: names.txt, numbers.txt, fruits.txt, csv.txt, words.txt\n" +
+ "\nExamples:\n" +
+ " echo 'hello world'\n" +
+ " cat numbers.txt\n" +
+ " echo '5 3 1 4 2' | fmt -w1 | sort -n\n" +
+ " wc -l fruits.txt\n" +
+ " seq 1 10 | factor\n" +
+ " basename /usr/local/bin/rustc\n" +
+ " date\n" +
+ " uname -a\n"
+ );
+ }
+
+ // Builtin: clear
+ if (line === "clear") {
+ if (terminal) terminal.clear();
+ return "";
+ }
+
+ // Builtin: cd
+ if (line === "cd" || line.startsWith("cd ")) {
+ const target = line === "cd" ? "" : line.slice(3).trim();
+ if (!target || target === "/" || target === "~") {
+ cwd = "";
+ return "";
+ }
+ const resolved = resolvePath(target);
+ if (resolved === ".") {
+ cwd = "";
+ return "";
+ }
+ const dir = lookupDir(resolved);
+ if (!dir) {
+ return `cd: ${target}: No such directory\n`;
+ }
+ cwd = resolved;
+ return "";
+ }
+
+ // Builtin: locale — show or set the current locale
+ if (line === "locale" || line.startsWith("locale ")) {
+ const arg = line === "locale" ? "" : line.slice(7).trim();
+ 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;
+ return `Locale set to ${currentLocale}\n`;
+ }
+
+ // Builtin: pwd (return virtual cwd)
+ if (line === "pwd") {
+ return "/" + cwd + "\n";
+ }
+
+ const pipeline = parseCommandLine(line);
+ let stdinData = "";
+
+ for (const args of pipeline) {
+ if (args.length === 0) continue;
+
+ const cmd = args[0];
+
+ // Try WASM execution for commands in the WASM build
+ if (wasmReady && AVAILABLE_COMMANDS.includes(cmd)) {
+ try {
+ // Resolve relative paths using the virtual cwd
+ const resolvedArgs = args.map((arg, i) => {
+ if (i === 0) return arg; // command name
+ if (arg.startsWith("-")) return arg; // flag
+ return resolvePath(arg);
+ });
+ // If the command takes a default path (like ls) and no path args
+ // were given, add the cwd so it lists the right directory
+ const hasPathArg = resolvedArgs.slice(1).some(a => !a.startsWith("-"));
+ if (!hasPathArg && cwd && ["ls", "dir"].includes(cmd)) {
+ resolvedArgs.push(cwd);
+ }
+ const wasmArgs = ["coreutils", ...resolvedArgs];
+ const result = await runCommand(wasmArgs, stdinData);
+ if (result.stderr) {
+ return result.stderr + result.stdout;
+ }
+ stdinData = result.stdout;
+ continue;
+ } catch (e) {
+ return `Error running '${cmd}': ${e.message}\n`;
+ }
+ }
+
+ // JS fallback for commands not in the WASM build or when WASM isn't ready
+ const result = jsFallback(args, stdinData);
+ if (result !== null) {
+ stdinData = result;
+ continue;
+ }
+
+ return `uutils: command not found: ${cmd}\nType 'help' for available commands.\n`;
+ }
+
+ return stdinData;
+}
+
+/**
+ * JS fallback implementations for when WASM is not loaded.
+ */
+function jsFallback(args, stdinData) {
+ const cmd = args[0];
+ const fileArgs = args.slice(1).filter((a) => !a.startsWith("-"));
+ const flags = args.slice(1).filter((a) => a.startsWith("-"));
+
+ let input = stdinData;
+ for (const f of fileArgs) {
+ if (SAMPLE_FILES[f]) input += SAMPLE_FILES[f];
+ }
+
+ switch (cmd) {
+ case "ls": {
+ if (flags.includes("--version")) {
+ return "ls: not available in WASM build (requires platform-specific syscalls)\nUse 'ls' to list sample files, or try 'echo --version' to verify WASM is working.\n";
+ }
+ return Object.keys(SAMPLE_FILES).join(" ") + "\n";
+ }
+
+ case "cat": {
+ const files = args.slice(1).filter((a) => !a.startsWith("-"));
+ let out = stdinData;
+ for (const f of files) {
+ if (SAMPLE_FILES[f]) out += SAMPLE_FILES[f];
+ else return `cat: ${f}: No such file or directory\n`;
+ }
+ return out || stdinData;
+ }
+
+ case "echo":
+ return args.slice(1).join(" ") + "\n";
+
+ case "seq": {
+ const nums = fileArgs.map(Number);
+ let start = 1, end = 1, step = 1;
+ if (nums.length === 1) end = nums[0];
+ else if (nums.length === 2) { start = nums[0]; end = nums[1]; }
+ else if (nums.length === 3) { start = nums[0]; step = nums[1]; end = nums[2]; }
+ const result = [];
+ for (let i = start; step > 0 ? i <= end : i >= end; i += step) result.push(String(i));
+ return result.join("\n") + "\n";
+ }
+
+ case "factor": {
+ const lines = input ? input.trimEnd().split("\n") : fileArgs;
+ return lines.map((n) => {
+ n = parseInt(n.toString().trim());
+ if (isNaN(n) || n < 2) return `${n}:`;
+ const factors = [];
+ let x = n;
+ for (let d = 2; d * d <= x; d++) {
+ while (x % d === 0) { factors.push(d); x /= d; }
+ }
+ if (x > 1) factors.push(x);
+ return `${n}: ${factors.join(" ")}`;
+ }).join("\n") + "\n";
+ }
+
+ case "basename": {
+ if (fileArgs.length > 0) {
+ const parts = fileArgs[0].split("/");
+ return parts[parts.length - 1] + "\n";
+ }
+ return "\n";
+ }
+
+ case "wc": {
+ const lines = input.trimEnd().split("\n");
+ const allFlags = flags.join(" ");
+ const showL = allFlags.includes("-l");
+ const showW = allFlags.includes("-w");
+ const showC = allFlags.includes("-c");
+ const showAll = !showL && !showW && !showC;
+ const parts = [];
+ if (showL || showAll) parts.push(lines.length);
+ if (showW || showAll) parts.push(input.trim().split(/\s+/).length);
+ if (showC || showAll) parts.push(new TextEncoder().encode(input).length);
+ return (showAll ? " " : "") + parts.join(" ") + "\n";
+ }
+
+ default:
+ if (AVAILABLE_COMMANDS.includes(cmd)) {
+ return `(WASM binary loading... JS fallback: '${cmd}' not yet implemented)\n`;
+ }
+ return null;
+ }
+}
+
+function writeToTerminal(text) {
+ if (!terminal) return;
+ const lines = text.split("\n");
+ for (let i = 0; i < lines.length; i++) {
+ terminal.write(lines[i]);
+ if (i < lines.length - 1) terminal.write("\r\n");
+ }
+}
+
+function promptStr() {
+ const dir = cwd ? `\x1b[1;34m${cwd}\x1b[0m ` : "";
+ return `\x1b[1;38;5;166muutils\x1b[0m ${dir}\x1b[1;38;5;166m$\x1b[0m `;
+}
+
+function promptLen() {
+ // Visible character count (without ANSI escapes) for cursor positioning
+ const dir = cwd ? `${cwd} ` : "";
+ return `uutils ${dir}$ `.length;
+}
+
+function prompt() {
+ if (!terminal) return;
+ terminal.write("\r\n" + promptStr());
+}
+
+function redrawInput() {
+ terminal.write("\r" + promptStr() + inputBuffer + "\x1b[K");
+ const moveBack = inputBuffer.length - cursorPos;
+ if (moveBack > 0) terminal.write(`\x1b[${moveBack}D`);
+}
+
+async function handleInput(data) {
+ for (let i = 0; i < data.length; i++) {
+ const ch = data[i];
+ const code = ch.charCodeAt(0);
+
+ // Handle escape sequences
+ if (ch === "\x1b" && i + 2 < data.length) {
+ if (data[i + 1] === "[") {
+ const seq = data[i + 2];
+ if (seq === "A") { // Up arrow
+ if (history.length > 0 && historyIndex < history.length - 1) {
+ historyIndex++;
+ inputBuffer = history[history.length - 1 - historyIndex];
+ cursorPos = inputBuffer.length;
+ redrawInput();
+ }
+ i += 2; continue;
+ }
+ if (seq === "B") { // Down arrow
+ if (historyIndex > 0) {
+ historyIndex--;
+ inputBuffer = history[history.length - 1 - historyIndex];
+ cursorPos = inputBuffer.length;
+ redrawInput();
+ } else if (historyIndex === 0) {
+ historyIndex = -1;
+ inputBuffer = "";
+ cursorPos = 0;
+ redrawInput();
+ }
+ i += 2; continue;
+ }
+ if (seq === "C") { // Right arrow
+ if (cursorPos < inputBuffer.length) { cursorPos++; terminal.write("\x1b[C"); }
+ i += 2; continue;
+ }
+ if (seq === "D") { // Left arrow
+ if (cursorPos > 0) { cursorPos--; terminal.write("\x1b[D"); }
+ i += 2; continue;
+ }
+ }
+ continue;
+ }
+
+ if (code === 13) { // Enter
+ terminal.write("\r\n");
+ const line = inputBuffer.trim();
+ if (line) {
+ history.push(line);
+ historyIndex = -1;
+ const output = await executeCommandLine(line);
+ if (output) writeToTerminal(output);
+ }
+ inputBuffer = "";
+ cursorPos = 0;
+ prompt();
+ continue;
+ }
+
+ if (code === 127 || code === 8) { // Backspace
+ if (cursorPos > 0) {
+ inputBuffer = inputBuffer.slice(0, cursorPos - 1) + inputBuffer.slice(cursorPos);
+ cursorPos--;
+ redrawInput();
+ }
+ continue;
+ }
+
+ if (code === 3) { // Ctrl+C
+ inputBuffer = "";
+ cursorPos = 0;
+ terminal.write("^C");
+ prompt();
+ continue;
+ }
+
+ if (code === 12) { // Ctrl+L
+ terminal.clear();
+ prompt();
+ terminal.write(inputBuffer);
+ continue;
+ }
+
+ if (code === 21) { // Ctrl+U
+ inputBuffer = "";
+ cursorPos = 0;
+ redrawInput();
+ continue;
+ }
+
+ if (code >= 32) { // Regular character
+ inputBuffer = inputBuffer.slice(0, cursorPos) + ch + inputBuffer.slice(cursorPos);
+ cursorPos++;
+ redrawInput();
+ }
+ }
+}
+
+/**
+ * Initialize the playground terminal in the given container element.
+ */
+async function initPlayground(containerId) {
+ const container = document.getElementById(containerId);
+ if (!container) return;
+
+ container.innerHTML = 'Loading terminal...
';
+
+ loadCSS(XTERM_CSS, XTERM_CSS_INTEGRITY);
+ await loadScript(XTERM_JS, XTERM_JS_INTEGRITY);
+ await loadScript(XTERM_FIT_JS, XTERM_FIT_JS_INTEGRITY);
+
+ container.innerHTML = "";
+
+ terminal = new window.Terminal({
+ cursorBlink: true,
+ fontSize: 14,
+ fontFamily: '"Fira Code", "Cascadia Code", Menlo, Monaco, monospace',
+ theme: {
+ background: "#1e1e2e",
+ foreground: "#cdd6f4",
+ cursor: "#f5e0dc",
+ selectionBackground: "#585b7066",
+ black: "#45475a",
+ red: "#f38ba8",
+ green: "#a6e3a1",
+ yellow: "#f9e2af",
+ blue: "#89b4fa",
+ magenta: "#f5c2e7",
+ cyan: "#94e2d5",
+ white: "#bac2de",
+ },
+ rows: 24,
+ cols: 80,
+ });
+
+ const fitAddon = new window.FitAddon.FitAddon();
+ terminal.loadAddon(fitAddon);
+ terminal.open(container);
+ fitAddon.fit();
+
+ window.addEventListener("resize", () => fitAddon.fit());
+
+ terminal.writeln("\x1b[1;38;5;166m _ _ _ _ _ _ _\x1b[0m");
+ terminal.writeln("\x1b[1;38;5;166m | | | | | | | |_(_) |___\x1b[0m");
+ terminal.writeln("\x1b[1;38;5;166m | |_| | |_| | _| | (_-<\x1b[0m");
+ terminal.writeln("\x1b[1;38;5;166m \\___/ \\___/ \\__|_|_/__/\x1b[0m");
+ terminal.writeln("");
+ terminal.writeln("Welcome to the \x1b[1muutils coreutils\x1b[0m playground!");
+ terminal.writeln("Run Rust coreutils directly in your browser via WebAssembly.");
+ terminal.writeln("");
+ terminal.write("Loading WASM binary...");
+
+ terminal.onData(handleInput);
+
+ // Load WASM in background
+ try {
+ await initWasm();
+ terminal.writeln(" \x1b[1;32mdone!\x1b[0m");
+ terminal.writeln("");
+ terminal.writeln("Type \x1b[1;32mhelp\x1b[0m for available commands.");
+ terminal.writeln("Sample data files: names.txt, numbers.txt, fruits.txt, csv.txt, words.txt");
+ } catch (e) {
+ terminal.writeln(" \x1b[1;31mfailed\x1b[0m");
+ terminal.writeln("Using JS fallback. Some commands may be limited.");
+ terminal.writeln("");
+ terminal.writeln("Type \x1b[1;32mhelp\x1b[0m for available commands.");
+ }
+
+ prompt();
+}
+
+/**
+ * Run a command in the terminal, displaying it as if the user typed it.
+ */
+async function runInTerminal(cmd) {
+ if (!terminal) return;
+ // Show the command on the prompt line
+ terminal.write(cmd);
+ terminal.write("\r\n");
+ const output = await executeCommandLine(cmd);
+ if (output) writeToTerminal(output);
+ prompt();
+}
+
+/**
+ * Set the locale and optionally update the terminal.
+ */
+function setLocale(locale) {
+ currentLocale = locale;
+ if (terminal) {
+ terminal.writeln(`\r\nLocale set to ${currentLocale}`);
+ prompt();
+ }
+}
+
+// Expose globally
+window.initPlayground = initPlayground;
+window.uutilsExecute = executeCommandLine;
+window.runInTerminal = runInTerminal;
+window.setLocale = setLocale;
+
+// Expose internals for testing
+window._uutilsTestInternals = {
+ parseCommandLine,
+ jsFallback,
+ executeCommandLine,
+ resolvePath,
+ lookupDir,
+ getPersistentDir,
+ get cwd() { return cwd; },
+ set cwd(v) { cwd = v; },
+ get locale() { return currentLocale; },
+ set locale(v) { currentLocale = v; },
+ get wasmReady() { return wasmReady; },
+ initWasm,
+ LOCALE_SHORTCUTS,
+ SAMPLE_FILES,
+ AVAILABLE_COMMANDS,
+};
diff --git a/static/style.css b/static/style.css
index 81e198b4b..7eb88d63d 100644
--- a/static/style.css
+++ b/static/style.css
@@ -519,3 +519,130 @@ main img {
background-color: #c04828;
border-color: #c04828;
}
+
+/* WASM PLAYGROUND */
+.playground-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ margin: 1em 0 0;
+ font-size: 0.9em;
+}
+
+.playground-toolbar select {
+ padding: 0.3em 0.5em;
+ border-radius: 0.3em;
+ border: 1px solid var(--header-border-color);
+ background: var(--highlight-bg-color);
+ color: var(--highlight-fg-color);
+ font-size: 1em;
+ cursor: pointer;
+}
+
+#wasm-playground {
+ width: 100%;
+ min-height: 480px;
+ border-radius: 0.5em;
+ overflow: hidden;
+ margin: 1.5em 0;
+ border: 1px solid var(--header-border-color);
+}
+
+#wasm-playground .wasm-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 480px;
+ background: #1e1e2e;
+ color: #cdd6f4;
+ font-family: monospace;
+ font-size: 1.1em;
+}
+
+/* INLINE WASM EXAMPLES */
+.wasm-example {
+ margin: 1em 0;
+}
+
+.wasm-example-code {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+}
+
+.wasm-example-code pre {
+ flex: 1;
+ padding: 0.8em 1em;
+ background: var(--highlight-bg-color);
+ color: var(--highlight-fg-color);
+ border-radius: 0.3em 0 0 0.3em;
+ font-family: "Fira Code", monospace;
+ font-size: 0.9em;
+ overflow-x: auto;
+ margin: 0;
+}
+
+.wasm-run-btn {
+ padding: 0.8em 1.5em;
+ background: var(--accent-color);
+ color: white;
+ border: none;
+ border-radius: 0 0.3em 0.3em 0;
+ cursor: pointer;
+ font-size: 0.9em;
+ font-weight: 600;
+ transition: background-color 0.2s;
+ white-space: nowrap;
+}
+
+.wasm-run-btn:hover {
+ background: #d45530;
+}
+
+.wasm-run-btn:disabled {
+ opacity: 0.6;
+ cursor: wait;
+}
+
+.wasm-example-output {
+ margin-top: 0;
+}
+
+.wasm-example-output pre {
+ padding: 0.8em 1em;
+ background: #1e1e2e;
+ color: #cdd6f4;
+ border-radius: 0 0 0.3em 0.3em;
+ font-family: "Fira Code", monospace;
+ font-size: 0.9em;
+ overflow-x: auto;
+ max-height: 300px;
+ overflow-y: auto;
+ margin: 0;
+}
+
+/* PLAYGROUND CLICKABLE EXAMPLES */
+.playground-examples {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5em;
+ margin: 1em 0 2em;
+}
+
+.playground-example {
+ padding: 0.4em 0.8em;
+ background: var(--highlight-bg-color);
+ color: var(--highlight-fg-color);
+ border: 1px solid var(--header-border-color);
+ border-radius: 0.3em;
+ font-family: "Fira Code", monospace;
+ font-size: 0.85em;
+ cursor: pointer;
+ transition: border-color 0.2s, background-color 0.2s;
+}
+
+.playground-example:hover {
+ border-color: var(--accent-color);
+ background: var(--accent-color);
+ color: white;
+}
diff --git a/templates/base.html b/templates/base.html
index 3be3f4f07..b9a62756f 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -43,6 +43,7 @@
Bsdutils
+ Playground
diff --git a/templates/project.html b/templates/project.html
index 73885b587..04df311c2 100644
--- a/templates/project.html
+++ b/templates/project.html
@@ -18,6 +18,7 @@
Documentation
{%- endif %}
Releases
+ {% if page.title == "coreutils" %}
Try it!{% endif %}
{{ page.content | safe }}
{%- endblock main -%}
diff --git a/templates/shortcodes/wasm_example.html b/templates/shortcodes/wasm_example.html
new file mode 100644
index 000000000..2217c012a
--- /dev/null
+++ b/templates/shortcodes/wasm_example.html
@@ -0,0 +1,10 @@
+
+
+
$ {{ command }}
+
+
+
+
+