Welcome to Chapter 20! In this chapter, we’ll apply the robust static site generator (SSG) we’ve been building to a practical, real-world scenario: creating a structured learning platform with courses, modules, and individual chapters. This example will highlight our SSG’s capabilities in handling hierarchical content, dynamic navigation generation, and flexible templating, demonstrating how it can power complex content architectures.
The core challenge of a learning platform is organizing content into a logical, navigable structure. We’ll leverage our existing content processing pipeline, frontmatter parsing, and Tera templating to define courses, modules, and chapters, automatically generating sequential navigation (previous/next lessons) and a course-specific sidebar table of contents. This chapter will solidify your understanding of how to model and render complex content relationships within a static site context.
By the end of this chapter, you will have a fully functional learning platform example, complete with structured content, a browsable course list, dedicated course overview pages, and individual chapter pages featuring seamless navigation. This will serve as a strong foundation for building any content-heavy, structured documentation, or learning portal using our Rust SSG.
Planning & Design
Building a learning platform requires a clear strategy for content organization and presentation. We need to define how courses, modules, and chapters relate to each other, how they’re ordered, and how users will navigate through them.
Content Architecture
Our learning platform will follow a hierarchical structure:
- Courses: Top-level learning paths (e.g., “Rust Basics”, “Advanced Web Development”).
- Modules: Sections within a course (e.g., “Introduction to Rust”, “Data Types”).
- Chapters: Individual lessons within a module.
This structure will be reflected in our content/ directory and driven by specific frontmatter fields.
File Structure Example:
content/
├── courses/
│ ├── rust-basics/
│ │ ├── _index.md # Course overview page
│ │ ├── 01-introduction/
│ │ │ ├── _index.md # Module overview (optional)
│ │ │ ├── 01-what-is-rust.md
│ │ │ ├── 02-setting-up.md
│ │ │ └── ...
│ │ ├── 02-data-types/
│ │ │ ├── _index.md # Module overview (optional)
│ │ │ ├── 01-primitives.md
│ │ │ ├── 02-collections.md
│ │ │ └── ...
│ │ └── ...
│ └── advanced-web-dev/
│ └── ...
└── _index.md # Homepage (optional)
Frontmatter Enhancements:
To support this structure, we’ll augment our content frontmatter with fields like:
course_id: A unique identifier for the course this content belongs to.module_id: A unique identifier for the module within a course.chapter_order: A numeric value to define the display order of chapters within a module (and modules within a course).chapter_title: A specific title for the chapter, distinct from the page title.
Templating Strategy
We’ll design a set of Tera templates to render different parts of the learning platform:
course_list.html: A template to display a list of all available courses, typically rendered at/courses/.course_page.html: A template for individual course overview pages (_index.mdfiles in course directories), listing its modules and chapters.chapter_page.html: The main template for rendering individual chapter content, including the main content, a course sidebar navigation, and “previous/next” chapter links.
Navigation Generation
The most critical part is generating the navigation. During the build process, we will:
- Collect all content files.
- Group them by
course_id, thenmodule_id. - Sort chapters and modules by their
chapter_order. - Construct a structured “content tree” or “course map” that can be passed to our templates. This map will allow us to render a sidebar, and compute previous/next links for each chapter.
Component Architecture for Learning Platform
Step-by-Step Implementation
3.1. Update SSG Core for Hierarchical Content
First, we need to modify our FrontMatter struct to include the new fields required for organizing our learning content.
a) Setup/Configuration
Open src/frontmatter.rs and add the new fields. We’ll make them optional, as not all content will be part of a course.
b) Core Implementation
// src/frontmatter.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] // Allows us to have default values for fields not present
pub struct FrontMatter {
pub title: String,
pub date: Option<String>, // Using Option<String> for flexibility
pub draft: bool,
pub description: Option<String>,
pub slug: Option<String>,
pub permalink: Option<String>,
pub weight: Option<i32>,
pub keywords: Vec<String>,
pub tags: Vec<String>,
pub categories: Vec<String>,
pub author: Option<String>,
pub show_reading_time: bool,
pub show_table_of_contents: bool,
pub show_comments: bool,
pub toc: bool,
pub template: Option<String>, // Custom template for this page
pub extra: Option<HashMap<String, String>>, // For any extra, unstructured data
// New fields for learning platform structure
pub course_id: Option<String>, // Unique identifier for the course
pub module_id: Option<String>, // Unique identifier for the module within a course
pub chapter_order: Option<i32>, // Order of this chapter/module within its parent
pub chapter_title: Option<String>, // Specific title for the chapter in navigation
}
impl Default for FrontMatter {
fn default() -> Self {
FrontMatter {
title: "Untitled".to_string(),
date: None,
draft: false,
description: None,
slug: None,
permalink: None,
weight: None,
keywords: Vec::new(),
tags: Vec::new(),
categories: Vec::new(),
author: None,
show_reading_time: true,
show_table_of_contents: true,
show_comments: false,
toc: true,
template: None,
extra: None,
course_id: None,
module_id: None,
chapter_order: None,
chapter_title: None,
}
}
}
Explanation:
We’ve added course_id, module_id, chapter_order, and chapter_title to the FrontMatter struct. These are Option<String> or Option<i32> to signify they might not be present for every piece of content, allowing our SSG to handle various content types beyond just learning chapters. The #[serde(default)] attribute on the struct and the impl Default for FrontMatter ensure that missing fields are gracefully handled during deserialization, preventing errors.
c) Testing This Component
To test this, we can create a dummy Markdown file with the new frontmatter fields and try to parse it.
Create a new file content/test-course/chapter-1.md:
+++
title = "My First Chapter"
course_id = "test-course-id"
module_id = "intro-module"
chapter_order = 1
chapter_title = "Introduction to Testing"
+++
This is the content of my first chapter.
Now, in src/main.rs, you could temporarily modify the content loading logic to specifically load and print this file’s frontmatter to verify:
// src/main.rs (temporary verification code)
// ... existing imports ...
use crate::content::{Content, ContentLoader}; // Ensure ContentLoader is public
fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
log::info!("Starting SSG build process...");
// ... existing content loader setup ...
// Temporary: Load a specific file to test new frontmatter fields
let temp_content_path = PathBuf::from("content/test-course/chapter-1.md");
if temp_content_path.exists() {
log::info!("Attempting to load test content: {:?}", temp_content_path);
match ContentLoader::load(&temp_content_path, &PathBuf::from("content")) {
Ok(content) => {
log::info!("Successfully loaded test content.");
log::info!("Test Content Frontmatter: {:?}", content.front_matter);
assert_eq!(content.front_matter.course_id, Some("test-course-id".to_string()));
assert_eq!(content.front_matter.module_id, Some("intro-module".to_string()));
assert_eq!(content.front_matter.chapter_order, Some(1));
assert_eq!(content.front_matter.chapter_title, Some("Introduction to Testing".to_string()));
log::info!("Test frontmatter fields verified successfully!");
},
Err(e) => log::error!("Failed to load test content: {}", e),
}
} else {
log::warn!("Test content file 'content/test-course/chapter-1.md' not found. Skipping frontmatter test.");
}
// ... rest of your build process ...
// For now, let's just exit after the test
Ok(()) // Or continue with the full build if you want
}
Run cargo run. You should see log messages confirming the successful parsing of the new frontmatter fields. Remember to remove this temporary testing code before proceeding.
3.2. Implement Content Tree / Navigation Builder
Now that our content can carry hierarchical metadata, we need a way to build a structured representation of it. This “Content Tree” will be crucial for generating course navigation.
a) Setup/Configuration
Create a new file src/content_tree.rs. We’ll also need to update src/build_context.rs to store this tree, making it accessible throughout the build process.
b) Core Implementation
First, define the structures for our course, module, and chapter within src/content_tree.rs:
// src/content_tree.rs
use crate::content::Content;
use std::collections::BTreeMap; // BTreeMap keeps keys sorted
#[derive(Debug, Clone, PartialEq)]
pub struct ChapterItem {
pub title: String,
pub url: String,
pub order: i32,
pub content: Content, // Reference to the full content for rendering
}
#[derive(Debug, Clone, PartialEq)]
pub struct ModuleItem {
pub id: String,
pub title: String,
pub order: i32,
pub chapters: BTreeMap<i32, ChapterItem>, // Key: chapter_order
pub module_page_url: Option<String>, // URL for the module's _index.md if it exists
}
#[derive(Debug, Clone, PartialEq)]
pub struct CourseItem {
pub id: String,
pub title: String,
pub url: String, // URL to the course overview page (_index.md)
pub modules: BTreeMap<i32, ModuleItem>, // Key: module_order
pub content: Content, // Reference to the course overview content
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ContentTree {
pub courses: BTreeMap<String, CourseItem>, // Key: course_id
}
impl ContentTree {
/// Builds a hierarchical content tree from a flat list of Content objects.
pub fn build(all_content: &[Content]) -> Self {
log::info!("Building content tree for learning platform...");
let mut courses: BTreeMap<String, CourseItem> = BTreeMap::new();
for content in all_content {
if let Some(course_id) = &content.front_matter.course_id {
let course_order = content.front_matter.weight.unwrap_or(0); // Use weight for course order
// Handle course overview pages (_index.md for a course)
if content.is_index_page() && content.file_path.parent().map_or(false, |p| p.ends_with(&format!("courses/{}", course_id))) {
let course_item = courses.entry(course_id.clone()).or_insert_with(|| CourseItem {
id: course_id.clone(),
title: content.front_matter.title.clone(),
url: content.permalink.clone(),
modules: BTreeMap::new(),
content: content.clone(),
});
// Update course title and URL if this is the canonical _index.md
course_item.title = content.front_matter.title.clone();
course_item.url = content.permalink.clone();
course_item.content = content.clone();
log::debug!("Found course overview: {} ({})", course_item.title, course_item.id);
continue; // Process other content for this course later
}
// Handle modules and chapters
if let Some(module_id) = &content.front_matter.module_id {
let chapter_order = content.front_matter.chapter_order.unwrap_or(0);
let chapter_title = content.front_matter.chapter_title.clone().unwrap_or_else(|| content.front_matter.title.clone());
let course_item = courses.entry(course_id.clone()).or_insert_with(|| {
// Create a dummy course item if _index.md not yet processed
log::warn!("Course '{}' content found before its _index.md. Creating placeholder.", course_id);
CourseItem {
id: course_id.clone(),
title: format!("Course {}", course_id), // Placeholder title
url: format!("/courses/{}", course_id), // Placeholder URL
modules: BTreeMap::new(),
content: Content::default(), // Placeholder content
}
});
let module_order = content.front_matter.weight.unwrap_or(0); // Use weight for module order
let module_item = course_item.modules.entry(module_order).or_insert_with(|| ModuleItem {
id: module_id.clone(),
title: module_id.clone(), // Placeholder, will be updated by module _index.md
order: module_order,
chapters: BTreeMap::new(),
module_page_url: None,
});
// Update module title and URL if this is a module _index.md
if content.is_index_page() && content.file_path.parent().map_or(false, |p| p.ends_with(&format!("{}/{}", course_id, module_id))) {
module_item.title = content.front_matter.title.clone();
module_item.module_page_url = Some(content.permalink.clone());
log::debug!("Found module overview: {} ({})", module_item.title, module_item.id);
continue; // Skip adding _index.md as a regular chapter
}
// Add regular chapters
let chapter_item = ChapterItem {
title: chapter_title,
url: content.permalink.clone(),
order: chapter_order,
content: content.clone(),
};
log::debug!("Adding chapter: {} (Order: {}) to Module: {} in Course: {}", chapter_item.title, chapter_item.order, module_item.id, course_id);
module_item.chapters.insert(chapter_order, chapter_item);
}
}
}
// Ensure course titles/urls are set even if no _index.md, or if _index.md was processed last.
// Also ensure module titles are set.
for (_course_id, course) in courses.iter_mut() {
// If course title is still placeholder, try to find a chapter with a good title
if course.title.starts_with("Course ") {
if let Some(first_module) = course.modules.values().next() {
if let Some(first_chapter) = first_module.chapters.values().next() {
course.title = first_chapter.content.front_matter.course_id
.as_ref()
.map(|id| id.replace('-', " ").to_string()) // Basic formatting
.unwrap_or_else(|| "Untitled Course".to_string());
}
}
}
for (_module_order, module) in course.modules.iter_mut() {
if module.title == module.id { // If module title is still placeholder
if let Some(first_chapter) = module.chapters.values().next() {
module.title = first_chapter.content.front_matter.module_id
.as_ref()
.map(|id| id.replace('-', " ").to_string()) // Basic formatting
.unwrap_or_else(|| "Untitled Module".to_string());
}
}
}
}
log::info!("Content tree built with {} courses.", courses.len());
ContentTree { courses }
}
/// Gets previous and next chapter URLs for a given chapter permalink.
pub fn get_prev_next_chapters(&self, current_permalink: &str) -> (Option<ChapterItem>, Option<ChapterItem>) {
for course in self.courses.values() {
for module in course.modules.values() {
let chapter_urls: Vec<&ChapterItem> = module.chapters.values().collect();
for (i, chapter) in chapter_urls.iter().enumerate() {
if chapter.url == current_permalink {
let prev = if i > 0 { Some(chapter_urls[i - 1].clone()) } else { None };
let next = if i < chapter_urls.len() - 1 { Some(chapter_urls[i + 1].clone()) } else { None };
return (prev, next);
}
}
}
}
(None, None)
}
/// Gets the CourseItem associated with a given permalink, if it's a chapter within that course.
pub fn get_course_for_permalink(&self, permalink: &str) -> Option<CourseItem> {
for course in self.courses.values() {
if course.url == permalink {
return Some(course.clone());
}
for module in course.modules.values() {
if module.module_page_url.as_ref().map_or(false, |url| url == permalink) {
return Some(course.clone());
}
for chapter in module.chapters.values() {
if chapter.url == permalink {
return Some(course.clone());
}
}
}
}
None
}
}
Explanation:
ChapterItem,ModuleItem,CourseItem: These structs define the hierarchical data model for our learning content. They store essential information like title, URL, order, and references to their child items or theContentobject itself.ContentTree: This struct holds aBTreeMapofCourseItems, keyed bycourse_id.BTreeMapis used to ensure keys are always sorted, which helps in consistent iteration and rendering.build(all_content: &[Content]): This is the core logic. It iterates through all processedContentitems:- It first attempts to identify and populate
CourseItems using_index.mdfiles withincontent/courses/<course_id>/. - Then, it processes other chapters/modules, associating them with their respective courses and modules based on
course_idandmodule_idin their frontmatter. BTreeMaps are used forchaptersandmodulesto maintain order based onchapter_orderandweight(for modules).- Placeholder logic is included to handle cases where a chapter is found before its corresponding
_index.mdfor a course or module.
- It first attempts to identify and populate
get_prev_next_chapters: A utility function to easily retrieve the previous and next chapter items for any given chapter, crucial for navigation links.get_course_for_permalink: A utility function to find the parent course for a given permalink, useful for rendering course-specific sidebars.
Now, let’s integrate this ContentTree into our BuildContext.
// src/build_context.rs
use std::collections::HashMap;
use tera::Tera;
use crate::content::Content;
use crate::component_processor::ComponentDefinitions;
use crate::content_tree::ContentTree; // New import
pub struct BuildContext<'a> {
pub tera: Tera,
pub all_content: Vec<Content>,
pub component_definitions: ComponentDefinitions,
pub output_dir: &'a Path,
pub base_url: &'a str,
pub content_tree: ContentTree, // Add the content tree
// ... potentially other fields like search index, etc.
}
impl<'a> BuildContext<'a> {
pub fn new(
tera: Tera,
all_content: Vec<Content>,
component_definitions: ComponentDefinitions,
output_dir: &'a Path,
base_url: &'a str,
) -> Self {
// Build the content tree as part of context creation
let content_tree = ContentTree::build(&all_content);
BuildContext {
tera,
all_content,
component_definitions,
output_dir,
base_url,
content_tree, // Initialize the content tree
}
}
}
Explanation:
We’ve added content_tree: ContentTree to our BuildContext and initialized it within the new function using ContentTree::build(&all_content). This ensures that the content tree is built once at the start of the build process and is available to all subsequent rendering steps.
c) Testing This Component
To test the ContentTree builder, we need to populate our content/ directory with some structured content.
Create the following files:
content/courses/_index.md:
+++
title = "All Courses"
template = "course_list.html"
slug = "courses"
+++
Welcome to our learning platform!
content/courses/rust-basics/_index.md:
+++
title = "Rust Basics Course"
course_id = "rust-basics"
template = "course_page.html"
weight = 1 # Order of this course in a list
+++
Learn the fundamentals of Rust programming.
content/courses/rust-basics/01-introduction/01-what-is-rust.md:
+++
title = "What is Rust?"
course_id = "rust-basics"
module_id = "01-introduction"
chapter_order = 1
chapter_title = "What is Rust?"
+++
Rust is a systems programming language that focuses on safety, speed, and concurrency.
content/courses/rust-basics/01-introduction/02-setting-up.md:
+++
title = "Setting Up Rust"
course_id = "rust-basics"
module_id = "01-introduction"
chapter_order = 2
chapter_title = "Setting Up Your Environment"
+++
This chapter guides you through installing Rust.
content/courses/rust-basics/02-data-types/01-primitives.md:
+++
title = "Primitive Data Types"
course_id = "rust-basics"
module_id = "02-data-types"
chapter_order = 1
chapter_title = "Rust Primitives"
+++
Rust has several primitive data types...
Now, modify src/main.rs to print the content_tree after it’s built:
// src/main.rs (temporary verification code)
// ... existing imports ...
use crate::build_context::BuildContext; // Ensure BuildContext is public
use crate::content::ContentLoader;
use crate::component_processor::ComponentDefinitions;
use tera::Tera;
use std::path::{Path, PathBuf};
fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
log::info!("Starting SSG build process...");
let content_dir = PathBuf::from("content");
let output_dir = PathBuf::from("public");
let templates_dir = PathBuf::from("templates");
let base_url = "/";
// 1. Load all content
let all_content = ContentLoader::load_all(&content_dir)?;
log::info!("Loaded {} content files.", all_content.len());
// 2. Load Tera templates
let mut tera = Tera::new(templates_dir.join("**/*.html").to_str().unwrap())?;
tera.autoescape_on(vec![".html", ".sql"]); // Example, adjust as needed
// 3. Load component definitions (assuming this is set up)
let component_definitions = ComponentDefinitions::load(&PathBuf::from("components"))?; // Adjust path if needed
// 4. Create BuildContext, which now builds the ContentTree
let build_context = BuildContext::new(
tera,
all_content,
component_definitions,
&output_dir,
base_url,
);
// Temporary: Print the built content tree
log::info!("--- Generated Content Tree ---");
log::info!("{:#?}", build_context.content_tree);
log::info!("------------------------------");
// ... rest of your build process (to be added later) ...
Ok(())
}
Run cargo run. You should see a detailed, structured output of your ContentTree, reflecting the courses, modules, and chapters you defined. This confirms our content parsing and tree building logic is working correctly. Remove this temporary print statement before moving on.
3.3. Create Learning Platform Templates
Now we’ll create the Tera templates that will render our learning platform pages.
a) Setup/Configuration
Create these files in your templates/ directory:
templates/course_list.htmltemplates/course_page.htmltemplates/chapter_page.html- Ensure you have a
templates/base.htmlthat includes common headers, footers, and styles.
b) Core Implementation
templates/base.html (Example - adjust as per your design):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page.title | default(value="Untitled Page") }} - My Learning Platform</title>
<link rel="stylesheet" href="/styles.css"> {# Assuming you have a CSS file #}
{% block head_extra %}{% endblock head_extra %}
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/courses/">Courses</a>
{# Add more global navigation links #}
</nav>
</header>
<main>
{% block content %}{% endblock content %}
</main>
<footer>
<p>© 2026 My Learning Platform</p>
{% block footer_extra %}{% endblock footer_extra %}
</footer>
</body>
</html>
templates/course_list.html:
{% extends "base.html" %}
{% block content %}
<div class="container">
<h1>{{ page.title }}</h1>
<p>{{ page.description | default(value="Browse our available courses.") }}</p>
<div class="course-grid">
{% for course_id, course in content_tree.courses %}
<div class="course-card">
<h2><a href="{{ course.url }}">{{ course.title }}</a></h2>
<p>{{ course.content.front_matter.description | default(value="No description available.") }}</p>
<a href="{{ course.url }}" class="btn">Start Learning</a>
</div>
{% endfor %}
</div>
</div>
{% endblock content %}
Explanation:
This template iterates through content_tree.courses (which is a BTreeMap and thus sorted by course_id alphabetically, or by weight if we added a custom sort). It renders a card for each course, linking to its dedicated course_page.
templates/course_page.html:
{% extends "base.html" %}
{% block content %}
<div class="container course-layout">
<aside class="sidebar">
<h2>{{ course.title }}</h2>
<nav>
{% for module_order, module in course.modules %}
<h3>{{ module.title }}</h3>
<ul>
{% for chapter_order, chapter in module.chapters %}
<li {% if chapter.url == page.permalink %}class="active"{% endif %}>
<a href="{{ chapter.url }}">{{ chapter.title }}</a>
</li>
{% endfor %}
</ul>
{% endfor %}
</nav>
</aside>
<article class="main-content">
<h1>{{ page.title }}</h1>
<div class="course-description">
{{ page.content | safe }} {# Render the markdown content of the _index.md #}
</div>
{% if course.modules %}
<h2>Modules</h2>
<ul class="module-list">
{% for module_order, module in course.modules %}
<li>
<h3>{{ module.title }}</h3>
<ul>
{% for chapter_order, chapter in module.chapters %}
<li><a href="{{ chapter.url }}">{{ chapter.title }}</a></li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
{% else %}
<p>No modules found for this course yet.</p>
{% endif %}
</article>
</div>
{% endblock content %}
Explanation:
This template expects a course variable (an instance of CourseItem) to be passed to it. It renders the course overview content, and then dynamically generates a sidebar navigation based on the course.modules and their chapters.
templates/chapter_page.html:
{% extends "base.html" %}
{% block content %}
<div class="container chapter-layout">
<aside class="sidebar">
{% if course %}
<h2><a href="{{ course.url }}">{{ course.title }}</a></h2>
<nav>
{% for module_order, module in course.modules %}
<h3>{{ module.title }}</h3>
<ul>
{% for chapter_order, chapter in module.chapters %}
<li {% if chapter.url == page.permalink %}class="active"{% endif %}>
<a href="{{ chapter.url }}">{{ chapter.title }}</a>
</li>
{% endfor %}
</ul>
{% endfor %}
</nav>
{% else %}
<p>Course navigation unavailable.</p>
{% endif %}
</aside>
<article class="main-content">
<h1>{{ page.title }}</h1>
<div class="chapter-body">
{{ page.content | safe }} {# Render the markdown content #}
</div>
<nav class="chapter-pagination">
{% if prev_chapter %}
<a href="{{ prev_chapter.url }}" class="prev-chapter">← {{ prev_chapter.title }}</a>
{% else %}
<span>← No Previous Chapter</span>
{% endif %}
{% if next_chapter %}
<a href="{{ next_chapter.url }}" class="next-chapter">{{ next_chapter.title }} →</a>
{% else %}
<span>No Next Chapter →</span>
{% endif %}
</nav>
</article>
</div>
{% endblock content %}
Explanation:
This is the workhorse template for individual chapters. It expects page (the current Content), course (the parent CourseItem), prev_chapter, and next_chapter variables. It displays the main chapter content, a course-specific sidebar, and the previous/next chapter navigation links.
c) Testing This Component
To test these templates, we need to ensure our SSG passes the correct context variables to them. This leads us to the next step: updating the Router and Renderer.
3.4. Update Router and Renderer for Learning Platform Pages
We need to modify our Router to generate routes for course lists and individual courses/chapters, and our Renderer to pass the ContentTree and derived navigation data to the templates.
a) Setup/Configuration
We’ll primarily modify src/main.rs (our build orchestrator) and src/renderer.rs.
b) Core Implementation
First, let’s update src/renderer.rs to accept and use the ContentTree and provide helper functions for rendering.
// src/renderer.rs
use crate::content::{Content, RenderedContent};
use crate::build_context::BuildContext;
use tera::{Context, Tera};
use std::path::{Path, PathBuf};
use std::fs;
use anyhow::{Result, anyhow};
use log::{info, error};
pub struct Renderer<'a> {
build_context: &'a BuildContext<'a>,
}
impl<'a> Renderer<'a> {
pub fn new(build_context: &'a BuildContext<'a>) -> Self {
Renderer { build_context }
}
/// Renders a single Content item to its final HTML page.
pub fn render_content_page(&self, content: &Content) -> Result<()> {
let template_name = content.front_matter.template.as_deref().unwrap_or("chapter_page.html"); // Default to chapter_page
info!("Rendering content: '{}' using template '{}'", content.permalink, template_name);
let mut context = Context::new();
context.insert("page", &content); // The current page's data
// Provide the entire content tree to all templates
context.insert("content_tree", &self.build_context.content_tree);
// If this is a chapter, provide previous/next links and its parent course
if content.front_matter.course_id.is_some() {
let (prev, next) = self.build_context.content_tree.get_prev_next_chapters(&content.permalink);
context.insert("prev_chapter", &prev);
context.insert("next_chapter", &next);
if let Some(course_item) = self.build_context.content_tree.get_course_for_permalink(&content.permalink) {
context.insert("course", &course_item);
}
}
// Render custom components
let mut rendered_html = content.rendered_html.clone();
for (component_key, component_data) in &content.component_data {
if let Some(component_def) = self.build_context.component_definitions.get(component_key) {
let component_context = component_data.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<std::collections::HashMap<String, String>>();
match self.build_context.tera.render(&component_def.template, &Context::from_serialize(component_context)?) {
Ok(rendered_component) => {
let placeholder = format!("<!--COMPONENT_{}-->", component_key); // Use the same placeholder from Chapter 14
rendered_html = rendered_html.replace(&placeholder, &rendered_component);
info!("Replaced component placeholder for '{}'", component_key);
},
Err(e) => error!("Failed to render component '{}': {}", component_key, e),
}
} else {
error!("Component definition not found for key: {}", component_key);
}
}
// Final render with Tera
let final_html = self.build_context.tera.render(template_name, &context)
.map_err(|e| anyhow!("Failed to render template '{}' for '{}': {}", template_name, content.permalink, e))?;
// Determine output path
let output_path = self.build_context.output_dir.join(&content.permalink.trim_start_matches('/'));
let output_file_path = if output_path.extension().is_none() {
output_path.join("index.html") // For clean URLs (e.g., /about/ -> /about/index.html)
} else {
output_path
};
// Ensure parent directories exist
if let Some(parent) = output_file_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| anyhow!("Failed to create output directory {}: {}", parent.display(), e))?;
}
fs::write(&output_file_path, final_html)
.map_err(|e| anyhow!("Failed to write output file {}: {}", output_file_path.display(), e))?;
info!("Successfully rendered and wrote: {}", output_file_path.display());
Ok(())
}
/// Renders a custom page (e.g., a course list page) that doesn't correspond directly to a Content item.
pub fn render_custom_page(&self, template_name: &str, output_path: &Path, context_data: serde_json::Value) -> Result<()> {
info!("Rendering custom page: '{}' using template '{}'", output_path.display(), template_name);
let mut context = Context::from_value(context_data)?;
// Always provide the content tree to custom pages too
context.insert("content_tree", &self.build_context.content_tree);
let final_html = self.build_context.tera.render(template_name, &context)
.map_err(|e| anyhow!("Failed to render custom template '{}' for '{}': {}", template_name, output_path.display(), e))?;
let output_file_path = if output_path.extension().is_none() {
self.build_context.output_dir.join(output_path).join("index.html")
} else {
self.build_context.output_dir.join(output_path)
};
if let Some(parent) = output_file_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| anyhow!("Failed to create output directory {}: {}", parent.display(), e))?;
}
fs::write(&output_file_path, final_html)
.map_err(|e| anyhow!("Failed to write output file {}: {}", output_file_path.display(), e))?;
info!("Successfully rendered custom page: {}", output_file_path.display());
Ok(())
}
}
Explanation of src/renderer.rs changes:
- Context Insertion: In
render_content_page, we now insert the entirecontent_treeinto the Tera context, making it available to all templates. - Course-Specific Data: If a content item has a
course_id, we fetch itsprev_chapter,next_chapter, and the parentcourse(using the newContentTreemethods) and insert them into the context. This allowschapter_page.htmlto render the navigation. - Default Template: The default template for content pages is now
chapter_page.html, which is more appropriate for a learning platform. render_custom_page: A new function is added to render pages that don’t directly map to aContentobject (like ourcourse_list.htmlwhich is generated fromcontent/courses/_index.mdbut needs to render a list of all courses, not just its own content). This function also ensures thecontent_treeis available.
Next, we update src/main.rs to orchestrate the build, including rendering the new learning platform pages.
// src/main.rs
use std::path::{Path, PathBuf};
use std::fs;
use anyhow::{Result, anyhow};
use log::{info, error, debug};
use tera::Tera;
mod content;
mod frontmatter;
mod parser;
mod renderer;
mod build_context;
mod component_processor;
mod content_tree; // New module import
use crate::content::{Content, ContentLoader};
use crate::renderer::Renderer;
use crate::build_context::BuildContext;
use crate::component_processor::ComponentDefinitions;
use crate::content_tree::{ContentTree, CourseItem}; // Import CourseItem too
fn main() -> Result<()> {
env_logger::init();
info!("Starting SSG build process...");
let content_dir = PathBuf::from("content");
let output_dir = PathBuf::from("public");
let templates_dir = PathBuf::from("templates");
let base_url = "/"; // Configure your base URL
// Clean output directory
if output_dir.exists() {
fs::remove_dir_all(&output_dir)
.map_err(|e| anyhow!("Failed to clean output directory {}: {}", output_dir.display(), e))?;
info!("Cleaned output directory: {}", output_dir.display());
}
fs::create_dir_all(&output_dir)
.map_err(|e| anyhow!("Failed to create output directory {}: {}", output_dir.display(), e))?;
// 1. Load component definitions
let component_definitions = ComponentDefinitions::load(&PathBuf::from("components"))?;
info!("Loaded {} component definitions.", component_definitions.len());
// 2. Load all content files, parse frontmatter and markdown
let mut all_content = ContentLoader::load_all(&content_dir)?;
info!("Loaded and parsed {} content files.", all_content.len());
// 3. Initialize Tera templates
let mut tera = Tera::new(templates_dir.join("**/*.html").to_str().unwrap())?;
tera.autoescape_on(vec![".html", ".sql"]); // Apply autoescaping rules
info!("Loaded Tera templates from: {}", templates_dir.display());
// 4. Create BuildContext (which now builds the ContentTree)
let build_context = BuildContext::new(
tera,
all_content, // BuildContext takes ownership of all_content
component_definitions,
&output_dir,
base_url,
);
// 5. Initialize Renderer
let renderer = Renderer::new(&build_context);
// 6. Render all content pages
for content in build_context.all_content.iter() {
if content.front_matter.draft {
debug!("Skipping draft content: {}", content.permalink);
continue;
}
renderer.render_content_page(content)?;
}
// 7. Handle custom pages, e.g., the main course listing page
// Find the content item for the courses index page
let courses_index_content = build_context.all_content.iter()
.find(|c| c.permalink == "/courses/"); // Assuming /courses/ is the URL for the main course list
if let Some(content) = courses_index_content {
// If it specifies a template, use it, otherwise a default
let template_name = content.front_matter.template.as_deref().unwrap_or("course_list.html");
let output_path = PathBuf::from(content.permalink.trim_start_matches('/'));
let mut context_data = serde_json::to_value(content)?; // Pass its own data
// The `content_tree` is automatically added by `render_custom_page`
renderer.render_custom_page(template_name, &output_path, context_data)?;
} else {
info!("No content file found for /courses/, skipping explicit course list page generation.");
// Optionally, you could generate a default /courses/ page even if no _index.md exists
// let default_course_list_context = serde_json::json!({ "title": "All Courses" });
// renderer.render_custom_page("course_list.html", &PathBuf::from("courses"), default_course_list_context)?;
}
// 8. Copy static assets (if you have a static directory)
// Example: copy_static_assets(&PathBuf::from("static"), &output_dir)?;
info!("SSG build process completed successfully!");
Ok(())
}
// Example function to copy static assets
fn copy_static_assets(src: &Path, dest: &Path) -> Result<()> {
if !src.exists() {
info!("Static assets directory '{}' not found. Skipping.", src.display());
return Ok(());
}
info!("Copying static assets from '{}' to '{}'...", src.display(), dest.display());
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let relative_path = path.strip_prefix(src)?;
let dest_path = dest.join(relative_path);
if path.is_dir() {
fs::create_dir_all(&dest_path)?;
copy_static_assets(&path, &dest_path)?; // Recursive copy for subdirectories
} else {
fs::copy(&path, &dest_path)?;
}
}
info!("Static assets copied.");
Ok(())
}
Explanation of src/main.rs changes:
content_treemodule import: We explicitly import our new module.BuildContextcreation: TheBuildContextis now responsible for building theContentTreefromall_content.- Rendering Loop: The main loop iterates through
build_context.all_content(which is now owned byBuildContext) and callsrenderer.render_content_pagefor each. - Custom Page Rendering: A specific block is added to handle the
/courses/index page. It finds theContentobject for/courses/_index.mdand usesrenderer.render_custom_pageto render it, ensuring thecontent_treeis available forcourse_list.htmlto display all courses.
c) Testing This Component
Create
static/styles.css:/* static/styles.css */ body { font-family: sans-serif; margin: 0; background-color: #f4f7f6; color: #333; } header { background-color: #28a745; color: white; padding: 1em; text-align: center; } header nav a { color: white; margin: 0 15px; text-decoration: none; } .container { max-width: 1200px; margin: 20px auto; padding: 0 20px; } footer { text-align: center; padding: 1em; background-color: #eee; margin-top: 40px; } /* Course List Page */ .course-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; } .course-card { background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .course-card h2 { margin-top: 0; } .course-card .btn { display: inline-block; background-color: #007bff; color: white; padding: 8px 15px; border-radius: 5px; text-decoration: none; margin-top: 10px; } /* Chapter/Course Page Layout */ .course-layout, .chapter-layout { display: grid; grid-template-columns: 250px 1fr; gap: 30px; } .sidebar { background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); } .sidebar h2, .sidebar h3 { margin-top: 0; color: #28a745; } .sidebar ul { list-style: none; padding-left: 0; margin-bottom: 15px; } .sidebar ul li a { display: block; padding: 5px 0; color: #555; text-decoration: none; } .sidebar ul li a:hover, .sidebar ul li.active a { color: #007bff; font-weight: bold; } .main-content { background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); } .chapter-body img { max-width: 100%; height: auto; } /* Pagination */ .chapter-pagination { display: flex; justify-content: space-between; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; } .chapter-pagination a, .chapter-pagination span { text-decoration: none; color: #007bff; } .chapter-pagination a:hover { text-decoration: underline; } .chapter-pagination .prev-chapter { text-align: left; } .chapter-pagination .next-chapter { text-align: right; }And modify
src/main.rsto includecopy_static_assetscall:// src/main.rs (inside main function, after rendering pages) // ... // 7. Handle custom pages, e.g., the main course listing page // ... // 8. Copy static assets copy_static_assets(&PathBuf::from("static"), &output_dir)?; info!("SSG build process completed successfully!"); Ok(())Run the SSG: Execute
cargo run.Inspect Output:
- Open
public/courses/index.htmlin your browser. You should see a list of your courses. - Click on “Rust Basics Course”. This should take you to
public/courses/rust-basics/index.html, showing the course overview and its modules/chapters in the sidebar. - Click on “What is Rust?”. This should take you to
public/courses/rust-basics/01-introduction/01-what-is-rust/index.html, displaying the chapter content, the course sidebar, and “previous/next” navigation links (with “No Previous Chapter” for the first chapter). - Verify all navigation links work as expected.
- Open
3.5. Integrate Custom Components (e.g., CodeBlock, Quiz)
Our SSG already supports custom components (as implemented in Chapter 14). This learning platform is an ideal place to use them for enhanced interactivity.
a) Setup/Configuration
Ensure you have a components/ directory with definitions and corresponding Tera templates. For instance, let’s create a simple Quiz component.
components/quiz.toml:
template = "components/quiz.html"
templates/components/quiz.html:
<div class="quiz-component" data-question="{{ question | default(value='No question?') }}" data-answer="{{ answer | default(value='No answer?') }}">
<p><strong>Quiz:</strong> {{ question | default(value='No question provided.') }}</p>
<button onclick="alert('Answer: {{ answer | default(value='No answer provided.') }}')">Show Answer</button>
{# For actual hydration, this would be a client-side JS component #}
</div>
b) Core Implementation
Now, in one of your chapter Markdown files, use the Quiz component.
content/courses/rust-basics/01-introduction/01-what-is-rust.md (add to the end):
+++
title = "What is Rust?"
course_id = "rust-basics"
module_id = "01-introduction"
chapter_order = 1
chapter_title = "What is Rust?"
+++
Rust is a systems programming language that focuses on safety, speed, and concurrency.
It's known for its strong memory safety guarantees without garbage collection.
<Quiz question="What is Rust primarily known for?" answer="Safety, speed, and concurrency." />
c) Testing This Component
- Run
cargo run. - Navigate to the
public/courses/rust-basics/01-introduction/01-what-is-rust/index.htmlpage. - You should see the “Quiz” component rendered with the question and a button. Clicking the button should trigger a JavaScript
alert(this is a simple static demonstration; a fully hydrated component would involve client-side JS).
Production Considerations
- Scalability: The
ContentTreegeneration iterates through all content once. For very large sites (tens of thousands of chapters), this might become a bottleneck. Incremental builds (Chapter 18) are crucial here, rebuilding only the affected parts of the tree. - Performance:
- Pre-rendering Navigation: We pre-render the entire course navigation into static HTML, which is excellent for initial page load performance and SEO.
- Client-side interactivity: For dynamic elements like quiz checks, progress tracking, or interactive code examples, partial hydration (Chapter 14) is essential to keep the initial payload small while adding interactivity where needed.
- Security: Ensure all user-provided content (especially within components) is properly escaped to prevent XSS attacks. Tera’s auto-escaping helps, but if you’re directly embedding raw user input, be cautious. Our current
{{ page.content | safe }}assumespulldown-cmarkhas already sanitized the HTML. - Logging and Monitoring: Comprehensive logging (as implemented with
logandenv_logger) helps track the build process, identify failed renders, or issues with content parsing, crucial for a production CI/CD pipeline. - Accessibility: Ensure the generated navigation uses semantic HTML (
<nav>,<ul>,<li>) and has appropriate ARIA attributes if necessary, especially for screen readers.
Code Review Checkpoint
At this point, we have significantly enhanced our SSG to handle complex, structured content:
Files Created:
src/content_tree.rs: Defines the data structures and logic for building the hierarchical content tree.templates/course_list.html: Renders a list of all courses.templates/course_page.html: Renders an overview for a single course, including its modules and chapters.templates/chapter_page.html: Renders individual chapter content with sidebar navigation and pagination.static/styles.css: Basic styling for the learning platform.components/quiz.toml,templates/components/quiz.html: Example custom component.- New content files in
content/courses/to demonstrate the structure.
Files Modified:
src/frontmatter.rs: Addedcourse_id,module_id,chapter_order,chapter_titlefields.src/build_context.rs: Addedcontent_treeto the build context and initialized it.src/renderer.rs: Modifiedrender_content_pageto passcontent_tree,course,prev_chapter,next_chapterto templates, and addedrender_custom_page.src/main.rs: Updated the build pipeline to create theBuildContext, use theRendererfor all content, and explicitly render the/courses/index page. Also added static asset copying.
Integration: The
ContentTreeis built early in themainfunction and stored inBuildContext. TheRendererthen accesses this tree to enrich the Tera context for course-related pages, enabling dynamic navigation and content relationships. Custom components are also seamlessly integrated within the chapter content.
Common Issues & Solutions
Issue: Incorrect Chapter Ordering / Missing Chapters in Navigation
- Cause: Missing
chapter_orderorweightin frontmatter, or duplicatechapter_ordervalues within the same module.BTreeMapuses the key for sorting; if it’sNoneor duplicated, behavior might be unexpected. - Debugging:
- Check
src/main.rstemporarylog::info!("{:#?}", build_context.content_tree);output to see the exact structure and order detected by the SSG. - Verify
chapter_order(for chapters) andweight(for modules/courses) fields in your Markdown frontmatter.
- Check
- Solution: Ensure all chapters have unique
chapter_ordervalues within their module, and modules/courses have appropriateweightvalues. Provide a defaultchapter_order = 0if not specified.
- Cause: Missing
Issue: Broken Navigation Links (404 errors)
- Cause: Mismatch between
permalinkgeneration andContentTree’s stored URLs, or incorrect template variables. - Debugging:
- Check the generated HTML for
hrefattributes on navigation links. Do they match the expected output paths? - Verify that
content.permalinkinsrc/content_tree.rsandsrc/renderer.rsis correctly derived and consistent. - Ensure the
output_file_pathlogic insrc/renderer.rscorrectly appendsindex.htmlfor directory URLs.
- Check the generated HTML for
- Solution: Double-check your
slugandpermalinkgeneration logic insrc/content.rsand theoutput_pathconstruction insrc/renderer.rs. Ensurebase_urlis correctly configured.
- Cause: Mismatch between
Issue: Template Rendering Errors (e.g., “Variable ‘course’ not found”)
- Cause: The
Rendereris not correctly inserting the expected variables into the Tera context for a specific template. - Debugging:
- Add
log::debug!("Context for {}: {:#?}", template_name, context);insrc/renderer.rsbeforetera.render(). This will show you exactly what variables are available. - Check the
if content.front_matter.course_id.is_some()block inrender_content_pageto ensure the logic for insertingcourse,prev_chapter,next_chapteris correctly triggered.
- Add
- Solution: Ensure the
Renderer’s logic for populating theContextmatches the variables expected by your Tera templates. Use{% if variable_name %}in templates to conditionally render sections if variables might be absent.
- Cause: The
Testing & Verification
- Clean and Build: Run
cargo runfrom your project root. This will clean thepublicdirectory, re-parse all content, build the content tree, and render all pages. - Serve Statically: Use a simple HTTP server to serve the
publicdirectory (e.g.,python3 -m http.server -d publicornpx serve public). - Verify Homepage: Navigate to
/(your site’s root). It should render your_index.md(if you have one) or a default. - Verify Course List: Navigate to
/courses/. You should see thecourse_list.htmltemplate rendered, displaying your “Rust Basics Course”. - Verify Course Overview: Click on “Rust Basics Course”. You should land on
/courses/rust-basics/, seeing thecourse_page.htmltemplate, with a sidebar listing modules and chapters. - Verify Chapter Pages: Click on any chapter, e.g., “What is Rust?”. You should see the
chapter_page.htmltemplate, displaying the chapter content, the course sidebar, and functional “Previous” and “Next” chapter navigation links. - Verify Component Integration: Check the chapter with the
Quizcomponent to ensure it’s rendered correctly. - Check Static Assets: Ensure your
styles.cssis applied correctly.
Summary & Next Steps
In this chapter, we successfully transformed our generic SSG into a specialized tool for building a structured learning platform. We enhanced our content model with hierarchical metadata, developed a robust “Content Tree” builder to organize this content, and designed flexible Tera templates to render course listings, course overviews, and individual chapter pages with dynamic navigation. This demonstrates the power and extensibility of our Rust SSG for managing complex content relationships.
You now have a production-ready blueprint for creating documentation sites, online courses, or any content platform requiring structured, navigable content.
In the next chapter, Chapter 21: Real-World Example: Building a Dynamic Blog System, we will tackle another common SSG use case: a blog. This will involve handling reverse chronological order, pagination, tag/category archives, and RSS feed generation, further pushing the capabilities of our SSG.