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.querySelector
anddocument.querySelectorAll
.
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 asjs_sys::Reflect::get
(in JavaScript,target[propertyKey]
), from Rust.web-sys
for using browser APIs and types, such asHtmlElement
( 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 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
statue
library is designed to compile quickly. It intentionally does not depend onproc_macro2
,syn
, andquote
traid of crates, which are known to slow down compilation times. Additionally,statue
usestl
, 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
ES6
module that imports thehello_world
package and calls theinit
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 withconsole.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 calledutils
. - Imports the
set_panic_hook
function from theutils
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
anddocument
objects from the Web APIs. - Queries the
h1
element and thename-input
element. Sinceweb_sys
doesn't have a dedicated type forh1
elements, we keep the type of theheader
variable asweb_sys::Element
. - Creates a closure that is then added as a listener to the
input
event on thename_input
element. The closure updates theh1
element with the text"Hello, <name>!"
where<name>
is the value of thename_input
element. This pattern is described in the officialwasm-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
, thestatue
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 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
html
field must be the first one, theelements
field must be the second one, and theopts
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. Sinceweb_sys::Document::query_selector_all
returns aweb_sys::NodeList
wrapped 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)None
or (2)Some
with the "return type kind" for thewindow
variable.document_ret_ty
- an optional field that can be either (1)None
or (2)Some
with the "return type kind" for thedocument
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 anRc<T>
whereT
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 theget
method on the list. This would also allow the user to use thefor
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 byDavidAnson.vscode-markdownlint
Visual Studio Code extension.Cargo.toml
andCargo.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.