ferrosite π©
A static site generator in Rust - powered by atomic design, slots and web components.
Philosophy
Keep hosting simple - Create a static website for everything that can be static. Dynamic content only via plugins.
Design Slots - content is not pasted into templates. Every markdown article declares a slot in its frontmatter. The build system routes it to the correct position on the correct page. Forty slot types cover atoms, molecules, organisms, and layout regions.
Progressive Enhancement - pages render as complete HTML. pfusch web components add interactivity without replacing static content. An optional Puppeteer SSR pass pre-renders shadow DOM for zero-JS environments.
Exception-free library - every operation is a Result<T, SiteError>. Pure functions transform data; side effects happen only at pipeline edges. Nothing panics in library code.
Is this the right CMS for you?
Probably not. I use this for my own matthias-kainer.de website, I wrote about the why here.
So unless you are me, have my requirements, or always secretly wanted to be me, this is probably not the right CMS for your problems. Go write your own - pretty sure there is space for one more CMS in this world!
Quick Start
# Install
# Create a new site from the developer template
# OR Create a new site from the company template
# OR Create a new site from the product template
# OR Create a new site from a GitHub template repository
# Skip the interactive questions and use defaults
# Create content interactively
# Build
# Preview locally (reload on changes), including plugin worker routes
# Ship (build + deploy)
Project Structure
my-site/
βββ ferrosite.toml # Site configuration
βββ content/ # Your markdown articles
β βββ home.md # Hero section content
β βββ about.md # About page body
β βββ blog/ # Blog posts (slot: article-body)
β βββ projects/ # Project details (slot: project-body)
β βββ skills/ # Skill groups (slot: skill-group)
βββ assets/ # Static assets (images, fonts, extra CSS)
βββ plugins/ # Optional site-local plugins or overrides
β βββ my-custom-plugin/
β βββ manifest.toml
β βββ component.js # pfusch component
β βββ worker.js # Cloudflare Worker / Lambda handler
βββ templates/ # Override bundled templates (optional)
β βββ developer/
β βββ theme.toml
β βββ layouts/ # Jinja2 HTML layouts
β βββ components/ # pfusch .js component files
βββ dist/ # Build output (gitignored)
Local markdown images are collected during the build, rewritten to /static/media/..., and emitted as optimized files in dist/static/media/.
Bundled templates currently include developer, company, and product.
Bundled plugins currently live in Ferrosite itself under ferrosite/plugins/ and are loaded automatically when enabled in ferrosite.toml.
- To build your own template, see
templates/HOWTO-CREATE-A-TEMPLATE.md. - To edit, build, and operate a site built from a template, see
HOWTO-BUILD-EDIT-OPERATE-YOUR-SITE.md.
The Slot System
Every markdown article has a slot field in its YAML frontmatter:
title: "Why I Rewrote Our Build Tool in Rust"
slot: "article-body" # <-- this routes the article
date: "2024-03-15"
tags: ["rust", "tooling"]
page_scope: "*" # which pages include this ("*", "home", "blog", ...)
order: 0 # sort position within the slot
weight: 80 # prominence (higher = more featured)
Your markdown content here...
Slot Tiers (Atomic Design)
| Tier | Examples |
|---|---|
| Atom | text-block, image, badge, link-button, code-snippet, stat-number |
| Molecule | article-card, project-card, skill-group, social-link, timeline-entry, nav-item, dock-item |
| Organism | hero, blog-feed, project-grid, skills-matrix, career-timeline, contact-form |
| Region | header-brand, footer-about, footer-nav-column, sidebar-widget |
Commands
# Re-route content to a different slot/page_scope
Authoring workflow
The authoring commands create or update markdown files with the frontmatter Ferrosite already understands, so they slot straight into the existing build pipeline.
# Interactive blog post
# Scriptable article creation
# Create about-page content and a matching nav item
# Create a standalone nav item
# Update frontmatter for an existing file
# Reassign an existing content file to a new slot
# Interactively reorder navigation entries and save fresh order values
ferrosite edit accepts a site-local path like content/about.md, a filename
stem like nav-about, or a unique slug/title match. Use --open with
ferrosite add ... or ferrosite edit ... to jump straight into your editor.
ferrosite add page scaffolds content that participates in an existing routed
page or slot. It does not, by itself, create a brand new template/layout route;
for that you still need to update the template layouts.
ferrosite reorder focuses on one slot at a time, shows the current order, and
accepts u <n>, d <n>, or m <from> <to> before writing normalized order
values back into the matching markdown files.
SSR (Server-Side Rendering)
Run the setup command to scaffold ssr/, install Puppeteer, and enable SSR in ferrosite.toml:
That writes:
[]
= true
= "node"
= "npm"
= 30000
= 2
If you prefer another package manager, set package_manager_bin = "pnpm" or run ferrosite ssr-setup --package-manager-bin yarn.
During ferrosite build --ssr, Ferrosite batches only the pages that actually instantiate SSR-marked components, serves the generated site locally once, and reuses a single Puppeteer browser across the batch. Pages are rendered with bounded concurrency via concurrency, then the rendered shadow DOM is written back to the output HTML using getHTML({ includeShadowRoots: true, serializableShadowRoots: true }).
Plugins
Plugins bring their own pfusch web component and a lambda worker. The worker implements a CQRS interface:
POST /api/plugin-routewith{ command, payload }- mutates stateGET /api/plugin-route?query=QueryName&...- reads state
The build system generates a CQRS wrapper around your worker code automatically.
For local end-to-end testing, ferrosite run serves the static site and dispatches plugin routes through a local worker runner.
Plugin resolution order is:
- site-local plugins from
./plugins/ - bundled Ferrosite plugins from
ferrosite/plugins/
If both provide the same plugin name, the site-local plugin wins. This lets templates enable shared plugins without copying them into every scaffolded site, while still allowing per-site overrides.
# plugins/contact-form/manifest.toml
[]
= "contact-form"
= ["contact-form"]
= "component.js"
= "worker.js"
= "/api/contact"
= "cloudflare-worker"
= ["RESEND_API_KEY", "TO_EMAIL"]
Manage plugins from the CLI:
# Install a bundled plugin shipped with ferrosite
# Install a plugin from GitHub via git clone
# Remove a plugin, then inspect the printed file list for remaining references
plugin remove also has the alias plugin uninstall, and plugin add has the alias plugin install.
There is currently no dedicated ferrosite plugin update command.
To update an existing plugin:
- identify how it was installed
- bundled Ferrosite plugin enabled via
plugins.enabled - git-based plugin cloned into your site's
plugins/folder - custom/local plugin you maintain directly in
plugins/
- bundled Ferrosite plugin enabled via
- apply the matching update path
- for a git-based plugin: run
git pullinsideplugins/<plugin-name>/ - for a bundled plugin: update Ferrosite itself, then rebuild or reinstall the binary
- for a local/custom plugin: edit
plugins/<plugin-name>/manifest.toml,plugins/<plugin-name>/component.js, andplugins/<plugin-name>/worker.js
- for a git-based plugin: run
- verify
plugins.enabledinferrosite.tomlstill contains the plugin name - run
ferrosite runto test the worker route and UI together - rebuild or redeploy once the updated plugin works as expected
For bundled plugins, plugin remove disables the plugin in config and prints files that still reference it. For site-local plugins, it removes the plugin directory and prints the same reference list.
Deploy
Cloudflare Pages (recommended - free tier)
[]
= "cloudflare"
[]
= "my-site"
= "YOUR_CF_ACCOUNT_ID"
Static files β Cloudflare Pages (free).
Plugin workers β Cloudflare Workers (free tier: 100k requests/day).
AWS S3 + CloudFront
[]
= "aws"
[]
= "my-site-bucket"
= "eu-central-1"
= "EXAMPLEID"
Azure Static Web Apps
[]
= "azure"
[]
= "my-rg"
= "my-site"
Architecture (Rust)
src/
βββ error.rs # SiteError + SiteResult<T> + collect_results()
βββ config/ # TOML config loading and theme tokens
βββ content/
β βββ frontmatter.rs # YAML frontmatter parsing (pure)
β βββ slot.rs # SlotType enum (40 types, atomic design tiers)
β βββ article.rs # Article model + markdown rendering (pure)
β βββ page.rs # Page assembly + SlotMap (pure)
βββ template/
β βββ engine.rs # minijinja template engine wrapper
β βββ component.rs # pfusch component registry + HTML generation
βββ plugin/
β βββ mod.rs # Plugin manifests, CQRS worker generation
βββ pipeline/
β βββ build.rs # Full ROP pipeline: collectβslotβassembleβrenderβwrite
βββ deploy/
β βββ mod.rs # Cloudflare / AWS / Azure deployers
βββ main.rs # CLI (clap)
Railway-Oriented Pipeline
Every function returns SiteResult<T>. Errors propagate automatically via ?. No unwrap() in library code.
License
MIT