Browse Source

Ask if user wants to download latest version of block on lunch of the devTool.

wordpress-build
Roman Axelrod 2 years ago
parent
commit
256450226b
  1. 37
      helpers.js
  2. 137
      layouts/scripts/dist/frame-index.min.js
  3. 9928
      layouts/scripts/dist/index.min.js
  4. 21
      layouts/scripts/dist/sync.min.js
  5. 100
      layouts/scripts/sync.jsx
  6. 18
      layouts/sync.hbs
  7. 111
      package-lock.json
  8. 10
      package.json
  9. 22
      rollup.config.js
  10. 103
      server.js

37
helpers.js

@ -265,3 +265,40 @@ export function replaceNames(content, images, uploadedImages) {
return content;
}
export async function getBlockFromCloud(blockName, blocksRegistry) {
const queryString = new URLSearchParams();
queryString.append('blockName', blockName);
queryString.append('includeDevConfig', 'true');
const response = await fetch(`${blocksRegistry}?${queryString.toString()}`);
const responseData = await response.json();
if (!responseData || !responseData.name) {
const message = "⚠️ Block not found, please contact administrator."
throw new Error(message);
}
if (responseData.statusCode && responseData.statusCode !== 200) {
const message = ["⚠️ [ERROR]", responseData.message || "Server side error."].join(' ');
throw new Error(message);
}
return responseData;
}
export async function verifyVersion(projectPath, blocksRegistry) {
const blockJson = await readJSONFile(path.join(projectPath, `block.json`));
const blockName = getBlockName(blockJson.name);
if (typeof blockJson.version === 'undefined' || !blockName.name) {
return true;
}
/*
* This block is managed on cloud.
* Let's detect the latest version.
*/
const block = await getBlockFromCloud('@' + blockJson.name, blocksRegistry);
return block.version === blockJson.version;
}

137
layouts/scripts/dist/frame-index.min.js

@ -1,136 +1 @@
window.initBlock = initBlock;
let template;
let data = {};
let reload;
// Blocks Initialization.
function initBlock(blockName = '', selector = '', cb) {
reload = function () {
document.querySelectorAll(selector).forEach((el) => cb(el));
};
reload();
}
// Data Updates Listeners.
(function () {
loadDataOptions();
listenToDataOptionsUpdates();
function listenToDataOptionsUpdates() {
window.addEventListener('message', function (event) {
const message = event.data;
const prefix = 'dataUpdate:';
if (typeof message !== "string" || !message.startsWith(prefix)) {
return;
}
try {
data = JSON.parse(message.substring(prefix.length));
updateBlock({data});
} catch (e) {
console.log('Error parsing incoming data.', e);
}
});
}
function getQueryParams() {
const urlParams = new URLSearchParams(window.location.search);
const params = {};
for (const [key, value] of urlParams) {
params[key] = value;
}
return params;
}
function loadDataOptions() {
const queryParameters = new URLSearchParams({name: getQueryParams().data || 'default'});
fetch(`/data?${queryParameters}`)
.then((response) => response.json())
.then((response) => {
data = response.data; // Update state.
updateBlock({data});
});
}
})();
// Listen to Template updates.
(function () {
initSocket();
function initSocket() {
const socket = window.io.connect();
socket.on('error', function (err) {
console.log(err);
});
// socket.on('connect', function () {
// console.log('user connected', socket.id)
// });
socket.on('templateUpdate', function (args) {
updateBlock({template: args.template});
});
}
})();
function updateBlock(args = {}) {
if (args.template) {
template = args.template; // Update state.
}
if (args.data) {
data = args.data; // Update state.
}
if (!template) {
return;
}
renderBlock(template, data || {}, document.getElementById("hbs-container"));
}
function renderBlock(templateHbs, jsonData, target) {
const template = Handlebars.compile(templateHbs);
/**
* Handlebars Helpers
*/
Handlebars.registerHelper('esc_attr', function (attr) {
return attr;
});
Handlebars.registerHelper('esc_url', function (attr) {
return attr;
});
Handlebars.registerHelper('esc_html', function (attr) {
return attr;
});
Handlebars.registerHelper('base_url', function () {
return '/';
});
let html;
try {
html = template(jsonData);
} catch (e) {
html = `<div style="max-width: 1280px; margin: 1rem auto;">
<h1 style="all: unset; font-size: 1.5rem; font-weight: bold; display: block;">Syntax Error:</h1>
<pre style="all: unset; padding: 10px 15px; background-color: #ffe6e6; border: 1px solid red; border-radius: 0.25rem; overflow: auto; display: block; white-space: pre;">${e.toString()}</pre>
</div>`;
}
target.innerHTML = html;
if (reload) {
reload();
}
}
//# sourceMappingURL=frame-index.min.js.map
let e;window.initBlock=function(e="",n="",r){t=function(){document.querySelectorAll(n).forEach((e=>r(e)))},t()};let t,n={};function r(r={}){r.template&&(e=r.template),r.data&&(n=r.data),e&&function(e,n,r){const a=Handlebars.compile(e);let o;Handlebars.registerHelper("esc_attr",(function(e){return e})),Handlebars.registerHelper("esc_url",(function(e){return e})),Handlebars.registerHelper("esc_html",(function(e){return e})),Handlebars.registerHelper("base_url",(function(){return"/"}));try{o=a(n)}catch(e){o=`<div style="max-width: 1280px; margin: 1rem auto;">\n <h1 style="all: unset; font-size: 1.5rem; font-weight: bold; display: block;">Syntax Error:</h1>\n <pre style="all: unset; padding: 10px 15px; background-color: #ffe6e6; border: 1px solid red; border-radius: 0.25rem; overflow: auto; display: block; white-space: pre;">${e.toString()}</pre>\n </div>`}r.innerHTML=o,t&&t()}(e,n||{},document.getElementById("hbs-container"))}!function(){function e(){const e=new URLSearchParams(window.location.search),t={};for(const[n,r]of e)t[n]=r;return t}!function(){const t=new URLSearchParams({name:e().data||"default"});fetch(`/data?${t}`).then((e=>e.json())).then((e=>{n=e.data,r({data:n})}))}(),window.addEventListener("message",(function(e){const t=e.data,a="dataUpdate:";if("string"==typeof t&&t.startsWith(a))try{n=JSON.parse(t.substring(a.length)),r({data:n})}catch(e){console.log("Error parsing incoming data.",e)}}))}(),function(){const e=window.io.connect();e.on("error",(function(e){console.log(e)})),e.on("templateUpdate",(function(e){r({template:e.template})}))}();

9928
layouts/scripts/dist/index.min.js

File diff suppressed because one or more lines are too long

21
layouts/scripts/dist/sync.min.js

File diff suppressed because one or more lines are too long

100
layouts/scripts/sync.jsx

@ -0,0 +1,100 @@
'use strict';
import React, {useState} from "react";
import * as ReactDOM from "react-dom/client";
function init() {
const wrapper = document.querySelector('#screen');
const root = ReactDOM.createRoot(wrapper);
const html = (<SyncScreen/>);
root.render(html);
}
function SyncScreen() {
const [loading, setLoading] = useState(null);
const [error, setError] = useState(null);
return <>
<section className="container py-5">
<h1 style={{marginBottom: '2rem'}}>Oops... Block not in sync.</h1>
{error && <p className="alert alert-danger">{error}</p>}
{loading ?
<>
{loading === 'syncing' &&
<p>Version upgrade in progress...</p>
}
<p>Please wait...</p>
</>
:
<>
<p>Your version of the block is not in sync with the cloud (not latest version).<br/>
Would you like to update it?</p>
<div className="options">
<button className="btn btn-primary" style={{marginRight: '0.5rem'}} onClick={runSyncLogic}>Yes, Update to
Latest
</button>
<button className="btn btn-secondary" onClick={ignoreVersionSync}>Ignore</button>
</div>
</>
}
</section>
</>
async function ignoreVersionSync() {
setLoading(true);
let data = {};
try {
const response = await fetch('/sync', {
method: 'POST',
body: JSON.stringify({ignore: true}),
headers: {
'Content-Type': 'application/json'
}
});
data = await response.json();
} catch (err) {
setError('Error: ' + err.message);
setLoading(false);
return;
}
if (data.status !== 200) {
setError(data.message);
setLoading(false);
return;
}
setTimeout(() => window.location.reload(), 1000);
}
async function runSyncLogic() {
setLoading('syncing');
let data = {};
try {
const response = await fetch('/sync', {method: 'POST'});
data = await response.json();
} catch (err) {
setError('Error: ' + err.message);
setLoading(false);
return;
}
if (data.status !== 200) {
setError(data.message);
setLoading(false);
return;
}
setTimeout(() => window.location.reload(), 1000);
}
}
init();

18
layouts/sync.hbs

@ -0,0 +1,18 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Block Development Tool</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
</head>
<body>
<div id="screen">Loading...</div>
<script src="/socket.io/socket.io.js"></script>
<script src="/scripts/dist/sync.min.js"></script>
</body>
</html>

111
package-lock.json

@ -9,8 +9,10 @@
"version": "1.0.28",
"license": "ISC",
"dependencies": {
"@axe-web/create-block": "^1.1.30",
"@braintree/sanitize-url": "^6.0.0",
"archiver": "^5.3.1",
"body-parser": "^1.20.2",
"browser-sync": "^2.27.9",
"config": "^3.3.7",
"escape-html": "^1.0.3",
@ -75,6 +77,33 @@
"node": ">=6.0.0"
}
},
"node_modules/@axe-web/create-block": {
"version": "1.1.30",
"resolved": "https://registry.npmjs.org/@axe-web/create-block/-/create-block-1.1.30.tgz",
"integrity": "sha512-jTlr2b1ZBo3ArFshbTlK+ohed3cqWPfwCTAd58nTnWVTlIdPx14Eu+QvbPPqrdTWPw0gLuYUEmdd5OaE95GT1A==",
"dependencies": {
"commander": "^9.4.1",
"dotenv": "^16.0.3",
"mem-fs": "^2.2.1",
"mem-fs-editor": "^9.5.0",
"node-fetch": "^3.2.10",
"node-stream-zip": "^1.15.0"
},
"bin": {
"create-block": "create-block.js"
},
"engines": {
"node": ">=14.17.3"
}
},
"node_modules/@axe-web/create-block/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/@babel/code-frame": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
@ -1638,12 +1667,12 @@
}
},
"node_modules/body-parser": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
"integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
"integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.4",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
@ -1651,7 +1680,7 @@
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.11.0",
"raw-body": "2.5.1",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
@ -2449,9 +2478,9 @@
}
},
"node_modules/content-type": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
"integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==",
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
@ -2917,6 +2946,17 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
"integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/motdotla/dotenv?sponsor=1"
}
},
"node_modules/duplexify": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
@ -3506,6 +3546,29 @@
"node": ">=10"
}
},
"node_modules/express/node_modules/body-parser": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
"integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.4",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.11.0",
"raw-body": "2.5.1",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -3561,6 +3624,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/express/node_modules/raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
"integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/express/node_modules/send": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
@ -7193,6 +7270,18 @@
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==",
"peer": true
},
"node_modules/node-stream-zip": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz",
"integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==",
"engines": {
"node": ">=0.12.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/antelle"
}
},
"node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@ -8010,9 +8099,9 @@
}
},
"node_modules/raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
"integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",

10
package.json

@ -7,10 +7,10 @@
"url": "https://axe-web.com/"
},
"scripts": {
"info": "NODE_ENV=development BLOCK_NAME=swiper-test MODULE_PATH= node debug.js",
"dev": "NODE_ENV=development BLOCK_NAME=swiper-test MODULE_PATH= node server.js",
"build-platform": "NODE_ENV=development BLOCK_NAME=swiper-test MODULE_PATH= node ./build.js",
"dev-dev-tool": "NODE_ENV=development rollup --config rollup.config.js --watch",
"info": "BLOCK_NAME=timeline-scroll MODULE_PATH= node debug.js",
"dev": "BLOCK_NAME=timeline-scroll MODULE_PATH= node server.js",
"build-platform": "BLOCK_NAME=timeline-scroll MODULE_PATH= node ./build.js",
"dev-dev-tool": "rollup --config rollup.config.js --watch",
"build-dev-tool": "rollup --config rollup.config.js"
},
"engines": {
@ -19,8 +19,10 @@
"license": "ISC",
"type": "module",
"dependencies": {
"@axe-web/create-block": "^1.1.30",
"@braintree/sanitize-url": "^6.0.0",
"archiver": "^5.3.1",
"body-parser": "^1.20.2",
"browser-sync": "^2.27.9",
"config": "^3.3.7",
"escape-html": "^1.0.3",

22
rollup.config.js

@ -47,4 +47,26 @@ export default [{
commonjs(),
!devMode && terser()
]
}, {
input: 'layouts/scripts/sync.jsx',
output: {
file: 'layouts/scripts/dist/sync.min.js',
sourcemap: devMode
},
plugins: [
nodeResolve({
extensions: [".js"],
}),
replace({
'process.env.NODE_ENV': JSON.stringify('production'),
preventAssignment: true,
}),
babel({
compact: false,
babelHelpers: 'bundled',
presets: ["@babel/preset-react"],
}),
commonjs(),
!devMode && terser()
]
}];

103
server.js

@ -31,12 +31,15 @@ import {
removeCommentsFromJs,
replaceNames,
uploadFile,
verifyVersion,
zipProject
} from "./helpers.js";
import PluginError from 'plugin-error';
import {Server} from "socket.io";
import {createServer} from 'http';
import {authtoken, connect} from "ngrok";
import {exec} from "child_process";
import bodyParser from "body-parser";
/**
* Constants
@ -57,12 +60,21 @@ let port = 3000; // This variable is used in `*.hbs` and it will be updated once
let previewFrameUrl = `/`; // This variable is used in `*.hbs` and it will be updated once BrowserSync is ready.
let shareUrl = '';
const sessions = [];
let ignoreVersionSync = false;
let bs;
/**
* Init server
*/
const app = express();
// parse application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({extended: false}))
// parse application/json
app.use(bodyParser.json())
const httpServer = createServer(app);
initSessionsServer(httpServer);
@ -91,7 +103,7 @@ app.set('views', path.join(modulesPath, 'layouts'));
// Routes
//
app.get('/', (req, res) => {
app.get('/', async (req, res, next) => {
const data = getBlockConfigs({modulesPath, dataFiles});
if (data.error && data.errorMessage) {
return res.send(data.errorMessage);
@ -104,17 +116,29 @@ app.get('/', (req, res) => {
include_partial: (filesPath) => handlebarLayoutsPath(modulesPath, filesPath),
}
try {
const verifiedVersion = await verifyVersion(projectPath, blocksRegistry);
if (!verifiedVersion && !ignoreVersionSync) {
return res.render('sync', data);
}
} catch (err) {
const errorMessage = "Can't verify block version.";
console.log(errorMessage, err);
return next(new Error(errorMessage));
}
data.baseView = baseView;
data.port = `/${baseViewUrl}`;
data.previewFrameUrl = `${previewFrameUrl}/${baseViewUrl}`;
data.shareUrl = shareUrl;
if (req.headers.referer) {
// NGROK, public URL
data.shareUrl = undefined; // Link already shared.
data.previewFrameUrl = `/${baseViewUrl}`;
data.publicUrl = true;
}
// TODO: Need to review this logic, conflicts with the browsersync work after "/sync" action.
// if (req.headers.referer) {
// // NGROK, public URL
// data.shareUrl = undefined; // Link already shared.
// data.previewFrameUrl = `/${baseViewUrl}`;
// data.publicUrl = true;
// }
res.render('index', data);
});
@ -131,23 +155,6 @@ app.get('/view/:baseView', (req, res) => {
res.render(baseView, data)
});
function getDataOfFrame(isIframe = false) {
const data = {config: getBlockConfigs({modulesPath, dataFiles})};
if (data.error && data.errorMessage) {
return data;
}
data.helpers = {
include_partial: (filesPath) => handlebarLayoutsPath(modulesPath, filesPath),
}
if (isIframe) {
data.iframeMode = true;
}
return data;
}
app.get('/publish', async (req, res) => {
const data = await readJSONFile(path.join(projectPath, `block.json`));
@ -276,6 +283,34 @@ app.get('/data', async (req, res) => {
});
});
app.post('/sync', async (req, res) => {
const blockJson = await readJSONFile(path.join(projectPath, `block.json`));
const blockName = blockJson.name.startsWith('@') ? blockJson.name : `@${blockJson.name}`;
if (req.body['ignore']) {
ignoreVersionSync = true;
res.json({status: 200, message: 'Version upgrade is ignored.'});
return;
}
bs.pause();
// Looks like it takes time to pause the browser-sync server, so delay(setTimeout) is necessary.
setTimeout(() => {
exec(`node ./node_modules/@axe-web/create-block/create-block.js sync --source ${blockName}`, (err, stdout, stderr) => {
if (err || stderr) {
console.error('Error:', err || stderr);
res.status(500).json({status: 500, message: err || stderr});
}
console.log(stdout);
bs.resume();
res.json({status: 200, message: 'Successfully synced!'});
});
})
});
// Errors handler
app.use(handleSyntaxErrors);
@ -320,6 +355,23 @@ function getListOfDesignPreviewFiles(jsonDataFileName, previewFiles) {
});
}
function getDataOfFrame(isIframe = false) {
const data = {config: getBlockConfigs({modulesPath, dataFiles})};
if (data.error && data.errorMessage) {
return data;
}
data.helpers = {
include_partial: (filesPath) => handlebarLayoutsPath(modulesPath, filesPath),
}
if (isIframe) {
data.iframeMode = true;
}
return data;
}
function startBrowserSync() {
return new Promise((resolve, reject) => {
const listener = httpServer.listen(0, async () => {
@ -327,7 +379,8 @@ function startBrowserSync() {
console.log(`The web server has started on port ${PORT}`);
const bs = browserSync.create();
// BS is global variable.
bs = browserSync.create();
const files = getJSBundleFiles();
gulp.watch(files, {delay: 400}, gulp.series(['build-script-files', function (cb) {

Loading…
Cancel
Save