Webp and picture tags in a rails app

Pablo Curell Mompo
7 min readApr 30, 2021

This article will guide you through the thought process behind creating a picture_tag helper in Rails.

We implemented this helper to optimize how we serve images on our website and improve performance for SEO.

The result will be close to this:

<picture>  <source srcset=”path_to_image.webp” >  <img src=”path_to_image.png’” alt=”alt text” ></picture>

Above, you could envision as many sources as you wanted for different formats (or for additional sizes).

The thought process

When I start to implement something in rails, I almost always have the same internal debate:

  • Should I find a gem for that (and believe me, there *is* a gem for that)
  • Or, should I go the DIY route

This question has probably as many answers as Rails/Ruby developers, and I believe it is a fair question (almost) every time. As I see it, the main tradeoff is one of control vs. implementation time.

Control vs. Implementation Time

A gem can take some time to implement, but usually, it is faster than coding all the functionalities of the gem yourself. Knowing that, why would you not always use a gem, right?

For me, there are three reasons:

  • Tool size, if you want to kill a fly, you would not use a bazooka. In the same vein, if you want a tool to auto-indent your code, you would not use rubocop
  • Control, if you code it yourself, you can do it precisely tailored to your needs. I love active admin (URL active admin), but if you want to do things differently from what active admin offers, you are in for a complicated process to make them coexist.
  • Dependencies, this one you can kind of control with the gems (or at least keep an eye out), but gems usually call other gems, and that can escalate quickly. If you code it yourself, the size of your dependencies will be smaller, which is good for performance and stability.

In all fairness, here there are (at least) two interesting gems if you want to use picture tag helpers: https://github.com/tomasc/picture_tag and https://github.com/G5/picture_tag-rails

The main problem I had with these two gems is that I wanted something tailor-made to replace my image_tag helpers easily.

Picture Tags and Webp

If you touch the front end of web applications and SEO is even a slight concern of yours, you will probably know about webp, but if you need a quick refresher

You also might know about picture tag, it has several useful features. The main ones are:

  • You can serve a different image depending on the screen size
  • You can serve different file types, and the browser will take the first one it can use

Where to start

When starting a project like this, I like to envision the result and the likely milestones. It is essential to know that none of that is set in stone and things might (and will) evolve.

As stated above, the result will look something like this:

<picture>  <source srcset=”path_to_version_one.webp”>  <source srcset=”path_to_image.webp” >  <img src=”path_to_image.png’” alt=”alt text” ></picture>

The likely milestones here will be:

  • Inserting the picture tag
  • creating the image tag
  • creating the source tag(s) above it.

We coded this using TDD. I will not show you the whole process. But I will highlight the beginning of the process and show you the result, which I hope will be clarifying when you attempt to implement something similar.

The first essential step is setting up our tests. Since we are using carrierwave to upload our pictures to aws and transform them to webp, we will simulate how a picture object looks like:

let(:picture_object) do  double(    'Photo',     versions: {       thumb: thumb,       thumb_webp: thumb_webp,       medium: medium,       medium_webp: medium_webp,       other: other     },     url: 'https://original_version.jpeg',     exists?: true  )endlet(:webp_file) { double('webp_file', extension: 'webp') }let(:jpeg_file) { double('jpeg_file', extension: 'jpeg') }let(:thumb) { double('thumb', url: 'https://thumb_url.jpg', file: jpeg_file) }let(:thumb_webp) { double('thumb_webp', url: 'https://thumb_url.webp', file: webp_file) }let(:medium_webp) { double('medium_webp', url: 'https://medium_url.webp', file: webp_file) }let(:medium) { double('medium', url: 'https://medium_url.jpg', file: jpeg_file) }let(:other) { double('other', url: 'https://other_url.jpg', file: jpeg_file) }

We set up our CarrierWave uploader such as a given version has a corresponding version_webp (e.g., the medium version of a picture will have a corresponding medium_webp).

we are now ready to start with our first test:

it ‘responds with a picture tag’ do  expect(helper.picture_tag(picture_object)).to include(‘<picture>’)  expect(helper.picture_tag(picture_object)).to include(‘</picture>’)end

This test is relatively easy to pass:

def picture_tag(picture)  tag.pictureend

The result

We continue to advance with TDD by writing a test, seeing it fail, making it pass, and finally refactoring our code.

The result of our passing specs will look like this in the end:

picture of passing tests in a terminal

And our spec file looks like this:

# frozen_string_literal: truerequire 'rails_helper'RSpec.describe Pictures::PictureTagHelper, type: :helper do describe '#picture_tag' do  let(:picture_object) do   double(    'Photo',    versions: {     thumb: thumb,     thumb_webp: thumb_webp,     medium: medium,     medium_webp: medium_webp,     other: other    },    url: 'https://original_version.jpeg',    exists?: true   )  end  let(:webp_file) { double('webp_file', extension: 'webp') }  let(:jpeg_file) { double('jpeg_file', extension: 'jpeg') }  let(:thumb) { double('thumb', url: 'https://thumb_url.jpg', file: jpeg_file) }  let(:thumb_webp) { double('thumb_webp', url: 'https://thumb_url.webp', file: webp_file) }  let(:medium_webp) { double('medium_webp', url: 'https://medium_url.webp', file: webp_file) }  let(:medium) { double('medium', url: 'https://medium_url.jpg', file: jpeg_file) }  let(:other) { double('other', url: 'https://other_url.jpg', file: jpeg_file) }  it 'responds with a picture tag' do   expect(helper.picture_tag(picture_object)).to include('<picture>')   expect(helper.picture_tag(picture_object)).to include('</picture>')  end  context 'without versions' do   it 'should have an img tag' do    expect(     helper.picture_tag(      picture_object, image: { alt: 'alt message', width: 300,height: 300 }     )    ).to include('<img alt="alt message" width="300" height="300" src="https://original_version.jpeg" />')   end   it 'should have a source tag and file_type per version ' do    expect(helper.picture_tag(picture_object)).to include(     '<source srcset="https://thumb_url.jpg" type="image/jpeg">'    )    expect(helper.picture_tag(picture_object)).to include(     '<source srcset="https://thumb_url.webp" type="image/webp">'    )    expect(helper.picture_tag(picture_object)).to include(     '<source srcset="https://medium_url.webp" type="image/webp">'    )    expect(helper.picture_tag(picture_object)).to include(     '<source srcset="https://medium_url.jpg" type="image/jpeg">'    )    expect(helper.picture_tag(picture_object)).to include(     '<source srcset="https://other_url.jpg" type="image/jpeg">'    )   end  end  context 'with options' do   context 'with versions option' do    context 'without extensions not flagged' do     it 'accepts symbols' do      expect(       helper.picture_tag(        picture_object, versions: %i[thumb thumb_webp]       )      ).to include('<source srcset="https://thumb_url.jpg" type="image/jpeg">')     end     it 'should have the selected_version with all extensions' do      expect(       helper.picture_tag(        picture_object, versions: [:thumb]       )      ).to include(       '<source srcset="https://thumb_url.webp" type="image/webp">'\       '<source srcset="https://thumb_url.jpg" type="image/jpeg">'      )     end     it 'in the correct order' do      expect(       helper.picture_tag(        picture_object, versions: %i[medium thumb]       )      ).to include(       '<source srcset="https://medium_url.webp" type="image/webp">'\       '<source srcset="https://medium_url.jpg" type="image/jpeg">'\       '<source srcset="https://thumb_url.webp" type="image/webp">'\       '<source srcset="https://thumb_url.jpg" type="image/jpeg">'      )     end     it 'should not have the versions not included' do      expect(       helper.picture_tag(        picture_object, versions: [:thumb]       )      ).not_to include('<source srcset="https://medium_url.jpg" type="image/jpeg">')      expect(       helper.picture_tag(        picture_object, versions: %i[thumb medium]       )      ).not_to include('<source srcset="https://other_url.webp" type="image/jpeg">')     end    end    context 'without extensions flagged' do     it 'accepts symbols' do      expect(       helper.picture_tag(        picture_object, without_extensions: true,        versions: %i[thumb thumb_webp]       )      ).to include('<source srcset="https://thumb_url.jpg" type="image/jpeg">')     end     it 'should have the selected_version' do      expect(       helper.picture_tag(        picture_object, without_extensions: true,        versions: [:thumb]       )      ).to include('<source srcset="https://thumb_url.jpg" type="image/jpeg">')     end     it 'in the correct order' do      expect(       helper.picture_tag(        picture_object, without_extensions: true,        versions: %i[medium thumb]       )      ).to include(       '<source srcset="https://medium_url.jpg" type="image/jpeg">'\       '<source srcset="https://thumb_url.jpg" type="image/jpeg">'      )     end     it 'should not have the versions not included' do      expect(       helper.picture_tag(        picture_object, without_extensions: true,        versions: [:thumb]       )      ).not_to include('<source srcset="https://medium_url.jpg" type="image/jpeg">')      expect(       helper.picture_tag(        picture_object, without_extensions: true,        versions: [:thumb]
)
).not_to include('<source srcset="https://thumb_url.webp" type="image/webp">') expect( helper.picture_tag( picture_object, without_extensions: true, versions: %i[thumb medium] ) ).not_to include('<source srcset="https://other_url.webp" type="image/jpeg">') end end end end endend

The corresponding helper will be:

# frozen_string_literal: truemodule Pictures # These helpers help you build versions of # a picture in order to user the 'picture' tag module PictureTagHelper  # Possible options:  #  - versions (should be a version existing  # in the carrierwave uploader) [Array] of symbols or strings, order is important!  # - without_extensions [Boolean] if true does not add  # webp version (webp version must exist on the uploader),  # other extensions can be easily added  def picture_tag(picture, **options)   image_options = options[:image] || {}   return image_tag(picture.default_url, image_options) unless picture.exists?   picture_options = options[:picture] || {}   tag.picture(picture_options) do    "#{build_source_tags(picture, options).join}"\    " #{image_tag(picture.url, image_options)}".html_safe   end  end  # Possible options:  #  - versions (should be a version existing  # in the carrierwave uploader) [Array] of symbols or strings, order is important!  # - without_extensions [Boolean] if true does not add  # webp version (webp version must exist on the uploader),  # other extensions can be easily added  def source_tags(picture, **options)   build_source_tags(picture, options).join.to_s.html_safe  end  private  def build_source_tags(picture, options)   versions(picture, options).map do |_version_name, version_object|    source_tag(version_object)   end  end  def versions(picture, options)   return picture.versions.slice(*build_extensions(options)) if options[:versions]   picture.versions  end  def source_tag(version)   return '' unless version.file   tag.source srcset: version.url.gsub(/\(|\)/, '_'), type: image_type(version.file.extension)  end  def image_type(extension)   Mime::Type.register 'image/webp', :webp   Mime::Type.lookup_by_extension(extension).to_s  end  def build_extensions(options)   return options[:versions] if options[:without_extensions]   options[:versions].flat_map do |version|    ordered_extentions.map { |extension| "#{version}_#{extension}".to_sym } << version.to_sym   end  end  def ordered_extentions   ['webp']  end endend

Next steps / How to do it better

The next step in this helper would be to implement a media attribute in our source tags. This media attribute would allow serving different versions of an image depending on the size of the screen.

There are, at first glance, two options to pass media breakpoints to our helper:

  1. Have a breakpoints Array in the options Hash
  2. Change the versions key of the options to be a Hash with the version name as the key and the break-point as the value

Conclusion

I am glad to have taken the DIY route on this one; I feel it is worth it since I was able to tailor the result to the exact needs of our application (for now, we do not care about breakpoints, for example).

Suppose you decide to implement this (if you want to improve your lighthouse score, you should). I recommend doing your version instead of copying the code you see above.

If any of you decide to implement it, I would be super curious to see how we differ in the approach, so don’t hesitate to contact me to compare notes :)

--

--

Pablo Curell Mompo

Full Stack developer @ Seraphin, learned how to code @ Le Wagon. I love coding and plan to keep learning as much as I can about it :)