0.1
There's a lot of open issues
A long-lived project that still receives updates
An embeddable file-backed content management system.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 1.11
~> 13.0
~> 3.0

Runtime

>= 2.99
 Project Readme

Sitepress

Sitepress is a file-backed website content manager that can be embedded in popular web frameworks like Rails, run stand-alone, or be compiled into static sites. Its useful for marketing pages or small websites that you need to deploy within your web frameworks.

It features:

  • Wide support for templates incuding Erb, Haml, Slim, and more.
  • Static site compilation to S3, Netlify, etc.
  • Embedable in Rails monoliths
  • Frontmatter
  • Page models
  • Helpers

Build status Maintainability

Installation

Rails Installation

It all starts by running the following from the root of your rails project:

bundle add sitepress-rails

Then follow the instructions in the Sitepress Rails gem.

Multiple sites in a single Rails app

You can serve any number of Sitepress sites from one Rails app — a marketing site at /, an admin docs site at /admin/docs, anything you like. Three pieces, plain Ruby, no DSL:

# 1. config/initializers/sitepress.rb — register the site at boot
Sitepress.sites << Sitepress::Site.new(root_path: "app/content/admin_docs")
# 2. app/controllers/admin/docs_controller.rb — bind it to a controller
class Admin::DocsController < Sitepress::SiteController
  self.site = Sitepress.sites.fetch("app/content/admin_docs")

  # Normal Rails things work here.
  layout "admin"
  before_action :require_admin
end
# 3. config/routes.rb — mount the controller
Rails.application.routes.draw do
  sitepress_pages   # default site at /

  namespace :admin do
    scope :docs do
      sitepress_pages controller: "admin/docs", as: :admin_doc
    end
  end
end

A request to /admin/docs/getting-started resolves Admin::DocsController.site (which class_attribute provides via the standard Rails class-level reader), and the controller renders the resource. The route helper admin_doc_path("getting-started") gives you /admin/docs/getting-started — generated by Rails from the as: parameter and the surrounding scope.

Why three pieces and not one? Boot ordering forces it:

  • Initializer — Zeitwerk needs helper / model paths registered before its eager-load pass, which happens before the first request. Sitepress.sites is the only piece that has to be at boot, and registering helpers / models / assets is the only thing it does — it has no opinion about routing or controllers.
  • Controllerself.site = Sitepress.sites.fetch("...") binds the controller to a registered site. It's a normal class_attribute writer with one extra behavior: assigning a site also prepend_view_paths the site's view directories onto this controller's lookup chain. Multi-site view lookups stay local — no global ActionView path pollution. Layouts, before_actions, and overrides hang off the controller the way they do in any Rails app.
  • Routessitepress_pages controller: "admin/docs" mounts the controller. The mount path comes from the surrounding scope/namespace, read once by the constraint. The site reference is whatever the controller's .site returns at request time.

The whole multi-site API is two methods: Sitepress.sites (the registry) and Sitepress.site (the configured default, unchanged). The registry has three operations on it — << to add, fetch to look up by root_path, and each plus the rest of Enumerable for iteration. There's no [] (no nil-on-miss footgun, no decision between soft-miss and hard-miss lookup forms), no add (<< is the one verb for adding, returns self per Ruby convention), no naming layer (the path is the identity, no symbols).

A typo in the path string fails loudly at controller class load with the registered paths listed in the error message:

NotFoundError: No Sitepress site registered at "app/contnet".
Registered: ["app/content/admin_docs", "app/content/marketing"]

The key invariant — Sitepress.sites.fetch(x).root_path.to_s == x — holds by construction because Sitepress::Site#root_path is immutable after construction (no writer) and the registry stores Site instances directly with no derived key alongside.

Multiple controllers, same site. A site can be referenced by more than one controller. A public docs reader and an admin docs editor can both call Sitepress.sites.fetch("app/content/docs") and bind to the same Site — same content tree, different request behavior. The Site is registered once in the initializer regardless.

Generator. bin/rails generate sitepress:site app/content/admin_docs scaffolds the directory tree, a stub pages/index.html.erb, the controller subclass with self.site = Sitepress.sites.fetch(...) already filled in, and either creates or appends to config/initializers/sitepress.rb with the registration line. Pass --mount-at=/admin/docs to also inject a scope block into config/routes.rb automatically; without the flag the generator just prints the routes line for you to paste.

Static compilation. Three rake tasks, namespaced to keep single-site and multi-site flows separate:

  • rake sitepress:compile — compiles the configured default site only. Single-site apps use this; the bare form never iterates Sitepress.sites so adding a registered site doesn't change what this task builds.
  • rake sitepress:sites:compile — compiles every registered site (default + everything in Sitepress.sites). Each site is written to tmp/sitepress/<basename of root_path> so two sites never collide on output.
  • rake "sitepress:sites:compile[app/content/admin_docs]" — compiles a single registered site by root_path. Raises Sitepress::NotFoundError listing registered paths if no match.
  • rake sitepress:sites — lists the configured default site and everything in Sitepress.sites. Useful for "is my site actually loaded?" debugging.

Two env vars adjust the compile tasks:

  • OUTPUT_PATH=build rake sitepress:sites:compile — overrides the default tmp/sitepress build root.
  • FAIL_ON_ERROR=true rake sitepress:sites:compile — raises on the first resource that fails to render and aborts rake with a non-zero exit. Useful in CI to make broken pages fail the deploy. The default (false) collects all failures and prints a summary at the end so you can see everything that broke in one run.

After every compile run, the tasks print a Compilation Summary block listing how many sites were built, how many resources succeeded/failed, and (if any failed) the path of every failing resource paired with the site it lives in.

These tasks only handle content compilation. Run your asset bundler (Propshaft, esbuild, Tailwind, etc.) separately if you have static assets that need building.

Adding multi-site to an existing single-site app. Your existing setup keeps working unchanged — sitepress_pages at the root continues to serve Sitepress.site (the configured default). To add a second site:

  1. Add Sitepress.sites << Sitepress::Site.new(root_path: "app/content/admin_docs") to an initializer.
  2. Generate the controller with bin/rails generate sitepress:site app/content/admin_docs (or write it by hand as a Sitepress::SiteController subclass with self.site = Sitepress.sites.fetch("...")).
  3. Mount it under a scope in routes.rb (the generator can do this for you with --mount-at=/admin/docs).

The default site is unaffected by any of this. Multi-site is purely additive.

Standalone Installation

Install the Sitepress gem on your system:

$ gem install sitepress

Then create a new site:

$ sitepress new my-site

Sitepress will create a new site and download and install the gems it needs. Once that's done run:

$ cd my-site

Then start the Sitepress development server:

$ sitepress server

You should then see the site at http://localhost:8080/. Time to start building something beautiful!

Features

Sitepress implements a subset of the best features from the Middleman static site generator including the Site and Parsers::Frontmatter.

Frontmatter

Frontmatter is a way to attach metadata to content pages. Its a powerful way to enable a team of writers and engineers work together on content. The engineers focus on reading values from frontmatter while the writers can change values.

---
title: This is a swell doc
meta:
  keywords: this, is, a, test
background_color: #0f0
---

%html
  %head
    %meta(name="keywords" value="#{current_page.data.dig("meta", "keywords")}")
  %body(style="background: #{current_page.data["background_color"]};")
    %h1=current_page.data["title"]
    %p And here's the rest of the content!

Site

The Site accepts a directory path

> site = Sitepress::Site.new(root_path: "spec/pages")
=> #<Sitepress::Site:0x007fcd24103710 @root=#<Pathname:spec/pages>, @request_path=#<Pathname:/>>

Then you can request a resource by request path:

> resource = site.get("/test")
=> #<Sitepress::Resource:0x007fcd2488a128 @request_path="/test", @content_type="text/html", @file_path=#<Pathname:spec/pages/test.html.haml>, @frontmatter=#<Sitepress::Parsers::Frontmatter:0x007fcd24889e80 @data="title: Name\nmeta:\n  keywords: One", @body="\n!!!\n%html\n  %head\n    %title=current_page.data[\"title\"]\n  %body\n    %h1 Hi\n    %p This is just some content\n    %h2 There\n">>

And access the frontmatter data (if available) and body of the template.

> resource.data
=> {"title"=>"Name", "meta"=>{"keywords"=>"One"}}
> resource.body
=> "\n!!!\n%html\n  %head\n    %title=current_page.data[\"title\"]\n  %body\n    %h1 Hi\n    %p This is just some content\n    %h2 There\n"

Resource globbing

The Site API is a powerful way to query content via resource globbing. For example, if you have a folder full of files but you only want all .html files within the docs directory, you'd do something like:

%ol
  -site.resources.glob("docs/*.html*").each do |page|
    %li=link_to page.data["title"], page.request_path

Architecture

Sitepress has a layered architecture that separates concerns: reading files, organizing them into a tree, and presenting them with domain logic.

Overview

┌─────────────────────────────────────────────────────────┐
│  PageModel (optional)                                   │
│  - Domain logic, computed properties                    │
│  - Wraps Resources, hoists data to methods              │
├─────────────────────────────────────────────────────────┤
│  Resource                                               │
│  - URL/request path, format, MIME type                  │
│  - Tree navigation (parent, children, siblings)         │
│  - Wraps a Source                                       │
├─────────────────────────────────────────────────────────┤
│  Source (Page, Image, or custom)                        │
│  - Reads files, provides data and body                  │
│  - Page: text with frontmatter, renderable              │
│  - Image: binary with dimensions                        │
├─────────────────────────────────────────────────────────┤
│  Node                                                   │
│  - Tree structure (parent, children)                    │
│  - Holds Resources by format                            │
├─────────────────────────────────────────────────────────┤
│  Site                                                   │
│  - Entry point, builds tree from files                  │
│  - Provides get/glob methods to query resources         │
└─────────────────────────────────────────────────────────┘

Building a Tree Manually

Understanding how to build a tree manually helps clarify how the pieces fit together.

require "sitepress-core"

# The root node is the top of the tree. Nodes represent positions
# in the URL hierarchy, like directories in a filesystem.
root = Sitepress::Node.new

# Sources know how to read files. A Page reads text files with
# optional YAML frontmatter. An Image reads binary image files
# and extracts dimensions.
homepage = Sitepress::Page.new(path: "pages/index.html.erb")
logo = Sitepress::Image.new(path: "pages/logo.png")

# Resources connect Sources to Nodes. A Resource has a format
# (html, png, etc.) and knows its request path based on its
# position in the tree.
#
# Here we add the homepage to the root node. The "index" child
# node is created automatically.
root.child("index").resources.add_source(homepage, format: :html)

# Multiple formats can exist at the same node. This is how
# /about.html and /about.json can coexist.
root.child("logo").resources.add_source(logo, format: :png)

# Now we can query the tree:
root.get("/index")           # => Resource (homepage)
root.get("/index").data      # => {"title" => "Welcome"} (from frontmatter)
root.get("/index").body      # => "<h1>Hello</h1>..." (template body)

root.get("/logo")            # => Resource (logo)
root.get("/logo").data       # => {"width" => 200, "height" => 100}
root.get("/logo").source.width  # => 200

Sources: Page and Image

Sources are responsible for reading files and providing a consistent interface.

# Page reads text files with optional YAML frontmatter.
# It's renderable through template handlers (ERB, Haml, etc.)
page = Sitepress::Page.new(path: "about.html.erb")
page.data          # => {"title" => "About Us"} (from frontmatter)
page.body          # => "<h1>About</h1>..." (template content)
page.format        # => :html
page.mime_type     # => #<MIME::Type: text/html>
page.renderable?   # => true

# Image reads binary image files and extracts dimensions.
# It's not renderable - you serve the binary directly.
image = Sitepress::Image.new(path: "photo.jpg")
image.data         # => {"width" => 1920, "height" => 1080}
image.body         # => binary content
image.format       # => :jpg
image.mime_type    # => #<MIME::Type: image/jpeg>
image.width        # => 1920
image.height       # => 1080

Resources and Tree Navigation

Resources wrap Sources and provide tree navigation filtered by format.

# Get a resource
about = site.get("/about")

# Tree navigation returns resources of the same format by default
about.parent          # => Resource at "/" (html format)
about.children        # => [Resource, Resource, ...] (html children)
about.siblings        # => [Resource, Resource, ...] (html siblings)

# This makes iteration natural - you're always dealing with
# the same type of content:
about.children.each do |child|
  puts child.data["title"]  # Works because all children are html pages
end

Mounting Content

Use << to add content to the site tree:

Sitepress.configure do |site|
  # Mount pages at the root
  site.root << Directory.new("./pages")

  # Mount docs under /docs
  site.root.child("docs") << Directory.new("./docs")
end

The resulting site tree:

/                          ← from ./pages
/about                     ← from ./pages
/docs/getting-started      ← from ./docs
/docs/api-reference        ← from ./docs

You can mount the same content in multiple places:

docs = Directory.new("./docs")

site.root.child("docs") << docs
site.dig("api", "v1", "docs") << docs

Example: Blog with Tags

Posts live on disk with tags in frontmatter:

./posts/2024/12/my-post.html.md
./posts/2024/11/another.html.md
# ./posts/2024/12/my-post.html.md
---
title: My Post
tags: [ruby, sitepress]
---

Mount posts, then mount tags that reference them:

Sitepress.configure do |site|
  site.root.child("posts") << Directory.new("./posts")

  site.root.child("tags") << Tags.new(
    source: site.root.child("posts"),
    template: "./templates/tag.html.erb"
  )
end

The Tags class collects tags from all resources under the source node:

class Tags
  def initialize(source:, template:)
    @source = source
    @template = template
  end

  def mount(node)
    collect_tags.each do |tag, resources|
      source = TagPage.new(tag: tag, resources: resources, template: @template)
      node.child(tag).resources.add_asset(source, format: :html)
    end
  end

  private

  def collect_tags
    index = Hash.new { |h, k| h[k] = [] }
    @source.resources.flatten.each do |resource|
      resource.data["tags"]&.each { |tag| index[tag] << resource }
    end
    index
  end
end

Resulting tree:

/posts/2024/12/my-post   ← from disk
/posts/2024/11/another   ← from disk
/tags/ruby               ← generated (both posts)
/tags/sitepress          ← generated (my-post only)

Custom Sources

You can create custom sources for other file types by implementing the source interface:

class VideoSource
  attr_reader :path

  def initialize(path:)
    @path = Pathname.new(path)
  end

  def format
    path.extname.delete(".").to_sym
  end

  def mime_type
    MIME::Types.type_for(path.to_s).first
  end

  def data
    # Extract video metadata (duration, dimensions, codec, etc.)
    @data ||= Sitepress::Data.manage({
      "width" => video_width,
      "height" => video_height,
      "duration" => video_duration
    })
  end

  def body
    File.binread(path)
  end
end

Then subclass Directory to use your custom source and mount it:

class VideoDirectory < Sitepress::Directory
  protected

  def process_asset(path, node)
    source = VideoSource.new(path: path)
    node.child(source.node_name).resources.add_source(source, format: source.format)
  end
end

site.root.child("videos") << VideoDirectory.new("./videos")

Page Models (Optional)

Page models add domain logic on top of resources. They're decoupled from resources - a single resource can be used by multiple page models.

class Photo
  def initialize(resource)
    @resource = resource
  end

  # Hoist data to methods
  def title
    @resource.data["title"] || filename_as_title
  end

  def width
    @resource.data["width"]
  end

  def height
    @resource.data["height"]
  end

  # Add computed properties
  def landscape?
    width > height
  end

  def thumbnail_url
    "#{@resource.request_path}?size=thumb"
  end

  # Class method to find all photos
  def self.all(site)
    site.resources.select { |r| r.source.is_a?(Sitepress::Image) }
                  .map { |r| new(r) }
  end

  private

  def filename_as_title
    @resource.source.filename.sub(/\.\w+$/, "").gsub(/[-_]/, " ").capitalize
  end
end

# Usage in templates:
Photo.all(site).each do |photo|
  puts "#{photo.title}: #{photo.width}x#{photo.height}"
  puts "Landscape!" if photo.landscape?
end

Backwards Compatibility

For backwards compatibility, Sitepress::Asset is an alias for Sitepress::Page.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/sitepress/sitepress.