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:

App.kittykittine-compiler (lexer → parser → codegen) → App.rs (marked "Generated by kittine-compiler. Do not edit by hand.") → cargo + rustc targeting wasm32-unknown-unknownApp.wasmwasm-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.