/ˈskrɪmɛl/
A complete compiler for the web. Markup, reactive state, scoped CSS, SQL, server functions, realtime, and tests in one .scrml file — the compiler reads it and does the wiring. No virtual DOM, no JSX, no node_modules, no API layer to drift out of sync.
scrml compile app.scrml -o dist/You declare the shape of the app; the compiler builds the machine.
This README demonstrates the language, not the current compiler. The code shown here is nominal — the language as designed, the shape the compiler is actively converging on. Some snippets may not compile clean against any given commit. See
docs/known-gaps.mdfor per-feature spec-vs-impl drift anddocs/changelog.mdfor what landed recently. The spec is incompiler/SPEC.md; the surface lives incompiler/SPEC-INDEX.md.
Here is one app — list, add, complete, filter, save — built from a 40-line prototype into a multi-device, server-backed, state-machine-driven thing without rewriting the markup tree. Each stage is the previous one plus a few lines.
You have an idea. Get something on screen.
<program>
<tasks> = [
{ id: 1, text: "Buy milk", done: false },
{ id: 2, text: "Walk the dog", done: true },
{ id: 3, text: "Write README", done: false }
]
<newTask> = ""
${
function addTask() {
if (@newTask.length == 0) return
@tasks = [...@tasks, { id: @tasks.length + 1, text: @newTask, done: false }]
reset(@newTask)
}
function toggle(id) {
@tasks = @tasks.map(t => t.id == id ? { ...t, done: !t.done } : t)
}
}
<h1>Today's Tasks</h1>
<form onsubmit=addTask()>
<input bind:value=@newTask placeholder="What needs doing?"/>
<button>Add</button>
</form>
<ul>
${ for (let t of @tasks) {
lift <li>
<input type="checkbox" checked=t.done onchange=${toggle(t.id)}/>
${t.text}
</li>
}
}
</ul>
</>
<tasks> = [...]declares a reactive cell. Mutating it (assignment,.map, push-then-assign) re-renders the list with no virtual DOM.@newTaskis how you read or write a reactive cell from inside an expression. Bare names (t,id) are plain locals.reset(@newTask)returns the cell to its declared init value (the empty string).${ for (t of @tasks) { lift <li>...</li> } }is the prototype iteration form — everyliftputs the resulting markup back into the surrounding<ul>.
The compiler will lint that loop: "You're iterating reactive state — try <each>." That's the path to Stage 2.
Now filter (All / Active / Done). You could add <activeOnly> = false, but the moment you add a fourth mode the flag-per-mode shape breaks. Use an enum — the compiler refuses if you forget a variant.
<program>
${
type Filter:enum = { All, Active, Done }
<tasks> = [
{ id: 1, text: "Buy milk", done: false },
{ id: 2, text: "Walk the dog", done: true },
{ id: 3, text: "Write README", done: false }
]
<newTask req length(>=1)> = <input placeholder="What needs doing?"/>
<filter>: Filter = .All
const <visible> = match @filter {
.All :> @tasks
.Active :> @tasks.filter(t => !t.done)
.Done :> @tasks.filter(t => t.done)
}
function addTask() {
@tasks = [...@tasks, { id: @tasks.length + 1, text: @newTask, done: false }]
reset(@newTask)
}
function toggle(id) {
@tasks = @tasks.map(t => t.id == id ? { ...t, done: !t.done } : t)
}
}
<h1>Today's Tasks</h1>
<form onsubmit=addTask()>
<newTask/>
<errors of=@newTask/>
<button>Add</button>
</form>
<nav>
<button onclick=${@filter = .All} class:active=${@filter == .All}>All</button>
<button onclick=${@filter = .Active} class:active=${@filter == .Active}>Active</button>
<button onclick=${@filter = .Done} class:active=${@filter == .Done}>Done</button>
</nav>
<each in=@visible key=@.id>
<li class:done=${@.done}>
<input type="checkbox" checked=${@.done} onchange=${toggle(@.id)}/>
${@.text}
</li>
<empty>Nothing left.</>
</each>
</>
What just landed:
<newTask req length(>=1)> = <input/>declares the cell, its render spec, and its validators in one line.<newTask/>in the markup expands to the bound input element — nobind:value=@newTaskneeded; the cell is the input.@newTask.isValid/.errors/.touchedare auto-synthesized read-only cells.<errors of=@newTask/>renders them at the right time.const <visible> = match @filter {...}is a derived cell. The compiler recomputes it when@filteror@taskschanges. It's exhaustive: leaving out.DoneisE-MATCH-NOT-EXHAUSTIVE. Add a fourth filter variant later and the compiler tells you every site that needs updating.<each in=@visible key=@.id>is the structural iteration form — keyed DOM reconciliation,@.is the current item,<empty>is the zero-items state. The Stage 1${for / lift}form still compiles fine;<each>is the structural commitment.
This document describes the nominal language at the time of any version release. It does not describe what the compiler is perfectly capable of doing. I am working full-bore to get the compiler as close to the nominal state as possible. I am just one guy.
If you are here (and reading this). Hello, My name is Bryan MacLee. I am co-owner of a small trucking company in rural Ut. I run the business, drive, mechanic, apparently I'm the HR department. I am also a husband, father and sometimes, a wannabe coder.
This message is from me. I typed it. but ~96% of what you read (99.9% for the actual code) is claude "written". (I dont care about the exact brand as long as I have a tool that will get the job done.) I do my best to skim, and review as much as I can. But (see the prior list). If you find this interesting, continue reading. if you find something doesn't quite add up (or some straight up bullshit). let me know.
This is my third round with the ai and coding. the first two were pretty underwhelming. This time around I wasn't expecting much but I thought "the hell with it" and I tried out claude. I was fudging impressed.
I had been working with these ideas (in one way or another) for a long time. Over the course of about 3 years I learned (yes, the old school way, not much different than I am doing right now) how compilers work and how to implement various parts in various methods. programming has always been my favorite activity. the thing that I look forward to all the time (other than hanging with my wife and kids. Of course.)
After my first couple of experiments with claude I realized, I might actually be able to build this language. Dont get me wrong, I absolutely could write this language by hand. I can say that factually. BUT it would absolutely take me 10-20 years to do it. I think the ideas are worth surfacing at least.
AI code is still what it is. 100% mid. But its still all human mid that it is regurget-asemble-ing, If the ideas on top of the impl are good, or at least novel. it doesn't matter if the impl is mid. The ideas still get across. that's all that really matters to me here.
are the ideas any good?
Now it's real: SQLite, server-backed, auth-gated, multi-device sync, with a load-state state machine.
// gate: skip
// illustrative end-to-end demo — requires a `tasks.db` file + a `Role:enum`
// in scope; the compile-time DB / auth-role checks need a real project context.
<program>
<db src="tasks.db" protect="passwordHash" tables="users"/>
<schema>
users {
id: integer primary key
email: text req email
passwordHash: (not to string)
}
tasks {
id: integer primary key
user_id: integer not null references(users.id)
text: text req length(>=1)
completed_at: (not to timestamp)
}
</>
${
type Filter:enum = { All, Active, Done }
type Phase:enum = { Loading, Empty, Editing, Saving, Saved, ErrorState(msg: string) }
type LoadError:enum = { Network(msg: string) }
type User:struct = { id: number, email: string }
fn isActive(t) -> boolean {
return t.completed_at is not
}
function loadTasks()! -> LoadError {
return ?{`SELECT id, text, completed_at FROM tasks WHERE user_id = ${@user.id} ORDER BY id`}.all()
}
function createTask(text: string(.length >= 1))! -> LoadError {
return ?{`INSERT INTO tasks (user_id, text, completed_at) VALUES (${@user.id}, ${text}, ${not}) RETURNING *`}.get()
}
function toggle(id) {
?{`UPDATE tasks SET completed_at =
CASE WHEN completed_at IS NULL THEN ${Date.now()} ELSE NULL END
WHERE id = ${id}`}.run()
}
function submit() {
@phase = .Saving
createTask(@newTask) !{
| ::Network msg :> { @phase = .ErrorState(msg); return }
}
reset(@newTask)
@phase = .Saved
}
}
<user>: User = not // populated from the session token at boot; see `scrml:auth.verifyJwt` for the canonical flow
<channel name="tasks" topic="user-${@user.id}">
<tasks> = []
</>
<filter>: Filter = .All
<newTask req length(>=1)> = <input placeholder="What needs doing?"/>
const <visible> = match @filter {
.All :> @tasks
.Active :> @tasks.filter(isActive)
.Done :> @tasks.filter(t => !isActive(t))
}
~{
test "isActive identifies open tasks" {
assert isActive({ id: 1, text: "open", completed_at: not })
assert !isActive({ id: 2, text: "done", completed_at: 1706745600000 })
}
}
<auth role="User">
<engine for=Phase initial=.Loading effect=${
@tasks = loadTasks() !{
| ::Network msg :> { @phase = .ErrorState(msg); return }
}
@phase = @tasks.length == 0 ? .Empty : .Editing
}>
<Loading rule=(.Empty | .Editing | .ErrorState)>
Loading your tasks…
</>
<Empty rule=.Saving>
<p>No tasks yet. Add your first.</p>
<form onsubmit=submit()>
<newTask/>
<errors of=@newTask/>
<button>Add</button>
</form>
</>
<Editing rule=.Saving>
<form onsubmit=submit()>
<newTask/>
<errors of=@newTask/>
<button>Add</button>
</form>
<nav>
<button onclick=${@filter = .All} class:active=${@filter == .All}>All</button>
<button onclick=${@filter = .Active} class:active=${@filter == .Active}>Active</button>
<button onclick=${@filter = .Done} class:active=${@filter == .Done}>Done</button>
</nav>
<each in=@visible key=@.id>
<li class:done=${@.completed_at is some}>
<input type="checkbox"
checked=${@.completed_at is some}
onchange=${toggle(@.id)}/>
${@.text}
</li>
<empty>Nothing left.</>
</each>
</>
<Saving rule=(.Saved | .ErrorState)>
Saving…
</>
<Saved rule=.Editing>
Saved.
<onTimeout after=1.5s to=.Editing/>
</>
<ErrorState msg rule=.Loading>
<div class="err">${msg}</div>
<button onclick=${@phase = .Loading}>Retry</button>
</>
</>
</auth>
</>
What the compiler did that you did NOT write:
- Route handlers for
loadTasks/createTask/toggle. They touch SQL, so the compiler classified them server-side — and generated the routes, the client-sidefetchcalls, the CSRF tokens, parameterized queries, and serialization. You call them like local functions because in your source they ARE. - The schema → DDL pipeline. The
<schema>block becomes both theCREATE TABLEstatement on first run AND a diff on every subsequent compile. New columns? Migration emitted. Removed columns? Surfaced for review. - Field-level data isolation.
<db src="tasks.db" protect="passwordHash" tables="users">tells the compiler thatpasswordHashis server-only — the compiler strips the field from the type the client sees, so server-fn responses that include a user row exclude the field, and reading@user.passwordHashon the client is a compile error (E-PROTECT-001). - The WebSocket plumbing.
<channel name="tasks" topic="user-${@user.id}">emits the Bun upgrade route, a client-side reconnect manager, and pub/sub routing. State declared inside the channel body (<tasks> = []) auto-syncs across every connected device subscribed to the same topic. - Per-role chunk splitting.
<auth role="User">tells the compiler that anonymous visitors will never reach this subtree. They get a strictly smaller initial bundle — the components and server functions inside the gate aren't downloaded for them at all. The project's auth flow populates<user>from the session token at boot (the canonical recipe lives atscrml:auth.verifyJwt— see §11.2); the server functions inside the gate reference@user.iddirectly because by the time they run, the gate's role predicate has matched. - The validity surface.
<newTask req length(>=1)> = <input/>produces@newTask.isValid/.errors/.touchedas reactive read-only cells.<errors of=@newTask/>renders them at the right time. The SAME predicates fire on the server boundary, in the HTML form attributes the compiler emits (required minlength="1"), and in the database CHECK constraints. - The lifecycle gate.
completed_at: (not to timestamp)means the column starts unset and transitions when a value is written. Reads oft.completed_atare checked per-access — before the row has been completed, the read isnot, and the compiler refuses to treat it as a timestamp. The compiler tracks the transition state symbolically. Zero runtime cost. - The engine's exhaustiveness check. Adding a seventh variant to
Phaseforces the compiler to demand a UI block + a transition source for it. You cannot ship a state with no UI. - The
~{}inline test.~{ test "..." { assert ... } }is a first-class context next to the code it verifies. The test runs against the live compile in dev; the entire~{}block is stripped from production builds, so the production bundle never sees the test code. Purefnhelpers (likeisActiveabove) are the most natural targets — no mocks needed, no test harness to wire up.
That's the centerpiece. The rest of this README is the surface around it.
The Stage-1 → Stage-2 → Stage-3 progression above maps onto a three-tier ladder. You start as a rough prototype and add structure as the design hardens — without rewriting the markup tree. State-children carry forward verbatim between tiers; the wrapper swap is the commitment moment.
| Tier | Form | What you get |
|---|---|---|
| 0 | if= chains / ${ if (...) lift ... } |
prototype — no exhaustiveness check |
| 1 | <match for=Type [on=expr]> + <each> |
structural exhaustiveness check at compile time; rule= is accepted + compiler-checked but inert at runtime (the lint nudges promotion to Tier 2) |
| 2 | <engine for=Type initial=.Variant> |
full deal — exhaustiveness + active transition rules (rule=) + per-state effect handlers (<onTransition>, <onTimeout>, <onIdle>) + composite hierarchy + history restore |
The Engine surface beyond Stage 3 — composite state-children with nested engines, the history attribute that restores prior inner state on re-entry, <onTimeout after=2s to=.Variant> for per-state timeouts (with named timers + cancelTimer("name") builtin), <onIdle> for engine-wide event-timeout watchdogs, internal:rule= for transitions that don't exit/re-enter the composite — lives at examples/14-mario-state-machine.scrml and SPEC §51.
State is the declaration primitive. <count> = 0 declares a reactive cell;
@count reads or writes it. Compound, derived (const <total> = expr),
server-pinned (<users server>), linear, refinement-typed cells are all the
same primitive with different attributes. The compiler tracks the dependency
graph and re-renders on change.
Engines are the centerpiece. When state goes from "a few booleans" to "this
app has phases," you promote up the Tier ladder (above) without rewriting the
markup tree — if= chains, then <match for=Type>, then <engine for=Type>.
The engine declares legal transitions, runs cross-state effects, and enforces
that every variant has a UI block. The Engine Example above is the full shape.
Full-stack in one file. Markup, logic, styles, SQL, server functions, error
handling, realtime channels, inline tests — all in .scrml. The compiler
analyzes the code and splits server from client automatically. No API layer, no
route files, no API/UI drift.
Errors are states, not booleans. try/catch is not in scrml's vocabulary.
Failable functions surface errors as enum variants (fn fetchItems()! -> LoadError); the !{} handler routes each variant into the right state. A
missing handler arm is a compile-time error — the failure modes live in the
type, not in <isError> boolean rubble.
Validators auto-synthesize a validity surface. Compound state with req /
length / other predicates produces reactive read-only @form.isValid /
.errors / .touched rollups plus per-field cells; <errors of=@form/>
renders them at the right time. The same predicate fires three places — state
validator, refinement type, schema column. No bilingual schema, no Zod.
Automatic N+1 elimination. A for loop whose body does ?{...where id = ${x.id}}.get() is rewritten to one WHERE id IN (...) fetch plus a keyed
lookup — no DataLoader, no manual batching. Independent reads in a ! handler
share one transaction envelope. (Opt-out, diagnostics, and measured wins:
Features → Server/Client and the benchmarks.)
Realtime and workers as language primitives. A <channel> block declares a
WebSocket endpoint — the compiler emits the upgrade route, reconnect, and
pub/sub routing; state declared inside auto-syncs across every connected client.
A nested <program> is a Web Worker (or WASM module, or sidecar) with typed RPC
and supervised restarts. No new WebSocket(), no postMessage plumbing.
No npm. scrml ships its own stdlib — sixteen modules (auth, crypto,
data, http, router, store, time, and more) covering the surface a
typical app reaches for. No package manager, no dependency trees, no
node_modules.
scrml runs TodoMVC at 15.8 KB total gzip / 0 dependencies against React 19 / Svelte 5 / Vue 3; partial-update is faster than Vanilla; build time is ~10-14× faster than Vite. Full numbers, methodology, and historical baselines live at benchmarks/RESULTS.md.
- Reactive state.
<count> = 0declares a reactive cell;@countreads or writes it. Declarations use the structural<x>form; reads and writes use the@xform. The two are visually distinguishable so a reader can scan any function body and count how many state cells it touches. Bare names in expressions are plain locals — they don't resolve to reactive state (locals cannot shadow registered state names;E-NAME-COLLIDES-STATE). The declaration/write distinction is enforced — bare@x = exprat body-top of a<program>/<page>/<channel>firesE-WRITE-NOT-IN-LOGIC-CONTEXT: declarations use structural<x>, writes go inside${...}functions. - Three RHS shapes for state decls. Shape 1 plain (
<count> = 0), Shape 2 decl-coupled-with-render-spec (<userName req length(>=2)> = <input/>—<userName/>in markup expands to the bound input withbind:valuewired), Shape 3 derived (const <doubled> = @count * 2— read-only; recomputes on dep change; markup-typed derived cells legal per L1). - Compound state (Variant C).
<formRes> <name> = "" <email> = "" </>— ad-hoc compound via structural children. Read@formRes.name; write@formRes.email = "alice". Tier 3 predefined-shape compound supports positional sugar against a known type. - Two-way binding (
bind:value) — compiler dispatches binding by render-spec (<input type="checkbox">→bind:checked;<select>→bind:value; etc.). Per L17. - Absence value (
not) — a unified null/undefined replacement.<result> = notmeans "no value yet." Check withis some/is not.== notmisuse isE-SYNTAX-042at compile time. null and undefined never appear in scrml — library mode inclusive. - Server-pinned + protected state.
<users server>pins state server-side so it never reaches the browser.protect=on struct fields hides them from the client schema view. Both enforced at compile time.
- Exact-once consumption (
lin) — values that must be used exactly once, with restricted intermediate visibility between declaration and consumption. The compiler verifies this statically across branches, loops, closures, and cross-${}blocks. See SPEC §35 for the normative surface. - The
~pipeline accumulator — an unbound expression statement drops its result into~; the next statement consumes it.step1(x)thenreturn step2(~)— no name on a value used exactly once, the same cleanliness as a ternary, for pipelines.~is itself a built-inlinvariable: exactly-once consumption, compiler-checked, scope-local to each${}body and function body. Misuse (~read twice, read uninitialized, reinitialized before consumption) is a compile error —E-TILDE-001/E-TILDE-002. See SPEC §32 andexamples/24-tilde-pipeline.scrml.
asIs(notany) — scrml has noanytype. There is no "turn off the type checker" escape hatch.asIsaccepts any type but forces you to resolve it to a concrete type before use or return — analogous to TypeScript'sunknown, notany. Component bare props followasIsrules: the compiler infers the concrete type from how you use the prop.
scrml has built-in runtime type validation. The type annotation IS the validation schema — no separate schema library, no z.object() wrappers, no z.infer<typeof> indirection.
// gate: skip
// illustrative fragments (no <program> wrapper; not standalone-runnable)
<price: number(>0 && <10000)> = userInput
<email: string(email)> = formValue
<password: string(.length > 7 && .length < 255)> = rawInput
type Invoice:struct = {
amount: number(>0 && <10000)
recipient: string(email)
}
fn process(amount: number(>0 && <10000)) {
// amount is proven valid here — zero runtime checks inside the function
let discounted = amount * 0.9
let safe: number(>0 && <10000) = discounted // boundary check emitted
}
The compiler uses a three-zone enforcement model (derived from SPARK/Ada):
| Zone | When | Cost |
|---|---|---|
| Static | Compiler can prove the value satisfies the constraint (e.g. literals) | Zero — no runtime code emitted |
| Boundary | Value comes from an unproven source (user input, API response, arithmetic) | One boolean check at assignment site |
| Trusted | Value was already checked in the current scope | Zero — compiler remembers the proof |
Boundary checks emit a single synchronous predicate test; on failure the compiler throws E-CONTRACT-001-RT labeled with the assignment site. Named shapes available today: email, url, uuid, phone, date, time, color. Composable predicates (number(>0 && <10000), string(.length > 7)) cover the same ground as Zod schemas — with zero dependencies, zero bundle cost in proven code paths, and no separate schema language to keep in sync with your types.
A struct type drives the form, the schema, and the table — no schema duplication, no model-to-DTO translation, no view-model boilerplate. The same predicates that validate the values also derive the right HTML form controls and the right SQL column types.
// gate: skip
// illustrative; shows the three call-sites against one struct type
import { formFor, schemaFor, tableFor } from "scrml:data"
type Contact:struct = {
name: string(.length > 0)
email: string(email)
phone: string(phone)?
}
// formFor — render a complete form from the type
<formFor for=Contact onsubmit=save/>
// schemaFor — emit SQL DDL from the type, inside a <schema> block
<schema>${ schemaFor(Contact) }</schema>
// tableFor — render a <table> from the type plus row data
<tableFor for=Contact rows=@contacts/>
Each primitive reads the struct field validators directly: string(email) becomes a <input type="email"> form control AND a TEXT CHECK(...) column. Add a field to Contact and form / schema / table all gain it at the next build — no second source of truth to keep in sync. pick=["a","b"] / omit=["secret"] / partial=true shape the field set per call site; <slot name="fieldName"> overrides a single field's render.
See examples/26-type-derived-schema.scrml and examples/27-type-derived-table.scrml for the schemaFor + tableFor examples. A formFor worked example is pending.
The same predicate powers browser-native form validation. On bind:value inputs, the compiler derives the matching HTML attributes — string(email) emits type="email", number(>0 && <100) emits min="0" max="100", string(uuid) emits pattern=..., string(.length > 7 && .length < 255) emits minlength="8" maxlength="254". One predicate, three enforcement points: server-side boundary check, client-side boundary check, browser-native pre-submit validation. You never write the HTML attrs by hand, and they never drift from the type.
The compiler renames JavaScript bindings in the compiled output using a deterministic, type-derived encoding. @shoppingCart of type Cart becomes _s7km3f2x00 — underscore prefix, kind character (s = struct, p = primitive, e = enum, and so on), an 8-character base36 FNV-1a hash of the canonical type string, and a per-scope sequence char. Two bindings of the same type share the hash; the sequence char disambiguates.
Because the name carries the type, runtime reflect() can recover the full type descriptor from a variable alone — without shipping any unused type metadata. The decode table is tree-shaken entirely when no ^{} meta blocks reference runtime state, so most apps ship zero reflection bytes. Debug builds append $originalName so stack traces and DevTools stay readable; production builds reject that flag as a hard error.
This isn't bundler-style single-letter renaming — the names are longer than a, b, c. The wins are different: collision-free across scopes, type-introspectable at runtime, and protected fields can never leak into a client-side encoded name (the client schema view excludes them by construction, verified again at emit).
Nominal — scrml's compiler model as designed. Specified in SPEC §58; compiler implementation pending.
*marks a claim not yet actual.
scrml's compiler has a build story. Compilation is a pure function of two inputs — your source and an explicit, committed build story that pins what "the compiler" is: a content-addressed Merkle closure over the compiler-proper's four components — compiler source, language tools, the standard library, and any vendored edge code — one root hash with the dependency edges between them inside the hash, plus a human-inspectable build-story.lock sidecar. Because every part — the compiler included — is identified by the hash of its content, customizing the compiler to your project and reproducing any build bit-for-bit* stop being in tension: a tuned compiler is just a different pinned build story, and "pinned" is what makes it portable.
A build story can be pinned per <program> — <program story="…">* — and because nested <program> contexts are already isolated, shared-nothing compilation units, different parts of one application can be built by different compilers, each independently reproducible. This is deliberately not a live or hot-swappable compiler: every build story is static, read once before parsing begins; only authorship is customizable, never the running compile.
* The bit-for-bit guarantee requires a whole-compiler determinism audit not yet done. The build-story artifact and the <program story=> attribute are specified in SPEC §58 but not yet implemented in the compiler.
- Auto-split via whole-program inference. The compiler walks the call graph and infers what runs where. Functions that touch SQL,
protect=fields,Bun.*APIs,process.*,scrml:auth/scrml:crypto/scrml:fs/scrml:store/scrml:redis/scrml:cron/scrml:oauth(server-only stdlib modules) are classified server-side automatically. Caller-context propagates the classification through transitive call chains (Insight 26 Trigger 5). Theserverkeyword still parses but is redundant when inference can prove server-classification —W-DEPRECATED-SERVER-MODIFIERfires at redundant uses;W → E → parser-stripdeprecation cycle follows<machine>precedent and lands in v0.3.0. Dead, never-called functions are warned (W-DEAD-FUNCTION) and tree-shaken. - SQL passthrough (
?{}) — query SQLite directly inside logic blocks. The compiler generates parameterized queries and handles serialization. - Automatic N+1 elimination (Tier 2). A
forloop whose body does?{...WHERE id = ${x.id}}.get()is rewritten to one pre-loopWHERE id IN (?,?,?,...)fetch plus a keyedMaplookup. No DataLoader, no manual batching. Measured ~1.7×/2.3×/3.3× at N=10/100/1000 on on-disk WALbun:sqlite(v0.3.0 refresh) — see benchmarks/sql-batching/RESULTS.md. - Implicit transaction envelopes (Tier 1). Independent reads in a
!handler share oneBEGIN DEFERRED..COMMITfor snapshot consistency under concurrent writers. Explicittransaction { }blocks are left alone; aW-BATCH-001warning fires if the two would conflict. - Mount-hydration coalescing. Multiple on-mount
<x server>loads on the same page are folded into a single__mountHydrateround-trip (§8.11) instead of one request per variable. - Opt-out per call site.
?{...}.nobatch()disables rewriting when you need an exact query shape — useful forEXPLAIN, stored-procedure calls, or measured hot paths. - Diagnostics, not silent magic.
D-BATCH-001flags near-miss loops that almost batch but don't (mutation in body, non-.get()chain, etc.), with the exact disqualifier.E-BATCH-001rejects.nobatch()composition with batched siblings;E-BATCH-002guards against the 32 766SQLITE_MAX_VARIABLE_NUMBERceiling at runtime. - No API boilerplate — server functions are called like local functions. The compiler generates routes, fetch calls, CSRF tokens, and serialization.
- Per-route per-role chunk splitting (Approach A; v0.3). Whole-stack closure analysis (§40) computes exactly which component code, server functions, and stdlib units are reachable per entry point and per role. A
<auth role="Admin">block tells the compiler that only Admin-role visitors will reach the gated subtree; other roles get a strictly smaller initial bundle. Cross-route prefetching is tiered (idle / hover / on-demand); every chunk filename embeds a stable FNV-1a content hash (§47) so adopter caches stay valid across builds when source bytes don't change. The W-CG-CHUNK-* + W-AUTH-* diagnostic family flags shapes that defeat the analysis — a route linking nowhere, a gate needing a runtime check.
- WebSocket channels (
<channel>) — a lifecycle element that declares a WebSocket endpoint. The compiler emits the Bun upgrade route, a client-side connection manager with exponential-backoff reconnect, and pub/sub topic routing.onserver:open,onserver:message,onserver:closerun server-side;onclient:open,onclient:close,onclient:errorrun in the browser.protect=gates the upgrade with a session cookie check. No WebSocket or Bun-specific API appears in your source. - Shared reactive state inside channels. State declared inside a
<channel>block (<messages> = []) auto-syncs across every connected client. No@sharedmodifier — being inside the channel body is the signal. Writing in one tab updates every other tab subscribed to the same topic; the sync wire format is compiler-generated. broadcast()anddisconnect()— available inside any server handler declared in a channel's lexical scope.broadcast(data)fans out to every client on the active topic;disconnect()closes the connection. Dynamic topics viatopic=@room— when@roomchanges, the channel re-subscribes; when@roomisnot, the connection stays open but subscribes to nothing.- Nested
<program>= Web Worker. Put a<program name="compute">inside your main program and the compiler spawns a Web Worker. Shared-nothing by construction — no accidental scope leaks. Call worker exports as typed RPC:const result = await <#compute>.add(1, 2). The compiler enforces that cross-program calls are awaited. - Message passing with
when.<#worker>.send(data)posts to the worker; inside,when message(data) { ... }handles it andsend(data)replies. The parent observes lifecycle withwhen message from <#worker> (data),when error from <#worker> (e), andwhen terminate from <#worker>. No manualaddEventListener('message', ...)scaffolding. - Supervised restarts. Declare
restart="on-error",max-restarts=3,within=60as attributes on the nested<program>and the compiler synthesizes crash detection and restart bookkeeping.autostart="false"defers launch until<#name>.start(). - WASM modules and foreign sidecars. The same
<program>syntax spawns a WASM module (lang="rust" mode="wasm") or a subprocess sidecar (lang="python") with HTTP/socket routing — one execution-context primitive covers workers, WASM, and language FFI.
- Components with props and slots —
const Card = <div>defines a component. Props are attributes; slots are named placeholders. - Enums and pattern matching — Rust-style enums with exhaustive
match. The compiler enforces that every variant is handled. - State machines as engines (Tier 2).
<engine for=Type initial=.Variant>declares an exhaustive state machine over an enum.rule=declares legal transitions per state;<onTransition from= to=>runs cross-state effects;<onTimeout after=Ns to=.Variant>schedules per-state timeouts (with named timers +cancelTimer("name")builtin);<onIdle after=Ns to=.Variant>watches for engine-wide event-timeout; composite state-children may nest sub-engines with shallowhistoryrestore;internal:rule=for transitions that don't exit/re-enter the composite. Illegal transitions are compile errors. The legacy<machine>keyword is a deprecated alias (W-DEPRECATED-001;bun scrml migraterewrites it).
- Compile-time meta (
^{}) — code that runs at compile time. Usereflect()to inspect types,emit()to generate markup,compiler.*to register macros. Meta blocks execute during compilation and produce source that's spliced into the AST. - Runtime meta — meta blocks that reference
@xreactive state run at runtime instead of compile time. The compiler classifies each block automatically based on what it references.
fn— compiler-enforced purity.fnis not shorthand forfunction— it declares a pure function. The compiler statically verifies five prohibitions: no SQL access, no DOM mutation, no reactive writes, nofetch/network calls, no<request>boundaries. Usefunctionfor general-purpose callables; usefnfor deterministic computations, state factories, predicates, and transformations.
- Scoped CSS (
#{}) — styles live next to the markup they apply to. The compiler handles scoping via native@scope. - Built-in Tailwind engine — the compiler embeds a Tailwind utility registry. Use utility classes directly in markup; the compiler scans your HTML, resolves classes from the embedded registry, and emits only the CSS rules actually used. No Tailwind CLI, no PostCSS, no purge step.
- Error handling (
!{}) — typed error contexts with pattern-matched arms. Error propagation is inferred automatically. - Inline tests (
~{}) — write tests next to the code they verify. Stripped from production builds.
-
One source file type, layered imports — scrml has one source file type,
.scrml, and code enters a build through a small set of explicit, layered surfaces, never an open-ended transitive dependency graph. The no-npm stance is not a no-user-code stance — you bring whatever code your app needs, third-party code included; the rule is only that it enters through an explicit, named surface, not an implicit auto-resolved dependency graph.importwires.scrmlmodules within a project. Thescrml:*standard library is bundled with the compiler and version-locked to it — no registry, no separate semver, ~88–90% of a typical app's third-party needs already on the shelf. Everything beyond that crosses a named, governed boundary:_{}foreign code* for inline non-scrml escapes,import:host* for the bounded self-host bridge, andvendor:* for third-party units — physical source copies you own, content-addressed by hash so identity is bytes not names, and capability-gated so a vendored unit reaches the network, filesystem, or host code only where your project manifest explicitly grants it. There is no central registry and nothing is fetched without you asking.*
_{}foreign code andimport:hostare specified, not yet implemented;vendor:is a ratified design direction with its mechanism still under debate. -
<program>root — configure database connections, protection rules, HTML spec version, and program-wide settings from a single root element.
V0 foundation shipped (stdlib + 11 tools + descriptor sidecars). The
<program mcp="dev-only">adopter opt-in + end-to-end docs land in the next release.
scrml ships a Model Context Protocol surface so an LLM agent can read your running scrml app's structure first-hand instead of guessing. The compiler emits descriptor sidecars (engines.json, forms.json, channels.json, serverfns.json) at build time, and the scrml:mcp stdlib exposes them over MCP stdio as 11 read-only tools:
| Tool | Surfaces |
|---|---|
get_app_topology |
the whole <program> tree shape |
list_engines / get_engine |
engine state machines + current variant + legal transitions |
list_forms / get_form_status |
form validity surfaces + per-field touched / errors |
list_routes / get_route_chunks |
route table + which chunks each route loads |
list_server_functions |
enumerable server-fn surface (V0 read-only — dispatchable: false) |
list_channels / get_channel_state |
active WebSocket channels + shared state |
get_reachable_server_fns |
per-route reachable server-fn closure |
The strategic frame: the same structural exhaustiveness that makes a scrml app provable to a compiler — engines as exhaustive state machines, typed enums, structural state access, explicit rule= contracts, whole-program inference — makes it introspectable to an agent. Other frameworks reach for LLM-friendliness at the tools layer; scrml gets it at the language layer.
V0 is read-only metadata. A future V1 would add server-fn dispatch behind a capability gate.
A short selection of silent-failure classes closed in v0.6.x:
- Precedence-preserving binary emission — grouped expressions like
(2+3)*4no longer drop the grouping parens during codegen (Bug W). notkeyword no longer corrupts regex literals — the lowering pass skips regex bodies + comments + string interiors (GITI-017; silent-corruption class closed).- Runtime chunker tree-shake fix —
_scrml_destroy_scopedeclaratively pulls in its timer + animation helpers; no more orphan-helper class (6nz-P). - Default-logic body-top writes surface loudly — bare
@x = exprat<program>body top firesE-WRITE-NOT-IN-LOGIC-CONTEXTinstead of silently no-op'ing (Bug Q via S123 Unit CC).
The compiler is actively hardening; see docs/changelog.md for the full landing log.
scrml uses sigil-delimited contexts to separate concerns within a single file:
| Context | Sigil | Purpose |
|---|---|---|
| Program | <program> |
App root — database, protection, config |
| Markup | <tag> |
HTML elements + scrml structural elements (<engine>, <match>, <channel>, <schema>, <errors>, <onTransition>, <onTimeout>, <onIdle>, <auth>, <page>) + state decls (<name> = init) — all live in the markup tree |
| Logic | ${} |
JavaScript expressions and functions |
| SQL | ?{} |
Database queries (Bun.SQL tagged-template; SQLite shipping, Postgres in progress); auto-batched N+1 + envelope |
| CSS | #{} |
Scoped styles |
| Error | !{} |
Typed error handling (failable !{ | ::V :> ... } arms) |
| Meta | ^{} |
Compile-time (or runtime) code generation |
| Test | ~{} |
Inline tests + test-bind server-fn mocks (stripped from production) |
| Foreign | _{} |
Inline foreign code (specced, not yet implemented) |
scrml is actively converging on its spec. A few features are designed but not yet implemented; a few are implemented with known issues; the rest is live. Full per-feature drift list (with reproducers + workarounds) lives at docs/known-gaps.md. The headlines:
| Feature | Spec | What it is |
|---|---|---|
Foreign code contexts (_{}) |
§23 | Inline non-JS code with level-marked braces (_{} / _={...}=) — Rust, Python, SQL extensions, etc., passed through to an external toolchain. |
| WASM call-char sigils | §23.3 | Single-char sigils (r{}, c{}, z{}) for invoking compiled WASM functions, paired with extern declarations. |
| Sidecar process declarations | §23.4 | use foreign:name { fn } — server-side HTTP/socket sidecar services routed by scrml. |
RemoteData enum |
§13.5 | Built-in Loading / Loaded(T) / Failed(Error) for async fetch state. |
Build Story (<program story=...>) |
§58 | Content-addressed Merkle closure over the four compiler components + per-<program> build identity. |
import:host self-host bridge |
§21.3.1 | Bounded, manifest-gated import form for self-host bootstrap. |
| Quoted-text body model compiler fire | §4.18 | The spec ratifies the code-default body model + "..." display-text literal; the compiler fire is queued. |
| Severity | What | Workaround |
|---|---|---|
| HIGH | Transitive auto-await — a client function calling a server function isn't always auto-awaited across transitive call chains |
Add async / await explicitly in the client function. Deferred to the A9-class compiler-managed-async work. |
| HIGH | <each> reactive class:NAME on reused DOM — the lift/reconcile path reuses DOM nodes; the reactive class binding doesn't re-evaluate against the new iteration item |
Use a static class string inside the loop, or push the reactive class onto a per-item wrapper component. Filed 6nz-V. |
| MED | Tailwind utility residuals — a small number of Tailwind utility classes don't fully resolve through the built-in engine | Write the equivalent class explicitly or use the #{} scoped CSS form. |
| MED | MCP V0 partial impl — V0.A+B+C+D shipped; V0.E (<program mcp="dev-only"> adopter opt-in + end-to-end docs) lands next release |
The compiled MCP surface runs; the adopter opt-in attribute is the last piece. |
| MED | L19 multi-statement inline event handlers — inline onclick={ doA(); doB() } is rejected (E-MULTI-STATEMENT-HANDLER); the relaxation lives behind an open design decision |
Name the function: function startOver() { doA(); doB() } then onclick=startOver(). |
| LOW | <each> key= inference fires W-EACH-KEY-001 even when the iter-var has .id — the type-introspection in the common pipeline path is conservative |
Explicit key=@.id silences the lint and is the recommended form anyway. |
| LOW | bun scrml promote --engine Tier-1 → 2 deferred — --match works; --each is in flight; --engine is queued |
Manual lift from <match> to <engine> — the inert rule= attributes at Tier 1 are the structural staging, so the lift is mechanical. |
Everything else in this README is implemented and shipping. See docs/changelog.md for what landed when.
The examples/ directory contains curated examples that show what scrml can do:
| Example | What it shows |
|---|---|
| 01-hello | Bare minimum — compiles to pure HTML |
| 02-counter | Reactive state, binding, scoped CSS |
| 03-contact-book | Full-stack with DB, server functions, SQL |
| 04-live-search | Reactive filtering, derived state |
| 05-multi-step-form | Components, enums, pattern matching |
| 06-kanban-board | Enum-driven UI, reusable components |
| 07-admin-dashboard | Metaprogramming, type reflection |
| 08-chat | Reactive lists, server persistence |
| 09-error-handling | Exhaustive error matching with !{} |
| 10-inline-tests | ~{} inline tests, stripped from production |
| 11-meta-programming | ^{} meta blocks, emit(), reflect() |
| 12-snippets-slots | Named content slots in components |
| 13-worker | Web workers as nested programs with typed messaging |
| 14-mario-state-machine | Enum states + <engine> Tier 2 transition enforcement |
| 15-channel-chat | <channel> realtime, auto-sync channel state |
| 16-remote-data | Enum loading-state, server boundary, async classification |
| 17-schema-migrations | <schema> declarative migrations, diff-on-reload |
| 18-state-authority | <x server> Tier 2 cell authority (§52) |
| 19-lin-token | lin exact-once consumption, site-agnostic threading |
| 20-middleware | <program> attrs + handle() HTTP middleware |
| 21-navigation | navigate() + route history-aware routing |
| 22-multifile | Cross-file import/export, pure-type files, component canonical-key |
| 23-trucking-dispatch | Multi-page auth-bearing app — real /login, role gates, per-route chunks |
| 24-tilde-pipeline | ~ pipeline accumulator — last-unbound-expression carry-forward |
| 25-triage-board | Drag-and-drop between columns, struct + enum state |
| 26-type-derived-schema | schemaFor(Type) — SQL DDL generated from a struct |
| 27-type-derived-table | tableFor(Type, rows) — a <table> generated from a struct |
- Tutorial — step-by-step introduction, zero to full-stack
- Design Notes — rationale and philosophy — why scrml is what it is
- Language Specification — full formal spec (~29,000 lines)
- Spec Quick-Lookup — find any section fast
- Pipeline Contracts — stage-by-stage compiler pipeline
The working compiler for scrml — a complete compiler for the web.
This is the TypeScript/JavaScript implementation that compiles .scrml source into
HTML, CSS, client JS, and server route handlers in a single pass.
scrml lets you write a complete app in one file: markup, reactive state, scoped CSS, SQL, server functions, and inline tests — no build config, no separate server file, no state management library.
Current state — v0.7 in flight. Live phase status: master-list.md §0 · recent landings: docs/changelog.md · known spec-vs-impl gaps + per-gap workarounds: docs/known-gaps.md.
compiler/— compiler source, the authoritativeSPEC.md(~29,000 lines / §58 + appendices) /SPEC-INDEX.md/PIPELINE.md, 19,000+ tests, and reference self-host modulesexamples/— 27 runnable single-file scrml apps + the trucking-dispatch multi-page appsamples/compilation-tests/— 289 compilation tests covering every accepted constructstdlib/— 16 user-facing stdlib modules (auth,crypto,data,format,fs,http,path,process,router,store,test,time,redis,cron,regex,oauth)benchmarks/— runtime, build, and full-stack benchmarks vs React / Svelte / Vueeditors/vscode/,editors/neovim/— editor integrationslsp/server.js— language serverdist/scrml-runtime.js— shared reactive runtime
For recent fixes and work currently in flight, see docs/changelog.md.
# Install Bun if you don't have it — https://bun.sh
curl -fsSL https://bun.sh/install | bash
# Install scrml dependencies
bun install
# Link the scrml binary onto your PATH (one-time, from the repo root)
bun link
# Scaffold a new project, then run it
scrml init my-app
cd my-app
scrml dev src/app.scrml # watch + serve
# Or use the CLI directly on any .scrml file or directory
scrml compile <file|dir>
scrml dev <file|dir> # watch + serve
scrml build <dir> # production build
# Run the test suite
bun test compiler/tests/A short glossary of scrml-specific terms used throughout the README.
- reactive cell — state declared with
<name> = init. Read or written via@name; mutating it re-renders the parts of the UI that depend on it. Three RHS shapes — plain (<x> = 0), decl-coupled-with-render-spec (<userName req> = <input/>—<userName/>in the markup IS the bound input), and derived (const <x> = expr— read-only, recomputes from dependencies). - engine — Tier-2 state machine declaration:
<engine for=Type initial=.Variant>. Auto-declares a singleton cell whose value is one of the enum variants; each state-child is one variant's UI block;rule=declares legal transitions;<onTransition>/<onTimeout>/<onIdle>attach effects. Centerpiece of the language. Singleton-by-design; components are the multi-instance vehicle. See SPEC §51. - match block — Tier-1 structural form
<match for=Type>over an enum-typed value. The compiler checks exhaustiveness at compile time: every variant of the discriminating type must have a UI block.rule=attributes parse and are checked but are inert at runtime; a lint nudges promotion to Tier 2. - lifecycle annotation — type-position annotation
(A to B)declaring that a location starts holding typeAand transitions to typeB. Reads before transition fireE-TYPE-001. Zero runtime cost — the compiler tracks per-access transition state symbolically. Permitted anywhere a type goes except on engine cells (engines already own variant-graph progression viarule=). See SPEC §14.12. <channel>— file-level real-time element declaring a WebSocket endpoint. State declared inside the channel body (<messages> = []) auto-syncs across every connected client subscribed to the same topic. Compiler emits the Bun upgrade route, a client-side reconnect manager, and pub/sub routing.- validity surface — auto-synthesized read-only cells (
@form.isValid/.errors/.touchedplus per-field equivalents) produced by declaring a compound cell whose children carry validator attributes (req,length,email, etc.).<errors of=@field/>renders them at the right time. - per-role chunk —
<auth role="X">tells the compiler that only role-Xvisitors will reach the gated subtree. Other roles get a strictly smaller initial bundle. Cross-route prefetching is tiered (idle / hover / on-demand); every chunk filename embeds a content hash so adopter caches stay valid across builds when source bytes don't change. fnvsfunction—fnis a compiler-enforced pure function — no SQL, no DOM mutation, no reactive writes, nofetch, no<request>boundaries.functionis a general-purpose callable. Usefnfor predicates, transformations, and deterministic computations.- contexts (
${}/?{}/#{}/!{}/~{}/^{}/_{}) — sigil-delimited contexts within a.scrmlfile: logic / SQL / scoped CSS / typed error handling / inline tests / compile-time or runtime meta / foreign code. See Language Contexts above for the full table. not— scrml's unified absence value.nullandundefineddo not exist in scrml — neither parses, neither runs. Check absence withis not; check presence withis some.notreplaces both null and undefined across the language.
MIT — see LICENSE.
- giti — a collaboration platform and git alternative designed around scrml's compiler strengths. The CLI (save, switch, merge, undo, history, status, land, init, describe, sync) wraps jj (jujutsu) as the engine until the scrml compiler can do AST-level conflict resolution natively. Long-term vision is a hosted forge; GitHub is the stopgap.
- 6nz — a purpose-built code editor for the scrml ecosystem. An "Interactive Development Experience" written entirely in scrml, with a focus-centered viewport, NeoVim-superset keybindings plus mouse, CodeMirror 6 + canvas overlay, and offline-first PWA delivery. Currently in design phase, awaiting compiler API exposure in scrml. The companion Z-motion input spec is released under CC0 so others can adopt it.
scrml is open source under the MIT License and shipping today — bun link and the compile is real. The spec evolves as we find friction; the compiler catches up. See docs/changelog.md for what just landed and what's in flight.
The compiler runs on Bun. Compiled output is plain JavaScript that runs in any browser or JavaScript runtime.