Welcome to Chapter 6 of our journey to build a modern Static Site Generator in Rust! In this chapter, we’re going to significantly enhance the flexibility and power of our SSG by introducing component-driven rendering and a custom Markdown syntax to embed these components directly into our content. This approach, inspired by modern frameworks like Astro, allows content creators to inject dynamic, reusable UI elements without writing raw HTML, and it sets the stage for future features like partial hydration.

The ability to define and use components within Markdown is crucial for building rich, interactive content. Imagine embedding a dynamic “Call to Action” button, a “Code Sandbox” viewer, or a “User Testimonial” block directly into your blog posts or documentation pages, all while keeping your content clean and maintainable. This chapter will guide you through designing a custom syntax for these components, parsing them from Markdown, and rendering them into static HTML using our existing Tera templating engine. We’ll focus on creating a robust system that can handle component properties and integrate seamlessly into our existing build pipeline.

By the end of this chapter, our SSG will be able to recognize and render custom components embedded in Markdown files. You’ll have a foundational understanding of how to extend Markdown’s capabilities to support rich, component-based content, making our SSG truly powerful and adaptable for various use cases. We will build this incrementally, ensuring each piece is testable and robust.

Planning & Design

Implementing component-driven rendering requires careful planning, especially when integrating with an existing Markdown parser. Our goal is to allow a syntax similar to JSX within Markdown, like <<ComponentName prop1="value1" prop2={some_data} />>.

Component Architecture

  1. Custom Markdown Syntax: We’ll define a unique syntax to identify components within Markdown. We’ll use double angle brackets <<Component ... />> to avoid conflicts with standard Markdown and Tera’s {{ }} syntax.
  2. Component Definition: Components will be defined as Tera templates, living in a dedicated components directory (e.g., templates/components/). Each component will receive a HashMap of properties (props) that it can use within its template.
  3. Parsing Strategy:
    • Pre-parsing: Before feeding Markdown to pulldown-cmark, we’ll scan the raw Markdown string for our custom component syntax.
    • Extraction & Replacement: When a component is found, we’ll parse its name and properties into a ComponentInvocation struct. The original component syntax in the Markdown will be replaced with a unique HTML comment placeholder (e.g., <!--COMPONENT_PLACEHOLDER_{UUID}-->).
    • Markdown to HTML: pulldown-cmark will then process the modified Markdown, generating HTML that includes our placeholders.
    • Post-processing HTML: After pulldown-cmark has done its work, we’ll iterate through the generated HTML. For each placeholder, we’ll retrieve the corresponding ComponentInvocation and render the actual component’s Tera template, injecting its generated HTML back into the document.
  4. Component Registry: A central place (e.g., a HashMap) to map component names to their rendering logic or template paths. For now, it will implicitly map to templates/components/{name}.html.
  5. Props Handling: Properties will be parsed from the custom syntax. String literals will be direct strings. We’ll support basic value types (strings, numbers, booleans) for now, parsed as serde_json::Value.

Architecture Diagram: Component Rendering Pipeline

flowchart TD A["Raw Markdown Content"] --> B{"Pre-process Markdown"}; B -->|Detect <<Component />>| C["Parse Component Invocations"]; C --> D["Generate Unique Placeholders"]; D --> E["Modified Markdown with Placeholders"]; E --> F["pulldown-cmark Parser"]; F --> G["Raw HTML with Placeholders"]; G --> H{"Post-process HTML"}; H -->|Find Placeholders| I["Retrieve Component Invocation"]; I --> J["Lookup Component Template (Tera)"]; J --> K["Render Component with Props"]; K --> L["Replace Placeholder with Rendered HTML"]; L --> M["Final Rendered HTML"]; subgraph "Component Definition" N["templates/components/greeting.html"] end subgraph "Data Structures" O["ComponentInvocation {name, props: HashMap<String, Value>}"]; P["ComponentRegistry"]; end C --> O; I --> O; J --> N;

File Structure Updates

We’ll introduce a new components directory within our templates folder.

.
├── src/
│   ├── main.rs
│   ├── content_parser.rs # Modified to handle component pre-processing
│   ├── renderer.rs       # Modified to handle component post-processing
│   └── ...
├── templates/
│   ├── base.html
│   ├── page.html
│   ├── components/       # NEW: Directory for component templates
│   │   └── greeting.html # NEW: Example component template
│   └── ...
├── content/
│   └── my-post.md        # Modified to include custom components
└── Cargo.toml

Step-by-Step Implementation

3.1 Setup/Configuration

First, let’s ensure our Cargo.toml has the necessary dependencies. We’ll need regex for parsing our custom syntax and serde_json for handling component properties.

Cargo.toml

# ... existing dependencies ...

[dependencies]
# ... other dependencies like pulldown-cmark, tera, serde, serde_yaml, etc. ...
regex = "1.10" # For parsing custom component syntax
serde_json = "1.0" # For handling component properties as JSON values
uuid = { version = "1.7", features = ["v4", "fast-rng", "macro-diagnostics"] } # For unique placeholders

# ... rest of Cargo.toml ...

Run cargo build to fetch the new dependencies.

3.2 Core Implementation

We’ll start by defining the data structures for component invocations and then integrate the parsing and rendering logic into our existing pipeline.

a) Define ComponentInvocation Struct

This struct will hold the parsed information about a component embedded in Markdown.

Create a new file src/components.rs to encapsulate component-related logic.

src/components.rs

use std::collections::HashMap;
use serde_json::Value; // To represent component properties

/// Represents a component invocation found in Markdown content.
#[derive(Debug, Clone)]
pub struct ComponentInvocation {
    pub name: String,
    pub props: HashMap<String, Value>,
    pub placeholder_uuid: String, // Unique ID for replacement in HTML
}

impl ComponentInvocation {
    pub fn new(name: String, props: HashMap<String, Value>, placeholder_uuid: String) -> Self {
        ComponentInvocation {
            name,
            props,
            placeholder_uuid,
        }
    }
}

// Re-export for easier access
pub use ComponentInvocation;

Now, we need to make src/main.rs aware of this new module. Add mod components; to src/main.rs.

b) Custom Markdown Parser Integration - Pre-processing

We’ll modify our ContentProcessor (or a similar module responsible for parsing content) to detect and extract component invocations before pulldown-cmark processes the Markdown.

First, let’s update src/content_parser.rs (or src/processor.rs if that’s where your main content processing logic resides). We’ll assume src/content_parser.rs for this example.

src/content_parser.rs (Add imports and the component parsing function)

use std::collections::HashMap;
use pulldown_cmark::{Parser, Options, html};
use regex::Regex;
use serde_json::Value;
use uuid::Uuid;

use crate::config::SiteConfig;
use crate::frontmatter::{FrontMatter, parse_frontmatter};
use crate::error::SiteError; // Assuming you have an error module
use crate::components::ComponentInvocation; // Import our new struct

// ... (existing structs like `Page` or `Content`) ...

#[derive(Debug)]
pub struct ProcessedContent {
    pub front_matter: FrontMatter,
    pub html_content: String,
    pub component_invocations: Vec<ComponentInvocation>, // Store extracted components
}

/// Parses Markdown content, extracts front matter, and processes custom components.
///
/// This function now performs a two-pass approach for components:
/// 1. Pre-processes the Markdown to find custom component syntax (e.g., `<<ComponentName ... />>`).
/// 2. Replaces these with unique HTML comment placeholders and stores the component details.
/// 3. Parses the modified Markdown to HTML using `pulldown-cmark`.
pub fn parse_markdown_with_components(markdown_input: &str) -> Result<ProcessedContent, SiteError> {
    let (front_matter, content_without_fm) = parse_frontmatter(markdown_input)?;

    // Regex to find custom component syntax: <<ComponentName prop="value" prop2=123 />>
    // This regex is simplified and might need refinement for complex cases (e.g., nested quotes, array props)
    // For now, it captures component name and key-value pairs.
    // It assumes properties are simple string literals or numbers.
    let component_regex = Regex::new(r"<<([A-Za-z0-9_]+)\s*([^>]*)/>>")
        .map_err(|e| SiteError::ParseError(format!("Failed to compile component regex: {}", e)))?;
    let prop_regex = Regex::new(r#"(\w+)=(?:"([^"]*)"|(\w+))"#) // Captures prop="value" or prop=value
        .map_err(|e| SiteError::ParseError(format!("Failed to compile prop regex: {}", e)))?;

    let mut content_with_placeholders = content_without_fm.to_string();
    let mut component_invocations: Vec<ComponentInvocation> = Vec::new();

    // Iterate matches in reverse to avoid issues with index changes after replacement
    for mat in component_regex.find_iter(&content_without_placeholders).rev() {
        let full_match = mat.as_str();
        let component_name = component_regex.captures(full_match)
            .and_then(|caps| caps.get(1))
            .ok_or_else(|| SiteError::ParseError(format!("Failed to extract component name from: {}", full_match)))?
            .as_str()
            .to_string();

        let props_str = component_regex.captures(full_match)
            .and_then(|caps| caps.get(2))
            .ok_or_else(|| SiteError::ParseError(format!("Failed to extract props string from: {}", full_match)))?
            .as_str();

        let mut props: HashMap<String, Value> = HashMap::new();
        for prop_mat in prop_regex.find_iter(props_str) {
            let key = prop_mat.get(1).unwrap().as_str().to_string();
            let value_str = if let Some(quoted_val) = prop_mat.get(2) {
                quoted_val.as_str().to_string() // Quoted string
            } else if let Some(unquoted_val) = prop_mat.get(3) {
                unquoted_val.as_str().to_string() // Unquoted value (number, bool, etc.)
            } else {
                continue; // Should not happen with current regex
            };

            // Attempt to parse value as JSON, otherwise treat as string
            let parsed_value = serde_json::from_str(&value_str)
                .unwrap_or_else(|_| Value::String(value_str)); // Default to string if not valid JSON

            props.insert(key, parsed_value);
        }

        let placeholder_uuid = Uuid::new_v4().to_string();
        let placeholder = format!("<!--COMPONENT_PLACEHOLDER_{}-->", placeholder_uuid);

        // Replace the component syntax with the placeholder
        content_with_placeholders.replace_range(mat.range(), &placeholder);

        component_invocations.push(ComponentInvocation::new(
            component_name,
            props,
            placeholder_uuid,
        ));
    }

    // Now parse the Markdown with placeholders to HTML
    let mut options = Options::empty();
    options.insert(Options::ENABLE_TABLES);
    options.insert(Options::ENABLE_FOOTNOTES);
    options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
    // Add other desired Markdown options here

    let parser = Parser::new_ext(&content_with_placeholders, options);
    let mut html_output = String::new();
    html::push_html(&mut html_output, parser);

    Ok(ProcessedContent {
        front_matter,
        html_content: html_output,
        component_invocations,
    })
}

// You might need to adjust your main processing loop to use `ProcessedContent`
// For example, in `src/main.rs` or a `build_site` function:
// let processed_content = parse_markdown_with_components(&raw_markdown)?;
// Then pass `processed_content.html_content` and `processed_content.component_invocations`
// to the renderer.

Explanation:

  1. Imports: We add regex, serde_json::Value, and uuid for our new functionality.
  2. ProcessedContent struct: This new struct bundles the front_matter, the html_content (which now contains placeholders), and the component_invocations (the parsed details of our components).
  3. Component Regex: We define a regex <<([A-Za-z0-9_]+)\s*([^>]*)/>> to capture the component name and its properties string.
  4. Property Regex: A second regex (\w+)=(?:"([^"]*)"|(\w+)) is used to parse individual key="value" or key=value pairs from the properties string. This handles basic string literals (quoted) and unquoted values (which we’ll try to parse as JSON).
  5. Reverse Iteration: We iterate over regex matches in reverse order. This is a crucial optimization when modifying the string being iterated over, as it prevents indices from shifting and invalidating subsequent matches.
  6. UUID Placeholder: For each component found, a unique UUID is generated. This UUID is embedded in an HTML comment placeholder (<!--COMPONENT_PLACEHOLDER_{UUID}-->) which replaces the original component syntax in the Markdown.
  7. ComponentInvocation Storage: The parsed component name, its properties (HashMap<String, Value>), and the generated placeholder_uuid are stored in a Vec<ComponentInvocation>.
  8. pulldown-cmark Processing: The Markdown with placeholders is then passed to pulldown-cmark as usual, generating HTML.
  9. Return ProcessedContent: The function now returns ProcessedContent, which includes the list of ComponentInvocation objects.
c) Component Template (Tera)

Let’s create a sample component template. This will live in templates/components/greeting.html.

templates/components/greeting.html

<div class="greeting-component">
    {% if name %}
        <p>Hello, {{ name | default(value="Guest") }}!</p>
    {% else %}
        <p>Hello there!</p>
    {% endif %}
    {% if message %}
        <p class="message">{{ message }}</p>
    {% endif %}
    <button onclick="alert('Greetings from {{ name | default(value="Guest") }}!')">Say Hi</button>
</div>

Explanation:

  • This is a standard Tera template.
  • It expects name and message variables to be passed as context (props).
  • It uses Tera’s if and default filters for robustness.
  • Includes a simple JavaScript onclick for demonstration, hinting at future hydration.
d) Rendering Logic - Post-processing HTML

Now, we need to modify our main rendering logic (likely in src/renderer.rs or src/main.rs) to perform the post-processing step: finding the placeholders and replacing them with the actual rendered component HTML.

Let’s assume you have a render_page function that takes html_content and a Tera instance. We’ll modify it to also accept the component_invocations.

src/renderer.rs (or where your main page rendering logic is)

use tera::{Context, Tera};
use std::collections::HashMap;
use crate::error::SiteError;
use crate::components::ComponentInvocation; // Import ComponentInvocation

// Assuming you have a `render_template` or `render_page` function
pub fn render_page_with_components(
    html_with_placeholders: String,
    component_invocations: Vec<ComponentInvocation>,
    tera: &Tera,
    page_context: &mut Context, // Context for the overall page template
) -> Result<String, SiteError> {
    let mut final_html = html_with_placeholders;

    // Iterate through component invocations and replace placeholders
    for invocation in component_invocations {
        let placeholder = format!("<!--COMPONENT_PLACEHOLDER_{}-->", invocation.placeholder_uuid);
        
        // Prepare context for the component template
        let mut component_context = Context::new();
        for (key, value) in invocation.props {
            // Tera's Context::insert expects a serializable type.
            // serde_json::Value is perfect for this.
            component_context.insert(key, &value)?;
        }

        // The component template path will be `components/{name}.html`
        let template_path = format!("components/{}.html", invocation.name.to_lowercase());

        // Render the component template
        let rendered_component_html = tera.render(&template_path, &component_context)
            .map_err(|e| SiteError::TemplateError(format!(
                "Failed to render component '{}' at path '{}': {}",
                invocation.name, template_path, e
            )))?;

        // Replace the placeholder in the final HTML
        final_html = final_html.replace(&placeholder, &rendered_component_html);
    }

    // Insert the final_html (with components rendered) into the page context
    // This assumes your main page template expects a `content` variable.
    page_context.insert("content", &final_html);

    // Render the main page template (e.g., `page.html` or `base.html`)
    // You might need to adjust this depending on how your main rendering works.
    let page_template_name = page_context.get("template")
        .and_then(|v| v.as_str())
        .unwrap_or("page.html"); // Default page template

    tera.render(page_template_name, page_context)
        .map_err(|e| SiteError::TemplateError(format!("Failed to render page template '{}': {}", page_template_name, e)))
}

Explanation:

  1. render_page_with_components Function: This new function takes the HTML with placeholders, the list of ComponentInvocations, the Tera instance, and the overall page context.
  2. Iterate Invocations: It loops through each ComponentInvocation.
  3. Create Component Context: For each component, it creates a new Tera::Context and populates it with the props from the ComponentInvocation. serde_json::Value is directly compatible with Tera’s context insertion.
  4. Determine Template Path: The component template is assumed to be in templates/components/{component_name.to_lowercase()}.html.
  5. Render Component: tera.render is called for the specific component template and its context.
  6. Replace Placeholder: The final_html string is updated by replacing the unique placeholder with the rendered_component_html.
  7. Final Page Rendering: After all components are rendered and placeholders replaced, the final_html (which is now the complete content HTML) is inserted into the main page_context (e.g., as the content variable). Finally, the main page template (e.g., page.html) is rendered with the updated page_context.
e) Integrate into Main Build Process

Now, we need to wire these new functions into our main SSG build loop, typically found in src/main.rs or a build_site function.

src/main.rs (Illustrative changes to your build loop)

use std::path::{Path, PathBuf};
use tera::{Tera, Context};
use std::fs;

mod config;
mod frontmatter;
mod error;
mod content_parser; // Our updated content parser
mod renderer;       // Our updated renderer
mod components;     // New components module

use config::SiteConfig;
use error::SiteError;
use content_parser::{parse_markdown_with_components, ProcessedContent}; // Import ProcessedContent

fn main() -> Result<(), SiteError> {
    // 1. Load configuration
    let config = SiteConfig::load("config.toml")?;

    // 2. Initialize Tera
    let mut tera = Tera::new("templates/**/*.html")
        .map_err(|e| SiteError::TemplateError(format!("Failed to initialize Tera: {}", e)))?;
    // Automatically reload templates in development if you have that feature
    tera.autoescape_on(vec![".html"]); // Ensure HTML is escaped by default

    // 3. Prepare output directory
    let output_dir = PathBuf::from(&config.output_dir);
    if output_dir.exists() {
        fs::remove_dir_all(&output_dir)
            .map_err(|e| SiteError::IoError(format!("Failed to clean output directory: {}", e)))?;
    }
    fs::create_dir_all(&output_dir)
        .map_err(|e| SiteError::IoError(format!("Failed to create output directory: {}", e)))?;

    // 4. Process content files
    let content_dir = PathBuf::from(&config.content_dir);
    for entry in walkdir::WalkDir::new(&content_dir) {
        let entry = entry.map_err(|e| SiteError::IoError(format!("WalkDir error: {}", e)))?;
        let path = entry.path();

        if path.is_file() && path.extension().map_or(false, |ext| ext == "md") {
            println!("Processing content file: {:?}", path);
            let raw_markdown = fs::read_to_string(path)
                .map_err(|e| SiteError::IoError(format!("Failed to read file {:?}: {}", path, e)))?;

            // Use the new parsing function
            let processed_content = parse_markdown_with_components(&raw_markdown)?;

            // Prepare context for the page
            let mut page_context = Context::new();
            page_context.insert("config", &config)?;
            page_context.insert("page", &processed_content.front_matter)?;
            // Add other global context variables as needed

            // Determine output path (simplified for this example)
            let relative_path = path.strip_prefix(&content_dir)
                .map_err(|e| SiteError::IoError(format!("Path strip prefix error: {}", e)))?;
            let output_file_name = relative_path.with_extension("html");
            let output_file_path = output_dir.join(&output_file_name);

            // Ensure parent directories exist for the output file
            if let Some(parent) = output_file_path.parent() {
                fs::create_dir_all(parent)
                    .map_err(|e| SiteError::IoError(format!("Failed to create parent directories for {:?}: {}", output_file_path, e)))?;
            }

            // Render the page with components
            let final_rendered_html = renderer::render_page_with_components(
                processed_content.html_content,
                processed_content.component_invocations,
                &tera,
                &mut page_context,
            )?;

            fs::write(&output_file_path, final_rendered_html)
                .map_err(|e| SiteError::IoError(format!("Failed to write output file {:?}: {}", output_file_path, e)))?;
        }
    }

    println!("Site built successfully to '{}'", config.output_dir);
    Ok(())
}

Explanation:

  1. Imports: We import our new components module and the ProcessedContent struct.
  2. parse_markdown_with_components Call: Inside the main content processing loop, we now call parse_markdown_with_components which handles both frontmatter and component extraction.
  3. render_page_with_components Call: The html_content (with placeholders) and component_invocations from processed_content are passed to our new renderer::render_page_with_components function, which performs the final rendering and placeholder replacement.
  4. Output: The fully rendered HTML is then written to the output file.

3.3 Testing This Component

Let’s create a sample Markdown file to test our new component rendering.

content/my-post.md

+++
title = "My First Post with Components"
date = 2026-03-02
template = "page.html"
+++

# Welcome to My Component-Powered Post

This is a regular paragraph in Markdown. We can now embed custom components directly!

Here's a simple greeting:

<<Greeting name="Alice" message="Hope you have a great day!" />>

And another greeting without a specific name, using default:

<<Greeting message="Welcome to our site!" />>

You can also pass numbers or booleans (though our current regex is simple for those):

<<ExampleComponent count=5 isActive=true />>

This demonstrates how powerful custom components can be.

To Test:

  1. Ensure you have a basic templates/page.html that includes a {{ content | safe }} block. templates/page.html (Example)

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{{ page.title }} - {{ config.site_name }}</title>
        <link rel="stylesheet" href="/css/style.css"> {# Example stylesheet #}
    </head>
    <body>
        <header>
            <h1>{{ page.title }}</h1>
        </header>
        <main>
            {{ content | safe }} {# THIS IS WHERE YOUR PROCESSED MARKDOWN/COMPONENTS GO #}
        </main>
        <footer>
            <p>&copy; 2026 {{ config.site_name }}</p>
        </footer>
    </body>
    </html>
    
  2. Also, create a dummy templates/components/examplecomponent.html for <<ExampleComponent />> to avoid a Tera template error.

    templates/components/examplecomponent.html

    <div class="example-component">
        <p>Example Component:</p>
        {% if count %}<p>Count: {{ count }}</p>{% endif %}
        {% if is_active %}<p>Active: {{ is_active }}</p>{% endif %}
    </div>
    
  3. Run your SSG: cargo run

  4. Check the generated HTML file in your public (or configured output) directory (e.g., public/my-post.html).

  5. Verify that the <<Greeting ... />> and <<ExampleComponent ... />> syntax has been replaced by the rendered HTML from their respective Tera templates.

Debugging Tips:

  • If components don’t render, check the target/debug output for SiteError::TemplateError messages.
  • Print processed_content.component_invocations after parse_markdown_with_components to ensure your components are being correctly parsed and extracted.
  • Inspect the final_html string in render_page_with_components before writing to file to see if placeholders are still present or if component HTML is malformed.
  • Ensure your Tera template paths (components/{name}.html) are correct and the files exist. Component names are lowercased for template lookup.

Production Considerations

  1. Error Handling:

    • Missing Component Template: Our renderer::render_page_with_components function already includes error handling for tera.render failures, which will catch missing templates. It’s crucial to log these errors clearly so developers know which component is failing.
    • Invalid Props: If a component expects a specific prop type (e.g., a number) but receives a string, Tera might handle it gracefully or throw an error depending on the filter used. More robust validation could be implemented within the component’s Tera template or even by defining a schema for component props. For now, serde_json::Value offers flexibility.
    • Malformed Syntax: The regex-based parsing is robust for the defined syntax but can be brittle. Add more specific error messages for regex parsing failures.
  2. Performance Optimization:

    • Regex Overhead: Running regexes on potentially large Markdown files can be slow. For extremely large sites, consider pre-compiling regexes once (which we are doing) and potentially optimizing the search for components (e.g., only searching within blocks that could contain them).
    • Tera Caching: Tera automatically caches parsed templates. Ensure your Tera instance is initialized once and reused across all rendering operations to leverage this.
    • Component Caching: If components are truly static and receive the same props repeatedly, their rendered output could be cached based on a hash of their name and props. This is an advanced optimization for future chapters (incremental builds).
  3. Security Considerations:

    • XSS via Props: If component props can come from untrusted sources (e.g., user-generated content), ensure that these props are properly sanitized before being inserted into Tera contexts or directly rendered. Tera’s autoescaping helps, but if you use |safe filter, you must be extremely cautious. Our current approach takes props from Markdown, which is controlled by the content author, reducing immediate XSS risk, but it’s a good practice to be aware of.
    • File System Access: Ensure component templates cannot access arbitrary files outside the templates directory, which Tera inherently handles.
  4. Logging and Monitoring:

    • Implement structured logging for component parsing success/failure, rendering times, and any warnings (e.g., unused props, unknown components). This helps debug build issues and monitor performance.

Code Review Checkpoint

At this point, we’ve made significant architectural changes:

  • New Files:
    • src/components.rs: Defines the ComponentInvocation struct.
    • templates/components/greeting.html: An example component template.
    • templates/components/examplecomponent.html: Another example component template.
  • Modified Files:
    • Cargo.toml: Added regex, serde_json, uuid dependencies.
    • src/content_parser.rs: Now includes parse_markdown_with_components for pre-processing Markdown, extracting component invocations, and replacing them with placeholders. Introduced ProcessedContent struct.
    • src/renderer.rs: Added render_page_with_components to post-process HTML, find placeholders, render component templates, and inject their HTML.
    • src/main.rs: Updated the main build loop to use the new parse_markdown_with_components and render_page_with_components functions.
  • Key Integration Points: The content_parser now hands off both parsed Markdown HTML (with placeholders) and a list of component invocations to the renderer, which then completes the component rendering.

This setup provides a powerful mechanism for embedding reusable UI components directly into Markdown content, significantly enhancing content flexibility.

Common Issues & Solutions

  1. Issue: Component syntax not recognized or parsed incorrectly.

    • Symptom: The <<Component ... />> syntax remains in the output HTML, or the component_invocations vector is empty when printed.
    • Debugging:
      • Double-check the component_regex in src/content_parser.rs. Regexes are finicky. Test your regex with an online tool.
      • Ensure the regex crate is correctly imported and used.
      • Verify that parse_markdown_with_components is being called with the correct markdown_input.
      • Add println! statements inside the for mat in component_regex.find_iter(...) loop to see if matches are being found.
    • Prevention: Use clear, distinct syntax. Thoroughly test regex patterns with various valid and invalid inputs.
  2. Issue: Component properties are not passed or rendered correctly.

    • Symptom: Component templates show “None” for props, or values are incorrect.
    • Debugging:
      • Print invocation.props inside render_page_with_components to see what values are being received.
      • Verify the prop_regex in src/content_parser.rs correctly extracts keys and values.
      • Check how serde_json::from_str is used; if a value isn’t valid JSON, it defaults to a string, which might not be what the Tera template expects for numbers/booleans.
      • Ensure prop names in Markdown (e.g., name="Alice") match the variable names used in the Tera template (e.g., {{ name }}).
    • Prevention: Standardize prop naming conventions. Provide clear documentation for component authors on expected prop types and how they are parsed.
  3. Issue: Tera template not found for a component.

    • Symptom: SiteError::TemplateError with a message like “Template ‘components/greeting.html’ not found.”
    • Debugging:
      • Verify the exact path in the error message matches the actual file path in your templates/components/ directory. Remember component names are lowercased for template lookup.
      • Ensure the component template file exists and has the correct .html extension.
      • Check that your Tera::new("templates/**/*.html") pattern correctly includes the components subdirectory.
    • Prevention: Use consistent naming conventions. Provide a clear error message that includes the expected template path.

Testing & Verification

To thoroughly test and verify the component rendering functionality:

  1. Create diverse content files:

    • content/post-with-greeting.md: Includes <<Greeting name="World" />>
    • content/post-with-multiple-components.md: Includes several <<Greeting />> and <<ExampleComponent />> instances with different props.
    • content/post-with-mixed-content.md: Includes components mixed with regular Markdown elements (lists, code blocks, images).
    • content/post-with-missing-component.md: Includes <<NonExistentComponent />> to test error handling.
    • content/post-with-malformed-component.md: Includes <<Greeting name="John /> (missing closing quote) to test parsing robustness.
  2. Run the build: Execute cargo run after creating these files.

  3. Inspect generated HTML:

    • Navigate to your public directory.
    • Open each generated HTML file in a browser or text editor.
    • Verify Component Output:
      • Ensure <!--COMPONENT_PLACEHOLDER_UUID--> comments are gone.
      • Check that the HTML output from greeting.html and examplecomponent.html is present in the correct locations.
      • Verify that props (like name, message, count, is_active) are correctly rendered within the component’s HTML.
      • Check for any SiteError messages in your console output, especially for the NonExistentComponent and malformed-component cases.
  4. Edge Case Verification:

    • Confirm that NonExistentComponent generates a SiteError::TemplateError because its template is not found.
    • Observe how malformed-component.md is handled. Our current regex might fail to parse it, leaving the original syntax in the Markdown, or it might parse partially. This highlights areas for improvement in the regex.

If all checks pass, you’ve successfully implemented component-driven rendering with custom Markdown syntax!

Summary & Next Steps

In this chapter, we’ve built a crucial feature for any modern SSG: the ability to embed and render reusable components directly within Markdown content. We designed a custom <<Component ... />> syntax, implemented a two-pass parsing strategy using regex and pulldown-cmark, and integrated Tera for rendering these components. This significantly increases the flexibility and maintainability of our content.

We now have a robust system that can:

  • Parse front matter and Markdown.
  • Extract custom component invocations from Markdown.
  • Render these components using Tera templates, passing dynamic properties.
  • Integrate the rendered components back into the final HTML output.

This component system lays the groundwork for even more advanced features. In the next chapter, Chapter 7: Introducing Partial Hydration for Interactive Components, we will take these static components and make them interactive by exploring how to bundle client-side JavaScript (or WebAssembly with Yew/Leptos) and “hydrate” specific components, bringing dynamic behavior to our otherwise static pages. This will bridge the gap between static content and modern web application interactivity, much like Astro’s island architecture.