A static site generator designed from day one to be transparent to downstream build systems.
The core idea
Unlike mkdocs, Hugo, Sphinx, Jekyll — which are opaque black boxes to build systems ("I'll produce something in _site/, ask me later") — rssite will:
Enumerate every file it will produce before it runs, as a pure function of configuration and sources.
This one design choice gives build systems (rsconstruct, Ninja, Bazel, custom Make) everything they need to:
- Cache each generated page individually (precise incremental builds)
- Compose with other processors that also write into the same output directory
- Model true cross-processor dependencies (e.g., a linter can depend on
_site/about.html) - Run per-page work in parallel (when applicable)
- Detect output conflicts at graph-build time instead of silently corrupting caches
Status
Implemented. rssite init && rssite build produces a site, rssite plan prints the manifest below, and the user guide covers configuration, content, templates and the manifest. The rest of this README is the design contract the implementation follows.
rssite plan print the manifest of every file a build would write, with the sources of each
rssite build build the site, writing only the files whose content changed
rssite check render every page twice without writing: templates, links, translations, determinism
rssite query print the parsed site as JSON
rssite clean remove every file the plan lists
rssite serve build, serve the output directory, rebuild on change, reload open pages
rssite init write a minimal working site
demos/ is a small site with a page per markdown feature (math, callouts, code blocks with line numbers, definition lists, external links, smart punctuation): rssite -C demos serve and open http://127.0.0.1:8000/.
Multi-language sites are the first-class case: every language is explicit and served under its own prefix, pages pair with their translations by name or by key, taxonomy terms pair through a translation table, dates format per language, and a language declares its text direction. Lessons from zola records why each of those is the way it is.
The contract
rssite exposes three modes:
rssite plan # reads config, scans sources, prints JSON manifest to stdout, exits 0
rssite build # runs plan internally, then builds; errors if outputs diverge from plan
rssite build --plan # prints manifest AND builds in one invocation (single-pass)
Manifest schema
{
"version": 1,
"outputs": [
{
"path": "_site/index.html",
"sources": ["docs/index.md", "templates/default.html", "rssite.toml"]
},
{
"path": "_site/about/index.html",
"sources": ["docs/about.md", "templates/default.html", "rssite.toml"]
},
{
"path": "_site/assets/style.css",
"sources": ["assets/style.scss", "assets/_vars.scss"]
}
]
}
version— integer schema version. Incremented on breaking changes. Consumers pin the major version they support.outputs— array, one entry per file rssite will produce. Order is deterministic (sorted by path).outputs[].path— output file path relative to the project root.outputs[].sources— every input file whose change would affect this output's content: markdown source, template, stylesheet partial, config file, plugin data, etc.
The sources array is the gold nugget — it lets downstream build tools wire per-file dependencies precisely. Without it, the best any build tool can do is "rebuild everything when any source changes."
Design invariants
These are contracts rssite must uphold. The feature is only valuable if these hold.
1. Plan is a pure function of inputs
Given the same configuration file and the same source tree contents, rssite plan must produce the same manifest — bit for bit. No:
- Network I/O
- Timestamps in the output
- Non-deterministic iteration
- Environment variable reads (unless declared as sources)
Why: cache keys must be stable. Plan output is fed into content-addressed caches.
2. Plan is amortized with single-pass mode
Computing the plan parses every markdown file (for front matter, tag aggregation, redirects). Two modes keep that fast:
- Downstream per-product caching: build systems like rsconstruct cache each manifest entry keyed on its declared
sources. - Single-pass mode:
rssite build --planproduces both manifest and outputs in one invocation, amortizing the plan cost with the build.
3. Built outputs must match the plan
When rssite build runs:
- Every file in the plan's
outputs[]MUST be produced. - No file outside the plan may be produced.
Divergence here would corrupt downstream caches, so the implementation makes it structurally impossible rather than detecting it: the build iterates the plan's entries and produces each one, so there is no second list of outputs to disagree with the first. A planned file that cannot be produced (a template error, a broken link) fails the build with that file named.
4. Plan and build share the same code path
The single most dangerous failure mode in this design is plan/build divergence — the plan says one thing, the build does another.
Guard against it structurally: the plan function IS the first phase of the build function. Internally, rssite should have a single generate_file_plan(config, sources) -> FilePlan, and both plan and build commands invoke it. The build pass then iterates the plan and produces each file.
Plugins follow the same rule (see below).
5. Variable outputs are deterministic
Some output types depend on content:
- Tag index pages (one per tag found in front matter)
- Archive pages (one per year/month found in dates)
- RSS/Atom feeds (contain hashes of included content)
- Search index (content-derived)
These are fine — they're pure functions of the source tree. The plan pass must parse enough of each source to enumerate them.
If an output can only be known after generating other outputs (e.g., a sitemap referencing pages), that's allowed but should be modeled explicitly: the sitemap's sources is the set of all its contributing pages' sources. The plan still enumerates it.
Plugins
If rssite supports plugins (likely yes), the plugin API must be plan-aware from day one.
A plugin registers:
trait Plugin {
/// Contribute to the plan. Called during `plan` and during `build`'s
/// first phase. Must be deterministic given the same inputs.
fn contribute_outputs(&self, ctx: &PlanContext) -> Vec<PlannedOutput>;
/// Actually produce the files declared in contribute_outputs.
/// Called only during `build`. MUST produce exactly the paths
/// it declared in contribute_outputs, no more and no less.
fn build_outputs(&self, ctx: &BuildContext) -> Result<()>;
}
This dual requirement is what mkdocs-style plugin ecosystems get wrong: plugins can override on_files but the output-listing logic is separate from the file-writing logic. In rssite's design, both MUST come from the same plugin, and the framework verifies consistency.
Non-goals
- Drop-in replacement for mkdocs/Hugo/Jekyll. This is a new tool with different guarantees. Existing theme/plugin ecosystems do not transfer.
- Support for all possible outputs. If a plugin fundamentally cannot enumerate its outputs without running the full generation, that plugin is incompatible with rssite's design. (Most can; some genuinely can't.)
- Dynamic/runtime content. rssite generates static files. No server-side rendering, no runtime template evaluation, no per-request generation.
Integration: rsconstruct MassGenerator
The rsconstruct build system defines a dedicated processor type, MassGenerator, that consumes tools emitting this manifest format. See:
- Output Prediction — the full design rationale on the rsconstruct side.
- MassGenerator processor type — user-facing contract.
Wiring rssite into an rsconstruct project:
[processor.mass_generator.generic.site]
command = "rssite"
args = ["build"]
predict_command = "rssite"
predict_args = ["plan"]
output_dirs = ["_site"]
rsconstruct runs rssite plan at graph-build time, turns each manifest entry into a declared product with its own sources as the input list, caches each page individually, and plays cleanly with other processors writing into _site/. The manifest's sources field is what enables rsconstruct to rebuild only the pages whose inputs changed.
Other build systems (Ninja generator scripts, Bazel macros, Make meta-rules) can follow the same pattern — the manifest format is deliberately build-tool-agnostic.
Design choices
- Config format: TOML (
rssite.toml), matching rsconstruct's configuration language. - Template language: Tera, with non-deterministic functions (
now,get_random,get_env) disabled. - Markdown engine:
pulldown-cmarkwithsyntectfor build-time syntax highlighting andgrassfor Sass compilation.
Prior art / what we're learning from
- mkdocs: excellent developer experience, opaque outputs, plugin APIs that couple rendering and file listing incorrectly.
- Sphinx: configurable,
conf.pyis literal Python (hard to analyze statically), genuinely incremental builds but project-scoped. - Hugo: very fast, but rebuilds everything on each invocation; no partial build.
- Jekyll: Liquid permalinks + front matter — manifest could in principle be computed, but the tool doesn't expose one.
- Zola: fast single binary, but no plan mode, an asymmetric default language, and a long list of silent failures around i18n and templating. The full list, drawn from building veltzer.org with it, is in docs/src/zola-problems.md.
- Bazel's
skylib: rules declare outputs up front viaattrs.output()and family. This is exactly the pattern rssite adopts at the tool level.
See also
- rsconstruct: Output Prediction design — rsconstruct's full design spec for consuming plan-emitting tools.
- rsconstruct: MassGenerator processor type — user-facing config reference for wiring rssite-style tools into an rsconstruct build.
- rsconstruct: Shared Output Directory — the fallback mechanism for tools that do NOT (yet) emit a manifest.
- rsconstruct: Processor Ordering — why rsconstruct prefers discovering outputs over letting users declare explicit ordering.
Contributing
Contributions welcome. Run cargo test, cargo clippy --all-targets -- -D warnings, and rsconstruct build before opening a pull request.
License
MIT, see LICENSE.