Markdown Blog in Rust with Tide

Up until now, it was relatively difficult to implement async web servers in rust-lang due to the constraints with the language. Now that async/await is part of the standard library (in terms of syntax and compilation) we can take advantage of async runtimes and popular web frameworks to build our very own blog in Rust!

To do this we will take advantage of an async runtime known as async-std as well as a minimal web framework http-rs/tide.

We will also use pulldown-cmark to support the CommonMark markdown specification and handlebars for templating.

Let's get started.

Dependencies

First you'll need to setup a new project with a few dependencies by running cargo init. Then add the following dependencies to your Cargo.toml file.

[package]
name = "notes"
version = "0.1.0"
edition = "2021"

[dependencies]
async-std = { version = "1.12.0", features = ["attributes"] }
handlebars = "4.3.1"
pulldown-cmark = "0.9.1"
serde = { version = "1.0.137", features = ["derive"] }
serde_json = "1.0.81"
tide = "0.16.0"

async primitives with async-std

We'll use async-std to manage the async runtime for us. All async/await code gets transformed into code that simply returns a Future trait. It's a bit more than that as the compiler will use generators to do this, but ultimately the result is still a Future.

Because futures are lazy by default, an executor (or runtime) needs to actually poll in order to advance each future in the executor's task queue. A runtime typically includes a task queue, an executor that manages those tasks, and an underlying reactor to react to underlying OS specific event notifications and non-blocking apis (e.g. mio or polling). The polling api will typically abstract over either readiness based models (e.g. epoll, kqueue, poll) or completion based models (e.g. iocp, io_uring).

For an in depth look at async I/O, runtimes and system call apis like the ones listed above take a look at a series I cover here on YouTube.

youtube.com/c/nyxtom

use async_std::net::TcpListener;
use async_std::io::WriteExt;

#[async_std::main]
async fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:7000").await?;
    while let Ok((mut client, addr)) = listener.accept().await {
        client.write_all(b"Hello world\n").await?;
    }

    Ok(())
}

You'll see the async_std::main above as a macro that simply expands out into a block_on function. The async_std runtime needs to convert the async function into something that returns a Future trait and does so with a simple function that spawns a task with an async block.

use async_std::net::TcpListener;
use async_std::io::WriteExt;

fn main() -> std::io::Result<()> {
    async fn main() -> std::io::Result<()> {
        let listener = TcpListener::bind("127.0.0.1:7000").await?;
        while let Ok((mut client, addr)) = listener.accept().await {
            client.write_all(b"Hello world\n").await?;
        }
        Ok(())
    }
    async_std::task::block_on(async { main().await });
}

Spawning a task on the executor kicks off the root future to be polled while leaf futures such as async_std::net::TcpListener will wrap std lib std::net::TcpListener within an Async adapter. What's great about the way async_std is now implemented is that the underlying async primitives rely on the smol-rs/async-io crate. These provide additional traits through generic Async types built for the standard library. This makes it easy to extend any primitive that has I/O handles that can be put into non-blocking mode (via epoll/kqueue..etc) to provide an async interface for it.

smol-rs is also a great lightweight and fast async runtime that I highly recommend as well. The code is split up between composable executors, async-fs, async-io, locking, async-process, task abstraction via async-task, lightweight futures, polling among a few other composable packages that make async primitives and runtimes a breeze to work with over more monolithic packages! It's so good that async-std switched #836

pulldown-cmark

In order to actually support transforming markdown (e.g. CommonMark specification) into HTML we will use the pulldown-cmark crate. There are numerous cmark/markdown parsers among all the variants, but in particular this crate will use a pull parsing approach. This design approach keeps parsing and rendering cleanly separated without the need for error-prone state based callbacks. At its core, the pull parser is an iterator of events and this makes pulldown-cmark particularly idiomatic rust in implementation. We'll use it below:

use pulldown_cmark::{html, Parser};

fn main() {
    let markdown_input: &str = "Hello world, this is a *very simple* example.";
    let parser = Parser::new(markdown_input);

    let mut html_output: String = String::new();
    html::push_html(&mut html_output, parser);

    let expected_html = "<p>Hello world, this is a <em>very simple</em> example.</p>\n";
    assert_eq!(expected_html, &html_output);
}

actix.rs vs http-rs/tide

In order to build a simple web application we are going to need to pick a web framework. I've chosen http-rs/tide for now due to some of the limitations and idiomatics of other frameworks. While tide only currently supports HTTP/1.x, the main reason I'd like to use this over other frameworks (such as actix-rs) has much to do with the way the code is implemented and the push for idiomatic async rust. Tide was created by the rust network service wg. Part of what I dislike about other frameworks like actix-rs is the use of the Service trait as it adopts some of the behavior that you typically find in places like Servlets in Java. While some of this is mitigated by the use of closures to make_service_fn, I still prefer an approach that can push towards some kind of simplified and functional async middleware.

While actix.rs does provide some nice tooling for simple handlers like many frameworks (including the use of decorators).

#[get("/")]
async fn index(_req: HttpRequest) -> String {
    "Hello world!".to_owned()
}

The implementation of a middleware function can get somewhat confusing pretty quickly due to the use of associated types and Service/Transform models. The advantage, however with this approach is that it can be easier to maintain internal state as a transform/service can do more than be a simple middleware function.

pub struct SayHiMiddleware<S> {
    service: S,
}

impl<S, B> Service<ServiceRequest> for SayHiMiddleware<S>
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;

    forward_ready!(service);

    fn call(&self, req: ServiceRequest) -> Self::Future {
        println!("Hi from start. You requested: {}", req.path());

        let fut = self.service.call(req);

        Box::pin(async move {
            let res = fut.await?;

            println!("Hi from response");
            Ok(res)
        })
    }
}

There are wrappers to mitigate having to implement a more robust middleware service as in the above case via the wrap_fn but it's more for simple cases.

use actix_web::{dev::Service as _, middleware, web, App};
use actix_web::http::header::{CONTENT_TYPE, HeaderValue};

async fn index() -> &'static str {
    "Welcome!"
}

let app = App::new()
    .wrap_fn(|req, srv| {
        let fut = srv.call(req);
        async {
            let mut res = fut.await?;
            res.headers_mut()
                .insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
            Ok(res)
        }
    })
    .route("/index.html", web::get().to(index));

In general, middleware is treated as a Service and Transform trait model. It also appears that much of this could of been designed prior to improvements to borrowing within async/await.

I'll also plainly admit that the bulk of the complexity in many of these libraries isn't necessarily their fault so much as its rust fault for not having proper support for async in traits. As async/await lands in traits and more improvements come such as async iterators we should see more clean and idiomatic libraries be released.

http-rs/tide

The complexity of Services, ServiceFactories, and Transformers adds some generic flexibility that may not be needed for relatively simple projects like ours. Complexity can be great for different requirements, but for our needs it helps with the learning curve to look at much more simplified models like tide where both the repo code and our app feels native. Let's start with a simple app:

async fn index(req: Request<()>) -> tide::Result {
    let mut res = Response::new(200);
    res.set_body("hello world");
    Ok(res)
}

#[async_std::main]
async fn main() -> std::io::Result<()> {
    let mut app = tide::new();

    // map / to index
    app.at("/static").serve_dir("client/dist")
    app.at("/").get(index);

    // listen and await
    app.listen("127.0.0.1:1234").await?;
    Ok(())
}

Server

Let's explore what this is doing in further detail. tide::new() is simply a function that returns a Server.

pub fn new() -> server::Server<()> {
    Server::new()
}

The actual Server implementation contains a few fields, a Router which contains a simple HashMap between methods to router functions, a State object for passing along between requests, and a thread safe middleware list for processing requests.

pub struct Server<State> {
    router: Arc<Router<State>>,
    state: State,
    middleware: Arc<Vec<Arc<dyn Middleware<State>>>>
}

Routing

The at("/") function in the Server simply uses the Router to return a Route where the majority of the api functions are. We use the .get(index) method here, but there are other methods available (get, head, put, post, delete, options, connect, patch, trace). All of which simply push the method, path and middleware endpoint to the router.

impl<'a, State: Clone + Send + Sync + 'static> Route<'a, State> {
    pub fn method(&mut self, method: http_types::Method, ep: impl Endpoint<State>) -> &mut Self {
        if self.prefix {
            let ep = StripPrefixEndpoint::new(ep);
            let wildcard = self.at("*");
            wildcard.router.add(
                &wildcard.path,
                method,
                MiddlewareEndpoint::wrap_with_middleware(ep, &wildcard.middleware),
            );
        } else {
            self.router.add(
                &self.path,
                method,
                MiddlewareEndpoint::wrap_with_middleware(ep, &self.middleware),
            );
        }
        self
    }
}

Static Files

Additional methods on the route provide the ability to serve files and serve directories. These are simple endpoint middleware implementations that use the http-types Body::from_file, which (along with setting content type and length) uses async_std File::open. async-std::fs interacts with the filesystem in an asynchronous way by initiating thread pool tasks and an AsyncBufReader to read the file contents.

#[async_trait]
impl<State: Clone + Send + Sync + 'static> Endpoint<State> for ServeFile {
    async fn call(&self, _: Request<State>) -> Result {
        match Body::from_file(&self.path).await {
            Ok(body) => Ok(Response::builder(StatusCode::Ok).body(body).build()),
            Err(e) if e.kind() == io::ErrorKind::NotFound => {
                log::warn!("File not found: {:?}", &self.path);
                Ok(Response::new(StatusCode::NotFound))
            }
            Err(e) => Err(e.into()),
        }
    }
}

Listener

The last line of code in the sample makes a call to app.listen("127.0.0.1:1234").await. This particular function makes use of an Into pattern to convert varying types to convert into a Listener.

impl<State> ToListener<State> for String
where
    State: Clone + Send + Sync + 'static,
{
    type Listener = ParsedListener<State>;
    fn to_listener(self) -> io::Result<Self::Listener> {
        ToListener::<State>::to_listener(self.as_str())
    }
}

impl<State> ToListener<State> for (String, u16)
where
    State: Clone + Send + Sync + 'static,
{
    type Listener = TcpListener<State>;
    fn to_listener(self) -> io::Result<Self::Listener> {
        ToListener::<State>::to_listener((self.0.as_str(), self.1))
    }
}

impl<State> ToListener<State> for async_std::net::TcpListener
where
    State: Clone + Send + Sync + 'static,
{
    type Listener = TcpListener<State>;
    fn to_listener(self) -> io::Result<Self::Listener> {
        Ok(TcpListener::from_listener(self))
    }
}

Bind/Accept Loop

As soon as the listener is parsed and converted into either TcpListener or UnixListener, bind and accept will be called. Where bind will await the underlying async_std::net::TcpListener bind, whereas accept begins an incoming accept loop.

let mut incoming = listener.incoming();

while let Some(stream) = incoming.next().await {
    match stream {
        Err(ref e) if is_transient_error(e) => continue,
        Err(error) => {
            let delay = std::time::Duration::from_millis(500);
            crate::log::error!("Error: {}. Pausing for {:?}.", error, delay);
            task::sleep(delay).await;
            continue;
        }

        Ok(stream) => {
            handle_tcp(server.clone(), stream);
        }
    };
}

task::spawn

The last part of the accept loop needs to spawn a new task for each client, read from the socket, parse into a request and execute the response middleware. This task::spawn is making use of async_std to push a new future task onto the runtime to be polled.

fn handle_tcp<State: Clone + Send + Sync + 'static>(app: Server<State>, stream: TcpStream) {
    task::spawn(async move {
        let local_addr = stream.local_addr().ok();
        let peer_addr = stream.peer_addr().ok();

        let fut = async_h1::accept(stream, |mut req| async {
            req.set_local_addr(local_addr);
            req.set_peer_addr(peer_addr);
            app.respond(req).await
        });

        if let Err(error) = fut.await {
            log::error!("async-h1 error", { error: error.to_string() });
        }
    });
}

async_h1 and http-types

To provide support for HTTP/1.x tide makes use of the async-h1 crate and encapsulates with it the http-types type system. This elegant separation of minimal single-purpose crates keeps tide straightforward to implement our own system by putting these concepts together. For async_h1, the main pattern here is implementing HTTP as a codec with an async encoder/decoder pattern. We can see this on the Request side of the decoder.

use async_std::io::{BufReader, Read, Write};

pub async fn decode<IO>(mut io: IO) -> http_types::Result<Option<(Request, BodyReader<IO>)>>
where
    IO: Read + Write + Clone + Send + Sync + Unpin + 'static,
{
    let mut reader = BufReader::new(io.clone());
    let mut buf = Vec::new();
    let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS];
    let mut httparse_req = httparse::Request::new(&mut headers);

    // Keep reading bytes from the stream until we hit the end of the stream.
    loop {
        let bytes_read = reader.read_until(LF, &mut buf).await?;

The main portions the decode function here is an async function that uses the async_std BufReader to read from the underlying client. The use of httpparse here is to simply get the underlying bytes into a more readable state (&str) before it is ultimately decoded into an http-types Request object.

On the other end of this we have the Response http-types where async-h1 can encode via the async encoder. This is especially useful to allow the for things like Chunked encoding.

impl Read for Encoder {
    fn poll_read(
        mut self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &mut [u8],
    ) -> Poll<io::Result<usize>> {
        loop {
            self.state = match self.state {
                EncoderState::Start => EncoderState::Head(self.compute_head()?),

                EncoderState::Head(ref mut cursor) => {
                    read_to_end!(Pin::new(cursor).poll_read(cx, buf));

                    if self.method == Method::Head {
                        EncoderState::End
                    } else {
                        EncoderState::Body(BodyEncoder::new(self.response.take_body()))
                    }
                }

                EncoderState::Body(ref mut encoder) => {
                    read_to_end!(Pin::new(encoder).poll_read(cx, buf));
                    EncoderState::End
                }

                EncoderState::End => return Poll::Ready(Ok(0)),
            }
        }
    }
}

You can see in the above implementation of the encoder on each time the future is polled (AyncRead) the encoder uses a state machine to either compute and read headers, read the body contents through the BodyEncoder and finally return Ready when the state is completed. It's an elegant use of a minimal state machine and I highly recommend browsing any of these dependencies to get a feel for how you might implement something similar.

Implementing a Blog

Now that we've got the fundamental dependencies covered, let's go ahead and actually make use of them with tide. I highly recommend browsing the above dependencies in detail as it gives a look at how to implement your own runtime, separate concerns, building a protocol layer, and making use of async I/O with single-purpose packages. These are great codebases to learn from and they do well to at aiming towards good ergonomics and idiomatics. I imagine more libraries will get similarly clean as new features are introduced in the rust language with support for things like async traits and even portable/interopable runtimes.

Setup

As before, we will use the dependencies listed at the beginning of this post. Here it is once more:

[package]
name = "notes"
version = "0.1.0"
edition = "2021"

[dependencies]
async-std = { version = "1.12.0", features = ["attributes"] }
handlebars = "4.3.1"
pulldown-cmark = "0.9.1"
serde = { version = "1.0.137", features = ["derive"] }
serde_json = "1.0.81"
tide = "0.16.0"

Our main function will setup a few modules we will expand on and import to run the tide app.

mod registry;
mod routes;
mod errors;

use tide::utils::After;

#[async_std::main]
async fn main() -> std::io::Result<()> {
    let mut app = tide::new();
    // serve static files
    app.at("/static").serve_dir("client/dist")?;

    app.with(After(errors::error_handler));
    routes::configure(&mut app);

    // listen and await
    app.listen("127.0.0.1:1234").await?;
    Ok(())
}

The use of tide::utils::After is a middleware that operates on outgoing responses. This is useful for us as we want to be able to have a catch-all for errors and write the appropriate response for them.

Template Registry

In order to support simple templates that output markdown html we will use the handlebars crate to get the job done. Within the registry.rs file as specified in the mod registry declaration add the following:

use handlebars::Handlebars;

#[derive(Clone)]
pub struct State {
    registry: Handlebars<'static>,
}

impl State {
    pub fn default() -> Self {
        let mut state = State {
            registry: Handlebars::new(),
        };
        state.registry.set_dev_mode(true);
        state.template("index.html", "client/dist/index.html");
        state.template("posts.html", "client/dist/posts.html");
        state
    }

    pub fn template(&mut self, name: &str, path: &str) {
        self.registry.register_template_file(name, path).unwrap();
    }
}

handlebars::Handlebars::set_dev_mode is a useful mode to allow us to ensure that changes made to the template will be loaded on every request rather than cached.

The handlebars::Handlebars::register_template_file here is simply registering a key to a file source based on the passed in path and name. This is later used below with a call to render where the compiled handlebars::template::Template is evaluated with the provided serializable context.

In order to render responses to http-types::Response that can then be encoded by async-h1 let's create a few utility methods.

use serde::Serialize;
use tide::{Body, Response};

impl State {
    pub fn default() -> Self { ... }
    pub fn template(&mut self, name: &str, path: &str) { ... }

    pub fn render<T: Serialize>(&self, name: &str, data: &T) -> tide::Result<Response> {
        let mut response = Response::new(200);
        self.render_body(&mut response, name, data);
        Ok(response)
    }

    pub fn render_body<T: Serialize>(&self, response: &mut Response, name: &str, data: &T) {
        let body = self.registry.render(name, data).unwrap();
        let mut body = Body::from_string(body);
        body.set_mime("text/html");
        response.set_body(body);
    }
}

Following the use of the handlebars::Handlebars::render, we also construct an http-types::Body from the rendered content and set the mime type header before returning the http-types:Response. Finally, we are going to make use of a thread static local state to encapsulate the template registry (rather than use the tide::State as part of the Request. The main reason for this is that I wanted to be able to use this static template registry in the error handling middleware as well without having any effect on the request pipeline. There are other ways we could do this but for now I wanted to keep template registry as a pre-built static registry. Simply add the following to the registry.rs file below:

thread_local! {
    pub static REGISTRY: State = State::default();
}

Routes

In order to handle a few handlers for the blog we simply need to register the route we are interested in such as posts/:year/:month/:day/:title to capture each of the variables in the request parameters. We will make quick use of pulldown-cmark to transform a loaded async_std::fs::File into HTML output through the pulldown_cmark::Parser

use async_std::{fs::File, io::ReadExt};
use serde_json::json;

use crate::registry::REGISTRY;

pub fn configure(app: &mut tide::Server<()>) {
    app.at("/posts/:year/:month/:day/:id").get(get_post);
}

async fn get_post(req: tide::Request<()>) -> tide::Result<tide::Response> {
    // open up file based on request (fallback to not found)
    let url = format!(
        "posts/{}/{}/{}/{}.md",
        req.param("year")?,
        req.param("month")?,
        req.param("day")?,
        req.param("id")?
    );
    // open markdown file and read to string
    let mut md_file = File::open(url).await?;
    let mut buf = String::new();
    md_file.read_to_string(&mut buf).await?;

    // convert markdown file to html
    let parser = pulldown_cmark::Parser::new(&buf);
    let mut html_content = String::new();
    pulldown_cmark::html::push_html(&mut html_content, parser);

    // render template with content
    REGISTRY.with(|c| c.render("index.html", &json!({ "content": html_content })))
}

Finally, a call to the REGISTRY to render the provided template and pass along some json! data thanks to serde-json.

Error Handling

As a fallback for when we encounter errors (such as when a file is not found or we receive an invalid request) we declare the use of After(errors::error_handler). Go ahead and add the following to errors.rs.

use std::io::ErrorKind;
use serde_json::json;

use crate::registry::REGISTRY;

async fn error_handler(mut res: tide::Response) -> tide::Result<Response> {
    if let Some(err) = res.downcast_error::<async_std::io::Error>() {
        if let ErrorKind::NotFound = err.kind() {
            res.set_status(StatusCode::NotFound);
        }
    }
    if res.status() == StatusCode::NotFound {
        REGISTRY.with(|c| {
            c.render_body(&mut res, "index.html", &json!({ "content": "Not found" }));
        });
    }
    Ok(res)
}

Here we are making use of std::io::ErrorKind to determine if the error happens to be a downcast from an async-std::io::Error and happens to be a NotFound error kind. We use this to set the appropriate set_status on the response. Finally, assuming the status code is NotFound we will simply render out the standard index.html template with some Not Found content.

Tailwind + Parcel + Highlight.js

Now that the backend is completed in Rust, we can move along to the HTML templates. I've chosen to use parcel as the build tool, tailwindcss as the CSS framework, and highlight.js for code syntax highlighting. We've already implemented the markdown to HTML part of the application, we simply need a way to render it in HTML/CSS. In a new directory client run the following.

mkdir client
touch client/package.json

Dependencies

Edit package.json and add the following, followed by running npm install to ensure the dependencies are properly setup.

{
  "name": "notes",
  "source": "src/index.html",
  "scripts": {
    "start": "parcel",
    "build": "parcel build"
  },
  "targets": {
      "default": {
          "publicUrl": "/static"
      }
  },
  "devDependencies": {
    "@tailwindcss/typography": "^0.5.2",
    "parcel": "latest",
    "postcss": "^8.4.14",
    "tailwindcss": "^3.1.4"
  }
}

Index Template / Tailwind CSS

Now that we have the dependencies in order and a few scripts we can setup the client/src/index.html and client/src/index.css appropriately.

<!DOCTYPE HTML>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Hello World</title>
    <link href="./index.css" rel="stylesheet">
    <link rel="stylesheet" href="https://unpkg.com/@highlightjs/[email protected]/styles/default.min.css">
</head>
<body class="container mx-auto bg-slate-50 py-4 antialiased">
    <div class="shadow-md border-t-4 bg-white rounded-md">
        <nav class="p-8 flex text-sm text-sky-900 lowercase tracking-wide">
            <h1 class="flex-initial font-medium uppercase"><a href="/">✎ Notes</a></h1>
            <div class="flex-1"></div>
            <div class="text-xs font-semibold">
                <a href="/todo">todo!</a>
            </div>
        </nav>
        <article class="p-8 prose lg:prose-l max-w-full">

        </article>
    </div>
    <footer class="flex py-5 px-3 text-xs font-bold text-gray-300 lowercase tracking-wide">
        <span>@nyxtom | <span class="italic">#tailwind #rustlang</span></span>
        <div class="flex-1"></div>
        <a href="https://twitter.com/nyxtom" class="text-gray-400 hover:text-gray-800 dark:hover:text-white">
            <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" /></svg>
        </a>
        <a href="https://youtube.com/c/nyxtom" class="pl-2 text-gray-400 hover:text-gray-800 dark:hover:text-white">
            <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
        </a>
    </footer>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js"></script>
    <script type="module">
        hljs.highlightAll();
    </script>
</body>
</html>
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
    .prose pre {
        padding: 0px;
        background: none;
        @apply border-t-2 border-t-gray-50 rounded-md block shadow-md;
    }
}

These files make use of a number of tailwind utilities but don't be particularly intimidated by them. A lot of that is just padding, the use of tailwind/typography and various flexbox and color values. I've also added a few social svg icons in the footer and styles for the font to make it a bit easier on the eye. The bulk of the work is being done by highlight.js to perform the actual syntax highlighting by the code.

Also make note of the content in the template. This is a raw html declaration that simply passes along the variable content as we declared in the rust backend code. Handlebars will make sure to parse this and render that content appropriately.

Tailwind Configuration

In order for tailwind to take effect, you'll also need a tailwind.config.js configuration file so that parcel can actually build the content. You'll also need a simple .postcssrc for postcss to work in combination with tailwind.

module.exports = {
    content: [
        "./src/**/*.{html,css,js,jsx,ts,tsx}"
    ],
    theme: {
        container: {
            center: true
        },
        extend: {}
    },
    plugins: [
        require("@tailwindcss/typography")
    ],
}
{
    "plugins": {
        "tailwindcss": {}
    }
}

Success!

All that's left is building with npm run build or parcel build in the client directory, followed by running the rust backend with cargo run.

# building the client
~/rust-tutorials/notes/client
[email protected]$ parcel build src/*
✨ Built in 1.49s

dist/index.css     13.58 KB    218ms
dist/index.html     2.21 KB    696ms

~/rust-tutorials/notes/client
[email protected]$ cd ../

# running the rust backend
~/rust-tutorials/notes
[email protected]$ cargo run
   Compiling notes v0.1.0 (/Users/nyxtom/rust-tutorials/notes)
    Finished dev [unoptimized + debuginfo] target(s) in 4.87s
     Running `/Users/nyxtom/.cache/cargo/debug/notes`

If you go ahead and create a sample markdown file in posts/2022/06/25/tide.md for instance you should be able to open up the the browser to localhost:1234/posts/2022/06/25/tide and see your post!

post

Conclusion

Thanks for joining me on this tutorial / journey. This tutorial was mainly designed to give an introduction to framework crates that are out there in Rust and what you can put together yourself if you look for the right idiomatic packages. There are a lot of resources out there for learning but sometimes it helps to just look at the source code of these packages and see how the internals are put together. I've found that I've learned a significant amount just by doing that and if something is a bit too complex I try and find packages that simplify the mental model. smol-rs and tide are great examples of building single-purpose dependencies as opposed to monolithic structures. These kind of packages are great for learning Rust!