Profile picture Schedule a Meeting
c a n d l a n d . n e t

Adding Sitepress to Rails

Dusty Candland | | rails, sitepress, seo

I've been using Proofreader.io and thought it's about time I fixed some of the issues that Proofreader is designed to surface. Like SEO & social meta tags, sitemaps, robots.txt.

I choose Sitepress to handle the content side of things, which make doing a lot of this easier. Here's a walk though.

Install and setup Sitepress

Sitepress - "Build ambitious content websites in Rails"

Add the gems. You can skip markdown-rails and rouge if you don't want Markdown support or syntax highlighting.

gem "sitepress-rails"
gem "markdown-rails"
gem "rouge"

Run the installers. They will create the basic initializers and structure for the content. Content ends up in app/content, along with helpers and layouts.

bin/rails generate sitepress:install
bin/rails generate markdown_rails:install

Since these pages will be cached in production, we want to make sure they're signed out when generated. I don't actually know if this is needed, but I want it to work that way in development regardless.

I ended up creating a sub directory for the website controllers, app/controllers/website/.

I'm also using Pundit Can which requires that I skip some filters.

class Website::WebsiteController < Sitepress::SiteController
  layout "website"

  skip_scoped_check :show
  skip_authorized_check :show

  protected

  def signed_in?
    false
  end

  def current_user
    nil
  end

  def current_account
    nil
  end

  def setup_current
    Current.user = nil
    Current.true_user = nil
    Current.account = nil
    Current.ip = request.remote_ip
  end
end

Next update the routes so they user the overriden controller. I also removed sitepress_root in favor of using the root: true parameter.

sitepress_pages root: true, controller: "website/website"

For some reason, Slim isn't loaded correctly. I added an initialer for it.

config/initializers/sitepress.rb

ActionView::Template::Handlers.extensions << :slim

Restart the server

Now Sitepress should be working and serving pages!

SEO & Social

Turns out url_for doesn't add anything when passed a string, so we need this helper. Add to app/content/helpers/page_helper.rb.

module PageHelper
  # `url_for` doesn't create a url when a string is passed
  def path_url path
    URI.const_get(request.scheme.upcase).build({
      host: Rails.application.routes.default_url_options.fetch(:host, request.host),
      port: Rails.application.routes.default_url_options.fetch(:port, request.port),
      path: path
    }).to_s
  end
end

Meta Tags

Next up, meta tags. These are used by the search engines and social sites to get a better understanding of the page. Open Graph for Facebook, Twitter has some specifice tags, and keywords & description for search engines.

This definitely could be done without a gem, but I liked the Meta Tags gem and used that.

Add to the Gemfile and bundle.

gem "meta-tags"

Install it. This adds a config, but I didn't change anything there.

rails generate meta_tags:install

I want to use the frontmatter from Sitepress to override data for the meta tags. This maps the frontmatter to a hash for the set_meta_tags method.

Add this to app/content/helpers/page_helper.rb

...
  def to_meta_tags page
    {
      title: page.data.title,
      description: page.data.description,
      keywords: page.data.keywords,
      image_src: page.data.image,
      canonical: canonical(page),
      noindex: page.data.noindex,
      index: page.data.index,
      nofollow: page.data.nofollow,
      follow: page.data.follow,
      noarchive: page.data.noarchive,
      prev: page.data.prev,
      next: page.data.next,
      og: page.data.og,
      twitter: page.data.twitter
    }.compact
  end

  def canonical page
    path_url(page.request_path.gsub(/index\.html\z/, "").gsub(/\.html\z/, "").gsub(/\/\z/, ""))
  end
...

The canonical method strips index.html, .html, and tailing slashes to get a normalized URL. Also uses the other helper method, path_url we added above.

Sitepress has some layout support, but I used the build in Rails layouts. This is where we connect Sitepress to meta_tags with the set_meta_tags method.

app/views/layouts/website.html.slim

- set_meta_tags to_meta_tags(current_page)

- content_for :content do
  = yield

= render template: "layouts/application"

Add the display_meta_tags to the application layout, or head.html.slim in my case. Here is where I set the defaults for tags that might not be set.

= display_meta_tags site: "Proofreader.io",
  reverse: true,
  description: "Automates monitoring the technical details to make sure your content shines!",
  image_src: image_url("manolo-chretien-252147-mono.jpg"),
  og: { title: :title,
    description: :description,
    site_name: :site,
    url: :canonical,
    image: :image_src,
    type: "website",
  },
  twitter: { card: "summary", site: "@proofreaderio" }

Sitemap

It's a good idea to have a sitemap. To set up we'll need a route, controller, and view.

The route.

get '/sitemap.xml' => 'sitemaps#index', defaults: { format: 'xml' }

The controller.

class Website::SitemapsController < ApplicationController
  layout false

  skip_authorized_check :index
  skip_scoped_check :index

  def index
    render formats: :xml
  end
end

The view. Here we use Sitepress and path_url to create a map of pages. I'm removing any that have noindex frontmatter.

xml.instruct! :xml, version: "1.0"
xml.tag! "urlset", "xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9" do
  site = Sitepress::Site.new(root_path: "app/content")
  site.resources.reject{_1.data.noindex}.each do |resource|
    xml.tag! "url" do
      xml.tag! "loc", path_url(resource.request_path)
      xml.lastmod resource.asset.updated_at.strftime("%F")
    end
  end
end

Breadcrumbs

Not huge fan of breadcrumbs, but they are helpful for SEO and large content sites. Again we can the Sitepress meta data to create them. parents is based on the file/folder stucture of the content and is exactly what I needed.

In the app/views/layouts/website.html.slim file, I added this:

/ ...
- content_for :content do
  - current_page.parents.any?
    menu.flex.flex-row.gap-3.text-sm.text-gray-700.mb-4
      - current_page.parents.reverse.each_with_index do |parent, idx|
        li
          a href=parent.request_path = parent.data.title
        - if idx < current_page.parents.size - 1
          li \
/ ...

Robots.txt

Very similar to the sitemap, we can have a more dynamic robots.txt. You could also use the one from the public directory, but I liked the idea of having a bit more control by moving into Rails.

The route.

get "/robots.txt" => "website/robots#index", :defaults => {format: "text"}

The controller.

class Website::RobotsController < ApplicationController
  layout false

  skip_authorized_check :index
  skip_scoped_check :index

  def index
    render format: :text
  end
end

The view. Used ERB for this one. index.text.erb. Also added a reference to the sitemap.

<% if Rails.env.production? %>
User-Agent: *
Disallow:
<% else %>
User-Agent: *
Noindex: /
<% end %>

Sitemap: <%= path_url("/sitemap.xml") %>

Remove the on in public/

rm public/robots.txt

Error pages, 404, 422, 500

Not sure if this actually helps with SEO, but I like the idea of having nicer error pages. Of course, there are some cases where the app is actually broken and we'll end up with the Nginx default error pages. Even still, I moved into Rails.

Remove the defaults

rm public/{404,422,400}.html

Tell Rails to use our routes for the exceptions in config/application.rb.

    config.exceptions_app = routes

Here's the 404 page in app/content/pages/404.html. I'm using the Sitepress meta data to list some suggestions that be helpful. Also note the noindex frontmatter.

---
title: Oops, we couldn't find what you're looking for - 404
noindex: true
---

.container.mx-auto.prose.lg:prose-lg.py-20.px-4'
  h1 = current_page.data.title

  h2 Maybe something here?
  menu.flex.flex-col.gap-1
    - PageModel.all.reject{_1.data.noindex}.each do |page|
      li : a href=page.request_path = page.data.title

And the 422 page.

---
title: The change you wanted was rejected - 422
noindex: true
---

.container.mx-auto.prose.lg:prose-lg.py-20.px-4'
  h1 = current_page.data.title

  p Maybe you tried to change something you didn't have access to.

Lastly, the 500 page.

---
title: We're sorry, but something went wrong - 500
noindex: true
---

.container.mx-auto.prose.lg:prose-lg.py-20.px-4'
  h1 = current_page.data.title

  p We've been notified of the error and will investigate it shortly.

  p Feel free to contact us if you have any questions!
  p
    a href="/contact" Contact Us

Force HTTPS

This is built in! Probably already handled by Nginx.

# config/environments/production.rb

Rails.application.configure do
  # ...
  # force HTTPS on production
  config.force_ssl = true
  #...
end

Redirect www to non-www

Generally I prefer without www. Added this to the top of config/routes.rb. Any request starting with www. will be redirected without it.

Rails.application.routes.draw do
  constraints(host: /\Awww\./i) do
    get "(*any)" => redirect { |params, request|
      URI.parse(request.url).tap { |uri| uri.host = uri.host.gsub(/\Awww\./, "") }.to_s
    }
  end

  #...
end

Testing

Making the above change will break tests b/c the defaults host is www.example.com. Here's how to default to example.com.

Add to test/test_helper.rb.

#...
class ActionDispatch::IntegrationTest
  before { host! "example.com" }
end

Redirects

As the application grows, there will probably be cases where we want to redirect old URLs. I setup a new routes file for them in config/routes/redirects.rb.

Rails.application.routes.draw do
  #
  # Redirects: https://guides.rubyonrails.org/routing.html#redirection
  #
  # get '/stories', to: redirect('/articles')
  # get '/stories/:name', to: redirect('/articles/%{name}')
end

We need to tell Rails about those additional routes files in config/application.rb. This also allows us to organize the routes.

#...
    config.paths["config/routes.rb"].concat Dir[Rails.root.join("config/routes/*.rb")]
#...

References

A lot of this was adapted and updated from SEO & Ruby On Rails : the comprehensive guide 2018

Webmentions

These are webmentions via the IndieWeb and webmention.io. Mention this post from your site: