The modern DSL (Design Sub-Language) for describing UI trees.
Sakko is a bracket-based markup language for describing UI trees. Write concise, readable markup. Get a typed AST and a diagnostic report from the mature parser and typechecker, or use it as a standalone parser. (JavaScript codegen is under development.)
<card {
heading: "Hello, world"
text: "This compiles to an AST."
button: "Get Started"
}>
That is the entire source. Sakko tokenizes it, parses it, and produces a structured AST. The AST can then be transformed into anything: Sazami web components, React VNodes, JSON, you name it.
Here is a more complex example:
<player {
card(row center) {
coverart(round): "album.jpg"
details {
text(bold): "Midnight City"
text(dim): "M83"
}
controls {
icon-btn: previous
icon-btn(accent large): play
icon-btn: next
}
}
}>
cargo add sakkouse sakko::{parse_sakko, tokenize};
fn main() -> sakko::Result<()> {
// Tokenize source to tokens (useful for debugging)
let tokens = tokenize("button(accent): Click me")?;
// Parse to AST
let ast = parse_sakko(r#"
<card {
heading: "Hello"
button: "Click"
}>
"#)?;
println!("{ast:#?}");
Ok(())
}The structure parser is low-alloc: AST nodes borrow their text straight from
the source via spans, and token/line values are Cow borrows of that same
source. Parsing large templates allocates little beyond those token vectors.
The parser produces one of four node types:
| Type | Fields | Description |
|---|---|---|
root |
name, modifiers, children |
Top-level container |
element |
name, modifiers, children |
Block element with children |
inline |
name, modifiers, value |
Leaf element with text value |
list |
items |
Comma-separated group |
Modifiers are either flags or key-value pairs:
Modifier::Flag { value: "accent".into() }
Modifier::Pair { key: "cols".into(), value: "3".into() }Every token and AST node carries a Span (byte offsets into the source), that is resolvable to line/column via LineIndex.
Every Sakko document has one root block wrapped in angle brackets:
<page {
...children
}>
The root is implicit: parse_sakko wraps a file's top-level markup in a
generated root node, so ports, lints and tooling all see a single unnamed
root above the content.
Elements with children use curly braces:
card {
heading: "Title"
text: "Description"
}
Elements without children use a colon:
text: Hello world
button(accent): Click me
icon: play
Parenthesized flags or key-value pairs after the element name:
button(primary large): Submit
grid(cols 3 gap medium): [...]
card(row center curved): { ... }
input(placeholder "Email"): ""
Comma-separated elements in square brackets:
row: [button: A, button: B, button: C]
Single-line comments with //:
// This is a comment
card {
text: Hello // inline comment
}
Declare reactive state with @state:
<counter {
@state {
count = 0
step = 1
}
button @on:click { count++ }: "+"
text: "Count: {count}"
}>
Compiles to Sairin signals. Read values with {name} interpolation.
Run side effects with @effect:
<app {
@state { count = 0 }
@effect {
console.log("Count changed:", count)
js { document.title = `Count: ${count}` }
}
button @on:click { count++ }: "Increment"
}>
Compute derived values with @derived:
<app {
@state { items = [] }
@derived {
count = items.length
isEmpty = items.length == 0
}
text: "{count} items"
}>
Handle events with @on:event:
button @on:click { count++ }: "Click"
input @on:input { value = e.target.value as string }: ""
div @on:mouseenter { isHovered = true }: "Hover me"
The event parameter e has type unknown: navigate and call freely, but
assert with as before using it as a typed value.
Saho is deliberately small. When you need the full platform, escape with a
raw js block. The bytes pass through untouched, so any valid JavaScript
works:
<dash {
@state {
theme = js { return localStorage.getItem("theme") } ?? "dark"
width = js { return window.innerWidth } as number
}
button @on:click {
js { document.title = "Dashboard" }
}: "Focus"
}>
Rules:
- A
js {}block always has typeunknown, wherever it appears. unknownnavigates (.prop,[i]) and calls freely; results stayunknown.- Typed consumption - arithmetic, comparisons, assigning into a typed slot -
requires an assertion first (
as number,as string, ...). - Bodies are never checked semantically; every occurrence is recorded in the compile report for audits.
Cast with postfix as. Supported types: number, string, boolean,
null, undefined, unknown, arrays (T[]), and one nullable suffix
(T | null, T | undefined). Provably impossible casts are errors:
@state {
n = raw as number // unknown -> number: trusted
bad = 5 as string // error SKT014: cannot cast 'number' to 'string'
}
Bind inputs with @bind:
<form {
input @bind="username": ""
input(type password) @bind="password": ""
text: "Hello, {username}!"
}>
Use {expression} in text values:
text: "Hello, {name}!"
text: "{a} + {b} = {a + b}"
text: "Items: {items.map(i => i.name).join(', ')}"
Expressions are parsed by Saho (sakko::saho), Sakko's embedded strict-expression language: Pratt-precedence parsing, template literals with nested substitutions, arrows, spread, optional chaining, the works. Every interpolation round-trips byte-for-byte through parse and lower.
Sakko's target output is pre-compiled JavaScript bound to the Sairin reactive runtime: components ship as plain JS plus script tags, with zero parser code in the browser.
The compiler pipeline is under active development:
| Stage | Status |
|---|---|
| Tokenizer + structure parser | done |
| Saho expression module | done |
Typecheck pass (sakko::typecheck) |
done |
| Codegen (AST to JS) | planned |
Sakumi CLI (sakumi build) |
planned |
Once the CLI lands, building a component will be:
sakumi build counter.sakko -o dist/This emits JS you drop into a page alongside the Sairin runtime.
crates/sakko/ Rust implementation crate
src/saho/ Expression language (lexer + Pratt parser)
src/syntax/ Template tokenizer + structure parser
src/typecheck/ Typecheck pass
crates/sakko-wasm/ WASM bindings
Tests/sakko/ Integration test suites
Examples/ Example .sako files
Docs/ Documentation site (powered by DocMD)
Requires rustup. The configured toolchain is selected automatically.
cargo build # build
cargo test # run all suites
cargo clippy # lint (CI treats warnings as errors)
cargo fmt # format| Document | Summary |
|---|---|
| Language Reference | Full Sakko syntax guide |
| docs.rs/sakko | Rust API reference (once published) |
cd Docs
npm install
npm run dev