Learn Rust Webdev by Creating a Custom CV Site

Learn Rust Webdev by Creating a Custom CV Site

A note to those in the United States, and other parts of the world: I will use the terms CV and resumé interchangeably here, as they are used this way in Australia to refer to a document more similar to the American conception of a CV.

A little while ago, I found myself needing to submit a CV as a formality for a casual admin position I’d been offered, which led me to a realisation: formatting a CV is actually quite the task! I had one from a while ago at the ready, but it needed a little updating, and, me being me, I just had to update the whole thing to look much nicer, because heaven forbid I submit a CV that didn’t look superbly aesthetically pleasing to an organisation that had already offered me a position…

Anyway, I found a number of neat little solutions, most notably Fresh, although the templates weren’t ideal, and JSON is a horrible format for human beings, so I ended up having to convert from TOML into JSON, and then the PDF renderer wasn’t working properly, so I had to render to HTML and then save as a PDF, and the whole process was just a bit annoying. That said, I did end up with something quite nice that I was happy to submit. But, I thought, I can definitely make this better.

So, that’s what I’ve done! If you’d like to have a look at the final product, check out the about page of this very website! It’s not replete with all the details I would submit to an actual employer, since this is the internet, and I do like to have some privacy, but nontheless, it provides a good guide of what I’ll wlak you through building. And, naturally, we’re going to do this in Rust, because it is a fantastic language with amazing support for building super-fast websites! The framework we’ll use to do this is Perseus, which I created back in 2021, and which I maintain today.

Getting started with Perseus

To kick things off, we’ll need a new Perseus site. If you’ve already got one, you can skip this stage.

First off, assuming you’ve installed Rust, run this command to get the latest beta version of Perseus:

cargo install perseus-cli --version 0.4.0-beta.21

Once that’s done, cd into a directory you want to put your project’s folder in, and then run these commands to create yourself a new template project:

perseus new cv-website
cd cv-website
perseus build

Replace cv-website here with whatever you want to call your site’s crate (this doesn’t impact anything else at all except the folder name and a metadata property in Cargo.toml). If you open http://localhost:8080 now, you should find an introductory page welcoming you to Perseus, which you can change the contents of to see things update automatically! If you’re working on an older machine, you might want to follow these instructions on how to improve Perseus compilation times by using an experimental compiler backend (perfectly safe, and up to 70% faster than normal rustc!).

Next up, we’ll need to add an engine-only dependency so we can parse our CV from TOML into a format Perseus can use to build our site, for which we’ll use the toml package. By making this an engine-only dependency, Perseus will automatically exclude it from our Wasm bundle, making compilation times faster, and keeping the size of the bundle you need to send to your users as small as possible. You can do this by adding this to the section of your Cargo.toml that begins with [target.'cfg(engine)'.dependencies] (that cfg(engine) is pretty self-explanatory):

toml = "0.7"
thiserror = "1" # We'll need this later...

The 0.7 here is the latest version of the toml crate at the time of writing.

Now we’ll need to create a Perseus template for handling our CV, which you can do wherever you like. For instance, on this website, it’s in the about template, which creates the about page (for more about Perseus’ distinction between pages and templates, see this page), but, if you’re just making this site to handle your CV and nothing else, you might want to put it in the index template. These templates typically have one file each in the src/templates/ directory at the root of your project, and, for the rest of this tutorial, I’ll assume you’re creating your CV in the index template (special in Perseus, as it denotes the landing page of your site). If you used perseus new as above, then you should have something like the following in there (if not, add it for now, and we’ll work from it):

use perseus::prelude::*;
use sycamore::prelude::*;

fn index_page<G: Html>(cx: Scope) -> View<G> {
    view! { cx,
        // Don't worry, there are much better ways of styling in Perseus!
        div(style = "display: flex; flex-direction: column; justify-content: center; align-items: center; height: 95vh;") {
            h1 { "Welcome to Perseus!" }
            p {
                "This is just an example app. Try changing some code inside "
                code { "src/templates/index.rs" }
                " and you'll be able to see the results here!"
            }
        }
    }
}

#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
    view! { cx,
        title { "Welcome to Perseus!" }
    }
}

pub fn get_template<G: Html>() -> Template<G> {
    Template::build("index").view(index_page).head(head).build()
}

The Perseus docs go over what all this sort of stuff means very well here, but we’ll cover it anyway now for brevity. The first two lines just import the preludes of Perseus and Sycamore, the library Perseus uses for reactivite primitives and view creation (as React is to NextJS, Sycamore is to Perseus, for those from the JS world), and then we define the index_page function, which returns a View<G>, where that <G> means it’s generic over the view backend, which is Rust’s way of expressing that this function can be rendered to any environment that has the Html trait (e.g. it can be rendered on the server-side, in the browser, or in hydration). This function takes a single argument for the Scope of the page it will generate, which is what lets you tie together all your reactive primitives. When this scope is destroyed, your page will be removed, and everything will be cleaned up neatly. Next, we use the view! macro, from Sycamore, to define the actual view of our page, in an HTML-like syntax that takes a bit of getting used to, but, to be honest, I really like it! Since this is an example app from Perseus itself, there’s no styling library, so this code is doing all the styling manually, but don’t worry, we’ll fix that up later.

The next thing this does is define a head function, which is used to define the <head> of this page (the document metadata, like the title, etc.). This also takes a Scope, but it always returns a View<SsrNode>, since Perseus intelligently prerenders document metadata on the server-side (or, in our case, at build-time). That #[engine_only_fn] macro is saying to Rust that the function should only be defined when #[cfg(engine)] (a Rust conditional compilation predicate) is true, and it should be a dummy function on the client-side. This means we can use engine-only dependencies here without them polluting our client-side code, and this is how Perseus clearly distinguishes functions that will only be defined on the server from those that will only be defined on the client (and this distinction is enforced by the compiler, so your site won’t even build if you accidentally mix things up, and you’ll get a nice error message). All this function is actually doing is defining a little title for our page.

Then, we have the public function that we’ll use to actually define our app (to learn more about what src/main.rs is doing, since we won’t need to touch it in this tutorial, see here), and that returns a Perseus Template<G> (again, generic over the rendering backend, like our View<G> from earlier). This template is called index, which Perseus recognises as the landing page of our site, and then we define the view as the index_page function, with the head function for the metadata, and then we call .build() to bring it all together.

Building a CV system

With all the preamble out of the way, let’s get to work on this thing! Before we can get to displaying anything though, we’ll need to actually have a system to parse some human-readable data from your filesystem so Rust, and by extension Perseus, can understand it and use it. For this, we need to define a schema for our data, which is the structure that we’ll expect it to have. Since this is Rust, if you make a mistake in your CV definition, there will be an error, which we’ll tell Perseus to propagate up and return to us on the command-line, so you know exactly what has gone wrong.

To begin, let’s keep things clean by defining a new module in our src/templates/index.rs file by adding the following:

mod cv {
    // ...
}

Now, let’s take a look at an example TOML file for what we want to write. Of course, you can modify this in any way you like, and adding new properties is fairly trivial once you’ve got all this done.

name = ""
role = ""
description = ""
time = [ "2023", "Present" ] # As an example

[[projects]]
name = ""
role = ""
status = ""
time = [ "2023", "Present" ]
link = "https://example.com"
description = ""
achievements = []

If we several of these files, which represent areas of our life, then each area has some projects in it, which all have their own details, and then some particular achievements. This could be represented by the following Rust schema, which we’ll put in that mod cv:

#[derive(Serialize, Deserialize)]
pub struct Cv {
    pub areas: Vec<CvArea>,
}

#[derive(Serialize, Deserialize)]
pub struct CvArea {
    pub name: String,
    pub role: String,
    pub description: String,
    pub time: (CvTime, CvTime),
    pub projects: Vec<CvProject>,
}

#[derive(Serialize, Deserialize)]
#[serde(untagged)]
pub enum CvTime {
    // This has to be a string because of TOML parsing requirements unfortunately
    Year(String),
    Present,
}

#[derive(Serialize, Deserialize)]
pub struct CvProject {
    pub name: String,
    pub role: String,
    pub status: String,
    pub time: (CvTime, CvTime),
    pub link: String,
    pub description: String,
    pub achievements: Vec<String>,
}

This is all pretty self-explanatory if you’re familiar with Rust, but some odd bits for those who aren’t might be Vec<T>, which is what we use in Rust to represent a list with elements of type T, and the #[derive(Serialize, Deserialize)] annotations: these are derive macros that ask Rust to use the code from the serde library to automatically implement functions to allow us to turn these representations of our CV into and out of strings. Strings in what format? Well, that can be defined by another library, like serde_json or, in our case, toml! (First-class support for serialization and deserialization through the serde crate is one of the most remarkable features of Rust, especially for those coming from other low-level languages.) To be able to do this though, we’ll need to add this to the top of our cv module (not the file, because the module is semantically separate):

use serde::{Deserialize, Serialize};

This just imports the two macros we’ll need to make this all work.

One final thing to note about all this code is that #[serde(untagged)] macro over CvTime (which, by the way, is an enum, which is Rust’s way of representing a type that can be in one of several states, like Result<T, E>, which can be Ok(T) or Err(E)): this tells Serde to not expect any tagging of what variant of this enum to expect. Without this, we couldn’t do something like [ "2023", "Present" ], we’d have to explicitly specify that "2023" is a year, which, in this case, is overly verbose and a little pointless.

Bringing it to life

So now we have a schema, but we want it to make it reactive. Now, we could use unreactive state here, since it’s never going to change, and that would probably be the smarter idea in general, but that won’t show off Perseus’ reactive collections nearly as well as making it all reactive would! Granted, this does add some overhead we don’t actually need at all, but it’s not really going to slow anything down, and it seems cooler to have everything be reactive, doesn’t it? If you want to make it all unreactive, like a sensible developer, that is left as an exercise to the reader. (Read: at least 30% of the point of this post is to show off the cool stuff Perseus can do, and it’d be a bit remiss of me to skip the reactive state system.)

To do this, we just need to derive ReactiveState, which we can find in the perseus::prelude module, so let’s import that at the start of =mod cv with this code:

use perseus::prelude::*;

Now let’s change all those #[derive(..)] macros to this:

#[derive(Serialize, Deserialize, Clone, ReactiveState)]

We also need to add Clone here to satisfy a few internal requirements of Perseus. You can skip the ReactiveState part of this on the enum CvTime, since there’s not really much point in making it reactive when everything above it already is. But you might be wondering why we’re not just making the top-level struct Cv reactive, and the reason for this is that we’re going to use nested reactive state, which lets you do crazy things like state.areas.get(), rather than state.get().areas, which makes lifetimes (a confusing, but incredibly powerful feature of Rust) cleaner, and it generally makes your code much neater. We can make things nested by adding #[rx(nested)] in a few places, which does…well, exactly what you’d expect. Here’s what you should end up with:

#[derive(Serialize, Deserialize, Clone, ReactiveState)]
pub struct Cv {
    #[rx(nested)]
    pub areas: RxVecNested<CvArea>,
}

#[derive(Serialize, Deserialize, Clone, ReactiveState)]
pub struct CvArea {
    pub name: String,
    pub role: String,
    pub description: String,
    pub time: (CvTime, CvTime),
    #[rx(nested)]
    pub projects: RxVecNested<CvProject>,
}

#[derive(Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum CvTime {
    // This has to be a string because of TOML parsing requirements unfortunately
    Year(String),
    Present,
}

#[derive(Serialize, Deserialize, Clone, ReactiveState)]
pub struct CvProject {
    pub name: String,
    pub role: String,
    pub status: String,
    pub time: (CvTime, CvTime),
    pub link: String,
    pub description: String,
    #[rx(nested)]
    pub achievements: RxVec<String>,
}

But, hang on a minute, what the heck is RxVecNested? And what about RxVec? What’s the difference? Where did these come from??

Well, dear reader, these are reactive collections: types Perseus provides natively that make your life much easier when working with lists (and maps) in reactive state types. We’ll need to import them though, since they are not in perseus::prelude, so add this to the top of mod cv:

use perseus::state::rx_collections::{RxVec, RxVecNested};

We’ll see how these collections work in a minute when we start using all this stuff to display things to our users, but, first, let’s make a brief clarification about the difference between RxVec and RxVecNested: the former is what you’d put something like a String in, and it makes its elements reactive by just throwing them inside Sycamore’s RcSignal type. The latter is what you’d use for a type that has its own fields, as it will call the .make_rx() method on each of its elements (that method is created automatically for us when we derive ReactiveState — nifty, eh?).

Now, let’s create a function that will create an instance of all this from a directory full of .toml files. On my site, I put these in a resume/ folder at the root of the site, but you could put them on a post-quantum floppy disk if you want.

Since we’re working with Rust, we may as well make this a method on Cv itself, but it’s important to ask ourselves where we’ll need this method: on the engine, the client, or both? The answer is just the engine, since we’ll parse these files into our struct Cv, and that’s what Perseus will send to our users’ browsers, which our code can work with directly. This is great, because it means we don’t have to worry about browser filesystems, getting toml into the browser, or anything else. To make this clear, and to make our code compile, we’ll annotate whatever we do with #[cfg(engine)], which tells Rust to simply delete whatever we do from our code when we compile it for the browser. Here’s our code:

#[cfg(engine)]
impl Cv {
    // This will not examine subdirectories
    pub fn from_directory(path: &str) -> Result<Self, Error> {
        use std::fs;

        let mut areas = Vec::new();

        for entry in fs::read_dir(path).map_err(Error::from)? {
            let entry = entry.map_err(Error::from)?;

            if entry.metadata().map_err(Error::from)?.is_file()
                && entry.file_name().to_string_lossy().ends_with(".toml")
            {
                let contents = fs::read_to_string(entry.path()).map_err(Error::from)?;
                let parsed: CvArea = toml::from_str(&contents).map_err(Error::from)?;
                areas.push(parsed);
            }
        }

        Ok(Self {
            areas: areas.into(),
        })
    }
}

This code might look a bit gnarly, but it’s actually really simple. We’re just using the std::fs::read_dir function to read through all the files at the top-level of our target directory, specified through the path argument, which will be interpreted relative to the root of our project, which means we can’t organise our files by folders, but that would be fairly trivial to implement with a library like this. We need to handle the fact that each entry in the directory might be unable to be accessed though, or the entire directory may be inaccessible, hence all that .map_err()? stuff, which we’ll come to. We’re also ignoring any folders, or any files that aren’t .toml, and we’re assuming that all files in our directory have UTF-8 names (this is the .to_string_lossy() method, since Rust strings must be UTF-8). If we find a file that matches though, we’ll read its contents, and convert them into a CvArea with the toml::from_str method (yeah, it’s that easy). If you don’t know what all those & characters mean, you’re in for a world of fun with Rust, and you should probably take a look at the Rust book! Finally, we’re using the .into() method to convert areas the Vec<CvArea> into the unreactive version of an RxVecNested<CvArea>. This method is automatically provided for convenience.

Now let’s come to the error handling in this method, which is a bit unique: we’re converting everything into the type Error, which we haven’t defined yet, using some kind of magical Error::from function, which appears to take as an argument an error of any kind. This is the preferred pattern at the moment for Perseus error handling when we’re generating the state we’ll use in our views: we create an error type that encompasses everything in our app, and then we use something like a ::from() associated function to convert everything into that. The reasoning for this will become clear in a little while. Let’s pop over to src/main.rs and drop the following code at the bottom, to define Error:

#[cfg(engine)]
#[derive(thiserror::Error, Debug)]
#[error(transparent)]
pub struct Error(#[from] Box<dyn std::error::Error + Send + Sync>);
#[cfg(engine)]
impl Error {
    #[inline]
    fn from<E: std::error::Error + Send + Sync + 'static>(value: E) -> Self {
        Error(value.into())
    }
}

Here, we’re using thiserror, a helpful crate that allows us to define our own error types easily, which it will then integrate with Rust. You could handle implementing the std::error::Error trait manually, but thiserror is simpler, and we only need all this on the engine-side anyway (since it’s only needed for state generation). The contents of our struct Error are a Box<dyn std::error::Error>, which you shouldn’t worry too much about if you’re new to Rust: it basically means that this can store anything that implements the std::error::Error trait, which is almost any error from any Rust library worth its salt. We use the #[from] helper macro in there so we can call .into() on any Box with an error inside to turn it into this error, and we use #[error(transparent)] to defer to the underlying error in the Box when we need to produce an error message. Finally, we create a manual from function that will take any error type (the 'static makes sure we don’t accept references to errors, since we only want owned types), which calls the .into() method implicit from our earlier #[from] macro use. Pretty much, this lets us take anything that returns a Result<T, E> (meaning it was either a success of type T or an error of type E) and call .map_err(Error::from) to turn E into our Error type. We can then use the ? operator to automatically return the error if we encounter it, terminating our function (a useful but of syntactic sugar Rust provides us with for brevity). All this means we can unify any errors we get (since std::fs::read_dir has a different error type from toml::from_str, as an example) into one type, which makes things much easier when we’re working with Perseus.

You’ll then want to add use crate::Error; (annotated with a #[cfg(engine)]!) to the top of mod cv back in src/templates/index.rs.

Getting it to Perseus

Now for the amusingly simple bit: getting our CV into Perseus. Since we have Cv::from_dir all implemented, we can just crate a wrapper function over that that satisfies Perseus, and then we’re good. To do that, add this function to src/templates/index.rs, but not in the mod cv!

#[engine_only_fn]
async fn get_build_state(_: StateGeneratorInfo<()>) -> Result<IndexState, BlamedError<Error>> {
    let cv = cv::Cv::from_directory("cv")?;

    Ok(IndexState { cv })
}

This get_build_state function is what we’d typically call the function that Perseus will call to generate state at build-time for our page. This will be called when we run perseus build, perseus serve, perseus export, etc., and then Perseus will automatically cache whatever it generates for later use, minimising the amount of work that has to be done when requests start to come in, and dramatically speeding up your app in many cases. This function is async, since Perseus will let us do all sorts of advanced things in here in parallel with the rest of our build-time state generator functions, and it takes a single argument with some information about the context we’re building in, like the path we’re building for, and the locale it’s in if we’re making a multilingual app. We aren’t, and we know we’re on the landing page, so we don’t need any of that stuff, hence the _ variable name to ignore it. You should change the "cv" directory to wherever you’re putting your CV files, relative to the root of your project.

Then we return a Result<IndexState, BlamedError<Error>>, which is a bit complex. First off, we need to define IndexState, which is the state type our page will expect. This will be a thin wrapper over Cv. In fact you could use Cv directly here, but we’ll keep things separate for readability. Pop this in the root of the src/templates/index.rs file:

#[derive(Serialize, Deserialize, Clone, ReactiveState)]
#[rx(alias = "IndexStateRx")]
#[rx(hsr_ignore)]
struct IndexState {
    #[rx(nested)]
    resume: cv::Cv,
}

Note that you’ll also need to add use serde::{Deserialize, Serialize}; to the top of your src/templates/index.rs file to make this work.

This is pretty similar to what we’ve seen before: just creating a reactive state type that uses nested reactivity with our resume, but we’re also adding two extra helper macros so Perseus can do some more cool stuff: we use #[rx(alias = "IndexStateRx")] so we can directly reference the reactive version of IndexState (which itself will stay unreactive, since Perseus creates a whole new struct when we derive ReactiveState). We also add #[rx(hsr_ignore)] so that the state of this page will be left out of Perseus’ hot state reloading system, which is what Perseus uses to keep your app in a constant state between reloads. When you change some code, Perseus will cache the current state of your app, reload the page, and then restore the old state. However, if you’ve changed that state itself, rather than the code (e.g. fixing a typo in one of those .toml files), then Perseus will still overwrite that with the old state unless you manually reload the page. This is annoying, and can be avoided by telling Perseus that we want to opt out of HSR on this particular page. Using unreactive state from the start would make this implicit, but, again, that is left as an exercise to the reader.

Now let’s come back to that BlamedError<Error> error type of our get_build_state function: this means that we return the blamed version of our Error type (which you’ll need to import in the root of the file as well with use crate::Error;), which Perseus uses to distinguish errors caused by client misbehaviour from those caused by genuine server errors. But, you might ask, this function runs at build-time, how could a client possibly manipulate it? Well, you’d be right! But Perseus doesn’t know that before it inspects your app, because there’s another feature of Perseus that lets you build pages in a template on-demand, meaning the get_build_state function can sometimes actually be executed before a request, in which case the client can provide a bogus path, whcih would be blamed on them. In those kinds of cases, we probably want 404 errors rather than 500s, because bogus data from clients doesn’t constitute a server error. Again, none of this is a concern in our app, but Perseus needs us to provide a BlamedError all the same.

So how are our Error types getting turned into BlamedError types? The ? operator! It implicitly performs a .into() transform when necessary, and anything implementing std::error::Error can be converted into a BlamedError! But this is also the reason why we need to convert everything into an Error first in Cv::from_directory: because ? can only perform one layer of conversions, not two, and because we need to specify a singular error type for our function to return. This means those Error::from calls are required for functions that have multiple types of errors that they need to deal with. Part of the rationale for this is also actually security, because any errors generated on the server should not necessarily just be blindly returned, because they will get to the client in a lot of cases, and you probably don’t want to have 404 errors that allow a client to enumerate the directory structure of your server…

Displaying it to the user

Alright, we’ve done enough schema work! Let’s get to actually displaying all this to users of our site now. To do that, let’s start by updating our get_template function to this:

pub fn get_template<G: Html>() -> Template<G> {
    Template::build("index")
        .view_with_state(index_page)
        .head(head)
        .build_state_fn(get_build_state)
        .build()
}

This is fairly self-explanatory: it’s the same as what we had before (and you’ll need to change the template name again if you’re using a different page for your CV), just changing .view() to .view_with_state(), which instead accepts a view function that takes in our state (defined as IndexState earlier). We’re leaving the head the same, and we’re also adding .build_state_fn(), which we’re giving our build state function (get_build_state) so Perseus knows about it, and can execute it at build-time. Unlike with a lot of JS frameworks, there are no magical function names that you just export: Perseus makes sure you know what’s going on at all times by asking you to explicitly tell it what your state generation functions are.

Next, you might as well change the title of your page, which can be done in the head fucntion from before (I trust you to figure this one out…).

Now, everything comes down to the index_page function, which we’ll redefine as follows:

#[auto_scope]
fn index_page<G: Html>(cx: Scope, IndexStateRx { resume }: &IndexStateRx) -> View<G> {
    view! { cx,
        // ...
    }
}

This has a few new things in it from our old function. We’ve replaced the contents we’re rendering with a placeholder for now, and we’re still taking in a Scope and returning a View<G>, where G satisfies the Html trait, but we’re also taking in a type IndexStateRx, which you’ll remember we defined as the name of the reactive version of our struct IndexState, which stores our resume! This is just using some fancy Rust destructuring syntax that JS developers will probably be familiar with, so that we can get the resume property out straight away. We could just as easily have taken state: &IndexStateRx and then written let resume = &state.resume;, but this is quicker.

The tricky thing to understand here is that #[auto_scope] (which we wouldn’t need if we’d been using unreactive state, which is simpler), which is one of the most complicated things in Perseus for people who are new to Rust. I mentioned lifetimes earlier, and these are how Rust keeps track of when it should dispose of variables to stop the program from running out of memory very quickly indeed, and the lifetimes for Perseus pages are a little convoluted: each page has its own lifetime, and all those lifetimes fall within the lifetime of the entire app. This can be expressed through the lifetimes on the cx variable as a BoundedScope<'app, 'page> (a Scope that lives as long as 'page, with context available from 'app). Perseus also caches our reactive state at the app-level so we can get back to pages we’ve already been to literally instantly (an example of app-level caching, which Perseus was the first to implement at a framework-level, making your life much easier), but it hands us a reference to that state that will only live as long as our page, so that we can’t accidentally create effects on our state that will outlive our page. In fact, there used to be a bug in Perseus where, if you tried to listen for when there was going to be a page change, those event listeners outlived your pages, meaning they kept on accumulating, leading to some very interesting console outputs! Today, this is long fixed, and Perseus is careful to make sure these kinds of errors are much harder to make. In short, the state we take in is &'page IndexStateRx, meaning it’s a reference to the reactive state that lives as long as the page.

You could write all this out manually, but, because it’s the same for every Perseus page, and because lifetimes are hard, Perseus provides the #[auto_scope] macro, which lets you omit all the lifetimes and use a Scope rather than a BoundedScope. Basically, that macro lets you be lazy and fixes your code for you. You can avoid it if you’d like, and you can learn more about it here.

Alright, with that tangent out of the way, let’s actually display our CV already! Let’s start off with just getting the markup (i.e. HTML) right, before we handle making it look pretty.

So, when we think about how our CV is structured, we probably want to display it something like this:

  • Area 1
    • Project 1
      • Achievements
    • Project 2
      • Achievements
  • Area 2
  • etc.

To do this, we’re going to need to do three levels of iteration: we’ll need to iterate over the areas, then we’ll need to iterate over the projects in each area, and finally over the achievements in each project. This might sound daunting, but Rust and Sycamore actually make this really easy: you can take a list of, say, areas, and turn that into a list of views that display them with the .map() function! So, let’s start with the areas by using this code inside the body of index_page (replacing the view! { cx, } etc.):

let areas = create_ref(cx, cv.areas.get());
let resume = View::new_fragment(
    areas.iter()
         .map(|area| {
             view! { cx,
                 div(class = "cv-area-wrapper") {
                    // Header
                     div(class = "cv-area-header") {
                         h3(class = "cv-area-heading") {
                             (area.name.get())
                         }
                         p(class = "cv-time") { Time(&area.time.get()) }
                     }
                     // Wrapper to maintain the spacing so the border continues out
                     div(class = "cv-content-wrapper") {
                         h4(class = "cv-area-role") { (area.role.get()) }
                         p(dangerously_set_inner_html = &area.description.get()) {}
                         div {
                             (projects)
                         }
                     }
                 }
             }
         })
         .collect()
);

view! { cx,
    div(class = "cv-wrapper-outer") {
        div(class = "cv-wrapper-inner") {
            (cv)
        }
    }
}

The first line of this is where our comprehension problems actually start, and it’s got to do with lifetimes again. But, stick with me, this is actually really easy to understand! Remember that the areas field of resume is nested? Okay, that means we can grab its value with resume.areas.get(), which will give us a reference. But that reference isn’t guaranteed to live as long as 'page, because no-one told it to! So, we need to tell it to, which is usually really hard in Rust, but Sycamore has an extremely convenient function called create_ref(cx, val) that returns &'page val, assuming cx: 'page. Pretty much, it takes a thing that it can coerce to live as long as the page (which isn’t everything, but it works here, because I lied a bit about .get(), it actually returns an Rc, which is a thing called a smart pointer in Rust…), and then returns to you a version of it that does live exactly that long. When the user goes to the next page, any such references get automatically cleaned up. The long and short of this is that you need to wrap resume.areas.get() in a create_ref() call, but the reasoning is pretty interesting if you’re getting into Rust! (I have to admit, coming from the JS world, this sort of thing felt like going from being a toddler throwing paint at a wall to developing actual software.)

But let’s backtrack for a second: why does areas need to live for as long as the page? Because we’re going to be displaying data from it, and, if those data suddenly aren’t in memory anymore, well, then what are we displaying to the user? Great question, that’s called undefined behaviour, which Rust conveniently makes impossible (well, not impossible, but really freaking hard) through things like lifetimes!

Next, we put the view for our resume in a variable called resume (views are just data structures, so you can store them in variables, send them to functions, etc.), which is basically a concatenation (i.e. summing together) of a list of View fragments created by calling the .iter().map() methods on areas, which let us provide a function that will transform each element of the list according to some logic (which, here, is creating a view to display that particular area). Then, we call .collect() to go from a Rust iterator into a Vec<View<G>> (i.e. a list of views), and that gets concatenated by View::new_fragment! Don’t worry if that doesn’t make a great deal of sense, you need to know a bit about how Rust handles iteration to properly get it, but basically understand that the function in .map() is being used to transform each element of the list.

So, what is that function doing? Well, it’s using the view! macro to create a new view, which is wrapped in a div that we’ll use for spacing, and then we have two sections: one for the header, which has the name of the area and the time we were involved in it, and then we’ve got a wrapper for the content, which contains the role we had in this area of our life, and then a description of the role (interpolated directly into HTML, which allows us to do things like <i>here's some italic text!</i> in our .toml files, or you could even do Markdown parsing on the descriptions if you wanted). There are two unexplained things in here though: where projects comes from, and what the heck Time(&area.time.get()) means. To understand the latter, remember that the area is a reactive struct CvArea, so each field can have .get() executed on it separately, which lets us not have to worry about the lifetime of area, even though we’re using .iter() and not .into_iter() (for those of you who are already Rustaceans). So, all that expression means is that we’re parsing a reference to the time field’s value to a component called Time (it’s convention in Sycamore, and most JS libraries as well, to capitalise the first letter of your component names) — this will be responsible for rendering those time tuples, when we come to it.

Finally, that last little view! macro at the bottom of our function defines the view we’ll be returning, which just interpolates the cv value (which we can do, views can be slotted into other views easily). Chances are that you’ll probably want to add some stuff around this, like a header, a footer, or maybe a picture of yourself, and perhaps your name, maybe an introduction — heck, maybe you want to put in a flappy bird clone! Go crazy, all we’re doing here is popping in that cv variable.

For now, let’s keep going on our iteration journey by defining projects. Pop the following code in just above the view! macro in the previous codeblock:

let projects = create_ref(cx, area.projects.get());
let projects = View::new_fragment(
    projects.iter()
            .map(|project| {
                let achievements = create_ref(cx, project.achievements.get());
                let achievements = View::new_fragment(
                    achievements.iter()
                                .map(|achievement| {
                                    view! { cx,
                                        // These can have styling, and they all come from
                                        // a trusted source
                                        li(class = "cv-achievement") {
                                            span(dangerously_set_inner_html = &achievement.get()) {}
                                        }
                                    }
                                })
                                .collect()
                );

                view! { cx,
                    div(class = "cv-project-header") {
                        a(class = "cv-project-name", href = project.link.get()) {
                            span { ((project.name.get())) }
                            span(style = "font-style: italic;") { (format!(" ({})", project.status.get())) }
                        }
                        p(class = "cv-time") { Time(&project.time.get()) }
                    }
                    h4(class = "cv-project-role") { (project.role.get()) }
                    p(dangerously_set_inner_html = &project.description.get()) {}
                    ul(class = "cv-achievement-list") {
                        (achievements)
                    }
                }
            })
            .collect()
);

We’ve actually just gone ahead here and thrown in the achievements as well, since they’re pretty easy to do. Notice that, because we’re doing another layer of iteration, we’re using create_ref on the area.projects field, and then we’re creating another concatenation of a list of View<G> objects, just like before — nothing new there. All this means that projects will end up holding a View<G> we can interpolate, as we did in the previous codeblock. Ignoring the section that defines achievements for a second, the markup of each project is just a header with the name of the project as a link to wherever we specified, and then the time next to it. The contents have the role we had in the project, the description (again interpolated directly as HTML to preserve things like italics), and then an unordered list (i.e. bullet points) for all the great things we did during this project.

Each achievement is just a string in our .toml files, so, fittingly, each one just translates to a list item (i.e. the li element in HTML), and we can do that with the code that defines achievements: again, it’s the same process of using create_ref and then View::new_fragment, and each achievement just gets its own li with a span for the contents, directly interpolated to preserve formatting. Notice the comment here, which clarifies a few things about dangerously_set_inner_html (which, by the way, only takes references, hence the & in front of achievement.get()): we should only ever use it when we control what’s going to be in there. Do not ever, ever, ever put stuff users have provided into this property, because they might throw scripts in there! For example, let’s say you turn this into a full-on social media site around CVs, and you let users specify their details in Markdown, which you then parse. If you don’t also run a special sanitiser on that Markdown, they might have put some nifty little <script> tags in there, which are then going to be executed in the browser of every user who visits their page, all because you put their untrusted content in dangerously_set_inner_html. These are called cross-site scripting attacks (XSS), and they are seriously not fun. Here, though, it’s fine to use this property, because it’s your personal website, and you wrote the content in the .toml files. That said, if you were to let anyone edit those files, using this property would be a terrible idea (because anyone could abuse it to make your website unusable by, say, loading cryptominer tech into your users’ browsers). Alright, XSS rant over!

Displaying the time

Now, we’ve got every little bit of this CV set up, which is pretty darn cool! However, we have a distinct problem, which is that, if you run this right now, Time is undefined. So, let’s define it! Since this is to do with the CV itself, I would put this into the cv module like so:

#[component]
pub fn Time<G: Html>(cx: Scope, time: &(CvTime, CvTime)) -> View<G> {
    let start = match &time.0 {
        CvTime::Present => "Present".to_string(),
        CvTime::Year(year) => year.to_string(),
    };
    let end = match &time.1 {
        CvTime::Present => "Present".to_string(),
        CvTime::Year(year) => year.to_string(),
    };

    view! { cx,
            span {
                (start)
                span(dangerously_set_inner_html = "&ndash;") {}
                (end)
            }
    }
}

Alright, let’s go for another breakdown. The first thing we’re doing is annotating our component function with the #[component] macro, which tells Sycamore to treat this function as…well, a component. Right now, all this actually does behind the scenes is tells Rust that it’s okay for you to have a function name that doesn’t start with a lower case letter (Rust is fussy about that kind of thing), but, in the past, it’s done much more, and it may well in the future (like enabling near-instant hot reloading, perhaps…). The rest of this function is similar to what you might expect from a Perseus view function (because they were modelled on components!): it takes in a scope (without the fancy lifetimes though, since we’re not using reactive state), and then a value, which is the property of the component. You can do all sorts of fancy stuff here, like define a struct that uses #[derive(Prop)] to let you do things like MyComponent(color = "red", foo = true, bar = 5) { span { "Hello!" } }, but, here, we just want the basics — we’ll take in a tuple of two CvTime values, and then we’re literally just matching on them to either display Present or the year in question, and then we render those in a little span. If you’re unfamiliar with HTML though, you’re probably wondering what the heck that thing in the middle is: it’s an en dash (i.e. –), which is longer than a hyphen, but shorter than an em dash (yes, these are real things, and these are what Word extends your hyphens between phrases to when you type the spacebar). In time ranges, an en dash is correct, so that’s what we’re using! With these kinds of non-bog-standard characters though, it’s generally a good idea to use the HTML codes for them: in this case, &ndash;, since otherwise the browser can get confused (especially if you’re doing this with internationalization, which Perseus supports natively, because that involves inserting some wacky special characters to tell the browser whether the language in question is read left-to-right, like English, or right-to-left, like Arabic — all this becomes a mess on the web, just use the HTML codes).

Anyway, if you import that into the root of the src/templates/index.rs file with use self::cv::Time, your code should compile! But, how do we know if it does? More to the point, how can we run this app?

Running it!

In Perseus, you’ve got the perseus CLI at your disposal for things like running your app, and debugging it while you build it. The first command you’ll want to get cozy with is perseus check -w, which, as it says on the tin, checks your app: it doesn’t compile it, it just checks if it’s valid (which is a damn sight quicker than an all-out compilation). This is different from the usual cargo check that you’d use in a Rust project in that it will automatically update when you change your code (that’s the -w, which can be applied to almost all perseus commands), and in that it checks both the engine-side and the client-side of your app. Remember those #[cfg(engine)] tags? Well, the Perseus CLI is what provides the --cfg=engine to the Rust compiler that’s needed to make this work. Because Rust views your app for the engine-side as completely different from your app for the client-side, Perseus separates the build artefacts for the two, and handles the separation of concerns in that magical dist/ directory, so you can just get on with coding, and not have to worry about recompiling everything when you change from engine-side to client-side development.

The next command you’ll use a lot is perseus serve -w, which will also watch your code for changes, but rather than checking it, it will compile and execute it, setting up a server to do so: that will then let you actually navigate to your app in a web browser, like we did at the very beginning of this tutorial (congrats on getting this far, by the way!). But, for this app, we haven’t actually done anything that needs a server: we aren’t doing anything at request-time (e.g. extracting cookies from the user’s request and using them to influence the pages we generate, say for authentication), and we aren’t doing any incremental generation or state revalidation (all really cool things you can do in Perseus that can supercharge your app), so all we actually need is a static site. In this case, we can use perseus export -sw, which isn’t faster than perseus serve -w, but it will produce a series of static files, which, because of the -s, it will then auto-serve for us. In production, we can put these onto a host like GitHub Pages, or, heck, even Dropbox, and serve them as a static website, which is usually much cheaper than running a proper server. So, let’s try it!

perseus export -sw

Chances are, you might get some errors here, maybe from a missing semicolon, or perhaps from a lifetime you missed, or maybe you misspelled the name of a macro, or maybe I can’t write code and I made a glaring mistake in this post (please point it out in the comments, thank you!), but Perseus just invokes the Rust compiler, so you should be able to pretty easily fix your mistakes (it basically guides you through the process).

Once your app is compiling and building, you should have all green ticks, and you’re good to go! Perseus will first compile your app, using Rust, and then it will execute the static generation functions you provided, like get_build_state, which could also produce errors, which the CLI would also report (e.g. if you haven’t created the directory for your .toml files yet). But, when all that is fixed, you should have an app that builds! And, because of the guarantees of Rust, if your app builds, it probably works too.

So, navigate over to http://localhost:8080, where Perseus serves your apps by default (configurable, of course), and see what you’ve got! It’ll probably look terrible, because we haven’t done any styling yet, but it should at least display all the information in your CV correctly. If it does, let’s get to styling this baby. And, you might as well keep that command running, since it will reload both your app, and the browser, whenever you change your code (and, since we added those #[rx(hsr_ignore)] lines, if you change the CV files themselves, your app will also update properly).

Making it look pretty

For us to be able to style this, we first need some kind of styling system. A lot of people, myself included, use Tailwind for this, but, to keep things open, we’ll just use pure CSS here (derived from the implementation in Tailwind on my own site). To get that working, we’ll need to import a stylesheet into our CV page, which can be done through the document metadata by adding this line after title { .. } in your head function:

link(rel = "stylesheet", href = ".perseus/static/cv.css")

Notice the lack of a leading / in this href, since Perseus inserts an HTML <base /> tag into your site automatically, meaning you can host it on relative paths (e.g. mysite.com/cv-website), and everything will still work. Adding a leading / would break this compatibility (but, if you’re hosting on a domain directly, you don’t need to worry about this).

The .perseus/static path is special, because it will contain the contents of the static/ directory at the root of your project, if you have one: so, let’s create that directory and throw a cv.css file inside! To check everything’s working, try putting this inside:

body {
    background-color: red;
}

When your app reloads, everything should be red now, and that means your CSS is being correctly imported. Here’s a style-file for you that makes things look nice, which you’re more than welcome to unpack (weird bits are commented), or change as you wish. Make sure to replace the background colour change from above with this (unless you want everything to be red, of course).

#+beginsrc css

{

margin: 0; padding: 0; }

.cv-wrapper-outer { /*

  • Put whatever you like in here to make it integrate with your site,
  • here we’re just making it centred.

/ width: 100%; display: flex; flex-direction: column; align-items: center; } .cv-wrapper-inner { / Good width for prose according to Tailwind / max-width: 65ch; } .cv-area-wrapper { margin-top: 0.75rem; margin-bottom: 0.75rem; } .cv-area-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid black; padding-left: 1rem; padding-right: 1rem; } .cv-area-heading { font-size: 1.5rem; line-height: 2rem; } .cv-time { margin-left: 0.25rem; } .cv-content-wrapper { padding-left: 1rem; padding-right: 1rem; } / Using Tailwind’s definition of a medium-sized screen here / @media(min-width: 768px) { .cv-content-wrapper { margin-left: 0.5rem; } } .cv-area-role { color: #404040; font-style: italic; font-size: 1.25rem; line-height: 1.75rem; } .cv-project-header { display: flex; justify-content: space-between; align-items: center; } .cv-project-name { font-size: 1.25rem; line-height: 1.75rem; color: #3b82f6; / Please change this to suit your site / / Let’s make a little hover animation for the link colour / transition-property: background-color, border-color, color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } .cv-project-name:hover { color: #60a5fa; / Please change this to suit your site */ } .cv-project-role { color: #404040; font-style: italic; font-size: 1.125rem; line-height: 1.75rem; } .cv-achievement-list { list-style-type: disc; } .cv-achievement { margin-left: 1.75rem; } #+endsrc

And, with that, you should have something that looks like this!

[TODO screenshot]

Deploying it!

Finally, before you go, let’s deploy this site of yours to GitHub Pages (free for public repositories). Create a new GitHub repo in your own account and name it whatever you like. Then, upload everything you’ve done to that repo (GitHub has excellent instructions for this if you’re new to Git), and then create a file in .github/workflows/deploy.yml, with these contents:

name: Build and Publish Site
on:
    push:
        branches:
            - main

jobs:
    build:
        runs-on: ubuntu-20.04
        steps:
            - uses: actions/checkout@v2

            - name: Install Dependencies
              run: cargo install perseus-cli --version 0.4.0-beta.21

            - name: Build site
              run: perseus deploy -e
              env:
                # Update this with your details so Perseus can host your site correctly
                PERSEUS_BASE_PATH: https://<your-username>.github.io/<repo-name>

            - name: Deploy site to GitHub Pages
              uses: peaceiris/actions-gh-pages@v3
              if: github.ref == 'refs/heads/main'
              with:
                  github_token: ${{ secrets.GITHUB_TOKEN }}
                  publish_dir: pkg

This will be our GitHub Actions workflow for building your app in release mode and sending it off to GitHub Pages!

If you’re unfamiliar with GHA workflows, don’t worry too much about this: it just runs whenever you push a new commit to the main branch of your repo, installs the Perseus CLI, and then runs the magic command:

perseus deploy -e

Yep, that’s all you need to deploy your brand-new app to a series of static files that can be hosted on any file hosting provider! Importantly, GitHub Pages will host your site by default at https://<your-username>.github.io/<repo-name>, which means it will be hosted at a relative path (i.e. not at the root of the <your-username>.github.io domain). This means any paths will have to have that prefixed to them, which, in some cases, could mean major changes to your app, but fear not! Perseus will automatically take this into account during deployment, as long as you provide it with a base URL where everything will be hosted. If you were using perseus deploy, which will produce a pkg/server binary, then you would provide this environment variable when you run the server, but, since we’re exporting (that’s the -e flag), we’ll provide it at compile-time (since there is no server to actually run, and this kind of info has to be hardcoded). This is done through the PERSEUS_BASE_PATH environment variable, the value of which you should update when you create your workflow file. A telltale sign that you’ve misconfigured this is if your site is uninteractive, and if there are errors in the browser console saying Perseus couldn’t find things like bundle.wasm (where all your app’s logic ends up).

Meanwhile, the actual deployment of the pkg/ directory (where perseus deploy puts everything by default) is handled by a nifty little action called actions-gh-pages, which does exactly what it says on the tin: takes the given directory, and populates the gh-pages branch with it. This means your main branch doesn’t get polluted with any artefacts from pkg/: they all get transferred into a dedicated branch that you can tell GitHub to host for you.

Assuming your repository is public, or if you’re a GitHub Pro user, then you can pop into your repo’s settings and go to the Pages section, which will let you pick a branch to deploy from: select gh-pages, and, a few moments later, your site should be ready! From here on, that workflow will ensure that, every time you push a commit to your site, it will be rebuilt for production and hosted on GitHub Pages! The default URL will be https://<your-github-username>.github.io/<repo-name>, but you can change this if you own any other domains (GitHub has some great documentation on how to do this).

Of course, if you want to deploy your static files to another provider, like Vercel or Netlify, you can do that as well! Anyone who accepts static files will work (although Netlify has had problems with the /.perseus URL on exported apps in the past, so you might have better luck with Vercel).

Conclusion

Building a website in Rust is an experience for those who haven’t worked with it before: you have to wrangle with references, lifetimes, the borrow checker, the rest of the compiler, and, if you’re coming from JS, even static typing can be a bit of a shock. Sycamore aims to make using Rust for building website views fairly painless, and Perseus builds on it to provide a comprehensive state generation system that allows you to do stuff like we’ve just done: take some .toml files, parse them at build-time, and interpolate them into your Sycamore views. In this post, we used reactive state, which wasn’t required for this use-case (and using unreactive state would simplify things), but doing so allows us to look at reactivity in Perseus, lifetimes, and application-level caching, together with a little explanation of how Perseus’ hot state reloading system works.

All in all, Perseus is a fantastic framework for building small, robust websites, as well as massive, state-heavy applications, all while maintaining ludicrous speed and excellent ergonomics (although, if you’re new to Rust, things take a while to get used to). Now that you’ve deployed your little CV website, try pointing this tool at it, and it will produce a score out of 100 based on Google’s Lighthouse metrics, which are commonly used to judge the performance of webpages. If you’ve followed these instructions correctly, you should have a 100 score on desktop easily, and a score well above 90 on mobile (Perseus doesn’t do quite as well on mobile, because Google tests with simulations of devices that aren’t optimised to run Wasm: real-world clients on more modern phones (in the Western world, at least) will usually have desktop-level speeds). If you have any problems with any of this, feel free to leave a comment on this post, and, if you’d like to learn more about Perseus, check out its website here (which is, of course, built with Perseus).

Happy coding!

Disclaimer: I am the maintainer of Perseus, give it a star!