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:
- 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?
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:
- 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.