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:

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).

Writebook book detail screenshot

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.


Thanks for reading! Send your comments to [email protected].