diff --git a/.gitignore b/.gitignore index cee9e12..3fdfa27 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,6 @@ package-lock.json .idea # Misc +*.map +sw.min.js assets/js/dist diff --git a/_config.yml b/_config.yml index 4ab1726..341e45d 100644 --- a/_config.yml +++ b/_config.yml @@ -63,7 +63,7 @@ google_analytics: # light - Use the light color scheme # dark - Use the dark color scheme # -theme_mode: # [light|dark] +theme_mode: # [light | dark] # The CDN endpoint for images. # Notice that once it is assigned, the CDN url @@ -108,10 +108,17 @@ assets: enabled: # boolean, keep empty means false # specify the Jekyll environment, empty means both # only works if `assets.self_host.enabled` is 'true' - env: # [development|production] + env: # [development | production] pwa: - enabled: true # the option for PWA feature + enabled: true # the option for PWA feature (installable) + cache: + enabled: true # the option for PWA offline cache + # Paths defined here will be excluded from the PWA cache. + # Usually its value is the `baseurl` of another website that + # shares the same domain name as the current website. + deny_paths: + # - "/example" # URLs match `/example/*` will not be cached by the PWA paginate: 10 @@ -157,10 +164,6 @@ defaults: values: layout: page permalink: /:title/ - - scope: - path: assets/img/favicons - values: - swcache: true - scope: path: assets/js/dist values: diff --git a/_data/origin/cors.yml b/_data/origin/cors.yml index 672df08..c2f65e9 100644 --- a/_data/origin/cors.yml +++ b/_data/origin/cors.yml @@ -8,6 +8,8 @@ cdns: - url: https://fonts.googleapis.com # jsDelivr CDN - url: https://cdn.jsdelivr.net + # polyfill.io for math + - url: https://polyfill.io # fonts diff --git a/_includes/favicons.html b/_includes/favicons.html index 201f6d8..957c933 100644 --- a/_includes/favicons.html +++ b/_includes/favicons.html @@ -8,7 +8,9 @@ - +{% if site.pwa.enabled %} + +{% endif %} diff --git a/_includes/head.html b/_includes/head.html index 70a33bd..a6fc255 100644 --- a/_includes/head.html +++ b/_includes/head.html @@ -45,6 +45,15 @@ {{ seo_tags }} + + + {%- unless page.layout == 'home' -%} {{ page.title | append: ' | ' }} diff --git a/_includes/js-selector.html b/_includes/js-selector.html index 6352c96..3515106 100644 --- a/_includes/js-selector.html +++ b/_includes/js-selector.html @@ -11,6 +11,8 @@ <!-- layout specified --> +{% assign js_dist = '/assets/js/dist/' %} + {% if page.layout == 'post' or page.layout == 'page' or page.layout == 'home' %} {% assign urls = urls | append: ',' | append: site.data.origin[type]['lazy-polyfill'].js %} @@ -65,7 +67,7 @@ {% assign js = 'commons' %} {% endcase %} -{% capture script %}/assets/js/dist/{{ js }}.min.js{% endcapture %} +{% capture script %}{{ js_dist }}{{ js }}.min.js{% endcapture %} <script defer src="{{ script | relative_url }}"></script> {% if page.math %} @@ -94,9 +96,7 @@ {% if jekyll.environment == 'production' %} <!-- PWA --> {% if site.pwa.enabled %} - <script defer src="{{ '/app.js' | relative_url }}"></script> - {% else %} - <script defer src="{{ '/unregister.js' | relative_url }}"></script> + <script defer src="{{ 'app.min.js' | prepend: js_dist | relative_url }}"></script> {% endif %} <!-- GA --> diff --git a/_javascript/pwa/app.js b/_javascript/pwa/app.js new file mode 100644 index 0000000..5add2d7 --- /dev/null +++ b/_javascript/pwa/app.js @@ -0,0 +1,58 @@ +/* PWA loader */ + +if ('serviceWorker' in navigator) { + const meta = document.querySelector('meta[name="pwa-cache"]'); + const isEnabled = meta.content === 'true'; + + if (isEnabled) { + let swUrl = '/sw.min.js'; + const baseUrl = meta.getAttribute('data-baseurl'); + + if (baseUrl !== null) { + swUrl = `${baseUrl}${swUrl}?baseurl=${encodeURIComponent(baseUrl)}`; + } + + const $notification = $('#notification'); + const $btnRefresh = $('#notification .toast-body>button'); + + navigator.serviceWorker.register(swUrl).then((registration) => { + // In case the user ignores the notification + if (registration.waiting) { + $notification.toast('show'); + } + + registration.addEventListener('updatefound', () => { + registration.installing.addEventListener('statechange', () => { + if (registration.waiting) { + if (navigator.serviceWorker.controller) { + $notification.toast('show'); + } + } + }); + }); + + $btnRefresh.on('click', () => { + if (registration.waiting) { + registration.waiting.postMessage('SKIP_WAITING'); + } + $notification.toast('hide'); + }); + }); + + let refreshing = false; + + // Detect controller change and refresh all the opened tabs + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!refreshing) { + window.location.reload(); + refreshing = true; + } + }); + } else { + navigator.serviceWorker.getRegistrations().then(function (registrations) { + for (let registration of registrations) { + registration.unregister(); + } + }); + } +} diff --git a/_javascript/pwa/sw.js b/_javascript/pwa/sw.js new file mode 100644 index 0000000..cb2bdef --- /dev/null +++ b/_javascript/pwa/sw.js @@ -0,0 +1,101 @@ +/* PWA service worker */ + +const swconfPath = '/assets/js/data/swconf.js'; +const params = new URL(location).searchParams; +const swconfUrl = params.has('baseurl') + ? `${params.get('baseurl')}${swconfPath}` + : swconfPath; + +importScripts(swconfUrl); +const purge = swconf.purge; + +function verifyHost(url) { + for (const host of swconf.allowHosts) { + const regex = RegExp(`^http(s)?://${host}/`); + if (regex.test(url)) { + return true; + } + } + return false; +} + +function verifyUrl(url) { + if (!verifyHost(url)) { + return false; + } + + const requestPath = new URL(url).pathname; + + for (const path of swconf.denyPaths) { + if (requestPath.startsWith(path)) { + return false; + } + } + return true; +} + +if (!purge) { + swconf.allowHosts.push(location.host); +} + +self.addEventListener('install', (event) => { + if (purge) { + return; + } + + event.waitUntil( + caches.open(swconf.cacheName).then((cache) => { + return cache.addAll(swconf.resources); + }) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keyList) => { + return Promise.all( + keyList.map((key) => { + if (purge) { + return caches.delete(key); + } else { + if (key !== swconf.cacheName) { + return caches.delete(key); + } + } + }) + ); + }) + ); +}); + +self.addEventListener('message', (event) => { + if (event.data === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +self.addEventListener('fetch', (event) => { + event.respondWith( + caches.match(event.request).then((response) => { + if (response) { + return response; + } + + return fetch(event.request).then((response) => { + const url = event.request.url; + + if (purge || event.request.method !== 'GET' || !verifyUrl(url)) { + return response; + } + + // See : <https://developers.google.com/web/fundamentals/primers/service-workers#cache_and_return_requests> + let responseToCache = response.clone(); + + caches.open(swconf.cacheName).then((cache) => { + cache.put(event.request, responseToCache); + }); + return response; + }); + }) + ); +}); diff --git a/assets/js/data/swcache.js b/assets/js/data/swcache.js deleted file mode 100644 index ed5d40e..0000000 --- a/assets/js/data/swcache.js +++ /dev/null @@ -1,49 +0,0 @@ ---- -layout: compress -# The list to be cached by PWA ---- - -const resource = [ - /* --- CSS --- */ - '{{ "/assets/css/:THEME.css" | replace: ':THEME', site.theme | relative_url }}', - - /* --- PWA --- */ - '{{ "/app.js" | relative_url }}', - '{{ "/sw.js" | relative_url }}', - - /* --- HTML --- */ - '{{ "/index.html" | relative_url }}', - '{{ "/404.html" | relative_url }}', - - {% for tab in site.tabs %} - '{{ tab.url | relative_url }}', - {% endfor %} - - /* --- Favicons & compressed JS --- */ - {% assign cache_list = site.static_files | where: 'swcache', true %} - {% for file in cache_list %} - '{{ file.path | relative_url }}'{%- unless forloop.last -%},{%- endunless -%} - {% endfor %} -]; - -/* The request url with below domain will be cached */ -const allowedDomains = [ - {% if site.google_analytics.id != empty and site.google_analytics.id %} - 'www.googletagmanager.com', - 'www.google-analytics.com', - {% endif %} - - '{{ site.url | split: "//" | last }}', - - {% if site.img_cdn contains '//' and site.img_cdn %} - '{{ site.img_cdn | split: '//' | last | split: '/' | first }}', - {% endif %} - - 'fonts.gstatic.com', - 'fonts.googleapis.com', - 'cdn.jsdelivr.net', - 'polyfill.io' -]; - -/* Requests that include the following path will be banned */ -const denyUrls = []; diff --git a/assets/js/data/swconf.js b/assets/js/data/swconf.js new file mode 100644 index 0000000..cc11f79 --- /dev/null +++ b/assets/js/data/swconf.js @@ -0,0 +1,51 @@ +--- +layout: compress +permalink: '/:path/swconf.js' +# Note that this file will be fetched by the ServiceWorker, so it will not be cached. +--- + +const swconf = { + {% if site.pwa.cache.enabled %} + cacheName: 'chirpy-{{ "now" | date: "%s" }}', + + {%- comment -%} Resources added to the cache during PWA installation. {%- endcomment -%} + resources: [ + '{{ "/assets/css/:THEME.css" | replace: ':THEME', site.theme | relative_url }}', + '{{ "/" | relative_url }}', + {% for tab in site.tabs %} + '{{- tab.url | relative_url -}}', + {% endfor %} + + {% assign cache_list = site.static_files | where: 'swcache', true %} + {% for file in cache_list %} + '{{ file.path | relative_url }}'{%- unless forloop.last -%},{%- endunless -%} + {% endfor %} + ], + + {%- comment -%} The request url with below domain will be cached. {%- endcomment -%} + allowHosts: [ + {% if site.img_cdn and site.img_cdn contains '//' %} + '{{ site.img_cdn | split: '//' | last | split: '/' | first }}', + {% endif %} + + {%- unless site.assets.self_host.enabled -%} + {% for cdn in site.data.origin["cors"].cdns %} + '{{ cdn.url | split: "//" | last }}' + {%- unless forloop.last -%},{%- endunless -%} + {% endfor %} + {% endunless %} + ], + + {%- comment -%} The request url with below path will not be cached. {%- endcomment -%} + denyPaths: [ + {% for path in site.pwa.cache.deny_paths %} + {% unless path == empty %} + '{{ path | relative_url }}'{%- unless forloop.last -%},{%- endunless -%} + {% endunless %} + {% endfor %} + ], + purge: false + {% else %} + purge: true + {% endif %} +}; diff --git a/assets/js/pwa/app.js b/assets/js/pwa/app.js deleted file mode 100644 index c798fe2..0000000 --- a/assets/js/pwa/app.js +++ /dev/null @@ -1,47 +0,0 @@ ---- -layout: compress -permalink: '/app.js' ---- - -const $notification = $('#notification'); -const $btnRefresh = $('#notification .toast-body>button'); - -if ('serviceWorker' in navigator) { - /* Registering Service Worker */ - navigator.serviceWorker.register('{{ "/sw.js" | relative_url }}') - .then(registration => { - - /* in case the user ignores the notification */ - if (registration.waiting) { - $notification.toast('show'); - } - - registration.addEventListener('updatefound', () => { - registration.installing.addEventListener('statechange', () => { - if (registration.waiting) { - if (navigator.serviceWorker.controller) { - $notification.toast('show'); - } - } - }); - }); - - $btnRefresh.click(() => { - if (registration.waiting) { - registration.waiting.postMessage('SKIP_WAITING'); - } - $notification.toast('hide'); - }); - }); - - let refreshing = false; - - /* Detect controller change and refresh all the opened tabs */ - navigator.serviceWorker.addEventListener('controllerchange', () => { - if (!refreshing) { - window.location.reload(); - refreshing = true; - } - }); -} - diff --git a/assets/js/pwa/sw.js b/assets/js/pwa/sw.js deleted file mode 100644 index 7e37234..0000000 --- a/assets/js/pwa/sw.js +++ /dev/null @@ -1,89 +0,0 @@ ---- -layout: compress -permalink: '/sw.js' -# PWA service worker ---- - -self.importScripts('{{ "/assets/js/data/swcache.js" | relative_url }}'); - -const cacheName = 'chirpy-{{ "now" | date: "%s" }}'; - -function verifyDomain(url) { - for (const domain of allowedDomains) { - const regex = RegExp(`^http(s)?:\/\/${domain}\/`); - if (regex.test(url)) { - return true; - } - } - - return false; -} - -function isExcluded(url) { - for (const item of denyUrls) { - if (url === item) { - return true; - } - } - return false; -} - -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(cacheName).then((cache) => { - return cache.addAll(resource); - }) - ); -}); - -self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys().then((keyList) => { - return Promise.all( - keyList.map((key) => { - if (key !== cacheName) { - return caches.delete(key); - } - }) - ); - }) - ); -}); - -self.addEventListener('message', (event) => { - if (event.data === 'SKIP_WAITING') { - self.skipWaiting(); - } -}); - -self.addEventListener('fetch', (event) => { - event.respondWith( - caches.match(event.request).then((response) => { - if (response) { - return response; - } - - return fetch(event.request).then((response) => { - const url = event.request.url; - - if ( - event.request.method !== 'GET' || - !verifyDomain(url) || - isExcluded(url) - ) { - return response; - } - - /* see: <https://developers.google.com/web/fundamentals/primers/service-workers#cache_and_return_requests> */ - let responseToCache = response.clone(); - - caches.open(cacheName).then((cache) => { - /* console.log('[sw] Caching new resource: ' + event.request.url); */ - cache.put(event.request, responseToCache); - }); - - return response; - }); - }) - ); -}); diff --git a/assets/js/pwa/unregister.js b/assets/js/pwa/unregister.js deleted file mode 100644 index bd91150..0000000 --- a/assets/js/pwa/unregister.js +++ /dev/null @@ -1,12 +0,0 @@ ---- -layout: compress -permalink: '/unregister.js' ---- - -if ('serviceWorker' in navigator) { - navigator.serviceWorker.getRegistrations().then((registrations) => { - for (let reg of registrations) { - reg.unregister(); - } - }); -} diff --git a/jekyll-theme-chirpy.gemspec b/jekyll-theme-chirpy.gemspec index 2f01881..d1898cd 100644 --- a/jekyll-theme-chirpy.gemspec +++ b/jekyll-theme-chirpy.gemspec @@ -11,7 +11,7 @@ Gem::Specification.new do |spec| spec.license = "MIT" spec.files = `git ls-files -z`.split("\x0").select { |f| - f.match(%r!^((_(includes|layouts|sass|(data\/(locales|origin)))|assets)\/|README|LICENSE)!i) + f.match(%r!^((_(includes|layouts|sass|(data\/(locales|origin)))|assets)\/|sw|README|LICENSE)!i) } spec.metadata = { diff --git a/package.json b/package.json index 334a506..eaa1c55 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ }, "homepage": "https://github.com/cotes2020/jekyll-theme-chirpy/", "scripts": { - "prebuild": "npx rimraf assets/js/dist", + "prebuild": "npx rimraf assets/js/dist sw.min.js*", "build": "NODE_ENV=production npx rollup -c --bundleConfigAsCjs", - "prewatch": "npx rimraf assets/js/dist", + "prewatch": "npx rimraf assets/js/dist sw.min.js*", "watch": "npx rollup -c --bundleConfigAsCjs -w", "test": "npx stylelint _sass/**/*.scss", "fixlint": "npm run test -- --fix" diff --git a/rollup.config.js b/rollup.config.js index 907ca3e..1aee2b8 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -3,21 +3,29 @@ import terser from '@rollup/plugin-terser'; import license from 'rollup-plugin-license'; import path from 'path'; -const JS_SRC = '_javascript'; -const JS_DIST = 'assets/js/dist'; +const SRC_DEFAULT = '_javascript'; +const DIST_DEFAULT = 'assets/js/dist'; const isProd = process.env.NODE_ENV === 'production'; -function build(filename) { +function build(filename, opts) { + let src = SRC_DEFAULT; + let dist = DIST_DEFAULT; + + if (typeof opts !== 'undefined') { + src = opts.src || src; + dist = opts.dist || dist; + } + return { - input: [`${JS_SRC}/${filename}.js`], + input: [`${src}/${filename}.js`], output: { - file: `${JS_DIST}/${filename}.min.js`, + file: `${dist}/${filename}.min.js`, format: 'iife', name: 'Chirpy', sourcemap: !isProd }, watch: { - include: `${JS_SRC}/**` + include: `${src}/**` }, plugins: [ babel({ @@ -28,7 +36,7 @@ function build(filename) { license({ banner: { commentStyle: 'ignored', - content: { file: path.join(__dirname, JS_SRC, '_copyright') } + content: { file: path.join(__dirname, SRC_DEFAULT, '_copyright') } } }), isProd && terser() @@ -42,5 +50,7 @@ export default [ build('categories'), build('page'), build('post'), - build('misc') + build('misc'), + build('app', { src: `${SRC_DEFAULT}/pwa` }), + build('sw', { src: `${SRC_DEFAULT}/pwa`, dist: '.' }) ]; diff --git a/tools/init b/tools/init index 5baac5d..d8c7059 100755 --- a/tools/init +++ b/tools/init @@ -103,7 +103,7 @@ init_files() { npm i && npm run build # track the js output - _sedi "/^assets.*\/dist/d" .gitignore + _sedi "/^assets.*\/dist/d;/^sw.*\.js/d" .gitignore } commit() { diff --git a/tools/release b/tools/release index 6b4cc37..771f697 100755 --- a/tools/release +++ b/tools/release @@ -27,6 +27,7 @@ NODE_CONFIG="package.json" CHANGE_LOG="docs/CHANGELOG.md" JS_DIST="assets/js/dist" +PWA_SW="sw.min.js" BACKUP_PATH="$(mktemp -d)" FILES=( @@ -70,12 +71,14 @@ _check_git() { exit 1 fi - if [[ $working_branch != "$DEFAULT_BRANCH" && - $working_branch != hotfix/* && - $working_branch != "$PROD_BRANCH" ]]; then - echo "> Abort: Please run on the default, release or patch branch." - exit 1 - fi + $opt_pre || ( + if [[ $working_branch != "$DEFAULT_BRANCH" && + $working_branch != hotfix/* && + $working_branch != "$PROD_BRANCH" ]]; then + echo "> Abort: Please run on the default, release or patch branch." + exit 1 + fi + ) } _check_src() { @@ -156,7 +159,7 @@ build_gem() { rm -f ./*.gem npm run build - git add "$JS_DIST" -f # add JS dist to gem + git add "$JS_DIST" "$PWA_SW" -f # add JS distribution files to gem gem build "$GEM_SPEC" cp "$JS_DIST"/* "$BACKUP_PATH"