# BootstrapEmail.js

    const fs = require('fs');
    const path = require('path');
    const cheerio = require('cheerio');
    const sass = require('sass-extract');
    const postcss = require('postcss');
    const extractHeaderCss = require('./lib/PostcssInlineStylesPlugin');
    const bunyan = require('bunyan');
    const juice = require('juice');
    const ContentCompiler = require('./lib/ContentCompiler');
    const ComponentCompiler = require('./lib/ComponentCompiler');
    const constants = require('./constants');

    /**
     * @readonly
     * @enum {string}
     * @memberOf BootstrapEmail
     */
    const STYLE_VARIABLES = {
    	/** $grid-columns */
    	COLUMNS: '$grid-columns',
    	/** $container-max-width */
    	CONTAINER_WIDTH: '$container-max-width'
    };

    /**
     * @readonly
     * @enum {number}
     * @memberOf BootstrapEmail
     * @see https://www.npmjs.com/package/bunyan#levels
     */
    const LOG_LEVEL = {
    	/** 20 */
    	DEBUG: 20,
    	/** 30 */
    	INFO: 30,
    	/** 40 */
    	WARN: 40,
    	/** 50 */
    	ERROR: 50,
    	/** 60 */
    	FATAL: 60
    };

    const __defaultConfig = {
    	style: path.join(__dirname, './assets/bootstrap-email.scss'),
    	head: path.join(__dirname, './assets/head.scss'),
    	containerWidthFallback: true
    };

    const __styleVariables = {
    	[STYLE_VARIABLES.COLUMNS]: 12
    };

    class BootstrapEmail {

    	//*****************************************
    	// CONSTRUCTOR
    	//*****************************************

    	/**
    	 * Creates a new BootstrapEmail instance
    	 *
    	 * @constructor
    	 * @param {string|string[]} templates Path to file, string containing HTML code or array of filepaths
    	 * @param {Object} [options]
    	 * @param {string} [options.style] Path to .css or .scss file that should be inlined
    	 * @param {string} [options.head] Path to .css or .scss file taht should be injected into header
    	 * @param {LOG_LEVEL} [options.logLevel] Defaults to LOG_LEVEL.INFO
    	 * @param {Object} [options.variables] Sets default SASS-Variables if not defined otherwise in SCSS-file
    	 */
    	constructor(templates, {
    		style = __defaultConfig.style,
    		head = __defaultConfig.head,
    		logLevel = LOG_LEVEL.INFO,
    		variables = {}
    	} = {}) {

    		/**
    		 * Path to main (s)css file
    		 * @type {string}
    		 * @private
    		 */
    		this._stylePath = style;

    		/**
    		 * Path to (s)css file which should be injected to <head>
    		 * @type {string}
    		 * @private
    		 */
    		this._headPath = head;

    		/**
    		 * Merged object containing default styling variables
    		 * @type {Object}
    		 * @private
    		 */
    		this._vars = {...__styleVariables, ...variables};

    		/**
    		 * "Bunyan"-Instance for loggin
    		 * @type {Logger}
    		 * @private
    		 */
    		this._logger = bunyan.createLogger({
    			name: 'Bootstrap Email',
    			level: logLevel
    		});

    		/**
    		 * Contains parsed html-templates which will be compiled
    		 * @type {Array.<{$:cheerio,path:string}>}
    		 * @private
    		 */
    		this._templates = [];

    		// HANDLE TEMPLATES-PARAMETER
    		//*****************************************
    		const templatePaths = [];
    		if (Array.isArray(templates)) {
    			templates.forEach(templatePath => {
    				if (typeof templatePath !== 'string') {
    					this._logger.error(`Template paths must be strings. Value ${templatePath} is a ${typeof templatePath} and will be ignored.`);
    					return;
    				}
    				templatePaths.push(templatePath);
    			});
    		} else {
    			if (typeof templates === 'string') {
    				templatePaths.push(templates);
    			} else {
    				this._logger.fatal(`Parameter 'templates' must be a string or string[]. Got: ${typeof templates}`);
    				return;
    			}
    		}

    		// LOAD ALL TEMPLATE PATHS
    		//*****************************************
    		templatePaths.forEach(filePath => {
    			if (fs.existsSync(filePath)) {
    				try {
    					const data = fs.readFileSync(filePath, 'utf8');
    					this._templates.push({
    						$: cheerio.load(data),
    						name: path.basename(filePath)
    					});
    					this._logger.debug(filePath + ' successfully loaded');
    				} catch (err) {
    					this._logger.error(err, 'Cannot read ' + filePath);
    				}
    			} else {
    				if (filePath.trim().charAt(0) !== '<') {
    					this._logger.error('Given template is not valid html or the file does not exist. Got: ' + filePath);
    					return;
    				}

    				this._templates.push({
    					$: cheerio.load(filePath),
    					name: null
    				});
    				this._logger.debug('HTML template successfully loaded');
    			}
    		});

    		// LOAD STYLES
    		//*****************************************
    		const {css, vars, queries} = this._processStyle(this._stylePath);
    		this._inlineStyles = css;
    		this._mobileStyles = queries;

    		for (let i in this._vars) {
    			if (vars.hasOwnProperty(i)) {
    				this._vars[i] = parseInt(vars[i].value);
    			}
    		}
    	}

    	//*****************************************
    	// PUBLIC METHODS
    	//*****************************************

    	/**
    	 * Performs a full compile and returns compiled templates
    	 * @return {string} If only one templates has been compiled, returns the compiled html code
    	 * @return {Array.<{path: string, document: string}>} If multiple templates have been compiled, returns an array containing the path and compiled document
    	 */
    	compile() {
    		this._compileHtml();
    		this._inlineCss();
    		this._injectHead();

    		return this._output();
    	}

    	/**
    	 * Performs a full compile and saves documents to given destination
    	 * @param {string} filePath - If only one template is compiled, filePath takes a full path including filename and extension. Otherwise it takes only the directory name and uses the filename from inputfiles
    	 * @returns void
    	 */
    	compileAndSave(filePath) {
    		const outputs = this.compile();

    		if (typeof outputs === 'string') {
    			fs.writeFileSync(filePath, outputs);
    		} else {
    			for (let output of outputs) {
    				fs.writeFileSync(path.join(filePath, output.name), output.document);
    			}
    		}
    	}

    	//*****************************************
    	// PRIVATE METHODS
    	//*****************************************

    	/**
    	 * Replaces bootstrap classes with saved workarounds
    	 * @private
    	 */
    	_compileHtml() {
    		for (let template of this._templates) {
    			this._logger.debug('Start compiling ' + template.name);
    			const contentCompiler = new ContentCompiler(template.$, this._logger);
    			const componentCompiler = new ComponentCompiler(template.$, this._logger);

    			// HIDE PREVIEW TEXT
    			//*****************************************
    			contentCompiler.preview();

    			// WRAP BODY
    			//*****************************************
    			contentCompiler.body();

    			// COMPILE SPACINGS
    			//*****************************************
    			contentCompiler.padding();
    			contentCompiler.margin();

    			// COMPILE CONTAINERS AND GRIDS
    			//*****************************************
    			contentCompiler.container(this._vars[BootstrapEmail.STYLE_VARIABLES.CONTAINER_WIDTH]);
    			contentCompiler.grid(this._vars[BootstrapEmail.STYLE_VARIABLES.COLUMNS]);

    			// COMPILE NECESSARY ELEMENTS
    			//*****************************************
    			contentCompiler.hr();

    			// COMPILE ALIGNMENT CLASSES
    			//*****************************************
    			contentCompiler.align(ContentCompiler.ALIGNMENT.LEFT);
    			contentCompiler.align(ContentCompiler.ALIGNMENT.RIGHT);
    			contentCompiler.align(ContentCompiler.ALIGNMENT.CENTER);

    			// COMPILE BOOTSTRAP COMPONENTS
    			//*****************************************
    			contentCompiler.component('.card');
    			contentCompiler.component('.card-body');
    			contentCompiler.component('.btn');
    			// TODO: replace button replacer
    			//componentCompiler.button();
    			componentCompiler.badge();
    			contentCompiler.component('.alert');

    			// REPLACE DIVS
    			//*****************************************
    			contentCompiler.div();

    			// ADD ATTRIBUTES TO TABLES
    			//*****************************************
    			contentCompiler.table();
    		}
    	}

    	/**
    	 * Inlines all css classes into style attributes
    	 * @private
    	 */
    	_inlineCss() {
    		for (let template of this._templates) {
    			juice.inlineDocument(template.$, this._inlineStyles, {
    				applyAttributesTableElements: true,
    				applyHeightAttributes: true,
    				applyWidthAttributes: true
    			});
    		}
    	}

    	/**
    	 * Injects content of given head-file into the head of each template
    	 * @private
    	 */
    	_injectHead() {
    		const headCss = this._processStyle(this._headPath).css;

    		for (let template of this._templates) {
    			const headStyle = template.$('<style>')
    				.attr('type', 'text/css')
    				.text(headCss);

    			const charset = template.$('<meta>').attr({
    				'http-equiv': 'Content-Type',
    				'content': 'text/html; charset=utf-8'
    			});

    			const viewport = template.$('<meta>').attr({
    				'name': 'viewport',
    				'content': 'width=device-width, initial-scale=1'
    			});

    			const head = template.$('head')
    				.append(charset)
    				.append(viewport)
    				.append(headStyle);

    			if (this._mobileStyles) {
    				const queryStyles = template.$('<style>')
    					.attr('type', 'text/css')
    					.text(this._mobileStyles);
    				head.append(queryStyles);
    			}
    		}
    	}

    	/**
    	 * Prepares user-friendly output structure
    	 * @return {(string)}
    	 * @returns {Array.<{path:string,document:string}>}
    	 * @private
    	 */
    	_output() {
    		const out = [];

    		for (let template of this._templates) {
    			out.push({
    				name: template.name,
    				document: constants.DOCTYPE + template.$.html()
    			});
    		}

    		if (out.length === 1) {
    			return out[0].document;
    		}
    		return out;
    	}

    	/**
    	 * Loads style and extracts all media queries
    	 * @param {string} stylePath
    	 * @return {{css: string, vars: Object, queries: string}}|{css: string}}
    	 * @private
    	 */
    	_processStyle(stylePath) {
    		const style = this._loadStyle(stylePath);
    		let headerCss = '';

    		this._logger.debug('Extract not inlineable css from style');

    		const postcssPlugins = [
    			extractHeaderCss({output: css => headerCss = css})
    		];
    		const mainCss = postcss(postcssPlugins).process(style.css).css;

    		this._logger.debug('Styles extracted successfully.');

    		return {
    			vars: style.vars,
    			css: mainCss,
    			queries: headerCss
    		};
    	}

    	/**
    	 * Loads file from given sourcepath. If file is scss or sass, it will be compiled into css
    	 * @static
    	 * @param stylePath
    	 * @return {{css: string, vars: Object}|{css: string}}
    	 * @private
    	 */
    	_loadStyle(stylePath) {
    		if (['.scss', '.sass'].includes(path.extname(stylePath))) {
    			this._logger.debug(stylePath + ' detected as sass-file');

    			const rendered = sass.renderSync({
    				file: stylePath,
    				outputStyle: 'compressed'
    			});

    			this._logger.debug(stylePath + ' read and parsed successfully');

    			return {
    				css: rendered.css.toString(),
    				vars: rendered.vars.global
    			}
    		} else {
    			const css = fs.readFileSync(stylePath, 'utf8');
    			this._logger.debug(stylePath + ' read successfully');
    			return {css};
    		}
    	}
    }

    BootstrapEmail.LOG_LEVEL = Object.freeze(LOG_LEVEL);
    BootstrapEmail.STYLE_VARIABLES = Object.freeze(STYLE_VARIABLES);

    /**
     * Default variables. Can be overwritten in the constructor
     * @readonly
     * @type {Object}
     */
    BootstrapEmail.defaultVariables = Object.freeze(constants.defaultVariables);

    /**
     * Array containing all tagnames of block-elements. Block-elements will be compiled differently
     * @type {string[]}
     */
    BootstrapEmail.blockElements = constants.blockElements;

    /**
     * Array containing all tagnames of void-elements which cannot contain any
     * @type {string[]}
     */
    BootstrapEmail.voidElements = constants.voidElements;

    /**
     * Doctype which will be used for parsed templates
     * @type {string}
     */
    BootstrapEmail.DOCTYPE = constants.DOCTYPE;

    module.exports = BootstrapEmail;