ferrosite 0.1.0

A railway-oriented static site generator for personal homepages
Documentation

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
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.


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

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.

# 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:

ferrosite ssr-setup

That writes:

[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.

# 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:

# 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)

[deploy]
provider = "cloudflare"

[deploy.cloudflare]
project_name = "my-site"
account_id   = "YOUR_CF_ACCOUNT_ID"
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

[deploy]
provider = "aws"

[deploy.aws]
bucket_name = "my-site-bucket"
region      = "eu-central-1"
cloudfront_distribution_id = "EXAMPLEID"

Azure Static Web Apps

[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

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