patrickarlt/acetate

View on GitHub
lib/Acetate.js

Summary

Maintainability
F
3 days
Test Coverage
const EventEmitter = require("events").EventEmitter;
const chokidar = require("chokidar");
const path = require("path");
const fs = require("fs");
const _ = require("lodash");
const minimatch = require("minimatch");
const async = require("async");
const glob = require("glob");
const MarkdownIt = require("markdown-it");
const nunjucks = require("nunjucks");
const hljs = require("highlight.js");
const yaml = require("js-yaml");
const fsExtra = require("fs-extra");

const Logger = require("./Logger.js");
const TemplateLoader = require("./TemplateLoader.js");
const TransformerError = require("./error-types/TransformerError.js");
const AcetateConfigError = require("./error-types/AcetateConfigError.js");
const Helper = require("./custom-tags/Helper.js");
const Block = require("./custom-tags/Block.js");
const CustomHelperError = require("./error-types/CustomHelperError.js");
const PageRenderError = require("./error-types/PageRenderError.js");
const createPage = require("./createPage");
const { mergeMetadata, processTemplate } = require("./utils");

class Acetate extends EventEmitter {
  constructor({
    config = "acetate.config.js",
    sourceDir = "src",
    outDir = "build",
    root = process.cwd(),
    log = "info",
    args = {}
  } = {}) {
    super();

    this.args = args;
    this.root = root;
    this.config = config;
    this.src = sourceDir;
    this.dest = outDir;
    this.sourceDir = path.join(root, sourceDir);
    this.outDir = path.join(root, outDir);
    this.log = new Logger({
      level: log
    });

    this.initConfig();
  }

  /**
   * Transformer
   */

  transform(pattern, transformer, label) {
    const matcher = new minimatch.Minimatch(pattern);
    const transformRunner = function(page, done) {
      if (!matcher.match(page.src)) {
        process.nextTick(() => {
          done(null, page);
        });

        return;
      }

      // async transformer
      if (transformer.length === 2) {
        try {
          transformer(page, done);
        } catch (e) {
          done(e);
        }
      } else {
        var nextPage;
        var error;

        try {
          nextPage = transformer(page);
        } catch (e) {
          error = e;
        } finally {
          done(error, nextPage);
        }
      }
    };

    transformRunner.label = label;

    this._transformers.push(transformRunner);
  }

  metadata(pattern, data) {
    this.transform(pattern, function(page) {
      return mergeMetadata(page, data);
    });
  }

  layout(pattern, layout) {
    this.metadata(pattern, {
      layout
    });
  }

  ignore(pattern) {
    this.metadata(pattern, {
      ignore: true
    });
  }

  transformPage(page) {
    return new Promise((resolve, reject) => {
      const done = (e, page) => {
        if (e) {
          const error = new TransformerError(e);
          this.log.error(error);
          reject(error);
          return;
        }

        resolve(page);
      };

      const iterator = (page, transformer, callback) => {
        transformer(page, callback);
      };

      async.reduce(this._transformers, page, iterator, done);
    });
  }

  query(name, pattern, mapper, reducer, inital) {
    this._queries[name] = {
      name,
      pattern,
      mapper,
      reducer,
      inital: _.cloneDeep(inital),
      result: null
    };
  }

  data(name, data) {
    this._dataSources[name] = {
      name: name,
      source: data,
      lastRead: null,
      value: null
    };
  }

  _loadDataFromFunction(source, done) {
    source.source((error, data) => {
      if (error) {
        done(error);
        return;
      }

      done(null, data);
    });
  }

  _loadDataFromFile(source, done) {
    const datapath = path.join(this.sourceDir, source.source);

    if (
      source.value !== null &&
      source.lastRead > fs.statSync(datapath).mtimeMs
    ) {
      done(null, source.value);
      return;
    }

    fs.readFile(datapath, "utf8", (error, content) => {
      if (error) {
        done(error);
        return;
      }

      const ext = path.extname(datapath);
      let data;

      if (ext === ".json") {
        data = JSON.parse(content);
      }

      if (ext === ".yaml" || ext === ".yml") {
        data = yaml.safeLoad(content);
      }

      this._dataSources[source.name].value = data;
      this._dataSources[source.name].lastRead = Date.now();

      done(null, data);
    });
  }

  /**
   * Renderer
   */

  _createPrerenderFunction(pattern, prerenderer) {
    return function(page, done) {
      if (minimatch(page.src, pattern)) {
        try {
          prerenderer(page, done);
        } catch (e) {
          done(e);
        }
      } else {
        done(null, page);
      }
    };
  }

  prerender(pattern, prerenderer) {
    this._prerenderers.push(
      this._createPrerenderFunction(pattern, prerenderer)
    );
  }

  renderPage(page) {
    var _timer = this.log.time();
    return this._prerenderPage(page)
      .then(page => this._renderPage(page))
      .then(result => {
        this.log.info(
          `Rendered ${page.url} (${page.src}) in ${this.log.timeEnd(_timer)}`
        );
        return result;
      });
  }

  helper(name, handler, defaults) {
    this.nunjucks.addExtension(name, new Helper(name, handler, defaults));
  }

  block(name, handler, defaults) {
    this.nunjucks.addExtension(name, new Block(name, handler, defaults));
  }

  filter(name, handler) {
    this.nunjucks.addFilter(name, function(...args) {
      let result;

      try {
        result = handler.apply(undefined, args);
      } catch (e) {
        throw new CustomHelperError("filter", name, e);
      }

      return new nunjucks.runtime.SafeString(result);
    });
  }

  global(name, value) {
    this.nunjucks.addGlobal(name, value);
  }

  _renderPage(page) {
    if (page.templatePath) {
      return this._loadTemplate(page.templatePath).then(template => {
        return this._renderTemplate(template, page);
      });
    }

    return this._renderTemplate(page.template, page);
  }

  _renderTemplate(template, page) {
    if (page.__isMarkdown) {
      return this._renderNunjucks(undefined, template, page)
        .then(markdownContent => {
          return this._renderMarkdown(markdownContent);
        })
        .then(htmlContent => {
          return this._renderNunjucks(page.layout, htmlContent, page);
        });
    }

    return this._renderNunjucks(page.layout, template, page);
  }

  _renderMarkdown(markdown) {
    return new Promise(resolve => {
      process.nextTick(() => {
        resolve(_.trim(this.markdown.render(markdown)));
      });
    });
  }

  _loadTemplate(templatePath) {
    return new Promise((resolve, reject) => {
      fs.readFile(templatePath, (error, content) => {
        if (error) {
          reject(error);
        }
        const { template } = processTemplate(content.toString());
        resolve(template);
      });
    });
  }

  _renderNunjucks(layout, template, page) {
    return new Promise((resolve, reject) => {
      if (layout) {
        let [layoutTemplate, block] = layout.split(":");
        template = `{% extends '${layoutTemplate}' %}{% block ${block} %}${template}{% endblock %}`;
      }

      this.nunjucks.renderString(template, page, (error, html) => {
        if (error) {
          reject(new PageRenderError(error, page));
        }
        resolve(_.trim(html));
      });
    });
  }

  _prerenderPage(page) {
    return new Promise((resolve, reject) => {
      const iterator = (page, prerenderer, callback) => {
        prerenderer(Object.assign({}, page), callback);
      };

      const done = (error, page) => {
        if (error) {
          reject(error);
          return;
        }

        resolve(page);
      };

      async.reduce(this._prerenderers, page, iterator, done);
    });
  }

  invalidateTemplate(name) {
    name = name.replace(path.extname(name), "");
    this.nunjucks.loaders[0].emit("update", name);
    this.nunjucks.loaders[0].emit("update", `${name}.md`);
    this.nunjucks.loaders[0].emit("update", `${name}.html`);
  }

  /**
   * Loader
   */

  symlink(src, dest) {
    const destFolder = path.join(this.sourceDir, dest);
    this._loaders.unshift((createPage, callback) => {
      fsExtra.ensureSymlink(src, destFolder, function(error) {
        callback(error, []);
      });
    });
  }

  load(pattern, { ignore = [], metadata = {}, basePath = "" } = {}) {
    this._paths.push(pattern);

    if (this._pageWatcher) {
      this._pageWatcher.add(pattern);
    }

    this._loaders.push(
      this._createLoaderTask(pattern, { ignore, metadata, basePath })
    );
    this._loaderMetadata[pattern] = { ignore, metadata };
  }

  getPages() {
    if (this._pagesLoaded) {
      return Promise.resolve(this._pages);
    }

    return this.loadPages();
  }

  loadPages() {
    return new Promise((resolve, reject) => {
      const iterator = (pages, loader, callback) => {
        try {
          loader(createPage, function(error, newPages) {
            if (error) {
              callback(error);
              return;
            }

            callback(error, pages.concat(newPages));
          });
        } catch (e) {
          callback(e);
        }
      };

      const done = (error, pages) => {
        if (error) {
          reject(error);
          return;
        }

        this._pagesLoaded = true;
        this._pages = pages;

        resolve(this._pages);
      };

      async.reduce(this._loaders, [], iterator, done);
    });
  }

  _createLoaderTask(
    pattern,
    { ignore = [], metadata = {}, basePath = "" } = {}
  ) {
    return (createPage, callback) => {
      var _timer = this.log.time();

      const done = (error, filepaths) => {
        if (error) {
          callback(error);
        }

        const pages = _(filepaths)
          .filter(filepath => {
            return path.basename(filepath)[0] !== "_";
          })
          .map(src => {
            return createPage.fromTemplate(
              src,
              path.join(this.sourceDir, src),
              metadata,
              { basePath }
            );
          })
          .value();

        Promise.all(pages)
          .then(pages => {
            this.log.info(
              `Loaded ${pages.length} pages in ${this.log.timeEnd(_timer)}`
            );
            callback(null, pages);
          })
          .catch(error => {
            this.log.error(error);
            callback(error, []);
          });
      };

      glob(
        pattern,
        {
          cwd: this.sourceDir,
          nodir: true,
          ignore,
          follow: true // follow all symlinks
        },
        done
      );
    };
  }

  generate(generator) {
    this._loaders.push(generator);
  }

  /**
   * Watcher
   */

  startPageWatcher() {
    if (this._pageWatcher) {
      this.stopWatcher();
    }

    this._pageWatcher = chokidar.watch(this._paths, {
      cwd: this.sourceDir,
      ignoreInitial: true
    });

    this._pageWatcher.on("ready", () => {
      this.log.debug("File watcher ready");
      this.emit("watcher:ready");
    });

    this._pageWatcher.on("add", src => {
      src = src.replace(/\\+/g, "/");
      this._handlePageWatcherEvent(src, "add", "added");
    });

    this._pageWatcher.on("change", src => {
      src = src.replace(/\\+/g, "/");
      this._handlePageWatcherEvent(src, "change", "changed");
    });

    this._pageWatcher.on("unlink", src => {
      src = src.replace(/\\+/g, "/");
      if (path.basename(src)[0] === "_") {
        this.emit("watcher:template:delete", src);
      } else {
        const [removedPage] = _.remove(this._pages, page => page.src === src);
        if (removedPage) {
          this.log.info(`${src} deleted`);
          this.emit("watcher:delete", removedPage);
        }
      }
    });
  }

  stopPageWatcher() {
    this._pageWatcher.close();
    this._pageWatcher = null;
  }

  _handlePageWatcherEvent(src, eventName, verb) {
    this.log.info(`${src} ${verb}`);
    if (path.basename(src)[0] === "_") {
      this.emit(`watcher:template:${eventName}`, src);
    } else {
      setTimeout(() => {
        let loader = Object.keys(this._loaderMetadata).find(pattern => {
          let ignores = this._loaderMetadata[pattern].ignore || [];
          return minimatch(src, pattern) && ignores.length > 0
            ? ignores.every(pattern => !minimatch(src, pattern))
            : true;
        });
        let metadata = this._loaderMetadata[loader].metadata;

        Object.keys(this._queries).forEach(name => {
          const q = this._queries[name];
          if (minimatch(src, q.pattern)) {
            this._queries[q.name].result = null;
          }
        });

        this.log.debug(`Using metadata from loader:${loader}`);

        return createPage
          .fromTemplate(src, path.join(this.sourceDir, src), metadata)
          .then(page => {
            let oldPageIndex = this._pages.findIndex(page => page.src === src);

            if (oldPageIndex >= 0) {
              this._pages[oldPageIndex] = page;
            } else {
              this._pages.push(page);
            }

            this.emit(`watcher:${eventName}`, page);

            return page;
          })
          .catch(this._handlePageWatcherError.bind(this));
      }, 100);
    }
  }

  _handlePageWatcherError(error) {
    this._pagesLoaded = false;
    this._pages = [];
    this.log.error(error);
    this.emit("watcher:error", {
      error: {
        name: error.name,
        message: error.message,
        stack: error.stack,
        file: error.file,
        line: error.line,
        column: error.column
      }
    });
  }

  /**
   * Config
   */

  initConfig() {
    this.log.info("Loading config file.");
    this.reloadConfig();
  }

  reloadConfig() {
    this.emit("config:reloading");
    this.reset();

    this.require("./helpers/highlight-block.js");
    this.require("./helpers/link-helper.js");
    this.require("./helpers/markdown-block.js");

    if (this.config) {
      try {
        // require new configs
        this.require(path.resolve(this.root, this.config));

        this.emit("config:loaded");
      } catch (e) {
        const error = new AcetateConfigError(e, this.root);
        this.log.error(error);
        this.emit("config:error", {
          error
        });
        throw error;
      }
    } else {
      this.emit("config:loaded");
    }

    this.require("./helpers/stats-transformer.js");

    this.prerender("**/*", (page, done) => {
      async.mapValues(
        this._queries,
        (q, name, callback) => {
          if (q.result !== null) {
            this.log.debug(`Using cached result for query:${q.name}`);
            callback(null, q.result);
            return;
          }

          this.log.debug(`Generating result for query:${q.name}`);
          this.getPages()
            .then(pages => {
              const matcher = new minimatch.Minimatch(q.pattern);
              return pages.filter(page => matcher.match(page.src));
            })
            .then(pages => {
              this.log.debug(`found ${pages.length} pages for query:${q.name}`);
              return Promise.all(pages.map(page => this.transformPage(page)));
            })
            .then(pages => {
              return _(pages)
                .map(q.mapper)
                .compact()
                .reduce(q.reducer, _.cloneDeep(q.inital));
            })
            .then(result => {
              this._queries[q.name].result = result;
              callback(null, result);
            })
            .catch(error => callback(error, null));
        },
        (error, data) => {
          if (error) {
            done(error, null);
            return;
          }

          page.queries = data;
          done(null, page);
        }
      );
    });

    this.prerender("**/*", (page, done) => {
      async.mapValues(
        this._dataSources,
        (source, name, callback) => {
          if (typeof source.source === "function") {
            this._loadDataFromFunction(source, callback);
            return;
          }

          this._loadDataFromFile(source, callback);
        },
        (error, data) => {
          if (error) {
            done(error, null);
            return;
          }

          page.data = data;
          done(null, page);
        }
      );
    });
  }

  require(configPath) {
    var fullPath;

    // resolve this path to node_modules or locally
    try {
      fullPath = require.resolve(configPath);
    } catch (e) {
      fullPath = path.resolve(this.root, configPath);
    }

    // add this path to the cache of config paths
    this._configPaths.push(fullPath);

    // make sure our new module isn't cached
    delete require.cache[fullPath];

    // require our new configuration
    this.use(require(fullPath));
  }

  use(plugin) {
    plugin(this);
  }

  reset() {
    this._paths = [];
    this._pages = [];
    this._pagesLoaded = false;
    this._loaders = [];
    this._loaderMetadata = {};
    this._transformers = [];
    this._configPaths = [];
    this._configLoaded = false;
    this._prerenderers = [];
    this._queries = {};
    this._dataSources = {};

    this._templateloader = new TemplateLoader({
      sourceDir: this.sourceDir,
      logger: this.log,
      errorHandler: error => {
        this.emit("renderer:error", {
          error
        });
        this.log.error(error);
      }
    });

    this.nunjucks = new nunjucks.Environment(this._templateloader);

    this.highlight = hljs;
    this.markdown = new MarkdownIt({
      html: true,
      linkify: true,
      langPrefix: "",
      highlight: (code, lang) => {
        if (lang === "text" || lang === "plain") {
          return code;
        }

        return lang
          ? this.highlight.highlight(lang, code).value
          : this.highlight.highlightAuto(code).value;
      }
    });
  }

  startWatcher() {
    if (this._configWatcher) {
      this.stopWatcher();
    }

    this._configWatcher = chokidar.watch(this._configPaths);

    this._configWatcher.on("ready", () => {
      this.emit("config:watcher:ready");
    });

    this._configWatcher.on("change", () => {
      this.log.info("Config file changed. Rebuilding configuration.");
      try {
        this.reloadConfig();
      } catch (e) {
        // nothing
      }
    });

    this.log.debug("Starting watcher.");

    this.startPageWatcher();
  }

  stopWatcher() {
    this._configWatcher.unwatch(this._configPaths);
    this._configWatcher.close();
    this.watcher = null;
    this.stopPageWatcher();
  }
}

module.exports = Acetate;