# Perron Perron is a gem that generates static sites from your Rails app. ## Requirements - Ruby 3.4+ - Rails 7.0+ ## Quickstart Run ```bash rails new my-new-site --minimal -T -O -m https://perron.railsdesigner.com/library/new/template.rb ``` ## Manual installation Start by adding Perron to your Rails app: ```bash bundle add perron bin/rails perron:install ``` This creates a configuration file at `config/initializers/perron.rb`: ```ruby Perron.configure do |config| config.site_name = "Chirp Form" # config.site_description = "Description for meta tags" # config.default_url_options = {host: "chirpform.com", protocol: "https"} end ``` ## Create your first content Perron organizes content into collections. Each collection needs: - a resource class in `app/models/content/` - a controller in `app/controllers/content/` - views in `app/views/content/` - content files in `app/content/{collection_name}/` Use the built-in generator to create one: ```bash bin/rails generate content Post ``` This creates: - `app/content/posts/`; content files go here - `app/models/content/post.rb`; resource class - `app/controllers/content/posts_controller.rb` - `app/views/content/posts/` And adds a route: `resources :posts, module: :content, only: %w[index show]` ## Add content Create your first article in `app/content/posts/hello-world.md`: ```markdown --- title: Hello World description: My first Perron post --- # Hello World This is my first post built with Perron! ``` Then run the Rails server to see your site: ```bash bin/dev ``` Visit `http://localhost:3000/posts/hello-world` to see your content. ## Build for production Generate static files for deployment: ```bash RAILS_ENV=production bin/rails perron:build ``` This creates HTML files in the `/output/` directory, ready to deploy to any static hosting platform. > [!tip] > Browse the [Library](/library/category/snippet/) for deployment templates to platforms like Netlify, [Seal Static](https://sealstatic.com) and statichost.eu.Set Perron's global configuration in `config/initializers/perron.rb`. This file is automatically created with `bin/rails perron:install`. It looks something like this: ```ruby Perron.configure do |config| config.site_name = "Perron" # Enable Live Reload with DOM Morphing in development # config.live_reload = true # config.default_url_options = {host: "perron.railsdesigner.com", protocol: "https", trailing_slash: true} end ``` ## All settings Below are available settings: - **site_name**; used as fallback for meta tags - **site_description**; used as fallback for meta description - **output**; location for site build, defaults to `/output/` - **mode**; `:standalone` or `:integrated`, defaults to `standalone` - **default_url_options**; set options for route helpers. Override with `PERRON_HOST`, `PERRON_PROTOCOL` and `PERRON_TRAILING_SLASH`. - **search_scope**; scopes available for search (used by [search-form element](/library/search-form/)) - **live_reload**; automatically reload the page on changes in development (uses the [Mata](https://github.com/Rails-Designer/mata) gem) - **output_server_strict**; when serving from output directory, only serve existing files (no dynamic fallback) - **additional_routes**: array of route helper names to include in the build beyond collections (e.g., `%w[root_path robots_path]`). Defaults to `%w[root_path]` in `:standalone` mode and `[]` in `:integrated` mode - **allowed_extensions**; set which extensions for content files are allowed, defaults to `%w[erb md]` - **exclude_from_public**; exclude directories with compiled files should be excluded from `public`, defaults to `%w[assets storage]` - **excluded_assets**; exclude which assets shoud be excluded when compiling, defaults to `%w[action_cable actioncable actiontext activestorage rails-ujs trix turbo]` - **view_unpublished**; option to show [unpublished content](/docs/publishing/) content, defaults to `Rails.env.development?`. Can be overridden with `VIEW_UNPUBLISHED` environment variable - **default_processors**; array of processors to run on every markdownify call (see [Markdown](/docs/markdown/#default-processors)) - **before_build**; lambda called before the build process (see [Deploy](/docs/deploy/#build-hooks)) - **after_build**; lambda called after a successful build (see [Deploy](/docs/deploy/#build-hooks)) - **cache_data_sources**; cache data sources - **markdown_parser**; specifiy custom markdown parser - **markdown_options**; pass options to the installed markdown gem - **sitemap.enabled**; enable creation of the [sitemap.xml](/docs/xml-sitemap/), defaults to `false` - **sitemap.priority**; default priority for sitemap items, defaults to `0.5` - **sitemap.change_frequency**; defaults to `:monthly`Perron provides several features to help you development your Perron-powered site. ## Running the server Start your Rails development server with `bin/dev` or `rails server`. ## Live reload Enable live reload to automatically refresh the browser when content changes: ```ruby Perron.configure do |config| config.live_reload = true end ``` This uses the [Mata](https://github.com/Rails-Designer/mata) gem to inject a script that watches for changes and morphs the DOM for a smoother experience than full page reloads. ## Local preview Preview the built static site locally using: ```bash RAILS_ENV=production rails perron:build && bin/dev ``` Requests are served from the `output/` directory when files exist. By default, the Output Server falls back to Rails rendering for any missing static HTML. Disable this behavior by setting `config.output_server_strict = true`. The Output Server will now raise a 404 for missing pages. > [!note] > When serving from the output directory in development the browser's tab title is prefixed with `[PREVIEW]`. ### Remove local build Use `rails perron:clobber` to remove the output directory and return to fully dynamic rendering.Perron can operate in two modes, configured via `config.mode`. This allows a build to be either a full static site or be integrated pages in a dynamic Rails application. **Full static sites** (`standalone` mode) can be deployed to any build-based platform, like [Netlify](/library/netlify/), [Seal Static](https://sealstatic.com) and [statichost.eu](/library/statichost/). **Integrated sites** (`integrated` mode) work within a typical Rails application and can be deployed to any Rails host like Heroku or using Kamal. | **Mode** | `standalone` (default) | `integrated` | | :--- | :--- | :--- | | **Use Case** | Full static site | Add static pages to a live Rails app | | **Deployment** | Build-based platforms (Netlify, Seal Static, statichost.eu, etc.) | Traditional Rails hosts (Heroku, Kamal, etc.) | | **Output** | `output/` directory | `public/` directory | | **Asset Handling** | Via Perron | Via Asset Pipeline | When in `standalone` mode and ready to generate your static site, run: ```bash RAILS_ENV=production bundle install && rails perron:build ``` This will create static HTML files in the configured output directory (`output` by default). ## Deploy task Perron provides a `perron:deploy` task that builds and deploys your site in one step. It uses [Beam Up](https://github.com/Rails-Designer/beam_up), which supports multiple providers including Netlify, [Seal Static](https://sealstatic.com) and statichost.eu. Add Beam Up to your Gemfile: ```bash bundle add beam_up ``` Then deploy: ```bash RAILS_ENV=production rails perron:deploy ``` The task creates a `config/deploy.yml` file on first run if one does not exist: ```yaml # config/deploy.yml # provider: seal_static # seal_static: # api_key: your_token_here before_actions: - RAILS_ENV=production bundle exec rails perron:build after_actions: - bundle exec rails perron:clobber ``` [Seal Static](https://sealstatic.com) is the default provider when no provider is configured. ### Deploy init Generate provider-specific configuration: ```bash rails perron:deploy:init ``` This interactively sets up configuration for the chosen provider and writes them to `config/deploy.yml`. ### Before and after actions Use `before_actions` and `after_actions` in `config/deploy.yml` to run shell commands before or after the deploy: ```yaml before_actions: - RAILS_ENV=production bundle exec rails perron:build - echo "Build complete, deploying…" after_actions: - bundle exec rails perron:clobber - echo "Deploy complete!" ``` ## Build hooks Configure `before_build` and `after_build` callbacks to run logic around the build process: ```ruby Perron.configure do |config| config.before_build = ->(context) { # do whatever before build } config.after_build = ->(context) { # do whatever after build succeeded } end ``` The `context` object includes the site builder instance. View the [Library](/library/#deployment) for deploy scripts to various platforms.Bugfixes, improvements and new features are much appreciated. For bigger new features, [create an issue in the GitHub repo first](https://github.com/rails-designer/perron/issues/new). For bugfixes you can directly create a PR instead of opening an issue first. This project uses [Standard](https://github.com/testdouble/standard) for formatting Ruby code. Please run `bundle exec standardrb` before submitting pull requests. Run tests with `rails test`. `rake` runs both `rails test` and `bundle exec standardrb`. Get set up by running `bin/setup`. [View GitHub repo](https://github.com/rails-designer/perron).Perron is, just like Rails, designed with convention over configuration in mind. All content is stored in `app/content/*/*.{erb,md,*}` and backed by a class, located in `app/models/content/` that inherits from `Perron::Resource`. The controllers are located in `app/controllers/content/`. To make them available, create a route: `resources :posts, module: :content, only: %w[index show]`. ## Resource class Every resource class inherits from `Perron::Resource`, e.g.: ```ruby # app/models/content/post.rb class Content::Post < Perron::Resource end ``` This gives each class its base behavior. It is just a regular Ruby class, so all common methods are available: ```ruby # app/models/content/post.rb class Content::Post < Perron::Resource delegate :title, to: :metadata def loud_category metadata.category.upcase end end ``` ## Associations Resources can be associated with each other using `has_many` and `belongs_to`, similar to ActiveRecord associations. ### has_many Define a `has_many` association to retrieve all resources that belong to the current resource: ```ruby # app/models/content/author.rb class Content::Author < Perron::Resource has_many :posts end ``` ```markdown <!-- app/content/authors/rails-designer.md --> --- name: Rails Designer bio: Creator of Perron --- What could I say? I create lots of things? Like Perron, for example! 😊 ``` Access the collection: ```ruby author = Content::Author.find("rails-designer") author.posts # => Array of Content::Post instances where author_id matches ``` ### belongs_to Define a `belongs_to` association when a resource references another resource: ```ruby # app/models/content/post.rb class Content::Post < Perron::Resource belongs_to :author belongs_to :editor, class_name: "Content::Author" end ``` In the content's frontmatter, add the association name with `_id` suffix: ```markdown <!-- app/content/posts/my-first-post.md --> --- title: My First Post author_id: rails-designer editor_id: cam --- Post content here… ``` Access the associated resource: ```ruby post = Content::Post.find("my-first-post") post.author # => Content::Author instance post.editor # => Content::Author instance ``` ### Associations with Data sources Associate resources with [data sources](/docs/data/) using the `class_name` option. This works for both `belongs_to` and `has_many` associations. #### belongs_to with Data ```ruby # app/models/content/post.rb class Content::Post < Perron::Resource belongs_to :editor, class_name: "Content::Data::Editors" end ``` ```yaml # app/content/data/editors.yml - id: kendall name: Kendall bio: Master of the edit - id: chris name: Chris bio: Editor Overlord ``` ```markdown <!-- app/content/posts/my-first-post.md --> --- title: My First Post editor_id: kendall --- Post content here… ``` The association works the same way as with Content resources, but pulls data from the structured data resource. #### has_many with Data For multiple associations, use an array of IDs in the frontmatter. This pattern mimics Rails' `has_and_belongs_to_many` associations, where the relationship is defined by a list of IDs: ```ruby # app/models/content/post.rb class Content::Post < Perron::Resource has_many :editors, class_name: "Content::Data::Editors" end ``` ```markdown <!-- app/content/posts/my-first-post.md --> --- title: My First Post editor_ids: [kendall, chris] --- Post content here… ``` Access the collection: ```ruby post = Content::Post.find("my-first-post") post.editors # => Array of Content::Data::Editor instances where id matches editor_id ``` When `{association_name}_ids` is present in the frontmatter, Perron uses those IDs to find the associated records. If not present, it falls back to foreign key matching (e.g., looking for `post_id` in the data records). ## Adjacency (next/previous) Resources can navigate to adjacent resources (next/previous) in the collection: ```erb <%= link_to "Previous", @resource.previous, rel: "prev" if @resource.previous %> <%= link_to "Next", @resource.next, rel: "next" if @resource.next %> ``` By default, adjacency uses the collection's default order (id). Configure your preferred ordering: ```ruby # app/models/content/post.rb class Content::Post < Perron::Resource adjacent_by :position delegate :position, to: :metadata end ``` You can also traverse cross-group by passing `within`: ```ruby class Content::Post < Perron::Resource adjacent_by :position, within: :category delegate :position, to: :metadata, :category end ``` This assumes resources are grouped/connected through a category. Or if categories are in a specific order, configure as: ```ruby class Content::Post < Perron::Resource adjacent_by :position, within: { category: %w[getting_started content metadata] } delegate :position, to: :metadata, :category end ``` ## Validations Validate values from resource classes, the frontmatter for example: ```ruby class Content::Post < Perron::Resource CATEGORIES = %w[rails ruby hotwire javascript updates] delegate :category, :title, :description, to: :metadata validates :title, :description, presence: true validates :category, inclusion: { in: CATEGORIES } # … end ``` Perron offers a `bin/rails perron:validate` task that runs all validations and outputs any failures. Useful to check if the title or meta description is within the correct range for SERP's or to make sure the correct category is added. To validate frontmatter, make them callable in the class, as seen above using `delegate`. Validation output could look something like this: ```console rails perron:validate ..........................F..... Resource: /perron/docs/app/content/articles/resources.md - Description can't be blank Validation finished with 1 failure. ``` > [!tip] > When using Rails 8.1, make Perron's validate task part of the `bin/ci` script. Simply add `step "Perron: validate", "bin/rails perron:validate"` within the `CI.run do` block. ## `@resource` instance In a `show` action, a resource instance variable is expected to be set. ```ruby class Content::PostsController < ApplicationController def show @resource = Content::Post.find!(params[:id]) end end ``` > [!important] > Various features from Perron rely on @resource instance variable being available. ## Inline rendering Use `@resource.inline` to render content without a view template. This is useful when your controller only needs to render the resource's content: ```ruby class Content::PostsController < ApplicationController def show @resource = Content::Post.find(params[:id]) render @resource.inline end end ``` ## Setting a root page To set a root page, create a `root.{md,erb}` file in the pages content directory (`app/content/pages/root.erb`) and add a `root` action in `Content::PagesController`: ```ruby # app/controllers/content/pages_controller.rb class Content::PagesController < ApplicationController def root @resource = Content::Page.root render :show end end ``` Then add the root route to `config/routes.rb`: ```ruby root to: "content/pages#root" ``` This is automatically generated when generating a `Page` collection using the [content generator](/docs/generator/). Opt out by passing `--no-include-root`: ```bash rails generate content Page --no-include-root ``` ## Destroy Delete resource content files programmatically: ```ruby Content::Post.find("slug").destroy # deletes app/content/posts/slug.md Content::Post.destroy_all # deletes app/content/posts/*.{ext} ``` ## Custom collection name When your route resource name doesn't match your collection name, define a custom collection name: ```ruby # app/controllers/content/saas_posts_controller.rb class Content::SaasPostsController < ApplicationController # Route is `resources :saas_posts` but collection is "posts" def self.collection_name = "posts" def index @resources = Content::Post.all end end ``` Perron checks for this method first before falling back to deriving the collection name from the controller name.Perron can consume structured data from YML, JSON or CSV files, making them available within views. This is useful for populating features, team members or any other repeated data structure. ## Usage Access data sources using the `Content::Data` namespace with the class name matching the file's basename: ```erb <% Content::Data::Features.all.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 `Features`, it would find `features.yml`, `features.json` or `features.csv`. Provide a path to any data resource in `/app/content/data/`, via `Content::Data.new("path/to/data-resource")`. ## 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 Render data collections directly using Rails-like partial rendering: ```erb <%= render Content::Data::Features.all %> ``` This expects a partial at `app/views/content/features/_feature.html.erb` that will be rendered once for each item in `Content::Data::Features.all`. 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> ``` ## Data structure Data resources must contain an array of objects. Each record should include an `id` field if used with [associations](/docs/resources/#associations) or with the `find` method: ```yaml # app/content/data/authors.yml - id: rails-designer name: Rails Designer bio: Creator of Perron - id: cam name: Cam bio: Contributing author ``` Look up a single entry with `Content::Data::Features.find("advanced-search")`, where `"advanced-search"` matches the value of the entry's `id` field. A flat array is accepted as well: ```yaml - Custom design based on our templates - Copywriting services - Dedicated project manager ``` ## Enumerable methods All data objects support enumerable methods like `select`, `sort_by`, `first` and `count`. See [Enumerable methods](/docs/rendering/#enumerable-methods) for the full list of available methods. ## Caching Data sources can be cached if you are dealing with large files (mutliple thousands of rows). This is especially useful where you dynamically uses data sources instead of creating resources. Enable by adding `config.cache_data_sources = false` to your Perron initializer.Create a new collection using the built-in generator: ```bash bin/rails generate content Post ``` This will create the following files: * `app/content/posts/` (all `.md`, `.erb` content will be added here) * `app/models/content/post.rb` * `app/controllers/content/posts_controller.rb` * `app/views/content/posts/index.html.erb` * `app/views/content/posts/show.html.erb` And adds a route: `resources :posts, module: :content, only: %w[index show]` > [!note] > View all available commands with `bin/rails generate content --help` Create the only needed action with: ```bash bin/rails generate content Post show ``` ## Inline content Use `--inline` to generate a show action that does not need a `show.html.erb` template because the resource has all the needed HTML. It will create a show action like this: ```erb def show @resource = Content::Post.find!(params[:id]) render @resource.inline end ``` This is useful for resources that do not need shared HTML as a `show.html.erb` provides. ## Creating new content files Once a collection is created, quickly generate new content files: ```bash bin/rails generate content Post --new bin/rails generate content Post --new "My First Post" ``` This creates a new file in `app/content/posts/` based on a template (if one exists). ### Using templates Drop a template file in the content directory to define the structure for new files: ```erb --- title: <%= @title %> published_at: <%= Time.current %> --- # <%= @title %> Start writing here… ``` If no template exists, an empty file with frontmatter dashes is created. Template filenames support `strftime` parameters for dynamic naming, when respectively a title (eg. `My First Post`) and no title is passed: - `template.md.tt`; generates `my-first-post.md` and `untitled.md` - `%Y-%m-%d-template.md.tt`; generates `2026-01-01-my-first-post.md` and `2026-01-01-untitled.md`Perron supports frontmatter. It is a way to set metadata for that content, using yaml—a key-value system. It is added at the beginning of the markdown file and set between three dashes (`---`). Something like this: ```yaml --- title: Frontmatter description: Frontmatter is supported in resources. --- ``` And it is typically used for data that is not directly visible, like for [metatags](/docs/metatags/). ## Usage All defined frontmatter on a resource is available at the `metadata` method. Given above example, `@resource.metadata.title` and `@resource.metadata.description` would output their values. ## Custom slugs Override the default slug (derived from filename) by setting `slug` in the frontmatter: ```markdown --- title: About Us slug: about-us --- ``` This changes the lookup from filename to the custom slug. For example, a file named `about.md` with `slug: about-us` would be accessed via `Content::Page.find("about-us")` instead of `Content::Page.find("about")`. ## Additional metadata Several frontmatter keys are used in various features in Perron: - `updated_at`; last modification date (used in sitemaps and feeds) - `sitemap_priority`; override sitemap priority for this resource - `sitemap_change_frequency`; override change frequency for this resource - `exclude_from_sitemap`; exclude this resource from the sitemap - `exclude_from_feed`; exclude this resource from feedsPerron uses standard Rails routing, allowing the use of familiar route helpers. The `config/routes.rb` could look like this: ```ruby Rails.application.routes.draw do resources :posts, module: :content, only: %w[index show] resources :pages, module: :content, only: %w[show] root to: "content/pages#root" end ``` ## Route configuration When using `_url` route helpers, configure `default_url_options` in the Perron initializer: ```ruby Perron.configure do |config| # … config.default_url_options = {host: "perron.railsdesigner.com", protocol: "https", trailing_slash: true} # … end ``` > [!tip] > You can also set (or override) these values with environment variables: `PERRON_HOST`, `PERRON_PROTOCOL` and `PERRON_TRAILING_SLASH` For a typical "clean slug", the filename without extension serves as the `id` parameter. ```ruby <%# For app/content/posts/announcement.md %> <%= link_to "Announcement", post_path("announcement") %> ``` This would render `<a href="/posts/announcement/">Announcement</a>`. ## Additional routes Include additional routes in the build beyond collections: ```ruby Perron.configure do |config| config.additional_routes = %w[search_path] end ``` ## Path building Perron builds paths from your defined routes rather than collections. This ensures URLs match exactly what gets built, including any nested route structures. For route-based path building, the collection is derived from: 1. The controller's `collection_name` method (if defined) 2. The route resource name (singularized) For example, `resources :posts` maps to the `posts` collection and builds paths like `/posts/my-post`.Generate content programmatically from [data sources](/docs/data/) instead of creating files manually. Define a template once and Perron creates resources for every combination of the data. Perfect to pull data from an API or for programmatic SEO where similar pages with different data are needed. ## Basic Usage First create data resources: ```json // app/content/data/countries.json [ {"id": "de", "name": "Germany"}, {"id": "nl", "name": "The Netherlands"} ] ``` ```csv # app/content/data/products.csv id,name,price 1,iPhone,999 2,iPad,799 ``` Then configure in the content resource class: ```ruby # app/models/content/product.rb class Content::Product < Perron::Resource sources :countries, :products def self.source_template(source) <<~TEMPLATE --- title: #{source.products.name} in #{source.countries.name} country_id: #{source.countries.id} product_id: #{source.products.id} --- # #{source.products.name} Available in #{source.countries.name} for $#{source.products.price}. TEMPLATE end end ``` > [!tip] > Use the generator `bin/rails generate content Product --data countries.json products.csv` Generate resources: ```bash bin/rails perron:sync_sources ``` This creates four files in `app/content/products/`: - `de-1.erb` - `nl-1.erb` - `de-2.erb` - `nl-2.erb` Each file is processed like any regular resource with full access to layouts, helpers and routing. ## Custom primary keys By default, Perron uses `id` to identify records. Use the `primary_key` option to specify a different column: ```ruby class Content::Product < Perron::Resource sources :countries, products: { primary_key: :code } def self.source_template(source) <<~TEMPLATE --- title: #{source.products.name} country_id: #{source.countries.id} product_code: #{source.products.code} --- TEMPLATE end end ``` Filenames use the specified primary keys: `us-iphone-15.erb` ## Lambda filtering Filter data sources using lambda expressions: ```ruby class Content::Product < Perron::Resource sources, :countries, products: -> (products) { products.select(&:featured?) } def self.source_template(source) <<~TEMPLATE --- title: #{source.products.name} in #{source.countries.name} product_id: #{source.products.id} --- TEMPLATE end end ``` ## Single source Use `source` (singular) for a single data source: ```ruby class Content::City < Perron::Resource source :cities def self.source_template(source) <<~TEMPLATE --- title: #{source.cities.name} city_id: #{source.cities.id} --- TEMPLATE end end ``` ### Mode option Control how data sources are combined using the `mode` option: ```ruby # Default: Cartesian product of all data sources source tools: { mode: :combinations } ``` With columns specified via `as:`: ```ruby source tools: { mode: :combinations, as: [:left, :right] } ``` ## Syncing Sync all source-backed resources: ```bash bin/rails perron:sync_sources ``` Sync a specific resource: ```bash bin/rails perron:sync_sources[products] ``` > [!tip] > In zsh, quote the task name: `bin/rails "perron:sync_sources[products]"` Run the sync task whenever data changes to regenerate affected resources. ## API integration with custom classes Use the `class` option to pull data from external APIs: ```ruby # app/models/content/project.rb class Content::Project < Perron::Resource source repos: { class: GitHubRepo, primary_key: :name, scope: -> (repos) { repos.select { it.language == "Ruby" } } } def self.source_template(source) <<~TEMPLATE --- title: #{source.repos.name} description: #{source.repos.description} language: #{source.repos.language} stars: #{source.repos.stargazers_count} repo_name: #{source.repos.name} --- # #{source.repos.name} #{source.repos.description} **Language:** #{source.repos.language} **Stars:** #{source.repos.stargazers_count} **URL:** #{source.repos.html_url} TEMPLATE end end ``` ```ruby # app/models/git_hub_repo.rb class GitHubRepo < ActiveResource::Base self.site = "https://api.github.com/" def self.all find(:all, from: "/users/Rails-Designer/repos") end end ``` This will generate individual project pages for each repository (tagged "Ruby") with live GitHub data. ### Custom class requirements Classes used with the `class` option must: - implement an `.all` method that returns an enumerable collection - return objects that respond to the specified primary_key method ### Use cases for API integration Ideas for pulling from APIs are infinite. Here are some ideas: **Content Generation:** - product catalogs; pull from Shopify, WooCommerce APIs - real estate listings; generate property pages from MLS data - job boards; create job posting pages from recruitment APIs - event listings; pull from Eventbrite, Meetup APIs **Programmatic SEO:** - location pages; "Service in [City]" - comparison pages; "[Product] vs [Competitor]" - industry pages; "[Tool] for [Industry]"Perron supports markdown with the `markdownify` helper. There are no markdown gems bundled by default, so add one of these to the `Gemfile`: - `commonmarker` - `kramdown` - `redcarpet` ```bash # choose one bundle add {commonmarker,kramdown,redcarpet} ``` This set up allows to choose your favorite markdown renderer and update it separately from Perron. ## Markdownify helper Once a markdown gem is installed, use the `markdownify` helper in any view and it will parse the content using the installed markdown parser, for example: ```erb <article class="content"> <h1> <%= @resource.title %> </h1> <%= markdownify @resource.content %> </article> ``` Pass a block: ```erb <article class="content"> <h1> <%= @resource.title %> </h1> <%= markdownify do %> Perron supports markdown with the `markdownify` helper. There are no markdown gems bundled by default, so add one of these to the `Gemfile`: - `commonmarker` - `kramdown` - `redcarpet` ```bash bundle add {commonmarker,kramdown,redcarpet} ``` <% end %> </article> ``` ## Configuration To pass options to the parser, set `markdown_options` in `config/initializers/perron.rb`. The options hash is passed directly to the chosen library. ```ruby Perron.configure do |config| # … # Commonmarker # Options are passed as keyword arguments. config.markdown_options = { options: { parse: { smart: true }, render: { unsafe: true } } # Kramdown # Options are passed as a standard hash. config.markdown_options = { input: "GFM", smart_quotes: "apos,quot" } # Redcarpet # Options are nested under :renderer_options and :markdown_options. config.markdown_options = { renderer_options: { hard_wrap: true }, markdown_options: { tables: true, autolink: true } } # … end ``` ## Custom markdown parser ```ruby class MyParser < Perron::Markdown::Parser def parse(text) # Do whatever you want here # `config.markdown_options` is available as `options` instance method end end ``` Extend the `Perron::Markdown::Parser` class or any of the three provided markdown providers. Then use it by setting `markdown_parser`: ```ruby Perron.configure do |config| # … config.markdown_parser = :my_parser # … end ``` ## Processors Perron can post-process the markdownified content using processors. ### Usage Apply transformations by passing an array of processor names or classes to the `markdownify` helper via the `process` option. ```erb <%= markdownify @resource.content, process: %w[lazy_load_images target_blank] %> ``` ### Built-in processors The following processors are built-in and can be activated by passing their string name: - `absolute_image_urls`; converts relative image URLs to absolute using configured `default_url_options` - `lazy_load_images`; adds `loading="lazy"` to all `<img>` tags - `target_blank`; adds `target="_blank"` to all external links > [!note] > Processors are included as _first-party_ options only when they require no setup or configuration. Otherwise, they are added to the [library](/library/). ### Accessing @resource in processors Processors have access to the the `@resource` instance variable assuming it is defined as such (e.g. `@resource = Content::Post.find!(params[:id])`). If the resource object is named differently, e.g. `@post`, pass it along: ```erb <%= markdownify @resource.content, process: %w[target_blank], resource: @post %> ``` ### Create your own processor Create your own processor by defining a class that inherits from `Perron::HtmlProcessor::Base` and implements a `process` method. ```ruby # app/processors/add_nofollow_processor.rb class AddNofollowProcessor < Perron::HtmlProcessor::Base def process @html.css("a[target=_blank]").each { it["rel"] = "nofollow" } end end ``` > [!note] > The `@html` instance variable is a `Nokogiri::HTML::DocumentFragment` object, that gives access to methods like `css()`, `xpath()` and DOM manipulation. See the [Nokogiri docs](https://nokogiri.org/) for more. Then, pass the class constant directly in the `process` array. ```erb <%= markdownify @resource.content, process: ["target_blank", AddNofollowProcessor] %> ``` ### Default processors Configure processors to run on every `markdownify` call: ```ruby Perron.configure do |config| config.default_processors = [MyProcessor, "target_blank"] end ``` Passing an explicit `process:` argument to `markdownify` overrides the defaults. To combine defaults with additional per-call processors in a specific view: ```erb <%= markdownify @resource.content, process: Perron.configuration.default_processors + [MyExtraProcessor] %> ```Perron's resources are just Ruby objects so it is straight-forward to render, filter and order resources. ## Resource content To render a resource's content, use `@resource.content`. ```erb <%= @resource.content %> ``` ## Rendering resources Render a list of resources with: `Content::Post.all`. Pass the array to `render`, just like with ActiveRecord models: ```erb <%= render Content::Post.all %> ``` This expects a partial `app/views/content/posts/_post.html.erb`. Or set a partial and pass the collection: ```erb <%= render partial: "content/posts/snippet", collection: Content::Post.all %> ``` ## ActiveRecord-style queries Perron supports familiar ActiveRecord-style query methods for cleaner, more readable code. ### Where Filter resources using hash syntax: ```ruby Content::Post.where(category: "ruby") Content::Post.where(published: true) Content::Post.where(section: [:content, :metadata]) ``` ### Order Sort resources by attributes: ```ruby Content::Post.order(:title) Content::Post.order(:publication_date, :desc) Content::Post.order(title: :desc) ``` ### Limit Limit the number of results: ```ruby Content::Post.limit(5) Content::Post.order(:publication_date, :desc).limit(3) ``` ### Offset Offset the number of results: ```ruby Content::Post.offset(2) Content::Post.offset(2).limit(5) ``` ### in_order_of Preserve a custom sort order by specifying field values: ```ruby Content::Post.in_order_of(:category, %w[featured popular recent]) Content::Post.in_order_of(:category, %w[featured popular], filter: false) ``` By default, resources not matching the given values are excluded. Pass `filter: false` to keep them (appended after the ordered set). ### Scopes Define reusable query scopes: ```ruby class Content::Post < Perron::Resource scope :getting_started, -> { where(section: :getting_started) } scope :recent, -> { order(:publication_date, :desc).limit(10) } end Content::Post.getting_started.order(:title).limit(5) ``` ### Chaining Chain methods together for complex queries: ```ruby Content::Post .where(published: true) .order(:publication_date, :desc) .limit(5) ``` ## Enumerable methods All standard Ruby enumerable methods are available: `select`, `reject`, `map`, `find`, `group_by`, `sort_by`, `count`, `any?`, `all?`, `first`, `last` and more. ```ruby Content::Post.all.select { it.published? } Content::Post.all.group_by(&:category) Content::Post.all.sort_by(&:publication_date).reverse ```Pagination splits large collections across multiple pages, each limited to a configurable number of items. ## Configuration Set the number of items per page in the resource class: ```ruby # app/models/content/post.rb class Content::Post < Perron::Resource configure do |config| config.pagination.per_page = 10 end end ``` ## Controller Include `Perron::PaginateHelper` and call `paginate` with the resource class and a collection: ```ruby # app/controllers/content/posts_controller.rb class Content::PostsController < ApplicationController include Perron::PaginateHelper def index @paginate, @resources = paginate(Content::Post.all) end end ``` `paginate` returns two values: a paginate object and the page's resource collection. The paginate object is available as `@paginate` in your views. ## Views Link to previous and next pages: ```erb <%# app/views/content/posts/index.html.erb %> <%= link_to "Previous", @paginate.previous if @paginate.previous? %> <%= link_to "Next", @paginate.next if @paginate.next? %> ``` The paginate object provides these methods: | Method | Description | | :----- | :---------- | | `previous` | Returns the path for the previous page | | `previous?` | Returns `true` if a previous page exists | | `next` | Returns the path for the next page | | `next?` | Returns `true` if a next page exists |Perron provides flexible options for embedding Ruby code using ERB. ## File extension Any content file with a `.erb` extension (e.g., `about.erb`) will automatically have its content processed as ERB. ## Frontmatter Enable ERB processing on a per-file basis, even for `.md` files, by adding `erb: true` to the file's frontmatter (like [this article's markdown file](https://github.com/Rails-Designer/perron-site/blob/main/app/content/articles/embed-ruby.md)). ```markdown --- title: Embed Ruby erb: true --- This entire page will be processed by ERB. The current date (upon building this site) is: 04 July 2026 ↳ uses `<%= Time.current.strftime("%d %B %Y") %>` ``` Alternatively, set `erb: false` to disable ERB processing. ## `erbify` helper For granular control, the `erbify` helper allows to process specific sections of a file as ERB. This is ideal for generating dynamic content like lists or tables from the resource's frontmatter, without needing to enable ERB for the entire file. **Example:** generating a list from frontmatter data in a standard `.md` file. ```markdown --- title: Features features: - Rails based - SEO friendly - Markdown first - ERB support --- Check out our amazing features: <%= erbify do %> <% end %> ``` This would iterate over the `features` array and display its value within the `li`-element. > [!note] > The `erbify` method only supports passing a block ## Rendering erb-tags When needing to show actual ERB tags (rather than having them processed), to escape them by doubling the percent signs. For example: `<%%` and `<%%=`.Perron includes a system for managing the publication status of content resources. This allows creating drafts, publish content immediately or schedule it to be published in the future. This status is determined by looking at the resource's frontmatter or, as a fallback, the date in its filename. ## Setting the Publication Date There are two ways to define when a piece of content should be considered published. Perron uses the first valid date it finds, checking in this order: ### Frontmatter (Recommended) Set a `published_at` key in the resource's frontmatter. This gives precise control over the publication time. The value should be a valid date or datetime string. Examples: * Date only: `2026-07-03` * Date with time: `2026-07-03 00:00:00` * ISO 8601 with timezone: `2026-07-03T00:00:00Z` ```yaml --- title: My Scheduled Post published_at: 2026-07-03 00:00:00 --- ``` > [!note] > If `published_at` is set to a time in the future, the content is considered **scheduled**. ### Filename If `published_at` is not set in the frontmatter, Perron will attempt to parse a date from the resource's filename. The filename must be prefixed with a date in the format `YYYY-MM-DD-`. For a file named `2026-07-03-my-first-post.md`, the publication date will be set to the beginning of that day. ## Drafts To prevent a resource from being published, mark it as draft. This is useful for content that is not yet ready. There are two ways to do this in the frontmatter: ```yaml --- title: This is a Work in Progress draft: true --- ``` Alternatively, use `published: false`: ```yaml --- title: Another Work in Progress published: false --- ``` > [!note] > A resource will not be published if `draft` is `true`, if `published` is `false` or if its publication date is in the future. ## Preview Set `preview: true` frontmatter to allow draft or scheduled content to be built in production with a secret token appended to the slug. Examples: ```yml preview: true # → "my-post-a1b2c3d4e5f6" preview: custom-token # → "my-post-custom-token" ``` The generated token is built off the content's file path. So if the file path changes, the generated token will change too. Note that anyone with the “secret link” can view the content, including (search) bots. To skip indexing, by decent bots, of “previewable resources” add this to the ``: ```erb <% if @resource.preview? %> <% end %> ``` ## Available Methods The publishing logic adds several helpful methods to content resource objects. | Method | Description | :----------------- | :---------- | `published?` | Returns `true` if the resource is currently visible to the public | `scheduled?` | Returns `true` if the resource's publication date is in the future | `draft?` | Returns `true` if the resource's frontmatter has `draft: true` or `published: false` | `preview?` | Returns `true` if the resource's frontmatter has `preview: true` and is not `published?`. Aliased as `previewable?` | `publication_date` | Returns the `Time` object for when the resource is/was published. Aliased as `published_at` | `scheduled_at` | Returns the publication date, but only if the resource is scheduled (otherwise returns `nil`) ## Viewing Unpublished Content For development or preview/staging environments, globally override the publishing rules to make all content visible, including drafts and scheduled posts. This is done by setting `Perron.configuration.view_unpublished = true` (defaults to `Rails.env.development?`, so content can always be previewed in development).The `meta_tags` helper automatically generates SEO and social sharing meta tags. ## Usage In the layout (e.g., `app/views/layouts/application.html.erb`), add the helper to the `<head>` section: ```erb <head> … <%= meta_tags %> … </head> ``` Render specific subsets of tags: ```erb <%= meta_tags only: %w[title description] %> ``` Or exclude certain tags: ```erb <%= meta_tags except: %w[twitter_card twitter_image] %> ``` ## Priority Values are determined with the following precedence, from highest to lowest: ### 1. Resource frontmatter Add values to the YAML frontmatter in content files: ```yaml --- title: My Awesome Post description: A deep dive into how meta tags work. image: /assets/images/my-awesome-post.png author: Kendall --- Your content here… ``` ### 2. Controller action Define a `@metadata` instance variable in the controller: ```ruby class Content::PostsController < ApplicationController def index @metadata = { title: "All Blog Posts", description: "A collection of our articles." } @resources = Content::Post.all end end ``` ### 3. Collection configuration Set collection defaults in the resource model: ```ruby class Content::Post < Perron::Resource configure do |config| # … config.metadata.description = "Add forms to any static site. Display responses anywhere." config.metadata.author = "Chirp Form team" end end ``` ### 4. Default values Set site-wide defaults in the initializer: ```ruby Perron.configure do |config| # … config.metadata.description = "Add forms to any static site. Display responses anywhere." config.metadata.author = "Chirp Form team" end ``` ## Generated HTML Tags Below is a complete list of the HTML tags that the `meta_tags` helper can generate. The helper will only render a tag if its corresponding data (e.g., content or href) is present. ```html <title>…</title> <link rel="canonical" href="…"> <meta name="description" content="…"> <meta property="article:published_time" content="…"> <meta property="og:title" content="…"> <meta property="og:type" content="…"> <meta property="og:url" content="…"> <meta property="og:image" content="…"> <meta property="og:description" content="…"> <meta property="og:site_name" content="…"> <meta property="og:logo" content="…"> <meta property="og:author" content="…"> <meta property="og:locale" content="…"> <meta name="twitter:card" content="…"> <meta name="twitter:title" content="…"> <meta name="twitter:description" content="…"> <meta name="twitter:image" content="…"> ```Perron can create RSS, Atom and JSON feeds of collections. The `feeds` helper automatically generates HTML `<link>` tags for generated feeds. ## Usage In the layout (e.g., `app/views/layouts/application.html.erb`), add the helper to the `<head>` section: ```erb <head> … <%= feeds %> … </head> ``` To render feeds for specific collections, such as `posts`: ```erb <%= feeds only: %w[posts] %> ``` Similarly, exclude collections with: ```erb <%= feeds except: %w[pages] %> ``` ## Configuration Feeds are configured in the `Resource`'s collection: ```ruby # app/models/content/post.rb class Content::Post < Perron::Resource configure do |config| config.feeds.atom.enabled = true # config.feeds.atom.title = "My Atom feed" # config.feeds.atom.description = "My Atom feed description" # config.feeds.atom.path = "posts.atom" # config.feeds.atom.max_items = 25 config.feeds.json.enabled = true # config.feeds.json.title = "My JSON feed" # defaults to configured site_name # config.feeds.json.description = "My JSON feed description" # defaults to configured site_description # config.feeds.json.max_items = 15 # config.feeds.json.path = "posts.json" # config.feeds.json.ref = "json-feed" # adds `?ref=json-feed` to item links for tracking config.feeds.rss.enabled = true # config.feeds.rss.title = "My RSS feed" # defaults to configured site_name # config.feeds.rss.description = "My RSS feed description" # defaults to configured site_description # config.feeds.rss.path = "posts.rss" # config.feeds.rss.max_items = 25 # config.feeds.rss.ref = "rss-feed" # adds `?ref=rss-feed` to item links for tracking end end ``` ## Author Feeds can include optional author information. Set a default author for the collection: ```ruby class Content::Post < Perron::Resource configure do |config| config.feeds.atom.author = { name: "Rails Designer", email: "support@railsdesigner.com" } config.feeds.json.author = { name: "Rails Designer", email: "support@railsdesigner.com" } config.feeds.rss.author = { name: "Rails Designer", email: "support@railsdesigner.com" } end end ``` Individual resources can override this using a [`belongs_to :author` relationship](/docs/resources/#associations): ```ruby class Content::Post < Perron::Resource belongs_to :author end ``` ```markdown --- title: My Post author_id: kendall --- ``` > [!NOTE] > RSS and Atom feeds require an email address. JSON feeds only require a name. All support optional `url` and `avatar` fields. ### Using a data resource Prefer to manage authors in a [data resource](/docs/data/) instead of individual content resources, create a YAML file in `app/content/data/`, e.g., `app/content/data/authors.yml` or `app/content/data/team.yml`: ```yaml - id: kendall name: Kendall email: kendall@railsdesigner.com url: https://example.com avatar: /images/kendall.jpg bio: Software developer and writer myspace: kendall-rd ``` > [!note] > For feeds to work, data must include at least `name` and `email` keys (or just `name` for JSON feeds). Then add the `class_name` option to the `belongs_to` association: ```ruby class Content::Post < Perron::Resource belongs_to :author, class_name: "Content::Data::Authors" end ``` ## Custom templates Override the default feed templates by creating custom views. Place them in `app/views/content/{collection_name}/`: - `rss.erb` - `atom.erb` - `json.erb` The builder provides access to: - `collection`; the collection object - `resources`; the actual resource items (to iterate over) - `config`; the feed configuration Example `app/views/content/posts/json.erb`: ```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 %> } ```The `related_resources` method allows to find and display a list of similar resources from the same collection. Similarity is calculated using the **[TF-IDF](https://en.wikipedia.org/wiki/Tf%E2%80%93idf)** algorithm on the content of each resource. ## Usage To get a list of the 5 most similar resources, call the method on any resource instance. ```ruby # app/views/content/posts/show.html.erb @resource.related_resources # Just the 3 most similar resources @resource.related_resources(limit: 3) ```A sitemap is a XML file that lists all the pages of a website to help search engines discover and index content more efficiently, containing URLs, last modification dates, change frequency and priority values. Enable it with the following line in the Perron configuration: ```ruby Perron.configure do |config| # … config.sitemap.enabled = true # config.sitemap.priority = 0.8 # config.sitemap.change_frequency = :monthly # … end ``` Values can be overridden per collection… ```ruby # app/models/content/post.rb class Content::Post < Perron::Resource configure do |config| config.sitemap.enabled = false config.sitemap.priority = 0.5 config.sitemap.change_frequency = :weekly end end ``` …or on a resource basis: ```ruby --- sitemap: false sitemap_priority: 0.25 sitemap_change_frequency: :daily --- ``` ## Last modification date The sitemap includes a `<lastmod>` element based on the resource's `updated_at` frontmatter value: ```markdown --- title: My Post updated_at: 2025-04-20 --- ``` If `updated_at` is not set, the file's modification time is used as a fallback. ## Explanation of priority - **1.0 – 0.8: high priority**; homepage, product information, landing pages, category pages - **0.7 – 0.4: mid-range priority**; news articles, weather services, blog posts, pages that no site would be complete without - **0.3 – 0.0: low priority**; FAQs, old news stories, old press releases, completely static pages that are still relevant ## Do you need a XML sitemap? If your site is "small", you do not need one. Small, meaning ~500 pages (that needs to be in search results) or less on your site.