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.

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:

the site is served over HTTPS

the service_worker.js script is served from the root of your domain (example: https://example.org/service_worker.js )

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

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:

Disk space Browsers manage cached data, thus, some assets might be removed by a browser to free disk space.

Browsers manage cached data, thus, some assets might be removed by a browser to free disk space. Bandwidth usage Smartphone users might be unhappy to see that a site's exhausted their Internet quota.

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.