Update secure site and Forgejo release integration
ober
a940a7c88952e3a890835576917bc356141221ae
--- a/README.md +++ b/README.md @@ -21,11 +21,11 @@ LLM-assisted documentation guardrails. The signed v0.2.5 release is live. `/install.sh` returns HTTP 200 with a bootstrap script that downloads the SSH-signed installer from the canonical -SourceHut repo. The installer verifies an Ed25519-signed manifest before +Forgejo repo. The installer verifies an Ed25519-signed manifest before trusting any artifact. -The public signer key (`ssh-ed25519 AAAAC3NzaC1...zeUQ`, identity `~lisp`) -is documented in the [release notes](https://git.sr.ht/~lisp/jerboa/refs/v0.2.5) +The public signer key (`ssh-ed25519 AAAAC3NzaC1...zeUQ`, identity `ober`) +is documented in the [release notes](https://git.jerboa.sh/ober/jerboa/releases/tag/v0.2.5) and pinned in [`support/installer-release.lock`](support/installer-release.lock). See [`docs/installer-trust.md`](docs/installer-trust.md) for the full trust @@ -34,11 +34,9 @@ reproducibility evidence. ## Package Registry -The site documents the built-in `@lisp` TUF registry at `/packages/`. -The registry lives at [git.sr.ht/~lisp/jerboa-registry](https://git.sr.ht/~lisp/jerboa-registry) -and is served via the SourceHut `/blob/main/<path>` endpoint (the -`/refs/download/` endpoint is blocked by the go-away anti-bot proxy for -anonymous scripted clients). +The site documents the built-in `ober` TUF registry at `/packages/`. +The registry lives at [git.jerboa.sh/ober/jerboa-registry](https://git.jerboa.sh/ober/jerboa-registry) +and is served via Forgejo raw file endpoints. ## Build new file mode 100644 Binary files /dev/null and b/assets/favicon.ico differ new file mode 100644 --- /dev/null +++ b/assets/repl/SHA256SUMS @@ -0,0 +1 @@ +2e5b07eb2c6f388cd2fb941a32482d8a07af5fcdefe43049bf0d1dfb95bf7ca6 jerboa-repl.wasm new file mode 100755 Binary files /dev/null and b/assets/repl/jerboa-repl.wasm differ new file mode 100644 --- /dev/null +++ b/assets/repl/manifest.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "abi_version": 1, + "subset_revision": "browser-subset-137", + "jerboa_version": "0.2.4", + "jerboa_commit": "05264127a90185d78efeb5cca33f05ae0bad6752", + "rust_toolchain": "1.94.1", + "rust_target": "wasm32-unknown-unknown", + "wasm_filename": "jerboa-repl.wasm", + "wasm_sha256": "2e5b07eb2c6f388cd2fb941a32482d8a07af5fcdefe43049bf0d1dfb95bf7ca6", + "wasm_bytes": 1146780, + "wasm_import_count": 0, + "wasm_memory_max_bytes": 33554432 +} new file mode 100644 --- /dev/null +++ b/assets/repl/repl-worker.js @@ -0,0 +1,86 @@ +let wasm = null; +let memory = null; +let ready = false; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +function resultEnvelope() { + const ptr = wasm.repl_result_ptr(); + const len = wasm.repl_result_len(); + const view = new Uint8Array(memory.buffer, ptr, len); + return JSON.parse(decoder.decode(view)); +} + +async function init(id, wasmUrl, expectedAbi) { + const response = await fetch(wasmUrl, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`engine fetch failed: ${response.status}`); + } + const contentType = response.headers.get("content-type") || ""; + if (contentType.split(";")[0].trim() !== "application/wasm") { + throw new Error("engine response is not application/wasm"); + } + const instanceResult = await WebAssembly.instantiateStreaming(response, {}); + wasm = instanceResult.instance.exports; + memory = wasm.memory; + const abi = wasm.repl_abi_version(); + if (abi !== expectedAbi) { + throw new Error(`engine ABI ${abi} does not match ${expectedAbi}`); + } + wasm.repl_engine_info(); + const info = resultEnvelope(); + ready = true; + postMessage({ + kind: "ready", + id, + abi, + engineVersion: "0.1.0", + subsetRevision: "browser-subset-137", + info, + }); +} + +function evalSource(id, source) { + if (!ready || !wasm) { + throw new Error("engine is not ready"); + } + const bytes = encoder.encode(source); + const ptr = wasm.repl_input_alloc(bytes.length); + if (ptr === 0) { + throw new Error("engine rejected input allocation"); + } + new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes); + const started = performance.now(); + wasm.repl_eval(bytes.length, 250000); + memory = wasm.memory; + postMessage({ + kind: "result", + id, + result: resultEnvelope(), + elapsedMs: Math.round(performance.now() - started), + }); +} + +onmessage = (event) => { + const message = event.data || {}; + Promise.resolve() + .then(() => { + if (message.kind === "init") { + return init(message.id, message.wasmUrl, message.expectedAbi); + } + if (message.kind === "eval") { + return evalSource(message.id, message.source || ""); + } + if (message.kind === "info") { + wasm.repl_engine_info(); + postMessage({ kind: "result", id: message.id, result: resultEnvelope(), elapsedMs: 0 }); + return undefined; + } + throw new Error("unknown worker message"); + }) + .catch((error) => { + ready = false; + postMessage({ kind: "fatal", id: message.id, message: String(error && error.message || error) }); + }); +}; new file mode 100644 --- /dev/null +++ b/assets/repl/repl.css @@ -0,0 +1 @@ +.repl-shell{max-width:1280px;margin:0 auto;padding:1rem clamp(1rem,3vw,2rem) 2rem}.repl-head{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-bottom:1px solid var(--line);padding:.8rem 0 1rem}.repl-head h1{font-size:1.7rem;line-height:1.1;margin:0;color:#101923}.repl-subset{font-size:.8rem;font-weight:850;color:#0b6b5c;border:1px solid #9bd1c6;border-radius:999px;padding:.18rem .55rem;background:#f2fffb}.repl-status{color:#526578;font-size:.92rem}.repl-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:1rem;min-height:calc(100vh - 12rem);padding-top:1rem}.repl-pane{display:flex;flex-direction:column;min-width:0}.repl-toolbar{display:flex;align-items:center;gap:.55rem;min-height:2.6rem}.repl-toolbar button,.repl-toolbar select{border:1px solid #c9d5de;background:#fff;color:#172230;border-radius:6px;min-height:2.25rem;font:inherit;font-weight:800}.repl-toolbar button{padding:.5rem .72rem;cursor:pointer}.repl-toolbar select{max-width:12.5rem;padding:.46rem .55rem}.repl-toolbar button:disabled{cursor:not-allowed;opacity:.55}.repl-toolbar button[data-repl-run]{background:#172230;color:#fff;border-color:#172230}.repl-editor-shell,.repl-transcript{width:100%;min-height:30rem;flex:1;border:1px solid #cbd8e2;border-radius:8px;background:#fff;color:#172230;font:14px/1.55 SFMono-Regular,ui-monospace,Consolas,monospace;overflow:hidden}.repl-editor-shell{position:relative;background:#fbfdff}.repl-highlight,.repl-editor{position:absolute;inset:0;width:100%;height:100%;margin:0;border:0;border-radius:8px;background:transparent;font:inherit;line-height:inherit;letter-spacing:0;padding:1rem;tab-size:2;white-space:pre;overflow:auto}.repl-highlight{pointer-events:none;color:#172230;z-index:1}.repl-editor{z-index:2;resize:none;color:transparent;caret-color:#172230;outline:none;-webkit-text-fill-color:transparent}.repl-editor::selection{background:rgba(36,87,197,.25)}.repl-editor:focus{box-shadow:inset 0 0 0 2px #2457c5}.tok-comment{color:#7a8792;font-style:italic}.tok-string{color:#a94d00}.tok-number{color:#c12652;font-weight:760}.tok-form{color:#7a26b8;font-weight:850}.tok-keyword{color:#0a7770;font-weight:760}.tok-module{color:#0069b8;font-weight:760}.tok-operator{color:#0e5fb0;font-weight:850}.tok-paren,.tok-quote{color:#495d70;font-weight:760}.tok-symbol{color:#172230}.repl-transcript{padding:1rem;overflow:auto;white-space:pre-wrap}.repl-transcript pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.repl-source,.repl-value,.repl-stdout,.repl-error,.repl-note{border-bottom:1px solid #edf1f4;padding:.6rem 0}.repl-source{color:#637487}.repl-value{color:#113a73;font-weight:750}.repl-stdout{color:#186247}.repl-error{color:#b53624;font-weight:760}.repl-note{color:#606f7e}.repl-noscript{display:block;margin:1rem 0;padding:.8rem;border:1px solid #e0a25a;border-radius:8px;background:#fff4df;color:#603d0d}@media(max-width:800px){.repl-head{align-items:flex-start;flex-direction:column}.repl-grid{grid-template-columns:1fr;min-height:auto}.repl-editor-shell,.repl-transcript{min-height:20rem}.repl-toolbar{flex-wrap:wrap}.repl-toolbar select{max-width:100%;flex:1 1 10rem}} new file mode 100644 --- /dev/null +++ b/assets/repl/repl.js @@ -0,0 +1,821 @@ +const config = { + abi: 1, + workerUrl: "/assets/repl/v1/repl-worker.js", + wasmUrl: "/assets/repl/v1/jerboa-repl.wasm", +}; +const examples = [ + { + name: "Core Data", + source: `(import (jerboa prelude)) + +(defstruct point (x y)) +(def p (make-point 3 4)) + +(match '(jerboa scheme wasm) + ((lang family target) + (list (point? p) (point-x p) (point-y p) lang family target)) + (_ 'no-match)) +`, + }, + { + name: "Numbers", + source: `(import (jerboa prelude)) + +(list + (integer-part 42.75) + (fractional-part 42.75) + (floor-align 37 8) + (ceiling-align 37 8) + (expt-mod 2 16 17) + (uint-length-in-u8 65535)) +`, + }, + { + name: "Bytes", + source: `(import (jerboa prelude)) + +(def bv #vu8(0 1 2 255)) + +(list + (u8vector-length bv) + (u8vector-ref bv 3) + (u8vector->uint #vu8(1 0) big) + (u8vector->uint #vu8(1 0) little) + (uint->u8vector 65535 big 2) + (u8vector-reverse bv) + (u8vector->bytestring #vu8(222 173 190 239))) +`, + }, + { + name: "Strings", + source: `(import (jerboa prelude)) + +(def words (string-split "jerboa,scheme,wasm" ",")) + +(list + words + (string-join words "/") + (string-prefix? "jer" "jerboa") + (string-suffix? "wasm" "browser-wasm") + (string-contains "browser-wasm" "wasm") + (string-trim " padded ")) +`, + }, + { + name: "Core Lists", + source: `(import (jerboa prelude)) + +(def nums '(1 2 3 4 5)) + +(list + (append-map (lambda (x) (list x (* x 2))) '(1 2 3)) + (take-last 2 nums) + (drop-last 2 nums) + (flatten '(1 (2 (3)) (4 5))) + (distinct '(a b a c b)) + (interpose 'sep '(a b c)) + (interleave '(a b c) '(1 2 3)) + (zip '(a b c) '(1 2 3)) + (assoc 'a (frequencies '(a b a c b a))) + (assoc #t (group-by odd? nums)) + (keep (lambda (x) (and (odd? x) (* x 10))) nums) + (take-while odd? '(1 3 4 5)) + (drop-while odd? '(1 3 4 5)) + (take-until even? '(1 3 4 5)) + (drop-until even? '(1 3 4 5)) + (butlast nums) + (assoc 'a (duplicates '(a b a c b a))) + (group-same '(a a b b b c a)) + (group-consecutive '(1 1 2 3 3 2)) + (group-n-consecutive 2 nums)) +`, + }, + { + name: "Functional Core", + source: `(import (jerboa prelude)) + +(def add-shift + (compose 1+ (lambda (x) (* x 2)))) + +(def summarize + (juxt 1+ 1- (lambda (x) (* x x)))) + +(list + (add-shift 5) + ((partial + 1 2) 3 4) + ((curry list 'a 'b) 'c 'd) + ((complement odd?) 2) + ((negate odd?) 3) + ((constantly 'stable) 'ignored) + ((flip cons) '(tail) 'head) + (summarize 5) + ((conjoin integer? positive?) 5) + ((disjoin string? symbol?) 'jerboa) + ((every-pred integer? positive?) -1) + ((some-fn string? symbol?) 'jerboa) + ((fnil + 0 10) #f #f)) +`, + }, + { + name: "Threading", + source: `(import (jerboa prelude)) + +(def values '(1 2 3 4 5)) + +(list + (-> 5 (1+) (* 2)) + (->> values + (map (lambda (x) (* x 2))) + (filter even?)) + (as-> 5 x + (+ x 1) + (list x (* x 2))) + (some-> 5 (1+) (* 2)) + (some-> #f (1+) (* 2)) + (cond-> 5 + #t (1+) + #f (* 100) + #t (* 2)) + (unwrap (->? (ok 10) + (+ 5) + (* 2))) + (unwrap (->>? (ok values) + (map (lambda (x) (* x 2))) + (filter even?)))) +`, + }, + { + name: "Conditional Binding", + source: `(import (jerboa prelude)) + +(def maybe-score 91) +(def missing-score #f) + +(list + (when-let (score maybe-score) + (if (>= score 90) 'honors 'pass)) + (void? (when-let (score missing-score) + (+ score 1))) + (if-let (score maybe-score) + (str "score=" score) + "missing") + (if-let (score missing-score) + (str "score=" score) + "missing") + (awhen maybe-score + (+ it 9)) + (aif missing-score + (+ it 9) + 'fallback) + (when/list #t + 'alpha + 'beta + (+ 1 2)) + (when/list #f + 'alpha + 'beta)) +`, + }, + { + name: "Results", + source: `(import (jerboa prelude)) + +(def (safe-div x y) + (if (= y 0) + (err "division by zero") + (ok (/ x y)))) + +(def results + (list (safe-div 10 2) + (safe-div 4 0) + (safe-div 9 3))) + +(list + (map-ok (lambda (x) (* x 10)) (safe-div 10 2)) + (and-then (safe-div 10 2) + (lambda (x) (ok (+ x 1)))) + (unwrap-or (safe-div 4 0) 'fallback) + (sequence-results (list (ok 1) (ok 2) (ok 3))) + (results-partition results) + (filter-ok results) + (filter-err results) + (call-with-values + (lambda () (result->values (safe-div 4 0))) + list) + (try-result (/ 1 0)) +(try-result* (error "boom"))) +`, + }, + { + name: "Iteration", + source: `(import (jerboa prelude)) + +(def scores '#(72 81 94 67 88)) +(def table (make-hash-table)) + +(hash-put! table 'alpha 1) +(hash-put! table 'beta 2) + +(list + (for/collect ((x (in-range 5))) + (* x x)) + (for/collect ((score (in-vector scores))) + (>= score 80)) + (for/collect ((ch (in-string "wasm"))) + ch) + (for/collect ((pair (in-indexed '(a b c)))) + pair) + (for/collect ((letter (in-list '(a b c))) + (number (in-list '(1 2)))) + (list letter number)) + (for/fold ((sum 0)) ((x (in-range 1 6))) + (+ sum x)) + (for/or ((x (in-list '(#f #f ready)))) + x) + (for/and ((x (in-list '(#t ok yes)))) + x) + (for/collect ((k (in-hash-keys table))) + k)) +`, + }, + { + name: "Port Iteration", + source: `(import (jerboa prelude)) + +(def datum-port + (open-input-string "1 two three")) + +(def byte-port + (open-input-u8vector #u8(65 66 67))) + +(list + (for/collect ((ch (in-chars (open-input-string "ab\\nc")))) + ch) + (for/collect ((line (in-lines (open-input-string "alpha\\nbeta\\r\\ngamma")))) + line) + (for/collect ((datum (in-port datum-port))) + datum) + (for/collect ((byte (in-bytes byte-port))) + byte) + (take (in-naturals 10) 5) + (let ((n 0)) + (for/collect + ((x (in-producer + (lambda () + (set! n (+ n 1)) + (if (> n 3) (eof-object) n))))) + x))) +`, + }, + { + name: "Alists", + source: `(import (jerboa prelude)) + +(def person + (alist (name "Alice") (age 30) (city "Boise"))) + +(def settings + '(mode "debug" port 8080)) + +(list + person + (let-alist person (name age city) + (list name age city)) + (agetq 'age person 'missing) + (asetq 'age 31 person) + (aremq 'city person) + (alist->plist* person) + (plist->alist* settings) + (pgetq 'mode settings 'missing) + (psetq 'port 9090 settings) + (premq 'mode settings) + (let ((out '())) + (dotimes (i 4) + (set! out (cons i out))) + (reverse out))) +`, + }, + { + name: "Data Interchange", + source: `(import (jerboa prelude)) + +(def payload + (string->json-object + (json-object->string + (list->hash-table + '(("name" . "Alice") + ("age" . 30) + ("tags" . ("ops" "wasm")) + ("active" . #t)))))) + +(def rows + '(("name" "age") ("Alice" "30") ("Bob" "25"))) + +(def people + '(((name . "Alice") (age . "30")) + ((name . "Bob") (age . "25")))) + +(list + (hash-ref payload "name") + (hash-ref payload "tags") + (json-object->string (list->hash-table '(("ok" . #t) ("count" . 2)))) + (read-csv "name,age\\nAlice,30\\n") + (csv->alists "name,age\\nAlice,30\\nBob,25") + (alists->csv people) + (rows->csv-string rows) + (call-with-output-string + (lambda (p) + (write-json payload p)))) +`, + }, + { + name: "Datetime", + source: `(import (jerboa prelude)) + +(def started + (parse-datetime "2024-03-25T10:30:05.123Z")) + +(def local + (make-datetime 2024 3 25 10 30 5 123000000 -420)) + +(def wait + (duration 90 250)) + +(list + (datetime->iso8601 started) + (date->string local) + (time->string local) + (datetime->epoch local) + (datetime->julian local) + (datetime->alist local) + (duration-seconds wait) + (duration-nanoseconds wait) + (datetime->iso8601 (datetime-add local 3600)) + (datetime-diff (datetime-add local 60) local) + (datetime<? local (datetime-add local 1)) + (datetime->iso8601 (datetime-floor-day local)) + (day-of-week 2024 3 25) + (day-of-year 2024 3 25) + (days-in-month 2024 2) + (leap-year? 2024)) +`, + }, + { + name: "Data Definitions", + source: `(import (jerboa prelude)) + +(define-enum status (queued running complete failed)) +(defrecord job (id owner state)) + +(def current + (make-job 42 "ops" status-running)) + +(list + status-queued + status-running + (status? status-complete) + (status? 'running) + (status->name (job-state current)) + (job? current) + (job-id current) + (job-owner current) + (job->alist current)) +`, + }, + { + name: "Paths", + source: `(import (jerboa prelude)) + +(def path + (path-join "/home" "jerboa" "notes.ss")) + +(list + path + (path-directory path) + (path-strip-directory path) + (path-extension path) + (path-strip-extension path) + (path-absolute? path) + (path-absolute? "relative.ss")) +`, + }, + { + name: "Sequence Tools", + source: `(import (jerboa prelude)) + +(list + (sort '(3 1 2) <) + (partition-all 2 '(1 2 3 4 5)) + (partition-by odd? '(1 3 2 4 5 7)) + (split-with odd? '(1 3 2 4)) + (reductions + 0 '(1 2 3)) + (iterate-n 5 1+ 0) + (slice '(a b c d e) 1 3) + (map/car 1+ '(1 2 3)) + (list + (length=? '(a b c) '(1 2 3)) + (length<n? '(a b c) 4) + (length>n? '(a b c) 2) + (first-and-only '(done)))) +`, + }, + { + name: "State Cells", + source: `(import (jerboa prelude)) + +(def counter (atom 0)) +(def shared-score (make-shared 10)) +(def scratch (volatile! 'draft)) + +(list + (atom? counter) + (deref counter) + (reset! counter 5) + (swap! counter + 3) + (compare-and-set! counter 8 13) + (deref counter) + (shared-ref shared-score) + (shared-set! shared-score 11) + (shared-swap! shared-score 1+) + (volatile? scratch) + (vderef scratch) + (vreset! scratch 'ready) + (vswap! scratch (lambda (x) (list x 'done)))) +`, + }, + { + name: "Metadata And Nested", + source: `(import (jerboa prelude)) + +(def wrapped + (with-meta '(1 2) (hash 'source "demo"))) + +(def state + (hash "user" (hash "name" "Alice" "count" 1))) + +(def updated + (assoc-in state '("user" "name") "Bob")) + +(assoc-in! state '("user" "count") 2) +(update-in! state '("user" "count") + 40) + +(def copied + (update-in updated '("user" "name") string-upcase)) + +(def v + (vector (hash "x" 1) 2)) + +(assoc-in! v '(0 "x") 9) + +(defstruct point (x y)) +(def p (make-point 3 4)) + +(list + (meta-wrapped? wrapped) + (hash-ref (meta wrapped) 'source) + (strip-meta wrapped) + (equal? wrapped '(1 2)) + (get-in state '("user" "count")) + (get-in updated '("user" "name")) + (get-in copied '("user" "name")) + (get-in v '(0 "x")) + (nested-get p 'x 'missing) + (get-in p '(y))) +`, + }, + { + name: "Clojure Style", + source: `(import (jerboa prelude) (std clojure)) + +(defn passing? [score] + (>= score 80)) + +(defn bump [score] + (inc score)) + +(def scores '(72 81 94 67 88)) + +(displayln "passing after bump") +(filter passing? (map bump scores)) +(reduce (lambda [acc score] (+ acc score)) 0 scores) +`, + }, + { + name: "Runtime", + source: `(import (jerboa prelude)) + +(list + (jerboa-system) + (jerboa-version-string) + (jerboa-home) + (features) + (current-second) + (system-type)) +`, + }, +]; +const editor = document.querySelector("[data-repl-editor]"); +const exampleSelect = document.querySelector("[data-repl-example]"); +const loadButton = document.querySelector("[data-repl-load]"); +const runButton = document.querySelector("[data-repl-run]"); +const resetButton = document.querySelector("[data-repl-reset]"); +const clearButton = document.querySelector("[data-repl-clear]"); +const transcript = document.querySelector("[data-repl-transcript]"); +const status = document.querySelector("[data-repl-status]"); + +let worker = null; +let nextId = 1; +let activeId = null; +let hardTimer = null; +let highlightLayer = null; + +const highlightKeywords = new Set([ + "and", + "begin", + "case", + "cond", + "def", + "defclass", + "defmethod", + "defstruct", + "define", + "defn", + "display", + "displayln", + "do", + "else", + "filter", + "foldl", + "for", + "if", + "import", + "lambda", + "let", + "let*", + "match", + "map", + "or", + "receive", + "quote", + "reduce", + "try", + "unless", + "when", +]); + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +function span(className, text) { + return `<span class="${className}">${escapeHtml(text)}</span>`; +} + +function highlightSource(source) { + let html = ""; + let index = 0; + while (index < source.length) { + const ch = source[index]; + if (ch === ";") { + const end = source.indexOf("\n", index); + const stop = end === -1 ? source.length : end; + html += span("tok-comment", source.slice(index, stop)); + index = stop; + continue; + } + if (ch === '"') { + let cursor = index + 1; + while (cursor < source.length) { + if (source[cursor] === "\\") { + cursor += 2; + } else if (source[cursor] === '"') { + cursor += 1; + break; + } else { + cursor += 1; + } + } + html += span("tok-string", source.slice(index, cursor)); + index = cursor; + continue; + } + if ("()[]{}".includes(ch)) { + html += span("tok-paren", ch); + index += 1; + continue; + } + if (ch === "'") { + html += span("tok-quote", ch); + index += 1; + continue; + } + if (/\s/.test(ch)) { + html += ch === "\n" ? "\n" : escapeHtml(ch); + index += 1; + continue; + } + let cursor = index; + while (cursor < source.length && !/\s/.test(source[cursor]) && !"()[]{}\";".includes(source[cursor])) { + cursor += 1; + } + const token = source.slice(index, cursor); + if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(token)) { + html += span("tok-number", token); + } else if (token.startsWith("#:") || token.endsWith(":")) { + html += span("tok-keyword", token); + } else if (token.startsWith(":")) { + html += span("tok-module", token); + } else if (highlightKeywords.has(token)) { + html += span("tok-form", token); + } else if (/^[*/+<>=!?$%_&~^.-]+$/.test(token)) { + html += span("tok-operator", token); + } else { + html += span("tok-symbol", token); + } + index = cursor; + } + return html + (source.endsWith("\n") ? " " : "\n"); +} + +function refreshHighlight() { + if (!highlightLayer) { + return; + } + highlightLayer.innerHTML = highlightSource(editor.value); +} + +function syncHighlightScroll() { + if (!highlightLayer) { + return; + } + highlightLayer.scrollTop = editor.scrollTop; + highlightLayer.scrollLeft = editor.scrollLeft; +} + +function installHighlighter() { + const shell = document.createElement("div"); + shell.className = "repl-editor-shell"; + highlightLayer = document.createElement("pre"); + highlightLayer.className = "repl-highlight"; + editor.parentNode.insertBefore(shell, editor); + shell.appendChild(highlightLayer); + shell.appendChild(editor); + refreshHighlight(); + syncHighlightScroll(); +} + +function installExamples() { + for (const example of examples) { + const option = document.createElement("option"); + option.value = example.name; + option.textContent = example.name; + exampleSelect.appendChild(option); + } + exampleSelect.value = examples[0].name; + editor.value = examples[0].source; + refreshHighlight(); +} + +function selectedExample() { + return examples.find((example) => example.name === exampleSelect.value) || examples[0]; +} + +function appendLine(className, text) { + const line = document.createElement("div"); + line.className = className; + const pre = document.createElement("pre"); + pre.textContent = text; + line.appendChild(pre); + transcript.appendChild(line); + transcript.scrollTop = transcript.scrollHeight; +} + +function setStatus(text) { + status.textContent = text; +} + +function setRunning(running) { + runButton.disabled = running || !worker; + resetButton.disabled = false; +} + +function killWorker(message) { + if (worker) { + worker.terminate(); + } + worker = null; + activeId = null; + clearTimeout(hardTimer); + setRunning(true); + appendLine("repl-error", message); + startWorker(); +} + +function startWorker() { + setStatus("Loading engine"); + runButton.disabled = true; + worker = new Worker(config.workerUrl, { type: "module" }); + const id = nextId++; + activeId = id; + worker.onmessage = (event) => { + const message = event.data || {}; + if (message.id && message.id !== activeId && message.kind !== "ready") { + return; + } + if (message.kind === "ready") { + activeId = null; + setStatus(`Ready: ABI ${message.abi}, ${message.subsetRevision}`); + runButton.disabled = false; + return; + } + if (message.kind === "result") { + clearTimeout(hardTimer); + activeId = null; + setRunning(false); + renderResult(message.result, message.elapsedMs); + return; + } + if (message.kind === "fatal") { + clearTimeout(hardTimer); + killWorker(`Engine restarted: ${message.message}`); + } + }; + worker.onerror = () => { + killWorker("Engine restarted after a worker failure"); + }; + worker.postMessage({ + kind: "init", + id, + wasmUrl: config.wasmUrl, + expectedAbi: config.abi, + }); +} + +function renderResult(result, elapsedMs) { + setStatus(`${result.status} in ${elapsedMs} ms`); + for (const event of result.events || []) { + appendLine(event.kind === "stdout" ? "repl-stdout" : "repl-value", event.text); + } + if (result.error) { + const where = result.error.line ? ` at ${result.error.line}:${result.error.column}` : ""; + appendLine("repl-error", `${result.status}${where}: ${result.error.message}`); + } +} + +function run() { + if (!worker || activeId) { + return; + } + const source = editor.value; + appendLine("repl-source", `> ${source}`); + const id = nextId++; + activeId = id; + setRunning(true); + setStatus("Running"); + hardTimer = setTimeout(() => { + killWorker("Engine timed out after 2000 ms; session state was reset"); + }, 2000); + worker.postMessage({ kind: "eval", id, source }); +} + +runButton.addEventListener("click", run); +loadButton.addEventListener("click", () => { + editor.value = selectedExample().source; + refreshHighlight(); + syncHighlightScroll(); + editor.focus(); +}); +resetButton.addEventListener("click", () => { + if (worker) { + worker.terminate(); + } + worker = null; + activeId = null; + clearTimeout(hardTimer); + appendLine("repl-note", "Session reset"); + startWorker(); +}); +clearButton.addEventListener("click", () => { + transcript.textContent = ""; +}); +editor.addEventListener("keydown", (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { + event.preventDefault(); + run(); + } +}); +editor.addEventListener("input", refreshHighlight); +editor.addEventListener("scroll", syncHighlightScroll); + +if (!("Worker" in window) || !("WebAssembly" in window)) { + setStatus("Browser does not support this REPL"); + runButton.disabled = true; + resetButton.disabled = true; +} else { + installHighlighter(); + installExamples(); + startWorker(); +} --- a/data/jerboa-source.json +++ b/data/jerboa-source.json @@ -2,14 +2,14 @@ "schema": "jerboa.site-source.v1", "repo": { "path": "jerboa", - "remote": "https://git.sr.ht/~lisp/jerboa", - "branch": "master", - "commit": "1cf9afb74a3d5da7ff9ee76e477dae2332364f74", - "version": "0.2.3", + "remote": "https://git.jerboa.sh/ober/jerboa", + "branch": "HEAD", + "commit": "ae716feb376ce9adefcad65e923ebfd866131947", + "version": "0.2.8", "source_tree": "HEAD", "working_tree_dirty": false }, - "generated_at": "2026-06-19T21:47:35Z", + "generated_at": "2026-07-30T17:45:56Z", "site_contract": { "human_docs": "docs/", "machine_data": "data/", @@ -18,128 +18,116 @@ }, "canonical_sources": { "docs": [ - {"path": "docs/Chez-changes.md", "sha256": "f8866866fe2eac24b969470b41c5e89ed63703b831b6c43ae37053dcae97facf", "bytes": 4500}, - {"path": "docs/Future-proofing.md", "sha256": "927b8f6ec50d338e570c90e0f1decf7cfd8847824646ceae6b3079910841a310", "bytes": 17232}, - {"path": "docs/JERBOA-LANG.md", "sha256": "60acc5ef32957b9e4608a65c237993e5699bcead31e570ad377175931573f49f", "bytes": 27440}, + {"path": "docs/Chez-changes.md", "sha256": "1192280f33100fd1fecac75260d2d36bc8a783d5aeb7218a4e457621eeb03027", "bytes": 4901}, + {"path": "docs/JERBOA-LANG.md", "sha256": "57ee01608e3bf8f2e8dc6cb257ef206ca515fc0cf5d956fe99430432fe279b7e", "bytes": 28382}, {"path": "docs/Philosophy.md", "sha256": "8d388cb1162837f0a53311f71bcc4e98dbbd184832ca83c649f58b2170c70669", "bytes": 13970},