Five independent pieces, one pipeline
Kittine is deliberately not one monolithic tool. It's several independent pieces that compose through plain files and process boundaries — there's no shared in-memory state between any of them. For a client-side rendered app, the flow looks like this:
wasm32-unknown-unknown → App.wasm → wasm-bindgen (--target web) → browser-ready pkg/*.js + pkg/*.wasm → served by vite-plugin-kittine.
Every arrow in that chain is a real subprocess call or a real file write. Nothing is held in memory between steps — which makes each stage independently debuggable: you can inspect the generated .rs file directly, or run cargo build by hand, without ever touching Kittine's own code.
Inside kittine-compiler: three passes, no shortcuts
The compiler itself is a Rust CLI built from three files, each with one job:
lexer.rs is a hand-written scanner. It always tries the longest matching multi-character token first — <{, >>, </, /> — before falling back to single characters, so <{ is never accidentally split into < and {. Every token it emits carries its own line and column, which matters later: the parser needs that position information to implement indentation-based blocks.
ast.rs is intentionally boring — plain data types (Program, Import, Item, Stmt, Expr, JsxNode) with no behavior attached. All the logic lives in the two files around it.
parser.rs is a recursive-descent parser, and its one genuinely clever piece is how if> / orif> / else> blocks get delimited without braces. Each block records the source column of its own keyword as a base_col, then keeps consuming statements as long as the next statement's column is greater than that. A sibling orif> or else> is recognized because it sits back at exactly base_col. It's the same "off-side rule" Python's tokenizer uses for indentation — applied here at parse time rather than lex time, since only if> chains need it (func bodies stay ordinary brace-delimited blocks).
codegen.rs walks the AST exactly once, emitting Rust source as a plain String — no intermediate Rust AST, no syn crate. That's a deliberate simplicity tradeoff: it keeps the generator easy to reason about and its output easy to eyeball-diff against the language spec. Along the way it tracks which variable names have already been declared in the current component, which is how it decides whether <{x}> >> .. should emit a fresh let (x, set_x) = signal(..) or a plain set_x.update(..) against an already-declared signal.
Wrapping all three is main.rs — a thin clap-based CLI that runs the passes in order and also resolves imports recursively: compiling a file that imports others also compiles each imported .kitty file (and anything it imports) to its own sibling .rs, tracking already-compiled files to skip duplicate work and flagging import cycles as a compile error instead of recursing forever.
vite-plugin-kittine: making the pipeline invisible
Nobody wants to run five CLI commands by hand on every save. vite-plugin-kittine is a single Vite plugin that hooks transform() for any .kitty module id and runs the whole chain automatically:
It finds the Rust crate the file lives in by walking up for the nearest Cargo.toml, shells out to kittine-compiler build <file>, then shells out to cargo build --target wasm32-unknown-unknown for that crate — skipped entirely if nothing .rs/.kitty/.toml-relevant changed since the last build, tracked via file mtime. Then it shells out to wasm-bindgen --target web to produce browser-ready glue, and finally returns a small JS module that re-exports the wasm-bindgen glue's init function and named exports — so import init from "./App.kitty" in application code transparently gets the compiled result, no different from importing any other module.
Why this design, specifically
The subprocess-per-stage design costs a little raw speed compared to an all-in-one compiler holding everything in memory — mtime-based caching claws most of that back on incremental builds. What it buys back is much larger: every stage is a real, independently runnable tool. If wasm-bindgen misbehaves, you can run it by hand on the exact .wasm file the pipeline produced. If codegen looks wrong, the generated .rs file is sitting right there, readable, with a comment telling you not to edit it — but nothing stopping you from reading it to understand exactly what your .kitty source actually became.
That transparency is the point. A language that compiles to a black box is a language you have to trust blindly. A language that compiles to a readable, ordinary .rs file — one a Leptos developer would recognize on sight — is a language you can verify.