Incremental Build
Incremental build tracks file checksums and dependency changes to skip unchanged pages, significantly reducing rebuild times for large sites.
hwaro build --cache
When to Use
Incremental builds are most effective when:
- Your site has many pages but you typically change only a few at a time
- You want faster edit-build-preview cycles
- You're working on content rather than templates or config
Usage
Enable caching
hwaro build --cache
On the first run, Hwaro builds every page and saves checksums to .hwaro_cache.json. On subsequent builds, only changed files are re-processed.
Force a full rebuild
hwaro build --cache --full
The --full flag clears the cache and rebuilds every page from scratch. The new cache is saved afterward, so the next build without --full will be incremental again.
How It Works
Change detection
Each cached entry tracks:
- File modification time (mtime) — fast first check
- Content checksum (MD5) — verifies actual change when mtime differs, catching false positives from file touches
When mtime hasn't changed, the file is considered unchanged without reading its content. When mtime differs, Hwaro computes and compares the content hash to confirm the change is real.
Dependency invalidation
Beyond per-file checksums, Hwaro tracks what each page actually depends on:
- Template closure — the page's template plus everything it transitively
extends,includes, orimports, plus any shortcode templates the page content invokes. Each page's cache entry stores a fingerprint of this closure, so editingpartials/footer.htmlonly rebuilds the pages whose template chain renders that partial. - Cascade fingerprint — the merged section
[cascade]values applied to the page, so editing a parent_index.mdcascade rebuilds its descendants. - Other pages it renders — the prev/next neighbours, series list, related
posts, translations and breadcrumb ancestors the page's templates read, its
parent section's title, description, assets and sort order
(
section.title,section.pages,section_title, …), the pages it fetches with a literalget_page(path="…"), and the URLs its@/links resolve to. Retitling a post re-renders the pages whose "next" link names it; changing a page'sslugre-renders every page linking to it with@/. - Listings — a page whose template closure loops over the page or section
set (
site.pages,paginate,get_section, aget_pagewith a computed path, …) re-renders when that set changes. The closure includes the shortcode templates the page's content calls and its output-format templates, so a{{ recent() }}shortcode listing posts counts too. - Config checksum — a hash of the effective merged config. A config change invalidates all entries.
- Render hooks — a fingerprint of every configured
templates/hooks/render-*template (see Render Hooks) is folded into every page's template closure. Since a hook isn't reached via a page's{% include %}/{% extends %}graph, editing one re-renders every page rather than a narrowed set. - Template inputs — what templates read outside the tracked files: the
fingerprinted
asset()bundle names, the[auto_includes]tags and their?v=digest,env()values,load_data()files outsidedata/, and the source images behindresize_image(), and the clock behindnow()and thecurrent_year/current_date/current_datetimevariables. A change to any of them re-renders all pages (a{{ current_year }}footer re-renders once a year;now()with a seconds format, on every build). Only the variable names, file paths and a digest are stored in.hwaro_cache.json— never an environment variable's value.
Template dependency tracking requires every template reference to be a string
literal. If any template uses a dynamic reference ({% include some_var %}),
Hwaro falls back to whole-site invalidation: any template change rebuilds
every page. You can also opt out explicitly:
[build]
template_deps = false # any template change rebuilds every page
What gets skipped
When a page is unchanged and its dependencies haven't changed:
- Content parsing is still performed (to build navigation, taxonomies, etc.)
- Rendering and writing are skipped — the existing output file is reused
- SEO files (sitemap, feeds, etc.) are always regenerated
Serve mode
The development server (hwaro serve) uses a more targeted incremental strategy:
| Change Type | Strategy |
|---|---|
| Content files only | Re-parse and re-render only affected pages + neighbors, plus any listing page the edit changed (see below) |
| Template files only | Re-render only pages whose template closure includes an edited template (all pages when tracking is off, the graph has dynamic references, or the edited file is under templates/hooks/); a full rebuild when the edited file is a [[content.generate]] body_template or a partial it includes |
| Config file | Full rebuild |
| Static files only | Copy only changed files; a full rebuild when a file lands where the build writes a page or a generated file (robots.txt, a feed, a taxonomy page, an alias stub, …), feeds a cache-busting ?v= hash, or is read by a literal load_data() path in a template |
A listing page is one whose template closure renders a global set — the
homepage's "latest posts" loop over site.pages, an archive, a paginated
index, a nav built from site.menus, a tag pill resolved through
get_taxonomy_url(), a footer printing get_page(path="about.md").title, a
shortcode the page's content calls to loop over a section's pages. It owns none of the page you edited, so the selections
above never reach it. Serve therefore fingerprints those sets either side of
the rebuild and re-renders the listing pages whose set moved.
Each kind of listing is gated on a fingerprint of its own inputs, not on one
site-wide digest. That matters because a nav partial in the shared base
layout is in every page's template closure: a coarser gate would answer
"re-render the whole site" for any metadata edit. A menu refreshes when a
menu entry's title or URL moves, a tag pill when the term set changes, a
site.pages loop when the page set changes — and an edit that moves nothing
a listing prints (a body-only change on a site whose listings show no
excerpts) still re-renders just the edited page.
Serve also watches an [assets] source_dir that lives outside static/, so
bundle sources kept in their own directory rebuild on save like any other
asset.
Removed pages
A cold build starts from an empty output directory, so a page you delete is
gone from public/ on the next build. --cache keeps the directory — that is
the point — so removals are handled from the cache instead: every entry records
the file the page wrote, and on each build the entries no live page claims have
their output deleted.
That covers a page that was deleted, renamed, moved to a new URL (slug,
path, a permalink rule), turned into a draft, passed its expires date, or
set render = false.
Everything a removed page brought with it goes too: its aliases redirect
stubs, its AMP mirror, its auto-generated OG image, the taxonomy term page of
a tag nobody uses any more (with that term's feed), and the pagination page a
section no longer fills. Turning a whole feature off — [amp] enabled = false,
[og.auto_image] enabled = false, [image_processing] enabled = false, or
the --skip-og-image / --skip-image-processing flags — removes what it used
to publish on the next build for the same reason, as does dropping a width
from [image_processing] widths.
Files that have a source but no cache entry are covered the same way, by
recording what each build publishes and deleting what the next one no longer
does: static/ copies, [content.files] and raw (.json/.xml) copies,
page-bundle assets, and the fingerprinted asset bundles — so editing a
stylesheet replaces main.<hash>.css instead of leaving every past revision
in public/assets/.
All of this relies on the cache remembering what it wrote. When it has no
usable record of the output directory — no .hwaro_cache.json yet (the tree
came from a plain hwaro build), a corrupt one, or one last used for a
different -o — a --cache build clears the output directory first, exactly
like a cold build (and under the same ownership rules), then records it. The
cache remembers one output directory at a time, so alternating
hwaro build --cache and hwaro build --cache -o dist rebuilds each tree from
scratch on every switch. hwaro serve --cache keeps a separate cache (see
below), so a serve session never costs the next hwaro build --cache its
record of public/.
A --cache build's output is therefore byte-identical to a clean build's, and
hwaro build --full is not needed to clear anything. (--full only clears the
cache, so it does nothing at all without --cache; a plain hwaro build
already rebuilds everything.)
Cache File
The cache is stored in .hwaro_cache.json at the project root
(hwaro serve --cache uses .hwaro/serve_cache.json, next to the
.hwaro/serve/ tree it describes; .hwaro/ keeps itself out of version
control). This file contains:
- Metadata — template and config checksums from the last build
- Entries — per-file records with path, mtime, content hash, and output path (the output path is also what identifies a removed page's stale file)
Add .hwaro_cache.json to your .gitignore (the one hwaro init scaffolds already lists it):
.hwaro_cache.json
Flag Reference
| Flag | Description |
|---|---|
--cache |
Enable incremental build caching |
--full |
Force a complete rebuild (clears and repopulates the cache) |
Examples
# First build — creates cache
hwaro build --cache
# Edit a few files, then rebuild — only changed pages are re-rendered
hwaro build --cache
# Something seems off — force a clean rebuild
hwaro build --cache --full
# Combine with other flags
hwaro build --cache --minify
See Also
- CLI Reference — Full list of build flags
- Streaming Build — Reduce memory usage for large sites
- Build Hooks — Run custom commands before/after builds