Adding PWA to your Hugo static site is quite easy.
What is PWA and why should I add it?
Progressive web applications (PWA) are a nice way to give your websites an “app makeover”. Using PWAs your sites are perceived to load faster, are available offline, and in general improve user experience.
PWAs are supported by all web browsers. Using PWAs on single page applications have shown remarkable improvement in site speed and have provided us the ability to continuing to serve static content even when the connectivity is down.
Back to Hugo - although I can see incremental gains, nothing jumped out to make up for a compelling case for PWA. The sites generated by Hugo are static - pages are already generated and waiting to be loaded. Depending on the theme, you may already be seeing the best load times that your site can achieve. So, YMMV.
How do I add PWA to Hugo?
At a minimum a PWA needs a manifest and service worker. We will use a offline-first cache strategy to enable PWA.
Create your icons
PWAs are available offline and can be installed on the phone. This requires icons of different sizes for your website. Just pluck your logo, remove the text if necessary and generate icons of all popular sizes. You can use a service like Favicomatic to make that a one-click process.
Create manifest
Create a new file called manifest.json
in your /static
folder and add content similar to below
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
{
"name": "Techformist",
"short_name": "techformist",
"icons": [
{
"src": "/appicons/favicon-128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/appicons/apple-touch-icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/appicons/apple-touch-icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/appicons/favicon-196x196.png",
"sizes": "196x196",
"type": "image/png"
},
{
"src": "/appicons/splash.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#FFFFFF",
"theme_color": "#FFFFFF"
}
|
Create service worker
Create a new javascript file sw.js
in /static
. This script does all the heavy lifting like maintaining cache and enabling your site to be treated like an app.
I just copied the code from the sample service worker script available at [https://github.com/wildhaber/offline-first-sw].
Modify the file and choose to make some or all files available offline - I typically include CSS, common Javascript files and images. See the end of this article for the file used by this blog.
Include manifest in head
Include a link to your manifest in your header.
I use Minimo theme and included the following line in <root>/layouts/partials/head/extra.html
. Note that I am not over-writing the file in the theme folder, but overriding it using my own version (as is typical to Hugo themes).
1
|
<link rel="manifest" href="/manifest.json">
|
Include the service worker JS file reference in your footer. For some strange reason, I chose to be different and included that in my sidebar.html
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
<script>
if('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js', { scope: '/' })
.then(function(registration) {
//console.log('Service Worker Registered');
});
navigator.serviceWorker
.ready
.then(function(registration) {
//console.log('Service Worker Ready');
});
}
</script>
|
See the Results
That is it. Your site automagically is a PWA now.
Check out Chrome developer tools -
A site or an app?
Go to Application
tab.
You should see details provided in your manifest including the different icons. You will also see the different offline files you specified in the Cache Storage
section.
Lighthouse scores
Go to Audit
tab and do a Lighthouse audit for your site.
Mobile Lighthouse score for home page -
Lighthouse score for an article -
Rejoice.
Conclusion
Adding and setting up PWA to Hugo is trivial. But, I did not clearly make out any significant advantages of enabling PWA on my blog - given that I am not after providing a read-offline feature.
Post Script
sw.js code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
|
const CACHE_VERSION = 1;
const BASE_CACHE_FILES = [
'/css/custom.css',
'/js/custom.js',
'/search/index.json',
'/manifest.json',
'/favicon.png',
'/images/logo.png',
'/techformist-logo-no-text.png',
'/assets/css/main.6a060eb7.css',
'/assets/js/main.67d669ac.js',
'/assets/js/sidebar.9ea42a6e.js',
'/assets/js/fuse_search.1ada4bca.js',
];
const OFFLINE_CACHE_FILES = [
'/images/logo.png',
'/techformist-logo-no-text.png',
'/assets/css/main.6a060eb7.css',
'/assets/js/main.67d669ac.js',
'/assets/js/sidebar.9ea42a6e.js',
'/assets/js/fuse_search.1ada4bca.js',
];
const NOT_FOUND_CACHE_FILES = [
'/404.html',
];
const OFFLINE_PAGE = '/offline/index.html';
const NOT_FOUND_PAGE = '/404.html';
const CACHE_VERSIONS = {
assets: 'assets-v' + CACHE_VERSION,
content: 'content-v' + CACHE_VERSION,
offline: 'offline-v' + CACHE_VERSION,
notFound: '404-v' + CACHE_VERSION,
};
// Define MAX_TTL's in SECONDS for specific file extensions
const MAX_TTL = {
'/': 3600,
html: 3600,
json: 86400,
js: 86400,
css: 86400,
};
const CACHE_BLACKLIST = [
(str) => {
return !str.startsWith('http://localhost') ;
},
];
const SUPPORTED_METHODS = [
'GET',
];
/**
* isBlackListed
* @param {string} url
* @returns {boolean}
*/
function isBlacklisted(url) {
return (CACHE_BLACKLIST.length > 0) ? !CACHE_BLACKLIST.filter((rule) => {
if(typeof rule === 'function') {
return !rule(url);
} else {
return false;
}
}).length : false
}
/**
* getFileExtension
* @param {string} url
* @returns {string}
*/
function getFileExtension(url) {
let extension = url.split('.').reverse()[0].split('?')[0];
return (extension.endsWith('/')) ? '/' : extension;
}
/**
* getTTL
* @param {string} url
*/
function getTTL(url) {
if (typeof url === 'string') {
let extension = getFileExtension(url);
if (typeof MAX_TTL[extension] === 'number') {
return MAX_TTL[extension];
} else {
return null;
}
} else {
return null;
}
}
/**
* installServiceWorker
* @returns {Promise}
*/
function installServiceWorker() {
return Promise.all(
[
caches.open(CACHE_VERSIONS.assets)
.then(
(cache) => {
return cache.addAll(BASE_CACHE_FILES);
}
),
caches.open(CACHE_VERSIONS.offline)
.then(
(cache) => {
return cache.addAll(OFFLINE_CACHE_FILES);
}
),
caches.open(CACHE_VERSIONS.notFound)
.then(
(cache) => {
return cache.addAll(NOT_FOUND_CACHE_FILES);
}
)
]
);
}
/**
* cleanupLegacyCache
* @returns {Promise}
*/
function cleanupLegacyCache() {
let currentCaches = Object.keys(CACHE_VERSIONS)
.map(
(key) => {
return CACHE_VERSIONS[key];
}
);
return new Promise(
(resolve, reject) => {
caches.keys()
.then(
(keys) => {
return legacyKeys = keys.filter(
(key) => {
return !~currentCaches.indexOf(key);
}
);
}
)
.then(
(legacy) => {
if (legacy.length) {
Promise.all(
legacy.map(
(legacyKey) => {
return caches.delete(legacyKey)
}
)
)
.then(
() => {
resolve()
}
)
.catch(
(err) => {
reject(err);
}
);
} else {
resolve();
}
}
)
.catch(
() => {
reject();
}
);
}
);
}
self.addEventListener(
'install', event => {
event.waitUntil(installServiceWorker());
}
);
// The activate handler takes care of cleaning up old caches.
self.addEventListener(
'activate', event => {
event.waitUntil(
Promise.all(
[
cleanupLegacyCache(),
]
)
.catch(
(err) => {
event.skipWaiting();
}
)
);
}
);
self.addEventListener(
'fetch', event => {
event.respondWith(
caches.open(CACHE_VERSIONS.content)
.then(
(cache) => {
return cache.match(event.request)
.then(
(response) => {
if (response) {
let headers = response.headers.entries();
let date = null;
for (let pair of headers) {
if (pair[0] === 'date') {
date = new Date(pair[1]);
}
}
if (date) {
let age = parseInt((new Date().getTime() - date.getTime()) / 1000);
let ttl = getTTL(event.request.url);
if (ttl && age > ttl) {
return new Promise(
(resolve) => {
return fetch(event.request)
.then(
(updatedResponse) => {
if (updatedResponse) {
cache.put(event.request, updatedResponse.clone());
resolve(updatedResponse);
} else {
resolve(response)
}
}
)
.catch(
() => {
resolve(response);
}
);
}
)
.catch(
(err) => {
return response;
}
);
} else {
return response;
}
} else {
return response;
}
} else {
return null;
}
}
)
.then(
(response) => {
if (response) {
return response;
} else {
return fetch(event.request)
.then(
(response) => {
if(response.status < 400) {
if (~SUPPORTED_METHODS.indexOf(event.request.method) && !isBlacklisted(event.request.url)) {
cache.put(event.request, response.clone());
}
return response;
}
else {
return caches.open(CACHE_VERSIONS.notFound).then((cache) => {
return cache.match(NOT_FOUND_PAGE);
})
}
}
)
.then((response) => {
if(response) {
return response;
}
})
.catch(
() => {
return caches.open(CACHE_VERSIONS.offline)
.then(
(offlineCache) => {
return offlineCache.match(OFFLINE_PAGE)
}
)
}
)
}
}
)
.catch(
(error) => {
console.error(' Error in fetch handler:', error);
throw error;
}
);
}
)
);
}
);
|