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.xmlgeneration 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, andservesubcommands
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.tomlformat 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
xtaskworkflows - 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:
- Rust (edition 2024) — Install Rust
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:
| Phase | Owner | What it holds |
|---|---|---|
| Parse | taxus-domain defines the tree; taxus-generator fills it | SiteTree, SiteTreeBuilder, RouteDiscovery::discover_tree |
| Analyse | taxus-domain | derivation::documents, descendant_pages, recent, aggregate, group_by_terms, tree::sort_pages |
| Emit | taxus-generator, with taxus-common and taxus-client | SiteBuilder::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
pathbyUrlPath::from_node_pathwhenever it is needed. - Computed lists. A section has no "all posts sorted by date" field. A listing is a derivation, computed on request.
- Taxonomy indexes. Which pages carry the tag
rustis computed byderivation::group_by_terms, not stored on the tag or the page. - Its summary, word count or reading time. These are methods on the parsed page, computed when asked.
The reason is the one-source rule. If a section stored a sorted list and a page was later found to be a draft, the stored list would be wrong. If nothing derived is stored, nothing derived can go stale.
Containment versus reachability
pages and subsections hold direct children only. This is
containment: blog contains blog/project-launch; the root does not.
Reachability is everything below a node at any depth. It is not a field.
It is a question you ask: derivation::descendant_pages(section) walks
the subtree and returns every page in it.
Keeping the two apart is what makes section.pages mean one thing. A
section lists what it owns. When a section should list pages it does not
own, the author says so with pages_from, and
aggregation merges the named
sections' direct pages in. Nothing is listed by accident of depth.
Why the tree is immutable
SiteTreeBuilder::build returns the tree, and from then on every stage
only reads it. The generator holds it as &SiteTree. There is no method
that adds, removes or edits a node after construction.
Three things depend on this:
- Every derivation agrees. The feed, the sitemap, the section listings and the taxonomy pages are all computed from the same tree. If one stage could edit the tree, a later stage would see a different site than an earlier one.
- Order is deterministic. Children are sorted by slug when the tree is built. Every list starts from that order, so the same content produces the same output on every machine. The golden output test relies on this.
- Errors are found once. Two files that resolve to the same path, or a page and a section at the same path, are rejected by the builder before any output is written.
If the tree could change during a build, a template that calls
get_section early and a sitemap generated late could disagree about
which pages exist. The golden test would flap. Aliases could point at
URLs that no longer exist by the time they are written.
The scaffolded site
taxus init my-site --name "My Site" creates one content file. The tree
is small enough to show whole. File names on the right are the content_file values,
relative to content/.
SiteTree
└── root: SectionNode content/_index.md
path: [] (address: /)
content_file: Some("_index.md")
meta.title: "Home"
meta.description: "Welcome to My Site"
body: Some("# Welcome to My Site\n\n...")
pages: []
subsections: []
One node, one document, one output file: dist/index.html.
A site with a blog
The product site in the repository, get-taxus-org/, is a real site with
a blog. Its tree, with file names next to the nodes:
SiteTree
└── root: SectionNode path [] _index.md
├── subsections (by slug)
│ ├── SectionNode path ["appearance"] appearance/_index.md
│ ├── SectionNode path ["authoring"] authoring/_index.md
│ ├── SectionNode path ["blog"] blog/_index.md
│ │ └── pages (by slug)
│ │ ├── PageNode ["blog","project-launch"]
│ │ │ blog/2026-04-03-project-launch.md
│ │ ├── PageNode ["blog","taxus-feature-focus-hero-images"]
│ │ │ blog/2026-04-11-taxus-feature-focus-hero-images.md
│ │ ├── PageNode ["blog","taxus-feature-focus-search-island"]
│ │ │ blog/2026-04-14-taxus-feature-focus-search-island.md
│ │ ├── PageNode ["blog","taxus-feature-focus-syntax-highlighting"]
│ │ │ blog/2026-04-10-taxus-feature-focus-syntax-highlighting.md
│ │ └── PageNode ["blog","understanding-static-site-generators"]
│ │ blog/2026-04-05-understanding-static-site-generators.md
│ ├── SectionNode path ["interactivity"] interactivity/_index.md
│ └── SectionNode path ["structure"] structure/_index.md
└── pages: []
Two things to notice. The blog's pages are in slug order, not date order;
date order is a derivation the blog's sort_by asks for at render time.
And the two .jpg files in content/blog/ are not in the tree. They are
co-located assets, copied by the emit phase; the tree holds documents
only.
The Worked Example follows the first post through the whole build.
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.
| Name | Type | Example | Comes from |
|---|---|---|---|
| Content file | PathBuf | blog/2026-04-03-project-launch.md | the filesystem |
| Slug | taxus_domain::Slug | project-launch | the file name, or the frontmatter slug |
| Node path | taxus_domain::NodePath | blog/project-launch | the directory segments plus the slug |
| URL path | taxus_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 calledMy Créative Post.mdgets the slugmy-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 theterm_slugfilter, 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:
- If the frontmatter sets
slug, that string is the slug, verbatim.Slug::newvalidates it and the build fails if it cannot be a segment. - 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 file | Frontmatter | Node path | URL path | Output file |
|---|---|---|---|---|
about.md | (none) | about | /about/ | about/index.html |
blog/e.md | slug = "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:
- It goes in
taxus_domain::derivationif 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 asProcessedPage. - 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:
- Parent directory
blogbecomes the node path["blog"](slugified; unchanged here). - File stem
2026-04-03-project-launch. No frontmatterslug, sosplit_date_prefixremoves2026-04-03-, andslugify_segmentturnsproject-launchintoproject-launch. - 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:
| Kind | Term | Page appears at |
|---|---|---|
| tags | rust | /tags/rust/ (with all five posts) |
| tags | ssg | /tags/ssg/ |
| categories | announcements | /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`)
| Crate | Phase | Role | Output |
|---|---|---|---|
taxus-domain | parse, analyse | Defines 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-generator | all three | Fills 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-common | emit | Island components (Counter, SearchBox) rendered at build time and hydrated in the browser; the search index format. | library |
taxus-client | emit | Finds [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/ |
xtask | none | cargo 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
| Type | Made in stage | Holds |
|---|---|---|
SiteTree (taxus_domain) | 1 | the parsed site: sections, pages, frontmatter, bodies |
RouteRegistry, RouteInfo | 1 | per document: URL path, content file, output file, kind |
ProcessedPage | 3, 4 | route, parsed Page, rendered html_content, toc, hero_image |
TemplateContext | 6 | site, page, section, now, extra for one render |
RenderedPage | 6 | route and final HTML content |
TaxonomyMap | 10 | terms per kind, each with its documents' content files |
GeneratedFeed, GeneratedSitemap, GeneratedSearch | 11, 8, 13 | the bytes of one output file |
BuildReport | 15 | counts, 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
| Module | Phase | Types | Responsibility |
|---|---|---|---|
config | parse | SiteConfig, SiteMeta, BuildConfig, FeedConfig, HighlightConfig, ImageConfig, MarkdownConfig | load and validate site.toml |
content | parse | Page, Frontmatter (re-exported from the domain), ContentSource, split_date_prefix, TaxonomyMap | parse one content file; taxonomy map type |
routes | parse | RouteDiscovery, RouteRegistry, RouteInfo, RouteKind, slugify | build the tree from disk; derive routes; the two slug algorithms (node paths, taxonomy terms) |
build | all | SiteBuilder, BuildReport, ProcessedPage, RenderedPage, pipeline::* | the fifteen stages |
templates | emit | TeraRenderer, TemplateContext, PageContext, SectionContext, SiteContext, PaginationContext, TaxonomyTermContext | render Tera templates; tree functions |
images | emit | ImageProcessor, ProcessedImage, ImageRegistry, render_picture | hero image variants and <picture> markup |
highlighting | emit | CodeHighlighter, LanguageRegistry | tree-sitter syntax highlighting |
assets | emit | ScssProcessor, StaticCopier, AssetReport | SCSS and static files |
feed | emit | FeedGenerator, FeedEntry, FeedConfig | RSS and Atom documents |
init | none | InitScaffolder, InitOptions, InitReport | taxus init |
serve | none | DevServer, DevServerConfig, FileWatcher | dev server, file watching, live reload |
error | all | GeneratorError and the per-module errors | error types |
telemetry | none | init, init_tracing, init_with_level | logging 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
| Feature | Default | Effect |
|---|---|---|
lang-rust | on | Rust syntax highlighting via tree-sitter |
webp-lossy | on | Lossy 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:
| Concept | Lives in | Example |
|---|---|---|
| Storage path | the filesystem | content/blog/my-post.md |
| Slug | the tree (NodePath::last) | my-post |
| Section path | the tree | /blog/ |
| URL | derived, 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:
| Output | Derivation | Code |
|---|---|---|
| HTML pages | each node rendered through its template | build/, templates.rs |
| Section indexes | section.pages, sorted by sort_by | build/pipeline/pages.rs (derivation::aggregate) |
| Pagination | slices of a section's pages | build/pipeline/pages.rs |
| Taxonomy pages | group pages by tags/categories/series | build/pipeline/taxonomy.rs (derivation::group_by_terms) |
| RSS/Atom feeds | recent: dated pages, newest first, limited | build/pipeline/feeds.rs |
| Sitemap | effective_url_path() of every node | build/pipeline/sitemap.rs |
| Search index | page bodies and titles | build/pipeline/search.rs |
| Alias redirects | aliases frontmatter → derived URL | build/pipeline/alias.rs |
| Internal links | @/path.md resolved against the tree | build/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, withpages_from = ["blog"]in its_index.md, the blog's pages too (and can paginate)/blog/listsblog.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 sofeed.xmlandsitemap.xmlread the same tree througheffective_url_path()- a template reads the same tree:
section.subsectionsfor a section's children,get_section(path="blog")andget_page(path="about")for any other node
Design Invariants
Rules the codebase tries to hold onto — useful when evaluating new features:
- Model before projections. Walk storage once; derive everything from the tree. Projections never read the filesystem directly.
- One derivation point per concept. Slugs, URLs, summaries, reading time — each is computed in one place and shared. Fixing a derivation fixes all consumers.
- Membership by location, meaning by frontmatter, aboutness by taxonomy. Three orthogonal axes; don't blend them.
- URLs are derived, never stored. Addressability is a projection of the
model. Use
aliaseswhen a derived URL must change in the wild. - 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.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Site name/title |
base_url | string | Yes | Base URL for the site (used for absolute URLs) |
description | string | No | Site description for SEO |
author | string | No | Site author name |
[build] Section
Build configuration options. All fields have defaults.
| Field | Type | Default | Description |
|---|---|---|---|
content_dir | string | "content" | Directory containing Markdown content |
output_dir | string | "dist" | Output directory for generated files |
static_dir | string | "static" | Directory containing static assets |
styles_dir | string | "styles" | Directory containing SCSS stylesheets |
templates_dir | string | "templates" | Directory containing HTML templates |
islands | bool | true | Compile and embed the WASM hydration client. taxus init --no-islands writes false; the build then skips dist/wasm/ |
search | bool | true | Build search_index.bin. Set false on sites with no search box |
[feed] Section
RSS/Atom feed configuration for content syndication.
| Field | Type | Default | Description |
|---|---|---|---|
rss_enabled | bool | true | Enable RSS 2.0 feed generation |
atom_enabled | bool | false | Enable Atom feed generation |
limit | number | no limit | Maximum entries in feed. Unset means no limit; 0 is rejected (to disable feeds, use rss_enabled / atom_enabled) |
full_content | bool | false | Include full content vs summary |
title | string | None | Custom feed title (defaults to site name) |
rss_path | string | None | RSS feed output path (default: feed.xml) |
atom_path | string | None | Atom feed output path (default: feed.atom) |
sections | array | [] | Sections whose pages the feeds syndicate, e.g. ["blog"]; empty means every section |
[highlight] Section
Syntax highlighting configuration for code blocks.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable tree-sitter syntax highlighting |
class_prefix | string | "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.
| Field | Type | Default | Description |
|---|---|---|---|
widths | array | [400, 800, 1200] | Responsive breakpoint widths in pixels |
quality | number | 80 | Output quality (1–100). Applies to "jpeg" and "webp" only; "png" ignores it |
format | string | "webp" | Output format: "webp", "jpeg" (alias "jpg"), or "png" |
output_dir | string | "images" | Subdirectory within dist/ for processed images |
See Images for details on hero images and template usage.
[markdown] Section
Markdown rendering options.
| Field | Type | Default | Description |
|---|---|---|---|
insert_anchor_links | bool | false | Insert 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.namemust not be emptysite.base_urlmust not be empty and must start withhttp://orhttps://images.qualitymust be between 1 and 100images.formatmust be"webp","jpeg","jpg", or"png"images.widthsmust list at least one breakpoint[feed] limitmust not be0(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 customrss_path) - Atom:
https://example.com/feed.atom(or customatom_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
| File | Purpose |
|---|---|
_index.md | Section index page (home page at root, section index in subdirectories) |
*.md | Regular 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.mdis served at/blog/my-post/. - If the page has no
datein frontmatter, the prefix supplies the default publication date. A frontmatterdatealways 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:


Or use absolute paths from the site root:

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_altis 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
title | string | No* | "" | Page title |
description | string | No | None | Page description for SEO |
date | date | No | None | Publication 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 |
updated | date | No | None | Last updated date (same truncation rule as date) |
template | string | No | "page.html" | Template override |
draft | bool | No | false | Draft status |
summary | string | No | None | Custom summary/excerpt |
slug | string | No | None | Custom last URL segment; the page stays in its section |
aliases | array | No | [] | Old URLs that redirect to this page |
tags | array | No | [] | Tags (e.g., ["rust", "web"]) |
categories | array | No | [] | Categories (e.g., ["tutorial"]) |
series | string | No | None | Series name (e.g., "Learning Rust") |
sort_by | string | No | "date" | Sort order for sections: "date" (newest first, undated last), "title" (case-insensitive), "weight" (lowest first), "none" (tree order) |
paginate_by | number | No | 0 | Items per page (0 = no pagination) |
paginate_template | string | No | None | Template for paginated pages |
pages_from | array | No | [] | Sections whose direct pages this section also lists (e.g. ["blog"]); see Section listings |
weight | number | No | 0 | Weight for manual ordering |
hero_image | string | No | None | Relative path to a co-located hero image |
hero_alt | string | No | None | Alt text for hero image (falls back to page title) |
extra | table | No | None | Custom 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 File | URL 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
Links
[Link text](https://example.com)
Internal Links
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 Link | Resolved 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

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:
- Automatic extraction: First paragraph of content
- Manual marker: Use
<!-- more -->to mark where summary ends - 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:
| Template | URL | Purpose |
|---|---|---|
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
| Field | Type | Default | Description |
|---|---|---|---|
sort_by | string | "date" | Sort order: "date" (newest first, undated last), "title" (case-insensitive), "weight" (lowest first), "none" (tree order) |
paginate_by | number | 0 | Pages per slice (0 = no pagination) |
paginate_template | string | None | Template 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
| Field | Source |
|---|---|
| Title | Page title |
| Description | Page description or auto-extracted summary |
| URL | Full page URL (base_url + path) |
| Published | Page date field |
| Updated | Page updated field (Atom only) |
Feed URLs
- RSS:
https://example.com/feed.xml(or customrss_path) - Atom:
https://example.com/feed.atom(or customatom_path)
Sitemap Generation
Taxus generates sitemap.xml automatically:
- All routes included (pages and sections)
- Draft pages excluded
- Last modification date from page
datefield - Priorities: home
1.0, sections0.8, pages0.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:
| Name | Kind | Args |
|---|---|---|
image | inline | src (required; @/path refs resolve to the content-relative URL where co-located assets live), alt, class |
youtube | inline | id (required), title |
island | inline | component (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:
| Variable | Meaning |
|---|---|
args.* | The invocation's arguments (strings are HTML-escaped on output) |
body | Block form only: the body, already rendered as Markdown — emit with {{ body | safe }} |
page.title, page.description, page.draft, page.date | The containing page's frontmatter |
site_name, base_url | Site 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
islandshortcode.
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>© {{ 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:
| Variable | Type | Description |
|---|---|---|
extra.taxonomy.kind | String | Taxonomy kind: "Tags", "Categories", or "Series" |
extra.taxonomy.path | String | URL path (e.g., "/tags/") |
extra.taxonomy.terms | Array | List 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:
| Variable | Type | Description |
|---|---|---|
extra.taxonomy.kind | String | Taxonomy kind: "Tags", "Categories", or "Series" |
extra.taxonomy.name | String | Display name (e.g., "Rust") |
extra.taxonomy.slug | String | URL-safe slug (e.g., "rust") |
extra.taxonomy.path | String | URL path (e.g., "/tags/rust/") |
extra.taxonomy.page_count | Number | Number of pages with this term |
extra.taxonomy.pages | Array | List 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:
| Variable | Type | Description |
|---|---|---|
term.name | String | Display name |
term.slug | String | URL-safe slug |
term.path | String | URL path |
term.page_count | Number | Number of pages |
Available Variables
Site Context
| Variable | Type | Description |
|---|---|---|
site.name | String | Site name from configuration |
site.base_url | String | Base URL from configuration |
site.description | String? | Optional site description |
site.author | String? | Optional site author |
Page Context
| Variable | Type | Description |
|---|---|---|
page.title | String | Page title from frontmatter |
page.description | String? | Optional page description |
page.tagline | String? | Optional tagline from frontmatter |
page.path | String | URL path (e.g., /about/), derived from the page's node path |
page.permalink | String | Absolute URL (e.g., https://example.com/about/) |
page.content | String | Rendered HTML content |
page.raw_content | String | Raw markdown content |
page.date | String? | Publication date (ISO 8601) |
page.draft | Boolean | Whether page is a draft |
page.summary | String | Summary/excerpt for the page |
page.word_count | Number | Word count |
page.reading_time | Number | Estimated reading time in minutes |
page.toc | Array | Table of contents: entries with level, text, id, children (absent when the page has no headings) |
page.weight | Number | Frontmatter weight (0 when unset); sections with sort_by = "weight" list pages in this order |
page.tags | Array | Tags for the page |
page.categories | Array | Categories for the page |
page.series | String? | Series name |
page.hero | Object? | Hero image context (see below) |
Hero Image Context
When a page has hero_image in its frontmatter, page.hero contains:
| Variable | Type | Description |
|---|---|---|
page.hero.src | String | Fallback <img> src (middle variant) |
page.hero.srcset | String | Full srcset string for <source> |
page.hero.width | Number | Original image width |
page.hero.height | Number | Original image height |
page.hero.alt | String | Alt text (from hero_alt, or page title) |
page.hero.mime_type | String | MIME 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
| Variable | Type | Description |
|---|---|---|
section.title | String | Section title |
section.description | String? | Optional section description |
section.path | String | Section URL path |
section.permalink | String | Absolute URL of the section |
section.content | String? | Section HTML content |
section.toc | Array | Table of contents of the section's _index.md |
section.pages | Array | The section's direct child pages, plus the direct pages of any pages_from sections, sorted by sort_by |
section.subsections | Array | Direct child sections, in slug order; each has title, description, path, permalink |
section.pagination | Object? | Pagination information |
Pagination Context
| Variable | Type | Description |
|---|---|---|
section.pagination.current | Number | Current page (1-indexed) |
section.pagination.total | Number | Total pages |
section.pagination.per_page | Number | Items per page |
section.pagination.total_items | Number | Total items across all pages |
section.pagination.prev | String? | URL to previous page |
section.pagination.next | String? | URL to next page |
section.pagination.first | String | URL to first page |
section.pagination.last | String | URL to last page |
Current Date
| Variable | Type | Description |
|---|---|---|
now.year | Number | Current year (e.g., 2024) |
Useful for copyright notices: © {{ 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>
| Function | Returns |
|---|---|
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:
| Filter | Description |
|---|---|
safe | Output without HTML escaping |
default(value="...") | Provide default value |
upper | Convert to uppercase |
lower | Convert to lowercase |
trim | Remove leading/trailing whitespace |
first | Get first element of array |
last | Get last element of array |
length | Get length of string/array |
join(sep=", ") | Join array with separator |
slugify | Convert to URL-safe slug (ASCII) |
term_slug | Taxonomy 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...
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
hero_image | string | No | None | Relative path to a co-located image file |
hero_alt | string | No | None | Alt 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:
- Reads the source image and records its dimensions
- 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
- Converts to the configured format (default: WebP)
- Writes variant files to the output directory (default:
dist/images/) - 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
| Variable | Type | Description |
|---|---|---|
page.hero.src | String | Fallback <img> src (middle variant) |
page.hero.srcset | String | Full srcset string for <source> element |
page.hero.width | Number | Original image width (for layout shift prevention) |
page.hero.height | Number | Original image height (for layout shift prevention) |
page.hero.alt | String | Alt text (from hero_alt, or page title as fallback) |
page.hero.mime_type | String | MIME 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"
| Field | Type | Default | Description |
|---|---|---|---|
widths | array | [400, 800, 1200] | Responsive breakpoint widths in pixels |
quality | number | 80 | Output quality (1–100). Applies to "jpeg" and "webp" only; "png" ignores it |
format | string | "webp" | Output format: "webp", "jpeg" (alias "jpg"), or "png" |
output_dir | string | "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:
- 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:
| Language | Identifier | Aliases |
|---|---|---|
| Rust | rust | rs |
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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable or disable syntax highlighting |
class_prefix | string | "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:
| Class | Description |
|---|---|
hl-keyword | Keywords (fn, let, struct, impl, etc.) |
hl-string | String literals |
hl-string-special | Special strings (raw strings, format strings) |
hl-comment | Comments |
hl-function | Function names |
hl-function-builtin | Built-in functions |
hl-function-macro | Macro invocations |
hl-type | Type names |
hl-type-builtin | Built-in types (u32, str, etc.) |
hl-constant | Constants |
hl-constant-builtin | Built-in constants |
hl-number | Numeric literals |
hl-constructor | Constructors (Some, Ok, Err, etc.) |
hl-variable | Variables |
hl-variable-builtin | Built-in variables (self, Self) |
hl-variable-parameter | Function parameters |
hl-property | Struct fields/properties |
hl-label | Lifetimes and labels |
hl-attribute | Attributes (#[derive], #[cfg], etc.) |
hl-operator | Operators (=, +, -, etc.) |
hl-punctuation | General punctuation |
hl-punctuation-bracket | Brackets and braces |
hl-punctuation-delimiter | Commas, semicolons |
hl-tag | HTML/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:
- Add the tree-sitter grammar to
taxus-generator/Cargo.tomlas an optional dependency - Create a feature flag for the language
- Add
LanguageSpecregistration intaxus-generator/src/highlighting/languages.rs - 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:
- The
island()Tera function is called during template rendering - Yew SSR renders the component to HTML
- The output is wrapped in a mount point div with serialized props:
<div data-island="Counter" data-props='{"initial":5}'>
<!-- Pre-rendered by Yew SSR: -->
<div class="counter"><span>5</span><button>+</button></div>
</div>
Browser-Time: Hydration
When the page loads:
- The pre-rendered HTML is immediately visible (no JavaScript required)
- The WASM bundle loads asynchronously
- The client finds all
[data-island]elements in the DOM - For each island: deserialize
data-props, callyew::Renderer::hydrate() - The component becomes interactive without re-rendering
Two-Tier Interactivity
taxus supports two layers of interactivity:
| Tier | Technology | Use For |
|---|---|---|
| 1 — General | static/scripts.js (vanilla JS) | DOM manipulation, toggles, analytics, lightweight events |
| 2 — Performance | Yew WASM island | Heavy 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.
| Component | Arguments read by island() |
|---|---|
Counter | initial (integer, default 0), class |
SearchBox | placeholder (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='{"placeholder":"Search...","max_results":5,"class":"docs-search"}'>
<!-- 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):
-
The component lives in
taxus-common/src/components/with#[derive(Deserialize, Serialize, Properties, PartialEq)]props, and its module is exported fromtaxus-common/src/components.rs. -
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. -
The generator arm: a match arm in
island()reading the template kwargs into props, plus arender_island_my_widgethelper intaxus-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) } }
- The client arm: a match arm in
taxus-client'shydrate_island. Yew'sRenderer::<T>::hydrate()needs a concrete type per arm, so this step stays explicit — but the registry check runs first, so a forgotten arm logsskipping unknown island: MyWidgetin 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.
SearchBox
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
Search
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:
- Generates a search index at
dist/search_index.bin(stage 13, from every processed page in tree order; see Architecture) - The
SearchBoxcomponent 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.
Enabling Search
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
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | "Search..." | Placeholder text for the input |
class | string | "" | 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:
| Class | Element |
|---|---|
.search-box | Container div |
.search-input | Text input field |
.search-results | Results list (<ul>) |
.search-result | Individual result item (<li>) |
.search-result-link | Result title link |
.search-result-summary | Result 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
- Tokenization — Content is split into lowercase words, filtering out words shorter than 3 characters
- Stemming — Words are reduced to their root form using the Porter stemmer (e.g., "programming" → "program")
- 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:
- The query is tokenized and stemmed using the same process
- Each stem's postings are retrieved from the index
- TF-IDF scores are summed for matching documents
- 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:
- Uses a 200ms debounce on input to avoid excessive queries
- Requires at least 2 characters before searching
- Calls the
window.wasmBindings.search()function exposed by the WASM client - The WASM client lazily loads the search index on first use
- Results are truncated to
max_resultsand 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:
| Field | Description |
|---|---|
id | Unique document identifier |
title | Page title from frontmatter |
path | URL path (e.g., /blog/my-post/) |
summary | Page summary for display |
tags | Tags from frontmatter |
categories | Categories 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)>>, } }
| Method | Description |
|---|---|
new() -> Self | Create 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.
- Discover routes: build the Site Tree from
content/and derive the routes from it - Load Tera templates from
templates/ - Process content: render Markdown to HTML, resolve
@/links - Process hero images (responsive variants, WebP conversion, srcset)
- Copy co-located assets
- Render pages with templates
- Generate
robots.txt - Generate
sitemap.xml - Generate
404.html - Build and render taxonomy pages
- Generate feeds (RSS/Atom)
- Process assets (SCSS, static files)
- Generate search index
- Write WASM client
- 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
| File | Description |
|---|---|
site.toml | Site configuration |
content/_index.md | Home page content |
templates/base.html | Base HTML layout |
templates/page.html | Single-page template |
templates/section.html | Section/listing template |
templates/tags.html | Tag listing page |
templates/tags_term.html | Individual tag page |
templates/categories.html | Category listing page |
templates/categories_term.html | Individual category page |
templates/series.html | Series listing page |
templates/series_term.html | Individual series page |
templates/404.html | Not-found page |
styles/main.scss | Starter stylesheet |
styles/_highlight-dark.scss, styles/_highlight-light.scss | Code highlighting theme partials |
static/scripts.js | Placeholder scripts file |
static/favicon.png | Placeholder 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:
| Error | Hint |
|---|---|
site.toml not found | Run taxus init or use --dir |
| No content found | Add .md files to content/, start with content/_index.md |
| Template not found | Check 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:
| Level | Description |
|---|---|
error | Build failures only |
warn | Warnings and errors |
info | Build progress (default) |
debug | Detailed stage information |
trace | Verbose 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
| Option | Short | Default | Description |
|---|---|---|---|
--host | 127.0.0.1 | IP address to listen on | |
--port | -p | 3000 | Port to listen on |
--verbose | -v | false | Print detailed build progress |
--quiet | -q | false | Suppress all output except errors |
--open | -o | false | Open 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
- Server starts on the specified port
- HTML pages are injected with a live reload script
- Browser connects to
/__ws__WebSocket endpoint - On file change, server broadcasts reload message
- 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.mdextension - Templates:
templates/with.htmlextension - Styles:
styles/with.scssor.sass - Static:
static/
Browser Not Refreshing
- Check WebSocket connection in dev tools (Network → WS)
- Ensure JavaScript is enabled
- Check for console errors
- 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:
- Read SCSS files from
styles/ - Compile to CSS using
grass - 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
Disabling islands and search
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 correctContent-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):
- Build command:
taxus build(orcargo run --release -- buildin CI) - Output directory:
dist - 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
| Command | Description |
|---|---|
cargo build | Build all crates |
cargo test | Run all tests |
cargo run -- build | Build the static site |
cargo run -- serve | Start dev server |
cargo doc --workspace --no-deps | Generate API docs (taxus-domain warns on any undocumented public item) |
cargo clippy | Run linter |
cargo fmt | Format code |
xtask Task Runner
The workspace includes an xtask crate (aliased as cargo xtask via
.cargo/config.toml) that wraps common developer workflows:
| Command | Description |
|---|---|
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 audit | Run cargo audit security scan (requires cargo-audit) |
cargo xtask wasm [--release] | Build WASM artifacts |
cargo xtask clean | Clean build artifacts |
cargo xtask ci | Run 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 inrelease.tomlwithpush = 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 thetaxusbinary 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
| Level | When |
|---|---|
patch | Bugfixes only — no feat commits in the range |
minor | Any feat commit (new feature or behavior change) |
major | Breaking changes |
Check quickly:
git log v<last-tag>..HEAD --format="%s" | grep -c "^feat"
Notes:
--no-confirmskips the interactive prompt (required for non-interactive terminals).cargo-releaserunscargo 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-runskips the hook entirely; usecargo xtask release --bump <level> --dry-runto check the changelog step, orcargo release hookto run the hook alone. A plaincargo release <level>(no--execute) still modifiesCargo.tomlandCHANGELOG.mdbefore stopping (the hook runs even without--execute—git checkout -- .to undo).push = falseandpublish = falseinrelease.toml: nothing leaves the machine until steps 3–4.- The dist workflow also runs in
planmode on pull requests — a free check that the dist configuration still resolves; the expensive build jobs skip PRs. - If
cargo build/testfails withAccess is denied (os error 5)on Windows, a runningtaxus.exe(usually a leftoverserve) is holding the binary:taskkill /F /IM taxus.exeand 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, runcargo 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-domainhas#![warn(missing_docs)]: every public item says what it is in glossary terms and why it exists.- Every public module in
taxus-generatorstates which phase it belongs to (parse, analyse, emit) and links the theory page that explains it. CHANGELOG.mdgets an entry under[Unreleased]with every change.
Contributing
- Fork the repository
- Create a feature branch
- Make changes
- Run tests:
cargo test - Run linter:
cargo clippy - Format:
cargo fmt - 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 } } }
| Item | Description |
|---|---|
Slug::new(raw) -> Result<Slug, IdentityError> | Validate a segment: non-empty, no /, not . or .., no control characters. Does not slugify |
Slug::as_str(&self) -> &str | The segment |
NodePath::root() -> NodePath | The 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), segments | Path queries |
UrlPath::from_node_path(&NodePath) -> UrlPath | The one place addresses are derived: / for the root, else /a/b/ |
UrlPath::as_str(&self) -> &str | The 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 } }
| Item | Description |
|---|---|
Frontmatter::from_str(s) -> Result<Frontmatter, toml::de::Error> | Parse TOML (via std::str::FromStr) |
Frontmatter::template(&self) -> &str | template, 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) } }
| Item | Description |
|---|---|
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) -> bool | meta.draft |
SiteTreeBuilder::new() -> SiteTreeBuilder | Start with a default root |
SiteTreeBuilder::root(self, content_file, meta, body) -> Self | Set 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) } }
| Item | Description |
|---|---|
Node::path, meta, content_file, is_section, is_draft | Accessors 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
| Component | Props | Description |
|---|---|---|
counter::Counter | CounterProps { initial: i32, class: String } | Demonstration counter |
search_box::SearchBox | SearchBoxProps { 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>, } }
| Method | Description |
|---|---|
new(id, title, path, summary, tags, categories) -> Self | Create a new document |
SearchIndex
#![allow(unused)] fn main() { pub struct SearchIndex { pub documents: BTreeMap<u32, SearchDocument>, pub index: HashMap<String, Vec<(u32, f32)>>, } }
| Method | Description |
|---|---|
new() -> Self | Create 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, } }
| Method | Description |
|---|---|
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) -> Self | Create 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" } }
| Method | Description |
|---|---|
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 } }
| Method | Description |
|---|---|
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.
| Method | Description |
|---|---|
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) -> &str | template, or "page.html" |
is_draft(&self) -> bool | Check if draft |
summary(&self) -> String | Frontmatter summary, else text before <!-- more -->, else first paragraph |
word_count(&self) -> usize, reading_time(&self) -> usize | From the body; 200 words per minute, rounded up |
aliases, tags, categories, series | Frontmatter 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 */ } }
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
new(root: P) -> Self | Create 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
| Method | Description |
|---|---|
new() -> Self | Create empty registry |
from_tree(tree: &SiteTree) -> Self | Derive 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) -> bool | Check existence |
len(&self) -> usize, is_empty(&self) -> bool | Count routes |
iter(&self), pages(&self), sections(&self) | Iterate in registration order |
find_by_content_file(&self, &Path) -> Option<&RouteInfo> | Lookup by content file |
RouteDiscovery
| Method | Description |
|---|---|
new(content_dir: P) -> Self | Create 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
| Method | Description |
|---|---|
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>, } }
| Method | Description |
|---|---|
new(site: SiteContext) -> Self | Create with site |
with_page(self, page: PageContext) -> Self | Add page |
with_section(self, section: SectionContext) -> Self | Add section |
with_extra(self, extra: HashMap) -> Self | Add 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, } }
| Method | Description |
|---|---|
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 } }
compute_permalink
#![allow(unused)] fn main() { pub fn compute_permalink(base_url: &str, path: &str) -> String }
build Module
SiteBuilder
| Method | Description |
|---|---|
from_dir(dir: &Path) -> Result<Self> | Create from directory |
new(config: SiteConfig) -> Self | Create from config |
dry_run(self, bool) -> Self | Set dry-run mode |
verbose(self, bool) -> Self | No-op kept for API compatibility; verbosity is tracing debug level (#54) |
include_drafts(self, bool) -> Self | Include drafts |
output_dir(self, dir: impl Into<PathBuf>) -> Self | Override the output directory |
build(self) -> Result<BuildReport> | Run the fifteen-stage pipeline (see Architecture) |
clean(self) -> Result<()> | Clean output directory |
config(&self) -> &SiteConfig | The 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, } }
| Method | Description |
|---|---|
print_summary(&self) | Print summary |
total_files(&self) -> usize | Pages 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>, } }
| Method | Description |
|---|---|
ProcessedPage::effective_url_path(&self) -> String | The 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
| Function | Stage | Description |
|---|---|---|
load_config(dir) -> Result<SiteConfig> | setup | Load site.toml |
discover_tree(&SiteConfig) -> Result<SiteTree> | 1 | Build the Site Tree |
discover_routes(&SiteConfig) -> Result<RouteRegistry> | 1 | The tree projected to routes |
load_templates(&SiteConfig) -> Result<TeraRenderer> | 2 | Load templates |
process_content(&SiteTree, &RouteRegistry, &SiteConfig, include_drafts, highlighter) -> Result<(Vec<ProcessedPage>, usize)> | 3 | Render Markdown from the tree; returns the pages and the observed skip count (#55) |
process_images(&mut [ProcessedPage], &SiteConfig, dry_run) -> Result<ImageRegistry> | 4 | Hero image variants |
copy_colocated_assets(content_dir, output_dir, dry_run) -> Result<AssetReport> | 5 | Copy non-.md files |
pages::render_pages(&[ProcessedPage], &SiteTree, &TeraRenderer, &SiteContext) -> Result<Vec<RenderedPage>> | 6 | Run templates |
robots::generate_robots, write_robots | 7 | robots.txt |
not_found::generate_404, write_404 | 8 | 404.html |
taxonomy::build_taxonomy_map(&SiteTree), render_taxonomy_pages, write_taxonomy_pages | 9 | Taxonomy pages |
sitemap::generate_sitemap(&[RenderedPage], &[RenderedTaxonomy], &[ProcessedPage], &SiteConfig), write_sitemap | 10 | sitemap.xml from final outputs |
feeds::feed_pages(&SiteTree, &[String]), generate_feeds, write_feeds | 11 | Feeds |
process_assets(&SiteConfig, output_dir, dry_run) -> Result<AssetReport> | 12 | SCSS and static files |
search::generate_search(&[ProcessedPage]) -> Result<GeneratedSearch>, write_search_index | 13 | search_index.bin |
wasm::build_wasm_client(output_dir, dry_run) -> Result<WasmBuildOutput> | 14 | Write dist/wasm/ |
write_output(&[RenderedPage], output_dir, dry_run), alias::write_aliases | 15 | Write files |
clean_output(output_dir) | Remove the output directory | |
markdown::markdown_to_html_with_toc(markdown, highlighter, &MarkdownOptions) -> (String, Vec<TocEntry>) | 3 | Markdown rendering |
internal_links::resolve_internal_links(content, source_file, &RouteRegistry) | 3 | @/ links |
render_island_counter(CounterProps), render_search_box(SearchBoxProps) | 6 | Island 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
| Method | Description |
|---|---|
new() -> Self | Create with defaults |
with_include_paths(paths: Vec<P>) -> Self | Set include paths |
with_minify(self, bool) -> Self | Set minify |
StaticCopier
| Method | Description |
|---|---|
new() -> Self | Create with defaults |
with_exclusions(patterns: Vec<String>) -> Self | Set exclusions |
AssetReport
#![allow(unused)] fn main() { pub struct AssetReport { pub files_processed: usize, pub files_skipped: usize, pub errors: Vec<String>, } }
| Method | Description |
|---|---|
merge(&mut self, other: AssetReport) | Merge reports |
has_errors(&self) -> bool, total_files(&self) -> usize | Status |
images Module
| Item | Description |
|---|---|
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) -> bool | True when built without webp-lossy and the format is WebP |
ProcessedImage::srcset, fallback_src, mime_type, url_path(&ImageVariant) | What HeroContext is built from |
ImageRegistry | Processed images keyed by source path |
render_picture(&ProcessedImage, alt, loading) -> String | A <picture> element |
LOSSY_WEBP_AVAILABLE: bool | Whether the webp-lossy feature is compiled in |
highlighting Module
| Item | Description |
|---|---|
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) -> HighlightResult | Highlight 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 } }
| Item | Description |
|---|---|
FeedEntry::from_page(&Page, url) -> FeedEntry | Summary 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) -> String | XML escaping |
init Module
InitOptions
#![allow(unused)] fn main() { pub struct InitOptions { pub name: String, pub base_url: String, pub force: bool, pub islands: bool, } }
| Method | Description |
|---|---|
new(name, base_url) -> Self | Create options |
with_force(self, bool) -> Self | Set force |
with_islands(self, bool) -> Self, without_islands(self) -> Self | Islands on or off |
validate(&self) -> Result<(), InitError> | Non-empty name; base URL starts with http:// or https:// |
InitScaffolder
| Method | Description |
|---|---|
new(options: InitOptions) -> Self | Create 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
| Method | Description |
|---|---|
new(config: DevServerConfig, rebuild: RebuildFn) -> Self | Create server; RebuildFn is Arc<dyn Fn() -> Result<(), String> + Send + Sync> |
run(&self) -> Result<()> | Start server (async) |
host(&self) -> IpAddr, port(&self) -> u16 | Bind 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, // ... } }
| Method | Description |
|---|---|
default() -> Self | Create with defaults |
with_host(self, host: IpAddr) -> Self | Set bind address |
with_port(self, port: u16) -> Self | Set port |
with_output_dir(self, dir: PathBuf) -> Self | Set output dir |
with_site_dir(self, dir: PathBuf) -> Self | Set site dir |
with_include_drafts(self, bool) -> Self | Mirrored into every rebuild (#40) |
with_open(self, bool) -> Self | Open 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 }, } }
| Type | Description |
|---|---|
ConfigError | Configuration errors (not found, parse, missing field, invalid value) |
ContentError | Content errors (not found, frontmatter, IO) |
TemplateError | Template errors (not found, render, syntax) |
AssetError | Asset errors (SCSS, copy) |
RouteError | Route errors (not found, duplicate, invalid path, discovery failed) |
FeedError | Feed generation errors |
ImageError | Image processing errors |
InitError | Initialization errors (invalid name or URL, file write, cancelled) |
ServeError | Server errors (port in use, WebSocket) |
SearchError | Search index serialization errors |
WasmError | WASM client write errors (not wrapped by GeneratorError) |
Result
#![allow(unused)] fn main() { pub type Result<T> = std::result::Result<T, GeneratorError>; }
telemetry Module
| Function | Description |
|---|---|
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 |