/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program 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.
 *
 * This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Eswara Rajesh Pinapala epinapala@live.com
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
current_activity_closed = true;
current_activity_top = 0;
previous_help = null;
/*
 * Toggles Help element from display:none and display block.
 * Also changes the width of the Current activity accordingly.
 * @param String help element's id to toggle.
 * @param String is_mobile flag true/false.
 * @param String target_controller Edit page's controller name.
 */
function toggleHelp(id, is_mobile, target_controller)
{
    let activity = 'current-activity';
    let images = document.querySelectorAll(".wiki-resource-image");
    let all_help_elements =
        document.getElementsByClassName(activity);
    let help_node = all_help_elements[0];
    if (current_activity_closed === true && help_node) {
        current_activity_top = getCssProperty(help_node, 'top');
    }
    obj = elt(id);
    if (is_mobile === false) {
        toggleDisplay(id, "inline-block");
    } else {
        toggleDisplay(id, "inline-block");
        let height_after_toggle = (elt("mobile-help").clientHeight);
        /* clientHeight only returns in pixels */
        if (obj.style.display === "none") {
            /* on closing, restore top */
            if (help_node) {
                help_node.style.top = current_activity_top + "px";
            }
            current_activity_closed = true;
        } else {
            if (help_node) {
                help_node.style.top = current_activity_top +
                height_after_toggle + "px";
            }
            /* The div.clientHeight doesn't include the height
             of the images inside the div before the images are completely
             loaded. So we iterate through the
             image elements, as each image loads add the image
             height to the top of the current_activity div dynamically. */
            for (let i = 0; i < images.length; i++) {
                let image = images[i];
                image.onload = function ()
                {
                    if (help_node) {
                        help_node.style.top = getCssProperty(help_node,
                            'top') + this.height + 'px';
                    }
                };
            }
            current_activity_closed = false;
        }
    }
}
/*
 * Gets the Css property given an element and property name.
 * @param Object elm Element to get the Css property for.
 * @param String property Css property name.
 * @return String Css property value
 */
function getCssProperty(elm, property)
{
    /* always returns in px */
    return parseInt(window.getComputedStyle(elm, null)
        .getPropertyValue(property));
}
/*
 * The wiki markup the help panel shows is parsed by the two classes below,
 * MarkUpScanner and WikiParser, ported from the server's
 * src/library/MarkUpScanner.php and src/library/WikiParser.php so a help
 * page renders here the same way the server renders it. Only the mediawiki
 * engine is ported, which is all the help pages use. The parseWikiContent
 * function after them wraps the parser: it drops any head-variable section,
 * parses the body against the reading url for this group, then resolves the
 * cross-group markers and resource references the parser leaves behind into
 * links, the same way the wiki view assembles a read page.
 */
/* ---- small string helpers the parser leans on ---- */

/**
 * Whether a character is whitespace in the wider sense: a space, tab,
 * newline, carriage return, vertical tab, or form feed. Used where the
 * parser skips any of these rather than only spaces and tabs.
 *
 * @param {string} character one character to test
 * @return {boolean} true when the character is one of those
 */
function isWhitespaceChar(character)
{
    return character !== "" && " \t\n\r\x0B\f".indexOf(character) !== -1;
}

/**
 * Whether a character is a space or tab, the horizontal whitespace the
 * parser treats as blank filler within a line.
 *
 * @param {string} character one character to test
 * @return {boolean} true when the character is a space or tab
 */
function isSpaceChar(character)
{
    return character === " " || character === "\t";
}

/**
 * Whether a character is an ascii letter.
 *
 * @param {string} character one character to test
 * @return {boolean} true when the character is a through z either case
 */
function isAlphaChar(character)
{
    return (character >= "a" && character <= "z") ||
        (character >= "A" && character <= "Z");
}

/**
 * Whether a character is an ascii letter or digit.
 *
 * @param {string} character one character to test
 * @return {boolean} true when the character is a letter or a digit
 */
function isAlnumChar(character)
{
    return isAlphaChar(character) ||
        (character >= "0" && character <= "9");
}

/**
 * Removes a fixed set of whitespace from both ends of a string: spaces,
 * tabs, newlines, carriage returns, null bytes, and vertical tabs. This
 * set is narrower than the one the built-in javascript trim removes, and
 * is the set the server parser trims.
 *
 * @param {string} text the text to trim
 * @return {string} the text without that leading or trailing whitespace
 */
function trim(text)
{
    return ltrim(rtrim(text));
}

/**
 * Removes that same set of whitespace from the start of a string only.
 *
 * @param {string} text the text to trim
 * @return {string} the text without that leading whitespace
 */
function ltrim(text)
{
    var start = 0;
    while (start < text.length &&
        " \t\n\r\0\x0B".indexOf(text.charAt(start)) !== -1) {
        start++;
    }
    return text.slice(start);
}

/**
 * Removes that same set of whitespace from the end of a string only.
 *
 * @param {string} text the text to trim
 * @return {string} the text without that trailing whitespace
 */
function rtrim(text)
{
    var end = text.length;
    while (end > 0 &&
        " \t\n\r\0\x0B".indexOf(text.charAt(end - 1)) !== -1) {
        end--;
    }
    return text.slice(0, end);
}

/**
 * Escapes the five characters html treats specially so a run of text can
 * sit safely in page content or an attribute, escaping both quote marks.
 * When double encoding is off an existing entity is left alone rather
 * than having its ampersand escaped again.
 *
 * @param {string} text the text to escape
 * @param {boolean} double_encode whether to escape an ampersand that
 *      already begins an entity
 * @return {string} the escaped text
 */
function htmlSpecialChars(text, double_encode)
{
    if (double_encode === undefined) {
        double_encode = true;
    }
    var out = "";
    var index = 0;
    while (index < text.length) {
        var character = text.charAt(index);
        if (character === "&") {
            if (!double_encode && entityStartsAt(text, index)) {
                out += "&";
            } else {
                out += "&amp;";
            }
        } else if (character === "\"") {
            out += "&quot;";
        } else if (character === "'") {
            out += "&#039;";
        } else if (character === "<") {
            out += "&lt;";
        } else if (character === ">") {
            out += "&gt;";
        } else {
            out += character;
        }
        index++;
    }
    return out;
}

/**
 * Whether an ampersand at the given offset already begins an html entity,
 * either a named one, a decimal reference, or a hexadecimal reference, so
 * htmlspecialchars can leave it alone when double encoding is off.
 *
 * @param {string} text the text being escaped
 * @param {number} at the offset of the ampersand
 * @return {boolean} true when a full entity begins at the ampersand
 */
function entityStartsAt(text, at)
{
    var rest = text.slice(at + 1);
    var match = rest.match(/^#[0-9]+;/) || rest.match(/^#[xX][0-9a-fA-F]+;/) ||
        rest.match(/^[a-zA-Z][a-zA-Z0-9]*;/);
    return match !== null;
}

/**
 * Undoes html escaping of the five special characters, both quote marks
 * included, so a document escaped on the way in can be read back before
 * it is parsed.
 *
 * @param {string} text the text to unescape
 * @return {string} the text with entities turned back into characters
 */
function htmlSpecialCharsDecode(text)
{
    return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").
        replace(/&quot;/g, "\"").replace(/&#0*39;/g, "'").
        replace(/&#0*39;/g, "'").replace(/&apos;/g, "'").
        replace(/&amp;/g, "&");
}

/**
 * Removes every html tag from a run of text, leaving only the text
 * between and around them, used where a heading's own text is needed
 * without any inline markup it carries.
 *
 * @param {string} text the text to strip tags from
 * @return {string} the text with tags removed
 */
function stripTags(text)
{
    return text.replace(/<[^>]*>/g, "");
}

/**
 * Splits a string on a separator into at most a given number of pieces,
 * where the final piece keeps any further separators, used for link and
 * template parts that must not be split past their first few fields.
 *
 * @param {string} separator the text to split on
 * @param {string} text the text to split
 * @param {number} limit the greatest number of pieces to return
 * @return {Array} the pieces
 */
function splitLimit(separator, text, limit)
{
    var parts = text.split(separator);
    if (limit > 0 && parts.length > limit) {
        var head = parts.slice(0, limit - 1);
        head.push(parts.slice(limit - 1).join(separator));
        return head;
    }
    return parts;
}

/**
 * Counts the non-overlapping times a needle occurs in a string, used to
 * weigh nowiki openers against closers.
 *
 * @param {string} haystack the text to search
 * @param {string} needle the text to count
 * @return {number} how many non-overlapping times needle occurs
 */
function substrCount(haystack, needle)
{
    if (needle === "") {
        return 0;
    }
    var count = 0;
    var pos = 0;
    while ((pos = haystack.indexOf(needle, pos)) !== -1) {
        count++;
        pos += needle.length;
    }
    return count;
}

/**
 * Reads markup one character at a time so a parser can hunt for the next
 * token without copying the rest of the document, a port of the php
 * MarkUpScanner class. It keeps the source and a single cursor position;
 * every read returns a small span cut from the source rather than a
 * rebuilt tail.
 */
class MarkUpScanner
{
    /**
     * Sets up a scanner over a piece of markup, first normalizing its
     * line endings to plain newlines so a stray carriage return cannot
     * ride along at the end of a line and defeat a block matcher.
     *
     * @param {string} source the markup to scan
     * @param {string} block_start_chars each character that may start a
     *      block for this markup flavor, concatenated
     */
    constructor(source, block_start_chars)
    {
        this.source = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
        this.position = 0;
        this.length = this.source.length;
        this.block_start_chars = block_start_chars;
    }
    /**
     * Whether the cursor has reached the end of the source.
     * @return {boolean} true if nothing remains to read
     */
    atEnd()
    {
        return this.position >= this.length;
    }
    /**
     * The current cursor offset, so a caller can remember a spot and
     * later return to it.
     * @return {number} the offset into the source
     */
    tell()
    {
        return this.position;
    }
    /**
     * Moves the cursor back to a remembered offset.
     * @param {number} offset an offset previously returned by tell
     * @return {void}
     */
    seek(offset)
    {
        this.position = offset;
    }
    /**
     * Looks at a character near the cursor without consuming it.
     * @param {number} ahead how many characters past the cursor to look
     * @return {string} the character, or the empty string past the end
     */
    peek(ahead)
    {
        if (ahead === undefined) {
            ahead = 0;
        }
        var at = this.position + ahead;
        return at < this.length ? this.source.charAt(at) : "";
    }
    /**
     * Moves the cursor forward.
     * @param {number} count how many characters to consume
     * @return {void}
     */
    advance(count)
    {
        if (count === undefined) {
            count = 1;
        }
        this.position += count;
    }
    /**
     * Whether the source at the cursor begins with the given text.
     * @param {string} text the text to compare against
     * @return {boolean} true if the next characters match text exactly
     */
    startsWith(text)
    {
        return this.source.startsWith(text, this.position);
    }
    /**
     * Whether the cursor sits at the start of a line, meaning it is at
     * the very beginning of the source or just past a newline.
     * @return {boolean} true at a line start
     */
    atLineStart()
    {
        return this.position === 0 ||
            this.source.charAt(this.position - 1) === "\n";
    }
    /**
     * Returns the source from the cursor to the end without moving the
     * cursor, so a caller can inspect all that remains to be read.
     * @return {string} the unread remainder of the source
     */
    rest()
    {
        return this.source.slice(this.position);
    }
    /**
     * Returns the whole source the scanner runs over, so a caller that works
     * from an offset can be handed the text and the cursor rather than a
     * fresh copy of all that remains. The cursor is not moved.
     * @return {string} the full source
     */
    source_text()
    {
        return this.source;
    }
    /**
     * Consumes characters until one of the stop characters (or a newline,
     * or the end of the source) is reached, and returns what was consumed.
     *
     * @param {string} stops each character that ends the run, concatenated
     * @return {string} the characters read before the stop
     */
    readUntil(stops)
    {
        var start = this.position;
        while (this.position < this.length) {
            var current = this.source.charAt(this.position);
            if (current === "\n" || stops.indexOf(current) !== -1) {
                break;
            }
            this.position++;
        }
        return this.source.slice(start, this.position);
    }
    /**
     * Consumes the rest of the current line and returns it without the
     * trailing newline, leaving the cursor at the start of the next line.
     * @return {string} the line's text
     */
    readLine()
    {
        var start = this.position;
        while (this.position < this.length &&
            this.source.charAt(this.position) !== "\n") {
            this.position++;
        }
        var line = this.source.slice(start, this.position);
        if (this.position < this.length) {
            this.position++;
        }
        return line;
    }
    /**
     * Consumes a run of the same character and reports how many there
     * were, used for markers made of repeated characters.
     *
     * @param {string} character the single character to count
     * @return {number} how many copies were consumed
     */
    readRepeat(character)
    {
        var count = 0;
        while (this.position < this.length &&
            this.source.charAt(this.position) === character) {
            this.position++;
            count++;
        }
        return count;
    }
    /**
     * Skips a run of blank lines, leaving the cursor at the first line
     * that has content or at the end of the source.
     * @return {void}
     */
    skipBlankLines()
    {
        while (this.position < this.length) {
            var mark = this.position;
            while (this.position < this.length &&
                (this.source.charAt(this.position) === " " ||
                this.source.charAt(this.position) === "\t")) {
                this.position++;
            }
            if (this.position < this.length &&
                this.source.charAt(this.position) === "\n") {
                this.position++;
            } else if (this.position < this.length) {
                this.position = mark;
                break;
            } else {
                break;
            }
        }
    }
    /**
     * Whether the character at the cursor is one that can begin a block
     * for this markup flavor.
     * @return {boolean} true if the cursor's character may open a block
     */
    atBlockStartChar()
    {
        if (this.position >= this.length) {
            return false;
        }
        return this.block_start_chars.indexOf(
            this.source.charAt(this.position)) !== -1;
    }
    /**
     * Whether the line at the cursor is blank, that is empty or only
     * spaces and tabs before its newline.
     * @return {boolean} true when the current line has no visible content
     */
    atBlankLine()
    {
        var at = this.position;
        while (at < this.length && (this.source.charAt(at) === " " ||
            this.source.charAt(at) === "\t")) {
            at++;
        }
        return at >= this.length || this.source.charAt(at) === "\n";
    }
    /**
     * From a cursor sitting on an opening double brace, consumes through
     * the matching close, counting nested double braces, and returns the
     * text between them. When there is no match the cursor is left where
     * it began and false is returned.
     *
     * @return {(string|boolean)} the text between the braces, or false
     *      when the opening brace is never matched
     */
    matchBraces()
    {
        var start = this.position;
        var depth = 0;
        while (this.position < this.length - 1) {
            if (this.source.charAt(this.position) === "{" &&
                this.source.charAt(this.position + 1) === "{") {
                depth++;
                this.position += 2;
            } else if (this.source.charAt(this.position) === "}" &&
                this.source.charAt(this.position + 1) === "}") {
                depth--;
                this.position += 2;
                if (depth === 0) {
                    return this.source.slice(start + 2, this.position - 2);
                }
            } else {
                this.position++;
            }
        }
        this.position = start;
        return false;
    }
}

/**
 * Escapes a single quote, a double quote, a backslash, and a null byte,
 * used on the head variable fields so they are stored the same way the
 * server stores them.
 *
 * @param {string} text the text to escape
 * @return {string} the text with those characters backslash escaped
 */
function addSlashes(text)
{
    return text.replace(/\\/g, "\\\\").replace(/'/g, "\\'").
        replace(/"/g, "\\\"").replace(/\0/g, "\\0");
}

/**
 * Parses mediawiki markup into html, a port of the mediawiki engine of
 * the php WikiParser class. Only the mediawiki engine is ported; the
 * markdown engine the server class also holds is not needed for the
 * help pages this drives.
 */
class WikiParser
{
    /**
     * Sets up a parser.
     *
     * @param {string} base_address base url a page-name link is joined to
     * @param {boolean} minimal whether to skip head-variable handling
     */
    constructor(base_address, minimal)
    {
        this.base_address = base_address === undefined ? "" : base_address;
        this.minimal = minimal === undefined ? false : minimal;
        this.extra_rules = [];
        this.cite_references = [];
        this.toc_headings = [];
        this.collecting_headings = false;
        this.parse_depth = 0;
        this.inline_absent = {};
    }
    /**
     * Splits text into the chunks that blank lines separate, where a blank
     * line holds nothing but whitespace, used to break a page's head
     * section into its separate settings.
     *
     * @param {string} text the text to split
     * @return {Array} the non-blank chunks, each with its lines rejoined
     */
    splitOnBlankLines(text)
    {
        var chunks = [];
        var current = "";
        var lines = text.split("\n");
        for (var i = 0; i < lines.length; i++) {
            if (trim(lines[i]) === "") {
                if (current !== "") {
                    chunks.push(current);
                    current = "";
                }
            } else {
                current += (current === "") ? lines[i] : "\n" + lines[i];
            }
        }
        if (current !== "") {
            chunks.push(current);
        }
        return chunks;
    }
    /**
     * Parses a mediawiki document to produce an html equivalent. The head
     * variable section, when present and asked for, is separated off and
     * put back afterward, and the body is read block by block through the
     * scanner, with a table of contents and reference list added.
     *
     * @param {string} document the markup to parse
     * @param {boolean} parse_head_vars whether to honor a head section
     * @return {string} the html
     */
    parse(document, parse_head_vars)
    {
        if (parse_head_vars === undefined) {
            parse_head_vars = true;
        }
        var head = "";
        var page_type = "standard";
        var head_vars = {};
        var draw_toc = true;
        if (parse_head_vars && !this.minimal) {
            var document_parts = document.split(WikiParser.END_HEAD_VARS);
            if (document_parts.length > 1) {
                head = document_parts[0];
                document = document_parts[1];
                var head_lines = this.splitOnBlankLines(head);
                for (var i = 0; i < head_lines.length; i++) {
                    var line = head_lines[i];
                    var semi = line.indexOf(";");
                    var semi_pos = semi > 0 ? semi : line.length;
                    line = line.slice(0, semi_pos);
                    var line_parts = line.split("=");
                    if (line_parts.length === 2) {
                        head_vars[trim(addSlashes(line_parts[0]))] =
                            addSlashes(trim(line_parts[1]));
                    }
                }
                if (head_vars["page_type"] !== undefined) {
                    page_type = head_vars["page_type"];
                }
                if (head_vars["toc"] !== undefined) {
                    draw_toc = head_vars["toc"];
                }
            }
        }
        document = htmlSpecialCharsDecode(document);
        this.cite_references = [];
        this.toc_headings = [];
        this.collecting_headings = true;
        this.parse_depth = 0;
        var scanner = new MarkUpScanner(document,
            WikiParser.WIKI_BLOCK_START_CHARS);
        var body = this.parseBlocks(scanner);
        var toc = (draw_toc && page_type != "presentation") ?
            this.tableOfContents(this.toc_headings) : "";
        document = this.insertTableOfContents(body, toc) +
            this.referenceList();
        if (head != "" && parse_head_vars) {
            document = head + WikiParser.END_HEAD_VARS + document;
        }
        return document;
    }
    /**
     * Tries each added rule against the text at the given offset and
     * returns the first that matches, or null when none apply.
     *
     * @param {string} text the text being read
     * @param {number} index the offset to try the rules at
     * @return {(Object|null)} the matching rule's result, or null
     */
    applyExtraRules(text, index)
    {
        for (var i = 0; i < this.extra_rules.length; i++) {
            var result = this.extra_rules[i](text, index);
            if (result !== null) {
                return result;
            }
        }
        return null;
    }
    /**
     * Finds the next place a marker appears at or after a point, giving the
     * same answer a plain search would but remembering once a marker is
     * known to be absent from a point through to the end, so a later look
     * from that point or beyond returns at once instead of scanning the tail
     * again. Without this an inline run full of openers whose closer never
     * comes would search to the end from each opener and cost the square of
     * the run's length. The note lives in inline_absent, which each inline
     * pass clears for its own text; since a pass only moves its cursor
     * forward, a marker missing from one point is missing from every later
     * one.
     *
     * @param {string} text the text being scanned
     * @param {string} marker the marker to look for
     * @param {number} from the offset to search from
     * @return {number} the marker's offset, or minus one when there is none
     */
    findMark(text, marker, from)
    {
        if (this.inline_absent[marker] !== undefined &&
            from >= this.inline_absent[marker]) {
            return -1;
        }
        var found = text.indexOf(marker, from);
        if (found === -1) {
            this.inline_absent[marker] = from;
        }
        return found;
    }
    /**
     * Parses a run of blocks from the scanner, emitting each block's html
     * as it is read. Blank lines between blocks are skipped.
     *
     * @param {MarkUpScanner} scanner the scanner positioned at a block
     * @return {string} the html for the blocks read
     */
    parseBlocks(scanner)
    {
        this.parse_depth++;
        if (this.parse_depth > WikiParser.WIKI_MAX_PARSE_DEPTH) {
            this.parse_depth--;
            return this.escapeText(scanner.rest());
        }
        var html = "";
        while (!scanner.atEnd()) {
            scanner.skipBlankLines();
            if (scanner.atEnd()) {
                break;
            }
            html += this.parseBlock(scanner);
        }
        this.parse_depth--;
        return html;
    }
    /**
     * Reads and emits one block by looking at how its first line opens: a
     * template or table brace, a pre tag, a heading, a rule, an indent, a
     * list, or, when none of those fit, a paragraph.
     *
     * @param {MarkUpScanner} scanner the scanner at the block's first line
     * @return {string} the html for that block
     */
    parseBlock(scanner)
    {
        if (this.extra_rules.length > 0) {
            var rule_result = this.applyExtraRules(scanner.source_text(),
                scanner.tell());
            if (rule_result !== null) {
                scanner.seek(rule_result["next"]);
                return rule_result["html"];
            }
        }
        if (scanner.startsWith("{{")) {
            var template = this.parseTemplate(scanner);
            if (template !== null) {
                return template;
            }
        }
        if (scanner.startsWith("{|")) {
            return this.parseTable(scanner);
        }
        if (scanner.startsWith("<notoc>")) {
            return this.parseNotocRegion(scanner);
        }
        if (scanner.startsWith("<pre>")) {
            return this.parsePre(scanner);
        }
        if (scanner.startsWith("<code>")) {
            return this.parseCodeBlock(scanner);
        }
        var first = scanner.peek();
        if (first === " " && scanner.atLineStart()) {
            return this.parseSpacePre(scanner);
        }
        if (first === "=") {
            var heading = this.parseHeading(scanner);
            if (heading !== null) {
                return heading;
            }
        }
        if (first === "-") {
            var rule = this.parseRule(scanner);
            if (rule !== null) {
                return rule;
            }
        }
        if (first === ":" && this.startsBlock(scanner)) {
            return this.parseIndent(scanner);
        }
        if (first === ";" && this.startsBlock(scanner)) {
            return this.parseDefinitionList(scanner);
        }
        if (first === "*" || first === "#") {
            return this.parseList(scanner);
        }
        return this.parseParagraph(scanner);
    }
    /**
     * Reports whether the scanner sits at the start of a line that begins
     * a confirmed block, so a single newline inside running text does not
     * split it.
     *
     * @param {MarkUpScanner} scanner the scanner at a line start
     * @return {boolean} true when the line opens a new block
     */
    startsBlock(scanner)
    {
        var first = scanner.peek();
        if (first === "=" || first === "*" || first === "#" ||
            first === " ") {
            return true;
        }
        if (first === "-") {
            return scanner.startsWith("----");
        }
        if (first === "<") {
            return scanner.startsWith("<pre>") ||
                scanner.startsWith("<code>") ||
                scanner.startsWith("<notoc>");
        }
        if (first === "{") {
            return scanner.startsWith("{|") || scanner.startsWith("{{");
        }
        if (first === ":") {
            var ahead = 0;
            while (scanner.peek(ahead) === ":") {
                ahead++;
            }
            var after = scanner.peek(ahead);
            return after === " " || after === "\t";
        }
        if (first === ";") {
            var scan = 1;
            while (scanner.peek(scan) !== "" &&
                scanner.peek(scan) !== "\n") {
                if (scanner.peek(scan) === ":") {
                    return true;
                }
                scan++;
            }
            return false;
        }
        return false;
    }
    /**
     * Reads a heading line, one run of equals signs, the title, then a
     * closing run of equals signs; the smaller run count is its level. A
     * notoc marker keeps the heading out of the contents box. Returns null
     * when the line is not a heading.
     *
     * @param {MarkUpScanner} scanner the scanner at the heading line
     * @return {(string|null)} the heading html, or null when not a heading
     */
    parseHeading(scanner)
    {
        var mark = scanner.tell();
        var line = scanner.readLine();
        var in_contents = true;
        if (line.indexOf("<notoc>") !== -1) {
            in_contents = false;
            line = line.replace(/<notoc>/g, "").replace(/<\/notoc>/g, "");
        }
        var length = line.length;
        var open = 0;
        while (open < length && line.charAt(open) === "=") {
            open++;
        }
        var end = length;
        while (end > open && (line.charAt(end - 1) === " " ||
            line.charAt(end - 1) === "\t")) {
            end--;
        }
        var close = 0;
        while (close < end - open && line.charAt(end - 1 - close) === "=") {
            close++;
        }
        if (open === 0 || close === 0) {
            scanner.seek(mark);
            return null;
        }
        var level = Math.min(open, close, WikiParser.MAX_HEADING_LEVEL);
        var text = trim(line.slice(open, end - close));
        var inline = this.renderInline(text);
        var anchor = stripTags(inline);
        if (this.collecting_headings && in_contents) {
            this.toc_headings.push({level: level, text: anchor});
        }
        return "<h" + level + " id=\"" + anchor + "\">" + inline +
            "</h" + level + ">\n";
    }
    /**
     * Reads a horizontal rule, a line of four or more dashes and nothing
     * else. Returns null when the dashes are followed by other text.
     *
     * @param {MarkUpScanner} scanner the scanner at the dashed line
     * @return {(string|null)} the rule html, or null when not a rule
     */
    parseRule(scanner)
    {
        var mark = scanner.tell();
        var line = scanner.readLine();
        var length = line.length;
        var dashes = 0;
        while (dashes < length && line.charAt(dashes) === "-") {
            dashes++;
        }
        if (dashes >= 4 && rtrim(line.slice(dashes)) === "") {
            return "<hr />\n";
        }
        scanner.seek(mark);
        return null;
    }
    /**
     * Reads a colon indent: the leading colons set the depth and the rest
     * of the line, with any plain continuation lines, is the indented
     * text, shown as an indented paragraph.
     *
     * @param {MarkUpScanner} scanner the scanner at the colon line
     * @return {string} the indent html
     */
    parseIndent(scanner)
    {
        var line = scanner.readLine();
        var length = line.length;
        var colons = 0;
        while (colons < length && line.charAt(colons) === ":") {
            colons++;
        }
        var level = Math.min(colons, WikiParser.MAX_INDENT_LEVEL);
        var text = line.slice(colons + 1);
        while (!scanner.atEnd() && !scanner.atBlankLine() &&
            !this.startsBlock(scanner)) {
            text += "\n" + scanner.readLine();
        }
        return "<p><span class=\"indent" + level + "\">&nbsp;</span>" +
            this.renderInline(text) + "</p>\n";
    }
    /**
     * Reads a definition list, a run of lines each naming a term, a colon,
     * then that term's meaning, and emits them as one description list.
     *
     * @param {MarkUpScanner} scanner the scanner at the first term line
     * @return {string} the definition-list html
     */
    parseDefinitionList(scanner)
    {
        var html = "<dl>\n";
        while (!scanner.atEnd() && scanner.peek() === ";" &&
            this.startsBlock(scanner)) {
            var rest = scanner.readLine().slice(1);
            var colon = rest.indexOf(":");
            var term = trim(rest.slice(0, colon));
            var meaning = trim(rest.slice(colon + 1));
            html += "<dt>" + this.renderInline(term) + "</dt>\n<dd>" +
                this.renderInline(meaning) + "</dd>\n";
        }
        return html + "</dl>\n";
    }
    /**
     * Reads a whole list, gathering its item lines and folding plain
     * continuation lines into the item above them, then hands the run to
     * the nesting builder.
     *
     * @param {MarkUpScanner} scanner the scanner at the first list line
     * @return {string} the list html
     */
    parseList(scanner)
    {
        var list_lines = [];
        while (!scanner.atEnd() && !scanner.atBlankLine()) {
            var first = scanner.peek();
            if (first === "*" || first === "#") {
                var line = scanner.readLine();
                var length = line.length;
                var marker_length = 0;
                while (marker_length < length &&
                    (line.charAt(marker_length) === "*" ||
                    line.charAt(marker_length) === "#")) {
                    marker_length++;
                }
                var text = line.slice(marker_length);
                if (text !== "" && (text.charAt(0) === " " ||
                    text.charAt(0) === "\t")) {
                    text = text.slice(1);
                }
                list_lines.push({marker: line.slice(0, marker_length),
                    text: text});
            } else if (!this.startsBlock(scanner)) {
                var last = list_lines.length - 1;
                list_lines[last]["text"] += "\n" + scanner.readLine();
            } else {
                break;
            }
        }
        return this.buildListHtml(list_lines, 1);
    }
    /**
     * Emits one list level from a run of list lines, recursing on the
     * deeper-marked lines that follow a line so they become its nested
     * list; the marker character at this depth sets numbered or bulleted.
     *
     * @param {Array} list_lines the run of list lines, each marker and text
     * @param {number} base_depth the depth of the level being emitted
     * @return {string} the html for this list level and those under it
     */
    buildListHtml(list_lines, base_depth)
    {
        var marker = list_lines[0]["marker"];
        var at_depth = marker.charAt(base_depth - 1);
        var tag = (at_depth === "#") ? "ol" : "ul";
        var html = "<" + tag + ">\n";
        var count = list_lines.length;
        var index = 0;
        while (index < count) {
            var next = index + 1;
            while (next < count &&
                list_lines[next]["marker"].length > base_depth) {
                next++;
            }
            html += "<li>" + this.renderInline(list_lines[index]["text"]);
            if (next > index + 1) {
                html += "\n" + this.buildListHtml(list_lines.slice(
                    index + 1, next), base_depth + 1);
            }
            html += "</li>\n";
            index = next;
        }
        return html + "</" + tag + ">\n";
    }
    /**
     * Reads a run of inline text, turning nowiki and math spans, code
     * backticks, allowed inline tags, inline templates, emphasis, and
     * links into html and escaping everything else, so a block's text
     * carries its inline markup.
     *
     * @param {string} text the run of inline text
     * @return {string} the html for it
     */
    renderInline(text)
    {
        this.parse_depth++;
        if (this.parse_depth > WikiParser.WIKI_MAX_PARSE_DEPTH) {
            this.parse_depth--;
            return this.escapeText(text);
        }
        var html = "";
        var length = text.length;
        var index = 0;
        var saved_absent = this.inline_absent;
        this.inline_absent = {};
        while (index < length) {
            if (this.extra_rules.length > 0) {
                var rule_result = this.applyExtraRules(text, index);
                if (rule_result !== null) {
                    html += rule_result["html"];
                    index = rule_result["next"];
                    continue;
                }
            }
            var nowiki_open = "<nowiki>";
            var nowiki_close = "</nowiki>";
            if (text.substr(index, nowiki_open.length) === nowiki_open) {
                var start = index + nowiki_open.length;
                var close = this.findMark(text, nowiki_close, start);
                if (close !== -1) {
                    html += this.escapeVerbatim(text.slice(start, close));
                    index = close + nowiki_close.length;
                    continue;
                }
            }
            if (text.charAt(index) === "<") {
                var inline_tag = this.matchInlineTag(text, index);
                if (inline_tag !== null) {
                    html += inline_tag;
                    index += inline_tag.length;
                    continue;
                }
            }
            var math_open = "<math>";
            var math_close = "</math>";
            if (text.substr(index, math_open.length) === math_open) {
                var math_start = index + math_open.length;
                var math_end = this.findMark(text, math_close, math_start);
                if (math_end !== -1) {
                    html += "`" + this.escapeText(text.slice(math_start,
                        math_end)) + "`";
                    index = math_end + math_close.length;
                    continue;
                }
            }
            if (text.charAt(index) === "`") {
                var tick = this.findMark(text, "`", index + 1);
                if (tick !== -1) {
                    html += "`" + this.escapeText(text.slice(index + 1,
                        tick)) + "`";
                    index = tick + 1;
                    continue;
                }
            }
            if (text.substr(index, 2) === "{{") {
                var inline = this.readInlineTemplate(text, index);
                if (inline !== null) {
                    html += inline["html"];
                    index = inline["next"];
                    continue;
                }
            }
            var emphasis = this.readEmphasis(text, index);
            if (emphasis !== null) {
                html += emphasis["html"];
                index = emphasis["next"];
                continue;
            }
            if (text.substr(index, 2) === "[[") {
                var link_close = this.findMark(text, "]]", index + 2);
                if (link_close !== -1) {
                    html += this.renderLink(text.slice(index + 2,
                        link_close));
                    index = link_close + 2;
                    continue;
                }
            }
            html += this.escapeText(text.charAt(index));
            index++;
        }
        this.inline_absent = saved_absent;
        this.parse_depth--;
        return html;
    }
    /**
     * Reads one of the plain inline html tags the parser passes through
     * unchanged, such as a bold, italic, or break tag, when one starts at
     * the given position. Only the short allowed list is passed through;
     * the caller escapes anything else.
     *
     * @param {string} text the block text being scanned
     * @param {number} index the position of the opening angle bracket
     * @return {(string|null)} the tag text to pass through, or null when no
     *      allowed inline tag starts here
     */
    matchInlineTag(text, index)
    {
        var length = text.length;
        var cursor = index;
        if (cursor >= length || text.charAt(cursor) !== "<") {
            return null;
        }
        cursor++;
        if (cursor < length && text.charAt(cursor) === "/") {
            cursor++;
        }
        var name_start = cursor;
        while (cursor < length && isAlphaChar(text.charAt(cursor))) {
            cursor++;
        }
        var name = text.slice(name_start, cursor).toLowerCase();
        if (WikiParser.INLINE_TAGS.split("|").indexOf(name) === -1) {
            return null;
        }
        while (cursor < length && isWhitespaceChar(text.charAt(cursor))) {
            cursor++;
        }
        if (cursor < length && text.charAt(cursor) === "/") {
            cursor++;
        }
        if (cursor >= length || text.charAt(cursor) !== ">") {
            return null;
        }
        cursor++;
        return text.slice(index, cursor);
    }
    /**
     * Reads a bold, italic, or bold-italic span if one starts at the given
     * position. Bold-italic is five quotes, bold three, italic two; the
     * span runs to the next matching run of quotes and its body is parsed
     * for inline markup in turn.
     *
     * @param {string} text the block text being scanned
     * @param {number} index the position to look at
     * @return {(Object|null)} the span's html and the position after it, or
     *      null when no emphasis span starts here
     */
    readEmphasis(text, index)
    {
        var forms = [["'''''", "<b><i>", "</i></b>"],
            ["'''", "<b>", "</b>"], ["''", "<i>", "</i>"]];
        for (var form = 0; form < forms.length; form++) {
            var mark = forms[form][0];
            var width = mark.length;
            if (text.substr(index, width) !== mark) {
                continue;
            }
            var close = this.findMark(text, mark, index + width);
            if (close === -1) {
                continue;
            }
            var inner = text.slice(index + width, close);
            return {html: forms[form][1] + this.renderInline(inner) +
                forms[form][2], next: close + width};
        }
        return null;
    }
    /**
     * Renders one link between double brackets. A two-part link is
     * page|shown; a three-part link is relationship|page|shown and points
     * at the page, its middle part. A target on the scheme allowlist or a
     * same-page anchor is used as written; a group@page target becomes an
     * at-marker the view resolves to that group's read url; any other
     * target is treated as a page name in this group and joined to the base
     * address, so a scheme such as javascript cannot become an active url.
     *
     * @param {string} inner the text between the double brackets
     * @return {string} the html anchor for the link
     */
    renderLink(inner)
    {
        var parts = inner.split("|");
        var target;
        var shown;
        if (parts.length === 3) {
            target = trim(parts[1]);
            shown = parts[2];
        } else {
            var bar = inner.indexOf("|");
            if (bar === -1) {
                target = trim(inner);
                shown = inner;
            } else {
                target = trim(inner.slice(0, bar));
                shown = inner.slice(bar + 1);
            }
        }
        var scheme = this.linkScheme(target);
        var href;
        if (target.length > 0 && target.charAt(0) === "#") {
            href = target;
        } else if (WikiParser.ALLOWED_LINK_SCHEMES.indexOf(scheme) !== -1) {
            href = target;
        } else if (target.indexOf("@") !== -1) {
            href = "@@" + target + "@@";
        } else {
            href = this.base_address + target;
        }
        return "<a href=\"" + this.escapeText(href) + "\">" +
            this.renderInline(shown) + "</a>";
    }
    /**
     * Reads a link target's uri scheme, the name and colon that can lead a
     * web address such as the leading part of an https address. A scheme
     * starts with a letter and may carry letters, digits, and a few
     * punctuation marks before its colon.
     *
     * @param {string} target the link target text
     * @return {string} the lower-case scheme, or the empty string when the
     *      target does not start with one
     */
    linkScheme(target)
    {
        var length = target.length;
        if (length === 0 || !isAlphaChar(target.charAt(0))) {
            return "";
        }
        var cursor = 1;
        while (cursor < length) {
            var character = target.charAt(cursor);
            if (isAlnumChar(character) || character === "+" ||
                character === "." || character === "-") {
                cursor++;
            } else {
                break;
            }
        }
        if (cursor < length && target.charAt(cursor) === ":") {
            return target.slice(0, cursor).toLowerCase();
        }
        return "";
    }
    /**
     * Escapes text so any html-special character is shown literally. An
     * entity already written by hand is left as it is rather than escaped
     * a second time, so it shows the character it stands for.
     *
     * @param {string} text the text to escape
     * @return {string} the escaped text
     */
    escapeText(text)
    {
        return htmlSpecialChars(text, false);
    }
    /**
     * Escapes verbatim text shown exactly as typed, and additionally
     * breaks up the opener of a display-time token so a token shown as an
     * example is not swapped for its widget when the page is drawn.
     *
     * @param {string} text the verbatim text to escape
     * @return {string} the escaped text with any token opener broken up
     */
    escapeVerbatim(text)
    {
        return this.escapeText(text).replace(/\[\{/g, "[&#123;");
    }
    /**
     * Reads a template that sits inline in running text. The template
     * branches (toggle, citation, style span, search box, forms) are
     * ported after corpus parity is reached; the mediawiki corpus uses no
     * inline template, so returning null here gives the same result the
     * the parser does when it finds none, letting the caller show the braces
     * as text.
     *
     * @param {string} text the block text being scanned
     * @param {number} index the position of the opening double brace
     * @return {(Object|null)} the template result, or null
     */
    /**
     * Reads a template that sits inline in running text: a toggle link, a
     * citation footnote, a class, id, or style span, a search box, or a
     * form. Returns null for a double brace that starts some other
     * template, which the caller then leaves as text.
     *
     * @param {string} text the block text being scanned
     * @param {number} index the position of the opening double brace
     * @return {(Object|null)} the template's html and the position after
     *      it, or null when it is not an inline template
     */
    readInlineTemplate(text, index)
    {
        if (this.findMark(text, "}}", index + 2) === -1) {
            return null;
        }
        var close = this.matchBraces(text, index);
        if (close === false) {
            return null;
        }
        var inner = text.slice(index + 2, close);
        var next = close + 2;
        var head = this.readTemplateHead(inner);
        var name = head["name"];
        var separator = head["separator"];
        var rest = head["rest"];
        var lower = name.toLowerCase();
        if (separator === "|" && name === "toggle") {
            var bar = rest.indexOf("|");
            if (bar !== -1) {
                return {html: "<a href=\"javascript:toggleDisplay('" +
                    this.escapeText(trim(rest.slice(0, bar))) + "')\">" +
                    this.renderInline(rest.slice(bar + 1)) + "</a>",
                    next: next};
            }
        }
        if (separator === "|" && (lower.indexOf("cite") === 0 ||
            lower.indexOf("vcite") === 0)) {
            return {html: this.citeMarker(rest), next: next};
        }
        if ((separator === "=" || separator === ":") &&
            (name === "class" || name === "id" || name === "style")) {
            var parsed = this.readQuotedValue(rest);
            if (parsed !== null) {
                return {html: "<span " + name + "=\"" +
                    this.escapeText(parsed["value"]) + "\">" +
                    this.renderInline(parsed["content"]) + "</span>",
                    next: next};
            }
        }
        if (separator === ":" && lower === "search") {
            var token = this.searchWidgetToken(rest);
            if (token !== null) {
                return {html: token, next: next};
            }
        }
        var form = this.readFormTemplate(inner);
        if (form !== null) {
            return {html: form, next: next};
        }
        return null;
    }
    /**
     * Turns the body of a search tag into a deferred search-box token,
     * left for the view to fill in when the page is shown, since a library
     * parser has no page address or button helpers. Returns null when the
     * three parts are not all present.
     *
     * @param {string} rest the tag body after the search word
     * @return {(string|null)} the search token, or null when malformed
     */
    searchWidgetToken(rest)
    {
        var parts = rest.split("|");
        if (parts.length < 3) {
            return null;
        }
        var its = trim(parts[0]);
        var size = this.afterFirstColon(parts[1]);
        var placeholder = this.afterFirstColon(parts[2]);
        if (its === "" || size === "" || placeholder === "") {
            return null;
        }
        return "[{search|" + its + "|" + size + "|" + placeholder + "}]";
    }
    /**
     * Returns the text after the first colon in a string, trimmed, or the
     * whole trimmed string when there is no colon.
     *
     * @param {string} part the tag part to read
     * @return {string} the value after the first colon
     */
    afterFirstColon(part)
    {
        var colon = part.indexOf(":");
        if (colon === -1) {
            return trim(part);
        }
        return trim(part.slice(colon + 1));
    }
    /**
     * Reads a wiki table from its opening brace-bar line through its
     * closing bar-brace line. A plus line gives the caption, a dash line
     * starts a row, a bang line holds header cells, and a bar line holds
     * data cells; only the table's class and style survive.
     *
     * @param {MarkUpScanner} scanner the scanner at the opening table line
     * @return {string} the table html
     */
    parseTable(scanner)
    {
        var open = scanner.readLine();
        var attributes = trim(open.slice(2));
        var caption = null;
        var rows = [];
        var cells = null;
        while (!scanner.atEnd()) {
            var line = scanner.readLine();
            if (line.slice(0, 2) === "|}") {
                break;
            }
            if (line.slice(0, 2) === "|+") {
                caption = trim(line.slice(2));
            } else if (line.slice(0, 2) === "|-") {
                if (cells !== null) {
                    rows.push(cells);
                }
                cells = [];
            } else if (line !== "" &&
                (line.charAt(0) === "!" || line.charAt(0) === "|")) {
                if (cells === null) {
                    cells = [];
                }
                var split = this.splitTableCells(line);
                for (var split_cell of split) {
                    cells.push(split_cell);
                }
            } else if (cells !== null && cells.length > 0) {
                var last = cells.length - 1;
                cells[last]["text"] += "\n" + line;
            }
        }
        if (cells !== null) {
            rows.push(cells);
        }
        var html = "<table" + this.safeTableAttributes(attributes) + ">\n";
        if (caption !== null) {
            html += "<caption>" + this.renderInline(caption) +
                "</caption>\n";
        }
        for (var row of rows) {
            html += "<tr>";
            for (var cell of row) {
                var tag = cell["header"] ? "th" : "td";
                html += "<" + tag + cell["attributes"] + ">" +
                    this.renderInline(cell["text"]) + "</" + tag + ">";
            }
            html += "</tr>\n";
        }
        return html + "</table>\n";
    }
    /**
     * Reads the leading run of a cell as its attributes when that run
     * begins with one or more safe name-value pairs, returning the
     * attribute string and how many leading characters were attributes.
     *
     * @param {string} text the text before a cell's first single bar
     * @return {Object} the safe attributes under text and the count of
     *      leading characters that were attributes under length
     */
    readCellAttributes(text)
    {
        var allowed = ["align", "valign", "style", "class", "colspan",
            "rowspan", "scope", "width", "height", "bgcolor", "id"];
        var result = "";
        var length = text.length;
        var index = 0;
        var consumed = 0;
        while (index < length) {
            while (index < length && isWhitespaceChar(text.charAt(index))) {
                index++;
            }
            var start = index;
            while (index < length && (isAlphaChar(text.charAt(index)) ||
                text.charAt(index) === "-")) {
                index++;
            }
            var name = text.slice(start, index).toLowerCase();
            var after = index;
            while (after < length && isWhitespaceChar(text.charAt(after))) {
                after++;
            }
            if (name === "" || after >= length ||
                text.charAt(after) !== "=") {
                break;
            }
            after++;
            while (after < length && isWhitespaceChar(text.charAt(after))) {
                after++;
            }
            if (after >= length || (text.charAt(after) !== "\"" &&
                text.charAt(after) !== "'")) {
                break;
            }
            var quote = text.charAt(after);
            var value_start = after + 1;
            var end = text.indexOf(quote, value_start);
            if (end === -1) {
                break;
            }
            if (allowed.indexOf(name) !== -1) {
                result += " " + name + "=\"" +
                    this.escapeText(text.slice(value_start, end)) + "\"";
            }
            index = end + 1;
            consumed = index;
        }
        return {text: result, length: consumed};
    }
    /**
     * Splits one table line into its cells. A double bang starts another
     * header cell and a bar, single or double, starts a data cell. A bar
     * inside a wiki link or a nowiki span is part of that cell, not a
     * divider. A safe run of attributes at a cell's wall is kept.
     *
     * @param {string} line the cell line, opening with a bang or a bar
     * @return {Array} the cells, each a header flag, text, and attributes
     */
    splitTableCells(line)
    {
        var cells = [];
        var header = (line.charAt(0) === "!");
        var rest = line.slice(1);
        var current = "";
        var index = 0;
        var length = rest.length;
        var link_depth = 0;
        var in_nowiki = false;
        var attributes = "";
        var attr_done = false;
        while (index < length) {
            if (!in_nowiki && rest.startsWith("<nowiki>", index)) {
                in_nowiki = true;
                current += "<nowiki>";
                index += 8;
                continue;
            }
            if (in_nowiki && rest.startsWith("</nowiki>", index)) {
                in_nowiki = false;
                current += "</nowiki>";
                index += 9;
                continue;
            }
            if (!in_nowiki && link_depth === 0) {
                var pair = rest.substr(index, 2);
                if (pair === "[[") {
                    link_depth++;
                    current += "[[";
                    index += 2;
                    continue;
                }
                if (pair === "!!" || pair === "||") {
                    cells.push({header: header, text: trim(current),
                        attributes: attributes});
                    header = (pair === "!!");
                    current = "";
                    attributes = "";
                    attr_done = false;
                    index += 2;
                    continue;
                }
                if (rest.charAt(index) === "|") {
                    if (!attr_done) {
                        var parsed = this.readCellAttributes(current);
                        if (parsed["text"] !== "") {
                            attributes = parsed["text"];
                            attr_done = true;
                            current = current.slice(parsed["length"]);
                            index++;
                            continue;
                        }
                    }
                    cells.push({header: header, text: trim(current),
                        attributes: attributes});
                    header = false;
                    attributes = "";
                    attr_done = false;
                    current = "";
                    index++;
                    continue;
                }
            }
            if (!in_nowiki && link_depth > 0 &&
                rest.startsWith("]]", index)) {
                link_depth--;
                current += "]]";
                index += 2;
                continue;
            }
            current += rest.charAt(index);
            index++;
        }
        cells.push({header: header, text: trim(current),
            attributes: attributes});
        return cells;
    }
    /**
     * Pulls just the class and style out of a table's attribute text and
     * returns them as an escaped attribute string, dropping anything else.
     *
     * @param {string} attributes the raw attribute text after the {|
     * @return {string} a leading-space attribute string, or empty
     */
    safeTableAttributes(attributes)
    {
        var safe = "";
        var css_class = this.extractQuotedAttribute(attributes, "class");
        if (css_class !== null) {
            safe += " class=\"" + this.escapeText(css_class) + "\"";
        }
        var style = this.extractQuotedAttribute(attributes, "style");
        if (style !== null) {
            safe += " style=\"" + this.escapeText(style) + "\"";
        }
        return safe;
    }
    /**
     * Pulls the double-quoted value of a named attribute out of a run of
     * attribute text, matching the name without regard to case.
     *
     * @param {string} attributes the raw attribute text to search
     * @param {string} name the attribute name to look for, in lower case
     * @return {(string|null)} the value between the quotes, or null
     */
    extractQuotedAttribute(attributes, name)
    {
        var lower = attributes.toLowerCase();
        var length = attributes.length;
        var name_length = name.length;
        var offset = 0;
        var found = lower.indexOf(name, offset);
        while (found !== -1) {
            var cursor = found + name_length;
            while (cursor < length &&
                isWhitespaceChar(attributes.charAt(cursor))) {
                cursor++;
            }
            if (cursor < length && attributes.charAt(cursor) === "=") {
                cursor++;
                while (cursor < length &&
                    isWhitespaceChar(attributes.charAt(cursor))) {
                    cursor++;
                }
                if (cursor < length && attributes.charAt(cursor) === "\"") {
                    cursor++;
                    var close = attributes.indexOf("\"", cursor);
                    if (close !== -1) {
                        return attributes.slice(cursor, close);
                    }
                }
            }
            offset = found + 1;
            found = lower.indexOf(name, offset);
        }
        return null;
    }
    /**
     * Removes nowiki tags from preformatted text while keeping the
     * characters they wrap; an opener with no closer is left as written.
     *
     * @param {string} text the preformatted text that may hold nowiki
     * @return {string} the text with matched nowiki tags removed
     */
    stripNowiki(text)
    {
        var open = "<nowiki>";
        var close = "</nowiki>";
        var result = "";
        var index = 0;
        var length = text.length;
        while (index < length) {
            if (text.startsWith(open, index)) {
                var end = text.indexOf(close, index + open.length);
                if (end !== -1) {
                    result += text.slice(index + open.length, end);
                    index = end + close.length;
                    continue;
                }
            }
            result += text.charAt(index);
            index++;
        }
        return result;
    }
    /**
     * Reads a pre block through its closing tag and emits it as
     * preformatted text shown exactly as typed; a nowiki span inside has
     * its tags dropped and its characters kept.
     *
     * @param {MarkUpScanner} scanner the scanner at the opening pre tag
     * @return {string} the pre block html
     */
    parsePre(scanner)
    {
        return this.parseVerbatimBlock(scanner, "pre");
    }
    /**
     * Reads a code block, from its opening code tag through its closing
     * one however many lines it spans, and emits it as code shown exactly
     * as typed. A wiki author uses it to show an example whose own lines,
     * such as a user-agent string laid out over several lines, should be
     * kept together and not read as wiki markup. It reads the same way a
     * pre block does, so a nowiki span inside has its tags dropped and its
     * characters kept and a closing code tag inside such a span does not
     * end the block.
     *
     * @param {MarkUpScanner} scanner the scanner at the opening code tag
     * @return {string} the code block html
     */
    parseCodeBlock(scanner)
    {
        return this.parseVerbatimBlock(scanner, "code");
    }
    /**
     * Reads a verbatim block wrapped in a named tag, either pre or code,
     * from just after its opening tag through its closing tag however many
     * lines it spans, and emits it wrapped in that same tag with its text
     * shown exactly as typed. A nowiki span inside has its tags dropped and
     * its characters kept; a closing tag that falls inside such a span does
     * not end the block. Sharing one reader keeps pre and code identical
     * except for the tag they carry.
     *
     * @param {MarkUpScanner} scanner the scanner at the opening tag
     * @param {string} tag the tag that opens and closes the block, "pre"
     *      or "code"
     * @return {string} the block html wrapped in that tag
     */
    parseVerbatimBlock(scanner, tag)
    {
        var open = "<" + tag + ">";
        var close = "</" + tag + ">";
        scanner.advance(open.length);
        var body = "";
        var nowiki_depth = 0;
        while (!scanner.atEnd()) {
            if (nowiki_depth === 0 && scanner.startsWith(close)) {
                scanner.advance(close.length);
                break;
            }
            if (scanner.startsWith("<nowiki>")) {
                nowiki_depth++;
                body += "<nowiki>";
                scanner.advance("<nowiki>".length);
                continue;
            }
            if (nowiki_depth > 0 && scanner.startsWith("</nowiki>")) {
                nowiki_depth--;
                body += "</nowiki>";
                scanner.advance("</nowiki>".length);
                continue;
            }
            body += scanner.peek();
            scanner.advance();
        }
        return "<" + tag + ">" +
            this.escapeVerbatim(this.stripNowiki(body)) +
            "</" + tag + ">\n";
    }
    /**
     * Reads a run of lines that each open with a space, the shorthand for
     * preformatted text, and emits them as one pre block.
     *
     * @param {MarkUpScanner} scanner the scanner at the first spaced line
     * @return {string} the pre block html
     */
    parseSpacePre(scanner)
    {
        var lines = [];
        while (!scanner.atEnd() && scanner.peek() === " " &&
            !scanner.atBlankLine()) {
            lines.push(scanner.readLine().slice(1));
        }
        return "<pre>" + this.escapeVerbatim(
            this.stripNowiki(lines.join("\n"))) + "</pre>\n";
    }
    /**
     * Reads a notoc region and emits its lines parsed as usual except that
     * no heading inside is added to the contents box.
     *
     * @param {MarkUpScanner} scanner the scanner at the opening notoc tag
     * @return {string} the html for the region's contents
     */
    parseNotocRegion(scanner)
    {
        var open = "<notoc>";
        var close = "</notoc>";
        var line = scanner.readLine();
        var content = line.slice(line.indexOf(open) + open.length);
        var end = content.indexOf(close);
        if (end !== -1) {
            content = content.slice(0, end);
        } else {
            while (!scanner.atEnd()) {
                line = scanner.readLine();
                end = line.indexOf(close);
                if (end !== -1) {
                    content += "\n" + line.slice(0, end);
                    break;
                }
                content += "\n" + line;
            }
        }
        var was = this.collecting_headings;
        this.collecting_headings = false;
        var inside = new MarkUpScanner(content,
            WikiParser.WIKI_BLOCK_START_CHARS);
        var html = this.parseBlocks(inside);
        this.collecting_headings = was;
        return html;
    }
    /**
     * Reads a block template that opens with a double brace: a wrapped
     * block, an alignment or attribute wrapper, a transclusion note, or a
     * form. Returns null, leaving the scanner untouched, when the braces do
     * not form a block template.
     *
     * @param {MarkUpScanner} scanner the scanner at the opening double brace
     * @return {(string|null)} the template html, or null
     */
    parseTemplate(scanner)
    {
        var mark = scanner.tell();
        var block = this.parseWrappedTemplate(scanner);
        if (block !== null) {
            return block;
        }
        var inner = scanner.matchBraces();
        if (inner === false) {
            scanner.seek(mark);
            return null;
        }
        var trailing = trim(scanner.readLine());
        var node = this.readBlockTemplate(inner);
        if (node !== null) {
            if (trailing !== "") {
                scanner.seek(mark);
                return null;
            }
            if (node["type"] === "html") {
                return node["html"];
            }
            var was = this.collecting_headings;
            this.collecting_headings = false;
            var content = new MarkUpScanner(node["content"],
                WikiParser.WIKI_BLOCK_START_CHARS);
            var inside = this.parseBlocks(content);
            this.collecting_headings = was;
            return "<" + node["tag"] + node["attributes"] + ">\n" +
                inside + "</" + node["tag"] + ">\n";
        }
        var form = this.readFormTemplate(inner);
        if (form !== null) {
            var tail = (trailing === "") ? "" :
                this.renderInline(trailing);
            if (form === "" && tail === "") {
                return "";
            }
            return form + tail + "\n";
        }
        scanner.seek(mark);
        return null;
    }
    /**
     * Reads the opening line of a block or inline-block wrapper template,
     * written as a block or inblock keyword with an id and style. Returns
     * null when the line is not a wrapper opening.
     *
     * @param {string} line the line to read
     * @return {(Object|null)} the keyword, id, and style, or null
     */
    matchWrappedTemplateOpen(line)
    {
        var trimmed = rtrim(line);
        if (trimmed.slice(0, 2) !== "{{" || trimmed.slice(-2) !== "}}") {
            return null;
        }
        var inner = trimmed.slice(2, trimmed.length - 2);
        var parts = inner.split("|");
        if (parts.length !== 3) {
            return null;
        }
        var keyword = parts[0];
        var id = parts[1];
        var style = parts[2];
        if (keyword !== "block" && keyword !== "inblock") {
            return null;
        }
        if (id === "" || id.indexOf("}") !== -1 ||
            style.indexOf("}") !== -1) {
            return null;
        }
        return {keyword: keyword, id: id, style: style};
    }
    /**
     * Reads the block-and-end-block or inblock form, wrapping the lines up
     * to a matching end marker in a div or span. Returns null when the
     * current line is not one of these openers.
     *
     * @param {MarkUpScanner} scanner the scanner at the opening double brace
     * @return {(string|null)} the wrapped html, or null
     */
    parseWrappedTemplate(scanner)
    {
        var mark = scanner.tell();
        var line = scanner.readLine();
        var open = this.matchWrappedTemplateOpen(line);
        if (open === null) {
            scanner.seek(mark);
            return null;
        }
        var tag = (open["keyword"] === "inblock") ? "span" : "div";
        var end_marker = "{{end-" + open["keyword"] + "}}";
        var collected = "";
        while (!scanner.atEnd()) {
            line = scanner.readLine();
            if (rtrim(line) === end_marker) {
                break;
            }
            collected += (collected === "" ? "" : "\n") + line;
        }
        var was = this.collecting_headings;
        this.collecting_headings = false;
        var content = new MarkUpScanner(collected,
            WikiParser.WIKI_BLOCK_START_CHARS);
        var inside = this.parseBlocks(content);
        this.collecting_headings = was;
        return "<" + tag + " id=\"" + this.escapeText(open["id"]) +
            "\" style=\"" + this.escapeText(open["style"]) + "\">\n" +
            inside + "</" + tag + ">\n";
    }
    /**
     * Reads a paragraph: the first line plus any plain continuation lines,
     * stopping at a blank line or a line that opens a new block.
     *
     * @param {MarkUpScanner} scanner the scanner at the paragraph's start
     * @return {string} the paragraph html
     */
    parseParagraph(scanner)
    {
        var text = scanner.readLine();
        while (!scanner.atEnd()) {
            if (!this.hasOpenNowiki(text) &&
                (scanner.atBlankLine() || this.startsBlock(scanner))) {
                break;
            }
            text += "\n" + scanner.readLine();
        }
        return "<p>" + this.renderInline(text) + "</p>\n";
    }
    /**
     * Reports whether the gathered text has a nowiki span still open, so a
     * paragraph keeps gathering lines even past what would begin a new
     * block while example markup inside nowiki is read as literal text.
     *
     * @param {string} text the paragraph text gathered so far
     * @return {boolean} true when a nowiki span is still open
     */
    hasOpenNowiki(text)
    {
        return substrCount(text, "<nowiki>") >
            substrCount(text, "</nowiki>");
    }
    /**
     * Finds the double-brace that closes the template opening at the given
     * position, counting nested templates so an inner close does not end
     * the outer one.
     *
     * @param {string} text the text being scanned
     * @param {number} start the position of the opening double brace
     * @return {(number|boolean)} the position of the matching close, or
     *      false when there is none
     */
    matchBraces(text, start)
    {
        var depth = 0;
        var length = text.length;
        var index = start;
        while (index < length - 1) {
            var pair = text.substr(index, 2);
            if (pair === "{{") {
                depth++;
                index += 2;
            } else if (pair === "}}") {
                depth--;
                if (depth === 0) {
                    return index;
                }
                index += 2;
            } else {
                index++;
            }
        }
        return false;
    }
    /**
     * Reads the head of a template's braces: the leading name, the first
     * separator after it (a bar, an equals, or a colon), and the rest.
     *
     * @param {string} inner the text between the template's braces
     * @return {Object} the name, the separator character, and the rest
     */
    readTemplateHead(inner)
    {
        var length = inner.length;
        var index = 0;
        while (index < length && isWhitespaceChar(inner.charAt(index))) {
            index++;
        }
        var start = index;
        while (index < length &&
            "|=:".indexOf(inner.charAt(index)) === -1) {
            index++;
        }
        var separator = (index < length) ? inner.charAt(index) : "";
        var rest = (index < length) ? inner.slice(index + 1) : "";
        return {name: trim(inner.slice(start, index)),
            separator: separator, rest: rest};
    }
    /**
     * Reads a quoted value and the text that follows it, used by the
     * class, id, and style forms. Returns null when the text does not open
     * with a quote.
     *
     * @param {string} text the text after the attribute's separator
     * @return {(Object|null)} the quoted value and following content, or
     *      null
     */
    readQuotedValue(text)
    {
        var length = text.length;
        var index = 0;
        while (index < length && isWhitespaceChar(text.charAt(index))) {
            index++;
        }
        if (index >= length || (text.charAt(index) !== "\"" &&
            text.charAt(index) !== "'")) {
            return null;
        }
        var quote = text.charAt(index);
        index++;
        var start = index;
        while (index < length && text.charAt(index) !== quote) {
            index++;
        }
        if (index >= length) {
            return null;
        }
        return {value: text.slice(start, index),
            content: ltrim(text.slice(index + 1))};
    }
    /**
     * Reads a styling template's inside into a template node, or returns
     * null when it is not one this reader handles: an alignment, a class,
     * id, or style wrapper, or a see or hatnote note.
     *
     * @param {string} inner the text between the double braces
     * @return {(Object|null)} a template node, or null when unhandled
     */
    readBlockTemplate(inner)
    {
        var head = this.readTemplateHead(inner);
        var name = head["name"];
        var separator = head["separator"];
        var rest = head["rest"];
        var lower = name.toLowerCase();
        var alignments = {center: "center", left: "align-left",
            right: "align-right"};
        var owns = Object.prototype.hasOwnProperty;
        if (separator === "|" && owns.call(alignments, name)) {
            return {type: "template", tag: "div",
                attributes: " class=\"" + alignments[name] + "\"",
                content: rest};
        }
        if ((separator === "=" || separator === ":") &&
            (name === "class" || name === "id" || name === "style")) {
            var parsed = this.readQuotedValue(rest);
            if (parsed !== null) {
                return {type: "template", tag: "div",
                    attributes: " " + name + "=\"" +
                    this.escapeText(parsed["value"]) + "\"",
                    content: parsed["content"]};
            }
        }
        if (separator === "|" && (lower === "main" || lower === "see" ||
            lower === "see also")) {
            var bar = rest.indexOf("|");
            var target = trim((bar === -1) ? rest : rest.slice(0, bar));
            return {type: "html", html:
                "<div class=\"indent\">(<a href=\"" +
                this.escapeText(this.base_address + target) + "\">" +
                this.escapeText(target) + "</a>)</div>\n"};
        }
        if (separator === "|" &&
            (lower === "hatnote" || lower === "dablink")) {
            return {type: "html",
                html: "(" + this.renderInline(rest) + ")\n"};
        }
        return null;
    }
    /**
     * Reads a form template. The form templates are ported after corpus
     * parity is reached; none appear in the mediawiki corpus, so returning
     * null is the same as finding no form template and lets a
     * plain block template be read as text.
     *
     * @param {string} inner the text between the template's braces
     * @return {(string|null)} the form html or marker, or null
     */
    readFormTemplate(inner)
    {
        return null;
    }
    /**
     * Builds the table of contents that goes above a page, or the empty
     * string when the page has too few headings. A single top-level
     * heading is the page's title and is left out.
     *
     * @param {Array} headings the page's headings, each a level and text
     * @return {string} the html contents box, or empty when not drawn
     */
    tableOfContents(headings)
    {
        if (headings.length < WikiParser.MIN_SECTIONS_FOR_TOC) {
            return "";
        }
        var top_count = 0;
        for (var i = 0; i < headings.length; i++) {
            if (headings[i]["level"] === 1) {
                top_count++;
            }
        }
        if (top_count === 1) {
            var kept = [];
            for (var j = 0; j < headings.length; j++) {
                if (headings[j]["level"] !== 1) {
                    kept.push(headings[j]);
                }
            }
            headings = kept;
        }
        if (headings.length === 0) {
            return "";
        }
        var min_level = headings[0]["level"];
        for (var k = 1; k < headings.length; k++) {
            if (headings[k]["level"] < min_level) {
                min_level = headings[k]["level"];
            }
        }
        var items = this.nestHeadings(headings, min_level);
        return "<div class=\"toc top-color\">\n" +
            this.renderContents(items) + "</div>\n";
    }
    /**
     * Nests a flat list of headings into a tree by their levels, so a
     * heading a level deeper than the one before becomes its child.
     *
     * @param {Array} headings the headings, each a level and text
     * @param {number} base_level the level of the tier being built
     * @return {Array} the heading items at this tier
     */
    nestHeadings(headings, base_level)
    {
        var items = [];
        var count = headings.length;
        var index = 0;
        while (index < count) {
            var item = {text: headings[index]["text"], sublist: null};
            var next = index + 1;
            while (next < count &&
                headings[next]["level"] > base_level) {
                next++;
            }
            if (next > index + 1) {
                item["sublist"] = this.nestHeadings(
                    headings.slice(index + 1, next), base_level + 1);
            }
            items.push(item);
            index = next;
        }
        return items;
    }
    /**
     * Renders the nested heading items into an ordered list of anchor
     * links, each pointing at the matching heading's id.
     *
     * @param {Array} items the heading items from nestHeadings
     * @return {string} the html ordered list
     */
    renderContents(items)
    {
        var html = "<ol>\n";
        for (var i = 0; i < items.length; i++) {
            html += "<li><a href=\"#" + items[i]["text"] + "\">" +
                items[i]["text"] + "</a>";
            if (items[i]["sublist"] !== null) {
                html += "\n" + this.renderContents(items[i]["sublist"]);
            }
            html += "</li>\n";
        }
        return html + "</ol>\n";
    }
    /**
     * Inserts the table of contents just before the first level-two
     * heading of the page, the spot below the page's title.
     *
     * @param {string} page the rendered page body
     * @param {string} toc the contents box html
     * @return {string} the page with the contents box inserted
     */
    insertTableOfContents(page, toc)
    {
        var pos = page.indexOf("<h2");
        if (pos !== -1) {
            var start = page.slice(0, pos);
            var end = page.slice(pos);
            page = start + toc + end;
        }
        return page;
    }
    /**
     * Turns one citation into a numbered footnote marker and files the
     * citation away for the reference list.
     *
     * @param {string} content the citation's fields, after the cite word
     * @return {string} the html footnote marker
     */
    citeMarker(content)
    {
        this.cite_references.push(content);
        var number = this.cite_references.length;
        return "<sup id=\"cite_" + number + "\"><a href=\"#ref_" +
            number + "\">[" + number + "]</a></sup>";
    }
    /**
     * Builds the reference list at the foot of a page from the citations
     * gathered while it rendered, or the empty string when there were none.
     *
     * @return {string} the html reference list, or empty
     */
    referenceList()
    {
        if (this.cite_references.length === 0) {
            return "";
        }
        var shown_fields = ["title", "author", "publisher", "journal",
            "url", "quote"];
        var html = "<ol class=\"references\">\n";
        var number = 1;
        for (var reference of this.cite_references) {
            var fields = {};
            var parts = reference.split("|");
            for (var part of parts) {
                var pair = splitLimit("=", part, 2);
                if (pair.length === 2) {
                    fields[trim(pair[0]).toLowerCase()] = trim(pair[1]);
                }
            }
            var shown = [];
            var owns = Object.prototype.hasOwnProperty;
            for (var field of shown_fields) {
                if (owns.call(fields, field)) {
                    shown.push(this.renderInline(fields[field]));
                }
            }
            html += "<li id=\"ref_" + number + "\">" +
                "<a href=\"#cite_" + number + "\">^</a> " +
                shown.join(", ") + "</li>\n";
            number++;
        }
        return html + "</ol>\n";
    }
}

/* ---- markup constants, mirrored from the server class ---- */
WikiParser.MAX_HEADING_LEVEL = 6;
WikiParser.INLINE_TAGS = "tt|u|s|strike|ins|del|sub|sup|b|i|br|code";
WikiParser.MAX_INDENT_LEVEL = 4;
WikiParser.MIN_SECTIONS_FOR_TOC = 4;
WikiParser.WIKI_BLOCK_START_CHARS = "=*#:;-{<";
WikiParser.WIKI_MAX_PARSE_DEPTH = 20;
WikiParser.ALLOWED_LINK_SCHEMES = ["http", "https", "mailto", "gopher",
    "ftp"];
WikiParser.END_HEAD_VARS = "END_HEAD_VARS";
/*
 * Converts yioop wiki markup to html for the help panel. The body is
 * parsed by the ported WikiParser against the reading url for this group,
 * then any cross-group marker and resource reference is turned into a link,
 * matching how the wiki view assembles a read page.
 *
 * @param String wiki_text the wiki markup to parse
 * @param String group_id the id of the group the page belongs to
 * @param String page_id the id of the page, used for resource urls
 * @param String controller_name the controller a page link should target
 * @param String csrf_token_key the query variable name for the csrf token
 * @param String csrf_token_value the csrf token value
 * @return String the parsed html
 */
function parseWikiContent(wiki_text, group_id, page_id, controller_name,
    csrf_token_key, csrf_token_value)
{
    if (!wiki_text) {
        return "";
    }
    /* a page may carry a head-variable section ended by an END_HEAD_VARS
       marker; it holds settings such as the page title, not body text, so
       drop it before parsing the body */
    var end_marker = "END_HEAD_VARS";
    var marker_at = wiki_text.indexOf(end_marker);
    if (marker_at !== -1) {
        wiki_text = wiki_text.slice(marker_at + end_marker.length);
    }
    var read_address = "?c=" + controller_name +
        "&a=wiki&arg=read&group_id=" + group_id + "&" + csrf_token_key +
        "=" + csrf_token_value + "&page_name=";
    var parser = new WikiParser(read_address);
    var html = parser.parse(wiki_text, false);
    /* resolve a cross-group marker left for a group@page link into that
       group's reading url, the way the wiki view does */
    var group_address = "?c=" + controller_name +
        "&amp;a=wiki&amp;arg=read&amp;" + csrf_token_key + "=" +
        csrf_token_value + "&amp;group_name=";
    html = html.replace(/@@(.*)@(.*)@@/g, function (match, group_part,
        page_part) {
        return group_address + group_part + "&amp;page_name=" + page_part;
    });
    /* turn a resource reference into the image, video, or link it names,
       addressed to this page's resources */
    html = html.replace(/\(\(resource:(.+?)\|(.+?)\)\)/g, function (match,
        resource_name, description) {
        var resource_url = "?c=resource&a=get&f=resources&g=" + group_id +
            "&p=" + page_id + "&n=" + resource_name;
        if ((/\.(gif|jpg|jpeg|tiff|png|bmp|tif)$/i).test(resource_name)) {
            return "<img src=\"" + resource_url + "\" alt=\"" +
                description + "\" class=\"wiki-resource-image\" >";
        }
        if ((/\.(avi|m4v|flv|wmv|mp4|webm|ogg|mov|mpg)$/i).test(
            resource_name)) {
            return "<video style=\"width:100%\" controls=\"controls\">" +
                "<source src=\"" + resource_url +
                "\" type=\"video/mp4\">" + description + "</video>";
        }
        return "<a href=\"" + resource_url + "\" alt=\"" + description +
            "\" >" + description + "</a>";
    });
    return html;
}
/*
 * Fills a wiki-history revision into the page in the browser instead of on
 * the server. A historical revision never changes, yet a crawler viewing it
 * would make the server re-parse the whole page every request; doing the
 * render here moves that cost off the server. A mediawiki revision is run
 * through the ported parser; a markdown one is shown as its raw source,
 * since only the mediawiki engine is ported.
 *
 * @param String target_id id of the element the revision is placed into.
 * @param String source the raw wiki source of the revision.
 * @param bool is_mediawiki whether the revision uses the mediawiki engine.
 * @param String group_id id of the group the page belongs to.
 * @param String page_id id of the page the revision belongs to.
 * @param String controller controller name the page is read through.
 * @param String csrf_key name of the anti-cross-site-request token field.
 * @param String csrf_value value of the anti-cross-site-request token.
 */
function renderWikiHistory(target_id, source, is_mediawiki, group_id,
    page_id, controller, csrf_key, csrf_value)
{
    var target = elt(target_id);
    if (!target) {
        return;
    }
    if (is_mediawiki && typeof parseWikiContent === "function") {
        target.innerHTML = parseWikiContent(source, group_id, page_id,
            controller, csrf_key, csrf_value);
    } else {
        target.textContent = source;
    }
}
/*
 * Renders the line-by-line diff of two wiki-page revisions into the element
 * named by target_id. Doing this in the browser keeps the server from
 * building a longest-common-subsequence table on every visit to a diff url.
 *
 * @param String target_id id of the element to fill with the diff
 * @param String source1 raw source of the first revision
 * @param String source2 raw source of the second revision
 * @param int max_cells largest subsequence table computed exactly; a larger
 *      differing region is searched within a band instead
 * @param int band how far a banded search may drift from the diagonal
 */
function renderWikiDiff(target_id, source1, source2, max_cells, band)
{
    var target = elt(target_id);
    if (!target) {
        return;
    }
    target.innerHTML = wikiDiff(source1, source2, max_cells, band);
}
/*
 * Computes the html for the line-by-line diff of two strings. The output
 * matches the server diff(): removed lines in red, added lines in green,
 * shared context in light gray, each hunk led by an "@@" line. Common
 * leading and trailing lines are trimmed first; the differing middle is
 * aligned with an exact table when small enough, otherwise within a band.
 *
 * @param String data1 first string to compare
 * @param String data2 second string to compare
 * @param int max_cells largest table computed exactly
 * @param int band how far a banded search may drift from the diagonal
 * @return String html describing where data1 and data2 differ
 */
function wikiDiff(data1, data2, max_cells, band)
{
    var start = "<div>", start_same = "<div class='light-gray'>",
        start1 = "<div class='red'>", start2 = "<div class='green'>",
        end = "</div>";
    var lines1 = data1.replace(/\r\n?/g, "\n").split("\n");
    var lines2 = data2.replace(/\r\n?/g, "\n").split("\n");
    var num1 = lines1.length, num2 = lines2.length;
    var shorter = Math.min(num1, num2), longer = Math.max(num1, num2);
    var first_diff = 0, head_lcs = [], i;
    while (first_diff < shorter &&
        lines1[first_diff] === lines2[first_diff]) {
        head_lcs.push([first_diff, first_diff, lines1[first_diff]]);
        first_diff++;
    }
    if (first_diff === shorter) {
        if (num1 === num2) {
            return "";
        }
        var tmp = lines1, prefix = start1 + "-";
        if (num1 === shorter) {
            tmp = lines2;
            prefix = start2 + "+";
        }
        var out = start + "@@ -" + shorter + ",0 +" + shorter + "," +
            longer + " @@" + end + "\n";
        for (i = shorter; i < longer; i++) {
            out += prefix + tmp[i] + end + "\n";
        }
        return out;
    }
    var last_diff = 0, tail_lcs = [];
    var idx1 = num1 - 1, idx2 = num2 - 1;
    while (shorter - last_diff > first_diff &&
        lines1[idx1] === lines2[idx2]) {
        tail_lcs.unshift([idx1, idx2, lines1[idx1]]);
        last_diff++;
        idx1--;
        idx2--;
    }
    var trim1 = lines1.slice(first_diff, num1 - last_diff);
    var trim2 = lines2.slice(first_diff, num2 - last_diff);
    var cells = trim1.length * trim2.length, lcs;
    if (cells <= max_cells) {
        lcs = wikiDiffLcsFull(trim1, trim2, first_diff);
    } else {
        lcs = wikiDiffLcs(trim1, trim2, first_diff, band);
    }
    lcs = head_lcs.concat(lcs, tail_lcs);
    var previous_first = -1, previous_second = -1, current_first = 0,
        current_second = 0, old_line = "", out_string = "";
    if (lcs.length === 0) {
        out_string += start + "@@ -0," + num1 + "  +0," + num2 + " @@" +
            end + "\n";
        for (i = 0; i < num1; i++) {
            out_string += start1 + "-" + lines1[i] + end + "\n";
        }
        for (i = 0; i < num2; i++) {
            out_string += start2 + "+" + lines2[i] + end + "\n";
        }
        return out_string;
    }
    for (var k = 0; k < lcs.length; k++) {
        current_first = lcs[k][0];
        current_second = lcs[k][1];
        var line = lcs[k][2];
        var gap1 = current_first - previous_first;
        var gap2 = current_second - previous_second;
        if (gap1 > 1 || gap2 > 1) {
            gap1++;
            gap2++;
            out_string += start + "@@ -" + previous_first + "," + gap1 +
                "  +" + previous_second + "," + gap2 + " @@" + end + "\n";
            out_string += start_same + " " + old_line + end + "\n";
            for (i = previous_first + 1; i < current_first; i++) {
                out_string += start1 + "-" + lines1[i] + end + "\n";
            }
            for (i = previous_second + 1; i < current_second; i++) {
                out_string += start2 + "+" + lines2[i] + end + "\n";
            }
            out_string += start_same + " " + line + end + "\n";
        }
        previous_first = current_first;
        previous_second = current_second;
        old_line = line;
    }
    if (current_first < num1 - 1 || current_second < num2 - 1) {
        out_string += start_same + " " + old_line + end + "\n";
        for (i = current_first + 1; i < num1; i++) {
            out_string += start1 + "-" + lines1[i] + end + "\n";
        }
        for (i = current_second + 1; i < num2; i++) {
            out_string += start2 + "+" + lines2[i] + end + "\n";
        }
    }
    return out_string;
}
/*
 * Computes the exact longest common subsequence of two arrays of lines with
 * the full dynamic-programming table, giving the same result the server
 * computeLCS() would but kept memory-lean for the browser: the backtrack
 * directions are held one byte per cell in a typed array, and only two rows
 * of lengths are carried at a time, so a region of a few million cells costs
 * a few megabytes and finishes well under a second. The caller reaches this
 * routine only when the region fits under the client cell ceiling; a larger
 * region uses the banded wikiDiffLcs() below instead.
 *
 * @param Array lines1 first array of lines
 * @param Array lines2 second array of lines
 * @param int offset amount added to each output line number, used because
 *      the common leading lines were trimmed before the call
 * @return Array list of [index1, index2, line] triples in order
 */
function wikiDiffLcsFull(lines1, lines2, offset)
{
    var num1 = lines1.length, num2 = lines2.length;
    if (num1 === 0 || num2 === 0) {
        return [];
    }
    var stride = num2 + 1;
    var move_diagonal = 1, move_up = 2, move_left = 3;
    var moves = new Int8Array(stride * (num1 + 1));
    var row_prev = new Int32Array(stride);
    var row_cur = new Int32Array(stride);
    var i, j;
    for (i = 1; i <= num1; i++) {
        var base = i * stride;
        row_cur[0] = 0;
        for (j = 1; j <= num2; j++) {
            if (lines1[i - 1] === lines2[j - 1]) {
                row_cur[j] = row_prev[j - 1] + 1;
                moves[base + j] = move_diagonal;
            } else if (row_prev[j] >= row_cur[j - 1]) {
                row_cur[j] = row_prev[j];
                moves[base + j] = move_up;
            } else {
                row_cur[j] = row_cur[j - 1];
                moves[base + j] = move_left;
            }
        }
        var swap = row_prev;
        row_prev = row_cur;
        row_cur = swap;
    }
    var lcs = [];
    i = num1;
    j = num2;
    while (i > 0 && j > 0) {
        var move = moves[i * stride + j];
        if (move === move_diagonal) {
            lcs.unshift([i + offset - 1, j + offset - 1, lines1[i - 1]]);
            i--;
            j--;
        } else if (move === move_up) {
            i--;
        } else {
            j--;
        }
    }
    return lcs;
}
/*
 * Computes the longest common subsequence of two arrays of lines within a
 * band around the diagonal, mirroring the server computeLCS() but bounded so
 * a large region cannot slow the browser. Returns an empty list when the two
 * lengths differ by more than the band, which the caller then renders as a
 * coarse block of removals followed by additions.
 *
 * @param Array lines1 first array of lines
 * @param Array lines2 second array of lines
 * @param int offset amount added to each output line number, used because
 *      the common leading lines were trimmed before the call
 * @param int band how far the search may drift from the diagonal
 * @return Array list of [index1, index2, line] triples in order
 */
function wikiDiffLcs(lines1, lines2, offset, band)
{
    var num1 = lines1.length, num2 = lines2.length;
    if (num1 === 0 || num2 === 0 || Math.abs(num1 - num2) > band) {
        return [];
    }
    var width = 2 * band + 1;
    var val = [], moves = [], i, j;
    for (i = 0; i <= num1; i++) {
        val[i] = new Array(width).fill(0);
        moves[i] = new Array(width).fill("");
    }
    function vget(row, col_line)
    {
        if (row < 0 || col_line < 0 || row > num1) {
            return 0;
        }
        var band_col = col_line - row + band;
        if (band_col < 0 || band_col >= width) {
            return 0;
        }
        return val[row][band_col];
    }
    for (i = 1; i <= num1; i++) {
        var low = Math.max(1, i - band), high = Math.min(num2, i + band);
        for (j = low; j <= high; j++) {
            var col = j - i + band;
            if (lines1[i - 1] === lines2[j - 1]) {
                val[i][col] = vget(i - 1, j - 1) + 1;
                moves[i][col] = "d";
            } else if (vget(i - 1, j) >= vget(i, j - 1)) {
                val[i][col] = vget(i - 1, j);
                moves[i][col] = "u";
            } else {
                val[i][col] = vget(i, j - 1);
                moves[i][col] = "l";
            }
        }
    }
    var lcs = [];
    i = num1;
    j = num2;
    while (i > 0 && j > 0) {
        var back_col = j - i + band;
        if (back_col < 0 || back_col >= width) {
            break;
        }
        var move = moves[i][back_col];
        if (move === "d") {
            lcs.unshift([i + offset - 1, j + offset - 1, lines1[i - 1]]);
            i--;
            j--;
        } else if (move === "u") {
            i--;
        } else if (move === "l") {
            j--;
        } else {
            break;
        }
    }
    return lcs;
}
/*
 * getPageWithCallback does a GET HTTP call on the url passed.
 * Also fires the callback functions passed as
 * params appropriately.
 *
 * @param String url the url used for making GET HTTP call.
 * @param String response_type The response type expected.
 * @param Function object success_call_back Callback function on success.
 * @param Function object error_handler Callback function on failure.
 */
function getPageWithCallback(url, response_type, success_call_back,
    error_handler)
{
    let request = makeRequest();
    request.open('GET', url, true);
    request.onload = function ()
    {
        let status = request.status;
        if (status == 200) {
            success_call_back && success_call_back(
                JSON.parse(request.responseText));
        } else {
            error_handler && error_handler(status,
                JSON.parse(request.responseText));
        }
    };
    request.send();
};
/*
 * Takes in the help point id, uses it to fetch wiki content, then
 * wiki content is being eval'd to be painted int he help pane.
 * Ajax call happens only if help needs to be displayed.
 * @param Object help_point element
 * @param String is_mobile flag to check if the client is mobile
 * or not.
 * @param String target_controller Wiki page's controller name.
 * @param String csrf_token_key the dynamic name used for CSRF token.
 * @param String csrf_token_value The CSRF token to render edit page.
 * @param String help_group_id  help's group_id required to render resources.
 * @param String api_controller api's controller name.
 * @param String api_action api's action name.
 * @param String mode r/w mode , usually read.
 */
function displayHelpForId(help_point, is_mobile, target_controller,
    current_action, csrf_token_key, csrf_token_value, help_group_id,
    api_controller, api_action, mode)
{
    if ((elt("help-frame").style.display) === "inline-block") {
        toggleHelp('help-frame', is_mobile, target_controller);
        if (previous_help == help_point) {
            return;
        }
    }
    previous_help = help_point;
    let tl = eval('(' + help_point.getAttribute("data-tl") + ')');
    let back_params = eval('(' + help_point.getAttribute("data-back-params")
    + ')');
    let page_name = help_point.getAttribute("data-pagename");
    getPageWithCallback("?c=" + api_controller + "&group_id=" +
        help_group_id + "&" +
        "arg=" + mode + "&" +
        "a=" + api_action + "&" +
        csrf_token_key + '=' + csrf_token_value + "&" +
        "page_name=" + page_name,
        'json',
        function(data)
        {
            elt("help-frame-body").innerHTML = parseWikiContent(
                data.wiki_content,
                data.group_id,
                data.page_id,
                target_controller,
                csrf_token_key,
                csrf_token_value
            );
            console.log(data);
            if (!data.page_title) {
                data.page_title = page_name;
            }
            elt('page_name').innerHTML = data.page_view_title ||
            data.page_title || page_name;
            if (data.can_edit) {
                elt('page_name').innerHTML +=
                    " <div class='position-context'>"+
                    "<a class='media-anchor-button white'  href=\"" +
                    getEditLink(
                        target_controller,
                        current_action,
                        csrf_token_key,
                        csrf_token_value,
                        help_group_id,
                        data.page_title,
                        back_params) + '">' +
                    "<span role='img' aria-label='" +
                    tl["helpbutton_helper_edit"] +
                    "'>✏️</span></a>";
            }
            toggleHelp('help-frame', is_mobile, target_controller);
        },
        function(status, response)
        {
            if (status === 404 && response && response.can_edit) {
                elt('page_name').innerHTML = help_point
                    .getAttribute("data-pagename");
                elt('page_name').innerHTML +=
                    " <div class='position-context'>"+
                    "<a class='media-anchor-button white'  href=\"" +
                    getEditLink(
                        target_controller,
                        current_action,
                        csrf_token_key,
                        csrf_token_value,
                        help_group_id,
                        help_point.getAttribute("data-pagename"),
                        back_params) + '">' +
                        "<span role='img' aria-label='" +
                        tl["helpbutton_helper_edit"] +
                        "'>✏️</span></a>";
                elt("help-frame-body").innerHTML =
                    (tl["helpbutton_helper_page_no_exist"]).replace("%s", "'" +
                    help_point.getAttribute("data-pagename") + "'") +
                    tl["helpbutton_helper_create_edit"];
                toggleHelp('help-frame', is_mobile, target_controller);
            } else {
                doMessage("<h2 class='red'>" +
                tl["helpbutton_helper_not_available"] +
                "</h2>");
            }
        });
}
/*
 * Simple function to construct the Wiki Edit hyperlink with passed in params.
 * @param String target_controller Edit page's controller name.
 * @param String csrf_token_key the dynamic name used for CSRF token.
 * @param String csrf_token_value The CSRF token to render edit page.
 * @param String group_id GroupId of the group which has the wiki.
 * @param String page_name Page name,unique Identifier for wiki edit page.
 * @return String the edit link
 */
function getEditLink(target_controller, current_action, csrf_token_key,
    csrf_token_value, group_id, page_name, back_params)
{
    let edit_link = '?c=group' +
        '&' + csrf_token_key + '=' + csrf_token_value +
        '&group_id=' + group_id +
        '&arg=edit' +
        '&a=wiki' +
        '&page_name=' + page_name +
        '&back_params[open_help_page]=' + page_name +
        '&back_params[c]=' + target_controller +
        '&back_params[a]=' + current_action;
    for (let key in back_params) {
        let value = back_params[key];
        edit_link += "&back_params[" + key + "]=" + value;
    }
    return edit_link;
}
/**
 * Makes the help panel draggable by its title bar so a reader can move it
 * out of the way of the page beneath it. On the first drag the panel is
 * switched to fixed positioning at its current spot and then follows the
 * pointer, kept inside the browser window. A press that lands on a link or
 * button in the bar (such as the close control) is left alone so its own
 * click still works.
 *
 * @param String frame_id id of the help panel to move
 * @param String handle_id id of the title bar that acts as the drag grip
 */
function makeHelpDraggable(frame_id, handle_id)
{
    let frame = elt(frame_id);
    let handle = elt(handle_id);
    if (!frame || !handle) {
        return;
    }
    let offset_x = 0;
    let offset_y = 0;
    let dragging = false;
    listen(handle, "mousedown", function (event) {
        if (event.target.closest("a") || event.target.closest("button")) {
            return;
        }
        let rect = frame.getBoundingClientRect();
        frame.style.position = "fixed";
        frame.style.left = rect.left + "px";
        frame.style.top = rect.top + "px";
        frame.style.right = "auto";
        frame.style.margin = "0";
        offset_x = event.clientX - rect.left;
        offset_y = event.clientY - rect.top;
        dragging = true;
        event.preventDefault();
    });
    listen(document, "mousemove", function (event) {
        if (!dragging) {
            return;
        }
        let max_left = window.innerWidth - frame.offsetWidth;
        let max_top = window.innerHeight - frame.offsetHeight;
        let left = Math.max(0, Math.min(event.clientX - offset_x, max_left));
        let top = Math.max(0, Math.min(event.clientY - offset_y, max_top));
        frame.style.left = left + "px";
        frame.style.top = top + "px";
    });
    listen(document, "mouseup", function () {
        dragging = false;
    });
}
listen(window, "DOMContentLoaded", function () {
    makeHelpDraggable("help-frame", "help-frame-head");
});
