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 ..."></div>
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.