Perron skill for LLM agents
to run in your site's root directory
View template source
gem "perron" unless File.read("Gemfile").include?("perron")
after_bundle do
unless File.exist?("config/initializers/perron.rb")
rails_command "perron:install"
end
create_file "SKILL.md", <<~'TEXT', force: true
---
name: perron
description: Build static sites with Perron, a Rails-based static site generator. Use this skill when the user mentions Perron or SSG in Rails, when the perron gem is available or when `config/initializers/perron.rb` is available.
triggers:
- perron
- ssg
- static site
- static site generator
- website
invocable: true
---
Perron is a Rails-based Static Site Generator (SSG). Build with Rails. Deploy static sites.
## Core Concepts
- **Build time ≠ request time** - `rails perron:build` generates static HTML for deployment
- **Content files ARE models** - no database, no ActiveRecord; content lives in `app/content/{collection}/`
- **Development vs Production** - use `bin/dev` or `rails server` for local development; use `rails perron:build` for production (generates static files)
- **Feeds and sitemaps** - only generated during `perron:build`, not in development
- **Rails Engine** - Perron is a Rails engine that extends your app
## Content Collections
Content is organized in collections under `app/content/`:
```
app/content/
├── pages/
│ ├── root.erb # Special: becomes "/" (homepage)
│ ├── about.md # Becomes "/about/"
│ └── 2024-01-15-my-page.md # Date prefix stripped from slug
├── posts/
│ ├── 2024-01-15-welcome.md # Becomes "/posts/welcome/"
│ └── 2024-01-16-another-post.md
└── data/
└── authors.yml # Static data accessible via Content::Data::Authors
```
## Image Assets
Place static assets (images, etc.) in the `public/` folder:
```
public/
└── images/
└── og-default.jpg
```
Reference them in frontmatter as `/images/og-default.jpg` — they will be copied to the output during build.
## Routing
Perron controllers live in the `content/` module. Use standard Rails routing with path overrides:
```ruby
Rails.application.routes.draw do
root to: "content/pages#root"
resources :articles, path: "docs", module: :content, only: %%w[index show] do
get ":id.md", to: "articles/markdown#show", as: :markdown, on: :collection
end
resources :categories, module: :content, path: "library/category", constraints: { id: /#{Content::Resource::TYPES.keys.join("|")}/ }, only: %%w[show]
resources :resources, module: :content, path: "library", only: %%w[index show] do
resource :template, path: "template.rb", module: :resources, only: %%w[show] # IMPORTANT: the nested resource (controller, views, etc.) needs to be in correct namespace, eg. `app/controllers/content/resources/template_controller.rb`
end
end
```
### Collection Name Override
Override the collection name on a controller to reuse an existing collection:
```ruby
class Content::MembersController < ApplicationController
def self.collection_name = "pages"
def show
end
end
```
Now files in `app/content/pages/` can be rendered via `/team/:id`:
```ruby
resources :members, module: :content, path: "team", constraints: { id: /cam|kendall|chris/ }, only: %%w[show]
```
## AR-Style Relations
Perron provides ActiveRecord-style query methods via `Perron::Relation`. **Not full AR parity** — these are the supported methods:
```ruby
# Class-level queries (returns Perron::Relation)
Content::Post.all
Content::Post.where(slug: "my-post")
Content::Post.where(author: ["alice", "bob"]) # OR logic
Content::Post.where(draft: false)
Content::Post.order(:published_at, :desc)
Content::Post.order(published_at: :desc) # hash syntax
Content::Post.limit(10)
Content::Post.offset(5)
Content::Post.first(5) # returns array of 5
Content::Post.last # single resource
Content::Post.pluck(:slug, :title) # returns [[slug1, title1], ...]
# Find by id (raises if not found — like Rails)
Content::Post.find(params[:id])
Content::Post.find!("slug-here") # raises RecordNotFound, used in generated controllers
# Chaining
Content::Post.where(draft: false).order(:published_at).limit(5)
```
### Custom Scopes
Define scopes in your Content model as lambdas:
```ruby
class Content::Post < Perron::Resource
scope :published, -> { where(draft: false) }
scope :recent, -> { order(:published_at, :desc).limit(10) }
scope :by_author, ->(name) { where(author: name) }
end
Content::Post.published.recent
Content::Post.published.by_author("alice")
```
## Views
### @resource Convention
Set `@resource` yourself in your controller — the generator adds this, but the variable **must be named `@resource`** (not `@post`, `@article`, etc.) since various Perron features rely on it:
```ruby
class Content::PostsController < ApplicationController
def show
@resource = Content::Post.find(params[:id])
end
def index
@resources = Content::Post.all
end
end
```
Then use it in views:
```erb
<%# app/views/content/posts/show.html.erb %%>
<h1><%= @resource.metadata.title %%></h1>
<%= markdownify @resource.content %%>
<%# app/views/content/posts/index.html.erb %%>
<%= render @resources %%>
<%# app/views/content/posts/_post.html.erb %%>
<article>
<h2><%= post.metadata.title %%></h2>
</article>
```
## View Helpers
### meta_tags
Renders SEO meta tags. **Do NOT write raw `<meta>` tags** — use this helper:
```erb
<%= meta_tags %%>
<%= meta_tags only: [:title, :description] %%>
<%= meta_tags except: [:image] %%>
```
Available options: `title`, `description`, `image`, `url`, `type`, `site_name`, `published_time`, `modified_time`, `author`
### feeds
Renders feed link tags. Feeds are only generated during `perron:build`, not in development:
```erb
<%= feeds %%>
<%= feeds formats: %%w[rss atom] %%>
```
Override default feed templates by creating `app/views/content/{collection}/{rss,atom,json}.erb`:
```erb
<%# app/views/content/posts/json.erb %%>
{
"version": "https://jsonfeed.org/version/1",
"title": "<%= config.title || collection.name %%>",
"items": <%= resources.map { |resource| {
id: resource.id,
title: resource.metadata.title,
url: "/posts/#{resource.slug}",
content_html: resource.content
}}.to_json %%>
}
```
Templates have access to: `collection`, `resources`, `config`
### markdownify
Renders markdown content:
```erb
<%= markdownify @resource.content %%>
<%= markdownify @resource.content, process: %%w[absolute_urls lazy_load_images] %%>
```
Available processors: `absolute_urls`, `lazy_load_images`, `target_blank`
Custom processors go in `app/processors/` (or any loadable location):
```ruby
# app/processors/add_nofollow_processor.rb
class AddNofollowProcessor < Perron::HtmlProcessor::Base
def process
@html.css("a[target=_blank]").each { |it| it["rel"] = "nofollow" }
end
end
```
```erb
<%= markdownify @resource.content, process: ["absolute_urls", AddNofollowProcessor] %%>
```
### erbify
Embed ERB in markdown content:
```erb
<%= erbify do %%>
The current page slug is: <%= @resource.slug %%>
<% end %%>
```
## Frontmatter
Each content file supports YAML frontmatter:
```yaml
---
title: My Post Title
description: SEO description for this page
image: /images/og-default.jpg
author_id: alice # Used by belongs_to associations
published_at: 2024-01-15 # Overrides filename date; future dates = scheduled
draft: true # Excluded from production build
preview: true # Adds obscured token to slug
preview: secret # Adds "-secret" to slug (predictable suffix)
---
```
### Meta Tag Options
Use these keys in frontmatter for meta_tags helper:
| Key | Meta tag |
|-----|----------|
| `title` | `<title>` and `og:title` |
| `description` | `description` and `og:description` |
| `image` | `og:image` |
| `url` | `og:url` |
| `type` | `og:type` |
| `site_name` | `og:site_name` |
| `published_at` | `article:published_time` |
| `updated_at` | `article:modified_time` |
| `author` | `article:author` |
### Publication States
- `draft: true` or `published: false` → draft
- `published_at` in the future → scheduled
- In development, drafts and scheduled content ARE shown
- In production build, they are excluded
### Preview URLs
`preview: true` adds an obscured token to the slug (e.g., `/posts/welcome-a3f8b2`). Use `preview: secret` to add a predictable suffix (`/posts/welcome-secret`). Share these URLs to preview drafts or scheduled content without deploying.
## Static Data (Content::Data)
Files in `app/content/data/` become data classes. The `id` field is only required if you plan to use `.find`:
```ruby
# app/content/data/authors.yml
# - id: alice
# name: Alice Smith
# role: editor
Content::Data::Authors.all
Content::Data::Authors.find("alice") # requires id field
Content::Data::Authors.first
```
You can iterate or loop over data without an `id` field.
## Associations
Define in your Content model. Use `class_name` with `Content::Data::*` prefix to reference data content:
```ruby
class Content::Post < Perron::Resource
belongs_to :author, class_name: "Content::Data::Authors"
has_many :comments
end
```
Perron looks for `author_id` or `comment_ids` in frontmatter and resolves them against the associated class.
## Programmatic Content (Sources)
### Lambda Filtering
Filter source items with a lambda:
```ruby
class Content::Product
sources :countries, products: -> (products) { products.select(&:featured?) }
end
```
### External API Sources
Pull data from external APIs by implementing a class that responds to `.all`. How you interact with the API is up to you. Here's an example using the [ActiveResource gem](https://github.com/rails/activeresource):
```ruby
class GitHubRepo < ActiveResource::Base
self.site = "https://api.github.com/"
def self.all
find(:all, from: "/users/Rails-Designer/repos", params: { per_page: 5 })
end
end
class Content::Project < Perron::Resource
sources repos: { class: GitHubRepo, primary_key: :name }
def self.source_template(source)
<<~TEMPLATE
---
title: #{source.repos.name}
description: #{source.repos.description}
language: #{source.repos.language}
stars: #{source.repos.stargazers_count}
---
#{source.repos.description}
TEMPLATE
end
end
```
Your class just needs to respond to `.all` and return objects matching the `primary_key`.
## Content Generators
Use `rails g content Post --new` to generate a template. Filename strftime patterns create dated files:
| Pattern | Example output |
|---------|---------------|
| `%%s-template.md.tt` | `1709337600-my-post.md` |
| `%%Y-%%m-%%d-template.md.tt` | `2026-03-02-my-post.md` |
| `%%d-template.md.tt` | `02-my-post.md` |
Every [strftime specifier](https://docs.ruby-lang.org/en/master/language/strftime_formatting_rdoc.html) is supported.
## Configuration
```ruby
# config/initializers/perron.rb
Perron.configure do |config|
config.mode = :standalone # :standalone (default) or :integrated
config.output = "output"
config.live_reload = true
config.site_name = "My Site"
config.markdown_parser = :commonmarker # only needed if you want to use your own Markdown parser, otherwise auto-picked from: commonmarker, kramdown, redcarpet
end
```
By default no markdown gem is added to your Gemfile. Perron auto-detects which one is installed.
### Modes
Choose one mode:
- **standalone** (default) - outputs a complete static site in `/output/`. Deploy the output folder.
- **integrated** - creates static pages in `/public/` alongside an otherwise normal Rails app.
### Environment Variables
`VIEW_UNPUBLISHED=true` shows drafts and scheduled content without editing the initializer.
## Rake Tasks
| Task | Purpose |
|------|---------|
| `bin/rails perron:install` | Sets up Perron in a Rails app |
| `rails perron:build` | Generates static HTML. Run in `RAILS_ENV=production`. For deployment. |
| `rails perron:clobber` | Removes compiled static output |
| `rails perron:validate` | Validates all site resources |
| `rails perron:sync_sources` | Generates files from programmatic content sources |
When an output folder exists (from a previous build), `bin/dev` or `rails server` serves it directly. Useful to verify what will be deployed. Run `rails perron:clobber` to remove it.
## Library
The docs site at [perron.railsdesigner.com/library/](https://perron.railsdesigner.com/library/) contains copy-pasteable components and snippets using Rails' template feature.
TEXT
%%w[.opencode .claude .gemini].each do |folder|
destination = File.join(folder, "skills/perron")
if File.directory?(File.join(destination_root, folder))
FileUtils.mkdir_p(File.join(destination_root, destination))
copy_file "#{__dir__}/../SKILL.md", File.join(destination, "SKILL.md")
say "Add Perron skill for #{folder.delete_prefix(".").humanize}"
end
end
end
This Perron skill helps LLM agents find their way around Perron. It is automatically added to any of the skills folder in one of; .opencode, .claude or .gemini.