Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
213 lines (147 sloc) 8.3 KB

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!

You can’t perform that action at this time.