Welcome to Chapter 14! In this installment, we’ll elevate the usability of our static site generator by implementing powerful, client-side search capabilities. While our SSG is excellent for generating static content, a modern website often requires a way for users to quickly find specific information. We’ll integrate Pagefind, a fast and efficient search library designed specifically for static sites, to provide an intuitive search experience without needing a backend server.

This chapter will guide you through adding Pagefind to our build pipeline. We’ll learn how to configure our Rust SSG to execute Pagefind after all HTML content has been generated, allowing it to crawl our output directory and create a search index. Subsequently, we’ll integrate the necessary JavaScript and CSS assets into our Tera templates to enable the search interface on the frontend. By the end of this chapter, your generated site will feature a fully functional search bar, significantly improving content discoverability.

To follow along, ensure you have a working build of our SSG from Chapter 13, where we established the core rendering and output generation. We’ll be modifying the Builder structure and potentially some Tera templates. The expected outcome is a static website that, upon a rebuild, includes Pagefind’s search assets and allows users to search content directly within their browser.

Planning & Design

Integrating a tool like Pagefind into an SSG requires careful consideration of the build pipeline. Pagefind operates on already generated HTML files. This means it must run after our SSG has completed its primary task of rendering all content and writing it to the output directory (e.g., public/).

Our existing build process looks something like this:

  1. Load configuration.
  2. Scan content files.
  3. Parse content (frontmatter, Markdown to AST, component resolution).
  4. Render HTML using Tera templates.
  5. Write rendered HTML to the output directory.

We’ll insert the Pagefind execution as a post-processing step after step 5.

Component Architecture for Search Integration

The core changes will involve:

  1. Builder Structure: Adding a method or modifying the existing build method to orchestrate the Pagefind execution.
  2. Config Structure: Potentially adding Pagefind-specific configuration, like enabling/disabling it or specifying its executable path if not globally available.
  3. Frontend Templates: Modifying our base Tera template (e.g., base.html) to include Pagefind’s client-side assets (CSS and JavaScript) and a search input element.

Here’s a high-level overview of the updated build process:

flowchart TD A[Start Build Process] --> B[Load Configuration] B --> C[Scan Content Files] C --> D[Parse Content] D --> E[Render HTML Pages] E --> F[Write HTML to Output Dir] F --> G{Pagefind Enabled?} G -->|Yes| H[Execute Pagefind] H --> I[Generate Search Assets] I --> J[Build Complete] G -->|No| J

Pagefind Installation

Pagefind is an external command-line tool written in Rust, but we’ll interact with it via our SSG’s build process. You’ll need to install it globally or ensure it’s available in your project’s PATH.

Installation (via Cargo):

cargo install pagefind

Verify the installation:

pagefind --version

You should see a version number (e.g., pagefind v1.0.0).

Step-by-Step Implementation

First, let’s update our Builder and add configuration.

a) Setup/Configuration

We’ll add a new field to our Config struct to control Pagefind integration.

File: src/config.rs

// ... existing imports ...
use serde::Deserialize; // Add this import if not already present

#[derive(Debug, Deserialize, Clone)]
pub struct SiteConfig {
    pub base_url: String,
    pub title: String,
    // ... other fields ...
    #[serde(default = "default_pagefind_enabled")]
    pub pagefind_enabled: bool,
    #[serde(default = "default_pagefind_output")]
    pub pagefind_output_dir: String,
}

impl Default for SiteConfig {
    fn default() -> Self {
        SiteConfig {
            base_url: "http://localhost:8000".to_string(),
            title: "My Awesome Site".to_string(),
            // ... other default fields ...
            pagefind_enabled: default_pagefind_enabled(),
            pagefind_output_dir: default_pagefind_output(),
        }
    }
}

fn default_pagefind_enabled() -> bool {
    true // By default, enable Pagefind
}

fn default_pagefind_output() -> String {
    "pagefind".to_string() // Default output directory for Pagefind assets
}

// ... rest of config.rs ...

Explanation:

  • We added pagefind_enabled (defaulting to true) to allow users to turn Pagefind off if they don’t need it.
  • pagefind_output_dir specifies where Pagefind will place its generated assets within our public directory. This is useful for consistency and potential CDN configurations.
  • #[serde(default = "...")] ensures these fields have default values if not specified in config.toml.

Now, update your config.toml to reflect these new options (or rely on defaults):

File: config.toml

base_url = "http://localhost:8000"
title = "My Awesome SSG Site"
# ... other config ...

[pagefind]
enabled = true
output_dir = "pagefind" # This will be public/pagefind/

b) Core Implementation - Integrating Pagefind into the Build

Next, we’ll modify our Builder to execute the pagefind command. We’ll use Rust’s std::process::Command for this.

File: src/builder.rs

use std::process::Command; // Add this import

// ... other imports ...

impl Builder {
    // ... existing new, load_config, etc. methods ...

    pub async fn build(&self) -> Result<(), Box<dyn Error>> {
        info!("Starting site build...");

        // 1. Ensure output directory is clean
        let output_dir = &self.config.output_dir; // Assuming output_dir is a field in Config
        if output_dir.exists() {
            fs::remove_dir_all(output_dir)?;
            info!("Cleaned output directory: {}", output_dir.display());
        }
        fs::create_dir_all(output_dir)?;
        info!("Created output directory: {}", output_dir.display());

        // 2. Load content and render pages
        let site_data = self.load_site_data().await?; // Assuming this method exists and populates site data
        let rendered_pages = self.render_all_pages(&site_data).await?;

        // 3. Write rendered HTML to output directory
        for (path, content) in rendered_pages {
            let full_path = output_dir.join(&path);
            if let Some(parent) = full_path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::write(&full_path, content)?;
            debug!("Wrote page to: {}", full_path.display());
        }
        info!("Successfully rendered and wrote all pages.");

        // 4. Run Pagefind if enabled
        if self.config.site.pagefind_enabled {
            info!("Pagefind integration enabled. Running Pagefind...");
            self.run_pagefind().await?;
            info!("Pagefind execution complete.");
        } else {
            info!("Pagefind integration disabled.");
        }

        info!("Site build complete!");
        Ok(())
    }

    /// Executes the Pagefind CLI tool to generate a search index.
    async fn run_pagefind(&self) -> Result<(), Box<dyn Error>> {
        let output_dir = &self.config.output_dir; // Our main public/ directory
        let pagefind_output_dir = output_dir.join(&self.config.site.pagefind_output_dir); // public/pagefind/

        // Ensure Pagefind's output directory exists and is clean
        if pagefind_output_dir.exists() {
            fs::remove_dir_all(&pagefind_output_dir)?;
        }
        fs::create_dir_all(&pagefind_output_dir)?;

        let command_args = vec![
            "pagefind",
            "--source",
            output_dir.to_str().ok_or("Invalid output directory path")?,
            "--output-path",
            pagefind_output_dir.to_str().ok_or("Invalid Pagefind output path")?,
        ];

        info!("Executing command: `pagefind {}`", command_args[1..].join(" "));

        let output = Command::new(command_args[0])
            .args(&command_args[1..])
            .output()?;

        if output.status.success() {
            info!("Pagefind completed successfully.");
            if !output.stdout.is_empty() {
                debug!("Pagefind stdout: {}", String::from_utf8_lossy(&output.stdout));
            }
        } else {
            error!("Pagefind failed with error code: {:?}", output.status.code());
            if !output.stderr.is_empty() {
                error!("Pagefind stderr: {}", String::from_utf8_lossy(&output.stderr));
            }
            return Err("Pagefind execution failed".into());
        }

        Ok(())
    }

    // ... existing helper methods like render_all_pages, load_site_data, etc. ...
}

Explanation:

  • We added use std::process::Command; to interact with external commands.
  • The build method now includes a conditional call to self.run_pagefind() after all pages are written.
  • The run_pagefind asynchronous method constructs and executes the pagefind command.
  • It specifies --source as our SSG’s main output directory (e.g., public/) and --output-path as the configured pagefind_output_dir (e.g., public/pagefind/).
  • Error handling is included:
    • It checks output.status.success() to determine if the command ran without errors.
    • It logs stdout and stderr for debugging purposes.
    • It returns an Err if Pagefind fails.
  • Before running Pagefind, we clean and recreate its specific output directory to ensure a fresh index.

Pagefind generates a set of static assets (JavaScript, WASM, CSS) that need to be included in your site’s HTML to enable the search interface. We’ll modify our base Tera template.

File: templates/base.html (or your main layout template)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ site.title }} - {% if page.title %}{{ page.title }}{% else %}Home{% endif %}</title>
    <!-- Basic styling for demonstration -->
    <style>
        body { font-family: sans-serif; margin: 2em; line-height: 1.6; }
        nav { margin-bottom: 2em; }
        nav a { margin-right: 1em; }
        .pagefind-ui {
            margin-top: 2em;
            border-top: 1px solid #eee;
            padding-top: 1em;
        }
    </style>
    <!-- Pagefind CSS -->
    {% if site.pagefind_enabled %}
    <link href="/{{ site.pagefind_output_dir }}/pagefind-ui.css" rel="stylesheet">
    {% endif %}
</head>
<body>
    <header>
        <h1><a href="/">{{ site.title }}</a></h1>
        <nav>
            <a href="/">Home</a>
            <!-- Add other navigation links here -->
        </nav>
        <!-- Search Input (optional, Pagefind UI can generate its own) -->
        <div class="search-container">
            {% if site.pagefind_enabled %}
            <label for="search">Search:</label>
            <input type="text" id="search" placeholder="Search site content...">
            {% endif %}
        </div>
    </header>

    <main>
        {% block content %}{% endblock content %}
    </main>

    <footer>
        <p>&copy; 2026 {{ site.title }}</p>
    </footer>

    <!-- Pagefind JS -->
    {% if site.pagefind_enabled %}
    <script src="/{{ site.pagefind_output_dir }}/pagefind-ui.js" type="text/javascript"></script>
    <script>
        window.addEventListener('DOMContentLoaded', (event) => {
            new PagefindUI({
                element: "#search", // The input element to bind to
                showImages: false,
                highlightQuery: true,
                // Add more options as needed for customization
                // For example, to show results in a custom div:
                // result_parent: "#search-results",
            });
        });
    </script>
    {% endif %}
</body>
</html>

Explanation:

  • We’ve added conditional blocks {% if site.pagefind_enabled %} to ensure Pagefind assets are only included if the feature is enabled in our configuration.
  • <link href="/{{ site.pagefind_output_dir }}/pagefind-ui.css" rel="stylesheet"> includes the Pagefind UI styling.
  • <input type="text" id="search"> provides a simple search input field. Pagefind’s UI library will attach to this.
  • <script src="/{{ site.pagefind_output_dir }}/pagefind-ui.js" type="text/javascript"></script> loads the Pagefind UI JavaScript.
  • The new PagefindUI(...) call initializes the search interface, binding it to our #search input element. You can customize its behavior and appearance extensively using the options provided.

d) Testing This Component

  1. Build your site: Run your SSG’s build command:
    cargo run -- build
    
  2. Verify Pagefind output:
    • Check your public/ directory. You should see a new subdirectory, public/pagefind/ (or whatever pagefind_output_dir you configured).
    • This directory should contain files like pagefind-ui.css, pagefind-ui.js, pagefind.js, pagefind.wasm, and various index files (e.g., pagefind.pf_idx).
  3. Serve your site: Use a local web server to serve the public/ directory. A simple way is to use miniserve (install with cargo install miniserve) or Python’s http.server:
    # From the project root, assuming public/ is your output
    miniserve public/
    # or
    python3 -m http.server --directory public/ 8000
    
  4. Test search:
    • Open your browser to http://localhost:8000.
    • You should see the search input field.
    • Type a word or phrase that exists in your content (e.g., “markdown”, “rust”, “component”).
    • As you type, Pagefind should display search results dynamically below the input field.

Debugging Tips:

  • “pagefind not found” error: Ensure pagefind is installed and available in your system’s PATH. Run pagefind --version in your terminal to confirm.
  • No search results / Pagefind assets missing:
    • Check your SSG’s build logs for Pagefind failed or Pagefind stdout/stderr messages.
    • Verify the public/pagefind directory exists and contains the necessary files.
    • Inspect your browser’s developer console for any JavaScript errors related to PagefindUI or network errors when trying to load /pagefind/pagefind-ui.js or /pagefind/pagefind.wasm. Ensure the paths in base.html match your pagefind_output_dir.
    • Confirm pagefind_enabled = true in your config.toml.

Production Considerations

  1. Error Handling: Our run_pagefind method already includes basic error handling by checking the command’s exit status. In a production system, you might want more robust error reporting (e.g., sending an alert, failing the CI/CD pipeline explicitly).
  2. Performance Optimization:
    • Build Time: For very large sites, Pagefind’s indexing step can add noticeable time to the build process. Pagefind is highly optimized, but it’s a trade-off for client-side search.
    • Client-Side Performance: Pagefind assets (especially pagefind.wasm) are loaded asynchronously and are generally small. The search is performed entirely client-side, making it very fast for users. Ensure your web server serves these static assets with appropriate caching headers.
    • Incremental Builds: In future chapters, when we implement incremental builds, we’ll need to consider how Pagefind integrates. Ideally, Pagefind would only re-index changed pages, but its current design is to re-index the entire output directory. This is a known limitation for SSGs focused on speed, but often acceptable given Pagefind’s efficiency.
  3. Security Considerations:
    • Since Pagefind operates purely on static files and client-side JavaScript, the security implications are minimal. There’s no server-side component to attack.
    • The main “security” concern is ensuring that sensitive content is not included in your static HTML if it shouldn’t be searchable. Pagefind will index whatever HTML it finds.
  4. Logging and Monitoring: Our run_pagefind method logs Pagefind’s stdout and stderr. In a CI/CD environment, ensure these logs are captured so you can diagnose build failures related to Pagefind.

Code Review Checkpoint

At this point, you should have:

  • Modified src/config.rs: Added pagefind_enabled and pagefind_output_dir fields to SiteConfig.
  • Updated config.toml: Included [pagefind] section with enabled and output_dir.
  • Modified src/builder.rs:
    • Added use std::process::Command;.
    • Integrated a call to self.run_pagefind().await? into the main build method.
    • Implemented the run_pagefind async method to execute the pagefind CLI tool.
  • Modified templates/base.html:
    • Added conditional <link> for pagefind-ui.css.
    • Added an <input type="text" id="search"> element.
    • Added conditional <script> for pagefind-ui.js and its initialization code.

This setup ensures that every time you run cargo run -- build, your site’s HTML is generated, and then Pagefind is executed to create a search index based on that HTML, with the necessary client-side assets injected into your templates.

Common Issues & Solutions

  1. Issue: error: process didn't exit successfully: pagefind … (exit code: 1) or Pagefind execution failed.

    • Reason: The pagefind command failed for some reason. This could be due to incorrect paths, permissions, or a problem within Pagefind itself.
    • Solution:
      • Check the stderr output logged by our run_pagefind function. It usually contains the specific error message from Pagefind.
      • Verify the --source and --output-path arguments in run_pagefind point to valid, accessible directories.
      • Ensure the pagefind executable is correctly installed and accessible (pagefind --version).
      • Try running the pagefind command manually from your terminal with the same arguments to see the exact error.
  2. Issue: Search input appears, but no results are shown, or JavaScript errors in the console like PagefindUI is not defined.

    • Reason: Pagefind’s client-side assets (pagefind-ui.js, pagefind.wasm) are not being loaded correctly by the browser.
    • Solution:
      • Inspect your browser’s developer tools (Network tab) to see if pagefind-ui.js and pagefind.wasm are being requested and loaded successfully. Check their paths.
      • Ensure the paths in templates/base.html (/{{ site.pagefind_output_dir }}/pagefind-ui.js) correctly resolve to the actual location of the Pagefind assets in your public/ directory. Remember the leading / for absolute paths.
      • Verify that site.pagefind_enabled is true in your config.toml and that the Tera template conditional renders the script tags.
      • Make sure public/pagefind/ (or your configured output directory) actually contains the Pagefind assets after a build.
  3. Issue: Pagefind builds successfully, but the search results are empty or incomplete.

    • Reason: Pagefind might not be indexing all your content, or your content might not contain the keywords you’re searching for.
    • Solution:
      • Pagefind by default indexes the visible text content of HTML files. If parts of your content are dynamically loaded or hidden, they might not be indexed.
      • Pagefind supports data attributes (data-pagefind-filter, data-pagefind-meta, data-pagefind-body) to control what gets indexed. Refer to Pagefind documentation if you need more granular control over indexing specific elements or adding custom metadata to search results. For now, it should index all visible text.
      • Ensure your content files are actually being rendered to HTML in the public/ directory.

Testing & Verification

To thoroughly test the Pagefind integration:

  1. Clean Build:
    • rm -rf public (to ensure a completely fresh start)
    • cargo run -- build
    • Verify public/pagefind/ exists and contains files.
  2. Local Server:
    • miniserve public/ (or python3 -m http.server --directory public/ 8000)
  3. Browser Tests:
    • Open http://localhost:8000.
    • Positive Test: Search for keywords you know exist in your content (e.g., a specific phrase from a Markdown file, a title, a component name). Verify that accurate search results appear.
    • Negative Test: Search for a keyword you know does not exist. Verify that “No results found” or similar message is displayed.
    • Empty Search: Type nothing, or delete your input. Verify the search results clear.
    • Navigation: Click on a search result. Verify that it navigates you to the correct page and, if highlightQuery is enabled, that the search term is highlighted on the page.
    • Configuration Change: Temporarily set pagefind_enabled = false in config.toml, rebuild, and verify that the search input and Pagefind assets are no longer present on the site. This confirms our conditional rendering works.

This comprehensive testing ensures that Pagefind is correctly integrated into both the build process and the frontend, providing a robust search experience.

Summary & Next Steps

In this chapter, we successfully integrated Pagefind into our Rust static site generator. We configured our SSG to execute Pagefind as a post-build step, allowing it to crawl our generated HTML and create a client-side search index. We then modified our Tera templates to include Pagefind’s UI assets and initialize the search functionality, providing a fast and efficient search experience for our users.

This addition significantly enhances the usability and discoverability of content on our static site, moving us closer to a production-ready content platform. We’ve also considered production aspects like error handling, performance, and security.

In the next chapter, Chapter 15: Incremental Builds and Caching, we will tackle the challenge of optimizing build times for large sites. We’ll explore strategies for detecting changes and only rebuilding what’s necessary, along with implementing caching mechanisms to speed up the development workflow and deployment process.