/* info.js - Javascript UI for Texinfo manuals
   Copyright (C) 2017-2022 Free Software Foundation, Inc.

   This file is part of GNU Texinfo.

   GNU Texinfo is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 3 of the License, or
   (at your option) any later version.

   GNU Texinfo is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with GNU Texinfo.  If not, see <http://www.gnu.org/licenses/>.  */

(function (features, user_config) {
  "use strict";

  /*-------------------.
  | Define constants.  |
  `-------------------*/

  /** To override those default parameters, define 'INFO_CONFIG' in the global
      environment before loading this script.  */
  var config = {
    EXT: ".html",
    INDEX_NAME: "index.html",
    INDEX_ID: "index",
    CONTENTS_ID: "SEC_Contents",
    MAIN_ANCHORS: ["Top"],
    WARNING_TIMEOUT: 3000,
    SCREEN_MIN_WIDTH: 700,
    LOCAL_HTML_PAGE_PATTERN: "^([^:/]*[.](html|htm|xhtml))?([#].*)?$",
    SHOW_SIDEBAR_HTML: '<span class="hide-icon">&#x21db;</span>',
    HIDE_SIDEBAR_HTML: '<span class="hide-icon">&#x21da;</span><span class="hide-text"></span>',
    SHOW_SIDEBAR_TOOLTIP: 'Show navigation sidebar',
    HIDE_SIDEBAR_TOOLTIP: 'Hide navigation sidebar',

    // hooks:
    /** Define a function called after 'DOMContentLoaded' event in
        the INDEX_NAME context.
        @type {function (): void}*/
    on_main_load: null,
    /** Define a function called after 'DOMContentLoaded' event in
        the iframe context.
        @type {(function (): void)} */
    on_iframe_load: null
  };

  /*-------------------.
  | State Management.  |
  `-------------------*/

  /** A 'store' is an object managing the state of the application and having
      a dispatch method which accepts actions as parameter.  This method is
      the only way to update the state.
      @typedef {function (Action): void} Action_consumer
      @type {{dispatch: Action_consumer, state?: any, listeners?: any[]}}.  */
  var store;

  var section_names = [
    'top', 'chapter', 'unnumbered', 'chapheading', 'appendix',
    'section', 'unnumberedsec', 'heading', 'appendixsec',
    'subsection', 'unnumberedsubsec', 'subheading', 'appendixsubsec',
    'subsubsection', 'unnumberedsubsubsec', 'subsubheading',
    'appendixsubsubsec' ];

  /** Create a Store that calls its listeners at each state change.
      @arg {function (Object, Action): Object} reducer
      @arg {Object} state  */
  function
  Store (reducer, state)
  {
    this.listeners = [];
    this.reducer = reducer;
    this.state = state;
  }

  /** @arg {Action} action */
  Store.prototype.dispatch = function dispatch (action) {
    var new_state = this.reducer (this.state, action);
    if (new_state !== this.state)
      {
        this.state = new_state;
        if (window["INFO_DEBUG"])
          console.log ("state: ", new_state);
        this.listeners.forEach (function (listener) {
          listener (new_state);
        });
      }
  };

  /** Build a store delegate that will forward its actions to a "real"
      store that can be in a different browsing context.  */
  function
  Remote_store ()
  {
    /* The browsing context containing the real store.  */
    this.delegate = window.parent;
  }

  /** Dispatch ACTION to the delegate browing context.  This method must be
      used in conjunction of an event listener on "message" events in the
      delegate browsing context which must forwards ACTION to an actual store.
      @arg {Action} action */
  Remote_store.prototype.dispatch = function dispatch (action) {
    this.delegate.postMessage ({ message_kind: "action", action: action }, "*");
  };

  /** @typedef {{type: string, [x: string]: any}} Action - Payloads
      of information meant to be treated by the store which can receive them
      using the 'store.dispatch' method.  */

  /* Place holder for action creators.  */
  var actions = {
    /** Return an action that makes LINKID the current page.
        @arg {string} linkid - link identifier
        @arg {string|false} [history] - method name that will be applied on
        the 'window.history' object.  */
    set_current_url: function (linkid, history, clicked) {
      if (undef_or_null (history))
        history = "pushState";
      return { type: "current-url", url: linkid,
               history: history, clicked: clicked };
    },

    /** Set current URL to the node corresponding to POINTER which is an
        id refering to a linkid (such as "*TOP*" or "*END*"). */
    set_current_url_pointer: function (pointer) {
      return { type: "current-url", pointer: pointer, history: "pushState" };
    },

    /** @arg {string} dir */
    navigate: function (dir) {
      return { type: "navigate", direction: dir, history: "pushState" };
    },

    /** @arg {{[id: string]: string}} links */
    cache_links: function (links) {
      return { type: "cache-links", links: links };
    },

    /** @arg {NodeListOf<Element>} links */
    cache_index_links: function (links) {
      var dict = {};
      var text0 = "", text1 = ""; // for subentries
      for (var i = 0; i < links.length; i += 1)
        {
          var link = links[i];
          var link_cl = link.classList;
          var text = link.textContent;
          if (link_cl.contains("index-entry-level-2"))
            {
                text = text0 + "; " + text1 + "; " + text;
            }
          else if (link_cl.contains("index-entry-level-1"))
            {
              text1 = text;
                text = text0 + "; " + text;
            }
          else
            {
              text0 = text;
            }

          if ((link = link.parentElement.parentElement.lastChild)
              && link.classList.contains("printindex-index-section")
              && (link = link.firstChild))
            {
              dict[text] = href_hash (link_href (link));
            }
        }
      return { type: "cache-index-links", links: dict };
    },

    /** Show or hide the help screen.  */
    show_help: function () {
      return { type: "help", visible: true };
    },

    /** Make the text input INPUT visible.  If INPUT is a falsy value then
        hide current text input.
        @arg {string} input */
    show_text_input: function (input) {
      return { type: "input", input: input };
    },

    /** Hide the current current text input.  */
    hide_text_input: function () {
      return { type: "input", input: null };
    },

    /** @arg {string} msg */
    warn: function (msg) {
      return { type: "warning", msg: msg };
    },

    window_title: function (title) {
      return { type: "window-title", title: title };
    },

    /** Search EXP in the whole manual.
        @arg {RegExp|string} exp*/
    search: function (exp) {
      var rgxp;
      if (typeof exp === "object")
        rgxp = exp;
      else if (exp === "")
        rgxp = null;
      else
        rgxp = new RegExp (exp, "i");

      return { type: "search-init", regexp: rgxp, input: exp };
    }
  };

  /** Update STATE based on the type of ACTION.  This update is purely
      fonctional since STATE is not modified in place and a new state object
      is returned instead.
      @arg {Object} state
      @arg {Action} action
      @return {Object} a new state */
  /* eslint-disable complexity */
  /* A "big" switch has been preferred over splitting each case in a separate
     function combined with a dictionary lookup.  */
  function
  updater (state, action)
  {
    var res = Object.assign ({}, state, { action: action });
    var linkid;
    switch (action.type)
      {
      case "cache-links":
        {
          var nodes = Object.assign ({}, state.loaded_nodes);
          Object
            .keys (action.links)
            .forEach (function (key) {
              if (typeof action.links[key] === "object")
                nodes[key] = Object.assign ({}, nodes[key], action.links[key]);
              else
                nodes[key] = action.links[key];
            });

          return Object.assign (res, { loaded_nodes: nodes });
        }
      case "cache-index-links":
        {
          // Initially res.index is undefined, which is ignored.
          res.index = Object.assign ({}, res.index, action.links);
          return res;
        }
      case "section":
        {
          res.section_hash = action.section_hash;
          return res;
        }
      case "current-url":
        {
          if (document.body.getAttribute("show-sidebar") == "yes"
              && is_narrow_window ()
              && action.clicked === "in-page")
            return state;
          linkid = (action.pointer) ?
              state.loaded_nodes[action.pointer] : action.url;

          res.current = linkid;
          res.section_hash = null;
          res.history = action.history;
          res.text_input = null;
          res.warning = null;
          res.window_title = null;
          res.help = false;
          res.focus = false;
          res.highlight = null;
          res.loaded_nodes = Object.assign ({}, res.loaded_nodes);
          res.loaded_nodes[linkid] = res.loaded_nodes[linkid] || {};
          return res;
        }
      case "unfocus":
        {
          if (!res.focus)
            return state;
          else
            {
              res.help = false;
              res.text_input = null;
              res.focus = false;
              return res;
            }
        }
      case "help":
        {
          res.help = action.visible;
          res.focus = true;
          return res;
        }
      case "show-sidebar": // "yes" (show), "no" (hide) or "hide-if-narrow"
        {
          res.show_sidebar = action.show === "hide-if-narrow" && res.show_sidebar === "no" ? "no" : action.show;
          return res;
        }
      case "navigate":
        {
          var current = state.current;
          var link = linkid_split (state.current);

          /* Handle inner 'config.INDEX_NAME' anchors specially.  */
          if (link.pageid === config.INDEX_ID)
            current = config.INDEX_ID;

          var ids = state.loaded_nodes[current];
          linkid = ids[action.direction];
          if (!linkid)
            {
              // Look for sibling link in ToC.
              // Needed for (say) @subsection without corresponding @node.
              let toc_current =
                document.querySelector ('#slider a[toc-current="yes"]');
              if (toc_current)
                {
                  let item_current = toc_current.parentNode; // 'li' element
                  let nlink = (action.direction === "next"
                               ? item_current.nextElementSibling
                               : action.direction === "prev"
                               ? item_current.previousElementSibling
                               : action.direction === "up"
                               ? item_current.parentNode.parentNode
                               : null);
                  if (nlink)
                    nlink = nlink.firstElementChild;
                  if (nlink && nlink.matches("a"))
                    {
                      let href = link_href (nlink)
                      if (href)
                        linkid = href_hash (href) ;
                    }
                }
            }
          if (!linkid)
            {
              /* When CURRENT is in index but doesn't have the requested
                 direction, ask its corresponding 'pageid'.  */
              var is_index_ref =
                Object.keys (state.index)
                      .reduce (function (acc, val) {
                        return acc || state.index[val] === current;
                      }, false);
              if (is_index_ref)
                {
                  ids = state.loaded_nodes[link.pageid];
                  linkid = ids[action.direction];
                }
            }

          if (!linkid)
            return state;
          else
            {
              res.current = linkid;
              res.section_hash = null;
              res.history = action.history;
              res.text_input = null;
              res.warning = null;
              res.window_title = null;
              res.help = false;
              res.highlight = null;
              res.focus = false;
              res.loaded_nodes = Object.assign ({}, res.loaded_nodes);
              res.loaded_nodes[action.url] = res.loaded_nodes[action.url] || {};
              return res;
            }
        }
      case "search-init":
        {
          res.search = {
            regexp: action.regexp || state.search.regexp,
            input: action.input || state.search.input,
            status: "ready",
            current_pageid: linkid_split (state.current).pageid,
            found: false
          };
          res.focus = false;
          res.help = false;
          res.text_input = null;
          res.warning = null;
          return res;
        }
      case "search-query":
        {
          res.search = Object.assign ({}, state.search);
          res.search.status = "searching";
          return res;
        }
      case "search-result":
        {
          res.search = Object.assign ({}, state.search);
          if (action.found)
            {
              res.search.status = "done";
              res.search.found = true;
              res.current = res.search.current_pageid;
              res.section_hash = null;
              res.history = "pushState";
              res.highlight = res.search.regexp;
            }
          else
            {
              var fwd = forward_pageid (state, state.search.current_pageid);
              if (fwd === null)
                {
                  res.search.status = "done";
                  res.warning = "Search failed: \"" + res.search.input + "\"";
                  res.highlight = null;
                }
              else
                {
                  res.search.current_pageid = fwd;
                  res.search.status = "ready";
                }
            }
          return res;
        }
      case "input":
        {
          var needs_update = (state.text_input && !action.input)
              || (!state.text_input && action.input)
              || (state.text_input && action.input
                  && state.text_input !== action.input);

          if (!needs_update)
            return state;
          else
            {
              res.focus = Boolean (action.input);
              res.help = false;
              res.text_input = action.input;
              res.warning = null;
              return res;
            }
        }
      case "iframe-ready":
        {
          res.ready = Object.assign ({}, res.ready);
          res.ready[action.id] = true;
          return res;
        }
      case "echo":
        {
          res.echo = action.msg;
          return res;
        }
      case "window-title":
        {
          res.window_title = action.title;
          return res;
        }
      case "warning":
        {
          res.warning = action.msg;
          if (action.msg !== null)
            res.text_input = null;
          return res;
        }
      default:
        console.error ("no reducer for action type:", action.type);
        return state;
      }
  }
  /* eslint-enable complexity */

  /*-----------------------.
  | Context initializers.  |
  `-----------------------*/

  /** Initialize the index page of the manual which manages the state of the
      application.  */
  function
  init_index_page ()
  {
    /*--------------------------.
    | Component for help page.  |
    `--------------------------*/

    function
    Help_page ()
    {
      this.id = "help-screen";
      /* Create help div element.*/
      var div = document.createElement ("div");
      div.setAttribute ("hidden", "true");
      div.classList.add ("modal");
      div.innerHTML = "\
<div class=\"modal-content\">\
<span class=\"close\">&times;</span>\
<h2>Keyboard Shortcuts</h2>\
<table id=\"keyboard-shortcuts\">\
  <tbody>\
    <tr><th colspan=\"2\">Moving around</th></tr>\
    <tr><td><code><b>n / p</b></code></td>\
        <td>goto the next / previous section</td></tr>\
    <tr><td><code><b>[ / ]</b></code></td>\
        <td>goto the next / previous sibling</td></tr>\
    <tr><td><code><b>&lt; / &gt;</b></code></td>\
        <td>goto the first / last section</td></tr>\
    <tr><td><code><b>t</b></code></td><td>goto the first section</td></tr>\
    <tr><td><code><b>u</b></code></td>\
        <td>go one level up (parent section)</td></tr>\
    <tr><td><code><b>l / r</b></code></td>\
        <td>go back / forward in history.</td></tr>\
  </tbody>\
  <tbody>\
    <tr><th colspan=\"2\">Searching</th></tr>\
    <tr><td><code><b>i</b></code></td><td>search in the index</td></tr>\
    <tr><td><code><b>s</b></code></td>\
        <td>global text search in the manual</td></tr>\
    <tr><td><code><b>m</b></code></td>\
        <td>search in current node menu</td></tr>\
  </tbody>\
  <tbody>\
    <tr><th colspan=\"2\">Misc</th></tr>\
    <tr><td><code><b>?</b></code></td><td>show this help screen</td></tr>\
    <tr><td><code><b>Esc</b></code></td><td>hide this help screen</td></tr>\
  </tbody>\
</table>\
</div>\
";
      var span = div.querySelector (".close");
      div.addEventListener ("click", function (event) {
        if (event.target === span || event.target === div)
          store.dispatch ({ type: "unfocus" });
      }, false);

      this.element = div;
    }

    Help_page.prototype.render = function render (state) {
      if (!state.help)
        this.element.style.display = "none";
      else
        {
          this.element.style.display = "block";
          this.element.focus ();
        }
    };

    /*---------------------------------.
    | Components for menu navigation.  |
    `---------------------------------*/

    /** @arg {string} id */
    function
    Search_input (id)
    {
      this.id = id;
      this.render = null;
      this.prompt = document.createTextNode ("Text search" + ": ");

      /* Create input div element.*/
      var div = document.createElement ("div");
      div.setAttribute ("hidden", "true");
      div.appendChild (this.prompt);
      this.element = div;

      /* Create input element.*/
      var input = document.createElement ("input");
      input.setAttribute ("type", "search");
      this.input = input;
      this.element.appendChild (input);

      /* Define a special key handler when 'this.input' is focused and
         visible.*/
      this.input.addEventListener ("keydown", (function (event) {
        if (is_escape_key (event.key))
          store.dispatch ({ type: "unfocus" });
        else if (event.key === "Enter")
          store.dispatch (actions.search (this.input.value));
        event.stopPropagation ();
      }).bind (this));
    }

    Search_input.prototype.show = function show () {
      /* Display previous search. */
      var search = store.state.search;
      var input = search && (search.status === "done") && search.input;
      if (input)
        this.prompt.textContent = "Text search (default " + input + "): ";
      this.element.removeAttribute ("hidden");
      this.input.focus ();
    };

    Search_input.prototype.hide = function hide () {
      this.element.setAttribute ("hidden", "true");
      this.input.value = "";
    };

    /** @arg {string} id */
    function
    Text_input (id)
    {
      this.id = id;
      this.data = null;
      this.datalist = null;
      this.render = null;

      /* Create input div element.*/
      var div = document.createElement ("div");
      div.setAttribute ("hidden", "true");
      div.appendChild (document.createTextNode (id + ": "));
      this.element = div;

      /* Create input element.*/
      var input = document.createElement ("input");
      input.setAttribute ("type", "search");
      input.setAttribute ("list", id + "-data");
      this.input = input;
      this.element.appendChild (input);

      /* Define a special key handler when 'this.input' is focused and
         visible.*/
      this.input.addEventListener ("keydown", (function (event) {
        if (is_escape_key (event.key))
          store.dispatch ({ type: "unfocus" });
        else if (event.key === "Enter")
          {
            var linkid = this.data[this.input.value];
            if (linkid)
              {
                hide_sidebar_if_narrow ();
                store.dispatch (actions.set_current_url (linkid));
              }
          }
        event.stopPropagation ();
      }).bind (this));
    }

    /* Display a text input for searching through DATA.*/
    Text_input.prototype.show = function show (data) {
      if (!features || features.datalistelem)
        {
          var datalist = create_datalist (data);
          datalist.setAttribute ("id", this.id + "-data");
          this.data = data;
          this.datalist = datalist;
          this.element.appendChild (datalist);
        }
      this.element.removeAttribute ("hidden");
      this.input.focus ();
    };

    Text_input.prototype.hide = function hide () {
      this.element.setAttribute ("hidden", "true");
      this.input.value = "";
      if (this.datalist)
        {
          this.datalist.remove ();
          this.datalist = null;
        }
    };

    function
    Minibuffer ()
    {
      /* Create global container.*/
      var elem = document.createElement ("div");
      elem.classList.add ("text-input");

      var menu = new Text_input ("menu");
      menu.render = function (state) {
        if (state.text_input === "menu")
        {
          var current_menu = state.loaded_nodes[state.current].menu;
          if (current_menu)
            this.show (current_menu);
          else
            store.dispatch (actions.warn ("No menu in this node"));
        }
      };

      var index = new Text_input ("index");
      index.render = function (state) {
        if (state.text_input === "index")
        {
          if (state.index)
            this.show (state.index);
          else
            store.dispatch (actions.warn ("No index in this document"))
        }
      };

      var search = new Search_input ("regexp-search");
      search.render = function (state) {
        if (state.text_input === "regexp-search")
          this.show ();
      };

      elem.appendChild (menu.element);
      elem.appendChild (index.element);
      elem.appendChild (search.element);

      /* Create a container for warning when no menu in current page.*/
      var warn$ = document.createElement ("div");
      warn$.setAttribute ("hidden", "true");
      elem.appendChild (warn$);

      this.element = elem;
      this.menu = menu;
      this.index = index;
      this.search = search;
      this.warn = warn$;
      this.toid = null;
    }

    Minibuffer.prototype.render = function render (state) {
      if (!state.warning)
        {
          this.warn.setAttribute ("hidden", "true");
          this.toid = null;
        }
      else if (!this.toid)
        {
          console.warn (state.warning);
          var toid = window.setTimeout (function () {
            store.dispatch ({ type: "warning", msg: null });
          }, config.WARNING_TIMEOUT);
          this.warn.innerHTML = state.warning;
          this.warn.removeAttribute ("hidden");
          this.toid = toid;
        }

      if (!state.text_input || state.warning)
        {
          this.menu.hide ();
          this.index.hide ();
          this.search.hide ();
        }
      else
        {
          this.index.render (state);
          this.menu.render (state);
          this.search.render (state);
        }
    };

    function
    Echo_area ()
    {
      var elem = document.createElement ("div");
      elem.classList.add ("echo-area");
      elem.setAttribute ("hidden", "true");

      this.element = elem;
      this.toid = null;
    }

    Echo_area.prototype.render = function render (state) {
      if (!state.echo)
        {
          this.element.setAttribute ("hidden", "true");
          this.toid = null;
        }
      else if (!this.toid)
        {
          console.info (state.echo);
          var toid = window.setTimeout (function () {
            store.dispatch ({ type: "echo", msg: null });
          }, config.WARNING_TIMEOUT);
          this.element.innerHTML = state.echo;
          this.element.removeAttribute ("hidden");
          this.toid = toid;
        }
    };

    /*----------------------------.
    | Component for the sidebar.  |
    `----------------------------*/

    function
    Sidebar (contents_node)
    {
      this.element = document.createElement ("div"); // FIXME unneeded?
      this.element.setAttribute ("id", "slider");
      var div = document.createElement ("div");
      div.classList.add ("toc-sidebar");
      var toc = document.querySelector ("#SEC_Contents");
      toc.remove ();

      // Like n.cloneNode, but also copy _href
      function cloneNode(n)
      {
        let r = n.cloneNode(false);
        for (let c = n.firstChild; c; c = c.nextSibling)
          {
            let d = cloneNode(c);
            let h = c._href;
            if (h)
              d._href = h;
            r.appendChild(d);
          }
        return r;
      }
      contents_node.appendChild(cloneNode(toc));

      /* Remove table of contents header.  */
      toc = toc.querySelector(".contents"); // skip ToC header

      /* Move contents of <body> into a a fresh <div> to let the components
         treat the index page like other iframe page.  */
      var nav = document.createElement ("nav");
      nav.classList.add ("contents");
      for (var ch = toc.firstChild; ch; ch = toc.firstChild)
        nav.appendChild (ch);

      var div$ = document.createElement ("div");
      div$.classList.add ("toc");
      div$.appendChild (nav);
      div.appendChild (div$);
      this.element.appendChild (div);

      let header = document.createElement ("header");
      div$.parentElement.insertBefore (header, div$);
      let hider = document.createElement ("button");
      hider.classList.add ("sidebar-hider");
      hider.innerHTML = config.HIDE_SIDEBAR_HTML;
      this.show_sidebar_button = hider;
      header.appendChild(hider);
    }

    /* Render 'sidebar' according to STATE which is a new state. */
    Sidebar.prototype.render = function render (state) {
      /* Update sidebar to highlight the title corresponding to
         'state.current'.*/
      let currently_showing = document.body.getAttribute("show-sidebar");
      let show = state.show_sidebar;
      if (show == "hide-if-narrow")
        show = is_narrow_window() || currently_showing == "no" ? "no" : "yes";
      if (show === undefined)
        show = "yes";
      if (show !== currently_showing)
        {
          document.body.setAttribute("show-sidebar", show);
          this.show_sidebar_button.innerHTML = show == "yes" ? config.HIDE_SIDEBAR_HTML : config.SHOW_SIDEBAR_HTML;
          let tooltip = show == "yes" ? config.HIDE_SIDEBAR_TOOLTIP : config.SHOW_SIDEBAR_TOOLTIP;
          if (tooltip)
            this.show_sidebar_button.setAttribute("title", tooltip);
          else
            this.show_sidebar_button.removeAttribute("title");
        }
      var msg = { message_kind: "update-sidebar", selected: state.current, section_hash: state.section_hash };
      window.postMessage (msg, "*");
    };

    /*--------------------------.
    | Component for the pages.  |
    `--------------------------*/

    /** @arg {HTMLDivElement} index_div */
    function
    Pages (index_div)
    {
      index_div.setAttribute ("id", config.INDEX_ID);
      index_div.setAttribute ("node", config.INDEX_ID);
      index_div.setAttribute ("hidden", "true");
      this.element = document.createElement ("div");
      this.element.setAttribute ("id", "sub-pages");
      this.element.appendChild (index_div);
      /** @type {string[]} Currently created divs.  */
      this.ids = [config.INDEX_ID];
      /** @type {string} */
      this.prev_id = null;
      /** @type {HTMLElement} */
      this.prev_div = null;
      this.prev_search = null;
      this.main_title = document.title;
    }

    Pages.prototype.add_div = function add_div (pageid) {
      var div = document.createElement ("div");
      div.setAttribute ("id", pageid);
      div.setAttribute ("node", pageid);
      div.setAttribute ("hidden", "true");
      this.ids.push (pageid);
      this.element.appendChild (div);
      if (linkid_contains_index (pageid))
        load_page (pageid);
      return div;
    };

    Pages.prototype.render = function render (state) {
      var that = this;

      /* Create div elements for pages corresponding to newly added
         linkids from 'state.loaded_nodes'.*/
      Object.keys (state.loaded_nodes)
            .map (function (id) { return id.replace (/\..*$/, ""); })
            .filter (function (id) { return !that.ids.includes (id); })
            .reduce (function (acc, id) {
              return ((acc.includes (id)) ? acc : acc.concat ([id]));
            }, [])
            .forEach (function (id) { return that.add_div (id); });

      /* Blur pages if help screen is on.  */
      this.element.classList[(state.help) ? "add" : "remove"] ("blurred");
      let div = this.prev_div;
      if (state.current !== this.prev_id)
        {
          if (this.prev_id)
            {
              this.prev_div.setAttribute ("hidden", "true");
              /* Remove previous highlights.  */
              var old = linkid_split (this.prev_id);
              var msg = { message_kind: "highlight", regexp: null };
              post_message (old.pageid, msg);
            }
          div = resolve_page (state.current, true);
          update_history (state.current, state.history);
          this.prev_id = state.current;
          this.prev_div = div;
        }
        if (state.action.type === "window-title") {
            div.setAttribute("title", state.action.title);
        }
        let title = div.getAttribute("title") || this.main_title;
        if (title && document.title !== title)
            document.title = title;

      if (state.search
          && (this.prev_search !== state.search)
          && state.search.status === "ready")
        {
          this.prev_search = state.search;
          if (state.search.current_pageid === config.INDEX_ID)
            {
              window.setTimeout (function () {
                store.dispatch ({ type: "search-query" });
                var res = search (document.getElementById (config.INDEX_ID),
                                  state.search.regexp);
                store.dispatch ({ type: "search-result", found: res });
              }, 0);
            }
          else
            {
              window.setTimeout (function () {
                store.dispatch ({ type: "search-query" });
                var msg = {
                  message_kind: "search",
                  regexp: state.search.regexp,
                  id: state.search.current_pageid
                };
                post_message (state.search.current_pageid, msg);
              }, 0);
            }
        }

      /* Update highlight of current page.  */
      if (!state.highlight)
        {
          if (state.current === config.INDEX_ID)
            remove_highlight (document.getElementById (config.INDEX_ID));
          else
            {
              var link = linkid_split (state.current);
              var msg$ = { message_kind: "highlight", regexp: null };
              post_message (link.pageid, msg$);
            }
        }

      /* Scroll to highlighted search result. */
      if (state.search
          && (this.prev_search !== state.search)
          && state.search.status === "done"
          && state.search.found === true)
        {
          var link$ = linkid_split (state.current);
          var msg$$ = { message_kind: "scroll-to", hash: "#search-result" };
          post_message (link$.pageid, msg$$);
          this.prev_search = state.search;
        }
    };

    /*--------------.
    | Utilitaries.  |
    `--------------*/

    /** Load PAGEID.
        @arg {string} pageid */
    function
    load_page (pageid)
    {
      var div = resolve_page (pageid, false);
      /* Making the iframe visible triggers the load of the iframe DOM.  */
      if (div.hasAttribute ("hidden"))
        {
          div.removeAttribute ("hidden");
          div.setAttribute ("hidden", "true");
        }
    }

    /** Return the div element that correspond to LINKID.  If VISIBLE is true
        then display the div and position the corresponding iframe to the
        anchor specified by LINKID.
        @arg {string} linkid - link identifier
        @arg {boolean} [visible]
        @return {HTMLElement} the div element.  */
    function
    resolve_page (linkid, visible)
    {
      var msg;
      var link = linkid_split (linkid);
      var pageid = link.pageid;
      var div = document.getElementById (link.pageid);
      if (!div)
        {
          msg = "no iframe container correspond to identifier: " + pageid;
          throw new ReferenceError (msg);
        }

      /* Create iframe if necessary unless the div is refering to the Index
         or Contents page.  */
      if (pageid === config.INDEX_ID || pageid === config.CONTENTS_ID)
        {
          div.removeAttribute ("hidden");
          /* Unlike iframes, Elements are unlikely to be scrollable (CSSOM
             Scroll-behavior), so choose an arbitrary element inside "index"
             div and at the top of it.  */
          document.getElementById ("icon-bar").scrollIntoView ();
        }
      else
        {
          var iframe = div.querySelector ("iframe");
          if (!iframe)
            {
              iframe = document.createElement ("iframe");
              iframe.classList.add ("node");
              iframe.setAttribute ("src", linkid_to_url (pageid));
              div.appendChild (iframe);
              iframe.addEventListener ("load", function () {
                store.dispatch ({ type: "iframe-ready", id: pageid });
              }, false);
            }
          if (visible)
            {
              div.removeAttribute ("hidden");
              msg = { message_kind: "scroll-to", hash: link.hash };
              post_message (pageid, msg);
            }
        }

      return div;
    }

    /** Create a datalist element containing option elements corresponding
        to the keys in MENU.  */
    function
    create_datalist (menu)
    {
      var datalist = document.createElement ("datalist");
      Object.keys (menu)
            .sort ()
            .forEach (function (title) {
              var opt = document.createElement ("option");
              opt.setAttribute ("value", title);
              datalist.appendChild (opt);
            });
      return datalist;
    }

    /** Mutate the history of page navigation.  Store LINKID in history
        state, The actual way to store LINKID depends on HISTORY_MODE.
        @arg {string} linkid
        @arg {string} history_mode */
    function
    update_history (linkid, history_mode)
    {
      var method = window.history[history_mode];
      if (method)
        {
          /* Pretend that the current page is the one corresponding to the
             LINKID iframe.  Handle errors since changing the visible file
             name can fail on some browsers with the "file:" protocol.  */
          var visible_url =
            dirname (window.location.pathname) + linkid_to_url (linkid);
          try
            {
              method.call (window.history, linkid, null, visible_url);
            }
          catch (err)
            {
              /* Fallback to changing only the hash part which is safer.  */
              visible_url = window.location.pathname;
              if (linkid !== config.INDEX_ID)
                visible_url += ("#" + linkid);
              method.call (window.history, linkid, null, visible_url);
            }
        }
    }

    /** Send MSG to the browsing context corresponding to PAGEID.  This is a
        wrapper around 'Window.postMessage' which ensures that MSG is sent
        after PAGEID is loaded.
        @arg {string} pageid
        @arg {any} msg */
    function
    post_message (pageid, msg)
    {
      if (pageid === config.INDEX_ID || pageid === config.CONTENTS_ID)
        window.postMessage (msg, "*");
      else
        {
          load_page (pageid);
          var iframe = document.getElementById (pageid)
                               .querySelector ("iframe");
          /* Semantically this would be better to use "promises" however
             they are not available in IE.  */
          if (store.state.ready[pageid])
            iframe.contentWindow.postMessage (msg, "*");
          else
            {
              iframe.addEventListener ("load", function handler () {
                this.contentWindow.postMessage (msg, "*");
                this.removeEventListener ("load", handler, false);
              }, false);
            }
        }
    }

    /*--------------------------------------------
    | Event handlers for the top-level context.  |
    `-------------------------------------------*/

    /* Initialize the top level 'config.INDEX_NAME' DOM.  */
    function
    on_load ()
    {
      fix_links (document.links);
      add_icons ();
      document.body.classList.add ("mainbar");

      /* Move contents of <body> into a a fresh <div> to let the components
         treat the index page like other iframe page.  */
      var index_div = document.createElement ("div");
      for (var ch = document.body.firstChild; ch; ch = document.body.firstChild)
        index_div.appendChild (ch);

      /* Aggregation of all the sub-components.  */
      var components = {
        element: document.body,
        components: [],

        add: function add (component) {
          this.components.push (component);
          this.element.appendChild (component.element);
        },

        render: function render (state) {
          this.components
              .forEach (function (cmpt) { cmpt.render (state); });

          /* Ensure that focus is on the current node unless some component is
             focused.  */
          if (!state.focus)
            {
              var link = linkid_split (state.current);
              var elem = document.getElementById (link.pageid);
              if (link.pageid !== config.INDEX_ID && link.pageid !== config.CONTENTS_ID)
                elem.querySelector ("iframe").focus ();
              else
                {
                  /* Move the focus to top DOM.  */
                  document.documentElement.focus ();
                  /* Allow the spacebar scroll in the main page to work.  */
                  elem.focus ();
                }
            }
        }
      };
      var pages = new Pages (index_div);
      components.add (pages);
      var contents_node = pages.add_div(config.CONTENTS_ID);
      components.add (new Sidebar (contents_node));
      components.add (new Help_page ());
      components.add (new Minibuffer ());
      components.add (new Echo_area ());
      store.listeners.push (components.render.bind (components));

      if (window.location.hash)
        {
          var linkid = normalize_hash (window.location.hash);
          store.dispatch (actions.set_current_url (linkid, "replaceState"));
        }

      /* Retrieve NEXT link and local menu.  */
      var links = {};
      links[config.INDEX_ID] = navigation_links (document);
      store.dispatch (actions.cache_links (links));
      store.dispatch ({ type: "iframe-ready", id: config.INDEX_ID });
      store.dispatch ({
        type: "echo",
        msg: "Welcome to Texinfo documentation viewer 7.0.1, type '?' for help."
      });

      /* Call user hook.  */
      if (config.on_main_load)
        config.on_main_load ();
    }

    /* Handle messages received via the Message API.
       @arg {MessageEvent} event */
    function
    on_message (event)
    {
      var data = event.data;
      if (data.message_kind === "action")
        {
          /* Follow up actions to the store.  */
          store.dispatch (data.action);
        }
    }

    /* Event handler for 'popstate' events.  */
    function
    on_popstate (event)
    {
      /* When EVENT.STATE is 'null' it means that the user has manually
         changed the hash part of the URL bar.  */
      var linkid = (event.state === null) ?
        normalize_hash (window.location.hash) : event.state;
      store.dispatch (actions.set_current_url (linkid, false));
    }

    return {
      on_load: on_load,
      on_message: on_message,
      on_popstate: on_popstate
    };
  }

  /** Initialize the iframe which contains the lateral table of content.  */
  function
  init_sidebar ()
  {
    /* Keep children but remove grandchildren (Exception: don't remove
       anything on the current page; however, that's not a problem in the Kawa
       manual).  */
    function
    hide_grand_child_nodes (ul, excluded)
    {
      var lis = ul.children;
      for (var i = 0; i < lis.length; i += 1)
        {
          if (lis[i] === excluded)
            continue;
          var first = lis[i].firstElementChild;
          if (first && first.matches ("ul"))
            hide_grand_child_nodes (first);
          else if (first && first.matches ("a"))
            {
              var ul$ = first && first.nextElementSibling;
              if (ul$)
                ul$.setAttribute ("toc-detail", "yes");
            }
        }
    }

    /** Make the parent of ELEMS visible.
        @arg {HTMLElement} elem */
    function
    mark_parent_elements (elem)
    {
      if (elem && elem.parentElement && elem.parentElement.parentElement)
        {
          var pparent = elem.parentElement.parentElement;
          for (var sib = pparent.firstElementChild; sib;
               sib = sib.nextElementSibling)
            {
              if (sib !== elem.parentElement
                  && sib.firstElementChild
                  && sib.firstElementChild.nextElementSibling)
                {
                  sib.firstElementChild
                     .nextElementSibling
                     .setAttribute ("toc-detail", "yes");
                }
            }
        }
    }

    /** Scan ToC entries to see which should be hidden.
        @arg {HTMLElement} elem
        @arg {string} linkid */
    function
    scan_toc (elem, linkid, section_hash = null)
    {
      /** @type {Element} */
      var res;
      if (section_hash)
        {
          let dot = linkid.lastIndexOf('.');
          if (dot >= 0)
            linkid = linkid.substring(0, dot+1) + section_hash;
        }
      var url = with_sidebar_query (linkid_to_url (linkid));

      /** Set CURRENT to the node corresponding to URL linkid.
          @arg {Element} elem */
      function
      find_current (elem)
      {
        if (elem.localName === "a" && link_href(elem) == url)
          {
            elem.setAttribute ("toc-current", "yes");
            var sib = elem.nextElementSibling;
            if (sib && sib.matches ("ul"))
              hide_grand_child_nodes (sib);
            res = elem;
          }
      }

      var ul = elem.querySelector ("ul");
      if (linkid === config.INDEX_ID || linkid === config.CONTENTS_ID)
        {
          hide_grand_child_nodes (ul);
          res = document.getElementById(linkid);
        }
      else
        {
          depth_first_walk (ul, find_current, Node.ELEMENT_NODE);
          /* Mark every parent node.  */
          var current = res;
          while (current && current !== ul)
            {
              mark_parent_elements (current);
              /* XXX: Special case for manuals with '@part' commands.  */
              if (current.parentElement === ul)
                hide_grand_child_nodes (ul, current);
              current = current.parentElement;
            }
        }
      return res;
    }

    /* Build the global dictionary containing navigation links from NAV.  NAV
       must be an 'ul' DOM element containing the table of content of the
       manual.  */
    function
    create_link_dict (nav)
    {
      var prev_id = config.INDEX_ID;
      var links = {};

      function
      add_link (elem)
      {
        let href;
          if (elem.matches ("a") && (href = link_href(elem)))
          {
            var id = href_hash (href);
            links[prev_id] =
              Object.assign ({}, links[prev_id], { forward: id });
            links[id] = Object.assign ({}, links[id], { backward: prev_id });
            prev_id = id;
          }
      }

      depth_first_walk (nav, add_link, Node.ELEMENT_NODE);
      /* Add a reference to the first and last node of the manual.  */
      links["*TOP*"] = config.INDEX_ID;
      links["*END*"] = prev_id;
      return links;
    }

    /* Add a link from the sidebar to the main index file.  ELEM is the first
       sibling of the newly created header.  */
    function
    add_header (elem)
    {
      var h1 = document.querySelector ("h1.settitle");
      if (!h1)
        h1 = document.querySelector ("h1.top");
      if (h1)
        {
          var a = document.createElement ("a");
          a.setAttribute ("href", config.INDEX_NAME);
          a.setAttribute ("id", config.INDEX_ID);

          let header = elem.previousSibling;
          header.appendChild (a);
          if (window.sidebarLinkAppendContents)
            window.sidebarLinkAppendContents(a, h1.textContent);
          else
            {
              var div = document.createElement ("div");
              a.appendChild (div);
              var span = document.createElement ("span");
              span.textContent = h1.textContent;
              div.appendChild (span);
            }
        }
    }

    /*------------------------------------------
    | Event handlers for the sidebar context.  |
    `-----------------------------------------*/

    /* Initialize TOC_FILENAME which must be loaded in the context of an
       iframe.  */
    function
    on_load ()
    {
      var toc_div = document.getElementById ("slider");
      add_header (toc_div.querySelector (".toc"));

      /* Specify the base URL to use for all relative URLs.  */
      /* FIXME: Add base also for sub-pages.  */
      var base = document.createElement ("base");
      base.setAttribute ("href",
                         window.location.href.replace (/[/][^/]*$/, "/"));
      document.head.appendChild (base);

      scan_toc (toc_div, config.INDEX_NAME);

      /* Get 'backward' and 'forward' link attributes.  */
      var dict = create_link_dict (toc_div.querySelector ("nav.contents ul"));
      store.dispatch (actions.cache_links (dict));
    }

    /* Handle messages received via the Message API.  */
    function
    on_message (event)
    {
      var data = event.data;
      if (data.message_kind === "update-sidebar")
        {
          var toc_div = document.getElementById ("slider");

          /* Reset previous calls to 'scan_toc'.  */
          depth_first_walk (toc_div, function clear_toc_styles (elem) {
            elem.removeAttribute ("toc-detail");
            elem.removeAttribute ("toc-current");
          }, Node.ELEMENT_NODE);

          /* Remove the hash part for the main page.  */
          var pageid = linkid_split (data.selected).pageid;
          var selected = (pageid === config.INDEX_ID) ? pageid : data.selected;
          /* Highlight the current LINKID in the table of content.  */
          var elem = scan_toc (toc_div, selected, data.section_hash);
          if (elem)
            elem.scrollIntoView (true);
        }
    }

    return {
      on_load: on_load,
      on_message: on_message
    };
  }

  /** Initialize iframes which contain pages of the manual.  */
  function
  init_iframe ()
  {
    /* Initialize the DOM for generic pages loaded in the context of an
       iframe.  */
    function
    on_load ()
    {
      document.body.classList.add ("in-iframe");
      fix_links (document.links);
      var links = {};
      var linkid = basename (window.location.pathname, /[.]x?html$/);
      links[linkid] = navigation_links (document);
      store.dispatch (actions.cache_links (links));
      if (document.title)
         store.dispatch (actions.window_title (document.title));

      if (linkid_contains_index (linkid))
        {
          /* Scan links that should be added to the index.  */
          var index_entries = document.querySelectorAll
            ("td.printindex-index-entry a");
          store.dispatch (actions.cache_index_links (index_entries));
        }

      add_icons ();

      /* Call user hook.  */
      if (config.on_iframe_load)
        config.on_iframe_load ();
    }

    /* Handle messages received via the Message API.  */
    function
    on_message (event)
    {
      var data = event.data;
      if (data.message_kind === "highlight")
        remove_highlight (document.body);
      else if (data.message_kind === "search")
        {
          var found = search (document.body, data.regexp);
          store.dispatch ({ type: "search-result", found: found });
        }
      else if (data.message_kind === "scroll-to")
        {
          /* Scroll to the anchor corresponding to HASH.  */
          if (data.hash)
            {
              let elem = document.getElementById(data.hash.substring(1));
              // Check if hash reference is to a sectioing element.  
              // If not we need to find the outer sectioning element,
              // so we can update the sidebar's ToC correctly.
              if (elem)
                {
                  let p = elem;
                  let section = null;
                  let id = null;
                  for (let p = elem;
                       section === null && p instanceof Element;
                        p = p.parentNode)
                    {
                      let sid = p.getAttribute("id");
                      if (sid == null)
                        continue;
                      let cl = p.classList;
                      for (let i = cl.length; --i >= 0; )
                        {
                          if (section_names.indexOf(cl.item(i)) >= 0)
                            {
                              section = p;
                              id = sid;
                              break;
                            }
                        }
                    }
                    if (section && section !== elem)
                      {
                        // Send section id to sidebar so it can properly update.
                        store.dispatch({ type: "section", hash: data.hash, section_hash: id } );
                      }
                }
                window.location.replace (data.hash);
            }
          else
            window.scroll (0, 0);
        }
    }

    return {
      on_load: on_load,
      on_message: on_message
    };
  }

  /*-------------------------
  | Common event handlers.  |
  `------------------------*/

  /** Handle click events.  */
  function
  on_click (event)
  {
    let in_sidebar = event.target.matches ("#slider *");
    for (var target = event.target; target !== null; target = target.parentNode)
      {
        if (! (target instanceof Element))
          continue;
        if (target.matches ("a"))
          {
            var href = link_href(target);
            if (href && maybe_pageref_url_p (href)
                  && !external_manual_url_p (href))
              {
                var linkid = href_hash (href) || config.INDEX_ID;
                if (linkid === "index.SEC_Contents")
                    linkid = config.CONTENTS_ID;
                store.dispatch (actions.set_current_url (linkid, null,
                                                         in_sidebar ? "in-sidebar" : "in-page"));
                event.preventDefault ();
                event.stopPropagation ();
                break;
              }
          }
        if (target.matches (".sidebar-hider"))
          {
              let body = document.body;
              let show = body.getAttribute("show-sidebar");
              show_sidebar (show==="no");
              return;
          }
      }
    if (! in_sidebar)
      hide_sidebar_if_narrow ();
  }

  // Only valid when showing sidebar.
  function is_narrow_window ()
  {
    return document.body.firstChild.offsetLeft == 0;
  }

  function show_sidebar (show)
  {
    store.dispatch({ type: "show-sidebar", show: show ? "yes" : "no" });
  }

  function hide_sidebar_if_narrow ()
  {
    store.dispatch({ type: "show-sidebar", show: "hide-if-narrow" });
  }

  /** Handle unload events.  */
  function
  on_unload ()
  {
    /* Cross origin requests are not supported in file protocol.  */
    if (window.location.protocol !== "file:")
    {
      var request = new XMLHttpRequest ();
      request.open ("GET", "(WINDOW-CLOSED)");
      request.send (null);
    }
  }

  /** Handle Keyboard 'keydown' events.
      @arg {KeyboardEvent} event */
  function
  on_keydown (event)
  {
    if (is_escape_key (event.key))
      store.dispatch ({ type: "unfocus" });
    else
      {
        var val = on_keydown.dict[event.key];
        if (val)
          {
            if (typeof val === "function")
              val ();
            else
              store.dispatch (val);
            event.preventDefault();
          }
      }
  }

  /* Associate an Event 'key' property to an action or a thunk.  */
  on_keydown.dict = {
    i: actions.show_text_input ("index"),
    l: window.history.back.bind (window.history),
    m: actions.show_text_input ("menu"),
    n: actions.navigate ("next"),
    p: actions.navigate ("prev"),
    r: window.history.forward.bind (window.history),
    s: actions.show_text_input ("regexp-search"),
    t: actions.set_current_url_pointer ("*TOP*"),
    u: actions.navigate ("up"),
    "]": actions.navigate ("forward"),
    "[": actions.navigate ("backward"),
    "<": actions.set_current_url_pointer ("*TOP*"),
    ">": actions.set_current_url_pointer ("*END*"),
    "?": actions.show_help ()
  };

  /** Some standard methods used in this script might not be implemented by
      the current browser.  If that is the case then augment the prototypes
      appropriately.  */
  function
  register_polyfills ()
  {
    function
    includes (search, start)
    {
      start = start || 0;
      return ((start + search.length) <= this.length)
        && (this.indexOf (search, start) !== -1);
    }

    /* eslint-disable no-extend-native */
    if (!Array.prototype.includes)
      Array.prototype.includes = includes;

    if (!String.prototype.includes)
      String.prototype.includes = includes;

    if (!String.prototype.endsWith)
      {
        String.prototype.endsWith = function endsWith (search, position) {
          var subject_string = this.toString ();
          if (typeof position !== "number"
              || !isFinite (position)
              || Math.floor (position) !== position
              || position > subject_string.length)
            position = subject_string.length;

          position -= search.length;
          var last_index = subject_string.lastIndexOf (search, position);
          return last_index !== -1 && last_index === position;
        };
      }

    if (!Element.prototype.matches)
      {
        Element.prototype.matches = Element.prototype["matchesSelector"]
          || Element.prototype["mozMatchesSelector"]
          || Element.prototype["mozMatchesSelector"]
          || Element.prototype["msMatchesSelector"]
          || Element.prototype["webkitMatchesSelector"]
          || function element_matches (str) {
            var document = (this.document || this.ownerDocument);
            var matches = document.querySelectorAll (str);
            var i = matches.length;
            while ((i -= 1) >= 0 && matches.item (i) !== this);
            return i > -1;
          };
      }

    if (typeof Object.assign != "function")
      {
        Object.assign = function assign (target) {
          if (undef_or_null (target))
            throw new TypeError ("Cannot convert undefined or null to object");

          var to = Object (target);
          for (var index = 1; index < arguments.length; index += 1)
            {
              var next_source = arguments[index];
              if (undef_or_null (next_source))
                continue;
              for (var key in next_source)
                {
                  /* Avoid bugs when hasOwnProperty is shadowed.  */
                  if (Object.prototype.hasOwnProperty.call (next_source, key))
                    to[key] = next_source[key];
                }
            }
          return to;
        };
      }

    (function (protos) {
      protos.forEach (function (proto) {
        if (!proto.hasOwnProperty ("remove"))
          {
            Object.defineProperty (proto, "remove", {
              configurable: true,
              enumerable: true,
              writable: true,
              value: function value () {
                this.parentNode.removeChild (this);
              }
            });
          }
      });
    } ([Element.prototype, CharacterData.prototype, DocumentType.prototype]));
    /* eslint-enable no-extend-native */
  }

  /*---------------------.
  | Common utilitaries.  |
  `---------------------*/

  /** Check portably if KEY correspond to "Escape" key value.
      @arg {string} key */
  function
  is_escape_key (key)
  {
    /* In Internet Explorer 9 and Firefox 36 and earlier, the Esc key
       returns "Esc" instead of "Escape".  */
    return key === "Escape" || key === "Esc";
  }

  /** Check if OBJ is equal to 'undefined' or 'null'.  */
  function
  undef_or_null (obj)
  {
    return (obj === null || typeof obj === "undefined");
  }

  /** Return a relative URL corresponding to HREF, which refers to an anchor
      of 'config.INDEX_NAME'.  HREF must be a USVString representing an
      absolute or relative URL.  For example "foo/bar.html" will return
      "config.INDEX_NAME#bar".

      @arg {string} href.*/
  function
  with_sidebar_query (href)
  {
    if (basename (href) === config.INDEX_NAME)
      return config.INDEX_NAME;
    else
      {
        /* XXX: Do not use the URL API for IE portability.  */
        var url = with_sidebar_query.url;
        url.setAttribute ("href", href);
        var new_hash = "#" + basename (url.pathname, /[.]x?html/);
        /* XXX: 'new_hash !== url.hash' is a workaround to work with links
           produced by makeinfo which link to an anchor element in a page
           instead of directly to the page. */
        if (url.hash && new_hash !== url.hash)
          new_hash += ("." + url.hash.slice (1));
        return config.INDEX_NAME + new_hash;
      }
  }

  /* Use the same DOM element for every function call.  */
  with_sidebar_query.url = document.createElement ("a");

  /** Modify LINKS to handle the iframe based navigation properly.  Relative
      links will be opened inside the corresponding iframe and absolute links
      will be opened in a new tab.  If ID is true then define an "id"
      attribute with a linkid value for relative links.

      @typedef {HTMLAnchorElement|HTMLAreaElement} Links
      @arg {Links[]|HTMLCollectionOf<Links>} links
      @arg {boolean} [id]
      @return void  */
  function
  fix_links (links, id)
  {
    for (var i = 0; i < links.length; i += 1)
      {
        var link = links[i];
        var href = link_href(link);
        if (!href)
          continue;
        else if (external_manual_url_p (href))
          link.setAttribute ("target", "_top");
        else if (! maybe_pageref_url_p (href))
          link.setAttribute ("target", "_blank");
        else
          {
            var href$ = with_sidebar_query (href);
            if (href !== href$)
              {
                let protocol = location.protocol;
                if (protocol == "https:" || protocol == "http:")
                  link._href = href$;
                else
                  link.setAttribute ("href", href$);
              }
            if (id)
              {
                var linkid = (href$ === config.INDEX_NAME) ?
                    config.INDEX_ID : href_hash (href$);
                link.setAttribute ("id", linkid);
              }
          }
      }
  }

  /** Convert HASH which is something that can be found 'Location.hash' to a
      "linkid" which can be handled in our model.
      @arg {string} hash
      @return {string} linkid */
  function
  normalize_hash (hash)
  {
    var text = hash.slice (1);
    /* Some anchor elements are present in 'config.INDEX_NAME' and we need to
       handle link to them specially (i.e. not try to find their corresponding
       iframe).*/
    if (config.MAIN_ANCHORS.includes (text))
      return config.INDEX_ID + "." + text;
    else
      return text;
  }

  /** Return an object composed of the filename and the anchor of LINKID.
      LINKID can have the form "foobar.anchor" or just "foobar".
      @arg {string} linkid
      @return {{pageid: string, hash: string}} */
  function
  linkid_split (linkid)
  {
    if (!linkid.includes ("."))
      return { pageid: linkid, hash: "" };
    else
      {
        var ref = linkid.match (/^(.+)\.(.*)$/).slice (1);
        return { pageid: ref[0], hash: "#" + ref[1] };
      }
  }

  /** Convert LINKID which has the form "foobar.anchor" or just "foobar", to
      an URL of the form `foobar${config.EXT}#anchor`.
      @arg {string} linkid */
  function
  linkid_to_url (linkid)
  {
    if (linkid === config.INDEX_ID)
      return config.INDEX_NAME;
    else
      {
        var link = linkid_split (linkid);
        return link.pageid + config.EXT + link.hash;
      }
  }

  /** Check if 'URL' may be a cross-reference to another page in this manual. */
  function
  maybe_pageref_url_p (url)
  {
    return url.match(config.LOCAL_HTML_PAGE_PATTERN);
  }

  /** Check if 'URL' is a link to another manual.  For locally installed 
      manuals only. */
  function
  external_manual_url_p (url)
  {
    if (typeof url !== "string")
      throw new TypeError ("'" + url + "' is not a string");

    return url.match(/^..\//);
  }

  /** Return PATHNAME with any leading directory components removed.  If
      specified, also remove a trailing SUFFIX.  */
  function
  basename (pathname, suffix)
  {
    var res = pathname.replace (/.*[/]/, "");
    if (!suffix)
      return res;
    else if (suffix instanceof RegExp)
      return res.replace (suffix, "");
    else /* typeof SUFFIX === "string" */
      return res.replace (new RegExp ("[.]" + suffix), "");
  }

  /** Strip last component from PATHNAME and keep the trailing slash. For
      example if PATHNAME is "/foo/bar/baz.html" then return "/foo/bar/".
      @arg {string} pathname */
  function
  dirname (pathname)
  {
    var res = pathname.match (/\/?.*\//);
    if (res)
      return res[0];
    else
      throw new Error ("'location.pathname' is not recognized");
  }

  /** Apply FUNC to each nodes in the NODE subtree.  The walk follows a depth
      first algorithm.  Only consider the nodes of type NODE_TYPE.  */
  function
  depth_first_walk (node, func, node_type)
  {
    if (!node_type || (node.nodeType === node_type))
      func (node);

    for (var child = node.firstChild; child; child = child.nextSibling)
      depth_first_walk (child, func, node_type);
  }

  /** Return the "effective" href attribute of a link (a) element. */
  function
  link_href (alink)
  {
    return alink._href || alink.getAttribute("href");
  }

  /** Return the hash part of HREF without the '#' prefix.  HREF must be a
      string.  If there is no hash part in HREF then return the empty
      string.  */
  function
  href_hash (href)
  {
    if (typeof href !== "string")
      throw new TypeError (href + " is not a string");

    if (href.includes ("#"))
      return href.replace (/.*#/, "");
    else
      return "";
  }

  /** Check if LINKID corresponds to a page containing index links.  */
  function
  linkid_contains_index (linkid)
  {
    return linkid.match (/^.*-index$/i) || linkid.match (/^Index$/);
  }

  /** Retrieve PREV, NEXT, and UP links, and local menu from CONTENT and
      return an object containing references to those links.  CONTENT must be
      an object implementing the ParentNode interface (Element,
      Document...).  */
  function
  navigation_links (content)
  {
    var links = content.querySelectorAll ("a[accesskey][href]");
    var res = {};
    /* links have the form MAIN_FILE.html#FRAME-ID.  For convenience
       only store FRAME-ID.  */
    for (var i = 0; i < links.length; i += 1)
      {
        var link = links[i];
        var nav_id = navigation_links.dict[link.getAttribute ("accesskey")];
        if (nav_id)
          {
            var href = basename (link_href (link));
            if (href === config.INDEX_NAME)
              res[nav_id] = config.INDEX_ID;
            else
              res[nav_id] = href_hash (href);
          }
        else /* this link is part of local table of content. */
          {
            res.menu = res.menu || {};
            res.menu[link.text] = href_hash (link_href (link));
          }
      }

      return res;
  }

  navigation_links.dict = { n: "next", p: "prev", u: "up" };

  function
  add_icons ()
  {
    var div = document.createElement ("div");
    div.setAttribute ("id", "icon-bar");
    var span = document.createElement ("span");
    span.innerHTML = "?";
    // Set tool-tip (on hover)
    span.setAttribute ("title", "Help for keyboard shortcuts");
    span.classList.add ("icon");
    span.addEventListener ("click", function () {
      store.dispatch (actions.show_help ());
    }, false);
    div.appendChild (span);
    document.body.insertBefore (div, document.body.firstChild);
  }

  /** Check if ELEM matches SEARCH
      @arg {Element} elem
      @arg {RegExp} rgxp
      @return {boolean} */
  function
  search (elem, rgxp)
  {
    /** @type {Text} */
    var text = null;

    /** @arg {Text} node */
    function
    find (node)
    {
      if (rgxp.test (node.textContent))
        {
          /* Ignore previous match.  */
          var prev = node.parentElement.matches ("span.highlight");
          text = (prev) ? null : (text || node);
        }
    }

    depth_first_walk (elem, find, Node.TEXT_NODE);
    remove_highlight (elem);
    if (!text)
      return false;
    else
      {
        highlight_text (rgxp, text);
        return true;
      }
  }

  /** Find the pageid corresponding to forward direction.
      @arg {any} state
      @arg {string} linkid
      @return {string} the forward pageid */
  function
  forward_pageid (state, linkid)
  {
    var data = state.loaded_nodes[linkid];
    if (!data)
      throw new Error ("page not loaded: " + linkid);
    else if (!data.forward)
      return null;
    else
      {
        var cur = linkid_split (linkid);
        var fwd = linkid_split (data.forward);
        if (!fwd.hash && fwd.pageid !== cur.pageid)
          return fwd.pageid;
        else
          return forward_pageid (state, data.forward);
      }
  }

  /** Highlight text in NODE which match RGXP.
      @arg {RegExp} rgxp
      @arg {Text} node */
  function
  highlight_text (rgxp, node)
  {
    /* Skip elements corresponding to highlighted words to avoid infinite
       recursion.  */
    if (node.parentElement.matches ("span.highlight"))
      return;

    var matches = rgxp.exec (node.textContent);
    if (matches)
      {
        /* Create an highlighted element containing first match.  */
        var span = document.createElement ("span");
        span.appendChild (document.createTextNode (matches[0]));
        span.setAttribute ("id", "search-result");
        span.classList.add ("highlight");

        var right_node = node.splitText (matches.index);
        /* Remove first match from right node.  */
        right_node.textContent = right_node.textContent
                                           .slice (matches[0].length);
        node.parentElement.insertBefore (span, right_node);
      }
  }

  /** Remove every highlighted elements and inline their text content.
      @arg {Element} elem */
  function
  remove_highlight (elem)
  {
    var spans = elem.getElementsByClassName ("highlight");
    /* Replace spans with their inner text node.  */
    while (spans.length > 0)
      {
        var span = spans[0];
        var parent = span.parentElement;
        parent.replaceChild (span.firstChild, span);
      }
  }

  /*--------------.
  | Entry point.  |
  `--------------*/

  /** Depending on the role of the document launching this script, different
      event handlers are registered.  This script can be used in the context of:

      - the index page of the manual which manages the state of the application
      - the iframe which contains the lateral table of content
      - other iframes which contain other pages of the manual

      This is done to allow referencing the same script inside every HTML page.
      This has the benefits of reducing the number of HTTP requests required to
      fetch the Javascript code and simplifying the work of the Texinfo HTML
      converter.  */

  /* Display MSG bail out message portably.  */
  function
  error (msg)
  {
    /* XXX: This code needs to be highly portable.
       Check <https://quirksmode.org/dom/core/> for details.  */
    return function () {
        var div = document.createElement ("div");
        div.setAttribute ("class", "error");
        div.innerHTML = msg;
        var elem = document.body.firstChild;
        document.body.insertBefore (div, elem);
        window.setTimeout (function () {
          document.body.removeChild (div);
        }, config.WARNING_TIMEOUT);
      };
  }

  /* Check if current browser supports the minimum requirements required for
     properly using this script, otherwise bails out.  */
  if (features && !(features.es5
                    && features.classlist
                    && features.eventlistener
                    && features.hidden
                    && features.history
                    && features.postmessage
                    && features.queryselector))
    {
      window.onload = error ("'info.js' is not compatible with this browser");
      return;
    }

  /* Until we have a responsive design implemented, fallback to basic
     HTML navigation for small screen.  */
    /*
  if (window.screen.availWidth < config.SCREEN_MIN_WIDTH)
    {
      window.onload =
        error ("screen width is too small to display the table of content");
      return;
    }
*/

  register_polyfills ();
  /* Let the config provided by the user mask the default one.  */
  config = Object.assign (config, user_config);

  var inside_iframe = top !== window;
  var inside_index_page = window.location.pathname === config.INDEX_NAME
      || window.location.pathname.endsWith ("/" + config.INDEX_NAME)
      || window.location.pathname.endsWith ("/");

  if (inside_index_page)
    {
      var initial_state = {
        /* Dictionary associating page ids to next, prev, up, forward,
           backward link ids.  */
        loaded_nodes: {},
        /* Dictionary associating keyword to linkids.  */
        index: undefined,
        /* page id of the current page.  */
        current: config.INDEX_ID,
        /* dictionary associating a page id to a boolean.  */
        ready: {},
        /* Current mode for handling history.  */
        history: "replaceState",
        /* Define the name of current text input.  */
        text_input: null
      };

      store = new Store (updater, initial_state);
      var index = init_index_page ();
      var sidebar = init_sidebar ();
      window.addEventListener ("DOMContentLoaded", function () {
        index.on_load ();
        sidebar.on_load ();
      }, false);
      window.addEventListener ("message", index.on_message, false);
      window.addEventListener ("message", sidebar.on_message, false);
      window.onpopstate = index.on_popstate;
    }
  else if (inside_iframe)
    {
      store = new Remote_store ();
      var iframe = init_iframe ();
      window.addEventListener ("DOMContentLoaded", iframe.on_load, false);
      window.addEventListener ("message", iframe.on_message, false);
    }
  else
    {
      /* Jump to 'config.INDEX_NAME' and adapt the selected iframe.  */
      window.location.replace (with_sidebar_query (window.location.href));
    }

  /* Register common event handlers.  */
  window.addEventListener ("beforeunload", on_unload, false);
  window.addEventListener ("click", on_click, false);
  /* XXX: handle 'keydown' event instead of 'keypress' since Chromium
     doesn't handle the 'Escape' key properly.  See
     https://bugs.chromium.org/p/chromium/issues/detail?id=9061.  */
  window.addEventListener ("keydown", on_keydown, false);
} (window["Modernizr"], window["INFO_CONFIG"]));
