Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/website-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,8 @@ jobs:
# The X player card (public/play/) is generated from specs/x/player.t27 through the
# compiler: X's limits on the card, the tabs the page must carry, and the site bundle
# built from src/lib/highlight.ts. The tests also hold the RTL tab's reading of the
# emitted Verilog to what t27c wrote for hello_world.
# emitted Verilog to what t27c wrote for hello_world, and run every command of the
# Term tab (public/play/shell.js) on hello_world against the spec's SHELL_* and SAY_*.
- name: X player spec
run: npm run check:play && npm run test:play-spec
# The world scan's contract (specs/catalog/discovery.t27 through the compiler),
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ zig-macos-*/
zig-linux-*/
zig-windows-*/
zig/
# ...but a play's zig terminal page is a page, not an SDK download (play-from-spec.mjs writes it)
!apps/website/public/play/*/zig/
*.tar.xz

# Ralph logs (keep config)
Expand Down
25 changes: 23 additions & 2 deletions apps/website/public/play/embed.html
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,28 @@
.rtl h4 .cp:hover{background:rgba(0,255,136,.1)}
.rtl .note{color:#666;font-size:.74rem;margin:.5em 0 0}

/* term */
#pane-term{padding:.5em .7em}
/* term: a terminal over the same compiler (shell.js), and the recording it can replay */
#pane-term.on{display:flex;flex-direction:column}
#pane-term .t27t .shr{display:none}
.sh{display:flex;flex-direction:column;flex:1;min-height:0;font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace;font-size:.8rem;line-height:1.4;background:#030303}
.sh-out{flex:1;min-height:0;overflow:auto;padding:.5em .8em;position:relative;outline:none;white-space:pre-wrap;overflow-wrap:anywhere;color:#ddd}
.sh-out .ln{min-height:1.4em}
.sh-out .ln.cmd{margin-top:.35em;color:#fff}
.sh-out .ln.dim,.sh-out i{color:#777;font-style:normal}
.sh-out b{font-weight:600;color:#fff}
.sh-out .g{color:#00ff88}.sh-out .r{color:#ff5c5c}.sh-out .y{color:#ffd700}
.sh-out s{color:#666}
.sh-out .ps,.sh-in .ps{color:#00ff88;user-select:none}
.sh-out pre.blk{margin:.2em 0 .3em;padding:.35em .6em;background:#000;border-left:2px solid rgba(0,255,136,.25);white-space:pre;overflow:auto;font:inherit}
.sh-out pre.out{color:#00ff88}
.sh-box{margin:.3em 0}
.sh-in{display:flex;align-items:center;gap:.5em;padding:.3em .8em;border-top:1px solid rgba(255,255,255,.08)}
.sh-in input{flex:1;min-width:0;font:inherit;color:#fff;background:transparent;border:0;outline:none;caret-color:#00ff88;padding:.2em 0}
.sh-chips{flex-wrap:nowrap;overflow-x:auto;padding:.3em .8em .45em;scrollbar-width:none}
.sh-chips button{white-space:nowrap;font-size:.78rem}
.sh-chips button.cmd{color:#00ff88;border-color:rgba(0,255,136,.3)}
/* on a touch screen the prompt is never small enough for iOS to zoom into it */
@media (pointer:coarse){.sh-in input{font-size:var(--ed-touch-font)}}
.empty{padding:1em;color:#888}

@media (max-aspect-ratio:1/1){
Expand All @@ -157,6 +176,8 @@
footer{position:sticky;bottom:0;z-index:2}
.cmp{grid-template-columns:1fr;height:auto}
.viz svg{height:9em}
/* the page scrolls, so the terminal takes the screen between the header and the footer */
#pane-term.on{height:calc(100vh - 5rem);min-height:18em}
}
@media (prefers-reduced-motion:reduce){.stages li,.bars div i{transition:none}}
</style>
Expand Down
120 changes: 48 additions & 72 deletions apps/website/public/play/embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
//
// The colours are the spec explorer's: ./site.js is generated from the site's own highlighter and
// palette (SITE_MODULES in player.t27), so a spec reads the same here as on t27.ai. The RTL tab
// reads the emitted Verilog with ./rtl.js.
// reads the emitted Verilog with ./rtl.js; the Term tab is a terminal over the same compiler,
// ./shell.js.
//
// Query: spec=<path under t27/files/> cast=<recording id under term/> v=<player build>
// autoplay=1 | auto_play=true (X adds both on click) tab=<one of the spec's TABS>
// backend=<one of the spec's BACKENDS, the terminal's first>

import { runSpec } from './t27run.js'

const $ = (sel) => document.querySelector(sel)
const root = new URL('../', import.meta.url)
Expand All @@ -34,6 +35,9 @@ const status = (text, cls = '') => { $('#status').innerHTML = cls ? `<span class
// reaches the status line.
let site = null
let readRtl = null
let shell = null
// player.t27's constants, once read.
let cfg = null

// --- the compiler ---------------------------------------------------------------------------
async function loadCompiler() {
Expand Down Expand Up @@ -76,10 +80,12 @@ function playerConfig(analysis) {
}
const cfg = {}
for (const d of analysis.ast?.children ?? []) if (d.kind === 'ConstDecl' && d.children?.[0]) cfg[d.name] = lit(d.children[0], d.name)
const need = ['TABS', 'TAB_LABELS', 'FIRST_TAB', 'WIDE_MIN_PX', 'COMPILE_ANIMATION_MS', 'AUTOPLAY_LIMIT_MS', 'AUTOPLAY_PARAMS', 'RECOMPILE_DEBOUNCE_MS', 'EDITOR_TOUCH_FONT_PX', 'TESTS_NOTE', 'ORIGIN', 'SPEC_PAGE', 'PLAY_SPECS', 'PLAY_CASTS', 'PLAY_IDS', 'HAS_SOUND']
const need = ['TABS', 'TAB_LABELS', 'FIRST_TAB', 'WIDE_MIN_PX', 'COMPILE_ANIMATION_MS', 'AUTOPLAY_LIMIT_MS', 'AUTOPLAY_PARAMS', 'RECOMPILE_DEBOUNCE_MS', 'EDITOR_TOUCH_FONT_PX', 'TESTS_NOTE', 'ORIGIN', 'SPEC_PAGE', 'PLAY_SPECS', 'PLAY_CASTS', 'PLAY_IDS', 'HAS_SOUND',
'STAGES', 'BACKENDS', 'BACKEND_NAMES', 'BACKEND_EXTS', 'BACKEND_PAGES', 'BACKEND_RUN_LOCAL', 'RUNS_IN_BROWSER', 'SHELL_COMMANDS', 'SHELL_ARGS', 'SHELL_HELP', 'SHELL_CHIPS', 'SHELL_NOTE', 'SHELL_DEMO', 'SHELL_TYPE_MS']
const missing = need.filter((k) => !(k in cfg))
if (missing.length) throw new Error(`player.t27 lacks ${missing.join(', ')}`)
if (cfg.COMPILE_ANIMATION_MS > cfg.AUTOPLAY_LIMIT_MS) throw new Error('player.t27: the compile piece is longer than the autoplay limit')
if (cfg.STAGES?.length !== 7) throw new Error('player.t27: STAGES must name the seven compile stages')
return cfg
}

Expand Down Expand Up @@ -147,39 +153,10 @@ function download(text, name) {
a.click()
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove() }, 0)
}
const EXT = { zig: '.zig', c: '.c', rust: '.rs', verilog: '.v', verilog_hir: '.hir.v', js: '.js', ts: '.ts' }
// Verilog is saved under its module's name, which is what verilator's DECLFILENAME lint expects.
function fileName(backend, code, stem) {
const m = /^\s*module\s+([A-Za-z_][\w$]*)/m.exec(code)
return (backend.startsWith('verilog') && m ? m[1] : stem) + EXT[backend]
}

// --- the facts every tab shows ----------------------------------------------------------------
const BACKENDS = ['zig', 'c', 'rust', 'verilog', 'verilog_hir', 'js', 'ts']

function factsOf(source, analysis) {
const targets = analysis.targets ?? {}
const backends = 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 file a backend's output is saved as, and the facts every tab shows: ./shell.js, which the
// generator's test runs without a page.
const fileName = (backend, code, stem) => shell.fileName(cfg, backend, code, stem)
const factsOf = (source, analysis) => shell.factsOf(cfg, source, analysis)

// --- compile: the run as one short linear piece ---------------------------------------------
function icicle(ast) {
Expand Down Expand Up @@ -207,15 +184,16 @@ function renderCompile(pane, f, analysis, classes) {
const testLine = t.error ? `<span class="r">${html(t.error)}</span>` : `<span class="g">${t.pass} pass</span> <i>&middot;</i> <span class="${t.fail ? 'r' : ''}">${t.fail} fail</span> <i>&middot;</i> <span class="y">${t.skip} skip</span>`
const counted = Object.values(classes).reduce((s, n) => s + n, 0)
const tokbar = Object.entries(classes).sort((a, b) => b[1] - a[1]).map(([c, n]) => `<i style="width:${((n / Math.max(1, counted)) * 100).toFixed(2)}%;background:${site.CLS_COLOR[c]}" title="${c}: ${n}"></i>`).join('')
// The stage names are STAGES in player.t27; `t27c check` in the terminal prints the same ones.
const stages = [
['source', `${fmt(f.bytes)} <i>bytes &middot;</i> ${fmt(f.lines)} <i>lines</i>`],
['lexer', `${fmt(f.tokens)} <i>tokens &middot;</i> ${f.kinds} <i>kinds</i>${f.discarded ? ` <span class="r">&middot; ${f.discarded} dropped</span>` : ''}<div class="tokbar">${tokbar}</div>`],
['parser', f.astError ? `<span class="r">${html(f.astError)}</span>` : `${fmt(f.nodes)} <i>nodes &middot; depth</i> ${f.depth} <i>&middot;</i> <span class="nw">${f.topLevel} <i>top-level</i></span>`],
['types', f.typeOk ? `<span class="g">ok</span> <i>&middot; 0 errors</i>` : `<span class="r">${f.typeErrors.length} error(s)</span> <i>${html(f.typeErrors[0] ?? '')}</i>`],
['hir', f.hirOk ? `<span class="g">ok</span> <i>&middot;</i> ${fmt(f.hirBytes)} <i>bytes</i>` : `<span class="r">failed</span>`],
['backends', `<span class="${okBackends === f.backends.length ? 'g' : 'y'}">${okBackends}/${f.backends.length}</span> <i>&middot;</i> ${fmt(totalBackendBytes)} <i>bytes emitted</i>`],
['tests', testLine],
]
`${fmt(f.bytes)} <i>bytes &middot;</i> ${fmt(f.lines)} <i>lines</i>`,
`${fmt(f.tokens)} <i>tokens &middot;</i> ${f.kinds} <i>kinds</i>${f.discarded ? ` <span class="r">&middot; ${f.discarded} dropped</span>` : ''}<div class="tokbar">${tokbar}</div>`,
f.astError ? `<span class="r">${html(f.astError)}</span>` : `${fmt(f.nodes)} <i>nodes &middot; depth</i> ${f.depth} <i>&middot;</i> <span class="nw">${f.topLevel} <i>top-level</i></span>`,
f.typeOk ? `<span class="g">ok</span> <i>&middot; 0 errors</i>` : `<span class="r">${f.typeErrors.length} error(s)</span> <i>${html(f.typeErrors[0] ?? '')}</i>`,
f.hirOk ? `<span class="g">ok</span> <i>&middot;</i> ${fmt(f.hirBytes)} <i>bytes</i>` : `<span class="r">failed</span>`,
`<span class="${okBackends === f.backends.length ? 'g' : 'y'}">${okBackends}/${f.backends.length}</span> <i>&middot;</i> ${fmt(totalBackendBytes)} <i>bytes emitted</i>`,
testLine,
].map((v, i) => [cfg.STAGES[i], v])
const tree = analysis.ast ? icicle(analysis.ast) : { svg: '<svg></svg>', depth: 0 }
const maxBytes = Math.max(1, ...f.backends.map((b) => b.bytes))
pane.innerHTML = `<div class="cmp">
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -396,34 +360,31 @@ ${fns ? `<h4>Functions <span>inputs<sub>bits</sub> &middot; result bits &middot;
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 = '<div></div>'
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 = `<p class="empty">The recording did not load: ${html(e.message)}</p>`
el.innerHTML = `<p class="empty">The recording did not load: ${html(e.message)}</p>`
}
}

// --- 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)
const analyze = await loadCompiler()
status('compiling player.t27&hellip;')
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]
Expand All @@ -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) => `<button role="tab" type="button" data-tab="${t.id}"${t.id === 'spec' ? ' class="tab-spec"' : ''}>${html(t.label)}</button>`).join('')
const wideQuery = matchMedia(`(min-width: ${cfg.WIDE_MIN_PX}px) and (min-aspect-ratio: 4/3)`)
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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')
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading