Welcome to Chapter 21! In the previous chapters, we meticulously built a robust, high-performance Static Site Generator (SSG) in Rust, covering everything from content parsing and templating to component hydration and incremental builds. Now, it’s time to put our SSG to the ultimate test by building a full-fledged, modern blog system. This will demonstrate how all the individual pieces of our SSG come together to create a complex, real-world application.

This chapter will guide you through structuring blog content, generating index pages with pagination, creating individual post pages, and implementing features like categories and tags. We’ll leverage our existing content processing pipeline, Tera templating, and hydration mechanism to create a dynamic yet static blog. The goal is to produce a production-ready blog that is fast, secure, and easy to maintain, showcasing the power and flexibility of our Rust-based SSG.

By the end of this chapter, you will have a fully functional blog integrated into our SSG, capable of serving a multitude of posts with organized navigation. This exercise will solidify your understanding of how to apply the SSG’s core functionalities to diverse content types and demonstrate its capabilities for complex site generation.

Planning & Design

Building a blog system requires a clear structure for content, templates, and how these are processed by our SSG. We’ll design a content hierarchy that’s intuitive for authors and efficient for our generator.

Blog System Architecture Overview

The following Mermaid diagram illustrates the flow of data and processing for our blog system within the SSG.

graph TD subgraph Content_Input["Content Input"] A[Markdown Blog Posts] B[Frontmatter YAML TOML] end subgraph SSG_Pipeline["SSG Build Pipeline"] A --> C[Content Loader] B --> C C --> D{Parse Frontmatter} D --> E{Parse Markdown to AST} E --> F{Transform AST Components Hydration} F --> G[Content Context Builder] end subgraph Data_Aggregation_and_Routing["Data Aggregation and Routing"] G --> H[Blog Post Collection] H --> I[Sort Posts by Date] I --> J{Generate Blog Routes} J --> K[Individual Post Pages] I --> L{Generate Index Archive Routes} L --> M[Blog Listing Pages] L --> N[Category Tag Archive Pages] end subgraph Templating_and_Rendering["Templating and Rendering"] K --> P[Tera Template single html] M --> Q[Tera Template index html] N --> R[Tera Template category tag html] P & Q & R --> S[Render HTML] S --> T[Output Static Files] end subgraph Output_and_Deployment["Output and Deployment"] T --> U[Public Directory] U --> V[CDN Web Server] end

File Structure for Blog Content

We’ll adopt a simple, yet scalable content structure for our blog posts. Each post will reside in its own Markdown file within a content/blog directory.

.
├── config.toml
├── content
│   ├── blog
│   │   ├── my-first-post.md
│   │   ├── another-great-article.md
│   │   └── 2026
│   │       ├── january
│   │       │   └── new-year-resolutions.md
│   │       └── february
│   │           └── valentines-day-special.md
│   └── _index.md # For the main blog listing page
├── templates
│   ├── base.html
│   ├── blog
│   │   ├── index.html     # Blog listing page
│   │   ├── single.html    # Individual blog post
│   │   ├── category.html  # Category archive
│   │   └── tag.html       # Tag archive
│   └── components         # Reusable components
│       ├── header.html
│       └── footer.html
└── public                 # Generated output

Step-by-Step Implementation

We’ll extend our SSG to recognize blog content, aggregate it, and render it using specific templates.

1. Setup/Configuration: Blog Content Structure

First, let’s create some dummy blog content and ensure our SSG can process it.

Action: Create a content/blog directory and add a few Markdown files.

mkdir -p content/blog/2026/january

File: content/blog/my-first-post.md

+++
title = "My First Blog Post with Rust SSG"
date = 2026-01-15T10:00:00Z
description = "A foundational post demonstrating our Rust SSG's capabilities."
tags = ["Rust", "SSG", "Tutorial", "First Post"]
categories = ["Development", "Web"]
draft = false
+++

# Welcome to the Rust SSG Blog!

This is the very first post generated by our custom Static Site Generator.
We're excited to show you what it can do.

## Features Demonstrated

*   **Frontmatter Parsing**: All metadata above is read and processed.
*   **Markdown Conversion**: This entire document is converted to HTML.
*   **Templating**: This content will be injected into a `single.html` template.
*   **Routing**: This post will have its own clean URL.

File: content/blog/another-great-article.md

+++
title = "Another Great Article on Rust Web Development"
date = 2026-02-01T14:30:00Z
description = "Exploring advanced topics in Rust web development with our SSG."
tags = ["Rust", "WebDev", "Advanced"]
categories = ["Development"]
draft = false
+++

# Diving Deeper into Rust Web Development

In this article, we'll explore some more advanced patterns and techniques for building high-performance web applications using Rust. Our SSG is perfect for documenting such complex topics.

<SimpleCounter initial_value=10 />

This `SimpleCounter` component demonstrates partial hydration in action. The initial value is rendered statically, but the component becomes interactive on the client side.

File: content/blog/2026/january/new-year-resolutions.md

+++
title = "New Year, New Resolutions (and a New SSG!)"
date = 2026-01-01T09:00:00Z
description = "Reflecting on goals for the new year, powered by our SSG."
tags = ["Life", "Goals", "New Year"]
categories = ["Personal"]
draft = false
+++

# Setting Goals with Our Rust SSG

The start of a new year is always a great time for reflection and setting new goals. This post is a personal reflection, showcasing how easily our SSG handles nested content structures.

2. Core Implementation: Blog Post Collection and Routing

Our SSG’s ContentManager already loads all content. We need to enhance the BuildContext to specifically identify and collect blog posts, making them accessible for the blog listing and individual pages.

Action: Modify src/build/context.rs to include a collection of blog posts.

We’ll add a blog_posts field to BuildContext and populate it during the content processing phase.

// src/build/context.rs

use std::collections::HashMap;
use crate::content::{Content, ContentType};
use crate::config::SiteConfig;
use std::sync::Arc;
use tokio::sync::Mutex;
use std::path::PathBuf;

/// Represents the shared context available during the build process.
/// This includes parsed content, configuration, and aggregated data.
#[derive(Debug, Clone)]
pub struct BuildContext {
    pub config: Arc<SiteConfig>,
    pub content_map: Arc<Mutex<HashMap<String, Content>>>, // Path -> Content
    pub blog_posts: Arc<Mutex<Vec<Content>>>, // Specific collection for blog posts
    pub output_dir: PathBuf,
    // Add other shared data structures as needed, e.g., for tags, categories
}

impl BuildContext {
    pub fn new(config: SiteConfig, output_dir: PathBuf) -> Self {
        Self {
            config: Arc::new(config),
            content_map: Arc::new(Mutex::new(HashMap::new())),
            blog_posts: Arc::new(Mutex::new(Vec::new())), // Initialize blog_posts
            output_dir,
        }
    }

    /// Adds processed content to the context.
    pub async fn add_content(&self, content: Content) {
        let mut content_map = self.content_map.lock().await;
        let route = content.route.clone();

        if content_map.contains_key(&route) {
            log::warn!("Duplicate route detected: {}. Overwriting.", &route);
        }
        content_map.insert(route, content.clone());

        // Check if this content is a blog post and add it to the specific collection
        if content.content_type == ContentType::BlogPost {
            let mut blog_posts = self.blog_posts.lock().await;
            blog_posts.push(content);
            // We'll sort these later, during final aggregation
        }
    }

    // ... other methods (e.g., get_content_by_route, etc.)
}

// src/content.rs (ensure ContentType::BlogPost exists)
// Add to the existing ContentType enum
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContentType {
    Page,
    BlogPost,
    // ... other types
}

// ... in Content struct
pub struct Content {
    // ... existing fields
    pub content_type: ContentType,
}

impl Content {
    // ... in the `from_path` or `parse` method where you determine content type
    // Example logic in a hypothetical `determine_content_type` function:
    pub fn determine_content_type(file_path: &Path) -> ContentType {
        if file_path.starts_with("content/blog") {
            ContentType::BlogPost
        } else {
            ContentType::Page // Default for other content
        }
    }
}

Explanation:

  1. We added blog_posts: Arc<Mutex<Vec<Content>>> to BuildContext. This will hold all parsed blog posts.
  2. In add_content, after inserting into content_map, we check content.content_type. If it’s BlogPost, we add it to the blog_posts vector.
  3. We’ve added ContentType::BlogPost to our ContentType enum and a placeholder determine_content_type function. You’ll need to integrate this logic into your Content::from_path or Content::parse method where the ContentType is initially set based on the file path.

Action: Modify src/pipeline/mod.rs (or wherever you process files) to set content_type.

// src/pipeline/mod.rs (or relevant content processing function)

// ... existing imports
use crate::content::{Content, ContentType}; // Make sure ContentType is imported

// Inside your main content processing loop, e.g., `process_file`
pub async fn process_file(
    file_path: PathBuf,
    config: Arc<SiteConfig>,
    tera: Arc<Tera>,
    build_context: BuildContext,
) -> Result<(), Box<dyn Error + Send + Sync>> {
    log::info!("Processing file: {:?}", file_path);

    let file_content = tokio::fs::read_to_string(&file_path).await?;
    let (frontmatter_str, markdown_body) =
        crate::parser::parse_frontmatter_and_content(&file_content)?;

    let frontmatter: Frontmatter = match file_path.extension().and_then(|ext| ext.to_str()) {
        Some("md") | Some("html") => crate::parser::parse_frontmatter(&frontmatter_str)?,
        _ => return Err(format!("Unsupported file type for frontmatter: {:?}", file_path).into()),
    };

    // Determine content type based on path
    let content_type = if file_path.starts_with(&config.content_dir.join("blog")) {
        ContentType::BlogPost
    } else {
        ContentType::Page
    };

    let (html_body, hydrated_components) =
        crate::renderer::render_markdown_with_components(&markdown_body, &tera)?;

    let route = crate::router::generate_route(&file_path, &config)?;

    let content = Content {
        frontmatter,
        markdown_body,
        html_body,
        hydrated_components,
        route,
        file_path: file_path.clone(),
        content_type, // Assign the determined content type
    };

    build_context.add_content(content).await;

    Ok(())
}

Explanation: We’ve added a simple check if file_path.starts_with(&config.content_dir.join("blog")) to determine if the content is a blog post. This assumes your config.content_dir is content/.

3. Core Implementation: Blog Listing Page (Index)

Now that our BuildContext collects blog posts, we can generate the main blog listing page. This involves:

  1. Creating a new template for the blog index.
  2. Modifying our build process to create a context for this template, including sorted blog posts.
  3. Generating the output HTML.

Action: Create templates/blog/index.html.

This template will iterate over all blog posts and display a summary for each.

<!-- templates/blog/index.html -->
{% extends "base.html" %}

{% block title %}{{ page.title | default(value="Blog") }} - My Awesome SSG Blog{% endblock %}

{% block content %}
<main class="container mx-auto px-4 py-8">
    <h1 class="text-4xl font-bold mb-8 text-center">{{ page.title | default(value="Latest Blog Posts") }}</h1>

    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
        {% for post in blog_posts %}
        <article class="bg-white rounded-lg shadow-lg overflow-hidden transition-transform transform hover:scale-105 duration-300 ease-in-out">
            {% if post.frontmatter.image %}
            <img src="{{ post.frontmatter.image }}" alt="{{ post.frontmatter.title }}" class="w-full h-48 object-cover">
            {% endif %}
            <div class="p-6">
                <h2 class="text-2xl font-semibold mb-2"><a href="{{ post.route }}" class="text-blue-700 hover:text-blue-900">{{ post.frontmatter.title }}</a></h2>
                <p class="text-gray-600 text-sm mb-4">
                    Published on <time datetime="{{ post.frontmatter.date | date(format="%Y-%m-%d") }}">{{ post.frontmatter.date | date(format="%B %e, %Y") }}</time>
                    {% if post.frontmatter.categories %}
                    in
                    {% for category in post.frontmatter.categories %}
                        <a href="/categories/{{ category | lower | replace(from=' ', to='-') }}" class="text-blue-500 hover:underline">{{ category }}</a>{% if loop.index < post.frontmatter.categories | length %}, {% endif %}
                    {% endfor %}
                    {% endif %}
                </p>
                <p class="text-gray-700 mb-4">{{ post.frontmatter.description | default(value=post.html_body | striptags | truncate(length=150)) }}</p>
                <a href="{{ post.route }}" class="inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors duration-300">Read More</a>
            </div>
        </article>
        {% endfor %}
    </div>

    {# Pagination will go here later #}

</main>
{% endblock %}

Action: Modify src/main.rs (or your build function) to handle the blog index generation.

We need to sort the blog posts by date and then render the blog/index.html template.

// src/main.rs (or lib.rs if your build logic is there)

use std::error::Error;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tera::Tera;

mod config;
mod content;
mod parser;
mod pipeline;
mod renderer;
mod router;
mod build; // Our build context and manager
mod hydration; // For client-side hydration scripts

use config::SiteConfig;
use build::{BuildContext, BuildManager};
use content::Content;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    env_logger::init(); // Initialize logger

    let config = SiteConfig::load("config.toml")?;
    let output_dir = PathBuf::from(&config.output_dir);

    // Ensure output directory is clean
    if output_dir.exists() {
        fs::remove_dir_all(&output_dir).await?;
    }
    fs::create_dir_all(&output_dir).await?;

    let mut tera = Tera::new("templates/**/*.html")?;
    tera.autoescape_on(vec![".html"]); // Enable autoescaping for HTML templates

    // Register custom Tera functions/filters if any
    // For example, a `slugify` filter or `markdown` function

    let build_context = BuildContext::new(config.clone(), output_dir.clone());
    let build_manager = BuildManager::new(build_context.clone(), Arc::new(tera.clone()));

    // Phase 1: Process all content files
    log::info!("Starting content processing phase...");
    build_manager.process_content_files().await?;
    log::info!("Content processing complete.");

    // Phase 2: Generate individual pages based on content_map
    log::info!("Generating individual content pages...");
    let content_map_guard = build_context.content_map.lock().await;
    for (route, content) in content_map_guard.iter() {
        let output_path = build_manager.generate_output_path(&content.route);
        build_manager.render_and_write_content(content, &output_path).await?;
    }
    drop(content_map_guard); // Release the lock before further async ops

    // Phase 3: Generate blog listing page
    log::info!("Generating blog listing page...");
    let mut blog_posts = build_context.blog_posts.lock().await;
    // Sort blog posts by date, newest first
    blog_posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));

    let blog_index_route = "/blog/".to_string(); // Define the route for the blog index
    let blog_index_output_path = build_manager.generate_output_path(&blog_index_route);

    let mut context = tera::Context::new();
    context.insert("page", &config.blog_index_meta); // Use a dummy page object for the blog index
    context.insert("blog_posts", &*blog_posts); // Pass sorted blog posts

    // Render the blog index template
    let rendered_html = tera.render("blog/index.html", &context)?;
    fs::create_dir_all(blog_index_output_path.parent().unwrap()).await?;
    fs::write(&blog_index_output_path, rendered_html.as_bytes()).await?;
    log::info!("Generated blog index at: {:?}", blog_index_output_path);

    // Phase 4: Copy static assets
    log::info!("Copying static assets...");
    build_manager.copy_static_assets().await?;
    log::info!("Static assets copied.");

    // Phase 5: Generate hydration scripts
    log::info!("Generating hydration scripts...");
    build_manager.generate_hydration_scripts().await?;
    log::info!("Hydration scripts generated.");

    log::info!("Build complete!");
    Ok(())
}

// You'll need to add a meta struct for blog index to your config.rs
// src/config.rs
#[derive(Debug, Clone, serde::Deserialize)]
pub struct SiteConfig {
    pub site_name: String,
    pub base_url: String,
    pub content_dir: PathBuf,
    pub static_dir: PathBuf,
    pub output_dir: PathBuf,
    pub templates_dir: PathBuf,
    pub blog_index_meta: BlogIndexMeta, // Add this
    // ... other fields
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct BlogIndexMeta {
    pub title: String,
    pub description: String,
    // Add any other specific metadata for the blog index page
}

// ... in your config.toml
// Add this section
[blog_index_meta]
title = "My Awesome Rust SSG Blog"
description = "The official blog for our Rust Static Site Generator."

Explanation:

  1. We added a new blog_index_meta field to SiteConfig to provide metadata for the blog listing page, which will be used in the blog/index.html template.
  2. After processing all content, we acquire a lock on build_context.blog_posts, sort them by date (newest first), and then release the lock.
  3. We create a tera::Context, insert a dummy page object (using config.blog_index_meta) and the sorted blog_posts.
  4. We render blog/index.html and write it to public/blog/index.html.

4. Core Implementation: Individual Blog Post Pages

Our current render_and_write_content function already handles individual content pages. Blog posts are just another type of content. The key is to ensure they use the correct template.

Action: Create templates/blog/single.html.

This template will display the full content of an individual blog post.

<!-- templates/blog/single.html -->
{% extends "base.html" %}

{% block title %}{{ page.frontmatter.title }} - My Awesome SSG Blog{% endblock %}

{% block content %}
<main class="container mx-auto px-4 py-8">
    <article class="prose lg:prose-xl mx-auto">
        <header class="text-center mb-12">
            <h1 class="text-5xl font-extrabold text-gray-900 mb-4">{{ page.frontmatter.title }}</h1>
            <p class="text-gray-600 text-lg">
                Published on <time datetime="{{ page.frontmatter.date | date(format="%Y-%m-%d") }}">{{ page.frontmatter.date | date(format="%B %e, %Y") }}</time>
                {% if page.frontmatter.author %} by {{ page.frontmatter.author }}{% endif %}
            </p>
            {% if page.frontmatter.categories %}
            <div class="mt-2 text-sm text-gray-500">
                Categories:
                {% for category in page.frontmatter.categories %}
                    <a href="/categories/{{ category | lower | replace(from=' ', to='-') }}" class="text-blue-500 hover:underline px-1">{{ category }}</a>{% if loop.index < page.frontmatter.categories | length %}, {% endif %}
                {% endfor %}
            </div>
            {% endif %}
            {% if page.frontmatter.tags %}
            <div class="mt-1 text-sm text-gray-500">
                Tags:
                {% for tag in page.frontmatter.tags %}
                    <a href="/tags/{{ tag | lower | replace(from=' ', to='-') }}" class="text-green-500 hover:underline px-1">#{{ tag }}</a>{% if loop.index < page.frontmatter.tags | length %}, {% endif %}
                {% endfor %}
            </div>
            {% endif %}
        </header>

        <section class="mb-12">
            {{ page.html_body | safe }}
        </section>

        <footer class="text-center text-gray-500 text-sm">
            <p>&copy; 2026 My Awesome SSG Blog. All rights reserved.</p>
        </footer>
    </article>
</main>
{% endblock %}

Action: Modify src/build/manager.rs (or your rendering logic) to select the correct template.

Our render_and_write_content needs to know which template to use. We can add a template field to our Content struct or infer it. Inferring is more flexible for an SSG.

// src/build/manager.rs

use std::error::Error;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tera::Tera;

use crate::config::SiteConfig;
use crate::content::{Content, ContentType};
use super::BuildContext; // Assuming build_context is in the same module or parent

pub struct BuildManager {
    build_context: BuildContext,
    tera: Arc<Tera>,
}

impl BuildManager {
    // ... existing new, process_content_files, copy_static_assets, generate_hydration_scripts

    /// Renders a single Content item and writes it to the specified output path.
    pub async fn render_and_write_content(
        &self,
        content: &Content,
        output_path: &Path,
    ) -> Result<(), Box<dyn Error + Send + Sync>> {
        let mut context = tera::Context::new();
        context.insert("page", content); // The Content struct is passed as 'page'

        // Determine which template to use based on content_type
        let template_name = match content.content_type {
            ContentType::BlogPost => "blog/single.html",
            ContentType::Page => "page/single.html", // Assuming you have a default page template
            // Add more types as needed
        };

        log::debug!(
            "Rendering route '{}' with template '{}'",
            content.route,
            template_name
        );
        let rendered_html = self.tera.render(template_name, &context)?;

        fs::create_dir_all(output_path.parent().unwrap_or_else(|| Path::new("."))).await?;
        fs::write(output_path, rendered_html.as_bytes()).await?;

        log::info!("Generated page: {}", content.route);
        Ok(())
    }

    // ... existing generate_output_path
}

Explanation:

  1. We added a match statement in render_and_write_content to select blog/single.html if content.content_type is BlogPost.
  2. You might need to create a templates/page/single.html for general pages, or adjust the default.

5. Core Implementation: Categories and Tags Pages

To make our blog navigable, we need archive pages for categories and tags. This involves:

  1. Aggregating all unique categories and tags from blog posts.
  2. Creating new templates for category and tag listings.
  3. Generating routes and rendering pages for each category and tag.

Action: Enhance BuildContext to store aggregated categories and tags.

// src/build/context.rs

use std::collections::{HashMap, HashSet}; // Add HashSet
// ... other imports

#[derive(Debug, Clone)]
pub struct BuildContext {
    // ... existing fields
    pub blog_posts: Arc<Mutex<Vec<Content>>>,
    pub categories: Arc<Mutex<HashMap<String, Vec<Content>>>>, // Category -> List of Posts
    pub tags: Arc<Mutex<HashMap<String, Vec<Content>>>>,       // Tag -> List of Posts
}

impl BuildContext {
    pub fn new(config: SiteConfig, output_dir: PathBuf) -> Self {
        Self {
            // ... existing initializations
            blog_posts: Arc::new(Mutex::new(Vec::new())),
            categories: Arc::new(Mutex::new(HashMap::new())), // Initialize
            tags: Arc::new(Mutex::new(HashMap::new())),       // Initialize
        }
    }

    /// Adds processed content to the context.
    pub async fn add_content(&self, content: Content) {
        // ... existing content_map insertion

        if content.content_type == ContentType::BlogPost {
            let mut blog_posts = self.blog_posts.lock().await;
            blog_posts.push(content.clone()); // Clone to use for categories/tags

            // Aggregate categories
            if let Some(post_categories) = &content.frontmatter.categories {
                let mut categories_map = self.categories.lock().await;
                for category in post_categories {
                    categories_map
                        .entry(category.to_lowercase().replace(' ', "-")) // Use slug for key
                        .or_insert_with(Vec::new)
                        .push(content.clone());
                }
            }

            // Aggregate tags
            if let Some(post_tags) = &content.frontmatter.tags {
                let mut tags_map = self.tags.lock().await;
                for tag in post_tags {
                    tags_map
                        .entry(tag.to_lowercase().replace(' ', "-")) // Use slug for key
                        .or_insert_with(Vec::new)
                        .push(content.clone());
                }
            }
        }
    }
}

Explanation:

  1. We added categories and tags HashMaps to BuildContext to store content grouped by these taxonomies. The keys are slugified versions for clean URLs.
  2. In add_content, when a BlogPost is added, we iterate through its frontmatter.categories and frontmatter.tags, adding the post to the respective lists in our new HashMaps.

Action: Create templates/blog/category.html and templates/blog/tag.html.

These templates will be similar to blog/index.html but will display posts for a specific category or tag.

<!-- templates/blog/category.html -->
{% extends "base.html" %}

{% block title %}Category: {{ category_name }} - My Awesome SSG Blog{% endblock %}

{% block content %}
<main class="container mx-auto px-4 py-8">
    <h1 class="text-4xl font-bold mb-8 text-center">Category: {{ category_name }}</h1>

    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
        {% for post in category_posts %}
        <article class="bg-white rounded-lg shadow-lg overflow-hidden">
            <div class="p-6">
                <h2 class="text-2xl font-semibold mb-2"><a href="{{ post.route }}" class="text-blue-700 hover:text-blue-900">{{ post.frontmatter.title }}</a></h2>
                <p class="text-gray-600 text-sm mb-4">
                    Published on <time datetime="{{ post.frontmatter.date | date(format="%Y-%m-%d") }}">{{ post.frontmatter.date | date(format="%B %e, %Y") }}</time>
                </p>
                <p class="text-gray-700 mb-4">{{ post.frontmatter.description | default(value=post.html_body | striptags | truncate(length=150)) }}</p>
                <a href="{{ post.route }}" class="inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">Read More</a>
            </div>
        </article>
        {% endfor %}
    </div>
</main>
{% endblock %}
<!-- templates/blog/tag.html -->
{% extends "base.html" %}

{% block title %}Tag: #{{ tag_name }} - My Awesome SSG Blog{% endblock %}

{% block content %}
<main class="container mx-auto px-4 py-8">
    <h1 class="text-4xl font-bold mb-8 text-center">Tag: #{{ tag_name }}</h1>

    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
        {% for post in tag_posts %}
        <article class="bg-white rounded-lg shadow-lg overflow-hidden">
            <div class="p-6">
                <h2 class="text-2xl font-semibold mb-2"><a href="{{ post.route }}" class="text-blue-700 hover:text-blue-900">{{ post.frontmatter.title }}</a></h2>
                <p class="text-gray-600 text-sm mb-4">
                    Published on <time datetime="{{ post.frontmatter.date | date(format="%Y-%m-%d") }}">{{ post.frontmatter.date | date(format="%B %e, %Y") }}</time>
                </p>
                <p class="text-gray-700 mb-4">{{ post.frontmatter.description | default(value=post.html_body | striptags | truncate(length=150)) }}</p>
                <a href="{{ post.route }}" class="inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">Read More</a>
            </div>
        </article>
        {% endfor %}
    </div>
</main>
{% endblock %}

Action: Modify src/main.rs (or your build function) to generate category and tag archive pages.

This will be a new build phase after generating the blog index.

// src/main.rs (continued from previous modifications)

// ... after Phase 3 (Blog Listing Page generation)

    // Phase 4: Generate category archive pages
    log::info!("Generating category archive pages...");
    let categories_map_guard = build_context.categories.lock().await;
    for (category_slug, posts) in categories_map_guard.iter() {
        let category_route = format!("/categories/{}/", category_slug);
        let category_output_path = build_manager.generate_output_path(&category_route);

        let mut context = tera::Context::new();
        context.insert("category_name", &category_slug.replace('-', " ").to_string()); // For display
        context.insert("category_posts", posts);

        // Sort posts for the category, newest first
        let mut sorted_posts = posts.clone();
        sorted_posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
        context.insert("category_posts", &sorted_posts);


        let rendered_html = tera.render("blog/category.html", &context)?;
        fs::create_dir_all(category_output_path.parent().unwrap()).await?;
        fs::write(&category_output_path, rendered_html.as_bytes()).await?;
        log::info!("Generated category page: {}", category_route);
    }
    drop(categories_map_guard);

    // Phase 5: Generate tag archive pages
    log::info!("Generating tag archive pages...");
    let tags_map_guard = build_context.tags.lock().await;
    for (tag_slug, posts) in tags_map_guard.iter() {
        let tag_route = format!("/tags/{}/", tag_slug);
        let tag_output_path = build_manager.generate_output_path(&tag_route);

        let mut context = tera::Context::new();
        context.insert("tag_name", &tag_slug.replace('-', " ").to_string()); // For display
        let mut sorted_posts = posts.clone();
        sorted_posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
        context.insert("tag_posts", &sorted_posts);

        let rendered_html = tera.render("blog/tag.html", &context)?;
        fs::create_dir_all(tag_output_path.parent().unwrap()).await?;
        fs::write(&tag_output_path, rendered_html.as_bytes()).await?;
        log::info!("Generated tag page: {}", tag_route);
    }
    drop(tags_map_guard);

    // Phase 6: Copy static assets (renumbered)
    log::info!("Copying static assets...");
    build_manager.copy_static_assets().await?;
    log::info!("Static assets copied.");

    // Phase 7: Generate hydration scripts (renumbered)
    log::info!("Generating hydration scripts...");
    build_manager.generate_hydration_scripts().await?;
    log::info!("Hydration scripts generated.");

    log::info!("Build complete!");
    Ok(())
}

Explanation:

  1. We iterate through the categories and tags HashMaps.
  2. For each category/tag, we create a specific Tera context, inserting category_name/tag_name (for display) and the list of associated posts (sorted by date).
  3. We render the respective blog/category.html or blog/tag.html templates and write them to public/categories/{slug}/index.html or public/tags/{slug}/index.html.

6. Core Implementation: Pagination for Blog Listing

For large blogs, we need to paginate the main listing page. This requires:

  1. Determining the number of posts per page.
  2. Splitting the blog_posts vector into chunks.
  3. Generating multiple index pages (e.g., /blog/page/1/, /blog/page/2/).

Action: Add pagination configuration to config.toml and SiteConfig.

# config.toml
# ... existing config

[blog_index_meta]
title = "My Awesome Rust SSG Blog"
description = "The official blog for our Rust Static Site Generator."
posts_per_page = 5 # New field for pagination
// src/config.rs
#[derive(Debug, Clone, serde::Deserialize)]
pub struct BlogIndexMeta {
    pub title: String,
    pub description: String,
    pub posts_per_page: usize, // Add this
}

Action: Modify src/main.rs to implement pagination for the blog index.

// src/main.rs (modifying Phase 3: Generate blog listing page)

    // Phase 3: Generate blog listing page(s) with pagination
    log::info!("Generating blog listing page(s) with pagination...");
    let mut blog_posts_vec = build_context.blog_posts.lock().await;
    blog_posts_vec.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date)); // Ensure sorted

    let posts_per_page = build_context.config.blog_index_meta.posts_per_page;
    let total_posts = blog_posts_vec.len();
    let total_pages = (total_posts as f64 / posts_per_page as f64).ceil() as usize;

    for page_num in 1..=total_pages {
        let start_index = (page_num - 1) * posts_per_page;
        let end_index = (start_index + posts_per_page).min(total_posts);
        let current_page_posts = &blog_posts_vec[start_index..end_index];

        let page_route = if page_num == 1 {
            "/blog/".to_string() // First page is root blog index
        } else {
            format!("/blog/page/{}/", page_num)
        };
        let page_output_path = build_manager.generate_output_path(&page_route);

        let mut context = tera::Context::new();
        context.insert("page", &build_context.config.blog_index_meta);
        context.insert("blog_posts", current_page_posts);
        context.insert("current_page", &page_num);
        context.insert("total_pages", &total_pages);
        context.insert("has_prev_page", &(page_num > 1));
        context.insert("has_next_page", &(page_num < total_pages));
        context.insert("prev_page_link", &if page_num > 1 {
            if page_num == 2 {
                "/blog/".to_string()
            } else {
                format!("/blog/page/{}/", page_num - 1)
            }
        } else { "".to_string() });
        context.insert("next_page_link", &if page_num < total_pages {
            format!("/blog/page/{}/", page_num + 1)
        } else { "".to_string() });

        let rendered_html = tera.render("blog/index.html", &context)?;
        fs::create_dir_all(page_output_path.parent().unwrap()).await?;
        fs::write(&page_output_path, rendered_html.as_bytes()).await?;
        log::info!("Generated blog index page {}: {}", page_num, page_route);
    }
    drop(blog_posts_vec);

Action: Update templates/blog/index.html to include pagination links.

<!-- templates/blog/index.html (add this block at the end of the <main> tag) -->
    {# Pagination #}
    {% if total_pages > 1 %}
    <nav class="flex justify-center items-center space-x-4 mt-12">
        {% if has_prev_page %}
            <a href="{{ prev_page_link }}" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-300">Previous</a>
        {% else %}
            <span class="px-4 py-2 bg-gray-300 text-gray-600 rounded cursor-not-allowed">Previous</span>
        {% endif %}

        {% for i in range(start=1, end=total_pages + 1) %}
            {% if i == current_page %}
                <span class="px-4 py-2 bg-blue-800 text-white rounded font-bold">{{ i }}</span>
            {% else %}
                <a href="{% if i == 1 %}/blog/{% else %}/blog/page/{{ i }}/{% endif %}" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors duration-300">{{ i }}</a>
            {% endif %}
        {% endfor %}

        {% if has_next_page %}
            <a href="{{ next_page_link }}" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-300">Next</a>
        {% else %}
            <span class="px-4 py-2 bg-gray-300 text-gray-600 rounded cursor-not-allowed">Next</span>
        {% endif %}
    </nav>
    {% endif %}

Explanation:

  1. We calculate total_pages based on posts_per_page from the config.
  2. We loop page_num from 1 to total_pages.
  3. For each page, we slice the blog_posts_vec to get current_page_posts.
  4. We determine the page_route (/blog/ for page 1, /blog/page/N/ for subsequent pages).
  5. The Tera context now includes current_page, total_pages, has_prev_page, has_next_page, prev_page_link, and next_page_link to enable navigation links in the template.

7. Testing This Component

To test the blog system:

  1. Ensure you have the SimpleCounter component implementation from previous chapters for the another-great-article.md post.
  2. Run your SSG build:
    cargo run
    
  3. Navigate to your public directory and open public/blog/index.html in a browser.
    • You should see a list of your blog posts.
    • Verify pagination links (if you have more posts than posts_per_page).
  4. Click on an individual post link.
    • It should open public/blog/my-first-post/index.html (or similar).
    • Verify the content, frontmatter details, and if SimpleCounter is present in another-great-article.html, check its static rendering and client-side hydration.
  5. Check the generated category and tag pages (e.g., public/categories/development/index.html).

Expected Behavior:

  • All blog posts are parsed, rendered, and outputted to public/blog/{slug}/index.html.
  • The main blog listing page at public/blog/index.html (and public/blog/page/N/index.html if paginated) correctly displays summaries of blog posts.
  • Category and tag archive pages are generated at public/categories/{slug}/index.html and public/tags/{slug}/index.html respectively, listing relevant posts.
  • Interactive components (like SimpleCounter) in blog posts are correctly hydrated.

8. Production Considerations

Error Handling:

  • Missing Templates: Ensure tera.render() calls are wrapped in Result handling. If blog/index.html or blog/single.html are missing, the build should fail gracefully with an informative error.
  • Invalid Dates: If frontmatter.date is not a valid DateTime, our Content parsing should catch it. Ensure conversion errors are logged and potentially halt the build for critical content.
  • Empty Taxonomies: Handle cases where frontmatter.categories or frontmatter.tags are empty or None gracefully in templates (e.g., using {% if page.frontmatter.categories %}).

Performance Optimization:

  • Batch Processing: Our current pipeline processes files in parallel. For category/tag aggregation, iterating through blog_posts_vec after all content is processed is efficient.
  • Caching: The incremental build system from Chapter 20 will ensure that only changed blog posts or relevant templates trigger a rebuild, significantly speeding up subsequent builds.
  • Lazy Loading Images: In blog post templates, consider implementing lazy loading for images (e.g., <img loading="lazy" ...>) to improve initial page load performance, especially for image-heavy posts.
  • CSS/JS Minification: Ensure your static asset copying includes steps for minifying CSS and JavaScript files, including hydration scripts.

Security Considerations:

  • XSS Protection (Tera): Tera’s autoescape_on for HTML templates is crucial. It prevents Cross-Site Scripting (XSS) by escaping user-generated content (like markdown converted to HTML) that might contain malicious scripts, unless explicitly marked as | safe. Use | safe sparingly and only for trusted content (like our page.html_body which is already sanitized by pulldown-cmark).
  • Frontmatter Validation: While serde handles deserialization, consider adding custom validation logic for frontmatter fields (e.g., date formats, valid tag characters) to prevent malformed data from breaking the build or rendering.

Logging and Monitoring:

  • Build Logs: Continue using env_logger to output detailed logs during the blog generation process. Log when each blog post, category, or tag page is generated.
  • Error Reporting: For production, integrate with an error reporting service if the SSG were to be used in a CI/CD pipeline for automated builds, to immediately detect and report build failures.

Code Review Checkpoint

At this point, you have significantly extended our SSG to support a complete blog system.

Summary of what was built:

  • Blog Content Structure: Established a content/blog directory for blog posts.
  • Blog Post Collection: BuildContext now identifies and collects BlogPost content types.
  • Blog Index Page: Generated a main blog listing page (/blog/) with pagination.
  • Individual Post Pages: Individual blog posts are rendered using a dedicated blog/single.html template.
  • Category and Tag Archives: Automatically generated archive pages for each category and tag found in blog post frontmatter.
  • Templating: Leveraged Tera for all blog-related templates, demonstrating dynamic content generation.

Files created/modified:

  • content/blog/*.md: New blog content files.
  • config.toml: Added blog_index_meta with posts_per_page.
  • src/config.rs: Updated SiteConfig and added BlogIndexMeta struct.
  • src/content.rs: Added ContentType::BlogPost.
  • src/build/context.rs: Added blog_posts, categories, tags collections and logic to populate them.
  • src/pipeline/mod.rs: Modified process_file to determine ContentType::BlogPost.
  • src/build/manager.rs: Modified render_and_write_content to select blog/single.html for blog posts.
  • src/main.rs: Added new phases for generating blog index (with pagination), category archives, and tag archives.
  • templates/blog/index.html: New template for blog listing with pagination.
  • templates/blog/single.html: New template for individual blog posts.
  • templates/blog/category.html: New template for category archive pages.
  • templates/blog/tag.html: New template for tag archive pages.

How it integrates with existing code:

  • The content processing pipeline (parsing frontmatter, markdown to AST, component rendering) remains largely unchanged, demonstrating its reusability.
  • BuildContext acts as the central hub for aggregating blog-specific data.
  • Tera templating system is used extensively, showcasing its power for complex layouts and data iteration.
  • The routing mechanism ensures clean URLs for all generated blog pages.
  • The hydration system (if components are used in blog posts) works seamlessly, as blog post html_body is processed like any other content.

Common Issues & Solutions

  1. Issue: “Tera Error: Template not found: blog/index.html”

    • Cause: The templates directory structure or file name is incorrect, or Tera was not initialized to scan the correct path.
    • Solution: Double-check templates/blog/index.html exists and Tera::new("templates/**/*.html") is correctly configured to find it. Ensure the build_manager.render call uses the exact path relative to the templates root.
    • Prevention: Always verify file paths and template names carefully. Use log::debug! to print the template path being requested by Tera.
  2. Issue: Blog posts not appearing on the index page, or categories/tags are empty.

    • Cause:
      • ContentType::BlogPost is not being correctly assigned in process_file.
      • add_content logic for blog_posts, categories, or tags is flawed or not being called.
      • Frontmatter fields (date, categories, tags) in Markdown files are missing or malformed.
      • Sorting logic is incorrect, or the blog_posts vector is empty before rendering.
    • Solution:
      1. Add log::debug!("Content type for {:?}: {:?}", &file_path, content_type); in process_file to verify type assignment.
      2. Inspect build_context.blog_posts.lock().await (with a breakpoint or log::debug!) before rendering the blog index to ensure it contains posts.
      3. Verify frontmatter in your Markdown files for correctness.
      4. Ensure content.clone() is used when adding to multiple collections (blog_posts, categories, tags) to avoid move errors.
  3. Issue: Pagination links are broken or lead to 404s.

    • Cause:
      • The page_route generation logic has an error (e.g., missing trailing slashes, incorrect page number logic).
      • The generate_output_path function is not creating the correct directory structure for paginated pages.
    • Solution:
      1. Print page_route and page_output_path during the build process to verify the generated paths.
      2. Ensure fs::create_dir_all(page_output_path.parent().unwrap()).await?; is called before writing the file to guarantee the directory structure exists.
      3. Check the href attributes in your index.html pagination links to ensure they match the routes generated by the SSG.

Testing & Verification

  1. Clean Build: Run cargo run after making all changes.
  2. Output Directory Inspection:
    • Verify public/blog/index.html exists.
    • Verify public/blog/page/{N}/index.html exists for subsequent pages.
    • Verify public/blog/{post-slug}/index.html exists for each post.
    • Verify public/categories/{category-slug}/index.html for each category.
    • Verify public/tags/{tag-slug}/index.html for each tag.
  3. Browser Verification:
    • Open public/blog/index.html.
    • Check if all posts are listed (or the correct number for the first page).
    • Verify pagination links work correctly.
    • Click on individual post links to ensure they load correctly with the single.html template.
    • Check that categories and tags displayed on posts link to their respective archive pages.
    • Verify category and tag archive pages list only relevant posts.
    • If you included a component like SimpleCounter in a blog post, ensure it’s functional after the page loads, indicating successful hydration.

Summary & Next Steps

In this chapter, we successfully transformed our generic Rust SSG into a powerful blog platform. We designed and implemented a robust content structure for blog posts, built the logic to aggregate and sort them, and generated dynamic index pages with pagination, individual post pages, and comprehensive category and tag archives. We leveraged all the core features of our SSG, including frontmatter parsing, Markdown rendering, Tera templating, and our hydration mechanism, to create a fully functional and modern blog system. This serves as a strong testament to the flexibility and extensibility of the SSG we’ve built.

We’ve covered the full lifecycle of building a real-world application with our SSG, from content creation to static output, while keeping production considerations in mind.

In the final chapter, Chapter 22: Deployment and CI/CD for Production, we will take our fully built SSG and its generated output and prepare it for production deployment. We will explore deployment strategies, set up a Continuous Integration/Continuous Deployment (CI/CD) pipeline, discuss monitoring, and ensure our Rust-based content platform is ready for the real world.