feat: add pwa.cache.* option to precisely control caching (#1501)

This commit is contained in:
Cotes Chung 2024-01-28 02:22:33 +08:00 committed by GitHub
parent ea3a22e13c
commit 1127c43823
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 272 additions and 228 deletions

2
.gitignore vendored
View file

@ -18,4 +18,6 @@ package-lock.json
.idea .idea
# Misc # Misc
*.map
sw.min.js
assets/js/dist assets/js/dist

View file

@ -63,7 +63,7 @@ google_analytics:
# light - Use the light color scheme # light - Use the light color scheme
# dark - Use the dark color scheme # dark - Use the dark color scheme
# #
theme_mode: # [light|dark] theme_mode: # [light | dark]
# The CDN endpoint for images. # The CDN endpoint for images.
# Notice that once it is assigned, the CDN url # Notice that once it is assigned, the CDN url
@ -108,10 +108,17 @@ assets:
enabled: # boolean, keep empty means false enabled: # boolean, keep empty means false
# specify the Jekyll environment, empty means both # specify the Jekyll environment, empty means both
# only works if `assets.self_host.enabled` is 'true' # only works if `assets.self_host.enabled` is 'true'
env: # [development|production] env: # [development | production]
pwa: 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 `<SITE_URL>/example/*` will not be cached by the PWA
paginate: 10 paginate: 10
@ -157,10 +164,6 @@ defaults:
values: values:
layout: page layout: page
permalink: /:title/ permalink: /:title/
- scope:
path: assets/img/favicons
values:
swcache: true
- scope: - scope:
path: assets/js/dist path: assets/js/dist
values: values:

View file

@ -8,6 +8,8 @@ cdns:
- url: https://fonts.googleapis.com - url: https://fonts.googleapis.com
# jsDelivr CDN # jsDelivr CDN
- url: https://cdn.jsdelivr.net - url: https://cdn.jsdelivr.net
# polyfill.io for math
- url: https://polyfill.io
# fonts # fonts

View file

@ -8,7 +8,9 @@
<link rel="apple-touch-icon" sizes="180x180" href="{{ favicon_path }}/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="{{ favicon_path }}/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ favicon_path }}/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="{{ favicon_path }}/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{ favicon_path }}/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="{{ favicon_path }}/favicon-16x16.png">
<link rel="manifest" href="{{ favicon_path }}/site.webmanifest"> {% if site.pwa.enabled %}
<link rel="manifest" href="{{ favicon_path }}/site.webmanifest">
{% endif %}
<link rel="shortcut icon" href="{{ favicon_path }}/favicon.ico"> <link rel="shortcut icon" href="{{ favicon_path }}/favicon.ico">
<meta name="apple-mobile-web-app-title" content="{{ site.title }}"> <meta name="apple-mobile-web-app-title" content="{{ site.title }}">
<meta name="application-name" content="{{ site.title }}"> <meta name="application-name" content="{{ site.title }}">

View file

@ -45,6 +45,15 @@
{{ seo_tags }} {{ seo_tags }}
<!-- PWA cache settings -->
<meta
name="pwa-cache"
content="{{ site.pwa.cache.enabled | default: 'false' }}"
{%- if site.baseurl and site.baseurl != empty -%}
data-baseurl="{{ site.baseurl }}"
{%- endif -%}
>
<title> <title>
{%- unless page.layout == 'home' -%} {%- unless page.layout == 'home' -%}
{{ page.title | append: ' | ' }} {{ page.title | append: ' | ' }}

View file

@ -11,6 +11,8 @@
<!-- layout specified --> <!-- layout specified -->
{% assign js_dist = '/assets/js/dist/' %}
{% if page.layout == 'post' or page.layout == 'page' or page.layout == 'home' %} {% if page.layout == 'post' or page.layout == 'page' or page.layout == 'home' %}
{% assign urls = urls | append: ',' | append: site.data.origin[type]['lazy-polyfill'].js %} {% assign urls = urls | append: ',' | append: site.data.origin[type]['lazy-polyfill'].js %}
@ -65,7 +67,7 @@
{% assign js = 'commons' %} {% assign js = 'commons' %}
{% endcase %} {% 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> <script defer src="{{ script | relative_url }}"></script>
{% if page.math %} {% if page.math %}
@ -94,9 +96,7 @@
{% if jekyll.environment == 'production' %} {% if jekyll.environment == 'production' %}
<!-- PWA --> <!-- PWA -->
{% if site.pwa.enabled %} {% if site.pwa.enabled %}
<script defer src="{{ '/app.js' | relative_url }}"></script> <script defer src="{{ 'app.min.js' | prepend: js_dist | relative_url }}"></script>
{% else %}
<script defer src="{{ '/unregister.js' | relative_url }}"></script>
{% endif %} {% endif %}
<!-- GA --> <!-- GA -->

58
_javascript/pwa/app.js Normal file
View file

@ -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();
}
});
}
}

101
_javascript/pwa/sw.js Normal file
View file

@ -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;
});
})
);
});

View file

@ -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 = [];

51
assets/js/data/swconf.js Normal file
View file

@ -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 %}
};

View file

@ -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;
}
});
}

View file

@ -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;
});
})
);
});

View file

@ -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();
}
});
}

View file

@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
spec.license = "MIT" spec.license = "MIT"
spec.files = `git ls-files -z`.split("\x0").select { |f| 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 = { spec.metadata = {

View file

@ -13,9 +13,9 @@
}, },
"homepage": "https://github.com/cotes2020/jekyll-theme-chirpy/", "homepage": "https://github.com/cotes2020/jekyll-theme-chirpy/",
"scripts": { "scripts": {
"prebuild": "npx rimraf assets/js/dist", "prebuild": "npx rimraf assets/js/dist sw.min.js*",
"build": "NODE_ENV=production npx rollup -c --bundleConfigAsCjs", "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", "watch": "npx rollup -c --bundleConfigAsCjs -w",
"test": "npx stylelint _sass/**/*.scss", "test": "npx stylelint _sass/**/*.scss",
"fixlint": "npm run test -- --fix" "fixlint": "npm run test -- --fix"

View file

@ -3,21 +3,29 @@ import terser from '@rollup/plugin-terser';
import license from 'rollup-plugin-license'; import license from 'rollup-plugin-license';
import path from 'path'; import path from 'path';
const JS_SRC = '_javascript'; const SRC_DEFAULT = '_javascript';
const JS_DIST = 'assets/js/dist'; const DIST_DEFAULT = 'assets/js/dist';
const isProd = process.env.NODE_ENV === 'production'; 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 { return {
input: [`${JS_SRC}/${filename}.js`], input: [`${src}/${filename}.js`],
output: { output: {
file: `${JS_DIST}/${filename}.min.js`, file: `${dist}/${filename}.min.js`,
format: 'iife', format: 'iife',
name: 'Chirpy', name: 'Chirpy',
sourcemap: !isProd sourcemap: !isProd
}, },
watch: { watch: {
include: `${JS_SRC}/**` include: `${src}/**`
}, },
plugins: [ plugins: [
babel({ babel({
@ -28,7 +36,7 @@ function build(filename) {
license({ license({
banner: { banner: {
commentStyle: 'ignored', commentStyle: 'ignored',
content: { file: path.join(__dirname, JS_SRC, '_copyright') } content: { file: path.join(__dirname, SRC_DEFAULT, '_copyright') }
} }
}), }),
isProd && terser() isProd && terser()
@ -42,5 +50,7 @@ export default [
build('categories'), build('categories'),
build('page'), build('page'),
build('post'), build('post'),
build('misc') build('misc'),
build('app', { src: `${SRC_DEFAULT}/pwa` }),
build('sw', { src: `${SRC_DEFAULT}/pwa`, dist: '.' })
]; ];

View file

@ -103,7 +103,7 @@ init_files() {
npm i && npm run build npm i && npm run build
# track the js output # track the js output
_sedi "/^assets.*\/dist/d" .gitignore _sedi "/^assets.*\/dist/d;/^sw.*\.js/d" .gitignore
} }
commit() { commit() {

View file

@ -27,6 +27,7 @@ NODE_CONFIG="package.json"
CHANGE_LOG="docs/CHANGELOG.md" CHANGE_LOG="docs/CHANGELOG.md"
JS_DIST="assets/js/dist" JS_DIST="assets/js/dist"
PWA_SW="sw.min.js"
BACKUP_PATH="$(mktemp -d)" BACKUP_PATH="$(mktemp -d)"
FILES=( FILES=(
@ -70,12 +71,14 @@ _check_git() {
exit 1 exit 1
fi fi
$opt_pre || (
if [[ $working_branch != "$DEFAULT_BRANCH" && if [[ $working_branch != "$DEFAULT_BRANCH" &&
$working_branch != hotfix/* && $working_branch != hotfix/* &&
$working_branch != "$PROD_BRANCH" ]]; then $working_branch != "$PROD_BRANCH" ]]; then
echo "> Abort: Please run on the default, release or patch branch." echo "> Abort: Please run on the default, release or patch branch."
exit 1 exit 1
fi fi
)
} }
_check_src() { _check_src() {
@ -156,7 +159,7 @@ build_gem() {
rm -f ./*.gem rm -f ./*.gem
npm run build 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" gem build "$GEM_SPEC"
cp "$JS_DIST"/* "$BACKUP_PATH" cp "$JS_DIST"/* "$BACKUP_PATH"