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
- 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. - Component Definition: Components will be defined as Tera templates, living in a dedicated
componentsdirectory (e.g.,templates/components/). Each component will receive aHashMapof properties (props) that it can use within its template. - 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
ComponentInvocationstruct. 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-cmarkwill then process the modified Markdown, generating HTML that includes our placeholders. - Post-processing HTML: After
pulldown-cmarkhas done its work, we’ll iterate through the generated HTML. For each placeholder, we’ll retrieve the correspondingComponentInvocationand render the actual component’s Tera template, injecting its generated HTML back into the document.
- Pre-parsing: Before feeding Markdown to
- 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 totemplates/components/{name}.html. - 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
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:
- Imports: We add
regex,serde_json::Value, anduuidfor our new functionality. ProcessedContentstruct: This new struct bundles thefront_matter, thehtml_content(which now contains placeholders), and thecomponent_invocations(the parsed details of our components).- Component Regex: We define a regex
<<([A-Za-z0-9_]+)\s*([^>]*)/>>to capture the component name and its properties string. - Property Regex: A second regex
(\w+)=(?:"([^"]*)"|(\w+))is used to parse individualkey="value"orkey=valuepairs from the properties string. This handles basic string literals (quoted) and unquoted values (which we’ll try to parse as JSON). - 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.
- 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. ComponentInvocationStorage: The parsed component name, its properties (HashMap<String, Value>), and the generatedplaceholder_uuidare stored in aVec<ComponentInvocation>.pulldown-cmarkProcessing: The Markdown with placeholders is then passed topulldown-cmarkas usual, generating HTML.- Return
ProcessedContent: The function now returnsProcessedContent, which includes the list ofComponentInvocationobjects.
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
nameandmessagevariables to be passed as context (props). - It uses Tera’s
ifanddefaultfilters for robustness. - Includes a simple JavaScript
onclickfor 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:
render_page_with_componentsFunction: This new function takes the HTML with placeholders, the list ofComponentInvocations, the Tera instance, and the overall page context.- Iterate Invocations: It loops through each
ComponentInvocation. - Create Component Context: For each component, it creates a new
Tera::Contextand populates it with thepropsfrom theComponentInvocation.serde_json::Valueis directly compatible with Tera’s context insertion. - Determine Template Path: The component template is assumed to be in
templates/components/{component_name.to_lowercase()}.html. - Render Component:
tera.renderis called for the specific component template and its context. - Replace Placeholder: The
final_htmlstring is updated by replacing the unique placeholder with therendered_component_html. - 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 mainpage_context(e.g., as thecontentvariable). Finally, the main page template (e.g.,page.html) is rendered with the updatedpage_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:
- Imports: We import our new
componentsmodule and theProcessedContentstruct. parse_markdown_with_componentsCall: Inside the main content processing loop, we now callparse_markdown_with_componentswhich handles both frontmatter and component extraction.render_page_with_componentsCall: Thehtml_content(with placeholders) andcomponent_invocationsfromprocessed_contentare passed to our newrenderer::render_page_with_componentsfunction, which performs the final rendering and placeholder replacement.- 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:
Ensure you have a basic
templates/page.htmlthat 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>© 2026 {{ config.site_name }}</p> </footer> </body> </html>Also, create a dummy
templates/components/examplecomponent.htmlfor<<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>Run your SSG:
cargo runCheck the generated HTML file in your
public(or configured output) directory (e.g.,public/my-post.html).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/debugoutput forSiteError::TemplateErrormessages. - Print
processed_content.component_invocationsafterparse_markdown_with_componentsto ensure your components are being correctly parsed and extracted. - Inspect the
final_htmlstring inrender_page_with_componentsbefore 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
Error Handling:
- Missing Component Template: Our
renderer::render_page_with_componentsfunction already includes error handling fortera.renderfailures, 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::Valueoffers 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.
- Missing Component Template: Our
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).
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
|safefilter, 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
templatesdirectory, which Tera inherently handles.
- 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
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 theComponentInvocationstruct.templates/components/greeting.html: An example component template.templates/components/examplecomponent.html: Another example component template.
- Modified Files:
Cargo.toml: Addedregex,serde_json,uuiddependencies.src/content_parser.rs: Now includesparse_markdown_with_componentsfor pre-processing Markdown, extracting component invocations, and replacing them with placeholders. IntroducedProcessedContentstruct.src/renderer.rs: Addedrender_page_with_componentsto post-process HTML, find placeholders, render component templates, and inject their HTML.src/main.rs: Updated the main build loop to use the newparse_markdown_with_componentsandrender_page_with_componentsfunctions.
- Key Integration Points: The
content_parsernow hands off both parsed Markdown HTML (with placeholders) and a list of component invocations to therenderer, 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
Issue: Component syntax not recognized or parsed incorrectly.
- Symptom: The
<<Component ... />>syntax remains in the output HTML, or thecomponent_invocationsvector is empty when printed. - Debugging:
- Double-check the
component_regexinsrc/content_parser.rs. Regexes are finicky. Test your regex with an online tool. - Ensure the
regexcrate is correctly imported and used. - Verify that
parse_markdown_with_componentsis being called with the correctmarkdown_input. - Add
println!statements inside thefor mat in component_regex.find_iter(...)loop to see if matches are being found.
- Double-check the
- Prevention: Use clear, distinct syntax. Thoroughly test regex patterns with various valid and invalid inputs.
- Symptom: The
Issue: Component properties are not passed or rendered correctly.
- Symptom: Component templates show “None” for props, or values are incorrect.
- Debugging:
- Print
invocation.propsinsiderender_page_with_componentsto see what values are being received. - Verify the
prop_regexinsrc/content_parser.rscorrectly extracts keys and values. - Check how
serde_json::from_stris 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 }}).
- Print
- Prevention: Standardize prop naming conventions. Provide clear documentation for component authors on expected prop types and how they are parsed.
Issue: Tera template not found for a component.
- Symptom:
SiteError::TemplateErrorwith 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
.htmlextension. - Check that your
Tera::new("templates/**/*.html")pattern correctly includes thecomponentssubdirectory.
- Verify the exact path in the error message matches the actual file path in your
- Prevention: Use consistent naming conventions. Provide a clear error message that includes the expected template path.
- Symptom:
Testing & Verification
To thoroughly test and verify the component rendering functionality:
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.
Run the build: Execute
cargo runafter creating these files.Inspect generated HTML:
- Navigate to your
publicdirectory. - 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.htmlandexamplecomponent.htmlis 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
SiteErrormessages in your console output, especially for theNonExistentComponentandmalformed-componentcases.
- Ensure
- Navigate to your
Edge Case Verification:
- Confirm that
NonExistentComponentgenerates aSiteError::TemplateErrorbecause its template is not found. - Observe how
malformed-component.mdis 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.
- Confirm that
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.