MP

Adding Tags to my Static Site: Part 2

For Part 1, go here

Getting a mapping of tags to Posts

So, after part one, we have a nice Metadata struct for each post, which contains a Vec<String> of tags, which is constructed from the first few lines of each of our markdown files.

For reference, here is our metadata struct:

#[derive(Debug)]
struct Metadata {
    title: String,
    slug: String,
    created: NaiveDate,
    updated: NaiveDate,
    tags: Vec<String>,
    summary: String,
}

and the Post struct in which we might find one:

#[derive(Debug)]
struct Post {
    metadata: Metadata,
    content: String,
}

We get our Post instances out of the markdown files using this relatively ugly but functional bit of code:

let mut posts = fs::read_dir("posts")
    .expect("Couldn't read posts dir")
    .map(|r| r.expect(&"couldn't read dir entry"))
    .filter(|de| (&de.file_type().unwrap()).is_file())
    .filter(|de| &de.file_name().len() > &0)
    .filter(|de| &de.file_name().to_string_lossy()[0..1] != ".")
    .map(|md| {
        let md_txt =
            fs::read_to_string(md.path()).expect(&format!("couldn't read md: {:?}", md));
        let metadata = Metadata::new(&md_txt);
        let md_content = &md_txt
            .lines()
            .skip(Metadata::NUM_HEADER_LNS.into())
            .collect::<Vec<&str>>()
            .join("\n");
        let content = md_to_html(&md_content, md_opts);
        Post { metadata, content }
    })
    .collect::<Vec<Post>>();

So, at this point, we've got every post we've ever written. For generating the lists of posts on the index and "Posts" pages, we sort the vector by create date:

posts.sort_by(|a, b| a.metadata.created.cmp(&b.metadata.created).reverse());

For tags, though, we'll want a map of tags to posts. We should use post references, so that we don't need to clone the posts in memory.

We can start with a simple, in-place construction of the HashMap:

// generate map of tags to posts
let mut tags_to_posts = HashMap::new();
posts.iter().for_each(|post| {
    post.metadata.tags.iter().for_each(|tag| {
        tags_to_posts
            .entry(tag)
            .and_modify(|post_vec: &mut Vec<&Post>| post_vec.push(post))
            .or_insert(vec![post]);
    });
});

A few of things to note in the above implementation:

Can we extract this into a function? Sure, but we're going to need to specify lifetimes:

fn tag_map<'a, T>(posts: T) -> HashMap<&'a String, Vec<&'a Post>>
    where T: IntoIterator<Item = &'a Post>
{
    let mut tags_to_posts = HashMap::new();
    posts.into_iter().for_each(|post| {
        post.metadata.tags.iter().for_each(|tag| {
            tags_to_posts
                .entry(tag)
                .and_modify(|post_vec: &mut Vec<&Post>| post_vec.push(post))
                .or_insert(vec![post]);
        });
    });
    tags_to_posts
}

Here, we've said, "give me any posts that can be turned into an Iterator over references to Post instances," and I'll return a HashMap of references to String instances pointing to vectors of references to Post instances." Because we have multiple references (and therefore multiple possible lifetimes), we must specify to the compiler that the String and Post references in the HashMap should live as long as the Post references on our incoming iterator, which is to say that it is not safe to drop the Posts and continue using the HashMap!

We can then update our call site to just look like this:

let tags_to_posts = tag_map(&posts);

One of the nice things about the IntoIterator implementations on Vec is that if you have Vec<T>, calling .into_iter() gives you an Iterator over T. Calling .into_iter() on a reference to Vec<T> gives you an Iterator over references to T, which is to say &T. Finally, calling .into_iter() on a mutable reference to Vec<T> gives you an Iterator over &mut T, mutable references to T. All of this means that we can satisfy our trait bound of IntoIterator<Item = &Post> by passing in a reference to our Vec<Post>, without any further conversion required.

Adding HTML for the tag overview page

Now that we have a tag -> post mapping, we can write some HTML. Our tags overview will be a combination of several snippets. The page will contain the usual header and footer. The content will be a listing of tags, with each associated post linked beneath the tag. We can reuse the simple posts-post.html snippet that we used for listing posts on the posts overview and index pages. It just contains a single <li> element, with a bolded link whose text is the post title, followed by the post summary. We can compose this into a tag snippet that looks something like:

<h4>{{ tag }}</h4>
{{ posts }}

if we were using the full power of the templating engine, we could do some looping logic in here, but I've been having fun so far with building everything up in application code (we'll see how long that lasts).

For now, there's no reason the tag overview can't just be an instance of our generic.html template, which looks like:

<!DOCTYPE html>
<html>

{{ head }}

<body>
    <header>
        {{ header }}
    </header>
    <main>
        {{ content }}
    </main>
    <footer>
        {{ footer-license }}
    </footer>
</body>

</html>

I think now we have everything we need to build an initial implementation of the tags page, but I'm starting to feel like we need to do some refactoring of the post generation process. Let's make that part 3, and then we'll get to actually doing tags.

Created: 2019-07-02

Tags: blog, programming, rust