Skip to content

sitepress/sitepress

Repository files navigation

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.

About

Sitepress ruby gems

Resources

License

Stars

Watchers

Forks

Packages