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.md files 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.

The most critical part is generating the navigation. During the build process, we will:

  1. Collect all content files.
  2. Group them by course_id, then module_id.
  3. Sort chapters and modules by their chapter_order.
  4. 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

flowchart TD A[Raw Markdown Content] --> B{Parse Frontmatter}; B --> C[Parse Markdown to AST]; B --> D[Extract Custom Components]; subgraph Content_Processing_Pipeline["Content Processing Pipeline"] C --> E[Transform AST to HTML]; D --> F[Process Custom Components]; F --> G[Generate Hydration Markers]; end subgraph Content_Tree_Builder["Content Tree Builder"] H[All Processed Content Items] --> I{Group by Course ID}; I --> J{Group by Module ID}; J --> K{Sort by Order}; K --> L[Generate Course Navigation Tree]; end subgraph Rendering_Phase["Rendering Phase"] L --> M[Pass Navigation Tree to Templates]; G --> N[Render Content with Tera]; M & N --> O[Apply Base Layout]; end O --> P[Output Static HTML Files]; style A fill:#f9f,stroke:#333,stroke-width:2px; style B fill:#bbf,stroke:#333,stroke-width:2px; style C fill:#bbf,stroke:#333,stroke-width:2px; style D fill:#bbf,stroke:#333,stroke-width:2px; style E fill:#ccf,stroke:#333,stroke-width:2px; style F fill:#ccf,stroke:#333,stroke-width:2px; style G fill:#ccf,stroke:#333,stroke-width:2px; style H fill:#f9f,stroke:#333,stroke-width:2px; style I fill:#bbf,stroke:#333,stroke-width:2px; style J fill:#bbf,stroke:#333,stroke-width:2px; style K fill:#bbf,stroke:#333,stroke-width:2px; style L fill:#ccf,stroke:#333,stroke-width:2px; style M fill:#eef,stroke:#333,stroke-width:2px; style N fill:#eef,stroke:#333,stroke-width:2px; style O fill:#eef,stroke:#333,stroke-width:2px; style P fill:#cfc,stroke:#333,stroke-width:2px;

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 the Content object itself.
  • ContentTree: This struct holds a BTreeMap of CourseItems, keyed by course_id. BTreeMap is 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 processed Content items:
    • It first attempts to identify and populate CourseItems using _index.md files within content/courses/<course_id>/.
    • Then, it processes other chapters/modules, associating them with their respective courses and modules based on course_id and module_id in their frontmatter.
    • BTreeMaps are used for chapters and modules to maintain order based on chapter_order and weight (for modules).
    • Placeholder logic is included to handle cases where a chapter is found before its corresponding _index.md for a course or module.
  • 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.html
  • templates/course_page.html
  • templates/chapter_page.html
  • Ensure you have a templates/base.html that 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>&copy; 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">&larr; {{ prev_chapter.title }}</a>
            {% else %}
            <span>&larr; No Previous Chapter</span>
            {% endif %}

            {% if next_chapter %}
            <a href="{{ next_chapter.url }}" class="next-chapter">{{ next_chapter.title }} &rarr;</a>
            {% else %}
            <span>No Next Chapter &rarr;</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 entire content_tree into the Tera context, making it available to all templates.
  • Course-Specific Data: If a content item has a course_id, we fetch its prev_chapter, next_chapter, and the parent course (using the new ContentTree methods) and insert them into the context. This allows chapter_page.html to 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 a Content object (like our course_list.html which is generated from content/courses/_index.md but needs to render a list of all courses, not just its own content). This function also ensures the content_tree is 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_tree module import: We explicitly import our new module.
  • BuildContext creation: The BuildContext is now responsible for building the ContentTree from all_content.
  • Rendering Loop: The main loop iterates through build_context.all_content (which is now owned by BuildContext) and calls renderer.render_content_page for each.
  • Custom Page Rendering: A specific block is added to handle the /courses/ index page. It finds the Content object for /courses/_index.md and uses renderer.render_custom_page to render it, ensuring the content_tree is available for course_list.html to display all courses.

c) Testing This Component

  1. 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.rs to include copy_static_assets call:

    // 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(())
    
  2. Run the SSG: Execute cargo run.

  3. Inspect Output:

    • Open public/courses/index.html in 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.

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

  1. Run cargo run.
  2. Navigate to the public/courses/rust-basics/01-introduction/01-what-is-rust/index.html page.
  3. 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 ContentTree generation 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 }} assumes pulldown-cmark has already sanitized the HTML.
  • Logging and Monitoring: Comprehensive logging (as implemented with log and env_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: Added course_id, module_id, chapter_order, chapter_title fields.
    • src/build_context.rs: Added content_tree to the build context and initialized it.
    • src/renderer.rs: Modified render_content_page to pass content_tree, course, prev_chapter, next_chapter to templates, and added render_custom_page.
    • src/main.rs: Updated the build pipeline to create the BuildContext, use the Renderer for all content, and explicitly render the /courses/ index page. Also added static asset copying.
  • Integration: The ContentTree is built early in the main function and stored in BuildContext. The Renderer then 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

  1. Issue: Incorrect Chapter Ordering / Missing Chapters in Navigation

    • Cause: Missing chapter_order or weight in frontmatter, or duplicate chapter_order values within the same module. BTreeMap uses the key for sorting; if it’s None or duplicated, behavior might be unexpected.
    • Debugging:
      • Check src/main.rs temporary log::info!("{:#?}", build_context.content_tree); output to see the exact structure and order detected by the SSG.
      • Verify chapter_order (for chapters) and weight (for modules/courses) fields in your Markdown frontmatter.
    • Solution: Ensure all chapters have unique chapter_order values within their module, and modules/courses have appropriate weight values. Provide a default chapter_order = 0 if not specified.
  2. Issue: Broken Navigation Links (404 errors)

    • Cause: Mismatch between permalink generation and ContentTree’s stored URLs, or incorrect template variables.
    • Debugging:
      • Check the generated HTML for href attributes on navigation links. Do they match the expected output paths?
      • Verify that content.permalink in src/content_tree.rs and src/renderer.rs is correctly derived and consistent.
      • Ensure the output_file_path logic in src/renderer.rs correctly appends index.html for directory URLs.
    • Solution: Double-check your slug and permalink generation logic in src/content.rs and the output_path construction in src/renderer.rs. Ensure base_url is correctly configured.
  3. Issue: Template Rendering Errors (e.g., “Variable ‘course’ not found”)

    • Cause: The Renderer is not correctly inserting the expected variables into the Tera context for a specific template.
    • Debugging:
      • Add log::debug!("Context for {}: {:#?}", template_name, context); in src/renderer.rs before tera.render(). This will show you exactly what variables are available.
      • Check the if content.front_matter.course_id.is_some() block in render_content_page to ensure the logic for inserting course, prev_chapter, next_chapter is correctly triggered.
    • Solution: Ensure the Renderer’s logic for populating the Context matches the variables expected by your Tera templates. Use {% if variable_name %} in templates to conditionally render sections if variables might be absent.

Testing & Verification

  1. Clean and Build: Run cargo run from your project root. This will clean the public directory, re-parse all content, build the content tree, and render all pages.
  2. Serve Statically: Use a simple HTTP server to serve the public directory (e.g., python3 -m http.server -d public or npx serve public).
  3. Verify Homepage: Navigate to / (your site’s root). It should render your _index.md (if you have one) or a default.
  4. Verify Course List: Navigate to /courses/. You should see the course_list.html template rendered, displaying your “Rust Basics Course”.
  5. Verify Course Overview: Click on “Rust Basics Course”. You should land on /courses/rust-basics/, seeing the course_page.html template, with a sidebar listing modules and chapters.
  6. Verify Chapter Pages: Click on any chapter, e.g., “What is Rust?”. You should see the chapter_page.html template, displaying the chapter content, the course sidebar, and functional “Previous” and “Next” chapter navigation links.
  7. Verify Component Integration: Check the chapter with the Quiz component to ensure it’s rendered correctly.
  8. Check Static Assets: Ensure your styles.css is 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.