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.
- To learn more about CSS selectors, see this.
- To learn more about querying selectors in JavaScript, see the docs for
document.querySelectoranddocument.querySelectorAll.
Place in the Rust frontend ecosystem
The statue library is meant to be used together with...
wasm-bindgenfor interoperability between Rust and JavaScript.js-sysfor using JavaScript APIs and types, such asjs_sys::Reflect::get(in JavaScript,target[propertyKey]), from Rust.web-sysfor using browser APIs and types, such asHtmlElement( docs.rs | MDN ), from Rust.wasm-packfor 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
statuelibrary 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
statuelibrary 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 asweb_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
statuelibrary is designed to compile quickly. It intentionally does not depend onproc_macro2,syn, andquotetraid of crates, which are known to slow down compilation times. Additionally,statueusestl, which is fast and whose tradoffs between speed and applicability are a perfect fit forstatue.
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
ES6module that imports thehello_worldpackage and calls theinitfunction, - An
h1element that in the absence of JS or WASM displays"Hello, friend!", - An
inputelement 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-bindgenwill allow us to expose Rust functions to JavaScript.console_error_panic_hookwill provide better debugging of panics, if any occur, by logging them withconsole.error.web-syswill 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.rsas a module calledutils. - Imports the
set_panic_hookfunction from theutilsmodule. - Imports the
wasm_bindgenprelude. - Defines a
startfunction that will be called when the WASM module is loaded.
The start function does the following:
- Calls the
set_panic_hookfunction to set up better debugging of panics. - Gets the
windowanddocumentobjects from the Web APIs. - Queries the
h1element and thename-inputelement. Sinceweb_sysdoesn't have a dedicated type forh1elements, we keep the type of theheadervariable asweb_sys::Element. - Creates a closure that is then added as a listener to the
inputevent on thename_inputelement. The closure updates theh1element with the text"Hello, <name>!"where<name>is the value of thename_inputelement. This pattern is described in the officialwasm-bindgenguide.
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.htmlin 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 8080If 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.htmlin 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 8080If 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
statuemacro invocation expands to strongly-typed expressions with inferred types. - If you make a typo in the selector, the
statuemacro invocation will fail to compile, which will help you catch the errors at compile time. - If there are breaking changes in
index.html, thestatuemacro 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_sysAPI.
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 theCARGO_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
htmlfield must be the first one, theelementsfield must be the second one, and theoptsfield, 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
headeris the name of the variable in Rust,Singleis 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. Sinceweb_sys::Document::query_selector_allreturns aweb_sys::NodeListwrapped in aResult, it's impossible to return a canonical type for the group of elements. Therefore, the type of the variable in Rust will beweb_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)Noneor (2)Somewith the "return type kind" for thewindowvariable.document_ret_ty- an optional field that can be either (1)Noneor (2)Somewith the "return type kind" for thedocumentvariable.
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 anRc<T>whereTis 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
ElementKindenum,#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq)] pub(crate) enum ElementKind { HtmlElement, HtmlDialogElement, HtmlDivElement, HtmlImageElement, HtmlButtonElement, HtmlSpanElement, HtmlCanvasElement, HtmlInputElement, } }
- edit
ElementKind::newmethod 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::NodeListtype. This would allow the user to iterate over the elements in the list without having to call thegetmethod on the list. This would also allow the user to use theforloop 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
tlis 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 byDavidAnson.vscode-markdownlintVisual Studio Code extension.Cargo.tomlandCargo.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.