Pressroom
Block-based article publishing for Rails, as a mountable engine.
Pressroom adds a block editor, an article model with drafts and scheduled publication, categories, tags and feeds to an application you already have. It brings no authentication, no user model and no public routes — those stay yours.
Its distinguishing feature is the block registry: a block is described once in Ruby, and that single description drives the editor, server-side rendering, sanitization and search-text extraction.
Installation
# Gemfile
gem "pressroom"
bundle install
bin/rails db:migrate
Mount the admin:
# config/routes.rb
mount Pressroom::Engine => "/admin"
Configuration
# config/initializers/pressroom.rb
Pressroom.configure do |config|
# Required. Without it the admin refuses to open.
config.authenticate_with do
redirect_to main_app.root_path unless current_user&.editor?
end
config. = "User"
config.transliteration_locale = :ru # slug rules for :ru and :uk ship with the gem
end
Pressroom fails closed: an admin with no authenticate_with hook raises
rather than letting anyone in.
Rendering an article
Pressroom does not own your public routes. Write your own controller and view, and render the document:
<h1><%= @post.title %></h1>
<%= render_blocks @post %>
Optionally include the structural stylesheet. It contains no typography, colour or spacing — only the handful of rules without which a block is broken rather than merely unstyled:
<%= stylesheet_link_tag "pressroom/content" %>
Registering your own block
Pressroom.register_block :callout do |b|
b.tool "Callout" # the Editor.js tool class
b.schema do
string :title, required: true
html :body # html fields are sanitized; string fields are escaped
enum :tone, in: %w[info warning], default: "info"
end
b.partial "blocks/callout"
b.searchable { |data| data["title"] }
end
Pin the JavaScript tool from your own importmap and it appears in the
toolbar. Field types: string, html, integer, boolean, enum,
array, nested, tree.
Publication
Publication is a timestamp comparison, not a background job:
post.update!(status: :published, published_at: Time.current) # live now
post.update!(status: :published, published_at: 3.days.from_now) # scheduled
Pressroom::Post.published # status published AND published_at <= now
Nothing polls and nothing is enqueued. A scheduled post becomes visible the moment its timestamp passes. The admin exposes both as buttons: Publish now, Unpublish, and a Schedule publication field.
Feeds
feed = Pressroom::Feed.new(
Pressroom::Post.published.recent.limit(20),
title: "Example blog",
url: "https://example.com",
description: "Latest posts"
) { |post| post_url(post) }
render xml: feed.to_rss # or feed.to_atom
Search
Pressroom maintains a plain_text column on every save and does not search
it. Index it with whatever you already use:
Pressroom::Post.published.where("plain_text ILIKE ?", "%rails%")
Images
The image block renders from a URL always. File uploads additionally require ActiveStorage; when it is present the upload endpoint and the editor's image tool are enabled automatically. Uploads keep CSRF protection — the editor sends the token as a request header.
Built-in blocks
paragraph, header, list (nested), quote, code, image,
delimiter. Tables, checklists, embeds and attachments are deliberately not
included — register them yourself.
JavaScript
Editor.js, Stimulus and Turbo are vendored into the gem and pinned through importmap. No Node build step is required in your application, and nothing is fetched from a CDN, so a strict CSP and an offline network are both fine.
Stimulus and Turbo are pinned under private names (pressroom/stimulus,
pressroom/turbo) so they cannot collide with your own pins for those
packages.
Security
- Fields declared
htmlare sanitized through an allow-list; every other field is escaped by ERB. - The allow-list is configurable via
sanitizer_tagsandsanitizer_attributes. - An unknown block type, malformed stored JSON, a missing partial or a deleted author degrade to a logged skip in production and raise in development, so one damaged article cannot take a page down.
Public API and versioning
Semver applies to: Pressroom.register_block and its block DSL,
Pressroom.configure, Pressroom::Post, Pressroom::Category,
Pressroom::Tag, Pressroom::Feed, the render_blocks helper, and the
stored JSON format. Everything else is internal.
Requirements
Ruby >= 3.4, Rails >= 7.2. Tested against Rails 7.2, 8.0 and 8.1 on PostgreSQL and SQLite.
License
MIT.