Crabrix blog

Crabrix update: Rust-generated pages and an offline web lab

Generate a real local website with Rust, keep HTML/CSS/JavaScript separate, and understand the compiler, browser, package and sandbox boundaries.

A new local workflow, with two execution engines

Development update, 24 October 2026. Crabrix’s current work adds a local web preview alongside the existing offline Rust workspace. A web project can contain HTML, CSS and JavaScript directly; a Rust program can also generate those assets into its writable sandbox. The page is then rendered by a separate WebKit view. These are two explicit execution paths, each with its own responsibilities and limits, rather than an attempt to turn every server repository into a phone application.

This article explains the design and supplies a complete standard-library Rust example you can inspect on a desktop or adapt to the application. The implementation is undergoing release verification; this entry is a development report, not a claim that an older installed build already contains every described control. The practical goal is simple: build a small real page locally, change its behavior, read useful errors, and understand exactly which work the Rust compiler and browser each perform.

A coral crab arranges HTML, CSS and JavaScript cards beside a small press that turns a Rust block into a web page.
A generated page is an ordinary set of local assets. Rust produces them; a browser supplies layout and interaction.

What belongs to Rust, and what belongs to the browser

Rust handles the generator’s computation, file construction and ordinary program diagnostics. The compiler produces a WASI program, and that program runs with explicitly provided local filesystem access. For this workflow, its useful result is a set of files such as index.html, styles.css and app.js. A successful Rust execution establishes that the generator completed; it does not establish that every selector or event handler in the generated page behaves correctly.

WebKit reads the resulting snapshot, lays out HTML, applies CSS and executes browser JavaScript. A missing element or JavaScript exception belongs to that browser stage. Keeping the stages separate makes an error actionable: a Rust type mismatch is repaired in source before execution, whereas a misspelled DOM selector is investigated against the generated page. It also avoids reporting a successful compiler check as proof that a website has passed a functional browser test.

Rust passes through the bundled compiler and WASI program to generated assets; direct web files and generated assets converge on an immutable offline WebKit preview.
The preview receives a bounded asset snapshot, and its external network is blocked. The drawing makes no performance claim.

Start with three files before introducing a generator

A useful first web exercise needs only one HTML document, one stylesheet and one script. Link the stylesheet with a relative href, and load the script with a relative src and defer. The deferred script can then find the elements declared by the parsed document. Keep the example free of remote fonts, analytics snippets, package registries and CDN imports so its offline behavior follows from the files you can actually inspect.

Add one visible interaction, such as a button that increments a counter and updates a status paragraph. This checks more than a decorative landing page: HTML must provide the element, JavaScript must attach the listener, and the rendered text must change after activation. Make the control a real button rather than a clickable generic container. Keyboard activation, a visible focus indicator and a text status make the small exercise useful to more learners without requiring a framework.

Generate the same assets with dependency-free Rust

The program below constructs an HTML page, writes a stylesheet and script, and reports its generated filenames. On an ordinary desktop it writes a web-output directory. Under the WASI target it writes into /sandbox, the application’s explicitly writable program area. The location is a property of this example and Crabrix’s host configuration; a different WASI host can expose a different directory arrangement.

The example returns I/O errors to its caller instead of printing a success message after a failed write. Its browser script is fixed application code, while the supplied title is inserted as escaped HTML text. That separation is deliberate. Introducing user-provided data into a page requires an encoding appropriate to its destination, and escaping ordinary text for an HTML element does not make it safe to splice into executable JavaScript or arbitrary CSS. Keep those contexts distinct as the example grows.

use std::{fs, io, path::Path};
fn escape_html(text: &str) -> String {
    let mut escaped = String::with_capacity(text.len());
    for ch in text.chars() {
        match ch {
            '&' => escaped.push_str("&"),
            '<' => escaped.push_str("&lt;"),
            '>' => escaped.push_str("&gt;"),
            '"' => escaped.push_str("&quot;"),
            '\'' => escaped.push_str("&#39;"),
            other => escaped.push(other),
        }
    }
    escaped
}
fn page(title: &str) -> String {
    let title = escape_html(title);
    format!(r#"<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title><link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script></head>
<body><main><h1>{title}</h1><p>Generated by Rust. Interactive in the browser.</p>
<button id="counter" type="button">Clicks: 0</button>
<p id="status" role="status">Ready.</p></main></body></html>"#)
}
fn main() -> io::Result<()> {
    let root = Path::new(if cfg!(target_os = "wasi") { "/sandbox" } else { "web-output" });
    fs::create_dir_all(root)?;
    fs::write(root.join("index.html"), page("Rust & local web pages"))?;
    fs::write(root.join("styles.css"), r#"
:root { font-family: system-ui, sans-serif; color: #eef4f3; background: #101b26; }
main { max-width: 38rem; margin: 3rem auto; padding: 1.5rem; line-height: 1.6; }
button { font: inherit; background: #7de0c3; border: 0; border-radius: 2rem; padding: .8rem 1.4rem; }
button:focus-visible { outline: 3px solid #ffc45d; outline-offset: 4px; }
"#)?;
    fs::write(root.join("app.js"), r#"
const button = document.querySelector('#counter');
let clicks = 0;
button.addEventListener('click', () => {
    button.textContent = `Clicks: ${++clicks}`;
    document.querySelector('#status').textContent = 'This JavaScript runs locally.';
});
"#)?;
    println!("Generated index.html, styles.css and app.js");
    Ok(())
}
#[cfg(test)]
mod tests {
    #[test]
    fn text_is_escaped_without_becoming_markup() {
        assert_eq!(super::escape_html("A & <B> \"x\" 'y'"), "A &amp; &lt;B&gt; &quot;x&quot; &#39;y&#39;");
        assert!(super::page("<script>").contains("<h1>&lt;script&gt;</h1>"));
    }
}

Escape at the boundary where text becomes markup

The title is used in the document title and a heading, both ordinary HTML text contexts in this example. Escaping ampersands, angle brackets and quotation characters prevents a title such as <script> from becoming an element. The helper walks Unicode characters once and appends their representation to a new string. It does not repeatedly replace fragments in its own output, which would risk escaping freshly produced entities again.

This function is intentionally narrow. It is not an HTML sanitizer, a template engine, a URL validator or a universal protection for every page context. If you later add a link, validate its destination scheme and encode the attribute correctly. If you add structured data for JavaScript, use a serialization strategy with rules appropriate to an HTML script boundary or load it as a separate local resource where supported. A helper’s name should communicate its actual contract so future code does not quietly depend on a stronger guarantee.

Keep the generated set internally consistent

Writing several files is not automatically an atomic site update. A generator can write the HTML and then fail while creating its stylesheet, leaving a mixture of old and new assets on disk. In Crabrix, each program run starts with its own temporary sandbox, and the preview snapshot is constructed after execution. That arrangement avoids treating a failed generator as a successful fresh preview, but a general desktop publishing tool needs its own deliberate commit strategy.

For a larger generator, write into a staging directory, validate the required asset set and publish only after every step succeeds. Use relative paths consistently and reject parent traversal before copying assets. If an output filename depends on source input, validate that filename separately from the HTML content. A beautifully escaped heading does not protect a filesystem operation that accepts ../ in its destination. The page boundary and the path boundary need different checks and different tests.

A snapshot is a capability boundary, not just a cache

The preview receives immutable asset bytes selected from the project and successful generated output. It does not receive a file URL pointing at the application container or an unrestricted directory handle. A request for a missing asset returns a missing-resource response rather than causing an arbitrary host filesystem lookup. This makes the resource set inspectable and gives a run a stable identity even if the editor changes afterward.

The current snapshot has explicit limits: at most 256 supported assets, no individual asset above two mebibytes, and eight mebibytes in total. These are preview input limits, not claims about WebKit’s memory consumption. A tiny script can still compute indefinitely, and a small image can decode into much more memory than its compressed size. Closing or reloading the preview is therefore a distinct control from stopping a running Rust program. The application should describe those separate lifetimes honestly.

External network blocking changes how a page should be written

The preview’s content policy and resource blocking rules reject external network resources. A page that depends on a stylesheet from a CDN or a remote JavaScript package will not behave like a self-contained offline project. Copy assets you are entitled to redistribute into the project, replace remote dependencies with local code where practical, and inspect the browser console when a resource does not appear.

Blocking external network also means this preview is not a general web browser or an API testing client. A button that performs a remote request should report an unavailable operation rather than silently pretending to have saved data. Build offline interactions around the document and local assets, then treat any eventual network-enabled deployment as a separate environment with its own configuration and security review. The learning value comes from making those dependencies explicit instead of letting a connection hide missing pieces.

A web preview is not an Axum server

Axum’s routing and extraction abstractions are useful Rust concepts, but a typical Axum application also expects a runtime, a TCP listener and an operating-system network stack. Rendering a generated HTML document does not supply those services. Likewise, a browser executing JavaScript does not cause a server’s Rust handlers to run automatically when a page requests a route. A static page and a running HTTP service are different deliverables.

The current compatibility work includes an Axum fixture that invokes a router with an in-memory HTTP request and checks its response, without opening a TCP server. That is a deliberately narrower experiment and remains a validation target until its build gate passes. Do not convert a package’s name into a blanket compatibility claim. Record the exact version, selected features and exercised behavior; report a routing experiment as routing, not as proof that an arbitrary production server repository runs unchanged on an iPhone.

Tokio features should follow the exercise’s actual requirements

A runtime crate exposes multiple capabilities, and an exercise rarely needs all of them. A current-thread runtime, channels and timers can teach scheduling and coordination without assuming a multithreaded network server. Crabrix’s compatibility fixture uses an exact Tokio version with a restricted feature selection and checks a real spawned task, a timer and a channel result. The completed Simulator execution is evidence for that selected workload, while physical-device timing is a separate measurement.

If a project enables every optional feature, identify which one introduces an unsupported operating-system dependency before changing its source. Disabling an unused network feature can be a legitimate adaptation; removing behavior the exercise actually requires is not. A useful compatibility report explains the adaptation and the resulting limits. This is also a teaching opportunity: a dependency’s feature graph reveals where a program’s portability assumptions enter the build, rather than treating portability as a property that the crate’s name alone can guarantee.

Build scripts are another explicit execution boundary

The current compiler integration adds support for standard-library-only build.rs programs executed locally as bounded WASI guests. A verified fixture reads a project input, writes generated Rust into OUT_DIR, emits configuration and environment directives, and rebuilds correctly after the input changes. That is a real capability, but it does not reproduce every service available to a desktop Cargo build script.

A script that launches a host compiler, invokes Git, requires an independent build-dependency graph or links a native library crosses additional boundaries. The integration rejects unsupported directives rather than reporting a successful configuration that it did not apply. The Rust target documentation also notes that standard-library thread and process spawning are unavailable on ordinary wasm32-wasip1. A portable source generator that reads files and writes Rust code is therefore a different case from a native-library discovery script, even though both files share the name build.rs.

Sources: Rust target documentation: wasm32-wasip1 requirements and limitations

Separate package acquisition from offline execution

A new crates.io dependency must first be acquired while a connection is available. The package manager resolves its graph, verifies archive checksums and retains the prepared source archives for offline use. An offline rebuild can then use a frozen lockfile and those local artifacts. None of that means a phone can retrieve an arbitrary package for the first time while disconnected; the dependency bytes must already exist somewhere the application can read.

The new package progress view makes this preparation visible. It lists each package, its dependencies and whether the package is waiting, downloading, being verified, ready or failed. Closing that view does not cancel the underlying work. Compilation stages appear separately in Output, and diagnostics belong in Problems. Those boundaries help explain a failure precisely: an unavailable archive is an acquisition failure, an invalid Rust type is a compiler diagnostic, and an exception inside a rendered page is a browser error.

Keep responsiveness and speed as separate acceptance criteria

Moving compilation to a worker queue prevents its ordinary computation from occupying the interface thread, but it does not make the computation itself faster. Similarly, an actor that owns package state provides isolation without proving that a large synchronous extraction inside it cannot consume a shared executor. Test scrolling, cancellation and view dismissal while the expensive work is actually in progress, then measure total duration separately.

For performance, distinguish a cold application launch, first parsing of the bundled compiler, cold dependency compilation, a fresh root build with prepared dependencies and a cached rerun. These cases can differ by orders of magnitude. A fast cached result cannot establish a thirty-second cold build for a large dependency graph. Report the device, build configuration, exact input and cache state with each number, and compare candidates against the same workload before describing an optimization as a demonstrated compiler speed improvement.

Use language-aware help without inventing verification

The editor’s web work adds offline syntax grammars for HTML, CSS, JavaScript, TypeScript, JSON, Markdown and YAML alongside the existing Rust and TOML handling. Source text is tokenized for presentation; a highlighter does not execute the project’s script. TypeScript highlighting also does not imply a transpiler has been bundled. A file can be readable in the editor while still requiring a build step before a browser can execute it.

Local completion snippets work without an account or model. On eligible devices with Apple Intelligence available, the completion prompt now includes the source language and offline environment. Browser diagnostics can receive an explanation, while the existing Rust patch path retains its compiler-verification requirement. Do not label a web suggestion as verified by Rust merely because it came from the same advisor screen. Human review and the relevant execution stage remain necessary, especially when a model proposes a dependency that the offline environment cannot provide.

A practical test sequence for the first generated page

Run the generator and require a successful exit. Confirm that the resulting snapshot contains the intended HTML, stylesheet and script. Open the preview, inspect the heading, activate the counter and verify that both the count and status change. Introduce a deliberate selector error, run again and confirm that the browser error is visible in the console and Problems without being confused with a Rust build failure.

Next, remove the stylesheet and observe a missing local resource, then restore it. Add a remote fetch and verify that it is blocked, rather than treating a temporarily available connection as part of the project. Finally, close and reopen the preview and check that its state resets as documented. This sequence exercises the complete path from generator source through output bytes to browser interaction. It gives a small project a real acceptance test before introducing larger templates, Markdown engines or additional dependency graphs.

Review accessibility and state reset as functional behavior

A working click handler is only the beginning of a useful page. Check that a keyboard can reach the control, that the focus indicator remains visible against the chosen background, and that the status is understandable without relying on color. The example uses an ordinary button and a status paragraph, keeping the interaction available without inventing custom pointer-only behavior. Test a narrow preview as well as a wide one before deciding that the layout fits.

The counter in this example is intentionally temporary. Reloading the page constructs a new JavaScript environment and resets its value. If the exercise later needs persistence, define what should survive closing a preview and which storage facility is actually available in its nonpersistent browser data store. Do not use a surviving value from one accidental browser session as evidence of a supported save format. A durable learning result belongs in the application’s own reviewed storage workflow, separate from the demonstration page’s disposable interaction state.

These checks also make regressions easier to isolate. A lost counter after reload is expected here, while a lost source file is not. A remote resource being blocked is expected, while a bundled local stylesheet failing to load is not. Writing those expectations beside the exercise turns visual testing into a set of concrete decisions. The learner can then change the page deliberately and recognize which behavior changed, instead of treating every difference between runs as a mysterious compiler problem.

Choose the next project by the boundary you want to learn

A personal notes page, a small quiz, a local data visualization or a generated documentation index are good next exercises because their requirements can remain inside a finite asset set. Add one capability at a time and write down its inputs and outputs. When a new dependency appears, inspect whether it only transforms data or expects a process, native library, network listener or platform-specific device API.

The [Crabrix user guide](/guide/) collects the application workflow and current limits with real screenshots. The [Cargo portability article](/blog/cargo-features-portable-dependencies/) explains how to reduce unnecessary feature assumptions, and the [build-script guide](/blog/cargo-build-scripts-generated-code/) examines host-side expectations. Together they support a more useful promise than “every repository works”: an offline workspace where you can build a concrete project, see which boundary failed, and improve the compiler or the exercise with evidence rather than guesswork.

Explore the free Crabrix lessons →