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.
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:
- We added
blog_posts: Arc<Mutex<Vec<Content>>>toBuildContext. This will hold all parsed blog posts. - In
add_content, after inserting intocontent_map, we checkcontent.content_type. If it’sBlogPost, we add it to theblog_postsvector. - We’ve added
ContentType::BlogPostto ourContentTypeenum and a placeholderdetermine_content_typefunction. You’ll need to integrate this logic into yourContent::from_pathorContent::parsemethod where theContentTypeis 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:
- Creating a new template for the blog index.
- Modifying our build process to create a context for this template, including sorted blog posts.
- 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:
- We added a new
blog_index_metafield toSiteConfigto provide metadata for the blog listing page, which will be used in theblog/index.htmltemplate. - After processing all content, we acquire a lock on
build_context.blog_posts, sort them by date (newest first), and then release the lock. - We create a
tera::Context, insert a dummypageobject (usingconfig.blog_index_meta) and the sortedblog_posts. - We render
blog/index.htmland write it topublic/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>© 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:
- We added a
matchstatement inrender_and_write_contentto selectblog/single.htmlifcontent.content_typeisBlogPost. - You might need to create a
templates/page/single.htmlfor 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:
- Aggregating all unique categories and tags from blog posts.
- Creating new templates for category and tag listings.
- 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:
- We added
categoriesandtagsHashMaps toBuildContextto store content grouped by these taxonomies. The keys are slugified versions for clean URLs. - In
add_content, when aBlogPostis added, we iterate through itsfrontmatter.categoriesandfrontmatter.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:
- We iterate through the
categoriesandtagsHashMaps. - 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). - We render the respective
blog/category.htmlorblog/tag.htmltemplates and write them topublic/categories/{slug}/index.htmlorpublic/tags/{slug}/index.html.
6. Core Implementation: Pagination for Blog Listing
For large blogs, we need to paginate the main listing page. This requires:
- Determining the number of posts per page.
- Splitting the
blog_postsvector into chunks. - 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:
- We calculate
total_pagesbased onposts_per_pagefrom the config. - We loop
page_numfrom 1 tototal_pages. - For each page, we slice the
blog_posts_vecto getcurrent_page_posts. - We determine the
page_route(/blog/for page 1,/blog/page/N/for subsequent pages). - The Tera context now includes
current_page,total_pages,has_prev_page,has_next_page,prev_page_link, andnext_page_linkto enable navigation links in the template.
7. Testing This Component
To test the blog system:
- Ensure you have the
SimpleCountercomponent implementation from previous chapters for theanother-great-article.mdpost. - Run your SSG build:
cargo run - Navigate to your
publicdirectory and openpublic/blog/index.htmlin a browser.- You should see a list of your blog posts.
- Verify pagination links (if you have more posts than
posts_per_page).
- 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
SimpleCounteris present inanother-great-article.html, check its static rendering and client-side hydration.
- It should open
- 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(andpublic/blog/page/N/index.htmlif paginated) correctly displays summaries of blog posts. - Category and tag archive pages are generated at
public/categories/{slug}/index.htmlandpublic/tags/{slug}/index.htmlrespectively, 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 inResulthandling. Ifblog/index.htmlorblog/single.htmlare missing, the build should fail gracefully with an informative error. - Invalid Dates: If
frontmatter.dateis not a validDateTime, ourContentparsing should catch it. Ensure conversion errors are logged and potentially halt the build for critical content. - Empty Taxonomies: Handle cases where
frontmatter.categoriesorfrontmatter.tagsare empty orNonegracefully 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_vecafter 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_onfor 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| safesparingly and only for trusted content (like ourpage.html_bodywhich is already sanitized bypulldown-cmark). - Frontmatter Validation: While
serdehandles 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_loggerto 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/blogdirectory for blog posts. - Blog Post Collection:
BuildContextnow identifies and collectsBlogPostcontent 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.htmltemplate. - 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: Addedblog_index_metawithposts_per_page.src/config.rs: UpdatedSiteConfigand addedBlogIndexMetastruct.src/content.rs: AddedContentType::BlogPost.src/build/context.rs: Addedblog_posts,categories,tagscollections and logic to populate them.src/pipeline/mod.rs: Modifiedprocess_fileto determineContentType::BlogPost.src/build/manager.rs: Modifiedrender_and_write_contentto selectblog/single.htmlfor 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.
BuildContextacts 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_bodyis processed like any other content.
Common Issues & Solutions
Issue: “Tera Error: Template not found: blog/index.html”
- Cause: The
templatesdirectory structure or file name is incorrect, or Tera was not initialized to scan the correct path. - Solution: Double-check
templates/blog/index.htmlexists andTera::new("templates/**/*.html")is correctly configured to find it. Ensure thebuild_manager.rendercall uses the exact path relative to thetemplatesroot. - Prevention: Always verify file paths and template names carefully. Use
log::debug!to print the template path being requested by Tera.
- Cause: The
Issue: Blog posts not appearing on the index page, or categories/tags are empty.
- Cause:
ContentType::BlogPostis not being correctly assigned inprocess_file.add_contentlogic forblog_posts,categories, ortagsis flawed or not being called.- Frontmatter fields (
date,categories,tags) in Markdown files are missing or malformed. - Sorting logic is incorrect, or the
blog_postsvector is empty before rendering.
- Solution:
- Add
log::debug!("Content type for {:?}: {:?}", &file_path, content_type);inprocess_fileto verify type assignment. - Inspect
build_context.blog_posts.lock().await(with a breakpoint orlog::debug!) before rendering the blog index to ensure it contains posts. - Verify frontmatter in your Markdown files for correctness.
- Ensure
content.clone()is used when adding to multiple collections (blog_posts,categories,tags) to avoidmoveerrors.
- Add
- Cause:
Issue: Pagination links are broken or lead to 404s.
- Cause:
- The
page_routegeneration logic has an error (e.g., missing trailing slashes, incorrect page number logic). - The
generate_output_pathfunction is not creating the correct directory structure for paginated pages.
- The
- Solution:
- Print
page_routeandpage_output_pathduring the build process to verify the generated paths. - Ensure
fs::create_dir_all(page_output_path.parent().unwrap()).await?;is called before writing the file to guarantee the directory structure exists. - Check the
hrefattributes in yourindex.htmlpagination links to ensure they match the routes generated by the SSG.
- Print
- Cause:
Testing & Verification
- Clean Build: Run
cargo runafter making all changes. - Output Directory Inspection:
- Verify
public/blog/index.htmlexists. - Verify
public/blog/page/{N}/index.htmlexists for subsequent pages. - Verify
public/blog/{post-slug}/index.htmlexists for each post. - Verify
public/categories/{category-slug}/index.htmlfor each category. - Verify
public/tags/{tag-slug}/index.htmlfor each tag.
- Verify
- 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.htmltemplate. - 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
SimpleCounterin a blog post, ensure it’s functional after the page loads, indicating successful hydration.
- Open
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.