@bva/gulp-shopify-upload
Version:
A tool to watch and upload files to Shopify for use in theme editing
353 lines (313 loc) • 9.8 kB
JavaScript
;
var path = require("path");
var through = require("through2");
var inquirer = require("inquirer");
var ShopifyApi = require("shopify-api");
var isBinaryFile = require("isbinaryfile");
var PluginError = require("plugin-error");
var chalk = require("chalk");
var log = require("fancy-log");
var Bottleneck = require('bottleneck');
var shopify = {};
var shopifyAPI;
var PLUGIN_NAME = "gulp-shopify-upload-with-callbacks";
// Set up shopify API information
shopify._api = false;
shopify._basePath = false;
/*
* Get the Shopify API instance.
*
* @return {ShopifyApi}
*/
shopify._getApi = function(password, host) {
if (!shopify._api) {
var opts = {
auth: ":" + password,
host: host,
port: "443",
timeout: 120000
};
shopify._api = new ShopifyApi(opts);
}
return shopify._api;
};
/*
* Convert a file path on the local file system to an asset path in shopify
* as you may run gulp at a higher directory locally.
*
* The original path to a file may be something like shop/assets/site.css
* whereas we require assets/site.css in the API. To customize the base
* set shopify.options.base config option.
*
* @param {string}
* @return {string}
*/
shopify._makeAssetKey = function(filepath, base) {
filepath = shopify._makePathRelative(filepath, base);
var queryParamIndex = filepath.lastIndexOf("?");
filepath = queryParamIndex > -1 ? filepath.slice(0, queryParamIndex) : filepath;
return encodeURI(filepath);
};
/*
* Get the base path.
*
* @return {string}
*/
shopify._getBasePath = function(filebase) {
if (!shopify._basePath) {
var base = filebase;
shopify._basePath = base.length > 0 ? path.resolve(base) : process.cwd();
}
return shopify._basePath;
};
shopify._getPrettyPath = function(file, base) {
return shopify._getBasePath(base) + file.relative;
};
/**
* Sets the base path
*
* @param {string} basePath
* @return {void}
*/
shopify._setBasePath = function(basePath) {
shopify._basePath = basePath;
};
/**
* Make a path relative to base path.
*
* @param {string} filepath
* @return {string}
*/
shopify._makePathRelative = function(filepath, base) {
var basePath = shopify._getBasePath(base);
filepath = path.relative(basePath, filepath);
return filepath.replace(/\\/g, "/");
};
/**
* Applies options to plugin
*
* @param {object} options
* @return {void}
*/
shopify._setOptions = function(options) {
if (!options) {
return;
}
if (options.hasOwnProperty("basePath")) {
shopify._setBasePath(options.basePath);
}
};
/*
* Upload a given file path to Shopify
*
* Assets need to be in a suitable directory.
* - Liquid templates => 'templates/'
* - Liquid layouts => 'layout/'
* - Liquid snippets => 'snippets/'
* - Theme settings => 'config/'
* - General assets => 'assets/'
* - Language files => 'locales/'
*
* Some requests may fail if those folders are ignored
* @param {filepath} string - filepath
* @param {file} string - file name
* @param {host} string- Shopify URL
* @param {base} sting - options.basePath
* @param {themeid} string - Shopify theme
*/
shopify.upload = function(filepath, file, host, base, themeid, callback) {
var api = shopifyAPI;
var themeid = themeid;
var key = shopify._makeAssetKey(filepath, base);
var isBinary = isBinaryFile(filepath);
var contents = file.contents;
var props = {
asset: {
key: key
}
};
if (isBinary) {
props.asset.attachment = contents.toString("base64");
} else {
props.asset.value = contents.toString();
}
var prettyPath = shopify._getPrettyPath(file, base);
log(chalk.gray.dim("Uploading: " + prettyPath));
function onUpdate(err, resp) {
if (!err) {
log(chalk.green("Upload Complete: " + file.relative));
} else {
var errorMessage =
err.type === "ShopifyInvalidRequestError"
? err.detail.asset.join(", ") + " in " + file.relative
: "Shopify API response error: " + err.type;
log(chalk.red("Error: " + errorMessage));
}
callback();
}
if (themeid) {
api.asset.update(themeid, props, onUpdate);
} else {
api.assetLegacy.update(props, onUpdate);
}
};
/*
* Remove a given file path from Shopify.
*
* File should be the relative path on the local filesystem.
*
* @param {filepath} string - filepath
* @param {file} string - file name
* @param {host} string- Shopify URL
* @param {base} sting - options.basePath
* @param {themeid} string - Shopify theme
*/
shopify.destroy = function(filepath, file, host, base, themeid, callback) {
var api = shopifyAPI;
var themeid = themeid;
var key = shopify._makeAssetKey(filepath, base);
var prettyPath = shopify._getPrettyPath(file, base);
log(chalk.red.dim("Removing file: " + prettyPath));
function onDestroy(err, resp) {
if (!err) {
log(chalk.green("File removed: " + prettyPath));
} else {
var errorMessage =
err.type === "ShopifyInvalidRequestError"
? err.detail.asset.join(", ") + " in " + file.relative
: "Shopify API response error: " + err.type;
log(chalk.red("Error: " + errorMessage));
}
callback();
}
if (themeid) {
api.asset.destroy(themeid, key, onDestroy);
} else {
api.assetLegacy.destroy(key, onDestroy);
}
};
var getRateLimiter = function(rateLimitIncreaseAmount) {
return new Bottleneck({
reservoir: 40,
reservoirIncreaseAmount: rateLimitIncreaseAmount,
reservoirIncreaseInterval: 1000,
reservoirIncreaseMaximum: 40,
maxConcurrent: 10,
minTime: 250,
});
};
/*
* Public function for process deployment queue for new files added via the stream.
* The queue is processed based on Shopify's leaky bucket algorithm that allows
* for infrequent bursts calls with a bucket size of 40. This regenerates overtime,
* but offers an unlimited leak rate of 2 calls per second. Use this variable to
* keep track of api call rate to calculate deployment.
* https://docs.shopify.com/api/introduction/api-call-limit
*
* @param {password} string - Shopify developer api key password
* @param {host} string - hostname provided from gulp file
* @param {themeid} string - unique id upload to the Shopify theme
* @param {options} object - named array of custom overrides.
*/
function gulpShopifyUpload(password, host, themeid, options) {
// Set up the API
shopify._setOptions(options);
shopifyAPI = shopify._getApi(password, host);
var backdoorKeyword = "BACKDOOR";
if (typeof password === "undefined") {
throw new PluginError(PLUGIN_NAME, "Error, password for shopify does not exist!");
}
if (typeof host === "undefined") {
throw new PluginError(PLUGIN_NAME, "Error, host for shopify does not exist!");
}
if (isNaN(parseInt(themeid)) && themeid !== backdoorKeyword) {
throw new PluginError(PLUGIN_NAME, "Error, not a valid theme id!");
}
var connectedToLogMessage = function(host, themeid, name) {
return (
chalk.gray("Connected to: ") +
chalk.magenta(`${host}/${chalk.gray(`?preview_theme_id=`)}${themeid}`) +
chalk.gray(" theme name: ") +
chalk.magenta(name)
);
};
var rateLimiter = getRateLimiter(options.rateLimitIncreaseAmount);
shopifyAPI.theme.list(function(err, obj) {
if (err || !obj.themes) {
log(chalk.red(err));
return;
} else {
if (themeid === backdoorKeyword) {
// Secret backdoor to upload to any theme on the fly
var themes = obj.themes.map(function(theme) {
var themeName = theme.id + " - " + theme.name;
return theme.role.length <= 0
? themeName
: themeName + " (" + theme.role + ")";
});
inquirer.prompt(
[
{
type: "list",
name: "theme",
message: "Which theme would you like to use?",
choices: themes,
filter: function(val) {
return {
id: val.match(/(\d+) - (.*)/)[1],
name: val.match(/(\d+) - (.*)/)[2]
};
}
}
],
function(answers) {
themeid = answers.theme.id;
if (/(production|staging)/.test(answers.theme.name.toLowerCase())) {
log(chalk.red("DIRECTLY UPLOADING TO A CLIENT FACING ENVIRONMENT -- CAREFUL!"));
}
log(connectedToLogMessage(host, themeid, answers.theme.name));
}
);
} else {
var matchingTheme = obj.themes.find(function(theme) {
return theme.id == themeid;
});
if (matchingTheme) {
log(connectedToLogMessage(host, themeid, matchingTheme.name));
} else {
throw new PluginError(PLUGIN_NAME, "please make sure you're using a valid theme id");
}
}
}
});
// creating a stream through which each file will pass
var stream = through.obj(function(file, enc, callback) {
if (file.isStream()) {
this.emit(
"error",
new PluginError(PLUGIN_NAME, "Streams are not supported!")
);
return callback();
}
var self = this;
if (themeid == null) {
callback(null, file);
} else {
if (file.isBuffer()) {
rateLimiter.submit(shopify.upload, file.path, file, host, '', themeid, function() {
callback(null, file);
});
}
// file is null if it was just deleted, so destroy it on Shopify
if (file.isNull()) {
shopify.destroy(file.path, file, host, "", themeid, function() {
callback(null, file);
});
}
}
});
// returning the file stream
return stream;
}
// exporting the plugin main function
module.exports = gulpShopifyUpload;