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.
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. ❤️
Want to read me more?
-
Introducing Perron: Rails-based static site generator
Introducing a new, OSS static site generator based on Rails: Perron. Build your next static site using the framework you love. -
Gems I use for Rails SaaS apps
The Rails ecosystem has a huge amount of gems to choose from. That doesn't mean you should add them without thinking. This article lists the carefully selected gems I use in my Rails SaaS apps. -
Introducing Icons: Add any icon library to your Ruby app
Icons is a new gem to add any icon library to your Ruby apps.
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
{{comment}}