feat: WASM web app (browser LesS/KEY) backed by the hel core

Run the real hel core in the browser via WebAssembly, replacing the plain-JS
ooke.github.io/sk reimplementation so the web and the CLI share one generator.

- hel core: new storage module (native fs / wasm localStorage) backs
  init/source/dump/correct; the pipe and pb branches are gated out of wasm;
  the wasm editor is completed (EditorRef) and its password prompt no longer
  blocks the single JS thread (synchronous hel_get_password instead of a
  thread::sleep poll).
- helwasm: hel_load_script for bulk import, a panic hook in hel_init, dropped
  the ok_add / web-sys leftovers; build.sh runs wasm-bindgen into a committed
  pkg/ so GitHub Pages can serve the static dir with no CI.
- UI: rewritten index.html + style.css in the kaizenkodo.org style — responsive,
  masked-but-copyable passwords with click-to-reveal, a quick-generate card plus
  a full command console, localStorage persistence, and paste import/export.
This commit is contained in:
Oleksandr Kozachuk
2026-06-08 18:43:19 +02:00
parent 74d3296882
commit 03c5dadccb
15 changed files with 1018 additions and 271 deletions
+1 -4
View File
@@ -16,7 +16,4 @@ hel = { version = "0.1.0", path = "../hel" }
lazy_static = "1.4.0"
wasm-bindgen = "0.2.83"
parking_lot = "0.12.1"
[dependencies.web-sys]
version = "0.3.4"
features = [ 'Document' ]
console_error_panic_hook = "0.1"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Build the helwasm web bundle into helwasm/pkg/ (committed so GitHub Pages can
# serve the static dir directly, no CI). Requires the wasm32 target and a
# wasm-bindgen CLI matching the wasm-bindgen crate version:
# rustup target add wasm32-unknown-unknown
# cargo install wasm-bindgen-cli --version <crate version>
set -e
cd "$(dirname "$0")/.."
cargo build --target wasm32-unknown-unknown -p helwasm --release
wasm-bindgen --target web --out-dir helwasm/pkg \
target/wasm32-unknown-unknown/release/helwasm.wasm
echo "built helwasm/pkg/ (helwasm.js + helwasm_bg.wasm)"
+247 -162
View File
@@ -1,172 +1,257 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
html {
height: 100%;
}
body {
min-height: 100%;
}
html,
body {
background-color: #000;
width: 100%;
margin: 0;
padding: 0;
font-family: monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.terminal {
height: 100%;
width: 100%;
margin: 0 auto;
color: #fff;
overflow-y: scroll;
padding: 20px;
}
.prompt {
color: #0f0;
}
.form {
display: flex;
width: 100vw;
margin: 0 auto;
}
.input {
background-color: transparent;
border: none;
color: #fff;
outline: none;
flex-grow: 1;
}
.input::placeholder {
color: #666;
}
label {
width: 100%;
white-space: nowrap;
display: inline-flex;
}
.terminal p,
.prompt,
.input {
font-family: monospace;
font-size: 16px;
white-space: pre-wrap;
}
#passwordPrompt {
position: fixed;
top: 50%;
left: 50%;
tranform: translate(-50%, -50%);
backrground-color: white;
border: 1px solid black;
padding: 20px;
}
</style>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#1b4161" />
<title>LesS/KEY — password generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="root"></div>
<script type="module">
import init, { hel_init, hel_command } from "./pkg/helwasm.js";
init().then(() => {
window.hel = {
hel_init: hel_init,
hel_command: hel_command,
}
});
</script>
<script type="text/babel">
function Terminal() {
const prompt = "> ";
const [input, setInput] = React.useState("");
const [output, setOutput] = React.useState([]);
const [passwordPrompt, setPasswordPrompt] = React.useState(undefined);
const [passwordInput, setPasswordInput] = React.useState("");
const outputRef = React.useRef(null);
<header class="site">
<div class="wrap nav">
<a class="brand" href="./">
<span class="enso" aria-hidden="true"></span>
<span class="wordmark">LesS/KEY</span>
</a>
<span class="nav-spacer"></span>
<button class="btn ghost" id="importBtn">Import</button>
<button class="btn ghost" id="exportBtn">Export</button>
</div>
</header>
React.useEffect(() => {
outputRef.current.scrollIntoView({ behaviour: "smooth" });
}, [output]);
function handleInput(event) {
setInput(event.target.value);
}
function handleSubmit(event) {
event.preventDefault();
const result = executeCommand(input);
setOutput([...output, { prompt: prompt, command: input }, { prompt: "", command: result }]);
setInput("");
}
function handlePasswordInput(event) {
setPasswordInput(event.target.value);
}
function handlePasswordSubmit(event) {
window.hel_current_password_value = passwordInput;
setPasswordPrompt(undefined);
setPasswordInput("");
}
function helReadPassword(prompt) {
window.hel_current_password_value = null;
setPasswordPrompt(prompt);
}
window.hel_read_password = helReadPassword;
function helCurrentPassword(prompt) {
return window.hel_current_password_value;
}
window.hel_current_password = helCurrentPassword;
function executeCommand(command) {
return window.hel.hel_command(command);
}
return (
<div className="terminal">
{output.map(({ prompt, command }, index) => (<p key={index}>{prompt && (<span className="prompt">{prompt}</span>)}{command}</p>))}
<form onSubmit={handleSubmit}>
<label>
<span className="prompt" ref={outputRef}>{prompt}</span>
<input
type="text"
value={input}
onChange={handleInput}
className="input"
autoFocus
/>
</label>
</form>
{passwordPrompt !== undefined && (<form onSubmit={handlePasswordSubmit}><label><span className="prompt">{passwordPrompt}</span><input type="password" value={passwordInput} onChange={handlePasswordInput} className="input" autoFocus /></label></form>)}
<main class="wrap">
<!-- Quick generate -->
<section class="card">
<div class="eyebrow">Generate</div>
<h2>Quick password</h2>
<p class="hint">
Deterministic — the same name + master always regenerate the same
password. Nothing secret is stored; this entry is not added to your
catalog.
</p>
<div class="grid2">
<div class="field">
<label for="qname">Name (+ optional rules)</label>
<input type="text" id="qname" placeholder="exa91 or exa91 20R 99 2020-01-01" autocomplete="off" spellcheck="false" autofocus />
</div>
);
<div class="field">
<label for="master">Master password</label>
<input type="password" id="master" placeholder="your master phrase" autocomplete="off" />
</div>
</div>
<div class="row">
<button class="btn" id="show">Show</button>
<button class="btn ghost" id="copy">Copy</button>
</div>
<div class="secret-line">
<span class="tag">result</span>
<span id="qsecret"><span class="secret"></span></span>
</div>
</section>
<!-- Console -->
<section class="card">
<div class="eyebrow">Console</div>
<h2>All commands</h2>
<p class="hint">
The full hel command line — <code>ls</code>, <code>add</code>,
<code>enc</code>, <code>gen</code>, <code>comment</code>,
<code>correct</code>, <code>help</code>, … Your catalog persists in this
browser (localStorage). <code>enc</code>/<code>gen</code> output is
masked — click to reveal.
</p>
<div class="console-out" id="cout"></div>
<div class="console-in">
<span class="prompt">&gt;</span>
<input type="text" id="cin" placeholder="type a command and press Enter" autocomplete="off" spellcheck="false" />
</div>
</section>
</main>
<footer class="foot wrap">
Runs the real hel core compiled to WebAssembly — same generator as the CLI.
</footer>
<div class="toast" id="toast"></div>
<!-- Import modal -->
<div class="modal-bg" id="importModal">
<div class="modal">
<h3>Import catalog</h3>
<p class="hint">Paste your catalog (e.g. the text of the Notion page). Existing entries with the same name are replaced.</p>
<textarea id="importText" placeholder="add ..."></textarea>
<div class="row">
<button class="btn" id="importDo">Import</button>
<button class="btn ghost" id="importCancel">Cancel</button>
</div>
</div>
</div>
<!-- Export modal -->
<div class="modal-bg" id="exportModal">
<div class="modal">
<h3>Export catalog</h3>
<p class="hint">Copy this and paste it back into your Notion page.</p>
<textarea id="exportText" readonly></textarea>
<div class="row">
<button class="btn" id="exportCopy">Copy</button>
<button class="btn ghost" id="exportClose">Close</button>
</div>
</div>
</div>
<script type="module">
import init, { hel_init, hel_command, hel_load_script } from "./pkg/helwasm.js";
const $ = (s) => document.querySelector(s);
const masterEl = () => document.getElementById("master");
const CATALOG_KEY = "hel_catalog";
// ---- host imports (wasm calls these by bare name → must be globals) ----
window.hel_get_password = () => (masterEl() ? masterEl().value : "") || "";
window.hel_rnd_range = (s, e) => {
if (e <= s) return s;
const r = crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296;
return s + Math.floor(r * (e - s));
};
window.hel_storage_get = (k) => localStorage.getItem("hel:" + k);
window.hel_storage_set = (k, v) => localStorage.setItem("hel:" + k, v);
// ---- helpers ----
const persist = () => hel_command("save " + CATALOG_KEY);
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const isSecretCmd = (cmd) => {
const v = cmd.trim().split(/\s+/)[0];
return v === "enc" || v === "gen";
};
let toastT;
function toast(msg) {
const t = $("#toast");
t.textContent = msg;
t.classList.add("show");
clearTimeout(toastT);
toastT = setTimeout(() => t.classList.remove("show"), 1600);
}
const container = document.getElementById('root');
const root = ReactDOM.createRoot(container);
root.render(<Terminal />);
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
toast("Copied");
} catch {
toast("Copy failed");
}
}
function secretSpan(text) {
const s = document.createElement("span");
s.className = "secret";
s.textContent = text;
s.title = "click to reveal";
s.addEventListener("click", () => s.classList.toggle("revealed"));
return s;
}
// ---- quick generate (stateless: never pollutes the stored catalog) ----
let lastSecret = "";
function quickShow() {
const spec = $("#qname").value.trim();
if (!spec) return toast("Enter a name");
if (!masterEl().value) return toast("Enter the master password");
const name = spec.split(/\s+/)[0];
let out = hel_command("enc " + name);
if (/not found/.test(out)) {
hel_command("add " + spec);
out = hel_command("enc " + name);
hel_command("rm " + name);
}
const pw = out.split("\n").filter((l) => l && !/^(warning|error):/.test(l)).pop() || "";
lastSecret = pw;
const slot = $("#qsecret");
slot.innerHTML = "";
slot.appendChild(secretSpan(pw));
if (!pw) toast("No output");
}
// ---- console ----
function appendLine(html, cls) {
const out = $("#cout");
const div = document.createElement("div");
if (cls) div.className = cls;
div.innerHTML = html;
out.appendChild(div);
out.scrollTop = out.scrollHeight;
}
function appendSecret(text) {
const out = $("#cout");
const div = document.createElement("div");
div.appendChild(secretSpan(text));
out.appendChild(div);
out.scrollTop = out.scrollHeight;
}
function consoleRun(cmd) {
appendLine('<span class="cmd">&gt; ' + esc(cmd) + "</span>");
const out = hel_command(cmd);
persist();
if (out) {
const secret = isSecretCmd(cmd);
for (const line of out.split("\n")) {
if (/^(warning|error):/.test(line)) appendLine(esc(line), "err");
else if (secret && line.trim() && !line.startsWith("add ")) appendSecret(line);
else appendLine(esc(line) || "&nbsp;");
}
}
}
// ---- boot ----
async function boot() {
await init();
hel_init();
if (localStorage.getItem("hel:" + CATALOG_KEY)) hel_command("source " + CATALOG_KEY);
$("#show").onclick = quickShow;
$("#copy").onclick = () => (lastSecret ? copyText(lastSecret) : toast("Nothing to copy"));
$("#qname").addEventListener("keydown", (e) => e.key === "Enter" && quickShow());
const ci = $("#cin");
ci.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const v = ci.value;
if (v.trim()) consoleRun(v);
ci.value = "";
}
});
$("#importBtn").onclick = () => $("#importModal").classList.add("show");
$("#importCancel").onclick = () => $("#importModal").classList.remove("show");
$("#importDo").onclick = () => {
const txt = $("#importText").value;
if (txt.trim()) {
hel_load_script(txt);
persist();
toast("Imported");
appendLine('<span class="muted"># imported catalog</span>');
}
$("#importModal").classList.remove("show");
$("#importText").value = "";
};
$("#exportBtn").onclick = () => {
$("#exportText").value = hel_command("dump -");
$("#exportModal").classList.add("show");
};
$("#exportClose").onclick = () => $("#exportModal").classList.remove("show");
$("#exportCopy").onclick = () => copyText($("#exportText").value);
appendLine('<span class="muted"># LesS/KEY ready — runs the real hel core via WASM. Try: help</span>');
}
boot();
</script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
/* tslint:disable */
/* eslint-disable */
/**
* Run a single hel command line and return its combined output.
*/
export function hel_command(cmd: string): string;
/**
* Call once at page load: routes Rust panics to the browser console with a
* readable message + stack instead of an opaque "unreachable" trap.
*/
export function hel_init(): void;
/**
* Run a whole multi-line script (every `add …` line, `set …`, etc.) against the
* shared state in one call. Used to bulk-import a pasted catalog (e.g. the text
* of the Notion page) and to load the persisted catalog from localStorage.
*/
export function hel_load_script(script: string): string;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly hel_command: (a: number, b: number) => [number, number];
readonly hel_init: () => void;
readonly hel_load_script: (a: number, b: number) => [number, number];
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
+320
View File
@@ -0,0 +1,320 @@
/* @ts-self-types="./helwasm.d.ts" */
/**
* Run a single hel command line and return its combined output.
* @param {string} cmd
* @returns {string}
*/
export function hel_command(cmd) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passStringToWasm0(cmd, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.hel_command(ptr0, len0);
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/**
* Call once at page load: routes Rust panics to the browser console with a
* readable message + stack instead of an opaque "unreachable" trap.
*/
export function hel_init() {
wasm.hel_init();
}
/**
* Run a whole multi-line script (every `add …` line, `set …`, etc.) against the
* shared state in one call. Used to bulk-import a pasted catalog (e.g. the text
* of the Notion page) and to load the persisted catalog from localStorage.
* @param {string} script
* @returns {string}
*/
export function hel_load_script(script) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passStringToWasm0(script, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.hel_load_script(ptr0, len0);
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg___wbindgen_throw_1506f2235d1bdba0: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.error(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
},
__wbg_getTime_00b3f7db575e4ef5: function(arg0) {
const ret = arg0.getTime();
return ret;
},
__wbg_getTimezoneOffset_08e2892156231088: function(arg0) {
const ret = arg0.getTimezoneOffset();
return ret;
},
__wbg_hel_get_password_271a2beac04c29db: function(arg0, arg1, arg2) {
const ret = hel_get_password(getStringFromWasm0(arg1, arg2));
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_hel_rnd_range_d01857a65a482c6d: function(arg0, arg1) {
const ret = hel_rnd_range(arg0 >>> 0, arg1 >>> 0);
return ret;
},
__wbg_hel_storage_get_9915f302e24bdacb: function(arg0, arg1, arg2) {
const ret = hel_storage_get(getStringFromWasm0(arg1, arg2));
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_hel_storage_set_78052521669832fa: function(arg0, arg1, arg2, arg3) {
hel_storage_set(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
},
__wbg_new_0_445c13a750296eb6: function() {
const ret = new Date();
return ret;
},
__wbg_new_227d7c05414eb861: function() {
const ret = new Error();
return ret;
},
__wbg_new_6d75fd236f920a62: function(arg0) {
const ret = new Date(arg0);
return ret;
},
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
const ret = arg1.stack;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbindgen_cast_0000000000000001: function(arg0) {
// Cast intrinsic for `F64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./helwasm_bg.js": import0,
};
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasmInstance, wasm;
function __wbg_finalize_init(instance, module) {
wasmInstance = instance;
wasm = instance.exports;
wasmModule = module;
cachedDataViewMemory0 = null;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('helwasm_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const hel_command: (a: number, b: number) => [number, number];
export const hel_init: () => void;
export const hel_load_script: (a: number, b: number) => [number, number];
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __wbindgen_start: () => void;
+25 -5
View File
@@ -1,22 +1,42 @@
use hel::lk::{LK, LKRef};
use hel::repl::LKRead;
use hel::utils::editor::Editor;
use std::sync::Arc;
use hel::parser::command_parser;
use hel::repl::{LKEval, LKRead};
use hel::structs::LKOut;
use hel::utils::editor::{password, Editor};
use parking_lot::ReentrantMutex;
use std::cell::RefCell;
use std::sync::Arc;
use wasm_bindgen::prelude::*;
lazy_static! {
static ref STATE: LKRef = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
}
#[allow(dead_code)]
/// Run a single hel command line and return its combined output.
#[wasm_bindgen]
pub fn hel_command(cmd: String) -> String {
let editor = Editor::new();
let mut lkread = LKRead::new(editor, "> ".to_string(), STATE.clone());
lkread.input = Some(cmd.to_string());
lkread.input = Some(cmd);
let lkeval = lkread.read();
let lkprint = lkeval.eval();
lkprint.out.output().join("\n")
}
/// Run a whole multi-line script (every `add …` line, `set …`, etc.) against the
/// shared state in one call. Used to bulk-import a pasted catalog (e.g. the text
/// of the Notion page) and to load the persisted catalog from localStorage.
#[wasm_bindgen]
pub fn hel_load_script(script: String) -> String {
let out = LKOut::new();
match command_parser::script(&script) {
Ok(cmds) => {
for cmd in cmds {
let print = LKEval::new(Editor::new(), cmd, STATE.clone(), password).eval();
print.out.copy(&out);
}
}
Err(e) => out.e(format!("error: {}", e)),
}
out.output().join("\n")
}
+3 -5
View File
@@ -4,13 +4,11 @@ extern crate hel;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn ok_add(a: i32, b: i32) -> i32 {
a + b + 1
}
mod hel_state;
/// Call once at page load: routes Rust panics to the browser console with a
/// readable message + stack instead of an opaque "unreachable" trap.
#[wasm_bindgen]
pub fn hel_init() {
console_error_panic_hook::set_once();
}
+177
View File
@@ -0,0 +1,177 @@
/* LesS/KEY web app — visual language mirrored from kaizenkodo.org
(~/Repos/Identity/website/main.css): washi-paper cream, ocean blue, hanko red,
Poppins / Inter / JetBrains Mono. Light, responsive, restrained. */
:root {
--cream: #f4f0e8;
--paper: #fbf8f2;
--ink: #15181b;
--muted: #6a675e;
--line: #ddd6c6;
--ocean: #1b4161;
--ocean-2: #2f6286;
--deep: #0a2540;
--seal: #e23b2e;
--disp: "Poppins", system-ui, sans-serif;
--body: "Inter", system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
--maxw: 880px;
--radius: 14px;
}
/* Masked-secret font: dots glyphs so the real chars stay selectable/copyable.
WebKit/Blink use -webkit-text-security; Firefox falls back to this font. */
@font-face {
font-family: "text-security-disc";
src: url("https://cdn.jsdelivr.net/npm/text-security/dist/text-security-disc.woff2") format("woff2"),
url("https://cdn.jsdelivr.net/npm/text-security/dist/text-security-disc.woff") format("woff");
font-display: swap;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { height: 100%; }
body {
min-height: 100%;
font-family: var(--body);
font-size: 16px;
line-height: 1.6;
color: var(--ink);
background-color: var(--cream);
background-image:
linear-gradient(rgba(27, 65, 97, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(27, 65, 97, 0.05) 1px, transparent 1px);
background-size: 48px 48px;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 20px; }
/* Header */
.site {
position: sticky; top: 0; z-index: 50;
background: rgba(244, 240, 232, 0.82);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
}
.nav { display: flex; align-items: center; gap: 14px; height: 64px; }
.brand { display: inline-flex; align-items: center; gap: 11px; text-decoration: none; }
.enso {
width: 34px; height: 34px; border-radius: 50%;
border: 3px solid var(--ocean); border-right-color: transparent;
transform: rotate(-20deg);
}
.wordmark { font-family: var(--disp); font-weight: 700; font-size: 21px; color: var(--ocean); letter-spacing: -0.01em; }
.nav-spacer { flex: 1; }
.nav .ghost { font-family: var(--mono); font-size: 12px; }
/* Eyebrow label */
.eyebrow {
font-family: var(--mono); font-size: 12px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--ocean);
display: inline-flex; align-items: center; gap: 9px; margin-bottom: 14px;
}
.eyebrow::before {
content: ""; width: 7px; height: 7px; border-radius: 50%;
background: var(--seal); box-shadow: 0 0 0 3px rgba(226, 59, 46, 0.16);
}
main { padding: 30px 0 64px; display: grid; gap: 22px; }
/* Cards */
.card {
background: var(--paper); border: 1px solid var(--line);
border-radius: var(--radius); padding: 22px;
}
.card h2 { font-family: var(--disp); font-weight: 600; font-size: clamp(20px, 3vw, 26px); color: var(--ink); margin-bottom: 4px; }
.card .hint { color: var(--muted); font-size: 14px; margin-bottom: 16px; }
/* Form fields */
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
input[type="text"], input[type="password"] {
font-family: var(--mono); font-size: 15px; color: var(--ink);
background: #fff; border: 1px solid var(--line); border-radius: 10px;
padding: 11px 13px; width: 100%; outline: none;
transition: border-color .15s, box-shadow .15s;
}
input:focus { border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27, 65, 97, 0.12); }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 16px; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
font-family: var(--disp); font-weight: 600; font-size: 14px;
background: var(--ocean); color: var(--cream);
border: 1px solid var(--ocean); border-radius: 999px; padding: 10px 20px;
transition: transform .15s, background .15s;
}
.btn:hover { transform: translateY(-1px); background: var(--deep); border-color: var(--deep); }
.btn.ghost { background: transparent; color: var(--ocean); }
.btn.ghost:hover { background: rgba(27, 65, 97, 0.06); color: var(--ocean); }
.btn:active { transform: none; }
/* Secret rendering: real text in the DOM, visually masked. Click toggles. */
.secret-line { margin-top: 16px; display: flex; align-items: baseline; gap: 12px; min-height: 28px; }
.secret-line .tag { font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; }
.secret {
font-family: "text-security-disc", var(--mono);
-webkit-text-security: disc;
font-size: 19px; letter-spacing: 0.06em; color: var(--ink);
cursor: pointer; word-break: break-all; user-select: text;
border-bottom: 1px dashed var(--line);
}
.secret.revealed { font-family: var(--mono); -webkit-text-security: none; color: var(--ocean); }
.secret:empty::before { content: "—"; color: var(--muted); -webkit-text-security: none; }
/* Console */
.console-out {
font-family: var(--mono); font-size: 13.5px; line-height: 1.55;
background: #fff; border: 1px solid var(--line); border-radius: 10px;
padding: 14px; height: 320px; overflow-y: auto; white-space: pre-wrap; word-break: break-word;
}
.console-out .cmd { color: var(--ocean); font-weight: 500; }
.console-out .err { color: var(--seal); }
.console-out .muted { color: var(--muted); }
.console-out .secret { font-size: 13.5px; }
.console-in { display: flex; align-items: center; gap: 8px; margin-top: 10px;
background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 4px 12px; }
.console-in .prompt { font-family: var(--mono); color: var(--ocean); font-weight: 600; }
.console-in input { border: none; box-shadow: none; padding: 8px 0; background: transparent; }
.console-in input:focus { box-shadow: none; }
.toast {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
background: var(--deep); color: var(--cream); font-family: var(--mono); font-size: 13px;
padding: 10px 18px; border-radius: 999px; box-shadow: 0 8px 24px rgba(10, 16, 24, 0.35);
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 80;
}
.toast.show { opacity: 1; }
/* Modal (import/export) */
.modal-bg {
position: fixed; inset: 0; background: rgba(10, 16, 24, 0.45);
display: none; align-items: center; justify-content: center; padding: 20px; z-index: 90;
}
.modal-bg.show { display: flex; }
.modal { background: var(--paper); border-radius: 16px; border: 1px solid var(--line);
width: 100%; max-width: 620px; padding: 22px; }
.modal h3 { font-family: var(--disp); font-weight: 600; margin-bottom: 10px; }
.modal textarea {
width: 100%; height: 240px; resize: vertical; font-family: var(--mono); font-size: 13px;
border: 1px solid var(--line); border-radius: 10px; padding: 12px; outline: none;
}
.modal textarea:focus { border-color: var(--ocean); }
.foot { color: var(--muted); font-family: var(--mono); font-size: 12px; text-align: center; padding: 8px 0 40px; }
.foot a { color: var(--ocean-2); }
@media (max-width: 620px) {
.grid2 { grid-template-columns: 1fr; }
.nav { gap: 8px; }
.console-out { height: 260px; }
body { font-size: 15px; }
}