Source: lib/kss_style_guide.js

'use strict';

const KssSection = require('./kss_section');

/**
 * The `kss/lib/kss_styleguide` module is normally accessed via the
 * [`KssStyleGuide()`]{@link module:kss.KssStyleGuide} class of the `kss`
 * module:
 * ```
 * const KssStyleGuide = require('kss').KssStyleGuide;
 * ```
 * @private
 * @module kss/lib/kss_styleguide
 */

/**
 * A KssStyleGuide object represents multi-section style guide.
 *
 * This class is normally accessed via the [`kss`]{@link module:kss} module:
 * ```
 * const KssStyleGuide = require('kss').KssStyleGuide;
 * ```
 *
 * @alias module:kss.KssStyleGuide
 */
class KssStyleGuide {

  /**
   * Creates a KssStyleGuide object and stores the given data.
   *
   * If passed an object, it will add `autoInit`, `customPropertyNames`, and
   * `sections` properties.
   *
   * @param {Object} [data] An object of data.
   */
  constructor(data) {
    data = data || {};

    this.meta = {
      autoInit: false,
      files: data.files || [],
      hasNumericReferences: true,
      needsDepth: false,
      needsReferenceNumber: false,
      needsSort: false,
      referenceDelimiter: '.',
      referenceMap: {},
      weightMap: {}
    };

    this.data = {
      customPropertyNames: [],
      sections: []
    };

    if (data.customPropertyNames) {
      this.customPropertyNames(data.customPropertyNames);
    }

    if (data.sections) {
      // Note that auto-initialization is temporarily off since we don't want to
      // init this new object until after these sections are added.
      this.sections(data.sections);
    }

    // Now that all sections are added, turn on auto-initialization. But allow a
    // flag passed to the constructor to turn off auto-initialization.
    if (data.autoInit !== false) {
      this.meta.autoInit = true;
    }

    if (this.meta.autoInit) {
      this.init();
    }
  }

  /**
   * Return the `KssStyleGuide` as a JSON object.
   *
   * @returns {Object} A JSON object representation of the KssStyleGuide.
   */
  toJSON() {
    let returnObject;

    returnObject = {
      customPropertyNames: this.customPropertyNames(),
      hasNumericReferences: this.hasNumericReferences(),
      referenceDelimiter: this.referenceDelimiter()
    };

    returnObject.sections = this.sections().map(section => {
      return section.toJSON();
    });

    return returnObject;
  }

  /**
   * Toggles the auto-initialization setting of this style guide.
   *
   * If a `false` value is provided, auto-initialization is disabled and users
   * will be required to call `init()` manually after adding sections via
   * `sections()`. If a `true' value is provided, auto-initialization will be
   * enabled and the `init()` method will immediately be called.
   *
   * @param {boolean} autoInit The new setting for auto-initialization.
   * @returns {KssStyleGuide} The `KssStyleGuide` object is returned to allow
   *   chaining of methods.
   */
  autoInit(autoInit) {
    this.meta.autoInit = !!autoInit;

    if (this.meta.autoInit) {
      this.init();
    }

    // Allow chaining.
    return this;
  }

  /**
   * Sorts the style guides sections and (re-)initializes some section values.
   *
   * Some section data is dependent on the state of the KssStyleGuide. When
   * sections are added with `sections()`, it determines what updates are needed.
   * If needed, this method:
   * - Calculates the depth of the reference for each section. e.g. Section 2.1.7
   *   has a depth of 3.
   * - Sorts all the sections by reference.
   * - Calculates a reference number if the style guide uses
   *   word-based references.
   *
   * By default this method is called automatically whenever new sections are
   * added to the style guide. This
   *
   * @returns {KssStyleGuide} Returns the `KssStyleGuide` object to allow chaining
   *   of methods.
   */
  init() {
    if (this.data.sections.length) {
      let numSections = this.data.sections.length;

      // The delimiter has changed, so recalculate the depth of each section's
      // reference.
      if (this.meta.needsDepth) {
        for (let i = 0; i < numSections; i++) {
          this.data.sections[i].depth(this.data.sections[i].reference().split(this.referenceDelimiter()).length);
        }
        this.meta.needsDepth = false;
      }

      // Sort all the sections.
      if (this.meta.needsSort) {
        let delimiter = this.referenceDelimiter();
        // Sorting helper function that gets the weight of the given reference at
        // the given depth. e.g. `getWeight('4.3.2.2', 2)` will return the weight
        // for section 4.3.
        let getWeight = (reference, depth) => {
          reference = reference.toLowerCase().split(delimiter, depth).join(delimiter);

          return this.meta.weightMap[reference] ? this.meta.weightMap[reference] : 0;
        };

        // Sort sections based on the references.
        this.data.sections.sort((a, b) => {
          // Split the 2 references into chunks by their period or dash separators.
          let refsA = a.reference().toLowerCase().split(delimiter),
            refsB = b.reference().toLowerCase().split(delimiter),
            weightA, weightB,
            maxRefLength = Math.max(refsA.length, refsB.length);

          // Compare each set of chunks until we know which reference should be listed first.
          for (let i = 0; i < maxRefLength; i++) {
            if (refsA[i] && refsB[i]) {
              // If the 2 chunks are unequal, compare them.
              if (refsA[i] !== refsB[i]) {
                // If the chunks have different weights, sort by weight.
                weightA = getWeight(a.reference(), i + 1);
                weightB = getWeight(b.reference(), i + 1);
                if (weightA !== weightB) {
                  return weightA - weightB;
                } else if (refsA[i].match(/^\d+$/) && refsB[i].match(/^\d+$/)) {
                  // If both chunks are digits, use numeric sorting.
                  return refsA[i] - refsB[i];
                } else {
                  // Otherwise, use alphabetical string sorting.
                  return (refsA[i] > refsB[i]) ? 1 : -1;
                }
              }
            } else {
              // If 1 of the chunks is empty, it goes first.
              return refsA[i] ? 1 : -1;
            }
          }

          return 0;
        });
        this.meta.needsSort = false;
      }

      // Create an auto-incremented reference number if the references are not
      // number-based references.
      if (this.meta.needsReferenceNumber) {
        let autoIncrement = [0], ref, previousRef = [];
        for (let i = 0; i < numSections; i++) {
          ref = this.data.sections[i].reference();

          // Compare the previous Ref to the new Ref.
          ref = ref.split(this.referenceDelimiter());
          // If they are already equal, we don't need to increment the section number.
          if (previousRef.join(this.referenceDelimiter()) !== ref.join(this.referenceDelimiter())) {
            let incrementIndex = 0;
            for (let index = 0; index < previousRef.length; index += 1) {
              // Find the index where the refs differ.
              if (index >= ref.length || previousRef[index] !== ref[index]) {
                break;
              }
              incrementIndex = index + 1;
            }
            if (incrementIndex < autoIncrement.length) {
              // Increment the part where the refs started to differ.
              autoIncrement[incrementIndex]++;
              // Trim off the extra parts of the autoincrement where the refs differed.
              autoIncrement = autoIncrement.slice(0, incrementIndex + 1);
            }
            // Add parts to the autoincrement to ensure it is the same length as the new ref.
            for (let index = autoIncrement.length; index < ref.length; index += 1) {
              autoIncrement[index] = 1;
            }
          }
          this.data.sections[i].referenceNumber(autoIncrement.join('.'));
          previousRef = ref;
        }
        this.meta.needsReferenceNumber = false;
      }
    }

    // Allow chaining.
    return this;
  }

  /**
   * Gets or sets the list of custom properties of the style guide.
   *
   * If the `names` value is provided, the names are added to the style guide's
   * list of custom properties. Otherwise, the style guide's list of custom
   * properties is returned.
   *
   * @param {string|string[]} [names] Optional. The names of  of the section.
   * @returns {KssStyleGuide|string[]} If `names` is given, the `KssStyleGuide`
   *   object is returned to allow chaining of methods. Otherwise, the list of
   *   custom properties of the style guide is returned.
   */
  customPropertyNames(names) {
    if (typeof names === 'undefined') {
      return this.data.customPropertyNames;
    }

    if (!(names instanceof Array)) {
      names = [names];
    }
    for (let i = 0; i < names.length; i++) {
      if (this.data.customPropertyNames.indexOf(names[i]) === -1) {
        this.data.customPropertyNames.push(names[i]);
      }
    }
    // Allow chaining.
    return this;
  }

  /**
   * Returns whether the style guide has numeric references or not.
   *
   * @returns {boolean} Whether the style guide has numeric references or not.
   */
  hasNumericReferences() {
    return this.meta.hasNumericReferences;
  }

  /**
   * Returns the delimiter used in the style guide's section references.
   *
   * @returns {string} The delimiter used in the section references.
   */
  referenceDelimiter() {
    return this.meta.referenceDelimiter;
  }

  /**
   * Gets or sets the sections of the style guide.
   *
   * If `sections` objects are provided, the sections are added to the style
   * guide. Otherwise, a search is performed to return the desired sections.
   *
   * There's a few ways to use search with this method:
   * - `sections()` returns all of the sections.
   *
   * Using strings:
   * - `sections('2')` returns Section 2.
   * - `sections('2.*')` returns Section 2 and all of its descendants.
   * - `sections('2.x')` returns Section 2's children only.
   * - `sections('2.x.x')` returns Section 2's children, and their children too.
   *
   * Or Regular Expressions:
   * - `sections(/2\.[1-5]/)` returns Sections 2.1 through to 2.5.
   *
   * @param {Object|Object[]|string|RegExp} [sections] Optional. A section object
   *   or array of secction objects to add to the style guide. Or a string or
   *   Regexp object to match a KssSection's style guide reference.
   * @returns {KssStyleGuide|KssSection|KssSection[]|boolean} If `sections` is
   *   given, the `KssStyleGuide` object is returned to allow chaining of methods.
   *   Otherwise, the exact KssSection requested, an array of KssSection objects
   *   matching the query, or false is returned.
   */
  sections(sections) {
    let query,
      matchedSections = [];

    if (typeof sections === 'undefined') {
      return this.data.sections;
    }

    // If we are given an object, assign the properties.
    if (typeof sections === 'object' && !(sections instanceof RegExp)) {
      if (!(sections instanceof Array)) {
        sections = [sections];
      }
      sections.forEach(section => {
        let originalDelimiter = this.referenceDelimiter();

        if (!(section instanceof KssSection)) {
          section = new KssSection(section);
        }

        // Set the style guide for each section.
        section.styleGuide(this);

        // Determine if the references are number-based or word-based.
        this.meta.hasNumericReferences = this.meta.hasNumericReferences && /^[\.\d]+$/.test(section.reference());
        // Store the reference for quick searching later.
        this.meta.referenceMap[section.reference()] = section;
        // Store the section's weight.
        this.meta.weightMap[section.reference().toLowerCase()] = section.weight();
        // Determine the separator used in references; e.g. 'a - b' or 'a.b'.
        if (section.reference().indexOf(' - ') > -1) {
          this.meta.referenceDelimiter = ' - ';
        }

        // Add the section to the style guide.
        this.data.sections.push(section);

        // If the delimiter changed, flag the depths as needing recalculation.
        if (originalDelimiter !== this.referenceDelimiter()) {
          this.meta.needsDepth = true;
        } else {
          // Set the depth of this section's reference.
          section.depth(section.reference().split(this.referenceDelimiter()).length);
        }

        // Determine if all sections need their reference number recalculated.
        if (!this.meta.hasNumericReferences) {
          this.meta.needsReferenceNumber = true;
        }

        // A new section means all sections need to be sorted.
        this.meta.needsSort = true;
      });

      // Automatically re-initialize the style guide.
      if (this.meta.autoInit) {
        this.init();
      }
      // Allow chaining.
      return this;
    }

    // Otherwise, we should search for the requested section.
    query = sections;

    // Exact queries.
    if (typeof query === 'string') {
      // If the query is '*', 'x', or ends with '.*', ' - *', '.x', or ' - x',
      // then it is not an exact query.
      if (!(/(^[x\*]$|\s\-\s[x\*]$|\.[x\*]$)/.test(query))) {
        if (this.meta.referenceMap[query]) {
          return this.meta.referenceMap[query];
        } else {
          return false;
        }
      }
    }

    // Convert regex strings into proper JavaScript RegExp objects.
    if (!(query instanceof RegExp)) {
      let delim = this.referenceDelimiter() === '.' ? '\\.' : '\\ \\-\\ ';
      query = new RegExp(
        query
        // Convert '*' to a simple .+ regex.
          .replace(/^\*$/, '.+')
          // Convert 'x' to a regex matching one level of reference.
          .replace(/^x$/, '^.+?(?=($|' + delim + '))')
          // Convert '.*' or ' - *' to a ([delim].+){0,1} regex.
          .replace(/(\.|\s+\-\s+)\*$/g, '(' + delim + '.+){0,1}')
          // Convert the first '.x' or ' - x' to a regex matching one sub-level
          // of a reference.
          .replace(/(\.|\s+\-\s+)x\b/, delim + '.+?(?=($|' + delim + '))')
          // Convert any remaining '.x' or ' - x' to a regex matching zero or one
          // sub-levels of a reference.
          .replace(/(\.|\s+\-\s+)x\b/g, '(' + delim + '.+?(?=($|' + delim + '))){0,1}')
          // Convert any remaining '-' into '\-'
          .replace(/([^\\])\-/g, '$1\\-')
      );
    }

    // General (regex) search
    for (let i = 0; i < this.data.sections.length; i += 1) {
      let match = this.data.sections[i].reference().match(query);
      // The regex must match the full reference.
      if (match && match[0] === this.data.sections[i].reference()) {
        matchedSections.push(this.data.sections[i]);
      }
    }

    return matchedSections;
  }
}

module.exports = KssStyleGuide;