view/widget/toc.jsx

/**
 * Table of contents widget JSX component.
 * @module view/widget/toc
 */
const { tocObj: getTocObj, unescapeHTML } = require('hexo-util');
const { Component } = require('inferno');
const { cacheComponent } = require('../../util/cache');

/**
 * Export a tree of headings of an article
 * @private
 * @example
 * getToc('HTML content...');
 * // {
 * //    "1": {
 * //        "id": "How-to-enable-table-of-content-for-a-post",
 * //        "text": "How to enable table of content for a post",
 * //        "index": "1"
 * //    },
 * //    "2": {
 * //        "1": {
 * //            "1": {
 * //                "id": "Third-level-title",
 * //                "text": "Third level title",
 * //                "index": "2.1.1"
 * //            },
 * //            "id": "Second-level-title",
 * //            "text": "Second level title",
 * //            "index": "2.1"
 * //        },
 * //        "2": {
 * //            "id": "Another-second-level-title",
 * //            "text": "Another second level title",
 * //            "index": "2.2"
 * //        },
 * //        "id": "First-level-title",
 * //        "text": "First level title",
 * //        "index": "2"
 * //    }
 * // }
 */
function getToc(content, maxDepth) {
  const toc = {};
  const tocObj = getTocObj(content, { min_depth: 1, max_depth: 6 });
  const levels = Array.from(new Set(tocObj.map((item) => item.level)))
    .sort((a, b) => a - b)
    .slice(0, maxDepth);
  const counters = new Array(levels.length).fill(0);

  tocObj.forEach((item) => {
    if (!levels.includes(item.level)) {
      return;
    }

    const { text, id } = item;
    const normalizedLevel = levels.indexOf(item.level);

    for (let i = 0; i < counters.length; i++) {
      if (i > normalizedLevel) {
        counters[i] = 0;
      } else if (i < normalizedLevel) {
        if (counters[i] === 0) {
          // if headings start with a lower level heading, set the former heading index to 1
          // e.g. h3, h2, h1, h2, h3 => 1.1.1, 1.2, 2, 2.1, 2.1.1
          counters[i] = 1;
        }
      } else {
        counters[i] += 1;
      }
    }
    let node = toc;
    for (const i of counters.slice(0, normalizedLevel + 1)) {
      if (!(i in node)) {
        node[i] = {};
      }
      node = node[i];
    }
    node.id = id;
    node.text = text;
    node.index = counters.slice(0, normalizedLevel + 1).join('.');
  });
  return toc;
}

/**
 * Table of contents widget JSX component.
 *
 * @example
 * <Toc
 *     title="Widget title"
 *     content="HTML content"
 *     showIndex={true}
 *     collapsed={true}
 *     maxDepth={3}
 *     jsUrl="******" />
 */
class Toc extends Component {
  renderToc(toc, showIndex = true) {
    let result;

    const keys = Object.keys(toc)
      .filter((key) => !['id', 'index', 'text'].includes(key))
      .map((key) => parseInt(key, 10))
      .sort((a, b) => a - b);

    if (keys.length > 0) {
      result = <ul class="menu-list">{keys.map((i) => this.renderToc(toc[i], showIndex))}</ul>;
    }
    if ('id' in toc && 'index' in toc && 'text' in toc) {
      result = (
        <li>
          <a class="level is-mobile" href={'#' + toc.id}>
            <span class="level-left">
              {showIndex ? <span class="level-item">{toc.index}</span> : null}
              <span class="level-item">{unescapeHTML(toc.text)}</span>
            </span>
          </a>
          {result}
        </li>
      );
    }
    return result;
  }

  render() {
    const { showIndex, maxDepth = 3, collapsed = true } = this.props;
    const toc = getToc(this.props.content, maxDepth);
    if (!Object.keys(toc).length) {
      return null;
    }

    const css =
      '#toc .menu-list > li > a.is-active + .menu-list { display: block; }' +
      '#toc .menu-list > li > a + .menu-list { display: none; }';

    return (
      <div class="card widget" id="toc" data-type="toc">
        <div class="card-content">
          <div class="menu">
            <h3 class="menu-label">{this.props.title}</h3>
            {this.renderToc(toc, showIndex)}
          </div>
        </div>
        {collapsed ? <style dangerouslySetInnerHTML={{ __html: css }}></style> : null}
        <script src={this.props.jsUrl} defer={true}></script>
      </div>
    );
  }
}

/**
 * Cacheable table of contents widget JSX component.
 * <p>
 * This class is supposed to be used in combination with the <code>locals</code> hexo filter
 * ({@link module:hexo/filter/locals}).
 *
 * @see module:util/cache.cacheComponent
 * @example
 * <Toc.Cacheable
 *     config={{ toc: true }}
 *     page={{ layout: 'post', content: 'HTML content' }}
 *     widget={{ index: true, collapsed: true, depth: 3 }}
 *     helper={{
 *         _p: function() {...},
 *         url_for: function() {...}
 *     }} /> />
 */
Toc.Cacheable = cacheComponent(Toc, 'widget.toc', (props) => {
  const { config, page, widget, helper } = props;
  const { layout, content, encrypt, origin } = page;
  const { index, collapsed = true, depth = 3 } = widget;

  if (config.toc !== true || (layout !== 'page' && layout !== 'post')) {
    return null;
  }

  return {
    title: helper._p('widget.catalogue', Infinity),
    collapsed: collapsed !== false,
    maxDepth: depth | 0,
    showIndex: index !== false,
    content: encrypt ? origin : content,
    jsUrl: helper.url_for('/js/toc.js'),
  };
});

module.exports = Toc;