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
Installation
Rails Installation
It all starts by running the following from the root of your rails project:
bundle add sitepress-railsThen 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
endA 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.sitesis 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. -
Controller —
self.site = Sitepress.sites.fetch("...")binds the controller to a registered site. It's a normalclass_attributewriter with one extra behavior: assigning a site alsoprepend_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. -
Routes —
sitepress_pages controller: "admin/docs"mounts the controller. The mount path comes from the surroundingscope/namespace, read once by the constraint. The site reference is whatever the controller's.sitereturns 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 iteratesSitepress.sitesso adding a registered site doesn't change what this task builds. -
rake sitepress:sites:compile— compiles every registered site (default + everything inSitepress.sites). Each site is written totmp/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 byroot_path. RaisesSitepress::NotFoundErrorlisting registered paths if no match. -
rake sitepress:sites— lists the configured default site and everything inSitepress.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 defaulttmp/sitepressbuild 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:
- Add
Sitepress.sites << Sitepress::Site.new(root_path: "app/content/admin_docs")to an initializer. - Generate the controller with
bin/rails generate sitepress:site app/content/admin_docs(or write it by hand as aSitepress::SiteControllersubclass withself.site = Sitepress.sites.fetch("...")). - Mount it under a
scopeinroutes.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_pathArchitecture
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 # => 200Sources: 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 # => 1080Resources 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
endMounting 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")
endThe 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") << docsExample: 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"
)
endThe 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
endResulting 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
endThen 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?
endBackwards 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.