Home Manual Reference Source Test Repository

lib/file.js

import path from 'path';
import Promise from 'bluebird';
import fs from 'fs-extra';
import _ from 'lodash';
import createChecksum from './checksum';
import log from './log';
import Url from './url';
import Parse from './parse';
import cache from './cache';
import filter from './filter';

export default class File {
  constructor(filePath = '', { config, renderer } = {}) {
    /**
     * Unique ID for this file. Right now an alias for the file's path.
     * @type {string}
     */
    this.id = filePath;

    /**
     * Absolute path to file location.
     * @type {string}
     */
    this.path = filePath;

    /**
     * Absolute destination path of where file should be written.
     * @type {string} destination Absolute path to file.
     */
    this.destination = '';

    /**
     * Frontmatter for this file. Can be undefined if a file has no frontmatter.
     * @type {object}
     */
    this.frontmatter = Object.create(null);

    /**
     * Template accessible data.
     * @type {Object.<string, Object>}
     */
    this.data = Object.create(null);

    /**
     * Should we skip processing this file, ignoring templates and markdown
     * conversion. This is generally only true for images and similar files.
     * @type {boolean}
     */
    this.skipProcessing = false;

    /**
     * An asset processor that will handle rendering this file.
     * @type {function?}
     */
    this.assetProcessor = null;

    /**
     * If this File is filtered out of rendering. Filter settings are defined
     * in the {@link Config.ConfigFilename} file.
     * @type {boolean}
     */
    this.filtered = false;

    /**
     * @type {Config}
     * @private
     */
    this._config = config;

    /**
     * @type {Renderer}
     * @private
     */
    this._renderer = renderer;
  }

  /**
   * Update's File's data from the file system.
   */
  async update() {
    // Check if a file has frontmatter.
    const hasFrontmatter = await Parse.fileHasFrontmatter(this.path);

    // If File doesn't have frontmatter then return early.
    if (!hasFrontmatter) {
      const assetConfig = this._config
        .get('assets')
        .find(({ test }) => test(this.path));

      if (assetConfig) {
        this.assetProcessor = assetConfig.use;
      }

      this.skipProcessing = true;
      this._calculateDestination();
      return;
    }

    /**
     * Raw contents of file, directly from file system.
     * @type {string} One long string.
     */
    const rawContent = await Promise.fromCallback(cb =>
      fs.readFile(this.path, 'utf8', cb)
    );

    /**
     * Checksum hash of rawContent, for use in seeing if file is different.
     * @example:
     *  '50de70409f11f87b430f248daaa94d67'
     * @type {string}
     */
    this.checksum = createChecksum(rawContent);

    // Try to parse File's frontmatter.
    let parsedContent;
    try {
      parsedContent = Parse.fromFrontMatter(rawContent);
    } catch (e) {
      // Couldn't parse File's frontmatter.
    }

    // Ensure we have an object to dereference.
    if (!_.isObject(parsedContent)) {
      parsedContent = {};
    }

    const {
      data: frontmatter,
      // The file's text content.
      content,
    } = parsedContent;

    // Create new data object.
    this.data = Object.create(null);

    this.frontmatter = frontmatter;

    this.defaults = this._gatherDefaults();

    // Merge in new data that's accessible from template.
    _.merge(this.data, this.defaults, this.frontmatter, {
      // The content of the Page.
      content,
    });

    this.filtered = filter.isFileFiltered(
      this._config.get('file.filters'),
      this
    );

    try {
      this._calculateDestination();
    } catch (e) {
      throw new Error(
        'Unable to calculate destination for file at ' +
          `${this.path}. Message: ${e.message}`
      );
    }
  }

  /**
   * Gather default values that should be applied to this file.
   * @return {Object} Default values applied to this file.
   */
  _gatherDefaults() {
    // Defaults are sorted from least to most specific, so we iterate over them
    // in the reverse order to allow most specific first chance to apply their
    // values.
    return _.reduceRight(
      this._config.get('file.defaults'),
      (acc, defaultObj) => {
        const { scope, values } = defaultObj;

        // If default path property is defined does it exist within this file's
        // path.
        const pathMatches =
          scope.path != null ? this.path.includes(scope.path) : true;

        // If metadata is set the does it match the file's metadata.
        const metadataMatches = _.isObject(scope.metadata)
          ? _.isMatch(this.frontmatter, scope.metadata)
          : true;

        // If we have a match then apply the values.
        if (pathMatches && metadataMatches) {
          return _.defaults(acc, values);
        }

        return acc;
      },
      {}
    );
  }

  /**
   * Calculate both relative and absolute destination path for where to write
   * the file.
   * @private
   */
  _calculateDestination() {
    let destinationUrl;
    const hasMarkdownExtension = Url.pathHasMarkdownExtension(
      this.path,
      this._config.get('markdown.extensions')
    );

    /**
     * If the file itself wants to customize what its URL is then it will use
     * the `config.file.urlKey` value of the File's frontmatter as the basis
     * for which the URL of this file should be.
     * So if you have a File with a frontmatter that has `url: /pandas/` then
     * the File's URL will be `/pandas/`.
     * @type {string?} url Relative path to file.
     */
    const url = this.frontmatter[this._config.get('file.urlKey')];

    if (url) {
      // If the individual File defined its own unique URL that gets first
      // dibs at setting the official URL for this file.
      destinationUrl = url;
    } else if (this.data.permalink) {
      // If the file has no URL but has a permalink set on it then use it to
      // find the URL of the File.
      destinationUrl = Url.interpolatePermalink(this.data.permalink, this.data);
    } else {
      // Path to file relative to root of project.
      destinationUrl = this.path.replace(this._config.get('path.source'), '');

      if (hasMarkdownExtension) {
        // If the file has no URL set and no permalink then use its relative
        // file path as its url.
        destinationUrl = Url.replaceMarkdownExtension(
          destinationUrl,
          this._config.get('markdown.extensions')
        );
      }
    }

    if (this.assetProcessor) {
      destinationUrl = this.assetProcessor.calculateDestination(destinationUrl);
    }

    if (hasMarkdownExtension) {
      this.destination = Url.makeUrlFileSystemSafe(destinationUrl);
      this.data.url = Url.makePretty(this.destination);
    } else {
      this.destination = destinationUrl;
      this.data.url = destinationUrl;
    }
  }

  /**
   * Render the markdown into HTML.
   * If there is an assetProcessor then we delegate render responsibility to
   * that assetProcessor.
   * @param {Object} globalData Global site metadata.
   * @return {string} Rendered content.
   */
  render(globalData) {
    if (this.assetProcessor) {
      return this.assetProcessor.render(this);
    }

    const template = this.data.template;

    let result = this.data.content;

    const templateData = {
      ...globalData,
      file: this.data,
    };

    try {
      // Set result of content to result content.
      result = this._renderer.renderTemplateString(
        this.data.content,
        templateData
      );

      // Set result to file's contents.
      this.data.content = result;
    } catch (e) {
      log.error(e.message);
      throw new Error(
        "File: Could not render file's contents.\n" +
          `File: ${JSON.stringify(this)}`
      );
    }

    // Convert to HTML.
    // However if the File's frontmatter sets markdown value to false then
    // skip the markdown conversion.
    if (this.data.markdown !== false) {
      result = this._renderer.renderMarkdown(this.data.content);
      this.data.content = result;
    }

    if (
      !_.isNil(template) &&
      !(_.isString(template) && template.length === 0)
    ) {
      result = this._renderer.renderTemplate(template, templateData);
    }

    return result;
  }

  /**
   * Writes a given File object to the file system.
   * @param {Object} globalData Site wide data.
   */
  async write(globalData) {
    const destinationPath = path.join(
      this._config.get('path.destination'),
      this.destination
    );

    if (this.assetProcessor) {
      const content = await this.render(this);
      await Promise.fromCallback(cb => {
        fs.outputFile(destinationPath, content, 'utf8', cb);
      });
      return;
    }

    // If this File is a static asset then we don't process it at all, and just
    // copy it to its destination path.
    // This typically applies to images and other similar files.
    if (this.skipProcessing) {
      await Promise.fromCallback(cb => fs.copy(this.path, destinationPath, cb));
      return;
    }

    // Don't write File if it is filtered.
    if (this.filtered) {
      return;
    }

    const content = await this.render(globalData);

    if (
      this._config.get('incremental') &&
      cache.get(this.path) === this.checksum
    ) {
      return;
    }

    await Promise.fromCallback(cb => {
      fs.outputFile(destinationPath, content, 'utf8', cb);
    });

    // Save checksum to cache for incremental builds.
    cache.put(this.path, this.checksum);
  }
}