eexit/ghost-storage-cloudinary

View on GitHub
plugins/retinajs.js

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
'use strict';

require('bluebird');

const _ = require('lodash'),
    debug = require('@tryghost/debug')('plugins:retinajs'),
    maxSupportedDpr = 5,
    sizeOf = require('image-size');

class RetinaJS {

    constructor(uploader, uploaderOptions, retinajsOptions) {
        this.uploader = uploader;
        this.uploaderOptions = uploaderOptions || {};
        this.retinajsOptions = retinajsOptions || {};
        this.retinajsOptions.baseWidth = parseInt(this.retinajsOptions.baseWidth, 10);
        this.retinajsOptions.fireForget = this.retinajsOptions.fireForget || false;

        debug('constructor:retinajsOptions', this.retinajsOptions);

        if (typeof this.uploader !== 'function') {
            throw new TypeError('RetinaJS: uploader must be callable');
        }

        if (typeof this.uploaderOptions.upload === 'undefined' ||
            typeof this.uploaderOptions.upload.public_id === 'undefined' ||
            this.uploaderOptions.upload.public_id.length === 0
        ) {
            throw new TypeError('RetinaJS error: invalid uploaderOptions.upload.public_id. Ensure to enable Cloudinary upload.use_filename option.');
        }

        if (isNaN(this.retinajsOptions.baseWidth)) {
            throw new TypeError('RetinaJS config error: invalid retinajs.baseWidth option');
        }

        if (this.retinajsOptions.baseWidth < 1) {
            throw new RangeError('RetinaJS config error: retinajs.baseWidth must be >= 1');
        }
    }

    /**
     *  Generates and creates the RetinaJS variants for given image
     *  @param {object} image The image object to retinize
     *  @return {Promise} A Promise
     */
    retinize(image) {
        const that = this,
            [head, ...tail] = this.generateDprConfigs(this.resolveMaxDpr(image.path));

        debug('retinize:configs', {
            head: head,
            tail: tail
        });

        // Image is not retinizable: creates DPR 1.0 variant only
        if (tail.length === 0) {
            return that.uploader(image.path, head, true);
        }

        // Creates the highest DPR variant first then creates subsequent variants
        return that.uploader(image.path, head, true).
            then((url) => {
                const variants = _.map(tail, (c) => that.uploader(url, c, false)),
                    // First creation call returns URL for highest DPR, in the post editor
                    // we need the DPR 1.0 variant (RetinaJS identifier-free) URL
                    finalUrl = that.sanitize(url);

                // Creates subsequent variants and returns URL regardless their fulfillment status
                if (that.retinajsOptions.fireForget) {
                    Promise.all(variants).catch((err) => {
                        console.error(new Error(`Fire&Forget RetinaJS: ${err}`));
                    });
                    return finalUrl;
                }

                // Waits for all subsequent variants to be done then returns the URL
                return Promise.all(variants).then(() => finalUrl);
            });
    }

    /**
     *  Removes the latest RetinaJS identifier (@{i}x) from the given string
     *  @param {string} string A string
     *  @return {string} The sanitized string
     */
    sanitize(string) {
        return decodeURIComponent(string).replace(/@\dx(?!.*@\dx)/, '');
    }

    /**
     *  Resolves the max DPR index available for given filename and baseWidth configuration.
     *  If baseWidth configuration is set to 800 and filename image has a width of 2500,
     *  the value returned by this method will be 2500 / 800 = 3.125 => 3.
     *  @param {string} filename Image filename
     *  @return {int} Max available DPR index or:
     *      - 1 if image is smaller than baseWidth
     *      - maxSupportedDpr if image max DPR is higher than Cloudinary can support
     */
    resolveMaxDpr(filename) {
        const dim = sizeOf(filename),
            mdpr = Math.floor(dim.width / this.retinajsOptions.baseWidth);

        if (mdpr === 0) {
            return 1;
        }
        if (mdpr > maxSupportedDpr) {
            return maxSupportedDpr;
        }
        return mdpr;
    }

    /**
     *  Generates a collection of upload options derivated from the original
     *  upload otions for each variant in desc mode (highest DPR on the top).
     *  @param {int} dpr The highest DPR value
     *  @return {array} A collection customized upload options for all DPRs
     */
    generateDprConfigs(dpr) {
        if (dpr < 1) {
            throw new RangeError(`Unexpected dpr value: ${dpr}`);
        }

        const configs = [];
        for (let i = dpr; i >= 1; i -= 1) {
            // Deep clone
            const config = JSON.parse(JSON.stringify(Object.assign({}, this.uploaderOptions))),
                dprConfig = {
                    // Forces the image width to baseWidth
                    width: this.retinajsOptions.baseWidth,
                    // No scale-up!
                    if: `iw_gt_${this.retinajsOptions.baseWidth}`,
                    // Resizing method
                    crop: 'scale',
                    // The DPR will resize the image accordingly to its value
                    dpr: `${i}.0`,
                    // Tags the DPR index so you can browse the DPRs easily
                    tags: [`dpr${i}`]
                };

            // Builds the RetinaJS identifier (@{i}x) for variants
            // with DPR > 1.0
            if (i > 1) {
                dprConfig.public_id = `${config.upload.public_id}@${i}x`;
            }

            _.mergeWith(config.upload, dprConfig, (objv, srcv) => {
                if (_.isArray(objv)) {
                    return objv.concat(srcv);
                }
                return srcv;
            });
            configs.push(config);
        }
        return configs;
    }
}

module.exports = RetinaJS;