Build the minimal compile-to-JS language end-to-end (lexer → parser → compiler → runtime), define the v0.1 spec, stand up Bun+TypeScript repo tooling (oxlint/oxfmt), and establish a serious testing spine (unit + a first e2e smoke test). Deliver a tiny “runner” web page and a build pipeline that can emit a single dist/index.html even if the UI is still barebones.
Implement the graphical computing environment (canvas-based), virtual filesystem (seeded with the project’s own sources), file explorer, and the live recompilation loop. Establish robust browser sandboxing for running user code safely. Grow e2e coverage around IDE workflows.
Phase 3 — “Product” layer: two small games + polish + documentation-grade code + final single-file build
Ship two small games written in the new language (or at least using its stdlib + graphics module), improve ergonomics (errors, traces, perf), add doc-quality in-code documentation, ensure everything works offline as a single index.html, and harden tests (including flake controls + CI gates).
We will create a tiny language (“Twig”, placeholder name) that compiles to JavaScript and runs in a browser with no FFI exposed to user programs (i.e., no direct “call arbitrary JS” escape hatch). The compiler and runtime will be written in TypeScript and bundled with Bun. We’ll establish strict correctness with Bun’s built-in test runner (TypeScript-supported, Jest-like) and snapshots for compiler output, plus a minimal Playwright smoke test to verify the browser runner loads and executes a sample program. (bun.com)
Phase 1 is explicitly about de-risking:
- “Can we implement a language pipeline cleanly?”
- “Can we run compiled output deterministically (tests)?”
- “Can we emit a single
index.htmlartifact from Bun tooling?”
-
Language v0.1 implemented:
- Lexer → parser → AST with spans
- Compiler to JS
- Minimal runtime + stdlib
- Deterministic behavior for tests
-
Tooling:
- Bun workspace (TS-first)
- oxlint for lint, oxfmt for format
-
Tests:
- Many unit tests for each layer (lexer/parser/compiler/runtime)
- Snapshot (“golden”) tests for compiler output
- Minimal e2e smoke test using Playwright (loads runner page, runs “hello”)
-
Build artifact proof:
- A build script that produces one
dist/index.htmlcontaining everything needed to run the minimal system offline. - This is a proof that the final “single file” constraint is feasible early. (The full IDE arrives in later phases.)
- A build script that produces one
- Full-feature language (types, macros, advanced modules, optimization passes)
- Rich IDE (file tree, editor UX, live recompile UI) — Phase 2+
- Two games — Phase 3
- Sophisticated security sandbox — Phase 2+ (Phase 1 just establishes the execution strategy and constraints)
Interpretation we will implement:
- User programs cannot call arbitrary JavaScript APIs.
- Compiled programs run against a sealed runtime object (e.g.,
__rt) that exposes only our standard library and (later) graphics/file APIs. - We do not implement constructs like
js.call("document.querySelector", ...).
Enforcement strategy (Phase 1 baseline):
-
Generated JS references only:
- local variables
__rt(runtime)
-
Generated JS executes in a wrapper that shadows common globals (
window,document,globalThis,Function,eval) to reduce accidental/intentional escape. (Not perfect security, but aligned with “minimal system”; hardening later.)
- Compiler, runtime, web runner, build scripts: TypeScript.
- Use Bun bundler and Bun test runner as defaults. (bun.com)
- For Playwright: we’ll use Bun for dependency management and scripts; but we will not force Playwright to run under Bun runtime if it causes instability (there are known Bun-runtime compatibility issues when forcing Bun execution). (GitHub)
- Use oxlint for linting and oxfmt for formatting. (Oxc)
- Phase 1 will build a minimal
dist/index.htmlsingle-file output. - We’ll implement a bundling+inlining build script using
Bun.build()outputs (which areBuildArtifacts and can be read via.text()), then inject JS/CSS into an HTML template. (bun.com)
Pick an s-expression syntax to minimize parser complexity and maximize extensibility:
Examples:
; hello
(print "hi")
; let + function
(let add (fn (a b) (+ a b)))
(print (add 1 2))
; if
(if (> x 10)
(print "big")
(print "small"))Rationale:
- Lexer+parser are straightforward.
- “Special forms” are explicit.
- We can grow features later without grammar wars.
-
Literals: number, string, boolean, nil
-
Identifier:
foo,bar-baz(decide exact identifier charset in spec) -
Call:
(f a b c) -
Special forms:
(do expr1 expr2 ... exprN)→ sequencing(let name expr)→ immutable binding in current scope(fn (arg1 arg2 ...) body)→ function(if cond then else)→ expression if
-
Comments:
;to end of line
Phase 1 deliberately excludes:
- mutation / assignment
- macros
- pattern matching
- user-defined types
- Lexical scoping, closures.
- Everything is an expression;
doreturns last expression. - Truthiness: only
falseandnilare falsy (or we can do JS-like; pick and document).
All compiler stages return structured diagnostics:
Diagnostic { kind, message, span, notes[] }spanreferences the source text by{ start, end, line/col }computed from a line map.
A clean “documentation-as-code” structure:
/src
/lang
ast.ts
span.ts
lexer.ts
parser.ts
printer.ts (optional, for debugging + tests)
diagnostics.ts
compiler.ts
/runtime
index.ts
std.ts
values.ts (tagging / runtime helpers)
/host
host.ts (Host interface: readFile, now, random, etc.)
bun-host.ts
browser-host.ts (stub in phase1; real use in phase2)
/web
template.html
app.ts (minimal runner UI: run one example program)
/build
build-single-html.ts
/tests
lexer.test.ts
parser.test.ts
compiler.test.ts
runtime.test.ts
integration.test.ts
/e2e
smoke.spec.ts
Even though Phase 1 won’t ship a full IDE, we should design for it now.
Host abstraction
interface Host {
readText(path: string): Promise<string | null>; // phase2: virtual FS
now(): number;
random(): number; // injectable deterministic PRNG for tests
}Compiler API
type CompileResult =
| { ok: true; js: string; diagnostics: Diagnostic[] }
| { ok: false; js?: string; diagnostics: Diagnostic[] };
async function compileSource(source: string, opts: CompileOpts): Promise<CompileResult>;Later, Phase 2 will extend this to compileModule(entryPath, host) and a module graph. We keep the types aligned so we don’t rewrite everything.
- Lex: input string → tokens with spans
- Parse: tokens → AST with spans
- Lower: AST → “core AST” (optional in Phase 1; but we should reserve a file/module)
- Emit: core AST → JS string
Generated JS is a module-like string that exports a single function:
export function __run(__rt) {
"use strict";
const window = undefined, document = undefined, globalThis = undefined;
const Function = undefined, eval = undefined;
// compiled code...
return result;
}The runtime provides a controlled API surface:
type Runtime = {
std: StdLib;
// phase2: gfx, fs, ui...
};- In unit/integration tests: evaluate generated JS via
new Functionor dynamic import from a data URL. - In browser runner: create a
BlobURL from generated JS andimport()it, then call__run(runtime).
We will keep values “JS-native” where possible:
- numbers → JS number
- strings → JS string
- booleans → JS boolean
- nil →
null(or a unique symbol; pick and document) - functions → JS functions, but only those created by compiled code or stdlib
Core
print(x)→ append stringified output to a runtime buffer (and optionallyconsole.log)str(x)→ string conversion=,<,>,<=,>=
Math
+,-,*,/,%
Logic
and,or,not(or just rely onif+ truthiness; choose explicit functions for clarity)
Lists (minimal)
list(a b c)→ arraylen(xs)get(xs i)with bounds checks → nil or error (decide)push(xs x)(returns new array, immutable style)
We should keep phase1 stdlib pure and deterministic, except print.
- runtime gets
random()from Host; tests inject a seeded PRNG. - runtime gets
now()from Host; tests can fix it.
We will rely on:
- Bun test runner for unit/integration/snapshot tests. (bun.com)
- Bun bundler/build APIs for producing browser bundles and the single-file artifact. (bun.com)
- Bun can run HTML entrypoints in dev (useful later), but Phase 1 only needs a minimal runner and a build. (bun.com)
- Add
oxlintandoxfmtas dev dependencies and wire scripts. (Oxc) - Use
.oxlintrc.json(json/jsonc supported) and keep rules sane for a compiler codebase. (Oxc)
Recommended scripts
bun run lint→oxlintbun run lint:fix→oxlint --fixbun run format→oxfmt .bun run format:check→oxfmt --check .(verify flag support when implementing; if not available, alternative is running and checking git diff)
(We’ll keep formatting config minimal and documented in-repo.)
Single-file build is a high-risk “integration constraint.” If we postpone it, we risk a painful Phase 3 surprise. Phase 1 will prove the mechanism with the minimal runner.
-
Use
Bun.build()onsrc/web/app.ts(browser target, minify optional). -
From
BuildOutput.outputs, pick:- the JS entry-point/chunk
- optionally a CSS artifact (if we produce CSS as a separate file; we can also inline CSS by writing it in the HTML template directly)
-
Read artifact content using
await artifact.text()(supported on BuildArtifact). (bun.com) -
Inject JS into
src/web/template.html:<script type="module"> ...bundled JS... </script><style> ...css... </style>
-
Write the resulting HTML to
dist/index.html.
Result: a single file that can be opened directly.
Bun’s test runner supports TypeScript and snapshots, so we will lean on that heavily. (bun.com)
-
tokenization of:
- identifiers
- numbers (including edge cases:
-1,1.0,.5if allowed) - strings (escapes, unterminated)
- parentheses
- comments
-
span accuracy: start/end indexes correct
-
parse each syntactic form
-
precedence is irrelevant in s-exprs, but we test:
- nesting
- empty lists
() - error recovery: unexpected EOF, mismatched parens
-
spans: AST nodes carry correct ranges
-
“golden” snapshot tests:
- compile small inputs and snapshot emitted JS
- keep snapshots stable by normalizing whitespace and deterministic temp names
-
semantic tests:
- compile expression → execute → compare result
- compile
if,let,fn+ closure capture
printoutput buffer behavior- numeric ops correctness
- list ops correctness and bounds behavior
Test full pipeline:
-
compile sample programs (fibonacci, map/reduce, closure) and run them through runtime host.
-
verify both:
- final returned value
- captured printed output (exact strings)
We want e2e “as we go,” but Phase 1 keeps it minimal:
-
Build
dist/index.html -
Serve it via a tiny HTTP server (recommended; avoids
file://quirks) -
Playwright opens the page and asserts:
- the page loads without console errors
- the sample program output area contains expected text (e.g., “hi”)
Playwright usage is via its standard playwright test workflow. (playwright.dev)
Important runtime note (pragmatic Bun posture): There are documented issues when forcing Playwright to run under Bun’s runtime; we will not force that mode in Phase 1. We will run Playwright in its stable configuration (typically Node execution) while still using Bun for dependency management and scripting. (GitHub)
-
Every new language feature ships with:
- parser unit tests
- compiler snapshot
- runtime semantic test (compile+run)
-
Bugs get a “regression test first” rule.
bun testis greenbun run lintis greenbun run format:checkis greenbun run buildproduces a singledist/index.htmlbun run e2epasses smoke test
-
docs/language-v0.1.mddescribing:- syntax
- semantics
- stdlib surface
- error philosophy
-
Small examples (hello, let/fn/if)
- Clean module boundaries
- Diagnostics with spans
- Deterministic test harness host
-
template.html+app.ts:- compiles embedded example program string
- runs it
- shows output in the DOM (simple
<pre id="output">)
build-single-html.ts(Bun-run) producingdist/index.htmlfully self-contained (inline JS/CSS)
- Unit tests for lexer/parser/compiler/runtime
- Integration tests for compile+run
- Playwright smoke test loads the page and checks expected output
Mitigation:
- De-risk immediately by implementing the inlining build step in Phase 1 using
Bun.build()+BuildArtifact.text(). (bun.com) - Avoid external assets (images/fonts) in Phase 1; everything is inline code.
Mitigation:
- Don’t force Bun runtime for Playwright execution; use the stable Playwright runner path.
- Keep e2e smoke minimal in Phase 1; expand in Phase 2. (GitHub)
Mitigation:
- Keep the compiler API and host abstraction future-proof (module graph later).
- Adopt a tiny core language; add features only when demanded by Phase 2/3 needs.
Phase 1 is complete when:
-
We can write a small program in the new language and:
- compile it to JS
- execute it under Bun tests
- execute it in the browser runner
-
We can produce a single
dist/index.htmlthat runs offline. -
Lint/format/test/e2e gates are wired and green.
If you want, when you ask for Phase 2, I’ll write it as a similarly detailed engineering doc focused on:
- virtual filesystem representation + seeding with source code
- file explorer UI model
- editor component plan (minimal textarea vs richer editor)
- live compilation loop and sandbox strategy
- graphics runtime API design and rendering loop
- deeper Playwright coverage for IDE workflows