Using blog-tools

A few months ago, I wrote an article on making a blog in Rust. Since then, this idea has had quite a bit of development. I needed to make blogs at work too. This was a good opportunity to improve the code. One thing lead to another and eventually I published the blog library on crates.io. You can install this like you would any other Rust crate with

cargo add blog-tools

In this article I will walk you through setting up a basic blog.

Start by making a new Rust project with

cargo init basic-blog

First things first, for this tutorial we will be using Rocket, which currently requires the nightly toolchain, so add a file next to the Cargo.toml file called rust-toolchain.toml and add the following to it

[toolchain]
channel = "nightly"

Once that's been done, let's add our dependencies. For this tutorial you need to add these deps to your Cargo.toml

[dependencies]
tokio = { version = "1.29.1", features = ["full"] }
tera = "1.15.0"
rocket = "0.5.0-rc.3"
rocket_codegen = "0.4.4"
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["tera"] }
lazy_static = "1.4.0"
blog-tools = "0.0.2"

We'll be using Rocket for the web server, Tera for the templating and my blog-tools for the blog logic.

Next, next to the Cargo.toml file you need to make an assets and templates folder. Let's start a basic web server pointing at those folders

use rocket::fs::{relative, FileServer};
use rocket_dyn_templates::Template;

#[macro_use]
extern crate rocket;

#[rocket::main]
async fn main() {
    let port = 8080_u16;

    let figment = rocket::Config::figment()
        .merge(("port", port))
        .merge(("address", "0.0.0.0"));

    if let Err(e) = rocket::custom(figment)
        .mount("/", FileServer::from(relative!("assets/")))
        .register("/", catchers![])
        .attach(Template::fairing())
        // .attach(config)
        .mount("/", routes![ping])
        // .manage(bucket_info)
        .launch()
        .await
    {
        println!("Did not run. Error: {:?}", e)
    }
}

#[derive(Responder)]
#[response(status = 200, content_type = "text")]
struct RawOkText(&'static str);

#[get("/ping")]
fn ping() -> RawOkText {
    return RawOkText("pong");
}

That's all you need in terms of the server. We'll add a few more routes in a moment. Now, if you run this project, and go to localhost:8080/ping you should see "pong". This ping-pong is good for using to monitor your blog if you host it, by the way.

We're going to have three endpoints for the blog itself. We want a blog index which lists all the blogs we have. We also want a blog page for an individual blog. Finally, since blog-tools supports tags, we can have a page that shows off which blogs belong to which tags.

Let's add some super simple CSS to make this whole thing look a little nice. Inside the assets folder add a folder called css. Inside that folder add style.css. Add the following CSS

.blog-container {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-around;
    gap: 2rem;
    max-width: 1200px;
}

.blog-card {
    display: flex;
    flex-direction: column;
    width: clamp(20rem, calc(20rem + 2vw), 22rem);
    box-shadow: 0 .1rem 1rem rgba(0, 0, 0, 0.1);
    border-radius: 1em;
    background: #f4f4f4;
    overflow: hidden;
    flex-grow: 1;
    padding: .25em .75em;
    border-radius: 1em;
    border: solid 1px black;
}

.blog-card:hover {
    background: pink;
}

.blog-tag-container {
    display: flex;
    flex-wrap: wrap;
}

.blog-tag {
    padding: .25em .75em;
    border-radius: 1em;
    font-size: 1rem;
    background: linear-gradient(135deg, #cccccc, #ffffff);
    color: blue;
}

CSS is obviously very customisable, so feel free to change this as much as you like.

Now, inside templates make blog_index.html.tera, add

<!DOCTYPE html>
<html lang="en">

<head>
    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
    <title>Blog</title>
    <link rel="stylesheet" type="text/css" href="/css/style.css">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
    <header>
        <h1>Blog</h1>
    </header>
    <main>


        {% if blog.entries %}

        <div class="blog-container">
            {% for entry in blog.entries %}

            <div class="blog-card">
                <a href="/blog/{{ entry.slug }}" class="no-underline black-links">
                    <div class="card-body">
                        <h5>
                            <span>{{ entry.date }}</span>
                        </h5>
                        <div>
                            {{entry.preview | safe}}
                        </div>
                    </div>
                </a>

                <div class="blog-tag-container">
                    {% for tag in entry.tags %}
                    <a href="/blog/tag/{{ tag }}" class="blog-tag">#{{ tag }}</a>
                    {% endfor %}
                </div>

            </div>

            {% endfor %}
        </div>
        {% else %}
        <p>No blog found</p>

        {% endif %}
    </main>
</body>

This is looping through each of the blogs that we will provide, and adding them into a little CSS card, with a link.

Now make blog.html.tera and add

<!DOCTYPE html>
<html lang="en">

<head>
    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
    <title>{{ blog.title }}</title>
    <link rel="stylesheet" type="text/css" href="/css/style.css">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    {% if blog.author_name %}
    <meta name="author" content="{{ blog.author_name }}">
    {% endif %}

    {% if blog.author_webpage %}
    <link rel="author" href="{{ blog.author_webpage }}">
    {% endif %}

    {% if blog.keywords %}
    <meta name="keywords" content="
    {% for word in blog.keywords %}
    {{word}},
    {% endfor %}
    ">
    {% endif %}

    {% if blog.desc %}
    <meta name="description" content="{{ blog.desc }}">
    {% endif %}
</head>

<body>
    <main>

        {{ blog.html | safe }}

    </main>

</body>

</html>

This is where the text for our blog will go. As you can see in the head, the blog-tools crate supports a wide variety of SEO tools.

Finally, make a tags.html.tera and add the following

<!DOCTYPE html>
<html lang="en">

<head>
    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
    <title>Tags</title>
    <link rel="stylesheet" type="text/css" href="/css/style.css">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
    <header>
        <h1>#{{ tag }}</h1>
    </header>
    <main>


        {% if blogs %}

        <div class="blog-container">
            {% for entry in blogs %}

            <div class="blog-card">
                <a href="/blog/{{ entry.slug }}" class="no-underline black-links">
                    <div class="card-body">
                        <h5>
                            <span>{{ entry.date }}</span>
                        </h5>
                        <div>
                            {{entry.preview | safe}}
                        </div>
                    </div>
                </a>

                <div class="blog-tag-container">
                    {% for tag in entry.tags %}
                    <a href="/blog/tag/{{ tag }}" class="blog-tag">#{{ tag }}</a>
                    {% endfor %}
                </div>

            </div>

            {% endfor %}
        </div>

        {% else %}
        <p>No blog found</p>

        {% endif %}
    </main>
</body>

The tags page will list all the blogs that have that specific tag.

Now make a new folder next to your Cargo.toml called blog. Let's make some blog articles! You can organise this folder however you like, but the one rule is every markdown file must have a JSON file next to it which share the same name (except for the extension).

A structure that I found helpful was

So, I have a folder for the year, and then inside that a folder with the publication date for each blog. Again, it doesn't really matter how you choose to structure this. For the purposes of this tutorial you can put whatever you like in the markdown files. The JSON files have to have a very specific format

{
"title": String,
"date": ISO 8601 Date i.e. YYYY-MM-DD,
"desc": Optional<String>,
"slug": String,
"tags": [String],
"keywords": Optional<[String]>,
"canonical_link": Optional<String>,
"author_name": Optional<String>,
"author_webpage": Optional<String>
}

For instance, here's the JSON file for one of the blogs

{
    "title": "My First Blog",
    "date": "2023-08-18",
    "desc": "A blog!",
    "slug": "first-blog",
    "tags": [
        "philosophy",
        "blog",
        "science"
    ],
    "keywords": [
        "blog",
        "first",
        "time",
        "paper",
        "rock"
    ],
    "author_name": "Indigo Curnick",
    "author_website": "https://indigocurnick.xyz"
}

Once you have some blogs, back to main.rs. Add the following to actually make the blog

pub static BLOG_ROOT: &str = "blog";

lazy_static! {
    pub static ref STATIC_BLOG_ENTRIES: Blog = get_blog(PathBuf::from(BLOG_ROOT), None, None);
}

fn get_blog_context() -> &'static Blog {
    return &STATIC_BLOG_ENTRIES;
}

And that's it! The blog-tools crate is actually really easy to use. There's some configuration options which I won't go over here, but you can check the docs for them.

Finally, we need to add our three endpoints

#[get("/blog")]
fn blog_index() -> Option<Template> {
    let mut context = rocket_dyn_templates::tera::Context::new();
    context.insert("blog", get_blog_context());
    Some(Template::render("blog_index", context.into_json()))
}

#[get("/blog/<slug>")]
fn blog_article(slug: String) -> Option<Template> {
    let mut context = rocket_dyn_templates::tera::Context::new();
    let all_blogs = get_blog_context();
    let this_blog = match all_blogs.hash.get(&slug) {
        Some(x) => x,
        None => return None,
    };
    context.insert("blog", this_blog);
    Some(Template::render("blog", context.into_json()))
}

#[get("/blog/tag/<slug>")]
fn tag_page(slug: String) -> Option<Template> {
    let mut context = rocket_dyn_templates::tera::Context::new();
    let all_blogs = get_blog_context();

    let mut these_blogs: Vec<&BlogEntry> = vec![];

    for blog in &all_blogs.entries {
        if blog.tags.contains(&slug) {
            these_blogs.push(&blog);
        }
    }

    context.insert("blogs", &these_blogs);
    context.insert("tag", &slug);
    Some(Template::render("tags", context.into_json()))
}

Now we have a way to take an incoming request, and process the correct template using the blog-tools crate. Don't forget to add these routes to the server!

...
.mount("/", routes![ping, blog_index, blog_article, tag_page])
...

If you run again and go to localhost:8080/blog you should see a nice blog, all templated out with cards.

If you'd like to see the full code for this tutorial, it's inside the blog-tools repo. You can also run it from the repo using

cargo +nightly run --example blog  

That's really all you need to know in order to make a simplistic blog in Rust!