@@ -296,21 +274,7 @@ function renderCode(pane, f, pick, stem) {
pane.querySelector('[data-k="save"]')?.addEventListener('click', () => download(cur.code, fileName(cur.name, cur.code, stem)))
const run = pane.querySelector('[data-k="run"]')
if (run) run.addEventListener('click', async () => {
- // The emitted module, imported as it is. Its exports are printed, nothing is called.
- let out
- try {
- const url = URL.createObjectURL(new Blob([cur.code], { type: 'text/javascript' }))
- const mod = await import(url)
- URL.revokeObjectURL(url)
- const names = Object.keys(mod)
- out = `import ok: ${names.length} export(s)\n` + names.map((k) => {
- const v = mod[k]
- const s = typeof v === 'function' ? `function(${v.length})` : typeof v === 'bigint' ? `${v}n` : (() => { try { return JSON.stringify(v, (_, x) => (typeof x === 'bigint' ? `${x}n` : x)) } catch { return String(v) } })()
- return ` ${k} = ${String(s).slice(0, 160)}`
- }).join('\n')
- } catch (e) {
- out = `import failed: ${e.message}`
- }
+ const out = await shell.importJs(cur.code, cfg)
let pre = pane.querySelector('pre.out')
if (!pre) { pre = document.createElement('pre'); pre.className = 'out'; pane.querySelector('.code').appendChild(pre) }
pre.textContent = out
@@ -396,26 +360,23 @@ ${fns ? `
Functions inputsbits · result bits ·
pane.querySelector('[data-k="cmds"]').addEventListener('click', (e) => copyText(tryCmds.join('\n'), e.currentTarget))
}
-// --- term: a recording of t27c on the same file ------------------------------------------------
-let termMounted = null
-async function renderTerm(pane, cast, specName) {
- if (termMounted) return
- termMounted = true
+// --- term: a terminal over the same compiler (./shell.js), and the recording of t27c ------------
+async function replayCast(el, cast, specName) {
try {
const { mount } = await import(at('term/player.js').href)
- pane.innerHTML = ''
- mount(pane.firstChild, { src: new URL(`term/${cast}/session.cast`, root).href, title: `t27c on ${specName}`, share: new URL(`term/${cast}/`, root).href })
+ mount(el, { src: new URL(`term/${cast}/session.cast`, root).href, title: `t27c on ${specName}`, share: new URL(`term/${cast}/`, root).href })
} catch (e) {
- pane.innerHTML = `
The recording did not load: ${html(e.message)}
`
+ el.innerHTML = `
The recording did not load: ${html(e.message)}
`
}
}
// --- the page -----------------------------------------------------------------------------------
async function main() {
const v = build ? `?v=${build}` : ''
- ;[site, { readRtl }] = await Promise.all([
+ ;[site, { readRtl }, shell] = await Promise.all([
import(new URL('site.js' + v, import.meta.url).href).catch((e) => { throw new Error(`site.js did not load: ${e.message}`) }),
import(new URL('rtl.js' + v, import.meta.url).href).catch((e) => { throw new Error(`rtl.js did not load: ${e.message}`) }),
+ import(new URL('shell.js' + v, import.meta.url).href).catch((e) => { throw new Error(`shell.js did not load: ${e.message}`) }),
])
for (const [k, c] of Object.entries(site.CLS_COLOR)) document.documentElement.style.setProperty(`--h-${k}`, c)
for (const [k, c] of Object.entries(site.C)) if (typeof c === 'string' && !/[;{}]/.test(c)) document.documentElement.style.setProperty(`--c-${k}`, c)
@@ -423,7 +384,7 @@ async function main() {
status('compiling player.t27…')
const orchRes = await fetch(new URL('player.t27' + (build ? `?v=${build}` : ''), import.meta.url))
if (!orchRes.ok) throw new Error(`player.t27 did not load (HTTP ${orchRes.status})`)
- const cfg = playerConfig(analyze(await orchRes.text(), 'player.t27'))
+ cfg = playerConfig(analyze(await orchRes.text(), 'player.t27'))
document.documentElement.style.setProperty('--ed-touch-font', `${cfg.EDITOR_TOUCH_FONT_PX}px`)
const specPath = query.get('spec') || cfg.PLAY_SPECS[0]
@@ -442,8 +403,8 @@ async function main() {
if (!res.ok) throw new Error(`${specPath} is not published (HTTP ${res.status})`)
let source = await res.text()
- // Tabs, from the spec. Term only when there is a recording to show.
- const tabs = cfg.TABS.map((id, i) => ({ id, label: cfg.TAB_LABELS[i] })).filter((t) => t.id !== 'term' || cast)
+ // Tabs, from the spec.
+ const tabs = cfg.TABS.map((id, i) => ({ id, label: cfg.TAB_LABELS[i] }))
const nav = $('#tabs')
nav.innerHTML = tabs.map((t) => ``).join('')
const wideQuery = matchMedia(`(min-width: ${cfg.WIDE_MIN_PX}px) and (min-aspect-ratio: 4/3)`)
@@ -453,7 +414,7 @@ async function main() {
current = id
for (const b of nav.querySelectorAll('button')) b.setAttribute('aria-selected', String(b.dataset.tab === id))
for (const p of document.querySelectorAll('.pane')) p.classList.toggle('on', p.id === `pane-${id}`)
- if (id === 'term' && cast) renderTerm($('#pane-term'), cast, specName)
+ if (id === 'term') openTerm()
if (id !== 'compile' && player) player.end()
}
const layout = () => {
@@ -514,6 +475,21 @@ async function main() {
src.dispatchEvent(new Event('input'))
})
+ // The terminal, mounted the first time its tab opens. On a backend's share page with autoplay,
+ // it types SHELL_DEMO for that backend, at SHELL_TYPE_MS a character, and runs it.
+ let term = null
+ const openTerm = () => {
+ if (term) return
+ term = shell.mountShell($('#pane-term'), {
+ cfg, specName, stem, analyze, download, codeHtml,
+ source: () => src.value,
+ specHtml: (analysis) => site.highlightSource(src.value, analysis.tokens ?? []).map(spansHtml).join('\n'),
+ replay: cast ? (el) => replayCast(el, cast, specName) : null,
+ backend: query.get('backend'),
+ })
+ if (autoplay && !reduced && query.get('tab') === 'term') term.type(cfg.SHELL_DEMO.replaceAll('{backend}', cfg.BACKENDS.includes(query.get('backend')) ? query.get('backend') : cfg.BACKENDS[0]), cfg.SHELL_TYPE_MS)
+ }
+
const autoplay = cfg.AUTOPLAY_PARAMS.some((k) => ['1', 'true'].includes(query.get(k)))
layout()
const want = query.get('tab')
diff --git a/apps/website/public/play/hello-world/c/card.png b/apps/website/public/play/hello-world/c/card.png
new file mode 100644
index 0000000000..1dfa869fe1
Binary files /dev/null and b/apps/website/public/play/hello-world/c/card.png differ
diff --git a/apps/website/public/play/hello-world/c/index.html b/apps/website/public/play/hello-world/c/index.html
new file mode 100644
index 0000000000..808a3899ce
--- /dev/null
+++ b/apps/website/public/play/hello-world/c/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+hello_world.t27 to C, in a terminal inside the post · Trinity S³AI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/website/public/play/hello-world/index.html b/apps/website/public/play/hello-world/index.html
index 2a38f997d6..43ecad5693 100644
--- a/apps/website/public/play/hello-world/index.html
+++ b/apps/website/public/play/hello-world/index.html
@@ -1,35 +1,35 @@
-
+
hello_world.t27, compiled inside the post · Trinity S³AI
-
+
-
+
-
+
-
-
+
+
-
+
-
-
+
+
diff --git a/apps/website/public/play/hello-world/js/card.png b/apps/website/public/play/hello-world/js/card.png
new file mode 100644
index 0000000000..9b5e16befe
Binary files /dev/null and b/apps/website/public/play/hello-world/js/card.png differ
diff --git a/apps/website/public/play/hello-world/js/index.html b/apps/website/public/play/hello-world/js/index.html
new file mode 100644
index 0000000000..69451d0e9b
--- /dev/null
+++ b/apps/website/public/play/hello-world/js/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+hello_world.t27 to JavaScript, in a terminal inside the post · Trinity S³AI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/website/public/play/hello-world/rust/card.png b/apps/website/public/play/hello-world/rust/card.png
new file mode 100644
index 0000000000..670936b6c8
Binary files /dev/null and b/apps/website/public/play/hello-world/rust/card.png differ
diff --git a/apps/website/public/play/hello-world/rust/index.html b/apps/website/public/play/hello-world/rust/index.html
new file mode 100644
index 0000000000..be6e2a48b3
--- /dev/null
+++ b/apps/website/public/play/hello-world/rust/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+hello_world.t27 to Rust, in a terminal inside the post · Trinity S³AI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/website/public/play/hello-world/ts/card.png b/apps/website/public/play/hello-world/ts/card.png
new file mode 100644
index 0000000000..1a93c2e9cd
Binary files /dev/null and b/apps/website/public/play/hello-world/ts/card.png differ
diff --git a/apps/website/public/play/hello-world/ts/index.html b/apps/website/public/play/hello-world/ts/index.html
new file mode 100644
index 0000000000..515e316125
--- /dev/null
+++ b/apps/website/public/play/hello-world/ts/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+hello_world.t27 to TypeScript, in a terminal inside the post · Trinity S³AI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/website/public/play/hello-world/verilog-hir/card.png b/apps/website/public/play/hello-world/verilog-hir/card.png
new file mode 100644
index 0000000000..ae74854d3e
Binary files /dev/null and b/apps/website/public/play/hello-world/verilog-hir/card.png differ
diff --git a/apps/website/public/play/hello-world/verilog-hir/index.html b/apps/website/public/play/hello-world/verilog-hir/index.html
new file mode 100644
index 0000000000..94b5f63b65
--- /dev/null
+++ b/apps/website/public/play/hello-world/verilog-hir/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+hello_world.t27 to Verilog (HIR), in a terminal inside the post · Trinity S³AI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/website/public/play/hello-world/verilog/card.png b/apps/website/public/play/hello-world/verilog/card.png
new file mode 100644
index 0000000000..50f950fe57
Binary files /dev/null and b/apps/website/public/play/hello-world/verilog/card.png differ
diff --git a/apps/website/public/play/hello-world/verilog/index.html b/apps/website/public/play/hello-world/verilog/index.html
new file mode 100644
index 0000000000..10c5dde7ba
--- /dev/null
+++ b/apps/website/public/play/hello-world/verilog/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+hello_world.t27 to Verilog, in a terminal inside the post · Trinity S³AI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/website/public/play/hello-world/zig/card.png b/apps/website/public/play/hello-world/zig/card.png
new file mode 100644
index 0000000000..2210b27341
Binary files /dev/null and b/apps/website/public/play/hello-world/zig/card.png differ
diff --git a/apps/website/public/play/hello-world/zig/index.html b/apps/website/public/play/hello-world/zig/index.html
new file mode 100644
index 0000000000..68cb65b319
--- /dev/null
+++ b/apps/website/public/play/hello-world/zig/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+hello_world.t27 to Zig, in a terminal inside the post · Trinity S³AI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/website/public/play/player.t27 b/apps/website/public/play/player.t27
index b327b13e8e..efac42b7e6 100644
--- a/apps/website/public/play/player.t27
+++ b/apps/website/public/play/player.t27
@@ -23,8 +23,12 @@
; the spec's own test blocks. Code shows what each backend emitted, coloured the way the spec
; explorer colours it. RTL reads the emitted Verilog the way a hardware engineer would before
; synthesis: the module's ports and parameters, the bit map of every packed struct, the
-; functions, and what a Verilog-2005 tool will refuse in that text. Term replays a recording of
-; t27c on the same file in a terminal.
+; functions, and what a Verilog-2005 tool will refuse in that text. Term is a terminal over the
+; same compiler: `t27c gen rust` prints the Rust it emits for the spec in the editor, and so for
+; each of the seven backends; `t27c test`, `ls`, `cat` and `save` work on what it made; `run`
+; executes the JavaScript backend's module in the page; `replay` plays a recording of t27c on
+; the same file, made on a real machine. Each backend has a share page of its own, so a post can
+; open the terminal on that backend.
; WHAT IS DELIBERATELY ABSENT: sound, a sign-in, a cookie, a request to any host but t27.ai,
; and a test outcome this page made up. A block the evaluator cannot follow is a skip and says
@@ -37,8 +41,9 @@ pub const KIND : str = "player";
pub const ID : str = "x/player";
pub const NAME : str = "t27 in a post";
; The derived files the generator rewrites (relative to apps/website): this spec's public copy,
-; then the site modules below as one ES module, then one share page per play, in PLAY_IDS order.
-pub const GENERATED : [3]str = ["public/play/player.t27", "public/play/site.js", "public/play/hello-world/index.html"];
+; then the site modules below as one ES module, then per play, in PLAY_IDS order, its share page
+; and one share page per backend, in BACKENDS order.
+pub const GENERATED : [10]str = ["public/play/player.t27", "public/play/site.js", "public/play/hello-world/index.html", "public/play/hello-world/zig/index.html", "public/play/hello-world/c/index.html", "public/play/hello-world/rust/index.html", "public/play/hello-world/verilog/index.html", "public/play/hello-world/verilog-hir/index.html", "public/play/hello-world/js/index.html", "public/play/hello-world/ts/index.html"];
; --- What the player takes from the site -------------------------------------------------------
; The spec explorer's highlighter and palette, bundled from the site's own source rather than
@@ -101,6 +106,83 @@ pub const OUTCOMES : [3]str = ["pass", "fail", "skip"];
pub const SKIP_IS_NEVER_PASS : bool = true;
pub const TESTS_NOTE : str = "Run here, in your browser, by a small evaluator over the compiler's syntax tree. Fail means an assert was false, or the block divided by zero or indexed past the end. Anything the evaluator cannot follow is a skip and names the reason; a skip is never counted as a pass.";
+; --- Term: a terminal over the same compiler --------------------------------------------------
+; The compiler's backends, in the order every tab lists them. The generator refuses this list
+; unless it is exactly the set of targets the compiler answers with, so a backend added to t27c
+; and not named here (or named here and gone from t27c) stops the build instead of going unseen.
+pub const BACKENDS : [7]str = ["zig", "c", "rust", "verilog", "verilog_hir", "js", "ts"];
+pub const BACKEND_NAMES : [7]str = ["Zig", "C", "Rust", "Verilog", "Verilog (HIR)", "JavaScript", "TypeScript"];
+; The emitted file's name is the spec's stem plus this; Verilog takes its module's name instead.
+pub const BACKEND_EXTS : [7]str = [".zig", ".c", ".rs", ".v", ".hir.v", ".js", ".ts"];
+; Each backend's share page: public/play///, the terminal opened on that backend.
+pub const BACKEND_PAGES : [7]str = ["zig", "c", "rust", "verilog", "verilog-hir", "js", "ts"];
+; What `run` prints for the backends this page cannot run: the command on a machine that has
+; the toolchain. {file} is the emitted file. The terminal prints it; it never claims its outcome.
+pub const BACKEND_RUN_LOCAL : [7]str = ["zig test {file}", "cc -std=c11 -c {file}", "rustc --crate-type lib --edition 2021 {file}", "iverilog -g2012 -o /dev/null {file}", "iverilog -g2012 -o /dev/null {file}", "node {file}", "tsc --noEmit --strict {file}"];
+; The backends whose output this page runs itself. A browser has a JavaScript engine; it has no
+; zig, cc, rustc, iverilog or tsc, and this page fetches nothing from any other host to fake one.
+pub const RUNS_IN_BROWSER : [1]str = ["js"];
+; The commands, what each takes, and the line `help` prints for it. Two words are one command.
+pub const SHELL_COMMANDS : [12]str = ["help", "ls", "cat", "save", "lang", "t27c gen", "t27c test", "t27c check", "run", "replay", "history", "clear"];
+pub const SHELL_ARGS : [12]str = ["", "", "", "", "[backend]", "[backend]", "", "", "[backend]", "", "", ""];
+pub const SHELL_HELP : [12]str = ["this list", "the spec, and the file each backend emitted from it", "print a file", "download a file", "show the backend, or pick one", "compile the spec in the editor and print what the backend emits", "run the spec's test and invariant blocks", "what each compiler stage made of the spec", "run the emitted code, where this page can: JavaScript", "play a recording of t27c on this spec, made on a real machine", "the commands typed so far", "clear the screen"];
+; The commands a tap runs, for a reader with no keyboard; the backends are taps of their own.
+pub const SHELL_CHIPS : [5]str = ["t27c test", "run", "ls", "replay", "help"];
+pub const SHELL_NOTE : str = "Every line here is printed by the t27 compiler running in this page, or by the JavaScript it emitted. Nothing is sent anywhere. A browser has no zig, cc, rustc or iverilog: for those backends, run prints the command for your machine.";
+; Opened on a backend with autoplay, the terminal types this one command and runs it.
+pub const SHELL_DEMO : str = "t27c gen {backend}";
+pub const SHELL_TYPE_MS : u8 = 45;
+pub const SHELL_DEMO_MAX_CHARS : u8 = 32;
+; A backend's share page. {spec} is the play's file name, {lang} its BACKEND_NAMES entry and
+; {backend} its BACKENDS entry. Filled in, each is held to X's limits like any play's.
+pub const SHELL_PAGE_TITLE : str = "{spec} to {lang}, in a terminal inside the post";
+pub const SHELL_PAGE_DESCRIPTION : str = "A terminal in your browser running the t27 compiler as WebAssembly: t27c gen {backend} prints the {lang} it emits for {spec}, and t27c test runs the spec's tests.";
+pub const SHELL_PAGE_IMAGE_ALT : str = "The t27 player's terminal: t27c gen {backend} on {spec}, and the {lang} the compiler emitted, coloured.";
+
+; --- What the terminal says ------------------------------------------------------------------
+; Every word the terminal prints is one of these; public/play/shell.js holds none of its own.
+; It fills the {names} and lays the columns out, nothing more. The generator refuses a SAY_
+; constant that shell.js never says and a phrase shell.js says that is not here, so the two
+; cannot drift. The name sets the colour: SAY_ERR_ red, SAY_WARN_ yellow, SAY_NOTE_ dim.
+; shell.js is JavaScript by hand for one reason, measured: gen-js lowers declarations, not
+; function bodies, so the terminal's logic cannot yet be a t27 spec compiled by this compiler.
+pub const STAGES : [7]str = ["source", "lexer", "parser", "types", "hir", "backends", "tests"];
+pub const SAY_CHECK : [6]str = ["{bytes} bytes, {lines} lines", "{tokens} tokens, {kinds} kinds, {dropped} dropped", "{nodes} nodes, depth {depth}, {top} top-level", "{errors} errors", "{bytes} bytes", "{ok} of {all} emitted, {bytes} bytes"];
+pub const SAY_BANNER : str = "t27 terminal - {spec} - {count} backends";
+pub const SAY_NOTE_START : str = "type help, or tap a command below";
+pub const SAY_NOTE_BACKENDS : str = "backends: {backends}";
+pub const SAY_NOTE_ALSO : str = "also understood: {alias}, the local command of a backend that runs here, and the names of native tools";
+pub const SAY_SPEC_FILE : str = "the spec";
+pub const SAY_RUNS_HERE : str = "runs in this page";
+pub const SAY_FAILED : str = "failed";
+pub const SAY_ERR_WHICH_FILE : str = "{cmd}: which file? ls lists them";
+pub const SAY_ERR_NO_FILE : str = "{cmd}: {file}: no such file; ls lists them";
+pub const SAY_ERR_NOT_EMITTED : str = "{backend} emitted nothing: {error}";
+pub const SAY_SAVED : str = "asked your browser to download {file}, {bytes} bytes";
+pub const SAY_ERR_NO_BACKEND : str = "{cmd}: no backend called {name}; the backends are {backends}";
+pub const SAY_NOTE_GEN : str = "// {file}: {bytes} bytes of {lang}, compiled in {ms} ms by t27_compiler.wasm in this page";
+pub const SAY_ERR_RUNNER : str = "the test runner stopped: {error}";
+pub const SAY_NOTE_NO_TESTS : str = "this spec has no test or invariant block";
+pub const SAY_TESTS : str = "{pass} pass, {fail} fail, {skip} skip";
+pub const SAY_ASSERTS : str = "asserts: {count}";
+pub const SAY_NOTE_CHECKED : str = "compiled in {ms} ms, in this page";
+pub const SAY_NOTE_IMPORTING : str = "importing {file} into this page; its exports are printed, nothing is called";
+pub const SAY_IMPORT_OK : str = "import ok: {count} exports";
+pub const SAY_ERR_IMPORT : str = "import failed: {error}";
+pub const SAY_WARN_NOT_HERE : str = "{lang} does not run in a browser: this page has no {tool}. On a machine that has it:";
+pub const SAY_LOCAL_STEPS : [2]str = [" $ save {file}", " $ {command}"];
+pub const SAY_NOTE_NO_CAST : str = "there is no recording of t27c on this spec";
+pub const SAY_NOTE_REPLAY : str = "a recording of t27c on this spec, made on a real machine: the typing is staged, every byte printed is real";
+pub const SAY_WARN_NATIVE : str = "{tool} is not in your browser: this page has the t27 compiler and nothing else";
+pub const SAY_NOTE_NATIVE_RUN : str = "run {backend} prints the command for your machine";
+pub const SAY_ERR_T27C : str = "t27c {sub}: not in this terminal; it has {commands}";
+pub const SAY_ERR_NOT_FOUND : str = "{cmd}: command not found; help lists what this terminal does";
+pub const SAY_ERR_THREW : str = "{cmd} stopped: {error}";
+; Names a reader may type for a toolchain this page does not have, besides the first word of
+; each BACKEND_RUN_LOCAL; and the real t27c's spelling of gen, which the terminal also takes.
+pub const NATIVE_TOOLS : [7]str = ["gcc", "clang", "cargo", "verilator", "yosys", "deno", "bun"];
+pub const SHELL_GEN_ALIAS : str = "t27c gen-{backend}";
+
; --- Plays ---------------------------------------------------------------------------------------
; One entry per play, the same position in every array. SPEC is a path under public/t27/files/;
; CAST names a recording published at public/term//.
@@ -109,8 +191,8 @@ pub const PLAY_IDS : [1]str = ["hello-world"];
pub const PLAY_SPECS : [1]str = ["specs/demos/hello_world.t27"];
pub const PLAY_CASTS : [1]str = ["t27c-hello-world"];
pub const PLAY_TITLES : [1]str = ["hello_world.t27, compiled inside the post"];
-pub const PLAY_DESCRIPTIONS : [1]str = ["The t27 compiler, as WebAssembly, running on one spec in your browser: lexer to seven backends in eight seconds, the spec's own tests, the emitted code, its Verilog read for synthesis, and a t27c run."];
-pub const PLAY_IMAGE_ALTS : [1]str = ["The t27 player: hello_world.t27 on the left, the compiler's stages on the right with token, node and backend counts, and tabs for tests, code, RTL and a terminal recording."];
+pub const PLAY_DESCRIPTIONS : [1]str = ["The t27 compiler, as WebAssembly, running on one spec in your browser: lexer to seven backends, the spec's own tests, the emitted code, its Verilog read for synthesis, and a terminal."];
+pub const PLAY_IMAGE_ALTS : [1]str = ["The t27 player: hello_world.t27 on the left, the compiler's stages on the right with token, node and backend counts, and tabs for tests, code, RTL and a terminal."];
test the_player_is_sixteen_by_nine {
assert PLAYER_WIDTH * 9 == PLAYER_HEIGHT * 16;
@@ -156,8 +238,43 @@ test the_site_bundle_is_generated_with_the_rest {
assert SITE_MODULES[0] == "src/lib/highlight.ts";
}
+test the_terminal_speaks_every_backend {
+ assert BACKENDS[0] == "zig";
+ assert BACKENDS[2] == "rust";
+ assert BACKENDS[4] == "verilog_hir";
+ assert BACKEND_PAGES[4] == "verilog-hir";
+ assert BACKEND_EXTS[4] == ".hir.v";
+ assert BACKEND_NAMES[5] == "JavaScript";
+}
+
+test only_javascript_runs_in_the_browser {
+ assert RUNS_IN_BROWSER[0] == "js";
+ assert BACKENDS[5] == RUNS_IN_BROWSER[0];
+}
+
+test the_typed_command_ends_inside_the_autoplay_limit {
+ assert SHELL_DEMO_MAX_CHARS * SHELL_TYPE_MS <= AUTOPLAY_LIMIT_MS;
+ assert SHELL_TYPE_MS > 0;
+}
+
+test the_terminal_is_a_tab_and_gen_is_a_command {
+ assert TABS[5] == "term";
+ assert TAB_LABELS[5] == "Term";
+ assert SHELL_COMMANDS[0] == "help";
+ assert SHELL_COMMANDS[5] == "t27c gen";
+ assert SHELL_ARGS[5] == "[backend]";
+ assert SHELL_CHIPS[0] == SHELL_COMMANDS[6];
+}
+
test the_first_play_is_hello_world {
assert PLAY_COUNT == 1;
assert PLAY_IDS[0] == "hello-world";
assert PLAY_SPECS[0] == "specs/demos/hello_world.t27";
}
+
+test the_terminal_reads_the_compile_stages {
+ assert STAGES[0] == "source";
+ assert STAGES[5] == "backends";
+ assert STAGES[6] == "tests";
+ assert SAY_CHECK[3] == "{errors} errors";
+}
diff --git a/apps/website/public/play/shell.js b/apps/website/public/play/shell.js
new file mode 100644
index 0000000000..328e241606
--- /dev/null
+++ b/apps/website/public/play/shell.js
@@ -0,0 +1,361 @@
+// shell.js -- the player's Term tab: a terminal over the compiler already in the page.
+//
+// Everything the terminal knows and says is in player.t27: its commands (SHELL_COMMANDS, each a
+// key of COMMANDS below), the backends, the commands for a machine with a toolchain, the native
+// tools it recognises, and every phrase it prints (SAY_*). This file fills the {names} and lays
+// the columns out; scripts/play-from-spec.mjs refuses the build when a command or a phrase is on
+// one side and not the other. It is JavaScript written by hand for one measured reason: gen-js
+// lowers declarations, not function bodies, so this logic cannot yet be a t27 spec compiled by
+// the compiler it drives.
+//
+// Every line printed comes from the page's own t27_compiler.wasm (ctx.analyze), from the spec's
+// test blocks run by ./t27run.js, or from the JavaScript the compiler emitted. A browser can run
+// one of the seven outputs, JavaScript (RUNNERS); for the others, run prints the command for a
+// machine that has the toolchain and never a result it did not see.
+//
+// No DOM work at the top level: the generator imports this file to read COMMANDS and RUNNERS, and
+// its test runs every command through makeShell against the committed spec.
+
+import { runSpec } from './t27run.js'
+
+const html = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
+const fmt = (n) => Number(n).toLocaleString('en-US')
+const pad = (s, n) => String(s) + ' '.repeat(Math.max(1, n - String(s).length))
+const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '')
+const TONE = [['SAY_ERR_', 'r'], ['SAY_WARN_', 'y'], ['SAY_NOTE_', 'dim']]
+
+/** A SAY_ template with its {names} filled; a name left unfilled is a bug, and says so. */
+export function fill(template, vars = {}) {
+ return String(template).replace(/\{([a-z]+)\}/g, (_, k) => {
+ if (!(k in vars)) throw new Error(`"${template}" needs {${k}}`)
+ return vars[k]
+ })
+}
+
+// The emitted JavaScript, imported as it is. Its exports are printed; nothing is called.
+export async function importJs(code, cfg) {
+ const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))
+ try {
+ const mod = await import(url)
+ const names = Object.keys(mod)
+ return fill(cfg.SAY_IMPORT_OK, { count: names.length }) + '\n' + names.map((k) => {
+ const v = mod[k]
+ const s = typeof v === 'function' ? `function(${v.length})` : typeof v === 'bigint' ? `${v}n` : (() => { try { return JSON.stringify(v, (_, x) => (typeof x === 'bigint' ? `${x}n` : x)) } catch { return String(v) } })()
+ return ` ${k} = ${String(s).slice(0, 160)}`
+ }).join('\n')
+ } catch (e) {
+ return fill(cfg.SAY_ERR_IMPORT, { error: e.message })
+ } finally {
+ URL.revokeObjectURL(url)
+ }
+}
+
+// The file a backend's output is saved as: the spec's stem and BACKEND_EXTS. Verilog is saved under
+// its module's name, which is what verilator's DECLFILENAME lint expects.
+export function fileName(cfg, backend, code, stem) {
+ const m = /^\s*module\s+([A-Za-z_][\w$]*)/m.exec(code)
+ return (backend.startsWith('verilog') && m ? m[1] : stem) + cfg.BACKEND_EXTS[cfg.BACKENDS.indexOf(backend)]
+}
+
+// The facts every tab and t27c check show, in BACKENDS order; the spec's test blocks are run by
+// ./t27run.js, which never counts a skip as a pass.
+export function factsOf(cfg, source, analysis) {
+ const targets = analysis.targets ?? {}
+ const backends = cfg.BACKENDS.filter((k) => k in targets).map((k) => ({ name: k, ok: targets[k].ok !== false && typeof targets[k].code === 'string' && targets[k].code.length > 0, bytes: targets[k].bytes ?? (targets[k].code ?? '').length, code: targets[k].code ?? '', error: targets[k].error }))
+ let tests = null
+ try { tests = runSpec(analysis.ast) } catch (e) { tests = { error: e.message, results: [], pass: 0, fail: 0, skip: 0 } }
+ return {
+ bytes: analysis.sourceBytes ?? new TextEncoder().encode(source).length,
+ lines: analysis.sourceLines ?? source.split('\n').length,
+ tokens: analysis.tokenCount ?? (analysis.tokens ?? []).length,
+ kinds: new Set((analysis.tokens ?? []).map((t) => t.kind)).size,
+ nodes: analysis.nodeCount ?? 0,
+ depth: analysis.astDepth ?? 0,
+ topLevel: analysis.topLevel ?? (analysis.ast?.children ?? []).length,
+ discarded: (analysis.discarded?.length ?? 0) + (analysis.lexerDiscarded?.length ?? 0) + (analysis.swallowed?.length ?? 0),
+ typeOk: analysis.typecheck?.ok === true,
+ typeErrors: analysis.typecheck?.errors ?? [],
+ hirOk: analysis.hir?.ok !== false,
+ hirBytes: (analysis.hir?.text ?? '').length,
+ astError: analysis.astError ?? null,
+ backends,
+ tests,
+ }
+}
+
+// The backends whose output this page can execute, and how.
+export const RUNNERS = { js: importJs }
+
+// --- the commands: (sh, args) -> printed lines -------------------------------------------------
+export const COMMANDS = {
+ help(sh) {
+ const c = sh.cfg
+ const usage = c.SHELL_COMMANDS.map((k, i) => k + (c.SHELL_ARGS[i] ? ' ' + c.SHELL_ARGS[i] : ''))
+ const width = Math.max(...usage.map((u) => u.length)) + 2
+ usage.forEach((u, i) => sh.line(`${html(pad(u, width))}${html(c.SHELL_HELP[i])}`))
+ sh.say('SAY_NOTE_BACKENDS', { backends: c.BACKENDS.join(' ') })
+ sh.say('SAY_NOTE_ALSO', { alias: fill(c.SHELL_GEN_ALIAS, { backend: '' }) })
+ },
+ ls(sh) {
+ const { files } = sh.compile()
+ const width = Math.max(...files.map((f) => f.name.length)) + 2
+ for (const f of files) sh.line(`${f.ok ? html(pad(f.name, width)) : `${html(pad(f.name, width))}`}${pad(f.ok ? fmt(f.text.length) : '-', 8)}${html(f.backend ?? sh.word('SAY_SPEC_FILE'))}${f.ok ? '' : ` ${html(sh.word('SAY_FAILED'))}`}`)
+ },
+ cat(sh, [name]) {
+ const f = sh.file(name, 'cat')
+ if (!f) return
+ if (!f.ok) return sh.say('SAY_ERR_NOT_EMITTED', { backend: f.backend, error: f.error || sh.word('SAY_FAILED') })
+ sh.block(f.html())
+ },
+ save(sh, [name]) {
+ const f = sh.file(name, 'save')
+ if (!f) return
+ if (!f.ok) return sh.say('SAY_ERR_NOT_EMITTED', { backend: f.backend, error: f.error || sh.word('SAY_FAILED') })
+ sh.ctx.download(f.text, f.name)
+ sh.say('SAY_SAVED', { file: f.name, bytes: fmt(f.text.length) })
+ },
+ lang(sh, [want]) {
+ if (want) { const b = sh.pick(want, 'lang'); if (!b) return; sh.setBackend(b) }
+ const c = sh.cfg
+ c.BACKENDS.forEach((b, i) => sh.line(`${b === sh.backend ? '*' : ' '} ${html(pad(b, 13))}${html(pad(c.BACKEND_NAMES[i], 15))}${html(c.BACKEND_EXTS[i])}${c.RUNS_IN_BROWSER.includes(b) ? ', ' + html(sh.word('SAY_RUNS_HERE')) : ''}`))
+ },
+ 't27c gen'(sh, [want]) {
+ const b = want ? sh.pick(want, 't27c gen') : sh.backend
+ if (!b) return
+ sh.setBackend(b)
+ const { ms, files } = sh.compile()
+ const f = files.find((x) => x.backend === b)
+ if (!f || !f.ok) return sh.say('SAY_ERR_NOT_EMITTED', { backend: b, error: f?.error || sh.word('SAY_FAILED') })
+ sh.say('SAY_NOTE_GEN', { file: f.name, bytes: fmt(f.text.length), lang: sh.nameOf(b), ms: ms.toFixed(0) })
+ sh.block(f.html())
+ },
+ 't27c test'(sh) {
+ const { facts } = sh.compile()
+ const t = facts.tests
+ if (t.error) return sh.say('SAY_ERR_RUNNER', { error: t.error })
+ const mark = { pass: '✓', fail: '✗', skip: '–' }
+ for (const r of t.results) {
+ const failed = r.status === 'fail' ? (r.asserts ?? []).filter((a) => !a.ok).map((a) => `line ${a.line}${a.detail ? `: ${a.detail}` : ''}`) : []
+ const why = r.reason || failed.join('; ')
+ sh.line(`${mark[r.status]} ${html(pad(r.kind, 10))}${html(r.name)} ${html(fill(sh.cfg.SAY_ASSERTS, { count: (r.asserts ?? []).length }))}${why ? ' · ' + html(why) : ''}`)
+ }
+ if (!t.results.length) sh.say('SAY_NOTE_NO_TESTS')
+ sh.line(`${html(fill(sh.cfg.SAY_TESTS, { pass: t.pass, fail: t.fail, skip: t.skip }))}`)
+ sh.line(`${html(sh.cfg.TESTS_NOTE)}`)
+ },
+ 't27c check'(sh) {
+ const { facts: f, ms } = sh.compile()
+ const ok = f.backends.filter((b) => b.ok)
+ const c = sh.cfg
+ // One row per compile stage but the last (t27c test is that one): STAGES and SAY_CHECK.
+ const rows = [
+ [true, { bytes: fmt(f.bytes), lines: fmt(f.lines) }],
+ [!f.discarded, { tokens: fmt(f.tokens), kinds: f.kinds, dropped: f.discarded }],
+ [!f.astError, { nodes: fmt(f.nodes), depth: f.depth, top: f.topLevel }, f.astError],
+ [f.typeOk, { errors: f.typeErrors.length }, f.typeErrors[0]],
+ [f.hirOk, { bytes: fmt(f.hirBytes) }],
+ [ok.length === f.backends.length, { ok: ok.length, all: f.backends.length, bytes: fmt(ok.reduce((s, b) => s + b.bytes, 0)) }],
+ ]
+ rows.forEach(([good, vars, why], i) => sh.line(`${html(pad(c.STAGES[i], 10))}${html(fill(c.SAY_CHECK[i], vars))}${why ? ` ${html(why)}` : ''}`))
+ sh.say('SAY_NOTE_CHECKED', { ms: ms.toFixed(0) })
+ },
+ async run(sh, [want]) {
+ const b = want ? sh.pick(want, 'run') : sh.backend
+ if (!b) return
+ sh.setBackend(b)
+ const { files } = sh.compile()
+ const f = files.find((x) => x.backend === b)
+ if (!f || !f.ok) return sh.say('SAY_ERR_NOT_EMITTED', { backend: b, error: f?.error || sh.word('SAY_FAILED') })
+ const runner = sh.runners[b]
+ if (runner) {
+ sh.say('SAY_NOTE_IMPORTING', { file: f.name })
+ return sh.block(html(await runner(f.text, sh.cfg)), 'out')
+ }
+ const command = fill(sh.cfg.BACKEND_RUN_LOCAL[sh.cfg.BACKENDS.indexOf(b)], { file: f.name })
+ sh.say('SAY_WARN_NOT_HERE', { lang: sh.nameOf(b), tool: command.split(' ')[0] })
+ for (const step of sh.cfg.SAY_LOCAL_STEPS) sh.line(html(fill(step, { file: f.name, command })))
+ },
+ async replay(sh) {
+ if (!sh.ctx.replay) return sh.say('SAY_NOTE_NO_CAST')
+ sh.say('SAY_NOTE_REPLAY')
+ await sh.ctx.replay(sh.box())
+ },
+ history(sh) {
+ sh.history.forEach((h, i) => sh.line(`${pad(i + 1, 4)}${html(h)}`))
+ },
+ clear(sh) {
+ sh.clear()
+ },
+}
+
+// One typed line: the longest command it starts with, then its arguments. Shared by the page and
+// by scripts/play-from-spec.test.mjs, which runs every command against the committed spec.
+export async function dispatch(sh, text) {
+ const cfg = sh.cfg
+ let words = text.trim().split(/\s+/).filter(Boolean)
+ if (!words.length) return
+ sh.history.push(words.join(' '))
+ // The real t27c's spelling, t27c gen-rust, is t27c gen rust here.
+ const [aliasHead, aliasTail] = cfg.SHELL_GEN_ALIAS.split('{backend}')
+ const typed = words.slice(0, 2).join(' ')
+ if (typed.startsWith(aliasHead) && typed.endsWith(aliasTail) && typed.length > aliasHead.length + aliasTail.length) words = [...aliasHead.replace(/-$/, '').split(' '), typed.slice(aliasHead.length, typed.length - aliasTail.length), ...words.slice(2)]
+ const two = words.slice(0, 2).join(' ')
+ const key = COMMANDS[two] ? two : words[0]
+ const args = words.slice(key.split(' ').length)
+ // A backend's local command, typed for a backend this page runs, is run for that backend.
+ const local = cfg.BACKENDS.findIndex((b, i) => cfg.RUNS_IN_BROWSER.includes(b) && cfg.BACKEND_RUN_LOCAL[i].split(' ')[0] === words[0])
+ const tools = new Set([...cfg.BACKEND_RUN_LOCAL.map((c) => c.split(' ')[0]), ...cfg.NATIVE_TOOLS])
+ try {
+ if (COMMANDS[key]) await COMMANDS[key](sh, args)
+ else if (local >= 0 && args.length) await COMMANDS.run(sh, [cfg.BACKENDS[local]])
+ else if (tools.has(words[0])) {
+ sh.say('SAY_WARN_NATIVE', { tool: words[0] })
+ const i = cfg.BACKEND_RUN_LOCAL.findIndex((c) => c.split(' ')[0] === words[0])
+ if (i >= 0) sh.say('SAY_NOTE_NATIVE_RUN', { backend: cfg.BACKENDS[i] })
+ } else if (words[0] === 't27c') sh.say('SAY_ERR_T27C', { sub: words.slice(1).join(' '), commands: cfg.SHELL_COMMANDS.filter((c) => c.startsWith('t27c ')).join(', ') })
+ else sh.say('SAY_ERR_NOT_FOUND', { cmd: words[0] })
+ } catch (e) {
+ sh.say('SAY_ERR_THREW', { cmd: key, error: e.message })
+ }
+}
+
+// The terminal's state and its printing, apart from the page: print(kind, html) is the only way
+// out, so the generator's test can run every command without a DOM.
+// ctx: { cfg, specName, stem, source(), analyze(source, name), specHtml(analysis),
+// codeHtml(code, backend), download(text, name), replay(el) | null, backend }
+export function makeShell(ctx, print, runners = RUNNERS) {
+ const cfg = ctx.cfg
+ const sh = {
+ cfg,
+ ctx,
+ runners,
+ history: [],
+ backend: cfg.BACKENDS.includes(ctx.backend) ? ctx.backend : cfg.BACKENDS[0],
+ onBackend: () => {},
+ line: (h, cls = '') => print('line', h, cls),
+ block: (h, cls = '') => print('block', h, cls),
+ box: () => print('box'),
+ clear: () => print('clear'),
+ word: (key) => cfg[key],
+ say(key, vars) { return sh.line(html(fill(cfg[key], vars)), TONE.find(([p]) => key.startsWith(p))?.[1] ?? '') },
+ nameOf: (b) => cfg.BACKEND_NAMES[cfg.BACKENDS.indexOf(b)] ?? b,
+ // A backend by its id, its page name, its language's name or its file extension.
+ pick(want, cmd) {
+ const w = norm(want)
+ const i = cfg.BACKENDS.findIndex((b, j) => [b, cfg.BACKEND_PAGES[j], cfg.BACKEND_NAMES[j], cfg.BACKEND_EXTS[j]].some((x) => norm(x) === w))
+ if (i < 0) { sh.say('SAY_ERR_NO_BACKEND', { cmd, name: want, backends: cfg.BACKENDS.join(' ') }); return null }
+ return cfg.BACKENDS[i]
+ },
+ setBackend(b) { sh.backend = b; sh.onBackend(b) },
+ // A fresh compile of whatever is in the editor now, and the files it makes.
+ compile() {
+ const source = ctx.source()
+ const t0 = performance.now()
+ const analysis = ctx.analyze(source, ctx.specName)
+ const facts = factsOf(cfg, source, analysis)
+ const ms = performance.now() - t0
+ const files = [{ name: ctx.specName, ok: true, text: source, html: () => ctx.specHtml(analysis) }]
+ for (const t of facts.backends) files.push({ name: fileName(cfg, t.name, t.code, ctx.stem), backend: t.name, ok: t.ok, text: t.code, error: t.error, html: () => ctx.codeHtml(t.code, t.name) })
+ return { analysis, facts, ms, files }
+ },
+ file(name, cmd) {
+ if (!name) { sh.say('SAY_ERR_WHICH_FILE', { cmd }); return null }
+ const { files } = sh.compile()
+ const f = files.find((x) => x.name === name) || files.find((x) => x.name.toLowerCase() === name.toLowerCase())
+ if (!f) sh.say('SAY_ERR_NO_FILE', { cmd, file: name })
+ return f || null
+ },
+ }
+ return sh
+}
+
+// --- the terminal on the page ---------------------------------------------------------------
+export function mountShell(el, ctx) {
+ const cfg = ctx.cfg
+ el.innerHTML = `
+
+
`
+ const out = el.querySelector('.sh-out')
+ const input = el.querySelector('input')
+ const ps = el.querySelector('.ps')
+ const chips = el.querySelector('.sh-chips')
+ const sh = makeShell(ctx, (kind, h, cls) => {
+ if (kind === 'clear') { out.innerHTML = ''; return null }
+ const node = document.createElement(kind === 'block' ? 'pre' : 'div')
+ node.className = kind === 'box' ? 'sh-box' : `${kind === 'block' ? 'blk' : 'ln'}${cls ? ' ' + cls : ''}`
+ if (kind !== 'box') node.innerHTML = h || ' '
+ out.appendChild(node)
+ return node
+ })
+ const prompt = () => {
+ ps.textContent = `${ctx.stem} [${sh.backend}] $`
+ for (const b of chips.querySelectorAll('[data-b]')) b.setAttribute('aria-pressed', String(b.dataset.b === sh.backend))
+ }
+ sh.onBackend = prompt
+
+ async function run(text) {
+ const echo = sh.line(`${html(ps.textContent)} ${html(text.trim())}`, 'cmd')
+ await dispatch(sh, text)
+ // A long answer is read from its top, so the command line is scrolled to, not the last line.
+ out.scrollTop = echo.isConnected ? echo.offsetTop - out.offsetTop : 0
+ }
+
+ // Tab: a command, then a file or a backend, by what SHELL_ARGS says the command takes.
+ function complete() {
+ const v = input.value
+ const sp = v.lastIndexOf(' ')
+ const head = v.slice(0, sp + 1).trim()
+ const word = v.slice(sp + 1)
+ const arg = cfg.SHELL_ARGS[cfg.SHELL_COMMANDS.indexOf(head)] ?? ''
+ let pool
+ if (!head || cfg.SHELL_COMMANDS.some((c) => c.startsWith(head + ' '))) pool = cfg.SHELL_COMMANDS.filter((c) => c.startsWith(v)).map((c) => c.slice(head ? head.length + 1 : 0))
+ else if (arg.includes('file')) pool = sh.compile().files.filter((f) => f.ok).map((f) => f.name)
+ else if (arg.includes('backend')) pool = cfg.BACKENDS
+ else return
+ const hits = [...new Set(pool.filter((c) => c.startsWith(word)))]
+ if (!hits.length) return
+ let common = hits[0]
+ for (const h of hits) while (!h.startsWith(common)) common = common.slice(0, -1)
+ if (hits.length === 1) common += ' '
+ if (common.length > word.length) input.value = v.slice(0, sp + 1) + common
+ else sh.line(hits.map(html).join(' '), 'dim')
+ }
+
+ let back = 0
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Tab') { e.preventDefault(); complete() }
+ else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
+ e.preventDefault()
+ back = Math.max(0, Math.min(sh.history.length, back + (e.key === 'ArrowUp' ? 1 : -1)))
+ input.value = back ? sh.history[sh.history.length - back] : ''
+ } else if (e.key === 'l' && e.ctrlKey) { e.preventDefault(); sh.clear() }
+ else if (e.key === 'c' && e.ctrlKey && input.selectionStart === input.selectionEnd) { sh.line(`${html(ps.textContent)} ${html(input.value)}^C`, 'cmd'); input.value = '' }
+ })
+ el.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); const v = input.value; input.value = ''; back = 0; run(v) })
+ // A tap on the output starts typing, unless it selects text.
+ out.addEventListener('click', () => { if (!String(getSelection?.() ?? '')) input.focus({ preventScroll: true }) })
+
+ chips.innerHTML = cfg.BACKENDS.map((b) => ``).join('') + cfg.SHELL_CHIPS.map((c) => ``).join('')
+ chips.addEventListener('click', (e) => { const b = e.target.closest('button[data-run]'); if (b) run(b.dataset.run) })
+ prompt()
+ sh.line(`${html(fill(cfg.SAY_BANNER, { spec: ctx.specName, count: cfg.BACKENDS.length }))}`)
+ sh.line(`${html(cfg.SHELL_NOTE)}`)
+ sh.say('SAY_NOTE_START')
+
+ return {
+ run,
+ // The command typed at the given pace, as a reader would see it typed, then run.
+ type(text, ms) {
+ return new Promise((done) => {
+ let i = 0
+ const step = () => {
+ input.value = text.slice(0, ++i)
+ if (i < text.length) setTimeout(step, ms)
+ else setTimeout(() => { input.value = ''; run(text).then(done) }, ms * 4)
+ }
+ step()
+ })
+ },
+ focus: () => input.focus({ preventScroll: true }),
+ }
+}
diff --git a/apps/website/public/term/index.html b/apps/website/public/term/index.html
index feb6eb640a..a267fe9015 100644
--- a/apps/website/public/term/index.html
+++ b/apps/website/public/term/index.html
@@ -12,9 +12,9 @@
-
+
-
+
+
+
+
+
$ npm run -s check:play
+$ npm run -s test:play-spec
+
Staged: the prompt and the typing. Real: every byte the commands printed, at the time they printed it. Any silence longer than 2 s is shown for 2 s, and the title bar says so while it happens. Edited: home directory shown as ~.