Welcome to a new Rails Designer

In January 2024 I published the first article on Rails Designer’s blog. It listed some of my best practices; most of which I still use today.

Over those two years there were almost 200 articles published on topics ranging from Hotwire, to Rails and Tailwind CSS. Next to that I’ve launched various tools and dozen open source projects. With all those articles, tools and other pages, the site slowly started to feel incoherent. Less consistent in style, feel and branding. So it was time to lose some weight and put on some fresh clothes! 🎩💅

I spent some time recently to build the site from scratch. Not coincidentally this was a good time to build it using Perron, the Rails-based static site generator I launched about seven months ago. If you haven’t checked it out, please do! I think you will like it for your next marketing site, docs or landing page.

Have a look around.

Over time I will tighten things here and there, but if you find glaring issues or certain missing, please comment below. 😊

If you want to read about the tech details, continue reading! 👇


Building a medium-sized site with Perron

Rails Designer’s site is built with Perron. It is the Rails-based SSG. While that certainly might sound weird (isn’t Rails way too big for just static sites?!), it is surprising lean and fast to work with.

I develop the site, rendering the site runningrails server (bin/dev). Perron, via the new Mata gem auto-refreshes the page, making it easy to see what I fixed (or messed up). Once I am ready to publish, I run bin/rails perron:build and, presto: you are looking at the static-build site.

Most of the standard Rails libraries are disabled, the only ones added are:

# config/application.rb
require_relative "boot"

require "active_model/railtie"
require "action_controller/railtie"
require "action_view/railtie"

Bundler.require(*Rails.groups)

module RailsDesigner
  class Application < Rails::Application
  #

Perron works with collections, like posts, articles or features. Those you are likely familiar with you already. Let’s look at the posts collection, for example.

First the routes:

Rails.application.routes.draw do
  resources :articles, module: :content, path: "components/docs", only: %w[index show]
  resources :changelogs, module: :content, path: "components/changelog", only: %w[index]
  resources :components, module: :content, only: %w[index show]

  get "version.json", to: "content/versions#show", as: :version

  # ✂️

  resources :posts, module: :content, path: "articles", only: %w[index show]

  # ✂️

  root to: "content/pages#root"
end

These are just Rails’ routes you are already familiar with, right?! Let’s see the Content::PostsController:

class Content::PostsController < ApplicationController
  def index
    @metadata = {
      #    }

    @resources = Content::Post.all
  end

  def show
    @resource = Content::Post.find!(params[:id])
  end
end

Also just vanilla Rails here! Then one more step: the views. First the index view:

<%= component "hero", heading: "Rails UI Engineering Articles", description: "Practical guides to build better Rails UI. I share the patterns and approaches I use in my daily work with Hotwire, Rails and modern front-end practices." %>

<%= component "container" do %>
  <section class="grid gap-y-2">
    <%= component("heading") { "Latest articles" } %>

    <ul class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:gap-6">
      <%= render partial: "post_card", collection: @resources.recently_published.limit(4), as: :post %>
    </ul>
  </section>

  <section class="mt-8 md:mt-10">
    <%= component("heading") { "Popular articles" } %>

    <p class="mt-0.5 text-sm text-gray-600 lg:text-base">
      Based on views using the Wilson Score with time decay.
    </p>

    <ol class="grid grid-cols-1 gap-4 mt-4 md:grid-cols-2 lg:gap-6">
      <%= render partial: "post_card", collection: @resources.featured.limit(4), as: :post %>
    </ol>
  </section>

  <!-- ✂️ -->

Pretty clean and still looks like your average Rails view. How about the show template?

<article>
  <%= component "hero", heading: @resource.title %>

  <%= component("container", additional_css: "grid grid-cols-12 lg:gap-20") do %>
    <div class="col-span-12 content md:col-span-9">
      <%= markdownify @resource.content, process: ["lazy_load_images", CopyableCodeProcessor, StyledBlockquoteProcessor, NofollowProcessor, AbsoluteImagesProcessor] %>
    </div>

    <%= render "content/posts/aside", resource: @resource %>
    <%= render "content/posts/dialog", resource: @resource %>
    <%= render "content/posts/comments" %>

    <section class="col-span-12 px-2 py-3 bg-gray-50 border border-gray-100 rounded-lg md:col-span-9 md:px-4 md:py-6 max-md:mt-8">
      <%= component("heading", level: :h2) { "Want to read me more?" } %>

      <ul class="grid gap-y-4 mt-4">
        <%= render partial: "content/posts/post", collection: @resource.related_resources(limit: 3), as: :post, locals: { description: true } %>
      </ul>
    </section>
  <% end %>
</article>

See how resources, which are erb or markdown files stored in app/content/posts, use recently_published, featured and limit? Those are, indeed, scopes defined on the content/post model:

# app/models/content/post.rb
class Content::Post < Perron::Resource
  include Categories

  configure do |config|
    config.feeds.atom.enabled = true

    #
    config.sitemap.priority = 0.4
  end

  delegate :title, :description, :category, :section, :featured, :updated_at, to: :metadata

  scope :featured, -> { where(featured: true) }
  scope :recently_published, -> { order(published_at: :desc) }
  scope :earliest_published, -> { order(published_at: :asc) }

  validates :category, inclusion: {in: Content::Post::CATEGORIES.keys.map(&:to_s)}, if: -> { section == "general" }
end

The only thing you notice that is different from a regular Active Model resource is the parent class: Perron::Resource, but otherwise it “quacks” much the same.

Auto pull data to create resources

Perron offers many features on top it all, like feeds, sitemap and data resource handling. The latter is one I like to highlight now.

I like to list all the open source projects I built and maintain. I am ashamed to admit that previously I built all these pages manually. I just never came around automating it. But also: my previous SSG didn’t have a clear way of doing. Perron does, via the programmatic content feature.

This is how it works. I list all projects I want to include in app/content/data/tools.yml:

- id: rails-icons
  name: Rails Icons
  github_url: https://github.com/rails-designer/rails_icons
  category: oss
  position: 1
  package_registry_url: https://rubygems.org/gems/rails_icons
- id: courrier
  name: Courrier
  category: oss
  github_url: https://github.com/rails-designer/courrier
  package_registry_url: https://rubygems.org/gems/courrier
- id: icons
  name: Icons
  category: oss
  github_url: https://github.com/rails-designer/icons
  package_registry_url: https://rubygems.org/gems/icons
- id: mata
  name: Mata
  category: oss
  github_url: https://github.com/rails-designer/mata
  package_registry_url: https://rubygems.org/gems/mata
- id: requestkit
  name: Requestkit
  category: oss
  github_url: https://github.com/rails-designer/requestkit
  package_registry_url: https://rubygems.org/gems/requestkit
- id: turbo-transition
  name: Turbo Transition
  category: oss
  github_url: https://github.com/rails-designer/turbo-transition
  package_registry_url: https://www.npmjs.com/package/turbo-transition
// …

I then define the template:

class Content::Tool < Perron::Resource
  include GithubFetch, RubygemsFetch

  source :tools

  #
  def self.source_template(sources)
    tool = sources.tools

    return if tool.respond_to?(:url) && tool.url.present?

    <<~TEMPLATE
      ---
      category: #{tool.category}
      title: #{tool.name}
      description: #{description_for(tool)}
      github_url: #{tool.github_url}
      github_stars: #{stars_for(tool)}
      package_registry_url: #{tool.package_registry_url}
      package_downloads: #{downloads_for(tool)}
      ---

      #{content_for(tool)}
    TEMPLATE
  end
end

GithubFetch and RubygemsFetch have some custom logic to pull the required data, looking something like this:

module Content::Tool::GithubFetch
  extend ActiveSupport::Concern

  class_methods do
    private

    def description_for(tool)
      return tool.description if tool.respond_to?(:description) && tool.description.present?

      github_data(tool.github_url)[:description]
    end

    def stars_for(tool)
      github_data(tool.github_url)[:stars]
    end

    #  end
end

Then all that is need is run bin/rails perron:sync_sources[tools] to update all content and stars + downloads:

bin/rails perron:sync_sources
Fetching RubyGems downloads for rails_icons…
Fetching README for rails-designer/rails_icons…
Fetching RubyGems downloads for courrier…
Fetching README for rails-designer/courrier…
Fetching RubyGems downloads for icons…
Fetching README for rails-designer/icons…
Fetching RubyGems downloads for mata…
Fetching README for rails-designer/mata…
Fetching RubyGems downloads for requestkit…
Fetching README for rails-designer/requestkit…
Fetching NPM downloads for turbo-transition…
Fetching README for rails-designer/turbo-transition…
#

Pretty cool, right? 😊 I am pretty pleased with it at all.

Let me know if you want me to highlight any other thing below in the comments. And if you want big holes or odd rendering issues, do let me know as well. ❤️

Product-minded Rails notes

Once a month: straightforward notes on improving UX in Rails—what to simplify, what to measure, and UI/frontend changes that move real usage.

Over to you…

What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!

Comments are powered by Chirp Form

Want to read me more?