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:
.base("content")removes the content directory from document URLs, soOutput::to(doc)writes the home page todist/index.htmland About todist/about/index.html..root("templates")names the templatebase.htmlinstead oftemplates/base.html.- CSS lookups still use the source key,
styles/main.scss. The resultingstylesheet.pathalready starts with/hash/and goes directly intohref.
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.