Last Updated: September 27, 2021
·
13.91K
· ProGM

How to create an AMP page for your dynamic content in Rails

Google AMP is here. And it's great!

AMP is a new standard to create faster mobile pages, built on top of HTML, that allows instant page load.

How to setup an AMP page for user generated articles in Rails? Here's my tutorial!

First of all, I created an example repository here. I'm using it as reference for this article.

Introduction

Let's start with a Model!

In my example we have an Article model, that stores a title and a content. The content can store some HTML generated by a WYSIWYG editor, like Tinymce.

class Article < ActiveRecord::Base
  belongs_to :user
  validates :title, :content, presence: true
end

And here's it's controller:

class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])
  end
end

The view:

<h1><%= @article.title %></h1>
<p><%= @article.content.html_safe %></p>

and it's router:

resources :articles, only: :show

Not more than a standard template for a rails app.
Now, what we want is to create an alternative view for this article page, that follows the AMP standard.

Define a new mime type

An easy way to create a new view for existing pages, without changing the controller, is to define a new mime type.
For example, we want that /articles/1 loads our standard article. Instead, loading /articles/1.amp will load it's AMP version.

First of all, let's create our mime type, adding it to our config/initializers/mime_types.rb:

Mime::Type.register 'text/html', :amp

Now, let's create a new layout and a new view.

app/views/layouts/application.amp.erb:

<!doctype html>
<html ⚡>
  <head>
    <meta charset="utf-8">
    <link rel="canonical" href="<%= url_for(format: :html, only_path: false) %>" >
    <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
    <style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
    <script async src="https://cdn.ampproject.org/v0.js"></script>
    <script async custom-element="amp-iframe" src="https://cdn.ampproject.org/v0/amp-iframe-0.1.js"></script>
    <script async custom-element="amp-youtube" src="https://cdn.ampproject.org/v0/amp-youtube-0.1.js"></script>
  </head>
  <body>
    <div class="amp">
      <%= yield %>
    </div>
  </body>
</html>

As you can see, it's substantially the default AMP HTML template, found here. I injected also amp-iframe and amp-youtube custom tags, since my articles could contain embedded videos. Moreover I defined the canonical link rel, that refers to my non-AMP version of the page. This is done simpy using url_for method, passing the html format.

Ok! Now we can define our custom view for this article in app/views/articles/show.amp.erb

Work with custom css

AMP requires to include only embedded css, not external <link> tags. Here's a trick to continue using our assets pipeline and the compiled css in our view.

First of all, let's create a new sass file under app/assets/stylesheets/amp/application.scss:

body {
  ...some styles here...
}

Now, let's register it in the precompilation, by adding this line to our config/application.rb file.

config.assets.precompile << 'amp/application.scss'

This says to rails to compile it as a file, instead of bundling inside our standard application.css.

Now, let's add this in our layout <head>:

<% if Rails.application.assets && Rails.application.assets['amp/application'] %>
<style amp-custom><%= Rails.application.assets['amp/application'].to_s.html_safe %></style>
<% else %>
<style amp-custom><%= File.read "#{Rails.root}/public#{stylesheet_path('amp/application', host: nil)}" %></style>
<% end %>

This simply copies all the sass compiled in our view.
There are two cases:
* In development, we can use the Rails.application.assets helper, that contains the sass compiled data of our files.
* In production, unfortunately, this variable is nil, but we can read the compiled file from the public/assets folder. Note that I use stylesheet_path with host: nil. This is because, normally, it appends the host name to the path.

Rendering article content

Now we have a big problem. In our standard view, we could simply print our @article.content in a DOM, without any change.

AMP, instead, has many limitations on allowed tags. Moreover, there are also some tags that requires to be changed a bit to work in AMP. For example, an "img" tag becomes "amp-img". The same is for iframes.

How we can deal with this?
Well, my solution was to implement a custom scrubber for the ActionView built-in sanitize method.

It uses a best-effort approach: It tries to convert as much as it can from the original DOM, and strips the unreadable parts, in order to make AMP page valid.

Here's how it looks like for now:

class AmpScrubber < Rails::Html::PermitScrubber
  TAG_MAPPINGS = {
    'img' => lambda { |node|
      if node['width'] && node['height']
        node.name = 'amp-img'
        node['layout'] = 'responsive'
        node['srcset'] = node['src']
      else
        node.remove
      end
    },
    'iframe' => lambda { |node|
      find_parent(node).add_child(node)

      node['src'] = node['src'].gsub(%r{^(\/\/|http:\/\/)}, 'https://')
      url = URI(node['src'])
      node['layout'] = 'responsive'

      if url.host.include?('youtube.com')
        node.name = 'amp-youtube'
        node['data-videoid'] = node['src'].match(%r{(\/embed\/|watch?v=)(.*)})[2]
        node.remove_attribute('src')
      else
        node.name = 'amp-iframe'
      end
    }
  }.freeze

  def initialize
    super
    @tags = %w(a em p span h1 h2 h3 h4 h5 h6 div strong s u br blockquote)
    @attributes = %w(style contenteditable frameborder allowfullscreen)
  end

  def self.find_parent(node)
    node = node.parent while node.parent
    node
  end

  protected

  def scrub_attribute?(name)
    !super
  end

  def scrub_node(node)
    if node.name.in?(TAG_MAPPINGS.keys)
      remap_node! node, TAG_MAPPINGS[node.name]
    else
      super
    end
  end

  def remap_node!(node, filter)
    case filter
    when String
      node.name = filter
    when Proc
      filter.call(node)
    end
  end
end

And here's how to use it. Just create a file under app/views/articles/show.amp.erb

<h1><%= @article.title %></h1>

<div>
  <%= sanitize @article.content, scrubber: AmpScrubber.new %>
</div>

You can check here the resulting view:

http://amp-example.herokuapp.com/articles/1.amp

Refer our AMP page from our standard one

Now we have our AMP page. How to say Google to index it? Well, following the docs, we just have to add a few <link rel> tags in our main page head.

Here's how to edit our layout.html.erb:

<link rel="canonical" href="<%= url_for(format: :html, only_path: false) %>" >
<link rel="amphtml" href="<%= url_for(format: :amp, only_path: false) %>" >

Conclusion

We have seen how to create an AMP version for our dynamic contents.

You can find the complete example on github.

Let me now if you have ideas, suggestions or concerns!

5 Responses
Add your response

Is there any sense integrating AMP into a site if there's already turbolinks enabled? Isn't that kind of the similar thing? I mean AMP requires a lot of work - adding image/video dimensions, refactoring all the css into specific layout fractions etc.

over 1 year ago ·

@banesto Not really. AMP pages are not like common "mobile sites". They usually get a great boost on Google from mobile users, since it shows AMP pages on the top of all search results.
Moreover they are instant to load, since google caches it in its servers ;) I think it's a great tool to implement, but they are not an alternative to a mobile compatible website.

over 1 year ago ·

Thanks for the nice tutorial.

I implemented this earlier successfully in a Rails 4 app. Now I upgraded it to Rails 5 with Turbolinks.
The problem is that the registered AMP mimetype becomes the default when navigating the site with turbolinks. This should be HTML.

Any suggestions how to fix this?

over 1 year ago ·

Not sure what the configuration difference that made a difference, but when I followed your steps I ended up with amp stylesheets that were passing validation locally but not when deployed. After a few hours of being dumb and not noticing the validator kept spitting back errors around encoded quotation marks, I figured out the solution for my problem: adding .html_safe to this line here:

<style amp-custom><%= File.read "#{Rails.root}/public#{stylesheet_path('amp/application', host: nil)}" %></style>

a la

<style amp-custom><%= File.read("#{Rails.root}/public#{stylesheetpath('amp/application', host: nil)}").html_safe %></style>

I hope that saves someone the trouble I ran into!

over 1 year ago ·

I hit a huge gotcha. We wanted to make a single page amp'ified and then move to a more generalized layout. We simply added Mime::Type.register "text/html", :amp to mimetypes.rb thinking it was adding an additional format. However, this wipes out the original "text/html" mime type in Rails and runs requests through amp layouts. If you don't create the .amp.erb layout, it will give 500 error with ActionView::MissingTemplate exception. Instead, you want `Mime::Type.registeralias "text/html", :amp`.

over 1 year ago ·