relations/Relation.js

const _ = require('lodash');
const extendDefined = require('../util/extendDefined');
const urlModule = require('url');

/**
 * In graph terminology a relation represents a directed edge, a
 * reference from one asset to another. For the purpose of being able
 * to treat all relations equally, there's a subclass for each
 * supported relation type, encapsulating the details of how to
 * retrieve, update, and (optionally) inline the asset being pointed
 * to.
 *
 * These are some examples of included subclasses:
 *
 *    - `relations.HtmlAnchor`         An anchor tag in an HTML document `<a href='...'>`.
 *    - `relations.HtmlImage`          An `<img src='...'>` tag in an HTML document.
 *    - `relations.CssImport`          An `@import` declaration in a CSS asset.
 *    - `relations.CacheManifestEntry` A line in a cache manifest.
 */
class Relation {
  /**
   * Create a new Relation instance. For existing assets the
   * instantiation of relations happens automatically if you use the
   * `populate` transform. You only need to create relations manually
   * when you need to introduce new ones.
   *
   * Note that the base Relation class should be considered
   * abstract. Please instantiate the appropriate subclass.
   *
   * Options:
   *
   *  - `from` The source asset of the relation.
   *  - `to`   The target asset of the relation, or an asset configuration
   *           object if the target asset hasn't yet been resolved and created.
   *
   * @constructor
   * @param {Object} config The relation configuration
   */
  constructor(config) {
    if (config.hrefType) {
      this._hrefType = config.hrefType;
      config.hrefType = undefined;
    }
    if (config.to) {
      this._to = config.to;
      config.to = undefined;
    }
    extendDefined(this, config);
    this.id = `${_.uniqueId()}`;
  }

  /**
   * The source asset of the relation.
   *
   * @member {Asset} Relation#from
   */

  /**
   * The target asset of the relation.
   *
   * @type {Asset}
   */
  get to() {
    return this._to;
  }

  set to(to) {
    if (this.to && this.to.incomingInlineRelation === this) {
      this.to.incomingInlineRelation = undefined;
    }
    this._to = this.from.assetGraph.addAsset(to, this);
    this.refreshHref();
  }

  /**
   * The ATS/DOM-node where the Relation originates in the parent document
   *
   * @member {Object} Relation#node
   */

  /**
   * Get or set the href of the relation. The relation must be
   * attached to an asset.
   *
   * What is actually retrieved or updated depends on the relation
   * type. For `HtmlImage` the `src` attribute of the HTML element
   * is changed, for `CssImport` the parsed representation of
   * the @import rule is updated, etc.
   *
   * Most of the time you don't need to think about this property,
   * as the href is automatically updated when the url of the source
   * or target asset is changed, or an intermediate asset is
   * inlined.
   *
   * @member {String} Relation#href
   */

  /**
   * Get or set the fragment identifier part of the href of the relation.
   *
   * Setting a fragment with a non-empty value requires the value to begin with `#`
   *
   * @type {String}
   */
  get fragment() {
    const href = this.href;
    if (typeof href === 'string') {
      const indexOfFragment = href.indexOf('#');
      if (indexOfFragment === -1) {
        return '';
      } else {
        return href.substr(indexOfFragment);
      }
    }
  }

  set fragment(fragment) {
    if (fragment && !/^#/.test(fragment)) {
      throw new Error('The fragment must begin with a # or be empty');
    }
    const href = this.href;
    if (href) {
      let currentHrefWithoutFragment;
      const indexOfFragment = href.indexOf('#');
      if (indexOfFragment === -1) {
        currentHrefWithoutFragment = href;
      } else {
        currentHrefWithoutFragment = href.substr(0, indexOfFragment);
      }
      this.href = currentHrefWithoutFragment + (fragment || '');
      this.from.markDirty();
    }
  }

  /**
   * Update `href` of a relation to make sure it points at the
   * current url of its target asset.
   *
   * It's not necessary to call this function manually as long as
   * the source and target assets of the relation have only been
   * moved by having their `url` property changed (the recommended
   * way), but some transforms will need this after some low-level
   * surgery, such as attaching an existing relation to a different
   * asset.
   *
   * @return {Relation} The relation itself (chaining-friendly).
   */
  refreshHref() {
    if (this.hrefType === 'inline') {
      this.inline();
    } else {
      const currentHref = this.href;
      if (/^#/.test(currentHref)) {
        // Pure fragment url, nothing to do
        return;
      }
      const targetUrl = this.to.url;
      let href = this.from.assetGraph.buildHref(targetUrl, this.baseUrl, {
        canonical: this.canonical,
        nonBareRelative: this.nonBareRelative,
        hrefType: this.hrefType,
      });
      // Hack: Avoid adding index.html to an href pointing at file://.../index.html if it's not already there:
      if (
        /^file:\/\/.*\/index\.html(?:[?#]|$)/.test(targetUrl) &&
        !/(?:^|\/)index\.html(?:[?#]:|$)/.test(this.href)
      ) {
        href = href.replace(/(^|\/)index\.html(?=[?#]|$)/, '$1');
      }
      const currentFragment = this.fragment;
      if (currentFragment) {
        href += currentFragment;
      }
      if (currentHref !== href) {
        this.href = href;
        this.from.markDirty();
      }
    }
    return this;
  }

  /**
   * Get the relations cross origins status defined as
   * differences in protocol or hostname.
   *
   * This property is quite useful as part of a population
   * query to ensure that the graph doesn't expand beyond
   * the current host or file system
   *
   * @type {Boolean}
   */
  get crossorigin() {
    const fromUrl = this.from.nonInlineAncestor.url;
    const toUrl = this.to.url;
    if (!toUrl) {
      // Inline
      return false;
    }
    if (this.canonical) {
      return false;
    }
    const fromUrlObj = new urlModule.URL(fromUrl);
    const toUrlObj = new urlModule.URL(toUrl, fromUrl);
    if (
      fromUrlObj.protocol !== toUrlObj.protocol ||
      fromUrlObj.hostname !== toUrlObj.hostname
    ) {
      return true;
    }
    const fromPort = fromUrlObj.port
      ? parseInt(fromUrlObj.port, 10)
      : { 'http:': 80, 'https:': 443 }[fromUrlObj.protocol];
    const toPort = toUrlObj.port
      ? parseInt(toUrlObj.port, 10)
      : { 'http:': 80, 'https:': 443 }[toUrlObj.protocol];
    return fromPort !== toPort;
  }

  /**
   * Get or set the canonical state of a Relation
   *
   * If [AssetGraph]{@link AssetGraph} has a [`canonicalRoot`]{@link AssetGraph#canonicalRoot}
   * property, AssetGraph will detect absolute URLs matching `AssetGraph.canonicalRoot`
   * as if they were of [`hrefType`]{@link Relation#hrefType} `rootRelative`.
   *
   * The getter tell you if [`Relation.href`]{@link Relation#href} will be prepended
   * with `Assetgraph.canonicalRoot`.
   *
   * The setter will change the Relation's `href` to be prefixed with `AssetGraph.canonicalRoot`
   * if `true`, and without if `false`
   *
   * @type {Boolean}
   */
  get canonical() {
    if (typeof this._canonical === 'undefined') {
      let canonical = false;

      if (
        this.href &&
        this.from &&
        this.from.assetGraph &&
        this.from.assetGraph.canonicalRoot
      ) {
        // Need to figure out another way to achieve the "slashesDenoteHost"
        // behavior (3rd parameter to urlModule.parse)
        // eslint-disable-next-line n/no-deprecated-api
        const canonicalRootObj = urlModule.parse(
          this.from.assetGraph.canonicalRoot,
          false,
          true
        );

        // Need to figure out another way to achieve the "slashesDenoteHost"
        // behavior (3rd parameter to urlModule.parse)
        // eslint-disable-next-line n/no-deprecated-api
        const hrefObj = urlModule.parse(this.href, false, true);

        canonical =
          hrefObj.slashes === true &&
          ['http:', 'https:', null].includes(hrefObj.protocol) &&
          canonicalRootObj.host === hrefObj.host &&
          hrefObj.path.startsWith(canonicalRootObj.path) &&
          (canonicalRootObj.protocol === hrefObj.protocol ||
            canonicalRootObj.protocol === null);
      }

      this._canonical = canonical;
    }

    return this._canonical;
  }

  set canonical(isCanonical) {
    if (
      this.from &&
      this.from.assetGraph &&
      this.from.assetGraph.canonicalRoot
    ) {
      isCanonical = !!isCanonical;
      if (this._canonical !== isCanonical) {
        this._canonical = isCanonical;

        if (
          !isCanonical &&
          (this._hrefType === 'absolute' ||
            this._hrefType !== 'protocolRelative')
        ) {
          // We're switching to non-canonical mode. Degrade the href type
          // to rootRelative so we won't issue absolute file:// urls
          // This is based on guesswork, though.
          this._hrefType = 'rootRelative';
        }
        this.refreshHref();
      }
    }
  }

  /**
   * Get the url of the first non-inlined ancestor of the `from`-Asset
   *
   * @type {String}
   */
  get baseUrl() {
    return this.from.baseUrl;
  }

  /**
   * Either `'absolute'`, `'rootRelative'`, `'protocolRelative'`, `'relative'`, or `'inline'`.
   * Decides what "degree" of relative url [`refreshHref()`]{@link Relation#refreshHref} tries to issue, except for
   * `'inline'` which means that the target asset is contained in a `data:` url or similar.
   *
   * @type {String}
   */
  get hrefType() {
    const to = this.to;
    if (to.isInline && to !== this.from) {
      return 'inline';
    }
    if (!this._hrefType) {
      const href = (this.href || '').trim();
      if (/^\/\//.test(href)) {
        this._hrefType = 'protocolRelative';
      } else if (/^\//.test(href)) {
        this._hrefType = 'rootRelative';
      } else if (/^[a-z+]+:/i.test(href)) {
        this._hrefType = 'absolute';
      } else {
        this._hrefType = 'relative';
      }
    }
    return this._hrefType;
  }

  set hrefType(hrefType) {
    const existingHrefType = this.hrefType;
    if (hrefType === 'inline') {
      this.inline();
    } else if (hrefType !== existingHrefType) {
      this._hrefType = hrefType;
      if (existingHrefType === 'inline') {
        this.to.externalize();
      } else {
        this.refreshHref();
      }
    }
  }

  /**
   * Inline the relation. This is only supported by certain relation
   * types and will produce different results depending on the type
   * (`data:` url, inline script, inline stylesheet...).
   *
   * Will make a clone of the target asset if it has more incoming
   * relations than this one.
   *
   * @return {Relation} The relation itself (chaining-friendly).
   */
  inline() {
    if (
      this.to.isAsset &&
      this.to.incomingRelations.filter((r) => r._to !== r.from).length > 1
    ) {
      // This isn't the only incoming relation to the asset, clone before inlining.
      this.to.clone(this);
    }
    this.to.incomingInlineRelation = this;
    if (!this.to.isInline) {
      this.to.url = null;
    }
    return this;
  }

  /**
   * Attaches the relation to an asset.
   *
   * The ordering of certain relation types is significant
   * (`HtmlScript`, for instance), so it's important that the order
   * isn't scrambled in the indices. Therefore the caller must
   * explicitly specify a position at which to insert the object.
   *
   * @param {String} position `"first"`, `"last"`, `"before"`, or `"after"`.
   * @param {Relation} adjacentRelation The adjacent relation, mandatory if the position is `"before"` or `"after"`.
   * @return {Relation} The relation itself (chaining-friendly).
   */
  attach(position, adjacentRelation) {
    this.from.markDirty();
    this.addToOutgoingRelations(position, adjacentRelation);
    if (this.to && this.to.url) {
      this.refreshHref();
    }
    return this;
  }

  addToOutgoingRelations(position, adjacentRelation) {
    const outgoingRelations = this.from.outgoingRelations;
    const existingIndex = outgoingRelations.indexOf(this);
    if (existingIndex !== -1) {
      outgoingRelations.splice(existingIndex, 1);
    }
    if (position === 'last') {
      outgoingRelations.push(this);
    } else if (position === 'first') {
      outgoingRelations.unshift(this);
    } else if (position === 'before' || position === 'after') {
      // Assume 'before' or 'after'
      if (!adjacentRelation || !adjacentRelation.isRelation) {
        throw new Error(
          `addRelation: Adjacent relation is not a relation: ${adjacentRelation}`
        );
      }
      const i =
        outgoingRelations.indexOf(adjacentRelation) +
        (position === 'after' ? 1 : 0);
      if (i === -1) {
        throw new Error(
          `addRelation: Adjacent relation ${adjacentRelation.toString()} is not among the outgoing relations of ${
            this.urlOrDescription
          }`
        );
      }
      outgoingRelations.splice(i, 0, this);
    } else {
      throw new Error(`addRelation: Illegal 'position' argument: ${position}`);
    }
  }

  /**
   * Detaches the relation from the asset it is currently attached
   * to. If the relation is currently part of a graph, it will
   * removed from it.
   *
   * Detaching implies that the tag/statement/declaration
   * representing the relation is physically removed from the
   * referring asset. Not all relation types support this.
   *
   * @return {Relation} The relation itself (chaining-friendly).
   */
  detach() {
    this.from.markDirty();
    this.remove();
    return this;
  }

  /**
   * Removes the relation from the graph it's currently part
   * of. Doesn't detach the relation (compare with
   * [`relation.detach()`]{@link Relation#detach}).
   *
   * @return {Relation} The relation itself (chaining-friendly).
   * @api public
   */
  remove() {
    if (this.to && this.to.incomingInlineRelation === this) {
      this.to.incomingInlineRelation = undefined;
    }
    this.from.removeRelation(this);
    return this;
  }

  /**
   * Get a brief text containing the type, id of the relation. Will
   * also contain the `.toString()` of the relation's source and
   * target assets if available.
   *
   * @return {String} The string, eg. `"[HtmlAnchor/141: [Html/40 file:///foo/bar/index.html] => [Html/76 file:///foo/bar/otherpage.html]]"``
   */
  toString() {
    return `[${this.type}/${this.id}: ${
      this.from && this.to
        ? `${this.from.toString()} => ${
            this.to.isAsset
              ? this.to.toString()
              : this.to.url || this.to.type || '?'
          }`
        : 'unattached'
    }]`;
  }
}

Object.assign(Relation.prototype, {
  /**
   * Property that's true for all relation instances. Avoids
   * reliance on the `instanceof` operator.
   *
   * @constant
   * @type {Boolean}
   * @memberOf Relation#
   */
  isRelation: true,
});

module.exports = Relation;