Hauchiwa Docs

Building out your site

Continue from Getting started to turn the Markdown pipeline into a small website with a shared template, navigation, a stylesheet, and browser live reload. All commands below run from your generator/ project root, where Cargo.toml lives.

Add the dependencies and files

Replace the dependencies section of Cargo.toml with:

[dependencies]
hauchiwa = { version = "0.22.1", default-features = false, features = ["grass", "minijinja", "live", "server", "logging"] }
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
comrak = "0.50"

These features enable Sass compilation, Jinja templates, file watching, the HTTP server, and build logging. This example needs no external JavaScript tools.

Create the additional directories:

mkdir -p content templates styles

The finished project will have this layout:

generator/
  Cargo.toml
  src/main.rs
  content/index.md
  content/about.md
  templates/base.html
  styles/main.scss

Replace content/index.md with:

---
title: Home
order: 1
---
Welcome to my site. Read [about this project](/about/).

Create content/about.md:

---
title: About
order: 2
---
This site is generated by a Rust program using **Hauchiwa**.

The optional order field controls navigation order. The Rust code below defaults it to zero when omitted and breaks ties by URL. Links in Markdown use public URLs: Hauchiwa does not automatically rewrite a link to about.md into /about/.

Create a shared template

Save this as templates/base.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ title }} | My site</title>
    <link rel="stylesheet" href="{{ css_url }}">
  </head>
  <body>
    <nav aria-label="Main navigation">
      {% for item in navigation %}
      <a href="{{ item.href }}"{% if item.href == current_href %} aria-current="page"{% endif %}>{{ item.title }}</a>
      {% endfor %}
    </nav>
    <main>
      <h1>{{ title }}</h1>
      {{ body | safe }}
    </main>
    {% if refresh %}
    <script>{{ refresh | safe }}</script>
    {% endif %}
  </body>
</html>

MiniJinja escapes ordinary values in .html templates, including titles and navigation labels. body is HTML produced by the Markdown renderer and refresh is JavaScript supplied by Hauchiwa, so those two values use safe to preserve their markup. Keep ordinary text fields escaped.

Add a stylesheet

Save this as styles/main.scss:

$accent: #2454a6;

body {
  max-width: 48rem;
  margin: 3rem auto;
  padding: 0 1rem;
  font-family: system-ui, sans-serif;
  line-height: 1.6;
  color: #202124;
}

nav {
  display: flex;
  gap: 1rem;
  border-bottom: 1px solid #ddd;
  padding-bottom: 1rem;

  a { color: $accent; }
  a[aria-current="page"] { font-weight: bold; }
}

The loader compiles and minifies this file, then gives it a content-hashed URL. The template receives that URL from the rendering task.

Connect the tasks

Replace src/main.rs with the complete generator below:

use hauchiwa::{Blueprint, Output};
use serde::{Deserialize, Serialize};

#[derive(Clone, Deserialize)]
struct Frontmatter {
    title: String,
    #[serde(default)]
    order: usize,
}

#[derive(Serialize)]
struct NavItem {
    title: String,
    href: String,
    order: usize,
}

fn main() -> anyhow::Result<()> {
    hauchiwa::init_logging()?;
    let watch = match std::env::args().nth(1).as_deref() {
        None | Some("build") => false,
        Some("watch") => true,
        Some(other) => anyhow::bail!("Unknown mode {other:?}; use build or watch"),
    };

    let mut config = Blueprint::<()>::new();

    let documents = config
        .load_documents::<Frontmatter>()
        .glob("content/**/*.md")?
        .base("content")
        .register();

    let templates = config
        .load_minijinja()
        .glob("templates/**/*.html")?
        .root("templates")
        .register();

    let css = config
        .load_css()
        .entry("styles/main.scss")?
        .watch("styles/**/*.scss")?
        .register();

    let navigation = config
        .task()
        .name("Build navigation")
        .using(documents)
        .merge(|_, documents| {
            let mut items: Vec<_> = documents
                .values()
                .map(|doc| NavItem {
                    title: doc.matter.title.clone(),
                    href: doc.meta.href.clone(),
                    order: doc.matter.order,
                })
                .collect();
            items.sort_by(|a, b| (a.order, &a.href).cmp(&(b.order, &b.href)));
            Ok(items)
        });

    config
        .task()
        .name("Render pages")
        .each(documents)
        .using((templates, css, navigation))
        .map(|ctx, doc, (templates, css, navigation)| {
            let body = comrak::markdown_to_html(&doc.text, &comrak::Options::default());
            let stylesheet = css.get("styles/main.scss")?;
            let template = templates.get_template("base.html")?;
            let html = template.render(hauchiwa::minijinja::context! {
                title => doc.matter.title,
                current_href => doc.meta.href,
                body => body,
                css_url => stylesheet.path.as_str(),
                navigation => navigation,
                refresh => ctx.env.get_refresh_script(),
            })?;
            Ok(Output::to(doc).html(html)?)
        });

    let mut website = config.finish();
    if watch {
        website.watch(())?;
    } else {
        website.build(())?;
    }
    Ok(())
}

Each loader returns a typed handle. The navigation task gathers all documents into one Vec<NavItem>, while the rendering task maps over individual documents and borrows the shared templates, stylesheet collection, and navigation.

Three path conventions matter here:

Build and preview

Build once:

cargo run -- build

The output contains both HTML pages and a stylesheet under dist/hash/. Both pages have the same navigation and their own active link. Production HTML contains no live-reload script.

Start the development server:

cargo run -- watch

Open http://localhost:8080/ and follow the About link. Edit a Markdown file, templates/base.html, or styles/main.scss; the site rebuilds and the browser refreshes. Press Ctrl-C to stop. Restart the generator after changing Rust source or dependencies.

The stylesheet watch pattern includes both the entry file and any future SCSS partials. Explicit .watch() patterns replace the loader's default entry-file patterns, so keep the entry covered.

Understand the rebuilds

The graph makes the dependencies explicit:

documents ──> navigation ──┐
    └─────────────────────┤
templates ────────────────┼──> rendered pages
css ──────────────────────┘

The navigation task reads every document. Adding, removing, or editing a document therefore rebuilds navigation, and its One<Vec<NavItem>> result invalidates all page renders—even if only the Markdown body changed. This is a deliberate simple starting point, not a promise that every content edit renders only one page. Without that shared dependency, .each(documents).map(...) can reuse renders for unchanged documents during watch rebuilds.

Template changes rerender pages using the template environment. A changed CSS bundle gets a new URL, which the dependent pages must include. These dependencies are why the browser receives matching HTML and assets after a rebuild.

For more detail, see Core concepts and How it works. When you need images, search, or a sitemap, continue with the Asset pipeline.