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
+53 -32
View File
@@ -1,10 +1,7 @@
use regex::Regex;
use sha1::{Digest, Sha1};
use std::cmp::min;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader};
use std::io::{BufWriter, Write};
use std::collections::HashSet;
use crate::parser::command_parser;
use crate::password::fix_password_recursion;
@@ -12,6 +9,9 @@ use crate::password::{Name, Password, PasswordRef};
use crate::repl::LKEval;
use crate::structs::{config_get, config_set, LKOut, Radix, CORRECT_FILE, DUMP_FILE};
use crate::utils::editor::password;
// call_cmd_with_input / get_cmd_args_from_command / get_copy_command_from_env are
// only used by the native (non-wasm) subprocess branches.
#[cfg_attr(target_arch = "wasm32", allow(unused_imports))]
use crate::utils::{call_cmd_with_input, get_cmd_args_from_command, get_copy_command_from_env, rnd};
impl<'a> LKEval<'a> {
@@ -214,6 +214,14 @@ impl<'a> LKEval<'a> {
let data = print.out.data();
print.out.copy_err(&out);
if data.len() > 0 {
// Clipboard copy shells out to pbcopy/xclip/tmux: native only.
// In the browser the page provides a Copy button instead.
#[cfg(target_arch = "wasm32")]
{
out.e("error: pb (clipboard copy) is not available in the browser; use the Copy button".to_string());
}
#[cfg(not(target_arch = "wasm32"))]
{
let (copy_command, copy_cmd_args) = get_copy_command_from_env();
match call_cmd_with_input(&copy_command, &copy_cmd_args, &data) {
Ok(s) if s.len() > 0 => {
@@ -228,13 +236,23 @@ impl<'a> LKEval<'a> {
};
}
}
}
Err(e) => out.e(format!("error: failed to parse command {}: {}", command, e.to_string())),
};
}
pub fn cmd_source(&self, out: &LKOut, source: &String) -> bool {
out.o(format!("source {}", source));
let script = if source.trim().ends_with("|") {
let script: String;
if source.trim().ends_with("|") {
// Loading from a command's output needs a subprocess: native only.
#[cfg(target_arch = "wasm32")]
{
out.e("error: pipe source is not available in the browser".to_string());
return false;
}
#[cfg(not(target_arch = "wasm32"))]
{
let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) {
Ok(c) => c,
Err(e) => {
@@ -242,23 +260,25 @@ impl<'a> LKEval<'a> {
return false;
}
};
match call_cmd_with_input(&cmd, &args, "") {
script = match call_cmd_with_input(&cmd, &args, "") {
Ok(o) => o,
Err(e) => {
out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string()));
return false;
}
};
}
} else {
let script = shellexpand::full(source).unwrap().into_owned();
match std::fs::read_to_string(script) {
// File path on native; localStorage key in the browser.
let key = shellexpand::full(source).unwrap().into_owned();
script = match crate::storage::read(&key) {
Ok(script) => script,
Err(e) => {
out.e(format!("error: failed to read file {}: {}", source, e.to_string()));
out.e(format!("error: failed to read {}: {}", source, e.to_string()));
return false;
}
}
};
}
match command_parser::script(&script) {
Ok(cmd_list) => {
for cmd in cmd_list {
@@ -321,17 +341,14 @@ impl<'a> LKEval<'a> {
None => config_get("hel_dump").unwrap_or_else(|| DUMP_FILE.to_str().unwrap().to_string()),
};
let script = shellexpand::full(&script).unwrap().into_owned();
fn save_dump(data: &HashMap<Name, PasswordRef>, script: &String) -> std::io::Result<()> {
let file = fs::File::create(script)?;
let mut writer = BufWriter::new(file);
let mut vals = data.values().map(|v| v.clone()).collect::<Vec<PasswordRef>>();
vals.sort_by(|a, b| a.lock().borrow().name.cmp(&b.lock().borrow().name));
for pwd in vals {
writeln!(writer, "add {}", pwd.lock().borrow().to_string())?
}
Ok(())
}
if script.trim().starts_with("|") {
// Piping the dump to a command needs a subprocess: native only.
#[cfg(target_arch = "wasm32")]
{
out.e("error: pipe dump is not available in the browser".to_string());
}
#[cfg(not(target_arch = "wasm32"))]
{
let (cmd, args) = match get_cmd_args_from_command(script.trim().trim_start_matches('|')) {
Ok(c) => c,
Err(e) => {
@@ -355,6 +372,7 @@ impl<'a> LKEval<'a> {
} else {
out.o(format!("Passwords saved to command {}", cmd));
}
}
} else if script.trim() == "-" {
let mut vals = (&self.state.lock().borrow().db).values().map(|v| v.clone()).collect::<Vec<PasswordRef>>();
vals.sort_by(|a, b| a.lock().borrow().name.cmp(&b.lock().borrow().name));
@@ -362,14 +380,15 @@ impl<'a> LKEval<'a> {
out.o(format!("add {}", pwd.lock().borrow().to_string()))
}
} else {
// File path on native; localStorage key in the browser.
let data = self.serialize_db();
self.show_dump_diff(out, &data);
// Bind first so the immutable borrow drops before the borrow_mut below.
let res = save_dump(&self.state.lock().borrow().db, &script);
match res {
// Trailing newline to match the historical file format (writeln per line).
let body = if data.is_empty() { String::new() } else { format!("{}\n", data) };
match crate::storage::write(&script, &body) {
Ok(()) => {
self.state.lock().borrow_mut().last_dump = Some(data);
out.o(format!("Passwords saved to file {}", script));
out.o(format!("Passwords saved to {}", script));
}
Err(e) => out.e(format!("error: failed to dump passwords to {}: {}", script, e.to_string())),
};
@@ -434,11 +453,13 @@ impl<'a> LKEval<'a> {
None => return,
};
fn load_lines() -> std::io::Result<HashSet<String>> {
let file = fs::File::open(CORRECT_FILE.to_str().unwrap())?;
let reader = BufReader::new(file);
let content = crate::storage::read(CORRECT_FILE.to_str().unwrap())?;
let mut lines = HashSet::new();
for line in reader.lines() {
lines.insert(line?.trim().to_owned());
for line in content.lines() {
let line = line.trim();
if !line.is_empty() {
lines.insert(line.to_owned());
}
}
Ok(lines)
}
@@ -469,12 +490,12 @@ impl<'a> LKEval<'a> {
data.remove(&encpwd);
}
fn save_lines(data: &HashSet<String>) -> std::io::Result<()> {
let file = fs::File::create(CORRECT_FILE.to_str().unwrap())?;
let mut writer = BufWriter::new(file);
let mut content = String::new();
for entry in data {
writeln!(writer, "{}", entry)?;
content.push_str(entry);
content.push('\n');
}
Ok(())
crate::storage::write(CORRECT_FILE.to_str().unwrap(), &content)
}
match save_lines(&data) {
Ok(()) => out.o(format!(
+1
View File
@@ -11,5 +11,6 @@ pub mod parser;
pub mod password;
pub mod repl;
pub mod skey;
pub mod storage;
pub mod structs;
pub mod utils;
+48
View File
@@ -0,0 +1,48 @@
//! Key -> value persistence with a per-target backend.
//!
//! - Native: the key is a filesystem path; backed by `std::fs`.
//! - WASM: the key is a localStorage key; backed by JS imports
//! `hel_storage_get` / `hel_storage_set` (provided by the host page).
//!
//! This lets the same command code (`init`, `source`, `dump`/`save`, `correct`)
//! persist to files on the CLI and to the browser's localStorage on the web,
//! with no per-call-site branching.
use std::io;
#[cfg(not(target_arch = "wasm32"))]
pub fn read(key: &str) -> io::Result<String> {
std::fs::read_to_string(key)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn write(key: &str, data: &str) -> io::Result<()> {
std::fs::write(key, data)
}
#[cfg(target_arch = "wasm32")]
mod imp {
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = hel_storage_get)]
pub fn get(key: &str) -> Option<String>;
#[wasm_bindgen(js_name = hel_storage_set)]
pub fn set(key: &str, val: &str);
}
}
#[cfg(target_arch = "wasm32")]
pub fn read(key: &str) -> io::Result<String> {
match imp::get(key) {
Some(v) => Ok(v),
None => Err(io::Error::new(io::ErrorKind::NotFound, "key not found in localStorage")),
}
}
#[cfg(target_arch = "wasm32")]
pub fn write(key: &str, data: &str) -> io::Result<()> {
imp::set(key, data);
Ok(())
}
+1 -1
View File
@@ -392,7 +392,7 @@ pub fn init() -> Option<LKRead> {
let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
let editor = Editor::new();
match std::fs::read_to_string(INIT_FILE.to_str().unwrap()) {
match crate::storage::read(INIT_FILE.to_str().unwrap()) {
Ok(script) => match command_parser::script(&script) {
Ok(cmd_list) => {
for cmd in cmd_list {
+15 -14
View File
@@ -150,25 +150,32 @@ pub mod editor {
#[cfg(target_arch = "wasm32")]
pub mod editor {
use crate::structs::LKErr;
use parking_lot::Mutex;
use std::sync::Arc;
use wasm_bindgen::prelude::*;
// Mirror the unix editor's contract so repl.rs (which holds an `EditorRef`
// and calls `.lock()`) compiles unchanged under wasm.
pub type EditorRef = Arc<Mutex<Editor>>;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = hel_read_password)]
fn extern_read_password(prompt: &str);
#[wasm_bindgen(js_name = hel_current_password)]
fn extern_current_password() -> Option<String>;
// Synchronous: the host page returns the current master-password value.
// (The old read/poll pair used thread::sleep, which deadlocks the single
// browser thread — never use blocking polling under wasm.)
#[wasm_bindgen(js_name = hel_get_password)]
fn extern_get_password(prompt: &str) -> String;
}
#[derive(Debug)]
pub struct Editor {
#[allow(dead_code)]
history: Vec<String>,
}
impl Editor {
pub fn new() -> Self {
Self { history: vec![] }
pub fn new() -> EditorRef {
Arc::new(Mutex::new(Self { history: vec![] }))
}
pub fn clear_history(&mut self) {
@@ -193,13 +200,7 @@ pub mod editor {
}
pub fn password(prompt: String) -> std::io::Result<String> {
extern_read_password(&prompt);
loop {
match extern_current_password() {
Some(p) => return Ok(p),
None => std::thread::sleep(std::time::Duration::from_millis(100)),
}
}
Ok(extern_get_password(&prompt))
}
}
+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)"
+243 -158
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>
<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>
<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 } from "./pkg/helwasm.js";
init().then(() => {
window.hel = {
hel_init: hel_init,
hel_command: hel_command,
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);
}
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 = "";
}
});
</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);
React.useEffect(() => {
outputRef.current.scrollIntoView({ behaviour: "smooth" });
}, [output]);
function handleInput(event) {
setInput(event.target.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);
function handleSubmit(event) {
event.preventDefault();
const result = executeCommand(input);
setOutput([...output, { prompt: prompt, command: input }, { prompt: "", command: result }]);
setInput("");
appendLine('<span class="muted"># LesS/KEY ready — runs the real hel core via WASM. Try: help</span>');
}
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>)}
</div>
);
}
const container = document.getElementById('root');
const root = ReactDOM.createRoot(container);
root.render(<Terminal />);
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; }
}