gulp-shopify-theme
Version:
Gulp plugin for Shopify theme development
352 lines (318 loc) • 11.5 kB
JavaScript
const gutil = require('gulp-util');
const PluginError = gutil.PluginError;
const through = require('through2');
const async = require('async');
const path = require('path');
const EventEmitter = require('events');
const Shopify = require('shopify-api-node');
const PLUGIN_NAME = 'gulp-shopify-theme';
const PLUGIN_NAME_COLOR = gutil.colors.inverse(' ', PLUGIN_NAME, ' ');
const PLUGIN_NAME_ERROR_COLOR = gutil.colors.white.bgRed(' ', PLUGIN_NAME, ' ');
const basedirs = ['layout', 'templates', 'snippets', 'assets', 'config', 'locales', 'sections'];
const basedirsRegExp = new RegExp('(' + basedirs.join('|') + ').+', 'i');
const protectedKeys = [
'config/settings_data.json',
'config/settings_schema.json',
'layout/theme.liquid',
'templates/cart.liquid',
'templates/blog.liquid',
'templates/index.liquid',
'templates/gift_card.liquid',
'templates/collection.liquid',
'templates/product.liquid',
'templates/page.liquid',
];
var shopifyThemeInstances = [];
function makeShopifyTheme (options) {
var shopifyTheme;
var previous = shopifyThemeInstances.filter((instance)=>{
return options.shop_name === instance._shopName && options.api_key === instance._apiKey;
});
if (previous.length) return previous[0];
shopifyTheme = new ShopifyTheme( options );
shopifyThemeInstances.push(shopifyTheme);
return shopifyTheme;
}
/*
var theme = shopifytheme.create();
gulp.src().pipe(theme.stream());
theme.init(options);
*/
/*
Asset API
---------
shopify.asset.create(themeId, params)
shopify.asset.delete(themeId, params)
shopify.asset.get(themeId, params)
shopify.asset.list(themeId[, params])
shopify.asset.update(themeId, params)
Possible error codes
--------------------
statusCode:
401 - API Request is not valid for this shop. You are either not using the right Access Token or the permission for that token has been revoked
404 - The resource does not exist
406 - Invalid request
422 - There was a problem with the body of your Request. Inspect the response body for the errors
429 - You've gone through your API Limit.
500 - various
code:
ECONNRESET
ETIMEDOUT
ENOTFOUND
EADDRINFO
*/
module.exports = {
create: create,
};
function create (options) {
return makeShopifyTheme(options);
}
class ShopifyTheme extends EventEmitter {
constructor(options) {
super();
this.makeConfig(options);
}
init (options) {
this.makeConfig(options, true);
}
makeConfig (options, isInit) {
options = options || {};
this._options = options;
this._taskQueue = [];
this._shopName = options.shop_name || options.shopName;
this._apiKey = options.api_key || options.apiKey;
this._themeId = options.theme_id || options.shopName;
this._password = options.password;
this._autoLimit = options.autoLimit || false;
this._timeout = options.timeout;
this._root = options.root;
if (isInit && (!options.shop_name || !options.api_key)) {
throw new Error('Missing configuration');
} else if (options.shop_name && options.api_key) {
this.api = new Shopify({
shopName: this._shopName,
apiKey: this._apiKey,
password: this._password,
autoLimit: this._autoLimit,
timeout: this._timeout,
});
this._initialised = true;
}
}
_makeAssetKey (file) {
var themeRoot = this._root ? path.posix.join(this._root, '/') : null;
var fullpath = file.path ? file.path : file.history.slice(-1).pop();
var basepath = fullpath.split(themeRoot).pop().match(basedirsRegExp);
if (!basepath) {
gutil.log('Invalid resource path');
throw new Error('Invalid resource path');
}
basepath = path.posix.join(basepath[0]);
return encodeURI(basepath);
}
_handleTaskError (error, file) {
if (!error) return;
var errorCode = error.code || error.statusCode;
switch (errorCode) {
// Retry
case 429 :
case 'ETIMEDOUT':
case 'ECONNRESET':
gutil.log(PLUGIN_NAME_COLOR, 'retry: ' + gutil.colors.yellow(this._makeAssetKey(file)));
this._addTask(file);
break;
// Unprocessable entity
case 422 :
gutil.log(PLUGIN_NAME_ERROR_COLOR, 'syntax error or wrong path at ' + gutil.colors.red(this._makeAssetKey(file)));
break;
// Invalid request
case 406 :
case 403 :
gutil.log(PLUGIN_NAME_ERROR_COLOR, 'invalid request at ' + gutil.colors.red(this._makeAssetKey(file)))
break;
// API Request is not valid for this shop
case 401 :
gutil.log(PLUGIN_NAME_ERROR_COLOR, 'invalid shop. Check your config.')
break;
default:
}
}
/*
Returns a wrapper around _runAssetTask()
used to call async.whilst() next() callback
*/
_makeConsumer (file) {
var _this = this;
return function (next) {
return _this._runAssetTask(file)
.then(function (res) {
gutil.log(PLUGIN_NAME_COLOR, file.action + ':', gutil.colors.green(_this._makeAssetKey(file)));
_this._taskQueue.shift();
file.done();
next();
return res;
})
.catch(function (error) {
_this._taskQueue.shift();
file.done(new PluginError(PLUGIN_NAME, error));
_this._handleTaskError (error, file);
next(error);
return error;
})
};
}
/*
Runs Shopify API calls from `file`.
The callback(err) is called by the Promise returned by the API
A file is a Vinyl object or a hash.
*/
_runAssetTask (file) {
var params = {};
var verb;
switch (file.action) {
case 'deleted':
params.asset = {key: this._makeAssetKey(file)};
verb = 'delete'; break;
case 'added':
params.key = this._makeAssetKey(file);
params.attachment = file.contents.toString('base64');
verb = 'create'; break;
case 'changed':
params.key = this._makeAssetKey(file);
params.attachment = file.contents.toString('base64');
verb = 'update'; break;
default:
params.key = this._makeAssetKey(file);
params.attachment = file.contents.toString('base64');
file.action = 'added';
verb = 'create';
}
return this.api.asset[verb](this._themeId, params);
}
/*
Continously take tasks from the _taskQueue list,
A task is a hash of:
- key: 'templates/index.liquid'
- consumer: fn(callback)
*/
_queue () {
if (this._queueRunning) return;
this._queueRunning = true;
var _this = this;
var taskPromisers = [];
async.whilst(
function condition () {
return _this._taskQueue.length !== 0;
},
function iterator (next) {
var task = _this._taskQueue[0];
taskPromisers.push(task.consumer(next));
},
/*
end() is reached *before* all files have finished uploading.
Promise.all() will complete once all tasks are finished or any error out.
*/
function end (error) {
_this._queueRunning = false;
if (error) {
_this._taskQueue = [];
return;
}
// if (!taskPromisers.length) return;
Promise.all(taskPromisers)
.then((responses)=>{
_this.emit('done', responses);
gutil.log(PLUGIN_NAME_COLOR, gutil.colors.bold('Done'));
})
.catch((error)=>{
_this.emit('error', error);
gutil.log( new PluginError(PLUGIN_NAME, error, {showStack: true}) );
})
}
);
}
_addTask (file) {
var key = this._makeAssetKey(file);
gutil.log(PLUGIN_NAME_COLOR, 'queued: ' + gutil.colors.yellow(key));
for (var index = 0; index < this._taskQueue.length; index++) {
if (this._taskQueue[index].key === key) {
gutil.log(PLUGIN_NAME_COLOR, 'requeued: ' + gutil.colors.yellow(key));
this._taskQueue.splice(index, 1);
}
}
this._taskQueue.push({
key: key,
consumer: this._makeConsumer(file)
});
this._queue();
}
purge (options) {
if (!this._initialised) return this._passthrough();
this._themeId = (options) ? options.theme_id : this._themeId;
if (!this._themeId) {
throw new Error('Missing {theme_id: "xxxx"}');
}
if (this._taskQueue.length === 0) {
setTimeout(this._queue.bind(this), 0);
}
var _this = this;
this.api.asset.list(this._themeId)
.then(function (list) {
list.forEach(function (asset) {
if (protectedKeys.indexOf(asset.key) >= 0) return;
_this._addTask({
path: asset.key,
action: 'deleted',
done: function () {},
});
});
})
.catch(function (err) {
gutil.log( new PluginError(PLUGIN_NAME, err) );
});
}
_passthrough () {
return through.obj(function (file, enc, cb) {
this.push();
cb();
});
}
stream (options) {
var _this = this;
// Stream right through if we are not initialised.
if (!this._initialised) return this._passthrough();
// Theme ID is required for /assets operations
this._themeId = (options && options.themeId) ? options.themeId : this._themeId;
if (!this._themeId) {
throw new Error('Missing {theme_id: "xxxx"}');
}
// Return a Transform stream
return through.obj(function(file, encoding, callback) {
// Move the file down the chain right away
this.push(file);
if (options && options.batchMode) {
file.done = function () {};
callback();
} else {
file.done = function (err) {
callback(err);
}
}
if (file.path && file.path.match(/\s+/)) {
let err = new PluginError(PLUGIN_NAME, 'Shopify filenames cannot contain spaces: ' + gutil.colors.green(file.path));
this.push(file);
return callback(err);
}
if (file.isBuffer()) {
_this._addTask(file);
} else if (file.isNull()) {
file.action = 'deleted';
_this._addTask(file);
} else if (file.isStream()) {
process.nextTick(function () {
callback(new PluginError('Streams are not supported'));
});
}
});
}
}