/ progressive web app

Progressive Web App Wordpress Theme (Part 1/2)

The Story

After I read two articles: Progressive Web Apps Are The Next Big Thing and More Surprising Statistics About WordPress Usage. I decided to build a Wordpress theme which integrated Progressive Web Apps (PWA) to see how cool it is when two "Big Thing" mix together.

TL;DR

To avoid Too Long; Didn't Read. I break the whole procedures into two blogs. In this part 1, I will build a Progressive Web App in local server.

What PWA features to be built here?

According to PWA Kiki, the PWA will be progressive enhancements. The final webapp will be:

  • Responsive design: I choose Bootstrap 4 because it's mobile-first design.
  • Contents retrieve from Wordpress CMS via REST API. It's natural way the app is developed in Javascript.
  • Offline support: The PWA still can run and display the contents when no Internel connection.
  • Installable on Android home screen: I will design the App icon and theme which makes the PWA more native app looks.

Developing steps by steps

Setup the environment using webpack

First of all, my NodeJS and NPM version are:

node -v && npm -v
v8.1.2
5.2.0

Create the project root directory and initialise the project:

mkdir pwa-wordpress-1 && cd pwa-wordpress-1
npm init -y

Thus, it created package.json as below:

{
  "name": "pwa-wordpress-1",
  "version": "1.0.0",
  "description": "Progressive Web App Wordpress theme part 1",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/simonho288/pwa-wordpress-1.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/simonho288/pwa-wordpress-1/issues"
  },
  "homepage": "https://github.com/simonho288/pwa-wordpress-1#readme"
}

Install Webpack:

npm install --save-dev webpack
... after a moment ...
+ webpack@3.4.1
added 363 packages in 41.937s

In the project root, create the directories:

mkdir src && mkdir src/pug && mkdir src/pug/inc && mkdir dist

Install necessary webpack & other packages/plugins:

npm install --save-dev amdefine babel babel-core babel-loader babel-preset-env babel-preset-es2015 caniuse-lite css-loader extract-text-webpack-plugin html-loader locate-path node-sass postcss-loader pug pug-html-loader rimraf sass-loader webpack-dev-server write-file-webpack-plugin

Install Bootstrap 4:

npm install --save bootstrap@4.0.0-alpha.6

It will automatically install jQuery and tether packages. I need to create a webpack config file to expose all packages (webpack.config.dev.js):

var webpack = require('webpack');
var ExtractTextPlugin = require('extract-text-webpack-plugin');
var autoprefixer = require('autoprefixer');
var path = require('path');
var WriteFilePlugin = require('write-file-webpack-plugin');

var extractSCSS = new ExtractTextPlugin('app.bundle.css');
var extractHtml = new ExtractTextPlugin('[name].html');

module.exports = [{
  entry: {
    home: path.resolve(__dirname, './src/pug/home.pug'),
    blogs: path.resolve(__dirname, './src/pug/blogs.pug'),
    pages: path.resolve(__dirname, './src/pug/pages.pug'),
    pageview: path.resolve(__dirname, './src/pug/pageview.pug'),
    postview: path.resolve(__dirname, './src/pug/postview.pug'),
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    // filename: '[name].bundle.js'
    filename: '[name].html'
  },
  module: {
    rules: [
      {
        test: /\.pug$/,
        use: extractHtml.extract(['html-loader', 'pug-html-loader?pretty&exports=false'])
      }
    ]
  },
  plugins: [
    extractHtml
  ]
}, {
  entry: {
    app: path.resolve(__dirname, './src/app.js')
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        include: path.resolve(__dirname, 'src'),
        loader: "babel-loader"
      },
      {
        test: /\.scss$/,
        use: extractSCSS.extract(['css-loader', 'postcss-loader', 'sass-loader'])
      },
      {
        test: /\.pug$/,
        use: extractHtml.extract(['html-loader', 'pug-html-loader?pretty&exports=false'])
      }
    ]
  },
  devServer: {
    hot: true,
    contentBase: path.resolve(__dirname, 'dist'),
    compress: true, // gzipped
    port: 8080,
    // stats: 'errors-only'
  },
  plugins: [
    new webpack.LoaderOptionsPlugin({
      minimize: false,
      debug: true,
      options: {
        postcss: [
          autoprefixer({
            browsers: ['last 2 version', 'Explorer >= 10', 'Android >= 4']
          })
        ]
      }
    }),
    extractSCSS,
    new WriteFilePlugin(),
    new webpack.ProvidePlugin({
      $: 'jquery',
      jQuery: 'jquery',
      Tether: 'tether'
    })
  ]
}]

PostCSS requires a config file. I simply create a /postcss.config.js as follow:

module.exports = {}

In /package.json, I modified some my personal info:

{
  "name": "pwa-wordpress-1",
  "version": "1.0.0",
  "description": "Progressive Web App Wordpress theme demo",
  "main": "index.js",
  "scripts": {
    "dev": "npm run clean && webpack -d --config webpack.config.dev.js",
    "clean": "rimraf ./dist/*"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/simonho288/pwa-wordpress-1.git"
  },
  "keywords": [],
  "author": "Simon Ho",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/simonho288/pwa-wordpress-1/issues"
  },
  "homepage": "https://github.com/simonho288/pwa-wordpress-1#readme",
  "devDependencies": {
    "amdefine": "^1.0.1",
    "babel": "^6.23.0",
    "babel-core": "^6.25.0",
    "babel-loader": "^7.1.1",
    "babel-preset-env": "^1.6.0",
    "babel-preset-es2015": "^6.24.1",
    "caniuse-lite": "^1.0.30000708",
    "css-loader": "^0.28.4",
    "extract-text-webpack-plugin": "^3.0.0",
    "html-loader": "^0.5.0",
    "locate-path": "^2.0.0",
    "node-sass": "^4.5.3",
    "postcss-loader": "^2.0.6",
    "pug": "^2.0.0-rc.2",
    "pug-html-loader": "^1.1.5",
    "rimraf": "^2.6.1",
    "sass-loader": "^6.0.6",
    "webpack": "^3.4.1",
    "webpack-dev-server": "^2.6.1",
    "write-file-webpack-plugin": "^4.1.0"
  },
  "dependencies": {
    "bootstrap": "^4.0.0-alpha.6"
  }
}

Crafting the webpages

I start programming the webpages. I want the final design something like this:
homepage

I am using pug for HTML preprocess. Pug can greatly simplify HTML coding, the markups look more structural. The good thing is ability to import another pug files. The program the home page /src/pug/home.pug content:

extends inc/layout.pug

block content
  -var current_active="home" // navigator needs this var
  include inc/navigator.pug
  div(class="container page-home")
    div.row
      div.col
        h2 Progressive Web App Wordpress Theme #{title}

You can see home.pug extends inc/layout.pug file:

doctype html
html(lang="en")
  head
    meta(charset='utf-8')
    meta(http-equiv='X-UA-Compatible' content='IE=edge')
    meta(name='viewport' content='width=device-width, initial-scale=1, shrink-to-fit=no')
    meta(name='theme-color' content='#800080')
    link(rel='manifest' href='/manifest.json')
    link(rel='shortcut icon' href='/favicon.ico')
    title title
    include styles.pug
  body
    include scripts.pug
    block content

It includes two more pug files, inc/styles.pug:

link(rel='stylesheet', href='app.bundle.css')

and inc/scripts.pug:

noscript You need to enable JavaScript to run this
app.script(src='app.bundle.js')

There has several other pug files (navigator.pug, blogs.pug, pages.pug, pageview.pug, postview.pug). I don't list it all here. You can check it out from my github repo.

Programming the main files

The main logic is covered in app.js, and the style app.scss. (Note: All require preprocessors, Javascript is based on Babel ES6 syntax and the style needs SCSS preprocessor to compile into CSS). The initially src/app.js content:

import css from './app.scss'

// jQuery is the first priority
import jQuery from 'jquery';
global.$ = global.jQuery = jQuery // expose jquery to global

// Then load tether and finally bootstrap
import 'tether'
import 'bootstrap'

Take attention on the import sequence. Otherwise Bootstrap will load failure caused by jQuery not exposed. I took almost a hour to solve :(

After that I create stylesheet src/app.scss:

$brand-primary: purple;

@import '../node_modules/tether/dist/css/tether.css';
@import '../node_modules/bootstrap/scss/bootstrap.scss';

body {
  background-color: #eee;
}

.page-home {
  margin-top: 20px;
}

.page-blogs {
  margin-top: 20px;
}

.postslist-post {
  margin-top: 20px;
  border-top: #ccc solid 1px;
  padding-top: 10px;
}

.page-postview {
  margin-top: 30px;
  margin-bottom: 50px;
  img {
    width: 90%;
  }
  .back-btn {
    margin-top: 10px;
  }
}

.pageslist-page {
  margin-top: 20px;
  border-top: #ccc solid 1px;
  padding-top: 10px;
}

.page-pageview {
  margin-top: 30px;
  margin-bottom: 50px;
  img {
    width: 90%;
  }
  .back-btn {
    margin-top: 10px;
  }
}

The first line I changed the brand color to purple. The webpack SCSS preprocessor produces all bootstrap CSS files then bundles it in a single CSS.

Getting Wordpress posts & pages via REST API

Wordpress starting from version 4.7+ has built-in support REST API v2. I can easily retrieve the posts and pages via JS fetch(). But before working on my JS logic, I test the wordpress with Postman to make sure the REST API is working. If 'Erorr 404: page not found', try changing Wordpress Permalink to 'Post name'. But for me using Nginx server, I need to change the Nginx site file /etc/nginx/sites-available/default:

server {
    ...
    location / {
        # IMPORTANT: Change to below line
        try_files $uri $uri/ /index.php$is_args$args;
    }
    ...

After the REST API proven working (like this) via Postman:

postman-result1

I start programming the 4 JS objects inside app.js. Those 4 objects are to handle blogs list, pages list, view full blog, view full page tasks respectively. Now the src/app.js content becomes:

import css from './app.scss'

// jQuery is the first priority
import jQuery from 'jquery';
global.$ = global.jQuery = jQuery // expose jquery to global

// Then load tether and finally bootstrap
import 'tether'
import 'bootstrap'

const WP_SERVER_URL = 'https://wordpress.simonho.net'

// Object to handle Blogs page
class BlogsListObj {
  constructor() {
  }

  execute() {
    this.fetchPosts((posts) => {
      this.showPosts(posts)
    })
  }

  fetchPosts(callback) {
    const url = WP_SERVER_URL + '/wp-json/wp/v2/posts'
    fetch(url)
      .then(res => { return res.json() })
      .then(data => {
        callback(data)
      })
  }

  showPosts(posts) {
    let markup = ''
    posts.forEach((post, i) => {
      let modified = new Date(post.modified).toDateString();
      markup += '<div class="postslist-post">'
      markup +=   '<h2><a href="/postview.html?id=' + post.id + '">' + post.title.rendered + '</a></h2>'
      markup +=   '<p><small>' + modified + '</small></p>'
      markup +=   '<p>' + post.excerpt.rendered + '</p>'
      markup += '</div>'
    })
    $('#posts').empty().append(markup)
  }
} // BlogsListObj

class PostViewObj {
  constructor() {
  }

  execute() {
    // extract the id from url
    var url = new URL(location.href)
    var id = url.searchParams.get('id')
    this.fetchThePost(id, (post) => {
      this.showThePost(post)
    })
  }

  fetchThePost(id, callback) {
    const url = WP_SERVER_URL + '/wp-json/wp/v2/posts/' + id
    fetch(url)
      .then(res => { return res.json() })
      .then(data => {
        callback(data)
      })
  }

  showThePost(post) {
    let modified = new Date(post.modified).toDateString();
    $('.post-title').html(post.title.rendered)
    $('.date').html(modified)
    $('.post-content').html(post.content.rendered)

    $('.back-btn').click((evt) => {
      window.history.back()
    })
  }
  
} // PostViewObj

class PagesListObj {
  constructor() {
  }

  execute() {
    this.fetchPosts((posts) => {
      this.showPosts(posts)
    })
  }

  fetchPosts(callback) {
    const url = WP_SERVER_URL + '/wp-json/wp/v2/pages'
    fetch(url)
      .then(res => { return res.json() })
      .then(data => {
        callback(data)
      })
  }

  showPosts(pages) {
    let markup = ''
    pages.forEach((page, i) => {
      let modified = new Date(page.modified).toDateString();
      markup += '<div class="pageslist-page">'
      markup +=   '<h2><a href="/pageview.html?id=' + page.id + '">' + page.title.rendered + '</a></h2>'
      markup +=   '<p>' + page.excerpt.rendered + '</p>'
      markup += '</div>'
    })
    $('#pages').empty().append(markup)
  }
} // PagesListObj

class PageViewObj {
  constructor() {
  }

  execute() {
    // extract the id from url
    var url = new URL(location.href)
    var id = url.searchParams.get('id')
    this.fetchThePage(id, (page) => {
      this.showThePage(page)
    })
  }

  fetchThePage(id, callback) {
    const url = WP_SERVER_URL + '/wp-json/wp/v2/pages/' + id
    fetch(url)
      .then(res => { return res.json() })
      .then(data => {
        callback(data)
      })
  }

  showThePage(page) {
    let modified = new Date(page.modified).toDateString();
    $('.page-title').html(page.title.rendered)
    $('.date').html(modified)
    $('.page-content').html(page.content.rendered)

    $('.back-btn').click((evt) => {
      window.history.back()
    })
  }
} // PageViewObj


global.BlogsListObj = BlogsListObj;
global.PostViewObj = PostViewObj;
global.PagesListObj = PagesListObj;
global.PageViewObj = PageViewObj;

All 4 objects calling fetch() to get posts & pages from my demo wordpress server (temporarily live for testing only) via REST API. To make the data more realistic, I use FakerPress Wordpress plugin to create several "loremipsum" posts.

Now the time to test my works. I build the project by running:

npm run dev
...
       [0] ./src/pug/home.pug 41 bytes {3} [built]
       [1] ./src/pug/blogs.pug 41 bytes {4} [built]
       [2] ./src/pug/pages.pug 41 bytes {2} [built]
       [3] ./src/pug/pageview.pug 41 bytes {1} [built]
       [4] ./src/pug/postview.pug 41 bytes {0} [built]
...
       Time: 6240ms
       Asset     Size  Chunks                    Chunk Names
       app.bundle.js  1.21 MB       0  [emitted]  [big]  app
       app.bundle.css   148 kB       0  [emitted]         app
...

Nice, it seems fine. Let start the local server http-server:

cd dist && http-server

Browse http://localhost:8080. The browser can display all pages without error, as below (5 screenshots, 5 seconds per screen):
webpages1

Create PWA assets files

As mentioned in Google Developer PWA tutorial, PWA requires a service worker (mention later in this blog), and manifest.json with favicon.ico and bunch of icons (I skipped in this blog). I create a very simple src/manifest.json as below:

{
  "short_name": "Wordpress PWA",
  "name": "Wordpress Progressive Web App",
  "icons": [
    {
      "src": "favicon.ico",
      "sizes": "192x192",
      "type": "image/png"
    }
  ],
  "start_url": "index.html",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff"
}

To copy those asset files to /dist in build processes. I simply change the scripts in/packag.json. Modify the script "dev" and add a script: "copy-assests", as below:

{
  ...
  "scripts": {
    "dev": "npm run clean && webpack -d --config webpack.config.dev.js && npm run copy-assets",
    "copy-assets": "cp src/manifest.json dist/ && cp src/favicon.ico dist/ && cp src/service-worker.js dist/"
  }
  ...
}

Create a Service Worker and register it

Service Worker is most important element of Progressive Web App. It handles offline caching, background communication to receive push notification. Simply say, it makes PWA more native Apps like.

Thanks to Chrome team, we can easily grab a service worker code, from list of service worker samples. Where the service workers range from basic to sophisticated. (or even we don't need to develop by hand). I select a basic one. Add some modification to handle pre-caching for the assets and runtime for REST API results. My `src/service-worker.js' codes are:

/*
 Copyright 2016 Google Inc. All Rights Reserved.
 ...
*/

// Names of the two caches used in this version of the service worker.
// Change to v2, etc. when you update any of the local resources, which will
// in turn trigger the install event again.
const PRECACHE = 'precache-v1';
const RUNTIME = 'runtime';

// A list of local resources we always want to be cached.
const PRECACHE_URLS = [
  '/', // Alias for index.php
  '/app.bundle.js',
  '/app.bundle.css',
  '/home.html',
  '/blogs.html',
  '/pages.html',
  '/pageview.html',
  '/postview.html'
];

// The install handler takes care of precaching the resources we always need.
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(PRECACHE)
      .then(cache => cache.addAll(PRECACHE_URLS))
      .then(self.skipWaiting())
  );
});

// The activate handler takes care of cleaning up old caches.
self.addEventListener('activate', event => {
  const currentCaches = [PRECACHE, RUNTIME];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return cacheNames.filter(cacheName => !currentCaches.includes(cacheName));
    }).then(cachesToDelete => {
      return Promise.all(cachesToDelete.map(cacheToDelete => {
        return caches.delete(cacheToDelete);
      }));
    }).then(() => self.clients.claim())
  );
});

// The fetch handler serves responses for same-origin resources from a cache.
// If no response is found, it populates the runtime cache with the response
// from the network before returning it to the page.
self.addEventListener('fetch', event => {
  // Skip cross-origin requests, like those for Google Analytics.
  if (event.request.url.startsWith(self.location.origin)) {
    event.respondWith(
      caches.match(event.request).then(cachedResponse => {
        if (cachedResponse) {
          return cachedResponse;
        }

        return caches.open(RUNTIME).then(cache => {
          return fetch(event.request).then(response => {
            // Put a copy of the response in the runtime cache.
            return cache.put(event.request, response.clone()).then(() => {
              return response;
            });
          });
        });
      })
    );
  }
});

PWA also needs to register the service worker. My registration logic is programmed insrc/registerServiceWorker.js:

/**
 * This module is used by app.js. See exported functions for it purposes.
 */

export function checkHTTPS() {
  // Service workers require HTTPS (http://goo.gl/lq4gCo). If we're running on a real web server
  // (as opposed to localhost on a custom port, which is whitelisted), then change the protocol to HTTPS.
  if ((!location.port || location.port == "80") && location.protocol != 'https:') {
    location.protocol = 'https:';
  }
}

export function registerServiceWorker() {
  if ('serviceWorker' in navigator) {
    // Override the default scope of '/' with './', so that the registration applies
    // to the current directory and everything underneath it.
    navigator.serviceWorker.register('/service-worker.js', {scope: './'}).then(function(registration) {
      // At this point, registration has taken place.
      // The service worker will not handle requests until this page and any
      // other instances of this page (in other tabs, etc.) have been
      // closed/reloaded.
      console.log('service worker registered successfully')
    }).catch(function(error) {
      // Something went wrong during registration. The service-worker.js file
      // might be unavailable or contain a syntax error.
      console.error('service worder registration failed')
      console.error(error);
    });
  } else {
    console.warn('Current browser doesn\'t support service workers.');
    console.log('more info: http://www.chromium.org/blink/serviceworker/service-worker-faq');
  }
}

export function GoogleAnalytics() {
  /* jshint ignore:start */
  (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
    (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
    m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
  })(window,document,'script','//www.google-analytics.com/analytics.js','ga');
  ga('create', '[TODO IN FUTURE]', 'auto');
  ga('send', 'pageview');
  /* jshint ignore:end */  
}

To trigger the function registerServiceWorker(), I add below codes into the src/app.js:

...
import {checkHTTPS, registerServiceWorker, GoogleAnalytics} from './registerServiceWorker.js'

// checkHTTPS() // enable this when running HTTPS
registerServiceWorker()
// GoogleAnalytics() // enable this if needed
...

Now I can test the service worker. Run the debug build: npm run dev. Then all the generated files store in /dist directory:

ll dist
total 2784
-rw-r--r--  1 simonho  staff   144K 29 Jul 22:35 app.bundle.css
-rw-r--r--  1 simonho  staff   1.2M 29 Jul 22:35 app.bundle.js
-rw-r--r--  1 simonho  staff   1.2K 29 Jul 22:35 blogs.html
-rw-r--r--  1 simonho  staff    24K 29 Jul 22:35 favicon.ico
-rw-r--r--  1 simonho  staff   1.1K 29 Jul 22:35 home.html
-rw-r--r--  1 simonho  staff   297B 29 Jul 22:35 manifest.json
-rw-r--r--  1 simonho  staff   1.2K 29 Jul 22:35 pages.html
-rw-r--r--  1 simonho  staff   1.5K 29 Jul 22:35 pageview.html
-rw-r--r--  1 simonho  staff   1.5K 29 Jul 22:35 postview.html
-rw-r--r--  1 simonho  staff   2.6K 29 Jul 22:35 service-worker.js

All necessary files are here, I run local web server as: cd dist && http-server. Open Chrome, open developer tools, and select Application tab. The screens showing (3 screenshots, 10 seconds each):
chrome-screens

As you can see, the Chrome shows the service worker is activated, cache storage is working. Even the webpages can be browsed when the web server is stopped.

To be continued...

This is the end of part 1. Part 2 will integrate it to build a WordPress theme. Finally test the PWA in mobile phone.

(Please click here go to part 2).

The source codes of this Part 1 keep in this Github repo.

Enjoy!