# What a component compiler owns when the server can’t run JavaScript

Author: Chris Chabot
Canonical URL: https://chabot.dev/blog/component-compiler-when-the-server-cant-run-javascript/
Published: 2026-11-03

Compiling JSX-shaped templates to plain Rust, and dividing scoping, type checks, escaping and navigation among the compiler, rustc and the runtime.

Rust has good template engines, and a constrained Ferrite application can't simply use any of them. [Maud](https://maud.lambda.xyz/) and [Askama](https://github.com/askama-rs/askama) do their work in procedural macros, which the platform's admission gate refuses. [Tera](https://keats.github.io/tera/) parses templates at runtime as an ordinary library, and an application on Ferrite, my Rust application platform, gets exactly one dependency, which is already spoken for. The other obvious route, a JavaScript component framework rendering on the server, needs a JavaScript server, and there isn't one. Components with scoped styles, layouts and the authoring comfort of JSX would have to be compiled into something the platform already accepts, which means plain Rust.

So the compiler runs in Ferrite's command-line tool, on the developer's machine, and never inside an application. It turns JSX-shaped templates, whose control blocks will look familiar to anyone who has used Svelte, into Rust that calls a small runtime and passes the same gate as everything else in the application. The convenience is bought at compile time, and what ships is ordinary code anybody can read. With the frontmatter left out and the output simplified, a template like this one

```
<article class="post">
  <h1>{post.title}</h1>
  {#if post.draft}
    <p>Not published yet.</p>
  {/if}
</article>

<style>
  h1 { margin: 0; }
</style>
```

comes out looking roughly like this:

```rust
out.push(Html::raw("<article class=\"scope-1a2b3c4d post\"><h1>"));
out.push(Html::text(&(post.title)));
out.push(Html::raw("</h1>"));
if post.draft {
    out.push(Html::raw("<p>Not published yet.</p>"));
}
out.push(Html::raw("</article>"));
```

The design question is how much of the job belongs to the compiler. With no JavaScript server to fall back on, nothing else is going to scope the styles, make the output safe or work out what a navigation has to resend. The compiler isn't alone, though: rustc is still there and is better at its job than anything I would write, and some values don't exist until a request arrives. Most of the decisions in this system came down to dividing the work among those three.

Expressions went to rustc. The compiler doesn't understand the Rust inside a template. It carries each expression as a verbatim token string with its byte offsets and writes it into the output, so the condition in an `{#if}` is literally the `if` condition rustc sees. That saves me from writing a type checker, and it creates a debt, because rustc now reports errors in a file the author never wrote. All code generation therefore goes through one buffer that keeps a map from generated line ranges back to template offsets, and the dev loop translates rustc's diagnostics through that map into a position in the template. The same concern for what the author sees explains a small detail in the output above: interpolations take a reference, so that printing a value in markup doesn't move it and break the next line of the author's code.

Styles went to the compiler outright, because rustc knows nothing about CSS and by request time it's too late. Each template's scope class comes from the first eight hex digits of a SHA-256 of its path. The root element gets the class prepended to its own, or a wrapper `<div>` is synthesized to carry it, and a root whose class is computed is refused, since the compiler can't prepend to a value it can't see. Rewriting selectors takes a real scanner, because a substring pass goes wrong on `content: "}"`, on `:is(.a, .b)` and on `[data-x=","]`, and the scanner has to recurse into conditional groups. `@import`, `@charset` and `@namespace` are refused altogether, because every template's styles end up aggregated into one stylesheet and those rules can't mean there what they meant in the file where they were written. Keys on `{#for}` loops follow the same reasoning: the key gets stamped on the loop body's single root element, and a body with several roots is refused because there is no one place to put it.

Safety had to be split between the compiler and the runtime, and that split is the part of the design I would defend hardest. Escaping is what everyone remembers, and there is one normative escaper for text and attributes. A hostile slug comes out escaped in both contexts, and the path canonicalizer refuses an encoded separator before any template sees it. But escaping does nothing for an active sink. A value escaped perfectly for HTML is still a script once it lands in `onclick`, because the browser decodes the attribute before running it. So the runtime's builder refuses those sinks by construction: `el` won't build an active tag such as `script` or `iframe`, `attr` won't set `on*`, `srcdoc` or `style`, and the framework's own static tags go through a separately named `trusted_el`. URLs get their own policy on every URL-bearing attribute: reject control characters, remove whitespace and normalize case for the scheme check, allow `data:` only for raster images so that SVG stays out, and render anything refused as `#refused`. The compiler helps by rejecting a literal `javascript:` href at build time, but that is an early warning for the case it can see. The boundary is the runtime check, which sees the values the compiler can't.

Inline styles make the distinction especially clear. The generic builder refuses `style`, keeping untrusted values out of the CSS interpreter. A separate trusted-style escape hatch accepts developer- or framework-authored declarations and has a simple filter for obvious mistakes, but CSS escapes and comment splitting can evade that filter. The refusal is the boundary; the filter is not a sanitizer for untrusted CSS.

Navigation went to the runtime, with the compiler supplying the structure. Because layouts compose in Rust, the runtime knows the chain of segments behind each page, and it hashes every layout with a sentinel in the hole where its child goes, so a layout hashes the same whichever page it wraps. A soft navigation can then be answered with a patch anchored at a layout slot, never at a page, with slot boundaries marked by HTML comments in the document. On the wire, moving around a blog section patches at that segment without re-sending the surrounding chrome, an identical render returns null HTML, and a matching ETag gets a 304 with an empty body. `<Suspense>` compiles into a slot call with two closures, one for the fallback and one for the body, and the request decides which to run, so the branch not taken costs nothing. The browser side of all this is a 16.4 KB gzipped client, and even that is materialized into Rust source, because the gate refuses `include_str!`.

Actions are the part most easily overstated. A handler stages its effects, and the staged effects, an admission marker and the recorded reply are written together as one envelope. A later delivery of the same action gets that reply back verbatim, failures included, and cache invalidation is applied on every delivery. Deliver the same action four times and you get the same envelope four times, with exactly one reporting itself admitted and the effect happening once. It would be natural to write that down as "handlers run once", and it would be wrong. The handler's body may run more than once, only what it staged is protected, and anything it does outside the envelope isn't covered.

The response transport also determines what the template can promise. A first page load goes out as one buffered reply, so `<Suspense>` selects content without streaming a later piece into the initial response. Live regions run over the platform's stream interface, with subscriptions validated against declared source prefixes and resumed by log offset, while per-connection tailers poll for updates.

The division works because each part checks what it can actually see. The component compiler owns the markup structure and styles, rustc checks the expressions, and the runtime examines values that arrive with a request. Moving a check earlier is useful only if the information it needs exists there, which is why a literal `javascript:` can be refused during compilation while a request-derived URL still needs the runtime policy.
