Source: lib/Compiler.js

'use strict';

var NodeCache = require('node-cache');
var sha256 = require('js-sha256').sha256;
var cheerio = require('cheerio');
var exprjs = new (require('exprjs'))();
var fs = require('fs');
var path = require('path');

/**
 * Returns the first directory where a node_modules directory exists.
 * @private
 */
function basePath() {
  var _path = __dirname;

  if (_path.indexOf('node_modules/goji') !== -1) {
    while (!fs.existsSync(_path + '/node_modules') || _path.substr(-4) === 'goji') {
      _path = path.resolve(_path + '/..');
    }
  } else {
    while (!fs.existsSync(_path + '/node_modules')) {
      _path = path.resolve(_path + '/..');
    }
  }

  return _path;
}

function extendObj(a, b) {
  if (!b) {
    return;
  }

  Object.keys(b).forEach(function keyIterator(val) {
    if (a.hasOwnProperty(val)) {
      a[val] = b[val];
    }
  });

  if (!b.hasOwnProperty('partialsDir')) {
    a.partialsDir = a.templatesDir + '/partials';
  }
}

/**
 * Provides methods for parsing, compiling, and rendering templates.
 *
 * @param options
 * @returns {Compiler}
 * @constructor
 */
function Compiler(options) {
  if (! (this instanceof Compiler)) {
    return new Compiler(options);
  }
  var self = Object.getPrototypeOf(this);

  if (options) {
    extendObj(self.options, options);
  }

  if (self.options.cache) {
    self._cache = new NodeCache({
      stdTTL: self.options.cacheTTL,
      checkperiod: 0 // Needs to be 0 to prevent node-cache from
      // causing Goji to hang indefinitely
    });
  }
}

/**
 * <p>An object literal that has the following properties:</p>
 * <ul>
 *   <li>
 *     <code>cache</code>: Set to <code>true</code> (default) to enable
 *     caching of compiled templates. Set to <code>false</code> to compile
 *     templates every load.
 *   </li>
 *   <li>
 *     <code>cacheTTL</code>: Time, in seconds, to keep templates in the cache.
 *     Default value is 300 (5 minutes).
 *   </li>
 *   <li>
 *     <code>templatesDir</code>: The location where templates are stored.
 *     This should be the full path (use `path.resolve`). If it is not present,
 *     then it will be set to an "html" directory that is in the same
 *     directory as a node_modules directory.
 *   </li>
 *   <li>
 *     <code>partialsDir</code>: The location where partial templates are
 *     stored. This should be the full path (use `path.resolve`). If this
 *     option is not present then it will be set to a sub-directory of the
 *     specified <code>templatesDir</code>. The name of the sub-directory
 *     will be <code>partials</code>.
 *   </li>
 *   <li>
 *     <code>templatesExt</code>: The file extension used on template files.
 *     This defaults to ".html". Note that it should include the leading dot.
 *   </li>
 * </ul>
 * @typedef {object} Compiler~Options
 */

Compiler.prototype = {
  options: {
    cache: true,
    cacheTTL: 300,
    templatesDir: path.resolve(basePath() + '/html'),
    partialsDir: path.resolve(basePath() + '/html/partials'),
    templatesExt: '.html'
  },
  _cache: {}
};

/**
 * <p>Parses for elements that have a <code>g-attr</code> attribute and renders
 * them. A <code>g-attr</code> attribute can have the following expression
 * forms as a value:</p>
 *
 * <ul>
 *   <li>
 *     <code>value</code>: where <code>value</code> is a name of an attribute
 *     to modify/add. For example: <code>g-attr="href"</code>
 *   </li>
 *   <li>
 *     <code>'value'</code>: same as above, but as an actual string
 *     expression
 *   </li>
 *   <li>
 *     <code>['value', 'value', ...]</code>: an array of attribute names to
 *     modify/add. For example: <code>g-attr="['href', 'data-foo']"</code>
 *   </li>
 * </ul>
 *
 * <p>Each named attribute in the <code>g-attr</code> expression will take
 * its value from an attribute, who's value is an expression, named in the
 * format <code>g-attr-attributeName</code>. For example:
 * <code>g-attr="data-foo"</code> will get the value for attribute
 * <code>data-foo</code> from the expression in the value of the
 * <code>g-attr-data-foo</code> attribute.</p>
 *
 * @param {object} $ A cheerio object that represent the template
 * @param {object} context The context for the template actions
 * @param {object} gContext A context of extra information provided by Goji
 * @returns {object} The modified cheerio object
 * @since 0.6.0
 * @private
 */
Compiler.prototype._attr = function _attr($, context, gContext) {
  var _$ = (typeof $ === 'function') ? $ : cheerio.load($.toString());
  return _$('[g-attr]').each(function gAttr(i, elem) {
    var $elem = $(elem);
    var gAttr = $elem.attr('g-attr').trim();
    var exprString = (function() {
      var q = String.fromCharCode(39);
      var c = gAttr.substring(0,1);
      var result = gAttr;
      if (c === q) {
        result = '[' + gAttr + ']';
      } else if (c === '[') {
        result = result;
      } else {
        result = '[' + q + gAttr + q + ']';
      }
      return result;
    }());
    var expression = exprjs.parse(exprString);
    var result = exprjs.run(expression, context, gContext);

    result.forEach(function gAttrExprEach(val) {
      var attrExpr = exprjs.parse($elem.attr('g-attr-' + val));
      var attrResl = exprjs.run(attrExpr, context, gContext);
      $elem.attr(val, attrResl);
      $elem.removeAttr('g-attr-' + val);
    });

    $elem.removeAttr('g-attr');
  });
};

/**
 * Parses for elements that have a <code>g-class</code> attribute and renders
 * them. If present, the attribute will be evaluated as an expression and the
 * result will be appended to the <code>class</code> attribute (creating it
 * if one does not already exist).
 *
 * @param {object} $ A cheerio object that respresents the template
 * @param {object} context The context for the template actions
 * @param {object} gContext A context of extra information provided by Goji
 * @returns {object} The modified cheerio object
 * @since 0.3.0
 * @private
 */
Compiler.prototype._class = function _class($, context, gContext) {
  var _$ = (typeof $ === 'function') ? $ : cheerio.load($.toString());
  return _$('[g-class]').each(function gClass(i, elem) {
    var $elem = _$(elem);
    var expression = exprjs.parse($elem.attr('g-class'));
    var result = exprjs.run(expression, context, gContext);

    $elem.addClass(result);
    $elem.removeAttr('g-class');

    return $elem;
  });
};

/**
 * Parses for elements that have a <code>g-classprepend</code> attribute and
 * renders them. If present, the attribute will be evaluated as an expression
 * and the result will be prepended to the <code>class</code> attribute
 * (creating it if one does not already exist).
 *
 * @param {object} $ A cheerio object that respresents the template
 * @param {object} context The context for the template actions
 * @param {object} gContext A context of extra information provided by Goji
 * @returns {object} The modified cheerio object
 * @since 0.3.0
 * @private
 */
Compiler.prototype._classprepend = function _classprepend($, context, gContext) {
  var _$ = (typeof $ === 'function') ? $ : cheerio.load($.toString());
  return _$('[g-classprepend]').each(function gClassprepend(i, elem) {
    var $elem = _$(elem);
    var expression = exprjs.parse($elem.attr('g-classprepend'));
    var result = exprjs.run(expression, context, gContext);
    var classes = $elem.attr('class');

    if (classes) {
      $elem.attr('class', result + ' ' + classes);
    } else {
      $elem.addClass(result);
    }

    return $elem;
  });
};

/**
 * <p>Parses for elements that have a <code>g-each</code> attribute and renders
 * them. If the element also has a <code>g-text</code> attribute, then the
 * element is used as the template. Otherwise, the content of the parent
 * element is used as the template. In either case, the final result will be
 * that the parent element's content is replaced with the rendered template.</p>
 *
 * <p>Additionally, an extra context will be present during the parsing of the
 * template. This extra context is an object named <code>iter</code>. It has a
 * property <code>i</code> that indicates the iteration number. It also has
 * two boolean properties: <code>odd</code> and <code>even</code>. These two
 * properties can be used as shortcuts to determine whether the iteration
 * number is odd or even.</p>
 *
 * @param {object} $ A cheerio object that respresents the template
 * @param {object} context The context for the template actions
 * @param {object} gContext A context of extra information provided by Goji
 * @returns {object} The modified cheerio object
 * @since 0.2.0
 * @private
 */
Compiler.prototype._each = function _each($, context, gContext) {
  var self = this;
  var iterContext = {
    iter: {
      i: 0,
      get odd() {
        return (this.i % 2) !== 0;
      },
      get even() {
        return (this.i % 2) === 0;
      }
    }
  };

  return $('[g-each]').each(function gEach(i, elem) {
    // This thing is a mess
    iterContext.iter.i = 0;
    var $elem = $(elem);
    var parts = $elem.attr('g-each').split(' in ').map(function(val) {
      return val.trim();
    });

    // Retrieve the desired array from the context
    var expr = exprjs.parse(parts[1]);
    var list = exprjs.run(expr, context, gContext);

    // Evaluate the substitution expression if it is directly on the element
    var gText = $elem.attr('g-text');
    if (gText) {
      var expr2 = exprjs.parse($elem.attr('g-text'));
      $elem.removeAttr('g-text');
    }

    $elem.removeAttr('g-each'); // remove it so we don't run it again
    var varName = parts[0];
    var _context = {};
    _context[varName] = '';

    // Prepare our target DOM nodes
    var $parent = $elem.parent();
    var $node = $elem.clone();
    $parent.html('');

    var parseClasses = function() {
      var result = $node;
      if ($node.attr('g-class')) {
        result = self._class($node, _context, iterContext);
      } else if ($node.attr('g-classprepend')) {
        result = self._classprepend($node, _context, iterContext);
      }

      return result;
    };

    var iterRender = function(){};
    if (gText) {
      iterRender = function iterRender1(item) {
        _context[varName] = item;
        var result = exprjs.run(expr2, _context, iterContext);
        $node.html(result);
        var _$node = parseClasses();
        $parent.append(_$node.clone());

        iterContext.iter.i += 1;
      };
    } else {
      var innerTemplate = $node.html();
      iterRender = function iterRender2(item) {
        _context[varName] = item;
        var result = self._render(innerTemplate, _context, iterContext);
        $node.html(result);
        var _$node = parseClasses();
        $parent.append(_$node.clone());

        iterContext.iter.i += 1;
      };
    }

    // Finally loop through and render
    list.forEach(iterRender);

    return $elem;
  });
};

/**
 * If caching is enabled, this method can be used to clear the cache.
 *
 * @since 0.5.0
 */
Compiler.prototype.emptyCache = function emptyCache() {
  if (this.options.cache) {
    this._cache.flushAll();
  }
};

/**
 * Parses for <code>g-if</code> conditional blocks. If the expression
 * evalutates to true, then the block will be rendered. Otherwise, the block
 * will be completely removed.
 *
 * @param {object} $ A cheerio object that respresents the template
 * @param {object} context The context for the template actions
 * @param {object} gContext A context of extra information provided by Goji
 * @returns {object} The modified cheerio object
 * @since 0.4.0
 * @private
 */
Compiler.prototype._if = function _if($, context, gContext) {
  var self = this;

  return $('[g-if]').each(function gIf(i, elem) {
    var $elem = $(elem);
    var expression = exprjs.parse($elem.attr('g-if'));
    var result = exprjs.run(expression, context, gContext);

    $elem.removeAttr('g-if');
    if (result === true) {
      self._render($elem.toString(), context, gContext);
    } else {
      $elem.remove();
    }

    return $elem;
  });
};

/**
 * This method is used to parse a given <code>template</code> for included
 * blocks from other templates. An include does not replace the element on
 * which the <code>g-include</code> attribute is present. The include takes
 * the content of the denoted template and inserts it as the content of
 * <code>g-include</code> element.
 *
 * @param template A template to parse for elements with <code>g-include</code>
 *        attributes
 * @returns {string} The parsed template with includes replaced
 * @private
 */
Compiler.prototype._include = function _include(template) {
  var $ = cheerio.load(template);

  this._ir($, 'g-include', function($elem, $newContent) {
    $elem.html($newContent.html());
    $elem.attr('g-include', null);
  });

  return $.html();
};

/**
 * This method is used to parse a given <code>template</code> for replaced
 * blocks from other templates. A replace fully replaces the element on
 * which the <code>g-replace</code> attribute is present. The replace takes
 * the content of the denoted template and inserts it in the same DOM location
 * of <code>g-replace</code> element.
 *
 * @param template A template to parse for elements with <code>g-replace</code>
 *        attributes
 * @returns {string} The parsed template with includes replaced
 * @private
 */
Compiler.prototype._replace = function _replace(template) {
  var $ = cheerio.load(template);

  this._ir($, 'g-replace', function($elem, $content) {
    $elem.replaceWith($content);
  });

  return $.html();
};

/**
 * Utility method used by {@link Compiler#_include} and
 * {@link Compiler#replace}.
 *
 * @param $ A cheerio object representing a template to work on
 * @param {string} attrName The attribute to look for, 'g-include' or 'g-replace'
 * @param {Function} action A function accepting <code>$elem</code> and <code>$newContent</code>
 * @returns {object} The modified cheerio object
 * @private
 */
Compiler.prototype._ir = function _ir($, attrName, action) {
  var self = this;

  return $('[' + attrName + ']').each(function _irParser(i, elem) {
    var $elem = $(elem);
    var parts = $elem.attr(attrName).split('::').map(function(elem) {
      return elem.trim();
    });

    var child = self.loadTemplateNamed(parts[0], false);

    var parsedChild = self._parse(child);
    var $newContent = $(parts[1], parsedChild);

    action($elem, $newContent);

    return $elem.html();
  });
};

/**
 * Looks for a file in the templates direcory with the given name. If the
 * file exists, it will be loaded and returned as a string.
 *
 * @param {string} name The name of the template to load
 * @param {boolean} isPartial If set, then the defined partials directory will
 *                            be used as the base path for loading the
 *                            template
 * @returns {string | null} The template or <code>null</code> (wasn't found)
 * @since 0.5.0
 */
Compiler.prototype.loadTemplateNamed = function loadTemplateNamed(name, isPartial) {
  var result;
  var baseDir = (isPartial) ?
    this.options.partialsDir : this.options.templatesDir;
  var fullPath = path.resolve(
    baseDir + '/' + name + this.options.templatesExt
  );

  if (!fs.existsSync(fullPath)) {
    return null;
  }

  if (this.options.cache) {
    result = this._cache.get(fullPath);
    if (result.hasOwnProperty(fullPath)) {
      result = result[fullPath];
    } else {
      result = fs.readFileSync(fullPath).toString();
      this._cache.set(fullPath, result);
    }
  } else {
    result = fs.readFileSync(fullPath).toString();
  }

  return result;
};

/**
 * Used to perform all parsing operations necessary to compile a template.
 *
 * @param template The template to parse for compilation
 * @returns {string} The fully parsed (compiled) template
 * @private
 */
Compiler.prototype._parse = function _parse(template) {
  var _template = this._include(template);
  _template = this._replace(_template);
  return _template;
};

/**
 * Parses for elements with <code>g-partial</code> attributes. Any partial
 * attributes are then evaluated as expressions, the result of which should be
 * template names that can be loaded with {@link Compiler#loadTemplateNamed}.
 * Said template is loaded, rendered, and injected as the content of the
 * element upon which the <code>g-partial</code> attribute is present.
 *
 * @param {object} $ A cheerio object that respresents the template
 * @param {object} context The context for the the template actions
 * @param {object} gContext A context of extra information provided by Goji
 * @returns {object} The modified cheerio object
 * @since 0.7.0
 * @private
 */
Compiler.prototype._partial = function _partial($, context, gContext) {
  var _$ = (typeof $ === 'function') ? $ : cheerio.load($.toString());
  var self = this;

  return _$('[g-partial]').each(function gPartial(i, elem) {
    var $elem = _$(elem);
    var expression = exprjs.parse($elem.attr('g-partial'));
    var templateName = exprjs.run(expression, context, gContext);

    var partialTemplate = self.loadTemplateNamed(templateName, true);
    $elem.html(self._render(partialTemplate, context, gContext));
    $elem.removeAttr('g-partial');

    return $elem;
  });
};

/**
 * Used to run through all supported attributes of a compiled template and
 * perform their actions.
 *
 * @param {string} template A template as compiled by {@link Compiler#_parse}
 * @param {object} context The object to use as the context for actions
 * @param {object} gContext A context of extra information provided by Goji
 * @returns {string} The rendered template
 * @private
 */
Compiler.prototype._render = function _render(template, context, gContext) {
  var $ = cheerio.load(template);

  this._if($, context, gContext);
  this._each($, context, gContext); // Should be parsed first
  this._partial($, context, gContext);
  this._class($, context, gContext);
  this._classprepend($, context, gContext);
  this._text($, context, gContext);
  this._attr($, context, gContext);

  return $.html();
};

/**
 * Looks through a compiled template for <code>g-text</code> attributes and
 * parses them.
 *
 * @param {object} $ A cheerio object that respresents the template
 * @param {object} context The context for the the template actions
 * @param {object} gContext A context of extra information provided by Goji
 * @returns {object} The modified cheerio object
 * @private
 */
Compiler.prototype._text = function _text($, context, gContext) {
  return $('[g-text]').each(function(i, elem) {
    var $elem = $(elem);
    var expression = $elem.attr('g-text');
    var parsed = exprjs.parse(expression);
    var result = exprjs.run(parsed, context, gContext);

    $elem.text(result);
    $elem.attr('g-text', null);
    return $elem;
  });
};

/**
 * Returned from the {@link Compiler#compile} method when a template has been
 * compiled. This function allows you to render the compiled template
 * with substitued values.
 *
 * @typedef {Function} Compiler~RenderFunction
 * @param {object} context An object of values that will be substituted in
 *        the rendered template
 * @param {object} options Not used (yet)
 */

/**
 * Parses a given <code>template</code> for <code>g-include</code> and
 * <code>g-replace</code> blocks. Said blocks are dealt with appropriately.
 *
 * @param {string} template The template to compile
 * @param {Compiler~Options} options Options to be used by the compiler
 * @returns {Compiler~RenderFunction} A function to use for rendering the
 *          compiled template
 */
Compiler.prototype.compile = function compile(template, options) {
  var self = this;

  if (options) {
    // TODO: decouple this from the full options so that we are not
    // overwriting global Compiler options every compile
    extendObj(self.options, options);
  }

  var compiledTemplate;
  if (self.options.cache) {
    var hash = sha256(template.toString());
    var result = self._cache.get(hash);

    if (result.hasOwnProperty(hash)) {
      compiledTemplate = result[hash];
    } else {
      compiledTemplate = self._parse(template);
      self._cache.set(hash, compiledTemplate);
    }
  } else {
    compiledTemplate = self._parse(template);
  }

  return function(context, options) {
    return self._render(compiledTemplate, context);
  };
};

exports = module.exports = Compiler;