Introduction

This book is about statue, a Rust library that intends to provide a convenient way to query selectors for static HTML pages. The statue library is a part of the Rust and WebAssembly ecosystem and is designed to be used in the browsers.

Place in the Rust frontend ecosystem

The statue library is meant to be used together with...

  • wasm-bindgen for interoperability between Rust and JavaScript.
  • js-sys for using JavaScript APIs and types, such as js_sys::Reflect::get (in JavaScript, target[propertyKey]), from Rust.
  • web-sys for using browser APIs and types, such as HtmlElement ( docs.rs | MDN ), from Rust.
  • wasm-pack for building and working with rust-generated WebAssembly packages.

If you're unfamiliar with wasm-bindgen, take a look at their official guide.

Unlike yew or leptos, statue does not aim to provide a full-fledged frontend framework. Instead, it focuses on a specific task: querying selectors in static HTML pages.

This makes statue a good fit for Vanilla-style, as opposed to framework-style, web development. See "Vanilla JavaScript Vs. JavaScript Frameworks: Ten Top Differences" article by Nwakor Chidinma Favour, if you want to learn more.

Features

  • Compile-time checks. The statue library harnesses the power of Rust's procedural macros to provide compile-time checks for selectors. You can ensure that your selectors are expressed using valid syntax and point to valid element(s) before running your code.
  • HTML element type inference. The statue library infers the types of the HTML elements that are pointed to by selectors based on the statically-known HTML. If the element type is currently not supported, it is inferred as web_sys::HtmlElement, which you may then cast to a more specific type yourself.
  • Quick compile-time. Even though procedural macros are generally slower to compile than regular Rust code, the statue library is designed to compile quickly. It intentionally does not depend on proc_macro2, syn, and quote traid of crates, which are known to slow down compilation times. Additionally, statue uses tl, which is fast and whose tradoffs between speed and applicability are a perfect fit for statue.

Examples of using statue

This section contains examples of using the statue, wasm-bindgen, js-sys, and web-sys crates, as well as wasm-pack CLI tool. Each example should have more information about what it's doing.

Hello world without statue

First of all, we will create a simple webpage that displays "Hello, <name>" with wasm_bindgen but without statue (and bundler). This will help you understand the basics of Rust and WebAssembly for browsers.

Go to an empty directory:

mkdir wb-hello-world
cd wb-hello-world

wb-hello-world/index.html

In this empty directory, create the index.html with the following contents:

<html>
  <head>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type"/>
  </head>
  <body>
    <script type="module">
      import init from './wb-hello-world/pkg/wb_hello_world.js';
      init();
    </script>
    <h1>Hello, friend!</h1>
    <input type="text" placeholder="Enter your name" id="name-input">
  </body>
</html>

This is a simple HTML file that has

  • An ES6 module that imports the hello_world package and calls the init function,
  • An h1 element that in the absence of JS or WASM displays "Hello, friend!",
  • An input element that allows the user to enter their name.

If we were to run a tree command in the wb-hello-world directory, we would see the following:

index.html

wb-hello-world/wb-hello-world

Ensure that you have wasm-pack installed:

wasm-pack --version

With wasm-pack installed, create a new Rust WASM project:

wasm-pack new wb-hello-world

If you were to run a tree command in the wb-hello-world directory again, you would see the following:

index.html
wb-hello-world
    ├───src
    │   ├───lib.rs
    │   └───utils.rs
    ├───Cargo.toml
    .................
    └───tests

What matters to us is that the nested wb-hello-world directory is a Rust library that we can build into a WASM package.

wb-hello-world/wb-hello-world/Cargo.toml

Find the [dependencies] section in the Cargo.toml file of the Rust library:

[dependencies]
wasm-bindgen = "0.2.84"

# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
# code size when deploying.
console_error_panic_hook = { version = "0.1.7", optional = true }

and add more dependencies:

[dependencies]
wasm-bindgen = "0.2.84"
console_error_panic_hook = { version = "0.1.7", optional = true }

[dependencies.web-sys]
version = "0.3.4"
features = [
  'Document',
  'Element',
  'HtmlElement',
  'HtmlInputElement',
  'Window',
  'EventTarget',
  'InputEvent'
]

Each of them will play a role in our project:

  • wasm-bindgen will allow us to expose Rust functions to JavaScript.
  • console_error_panic_hook will provide better debugging of panics, if any occur, by logging them with console.error.
  • web-sys will provide bindings to the Web APIs.

wb-hello-world/wb-hello-world/src/lib.rs

Replace the contents of the lib.rs file with the following:

#![allow(unused)]
fn main() {
mod utils;

use utils::set_panic_hook;
use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn start() {
    set_panic_hook();

    let window = web_sys::window().unwrap();
    let document = window.document().unwrap();

    let header = document.query_selector("h1").unwrap().unwrap();
    let name_input = document.get_element_by_id("name-input").unwrap()
        .dyn_into::<web_sys::HtmlInputElement>().unwrap();
    
    {
        let closure = Closure::<dyn FnMut(_)>::new(move |event: web_sys::InputEvent| {
            let input_element = event.target().unwrap().dyn_into::<web_sys::HtmlInputElement>().unwrap();
            let name = input_element.value();
            header.set_inner_html(&format!("Hello, {name}!")); 
        });
        name_input.add_event_listener_with_callback("input", closure.as_ref().unchecked_ref()).unwrap();
        closure.forget();
    }
}
}

This code does the following:

  • Adds wb-hello-world/wb-hello-world/src/utils.rs as a module called utils.
  • Imports the set_panic_hook function from the utils module.
  • Imports the wasm_bindgen prelude.
  • Defines a start function that will be called when the WASM module is loaded.

The start function does the following:

  • Calls the set_panic_hook function to set up better debugging of panics.
  • Gets the window and document objects from the Web APIs.
  • Queries the h1 element and the name-input element. Since web_sys doesn't have a dedicated type for h1 elements, we keep the type of the header variable as web_sys::Element.
  • Creates a closure that is then added as a listener to the input event on the name_input element. The closure updates the h1 element with the text "Hello, <name>!" where <name> is the value of the name_input element. This pattern is described in the official wasm-bindgen guide.

Build the project

Once you have the Cargo.toml and lib.rs files set up, build the project:

cd wb-hello-world
wasm-pack build --target web
cd ..

This will create a pkg directory in the wb-hello-world directory, and the pkg directory will contain the wb_hello_world.js file, among others.

Run the project

Note: You cannot directly open index.html in your web browser due to CORS limitations. Instead, you can set up a quick development environment using Python's built-in HTTP server:

wasm-pack build --target web
python3 -m http.server 8080

If you don't have Python installed, you can also use miniserve which is installable via Cargo:

cargo install miniserve
miniserve . --index "index.html" -p 8080

"Hello world" with statue

Using "Hello world" without statue as a starting point, we can update it to use statue for querying selectors.

wb-hello-world/wb-hello-world/Cargo.toml

Find the dependencies in the Cargo.toml file:

[dependencies]
wasm-bindgen = "0.2.84"
console_error_panic_hook = { version = "0.1.7", optional = true }

[dependencies.web-sys]
version = "0.3.4"
features = [
  'Document',
  'Element',
  'HtmlElement',
  'HtmlInputElement',
  'Window',
  'EventTarget',
  'InputEvent'
]

And add the statue dependency:

[dependencies]
wasm-bindgen = "0.2.84"
console_error_panic_hook = { version = "0.1.7", optional = true }
statue = "0.3"

[dependencies.web-sys]
version = "0.3.4"
features = [
  'Document',
  'Element',
  'HtmlElement',
  'HtmlInputElement',
  'Window',
  'EventTarget',
  'InputEvent'
]

wb-hello-world/wb-hello-world/src/lib.rs

Replace the initialization of the elements via the manual querying of selectors and casting to the desired types:

#![allow(unused)]
fn main() {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();

let header = document.query_selector("h1").unwrap().unwrap();
let name_input = document.get_element_by_id("name-input").unwrap()
    .dyn_into::<web_sys::HtmlInputElement>().unwrap();
}

to the invocation of the statue::initialize_elements macro:

#![allow(unused)]
fn main() {
initialize_elements!{
    html: "../index.html",
    elements: {
        let header = Single("h1");
        let name_input = Single("#name-input");
    }
};
}

Note: the various aspects of the syntax of the macro will be discussed in the Syntax section of the guide.

Also, update the use statements from this:

#![allow(unused)]
fn main() {
use utils::set_panic_hook;
use wasm_bindgen::prelude::*;
}

to this:

#![allow(unused)]
fn main() {
use utils::set_panic_hook;
use wasm_bindgen::prelude::*;
use statue::initialize_elements;
}

Build the project

Once you have the Cargo.toml and lib.rs files set up, build the project:

cd wb-hello-world
wasm-pack build --target web
cd ..

This will create a pkg directory in the nested wb-hello-world directory, and the pkg directory will contain the wb_hello_world.js file, among others.

Run the project

Note: You cannot directly open index.html in your web browser due to CORS limitations. Instead, you can set up a quick development environment using Python's built-in HTTP server:

wasm-pack build --target web
python3 -m http.server 8080

If you don't have Python installed, you can also use miniserve which is installable via Cargo:

cargo install miniserve
miniserve . --index "index.html" -p 8080

About the advantages of using statue

Even in this simple example, using statue has some benefits:

  • You can enjoy the benefits of using contextual suggestions and type inference in your IDE, since the statue macro invocation expands to strongly-typed expressions with inferred types.
  • If you make a typo in the selector, the statue macro invocation will fail to compile, which will help you catch the errors at compile time.
  • If there are breaking changes in index.html, the statue macro invocation will fail to compile, which will help you catch the errors early.
  • You can use the familiar CSS selector syntax to query elements instead of picking the right method from the web_sys API.

The syntax of statue::initialize_elements macro

This section will cover the syntax of the statue::initialize_elements function-like procedural macro.

The syntax of initialize_elements macro

First of all, statue::initialize_elements is a function-like procedural macro.

Delimiters

Since statue::initialize_elements is a function-like procedural macro, it can technically be used in any of the following ways:

  • With parentheses (❌ not recommended):
#![allow(unused)]
fn main() {
initialize_elements!(/*tokens*/);
}
  • With square brackets (❌ not recommended):
#![allow(unused)]
fn main() {
initialize_elements![/*tokens*/];
}
  • With curly braces (✔️ recommended):
#![allow(unused)]
fn main() {
initialize_elements!{/*tokens*/};
}

However, since the tokens in its invocations resemble an object, it's recommended to use curly braces.

Tokens

Any function-like proc macro is a function that takes a stream of tokens (proc_macro::TokenStream) as input and produces a stream of tokens (proc_macro::TokenStream) as output.

#![allow(unused)]
fn main() {
#[proc_macro]
fn initialize_elements(tokens: TokenStream) -> TokenStream {
    // ...
}
}

The proc macro cannot get access to the type information of the variables declared in the invocation of the macro. Therefore, the macro must be able to infer the information from the tokens it receives, and - possibly - from the context in which it is invoked. However, the proc macros currently have limited access to the information about the context in which they are invoked.

The structure of the accepted tokens will be discussed in the next subsection.

Structure of the accepted tokens

The tokens accepted by the statue::initialize_elements macro can be seen as an object with the following fields:

  • html (mandatory) - a string literal representing the path to the HTML file, relative to the CARGO_MANIFEST_DIR.
  • elements (mandatory) - a sequence of tokens resembling a block expression.
  • opts (optional) - a sequence of tokens resembling an object.

For example:

#![allow(unused)]
fn main() {
initialize_elements!(
    html: "index.html",
    elements: {
        let work_area = Single("#work-area");
        let layer_list_div = Single("#layer-list");   
        let save_files_btn = Single("#save-files", RcT);
    },
    opts: {
        window_ret_ty: Some(RcT),
        document_ret_ty: None
    }
);
}

For ease of parsing,

  • all of the fields must appear in the same order as shown above. The html field must be the first one, the elements field must be the second one, and the opts field, if present, must be the last one.
  • trailing commas are not allowed.

The html field

The html field in the invocation of the statue::initialize_elements macro like this one:

#![allow(unused)]
fn main() {
initialize_elements!(
    html: "../index.html",
    elements: {
        let header = Single("h1");
        let name_input = Single("#name-input");
    }
);
}

is always a string literal like "../index.html".

It cannot be a constant or a variable because procedural macros have access only to the tokens they receive as input and not to the compile-time or, moreover, runtime information.

The path in the html field is resolved relative to the CARGO_MANIFEST_DIR. This is the case because proc_macro::Span::source_file is not available on stable Rust.

TODO: Document the interaction of the workspaces and the resolution of the path.

The elements field

The elements field in the invocation of the statue::initialize_elements macro like this one:

#![allow(unused)]
fn main() {
initialize_elements!(
    html: "../index.html",
    elements: {
        let header = Single("h1");
        let name_input = Single("#name-input");
    }
);
}

is a sequence of tokens resembling a block expression like this one:

#![allow(unused)]
fn main() {
{
    let header = Single("h1");
    let name_input = Single("#name-input");
}
}

This block expression is used to define the mapping between the variables in Rust and the individual elements and their groups in the HTML file.

Each line in the block expression must be a variable binding like this one:

#![allow(unused)]
fn main() {
let header = Single("h1");
}

where

  • header is the name of the variable in Rust,
  • Single is the kind of the selector, and
  • "h1" is the selector itself.

Note: currently, it's impossible to specify the type for the variable in Rust because it would require parsing a type. And doing so is complicated without syn. If you need this feature, the contributions are welcome.

Selector kinds

  • Single - a single element. Specifies that the element is unique and its type will be known at compile time.
  • Multi - a group of elements. Since web_sys::Document::query_selector_all returns a web_sys::NodeList wrapped in a Result, it's impossible to return a canonical type for the group of elements. Therefore, the type of the variable in Rust will be web_sys::NodeList.

Note: internally the macro already knows the common denominator type for the group of elements, yet it's currently impossible to utilize this information in the generated code. Once the ecosystem for rustic wrappers around the Web APIs matures, this feature will be implemented.

Single selector kind

The Single selector kind is used to specify that the element is unique and its type will be known at compile time.

For example, the macro invocation below:

#![allow(unused)]
fn main() {
initialize_elements!(
    html: "../index.html",
    elements: {
        let header = Single("h1");
        let name_input = Single("#name-input");
    }
);
}

Will expand to the following code:

#![allow(unused)]
fn main() {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();

let header: web_sys::HtmlElement = document
    .query_selector("h1")
    .unwrap()
    .unwrap()
    .dyn_into::<::web_sys::HtmlElement>()
    .unwrap();
let name_input: web_sys::HtmlInputElement = document
    .query_selector("#name-input")
    .unwrap()
    .unwrap()
    .dyn_into::<::web_sys::HtmlInputElement>()
    .unwrap();
}

assuming that the element with id name-input is an input element. The type of the header variable is web_sys::HtmlElement because there's no dedicated type for h1 elements in web_sys.

Arguments

The Single selector kind can also be seen a function where

  • the first argument (mandatory) is the selector itself and
  • the second argument (optional) is the "return type kind".

For example, the macro invocation below:

#![allow(unused)]
fn main() {
initialize_elements!(
    html: "index.html", elements: {
        let save_files_btn = Single("#save-files", RcT);
    }
);
}

will expand to the following code:

#![allow(unused)]
fn main() {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();

let save_files_btn: std::rc::Rc<web_sys::HtmlButtonElement> = document
    .query_selector("#save-files")
    .unwrap()
    .unwrap()
    .dyn_into::<web_sys::HtmlButtonElement>()
    .unwrap()
    .into();
}

assuming that the element with id save-files is a button element.

You can learn more about return type kinds in the dedicated section.

Multi selector kind

The Multi selector kind is used to define a group of elements in the HTML file. Since web_sys::Document::query_selector_all returns a web_sys::NodeList wrapped in a Result, it's impossible to return a canonical type for the group of elements. Therefore, the type of the variable in Rust will be web_sys::NodeList.

Arguments

The Multi selector kind can also be seen a function where

  • the first argument (mandatory) is the selector itself and
  • the second argument (optional) is the "return type kind".

For example, the macro invocation below:

#![allow(unused)]
fn main() {
initialize_elements!(
    html: "index.html",
    elements: {
        let layers = Multi(".layer");
    }
);
}

will expand to the following code:

#![allow(unused)]
fn main() {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();

let layers: web_sys::NodeList = document
    .query_selector_all(".layer")
    .unwrap();
}

You can learn more about return type kinds in the dedicated section.

The opts field

The opts field in the invocation of the statue::initialize_elements macro like this one:

#![allow(unused)]
fn main() {
initialize_elements!(
    html: "../index.html",
    elements: {
        let header = Single("h1");
        let name_input = Single("#name-input");
    },
    opts: {
        window_ret_ty: Some(RcT),
        document_ret_ty: None
    }
);
}

is always a sequence of tokens resembling an object. It is optional and can be omitted. If present, it must follow after the elements field.

If defined, the opts field's object must contain both of the following fields:

  • window_ret_ty - an optional field that can be either (1) None or (2) Some with the "return type kind" for the window variable.
  • document_ret_ty - an optional field that can be either (1) None or (2) Some with the "return type kind" for the document variable.

You can learn more about return type kinds in the dedicated section.

Return type kinds

The "return type kind" is a concept that allows you to specify the type of the variable in Rust that will hold the result of the invocation of the query_selector or query_selector_all method.

Sometimes, you want to reference the elements in multiple places in your code - for example, when you want to reference the same element in various event listeners. In such cases, you can use the RcT return type kind.

TODO: add an example of using the RcT return type kind

Currently, there are two return type kinds available:

  • RcT - a reference-counted pointer to the element. Wraps the element in an Rc<T> where T is the type of the element.
  • T - the element itself.

Supported types

At the time of writing, the statue library supports the following types of HTML elements:

  • HtmlElement - the most generic type that represents an HTML element.
  • HtmlDialogElement - represents a <dialog> element.
  • HtmlDivElement - represents a <div> element.
  • HtmlImageElement - represents an <img> element.
  • HtmlButtonElement - represents a <button> element.
  • HtmlSpanElement - represents a <span> element.
  • HtmlCanvasElement - represents a <canvas> element.
  • HtmlInputElement - represents an <input> element.

New types can be added in the future. Feel free to contribute to the project by opening a pull request with the desired type.

One easy way to add support for more elements is to edit src/elements.rs. There you should

  • add the element to the ElementKind enum,
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum ElementKind {
    HtmlElement,
    HtmlDialogElement,
    HtmlDivElement,
    HtmlImageElement,
    HtmlButtonElement,
    HtmlSpanElement,
    HtmlCanvasElement,
    HtmlInputElement,
}
}
  • edit ElementKind::new method to return the element kind you added,
#![allow(unused)]
fn main() {
impl ElementKind {
    pub(crate) fn new(name: &Bytes) -> Self {
        if name == "div" {
            Self::HtmlDivElement
        } else if name == "img" {
            Self::HtmlImageElement
        } else if name == "button" {
            Self::HtmlButtonElement
        } else if name == "canvas" {
            Self::HtmlCanvasElement
        } else if name == "span" {
            Self::HtmlSpanElement
        } else if name == "input" {
            Self::HtmlInputElement
        } else if name == "dialog" {
            Self::HtmlDialogElement
        } else {
            Self::HtmlElement
        }
    }

    // ...
}
}
  • edit ElementKind::to_web_sys_name
#![allow(unused)]
fn main() {
impl ElementKind {
    // ...

    pub(crate) fn to_web_sys_name(&self) -> &'static str {
        match self {
            Self::HtmlElement => "HtmlElement",
            Self::HtmlDialogElement => "HtmlDialogElement",
            Self::HtmlDivElement => "HtmlDivElement",
            Self::HtmlImageElement => "HtmlImageElement",
            Self::HtmlButtonElement => "HtmlButtonElement",
            Self::HtmlCanvasElement => "HtmlCanvasElement",
            Self::HtmlSpanElement => "HtmlSpanElement",
            Self::HtmlInputElement => "HtmlInputElement",
        }
    }
}
}

Opportunities for improvement

  • It would be awesome to have a standardized typed wrapper around the web_sys::NodeList type. This would allow the user to iterate over the elements in the list without having to call the get method on the list. This would also allow the user to use the for loop syntax to iterate over the elements in the list.
  • It would be awesome to have a macro that would track the usage of the elements in the HTML file and generate the code in a way that would interleave the querying the elements with their usage. This would improve the interactivity of the pages and the perceived performance of the applications using statue.
  • It would be amazing to use the structure of the HTML file to query the elements optimally (e.g. perform selector optimization). This would allow the user to write the selectors in an easy way and have the macro generate the optimized code.
  • It would be nice to have a way to support a parser with full HTML spec compliance. Even though tl is fast and works like a charm, there still can be some edge cases where it might not work as expected.

Repo structure

The structure of the statue dir:

  • src/: Source code.
  • target/: Compiled code (generated).
  • .gitingore: Git ignore file.
  • .markdownlint.json: Configuration file for Markdown linting, used by DavidAnson.vscode-markdownlint Visual Studio Code extension.
  • Cargo.toml and Cargo.lock : Manifest and machine-generated list of dependencies. Learn more.
  • `LICENSE-APACHE: Apache License, Version 2.0.
  • `LICENSE-MIT: MIT License.
  • `README.md: The README for the crate.

The structure of the docs dir:

  • src/: the source code for the mdbook.
  • book.toml: the configuration file for the mdbook.
  • book: the compiled mdbook (generated).
  • .gitingore: Git ignore file.

Also,

  • .github/workflows: GitHub Actions workflows.