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](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)

| 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

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