Kyle Martin
Computer Engineer
\n" + clone.outerHTML,
"text/html;charset=utf-8"
);
};
window.elykInputFileText = function (input) {
const file = input?.files?.[0];
return file ? file.text() : Promise.resolve(null);
};
window.elykDroppedFileText = function (event) {
const file = event?.dataTransfer?.files?.[0];
return file ? file.text() : Promise.resolve(null);
};
const ELYK_DEFAULT_WORD_ASSETS = {
version: "sha256-1b18b0b6b7a726a1-22e675f15226a908",
wordlist: "/wordlist.json.gz",
dictionary: "/dictionary.json.gz",
};
let _wordAssetManifest = null;
let _wordList = null;
const _fullDictionaries = new Map();
const _compressedAssetLoads = new Map();
let _dictionaryStatus = {
state: "idle",
loaded: 0,
total: 0,
percent: null,
};
async function _elykWordAssets() {
if (!_wordAssetManifest) {
_wordAssetManifest = fetch("/word-assets.json", { cache: "no-cache" })
.then((response) => response.ok ? response.json() : ELYK_DEFAULT_WORD_ASSETS)
.then((manifest) => ({
version: typeof manifest.version === "string" ? manifest.version : ELYK_DEFAULT_WORD_ASSETS.version,
wordlist: typeof manifest.wordlist === "string" ? manifest.wordlist : ELYK_DEFAULT_WORD_ASSETS.wordlist,
dictionary: typeof manifest.dictionary === "string" ? manifest.dictionary : ELYK_DEFAULT_WORD_ASSETS.dictionary,
}))
.catch((err) => {
console.warn("[assets] could not load word asset manifest:", err);
return ELYK_DEFAULT_WORD_ASSETS;
});
}
return _wordAssetManifest;
}
function _elykVersionedAssetUrl(url, version) {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}v=${encodeURIComponent(version)}`;
}
async function _elykAssetInfo(assetName) {
const assets = await _elykWordAssets();
const url = assets[assetName];
return {
url,
fetchUrl: _elykVersionedAssetUrl(url, assets.version),
loadKey: `${assets.version}:${url}`,
};
}
async function _elykFetchGzipBytes(url, label, status) {
const response = await fetch(url, { cache: "force-cache" });
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const total = Number(response.headers.get("content-length")) || 0;
if (status && response.body && total > 0) {
const reader = response.body.getReader();
const chunks = [];
let loaded = 0;
status.state = "loading";
status.loaded = 0;
status.total = total;
status.percent = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
status.loaded = loaded;
status.percent = Math.min(99, Math.floor((loaded / total) * 100));
}
return new Blob(chunks).arrayBuffer();
}
return response.arrayBuffer();
}
async function _elykInflateGzipText(compressed, label, status) {
if (!("DecompressionStream" in window)) {
throw new Error(`This browser cannot decompress the ${label}.`);
}
const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream("gzip"));
const text = await new Response(stream).text();
if (status) {
status.state = "ready";
status.loaded = status.total || status.loaded;
status.percent = 100;
}
return text;
}
async function _elykLoadCompressedGzipAsset(assetName, label, status) {
const info = await _elykAssetInfo(assetName);
if (_compressedAssetLoads.has(info.loadKey)) {
if (status) {
status.state = "loading";
status.loaded = 0;
status.total = 0;
status.percent = null;
}
return _compressedAssetLoads.get(info.loadKey);
}
const load = _elykFetchGzipBytes(info.fetchUrl, label, status)
.finally(() => {
_compressedAssetLoads.delete(info.loadKey);
});
_compressedAssetLoads.set(info.loadKey, load);
return load;
}
async function _elykLoadCachedGzipText(assetName, label, status) {
const compressed = await _elykLoadCompressedGzipAsset(assetName, label, status);
return _elykInflateGzipText(compressed, label, status);
}
window.elykLoadWordList = function () {
if (!_wordList) {
_wordList = _elykLoadCachedGzipText("wordlist", "word list")
.catch((err) => {
_wordList = null;
throw err;
});
}
return _wordList;
};
function _elykLoadDictionary(assetName, label) {
if (!_fullDictionaries.has(assetName)) {
_dictionaryStatus = { state: "idle", loaded: 0, total: 0, percent: null };
const load = _elykLoadCachedGzipText(assetName, label, _dictionaryStatus)
.catch((err) => {
_fullDictionaries.delete(assetName);
_dictionaryStatus.state = "failed";
throw err;
});
_fullDictionaries.set(assetName, load);
}
return _fullDictionaries.get(assetName);
}
window.elykLoadTextropolisDictionary = function () {
return _elykLoadDictionary("dictionary", "dictionary");
};
window.elykLoadWordHuntDictionary = function () {
return _elykLoadDictionary("dictionary", "dictionary");
};
window.elykDictionaryLoadStatus = function () {
return JSON.stringify(_dictionaryStatus);
};
(() => {
let dragging = false;
let mode = null;
let lastButton = null;
const letterButtonFromPoint = (x, y) => {
const element = document.elementFromPoint(x, y);
const button = element?.closest?.(".textropolis-letters [data-letter-index]");
return button instanceof HTMLButtonElement ? button : null;
};
const begin = (event, inputMode, x, y) => {
const button = letterButtonFromPoint(x, y);
if (!button) return;
event.preventDefault();
event.stopPropagation();
dragging = true;
mode = inputMode;
lastButton = null;
button.closest(".textropolis-game")?.focus();
pick(button);
};
const move = (event, inputMode, x, y) => {
if (!dragging || mode !== inputMode) return;
event.preventDefault();
event.stopPropagation();
pick(letterButtonFromPoint(x, y));
};
const end = (inputMode) => {
if (mode !== inputMode) return;
dragging = false;
mode = null;
lastButton = null;
};
const pick = (button) => {
if (!button || button === lastButton) return;
lastButton = button;
button.click();
};
document.addEventListener("pointerdown", (event) => {
begin(event, "pointer", event.clientX, event.clientY);
}, { capture: true, passive: false });
document.addEventListener("pointermove", (event) => {
move(event, "pointer", event.clientX, event.clientY);
}, { capture: true, passive: false });
document.addEventListener("pointerup", () => end("pointer"), { capture: true });
document.addEventListener("pointercancel", () => end("pointer"), { capture: true });
document.addEventListener("touchstart", (event) => {
if (mode === "pointer") return;
const touch = event.touches[0];
if (touch) begin(event, "touch", touch.clientX, touch.clientY);
}, { capture: true, passive: false });
document.addEventListener("touchmove", (event) => {
if (mode === "pointer") return;
const touch = event.touches[0];
if (touch) move(event, "touch", touch.clientX, touch.clientY);
}, { capture: true, passive: false });
document.addEventListener("touchend", () => end("touch"), { capture: true });
document.addEventListener("touchcancel", () => end("touch"), { capture: true });
})();
// Pyodide is large and async; the Rust side talks to it only through the
// functions below so the JS-interop surface stays small.
const PYODIDE_SCRIPT_URL = "https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js";
let _pyodide = null;
let _pyodideLoading = null;
let _pyodideScriptLoading = null;
function _elykLoadPyodideScript() {
if (window.loadPyodide) return Promise.resolve();
if (!_pyodideScriptLoading) {
_pyodideScriptLoading = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = PYODIDE_SCRIPT_URL;
script.async = true;
script.onload = () => resolve();
script.onerror = () => {
_pyodideScriptLoading = null;
reject(new Error("Could not load Pyodide."));
};
document.head.appendChild(script);
});
}
return _pyodideScriptLoading;
}
window.elykPyodideReady = function () {
return _pyodide !== null;
};
window.elykEnsurePyodide = function () {
if (_pyodide) return Promise.resolve(_pyodide);
if (!_pyodideLoading) {
console.log("[py] Pyodide loading starting…");
_pyodideLoading = _elykLoadPyodideScript()
.then(() => loadPyodide())
.then((py) => {
// Packages live in the visible terminal filesystem, not hidden in
// Pyodide's runtime directories.
py.runPython(`
import os, sys, io
os.makedirs("/python/site-packages", exist_ok=True)
if "/python/site-packages" not in sys.path:
sys.path.insert(0, "/python/site-packages")
# Output capture replaces sys.stdout/stdin with a StringIO; some packages (e.g.
# fortune) call sys.stdout.reconfigure() at import time, which StringIO lacks.
# Expose a forgiving capture buffer on the io module for all bridge snippets.
class _ElykCapture(io.StringIO):
encoding = "utf-8"
def reconfigure(self, *a, **k):
pass
io.ElykCapture = _ElykCapture
`);
console.log("[py] Pyodide ready:", py.version);
_pyodide = py;
return py;
})
.catch((err) => {
console.error("[py] loadPyodide() failed:", err);
_pyodideLoading = null;
throw err;
});
}
return _pyodideLoading;
};
// The terminal-visible parts of Pyodide's filesystem. Everything else
// (/lib, /usr, /tmp, …) is CPython/system state.
const ELYK_HOME = "/root";
const ELYK_MIRROR_ROOTS = ["/root", "/python"];
// Apply a batch of filesystem operations (computed in Rust by diffing the
// overlay against the mirror) to Pyodide's FS, then chdir into `cwd` so
// relative paths in Python resolve like the terminal's working directory.
window.elykFsApply = async function (opsJson) {
const py = await window.elykEnsurePyodide();
const FS = py.FS;
const ops = JSON.parse(opsJson);
const mkdirp = (dir) => {
const parts = dir.split("/").filter(Boolean);
let cur = "";
for (const part of parts) {
cur += "/" + part;
try {
FS.mkdir(cur);
} catch (e) {
/* already exists */
}
}
};
for (const dir of ops.mkdirs || []) mkdirp(dir);
for (const [path, content] of ops.writes || []) {
mkdirp(path.slice(0, path.lastIndexOf("/")) || "/");
FS.writeFile(path, content);
}
for (const path of ops.unlinks || []) {
try {
FS.unlink(path);
} catch (e) {
/* already gone */
}
}
for (const path of ops.rmdirs || []) {
try {
FS.rmdir(path);
} catch (e) {
/* missing or non-empty */
}
}
if (ops.cwd) {
mkdirp(ops.cwd);
try {
FS.chdir(ops.cwd);
} catch (e) {
/* not a directory */
}
}
};
// Install packages, then relocate whatever new top-level entries landed
// in Pyodide's real site-packages into /python/site-packages, which is
// first on sys.path and mirrored into the terminal filesystem. Returns
// JSON {ok, output}. Network failures and missing wheels surface in output.
window.elykPipInstall = async function (pkgsJson) {
const py = await window.elykEnsurePyodide();
await py.loadPackage("micropip");
const ns = py.toPy({ pkgs_json: pkgsJson });
try {
return await py.runPythonAsync(`
import micropip, os, shutil, importlib, sysconfig, json, io, contextlib, traceback
pkgs = json.loads(pkgs_json)
default = sysconfig.get_path("purelib")
target = "/python/site-packages"
os.makedirs(target, exist_ok=True)
before = set(os.listdir(default))
ok = True
buf = io.ElykCapture()
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
try:
await micropip.install(pkgs)
except BaseException:
ok = False
traceback.print_exc()
if ok:
moved = []
for name in sorted(set(os.listdir(default)) - before):
dst = os.path.join(target, name)
if os.path.isdir(dst):
shutil.rmtree(dst)
elif os.path.exists(dst):
os.remove(dst)
shutil.move(os.path.join(default, name), dst)
moved.append(name)
importlib.invalidate_caches()
print("Installed " + ", ".join(pkgs) if moved else "Already installed.")
json.dumps({"ok": ok, "output": buf.getvalue()})
`, { globals: ns });
} finally {
ns.destroy();
}
};
// Run a package-provided console_scripts entry point from terminal command
// dispatch. Rust decides the name exists from entry_points.txt; Python
// resolves and executes the installed metadata so import semantics match
// real console script wrappers.
window.elykRunConsoleScript = async function (scriptName, argsJson, stdinText) {
const py = await window.elykEnsurePyodide();
const ns = py.toPy({ script_name: scriptName, args_json: argsJson, stdin_text: stdinText ?? null });
try {
return await py.runPythonAsync(`
import importlib.metadata, io, contextlib, json, sys, os, glob, runpy, traceback
args = json.loads(args_json)
buf = io.ElykCapture()
ok = True
exit_code = 0
def find_entry_point():
eps = importlib.metadata.entry_points()
if hasattr(eps, "select"):
matches = list(eps.select(group="console_scripts", name=script_name))
else:
matches = [ep for ep in eps.get("console_scripts", []) if ep.name == script_name]
return matches[0] if matches else None
def find_data_script():
# Wheels that predate entry points ship the executable under */*.data/scripts/.
for base in sys.path:
if not base:
continue
for cand in glob.glob(os.path.join(base, "*.data", "scripts", script_name)):
if os.path.isfile(cand):
return cand
return None
try:
entry_point = find_entry_point()
script_path = None if entry_point else find_data_script()
if entry_point is None and script_path is None:
ok = False
print(f"No Python console script named {script_name!r} is installed.", file=buf)
else:
old_argv = sys.argv[:]
old_stdin = sys.stdin
sys.argv = [script_name, *args]
if stdin_text is not None:
sys.stdin = io.ElykCapture(stdin_text)
try:
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
try:
if entry_point is not None:
result = entry_point.load()()
if isinstance(result, int):
exit_code = result
elif result is not None:
print(result, file=sys.stderr)
exit_code = 1
else:
runpy.run_path(script_path, run_name="__main__")
except SystemExit as exc:
code = exc.code
if code is None:
exit_code = 0
elif isinstance(code, int):
exit_code = code
else:
print(code, file=sys.stderr)
exit_code = 1
except BaseException:
ok = False
traceback.print_exc()
finally:
sys.argv = old_argv
sys.stdin = old_stdin
if exit_code:
ok = False
print(f"{script_name}: exited with status {exit_code}", file=buf)
except BaseException:
ok = False
traceback.print_exc(file=buf)
json.dumps({"ok": ok, "output": buf.getvalue()})
`, { globals: ns });
} finally {
ns.destroy();
}
};
// Run a Python file as `__main__` (like `python3 file.py`), capturing
// stdout/stderr. `args` become sys.argv[1:].
window.elykRunPythonFile = async function (path, argsJson) {
const py = await window.elykEnsurePyodide();
const ns = py.toPy({ path, args_json: argsJson });
try {
return await py.runPythonAsync(`
import io, contextlib, json, sys, runpy, traceback
args = json.loads(args_json)
buf = io.ElykCapture()
ok = True
old_argv = sys.argv[:]
sys.argv = [path, *args]
try:
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
try:
runpy.run_path(path, run_name="__main__")
except SystemExit:
pass
except BaseException:
ok = False
traceback.print_exc()
finally:
sys.argv = old_argv
json.dumps({"ok": ok, "output": buf.getvalue()})
`, { globals: ns });
} finally {
ns.destroy();
}
};
// Walk the mirrored roots and return them as JSON. UTF-8 files include
// their text; binary files are flagged so the Rust side can skip them (the
// overlay stores text only). Paths outside these roots are left alone.
window.elykFsSnapshot = async function () {
const py = await window.elykEnsurePyodide();
const FS = py.FS;
const out = [];
const walk = (dir) => {
let names;
try {
names = FS.readdir(dir);
} catch (e) {
return;
}
for (const name of names) {
if (name === "." || name === "..") continue;
const path = dir + "/" + name;
let mode;
try {
mode = FS.stat(path).mode;
} catch (e) {
continue;
}
if (FS.isDir(mode)) {
out.push({ path, dir: true });
walk(path);
} else if (FS.isFile(mode)) {
const bytes = FS.readFile(path);
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
out.push({ path, content: text });
} catch (e) {
out.push({ path, binary: true });
}
}
}
};
for (const root of ELYK_MIRROR_ROOTS) {
try {
FS.stat(root);
walk(root);
} catch (e) {
/* root not created yet */
}
}
return JSON.stringify(out);
};
// Evaluate one REPL submission. `codeop.compile_command` decides whether
// the buffered source is a complete statement (run it), still incomplete
// (caller should keep buffering with a `...` prompt), or a real syntax
// error (run it so the traceback is captured). Single mode makes bare
// expressions echo their repr, exactly like the CPython prompt. Returns a
// JSON string: {"incomplete": bool, "output": str}.
// Tab completion for the REPL: complete the trailing identifier/dotted
// expression against the live __main__ namespace via rlcompleter. Returns
// JSON {text, matches} where `text` is the fragment being completed.
window.elykPythonComplete = async function (line) {
const py = await window.elykEnsurePyodide();
const ns = py.toPy({ line });
try {
return await py.runPythonAsync(`
import json, re, rlcompleter, __main__
m = re.search(r"[\\w.]*$", line)
text = m.group(0) if m else ""
completer = rlcompleter.Completer(__main__.__dict__)
matches, seen, i = [], set(), 0
while i < 500:
try:
c = completer.complete(text, i)
except Exception:
break
i += 1
if c is None:
break
if c not in seen:
seen.add(c)
matches.append(c)
json.dumps({"text": text, "matches": matches})
`, { globals: ns });
} finally {
ns.destroy();
}
};
window.elykRunPython = async function (source) {
const py = await window.elykEnsurePyodide();
// Run the harness in a throwaway namespace; user code execs into
// __main__ so its names persist, but ours don't leak into the REPL.
const ns = py.toPy({ source });
try {
return await py.runPythonAsync(`
import io, contextlib, traceback, codeop, json, __main__
result = {"incomplete": False, "output": ""}
try:
code = codeop.compile_command(source, "", "single")
except (SyntaxError, ValueError, OverflowError):
code = "__error__" # complete but invalid — fall through to report it
if code is None:
result["incomplete"] = True
else:
buf = io.ElykCapture()
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
try:
if isinstance(code, str):
compile(source, "", "single") # re-raise the SyntaxError
else:
exec(code, __main__.__dict__)
except SystemExit:
pass
except BaseException:
traceback.print_exc()
result["output"] = buf.getvalue()
json.dumps(result)
`, { globals: ns });
} finally {
ns.destroy();
}
};