feat: add pwa.cache.*
option to precisely control caching (#1501)
This commit is contained in:
parent
ea3a22e13c
commit
1127c43823
18 changed files with 272 additions and 228 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -18,4 +18,6 @@ package-lock.json
|
|||
.idea
|
||||
|
||||
# Misc
|
||||
*.map
|
||||
sw.min.js
|
||||
assets/js/dist
|
||||
|
|
13
_config.yml
13
_config.yml
|
@ -111,7 +111,14 @@ assets:
|
|||
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 `<SITE_URL>/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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
<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="16x16" href="{{ favicon_path }}/favicon-16x16.png">
|
||||
{% if site.pwa.enabled %}
|
||||
<link rel="manifest" href="{{ favicon_path }}/site.webmanifest">
|
||||
{% endif %}
|
||||
<link rel="shortcut icon" href="{{ favicon_path }}/favicon.ico">
|
||||
<meta name="apple-mobile-web-app-title" content="{{ site.title }}">
|
||||
<meta name="application-name" content="{{ site.title }}">
|
||||
|
|
|
@ -45,6 +45,15 @@
|
|||
|
||||
{{ 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>
|
||||
{%- unless page.layout == 'home' -%}
|
||||
{{ page.title | append: ' | ' }}
|
||||
|
|
|
@ -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 -->
|
||||
|
|
58
_javascript/pwa/app.js
Normal file
58
_javascript/pwa/app.js
Normal 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
101
_javascript/pwa/sw.js
Normal 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;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
|
@ -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
51
assets/js/data/swconf.js
Normal 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 %}
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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 = {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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: '.' })
|
||||
];
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
||||
$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"
|
||||
|
||||
|
|
Loading…
Reference in a new issue