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.author_class = "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

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 html are sanitized through an allow-list; every other field is escaped by ERB.
  • The allow-list is configurable via sanitizer_tags and sanitizer_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.