TryGhost/Ghost

View on GitHub
ghost/minifier/lib/Minifier.js

Summary

Maintainability
C
1 day
Test Coverage
const errors = require('@tryghost/errors');
const debug = require('@tryghost/debug')('minifier');
const tpl = require('@tryghost/tpl');
const csso = require('csso');
const terser = require('terser');
const glob = require('tiny-glob');
const path = require('path');
const fs = require('fs').promises;

const messages = {
    badDestination: {
        message: 'Unexpected destination {dest}',
        context: 'Minifier expected a destination that ended in .css or .js'
    },
    badSource: {
        message: 'Unable to read source files {src}',
        context: 'Minifier was unable to locate or read the source files'
    },
    missingConstructorOption: {
        message: 'Minifier missing {opt} option',
        context: 'new Minifier({}) requires a {opt} option'
    },
    globalHelp: 'Refer to the readme for @tryghost/minifier for how to use this module'
};

// public API for minify hooks
class Minifier {
    constructor({src, dest}) {
        if (!src) {
            throw new errors.IncorrectUsageError({
                message: tpl(messages.missingConstructorOption.message, {opt: 'src'}),
                context: tpl(messages.missingConstructorOption.context, {opt: 'src'}),
                help: tpl(messages.globalHelp)
            });
        }
        if (!dest) {
            throw new errors.IncorrectUsageError({
                message: tpl(messages.missingConstructorOption.message, {opt: 'dest'}),
                context: tpl(messages.missingConstructorOption.context, {opt: 'dest'}),
                help: tpl(messages.globalHelp)
            });
        }
        this.srcPath = src;
        this.destPath = dest;
    }

    getFullSrc(src) {
        return path.join(this.srcPath, src);
    }

    getFullDest(dest) {
        return path.join(this.destPath, dest);
    }

    async minifyCSS(contents) {
        const result = await csso.minify(contents);
        if (result && result.css) {
            return result.css;
        }
        return null;
    }

    async minifyJS(contents) {
        const result = await terser.minify(contents);
        if (result && result.code) {
            return result.code;
        }
        return null;
    }

    async getMatchingFiles(src) {
        return await glob(this.getFullSrc(src));
    }

    async readFiles(files) {
        let mergedFiles = '';
        for (const file of files) {
            const contents = await fs.readFile(file, 'utf8');
            mergedFiles += contents;
        }

        return mergedFiles;
    }

    async getSrcFileContents(src) {
        try {
            const files = await this.getMatchingFiles(src);

            if (files) {
                return await this.readFiles(files);
            }
        } catch (error) {
            throw new errors.IncorrectUsageError({
                message: tpl(messages.badSource.message, {src}),
                context: tpl(messages.badSource.context),
                help: tpl(messages.globalHelp)
            });
        }
    }

    async writeFile(contents, dest) {
        if (contents) {
            let writePath = this.getFullDest(dest);
            // Ensure the output folder exists
            await fs.mkdir(this.destPath, {recursive: true});
            // Create the file
            await fs.writeFile(writePath, contents);
            return writePath;
        }
    }

    /**
     * Minify files
     * 
     * @param {Object} globs An object in the form of 
     * ```js
     * {
     *     'destination1.js': 'glob/*.js',
     *     'destination2.js': 'glob2/*.js'
     * }
     * ```
     * @param {Object} [options]
     * @param {Object} [options.replacements] Key value pairs that should get replaced in the content before minifying
     * @returns {Promise<string[]>} List of minified files (keys of globs)
     */
    async minify(globs, options) {
        debug('Begin', globs);
        const destinations = Object.keys(globs);
        const minifiedFiles = [];

        for (const dest of destinations) {
            const src = globs[dest];
            let contents = await this.getSrcFileContents(src);

            if (options?.replacements) {
                for (const key of Object.keys(options.replacements)) {
                    contents = contents.replace(key, options.replacements[key]);
                }
            }
            let minifiedContents;

            if (dest.endsWith('.css')) {
                minifiedContents = await this.minifyCSS(contents);
            } else if (dest.endsWith('.js')) {
                minifiedContents = await this.minifyJS(contents);
            } else {
                throw new errors.IncorrectUsageError({
                    message: tpl(messages.badDestination.message, {dest}),
                    context: tpl(messages.badDestination.context),
                    help: tpl(messages.globalHelp)
                });
            }

            const result = await this.writeFile(minifiedContents, dest);
            if (result) {
                minifiedFiles.push(dest);
            }
        }

        debug('End');
        return minifiedFiles;
    }
}

module.exports = Minifier;