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;