Exploring the Writebook Source Code
Posted:
Earlier this year 37signals released Writebook, a self-hosted book publishing platform. It's offering number two from their ONCE series, pitched as the antithesis of SaaS. Buy it once and own it for life, but run it on your own infrastructure.
Unlike the other ONCE offering, Writebook is totally free. When you "purchase" it through the ONCE checkout, they hook you up with the source code and a convenient means of downloading the software on a remote service. Since the software is free (but not open source) I thought it's fair game to read through it and write a little post about its implementation. It's not everyday that we can study a production Rails application made by the same folks behind Rails itself.
Note: I'll often omit code for the sake of brevity with a "--snip" marker. I encourage you to download Writebook yourself and follow along so you can discover the complete context.
Run the thing
A good place to start is the application entrypoint: Procfile
. I think Procfile
is a holdover from the Heroku-era, when everyone was hosting their Rails applications on free-tier dynos (RIP). Either way, it describes the top-level processes that make up the server:
web: bundle exec thrust bin/start-app
redis: redis-server config/redis.conf
workers: FORK_PER_JOB=false INTERVAL=0.1 bundle exec resque-pool
Nice and simple. There are three main components:
web
, Writebook's web and application serverredis
, the backing database for the application cache and asynchronous workersworkers
, the actual process that executes asynchronous tasks
The only other infrastructure of note is the application database, which is running as a single file via SQLite3.
bundle exec thrust bin/start-app
might be surprising for folks expecting bin/rails server
as the main Rails process. thrust
is the command invocation for thruster, a fairly recent HTTP proxy developed by 37signals specifically for ONCE projects. It provides a similar role to nginx, a web server that sits in front of the main Rails process to handle static file caching and TLS. The thrust
command takes a single argument, bin/start-app
, which contains your standard bin/rails s
invocation, booting up the application server.
Redis and workers
fill out the rest of the stack. Redis fills a few different purposes for Writebook, serving as the application cache and the task queue for asynchronous work. I'm a little surprised Solid Queue and Solid Cache don't make an appearance, swapping out Redis for the primary data store (SQLite in this case). But then again, perhaps it's more cost-efficient to run Redis in this case, since Writebook probably wants to be self-hosted on minimal hardware (and not have particular SSD requirements).
You can run the application locally with foreman (note you'll need Redis installed, as well as libvips for image processing):
foreman start
Pages that render markdown
When it comes to the textual content of books created with Writebook, everything boils down to the Page
model and it's fancy has_markdown :body
invocation:
class Page < ApplicationRecord
# --snip
has_markdown :body
end
That single line of code sets up an ActionText association with Page
under the attribute name body
. All textual content in Writebook is stored in the respective ActionText table, saved as raw markdown. Take a look at this Rails console query for an example:
writebook(dev)> Page.first.body.content
=> "# Welcome to Writebook\n\nThanks for downloading Writebook...
To my surprise, has_markdown
is not actually a Rails ActionText built-in. It's manually extended into Rails by Writebook in lib/rails_ext/action_text_has_markdown.rb
, along with a couple other files that integrate ActionText with the third-party gem redcarpet:
module ActionText
module HasMarkdown
extend ActiveSupport::Concern
class_methods do
def has_markdown(name, strict_loading: strict_loading_by_default)
# --snip
has_one :"markdown_#{name}", -> { where(name: name) },
class_name: "ActionText::Markdown", as: :record, inverse_of: :record, autosave: true, dependent: :destroy,
strict_loading: strict_loading
# --snip
end
end
end
end
# ...
module ActionText
class Markdown < Record
# --snip
mattr_accessor :renderer, default: Redcarpet::Markdown.new(
Redcarpet::Render::HTML.new(DEFAULT_RENDERER_OPTIONS), DEFAULT_MARKDOWN_EXTENSIONS)
belongs_to :record, polymorphic: true, touch: true
def to_html
(renderer.try(:call) || renderer).render(content).html_safe
end
end
end
lib/rails_ext/
as the folder name is very intentional. The code belongs in lib/
and not app/lib/
because it's completely agnostic to the application. It's good ol' reusable Ruby code for any Rails application that has ActionText. rails_ext/
stands for "Rails extension", a common naming convention for vendor monkey patches that might live in a Rails application. This code re-opens an existing namespace (the ActionText
module, in this case) and adds new functionality (ActionText::Markdown
). Within the application, users can use ActionText::Markdown
without evet knowing it's not a Rails built-in.
This is a neat little implementation for adding markdown support to ActionText, which is normally just a rich text format coupled to the Trix editor.
Beyond pages
Page
is certainly the most important data model when it comes to the core functionality of Writebook: writing and rendering markdown. The platform supports a couple other fundamental data types, that being Section
and Picture
, that can be assembled alongside Pages
to make up an entire Book
.
The model hierarchy of a Book
looks something like this:
Book = Leaf[], where Leaf = Page | Section | Picture
In other words, a Book
is made up of many Leaf
instances (leaves), where a Leaf
is either a Page
(markdown content), a Section
(basically a page break with a title), or a Picture
(a full-height image).
You can see the three different Leaf
kinds near the center of the image, representing the three different types of content that can be added to a Book
. This relationship is clearly represented by the Rails associations in the respective models:
# app/models/book.rb
class Book < ApplicationRecord
# --snip
has_many :leaves, dependent: :destroy
end
# app/models/leaf.rb
class Leaf < ApplicationRecord
# --snip
belongs_to :book, touch: true
delegated_type :leafable, types: Leafable::TYPES, dependent: :destroy
positioned_within :book, association: :leaves, filter: :active
end
Well, maybe not completely "clearly". One thing that's interesting about this implementation is the use of a Rails concern and delegated_type
to represent the three kinds of leaves:
module Leafable
extend ActiveSupport::Concern
TYPES = %w[ Page Section Picture ]
included do
has_one :leaf, as: :leafable, inverse_of: :leafable, touch: true
has_one :book, through: :leaf
delegate :title, to: :leaf
end
end
There are three kinds of Leaf
that Writebook supports: Page
, Section
, and Picture
. Each Leaf
contains different attributes according to its kind. A Page
has ActionText::Markdown
content, a Section
has plaintext, and a Picture
has an image upload and a caption. However, despite their difference in schema, each of the three Leaf
kinds is used in the exact same way by Book
. In other words, Book
doesn't care which kind of Leaf
it holds a reference to.
This is where delegated_type
comes into play. With delegated_type
, all of the shared attributes among our three Leaf
kinds live on the "superclass" record, Leaf
. Alongside those shared attributes is a leafable_type
, denoting which "subclass" the Leaf
falls into, one of "Page"
, "Section"
, or "Picture"
. When we call Leaf#leafable
, we fetch data from the matching "subclass" table to pull the non-shared attributes for that Leaf
.
The pattern is made clear when querying in the Rails console:
writebook(dev)> Leaf.first.leafable
SELECT "leaves".* FROM "leaves" ORDER BY "leaves"."id" ASC LIMIT 1
SELECT "pages".* FROM "pages" WHERE "pages"."id" = ?
Rails knows from leafable_type
that Leaf.first
is a Page
. To read the rest of that Leaf
's attributes, we need to fetch the Page
from the pages
table associated to the leafable_id
on the record. Same deal for Section
and Picture
.
Another thing that's interesting about Writebook's use of delegated_type
is that the Leaf
model isn't exposed on a route:
resources :books, except: %i[ index show ] do
# --snip
resources :sections
resources :pictures
resources :pages
end
This makes a ton of sense because the concept of Leaf
isn't exactly "user-facing". It's more of an implementation detail. The relation between the three different Leafable
types is exposed by some smart inheritance in each of the "subclasses". Take SectionsController
as an example:
class SectionsController < LeafablesController
private
def new_leafable
Section.new leafable_params
end
def leafable_params
params.fetch(:section, {}).permit(:body, :theme)
.with_defaults(body: default_body)
end
def default_body
params.fetch(:leaf, {})[:title]
end
end
All of the public controller handlers are implemented in LeafablesController
, presumably because each Leafable
is roughly handled in the same way. The only difference is the params object sent along in the request to create a new Leaf
.
class LeafablesController < ApplicationController
# --snip
def create
@leaf = @book.press new_leafable, leaf_params
position_new_leaf @leaf
end
end
I appreciate the nomenclature of Book#press
to create add a new Leaf
to a Book
instance. Very clever.
Authentication and users
My go-to when setting up authentication with Rails is devise since it's an easy drop-in component. Writebook instead implements its own lightweight authentication around the built-in has_secure_password
:
class User < ApplicationRecord
include Role, Transferable
has_many :sessions, dependent: :destroy
has_secure_password validations: false
has_many :accesses, dependent: :destroy
has_many :books, through: :accesses
# --snip
end
The authentication domain in Writebook is surprisingly complicated because the application supports multiple users with different roles and access permissions, but most of it is revealed through the User
model.
The first time you visit a Writebook instance, you're asked to provide an email and password to create the first Account
and User
. This is represented via a non-ActiveRecord model class, FirstRun
:
class FirstRun
ACCOUNT_NAME = "Writebook"
def self.create!(user_params)
account = Account.create!(name: ACCOUNT_NAME)
User.create!(user_params.merge(role: :administrator)).tap do |user|
DemoContent.create_manual(user)
end
end
end
Whether or not a user can access or edit a book is determined by the Book::Accessable
concern. Basically, a Book
has many Access
objects associated with it, each representing a user and a permission. Here's the Access
created for the DemoContent
referenced in FirstRun
:
#<Access:0x00007f06efac0538
id: 1,
user_id: 1,
book_id: 1,
level: "editor"
#--snip>
Likewise, when new users are invited to a book, they are assigned an Access
level that matches their permissions (reader or editor). Note that all of this access-stuff is for books that have not yet been published to the web for public viewing. Writebook allows you to invite early readers or editors for feedback before you go live.
Whoa, whoa, whoa. What is this rate_limit
on the SessionsController
?
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10,
within: 3.minutes,
only: :create,
with: -> { render_rejection :too_many_requests }
Rails 8 comes with built-in rate limiting support? That's awesome.
Style notes
I like the occasional nesting of concerns under model classes, e.g. Book::Sluggable
. These concerns aren't reusable (hence the nesting), but they nicely encapsulate a particular piece of functionality with a callback and a method.
# app/models/book/sluggable.rb
module Book::Sluggable
extend ActiveSupport::Concern
included do
before_save :generate_slug, if: -> { slug.blank? }
end
def generate_slug
self.slug = title.parameterize
end
end
Over on the HTML-side, Writebook doesn't depend on a CSS framework. All of the classes are hand-written and applied in a very flexible, atomic manner:
<div class="page-toolbar fill-selected align-center gap-half ...">
These classes are grouped together in a single file, utilities.css
. Who needs Tailwind?
.justify-end { justify-content: end; }
.justify-start { justify-content: start; }
.justify-center { justify-content: center; }
.justify-space-between { justify-content: space-between; }
/* --snip */
I'm also surprised at how little JavaScript is necessary for Writebook. There are only a handful of StimulusJS controllers, each of which encompasses a tiny amount of code suited to a generic purpose. The AutosaveController
is probably my favorite:
import { Controller } from "@hotwired/stimulus"
import { submitForm } from "helpers/form_helpers"
const AUTOSAVE_INTERVAL = 3000
export default class extends Controller {
static classes = [ "clean", "dirty", "saving" ]
#timer
// Lifecycle
disconnect() {
this.submit()
}
// Actions
async submit() {
if (this.#dirty) {
await this.#save()
}
}
change(event) {
if (event.target.form === this.element && !this.#dirty) {
this.#scheduleSave()
this.#updateAppearance()
}
}
// Private
async #save() {
this.#updateAppearance(true)
this.#resetTimer()
await submitForm(this.element)
this.#updateAppearance()
}
#updateAppearance(saving = false) {
this.element.classList.toggle(this.cleanClass, !this.#dirty)
this.element.classList.toggle(this.dirtyClass, this.#dirty)
this.element.classList.toggle(this.savingClass, saving)
}
#scheduleSave() {
this.#timer = setTimeout(() => this.#save(), AUTOSAVE_INTERVAL)
}
#resetTimer() {
clearTimeout(this.#timer)
this.#timer = null
}
get #dirty() {
return !!this.#timer
}
}
When you're editing markdown content with Writebook, this handy controller automatically saves your work. I especially appreciate the disconnect handler that ensures your work is always persisted, even when you navigate out of the form to another area of the application.
Closing thoughts
There's more to explore here, particularly on the HTML side of things where Hotwire does a lot of the heavy lifting. Unfortunately I'm not a good steward for that exploration since most of my Rails experience involves some sort of API/React split. The nuances of HTML-over-the-wire are over my head.
That said I'm impressed with Writebook's data model, it's easy to grok thanks to some thoughtful naming and strong application of lesser-known Rails features (e.g. delegated_type
). I hope this code exploration was helpful and inspires the practice of reading code for fun.