src/SectionSkeleton.js

import CdError from './CdError';
import TreeWalker from './TreeWalker';
import cd from './cd';
import { defined, isHeadingNode, isMetadataNode } from './utils-general';

/**
 * Class containing the main properties of a section and building it from a heading (we should
 * probably extract `SectionParser` from it). It is extended by {@link Section}. This class is the
 * only one used in the worker context for sections.
 */
class SectionSkeleton {
  /**
   * Create a section skeleton instance.
   *
   * @param {import('./Parser').default} parser
   * @param {object} heading
   * @param {object[]} targets
   */
  constructor(parser, heading, targets) {
    this.parser = parser;

    /**
     * Heading element (`.mw-heading` or `<h1>` - `<h6>`).
     *
     * @type {Element|external:Element}
     */
    this.headingElement = heading.element;

    const returnNodeIfHNode = (node) => isHeadingNode(node, true) ? node : null;

    /**
     * `H1...6` element.
     *
     * @type {Element|external:Element}
     */
    this.hElement = (
      returnNodeIfHNode(this.headingElement) ||
      returnNodeIfHNode(this.headingElement.firstElementChild) ||

      // Russian Wikivoyage and anything with .mw-h2section (not to be confused with .mw-heading2).
      // Also, a precaution in case something in MediaWiki changes.
      this.headingElement.querySelectorAll('h1, h2, h3, h4, h5, h6')[0]
    );

    /**
     * Headline element.
     *
     * @type {Element|external:Element}
     */
    this.headlineElement = cd.g.isParsoidUsed ?
      this.hElement :

      // Presence of .mw-heading doesn't guarantee we have the new HTML for headings
      // (https://www.mediawiki.org/wiki/Heading_HTML_changes). We should test for the existence of
      // .mw-headline to make sure it's not there. (Could also check that .mw-editsection follows
      // hN.)
      (this.parser.context.getElementByClassName(this.hElement, 'mw-headline') || this.hElement);

    if (!this.headlineElement) {
      throw new CdError();
    }

    /**
     * Section id.
     *
     * @type {string}
     */
    this.id = this.headlineElement.getAttribute('id');

    this.parseHeadline();

    const levelMatch = this.hElement.tagName.match(/^H([1-6])$/);

    /**
     * Section level. A level is a number representing the number of `=` characters in the section
     * heading's code.
     *
     * @type {number}
     */
    this.level = levelMatch && Number(levelMatch[1]);

    /**
     * Sequental number of the section at the time of the page load.
     *
     * @type {?number}
     */
    this.sectionNumber = null;

    const editLink = [
      ...(
        // Get menu links. Use two calls because our improvised .querySelectorAll() in
        // htmlparser2Extended doesn't support composite selectors.
        this.parser.context.getElementByClassName(this.headingElement, 'mw-editsection')
          ?.getElementsByTagName('a') ||
        []
      )
    ]
      // &action=edit, ?action=edit (couldn't figure out where this comes from, but at least one
      // user has such links), &veaction=editsource. We perhaps could catch veaction=edit, but
      // there's probably no harm in that.
      .find((link) => link.getAttribute('href')?.includes('action=edit'));

    if (editLink) {
      // `href` property with the full URL is not available in the worker context.
      /**
       * URL to edit the section.
       *
       * @type {string}
       */
      this.editUrl = new URL(cd.g.server + editLink.getAttribute('href'));

      if (this.editUrl) {
        const sectionParam = this.editUrl.searchParams.get('section');
        if (sectionParam.startsWith('T-')) {
          this.sourcePageName = this.editUrl.searchParams.get('title');
          this.sectionNumber = Number(sectionParam.match(/\d+/)[0]);
        } else {
          this.sectionNumber = Number(sectionParam);
        }
        if (Number.isNaN(this.sectionNumber)) {
          this.sectionNumber = null;
        }
        this.editUrl = this.editUrl.href;
      }
    }

    this.initContent(heading, targets);

    /**
     * Section index. Same as the index in the array returned by
     * {@link module:sectionRegistry.getAll}.
     *
     * @type {number}
     */
    this.index = cd.sections.length;
  }

  /**
   * Set some properties related to the content of the section (contained elements and comments).
   *
   * @param {object} heading
   * @param {object[]} targets
   * @private
   */
  initContent(heading, targets) {
    this.headingNestingLevel = this.parser.getNestingLevel(this.headingElement);

    // Find the next heading element
    const headingIndex = targets.indexOf(heading);
    let nextHeadingIndex = targets
      .findIndex((target, i) => i > headingIndex && target.type === 'heading');
    if (nextHeadingIndex === -1) {
      nextHeadingIndex = undefined;
    }
    const nextHeadingElement = targets[nextHeadingIndex]?.element;

    // Find the next heading element whose section is not a descendant of this section
    let nndheIndex = targets.findIndex((target, i) => (
      i > headingIndex &&
      target.type === 'heading' &&
      target.level <= this.level
    ));
    if (nndheIndex === -1) {
      nndheIndex = undefined;
    }
    const nextNotDescendantHeadingElement = targets[nndheIndex]?.element;

    const treeWalker = new TreeWalker(
      this.parser.context.rootElement,
      (node) => !isMetadataNode(node) && !node.classList.contains('cd-section-button-container'),
      true
    );

    /**
     * Last element in the section.
     *
     * @type {Element|external:Element}
     */
    this.lastElement = this.getLastElement(nextNotDescendantHeadingElement, treeWalker);

    /**
     * Last element in the first chunk of the section, i.e. all elements up to the first subheading
     * if it is present or just all elements if it is not.
     *
     * @type {Element|external:Element}
     */
    this.lastElementInFirstChunk = nextHeadingElement === nextNotDescendantHeadingElement ?
      this.lastElement :
      this.getLastElement(nextHeadingElement, treeWalker);

    const targetsToComments = (targets) => (
      targets
        .filter((target) => target.type === 'signature')
        .map((target) => target.comment)
        .filter(defined)
    );

    /**
     * Comments contained in the section.
     *
     * @type {import('./Comment').default[]}
     */
    this.comments = targetsToComments(targets.slice(headingIndex, nndheIndex));

    /**
     * Comments contained in the first chunk of the section, i.e. all elements up to the first
     * subheading if it is present, or all elements if it is not.
     *
     * @type {import('./Comment').default[]}
     */
    this.commentsInFirstChunk = targetsToComments(targets.slice(headingIndex, nextHeadingIndex));

    this.comments.forEach((comment) => {
      if (
        !this.oldestComment ||
        (comment.date && (!this.oldestComment.date || this.oldestComment.date > comment.date))
      ) {
        /**
         * Oldest comment in the section.
         *
         * @type {import('./CommentSkeleton').default}
         */
        this.oldestComment = comment;
      }
    });

    this.comments ||= [];

    this.commentsInFirstChunk ||= this.comments;
    this.commentsInFirstChunk.forEach((comment) => {
      comment.section = this;
    });
  }

  /**
   * Get the last element in the section based on a following (directly or not) section's heading
   * element.
   *
   * Sometimes sections are nested trickily in some kind of container elements, so a following
   * structure may take place:
   * ```html
   * == Heading 1 ==
   * <p>Paragraph 1.</p>
   * <div>
   *   <p>Paragraph 2.</p>
   *   == Heading 2 ==
   *   <p>Paragraph 3.</p>
   * </div>
   * <p>Paragraph 4.</p>
   * == Heading 3 ==
   * ```
   *
   * In this case, section 1 has paragraphs 1 and 2 as the first and last, and section 2 has
   * paragraphs 3 and 4 as such. Our code must capture that.
   *
   * @param {Element|external:Element|undefined} followingHeadingElement
   * @param {import('./TreeWalker').TreeWalker} treeWalker
   * @returns {Element|external:Element}
   */
  getLastElement(followingHeadingElement, treeWalker) {
    let lastElement;
    if (followingHeadingElement) {
      treeWalker.currentNode = followingHeadingElement;
      while (!treeWalker.previousSibling()) {
        if (!treeWalker.parentNode()) break;
      }
      lastElement = treeWalker.currentNode;
    } else {
      lastElement = this.parser.context.rootElement.lastElementChild;
    }

    // Some wrappers that include the section heading added by users
    while (lastElement.contains(this.headingElement) && lastElement !== this.headingElement) {
      lastElement = lastElement.lastElementChild;
    }

    if (cd.config.reflistTalkClasses.some((name) => lastElement.classList?.contains(name))) {
      lastElement = lastElement.previousElementSibling;
    }

    return lastElement;
  }

  /**
   * _For internal use._ Parse the headline of the section and fill the
   * {@link SectionSkeleton#headline headline} property that contains no HTML tags.
   */
  parseHeadline() {
    const classesToFilter = [
      // Was removed in 2021, see T284921. Keep this for some time.
      'mw-headline-number',

      'mw-editsection-like',
      ...cd.config.excludeFromHeadlineClasses,
    ];

    /**
     * Section headline as it appears on the page.
     *
     * Foreign elements can get there, add the classes of these elements to
     * {@link module:defaultConfig.excludeFromHeadlineClasses} to filter them out.
     *
     * @type {string}
     */
    this.headline = [...this.headlineElement.childNodes]
      .filter((node) => (
        node.nodeType === Node.TEXT_NODE ||
        (
          node.nodeType === Node.ELEMENT_NODE &&
          !(isMetadataNode(node) || classesToFilter.some((name) => node.classList.contains(name)))
        )
      ))
      .map((node) => node.textContent)
      .join('')
      .trim();
  }

  /**
   * Get the parent section of the section.
   *
   * @param {boolean} [ignoreFirstLevel=true] Don't consider sections of the first level parent
   *   sections; stop at second level sections.
   * @returns {?SectionSkeleton}
   */
  getParent(ignoreFirstLevel = true) {
    if (ignoreFirstLevel && this.level <= 2) {
      return null;
    }
    return (
      cd.sections
        .slice(0, this.index)
        .reverse()
        .find((section) => section.level < this.level) ||
      null
    );
  }

  /**
   * Get the chain of ancestors of the section as an array, starting with the parent section.
   *
   * The returned value is cached, so don't change the array in-place. (That's ugly, need to check
   * if running .slice() on the array slows anything down. To be clear – this method is run very
   * frequently.)
   *
   * @returns {SectionSkeleton[]}
   */
  getAncestors() {
    if (!this.cachedAncestors) {
      this.cachedAncestors = [];
      let section = this;
      while ((section = section.getParent(false))) {
        this.cachedAncestors.push(section);
      }
    }
    return this.cachedAncestors;
  }
}

/**
 * Object with the same basic structure as {@link SectionSkeleton} has. (It comes from a web
 * worker so its constructor is lost.)
 *
 * @typedef {object} SectionSkeletonLike
 */

export default SectionSkeleton;