/* info.js - Javascript UI for Texinfo manuals
   Copyright (C) 2017-2024 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/>.  */

/* Note that the entire body of this file apart from the first and last
   lines are within a function call. */

(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",
  TOP_NAME: "index.html",
  TOP_ID: "top",
  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 TOP_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,
  DEBUG: 0,
};

/*-------------------.
| 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>} entries */
  cache_index_links: function (entries) {
    var dict = {};
    var text0 = "", text1 = ""; // for subentries
    for (var i = 0; i < entries.length; i += 1)
      {
        var entry = entries[i];
        var entry_cl = entry.classList;
        var text = entry.textContent;
        if (entry_cl.contains("index-entry-level-2"))
          {
            text = text0 + "; " + text1 + "; " + text;
          }
        else if (entry_cl.contains("index-entry-level-1"))
          {
            text1 = text;
            text = text0 + "; " + text;
          }
        else
          {
            text0 = text;
          }

        var link = entry.firstChild;
        if (link && link.nodeName == 'A')
          {
            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
    functional 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 result = Object.assign ({}, state, { action: action });
  var linkid;
  debug (action.type);
  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 (result, { loaded_nodes: nodes });
      }
    case "cache-index-links":
      {
        // Initially result.TI_index is undefined, which is ignored.
        result.TI_index = Object.assign ({}, result.TI_index, action.links);
        return result;
      }
    case "section":
      {
        result.section_hash = action.section_hash;
        return result;
      }
    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;

        debug (":linkid: " + linkid);
        if (action.pointer)
          debug (":pointer: " + action.pointer);

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

        /* Handle inner 'config.TOP_NAME' anchors specially.  */
        if (link.pageid === config.TOP_ID)
          current = config.TOP_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'.  */

            /* This checks if CURRENT is a value in state.TI_index for
               some key. */
            var is_index_ref =
              Object.keys (state.TI_index)
                    .reduce (function (acc, val) {
                      return acc || state.TI_index[val] === current;
                    }, false);

            if (is_index_ref)
              {
                ids = state.loaded_nodes[link.pageid];
                linkid = ids[action.direction];
              }
          }

        if (!linkid)
          return state;
        else
          {
            result.current = linkid;
            result.section_hash = null;
            result.history = action.history;
            result.text_input = null;
            result.warning = null;
            result.window_title = null;
            result.help = false;
            result.highlight = null;
            result.focus = false;
            result.loaded_nodes = Object.assign ({}, result.loaded_nodes);
            result.loaded_nodes[action.url] = result.loaded_nodes[action.url] || {};
            return result;
          }
      }
    case "search-init":
      {
        result.search = {
          regexp: action.regexp || state.search.regexp,
          input: action.input || state.search.input,
          status: "ready",
          current_pageid: linkid_split (state.current).pageid,
          found: false
        };
        result.focus = false;
        result.help = false;
        result.text_input = null;
        result.warning = null;
        return result;
      }
    case "search-query":
      {
        result.search = Object.assign ({}, state.search);
        result.search.status = "searching";
        return result;
      }
    case "search-result":
      {
        result.search = Object.assign ({}, state.search);
        if (action.found)
          {
            result.search.status = "done";
            result.search.found = true;
            result.current = result.search.current_pageid;
            result.section_hash = null;
            result.history = "pushState";
            result.highlight = result.search.regexp;
          }
        else
          {
            var fwd = forward_pageid (state, state.search.current_pageid);
            if (fwd === null)
              {
                result.search.status = "done";
                result.warning = "Search failed: \"" + result.search.input + "\"";
                result.highlight = null;
              }
            else
              {
                result.search.current_pageid = fwd;
                result.search.status = "ready";
              }
          }
        return result;
      }
    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
          {
            result.focus = Boolean (action.input);
            result.help = false;
            result.text_input = action.input;
            result.warning = null;
            return result;
          }
      }
    case "iframe-ready":
      {
        result.ready = Object.assign ({}, result.ready);
        result.ready[action.id] = true;
        return result;
      }
    case "echo":
      {
        result.echo = action.msg;
        return result;
      }
    case "window-title":
      {
        result.window_title = action.title;
        return result;
      }
    case "warning":
      {
        result.warning = action.msg;
        if (action.msg !== null)
          result.text_input = null;
        return result;
      }
    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)
            {
              debug ("Text input set linkid " + 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 TI_menu = new Text_input ("menu");
    TI_menu.render = function (state) {
      if (state.text_input === "menu")
      {
        var current_menu = state.loaded_nodes[state.current].TI_menu;
        if (current_menu)
          this.show (current_menu);
        else
          store.dispatch (actions.warn ("No menu in this node"));
      }
    };

    var TI_index = new Text_input ("index");
    TI_index.render = function (state) {
      if (state.text_input === "index") {
        if (state.TI_index)
          this.show (state.TI_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 (TI_menu.element);
    elem.appendChild (TI_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.TI_menu = TI_menu;
    this.TI_index = TI_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.TI_menu.hide ();
        this.TI_index.hide ();
        this.search.hide ();
      }
    else
      {
        this.TI_index.render (state);
        this.TI_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.TOP_ID);
    index_div.setAttribute ("node", config.TOP_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.TOP_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.TOP_ID)
          {
            window.setTimeout (function () {
              store.dispatch ({ type: "search-query" });
              var res = search (document.getElementById (config.TOP_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.TOP_ID)
          remove_highlight (document.getElementById (config.TOP_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.TOP_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.TOP_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.TOP_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.TOP_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.TOP_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.TOP_ID] = navigation_links (document);
    store.dispatch (actions.cache_links (links));
    store.dispatch ({ type: "iframe-ready", id: config.TOP_ID });
    store.dispatch ({
      type: "echo",
      msg: "Welcome to Texinfo documentation viewer 7.2, 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.TOP_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.TOP_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.TOP_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.TOP_NAME);
        a.setAttribute ("id", config.TOP_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.TOP_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;
        debug ("sidebar - " + pageid);

        /* Highlight the current LINKID in the table of content.  */
        var elem = scan_toc (toc_div, pageid, data.section_hash);
        if (elem)
          elem.scrollIntoView (true);
        else
          debug ("sidebar - no elem " + pageid);
      }
  }

  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");
        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.TOP_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.TOP_NAME'.  HREF must be a USVString representing an
    absolute or relative URL.  For example "foo/bar.html" will return
    "config.TOP_NAME#bar".

    @arg {string} href.*/
function
with_sidebar_query (href)
{
  if (basename (href) === config.TOP_NAME)
    return config.TOP_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.TOP_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.TOP_NAME) ?
                  config.TOP_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.TOP_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.TOP_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.TOP_ID)
    return config.TOP_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.TOP_NAME)
            res[nav_id] = config.TOP_ID;
          else
            res[nav_id] = href_hash (href);
        }
      else /* this link is part of local table of content. */
        {
          res.TI_menu = res.TI_menu || {};
          res.TI_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);
    }
}

/*--------------.
|   Tracing.    |
`--------------*/

function
debug (msg)
{
  if (config.DEBUG) {
    console.log (msg);
  }
}

/*--------------.
| 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_top_page = window.location.pathname === config.TOP_NAME
    || window.location.pathname.endsWith ("/" + config.TOP_NAME)
    || window.location.pathname.endsWith ("/");

if (inside_top_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.TOP_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.TOP_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"]));
