There's a lot of open issues
A long-lived project that still receives updates
Sitepress rack app for stand-alone of embedded usage.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

Runtime

 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.

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.