Personal, minimal blog template

rails app:template LOCATION='https://perron.railsdesigner.com/library/personal-blog/template.rb'

This template for a minimal and stylish personal blog is designed with Tailwind CSS. It features static pages, like about and contact. And next to that a Content::Post + views to showcase all your writings in a clean and minimal visual design.

Just run the template command in your new Perron-powered site to get writing (and publishing) right away.

Template source

gem "tailwindcss-rails"

after_bundle do
  rails_command "tailwindcss:install"
  rails_command "generate content page"
  rails_command "generate content post"


file "app/assets/tailwind/application.css" do
<<~"_"
@import "tailwindcss";

@layer base {
  a:not([class]) {
    @apply underline;

    &:hover {
      @apply no-underline;
    }
  }
}

@layer component {
  .content {
    @apply [&_*:not(h1,h2,h3,h4,h5,h6)+*:not(pre,code,li,blockquote>p,svg),blockquote+pre]:mt-5;
    @apply [&_h1+p,&_h2+p,&_h3+p,&_h4+p,&_h5+p,&_h6+p]:mt-1.5;
    @apply [&>p,&_li]:text-base [&>p,&_li]:md:text-lg [&>p]:text-gray-950;

    @apply [&_*+h2,&_*+h3]:mt-8;
    @apply [&_h2]:font-bold [&_h2]:text-2xl [&_h2]:text-gray-800;
    @apply [&_h3]:font-semibold [&_h3]:text-xl [&_h3]:text-gray-800;
    @apply [&_h4]:font-semibold [&_h4]:text-lg [&_h4]:text-gray-800;

    @apply [&_ul,&_ol]:mt-2 [&_ul]:list-disc [&_ul,&_ol]:list-outside [&_ul,&_ol]:ml-4.5;
    @apply [&_ol]:list-decimal;

    @apply [&_pre]:mt-1.25 [&_pre]:px-1.5 [&_pre]:py-2 [&_pre]:sm:px-3 [&_pre]:sm:py-4 [&_pre]:rounded-md [&_pre]:overflow-auto;
    @apply [&_code]:p-0.5 [&_code]:text-[0.8em] [&_code]:font-medium [&_code]:bg-gray-200/70 [&_code]:rounded-sm;
    @apply [&_pre>code]:text-base [&_pre>code]:font-normal [&_pre>code]:text-gray-700 [&_pre>code]:bg-transparent;

    @apply [&_strong]:font-semibold;

    @apply [&_table]:w-full [&_table]:text-left [&_table]:text-base;
    @apply [&_thead]:border-b [&_thead]:border-gray-200;
    @apply [&_th]:px-2 [&_th]:py-1.5 [&_th]:font-semibold [&_th]:text-gray-800;
    @apply [&_tbody_tr]:border-b [&_tbody_tr]:border-gray-100;
    @apply [&_tbody_tr:last-child]:border-0;
    @apply [&_td]:px-2 [&_td]:py-1.5 [&_td]:text-base [&_td]:sm:text-lg [&_td]:text-gray-800;

    @apply marker:text-gray-500;
  }
}

_
end

file "app/content/data/README.md" do
<<~"_"
# Data

Perron can consume structured data from YML, JSON, or CSV files, making them available within your templates.
This is useful for populating features, team members, or any other repeated data structure.


## Usage

To use a data file, you can access it through the `Perron::Site.data` object followed by the basename of the file:
```erb
<% Perron::Site.data.features.each do |feature| %>
  <h4><%= feature.name %></h4>

  <p><%= feature.description %></p>
<% end %>
```

This is a convenient shorthand for `Perron::Data.new("features")`, which can also be used directly:
```ruby
<% Perron::Data.new("features").each do |feature| %>
  <h4><%= feature.name %></h4>

  <p><%= feature.description %></p>
<% end %>
```


## File Location and Formats

By default, Perron looks up `app/content/data/` for files with a `.yml`, `.json`, or `.csv` extension.
For a `new("features")` call, it would find `features.yml`, `features.json`, or `features.csv`. You can also provide a full, absolute path to any data file, like `Perron::Data.new("path-to-some-data-file")`.


## Accessing Data

The wrapper object provides flexible, read-only access to each record's attributes. Both dot notation and hash-like key access are supported.
```ruby
feature.name
feature[:name]
```


## Rendering

You can render data collections directly using Rails-like partial rendering. When you call `render` on a data collection, Perron will automatically render a partial for each item.
```erb
<%= render Perron::Site.data.features %>
```

This expects a partial at `app/views/content/features/_feature.html.erb` that will be rendered once for each feature in your data file. The individual record is made available as a local variable matching the singular form of the collection name.
```erb
<!-- app/views/content/features/_feature.html.erb -->
<div class="feature">
  <h4><%= feature.name %></h4>
  <p><%= feature.description %></p>
</div>
```

_
end

file "app/content/data/social_links.yml" do
<<~"_"
- name: Social X
  url: "https://example.com"
- name: Social Y
  url: "https://example.com"
- name: Social Z
  url: "https://example.com"

_
end

file "app/content/pages/about.erb" do
<<~"_"
---
nav_label: About
title: About me
description: description
---

<article class="max-w-4xl mx-auto mt-16 px-2">
  <h1 class="text-2xl font-sans font-bold text-gray-700 md:text-balance md:text-4xl md:tracking-tight">
    <%= @resource.title %>
  </h1>

  <p class="mt-5 text-lg text-gray-800 md:text-2xl max-w-prose md:mt-8">
    Minim ad et nostrud. Nisi culpa adipisicing nulla cupidatat eu adipisicing non occaecat tempor pariatur sint eiusmod ut pariatur. Et nisi occaecat id tempor qui ex reprehenderit ullamco ullamco duis ad. Amet aliquip velit non pariatur esse aliquip magna. Cillum Lorem proident dolor voluptate fugiat mollit mollit id fugiat dolor minim.
  </p>

  <p class="mt-5 text-lg text-gray-800 md:text-2xl max-w-prose md:mt-8">
    Est esse ipsum mollit ad sit esse duis commodo voluptate irure commodo proident dolor. Cupidatat officia excepteur Lorem non id ullamco. Pariatur sunt dolore eiusmod Lorem sunt deserunt excepteur veniam occaecat in magna. Exercitation mollit enim eu consectetur adipisicing labore adipisicing eu.
  </p>
</article>

_
end

file "app/content/pages/contact.erb" do
<<~"_"
---
nav_label: Contact
title: Contact
description: description
---

<article class="max-w-4xl mx-auto mt-16 px-2">
  <h1 class="text-2xl font-sans font-bold text-gray-700 md:text-balance md:text-4xl md:tracking-tight">
    <%= @resource.title %>
  </h1>

  <p class="mt-5 text-lg text-gray-800 md:text-2xl max-w-prose md:mt-8">
    Want to say? Have a question? <%= mail_to "me@example.com", "Send me an email" %> or find me on a platform below.
  </p>

  <ul class="flex flex-col gap-y-1 mt-5 text-lg">
    <%= render Perron::Site.data.social_links %>
  </ul>
</article>

_
end

file "app/content/pages/now.erb" do
<<~"_"
---
nav_label: Now
title: What am I doing now?
description: description
updated_at: 2025-01-01T00:00:00Z
doing:
  - Mastering Blender 3D's geometry nodes system

  - Writing a book on sustainable design practices

  - Learning Thai language (currently at B1 level)

  - Building a hydroponic garden in my apartment
---

<article class="max-w-4xl mx-auto mt-16 px-2">
  <h1 class="text-2xl font-sans font-bold text-gray-700 md:text-balance md:text-4xl md:tracking-tight">
    <%= @resource.title %>
  </h1>

  <p class="mt-5 text-lg text-gray-800 md:text-2xl max-w-prose md:mt-8">
    These things are keeping me busy day-to-day. Last updated on <%= tag.time @resource.metadata.updated_at.strftime("%Y/%m/%d"), datetime: @resource.metadata.updated_at %>
  </p>

  <ul class="flex flex-col gap-y-2 mt-5 text-lg list-inside">
    <%= safe_join @resource.metadata.doing.map { tag.li it, class: "list-disc" } %>
  </ul>
</article>


_
end

file "app/content/pages/root.erb" do
<<~"_"
---
title: Welcome to my blog
description: Welcome to this blog of mine. Here I write about things, stuff and everything.
---

<div class="max-w-4xl mx-auto mt-16 px-2 sm:mt-32">
  <p class="text-2xl font-normal text-gray-700 md:text-balance md:text-3xl md:tracking-tight">
    Hi, my name is Rails Designer. I help companies make their Rails app prettier, more maintainable and a joy to use.
  </p>

  <div class="grid grid-cols-6 gap-5 mt-8 text-lg text-gray-900 md:mt-16 md:gap-12">
    <p class="col-span-6 sm:col-span-3 first-letter:text-8xl first-letter:float-left first-letter:mr-2 first-letter:mt-2.5">
      Minim ad et nostrud. Nisi culpa adipisicing nulla cupidatat eu adipisicing non occaecat tempor pariatur sint eiusmod ut pariatur. Et nisi occaecat id tempor qui ex reprehenderit ullamco ullamco duis ad. Amet aliquip velit non pariatur esse aliquip magna. Cillum Lorem proident dolor voluptate fugiat mollit mollit id fugiat dolor minim.
    </p>

    <p class="col-span-6 sm:col-span-3">
      Est esse ipsum mollit ad sit esse duis commodo voluptate irure commodo proident dolor. Cupidatat officia excepteur Lorem non id ullamco. Pariatur sunt dolore eiusmod Lorem sunt deserunt excepteur veniam occaecat in magna. Exercitation mollit enim eu consectetur adipisicing labore adipisicing eu.
    </p>
  </div>

  <%= render partial: "shared/latest_post" %>
</div>

_
end

file "app/content/posts/2025-10-01-my-first-post.md" do
<<~"_"
---
title: My first post on Perron
description: Hello world, from Perron!
---

Irure nulla pariatur anim mollit labore ad eu amet. Magna ut deserunt officia sunt occaecat reprehenderit veniam voluptate reprehenderit. Sit aliqua cupidatat ea labore consectetur anim amet laborum velit proident pariatur enim. Non mollit laborum dolor. Proident amet cupidatat proident nulla officia duis deserunt mollit dolor sit anim sint consectetur. Proident aliquip eiusmod laborum pariatur proident cupidatat velit minim amet occaecat nisi velit officia sint.

Aute fugiat aliquip eiusmod ullamco eu velit ad ad voluptate sunt. Mollit esse magna duis proident consectetur quis duis ipsum eu irure quis aute in veniam. Eu ea tempor nulla ea duis ad incididunt. Non ullamco et nostrud esse ipsum occaecat. Adipisicing sit amet ut sunt occaecat ad Lorem qui cillum. Labore laboris cillum laboris quis non commodo fugiat sit. Adipisicing officia elit sunt ullamco.

Laboris exercitation elit cupidatat aliqua excepteur ad laboris ipsum dolore est amet elit velit. Excepteur minim fugiat sit. Amet tempor voluptate fugiat. Veniam minim exercitation laboris excepteur qui ullamco mollit dolor. Lorem minim Lorem pariatur eiusmod quis nulla id magna ullamco laboris ipsum consectetur.

Dolor sunt eu mollit magna et id dolor exercitation. Ut voluptate veniam ullamco occaecat minim. Esse Lorem nostrud deserunt exercitation tempor officia amet. Commodo ullamco eiusmod culpa eiusmod velit.

Cillum non cupidatat minim sint adipisicing. Excepteur voluptate ullamco commodo eiusmod nulla aute cillum do sunt sunt non laborum est. Voluptate sint quis duis id aute excepteur sunt dolor minim do fugiat ea id irure consequat. Mollit proident exercitation anim duis velit tempor pariatur et deserunt reprehenderit sunt mollit elit exercitation ea. Amet et fugiat est consectetur enim. Amet incididunt deserunt amet dolor ex nostrud nostrud ex. Nulla eu anim id fugiat qui sit id aliquip veniam cupidatat nostrud velit elit.

Lorem non sunt sit proident nostrud veniam cupidatat voluptate ex aliqua Lorem minim eiusmod sit. Qui velit exercitation deserunt enim reprehenderit ad. Proident officia duis in velit aliqua voluptate in non cupidatat et quis nulla. Pariatur nostrud ea deserunt proident pariatur velit non occaecat ad quis est. Magna proident ex aliqua dolor esse Lorem labore ex irure do sunt aliquip. Sunt reprehenderit labore deserunt tempor. Cupidatat quis labore aute tempor consequat est ipsum in aliquip laborum. Cillum est dolor dolor aute nisi in laborum laboris.

_
end

file "app/controllers/content/pages_controller.rb" do
<<~"_"
class Content::PagesController < ApplicationController
  def root
    # Setting `@resource` is required for the the metadata/meta tags generation
    @resource = Content::Page.root

    render html: @resource.content, layout: "application"
  end

  def show
    @resource = Content::Page.find(params[:id])

    render html: @resource.content, layout: "application"
  end
end

_
end

file "app/controllers/content/posts_controller.rb" do
<<~"_"
class Content::PostsController < ApplicationController
  def index
    @resources = Content::Post.all
  end

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

_
end

file "app/models/content/page.rb" do
<<~"_"
class Content::Page < Perron::Resource
  delegate :title, :nav_included, to: :metadata
end

_
end

file "app/models/content/post.rb" do
<<~"_"
class Content::Post < Perron::Resource
  delegate :title, to: :metadata
end

_
end

file "app/views/content/posts/_post.html.erb" do
<<~"_"
<%# locals: (post:) %>
<li>
  <%= link_to post, class: "flex items-center justify-between text-lg text-gray-800 underline hover:text-gray-500 hover:no-underline" do %>
    <%= tag.span post.title %>

    <%= tag.time post.published_at.strftime("%Y/%m/%d"), datetime: post.published_at, class: "text-base" %>
  <% end %>
</li>

_
end

file "app/views/content/posts/index.html.erb" do
<<~"_"
<div class="max-w-4xl mx-auto mt-8 px-2 md:mt-16">
  <h1 class="text-2xl font-sans font-bold text-gray-700 md:text-balance md:text-4xl md:tracking-tight">
    My writings
  </h1>

  <ul class="grid mt-8 gap-y-2">
    <%= render @resources %>
  </ul>
</div>

_
end

file "app/views/content/posts/show.html.erb" do
<<~"_"
<article class="max-w-4xl mx-auto mt-8 px-2 md:mt-16">
  <%= link_to "← View all my writings", posts_path, class: "text-sm font-sans font-normal text-gray-500 transition hover:text-gray-800" %>

  <h1 class="mt-2 text-2xl font-sans font-bold text-gray-700 md:text-balance md:text-4xl md:tracking-tight">
    <%= @resource.title %>
  </h1>

  <p class="mt-6 flex items-center gap-x-4 empty:hidden">
    <%= tag.span "Published at #{@resource.published_at.strftime("%Y/%m/%d")}", class: "text-sm text-gray-500" if @resource.published_at.present? %>

    <%= tag.span "Scheduled", class: "inline-block px-2 py-0.5 font-sans text-xs font-medium text-orange-600 bg-orange-100 rounded-md" if @resource.scheduled? %>
  </p>

  <div class="content my-5 md:my-8">
    <%= markdownify @resource.content %>
  </div>
</article>

_
end

file "app/views/content/social_links/_social_link.html.erb" do
<<~"_"
<%# locals: (social_link:) %>
<li>
  <%= link_to social_link.name, social_link.url, target: "_blank", class: "underline text-gray-700 hover:no-underline" %>
</li>

_
end

file "app/views/layouts/application.html.erb" do
<<~"_"
<!DOCTYPE html>
<html>
  <head>
    <%= meta_tags %>
    <meta name="viewport" content="width=device-width,initial-scale=1">

    <%= yield :head %>

    <%= stylesheet_link_tag :app %>
  </head>

  <body class="font-serif antialiased">
    <%= render partial: "shared/navigation", locals: { items: Content::Page.all.select(&:nav_label) } %>

    <%= yield %>
  </body>
</html>

_
end

file "app/views/shared/_latest_post.html.erb" do
<<~"_"
<%# locals: (latest_post: Content::Post.last) %>

<dl class="flex flex-col md:items-end gap-x-4 mt-8 font-sans text-base text-gray-900 md:flex-row md:mt-16 md:text-sm">
  <dt class="font-semibold tracking-tight text-gray-800">
    <hr class="h-px my-1 border-gray-400 md:border-gray-700">

    My last writing
  </dt>

  <dd>
    <%= link_to latest_post.title, latest_post, class: "text-gray-500 transition hover:text-gray-800" %>
  </dd>
</dl>

_
end

file "app/views/shared/_navigation.html.erb" do
<<~"_"
<%# locals: (items: []) %>
<nav class="sticky top-0 flex items-center justify-between px-2 py-3 max-w-4xl mx-auto font-sans bg-white">
  <%= link_to "", root_path, aria: {label: "Go to the homepage"}, class: "block w-14 h-3 bg-gray-800 rounded-xs transition hover:bg-gray-500" %>

  <ul class="flex items-center gap-x-4 sm:gap-x-6">
    <%= render partial: "shared/navigation/item", collection: items %>
  </ul>
</nav>

_
end

file "app/views/shared/navigation/_item.html.erb" do
<<~"_"
<li>
  <%= link_to item.nav_label, item, class: class_names("block px-1 py-2 text-sm font-medium tracking-tight text-gray-800 border-b-2 transparent sm:text-base hover:border-gray-200", {"border-transparent": !current_page?(item), "border-gray-800 hover:border-gray-800": current_page?(item)}) %>
</li>

_
end

end