# 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](https://github.com/MatthiasKainer/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](https://matthias-kainer.de) website, I wrote about the why [here](https://matthias-kainer.de/blog/posts/relaunching-the-website-and-accidentally-writing-a-cms/).
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
```bash
# Install
cargo install ferrosite
# Create a new site from the developer template
ferrosite new my-site --template developer
# OR Create a new site from the company template
ferrosite new my-site --template company
# OR Create a new site from the product template
ferrosite new my-site --template product
# OR Create a new site from a GitHub template repository
ferrosite new my-site --template https://github.com/user/ferrosite-template.git
# Skip the interactive questions and use defaults
ferrosite new my-site --template developer --yolo
cd my-site
# Create content interactively
ferrosite add article
ferrosite add page
# Build
ferrosite build
# Preview locally (reload on changes), including plugin worker routes
ferrosite run
# Ship (build + deploy)
ferrosite ship
```
---
## 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`](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`](HOWTO-BUILD-EDIT-OPERATE-YOUR-SITE.md).
---
## The Slot System
Every markdown article has a `slot` field in its YAML frontmatter:
```markdown
---
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)
| **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
```bash
ferrosite new <name> # Scaffold a new site (interactive by default)
ferrosite add article # Interactive blog post scaffold in content/blog/
ferrosite add project # Interactive project scaffold in content/projects/
ferrosite add page # Slot-based page content scaffold + optional nav item
ferrosite add nav # Standalone nav-item content scaffold
ferrosite edit <selector> # Update frontmatter by path, slug, filename, or title
ferrosite assign-slot <selector> <slot>
# Re-route content to a different slot/page_scope
ferrosite reorder # Interactively reorder entries in a slot (defaults to nav-item)
ferrosite build # Build the site into dist/
ferrosite build --ssr # Build + Puppeteer SSR pass
ferrosite run # build + serve locally with plugin runners
ferrosite check # Validate config and content
ferrosite slots # List all article slot assignments
ferrosite ssr-setup # Scaffold ssr/ and install Puppeteer deps
ferrosite setup-ssr # Alias for ssr-setup because I can't remember my own cmds
ferrosite config # Print resolved configuration
ferrosite deploy # Deploy dist/ to configured provider
ferrosite ship # build + deploy
```
### 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.
```bash
# Interactive blog post
ferrosite add article
# Scriptable article creation
ferrosite add article --title "Launch notes" --tags launch,product --yolo
# Create about-page content and a matching nav item
ferrosite add page --title "About" --page-scope about --slot about-body
# Create a standalone nav item
ferrosite add nav --title "Services" --url /services/
# Update frontmatter for an existing file
ferrosite edit rewriting-build-tool-rust --title "Why We Rebuilt Our Tooling"
# Reassign an existing content file to a new slot
ferrosite assign-slot content/home.md hero --page-scope home
# Interactively reorder navigation entries and save fresh order values
ferrosite reorder --slot nav-item
```
`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`:
```bash
ferrosite ssr-setup
```
That writes:
```toml
[build.ssr]
enabled = true
node_bin = "node"
package_manager_bin = "npm"
timeout_ms = 30000
concurrency = 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-route` with `{ command, payload }` - mutates state
- `GET /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:
1. site-local plugins from `./plugins/`
2. 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.
```toml
# plugins/contact-form/manifest.toml
[plugin]
name = "contact-form"
slots = ["contact-form"]
component_file = "component.js"
worker_file = "worker.js"
worker_route = "/api/contact"
worker_runtime = "cloudflare-worker"
required_env = ["RESEND_API_KEY", "TO_EMAIL"]
```
Manage plugins from the CLI:
```bash
# Install a bundled plugin shipped with ferrosite
ferrosite plugin add contact-form
# Install a plugin from GitHub via git clone
ferrosite plugin add https://github.com/user/repo.git
# Remove a plugin, then inspect the printed file list for remaining references
ferrosite plugin remove contact-form
```
`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:
1. 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/`
2. apply the matching update path
- for a git-based plugin: run `git pull` inside `plugins/<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`, and `plugins/<plugin-name>/worker.js`
3. verify `plugins.enabled` in `ferrosite.toml` still contains the plugin name
4. run `ferrosite run` to test the worker route and UI together
5. 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)
```toml
[deploy]
provider = "cloudflare"
[deploy.cloudflare]
project_name = "my-site"
account_id = "YOUR_CF_ACCOUNT_ID"
```
```bash
npm install -g wrangler
wrangler login
ferrosite ship
```
Static files β Cloudflare Pages (free).
Plugin workers β Cloudflare Workers (free tier: 100k requests/day).
### AWS S3 + CloudFront
```toml
[deploy]
provider = "aws"
[deploy.aws]
bucket_name = "my-site-bucket"
region = "eu-central-1"
cloudfront_distribution_id = "EXAMPLEID"
```
### Azure Static Web Apps
```toml
[deploy]
provider = "azure"
[deploy.azure]
resource_group = "my-rg"
app_name = "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
```rust
pub fn build_site(site_root: &Path) -> SiteResult<BuildReport> {
BuildContext::load(site_root) // IO edge
.and_then(|ctx| {
collect_articles(&ctx) // IO edge
.and_then(|arts| build_global_slot_map(&arts)) // pure
.and_then(|slots| assemble_pages(&arts, &slots, &ctx.config)) // pure
.and_then(|pages| render_pages(&pages, &ctx)) // pure-ish
.and_then(|rendered| write_output(&rendered, &output_dir)) // IO edge
})
}
```
Every function returns `SiteResult<T>`. Errors propagate automatically via `?`. No `unwrap()` in library code.
---
## License
MIT