Offline Middleman blog

Posted: Jan 27, 2018

There are lots of sites with different content. In most cases, similar information a user looks for can be found on a few resources. Thus, if a site is slow or it doesn't respond, the user will choose another one. To win this competition, your site should be fast enough to keep the user's attention.

Last years, frontend developers have been talking about Progressive Web Apps. One feature of PWA is offline accessibility. This feature can be easily added to a blog built via Middleman. If you want to see the result, turn on an offline mode in Chrome DevTools (F12 -> Network tab -> Offline checkbox) and navigate to the other pages on this blog, it is already capable to work offline.

You might think: "Hey, where is correlation between the fast site and the offline capability?". Nowadays, most people have a smartphone, network connection supplied by mobile providers isn't stable. There are cases when it isn't their fault, for example, you are in an elevator where the connection is lacking. Considering that fact, it would be smart to support users in such cases.

Solution

The offline solution is based on Service Workers. Although, there are users who won't benefit from this feature, fortunately, that number decreases.

The idea is fairly simple. A user opens your blog, a service worker stores all pages and assets to them in a cache. When the user is offline, pages and assets get served from the cache.

The first step is to gather paths to assets. Below you will find helpers which return paths to JS, CSS and images.

# helpers/assets_helpers.rb

module AssetsHelpers
  def js_paths
    all_entries_in config.js_dir
  end

  def css_paths
    # CSS files might have extensions like `scss`
    # or `erb`, so additional processing is required
    # to get the `css` extension
    all_entries_in(config.css_dir, '[^_]*.*') do |path|
      # cut extra extension
      path.gsub!(%r{.[^.]+\z}, '')
    end
  end

  def image_paths
    all_entries_in config.images_dir, '**/*.*'
  end

  protected

  def all_entries_in(dir, pattern = '*.*', &block)
    # kind of a null object
    block = -> (path) { path } unless block

    Dir.chdir("source/#{dir}") do
      Dir.glob(pattern).map do |file_path|
        path = "/#{dir}/#{file_path}"
        block.call(path)
      end
    end
  end
end

My js and css directories don't contain nested directories, so I only look for files in the roots. Although, the images directory has nested directories, therefore, the pattern for recursive reading is applied.

source
├── images
│   ├── articles
│   │   ├── prerender-fallback
│   │   │   └── page-served-by-service-worker.png
│   │   └── prerendering-pages
│   │       ├── sessions-graph.png
│   │       ├── start-exit-graph.png
│   │       └── start-exit-graph-with-session.png
│   └── icons-s48f2bd4bb9.png
├── javascripts
│   ├── application.js
│   └── sidebar.js
├── stylesheets
│   ├── application.css.scss
│   ├── code_highlight.css.erb
│   └── _variables.scss

Then we need to create the service worker.

// source/service_worker.js.erb

var cacheName = <% if build? %>
  'cached-assets-<%= Time.now.to_i %>';
<% else %>
  'cached-assets';
<% end %>

var urls = ['/'];

<% js_paths.each do |js| %>
  urls.push('<%= js %>');
<% end %>

<% css_paths.each do |css| %>
  urls.push('<%= css %>');
<% end %>

<% image_paths.each do |image| %>
  urls.push('<%= image %>');
<% end %>

<% blog.articles.each do |article| %>
  urls.push('<%= article.url %>');
<% end %>

self.addEventListener('install', function(event) {
  // cache assets during installation
  var prom = caches.open(cacheName)
                   .then(function(cache) {
                      return cache.addAll(urls);
                    })
                    .then(function() {
                      // kick out any previous version of the worker
                      return self.skipWaiting();
                    });

  event.waitUntil(prom);
});

self.addEventListener('activate', function(event) {
  // delete old assets during activation
  caches.keys()
        .then(function(keys) {
          keys.forEach(function(key) {
            if (cacheName !== key)
              caches.delete(key);
          });
        });

  // take immediate control over pages
  event.waitUntil(self.clients.claim());
});

self.addEventListener('fetch', function(event) {
  var request = event.request;

  // try to fetch whatever the user requests,
  // in case of failure, try to fetch it from the cache
  var prom = fetch(request)
             .catch(function() {
                return caches.match(
                  request,
                  { cacheName: cacheName }
                );
              });

  event.respondWith(prom);
});

After each deploy, assets get cached under a different key, previously cached assets get removed. It is required to avoid serving stale content in the offline mode.

The last step is to register the service worker:

  <script>
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/service_worker.js');
    }
  </script>
</body>
</html>

To make this solution work, your site must meet following criteria:

After visiting your blog, users can continue browsing it on a plane or in a middle of forest, isn't that cool?

Drawbacks

This solution works fine for small blogs, but it mightn't work for blogs with a large number of pages and assets. There are a few reasons:

To get rid of these drawbacks, analytics should be applied. For example, if a user visits a site to read an article about Ruby, it makes sense to cache pages which are related to the current article and some other articles about Ruby (probably, 10 most popular). Eventually, only some pages of the blog will work offline. I guess it is a fair trade-off.