The Site Tree
A site is a tree. This page says what the tree is made of, what each node
carries, what it deliberately leaves out, and why it never changes once
built. The types live in taxus-domain/src/tree.rs.
What a site is
The content directory is a folder of folders. Each folder is a
section. Each Markdown file that is not
_index.md is a page. A file named
_index.md gives its folder a title, a body and settings.
The Site Tree is that folder structure in memory, with every file already parsed. There is one tree per build. The root of the tree is the content directory itself.
#![allow(unused)] fn main() { pub struct SiteTree { pub root: SectionNode, } }
The two node types
A section node is a directory.
#![allow(unused)] fn main() { pub struct SectionNode { pub path: NodePath, // where it is: ["blog"]; the root is [] pub content_file: Option<PathBuf>, // "blog/_index.md", or None pub meta: Frontmatter, // from _index.md, or defaults pub body: Option<String>, // the Markdown of _index.md pub pages: Vec<PageNode>, // direct child pages, by slug pub subsections: Vec<SectionNode>, // direct child sections, by slug } }
A directory without an _index.md is still a section. Its content_file
is None, its meta is the default frontmatter, and it has nothing to
render, so no HTML file is written for it. Its pages are still rendered,
and templates can still fetch it with get_section.
A page node is a document.
#![allow(unused)] fn main() { pub struct PageNode { pub path: NodePath, // ["blog", "project-launch"] pub content_file: PathBuf, // "blog/2026-04-03-project-launch.md" pub meta: Frontmatter, pub body: String, } }
Notice that content_file and path differ. The file name carries a date
prefix; the node path does not. The file is storage. The path is
identity. Identity explains the rule.
What a node does not carry
A node holds what was read from disk and nothing that was computed from it. In particular a node never holds:
- Rendered HTML. Markdown is rendered in the emit phase and kept on a
ProcessedPage, outside the tree. - Its URL. The address is derived from
pathbyUrlPath::from_node_pathwhenever it is needed. - Computed lists. A section has no "all posts sorted by date" field. A listing is a derivation, computed on request.
- Taxonomy indexes. Which pages carry the tag
rustis computed byderivation::group_by_terms, not stored on the tag or the page. - Its summary, word count or reading time. These are methods on the parsed page, computed when asked.
The reason is the one-source rule. If a section stored a sorted list and a page was later found to be a draft, the stored list would be wrong. If nothing derived is stored, nothing derived can go stale.
Containment versus reachability
pages and subsections hold direct children only. This is
containment: blog contains blog/project-launch; the root does not.
Reachability is everything below a node at any depth. It is not a field.
It is a question you ask: derivation::descendant_pages(section) walks
the subtree and returns every page in it.
Keeping the two apart is what makes section.pages mean one thing. A
section lists what it owns. When a section should list pages it does not
own, the author says so with pages_from, and
aggregation merges the named
sections' direct pages in. Nothing is listed by accident of depth.
Why the tree is immutable
SiteTreeBuilder::build returns the tree, and from then on every stage
only reads it. The generator holds it as &SiteTree. There is no method
that adds, removes or edits a node after construction.
Three things depend on this:
- Every derivation agrees. The feed, the sitemap, the section listings and the taxonomy pages are all computed from the same tree. If one stage could edit the tree, a later stage would see a different site than an earlier one.
- Order is deterministic. Children are sorted by slug when the tree is built. Every list starts from that order, so the same content produces the same output on every machine. The golden output test relies on this.
- Errors are found once. Two files that resolve to the same path, or a page and a section at the same path, are rejected by the builder before any output is written.
If the tree could change during a build, a template that calls
get_section early and a sitemap generated late could disagree about
which pages exist. The golden test would flap. Aliases could point at
URLs that no longer exist by the time they are written.
The scaffolded site
taxus init my-site --name "My Site" creates one content file. The tree
is small enough to show whole. File names on the right are the content_file values,
relative to content/.
SiteTree
└── root: SectionNode content/_index.md
path: [] (address: /)
content_file: Some("_index.md")
meta.title: "Home"
meta.description: "Welcome to My Site"
body: Some("# Welcome to My Site\n\n...")
pages: []
subsections: []
One node, one document, one output file: dist/index.html.
A site with a blog
The product site in the repository, get-taxus-org/, is a real site with
a blog. Its tree, with file names next to the nodes:
SiteTree
└── root: SectionNode path [] _index.md
├── subsections (by slug)
│ ├── SectionNode path ["appearance"] appearance/_index.md
│ ├── SectionNode path ["authoring"] authoring/_index.md
│ ├── SectionNode path ["blog"] blog/_index.md
│ │ └── pages (by slug)
│ │ ├── PageNode ["blog","project-launch"]
│ │ │ blog/2026-04-03-project-launch.md
│ │ ├── PageNode ["blog","taxus-feature-focus-hero-images"]
│ │ │ blog/2026-04-11-taxus-feature-focus-hero-images.md
│ │ ├── PageNode ["blog","taxus-feature-focus-search-island"]
│ │ │ blog/2026-04-14-taxus-feature-focus-search-island.md
│ │ ├── PageNode ["blog","taxus-feature-focus-syntax-highlighting"]
│ │ │ blog/2026-04-10-taxus-feature-focus-syntax-highlighting.md
│ │ └── PageNode ["blog","understanding-static-site-generators"]
│ │ blog/2026-04-05-understanding-static-site-generators.md
│ ├── SectionNode path ["interactivity"] interactivity/_index.md
│ └── SectionNode path ["structure"] structure/_index.md
└── pages: []
Two things to notice. The blog's pages are in slug order, not date order;
date order is a derivation the blog's sort_by asks for at render time.
And the two .jpg files in content/blog/ are not in the tree. They are
co-located assets, copied by the emit phase; the tree holds documents
only.
The Worked Example follows the first post through the whole build.