From 5c22a5b3d68a979d8925dac504da022855f8d520 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 3 Apr 2026 20:59:37 +0200 Subject: [PATCH] Add interactive WASM playground for uutils coreutils A browser-based terminal that runs real Rust coreutils compiled to WebAssembly (wasm32-wasip1) using @bjorn3/browser_wasi_shim. Features: - xterm.js terminal with command history and line editing - Pipe support (cmd1 | cmd2 | cmd3), file redirects (>) - Persistent virtual filesystem across commands - cd/pwd builtins with virtual working directory - Locale support (locale builtin + selector) - UTF-8/multibyte correct output (raw ConsoleStdout, fixed args_sizes_get) - Click-to-run example buttons - Inline wasm_example shortcode for blog posts --- content/playground.md | 90 +++ static/js/wasm-example.js | 50 ++ static/js/wasm-terminal.js | 794 +++++++++++++++++++++++++ static/style.css | 127 ++++ templates/base.html | 1 + templates/project.html | 1 + templates/shortcodes/wasm_example.html | 10 + 7 files changed, 1073 insertions(+) create mode 100644 content/playground.md create mode 100644 static/js/wasm-example.js create mode 100644 static/js/wasm-terminal.js create mode 100644 templates/shortcodes/wasm_example.html 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
{{ 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 }}
+ +
+ +
+