Introduction

Welcome to Taxus, a Rust-based static site generator built with Tera, featuring WebAssembly "islands" for interactive components.

What is Taxus?

Taxus is a static site generator that combines:

  • Tera templates for the static "sea of HTML" — page layout, content, navigation
  • Markdown content with TOML frontmatter for writing pages and posts
  • SCSS for modern styling
  • Yew components — pre-rendered server-side at build time, hydrated by WASM in the browser for interactivity

The key insight: pages are immediately visible with no JavaScript required. Interactive components load asynchronously and attach without re-rendering.

Features

  • Static-first: Pre-rendered HTML for optimal performance and SEO
  • Islands Architecture: Interactive Yew WASM components embedded within static pages
  • Markdown Content: Write content in Markdown files with TOML frontmatter
  • Syntax Highlighting: Tree-sitter based code highlighting (Rust built in)
  • Co-located Assets: Images and files in the content directory are automatically copied to output
  • Hero Images: Responsive variants, WebP conversion, and <picture>/srcset markup generated automatically
  • Internal Links: Reference other pages by content file path with build-time validation
  • Blog Features: Summary extraction, reading time, word count, custom slugs
  • Pagination: Split large collections across multiple pages
  • RSS/Atom Feeds: Automatic feed generation for content syndication
  • Sitemap: Automatic sitemap.xml generation with priorities and lastmod dates
  • Taxonomies: Tags, categories, and series for content organization
  • Full-Text Search: TF-IDF search index searched client-side by the WASM client
  • Development Server: Hot-reloading local server with WebSocket live reload
  • CLI Interface: build, clean, init, routes, and serve subcommands

Who is this for?

Taxus is ideal for:

  • Rust developers who want to build websites without leaving their favorite language
  • Performance enthusiasts who want fast, optimized static sites with selective WASM interactivity
  • SEO-conscious developers who need pre-rendered content with no JavaScript dependency for initial render
  • Component lovers who prefer the Yew component model for interactive UI pieces

Documentation Overview

  • Getting Started — Quick start guide
  • Theory — How a build works: the Site Tree, identity, derivations, a worked example, the glossary, and the reasons behind the design
  • Architecture — The crates, modules, and the build pipeline stage by stage
  • Content Model — The content directory as a database: rows, parent pointers, indexes
  • Configuration — site.toml format and options
  • Content — Markdown files, frontmatter, taxonomies, pagination
  • Templates — Tera templates and context variables
  • Images — Hero image processing and responsive variants
  • Syntax Highlighting — Tree-sitter highlighting setup
  • Islands Architecture — How to write and use Yew components
  • Search — Client-side full-text search
  • Styling — SCSS stylesheets
  • CLI Reference — Command-line interface documentation
  • Development Server — Hot reload and file watching
  • Development — Building, testing, and xtask workflows
  • API Reference — Library API documentation

License

This project is licensed under the MIT License - see the License.txt file for details.

Getting Started

This guide will help you get up and running with Taxus quickly.

Prerequisites

Before you begin, ensure you have the following installed:

Quick Start

Step 1: Clone and Initialize

# Clone the repository
git clone https://github.com/crustyrustacean/taxus.git
cd taxus

# Create a new site
cargo run -- init my-site --name "My Site" --base-url "https://example.com"

This creates the following structure:

my-site/
├── site.toml               # Site configuration
├── content/
│   └── _index.md           # Home page
├── templates/
│   ├── base.html           # Base HTML layout
│   ├── page.html           # Single-page template
│   ├── section.html        # Section/listing template
│   ├── tags.html           # Tag listing page
│   ├── tags_term.html      # Individual tag page
│   ├── categories.html     # Category listing page
│   ├── categories_term.html # Individual category page
│   ├── series.html         # Series listing page
│   ├── series_term.html    # Individual series page
│   └── 404.html            # Not found page
├── static/
│   ├── scripts.js          # Placeholder scripts
│   └── favicon.png         # Placeholder favicon
└── styles/
    ├── main.scss           # Starter stylesheet
    ├── _highlight-dark.scss  # Code highlighting theme (dark)
    └── _highlight-light.scss # Code highlighting theme (light)

Step 2: Build the Site

cargo run -- build --dir my-site --verbose

This runs the 15-stage build pipeline (see Architecture) and writes output to my-site/dist/. The WASM client (client.js and client_bg.wasm) is compiled during the Cargo build, embedded in the binary, and written to dist/wasm/ automatically — no separate build step is needed.

Step 3: Serve and View

cargo run -- serve --dir my-site --open

This starts a development server at http://localhost:3000 and opens it in your browser.

You should see the home page rendered from the Markdown content in content/_index.md.

Next Steps

  • Read the Theory chapters to learn how a build works
  • Learn about Configuration for customizing your site
  • Understand Content for writing pages and posts
  • Explore Templates for customizing HTML output
  • Read the CLI Reference for all command options
  • Deploy the built site to a static host

Opting Out of Islands

Islands (Yew/WASM hydration) are enabled by default. If you want a plain Tera/Markdown scaffold with no WASM hydration, pass --no-islands when initializing:

cargo run -- init my-site --no-islands

See Islands Architecture for the complete guide.

Theory

Taxus is a compiler for websites. This chapter and the ones under it explain how a build works; the reference pages that follow describe the code.

A compiler reads source files, builds a model of what they mean, computes facts about that model, and writes output files. Taxus does the same with a site. The input is a folder: content files, assets, templates and a config file. The output is a folder of HTML plus a few generated files (feeds, a sitemap, a search index, robots.txt, 404.html, the WASM client).

The build has three phases.

Parse. Read the content directory once and build the Site Tree. Every content file is parsed into frontmatter and body and placed in the tree at its node path. After this phase the tree does not change.

Analyse. Compute derivations over the tree: the order of documents, each section's listing, the taxonomy terms, the recent pages a feed should carry, the entries a sitemap needs. A derivation is a pure function of the tree and the config. Nothing is written back.

Emit. Turn the tree and the derivations into files: render Markdown to HTML, run templates, resize hero images, compile SCSS, copy assets, and write everything under the output directory.

   content/  templates/  styles/  static/  site.toml
        │
        ▼
  ┌──────────────────────────────────────────────────────┐
  │ PARSE     filesystem ──► Site Tree                    │
  │           taxus-generator (routes::discovery) builds  │
  │           taxus-domain   (tree, identity, schema) owns│
  └──────────────────────────┬───────────────────────────┘
                             │  SiteTree (immutable)
                             ▼
  ┌──────────────────────────────────────────────────────┐
  │ ANALYSE   derivations over (tree, config)             │
  │           taxus-domain   (derivation) owns the pure   │
  │           functions; taxus-generator calls them       │
  └──────────────────────────┬───────────────────────────┘
                             │  lists, groupings, orders
                             ▼
  ┌──────────────────────────────────────────────────────┐
  │ EMIT      tree + derivations ──► files                │
  │           taxus-generator (build, templates, images,  │
  │           assets, feed); taxus-common supplies island │
  │           components; taxus-client is written to      │
  │           dist/wasm/                                  │
  └──────────────────────────┬───────────────────────────┘
                             │
                             ▼
                          dist/

Which crate owns which phase:

PhaseOwnerWhat it holds
Parsetaxus-domain defines the tree; taxus-generator fills itSiteTree, SiteTreeBuilder, RouteDiscovery::discover_tree
Analysetaxus-domainderivation::documents, descendant_pages, recent, aggregate, group_by_terms, tree::sort_pages
Emittaxus-generator, with taxus-common and taxus-clientSiteBuilder::build, templates, images, assets, feed, the pipeline stages

The domain crate does no I/O. It never reads a file, never renders Markdown, and never runs a template. That is what makes its functions easy to test and easy to reason about: give them a tree and they give back a list. The generator does all the reading and writing around it.

The fifteen numbered stages that taxus build logs are a finer cut of the same three phases. Architecture maps each stage to its phase and says what it reads and produces.

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 path by UrlPath::from_node_path whenever 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 rust is computed by derivation::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:

  1. 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.
  2. 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.
  3. 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.

Identity

A document has four names. This page says what each one is, where it comes from, which one is the source of truth, and where the others are derived from it.

NameTypeExampleComes from
Content filePathBufblog/2026-04-03-project-launch.mdthe filesystem
Slugtaxus_domain::Slugproject-launchthe file name, or the frontmatter slug
Node pathtaxus_domain::NodePathblog/project-launchthe directory segments plus the slug
URL pathtaxus_domain::UrlPath/blog/project-launch/the node path, in one function

Content file

The content file is where a document is stored, as a path relative to the content directory. It is the storage identity. It appears on the tree node (content_file), on the route (RouteInfo::content_file) and on the parsed page (Page::source), always with the same value.

Stages that need to join two views of the same document join on the content file. The render stage looks up a tree node's ProcessedPage by content file; so do the feed, the sitemap and the taxonomy pages. It is the one name that survives every transformation unchanged.

The content file is not an address. Its date prefix, its capital letters and its spaces never reach a URL.

Slug

A slug is one URL segment. The domain type Slug only checks that a string can stand as a segment: not empty, no /, not . or .., no control characters. It does not make strings safe; the generator does.

The generator owns two slug algorithms, one per concern, both in routes::slugify:

  • Node paths use slugify_segment: lowercase, transliterate to ASCII, collapse runs of whitespace and punctuation to a single dash, never start or end with a dash. A file called My Créative Post.md gets the slug my-creative-post.
  • Taxonomy terms use slugify_term: lowercase, spaces and underscores to dashes, punctuation stripped — but non-ASCII letters are kept (Café → café), because a term is display-facing. Templates reach the same rule through the term_slug filter, so a tag's term page and the links to it can never disagree.

A page's slug is decided by two rules, in this order:

  1. If the frontmatter sets slug, that string is the slug, verbatim. Slug::new validates it and the build fails if it cannot be a segment.
  2. Otherwise, take the file stem, remove a YYYY-MM-DD- date prefix if there is one (content::split_date_prefix), and slugify what remains.

A section's slug is its directory name, slugified. A slug field in an _index.md is ignored: a section is named by its directory.

Node path

The node path is the list of slugs from the root section down to the node. It is the node's name inside the tree and the key for get_section and get_page. The root's node path is empty.

The node path is built in exactly one place, RouteDiscovery::discover_tree in taxus-generator/src/routes/discovery.rs: the parent directory's segments are slugified one by one, and the page's slug is appended.

URL path

The URL path is the address. It is derived from the node path by one function and nowhere else:

#![allow(unused)]
fn main() {
impl UrlPath {
    pub fn from_node_path(path: &NodePath) -> Self {
        if path.is_root() { "/" } else { format!("/{path}/") }
    }
}
}

The root is /. Every other document is / + segments joined by / + /. There is no configuration that changes this shape and no frontmatter field that sets a whole URL.

The output file mirrors the URL path: blog/project-launch/index.html, and index.html for the root. RouteRegistry::from_tree computes both from the node path when it builds the routes.

Which one is the source of truth

The node path. It is stored on the node, it is what the builder checks for duplicates, and everything downstream is a function of it:

content file ──(slugify, strip date, apply slug override)──► node path
                                                                │
                                          UrlPath::from_node_path
                                                                ▼
                                                            URL path ──► output file

The arrow from content file to node path runs once, during parse. The arrows from node path onward run whenever a URL is needed, and always give the same answer.

The boundary rule

Slug overrides and date prefixes are applied before a path enters the tree. SiteTreeBuilder::add_page receives the final node path and stores it as given. The builder does not look at meta.slug; it does not look at the file name. Its rustdoc calls this "paths are final".

This puts all file-name interpretation in one function of the generator, and lets the domain crate stay free of naming rules. A test can build a tree with any paths it likes and never touch a file. It also means the tree can never disagree with itself: there is no second field that a later stage could reinterpret into a different address.

The old behaviour, where a frontmatter slug was reapplied after discovery and moved the page to the site root, was the bug this rule fixed (PR #79).

Three files, four names

Content fileFrontmatterNode pathURL pathOutput file
about.md(none)about/about/about/index.html
blog/e.mdslug = "renamed-entry"blog/renamed-entry/blog/renamed-entry/blog/renamed-entry/index.html
blog/2026-04-03-project-launch.md(none)blog/project-launch/blog/project-launch/blog/project-launch/index.html

The first row is plain. The second shows that a slug override replaces the last segment only; the page stays in blog. The third shows the date prefix removed from the path and kept in the file name. In that third case the frontmatter has no date of its own, so the prefix also becomes the page's date: 2026-04-03.

Two files that reach the same node path are an error, reported as Duplicate route: /blog/project-launch/. So is a page and a section at the same path.

Derivations

Anything you can compute, you don't store.

A derivation is a pure function of the Site Tree and the config that returns a view: a list of pages, a grouping of documents, an order. It reads the tree. It reads nothing else. It writes nothing. Call it twice and you get the same answer. That is the whole definition, and it is what lets every output of a build agree with every other output.

The pure derivations live in taxus-domain/src/derivation.rs and taxus-domain/src/tree.rs. The generator has a few functions that fit the definition but need the config or the generator's own types; this page lists those too and says where they live.

Tree order: derivation::documents

Question. In what order should the site be walked?

Inputs. The tree.

Where. taxus_domain::derivation::documents(tree) -> Vec<Node>.

Answer. Every document in tree order: a section's own index file if it has one, then its pages by slug, then each subsection by slug, recursively. Drafts are included; callers filter. This is the order routes are registered in (RouteRegistry::from_tree), the order content is processed in, and the order every later list starts from. It is what makes builds deterministic.

Consumed by. RouteRegistry::from_tree (stage 1), the sitemap (stage 8), taxonomy grouping (stage 10), and iter_pages on the tree.

Reachability: derivation::descendant_pages

Question. Which pages are anywhere under this section?

Inputs. A section node.

Where. taxus_domain::derivation::descendant_pages(section) -> Vec<&PageNode>.

Answer. The section's own pages, then each subsection's, depth-first. This is the query that replaces a stored "all pages below me" field. The tree's SiteTree::iter_pages is this function applied to the root.

Consumed by. recent (below). No template field exposes it directly.

Sorting: tree::sort_pages

Question. In what order should a listing show its pages?

Inputs. A list of pages and a SortBy.

Where. taxus_domain::tree::sort_pages(&mut [&PageNode], SortBy).

Answer. date: newest first, undated last. title: case-insensitive ascending. weight: lowest first. none: leave the input order. The sort is stable, so ties keep tree order. SortBy comes from the listing section's sort_by frontmatter.

Consumed by. Section listings (stage 6) and recent.

Recent pages: derivation::recent

Question. What are the newest pages on the whole site?

Inputs. The tree and whether drafts count.

Where. taxus_domain::derivation::recent(tree, include_drafts) -> Vec<&PageNode>.

Answer. Every page in the site, filtered by draft status, sorted by date newest first. Section index files are not pages and are not included.

Consumed by. Feeds (stage 11), through feed_pages below.

Aggregation: derivation::aggregate (pages_from)

Question. Which pages does this section list, including ones it does not own?

Inputs. The receiving section, the tree, and the node paths named in the receiver's pages_from.

Where. taxus_domain::derivation::aggregate(section, tree, from) -> Vec<&PageNode>.

Answer. The receiver's direct pages, then each donor's direct pages in the order the donors are named, with duplicates removed by node path. Donors that do not exist are skipped. The result is not sorted; the caller sorts with the receiver's sort_by.

Consumed by. build::pipeline::pages::collect_child_pages, which resolves the pages_from strings to node paths (warning on bad ones), calls aggregate, sorts, and drops pages the build skipped. The result is the template field section.pages (stage 6) and what get_section returns.

Taxonomies: derivation::group_by_terms

Question. Which documents carry each tag, category or series?

Inputs. The tree, whether drafts count, and a function that reads one taxonomy's terms off a frontmatter.

Where. taxus_domain::derivation::group_by_terms(tree, include_drafts, terms_of) -> BTreeMap<String, Vec<Node>>.

Answer. A map from term name to the documents that declare it, in tree order, with term names sorted. Sections with an index file participate like pages. A document that repeats a term appears twice under it.

Consumed by. build::pipeline::taxonomy::build_taxonomy_map (stage 10) calls it three times, once per kind, and fills a TaxonomyMap. The term pages /tags/rust/ and the list pages /tags/ are rendered from that map, with extra.taxonomy as the template variable.

Pagination

Question. How is a long listing split across several URLs?

Inputs. The section's sorted listing and its paginate_by.

Where. build::pipeline::pages::render_paginated_section (private, in taxus-generator/src/build/pipeline/pages.rs).

Answer. Slices of paginate_by items. Slice 1 renders at the section's own URL; slice n renders at <section>/page/n/. Each render gets a PaginationContext with current, total, prev, next, first and last. This is a derivation over a derivation: it takes the listing above and cuts it. It is not in the domain crate because the slices exist only to be rendered.

Consumed by. The template field section.pagination (stage 6). get_section never returns pagination; slicing belongs to the section's own render.

Feed entries

Question. Which pages does the feed announce, and in what order?

Inputs. The tree and the [feed] sections config.

Where. build::pipeline::feeds::feed_pages(tree, sections) -> Vec<&PageNode>, then feed::FeedEntry::from_page for each.

Answer. recent(tree, false), narrowed to pages that have a date, and to pages under one of the configured sections when any are named. The feed generator then applies [feed] limit. Undated pages are left out on purpose: a feed entry needs a publication date, and stamping it with the build time would re-announce the page on every build.

Consumed by. dist/feed.xml and dist/feed.atom (stage 11).

Sitemap entries

Question. Which addresses does the site have?

Inputs. The tree, the processed pages, and the site's base URL.

Where. build::pipeline::sitemap::generate_sitemap.

Answer. One entry per non-draft document from documents, with the permalink, the date as lastmod, priority 1.0 for the root, 0.8 for sections and 0.7 for pages, sorted by URL. Taxonomy and pagination pages are not documents and are not listed.

Consumed by. dist/sitemap.xml (stage 8).

Prev/next and ancestors

There is no prev/next derivation and no ancestors derivation in the code today. NodePath::parent gives a node's parent path, and get_section can fetch it, so a template can reach the parent. Nothing walks the ancestor chain for it, and no context field names a previous or next page.

Tree method or derivation?

Two kinds of function take a tree. The rule for deciding which to write:

  • It is a tree method if it answers "is this node here, and give it to me": a lookup by path, or an iteration that takes no parameters and applies no policy. SiteTree::get_section, get_page, iter_pages.
  • It is a derivation if it selects, filters, orders or groups, or if the answer depends on frontmatter values, config, or a caller's choice such as whether drafts count. Everything in derivation.rs.

A method that took include_drafts would be a derivation in disguise. A derivation that returned a single node by path would be a method in disguise. Keeping them apart keeps the tree small and the policy visible.

Two further rules for a new derivation:

  1. It goes in taxus_domain::derivation if it needs only the tree and plain values. It stays in the generator if it needs the config struct, rendered HTML, or generator types such as ProcessedPage.
  2. It returns borrowed nodes (&PageNode, Node<'_>), never copies. The tree owns the data; a derivation is a view of it.

Worked Example

This page follows one real file through the whole system. The file is the first blog post of the product site, get-taxus-org/ in the repository. It was chosen because the scaffold that taxus init creates has no blog post, and because its date-prefixed name exercises every identity rule. Every value below was taken from an actual build of that site; nothing is invented.

The file:

get-taxus-org/content/blog/2026-04-03-project-launch.md

The site's config, as far as this page needs it:

[site]
name = "Taxus"
base_url = "https://get-taxus.org"

1. Bytes on disk

+++
title = "Project Launch"
date = 2026-04-03
description = "The inaugural blog post for the Taxus SSG project."
tags = ["rust", "ssg"]
categories = ["announcements"]
draft = false
+++

### Ready, Set, Go!

Welcome to the official launch of Taxus! I'm excited to introduce this new
static site generator and share the journey that brought us here. ...

The file sits next to blog/_index.md (title "Blog", template = "blog.html") and four other posts.

2. Frontmatter and body

Stage 1, [1/15] Discovering routes..., lists every .md file and calls Page::from_str(content, "blog/2026-04-03-project-launch.md"). That splits the file at the +++ lines and parses the TOML into a Frontmatter:

#![allow(unused)]
fn main() {
Frontmatter {
    title: "Project Launch",
    date: Some(2026-04-03),          // from frontmatter; the file name agrees
    description: Some("The inaugural blog post for the Taxus SSG project."),
    tags: ["rust", "ssg"],
    categories: ["announcements"],
    draft: false,
    sort_by: Date, paginate_by: 0, weight: 0, // defaults
    ..                                        // every other field None or empty
}
}

The body is everything after the closing +++, with leading blank lines removed, as a String starting ### Ready, Set, Go!.

Had the frontmatter omitted date, split_date_prefix would have supplied 2026-04-03 from the file name. Here both agree.

3. The node and its path

Still in stage 1, RouteDiscovery::discover_tree computes where the file goes in the tree:

  1. Parent directory blog becomes the node path ["blog"] (slugified; unchanged here).
  2. File stem 2026-04-03-project-launch. No frontmatter slug, so split_date_prefix removes 2026-04-03-, and slugify_segment turns project-launch into project-launch.
  3. The node path is the parent joined to the slug.

SiteTreeBuilder::add_page receives the final path and stores it:

#![allow(unused)]
fn main() {
PageNode {
    path:         NodePath(["blog", "project-launch"]),
    content_file: "blog/2026-04-03-project-launch.md",
    meta:         Frontmatter { title: "Project Launch", .. },
    body:         "### Ready, Set, Go!\n\nWelcome to the official launch ...",
}
}

SiteTreeBuilder::build places it under the blog section, whose pages end up in slug order:

blog/project-launch
blog/taxus-feature-focus-hero-images
blog/taxus-feature-focus-search-island
blog/taxus-feature-focus-syntax-highlighting
blog/understanding-static-site-generators

RouteRegistry::from_tree then derives the route from the path and nothing else:

[page   ]  /blog/project-launch/   blog/2026-04-03-project-launch.md   blog/project-launch/index.html

That line is what taxus routes --dir get-taxus-org prints. The URL path came from UrlPath::from_node_path(&["blog", "project-launch"]). The date is not in it.

4. The processed page

Stage 3, [3/15] Processing content..., takes the tree node — the same parse discovery already made; nothing is read from disk a second time — resolves @/ links (there are none), and renders the Markdown. The result:

#![allow(unused)]
fn main() {
ProcessedPage {
    route:        RouteInfo { path: "/blog/project-launch/", .. },
    page:         Page { frontmatter, raw_content: body },
    html_content: "<h3 id=\"ready-set-go\">Ready, Set, Go!</h3>\n<p>Welcome to the official launch of Taxus! ...",
    toc:          [TocEntry { level: 3, text: "Ready, Set, Go!", id: "ready-set-go", .. }, ..],
    hero_image:   None,
}
}

Stage 4 does nothing for this page; it has no hero_image.

5. The derivations that include it

Every list that mentions this post is computed from the tree. None of them is stored on the node.

Tree order (derivation::documents). The post is the fifth document: root index, appearance, authoring, blog index, then blog/project-launch. The route above was registered in that position.

The blog listing (stage 6, collect_child_pages). The blog section has no pages_from, so aggregate returns its five direct pages. Its sort_by is the default, Date, so sort_pages orders them newest first. The post is the oldest and comes last. This is the order section.pages has when blog.html renders /blog/:

Taxus Feature Focus: Search Island         2026-04-14
Taxus Feature Focus: Hero Images           2026-04-11
Taxus Feature Focus: Syntax Highlighting   2026-04-10
Understanding Static Site Generators       2026-04-05
Project Launch                             2026-04-03

The root section lists nothing: its _index.md has no pages_from, and the post is not its direct child.

Taxonomies (stage 10, group_by_terms). The post's frontmatter puts it under three terms. TaxonomyMap records its content file under each:

KindTermPage appears at
tagsrust/tags/rust/ (with all five posts)
tagsssg/tags/ssg/
categoriesannouncements/categories/announcements/

On /tags/rust/ the post is listed first: the term page keeps tree order, and the post is the first blog page by slug.

Feed (stage 11, feed_pages). recent(tree, false) yields all five posts newest first; the post has a date, so it stays; no [feed] sections is configured, so nothing is scoped out. It is the last of five items.

Sitemap (stage 8). The post is one of eleven documents, none of them drafts, so it gets an entry.

Search (stage 13). Every processed page gets a SearchDocument.

6. The page context

Stage 6 builds a PageContext for the post from the processed page and the site's base URL (page_context_from in build/pipeline/pages.rs):

#![allow(unused)]
fn main() {
PageContext {
    title:        "Project Launch",
    description:  Some("The inaugural blog post for the Taxus SSG project."),
    tagline:      None,
    path:         "/blog/project-launch/",
    permalink:    "https://get-taxus.org/blog/project-launch/",
    content:      "<h3 id=\"ready-set-go\">Ready, Set, Go!</h3>\n<p>Welcome ...",
    raw_content:  "### Ready, Set, Go!\n\nWelcome ...",
    date:         Some("2026-04-03"),
    draft:        false,
    summary:      "Ready, Set, Go!",
    word_count:   278,
    reading_time: 2,
    toc:          [ .. ],
    tags:         ["rust", "ssg"],
    categories:   ["announcements"],
    series:       None,
    weight:       0,
    hero:         None,
}
}

Two values deserve a note. summary is "Ready, Set, Go!" because the frontmatter sets no summary and the body has no <!-- more -->, so Page::summary takes the first paragraph, which is the heading, and strips its ### . reading_time is 278 words at 200 words a minute, rounded up.

The same PageContext is what the blog listing, the tag pages and get_page(path="blog/project-launch") hand to templates.

7. The template

The post has no template field, so it renders with page.html. The context is:

site    = SiteContext { name: "Taxus", base_url: "https://get-taxus.org", .. }
page    = the PageContext above
section = (absent; this is a page, not a section)
now     = NowContext { year: 2026 }
extra   = {}                     // the post has no [extra] table

The product site's page.html extends base.html and, in its content block, prints the date, the title, the description, the rendered body, and the tag links:

<time datetime="{{ page.date }}">{{ page.date }}</time>
<h2>{{ page.title }}</h2>
<p class="description">{{ page.description }}</p>
{{ page.content | safe }}
{% for tag in page.tags %}
<a href="/tags/{{ tag | term_slug }}/">{{ tag }}</a>
{% endfor %}

The rendered result, RenderedPage { route, content }, contains:

<title>Project Launch - Taxus</title>
<meta name="description" content="The inaugural blog post for the Taxus SSG project.">
<link rel="canonical" href="https://get-taxus.org/blog/project-launch/">
...
<time datetime="2026-04-03">2026-04-03</time>
<h2>Project Launch</h2>
<p class="description">The inaugural blog post for the Taxus SSG project.</p>
<h3 id="ready-set-go">Ready, Set, Go!</h3>
<p>Welcome to the official launch of Taxus! ...</p>
...
<a href="/tags/rust/">rust</a>, <a href="/tags/ssg/">ssg</a>

8. The files

Stage 15 writes the rendered page to route.output_file under the output directory:

dist/blog/project-launch/index.html

The other outputs that mention the post, each written by its own stage:

dist/sitemap.xml (stage 10):

<url>
  <loc>https://get-taxus.org/blog/project-launch/</loc>
  <lastmod>2026-04-03</lastmod>
  <changefreq>monthly</changefreq>
  <priority>0.7</priority>
</url>

dist/feed.xml (stage 11), the last of five items; the description is the frontmatter description because FeedEntry::from_page prefers it over the computed summary:

<item>
  <title>Project Launch</title>
  <link>https://get-taxus.org/blog/project-launch/</link>
  <description>The inaugural blog post for the Taxus SSG project.</description>
  <pubDate>Fri, 03 Apr 2026 00:00:00 +0000</pubDate>
  <category>rust</category>
  <category>ssg</category>
  <guid isPermaLink="true">https://get-taxus.org/blog/project-launch/</guid>
</item>

dist/search_index.bin (stage 13), one record:

#![allow(unused)]
fn main() {
SearchDocument {
    id: 4,                          // its position in registry order
    title: "Project Launch",
    path: "/blog/project-launch/",
    summary: "Ready, Set, Go!",
    tags: ["rust", "ssg"],
    categories: ["announcements"],
}
}

And the listing pages that link to it: dist/blog/index.html, dist/tags/rust/index.html, dist/tags/ssg/index.html, dist/categories/announcements/index.html.

What to take from this

Six outputs name the post. All six got its address from one node path, computed once in stage 1. All six got its membership from one tree. Rename the file to 2026-04-03-launch.md and every one of them changes together on the next build, and aliases = ["/blog/project-launch/"] in the frontmatter would keep the old address working.

Glossary

This page is the vocabulary of Taxus. Every other page uses these words in these senses. Each entry gives a one-sentence meaning, an example from a real site, and the Rust type or function that embodies the term.

Examples come from two sites. The scaffold is what taxus init my-site creates: one content file, content/_index.md. The product site is get-taxus-org/ in the repository, which has a blog and is built in CI. The scaffold has no blog post, so blog examples use the product site.

Where Taxus uses a word differently from Zola, the entry says so under Zola.

Storage

Site directory. The folder that holds site.toml, content/, templates/, styles/ and static/. Example: my-site/. Type: taxus_lib::config::SiteConfig (loaded from site.toml; base_dir is this folder).

Config. The site.toml file: site name, base URL, directory names, feed, image and highlighting options. Example: name = "My Site". Type: SiteConfig.

Content directory. The folder whose Markdown files become the site, content/ by default. Example: my-site/content/. Field: BuildConfig::content_dir.

Content file. One Markdown file inside the content directory, always named by its path relative to that directory. Example: blog/2026-04-03-project-launch.md. Fields: PageNode::content_file, SectionNode::content_file, Page::source, RouteInfo::content_file. These four fields hold the same value; it is the storage identity of a document.

Index file. A content file named _index.md; it gives a section its frontmatter and body. Example: content/blog/_index.md. Field: SectionNode::content_file (Some when the index file exists).

Frontmatter. The TOML block between +++ lines at the top of a content file. Example: title = "Project Launch". Type: taxus_domain::Frontmatter; the node field is meta.

Body. Everything in a content file after the frontmatter, as raw Markdown. Example: the paragraphs of project-launch.md. Fields: PageNode::body, SectionNode::body, Page::raw_content.

Date prefix. A YYYY-MM-DD- at the start of a file name; it is removed from the slug and supplies a default date. Example: 2026-04-03- in 2026-04-03-project-launch.md. Function: taxus_lib::content::split_date_prefix. Zola: the same convention, with the same effect.

Co-located asset. A non-Markdown file inside the content directory, copied to the same relative path in the output. Example: content/blog/mountain_sunset.jpg becomes dist/blog/mountain_sunset.jpg. Function: build::pipeline::copy_colocated_assets. Zola: a page's assets live in a folder with its index.md; Taxus has no per-page folders.

Static asset. A file under static/, copied to dist/static/. Example: static/favicon.png. Type: assets::StaticCopier.

Output directory. Where the built site is written, dist/ by default. Field: BuildConfig::output_dir.

The Site Tree

Site Tree. The in-memory model of one site: a tree of sections and pages, built once per build from the content directory. Example: for the scaffold, a root section with no pages. Types: taxus_domain::SiteTree, SiteTreeBuilder. Zola: the equivalent structure is called the library.

Node. One item in the Site Tree, either a section or a page. Type: taxus_domain::derivation::Node (a borrowed view of either kind).

Section. A directory inside the content directory, including the content directory itself; it groups pages and other sections. Example: content/blog/ is the section blog. Type: taxus_domain::SectionNode. Zola: a directory is a section only if it contains _index.md; in Taxus every directory is a section, and one without an index file has default frontmatter and nothing to render.

Root section. The section that is the content directory itself; its node path is empty and its address is /. Example: content/_index.md in the scaffold. Functions: NodePath::root, field SiteTree::root.

Page. A content file that is not an index file; a leaf of the tree. Example: content/blog/2026-04-03-project-launch.md. Type: taxus_domain::PageNode. Zola: the same.

Document. Anything the build renders to an HTML file: every page, and every section that has an index file. Example: the product site has eleven documents, six sections with index files and five posts. Type: taxus_domain::derivation::Node, yielded by derivation::documents.

Containment. The relation between a section and its direct children. SectionNode::pages and SectionNode::subsections hold direct children only. Example: blog contains blog/project-launch; the root does not.

Reachability. Everything below a section at any depth. It is computed on request, never stored. Function: derivation::descendant_pages.

Tree order. The canonical order of documents: a section's index file, then its pages by slug, then each subsection by slug, recursively. Function: derivation::documents. Every list the build produces starts from this order.

Draft. A document whose frontmatter sets draft = true; it is excluded unless the build passes --include-drafts. Field: Frontmatter::draft; method PageNode::is_draft. Zola: the same.

Identity

Slug. One segment of a URL path, already made safe for that use. Example: project-launch. Type: taxus_domain::Slug. Slugs are produced by taxus_lib::routes::slugify::slugify_segment (lowercase, ASCII, dashes) or taken verbatim from the frontmatter slug field. Zola: the same word and the same override field.

Node path. The list of slugs from the root section to a node; the tree's name for the node. Example: ["blog", "project-launch"], written blog/project-launch. Type: taxus_domain::NodePath. Older text calls this the membership path or the tree path; they are the same thing.

URL path. The address a document is served at, derived from its node path in one place. Example: /blog/project-launch/. Type: taxus_domain::UrlPath; function UrlPath::from_node_path. In the generator this is RouteInfo::path, ProcessedPage::effective_url_path() and the template variable page.path.

Permalink. The URL path joined to the site's base URL. Example: https://get-taxus.org/blog/project-launch/. Function: taxus_lib::templates::compute_permalink.

Output file. The file under the output directory that a document is written to, mirroring the URL path. Example: blog/project-launch/index.html. Field: RouteInfo::output_file.

Route. One document's URL path, content file, output file and kind (page or section), as a plain record. Example: the row taxus routes prints for /blog/project-launch/. Types: RouteInfo, RouteKind, RouteRegistry (built from the tree by RouteRegistry::from_tree).

Alias. An old URL path that should redirect to a document. Example: aliases = ["/launch/"]. Type: build::pipeline::alias::AliasPage. Zola: the same field.

Internal link. A Markdown link whose target starts with @/ and names a content file; the build replaces it with the document's URL path. Example: [launch](@/blog/2026-04-03-project-launch.md). Function: build::pipeline::internal_links::resolve_internal_links. Zola: the same syntax.

Derivations

Derivation. A pure function of the Site Tree and the config that returns a list or a grouping; it stores nothing and reads no files. Module: taxus_domain::derivation. Older pages say projection or query.

Sorting. Putting a list of pages in the order a section asks for: date newest first with undated last, title case-insensitive, weight lowest first, or none. Function: taxus_domain::tree::sort_pages; type SortBy; frontmatter key sort_by. Zola: the same keys; Zola sorts undated pages differently.

Listing. The pages a section shows on its own index page: its direct child pages, plus any aggregation, sorted. Example: /blog/ lists the five posts. Template variable: section.pages. Function: build::pipeline::pages::collect_child_pages (private).

Aggregation. A section listing pages it does not contain, declared by naming the sections to take them from. Example: pages_from = ["blog"] on the root index file. Function: derivation::aggregate; frontmatter key pages_from. The listing section is the receiver; each named section is a donor. Zola: the nearest feature is transparent, which pushes pages up to the parent; pages_from pulls, and the receiver chooses.

Recent pages. Every non-draft page in the site, newest first. Function: derivation::recent. Feeds start from it.

Taxonomy. One of the three ways a document says what it is about: tags, categories or series. Example: tags = ["rust", "ssg"]. Type: taxus_lib::content::TaxonomyKind. Zola: taxonomies are declared in config.toml; in Taxus the three kinds are fixed.

Term. One value of a taxonomy, with the documents that carry it. Example: the tag rust, listed at /tags/rust/. Types: derivation::group_by_terms (the grouping), content::TaxonomyTerm, templates::TaxonomyTermContext. Zola: the same word.

Pagination. Splitting a listing into fixed-size slices, each rendered to its own URL. Example: paginate_by = 10 gives /blog/ and /blog/page/2/. Type: templates::PaginationContext; frontmatter keys paginate_by, paginate_template. Zola: the same keys and URLs.

Feed. The RSS or Atom file that lists dated, non-draft pages newest first. Example: dist/feed.xml. Functions: build::pipeline::feeds::feed_pages, feed::FeedEntry::from_page.

Sitemap. The sitemap.xml that lists every non-draft document's permalink. Function: build::pipeline::sitemap::generate_sitemap.

Search index. A binary file of every rendered document's words, read by the browser. Example: dist/search_index.bin. Types: taxus_common::search::SearchIndex, SearchDocument.

Rendering

Template. A Tera HTML file under templates/. Example: templates/page.html. Type: taxus_lib::templates::TeraRenderer.

Context. The set of variables one template render can see: site, page, section, now and extra. Type: templates::TemplateContext. The pieces are SiteContext, PageContext, SectionContext, NowContext, and HeroContext under page.hero.

Page context. A document as a template sees it: title, URL path, permalink, rendered HTML, summary, taxonomies. Type: PageContext. The page variable holds one during every render, including a section's own render, where it holds the index file.

Section context. A section as a template sees it, including its listing and its direct subsections. Type: SectionContext; the subsection entries are SubsectionContext.

Tree function. A Tera function that fetches any node's context by node path: get_section(path="blog") and get_page(path="blog/project-launch"). Function: TeraRenderer::set_site_lookup fills what they read. Zola: the same functions; Zola takes only content-file paths.

Summary. The short text used for listings, feeds and search: the frontmatter summary, else the text before <!-- more -->, else the first paragraph. Method: Page::summary.

Hero image. An image named in frontmatter and resized into several widths for a <picture> element. Example: hero_image = "mountain_sunset.jpg". Types: images::ProcessedImage, templates::HeroContext.

Island. A Yew component rendered to HTML at build time and made interactive in the browser by the WASM client. Example: {{ island(component="Counter", initial=3) | safe }}. The build-time call is the Tera function island(); the HTML wrapper is the mount point, <div data-island="Counter" data-props='…'>; the browser step is hydration, taxus_client::hydrate_islands.

The build

Phase. One of the three parts of a build: parse (files to tree), analyse (derivations over the tree), emit (files out). See Overview.

Stage. One of the fifteen numbered steps SiteBuilder::build logs, such as [1/15] Discovering routes.... Each stage belongs to one phase. See Architecture.

Processed page. A document after its Markdown has been rendered: route, parsed file, HTML, table of contents and hero image. Type: build::pipeline::ProcessedPage.

Rendered page. A processed page after its template has run: route and final HTML. Type: build::pipeline::RenderedPage.

Decisions

Short answers to "why is it like this?", one paragraph each, with a link to the pull request or issue where the choice was made when there is one.

Why a tree and not a flat route list

Taxus 0.x ran on a flat list of routes: one record per file, keyed by URL. That list could not answer "which pages belong to this section?" without guessing from URL prefixes, and the guess was wrong: the root's listing matched every URL that started with /, so the home page listed the whole site (#70). A tree answers membership by construction: a section's children are the nodes under it, nothing more. The route list still exists, but it is now a projection of the tree (RouteRegistry::from_tree), so the two cannot disagree. The tree was introduced in PR #71 and wired through the build in PR #72.

Why derivations are free functions rather than methods

A method on SiteTree suggests the answer is a property of the tree. A listing is not: it depends on a section's sort_by, on whether drafts count, on which donors pages_from names. Free functions make those inputs explicit parameters, so a reader sees at the call site what the answer depends on. It also keeps the tree type small enough to read in one sitting, and lets the generator add its own derivations (feed pages, sitemap entries) in the same shape without touching the domain crate. The rule for choosing is in Derivations.

Why the domain crate has no I/O

taxus-domain never reads a file, renders Markdown or runs a template. Everything it does can be tested with a SiteTreeBuilder and a few string literals, and its tests run in milliseconds with no fixtures. It also fixes a boundary: file-name conventions such as the date prefix are interpreted by the generator before a path enters the tree, so the model does not depend on how files happen to be named. The decision is recorded in PR #71 and the boundary rules were settled in review (commit feca3c7).

Why there are no themes

A theme is a second source of templates, styles and static files that the generator has to merge with the site's own, with rules for which one wins. Taxus has one templates/ directory, one styles/ directory and one static/ directory, and taxus init copies a complete starting set into them. Everything a site renders is in the site. There is no lookup order to learn and no theme update that can change a page under you. A site that wants to share a look with another copies the files. There is no issue for this; it is the absence of a feature.

Why islands instead of a JS framework

Most pages need no script. A framework that renders the page in the browser makes every page wait for JavaScript. Islands keep the page as HTML and add interactivity only where a template asks for it: the island() function renders a Yew component to HTML at build time, and the WASM client hydrates that one element in the browser. The HTML is readable before the WASM loads, and a page without islands ships no component code at all. Yew was chosen because the same component compiles to both the build-time renderer and the browser, so there is one source for each island. See Islands. The runtime plumbing that lets build() render islands from any calling context is PR #63 (#37).

Why the image quality is part of the cache key

Hero image variants are named by a hash and skipped when the files already exist. If the hash covered only the image bytes, changing images.quality in site.toml would do nothing until someone deleted dist/images/. Folding the effective quality into the hash means a quality change re-encodes on the next build, and an unchanged image keeps its file names, so cached URLs stay stable across deployments. The hash is of the file's contents rather than its path and modification time so that a fresh checkout produces the same names, which is what lets the golden output test pin them. PR #65 made quality take effect (#34); PR #81 moved the key to content.

Why section listings are direct children only

Before the tree, a section listed every page whose URL started with the section's URL. That made the home page a copy of the whole site and made nested sections list each other's pages. Listing direct children only matches what the directory shows, and pages_from lets an author opt in to more. The alternative, Zola's transparent, pushes pages upward from the child; pages_from pulls from the parent, so the section that shows the pages is the one that declares it. PR #76 (#70).

Why feeds carry dated pages only

A feed entry must have a publication date. The old feed put every document in, including the home page and section indexes, and stamped undated ones with the build time, which re-announced them to every subscriber on every build. PR #77 (#44).

Why dates come out of file names

2026-04-03-project-launch.md sorts by date in a file listing, which is useful on disk. The date is not part of the page's name, so it is removed from the slug and, when the frontmatter sets no date, used as the default. Metadata belongs in frontmatter; the file name is a storage convention the parser interprets. PR #68 (#67).

Why the slug override stays inside its section

slug = "renamed-entry" on content/blog/e.md used to move the page to /renamed-entry/. A slug is one segment, so it replaces the last segment of the node path and nothing else: /blog/renamed-entry/. A site that relied on the old address keeps it with aliases. PR #79.

Architecture

This page is the map of the code: the crates, the modules, and the build pipeline stage by stage. The ideas behind the pipeline are in the Theory chapters; the words used here are defined in the Glossary.

Workspace

Taxus is a Cargo workspace of five crates.

taxus/
├── taxus-domain/    # the Site Tree, identity types, frontmatter, derivations (no I/O)
├── taxus-generator/ # the build: parse, analyse, emit; the `taxus` CLI
├── taxus-common/    # Yew island components and the search index, shared by both sides
├── taxus-client/    # the browser-side WASM that hydrates islands (embedded in the binary)
└── xtask/           # developer task runner (`cargo xtask`)
CratePhaseRoleOutput
taxus-domainparse, analyseDefines what a site is (SiteTree, SectionNode, PageNode), how nodes are named (Slug, NodePath, UrlPath), the frontmatter schema, and the pure derivations. Reads no files.library
taxus-generatorall threeFills the tree from disk, calls the derivations, renders Markdown and templates, processes images and assets, writes the output.library taxus_lib and binary taxus
taxus-commonemitIsland components (Counter, SearchBox) rendered at build time and hydrated in the browser; the search index format.library
taxus-clientemitFinds [data-island] mount points in the page and hydrates them; fetches the search index on demand. Compiled to WASM by the generator's build script and embedded with include_bytes!.WASM bundle written to dist/wasm/
xtasknonecargo xtask build, test, lint, book, release, and the rest.binary

How the crates depend on each other:

 taxus-domain ◄── taxus-generator ──► taxus-common ◄── taxus-client
   (model)          (the build)        (islands,        (hydration,
                         │              search)           compiled to
                         │ build.rs compiles taxus-client   wasm32)
                         │ and embeds client.js + client_bg.wasm
                         ▼
                       dist/

The three phases

Every stage below belongs to one of three phases. Parse turns the filesystem into the Site Tree. Analyse computes derivations over the tree. Emit turns the tree, the derivations and the other inputs into files. The tree is built in stage 1 and is immutable from then on; every later stage holds it as &SiteTree.

The build pipeline, stage by stage

SiteBuilder::build in taxus-generator/src/build/builder.rs runs fifteen stages and logs one line per stage. The list below uses the same numbers and the same wording as the log. For each stage: which phase it is, what it reads, what it produces.

[1/15] Discovering routes (parse). Reads every .md under the content directory. Produces the SiteTree (RouteDiscovery::discover_tree) and, from it, the RouteRegistry (RouteRegistry::from_tree): one route per document in tree order. Frontmatter is parsed here, slugs are computed here, and duplicate paths fail here. An empty registry ends the build with NoContent.

[2/15] Loading templates (emit setup). Reads templates/**/*.html. Produces a TeraRenderer with the island(), get_section() and get_page() functions and the slugify and date filters registered.

[3/15] Processing content (emit). Reads the tree — the same parse discovery already made; no file is read from disk a second time — the registry and the config. Produces one ProcessedPage per document in canonical tree order: internal links resolved against the registry, Markdown rendered to HTML, headings collected into a table of contents, code blocks highlighted. Drafts are dropped here unless --include-drafts was passed; skipped documents are counted as they happen, not inferred by subtraction (#55).

[4/15] Processing images (emit). Reads the processed pages and [images] config. For every page with hero_image, produces resized variants under dist/images/ (or their paths, in dry run) and attaches a ProcessedImage to the page.

[5/15] Copying co-located assets (emit). Reads the content directory. Copies every non-.md file to the same relative path under the output directory.

[6/15] Rendering pages (analyse and emit). Reads the processed pages, the tree and the templates. First fills the tree functions' lookup with every section and page as a context (site_lookup). Then, for each processed page, builds a TemplateContext and runs its template. A section's section.pages is the derivation aggregate sorted by sort_pages; a section with paginate_by is rendered once per slice. Produces one RenderedPage per output file.

[7/15] Generating robots.txt (emit). Reads the config and checks for static/robots.txt. If none exists, produces a default robots.txt pointing at the sitemap and writes it.

[8/15] Generating 404.html (emit). Reads the templates. If 404.html exists, renders it with the site context and writes it.

[9/15] Building taxonomy pages (analyse and emit). Reads the tree, the processed pages and the templates. derivation::group_by_terms fills a TaxonomyMap for tags, categories and series. For each kind whose templates exist, renders /tags/ and /tags/<term>/ and the same for the other two. Produces RenderedTaxonomy values; they are written in stage 15.

[10/15] Generating sitemap.xml (analyse and emit). Reads the rendered pages, the taxonomy pages and the base URL — the final outputs, not the tree (#47). The URL set is every RenderedPage (which includes the pagination pages stage 6 emits) plus every taxonomy list and term page; each entry's date is joined from its processed page by content file. <loc> is XML-escaped. Alias redirects are excluded deliberately: a redirect is not content, and each targets a URL already in the set.

[11/15] Generating feeds (analyse and emit). Reads the tree, the processed pages and [feed] config. feed_pages selects dated, non-draft pages newest first (from derivation::recent), scoped by sections if set. Produces the RSS and Atom documents; written in stage 15.

[12/15] Processing assets (emit). Reads styles/ and static/. Compiles SCSS to dist/css/ and copies static files to dist/static/. The co-located asset report from stage 5 is merged in here.

[13/15] Generating search index (emit). Reads the processed pages in registry order — skipped entirely when [build] search = false. Produces dist/search_index.bin: one SearchDocument per page with its title, URL path, truncated summary and taxonomies, plus the TF-IDF postings over the page's Markdown text with its title and taxonomy terms repeated as a field boost (never the rendered HTML, whose markup would pollute the term space).

[14/15] Writing WASM client (emit). Reads nothing from the site. Writes the embedded client.js and client_bg.wasm to dist/wasm/ — skipped when [build] islands = false (what taxus init --no-islands writes; a plain Tera/Markdown site ships no hydration code).

[15/15] Writing output (emit). Writes every RenderedPage to its output file, then the taxonomy pages, then the feeds, then one redirect page per aliases entry. Produces the BuildReport.

In --dry-run every stage runs and nothing is written; stage 4 skips pixel work and stage 12 still compiles SCSS so errors surface.

Key types along the way

TypeMade in stageHolds
SiteTree (taxus_domain)1the parsed site: sections, pages, frontmatter, bodies
RouteRegistry, RouteInfo1per document: URL path, content file, output file, kind
ProcessedPage3, 4route, parsed Page, rendered html_content, toc, hero_image
TemplateContext6site, page, section, now, extra for one render
RenderedPage6route and final HTML content
TaxonomyMap10terms per kind, each with its documents' content files
GeneratedFeed, GeneratedSitemap, GeneratedSearch11, 8, 13the bytes of one output file
BuildReport15counts, duration, asset report

Stages join the tree to the processed pages by content file: a PageNode::content_file equals a RouteInfo::content_file equals a Page::source. That is the one name that survives every stage.

Generator module map

ModulePhaseTypesResponsibility
configparseSiteConfig, SiteMeta, BuildConfig, FeedConfig, HighlightConfig, ImageConfig, MarkdownConfigload and validate site.toml
contentparsePage, Frontmatter (re-exported from the domain), ContentSource, split_date_prefix, TaxonomyMapparse one content file; taxonomy map type
routesparseRouteDiscovery, RouteRegistry, RouteInfo, RouteKind, slugifybuild the tree from disk; derive routes; the two slug algorithms (node paths, taxonomy terms)
buildallSiteBuilder, BuildReport, ProcessedPage, RenderedPage, pipeline::*the fifteen stages
templatesemitTeraRenderer, TemplateContext, PageContext, SectionContext, SiteContext, PaginationContext, TaxonomyTermContextrender Tera templates; tree functions
imagesemitImageProcessor, ProcessedImage, ImageRegistry, render_picturehero image variants and <picture> markup
highlightingemitCodeHighlighter, LanguageRegistrytree-sitter syntax highlighting
assetsemitScssProcessor, StaticCopier, AssetReportSCSS and static files
feedemitFeedGenerator, FeedEntry, FeedConfigRSS and Atom documents
initnoneInitScaffolder, InitOptions, InitReporttaxus init
servenoneDevServer, DevServerConfig, FileWatcherdev server, file watching, live reload
errorallGeneratorError and the per-module errorserror types
telemetrynoneinit, init_tracing, init_with_levellogging setup

The build::pipeline modules, one per stage or output: markdown, internal_links, pages, robots, sitemap, not_found, taxonomy, feeds, search, wasm, alias.

Islands

At build time the island() Tera function renders a Yew component from taxus-common to HTML and wraps it in a mount point:

<div data-island="Counter" data-props='{"initial":3,"class":""}'>
  <!-- HTML rendered by Yew at build time -->
</div>

In the browser the embedded client (dist/wasm/client.js) finds every [data-island], reads data-props, and calls yew::Renderer::hydrate on it, attaching event handlers without re-rendering. Pages with no islands are plain HTML. See Islands Architecture.

Feature flags

FeatureDefaultEffect
lang-rustonRust syntax highlighting via tree-sitter
webp-lossyonLossy WebP hero variants via libwebp; without it WebP is lossless and images.quality is ignored for WebP

Islands are not a feature flag. The WASM client is always compiled and embedded; taxus init --no-islands only leaves the hydration script out of the scaffolded base.html.

Content Model

This chapter describes the content directory as a database: what the directory means, how it becomes an in-memory structure, and how every output is derived from it. The practical reference for frontmatter fields and file conventions lives in Content. The Theory chapters cover the same ground from the compiler's point of view and define the vocabulary in the Glossary.

The short version: the content directory is a database. Markdown files are rows, frontmatter is the schema, directories are parent–child relations, and the build projects that data into every output the site has.

The Three Layers

A Taxus build has three layers, and each concept in the system belongs to exactly one of them:

┌─────────────────────────────────────────────────────────┐
│ 1. STORAGE          content/  (*.md + frontmatter)      │
│                     Dumb by design. No logic here.      │
├─────────────────────────────────────────────────────────┤
│ 2. MODEL            Sections + Pages (a tree)           │
│                     Built once per build. All meaning.  │
├─────────────────────────────────────────────────────────┤
│ 3. PROJECTIONS      HTML, feeds, sitemap, search index, │
│                     taxonomies, aliases                 │
│                     Queries over the model. No state.   │
└─────────────────────────────────────────────────────────┘

The build walks storage once, builds the model, then derives every projection from the model. Nothing in a projection is ever written back or stored: URLs, tag indexes, summaries — all of it is computed, discarded, and recomputed on the next build.

Pages Are Rows

Every Markdown file is one page — one row. Page::from_str (taxus-generator/src/content/page.rs) parses it into:

  • Frontmatter — the columns. Typed fields: title, date, draft, slug, tags, and so on. All meaning lives here.
  • Body — the Markdown payload, rendered once and reused by every projection that needs it.

Derived values (summary, reading time, word count) are computed from these two on demand by methods on Page. They are never stored.

Sections Are Parent Pointers

In a database, a row points at its parent with a parent_id column. In Taxus, the directory is the foreign key: a file belongs to whatever directory it sits in.

Every directory becomes a SectionNode in the Site Tree (taxus-domain/src/tree.rs):

#![allow(unused)]
fn main() {
pub struct SectionNode {
    pub path: NodePath,                 // membership path, e.g. ["blog"]
    pub content_file: Option<PathBuf>,  // "blog/_index.md", or None
    pub meta: Frontmatter,              // from _index.md (or defaults)
    pub body: Option<String>,
    pub pages: Vec<PageNode>,           // direct children = directory contents
    pub subsections: Vec<SectionNode>,  // direct child directories
}
}

A section with an _index.md is itself a document: the index file has frontmatter and a body, and its frontmatter carries section behaviour — sort_by, paginate_by, pages_from. The result is a tree: the root section (the content directory itself) contains pages and subsections, recursively.

This is why content organization requires no configuration. You never declare "this post belongs to the blog"; the tree shape is the declaration, and URL paths, membership, and section indexes all follow from it.

Identity: Filename, Slug, Path, and URL Are Different Things

Four distinct concepts are easy to conflate, and the model deliberately keeps them apart:

ConceptLives inExample
Storage paththe filesystemcontent/blog/my-post.md
Slugthe tree (NodePath::last)my-post
Section paththe tree/blog/
URLderived, never stored/blog/my-post/

The slug is a computed value: the slug frontmatter field if set, otherwise derived from the filename (file_stem(), date prefix stripped, slugified). It is computed once, in RouteDiscovery::discover_tree, before the page enters the tree; see Identity. The URL is then composed as section path + slug: the Site Tree records the membership path when discovery builds it (RouteDiscovery::discover_tree), and UrlPath::from_node_path derives the address from it. ProcessedPage::effective_url_path() is that derived address, and the single accessor every downstream consumer uses. A slug replaces the last segment only: content/blog/e.md with slug = "renamed-entry" is /blog/renamed-entry/.

The design rule that falls out of this: metadata belongs in frontmatter, not in filenames. A filename is a storage detail; the model should not depend on encoding data (such as dates) into it. Filenames that carry data are a storage convention, and the loader — not the model — is the right place to interpret them.

Taxonomies Are Indexes

The tree answers where does this live — one parent per page, hierarchical. Taxonomies answer what is this about — many memberships per page, flat.

Tags, categories, and series are declared in frontmatter and derived into listing and term pages after the tree walk (taxus_domain::derivation::group_by_terms, rendered by taxus-generator/src/build/pipeline/taxonomy.rs). They are indexes over the model, never stored: add a tag to five posts and /tags/your-tag/ exists; remove the last one and it disappears. No configuration, no manifest.

URLs Are a Computed Column

No URL is ever written down anywhere in a Taxus site. Every URL — page links, feed entries, the sitemap, @/ internal links — is computed at build time from the same formula:

URL = section path + slug

This has a practical consequence that is easy to underestimate: because the formula exists in exactly one place, changing it (or changing a slug, or moving a file) updates every URL on the site consistently on the next build. You edit the derivation, not fifty outputs.

When URLs must change without breaking the world, the aliases frontmatter field bridges the gap: the build emits redirect pages from old URLs to the new derived one (build/pipeline/alias.rs).

Projections

Everything the build emits is a query over the model:

OutputDerivationCode
HTML pageseach node rendered through its templatebuild/, templates.rs
Section indexessection.pages, sorted by sort_bybuild/pipeline/pages.rs (derivation::aggregate)
Paginationslices of a section's pagesbuild/pipeline/pages.rs
Taxonomy pagesgroup pages by tags/categories/seriesbuild/pipeline/taxonomy.rs (derivation::group_by_terms)
RSS/Atom feedsrecent: dated pages, newest first, limitedbuild/pipeline/feeds.rs
Sitemapeffective_url_path() of every nodebuild/pipeline/sitemap.rs
Search indexpage bodies and titlesbuild/pipeline/search.rs
Alias redirectsaliases frontmatter → derived URLbuild/pipeline/alias.rs
Internal links@/path.md resolved against the treebuild/pipeline/internal_links.rs

Worked Example

content/
├── _index.md                ROOT section        → /
├── about/_index.md          section             → /about/
└── blog/
    ├── _index.md            section             → /blog/
    │                        (sort_by = "date", paginate_by = 10)
    └── my-post.md           page                → /blog/my-post/

The model after the walk:

root (SectionNode, path [])
├── about (PageNode, path ["about"])
└── blog (SectionNode, path ["blog"], sort_by = date)
    └── my-post (PageNode { title: "My Post", date: 2026-04-06, tags: ["rust"] })

Every output follows:

  • / lists the root's own pages — and, with pages_from = ["blog"] in its _index.md, the blog's pages too (and can paginate)
  • /blog/ lists blog.pages — its direct children — sorted by date, sliced into pages of 10
  • /blog/my-post/ renders the page
  • /tags/rust/ exists because the taxonomy index says so
  • feed.xml and sitemap.xml read the same tree through effective_url_path()
  • a template reads the same tree: section.subsections for a section's children, get_section(path="blog") and get_page(path="about") for any other node

Design Invariants

Rules the codebase tries to hold onto — useful when evaluating new features:

  1. Model before projections. Walk storage once; derive everything from the tree. Projections never read the filesystem directly.
  2. One derivation point per concept. Slugs, URLs, summaries, reading time — each is computed in one place and shared. Fixing a derivation fixes all consumers.
  3. Membership by location, meaning by frontmatter, aboutness by taxonomy. Three orthogonal axes; don't blend them.
  4. URLs are derived, never stored. Addressability is a projection of the model. Use aliases when a derived URL must change in the wild.
  5. Storage stays dumb. Conventions that smuggle data into filenames or directory names belong in the loader, interpreted into frontmatter — the model itself should never depend on them.

Configuration

Taxus uses a site.toml configuration file to define site settings and build options.

Configuration File

Create a site.toml file in your project root:

[site]
name = "My Site"
base_url = "https://example.com"
description = "A description of my site"
author = "Your Name"

[build]
content_dir = "content"
output_dir = "dist"
static_dir = "static"
styles_dir = "styles"
templates_dir = "templates"

[feed]
rss_enabled = true
atom_enabled = false
limit = 20
full_content = false

Configuration Sections

[site] Section

Site metadata and information.

FieldTypeRequiredDescription
namestringYesSite name/title
base_urlstringYesBase URL for the site (used for absolute URLs)
descriptionstringNoSite description for SEO
authorstringNoSite author name

[build] Section

Build configuration options. All fields have defaults.

FieldTypeDefaultDescription
content_dirstring"content"Directory containing Markdown content
output_dirstring"dist"Output directory for generated files
static_dirstring"static"Directory containing static assets
styles_dirstring"styles"Directory containing SCSS stylesheets
templates_dirstring"templates"Directory containing HTML templates
islandsbooltrueCompile and embed the WASM hydration client. taxus init --no-islands writes false; the build then skips dist/wasm/
searchbooltrueBuild search_index.bin. Set false on sites with no search box

[feed] Section

RSS/Atom feed configuration for content syndication.

FieldTypeDefaultDescription
rss_enabledbooltrueEnable RSS 2.0 feed generation
atom_enabledboolfalseEnable Atom feed generation
limitnumberno limitMaximum entries in feed. Unset means no limit; 0 is rejected (to disable feeds, use rss_enabled / atom_enabled)
full_contentboolfalseInclude full content vs summary
titlestringNoneCustom feed title (defaults to site name)
rss_pathstringNoneRSS feed output path (default: feed.xml)
atom_pathstringNoneAtom feed output path (default: feed.atom)
sectionsarray[]Sections whose pages the feeds syndicate, e.g. ["blog"]; empty means every section

[highlight] Section

Syntax highlighting configuration for code blocks.

FieldTypeDefaultDescription
enabledbooltrueEnable tree-sitter syntax highlighting
class_prefixstring"hl-"CSS class prefix for highlight spans

See Syntax Highlighting for details on styling and supported languages.

[images] Section

Responsive image processing configuration for hero images.

FieldTypeDefaultDescription
widthsarray[400, 800, 1200]Responsive breakpoint widths in pixels
qualitynumber80Output quality (1–100). Applies to "jpeg" and "webp" only; "png" ignores it
formatstring"webp"Output format: "webp", "jpeg" (alias "jpg"), or "png"
output_dirstring"images"Subdirectory within dist/ for processed images

See Images for details on hero images and template usage.

[markdown] Section

Markdown rendering options.

FieldTypeDefaultDescription
insert_anchor_linksboolfalseInsert a visible anchor link (#) into each heading for deep-linking

Minimal Configuration

The minimal required configuration:

[site]
name = "My Site"
base_url = "https://example.com"

All other sections ([build], [feed], [highlight], [images], [markdown]) use their default values.

Full Configuration Example

[site]
name = "My Blog"
base_url = "https://example.com"
description = "A blog about Rust and web development"
author = "Jane Doe"

[build]
content_dir = "content"
output_dir = "dist"
static_dir = "static"
styles_dir = "styles"
templates_dir = "templates"

[feed]
rss_enabled = true
atom_enabled = true
limit = 20
full_content = false
title = "My Blog Feed"
rss_path = "feed.xml"
atom_path = "feed.atom"

[highlight]
enabled = true
class_prefix = "hl-"

[images]
widths = [400, 800, 1200]
quality = 80
format = "webp"
output_dir = "images"

[markdown]
insert_anchor_links = false

Validation

Configuration is validated when loaded:

  • site.name must not be empty
  • site.base_url must not be empty and must start with http:// or https://
  • images.quality must be between 1 and 100
  • images.format must be "webp", "jpeg", "jpg", or "png"
  • images.widths must list at least one breakpoint
  • [feed] limit must not be 0 (unset means no limit)

Unknown keys are rejected in every section ([site], [build], [feed], [highlight], [images], [markdown]): a typo like ouput_dir fails the build naming the unknown field, rather than silently leaving the real key at its default.

Feed URLs

After generation, feeds are available at:

  • RSS: https://example.com/feed.xml (or custom rss_path)
  • Atom: https://example.com/feed.atom (or custom atom_path)

Content

Content in Taxus is written in Markdown files with TOML frontmatter.

For the conceptual overview — how pages, sections, taxonomies, and URLs relate — see Content Model. This chapter is the practical reference.

Content Files

Content files are stored in the content/ directory:

content/
├── _index.md      # Home page
├── about.md       # About page
└── blog/
    ├── _index.md  # Blog section index
    ├── first-post.md
    └── second-post.md

Special Files

FilePurpose
_index.mdSection index page (home page at root, section index in subdirectories)
*.mdRegular pages

Dated Filenames

A YYYY-MM-DD- prefix on a content filename is treated as a storage convention, not as part of the slug:

  • The prefix is stripped from the slug: content/blog/2026-04-06-my-post.md is served at /blog/my-post/.
  • If the page has no date in frontmatter, the prefix supplies the default publication date. A frontmatter date always wins.
  • Stems that are only a date (2026-04-06.md) and filenames whose prefix is not a valid date (2026-13-45-post.md) are left untouched.

This keeps dates out of URLs while letting filenames sort chronologically on disk. See Content Model for the design rule behind it.

Co-located Assets

Non-Markdown files in the content directory are automatically copied to the output directory, preserving their relative paths. This allows you to keep images and other assets alongside the content that uses them.

content/
├── blog/
│   ├── first-post.md
│   ├── photo.jpg          → dist/blog/photo.jpg
│   └── diagrams/
│       └── architecture.png  → dist/blog/diagrams/architecture.png
└── about/
    ├── about.md
    └── headshot.png       → dist/about/headshot.png

Referencing co-located assets:

![Photo](photo.jpg)
![Diagram](diagrams/architecture.png)

Or use absolute paths from the site root:

![Photo](/blog/photo.jpg)

When to use co-located assets:

  • Blog post images and diagrams
  • Page-specific downloads (PDFs, etc.)
  • Content-specific data files

For global assets (logos, favicons, shared images), use the static/ directory instead.

Hero Images

Pages can have a hero image — a prominent image displayed at the top of the page. Place the image file next to your markdown and reference it in frontmatter:

+++
title = "My Post"
hero_image = "sunset.jpg"
hero_alt = "A dramatic mountain sunset"
date = 2024-03-15
+++

# My Post

Taxus automatically:

  • Generates responsive variants at multiple widths (default: 400, 800, 1200)
  • Converts to WebP (or JPEG/PNG if configured)
  • Produces a <picture> element with srcset for optimal browser delivery
  • Falls back to the page title if hero_alt is not provided

See Images for full configuration and template usage.

Frontmatter

Each Markdown file can include TOML frontmatter enclosed in +++:

+++
title = "Page Title"
description = "A brief description"
date = 2024-01-15
template = "custom.html"
draft = false

[extra]
author = "John Doe"
tags = ["rust", "web"]
+++

# Page Content

Your markdown content here.

Frontmatter Fields

FieldTypeRequiredDefaultDescription
titlestringNo*""Page title
descriptionstringNoNonePage description for SEO
datedateNoNonePublication date (YYYY-MM-DD). Falls back to a YYYY-MM-DD- filename prefix when omitted. A full TOML datetime (2026-04-03T14:30:00Z) is accepted but truncated to its date part, with a build-log warning — taxus dates are day-granular by design
updateddateNoNoneLast updated date (same truncation rule as date)
templatestringNo"page.html"Template override
draftboolNofalseDraft status
summarystringNoNoneCustom summary/excerpt
slugstringNoNoneCustom last URL segment; the page stays in its section
aliasesarrayNo[]Old URLs that redirect to this page
tagsarrayNo[]Tags (e.g., ["rust", "web"])
categoriesarrayNo[]Categories (e.g., ["tutorial"])
seriesstringNoNoneSeries name (e.g., "Learning Rust")
sort_bystringNo"date"Sort order for sections: "date" (newest first, undated last), "title" (case-insensitive), "weight" (lowest first), "none" (tree order)
paginate_bynumberNo0Items per page (0 = no pagination)
paginate_templatestringNoNoneTemplate for paginated pages
pages_fromarrayNo[]Sections whose direct pages this section also lists (e.g. ["blog"]); see Section listings
weightnumberNo0Weight for manual ordering
hero_imagestringNoNoneRelative path to a co-located hero image
hero_altstringNoNoneAlt text for hero image (falls back to page title)
extratableNoNoneCustom metadata

*Title is recommended but not required by the library.

Date Format

Dates use the TOML date format:

date = 2024-01-15
updated = 2024-02-20

Extra Metadata

The extra field allows custom metadata:

[extra]
author = "Jane Doe"
custom_field = "any value"

Access extra metadata in templates through the extra variable.

URL Path Generation

Content files are mapped to URL paths:

Content FileURL Path
content/_index.md/
content/about.md/about/
content/blog/_index.md/blog/
content/blog/my-post.md/blog/my-post/

Custom Slugs

Override the default URL path using the slug frontmatter field:

+++
title = "My First Post"
slug = "hello-world"
+++

This creates /blog/hello-world/ instead of /blog/my-first-post/. The slug replaces only the last segment: the page stays in its section, and every consumer — page links, section listings, feeds, the sitemap, the search index, alias redirects — agrees on the URL because all of them derive it from the Site Tree. To keep an old address working, add it to aliases.

Markdown Support

Taxus supports standard Markdown syntax:

Headings

# Heading 1
## Heading 2
### Heading 3

Lists

- Unordered item
- Another item

1. Ordered item
2. Another item
[Link text](https://example.com)

Reference other pages by their content file path with build-time validation:

See my [about page](@/about.md) for more details.
Check out this [blog post](@/blog/first-post.md).

The @/ prefix signals an internal link. Paths are relative to content/.

Markdown LinkResolved HTML
[about page](@/about.md)<a href="/about/">about page</a>
[blog post](@/blog/post.md)<a href="/blog/post/">blog post</a>

If an internal link references a non-existent file, the build fails with a clear error.

Shortcodes

Embeds and reusable markup live in Shortcodes:

{{ youtube(id="dQw4w9WgXcQ") }}

Images

![Alt text](/images/photo.png)

Code

Inline `code` in text.

```rust
fn main() {
    println!("Hello, world!");
}
```

Code blocks with a language identifier are highlighted using tree-sitter. See Syntax Highlighting for configuration and supported languages.

Blockquotes

> This is a blockquote.

Blog Features

Summary and Excerpt

Taxus automatically extracts a summary for each page:

  1. Automatic extraction: First paragraph of content
  2. Manual marker: Use <!-- more --> to mark where summary ends
  3. Frontmatter override: Set a custom summary in frontmatter
+++
title = "My Post"
summary = "A custom summary for SEO"
+++

This is the first paragraph.

<!-- more -->

The rest appears after the summary...

Access in templates: {{ page.summary }}

Reading Time and Word Count

Each page calculates reading time (200 words/minute) and word count:

<span class="reading-time">{{ page.reading_time }} min read</span>
<span class="word-count">{{ page.word_count }} words</span>

Taxonomies

Taxus supports three taxonomy types:

Tags

Multiple keywords associated with a page:

+++
title = "Introduction to Rust"
tags = ["rust", "programming", "tutorial"]
+++

Categories

Broader classifications (also multiple):

+++
title = "My Tutorial"
categories = ["tutorial", "beginner"]
+++

Series

Groups related posts in a sequence (single value):

+++
title = "Part 1: Getting Started"
series = "Learning Rust"
+++

Taxonomy Pages

Taxus generates taxonomy listing and term pages automatically when the corresponding templates exist. The scaffold (taxus init) creates all six templates:

TemplateURLPurpose
tags.html/tags/Lists all tags
tags_term.html/tags/rust/Lists pages with the "rust" tag
categories.html/categories/Lists all categories
categories_term.html/categories/tutorial/Lists pages in "tutorial" category
series.html/series/Lists all series
series_term.html/series/learning-rust/Lists pages in "Learning Rust" series

If a template is missing, that particular page is skipped silently. See Templates for the full taxonomy template context and examples.

Section listings

A section's section.pages are its direct children: the pages in its own directory, and nothing deeper. blog/ lists blog/my-post.md but not blog/2026/older-post.md, and the root _index.md lists only pages at the top of content/.

To list pages a section does not own — the classic "recent posts on the homepage" — declare where they come from:

+++
title = "Home"
pages_from = ["blog"]
+++

pages_from names sections by their content-relative path ("blog", "blog/2026"). Each donor contributes its own direct pages; the merged list is deduplicated and sorted by this section's sort_by, and paginate_by slices it like any other listing. A pages_from entry that names a section that does not exist is ignored with a warning in the build log.

Pagination

Enable pagination in a section's _index.md:

+++
title = "Blog"
sort_by = "date"
paginate_by = 10
+++

# Blog

Welcome to my blog!

Pagination Configuration

FieldTypeDefaultDescription
sort_bystring"date"Sort order: "date" (newest first, undated last), "title" (case-insensitive), "weight" (lowest first), "none" (tree order)
paginate_bynumber0Pages per slice (0 = no pagination)
paginate_templatestringNoneTemplate for paginated pages

Pagination URLs

  • /blog/ — First page
  • /blog/page/2/ — Second page
  • /blog/page/3/ — Third page

Pagination in Templates

{% if section.pagination %}
<nav class="pagination">
  {% if section.pagination.prev %}
  <a href="{{ section.pagination.prev }}">← Previous</a>
  {% endif %}
  
  <span>Page {{ section.pagination.current }} of {{ section.pagination.total }}</span>
  
  {% if section.pagination.next %}
  <a href="{{ section.pagination.next }}">Next →</a>
  {% endif %}
</nav>
{% endif %}

RSS/Atom Feeds

Configure feeds in site.toml:

[feed]
rss_enabled = true
atom_enabled = true
limit = 20
full_content = false
sections = ["blog"]

What a feed contains

Feeds syndicate dated pages: every non-draft page with a date, newest first, up to limit entries. Section index pages (_index.md) and undated pages such as /about/ are never feed entries — an undated page has no publication date to announce, and stamping it with the build time would re-announce it to subscribers on every build.

sections narrows the feed to pages under the named sections (content-relative paths such as "blog" or "blog/2026"; a page anywhere beneath a listed section counts). Leave it out to syndicate dated pages from the whole site. An entry that names no section is ignored with a warning in the build log.

Feed Entry Fields

FieldSource
TitlePage title
DescriptionPage description or auto-extracted summary
URLFull page URL (base_url + path)
PublishedPage date field
UpdatedPage updated field (Atom only)

Feed URLs

  • RSS: https://example.com/feed.xml (or custom rss_path)
  • Atom: https://example.com/feed.atom (or custom atom_path)

Sitemap Generation

Taxus generates sitemap.xml automatically:

  • All routes included (pages and sections)
  • Draft pages excluded
  • Last modification date from page date field
  • Priorities: home 1.0, sections 0.8, pages 0.7

Sitemap URL

https://example.com/sitemap.xml

Robots.txt Generation

Taxus generates robots.txt automatically if no static/robots.txt exists:

User-agent: *
Allow: /

Sitemap: https://example.com/sitemap.xml

To provide a custom robots.txt, create it in static/.

Shortcodes

Shortcodes are content-time macros: small invocations in Markdown that render to HTML during the build, between internal-link resolution and Markdown rendering. They are how content reaches everything from simple embeds up to interactive islands — without editing templates.

Watch this:

{{ youtube(id="dQw4w9WgXcQ", title="A demonstration") }}

Syntax

Two forms, Hugo-shaped:

Inline — {{ name(arg="value") }}, replaced by its rendering:

{{ image(src="@/blog/photo.jpg", alt="A co-located photo", class="wide") }}

Block — {{% name %}}…{{% /name %}}, with a body:

{{% box(class="callout") %}}
**Bold** works here — the body is Markdown.
{{% /box %}}

Arguments are named (k=v, comma-separated). Values are strings ("quoted" or 'quoted'), integers, floats, or true/false. Shortcode names are letters, digits, - and _, starting with a letter.

Where shortcodes come from

Built-ins ship with taxus:

NameKindArgs
imageinlinesrc (required; @/path refs resolve to the content-relative URL where co-located assets live), alt, class
youtubeinlineid (required), title
islandinlinecomponent (required), plus the component's props — see below

Your own live in shortcodes/ at the site root — one .html file per shortcode, named by its file stem. Tera renders them with the same filters templates use (term_slug, slugify, date, …). Nothing to register; drop the file in. taxus init does not scaffold the directory — it appears when you need it.

A file whose name collides with a built-in is a build error: built-in names are load-bearing.

Template context

Your shortcode templates render with:

VariableMeaning
args.*The invocation's arguments (strings are HTML-escaped on output)
bodyBlock form only: the body, already rendered as Markdown — emit with {{ body | safe }}
page.title, page.description, page.draft, page.dateThe containing page's frontmatter
site_name, base_urlSite identity

Arguments are autoescaped; body is pre-rendered HTML and needs | safe to pass through. Keep it that way — args come from content, bodies are your own rendered Markdown.

The island shortcode

The same islands templates place with {{ island(...) | safe }} can be placed from content — one system, one shared dispatch:

{{ island(component="Counter", initial=5) }}

component must be in the island registry (Counter, SearchBox); the remaining arguments are that component's props (same names and defaults the template function documents). Prefer the block placement — an island on its own line renders as its own HTML block; an island inline in a paragraph nests a <div> inside <p>, which browsers tolerate but is best avoided.

An unknown component is a hard build error naming the file and the component.

Code constructs are immune

Shortcode uses inside fenced code blocks, indented code blocks, or inline code spans are never expanded — documenting an example is safe:

```text
{{ image(src="never-expanded.png") }}
```

The immunity is structural (the Markdown parser reports code ranges; the expander skips them), the same machinery that protects @/ links.

Errors

  • Unknown shortcode — no built-in and no shortcodes/{name}.html — fails the build naming the content file and the name.
  • Malformed arguments fail the build with the byte position.
  • Name collision with a built-in fails the build.

What shortcodes are not

  • Not nested (v1): a shortcode inside another shortcode's body is passed through as text to the outer template.
  • Not in summaries, word counts, or search — shortcode spans are removed before those derivations, so {{ image(alt="sunset") }} never leaks into a feed summary or the search index.
  • Not runtime — they render at build time. Interactivity is the island tier, reached through the island shortcode.

Templates

Templates define the HTML structure for rendered pages using the Tera template engine.

Template Location

Templates are stored in the templates/ directory:

templates/
├── base.html              # Base template with common structure
├── page.html              # Single page template
├── section.html           # Section/list template (e.g., blog)
├── tags.html              # Tag listing page (all tags)
├── tags_term.html         # Individual tag page (e.g., /tags/rust/)
├── categories.html        # Category listing page (all categories)
├── categories_term.html   # Individual category page (e.g., /categories/tutorial/)
├── series.html            # Series listing page (all series)
├── series_term.html       # Individual series page (e.g., /series/learning-rust/)
└── 404.html               # Not found page

Template Engine

Taxus uses Tera, a Jinja2-like template engine for Rust:

  • Variables: {{ variable }} syntax
  • Filters: {{ content | safe }} for unescaped HTML
  • Conditionals: {% if condition %}...{% endif %}
  • Loops: {% for item in items %}...{% endfor %}
  • Template Inheritance: {% extends "base.html" %} and {% block name %}
  • Includes: {% include "partial.html" %}

Base Template

The base template defines the common HTML structure:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{% block title %}{{ site.name }}{% endblock %}</title>

    {% if page.permalink %}
    <link rel="canonical" href="{{ page.permalink }}" />
    <meta property="og:url" content="{{ page.permalink }}" />
    {% endif %}

    <link rel="stylesheet" href="/css/main.css" />
    <link rel="icon" href="/static/favicon.png" />
  </head>
  <body>
    <header>
      <nav>
        <a href="/">Home</a>
        <a href="/about/">About</a>
      </nav>
    </header>

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

    <footer>
      <p>&copy; {{ now.year }} {{ site.author | default(value="") }}</p>
    </footer>

    <script src="/static/scripts.js"></script>
  </body>
</html>

Page Template

Page templates extend the base template:

{% extends "base.html" %}

{% block title %}{{ page.title }} - {{ site.name }}{% endblock %}

{% block content %}
<article>
  {% if page.hero %}
  <picture>
    <source srcset="{{ page.hero.srcset | safe }}" type="{{ page.hero.mime_type }}">
    <img src="{{ page.hero.src | safe }}" alt="{{ page.hero.alt }}"
         width="{{ page.hero.width }}" height="{{ page.hero.height }}"
         loading="eager" decoding="async">
  </picture>
  {% endif %}

  <h1>{{ page.title }}</h1>

  {% if page.description %}
  <p class="description">{{ page.description }}</p>
  {% endif %}

  {% if page.date %}
  <time datetime="{{ page.date }}">{{ page.date }}</time>
  {% endif %}

  {% if page.tags %}
  <div class="tags">
    {% for tag in page.tags %}
    <a href="/tags/{{ tag | term_slug }}/">{{ tag }}</a>
    {% endfor %}
  </div>
  {% endif %}

  <div class="content">{{ page.content | safe }}</div>
</article>
{% endblock %}

Section Template

Section templates render lists of pages:

{% extends "base.html" %}

{% block title %}{{ section.title }} - {{ site.name }}{% endblock %}

{% block content %}
<section>
  <h1>{{ section.title }}</h1>

  {% if section.description %}
  <p class="description">{{ section.description }}</p>
  {% endif %}

  {% if section.content %}
  <div class="section-content">{{ section.content | safe }}</div>
  {% endif %}

  <ul class="page-list">
    {% for page in section.pages %}
    <li>
      <a href="{{ page.path }}">
        <span class="title">{{ page.title }}</span>
        {% if page.date %}
        <time datetime="{{ page.date }}">{{ page.date }}</time>
        {% endif %}
        <span class="reading-time">{{ page.reading_time }} min read</span>
      </a>
      {% if page.summary %}
      <p class="summary">{{ page.summary }}</p>
      {% endif %}
    </li>
    {% endfor %}
  </ul>

  {% if section.pagination %}
  <nav class="pagination">
    {% if section.pagination.prev %}
    <a href="{{ section.pagination.prev }}">← Previous</a>
    {% endif %}
    <span>Page {{ section.pagination.current }} of {{ section.pagination.total }}</span>
    {% if section.pagination.next %}
    <a href="{{ section.pagination.next }}">Next →</a>
    {% endif %}
  </nav>
  {% endif %}
</section>
{% endblock %}

Taxonomy Templates

Taxus generates taxonomy listing and term pages when the corresponding templates exist. The scaffold (taxus init) creates all six templates automatically.

Taxonomy List Templates

List templates render all terms for a taxonomy kind:

  • tags.html — /tags/
  • categories.html — /categories/
  • series.html — /series/

Each receives extra.taxonomy with:

VariableTypeDescription
extra.taxonomy.kindStringTaxonomy kind: "Tags", "Categories", or "Series"
extra.taxonomy.pathStringURL path (e.g., "/tags/")
extra.taxonomy.termsArrayList of term contexts

Example tags.html:

{% extends "base.html" %}

{% block title %}Tags - {{ site.name }}{% endblock %}

{% block content %}
<section>
  <h1>Tags</h1>
  {% if extra.taxonomy.terms %}
  <ul>
    {% for term in extra.taxonomy.terms %}
    <li>
      <a href="{{ term.path }}">{{ term.name }} ({{ term.page_count }})</a>
    </li>
    {% endfor %}
  </ul>
  {% endif %}
</section>
{% endblock %}

Taxonomy Term Templates

Term templates render pages for a specific term:

  • tags_term.html — /tags/rust/
  • categories_term.html — /categories/tutorial/
  • series_term.html — /series/learning-rust/

Each receives extra.taxonomy with:

VariableTypeDescription
extra.taxonomy.kindStringTaxonomy kind: "Tags", "Categories", or "Series"
extra.taxonomy.nameStringDisplay name (e.g., "Rust")
extra.taxonomy.slugStringURL-safe slug (e.g., "rust")
extra.taxonomy.pathStringURL path (e.g., "/tags/rust/")
extra.taxonomy.page_countNumberNumber of pages with this term
extra.taxonomy.pagesArrayList of page objects with title, path, description, etc.

Example tags_term.html:

{% extends "base.html" %}

{% block title %}Tag: {{ extra.taxonomy.name }} - {{ site.name }}{% endblock %}

{% block content %}
<section>
  <h1>Tagged "{{ extra.taxonomy.name }}"</h1>
  <p>{{ extra.taxonomy.page_count }} post(s)</p>
  <ul>
    {% for page in extra.taxonomy.pages %}
    <li><a href="{{ page.path }}">{{ page.title }}</a></li>
    {% endfor %}
  </ul>
</section>
{% endblock %}

Term Context in List Templates

When iterating extra.taxonomy.terms, each term has:

VariableTypeDescription
term.nameStringDisplay name
term.slugStringURL-safe slug
term.pathStringURL path
term.page_countNumberNumber of pages

Available Variables

Site Context

VariableTypeDescription
site.nameStringSite name from configuration
site.base_urlStringBase URL from configuration
site.descriptionString?Optional site description
site.authorString?Optional site author

Page Context

VariableTypeDescription
page.titleStringPage title from frontmatter
page.descriptionString?Optional page description
page.taglineString?Optional tagline from frontmatter
page.pathStringURL path (e.g., /about/), derived from the page's node path
page.permalinkStringAbsolute URL (e.g., https://example.com/about/)
page.contentStringRendered HTML content
page.raw_contentStringRaw markdown content
page.dateString?Publication date (ISO 8601)
page.draftBooleanWhether page is a draft
page.summaryStringSummary/excerpt for the page
page.word_countNumberWord count
page.reading_timeNumberEstimated reading time in minutes
page.tocArrayTable of contents: entries with level, text, id, children (absent when the page has no headings)
page.weightNumberFrontmatter weight (0 when unset); sections with sort_by = "weight" list pages in this order
page.tagsArrayTags for the page
page.categoriesArrayCategories for the page
page.seriesString?Series name
page.heroObject?Hero image context (see below)

Hero Image Context

When a page has hero_image in its frontmatter, page.hero contains:

VariableTypeDescription
page.hero.srcStringFallback <img> src (middle variant)
page.hero.srcsetStringFull srcset string for <source>
page.hero.widthNumberOriginal image width
page.hero.heightNumberOriginal image height
page.hero.altStringAlt text (from hero_alt, or page title)
page.hero.mime_typeStringMIME type (e.g., "image/webp")

Example usage:

{% if page.hero %}
<picture>
  <source srcset="{{ page.hero.srcset | safe }}" type="{{ page.hero.mime_type }}">
  <img src="{{ page.hero.src | safe }}" alt="{{ page.hero.alt }}"
       width="{{ page.hero.width }}" height="{{ page.hero.height }}"
       loading="eager" decoding="async">
</picture>
{% endif %}

See Images for the complete guide.

Section Context

VariableTypeDescription
section.titleStringSection title
section.descriptionString?Optional section description
section.pathStringSection URL path
section.permalinkStringAbsolute URL of the section
section.contentString?Section HTML content
section.tocArrayTable of contents of the section's _index.md
section.pagesArrayThe section's direct child pages, plus the direct pages of any pages_from sections, sorted by sort_by
section.subsectionsArrayDirect child sections, in slug order; each has title, description, path, permalink
section.paginationObject?Pagination information

Pagination Context

VariableTypeDescription
section.pagination.currentNumberCurrent page (1-indexed)
section.pagination.totalNumberTotal pages
section.pagination.per_pageNumberItems per page
section.pagination.total_itemsNumberTotal items across all pages
section.pagination.prevString?URL to previous page
section.pagination.nextString?URL to next page
section.pagination.firstStringURL to first page
section.pagination.lastStringURL to last page

Current Date

VariableTypeDescription
now.yearNumberCurrent year (e.g., 2024)

Useful for copyright notices: &copy; {{ now.year }}

Extra Variables

Custom variables from frontmatter extra field:

+++
title = "My Page"
[extra]
author = "John Doe"
custom = "value"
+++

Access in templates:

<p>Author: {{ extra.author }}</p>
<p>Custom: {{ extra.custom }}</p>

Template Inheritance

Templates can extend other templates:

base.html:

<html>
  <head>{% block head %}{% endblock %}</head>
  <body>{% block body %}{% endblock %}</body>
</html>

page.html:

{% extends "base.html" %}

{% block head %}
<title>{{ page.title }}</title>
{% endblock %}

{% block body %}
<h1>{{ page.title }}</h1>
{{ page.content | safe }}
{% endblock %}

Tree Functions

Templates can reach any part of the Site Tree, not just the section being rendered (#69). This is how a home page lists recent posts it does not own:

{% set blog = get_section(path="blog") %}
<ul>
{% for page in blog.pages | slice(end=5) %}
  <li><a href="{{ page.path }}">{{ page.title }}</a></li>
{% endfor %}
</ul>

{% for sub in section.subsections %}
  <a href="{{ sub.path }}">{{ sub.title }}</a>
{% endfor %}

{% set about = get_page(path="about") %}
<a href="{{ about.path }}">{{ about.title }}</a>
FunctionReturns
get_section(path="blog")The section as a section object: title, description, path, permalink, content, toc, pages (sorted by that section's sort_by, including its pages_from), subsections. pagination is never set — slicing belongs to the section's own render
get_page(path="blog/my-post")The page as a page object, with the same fields as page

Paths are content-relative tree paths, the same form pages_from uses: blog, blog/2026, blog/my-post. Leading and trailing slashes are ignored, a section can also be named by its index file (blog/_index.md), a page by its content file (blog/2026-04-06-my-post.md), and the root is "", / or _index.md. A directory without an _index.md is still a section (with an empty title and no content). Drafts are absent unless the build includes them. A path that names nothing fails the render, so a typo stops the build instead of producing an empty list.

Filters

Commonly used filters:

FilterDescription
safeOutput without HTML escaping
default(value="...")Provide default value
upperConvert to uppercase
lowerConvert to lowercase
trimRemove leading/trailing whitespace
firstGet first element of array
lastGet last element of array
lengthGet length of string/array
join(sep=", ")Join array with separator
slugifyConvert to URL-safe slug (ASCII)
term_slugTaxonomy term slug: keeps non-ASCII letters (Café → café); use for tag/category/series links

Custom Templates

Pages can specify custom templates in frontmatter:

+++
title = "Special Page"
template = "custom.html"
+++

This page uses custom.html instead of page.html.

Island Components

Use the island() function to embed interactive Yew components:

{% block content %}
  {{ page.content | safe }}
  {{ island(component="Counter", initial=5) | safe }}
{% endblock %}

Important: Always use | safe after island() to prevent HTML escaping.

See Islands Architecture for the complete guide.

Images

Taxus provides built-in responsive image processing for hero images — automatically generating multiple size variants, converting to modern formats, and producing the <picture> markup needed for optimal delivery.

Hero Images

Hero images are large, prominent images displayed at the top of a page. Taxus handles resizing, format conversion, and responsive markup automatically.

Adding a Hero Image

Place the image file alongside your markdown content (co-located), then reference it in frontmatter:

+++
title = "My Post"
hero_image = "sunset.jpg"
hero_alt = "A dramatic mountain sunset"
date = 2024-03-15
+++

# My Post

Content goes here...
FieldTypeRequiredDefaultDescription
hero_imagestringNoNoneRelative path to a co-located image file
hero_altstringNoNoneAlt text; falls back to page title

The hero_image path is resolved relative to the content file's directory. If your markdown is at content/blog/my-post.md, then hero_image = "photo.jpg" looks for content/blog/photo.jpg.

What Taxus Does

When a page has a hero_image, the build pipeline:

  1. Reads the source image and records its dimensions
  2. Generates responsive variants at each configured width (default: 400, 800, 1200), clamped to the original — a width at or beyond the original ships the original pixels, and clamped widths deduplicate, so a small source yields fewer variants, never duplicates
  3. Converts to the configured format (default: WebP)
  4. Writes variant files to the output directory (default: dist/images/)
  5. Attaches image metadata to the page's template context as page.hero, and carries it on the rendered page (RenderedPage::hero_image) for downstream consumers

images.widths must list at least one breakpoint; an empty list is a configuration error.

Variant filenames include a content hash for cache-busting:

dist/images/sunset-a3b2c1-400w.webp
dist/images/sunset-a3b2c1-800w.webp
dist/images/sunset-a3b2c1-1200w.webp

The hash is derived from the image's bytes (and the encoding quality), so the same image gets the same filenames on every machine. If all variants already exist on disk, processing is skipped — no redundant re-encoding.

Rendering in Templates

The page.hero object is available in page templates when a hero image is set:

{% if page.hero %}
<picture>
  <source srcset="{{ page.hero.srcset | safe }}" type="{{ page.hero.mime_type }}">
  <img src="{{ page.hero.src | safe }}"
       alt="{{ page.hero.alt }}"
       width="{{ page.hero.width }}"
       height="{{ page.hero.height }}"
       loading="eager"
       decoding="async">
</picture>
{% endif %}

Important: Use | safe on srcset and src to prevent Tera from HTML-escaping the URLs.

Hero Context Variables

VariableTypeDescription
page.hero.srcStringFallback <img> src (middle variant)
page.hero.srcsetStringFull srcset string for <source> element
page.hero.widthNumberOriginal image width (for layout shift prevention)
page.hero.heightNumberOriginal image height (for layout shift prevention)
page.hero.altStringAlt text (from hero_alt, or page title as fallback)
page.hero.mime_typeStringMIME type (e.g., "image/webp")

Alt Text Fallback

If hero_alt is not set in frontmatter, Taxus falls back to the page title:

+++
title = "Announcing Taxus 1.0"
hero_image = "banner.jpg"
+++

In this case, page.hero.alt will be "Announcing Taxus 1.0".

Image Configuration

Configure image processing in site.toml under the [images] section:

[images]
widths = [400, 800, 1200]
quality = 80
format = "webp"
output_dir = "images"
FieldTypeDefaultDescription
widthsarray[400, 800, 1200]Responsive breakpoint widths in pixels
qualitynumber80Output quality (1–100). Applies to "jpeg" and "webp" only; "png" ignores it
formatstring"webp"Output format: "webp", "jpeg" (alias "jpg"), or "png"
output_dirstring"images"Subdirectory within dist/ for processed images

quality outside 1–100 or an unknown format is a configuration error.

Omitting the Section

If [images] is not present in site.toml, all defaults are used.

Lossy WebP and the webp-lossy Feature

WebP variants are encoded with libwebp (the webp crate) at the configured quality. This is behind the webp-lossy cargo feature, which is enabled by default. If you build Taxus with --no-default-features, the C dependency is dropped and WebP output falls back to the image crate's lossless encoder: quality is then ignored for WebP and a warning is logged once per build. JPEG always honours quality; PNG is always lossless.

How It Works

Build Pipeline

Image processing runs as Stage 4 of the build pipeline, between content processing and co-located asset copying:

  1. Discover routes → 2. Load templates → 3. Process content → 4. Process images → 5. Copy co-located assets → ...

This means hero image variants are generated before assets are copied and pages are rendered, ensuring the image metadata is available in template context.

Caching

The image processor uses content-hash-based filenames. The hash is a digest of the source file's bytes and (for lossy formats) the quality setting — not its path or modification time, so a fresh clone or a touch produces the same variant names. If all expected variant files already exist on disk with the correct hash, the processor skips re-encoding and rebuilds the metadata from the cache. This makes subsequent builds fast, editing the image or changing quality in site.toml re-encodes on the next build, and unchanged images keep stable URLs across deployments.

Small Source Images

If the source image is smaller than a configured breakpoint width, Taxus does not upscale it. Instead, the original dimensions are used for that variant, preventing quality loss from upscaling.

Dry Run

When running taxus build --dry-run, the image processor calculates metadata and variant paths without reading pixel data or writing files. This allows you to inspect what would be generated without the I/O cost.

Syntax Highlighting

Taxus provides syntax highlighting for code blocks using tree-sitter, a fast and accurate parsing library.

Overview

Code blocks in Markdown are automatically highlighted during the build process. Tree-sitter provides:

  • Accurate parsing: Uses real language grammars, not regex patterns
  • Fast performance: Incremental parsing for quick builds
  • Rich highlighting: Detailed semantic token classification

Usage

Add a language identifier to your fenced code blocks:

```rust
fn main() {
    println!("Hello, world!");
}
```

This renders with syntax highlighting:

fn main() {
    println!("Hello, world!");
}

Supported Languages

Languages are enabled via Cargo features when building Taxus:

LanguageIdentifierAliases
Rustrustrs

Additional languages can be added by enabling more tree-sitter grammar features.

Configuration

Configure syntax highlighting in site.toml:

[highlight]
enabled = true
class_prefix = "hl-"

Configuration Options

FieldTypeDefaultDescription
enabledbooltrueEnable or disable syntax highlighting
class_prefixstring"hl-"CSS class prefix for highlight spans

Disabling Highlighting

To disable highlighting globally:

[highlight]
enabled = false

Code blocks will still render, but without syntax highlighting spans.

Custom Class Prefix

Use a custom prefix for CSS classes:

[highlight]
class_prefix = "syntax-"

This generates classes like syntax-keyword, syntax-string, etc.

Highlight Classes

Taxus generates semantic CSS classes for each token type:

ClassDescription
hl-keywordKeywords (fn, let, struct, impl, etc.)
hl-stringString literals
hl-string-specialSpecial strings (raw strings, format strings)
hl-commentComments
hl-functionFunction names
hl-function-builtinBuilt-in functions
hl-function-macroMacro invocations
hl-typeType names
hl-type-builtinBuilt-in types (u32, str, etc.)
hl-constantConstants
hl-constant-builtinBuilt-in constants
hl-numberNumeric literals
hl-constructorConstructors (Some, Ok, Err, etc.)
hl-variableVariables
hl-variable-builtinBuilt-in variables (self, Self)
hl-variable-parameterFunction parameters
hl-propertyStruct fields/properties
hl-labelLifetimes and labels
hl-attributeAttributes (#[derive], #[cfg], etc.)
hl-operatorOperators (=, +, -, etc.)
hl-punctuationGeneral punctuation
hl-punctuation-bracketBrackets and braces
hl-punctuation-delimiterCommas, semicolons
hl-tagHTML/XML tags

Styling

Built-in Themes

Taxus includes two highlight themes:

  • Light theme: _highlight-light.scss — GitHub-inspired colors
  • Dark theme: _highlight-dark.scss — Catppuccin-inspired colors

Import in your main stylesheet:

// Light theme (default)
@use "highlight-light";

// Or dark theme
@use "highlight-dark";

Custom Themes

Create custom themes by styling the highlight classes:

// Custom syntax highlighting theme
.hl-keyword { color: #ff79c6; }
.hl-string { color: #f1fa8c; }
.hl-comment { color: #6272a4; font-style: italic; }
.hl-function { color: #50fa7b; }
.hl-type { color: #8be9fd; }
.hl-number { color: #bd93f9; }

Base Styles

Include base styles for code blocks:

pre.highlight {
  background-color: #f6f8fa;
  border: 1px solid #e1e4e8;
  border-radius: 6px;
  padding: 16px;
  overflow-x: auto;
  font-size: 0.875rem;
  line-height: 1.45;

  code {
    background: none;
    padding: 0;
    font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
  }
}

Unsupported Languages

When a language is not supported, the code block renders as plain text with HTML escaping:

```brainfuck
++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>
```

The code will display in a code block without syntax highlighting, but HTML special characters are properly escaped.

Adding New Languages

To add support for additional languages:

  1. Add the tree-sitter grammar to taxus-generator/Cargo.toml as an optional dependency
  2. Create a feature flag for the language
  3. Add LanguageSpec registration in taxus-generator/src/highlighting/languages.rs
  4. Add highlight queries in taxus-generator/src/highlighting/queries/<lang>/highlights.scm

Example for adding JavaScript support:

# Cargo.toml
[dependencies]
tree-sitter-javascript = { version = "0.20", optional = true }

[features]
lang-javascript = ["tree-sitter-javascript"]
#![allow(unused)]
fn main() {
// languages.rs
#[cfg(feature = "lang-javascript")]
fn register_javascript(&mut self) {
    let spec = LanguageSpec {
        name: "javascript",
        language: tree_sitter_javascript::LANGUAGE.into(),
        highlight_query: include_str!("queries/javascript/highlights.scm"),
        injection_query: None,
        locals_query: None,
    };
    self.register(spec, &["js", "javascript"]);
}
}

Islands Architecture

Taxus implements the Islands Architecture: Tera templates render the static "sea" of HTML, while Yew components are pre-rendered server-side into "islands" and then hydrated by the WASM client in the browser.

How It Works

BUILD TIME (generator)                BROWSER
─────────────────────                 ───────
1. Tera renders page shell            4. HTML is immediately visible (no JS needed)
2. island() calls Yew SSR             5. WASM loads asynchronously
3. SSR HTML + props JSON emitted      6. Yew hydrates mount points → interactive

Build-Time: Server-Side Rendering

When you use {{ island(component="Counter", initial=5) | safe }} in a template:

  1. The island() Tera function is called during template rendering
  2. Yew SSR renders the component to HTML
  3. The output is wrapped in a mount point div with serialized props:
<div data-island="Counter" data-props='{&quot;initial&quot;:5}'>
  <!-- Pre-rendered by Yew SSR: -->
  <div class="counter"><span>5</span><button>+</button></div>
</div>

Browser-Time: Hydration

When the page loads:

  1. The pre-rendered HTML is immediately visible (no JavaScript required)
  2. The WASM bundle loads asynchronously
  3. The client finds all [data-island] elements in the DOM
  4. For each island: deserialize data-props, call yew::Renderer::hydrate()
  5. The component becomes interactive without re-rendering

Two-Tier Interactivity

taxus supports two layers of interactivity:

TierTechnologyUse For
1 — Generalstatic/scripts.js (vanilla JS)DOM manipulation, toggles, analytics, lightweight events
2 — PerformanceYew WASM islandHeavy computation, complex reactive state, data-intensive UI

Use vanilla JS for simple interactions; reserve Yew islands for components that benefit from the Yew component model.

Islands can be placed from templates ({{ island(...) | safe }}, below) or from content ({{ island(component="...") }} as a shortcode) — both funnel through the same dispatch and emit the same markup.

Using Islands in Templates

The island() Tera Function

Use the island() function in any .html template:

{% extends "base.html" %}

{% block content %}
  {{ page.content | safe }}
  
  <h2>Interactive Counter</h2>
  {{ island(component="Counter", initial=5) | safe }}
{% endblock %}

Important: Always use | safe after island() to prevent HTML escaping.

Passing Props

Props are passed as keyword arguments to island():

{{ island(component="Counter", initial=5) | safe }}
{{ island(component="SearchBox", placeholder="Find…", class="docs-search") | safe }}

Only the arguments a component's match arm reads are used; any other keyword is ignored. The props are serialized to JSON and stored in data-props.

ComponentArguments read by island()
Counterinitial (integer, default 0), class
SearchBoxplaceholder (default "Search..."), class, max_results (default 5, clamped 1–50)

The class Prop

All island components accept an optional class prop that appends custom CSS classes to the component's outer <div>:

{{ island(component="SearchBox", class="docs-search") | safe }}

This renders as:

<div data-island="SearchBox" data-props='{&quot;placeholder&quot;:&quot;Search...&quot;,&quot;max_results&quot;:5,&quot;class&quot;:&quot;docs-search&quot;}'>
  <!-- component content -->
</div>

The serialized props are HTML-entity-escaped inside the single-quoted attribute (#39), so a prop value containing ', <, >, or & cannot break out of the attribute. The browser's dataset accessor un-escapes the entities when the client reads the attribute, so hydration receives the original JSON unchanged.

This enables template authors to pass CSS styling hooks for targeting descendant elements without modifying component source.

Writing an Island Component

Island components live in taxus-common/src/components/. Their props must implement Serialize and Deserialize:

#![allow(unused)]
fn main() {
// taxus-common/src/components/counter.rs
use serde::{Deserialize, Serialize};
use yew::prelude::*;

#[derive(Properties, PartialEq, Clone, Serialize, Deserialize)]
pub struct CounterProps {
    #[prop_or_default]
    pub initial: i32,
    #[prop_or_default]
    pub class: String,
}

#[function_component(Counter)]
pub fn counter(props: &CounterProps) -> Html {
    let count = use_state(|| props.initial);
    let on_click = {
        let count = count.clone();
        Callback::from(move |_| count.set(*count + 1))
    };
    
    html! {
        <div class="counter">
            <span>{ *count }</span>
            <button onclick={on_click}>{ "+" }</button>
        </div>
    }
}
}

Export the Component

Add the component module to taxus-common/src/components.rs:

#![allow(unused)]
fn main() {
pub mod counter;
}

Registering a New Island

Adding an island touches one shared registry plus one explicit arm on each side; a mismatch is a loud failure, never a silent no-op (#50 collapsed the free-floating name lists into this design):

  1. The component lives in taxus-common/src/components/ with #[derive(Deserialize, Serialize, Properties, PartialEq)] props, and its module is exported from taxus-common/src/components.rs.

  2. The registry — taxus_common::islands::ISLANDS — gains one entry (IslandDef { name: "MyWidget" }, entries kept lexically sorted; tests enforce this). Both the generator's SSR dispatch and the client's hydration consult this list, so an unregistered name never renders at all, and a registered name the client does not know is logged and skipped rather than vanishing.

  3. The generator arm: a match arm in island() reading the template kwargs into props, plus a render_island_my_widget helper in taxus-generator/src/build/pipeline.rs:

#![allow(unused)]
fn main() {
pub fn render_island_my_widget(props: MyWidgetProps) -> String {
    let props_json = serde_json::to_string(&props).unwrap_or_else(|_| "{}".to_string());
    let ssr_html = block_on_ssr(ServerRenderer::<MyWidget>::with_props(move || props).render());
    island_mount("MyWidget", &props_json, &ssr_html) // escapes data-props (#39)
}
}
  1. The client arm: a match arm in taxus-client's hydrate_island. Yew's Renderer::<T>::hydrate() needs a concrete type per arm, so this step stays explicit — but the registry check runs first, so a forgotten arm logs skipping unknown island: MyWidget in the browser console instead of failing silently:
#![allow(unused)]
fn main() {
fn hydrate_island(name: &str, el: HtmlElement, props_json: &str) {
    if !taxus_common::islands::ISLANDS.iter().any(|i| i.name == name) {
        console_log(&format!("skipping unknown island: {name}"));
        return;
    }
    match name {
        "MyWidget" => {
            let props: MyWidgetProps = serde_json::from_str(props_json)
                .unwrap_or(MyWidgetProps::default());
            yew::Renderer::<MyWidget>::with_root_and_props(el.into(), props).hydrate();
        }
        _ => unreachable!("registry check ran first"),
    }
}
}

Built-in Islands

Taxus includes two built-in island components:

Counter

A simple counter with increment button. This is an example component demonstrating the islands architecture — useful for testing and learning, but not intended for production use.

A production-ready search component with debounced input and async results. See Search for full documentation.

{{ island(component="SearchBox", placeholder="Search...", class="my-search") | safe }}

Initializing a Site with Islands

Islands are enabled by default. Initializing a new site includes the WASM hydration script in the generated templates/base.html and a Counter island demo in templates/section.html:

cargo run -- init my-site

The generated base.html includes the WASM hydration script:

<script type="module">
  import init, * as bindings from '/wasm/client.js';
  const wasm = await init({ module_or_path: '/wasm/client_bg.wasm' });
  window.wasmBindings = bindings;
  bindings.hydrate_islands();
</script>

To generate a plain Tera/Markdown scaffold with no WASM hydration, pass --no-islands:

cargo run -- init my-site --no-islands

Building the WASM Client

The WASM client is compiled automatically as part of every Cargo build. A Cargo build script (taxus-generator/build.rs) compiles the taxus-client crate to wasm32-unknown-unknown, runs wasm-bindgen to generate JS bindings, and embeds the resulting client.js and client_bg.wasm into the generator binary via include_bytes!. At site build time, these embedded files are written to dist/wasm/.

No separate build step or external tooling (such as Trunk) is required.

Development Workflow

1. Create a Site

cargo run -- init my-site --name "My Site" --base-url "https://example.com"

2. Build the Static Site (includes WASM client)

cargo run -- build --dir my-site --verbose

The WASM client is compiled as part of the Cargo build and embedded in the binary. During the taxus build pipeline, the embedded client.js and client_bg.wasm are written to dist/wasm/ automatically — no separate build step needed.

3. Serve and Test

cargo run -- serve --dir my-site --open

The page should:

  • Render immediately from the pre-rendered HTML
  • Show the counter with the initial value
  • After WASM loads (~1s), the button becomes interactive

The build pipeline also generates a search index. See Search for details.

Search

Taxus provides a built-in search component with client-side full-text search. The SearchBox island component uses TF-IDF (Term Frequency-Inverse Document Frequency) ranking with English stemming.

Overview

The build pipeline:

  1. Generates a search index at dist/search_index.bin (stage 13, from every processed page in tree order; see Architecture)
  2. The SearchBox component is available for use in templates

The indexed text for each page is its Markdown body — not the rendered HTML, whose tags, attributes and highlighter classes would pollute the term space — plus its title and tags/categories, each repeated as a field boost so a query matching only the title finds the page.

The binary index contains:

  • Document metadata — Title, path, summary, tags, and categories for each page
  • Inverted index — Mapping from word stems to document IDs with TF-IDF scores

The index is serialized with postcard for compact storage and fast deserialization in the browser.

Search is on by default; the index is generated automatically:

cargo run -- build --dir my-site

This generates dist/search_index.bin alongside your static files. A site with no search box can skip it — set search = false in [build]:

[build]
search = false

Using the SearchBox Component

The SearchBox island component provides a ready-to-use search interface. Add it to any template:

<div class="search-container">
  {{ island(component="SearchBox") | safe }}
</div>

Props

PropTypeDefaultDescription
placeholderstring"Search..."Placeholder text for the input
classstring""Custom CSS classes to append to the outer container

The component also accepts a max_results prop (integer, default 5, clamped to 1–50): the number of results shown in the dropdown.

Example with custom props:

{{ island(component="SearchBox", placeholder="Find content...", class="docs-search") | safe }}

Styling

The component uses these CSS classes that you can style:

ClassElement
.search-boxContainer div
.search-inputText input field
.search-resultsResults list (<ul>)
.search-resultIndividual result item (<li>)
.search-result-linkResult title link
.search-result-summaryResult summary text

Use the class prop to add custom classes for styling hooks:

{{ island(component="SearchBox", class="docs-search") | safe }}

Then target the custom class in your SCSS:

.docs-search .search-input {
  // Custom styles for docs search input
}

Example SCSS:

.search-container {
  max-inline-size: 48rem;
  margin-inline: auto;
  padding-inline: 1.5rem;
}

.search-input {
  font-family: var(--font-mono);
  font-size: 0.95rem;
  padding: 0.6rem 1rem;
  border-radius: 0.5rem;
  border: 1px solid var(--border);
  background-color: var(--bg-surface);
  color: var(--text);
}

.search-input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}

.search-result {
  background-color: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  padding: 0.75rem 1rem;
}

.search-result-link {
  font-family: var(--font-mono);
  font-weight: 600;
  color: var(--accent);
  text-decoration: none;
}

.search-result-summary {
  font-size: 0.85rem;
  color: var(--text-muted);
}

How It Works

Indexing Pipeline

  1. Tokenization — Content is split into lowercase words, filtering out words shorter than 3 characters
  2. Stemming — Words are reduced to their root form using the Porter stemmer (e.g., "programming" → "program")
  3. TF-IDF Scoring — Each term gets a weight based on:
    • Term Frequency (TF) — How often the term appears in a document
    • Inverse Document Frequency (IDF) — How rare the term is across all documents

Search Query Processing

When a user searches:

  1. The query is tokenized and stemmed using the same process
  2. Each stem's postings are retrieved from the index
  3. TF-IDF scores are summed for matching documents
  4. Results are returned sorted by relevance score, capped at 10 — a cap, not a score threshold, because TF-IDF has no corpus-independent floor: on a three-page site every match is a good match

Component Architecture

The SearchBox component:

  1. Uses a 200ms debounce on input to avoid excessive queries
  2. Requires at least 2 characters before searching
  3. Calls the window.wasmBindings.search() function exposed by the WASM client
  4. The WASM client lazily loads the search index on first use
  5. Results are truncated to max_results and displayed in a list

Output Format

The search index is written to dist/search_index.bin in postcard binary format.

Each SearchDocument in the results contains:

FieldDescription
idUnique document identifier
titlePage title from frontmatter
pathURL path (e.g., /blog/my-post/)
summaryPage summary for display
tagsTags from frontmatter
categoriesCategories from frontmatter

API Reference

SearchDocument

#![allow(unused)]
fn main() {
pub struct SearchDocument {
    pub id: u32,
    pub title: String,
    pub path: String,
    pub summary: String,
    pub tags: Vec<String>,
    pub categories: Vec<String>,
}
}

SearchIndex

#![allow(unused)]
fn main() {
pub struct SearchIndex {
    pub documents: BTreeMap<u32, SearchDocument>,
    pub index: HashMap<String, Vec<(u32, f32)>>,
}
}
MethodDescription
new() -> SelfCreate an empty index
add_document(doc, content)Add a document with its content
search(query) -> Vec<&SearchDocument>Search and return ranked results
finalize()Apply IDF weighting (call after all documents added)
to_bytes() -> Result<Vec<u8>, postcard::Error>Serialize to binary
from_bytes(bytes) -> Result<Self, postcard::Error>Deserialize from binary

Helper Functions

#![allow(unused)]
fn main() {
pub fn tokenize(text: &str) -> Vec<String>
}

Splits text into lowercase tokens, filtering words shorter than 3 characters.

#![allow(unused)]
fn main() {
pub fn stem(tokens: &[String]) -> Vec<String>
}

Applies English Porter stemmer to tokens.

Performance

  • Index size — Typically 10-30% of total content size
  • Deserialization — Near-instant with postcard format
  • Search latency — Sub-millisecond for typical queries
  • Lazy loading — Index is loaded only when first search is performed

Limitations

  • English only — Stemming is currently English-only
  • No phrase search — Queries are treated as bag-of-words
  • No highlighting — Results don't include matched snippets

CLI Reference

The taxus binary provides five subcommands for building and managing static sites.

Global Usage

taxus <SUBCOMMAND> [OPTIONS]

taxus build

Build the static site from Markdown content and templates.

taxus build [OPTIONS]

Options:
  -d, --dir <PATH>         Root directory (must contain site.toml) [default: .]
  -v, --verbose            Print detailed progress for each build stage
  -q, --quiet              Suppress all output except errors
      --include-drafts     Include pages marked draft = true
      --dry-run            Simulate without writing files
      --clean              Remove output directory before building
  -o, --output <PATH>      Override the output directory from site.toml
  -h, --help               Print help

Examples

# Build from current directory
taxus build

# Build with verbose output
taxus build --verbose

# Build from a specific directory
taxus build --dir ./my-site

# Build including drafts
taxus build --include-drafts

# Dry run (validate without writing)
taxus build --dry-run

# Clean and rebuild
taxus build --clean

# Override output directory
taxus build --output /tmp/preview

Build Pipeline Stages

The numbers match the [n/15] lines in the build log. Each stage is described in Architecture.

  1. Discover routes: build the Site Tree from content/ and derive the routes from it
  2. Load Tera templates from templates/
  3. Process content: render Markdown to HTML, resolve @/ links
  4. Process hero images (responsive variants, WebP conversion, srcset)
  5. Copy co-located assets
  6. Render pages with templates
  7. Generate robots.txt
  8. Generate sitemap.xml
  9. Generate 404.html
  10. Build and render taxonomy pages
  11. Generate feeds (RSS/Atom)
  12. Process assets (SCSS, static files)
  13. Generate search index
  14. Write WASM client
  15. Write output files

taxus clean

Remove all generated files from the output directory.

taxus clean [OPTIONS]

Options:
  -d, --dir <PATH>   Root directory (must contain site.toml) [default: .]
  -h, --help         Print help

Examples

# Clean current site
taxus clean

# Clean a site in a different directory
taxus clean --dir ./my-site

taxus init

Initialize a new site with a default directory structure.

taxus init [OPTIONS] [PATH]

Arguments:
  [PATH]               Directory to initialize [default: .]

Options:
  -n, --name <NAME>       Site name used in templates and site.toml
  -u, --base-url <URL>    Base URL (must start with http:// or https://)
  -f, --force             Initialize even if directory is not empty
      --no-islands        Disable WASM islands hydration (enabled by default)
  -h, --help              Print help

Files Created

FileDescription
site.tomlSite configuration
content/_index.mdHome page content
templates/base.htmlBase HTML layout
templates/page.htmlSingle-page template
templates/section.htmlSection/listing template
templates/tags.htmlTag listing page
templates/tags_term.htmlIndividual tag page
templates/categories.htmlCategory listing page
templates/categories_term.htmlIndividual category page
templates/series.htmlSeries listing page
templates/series_term.htmlIndividual series page
templates/404.htmlNot-found page
styles/main.scssStarter stylesheet
styles/_highlight-dark.scss, styles/_highlight-light.scssCode highlighting theme partials
static/scripts.jsPlaceholder scripts file
static/favicon.pngPlaceholder favicon

Examples

# Initialize in current directory
taxus init

# Initialize in a new directory
taxus init my-site

# Initialize with custom options
taxus init my-site --name "My Blog" --base-url "https://myblog.com"

# Initialize a plain site without islands
taxus init my-site --no-islands

# Force initialization in non-empty directory
taxus init my-site --force

taxus routes

List all routes that would be discovered from the content directory without building.

taxus routes [OPTIONS]

Options:
  -d, --dir <PATH>   Root directory (must contain site.toml) [default: .]
  -h, --help         Print help

Example Output

Routes are listed sorted by URL path, with the content file and the output file in the next two columns. This is the product site in the repository, get-taxus-org/:

Routes for "Taxus"
─────────────────────────────────────────────────────
  [section]  /                             _index.md                       index.html
  [section]  /appearance/                  appearance/_index.md            appearance/index.html
  [section]  /authoring/                   authoring/_index.md             authoring/index.html
  [section]  /blog/                        blog/_index.md                  blog/index.html
  [page   ]  /blog/project-launch/         blog/2026-04-03-project-launch.md  blog/project-launch/index.html
  ...
  [section]  /structure/                   structure/_index.md             structure/index.html
─────────────────────────────────────────────────────
  Total: 11 routes (5 pages, 6 sections)

The URL paths are the served addresses, derived from the Site Tree: the date prefix on the post's file name is not in its URL. See Identity.

Examples

# List routes for current site
taxus routes

# List routes for a specific site
taxus routes --dir ./my-site

taxus serve

Start a development server with live reload.

taxus serve [OPTIONS]

Options:
  -d, --dir <PATH>        Root directory (must contain site.toml) [default: .]
      --host <ADDR>       IP address to bind to [default: 127.0.0.1]
  -p, --port <PORT>       Port to listen on [default: 3000]
  -v, --verbose           Print detailed progress for each build stage
  -q, --quiet             Suppress all output except errors
  -o, --open              Open browser automatically
      --include-drafts    Include draft pages in every rebuild
  -h, --help              Print help

The serve command performs an initial build automatically, then watches for file changes.

By default the server listens on 127.0.0.1 only, so nothing on your network can reach it. Pass --host 0.0.0.0 (or :: for IPv6) to expose it — for example, to test the site on a phone. See Development Server for details.

Examples

# Start on default port
taxus serve

# Start with custom port
taxus serve --port 8080

# Expose on the local network (e.g. to test on a phone)
taxus serve --host 0.0.0.0

# Start and open browser
taxus serve --open

# Serve from specific directory
taxus serve --dir ./my-site

# Preview drafts (rebuilds include them until restarted)
taxus serve --dir ./my-site --include-drafts

# Combined options
taxus serve --dir ./my-site --port 8080 --open --verbose

Error Hints

When a command fails, the CLI prints an actionable hint alongside the error:

ErrorHint
site.toml not foundRun taxus init or use --dir
No content foundAdd .md files to content/, start with content/_index.md
Template not foundCheck that templates/ contains base.html and page.html

Logging

Control log output with CLI flags or the RUST_LOG environment variable:

# Default: info level (build progress)
taxus build

# Verbose: debug level (detailed stages)
taxus build --verbose

# Quiet: errors only
taxus build --quiet

# Custom via RUST_LOG
RUST_LOG=debug taxus build
RUST_LOG=taxus_lib=trace taxus build

Log levels:

LevelDescription
errorBuild failures only
warnWarnings and errors
infoBuild progress (default)
debugDetailed stage information
traceVerbose internal diagnostics

Development Server

The serve command provides a local development server with hot reloading.

Basic Usage

# Start server on default port (3000)
taxus serve

# Start with custom port
taxus serve --port 8080

# Expose on the local network (e.g. to test on a phone)
taxus serve --host 0.0.0.0

# Start and open browser automatically
taxus serve --open

# Serve from a different directory
taxus serve --dir ./my-site

The serve command performs an initial build automatically before starting the server.

Command Options

OptionShortDefaultDescription
--host127.0.0.1IP address to listen on
--port-p3000Port to listen on
--verbose-vfalsePrint detailed build progress
--quiet-qfalseSuppress all output except errors
--open-ofalseOpen browser automatically

Network Access

By default the server listens on 127.0.0.1 only. That keeps both the site and the live-reload WebSocket private to your machine, which is what you want on a shared or public network.

To reach the server from another device — a phone or tablet on the same Wi-Fi, for example — bind to all interfaces and connect using your machine's LAN address:

taxus serve --host 0.0.0.0
# then on the other device: http://192.168.1.42:3000

Use --host :: for IPv6. The "listening on" line and --open always show a browsable address (127.0.0.1 or [::1]) rather than the wildcard.

Features

Hot Reloading

The server watches the source directories — content/, templates/, styles/, static/, and the site.toml file — and nothing else. The output directory is never watched, so a build's own writes cannot trigger another build.

When a change is detected, the server rebuilds and sends a reload signal to connected browsers via WebSocket.

Debouncing

Editors emit several filesystem events per save, and some tools emit bursts. Events are coalesced in a 150 ms window: one save produces one rebuild, whatever the filesystem did underneath. Events that arrive while a rebuild is running are folded into a single follow-up rebuild rather than queueing N.

Live Reload Protocol

  1. Server starts on the specified port
  2. HTML pages are injected with a live reload script
  3. Browser connects to /__ws__ WebSocket endpoint
  4. On file change, server broadcasts reload message
  5. Browser refreshes automatically

Error Overlay

Build errors are displayed in the browser with:

  • Error type and message
  • File that caused the error
  • Suggested fixes (when available)

The overlay dismisses when the error is resolved.

Graceful Shutdown

Press Ctrl+C to shut down cleanly:

  • In-flight requests complete
  • WebSocket connections close cleanly
  • Build operations are cancelled safely

Workflow

After init

taxus init my-site
cd my-site
taxus serve --open

With build

The serve command runs build internally. For production:

# Development
taxus serve

# Production
taxus build

Troubleshooting

Port Already in Use

taxus serve --port 3001

Check what's using the port:

# Linux/macOS
lsof -i :3000

# Windows
netstat -ano | findstr :3000

Files Not Being Watched

Ensure files are in correct directories:

  • Content: content/ with .md extension
  • Templates: templates/ with .html extension
  • Styles: styles/ with .scss or .sass
  • Static: static/

Browser Not Refreshing

  1. Check WebSocket connection in dev tools (Network → WS)
  2. Ensure JavaScript is enabled
  3. Check for console errors
  4. Verify live reload script is injected (view source)

Styling

Taxus supports SCSS for modern CSS authoring.

Styles Directory

SCSS files are stored in the styles/ directory:

styles/
└── main.scss

SCSS Compilation

The generator compiles SCSS to CSS during the build process:

  1. Read SCSS files from styles/
  2. Compile to CSS using grass
  3. Write to dist/css/

Example Stylesheet

styles/main.scss:

// Main stylesheet

// Variables
$primary-color: #0066cc;
$text-color: #333;
$background: #fff;

// Base styles
body {
  font-family:
    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  line-height: 1.6;
  color: $text-color;
  background: $background;
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

// Headings
h1,
h2,
h3 {
  margin-top: 1.5em;
  color: darken($text-color, 10%);
}

// Links
a {
  color: $primary-color;
  text-decoration: none;

  &:hover {
    text-decoration: underline;
  }
}

// Code blocks
pre {
  background: #f4f4f4;
  padding: 1rem;
  border-radius: 4px;
  overflow-x: auto;
}

code {
  font-family: "Consolas", "Monaco", monospace;
  font-size: 0.9em;
}

SCSS Features

Variables

$primary: #0066cc;
$spacing: 1rem;

.button {
  background: $primary;
  padding: $spacing;
}

Nesting

nav {
  ul {
    list-style: none;
  }

  li {
    display: inline-block;
  }

  a {
    color: $primary;
  }
}

Partials

Split styles into multiple files:

styles/
├── main.scss        # Main file
├── _variables.scss  # Variables
├── _base.scss       # Base styles
├── _nav.scss        # Navigation
└── _footer.scss     # Footer

Import in main file:

@use "variables";
@use "base";
@use "nav";
@use "footer";

Mixins

@mixin flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}

.container {
  @include flex-center;
}

Output

Compiled CSS is written to:

dist/
└── css/
    └── main.css

Syntax Highlighting Styles

Taxus includes built-in themes for syntax highlighting. Import them in your main stylesheet:

// Light theme (GitHub-inspired)
@use "highlight-light";

// Or dark theme (Catppuccin-inspired)
@use "highlight-dark";

These themes style the hl-* classes generated by tree-sitter syntax highlighting. See Syntax Highlighting for the full list of highlight classes and customization options.

Linking Styles

Include the stylesheet in your template:

<link rel="stylesheet" href="/css/main.css" />

Development

For development, you can use the sass CLI for live compilation:

# Install sass
npm install -g sass

# Watch for changes
sass --watch styles/main.scss dist/css/main.css

Future Enhancements

Planned improvements for styling:

  • PostCSS: Autoprefixer and other transformations
  • CSS minification: Production-ready output
  • Source maps: Debug support
  • CSS modules: Scoped styles for components

Deployment

A built taxus site is a static directory — any host that serves files can deploy it. This page covers what the build produces, the options that matter for deployment, and the common hosts.

What gets built

taxus build writes the site to the output directory (default dist/, configurable as [build] output_dir):

dist/
├── index.html            # and one <page>/index.html per page
├── blog/…                # section pages, term pages, page/2/ pagination
├── css/                  # compiled SCSS
├── static/               # copied verbatim from static/
├── images/               # hero image variants (WebP/srcset)
├── wasm/                 # islands hydration client (client.js, client_bg.wasm)
├── search_index.bin      # client-side search index
├── feed.xml, feed.atom   # RSS and Atom feeds (if enabled)
├── sitemap.xml, robots.txt
└── 404.html              # alias redirects write more <alias>/index.html

Everything is pre-rendered HTML. A page is readable with JavaScript disabled; islands hydrate progressively in browsers that run the WASM client.

Options that matter for deployment

A site that uses no island() call in its templates can skip shipping several hundred KB of WASM and search index. Set in site.toml:

[build]
islands = false   # skip dist/wasm/
search = false    # skip dist/search_index.bin

taxus init --no-islands writes islands = false for you. Both default to true.

Drafts

Drafts are excluded by default. taxus build --include-drafts includes them — useful for a preview deployment of unreleased content, and a mistake for production.

Base URL

site.base_url is baked into absolute URLs: permalinks, feeds, the sitemap, canonical links. It must match where the site is actually hosted (https://example.com, or https://user.github.io/repo for project sites).

Hosts

Any static file host

Upload the contents of dist/ — the directory itself, not a parent. The site has no server-side component: no PHP, no server functions, no environment variables.

Two files deserve host-specific attention:

  • 404.html — most static hosts let you designate a custom 404 page; point it at this file.
  • feed.xml / feed.atom — served as-is; the correct Content-Type (application/rss+xml / application/atom+xml) is a nicety most hosts get right or that readers tolerate without.

Cloudflare Pages

This is how get-taxus.org is deployed (see Development for the repo's own workflow):

  1. Build command: taxus build (or cargo run --release -- build in CI)
  2. Output directory: dist
  3. That's the whole configuration.

GitHub Pages

Two shapes:

Action-based (recommended): a workflow checks out, builds with taxus build, and uploads dist/:

- uses: actions/checkout@v4
- run: taxus build   # or build from source in CI
- uses: actions/upload-pages-artifact@v3
  with:
    path: dist

Branch-based: build locally, push dist/ to the gh-pages branch (a tool like git subtree push works), and set Pages to that branch. Remember to set base_url to the project-site path (https://user.github.io/repo) — pages link relatively, so the site works from a subdirectory.

Netlify / Vercel

Build command taxus build, publish directory dist. On Netlify add a redirect for the 404:

# netlify.toml
[build]
  command = "taxus build"
  publish = "dist"

[[redirects]]
  from = "/*"
  to = "/404.html"
  status = 404

Installing the taxus binary

The release workflow builds the taxus binary for six platforms (macOS, Linux and Windows, ARM64 and x86-64). Every GitHub release carries the archives plus generated one-line installers:

macOS / Linux:

curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/crustyrustacean/taxus/releases/latest/download/taxus-installer.sh | sh

Windows (PowerShell):

powershell -ExecutionPolicy ByPass -c \
  "irm https://github.com/crustyrustacean/taxus/releases/latest/download/taxus-installer.ps1 | iex"

Both install into ~/.cargo/bin (the install path is CARGO_HOME), so the binary sits beside cargo and is found if that directory is on your PATH.

Building from source remains supported — see the README — as is checking out a specific release tag first (git checkout vX.Y.Z) when you want to pin.

Development

This guide covers development workflows for contributing to Taxus.

Prerequisites

  • Rust — Install Rust
  • mdbook — Documentation: cargo install mdbook --locked (the docs workflow in CI pins 0.4.40; the book builds with 0.4 and 0.5)

Setup

git clone https://github.com/crustyrustacean/taxus.git
cd taxus

cargo build

Running Tests

# Run all unit, integration and doc tests
cargo test

# Run tests for a specific crate
cargo test -p taxus

# Run unit tests only
cargo test --lib

# Run integration tests only
cargo test --test config_loading

Building

# Build the site (islands and the WASM client are compiled and embedded automatically)
cargo run -- build --dir my-site

# Build release binary
cargo build --release

Development Server

# Start server with auto-reload
cargo run -- serve --dir my-site --open

Documentation

cd docs
mdbook serve

Open http://localhost:3000 to view.

Code Commands

CommandDescription
cargo buildBuild all crates
cargo testRun all tests
cargo run -- buildBuild the static site
cargo run -- serveStart dev server
cargo doc --workspace --no-depsGenerate API docs (taxus-domain warns on any undocumented public item)
cargo clippyRun linter
cargo fmtFormat code

xtask Task Runner

The workspace includes an xtask crate (aliased as cargo xtask via .cargo/config.toml) that wraps common developer workflows:

CommandDescription
cargo xtask build [--release] [--features ...]Build the project
cargo xtask test [--release] [--nextest] [--features ...]Run unit and integration tests
cargo xtask check [--features ...]Fast compile check (no codegen)
cargo xtask lint [--features ...] [--fix]Lint with Clippy
cargo xtask fmt [--check]Check formatting with rustfmt
cargo xtask doc [--open]Build Rust documentation
cargo xtask book [--serve]Build the mdBook documentation in docs/
cargo xtask auditRun cargo audit security scan (requires cargo-audit)
cargo xtask wasm [--release]Build WASM artifacts
cargo xtask cleanClean build artifacts
cargo xtask ciRun the full local CI pipeline (fmt, lint, build, test, WASM check, build get-taxus-org/ into target/ci-site)
cargo xtask release --bump <major|minor|patch> [--dry-run]Changelog only: promotes [Unreleased] in CHANGELOG.md to the workspace version bumped by the level. See Releasing — versioning and tagging go through cargo release
cargo xtask changelog --version <x.y.z> [--dry-run]The cargo release pre-release hook: promotes [Unreleased] to that version; a no-op once done
cargo xtask deploy [--project <name>] [--branch <name>] [--prod-branch <name>] [--no-build]Build get-taxus-org/ and deploy to Cloudflare Pages via wrangler (workspace tool; requires Cloudflare credentials)

Logging

Control log output with CLI flags or RUST_LOG:

# Default: info level
cargo run -- build

# Verbose: debug level
cargo run -- build --verbose

# Quiet: errors only
cargo run -- build --quiet

# Custom via RUST_LOG
RUST_LOG=debug cargo run -- build
RUST_LOG=taxus_lib=trace cargo run -- build

Add logging to code:

#![allow(unused)]
fn main() {
use tracing::{info, debug, warn, error};

fn build_site() {
    info!("Building site");
    debug!("Processing content");
    
    // Structured fields
    info!(pages = 5, sections = 2, "Build complete");
}
}

Releasing

The changelog is written by hand: every change adds an entry under ## [Unreleased] in CHANGELOG.md as it lands (Keep a Changelog headings — Added, Changed, Removed, Fixed). Releasing renames that section; nothing is generated from commit messages.

Releasing involves two machines, each triggered once:

  • cargo release (local) — bumps the workspace version, promotes the changelog, commits, tags. Configured in release.toml with push = false / publish = false: nothing leaves the machine on its own.
  • cargo-dist (CI, .github/workflows/release.yml) — watches for version-tag pushes. On one, it builds the taxus binary on six platforms (macOS/Linux/Windows × ARM64/x86-64), packages archives, installers and checksums, and creates the GitHub release with generated notes.

The tag is the starting gun for the second machine. Four steps, one decision — the bump level:

# 1. Read the [Unreleased] section — that is the release note
cargo xtask release --bump <level> --dry-run

# 2. Cut the release (bumps all crates, writes the changelog, commits, tags)
cargo release <level> --execute --no-confirm

# 3. Push the branch; CI runs on the commit as always
git push origin trunk

# 4. Push the tag — dist takes it from here: six-platform build, then
#    the GitHub release is created with binaries and generated notes
git push origin vX.Y.Z

After the release exists, replace dist's generated notes with the hand-written ones (the same voice as every previous release):

gh release edit vX.Y.Z --title vX.Y.Z --notes-file <file>

The division of labour matters: dist owns release creation (running gh release create by hand races it and loses), and we own the words. Editing is safe at any time after creation.

Choosing the bump level

LevelWhen
patchBugfixes only — no feat commits in the range
minorAny feat commit (new feature or behavior change)
majorBreaking changes

Check quickly:

git log v<last-tag>..HEAD --format="%s" | grep -c "^feat"

Notes:

  • --no-confirm skips the interactive prompt (required for non-interactive terminals).
  • cargo-release runs cargo xtask changelog --version <x.y.z> as its pre-release hook, which turns ## [Unreleased] into ## [x.y.z] - <date> and leaves an empty ## [Unreleased] above it. It fails if [Unreleased] is empty, and is a no-op if the version's section already exists, so re-running is safe.
  • cargo release --dry-run skips the hook entirely; use cargo xtask release --bump <level> --dry-run to check the changelog step, or cargo release hook to run the hook alone. A plain cargo release <level> (no --execute) still modifies Cargo.toml and CHANGELOG.md before stopping (the hook runs even without --execute — git checkout -- . to undo).
  • push = false and publish = false in release.toml: nothing leaves the machine until steps 3–4.
  • The dist workflow also runs in plan mode on pull requests — a free check that the dist configuration still resolves; the expensive build jobs skip PRs.
  • If cargo build/test fails with Access is denied (os error 5) on Windows, a running taxus.exe (usually a leftover serve) is holding the binary: taskkill /F /IM taxus.exe and retry.
  • CI runs on the push: build/test/clippy, security audit, docs, and the get-taxus.org deploy. Check with gh run list. The audit fails on yanked crates too, not just vulnerabilities — those appear unpredictably (they're other people's unpublish decisions, e.g. chacha20 0.10.1). If only the audit is red, run cargo update -p <crate> and push a lockfile-only follow-up.

Workspace Structure

taxus/
├── taxus-domain/    # Site Tree, identity types, frontmatter, derivations (no I/O)
├── taxus-generator/ # SSG library and CLI
├── taxus-common/    # Shared Yew components and the search index
├── taxus-client/    # WASM hydration client
├── xtask/           # Workspace task runner (`cargo xtask`)
└── docs/            # mdBook documentation

See Architecture for what each crate does and Theory for the model behind the build.

Documentation conventions

  • The vocabulary is fixed by the Glossary. A page that needs a new domain term adds it there first.
  • taxus-domain has #![warn(missing_docs)]: every public item says what it is in glossary terms and why it exists.
  • Every public module in taxus-generator states which phase it belongs to (parse, analyse, emit) and links the theory page that explains it.
  • CHANGELOG.md gets an entry under [Unreleased] with every change.

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Make changes
  4. Run tests: cargo test
  5. Run linter: cargo clippy
  6. Format: cargo fmt
  7. Submit a pull request

API Reference

This page documents the public API of the three library crates: taxus-domain (the model), taxus_lib (the generator library, crate taxus-generator) and taxus-common (islands and search). Terms are defined in the Glossary. cargo doc --workspace --no-deps --open builds the full rustdoc, which is the authoritative reference; this page is the map.

taxus-domain Crate

The pure data model. No I/O. See The Site Tree, Identity and Derivations.

identity Module

#![allow(unused)]
fn main() {
pub struct Slug(String);      // one validated URL segment
pub struct NodePath(Vec<Slug>); // slugs from the root to a node
pub struct UrlPath(String);   // the derived address, "/blog/my-post/"
pub enum IdentityError { Empty, Slash { s }, DotSegment { s }, ControlCharacter { s } }
}
ItemDescription
Slug::new(raw) -> Result<Slug, IdentityError>Validate a segment: non-empty, no /, not . or .., no control characters. Does not slugify
Slug::as_str(&self) -> &strThe segment
NodePath::root() -> NodePathThe root section's path (empty)
NodePath::parse(raw) -> Result<NodePath, IdentityError>From "blog/my-post"; "" and "/" are the root
NodePath::from_segments(iter) -> Result<NodePath, IdentityError>From slug strings
NodePath::is_root, parent, last, join(&Slug), segmentsPath queries
UrlPath::from_node_path(&NodePath) -> UrlPathThe one place addresses are derived: / for the root, else /a/b/
UrlPath::as_str(&self) -> &strThe address

schema Module

#![allow(unused)]
fn main() {
pub struct Frontmatter {
    pub title: String,                    // default ""
    pub description: Option<String>,
    pub tagline: Option<String>,
    pub date: Option<NaiveDate>,
    pub template: Option<String>,
    pub draft: bool,
    pub summary: Option<String>,
    pub slug: Option<String>,
    pub aliases: Vec<String>,
    pub tags: Vec<String>,
    pub categories: Vec<String>,
    pub series: Option<String>,
    pub extra: Option<toml::Value>,
    pub sort_by: SortBy,                  // default Date
    pub paginate_by: usize,               // 0 = no pagination
    pub paginate_template: Option<String>,
    pub weight: i32,
    pub pages_from: Vec<String>,          // donor sections, "blog"
    pub updated: Option<NaiveDate>,
    pub hero_image: Option<String>,
    pub hero_alt: Option<String>,
}

pub enum SortBy { Date, Title, Weight, None }
}
ItemDescription
Frontmatter::from_str(s) -> Result<Frontmatter, toml::de::Error>Parse TOML (via std::str::FromStr)
Frontmatter::template(&self) -> &strtemplate, or "page.html"
Frontmatter::extra_as_json(&self) -> HashMap<String, serde_json::Value>The [extra] table for templates

tree Module

#![allow(unused)]
fn main() {
pub struct SiteTree { pub root: SectionNode }

pub struct SectionNode {
    pub path: NodePath,
    pub content_file: Option<PathBuf>,   // "blog/_index.md", or None
    pub meta: Frontmatter,
    pub body: Option<String>,
    pub pages: Vec<PageNode>,            // direct children, by slug
    pub subsections: Vec<SectionNode>,   // direct children, by slug
}

pub struct PageNode {
    pub path: NodePath,
    pub content_file: PathBuf,           // "blog/2026-04-03-project-launch.md"
    pub meta: Frontmatter,
    pub body: String,
}

pub enum TreeError { Duplicate { path }, Collision { path, kind }, RootReserved, Identity(IdentityError) }
}
ItemDescription
SiteTree::get_section(&NodePath) -> Option<&SectionNode>Lookup by node path
SiteTree::get_page(&NodePath) -> Option<&PageNode>Lookup by node path
SiteTree::iter_pages(&self)Every page, depth-first, drafts included
PageNode::is_draft(&self) -> boolmeta.draft
SiteTreeBuilder::new() -> SiteTreeBuilderStart with a default root
SiteTreeBuilder::root(self, content_file, meta, body) -> SelfSet the root section's index file
SiteTreeBuilder::add_section(&mut self, &NodePath, content_file, meta, body) -> Result<(), TreeError>Declare a section at its final path
SiteTreeBuilder::add_page(&mut self, &NodePath, content_file, meta, body) -> Result<(), TreeError>Declare a page at its final path
SiteTreeBuilder::build(self) -> Result<SiteTree, TreeError>Assemble; auto-creates intermediate sections; sorts children by slug
sort_pages(&mut [&PageNode], SortBy)The ordering listings use: date newest first with undated last, title case-insensitive, weight lowest first, none

derivation Module

#![allow(unused)]
fn main() {
pub enum Node<'a> { Section(&'a SectionNode), Page(&'a PageNode) }
}
ItemDescription
Node::path, meta, content_file, is_section, is_draftAccessors shared by both kinds
documents(&SiteTree) -> Vec<Node>Every document in tree order; drafts included
descendant_pages(&SectionNode) -> Vec<&PageNode>Every page below a section, depth-first
recent(&SiteTree, include_drafts) -> Vec<&PageNode>All pages, newest first, undated last
aggregate(&SectionNode, &SiteTree, &[NodePath]) -> Vec<&PageNode>The receiver's pages plus each donor's direct pages, deduplicated, unsorted
group_by_terms(&SiteTree, include_drafts, terms_of) -> BTreeMap<String, Vec<Node>>Documents grouped by the terms a selector reads from frontmatter

The crate root re-exports Frontmatter, SortBy, NodePath, Slug, UrlPath, PageNode, SectionNode, SiteTree, SiteTreeBuilder and TreeError.


taxus-common Crate

Shared by the generator (build-time rendering) and the client (browser hydration).

components Module

ComponentPropsDescription
counter::CounterCounterProps { initial: i32, class: String }Demonstration counter
search_box::SearchBoxSearchBoxProps { placeholder: String, max_results: usize, class: String }Client-side search input; see Search

search Module

SearchDocument

#![allow(unused)]
fn main() {
pub struct SearchDocument {
    pub id: u32,
    pub title: String,
    pub path: String,
    pub summary: String,
    pub tags: Vec<String>,
    pub categories: Vec<String>,
}
}
MethodDescription
new(id, title, path, summary, tags, categories) -> SelfCreate a new document

SearchIndex

#![allow(unused)]
fn main() {
pub struct SearchIndex {
    pub documents: BTreeMap<u32, SearchDocument>,
    pub index: HashMap<String, Vec<(u32, f32)>>,
}
}
MethodDescription
new() -> SelfCreate empty index
add_document(&mut self, doc: SearchDocument, content: &str)Add a document with its content for indexing
search(&self, query: &str) -> Vec<&SearchDocument>Search and return ranked results
finalize(&mut self)Apply IDF weighting (call after all documents added)
to_bytes(&self) -> Result<Vec<u8>, postcard::Error>Serialize to binary (postcard format)
from_bytes(bytes: &[u8]) -> Result<Self, postcard::Error>Deserialize from binary

Helper Functions

#![allow(unused)]
fn main() {
pub fn tokenize(text: &str) -> Vec<String>  // lowercase tokens, words shorter than 3 characters dropped
pub fn stem(tokens: &[String]) -> Vec<String> // English Porter stemmer
}

taxus-generator Crate (taxus_lib)

Re-exports

#![allow(unused)]
fn main() {
pub use config::{BuildConfig, ImageConfig, SiteConfig, SiteMeta};
pub use content::{ContentSource, FilesystemContentSource, Frontmatter, Page};
pub use templates::{HeroContext, PageContext, SectionContext, SiteContext, TemplateContext, TemplateRenderer, TeraRenderer};
pub use assets::{AssetProcessor, AssetReport, ScssProcessor, StaticCopier};
pub use build::{BuildReport, SiteBuilder};
pub use feed::{FeedConfig, FeedEntry, FeedGenerator};
pub use highlighting::{CodeHighlighter, LanguageRegistry};
pub use images::{ImageProcessor, ImageRegistry, ProcessedImage, render_picture};
pub use init::{InitOptions, InitReport, InitScaffolder};
pub use routes::{RouteDiscovery, RouteInfo, RouteKind, RouteRegistry};
pub use error::{AssetError, ContentError, FeedError, GeneratorError, ImageError, InitError, Result, RouteError, TemplateError};
}

config Module

SiteConfig

#![allow(unused)]
fn main() {
pub struct SiteConfig {
    pub site: SiteMeta,
    pub build: BuildConfig,
    pub feed: FeedConfig,
    pub highlight: HighlightConfig,
    pub images: ImageConfig,
    pub markdown: MarkdownConfig,
    pub base_dir: PathBuf,
}
}
MethodDescription
from_file(path: P) -> Result<Self>Load from file
from_dir(dir: P) -> Result<Self>Load from directory (looks for site.toml)
new(name, base_url) -> SelfCreate programmatically
validate(&self) -> Result<()>Validate site.name, site.base_url, images.quality, images.format

SiteMeta

#![allow(unused)]
fn main() {
pub struct SiteMeta {
    pub name: String,
    pub base_url: String,
    pub description: Option<String>,
    pub author: Option<String>,
}
}

BuildConfig

#![allow(unused)]
fn main() {
pub struct BuildConfig {
    pub content_dir: PathBuf,    // default: "content"
    pub output_dir: PathBuf,     // default: "dist"
    pub static_dir: PathBuf,     // default: "static"
    pub styles_dir: PathBuf,     // default: "styles"
    pub templates_dir: PathBuf,  // default: "templates"
}
}
MethodDescription
resolve_paths(&mut self, base_dir: &Path)Make relative directories absolute against the site directory

FeedConfig

#![allow(unused)]
fn main() {
pub struct FeedConfig {
    pub rss_enabled: bool,       // default: true
    pub atom_enabled: bool,      // default: false
    pub limit: usize,            // default: 20 (0 is treated as 20)
    pub full_content: bool,      // default: false
    pub title: Option<String>,
    pub rss_path: Option<String>,   // default file: feed.xml
    pub atom_path: Option<String>,  // default file: feed.atom
    pub sections: Vec<String>,   // default: [] (whole site)
}
}

HighlightConfig, ImageConfig, MarkdownConfig

#![allow(unused)]
fn main() {
pub struct HighlightConfig { pub enabled: bool /* true */, pub class_prefix: String /* "hl-" */ }
pub struct ImageConfig { pub widths: Vec<u32>, pub quality: u8, pub format: String, pub output_dir: PathBuf }
pub struct MarkdownConfig { pub insert_anchor_links: bool }
}
MethodDescription
ImageConfig::normalize(&mut self)"jpg" becomes "jpeg"
ImageConfig::validate(&self) -> Result<()>Quality 1..=100; format webp, jpeg, jpg or png

content Module

Frontmatter and SortBy are re-exported from taxus-domain.

Page

One parsed content file (page or index file).

#![allow(unused)]
fn main() {
pub struct Page {
    pub frontmatter: Frontmatter, // page metadata
    pub raw_content: String,      // the Markdown body
}
}

A Page is the parsed form of one tree node's document — exactly what discovery read from disk, nothing computed. The build constructs them from the tree in stage 3; the served URL lives on ProcessedPage, never here.

MethodDescription
from_file(path: P) -> Result<Self>Load from a Markdown file
from_str(content: &str, source: &str) -> Result<Self>Parse from string (what discovery uses)
template(&self) -> &strtemplate, or "page.html"
is_draft(&self) -> boolCheck if draft
summary(&self) -> StringFrontmatter summary, else text before <!-- more -->, else first paragraph
word_count(&self) -> usize, reading_time(&self) -> usizeFrom the body; 200 words per minute, rounded up
aliases, tags, categories, seriesFrontmatter accessors

split_date_prefix

#![allow(unused)]
fn main() {
pub fn split_date_prefix(stem: &str) -> (&str, Option<NaiveDate>)
}

Strips a valid YYYY-MM-DD- prefix from a file stem and returns the date; a stem that is only a date is returned unchanged.

Sections

Sections are not a content type. A directory is a taxus_domain::SectionNode in the SiteTree built by RouteDiscovery::discover_tree; see The Site Tree.

TaxonomyKind, TaxonomyTerm, TaxonomyMap

#![allow(unused)]
fn main() {
pub enum TaxonomyKind { Tag, Category, Series }
pub struct TaxonomyTerm { pub kind, pub name: String, pub slug: String, pub page_count: usize, pub page_paths: Vec<String> /* content files */ }
pub struct TaxonomyMap { /* terms per kind */ }
}
MethodDescription
TaxonomyKind::path_prefix(&self) -> &str"tags", "categories", "series"
TaxonomyKind::plural_name(&self) -> &str"Tags", "Categories", "Series"
TaxonomyTerm::url_path(&self) -> String/tags/rust/
TaxonomyMap::add_term(&mut self, kind, name, content_file)File a document under a term
TaxonomyMap::tags(), categories(), series()Terms of a kind, sorted by name
TaxonomyMap::get_tag(slug), get_category(slug), get_series(slug)Lookup by term slug

ContentSource Trait

#![allow(unused)]
fn main() {
pub trait ContentSource: Send + Sync {
    fn load(&self, path: &Path) -> Result<String>;
    fn exists(&self, path: &Path) -> bool;
    fn list(&self) -> Result<Vec<PathBuf>>;
}
}

FilesystemContentSource

MethodDescription
new(root: P) -> SelfCreate with root directory

routes Module

RouteKind

#![allow(unused)]
fn main() {
pub enum RouteKind { Page, Section }
}

RouteInfo

#![allow(unused)]
fn main() {
pub struct RouteInfo {
    pub path: String,          // URL path, "/blog/my-post/"
    pub content_file: PathBuf,
    pub output_file: PathBuf,  // "blog/my-post/index.html"
    pub kind: RouteKind,
}
}

RouteRegistry

MethodDescription
new() -> SelfCreate empty registry
from_tree(tree: &SiteTree) -> SelfDerive the registry from a Site Tree (one route per document, in tree order)
register(&mut self, route: RouteInfo)Register a route
get(&self, path: &str) -> Option<&RouteInfo>Get by URL path
contains(&self, path: &str) -> boolCheck existence
len(&self) -> usize, is_empty(&self) -> boolCount routes
iter(&self), pages(&self), sections(&self)Iterate in registration order
find_by_content_file(&self, &Path) -> Option<&RouteInfo>Lookup by content file

RouteDiscovery

MethodDescription
new(content_dir: P) -> SelfCreate with content directory
discover_tree(&self) -> Result<SiteTree>Walk the content directory and build the Site Tree (what SiteBuilder::build uses)
discover_tree_from_source(&self, source: &impl ContentSource) -> Result<SiteTree>Same, from a ContentSource
discover(&self) -> Result<RouteRegistry, RouteError>Legacy file walk: routes keyed by filename, frontmatter not read
discover_from_source(&self, source: &impl ContentSource) -> Result<RouteRegistry, RouteError>Legacy walk from a ContentSource

slugify

#![allow(unused)]
fn main() {
pub fn slugify_segment(segment: &str) -> String  // "My Créative Post" -> "my-creative-post" (node paths; ASCII)
pub fn slugify_path(relative: &str) -> String    // "blog/My Old Post" -> "blog/my-old-post"
pub fn slugify_term(name: &str) -> String        // "Café" -> "café" (taxonomy terms; keeps non-ASCII letters)
}

templates Module

TemplateRenderer Trait

#![allow(unused)]
fn main() {
pub trait TemplateRenderer: Send + Sync {
    fn render(&self, template: &str, context: &TemplateContext) -> Result<String, TemplateError>;
    fn register_template(&mut self, name: &str, content: &str) -> Result<(), TemplateError>;
    fn has_template(&self, name: &str) -> bool;
    fn load_templates(&mut self, dir: &Path) -> Result<(), TemplateError>;
}
}

TeraRenderer

MethodDescription
new() -> Result<Self, TemplateError>Empty renderer with island(), get_section(), get_page(), slugify, term_slug, slug and date registered
from_dir(dir: P) -> Result<Self, TemplateError>Create and load **/*.html from a directory
set_site_lookup(&self, sections, pages)What get_section and get_page resolve to; the render stage fills it

TemplateContext

#![allow(unused)]
fn main() {
pub struct TemplateContext {
    pub page: Option<PageContext>,
    pub section: Option<SectionContext>,
    pub site: SiteContext,
    pub now: NowContext,
    pub extra: HashMap<String, serde_json::Value>,
}
}
MethodDescription
new(site: SiteContext) -> SelfCreate with site
with_page(self, page: PageContext) -> SelfAdd page
with_section(self, section: SectionContext) -> SelfAdd section
with_extra(self, extra: HashMap) -> SelfAdd extra

PageContext

#![allow(unused)]
fn main() {
pub struct PageContext {
    pub title: String,
    pub description: Option<String>,
    pub tagline: Option<String>,
    pub path: String,             // URL path
    pub permalink: String,        // base_url + path
    pub content: String,          // rendered HTML
    pub raw_content: String,
    pub date: Option<String>,     // ISO 8601
    pub draft: bool,
    pub summary: String,
    pub word_count: usize,
    pub reading_time: usize,
    pub toc: Vec<TocEntry>,
    pub tags: Vec<String>,
    pub categories: Vec<String>,
    pub series: Option<String>,
    pub weight: i32,
    pub hero: Option<HeroContext>,
}
}

HeroContext

#![allow(unused)]
fn main() {
pub struct HeroContext { pub src: String, pub srcset: String, pub width: u32, pub height: u32, pub alt: String, pub mime_type: String }
}

SectionContext and SubsectionContext

#![allow(unused)]
fn main() {
pub struct SectionContext {
    pub title: String,
    pub description: Option<String>,
    pub path: String,
    pub permalink: String,
    pub content: Option<String>,
    pub toc: Vec<TocEntry>,
    pub pages: Vec<PageContext>,          // the listing
    pub pagination: Option<PaginationContext>,
    pub subsections: Vec<SubsectionContext>,
}

pub struct SubsectionContext { pub title: String, pub description: Option<String>, pub path: String, pub permalink: String }
}

PaginationContext

#![allow(unused)]
fn main() {
pub struct PaginationContext {
    pub current: usize,
    pub total: usize,
    pub per_page: usize,
    pub total_items: usize,
    pub prev: Option<String>,
    pub next: Option<String>,
    pub first: String,
    pub last: String,
}
}
MethodDescription
is_first(&self), is_last(&self)Position checks
page_range(&self) -> Vec<Option<usize>>Page numbers for navigation, None for gaps

TaxonomyListContext and TaxonomyTermContext

#![allow(unused)]
fn main() {
pub struct TaxonomyListContext { pub kind: String, pub path: String, pub terms: Vec<TaxonomyTermContext> }
pub struct TaxonomyTermContext { pub kind: String, pub name: String, pub slug: String, pub path: String, pub page_count: usize, pub pages: Vec<PageContext> }
}

Both are passed to templates as extra.taxonomy.

SiteContext and NowContext

#![allow(unused)]
fn main() {
pub struct SiteContext { pub name: String, pub base_url: String, pub description: Option<String>, pub author: Option<String> }
pub struct NowContext { pub year: i32 }
}
#![allow(unused)]
fn main() {
pub fn compute_permalink(base_url: &str, path: &str) -> String
}

build Module

SiteBuilder

MethodDescription
from_dir(dir: &Path) -> Result<Self>Create from directory
new(config: SiteConfig) -> SelfCreate from config
dry_run(self, bool) -> SelfSet dry-run mode
verbose(self, bool) -> SelfNo-op kept for API compatibility; verbosity is tracing debug level (#54)
include_drafts(self, bool) -> SelfInclude drafts
output_dir(self, dir: impl Into<PathBuf>) -> SelfOverride the output directory
build(self) -> Result<BuildReport>Run the fifteen-stage pipeline (see Architecture)
clean(self) -> Result<()>Clean output directory
config(&self) -> &SiteConfigThe configuration

BuildReport

#![allow(unused)]
fn main() {
pub struct BuildReport {
    pub pages_rendered: usize,
    pub sections_rendered: usize,
    pub drafts_skipped: usize,
    pub sitemap_urls: usize,
    pub assets: AssetReport,
    pub duration: Duration,
    pub warnings: Vec<String>,
    pub output_dir: PathBuf,
}
}
MethodDescription
print_summary(&self)Print summary
total_files(&self) -> usizePages plus sections plus assets
has_warnings(&self), has_errors(&self), is_failure(&self)Status checks
add_warning(&mut self, warning)Record a warning

ProcessedPage and RenderedPage

#![allow(unused)]
fn main() {
pub struct ProcessedPage {
    pub route: RouteInfo,
    pub page: Page,
    pub html_content: String,
    pub toc: Vec<TocEntry>,
    pub hero_image: Option<ProcessedImage>,
}

pub struct RenderedPage {
    pub route: RouteInfo,
    pub content: String,
    pub hero_image: Option<ProcessedImage>,
}
}
MethodDescription
ProcessedPage::effective_url_path(&self) -> StringThe served URL path (the route's path, derived from the tree)
ProcessedImage::fallback_src(&self) -> Option<String>The middle variant's URL — None only for hand-built images with no variants (#51)

build::pipeline functions

FunctionStageDescription
load_config(dir) -> Result<SiteConfig>setupLoad site.toml
discover_tree(&SiteConfig) -> Result<SiteTree>1Build the Site Tree
discover_routes(&SiteConfig) -> Result<RouteRegistry>1The tree projected to routes
load_templates(&SiteConfig) -> Result<TeraRenderer>2Load templates
process_content(&SiteTree, &RouteRegistry, &SiteConfig, include_drafts, highlighter) -> Result<(Vec<ProcessedPage>, usize)>3Render Markdown from the tree; returns the pages and the observed skip count (#55)
process_images(&mut [ProcessedPage], &SiteConfig, dry_run) -> Result<ImageRegistry>4Hero image variants
copy_colocated_assets(content_dir, output_dir, dry_run) -> Result<AssetReport>5Copy non-.md files
pages::render_pages(&[ProcessedPage], &SiteTree, &TeraRenderer, &SiteContext) -> Result<Vec<RenderedPage>>6Run templates
robots::generate_robots, write_robots7robots.txt
not_found::generate_404, write_4048404.html
taxonomy::build_taxonomy_map(&SiteTree), render_taxonomy_pages, write_taxonomy_pages9Taxonomy pages
sitemap::generate_sitemap(&[RenderedPage], &[RenderedTaxonomy], &[ProcessedPage], &SiteConfig), write_sitemap10sitemap.xml from final outputs
feeds::feed_pages(&SiteTree, &[String]), generate_feeds, write_feeds11Feeds
process_assets(&SiteConfig, output_dir, dry_run) -> Result<AssetReport>12SCSS and static files
search::generate_search(&[ProcessedPage]) -> Result<GeneratedSearch>, write_search_index13search_index.bin
wasm::build_wasm_client(output_dir, dry_run) -> Result<WasmBuildOutput>14Write dist/wasm/
write_output(&[RenderedPage], output_dir, dry_run), alias::write_aliases15Write files
clean_output(output_dir)Remove the output directory
markdown::markdown_to_html_with_toc(markdown, highlighter, &MarkdownOptions) -> (String, Vec<TocEntry>)3Markdown rendering
internal_links::resolve_internal_links(content, source_file, &RouteRegistry)3@/ links
render_island_counter(CounterProps), render_search_box(SearchBoxProps)6Island SSR helpers

assets Module

AssetProcessor Trait

#![allow(unused)]
fn main() {
pub trait AssetProcessor: Send + Sync {
    fn process(&self, src: &Path, dest: &Path, dry_run: bool) -> Result<AssetReport, AssetError>;
    fn handles(&self, path: &Path) -> bool;
    fn name(&self) -> &'static str;
}
}

ScssProcessor

MethodDescription
new() -> SelfCreate with defaults
with_include_paths(paths: Vec<P>) -> SelfSet include paths
with_minify(self, bool) -> SelfSet minify

StaticCopier

MethodDescription
new() -> SelfCreate with defaults
with_exclusions(patterns: Vec<String>) -> SelfSet exclusions

AssetReport

#![allow(unused)]
fn main() {
pub struct AssetReport {
    pub files_processed: usize,
    pub files_skipped: usize,
    pub errors: Vec<String>,
}
}
MethodDescription
merge(&mut self, other: AssetReport)Merge reports
has_errors(&self) -> bool, total_files(&self) -> usizeStatus

images Module

ItemDescription
ImageProcessor::new(ImageConfig, output_dir)Create a processor
ImageProcessor::process(&self, source, alt) -> Result<ProcessedImage>Generate variants (cached by content hash and quality)
ImageProcessor::process_dry(&self, source, alt) -> Result<ProcessedImage>Paths only, no pixel work
ImageProcessor::quality_ignored_for_webp(&self) -> boolTrue when built without webp-lossy and the format is WebP
ProcessedImage::srcset, fallback_src, mime_type, url_path(&ImageVariant)What HeroContext is built from
ImageRegistryProcessed images keyed by source path
render_picture(&ProcessedImage, alt, loading) -> StringA <picture> element
LOSSY_WEBP_AVAILABLE: boolWhether the webp-lossy feature is compiled in

highlighting Module

ItemDescription
LanguageRegistry::new(), get(name), iter()Registered tree-sitter grammars (rust, alias rs)
CodeHighlighter::new(LanguageRegistry, class_prefix)Create a highlighter
CodeHighlighter::highlight(&mut self, code, language) -> HighlightResultHighlight one block; unknown languages are escaped plain text

feed Module

#![allow(unused)]
fn main() {
pub struct FeedConfig { pub title, pub description, pub base_url, pub author, pub author_email, pub language, pub limit: Option<usize>, pub full_content, pub filename }
pub struct FeedEntry { pub title, pub url, pub summary, pub content: Option<String>, pub date: DateTime<Utc>, pub updated, pub author, pub author_email, pub tags }
}
ItemDescription
FeedEntry::from_page(&Page, url) -> FeedEntrySummary is summary, else description, else Page::summary(); url is the served (effective) URL, supplied by the caller
FeedGenerator::new(FeedConfig)Create a generator
FeedGenerator::generate_rss(&[Page]), generate_atom(&[Page])Drafts dropped, newest first, truncated to limit; URLs derive from page.path, so prefer the _from_entries forms for pages with custom slugs
FeedGenerator::generate_rss_from_entries(Vec<FeedEntry>), generate_atom_from_entries(Vec<FeedEntry>)The pipeline form: entries carry their own effective URLs; newest first, truncated to limit
FeedGenerator::rss_filename(), atom_filename()feed.xml, feed.atom
escape_xml(&str) -> StringXML escaping

init Module

InitOptions

#![allow(unused)]
fn main() {
pub struct InitOptions {
    pub name: String,
    pub base_url: String,
    pub force: bool,
    pub islands: bool,
}
}
MethodDescription
new(name, base_url) -> SelfCreate options
with_force(self, bool) -> SelfSet force
with_islands(self, bool) -> Self, without_islands(self) -> SelfIslands on or off
validate(&self) -> Result<(), InitError>Non-empty name; base URL starts with http:// or https://

InitScaffolder

MethodDescription
new(options: InitOptions) -> SelfCreate scaffolder
scaffold(&self, path: &Path) -> Result<InitReport>Scaffold site

InitReport

#![allow(unused)]
fn main() {
pub struct InitReport {
    pub path: PathBuf,
    pub directories_created: usize,
    pub files_created: usize,
    pub created_dirs: Vec<PathBuf>,
    pub created_files: Vec<PathBuf>,
}
}

Helpers

#![allow(unused)]
fn main() {
pub fn is_directory_empty(path: &Path) -> Result<bool>
pub fn derive_site_name(path: &Path) -> String
}

serve Module

DevServer

MethodDescription
new(config: DevServerConfig, rebuild: RebuildFn) -> SelfCreate server; RebuildFn is Arc<dyn Fn() -> Result<(), String> + Send + Sync>
run(&self) -> Result<()>Start server (async)
host(&self) -> IpAddr, port(&self) -> u16Bind address

DevServerConfig

#![allow(unused)]
fn main() {
pub struct DevServerConfig {
    pub host: IpAddr,            // default: 127.0.0.1
    pub port: u16,               // default: 3000
    pub output_dir: PathBuf,
    pub site_dir: PathBuf,
    // ...
}
}
MethodDescription
default() -> SelfCreate with defaults
with_host(self, host: IpAddr) -> SelfSet bind address
with_port(self, port: u16) -> SelfSet port
with_output_dir(self, dir: PathBuf) -> SelfSet output dir
with_site_dir(self, dir: PathBuf) -> SelfSet site dir
with_include_drafts(self, bool) -> SelfMirrored into every rebuild (#40)
with_open(self, bool) -> SelfOpen a browser after starting

Also exported: browsable_url(SocketAddr) -> String, FileWatcher, WatchEvent, ChangeType, ReloadEvent, WebSocketMessage, inject_live_reload_script, LIVE_RELOAD_SCRIPT.

error Module

GeneratorError

#![allow(unused)]
fn main() {
pub enum GeneratorError {
    Config(Box<ConfigError>),
    Content(Box<ContentError>),
    Template(Box<TemplateError>),
    Asset(Box<AssetError>),
    Route(Box<RouteError>),
    Init(Box<InitError>),
    Serve(Box<ServeError>),
    Feed(Box<FeedError>),
    Image(Box<ImageError>),
    Search(Box<SearchError>),
    Io { path: PathBuf, source: std::io::Error },
    NoContent,
    BrokenInternalLink { file: String, target: String },
    PageRenderFailed { path: String, source: TemplateError },
}
}
TypeDescription
ConfigErrorConfiguration errors (not found, parse, missing field, invalid value)
ContentErrorContent errors (not found, frontmatter, IO)
TemplateErrorTemplate errors (not found, render, syntax)
AssetErrorAsset errors (SCSS, copy)
RouteErrorRoute errors (not found, duplicate, invalid path, discovery failed)
FeedErrorFeed generation errors
ImageErrorImage processing errors
InitErrorInitialization errors (invalid name or URL, file write, cancelled)
ServeErrorServer errors (port in use, WebSocket)
SearchErrorSearch index serialization errors
WasmErrorWASM client write errors (not wrapped by GeneratorError)

Result

#![allow(unused)]
fn main() {
pub type Result<T> = std::result::Result<T, GeneratorError>;
}

telemetry Module

FunctionDescription
init()Initialize with RUST_LOG env var
init_tracing(verbose: bool, quiet: bool)Initialize from CLI flags, falling back to RUST_LOG
init_with_level(level: &str)Initialize with specific level